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

* 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.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-06 15:51:41 +08:00
1 джерело 763bcc056c
коміт ba9ce1d842
29 files changed
+1770 -309

No files matched your search

+37
Переглянути файл
@@ -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({
+73 -21
Переглянути файл
@@ -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).
+184 -1
Переглянути файл
@@ -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();
}
});
+117 -37
Переглянути файл
@@ -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"]);
+43
Переглянути файл
@@ -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");
});
+21 -14
Переглянути файл
@@ -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),