From ae558d70f19d7760435173ea156702a2708fab5c Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:30:44 +0800 Subject: [PATCH] fix(run): resolve nested Go modules for context runs --- crates/ide-run/src/detect.rs | 7 ++- crates/ide-run/tests/detect.rs | 42 +++++++++++++++++ src/features/run/configs.test.ts | 37 +++++++++++++++ src/features/run/context.ts | 45 +++++++++++++----- src/features/run/producers.test.ts | 76 ++++++++++++++++++++++++++++++ src/features/run/producers.ts | 6 +-- 6 files changed, 196 insertions(+), 17 deletions(-) diff --git a/crates/ide-run/src/detect.rs b/crates/ide-run/src/detect.rs index 9b1e98bc9..d996079ee 100644 --- a/crates/ide-run/src/detect.rs +++ b/crates/ide-run/src/detect.rs @@ -712,11 +712,14 @@ fn detect_go(root: &Path, out: &mut Vec) { 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))); } } } diff --git a/crates/ide-run/tests/detect.rs b/crates/ide-run/tests/detect.rs index 5cbb58187..e69fb3a53 100644 --- a/crates/ide-run/tests/detect.rs +++ b/crates/ide-run/tests/detect.rs @@ -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(&[ diff --git a/src/features/run/configs.test.ts b/src/features/run/configs.test.ts index 1d515d92b..91b143b56 100644 --- a/src/features/run/configs.test.ts +++ b/src/features/run/configs.test.ts @@ -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({ diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 26ba771fa..68e486ef1 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -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 `:` suffix). diff --git a/src/features/run/producers.test.ts b/src/features/run/producers.test.ts index 2487c44e1..fc9cc7c64 100644 --- a/src/features/run/producers.test.ts +++ b/src/features/run/producers.test.ts @@ -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"); diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 20119565d..efe135b64 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -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 + "/"); }); }