дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
fix(workspace): unify nested project discovery and run ownership (#1716)
CI / Changes (push) Successful in 1s
Language smoke / Language smoke (go) (push) Failing after 6s
Language smoke / Language smoke (java) (push) Failing after 5s
Language smoke / Language smoke (kotlin) (push) Failing after 5s
Language smoke / Language smoke (python) (push) Failing after 4s
Language smoke / Language smoke (typescript) (push) Failing after 4s
Language smoke / Language smoke (rust) (push) Failing after 5s
CI / Rust (ubuntu-22.04) (push) Failing after 43s
CI / Plugins (SDK, official, fixtures) (push) Failing after 1m19s
CI / Remote server (linux musl) (push) Failing after 1m32s
CI / Cargo deny (push) Successful in 54s
CI / E2E (push) Successful in 15m57s
CI / Frontend (push) Successful in 24m49s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
CI / Changes (push) Successful in 1s
Language smoke / Language smoke (go) (push) Failing after 6s
Language smoke / Language smoke (java) (push) Failing after 5s
Language smoke / Language smoke (kotlin) (push) Failing after 5s
Language smoke / Language smoke (python) (push) Failing after 4s
Language smoke / Language smoke (typescript) (push) Failing after 4s
Language smoke / Language smoke (rust) (push) Failing after 5s
CI / Rust (ubuntu-22.04) (push) Failing after 43s
CI / Plugins (SDK, official, fixtures) (push) Failing after 1m19s
CI / Remote server (linux musl) (push) Failing after 1m32s
CI / Cargo deny (push) Successful in 54s
CI / E2E (push) Successful in 15m57s
CI / Frontend (push) Successful in 24m49s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
* fix(build): scope Kotlin bridge to linked Maven modules * fix(run): resolve nested Go modules for context runs * fix(gradle): discover nested builds and select compatible JVMs * fix(workspace): share project discovery and module ownership * fix(run): keep root toolchains for nested modules and resolve them once per module - Python run configurations of a nested module without its own virtualenv use the closest one up to the project root. - Gradle builds and runs fall back to the JDK matching the project's Java version when the Gradle version is unknown. - Module-local interpreters and toolchains are resolved lazily, once per owning module. - Tests build expected paths per segment so they hold on Windows.
Цей коміт міститься в:
1 джерело
763bcc056c
коміт
ba9ce1d842
29 files changed
+1770
-309
No files matched your search
@@ -23,7 +23,7 @@ export const notebookBackend = {
|
||||
request: RequestId,
|
||||
output: Channel<NotebookOutputEvent>,
|
||||
) => call(commands.notebookExecute(id, cells, python, request, output)),
|
||||
environments: () => Promise.all([call(commands.sdkDetect("python")), call(commands.sdkPythonEffective())]),
|
||||
environments: () => Promise.all([call(commands.sdkDetect("python")), call(commands.sdkPythonEffective(null))]),
|
||||
cancel: (request: RequestId) => commands.cancelRequest(request),
|
||||
};
|
||||
|
||||
|
||||
@@ -235,6 +235,43 @@ describe("context configuration", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not borrow another Go module's run or test configuration", () => {
|
||||
const go = [
|
||||
cfg({ id: "go:run:api", kind: "go", command: "go", args: ["run", "."], cwd: "api" }),
|
||||
cfg({ id: "go:test:api", kind: "go", command: "go", args: ["test", "./..."], cwd: "api", isTest: true }),
|
||||
];
|
||||
expect(contextConfig("other/main.go", go, "package main\nfunc main() {}")).toBeNull();
|
||||
expect(contextConfig("other/main_test.go", go, "package main")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses a nested Go module even when only the parent has a run configuration", () => {
|
||||
const go = [
|
||||
cfg({ id: "go:run", kind: "go", command: "go", args: ["run", "."] }),
|
||||
cfg({ id: "go:test:api", kind: "go", command: "go", args: ["test", "./..."], cwd: "api", isTest: true }),
|
||||
];
|
||||
expect(contextConfig("api/tool/main.go", go, "package main\nfunc main() {}")).toMatchObject({
|
||||
cwd: "api",
|
||||
args: ["run", "./tool"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not pass typed Go test flags to an application build", () => {
|
||||
const go = cfg({
|
||||
id: "user:go-test",
|
||||
kind: "go",
|
||||
command: "go",
|
||||
args: ["test", "-count=1", "./..."],
|
||||
isTest: true,
|
||||
params: { goKind: "test", module: "api", toolArgs: "-count=1" },
|
||||
});
|
||||
expect(contextConfig("api/cmd/server/main.go", [go], "package main\nfunc main() {}")).toMatchObject({
|
||||
cwd: "api",
|
||||
args: ["run", "./cmd/server"],
|
||||
params: undefined,
|
||||
isTest: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("finds run targets and script invocations", () => {
|
||||
expect(contextConfig("src/bin/server.rs", configs)!.id).toBe("cargo:run:server");
|
||||
expect(contextConfig("tools/gen.py", configs)).toMatchObject({
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { RunConfig } from "@/platform/ipc";
|
||||
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
|
||||
import { phpunitArgs } from "./testing/phpunit";
|
||||
import { goTestParts } from "./goArguments";
|
||||
import { goBuildType } from "./goTypes";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
|
||||
export type FileRole = "test" | "main" | "script" | null;
|
||||
@@ -51,16 +52,28 @@ function testKinds(relPath: string): string[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The module owning a configuration's targets, relative to the project root. */
|
||||
export function configScope(config: RunConfig): string {
|
||||
return (
|
||||
config.kind === "go"
|
||||
? (config.params?.module ?? config.module ?? config.cwd ?? "")
|
||||
: (config.module ?? config.cwd ?? "")
|
||||
)
|
||||
.replace(/^\.(?:\/|$)/, "")
|
||||
.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */
|
||||
function allClosest(configs: RunConfig[], relPath: string): RunConfig[] {
|
||||
export function allClosest(configs: readonly RunConfig[], relPath: string): RunConfig[] {
|
||||
const score = (c: RunConfig) => {
|
||||
const cwd = (c.kind === "go" ? (c.params?.module ?? c.cwd ?? "") : (c.cwd ?? ""))
|
||||
.replace(/^\.\/?/, "")
|
||||
.replace(/\/+$/, "");
|
||||
const cwd = configScope(c);
|
||||
if (!cwd) return 0;
|
||||
return relPath === cwd || relPath.startsWith(cwd + "/") ? cwd.length : -1;
|
||||
};
|
||||
const scored = configs.map((c) => [c, score(c)] as const).filter(([, s]) => s >= 0);
|
||||
const scored = configs
|
||||
.filter((c) => !c.id.startsWith("context:"))
|
||||
.map((c) => [c, score(c)] as const)
|
||||
.filter(([, s]) => s >= 0);
|
||||
const best = Math.max(...scored.map(([, s]) => s));
|
||||
return scored.filter(([, s]) => s === best).map(([c]) => c);
|
||||
}
|
||||
@@ -72,10 +85,10 @@ function closest(configs: RunConfig[], relPath: string): RunConfig | undefined {
|
||||
|
||||
/** `relPath` relative to the working directory of `config`. */
|
||||
export function relToCwd(relPath: string, config: RunConfig): string {
|
||||
const cwd = (config.kind === "go" ? (config.params?.module ?? config.cwd ?? "") : (config.cwd ?? ""))
|
||||
.replace(/^\.\/?/, "")
|
||||
const cwd = (config.kind === "go" ? configScope(config) : (config.cwd ?? ""))
|
||||
.replace(/^\.(?:\/|$)/, "")
|
||||
.replace(/\/+$/, "");
|
||||
return cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
|
||||
return relPath === cwd ? "" : cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
|
||||
}
|
||||
|
||||
function derived(base: RunConfig, relPath: string, patch: Partial<RunConfig>): RunConfig {
|
||||
@@ -94,14 +107,22 @@ function derived(base: RunConfig, relPath: string, patch: Partial<RunConfig>): R
|
||||
* The project's Python interpreter as `(command, leading args)`, derived from
|
||||
* detected configs (venv / uv / poetry), or null when none says.
|
||||
*/
|
||||
export function projectPython(configs: RunConfig[]): { command: string; prefix: string[] } | null {
|
||||
export function projectPython(configs: RunConfig[], relPath?: string): { command: string; prefix: string[] } | null {
|
||||
if (relPath !== undefined)
|
||||
configs = allClosest(
|
||||
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
|
||||
relPath,
|
||||
);
|
||||
const py = configs.find((c) => c.kind === "python");
|
||||
if (py) {
|
||||
// The interpreter part ends where the program starts: `-m`, or the script (`manage.py`). What follows
|
||||
// (`runserver`, `test`…) belongs to that program, never to the interpreter.
|
||||
const end = py.args.findIndex((a) => a === "-m" || a.endsWith(".py"));
|
||||
const python = py.args.findIndex((a) => /^python[\d.]*$/.test(a));
|
||||
return { command: py.command, prefix: py.args.slice(0, end >= 0 ? end : python + 1) };
|
||||
return {
|
||||
command: py.command,
|
||||
prefix: py.args.slice(0, end >= 0 ? end : python + 1),
|
||||
};
|
||||
}
|
||||
const pytest = configs.find((c) => c.kind === "pytest");
|
||||
if (!pytest) return null;
|
||||
@@ -120,15 +141,20 @@ export function projectPython(configs: RunConfig[]): { command: string; prefix:
|
||||
*/
|
||||
function implicitPytest(relPath: string, configs: RunConfig[]): RunConfig | null {
|
||||
if (!relPath.endsWith(".py")) return null;
|
||||
const py = projectPython(configs);
|
||||
const owners = allClosest(
|
||||
configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
|
||||
relPath,
|
||||
);
|
||||
const py = projectPython(owners);
|
||||
return {
|
||||
id: "pytest:implicit",
|
||||
name: "pytest",
|
||||
kind: "pytest",
|
||||
command: py?.command ?? "python3",
|
||||
args: [...(py?.prefix ?? []), "-m", "pytest"],
|
||||
cwd: null,
|
||||
env: {},
|
||||
cwd: owners[0]?.cwd ?? null,
|
||||
module: owners[0]?.module,
|
||||
env: { ...owners[0]?.env },
|
||||
detected: true,
|
||||
isTest: true,
|
||||
debugAdapter: "debugpy",
|
||||
@@ -147,7 +173,7 @@ export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[]
|
||||
.sort((a, b) => kinds.indexOf(a.kind) - kinds.indexOf(b.kind));
|
||||
const best = allClosest(candidates, relPath);
|
||||
if (best.length) return best;
|
||||
const fallback = candidates[0] ?? implicitPytest(relPath, configs);
|
||||
const fallback = implicitPytest(relPath, configs);
|
||||
return fallback ? [fallback] : [];
|
||||
}
|
||||
|
||||
@@ -213,21 +239,47 @@ export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunC
|
||||
function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null {
|
||||
if (relPath.endsWith(".rs")) {
|
||||
const bin = /src\/bin\/([^/]+)\.rs$/.exec(relPath)?.[1];
|
||||
const runs = configs.filter((c) => c.kind === "cargo" && c.id.startsWith("cargo:run"));
|
||||
const runs = allClosest(
|
||||
configs.filter((c) => c.kind === "cargo" && c.args[0] === "run"),
|
||||
relPath,
|
||||
);
|
||||
const byBin = bin ? runs.find((c) => c.id === `cargo:run:${bin}` || c.args.includes(bin)) : undefined;
|
||||
return byBin ?? closest(runs, relPath) ?? runs[0] ?? null;
|
||||
return byBin ?? runs[0] ?? null;
|
||||
}
|
||||
if (relPath.endsWith(".go")) {
|
||||
const gos = configs.filter((c) => c.kind === "go");
|
||||
const runs = gos.filter((c) => c.args[0] === "run");
|
||||
const gos = allClosest(
|
||||
configs.filter((c) => c.kind === "go" && !c.id.startsWith("context:")),
|
||||
relPath,
|
||||
);
|
||||
const runs = gos.filter((c) => c.args[0] === "run" || c.params?.goKind === "build");
|
||||
// The module containing the file (nested modules run from their own directory).
|
||||
const base = closest(runs, relPath) ?? closest(gos, relPath) ?? runs[0] ?? gos[0];
|
||||
const base = runs[0] ?? gos[0];
|
||||
if (!base) return null;
|
||||
const dir = dirOf(relToCwd(relPath, base));
|
||||
const target = dir ? `./${dir}` : ".";
|
||||
const hit = runs.find((c) => (c.cwd ?? "") === (base.cwd ?? "") && c.args[1] === target);
|
||||
if (base.params?.goKind === "build") {
|
||||
const params = {
|
||||
...base.params,
|
||||
goKind: "build",
|
||||
scope: "package",
|
||||
target,
|
||||
runAfterBuild: "true",
|
||||
};
|
||||
return derived(base, relPath, {
|
||||
...goBuildType.commandLine(params),
|
||||
params,
|
||||
name: `go run ${target}`,
|
||||
isTest: false,
|
||||
});
|
||||
}
|
||||
const hit = runs.find((c) => configScope(c) === configScope(base) && c.args[1] === target);
|
||||
if (hit) return hit;
|
||||
return derived(base, relPath, { args: ["run", target], name: `go run ${target}`, isTest: false });
|
||||
return derived(base, relPath, {
|
||||
args: ["run", target],
|
||||
name: `go run ${target}`,
|
||||
isTest: false,
|
||||
...(base.params?.goKind === "test" ? { params: undefined, cwd: base.params.module ?? base.cwd } : {}),
|
||||
});
|
||||
}
|
||||
if (/\.(kt|java)$/.test(relPath)) {
|
||||
// The build containing the file (builds nested in a folder of repositories carry a `:<dir>` suffix).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import * as projectRoots from "@/platform/projectRoots";
|
||||
import type { HierarchyItem, RunConfig } from "@/platform/ipc";
|
||||
import {
|
||||
innermostTarget,
|
||||
@@ -273,6 +274,82 @@ describe("single-file producers (scratches)", () => {
|
||||
expect(p.config).toMatchObject({ args: ["run", "./cmd/tool"] });
|
||||
});
|
||||
|
||||
it("Go: nested modules win over previous single-file runs", () => {
|
||||
const go = [
|
||||
cfg({
|
||||
id: "context:file:fixtures/go-app/cmd/server/main.go",
|
||||
kind: "go",
|
||||
args: ["run", "fixtures/go-app/cmd/server/main.go"],
|
||||
}),
|
||||
cfg({
|
||||
id: "go:run:server:fixtures/go-app",
|
||||
kind: "go",
|
||||
args: ["run", "./cmd/server"],
|
||||
cwd: "fixtures/go-app",
|
||||
}),
|
||||
];
|
||||
const input = {
|
||||
path: `${ROOT}/fixtures/go-app/cmd/server/main.go`,
|
||||
root: ROOT,
|
||||
configs: go,
|
||||
content: "package main\nfunc main() {}",
|
||||
};
|
||||
for (const line of [undefined, 1]) {
|
||||
expect(produceContext({ ...input, line }, ALL)?.config).toMatchObject({
|
||||
id: "go:run:server:fixtures/go-app",
|
||||
args: ["run", "./cmd/server"],
|
||||
cwd: "fixtures/go-app",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("Go: single-file runs never become module configurations", () => {
|
||||
const input = {
|
||||
path: `${ROOT}/standalone/main.go`,
|
||||
root: ROOT,
|
||||
configs: [] as RunConfig[],
|
||||
content: "package main\nfunc main() {}",
|
||||
};
|
||||
const first = produceContext(input, ALL)!;
|
||||
expect(first.config).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
|
||||
expect(produceContext({ ...input, configs: [first.config!] }, ALL)?.config).toEqual(first.config);
|
||||
});
|
||||
|
||||
it("Go: typed modules own only their own files", () => {
|
||||
const configs = [
|
||||
...["api", "worker"].map((module) =>
|
||||
cfg({
|
||||
id: `go:run:${module}`,
|
||||
kind: "go",
|
||||
args: ["build", "-race", "."],
|
||||
params: { module, goKind: "build", scope: "package", target: ".", toolArgs: "-race" },
|
||||
}),
|
||||
),
|
||||
];
|
||||
for (const module of ["api", "worker"]) {
|
||||
expect(
|
||||
produceContext(
|
||||
{
|
||||
path: `${ROOT}/${module}/cmd/server/main.go`,
|
||||
root: ROOT,
|
||||
configs,
|
||||
content: "package main\nfunc main() {}",
|
||||
},
|
||||
ALL,
|
||||
)?.config,
|
||||
).toMatchObject({
|
||||
args: ["build", "-race", "./cmd/server"],
|
||||
params: { module, target: "./cmd/server", goKind: "build", runAfterBuild: "true" },
|
||||
});
|
||||
}
|
||||
expect(
|
||||
produceContext(
|
||||
{ path: `${ROOT}/standalone/main.go`, root: ROOT, configs, content: "package main\nfunc main() {}" },
|
||||
ALL,
|
||||
)?.config,
|
||||
).toMatchObject({ id: "context:file:standalone/main.go", args: ["run", "standalone/main.go"] });
|
||||
});
|
||||
|
||||
it("Rust: rustc into the compile directory, then the binary; codelldb debugs the binary", () => {
|
||||
const p = produceContext(scratch("hihi.rs", 'fn main() {\n println!("hi");\n}'), ALL)!;
|
||||
expect(p.name).toBe("hihi.rs");
|
||||
@@ -1003,3 +1080,109 @@ describe("CMake file context", () => {
|
||||
expect(p?.missing?.body).toContain("CMake Configure");
|
||||
});
|
||||
});
|
||||
|
||||
describe("module ownership across languages", () => {
|
||||
it.each([
|
||||
["npm", "other.test.ts", ["run", "test"]],
|
||||
["deno", "other.test.ts", ["test"]],
|
||||
["cargo", "tests/other.rs", ["test"]],
|
||||
["go", "other_test.go", ["test", "./..."]],
|
||||
["phpunit", "OtherTest.php", []],
|
||||
["rspec", "other_spec.rb", []],
|
||||
["maven", "OtherTest.java", ["test"]],
|
||||
["gradle", "OtherTest.kt", ["test"]],
|
||||
])("%s never borrows a sibling test configuration", (kind, file, args) => {
|
||||
const configs = [cfg({ id: "sibling", kind, args, cwd: "apps/one", isTest: true })];
|
||||
const result = produceFile({ root: ROOT, path: `${ROOT}/apps/two/${file}`, configs }, OPTIMISTIC);
|
||||
expect(result?.config?.id ?? "").not.toContain("sibling");
|
||||
});
|
||||
|
||||
it("selects the nearest Python interpreter and ignores temporary configs", () => {
|
||||
const configs = [
|
||||
cfg({
|
||||
id: "one",
|
||||
kind: "pytest",
|
||||
command: "/env/one/bin/pytest",
|
||||
cwd: "one",
|
||||
isTest: true,
|
||||
}),
|
||||
cfg({
|
||||
id: "two",
|
||||
kind: "pytest",
|
||||
command: "/env/two/bin/pytest",
|
||||
cwd: "two",
|
||||
isTest: true,
|
||||
}),
|
||||
cfg({
|
||||
id: "context:old",
|
||||
kind: "python",
|
||||
command: "/env/stale/python",
|
||||
cwd: "two",
|
||||
}),
|
||||
];
|
||||
expect(produceFile({ root: ROOT, path: `${ROOT}/two/main.py`, configs }, OPTIMISTIC)?.config?.command).toBe(
|
||||
"/env/two/bin/python",
|
||||
);
|
||||
});
|
||||
|
||||
it("a Cargo package owns its binary even when commands run at the workspace root", () => {
|
||||
const configs = [
|
||||
cfg({
|
||||
id: "cargo:run:one",
|
||||
kind: "cargo",
|
||||
module: "crates/one",
|
||||
args: ["run", "-p", "one", "--bin", "server"],
|
||||
}),
|
||||
cfg({
|
||||
id: "workspace:member:cargo:run:two",
|
||||
kind: "cargo",
|
||||
module: "crates/two",
|
||||
args: ["run", "-p", "two", "--bin", "server"],
|
||||
}),
|
||||
];
|
||||
const result = produceFile({ root: ROOT, path: `${ROOT}/crates/two/src/bin/server.rs`, configs }, OPTIMISTIC);
|
||||
expect(result?.config?.args).toEqual(configs[1]!.args);
|
||||
expect(result?.config?.cwd).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["/work/app", "C:/work/app"])("matches absolute workspace configs under %s", (root) => {
|
||||
const configs = [
|
||||
cfg({
|
||||
id: "test",
|
||||
kind: "npm",
|
||||
cwd: `${root}/apps/web`,
|
||||
module: `${root}/apps/web`,
|
||||
args: ["run", "test"],
|
||||
isTest: true,
|
||||
}),
|
||||
];
|
||||
const result = produceFile({ root, path: `${root}/apps/web/a.test.ts`, configs }, OPTIMISTIC);
|
||||
expect(result?.config).toMatchObject({
|
||||
cwd: "apps/web",
|
||||
args: ["run", "test", "--", "a.test.ts"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("runs scripts from their owning module with arguments relative to that directory", () => {
|
||||
const configs = [cfg({ id: "web", kind: "npm", cwd: "apps/web", module: "apps/web" })];
|
||||
expect(produceFile({ root: ROOT, path: `${ROOT}/apps/web/src/main.js`, configs }, OPTIMISTIC)?.config).toMatchObject({
|
||||
cwd: "apps/web",
|
||||
args: ["src/main.js"],
|
||||
});
|
||||
});
|
||||
|
||||
it("nested Ruby scripts use their own source paths relative to their cwd", () => {
|
||||
const spy = vi.spyOn(projectRoots, "currentRoots").mockReturnValue([
|
||||
{ path: "apps/ruby/lib", kind: "source", detectedFrom: null },
|
||||
{ path: "apps/other/lib", kind: "source", detectedFrom: null },
|
||||
]);
|
||||
try {
|
||||
const configs = [cfg({ id: "ruby", kind: "rake", cwd: "apps/ruby", args: ["test"] })];
|
||||
expect(
|
||||
produceFile({ root: ROOT, path: `${ROOT}/apps/ruby/bin/main.rb`, configs }, OPTIMISTIC)?.config,
|
||||
).toMatchObject({ cwd: "apps/ruby", args: ["-Ilib", "bin/main.rb"] });
|
||||
} finally {
|
||||
spy.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -10,13 +10,22 @@
|
||||
* globally installed tools.
|
||||
*/
|
||||
import type { RunConfig } from "@/platform/ipc";
|
||||
import { ipcPath, isSameOrInside } from "@/platform/paths";
|
||||
import { isTestPath } from "@/platform/languageSettings";
|
||||
import { currentRoots } from "@/platform/projectRoots";
|
||||
import { applicationConfig, jvmModuleDir } from "./application";
|
||||
import { formatArgs } from "./configs";
|
||||
import { goGenerateConfig } from "./goGenerate";
|
||||
import { goTestParts } from "./goArguments";
|
||||
import { contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
|
||||
import {
|
||||
allClosest,
|
||||
contextConfig,
|
||||
fileRole,
|
||||
projectPython,
|
||||
testBasesFor,
|
||||
testConfigFor,
|
||||
relToCwd as relativeToCwd,
|
||||
} from "./context";
|
||||
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
|
||||
import { firstTopLevelType, scanJvmTargets, simpleClassName } from "./jvmTargets";
|
||||
import { isWindows } from "./jvmTypes";
|
||||
@@ -103,6 +112,26 @@ function relTo(path: string, root: string | null): string {
|
||||
return root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
}
|
||||
|
||||
function scopedInput<T extends Pick<ContextInput, "root" | "configs">>(input: T): T {
|
||||
const root = input.root && ipcPath(input.root).replace(/\/+$/, "");
|
||||
if (!root) return input;
|
||||
const relative = (value: string) => {
|
||||
const path = ipcPath(value);
|
||||
return path === root ? "" : path.startsWith(root + "/") ? path.slice(root.length + 1) : path;
|
||||
};
|
||||
return {
|
||||
...input,
|
||||
configs: input.configs.map((c) => ({
|
||||
...c,
|
||||
cwd: c.cwd == null ? c.cwd : relative(c.cwd),
|
||||
...(c.module == null ? {} : { module: relative(c.module) }),
|
||||
...(c.kind === "go" && c.params?.module != null
|
||||
? { params: { ...c.params, module: relative(c.params.module) } }
|
||||
: {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Short stable hash, so compiled binaries of same-named files don't collide. */
|
||||
function hash(s: string): string {
|
||||
let h = 5381;
|
||||
@@ -136,7 +165,7 @@ function mkdirLine(dir: string, windows = false): string {
|
||||
/** Absolute directory of a configuration's working directory (a Go module). */
|
||||
export function goModuleDir(root: string, cwd: string | null): string {
|
||||
const rel = (cwd ?? "").replace(/^\.(?:\/|$)/, "").replace(/\/+$/, "");
|
||||
if (rel.startsWith("/")) return rel;
|
||||
if (rel.startsWith("/") || /^[A-Za-z]:\//.test(rel)) return rel;
|
||||
return rel ? `${root}/${rel}` : root;
|
||||
}
|
||||
|
||||
@@ -384,10 +413,25 @@ function isProjectPath(input: Pick<ContextInput, "path" | "root" | "contentRoots
|
||||
function site(input: ContextInput): FileSite {
|
||||
const inside = isProjectPath(input);
|
||||
const rel = relTo(input.path, input.root);
|
||||
const language = scriptLanguage(input.path, input.content);
|
||||
const kinds: Record<string, string[]> = {
|
||||
js: ["npm", "deno", "bun", "node"],
|
||||
ts: ["npm", "deno", "bun", "node"],
|
||||
python: ["python", "pytest", "django-test"],
|
||||
ruby: ["ruby", "rake", "rspec"],
|
||||
php: ["php", "phpunit", "composer"],
|
||||
};
|
||||
const owner =
|
||||
inside && language
|
||||
? allClosest(
|
||||
input.configs.filter((c) => kinds[language]?.includes(c.kind)),
|
||||
rel,
|
||||
)[0]
|
||||
: undefined;
|
||||
return {
|
||||
idPath: rel,
|
||||
arg: inside ? rel : baseName(input.path),
|
||||
cwd: inside ? null : dirName(input.path),
|
||||
arg: inside ? (owner ? relativeToCwd(rel, owner) : rel) : baseName(input.path),
|
||||
cwd: inside ? owner?.cwd || null : dirName(input.path),
|
||||
file: baseName(input.path),
|
||||
inProject: inside,
|
||||
};
|
||||
@@ -442,13 +486,21 @@ function pythonModule(input: ContextInput): { name: string; dir: string } | null
|
||||
/** Python: the interpreter from settings, the detected project interpreter, then `python3` on the PATH. */
|
||||
function pythonCommand(input: ContextInput, env: ToolEnv, s: FileSite): { command: string; prefix: string[] } | null {
|
||||
if (env.python) return { command: env.python, prefix: [] };
|
||||
const py = projectPython([...input.configs]);
|
||||
const py = projectPython([...input.configs], relTo(input.path, input.root));
|
||||
if (py) {
|
||||
// A venv-relative interpreter is relative to the project root.
|
||||
const owner = allClosest(
|
||||
input.configs.filter((c) => ["python", "pytest", "django-test"].includes(c.kind)),
|
||||
relTo(input.path, input.root),
|
||||
)[0];
|
||||
const cwd = owner?.cwd ?? "";
|
||||
const relativeCommand = py.command.includes("/") && !py.command.startsWith("/") && !/^[A-Za-z]:/.test(py.command);
|
||||
const command =
|
||||
!s.inProject && input.root && py.command.includes("/") && !py.command.startsWith("/")
|
||||
? `${input.root}/${py.command.replace(/^\.\//, "")}`
|
||||
: py.command;
|
||||
relativeCommand && cwd
|
||||
? `${goModuleDir(input.root ?? "", cwd)}/${py.command.replace(/^\.\//, "")}`
|
||||
: !s.inProject && input.root && relativeCommand
|
||||
? `${input.root}/${py.command.replace(/^\.\//, "")}`
|
||||
: py.command;
|
||||
return { command, prefix: py.prefix };
|
||||
}
|
||||
const t = pick(env, ["python3", "python"]);
|
||||
@@ -599,8 +651,20 @@ function simple(
|
||||
}
|
||||
|
||||
/** `-I<dir>` for each Sources root of the project (the project root itself is already the cwd). */
|
||||
const rubyLoadPath = (roots: readonly { path: string; kind: string }[]): string[] =>
|
||||
roots.filter((r) => r.kind === "source" && r.path !== "").map((r) => `-I${r.path}`);
|
||||
const rubyLoadPath = (
|
||||
roots: readonly { path: string; kind: string }[],
|
||||
cwd: string | null,
|
||||
root: string | null,
|
||||
): string[] =>
|
||||
roots
|
||||
.filter((r) => r.kind === "source" && r.path !== "")
|
||||
.flatMap((r) => {
|
||||
if (!cwd) return [`-I${r.path}`];
|
||||
const dir = goModuleDir(root ?? "", cwd);
|
||||
const source = goModuleDir(root ?? "", r.path);
|
||||
if (!isSameOrInside(source, dir)) return [];
|
||||
return [`-I${source === dir ? "." : source.slice(dir.length + 1)}`];
|
||||
});
|
||||
|
||||
const hasGoMain = (content?: string) => content === undefined || /^\s*package\s+main\b/m.test(content);
|
||||
const hasRustMain = (content?: string) =>
|
||||
@@ -614,11 +678,10 @@ const hasKotlinMain = (content?: string) => content === undefined || /^(?:suspen
|
||||
* directory at or above it): a build nested in a folder of repositories owns only its files.
|
||||
*/
|
||||
function buildOwners(rel: string, configs: readonly RunConfig[], kinds: readonly string[]): RunConfig[] {
|
||||
return configs.filter((c) => {
|
||||
if (!kinds.includes(c.kind)) return false;
|
||||
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
|
||||
return !cwd || rel.startsWith(cwd + "/");
|
||||
});
|
||||
return allClosest(
|
||||
configs.filter((c) => kinds.includes(c.kind)),
|
||||
rel,
|
||||
);
|
||||
}
|
||||
|
||||
const cargoOwner = (rel: string, configs: readonly RunConfig[]) => buildOwners(rel, configs, ["cargo"]);
|
||||
@@ -655,6 +718,7 @@ function godotScene(rel: string, configs: readonly RunConfig[]): Produced | null
|
||||
|
||||
/** The file as a whole: tests of a test file, the project's run target, or the runtime for its language. */
|
||||
export function produceFile(input: ContextInput, env: ToolEnv): Produced | null {
|
||||
input = scopedInput(input);
|
||||
const s = site(input);
|
||||
const lang = scriptLanguage(input.path, input.content);
|
||||
const configs = [...input.configs];
|
||||
@@ -685,12 +749,23 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
if (lang === "rust" && cargoOwner(rel, configs).length) {
|
||||
const example = /(?:^|\/)examples\/([^/]+)\.rs$/.exec(rel)?.[1];
|
||||
if (example && hasRustMain(input.content)) {
|
||||
const base = cargoOwner(rel, configs).find((c) => c.id.startsWith("cargo:run")) ?? cargoOwner(rel, configs)[0]!;
|
||||
const base = cargoOwner(rel, configs).find((c) => c.args[0] === "run") ?? cargoOwner(rel, configs)[0]!;
|
||||
const config: RunConfig = {
|
||||
...base,
|
||||
id: `context:file:${rel}`,
|
||||
name: `Run ${example}`,
|
||||
args: ["run", "--example", example],
|
||||
args: [
|
||||
"run",
|
||||
...base.args.flatMap((arg, i, args) =>
|
||||
["-p", "--package", "--manifest-path"].includes(arg)
|
||||
? [arg, args[i + 1]!]
|
||||
: /^(?:--package=|--manifest-path=|-p.)/.test(arg)
|
||||
? [arg]
|
||||
: [],
|
||||
),
|
||||
"--example",
|
||||
example,
|
||||
],
|
||||
env: { ...base.env },
|
||||
isTest: false,
|
||||
detected: true,
|
||||
@@ -811,7 +886,10 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null
|
||||
}
|
||||
case "ruby":
|
||||
// Sources roots go on the load path (`-Ilib`), as `rake test` does; paths are project-relative.
|
||||
return simple(lang, s, env, lang, () => [...(s.inProject ? rubyLoadPath(currentRoots()) : []), s.arg]);
|
||||
return simple(lang, s, env, lang, () => [
|
||||
...(s.inProject ? rubyLoadPath(currentRoots(), s.cwd, input.root) : []),
|
||||
s.arg,
|
||||
]);
|
||||
case "php":
|
||||
if (env.php) return { name: s.file, scope: "file", config: fileConfig(s, "php", env.php, [s.arg], "php-debug") };
|
||||
return simple(lang, s, env, lang, () => [s.arg], "php-debug");
|
||||
@@ -926,6 +1004,7 @@ export function fileTargets(
|
||||
input: Pick<ContextInput, "path" | "root" | "configs" | "contentRoots">,
|
||||
content: string,
|
||||
): RunTarget[] {
|
||||
input = scopedInput(input);
|
||||
const s = { inProject: isProjectPath(input), rel: relTo(input.path, input.root) };
|
||||
const rust = /\.rs$/.test(input.path);
|
||||
if (/\.(c|cpp|cc|cxx)$/.test(input.path)) return standaloneTargets(content, input.path);
|
||||
@@ -980,6 +1059,7 @@ function elementProduction(input: ContextInput, env: ToolEnv, target: RunTarget)
|
||||
* main function — else the file.
|
||||
*/
|
||||
export function produceContext(input: ContextInput, env: ToolEnv): Produced | null {
|
||||
input = scopedInput(input);
|
||||
if (input.content !== undefined && input.line !== undefined) {
|
||||
const targets = fileTargets(input, input.content.slice(0, 400_000));
|
||||
const inner = innermostTarget(input.content, targets, input.line, /\.(py|rb)$/.test(input.path));
|
||||
@@ -997,6 +1077,7 @@ export function produceContext(input: ContextInput, env: ToolEnv): Produced | nu
|
||||
* like IDEA, the user then chooses one. A single production otherwise.
|
||||
*/
|
||||
export function produceContextChoices(input: ContextInput, env: ToolEnv): Produced[] {
|
||||
input = scopedInput(input);
|
||||
const produced = produceContext(input, env);
|
||||
if (!produced?.config || produced.scope === "directory" || !isProjectPath(input)) return produced ? [produced] : [];
|
||||
const rel = relTo(input.path, input.root);
|
||||
@@ -1046,6 +1127,7 @@ function rubyDirectory(
|
||||
relToCwd: (c: RunConfig) => string,
|
||||
make: (base: RunConfig, label: string, args: string[]) => Produced,
|
||||
label: string,
|
||||
root: string | null,
|
||||
): Produced | null {
|
||||
const rspec = closest(["rspec"]);
|
||||
if (rspec) return make(rspec, label, [...rspec.args, relToCwd(rspec)]);
|
||||
@@ -1058,13 +1140,14 @@ function rubyDirectory(
|
||||
const minitest = closest(["ruby"]);
|
||||
if (!minitest) return null;
|
||||
const at = minitest.args.indexOf("-e");
|
||||
const flags = rubyLoadPath(currentRoots()).filter((f) => !minitest.args.includes(f));
|
||||
const flags = rubyLoadPath(currentRoots(), minitest.cwd, root).filter((f) => !minitest.args.includes(f));
|
||||
const args = [...minitest.args.slice(0, -1)];
|
||||
args.splice(at < 0 ? args.length : at, 0, ...flags);
|
||||
return make(minitest, label, [...args, relToCwd(minitest)]);
|
||||
}
|
||||
|
||||
export function produceDirectory(input: ContextInput): Produced | null {
|
||||
input = scopedInput(input);
|
||||
const { root } = input;
|
||||
if (!root || !isProjectPath(input) || input.path === root) return null;
|
||||
const rel = relTo(input.path, root);
|
||||
@@ -1074,26 +1157,23 @@ export function produceDirectory(input: ContextInput): Produced | null {
|
||||
const marked = isTestPath(currentRoots(), rel);
|
||||
const tests = input.configs.filter((c) => c.isTest);
|
||||
if (!TEST_DIR.test(rel) && !jvmRoot && !jvmPackage && !marked && !/(^|\/)src\/test(\/|$)/.test(rel)) return null;
|
||||
const closest = (kinds: string[]) => {
|
||||
const cands = tests.filter((c) => kinds.includes(c.kind));
|
||||
const score = (c: RunConfig) => {
|
||||
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
|
||||
if (!cwd) return 0;
|
||||
return rel === cwd || rel.startsWith(cwd + "/") ? cwd.length : -1;
|
||||
};
|
||||
return cands
|
||||
.map((c) => [c, score(c)] as const)
|
||||
.filter(([, n]) => n >= 0)
|
||||
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
};
|
||||
const relToCwd = (c: RunConfig) => {
|
||||
const cwd = (c.cwd ?? "").replace(/^\.\/?/, "").replace(/\/+$/, "");
|
||||
return cwd && rel.startsWith(cwd + "/") ? rel.slice(cwd.length + 1) : rel === cwd ? "" : rel;
|
||||
};
|
||||
const closest = (kinds: string[]) =>
|
||||
allClosest(
|
||||
tests.filter((c) => kinds.includes(c.kind)),
|
||||
rel,
|
||||
)[0];
|
||||
const relToCwd = (c: RunConfig) => relativeToCwd(rel, c);
|
||||
const make = (base: RunConfig, label: string, args: string[]): Produced => ({
|
||||
name: label,
|
||||
scope: "directory",
|
||||
config: { ...base, id: `context:dir:${base.id}:${rel}`, name: label, args, env: { ...base.env }, detected: true },
|
||||
config: {
|
||||
...base,
|
||||
id: `context:dir:${base.id}:${rel}`,
|
||||
name: label,
|
||||
args,
|
||||
env: { ...base.env },
|
||||
detected: true,
|
||||
},
|
||||
});
|
||||
const testsIn = `Tests in '${name}'`;
|
||||
|
||||
@@ -1114,7 +1194,7 @@ export function produceDirectory(input: ContextInput): Produced | null {
|
||||
}
|
||||
}
|
||||
const js = closest(["npm", "bun", "deno"]);
|
||||
const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn);
|
||||
const ruby = marked && !js && rubyDirectory(closest, relToCwd, make, testsIn, root);
|
||||
if (ruby) return ruby;
|
||||
const py = closest(["pytest"]);
|
||||
const django = closest(["django-test"]);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const { entries, pythonNear } = vi.hoisted(() => ({
|
||||
entries: new Map<string, string[]>(),
|
||||
pythonNear: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/platform/ipc", () => ({
|
||||
isTauri: () => true,
|
||||
call: (promise: Promise<unknown>) => promise,
|
||||
commands: {
|
||||
fsReadDir: async (dir: string) => (entries.get(dir) ?? []).map((name) => ({ name })),
|
||||
sdkPythonEffective: pythonNear,
|
||||
sdkLocateTools: async () => ({ compileDir: "/tmp/run", tools: [] }),
|
||||
},
|
||||
}));
|
||||
import { clearRuntimeCache, rootMarkers, toolEnv } from "./runtimes";
|
||||
|
||||
beforeEach(() => {
|
||||
entries.clear();
|
||||
pythonNear.mockReset().mockResolvedValue("/env/python");
|
||||
clearRuntimeCache();
|
||||
});
|
||||
|
||||
it("a nested runtime wins and a package boundary blocks a parent's runtime", async () => {
|
||||
entries.set("/repo", ["deno.json"]);
|
||||
entries.set("/repo/apps/node", ["package.json"]);
|
||||
entries.set("/repo/apps/bun", ["bun.lock", "package.json"]);
|
||||
expect(await rootMarkers("/repo", "/repo/apps/node/src/main.ts")).toEqual({ deno: false, bun: false });
|
||||
expect(await rootMarkers("/repo", "/repo/apps/bun/src/main.ts")).toEqual({ deno: false, bun: true });
|
||||
expect(await rootMarkers("/repo", "/repo/apps/deno/src/main.ts")).toEqual({ deno: true, bun: false });
|
||||
});
|
||||
|
||||
it("a nested Deno project is discovered under a Node root", async () => {
|
||||
entries.set("/repo", ["package.json"]);
|
||||
entries.set("/repo/apps/deno", ["deno.jsonc"]);
|
||||
expect(await rootMarkers("/repo", "/repo/apps/deno/main.ts")).toEqual({ deno: true, bun: false });
|
||||
});
|
||||
|
||||
it("the production Python runtime query includes the source directory", async () => {
|
||||
const env = await toolEnv(["python3"], "/repo/apps/python/src/main.py", "/repo", true);
|
||||
expect(pythonNear).toHaveBeenCalledWith("/repo/apps/python/src");
|
||||
expect(env.python).toBe("/env/python");
|
||||
});
|
||||
@@ -78,9 +78,9 @@ export async function locateTools(
|
||||
return out;
|
||||
}
|
||||
|
||||
async function projectPython(): Promise<string | null> {
|
||||
async function projectPython(file: string | null): Promise<string | null> {
|
||||
try {
|
||||
return await call(commands.sdkPythonEffective());
|
||||
return await call(commands.sdkPythonEffective(file ? dirOf(file) : null));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -94,18 +94,23 @@ async function projectPhp(): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
async function rootMarkers(root: string | null): Promise<{ deno: boolean; bun: boolean }> {
|
||||
export async function rootMarkers(root: string | null, file: string | null): Promise<{ deno: boolean; bun: boolean }> {
|
||||
if (!root) return { deno: false, bun: false };
|
||||
try {
|
||||
const entries = await call(commands.fsReadDir(root));
|
||||
const names = new Set(entries.map((e) => e.name));
|
||||
return {
|
||||
deno: names.has("deno.json") || names.has("deno.jsonc"),
|
||||
bun: names.has("bun.lockb") || names.has("bun.lock"),
|
||||
};
|
||||
} catch {
|
||||
return { deno: false, bun: false };
|
||||
let dir = file && file.startsWith(root + "/") ? dirOf(file) : root;
|
||||
while (dir === root || dir.startsWith(root + "/")) {
|
||||
try {
|
||||
const entries = await call(commands.fsReadDir(dir));
|
||||
const names = new Set(entries.map((e) => e.name));
|
||||
const deno = names.has("deno.json") || names.has("deno.jsonc");
|
||||
const bun = names.has("bun.lockb") || names.has("bun.lock");
|
||||
if (deno || bun || names.has("package.json")) return { deno, bun };
|
||||
} catch {
|
||||
/* Unreadable ancestors contribute no runtime markers. */
|
||||
}
|
||||
if (dir === root) break;
|
||||
dir = dirOf(dir);
|
||||
}
|
||||
return { deno: false, bun: false };
|
||||
}
|
||||
|
||||
/** The tool environment for running `file` with any of `names`. */
|
||||
@@ -118,9 +123,11 @@ export async function toolEnv(
|
||||
): Promise<ToolEnv> {
|
||||
const [tools, py, phpInterpreter, markers] = await Promise.all([
|
||||
locateTools(names, file),
|
||||
python ? projectPython() : Promise.resolve(null),
|
||||
python ? projectPython(file) : Promise.resolve(null),
|
||||
php ? projectPhp() : Promise.resolve(null),
|
||||
names.includes("deno") || names.includes("bun") ? rootMarkers(root) : Promise.resolve({ deno: false, bun: false }),
|
||||
names.includes("deno") || names.includes("bun")
|
||||
? rootMarkers(root, file)
|
||||
: Promise.resolve({ deno: false, bun: false }),
|
||||
]);
|
||||
return {
|
||||
tool: (n) => tools.get(n),
|
||||
|
||||
@@ -103,7 +103,7 @@ export function pip(
|
||||
|
||||
/** Interpreter the project uses when none is configured (its virtualenv). */
|
||||
export function effectivePython(): Promise<string | null> {
|
||||
return isTauri() ? call(commands.sdkPythonEffective()) : Promise.resolve(null);
|
||||
return isTauri() ? call(commands.sdkPythonEffective(null)) : Promise.resolve(null);
|
||||
}
|
||||
|
||||
/** The PHP language level of the project `path` belongs to; null outside Tauri. */
|
||||
|
||||
Reference in new issue
Заблокувати користувача