Compare commits

...
Author SHA1 Message Date
l0ng-ai ae558d70f1 fix(run): resolve nested Go modules for context runs 2026-11-06 13:30:44 +08:00
6 changed files with 196 additions and 17 deletions

No files matched your search

+5 -2
View File
@@ -712,11 +712,14 @@ fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
detect_go_module(root, None, out);
return;
}
// A polyglot repository: Go modules one level down (`api/go.mod`).
for dir in subdirs(root) {
// Match Maven's shallow discovery without walking every source directory.
let mut pending = vec![(root.to_path_buf(), 0)];
while let Some((dir, depth)) = pending.pop() {
if dir.join("go.mod").is_file() {
let rel = relative(root, &dir);
detect_go_module(&dir, Some(rel), out);
} else if depth < 3 {
pending.extend(subdirs(&dir).into_iter().rev().map(|child| (child, depth + 1)));
}
}
}
+42
View File
@@ -362,6 +362,48 @@ fn go_projects() {
assert_eq!(ids(&detect(dir.path())), ["go:test"]);
}
#[test]
fn nested_go_modules_keep_their_working_directories() {
let dir = project(&[
("Cargo.toml", "[package]\nname = 'workspace'\n"),
("fixtures/go-app/go.mod", "module example.com/openidea/notes\n"),
("fixtures/go-app/cmd/server/main.go", "package main\nfunc main() {}\n"),
("services/backend/api/go.mod", "module example.com/api\n"),
("services/backend/api/main.go", "package main\nfunc main() {}\n"),
("fixtures/go-app/nested/go.mod", "module ignored\n"),
("vendor/ignored/go.mod", "module ignored\n"),
("node_modules/ignored/go.mod", "module ignored\n"),
("target/ignored/go.mod", "module ignored\n"),
("build/ignored/go.mod", "module ignored\n"),
("dist/ignored/go.mod", "module ignored\n"),
(".hidden/ignored/go.mod", "module ignored\n"),
]);
let configs = detect(dir.path());
let go: Vec<_> = configs.iter().filter(|c| c.kind == "go").map(|c| c.id.as_str()).collect();
assert_eq!(
go,
[
"go:run:server:fixtures/go-app",
"go:test:fixtures/go-app",
"go:run:services/backend/api",
"go:test:services/backend/api",
]
);
let run = find(&configs, "go:run:server:fixtures/go-app");
assert_eq!(cmdline(run), "go run ./cmd/server");
assert_eq!(run.cwd.as_deref(), Some(Path::new("fixtures/go-app")));
let test = find(&configs, "go:test:services/backend/api");
assert_eq!(test.cwd.as_deref(), Some(Path::new("services/backend/api")));
}
#[cfg(unix)]
#[test]
fn nested_go_discovery_does_not_follow_symlinks() {
let dir = project(&[("real/go.mod", "module real\n")]);
std::os::unix::fs::symlink(dir.path().join("real"), dir.path().join("alias")).unwrap();
assert_eq!(ids(&detect(dir.path())), ["go:test:real"]);
}
#[test]
fn python_projects() {
let dir = project(&[
+37
View File
@@ -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({
+33 -12
View File
@@ -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,12 +52,17 @@ 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.cwd ?? "") : (config.cwd ?? ""))
.replace(/^\.\/?/, "")
.replace(/\/+$/, "");
}
/** Configs whose working directory most specifically contains `relPath` (ties in their original order). */
function allClosest(configs: 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;
};
@@ -72,9 +78,7 @@ 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(/^\.\/?/, "")
.replace(/\/+$/, "");
const cwd = configScope(config);
return cwd && relPath.startsWith(cwd + "/") ? relPath.slice(cwd.length + 1) : relPath;
}
@@ -147,7 +151,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 = (kinds.includes("go") ? undefined : candidates[0]) ?? implicitPytest(relPath, configs);
return fallback ? [fallback] : [];
}
@@ -218,16 +222,33 @@ function mainConfigFor(relPath: string, configs: RunConfig[]): RunConfig | null
return byBin ?? closest(runs, relPath) ?? 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).
+76
View File
@@ -273,6 +273,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");
+3 -3
View File
@@ -16,7 +16,7 @@ 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 { configScope, contextConfig, fileRole, projectPython, testBasesFor, testConfigFor } from "./context";
import { djangoLabel, djangoTestsOf, hasDjangoTests } from "./djangoTypes";
import { firstTopLevelType, scanJvmTargets, simpleClassName } from "./jvmTargets";
import { isWindows } from "./jvmTypes";
@@ -615,8 +615,8 @@ const hasKotlinMain = (content?: string) => content === undefined || /^(?:suspen
*/
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(/\/+$/, "");
if (!kinds.includes(c.kind) || c.id.startsWith("context:")) return false;
const cwd = configScope(c);
return !cwd || rel.startsWith(cwd + "/");
});
}