diff --git a/.dagger/modules/e2e/dagger-module.toml b/.dagger/modules/e2e/dagger-module.toml index 79cf559..d80999d 100644 --- a/.dagger/modules/e2e/dagger-module.toml +++ b/.dagger/modules/e2e/dagger-module.toml @@ -1,5 +1,5 @@ name = "e2e" -engineVersion = "v0.20.6" +engineVersion = "v0.21.5" [runtime] source = "dang" diff --git a/.dagger/modules/e2e/main.dang b/.dagger/modules/e2e/main.dang index 6d1c9b9..a042010 100644 --- a/.dagger/modules/e2e/main.dang +++ b/.dagger/modules/e2e/main.dang @@ -7,23 +7,32 @@ type E2e { let baseFixturePath: String! = "testdata/go-module-custom-base" let baseGeneratedFilePath: String! = baseFixturePath + "/generated.go" let emptyFixturePath: String! = "testdata/go-module-empty" - let skipLint: [String!]! = [ - baseFixturePath, - emptyFixturePath, - "testdata/go-module-excluded", - "testdata/go-module-skip-tree", + let cgoCxxFixturePath: String! = "testdata/go-module-cgo-cxx" + let lintFailureFixturePath: String! = "testdata/go-module-lint-fail" + let lint: [String!]! = [ + "**", + "!" + baseFixturePath, + "!" + cgoCxxFixturePath, + "!" + emptyFixturePath, + "!" + lintFailureFixturePath, + "!testdata/go-module-excluded", + "!testdata/go-module-skip-tree", ] - let skipTest: [String!]! = [ - baseFixturePath, - emptyFixturePath, - "testdata/go-module-excluded", - "testdata/go-module-skip-tree", + let test: [String!]! = [ + "**", + "!" + baseFixturePath, + "!" + cgoCxxFixturePath, + "!" + emptyFixturePath, + "!" + lintFailureFixturePath, + "!testdata/go-module-excluded", + "!testdata/go-module-skip-tree", ] - let skipGenerate: [String!]! = [ - "helpers/go-includes", - baseFixturePath, - "testdata/go-module-excluded", - "testdata/go-module-skip-tree", + let generate: [String!]! = [ + "**", + "!helpers/go-includes", + "!" + baseFixturePath, + "!testdata/go-module-excluded", + "!testdata/go-module-skip-tree", ] let base: Container! { @@ -46,17 +55,31 @@ type E2e { .length > 0 } - let containsModulePath(mods: [{{path: String!}}!]!, want: String!): Boolean! { - mods - .filter { mod => mod.path == want } - .length > 0 + let containsModulePath(paths: [String!]!, want: String!): Boolean! { + containsPath(paths, want) + } + + """ + Whether a module at modPath is excluded from generate selection `patterns`. + Uses findUp: false so modPath need not be a real module on disk. + """ + let selectsOut(ws: Workspace!, patterns: [String!]!, modPath: String!): Boolean! { + go(version: "1.26.1", generate: patterns).module(ws, modPath, findUp: false).skipGenerate(ws) + } + + """ + Whether a module at modPath is excluded from lint selection `patterns`. + Uses findUp: false so modPath need not be a real module on disk. + """ + let lintSelectsOut(ws: Workspace!, patterns: [String!]!, modPath: String!): Boolean! { + go(version: "1.26.1", lint: patterns).module(ws, modPath, findUp: false).skipLint(ws) } """ Run testAll while respecting per-module skip configuration. """ pub singleModuleCheck(ws: Workspace!): Void @check { - let tool = go(version: "1.26.1", skipLint: skipLint, skipTest: skipTest, skipGenerate: skipGenerate) + let tool = go(version: "1.26.1", lint: lint, test: test, generate: generate) tool.lintAll(ws) tool.testAll(ws) tool.module(ws, "testdata/go-module-excluded").lint(ws) @@ -86,6 +109,13 @@ type E2e { go(version: "1.26.1").module(ws, "fixtures/go-module-with-replace").test(ws) } + """ + Lint can typecheck cgo packages that need a C++ compiler. + """ + pub cgoCxxLintCheck(ws: Workspace!): Void @check { + go(version: "1.26.1").module(ws, cgoCxxFixturePath).lint(ws) + } + """ A custom base container must be used for Go helpers, tests, and generate. """ @@ -112,7 +142,7 @@ type E2e { let testDirs = mod.testDirectories(ws) assert( - containsModulePath(testDirs.{path}, baseFixturePath), + containsModulePath(testDirs.{{path}}.map { dir => dir.path }, baseFixturePath), "custom base test directory discovery did not include expected path", ) @@ -156,8 +186,9 @@ type E2e { "module lookup unexpectedly snapped with findUp false: " + direct.path, ) - let tool = go(version: "1.26.1", skipLint: skipLint, skipTest: skipTest, skipGenerate: skipGenerate) - let allModulePaths = tool.modules(ws).{path} + let tool = go(version: "1.26.1", lint: lint, test: test, generate: generate) + let allModules = tool.modules(ws) + let allModulePaths = allModules.keys assert( containsModulePath(allModulePaths, "fixtures/go-module-cross-include-a"), "unfiltered modules did not include expected fixture module", @@ -167,13 +198,21 @@ type E2e { "unfiltered modules did not include expected fixture module", ) assert( - containsModulePath(allModulePaths, "testdata/go-module-excluded"), - "unfiltered modules did not include skipped fixture module by default", + containsModulePath(allModulePaths, "testdata/go-module-excluded") == false, + "modules included fixture excluded from every operation", + ) + assert( + allModules.get(key: "testdata/go-module-with-testdata").path == "testdata/go-module-with-testdata", + "module collection get did not return the requested module", + ) + assert( + allModules.subset(keys: ["fixtures/go-module-cross-include-a"]).keys.length == 1, + "module collection subset did not narrow to the requested keys", ) let includedModulePaths = tool .modules(ws, include: ["fixtures/go-module-cross-include-a"]) - .{path} + .keys assert( containsModulePath(includedModulePaths, "fixtures/go-module-cross-include-a"), "modules include filter did not include matching module root", @@ -185,7 +224,7 @@ type E2e { let excludedModulePaths = tool .modules(ws, exclude: ["fixtures/go-module-cross-include-a"]) - .{path} + .keys assert( containsModulePath(excludedModulePaths, "fixtures/go-module-cross-include-a") == false, "modules exclude filter included excluded module root", @@ -197,7 +236,7 @@ type E2e { include: ["fixtures/go-module-cross-include-*"], exclude: ["fixtures/go-module-cross-include-b"], ) - .{path} + .keys assert( containsModulePath(combinedModulePaths, "fixtures/go-module-cross-include-a"), "modules combined filters did not include matching module root", @@ -209,7 +248,7 @@ type E2e { let contentMatchedModulePaths = tool .modules(ws, include: ["**/module-a-only.data"]) - .{path} + .keys assert( containsModulePath(contentMatchedModulePaths, "fixtures/go-module-cross-include-a"), "modules include filter did not match module directory contents", @@ -221,7 +260,7 @@ type E2e { let lintSkippedModulePaths = tool .modules(ws, includeSkipLint: false) - .{path} + .keys assert( containsModulePath(lintSkippedModulePaths, "testdata/go-module-excluded") == false, "modules includeSkipLint false included lint-skipped module", @@ -241,7 +280,7 @@ type E2e { let testSkippedModulePaths = tool .modules(ws, includeSkipTest: false) - .{path} + .keys assert( containsModulePath(testSkippedModulePaths, "testdata/go-module-excluded") == false, "modules includeSkipTest false included test-skipped module", @@ -261,7 +300,7 @@ type E2e { let generateSkippedModulePaths = tool .modules(ws, includeSkipGenerate: false) - .{path} + .keys assert( containsModulePath(generateSkippedModulePaths, "testdata/go-module-excluded") == false, "modules includeSkipGenerate false included generate-skipped module", @@ -275,6 +314,40 @@ type E2e { "modules includeSkipGenerate false excluded non-generate-skipped module", ) + let normalizedSelectionTool = go( + version: "1.26.1", + lint: ["[\"!" + emptyFixturePath + "\"", "\"!" + lintFailureFixturePath + "\"]"], + test: ["[\"!" + emptyFixturePath + "\"", "\"!" + lintFailureFixturePath + "\"]"], + generate: ["[\"!" + emptyFixturePath + "\"", "\"!" + lintFailureFixturePath + "\"]"], + ) + assert( + normalizedSelectionTool.module(ws, emptyFixturePath).skipLint(ws), + "module did not normalize beta workspace-settings lint selector array fragment", + ) + assert( + normalizedSelectionTool.module(ws, lintFailureFixturePath).skipLint(ws), + "module did not normalize trailing beta workspace-settings lint selector array fragment", + ) + assert( + normalizedSelectionTool.module(ws, "testdata/go-module-with-testdata").skipLint(ws) == false, + "normalized beta workspace-settings lint selector array skipped unrelated module", + ) + assert( + normalizedSelectionTool.module(ws, emptyFixturePath).skipTest(ws), + "module did not normalize beta workspace-settings test selector array fragment", + ) + assert( + normalizedSelectionTool.module(ws, lintFailureFixturePath).skipGenerate(ws), + "module did not normalize beta workspace-settings generate selector array fragment", + ) + let normalizedLintSkippedModulePaths = normalizedSelectionTool + .modules(ws, includeSkipLint: false) + .keys + assert( + containsModulePath(normalizedLintSkippedModulePaths, lintFailureFixturePath) == false, + "normalized beta workspace-settings lint selector array included skipped module", + ) + let withExtraFiles = go( version: "1.26.1", includeExtraFiles: ["LICENSE"], @@ -426,6 +499,55 @@ type E2e { ), "go:generate scoped include did not include generator support files", ) + + # Test directory collection surface and static test discovery. + # cross-include-b has two top-level Test functions in a single file — + # exercises ripgrep-based listing and multi-match handling. + let crossB = tool.module(ws, "fixtures/go-module-cross-include-b") + let crossBTestDirs = crossB.testDirs(ws) + assert( + containsPath(crossBTestDirs.keys, "fixtures/go-module-cross-include-b"), + "testDirs collection did not include expected test directory", + ) + assert( + crossBTestDirs + .get(key: "fixtures/go-module-cross-include-b") + .path == "fixtures/go-module-cross-include-b", + "testDirs collection get did not return the requested directory", + ) + + let crossBTests = crossBTestDirs + .get(key: "fixtures/go-module-cross-include-b") + .tests() + assert( + crossBTests.keys.length == 2, + "tests collection did not discover expected number of tests", + ) + assert( + containsPath(crossBTests.keys, "TestSiblingModuleDirectiveIncludeIsNotMounted"), + "tests collection did not include expected test name", + ) + assert( + containsPath(crossBTests.keys, "TestSiblingModuleTestdataIsNotMounted"), + "tests collection did not include second test from same file", + ) + assert( + crossBTests + .get(key: "TestSiblingModuleDirectiveIncludeIsNotMounted") + .name == "TestSiblingModuleDirectiveIncludeIsNotMounted", + "tests collection get did not return the requested test", + ) + + # Skip-configured directory should report no tests at all. + let skippedTests = tool + .module(ws, "testdata/go-module-excluded") + .testDirs(ws) + .keys + assert( + skippedTests.length == 0, + "testDirs collection on a skipped module was not empty", + ) + null } @@ -439,7 +561,7 @@ type E2e { .layer .sync - let changes = go(version: "1.26.1", skipGenerate: skipGenerate).generateAll(ws) + let changes = go(version: "1.26.1", generate: generate).generateAll(ws) assert( changes.addedPaths.length == 1, @@ -466,7 +588,7 @@ type E2e { let emptyChanges = go( version: "1.26.1", - skipGenerate: [generatedFixturePath], + generate: ["**", "!" + generatedFixturePath], ).module(ws, generatedFixturePath).generate(ws) assert( emptyChanges.addedPaths.length == 0, @@ -497,4 +619,80 @@ type E2e { null } + + """ + Gitignore-style selection: "!" excludes, an include-only list scopes down. + """ + pub selectionCheck(ws: Workspace!): Void @check { + let excluded = go(version: "1.26.1", generate: ["**", "!" + generatedFixturePath]) + assert( + excluded.module(ws, generatedFixturePath).skipGenerate(ws), + "'!' pattern did not exclude its module", + ) + + let includeOnly = go(version: "1.26.1", generate: ["fixtures/**"]) + assert( + includeOnly.module(ws, generatedFixturePath).skipGenerate(ws), + "include-only selection did not exclude an out-of-scope module", + ) + assert( + includeOnly.module(ws, "fixtures/go-module-cross-include-a").skipGenerate(ws) == false, + "include-only selection excluded an in-scope module", + ) + + assert(selectsOut(ws, ["**"], "a/b") == false, "\"**\" did not select a module") + assert(selectsOut(ws, ["*"], "a/b") == false, "\"*\" did not select a module") + + assert(selectsOut(ws, ["**", "!docs"], "docs"), "exclude missed the module itself") + assert(selectsOut(ws, ["**", "!docs"], "docs/snippets"), "exclude missed a nested module") + assert(selectsOut(ws, ["**", "!docs/**"], "docs/snippets"), "\"X/**\" exclude missed a nested module") + assert(selectsOut(ws, ["**", "!docs"], "docs-tools") == false, "exclude leaked across a name-prefix boundary") + + assert(selectsOut(ws, ["!docs"], "docs"), "exclude-only list did not exclude") + assert(selectsOut(ws, ["!docs"], "core") == false, "exclude-only list did not include the rest") + assert(lintSelectsOut(ws, ["!docs"], "docs"), "lint exclude-only list did not exclude") + assert(lintSelectsOut(ws, ["!docs"], "core") == false, "lint exclude-only list did not include the rest") + assert( + lintSelectsOut(ws, ["[\"!docs\"", "\"!tmp\"]"], "docs"), + "lint did not normalize beta workspace-settings array fragments before excluding", + ) + assert( + lintSelectsOut(ws, ["[\"!docs\"", "\"!tmp\"]"], "core") == false, + "lint did not normalize beta workspace-settings array fragments before including the rest", + ) + + let excludeOnlyLintModules = go( + version: "1.26.1", + lint: ["!" + emptyFixturePath], + ).modules(ws, includeSkipLint: false).keys + assert( + containsModulePath(excludeOnlyLintModules, lintFailureFixturePath), + "exclude-only lint settings excluded every non-matching module", + ) + assert( + containsModulePath(excludeOnlyLintModules, emptyFixturePath) == false, + "exclude-only lint settings included the excluded module", + ) + + let scoped = go( + version: "1.26.1", + lint: ["testdata", "!testdata/go-module-skip-tree"], + test: ["testdata", "!testdata/go-module-skip-tree"], + generate: ["testdata/go-module-generate"], + ) + assert( + containsModulePath(scoped.modules(ws).keys, "testdata/go-module-with-testdata"), + "scoped module discovery excluded an included fixture", + ) + assert( + containsModulePath(scoped.modules(ws).keys, "testdata/go-module-skip-tree/nested") == false, + "fully excluded nested module was discovered", + ) + + assert(selectsOut(ws, ["."], "core"), "\".\" leaked to a non-root module") + assert(selectsOut(ws, ["."], ".") == false, "\".\" did not select the root module") + assert(selectsOut(ws, ["!."], "."), "\"!.\" did not exclude the root module") + + null + } } diff --git a/dagger.json b/dagger.json index e84b172..9f8ca8c 100644 --- a/dagger.json +++ b/dagger.json @@ -1,6 +1,6 @@ { "name": "go", - "engineVersion": "v0.20.6", + "engineVersion": "v0.21.5", "sdk": { "source": "dang" } diff --git a/go.dang b/go.dang index 815af65..b200a5b 100644 --- a/go.dang +++ b/go.dang @@ -32,17 +32,21 @@ type Go { """ includeExtraFiles: [String!]! = [], """ - Workspace-relative module roots or ancestor subtrees to skip for lint checks. + Module roots to lint. A bare pattern selects, a "!"-prefixed pattern + excludes, and an exclude always wins within this operation. With no + selecting pattern, every module is linted. Supported patterns: "**" or "*" + (all modules), "." (root module only), and "X" or "X/**" (module X and any + module below it). """ - skipLint: [String!]! = [], + lint: [String!]! = ["**"], """ - Workspace-relative module roots or ancestor subtrees to skip for tests. + Module roots to test. Same selection rules as `lint`. """ - skipTest: [String!]! = [], + test: [String!]! = ["**"], """ - Workspace-relative module roots or ancestor subtrees to skip for go generate. + Module roots to run go generate in. Same selection rules as `lint`. """ - skipGenerate: [String!]! = [], + generate: [String!]! = ["**"], ) { if (version != null and base != null) { raise "version and base are mutually exclusive" @@ -50,13 +54,26 @@ type Go { self.version = if (base == null) { version ?? "1.26" } else { null } self.base = base ?? container.from("golang:" + (version ?? "1.26") + "-alpine") self.includeExtraFiles = includeExtraFiles - self.skipLint = skipLint - self.skipTest = skipTest - self.skipGenerate = skipGenerate + self.lint = normalizePatterns(lint) + self.test = normalizePatterns(test) + self.generate = normalizePatterns(generate) self } } + """ + Normalize selector strings from workspace module settings. + + Dagger v1.0.0-beta.4 may pass TOML string arrays as JSON-fragment strings + like ["[\"!infra\"", "\"!docs\"]"]; trim those wrappers so selection still + sees the intended patterns. + """ + let normalizePatterns(patterns: [String!]!): [String!]! { + patterns.map { p => + p.trimPrefix("[").trimSuffix("]").trimPrefix("\"").trimSuffix("\"") + } + } + """ Extra workspace-root include patterns mounted for each module's Go commands. Use this for fixtures, generator inputs, or other files not covered by the @@ -65,22 +82,23 @@ type Go { pub includeExtraFiles: [String!]! = [] """ - Workspace-relative module roots or ancestor subtrees to skip for lint checks. + Module roots to lint ("!" excludes; see the `lint` constructor argument). """ - pub skipLint: [String!]! = [] + pub lint: [String!]! = ["**"] """ - Workspace-relative module roots or ancestor subtrees to skip for tests. + Module roots to test ("!" excludes; see the `lint` constructor argument). """ - pub skipTest: [String!]! = [] + pub test: [String!]! = ["**"] """ - Workspace-relative module roots or ancestor subtrees to skip for go generate. + Module roots to run go generate in ("!" excludes; see `lint`). """ - pub skipGenerate: [String!]! = [] + pub generate: [String!]! = ["**"] """ - Return every Go module discovered from workspace go.mod files. + Return Go modules discovered from workspace go.mod files, as a collection + keyed by module root path. Optional include/exclude patterns filter discovered module root directories. """ pub modules( @@ -90,13 +108,17 @@ type Go { includeSkipLint: Boolean! = true, includeSkipTest: Boolean! = true, includeSkipGenerate: Boolean! = true, - ): [GoModule!]! { - ws - .directory("/", include: ["**/go.mod"]) - .glob("**/go.mod") - .map { modPath => - if (modPath == "go.mod") { "." } else { modPath.trimSuffix("/go.mod") } - } + ): GoModules! { + # Single workspace-wide snapshot of every *_test.go file, materialized + # once at this Workspace boundary and threaded down through the + # collection so GoDirectory.tests() can search/filter without ever + # touching Workspace again. Functions taking Workspace always invalidate; + # operations on a Directory receiver cache by content hash. + let paths = uniqueStrings( + moduleDiscoveryPaths(ws, lint) + + moduleDiscoveryPaths(ws, test) + + moduleDiscoveryPaths(ws, generate), + ) .filter { modPath => modulePathIncluded(ws, modPath, include, exclude) } @@ -106,9 +128,12 @@ type Go { version: version, baseImage: base, includeExtraFiles: includeExtraFiles, - skipLintPaths: skipLint, - skipTestPaths: skipTest, - skipGeneratePaths: skipGenerate, + lintPatterns: lint, + testPatterns: test, + generatePatterns: generate, + includeScanDir: selectedGoFilesDir(ws), + testScanDir: selectedTestScanDir(ws), + testFilesDir: selectedTestFilesDir(ws), ) } .filter { mod => @@ -116,6 +141,162 @@ type Go { (includeSkipTest or mod.skipTest(ws) == false) and (includeSkipGenerate or mod.skipGenerate(ws) == false) } + .map { mod => mod.path } + + GoModules( + paths: paths, + version: version, + baseImage: base, + includeExtraFiles: includeExtraFiles, + lintPatterns: lint, + testPatterns: test, + generatePatterns: generate, + includeScanDir: selectedGoFilesDir(ws), + testScanDir: selectedTestScanDir(ws), + testFilesDir: selectedTestFilesDir(ws), + ) + } + + let uniqueStrings(values: [String!]!): [String!]! { + values.reduce([]) { acc, value => + if (acc.any { existing => existing == value }) { + acc + } else { + acc + [value] + } + } + } + + let moduleDiscoveryPaths(ws: Workspace!, patterns: [String!]!): [String!]! { + ws + .directory( + "/", + include: selectorGoModIncludes(patterns), + exclude: selectorGoModExcludes(patterns), + ) + .glob("**/go.mod") + .map { modPath => + if (modPath == "go.mod") { "." } else { modPath.trimSuffix("/go.mod") } + } + .filter { modPath => moduleSelected(patterns, modPath) } + } + + let selectedTestFilesDir(ws: Workspace!): Directory! { + ws.directory( + "/", + include: selectorTestFileIncludes(test), + exclude: selectorTestFileExcludes(test), + ) + } + + let selectedGoFilesDir(ws: Workspace!): Directory! { + ws.directory( + "/", + include: uniqueStrings( + selectorGoFileIncludes(lint) + + selectorGoFileIncludes(test) + + selectorGoFileIncludes(generate) + + selectorGoModIncludes(lint) + + selectorGoModIncludes(test) + + selectorGoModIncludes(generate), + ), + exclude: uniqueStrings( + selectorGoFileExcludes(lint) + + selectorGoFileExcludes(test) + + selectorGoFileExcludes(generate) + + selectorGoModExcludes(lint) + + selectorGoModExcludes(test) + + selectorGoModExcludes(generate), + ), + ) + } + + let selectedTestScanDir(ws: Workspace!): Directory! { + ws.directory( + "/", + include: uniqueStrings( + selectorGoFileIncludes(test) + + selectorGoModIncludes(test), + ), + exclude: uniqueStrings( + selectorGoFileExcludes(test) + + selectorGoModExcludes(test), + ), + ) + } + + let selectorGoModIncludes(patterns: [String!]!): [String!]! { + let included = patterns.filter { p => p.hasPrefix("!") == false } + if (included.length == 0) { + ["**/go.mod"] + } else { + included.map { p => selectorFileGlob(p, "go.mod") } + } + } + + let selectorGoModExcludes(patterns: [String!]!): [String!]! { + patterns + .filter { p => p.hasPrefix("!") } + .map { p => selectorFileGlob(p.trimPrefix("!"), "go.mod") } + } + + let selectorGoFileIncludes(patterns: [String!]!): [String!]! { + let included = patterns.filter { p => p.hasPrefix("!") == false } + if (included.length == 0) { + ["**/*.go"] + } else { + included.map { p => selectorFileGlob(p, "*.go") } + } + } + + let selectorGoFileExcludes(patterns: [String!]!): [String!]! { + patterns + .filter { p => p.hasPrefix("!") } + .map { p => selectorFileGlob(p.trimPrefix("!"), "*.go") } + } + + let selectorTestFileIncludes(patterns: [String!]!): [String!]! { + let included = patterns.filter { p => p.hasPrefix("!") == false } + if (included.length == 0) { + ["**/*_test.go"] + } else { + included.map { p => selectorFileGlob(p, "*_test.go") } + } + } + + let selectorTestFileExcludes(patterns: [String!]!): [String!]! { + patterns + .filter { p => p.hasPrefix("!") } + .map { p => selectorFileGlob(p.trimPrefix("!"), "*_test.go") } + } + + let selectorFileGlob(pattern: String!, filePattern: String!): String! { + let p = pattern.trimPrefix("/").trimSuffix("/") + if (p == "**" or p == "*") { + "**/" + filePattern + } else if (p == ".") { + filePattern + } else { + p.trimSuffix("/**") + "/**/" + filePattern + } + } + + let moduleSelected(patterns: [String!]!, modPath: String!): Boolean! { + let included = patterns.filter { p => p.hasPrefix("!") == false } + let excluded = patterns.filter { p => p.hasPrefix("!") }.map { p => p.trimPrefix("!") } + let isIncluded = included.length == 0 or included.any { p => matchesModulePath(modPath, p) } + let isExcluded = excluded.any { p => matchesModulePath(modPath, p) } + isIncluded and isExcluded == false + } + + let matchesModulePath(modPath: String!, pattern: String!): Boolean! { + let p = pattern.trimPrefix("/").trimSuffix("/") + if (p == "**" or p == "*") { + true + } else { + let prefix = p.trimSuffix("/**") + modPath == prefix or modPath.hasPrefix(prefix + "/") + } } let modulePathIncluded( @@ -160,9 +341,12 @@ type Go { version: version, baseImage: base, includeExtraFiles: includeExtraFiles, - skipLintPaths: skipLint, - skipTestPaths: skipTest, - skipGeneratePaths: skipGenerate, + lintPatterns: lint, + testPatterns: test, + generatePatterns: generate, + includeScanDir: selectedGoFilesDir(ws), + testScanDir: selectedTestScanDir(ws), + testFilesDir: selectedTestFilesDir(ws), ) } @@ -171,7 +355,9 @@ type Go { Modules configured to skip lint are skipped. """ pub lintAll(ws: Workspace!): Void @check { - let layers = modules(ws, includeSkipLint: false).reduce(directory) { layers, mod => + let mods = modules(ws, includeSkipLint: false) + let layers = mods.paths.reduce(directory) { layers, path => + let mod = mods.module(path) layers.withDirectory( mod.path, directory.withFile("go.mod", mod.lintExec(ws).file("go.mod")), @@ -190,8 +376,9 @@ type Go { Modules configured to skip tests are skipped. """ pub testAll(ws: Workspace!): Void @check { - let mods = modules(ws, includeSkipTest: false) - let layers = mods.reduce(directory) { layers, mod => + let collection = modules(ws, includeSkipTest: false) + let layers = collection.paths.reduce(directory) { layers, path => + let mod = collection.module(path) layers.withDirectory( mod.path, directory.withFile("go.mod", mod.testExec(ws).file("go.mod")), @@ -211,7 +398,8 @@ type Go { are skipped. """ pub generateAll(ws: Workspace!): Changeset! @generate { - let mods = modules(ws).filter { mod => + let collection = modules(ws) + let mods = collection.paths.map { path => collection.module(path) }.filter { mod => mod.skipGenerate(ws) == false and mod.hasGenerateDirectives(ws) } @@ -231,6 +419,44 @@ type Go { } +""" +Go modules discovered in a workspace, keyed by module root path. +""" +type GoModules { + """ + Workspace-relative module root paths in discovery order. + """ + pub paths: [String!]! @keys + + let version: String + let baseImage: Container! + let includeExtraFiles: [String!]! + let lintPatterns: [String!]! + let testPatterns: [String!]! + let generatePatterns: [String!]! + let includeScanDir: Directory! + let testScanDir: Directory! + let testFilesDir: Directory! + + """ + Return the discovered Go module with the given workspace-relative root path. + """ + pub module(path: String!): GoModule! @get { + GoModule( + path: path, + version: version, + baseImage: baseImage, + includeExtraFiles: includeExtraFiles, + lintPatterns: lintPatterns, + testPatterns: testPatterns, + generatePatterns: generatePatterns, + includeScanDir: includeScanDir, + testScanDir: testScanDir, + testFilesDir: testFilesDir, + ) + } +} + """ A Go module rooted at a workspace-relative path. """ @@ -256,19 +482,36 @@ type GoModule { pub includeExtraFiles: [String!]! """ - Workspace-relative module roots or ancestor subtrees to skip for lint checks. + Gitignore-style globs selecting which module roots to lint. + """ + let lintPatterns: [String!]! + + """ + Gitignore-style globs selecting which module roots to test. """ - let skipLintPaths: [String!]! + let testPatterns: [String!]! """ - Workspace-relative module roots or ancestor subtrees to skip for tests. + Gitignore-style globs selecting which module roots to run go generate in. """ - let skipTestPaths: [String!]! + let generatePatterns: [String!]! """ - Workspace-relative module roots or ancestor subtrees to skip for go generate. + Workspace snapshot used by the local include helper. """ - let skipGeneratePaths: [String!]! + let includeScanDir: Directory! + + """ + Workspace snapshot used by the local test-directory helper. + """ + let testScanDir: Directory! + + """ + Workspace-wide snapshot of every *_test.go file, threaded down from the + Go.modules / Go.module constructor. Used by GoDirectory.tests so that + test discovery never crosses Workspace again. + """ + let testFilesDir: Directory! """ Return a workspace-root path for a module-relative subpath. @@ -304,25 +547,56 @@ type GoModule { .withEnvVariable("DAGGER_GO_WORKSPACE_ID", toJSON(ws.id)) } + let moduleOutputFile: String! { + (if (path == ".") { "_root_" } else { path }) + ".inc" + } + + """ + Base container for local helper passes over a scoped workspace snapshot. + """ + let localIncludesHelper(scanDir: Directory!): Container! { + baseImage + .withoutEntrypoint + .withWorkdir("/helpers/go-includes") + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helpers/go-includes", currentModule.source.directory("helpers/go-includes")) + .withExec(["go", "build", "-o", "/usr/local/bin/go-includes", "."]) + .withDirectory("/ws", scanDir) + .withWorkdir("/ws") + } + + let allIncludesDir(lint: Boolean!, test: Boolean!, generate: Boolean!): Directory! { + localIncludesHelper(includeScanDir) + .withExec(["go-includes", "--all", "--output-dir", "/output"] + includeFlags(lint, test, generate)) + .directory("/output") + } + + let allTestDirsDir: Directory! { + localIncludesHelper(testScanDir) + .withExec(["go-includes", "--all", "--test-dirs", "--output-dir", "/output"]) + .directory("/output") + } + """ - Whether this module or an ancestor subtree is configured to skip lint. + Whether this module falls outside the configured lint selection. """ pub skipLint(ws: Workspace!): Boolean! { - hasSkipPath(skipLintPaths) + outOfScope(lintPatterns) } """ - Whether this module or an ancestor subtree is configured to skip tests. + Whether this module falls outside the configured test selection. """ pub skipTest(ws: Workspace!): Boolean! { - hasSkipPath(skipTestPaths) + outOfScope(testPatterns) } """ - Whether this module or an ancestor subtree is configured to skip go generate. + Whether this module falls outside the configured go generate selection. """ pub skipGenerate(ws: Workspace!): Boolean! { - hasSkipPath(skipGeneratePaths) + outOfScope(generatePatterns) } """ @@ -339,7 +613,7 @@ type GoModule { literal: true, filesOnly: true, limit: 1, - ).{id} + ).{{id}} .length > 0 } } @@ -352,12 +626,33 @@ type GoModule { } """ - Whether this module root is at or below a configured skip path. + Whether this module root falls outside the selection `patterns`. + + Matching is on the module root path only: no workspace files are read. A + bare pattern includes; a "!"-prefixed pattern excludes. With no include + pattern, every module is included. A module is in scope when it matches an + include and no exclude; an exclude always wins within that operation. """ - let hasSkipPath(skipPaths: [String!]!): Boolean! { - skipPaths.any { skipPath => - let clean = skipPath.trimPrefix("/").trimSuffix("/") - clean == "." or clean == "" or path == clean or path.hasPrefix(clean + "/") + let outOfScope(patterns: [String!]!): Boolean! { + let included = patterns.filter { p => p.hasPrefix("!") == false } + let excluded = patterns.filter { p => p.hasPrefix("!") }.map { p => p.trimPrefix("!") } + let isIncluded = included.length == 0 or included.any { p => matchesModulePath(p) } + let isExcluded = excluded.any { p => matchesModulePath(p) } + isIncluded == false or isExcluded + } + + """ + Whether this module root matches a single glob: "**"/"*" match everything, + "." matches only the root module, and "X/**" or bare "X" match X and any + module below it. + """ + let matchesModulePath(pattern: String!): Boolean! { + let p = pattern.trimPrefix("/").trimSuffix("/") + if (p == "**" or p == "*") { + true + } else { + let prefix = p.trimSuffix("/**") + path == prefix or path.hasPrefix(prefix + "/") } } @@ -372,12 +667,8 @@ type GoModule { test: Boolean! = false, generate: Boolean! = false, ): [String!]! { - goIncludesHelper(ws) - .withExec( - ["go-includes", "--output", "/output"] + includeFlags(lint, test, generate) + [workspacePath], - experimentalPrivilegedNesting: true, - ) - .file("/output") + allIncludesDir(lint: lint, test: test, generate: generate) + .file(moduleOutputFile) .contents .split("\n") .filter { pattern => pattern != "" } @@ -387,25 +678,48 @@ type GoModule { Directories in this module containing Go test files. """ pub testDirectories(ws: Workspace!): [GoDirectory!]! { - goIncludesHelper(ws) - .withExec( - ["go-includes", "--output", "/output", "--test-dirs", workspacePath], - experimentalPrivilegedNesting: true, + testDirectoryPaths(ws).map { testPath => + GoDirectory( + path: testPath, + ws: ws, + modulePath: path, + baseImage: baseImage, + includeExtraFiles: includeExtraFiles, + testPatterns: testPatterns, + includeScanDir: includeScanDir, + testScanDir: testScanDir, + testFilesDir: testFilesDir, ) - .file("/output") - .contents - .split("\n") - .filter { testPath => testPath != "" } - .map { testPath => - GoDirectory( - path: testPath, - ws: ws, - modulePath: path, - baseImage: baseImage, - includeExtraFiles: includeExtraFiles, - skipTestPaths: skipTestPaths, - ) - } + } + } + + let testDirectoryPaths(ws: Workspace!): [String!]! { + if (skipTest(ws)) { + [] + } else { + allTestDirsDir + .file(moduleOutputFile) + .contents + .split("\n") + .filter { testPath => testPath != "" } + } + } + + """ + The test directories of this module, as a collection keyed by path. + """ + pub testDirs(ws: Workspace!): GoTestDirs! { + GoTestDirs( + paths: testDirectoryPaths(ws), + ws: ws, + modulePath: path, + baseImage: baseImage, + includeExtraFiles: includeExtraFiles, + testPatterns: testPatterns, + includeScanDir: includeScanDir, + testScanDir: testScanDir, + testFilesDir: testFilesDir, + ) } """ @@ -425,7 +739,13 @@ type GoModule { [ subpath("**/*.go"), subpath("**/*.c"), + subpath("**/*.cc"), + subpath("**/*.cpp"), + subpath("**/*.cxx"), subpath("**/*.h"), + subpath("**/*.hh"), + subpath("**/*.hpp"), + subpath("**/*.hxx"), subpath("**/*.s"), subpath("**/*.S"), subpath("**/*.syso"), @@ -557,6 +877,8 @@ type GoModule { let lintImage = "docker.io/golangci/golangci-lint:v2.11.4-alpine@sha256:" + "72bcd68512b4e27540dd3a778a1b7afd45759d8145cfb3c089f1d7af53e718e9" container .from(lintImage) + # cgo dependencies may need a C/C++ toolchain during typecheck. + .withExec(["apk", "add", "--no-cache", "build-base"]) .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) .withMountedCache("/root/.cache/golangci-lint", cacheVolume("golangci-lint")) @@ -601,6 +923,42 @@ type GoModule { """ A workspace directory containing Go test files. """ +""" +Test directories of a Go module, keyed by workspace-relative path. +""" +type GoTestDirs { + """ + Workspace-relative test directory paths in discovery order. + """ + pub paths: [String!]! @keys + + let ws: Workspace! + let modulePath: String! + let baseImage: Container! + let includeExtraFiles: [String!]! + let testPatterns: [String!]! + let includeScanDir: Directory! + let testScanDir: Directory! + let testFilesDir: Directory! + + """ + Return the test directory with the given workspace-relative path. + """ + pub directory(path: String!): GoDirectory! @get { + GoDirectory( + path: path, + ws: ws, + modulePath: modulePath, + baseImage: baseImage, + includeExtraFiles: includeExtraFiles, + testPatterns: testPatterns, + includeScanDir: includeScanDir, + testScanDir: testScanDir, + testFilesDir: testFilesDir, + ) + } +} + type GoDirectory { """ Workspace-relative path of this directory. @@ -628,9 +986,26 @@ type GoDirectory { let baseImage: Container! """ - Workspace-relative module roots or ancestor subtrees to skip for tests. + Gitignore-style globs selecting which module roots to test. + """ + let testPatterns: [String!]! + + """ + Workspace snapshot used by the local include helper. + """ + let includeScanDir: Directory! + + """ + Workspace snapshot used by the local test-directory helper. + """ + let testScanDir: Directory! + + """ + Workspace-wide snapshot of every *_test.go file, threaded down from the + parent collection. tests() searches/filters this directory so the cost + is paid once per session (Directory receiver = content-addressed cache). """ - let skipTestPaths: [String!]! + let testFilesDir: Directory! """ Return a workspace-root path for a module-relative subpath. @@ -666,13 +1041,58 @@ type GoDirectory { .withEnvVariable("DAGGER_GO_WORKSPACE_ID", toJSON(ws.id)) } + let moduleOutputFile: String! { + (if (modulePath == ".") { "_root_" } else { modulePath }) + ".inc" + } + """ - Whether this directory's module is at or below a configured test skip path. + Base container for local helper passes over a scoped workspace snapshot. + """ + let localIncludesHelper(scanDir: Directory!): Container! { + baseImage + .withoutEntrypoint + .withWorkdir("/helpers/go-includes") + .withMountedCache("/go/pkg/mod", cacheVolume("go-mod")) + .withMountedCache("/root/.cache/go-build", cacheVolume("go-build")) + .withDirectory("/helpers/go-includes", currentModule.source.directory("helpers/go-includes")) + .withExec(["go", "build", "-o", "/usr/local/bin/go-includes", "."]) + .withDirectory("/ws", scanDir) + .withWorkdir("/ws") + } + + let allIncludesDir: Directory! { + localIncludesHelper(includeScanDir) + .withExec(["go-includes", "--all", "--test", "--output-dir", "/output"]) + .directory("/output") + } + + """ + Whether this directory's module falls outside the configured test selection. + + Matching is on the module root path only: no workspace files are read. A + bare pattern includes; a "!"-prefixed pattern excludes; an exclude always + wins. With no include pattern, every module is included. """ let skipTest: Boolean! { - skipTestPaths.any { skipPath => - let clean = skipPath.trimPrefix("/").trimSuffix("/") - clean == "." or clean == "" or modulePath == clean or modulePath.hasPrefix(clean + "/") + let included = testPatterns.filter { p => p.hasPrefix("!") == false } + let excluded = testPatterns.filter { p => p.hasPrefix("!") }.map { p => p.trimPrefix("!") } + let isIncluded = included.length == 0 or included.any { p => matchesModulePath(p) } + let isExcluded = excluded.any { p => matchesModulePath(p) } + isIncluded == false or isExcluded + } + + """ + Whether this directory's module root matches a single glob: "**"/"*" match + everything, "." matches only the root module, and "X/**" or bare "X" match X + and any module below it. + """ + let matchesModulePath(pattern: String!): Boolean! { + let p = pattern.trimPrefix("/").trimSuffix("/") + if (p == "**" or p == "*") { + true + } else { + let prefix = p.trimSuffix("/**") + modulePath == prefix or modulePath.hasPrefix(prefix + "/") } } @@ -680,12 +1100,8 @@ type GoDirectory { Additional workspace include patterns discovered for this directory's Go tests. """ let includeDiscovered: [String!]! { - goIncludesHelper - .withExec( - ["go-includes", "--output", "/output", "--test", workspacePath], - experimentalPrivilegedNesting: true, - ) - .file("/output") + allIncludesDir + .file(moduleOutputFile) .contents .split("\n") .filter { pattern => pattern != "" } @@ -757,4 +1173,108 @@ type GoDirectory { null } + """ + Go test container for this directory, before a test command is added. + """ + pub testContainer(): Container! { + base + .withDirectory(".", source) + .withDirectory(".", testData) + .withWorkdir(path) + } + + """ + The tests in this directory, as a collection keyed by test name. + Skipped directories produce an empty collection. + + Test names are discovered by statically matching top-level test function + declarations in *_test.go files, without compiling or running go test. + + Search runs against testFilesDir, a Directory threaded from the parent + collection. The search arguments are identical for every GoDirectory, + so the result is cached once per session and reused; per-directory + restriction is a pure-Dang filter on the returned filePath. + """ + pub tests(): GoTests! { + let names = if (skipTest) { + [] + } else { + let prefix = if (path == ".") { "" } else { path.trimSuffix("/") + "/" } + testFilesDir + .search(pattern: "^func Test\\w+\\(") + .{{filePath, matchedLines}} + .filter { result => + if (path == ".") { + result.filePath.contains("/") == false + } else { + result.filePath.hasPrefix(prefix) and + result.filePath.trimPrefix(prefix).contains("/") == false + } + } + .map { result => + result.matchedLines.trimSpace.trimPrefix("func ").split("(")[0] ?? "" + } + .filter { name => name != "" } + } + GoTests(names: names, dir: self) + } + +} + +""" +Tests in a Go directory, keyed by test name. +""" +type GoTests { + """ + Test names in listing order. + """ + pub names: [String!]! @keys + + let dir: GoDirectory! + + """ + Return the test with the given name. + """ + pub test(name: String!): GoTest! @get { + GoTest(name: name, dir: dir) + } + + """ + Run every test in the current subset in a single go test invocation. + """ + pub run(): Void @check { + if (names.length == 0) { + null + } else { + dir + .testContainer() + .withExec(["go", "test", "-run", "^(" + names.join("|") + ")$", "."]) + .sync + null + } + } +} + +""" +A single Go test. +""" +type GoTest { + """ + The test's name. + """ + pub name: String! + + let dir: GoDirectory! + + """ + Run this test. Batched through the collection when several tests are + selected together. + """ + pub run(): Void @check { + dir + .testContainer() + .withExec(["go", "test", "-run", "^" + name + "$", "."]) + .sync + null + } } diff --git a/helpers/go-includes/all.go b/helpers/go-includes/all.go new file mode 100644 index 0000000..64b0692 --- /dev/null +++ b/helpers/go-includes/all.go @@ -0,0 +1,301 @@ +package main + +import ( + "flag" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "golang.org/x/mod/modfile" +) + +// runAll computes results for every module in one local pass over a mounted +// workspace snapshot (default: the current directory). It never touches the +// workspace gateway; all reads are os.ReadFile. +func runAll(cliArgs []string) error { + flags := flag.NewFlagSet("go-includes --all", flag.ExitOnError) + flags.Bool("all", false, "compute results for every module") + lint := flags.Bool("lint", false, "include lint inputs") + test := flags.Bool("test", false, "include test inputs") + generate := flags.Bool("generate", false, "include generate inputs") + testDirs := flags.Bool("test-dirs", false, "write directories containing Go tests") + root := flags.String("root", ".", "workspace root to scan") + outputDir := flags.String("output-dir", "", "directory to write one file per module to") + if err := flags.Parse(cliArgs); err != nil { + return err + } + if *outputDir == "" { + return fmt.Errorf("--output-dir is required") + } + if !*lint && !*test && !*generate && !*testDirs { + *test = true + } + + index, err := indexLocal(*root) + if err != nil { + return err + } + if *testDirs { + return index.writeAllTestDirsDir(*outputDir) + } + return index.writeAllIncludesDir(*outputDir, *lint, *test, *generate) +} + +// moduleOutputFile returns the per-module output filename for a module root. +// The ".inc" suffix keeps a module's file distinct from a nested module's +// subdirectory (e.g. "sdk/go.inc" never collides with the "sdk/go/" tree). +func moduleOutputFile(moduleRoot string) string { + name := moduleRoot + if moduleRoot == "." { + name = "_root_" + } + return filepath.FromSlash(name) + ".inc" +} + +func writeLines(filePath string, lines []string) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return err + } + data := "" + if len(lines) > 0 { + data = strings.Join(lines, "\n") + "\n" + } + return os.WriteFile(filePath, []byte(data), 0o644) +} + +// writeAllIncludesDir writes one include-pattern file per module, so each +// consumer reads only its own slice instead of re-scanning a combined blob. +func (index *localIndex) writeAllIncludesDir(dir string, lint, test, generate bool) error { + for _, moduleRoot := range index.moduleRoots { + includes, err := index.includesFor(moduleRoot, lint, test, generate) + if err != nil { + return err + } + if err := writeLines(filepath.Join(dir, moduleOutputFile(moduleRoot)), includes); err != nil { + return err + } + } + return nil +} + +// writeAllTestDirsDir writes one test-directory file per module. +func (index *localIndex) writeAllTestDirsDir(dir string) error { + for _, moduleRoot := range index.moduleRoots { + if err := writeLines(filepath.Join(dir, moduleOutputFile(moduleRoot)), index.testDirectoriesFor(moduleRoot)); err != nil { + return err + } + } + return nil +} + +// localIndex holds a workspace snapshot indexed for local include computation. +type localIndex struct { + root string + moduleRoots []string + moduleSet map[string]bool + goFilesByModule map[string][]string +} + +// indexLocal walks the snapshot once, recording modules and their Go files. +func indexLocal(root string) (*localIndex, error) { + index := &localIndex{ + root: root, + moduleSet: map[string]bool{}, + goFilesByModule: map[string][]string{}, + } + var goMods, goFiles []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + switch { + case d.Name() == "go.mod": + goMods = append(goMods, rel) + case strings.HasSuffix(d.Name(), ".go"): + goFiles = append(goFiles, rel) + } + return nil + }) + if err != nil { + return nil, err + } + + sort.Strings(goMods) + for _, goModPath := range goMods { + moduleRoot := strings.TrimSuffix(goModPath, "/go.mod") + if goModPath == "go.mod" { + moduleRoot = "." + } + index.moduleRoots = append(index.moduleRoots, moduleRoot) + index.moduleSet[moduleRoot] = true + } + + for _, goFile := range goFiles { + moduleRoot, ok := containingModuleDir(index.moduleSet, path.Dir(goFile)) + if !ok { + continue + } + index.goFilesByModule[moduleRoot] = append(index.goFilesByModule[moduleRoot], goFile) + } + return index, nil +} + +// includesFor mirrors targetModule.includes using local file reads. +func (index *localIndex) includesFor(moduleRoot string, lint, test, generate bool) ([]string, error) { + queued := map[string]bool{moduleRoot: true} + queue := []string{moduleRoot} + var includes []string + + for len(queue) > 0 { + module := queue[0] + queue = queue[1:] + + directives, err := index.directives(module) + if err != nil { + return nil, err + } + + includes = append(includes, includeBasePatterns(module)...) + for _, directive := range directives { + switch { + case directive.isEmbed(): + case generate && directive.isGenerateInclude(): + case test && directive.isTestInclude(): + default: + continue + } + patterns, err := directive.includePatterns() + if err != nil { + return nil, err + } + includes = append(includes, patterns...) + } + + next, err := index.replaceModules(module) + if err != nil { + return nil, err + } + if generate { + generateModules, err := index.generateModules(directives) + if err != nil { + return nil, err + } + next = append(next, generateModules...) + } + for _, nextModule := range next { + if !queued[nextModule] { + queued[nextModule] = true + queue = append(queue, nextModule) + } + } + } + + deduped := make([]string, 0, len(includes)) + seen := map[string]bool{} + for _, include := range includes { + if seen[include] { + continue + } + seen[include] = true + deduped = append(deduped, include) + } + return deduped, nil +} + +// testDirectoriesFor returns module directories containing at least one *_test.go file. +func (index *localIndex) testDirectoriesFor(moduleRoot string) []string { + dirs := make([]string, 0, len(index.goFilesByModule[moduleRoot])) + seen := map[string]bool{} + for _, goFile := range index.goFilesByModule[moduleRoot] { + if !strings.HasSuffix(goFile, "_test.go") { + continue + } + dir := path.Dir(goFile) + if seen[dir] { + continue + } + seen[dir] = true + dirs = append(dirs, dir) + } + sort.Strings(dirs) + return dirs +} + +// directives parses every Go file belonging to a module root. +func (index *localIndex) directives(moduleRoot string) ([]goDirective, error) { + files := index.goFilesByModule[moduleRoot] + sort.Strings(files) + + var directives []goDirective + for _, filePath := range files { + data, err := os.ReadFile(filepath.Join(index.root, filepath.FromSlash(filePath))) + if err != nil { + return nil, err + } + fileDirectives, err := goDirectivesInFile(filePath, data) + if err != nil { + return nil, err + } + directives = append(directives, fileDirectives...) + } + return directives, nil +} + +// replaceModules resolves local go.mod replace targets to module roots. +func (index *localIndex) replaceModules(moduleRoot string) ([]string, error) { + goModPath := path.Join(moduleRoot, "go.mod") + data, err := os.ReadFile(filepath.Join(index.root, filepath.FromSlash(goModPath))) + if err != nil { + return nil, err + } + goMod, err := modfile.Parse(goModPath, data, nil) + if err != nil { + return nil, err + } + + var roots []string + for _, replace := range goMod.Replace { + if replace.New.Version != "" || (!strings.HasPrefix(replace.New.Path, "./") && !strings.HasPrefix(replace.New.Path, "../")) { + continue + } + target := strings.TrimSuffix(replace.New.Path, "/") + root, ok := containingModuleDir(index.moduleSet, path.Join(path.Dir(goModPath), target)) + if !ok { + return nil, fmt.Errorf("no Go module found for local replace target: %s", replace.New.Path) + } + roots = append(roots, root) + } + return roots, nil +} + +// generateModules resolves go:generate go -C targets to module roots. +func (index *localIndex) generateModules(directives []goDirective) ([]string, error) { + var roots []string + for _, directive := range directives { + workdir, ok, err := directive.generateGoDashC() + if err != nil { + return nil, err + } + if !ok { + continue + } + root, ok := containingModuleDir(index.moduleSet, path.Join(directive.dir(), workdir)) + if !ok { + return nil, fmt.Errorf("%s: no Go module found for go -C directory: %s", directive.position, workdir) + } + roots = append(roots, root) + } + return roots, nil +} diff --git a/helpers/go-includes/main.go b/helpers/go-includes/main.go index 6d4c89d..c1e39a9 100644 --- a/helpers/go-includes/main.go +++ b/helpers/go-includes/main.go @@ -29,6 +29,18 @@ func main() { ctx := telemetry.Init(context.Background(), telemetry.Config{Detect: true}) defer telemetry.Close() + // --all computes results for every module in one local pass over a mounted + // workspace snapshot, with no workspace gateway round-trips. + for _, arg := range os.Args[1:] { + if arg == "--all" { + if err := runAll(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + return + } + } + targetModule, testDirs, outputPath, err := newTargetModuleFromArgs(ctx, os.Args[1:]) if err != nil { fmt.Fprintln(os.Stderr, err) @@ -166,9 +178,14 @@ func (w *workspace) indexModules(ctx context.Context) error { // containingModuleDir finds the nearest ancestor module root for a workspace path. func (w *workspace) containingModuleDir(dir string) (string, bool) { + return containingModuleDir(w.moduleSet, dir) +} + +// containingModuleDir finds the nearest ancestor module root in moduleSet. +func containingModuleDir(moduleSet map[string]bool, dir string) (string, bool) { dir = path.Clean(strings.TrimPrefix(dir, "/")) for { - if w.moduleSet[dir] { + if moduleSet[dir] { return dir, true } if dir == "." { @@ -353,10 +370,21 @@ func readRegularFile(ctx context.Context, dir *dagger.Directory, filePath string // includeBase returns the static Go source patterns for this module root. func (t targetModule) includeBase() []string { + return includeBasePatterns(t.moduleRoot) +} + +// includeBasePatterns returns the static Go source patterns for a module root. +func includeBasePatterns(moduleRoot string) []string { patterns := []string{ "**/*.go", "**/*.c", + "**/*.cc", + "**/*.cpp", + "**/*.cxx", "**/*.h", + "**/*.hh", + "**/*.hpp", + "**/*.hxx", "**/*.s", "**/*.S", "**/*.syso", @@ -370,7 +398,7 @@ func (t targetModule) includeBase() []string { "go.work.sum", } for i, pattern := range patterns { - patterns[i] = t.subpath(pattern) + patterns[i] = path.Join(moduleRoot, pattern) } return patterns } diff --git a/helpers/go-includes/main_test.go b/helpers/go-includes/main_test.go index c642e9f..7bb8699 100644 --- a/helpers/go-includes/main_test.go +++ b/helpers/go-includes/main_test.go @@ -106,7 +106,13 @@ func TestIncludeBasePreservesNestedModuleBoundaries(t *testing.T) { want := []string{ "pkg/**/*.go", "pkg/**/*.c", + "pkg/**/*.cc", + "pkg/**/*.cpp", + "pkg/**/*.cxx", "pkg/**/*.h", + "pkg/**/*.hh", + "pkg/**/*.hpp", + "pkg/**/*.hxx", "pkg/**/*.s", "pkg/**/*.S", "pkg/**/*.syso", diff --git a/testdata/go-module-cgo-cxx/answer.cc b/testdata/go-module-cgo-cxx/answer.cc new file mode 100644 index 0000000..d58e49e --- /dev/null +++ b/testdata/go-module-cgo-cxx/answer.cc @@ -0,0 +1,3 @@ +extern "C" int answer() { + return 42; +} diff --git a/testdata/go-module-cgo-cxx/cgocxx.go b/testdata/go-module-cgo-cxx/cgocxx.go new file mode 100644 index 0000000..bf4f0bc --- /dev/null +++ b/testdata/go-module-cgo-cxx/cgocxx.go @@ -0,0 +1,8 @@ +package cgocxx + +// int answer(); +import "C" + +func Answer() int { + return int(C.answer()) +} diff --git a/testdata/go-module-cgo-cxx/go.mod b/testdata/go-module-cgo-cxx/go.mod new file mode 100644 index 0000000..b59f791 --- /dev/null +++ b/testdata/go-module-cgo-cxx/go.mod @@ -0,0 +1,3 @@ +module example.com/cgocxx + +go 1.25 diff --git a/testdata/go-module-lint-fail/go.mod b/testdata/go-module-lint-fail/go.mod new file mode 100644 index 0000000..9fbdea5 --- /dev/null +++ b/testdata/go-module-lint-fail/go.mod @@ -0,0 +1,3 @@ +module example.com/lintfail + +go 1.25 diff --git a/testdata/go-module-lint-fail/lint_fail.go b/testdata/go-module-lint-fail/lint_fail.go new file mode 100644 index 0000000..93dda80 --- /dev/null +++ b/testdata/go-module-lint-fail/lint_fail.go @@ -0,0 +1,5 @@ +package lintfail + +func LintFailure() { + unused := 1 +} diff --git a/testdata/go-module-with-testdata/nesting_test.go b/testdata/go-module-with-testdata/nesting_test.go index 0ed3295..4cbe4c4 100644 --- a/testdata/go-module-with-testdata/nesting_test.go +++ b/testdata/go-module-with-testdata/nesting_test.go @@ -28,7 +28,7 @@ func TestDaggerSessionAvailableFromGoTest(t *testing.T) { directory(path: %q) { entries } } currentWorkspace { - cwd + id directory(path: "/", include: ["LICENSE"]) { entries } } }`, wd), @@ -70,7 +70,7 @@ func TestDaggerSessionAvailableFromGoTest(t *testing.T) { } `json:"directory"` } `json:"host"` CurrentWorkspace struct { - Cwd string `json:"cwd"` + ID string `json:"id"` Directory struct { Entries []string `json:"entries"` } `json:"directory"` @@ -90,8 +90,8 @@ func TestDaggerSessionAvailableFromGoTest(t *testing.T) { t.Fatalf("unexpected nested Dagger host directory entries: %v", entries) } - if result.Data.CurrentWorkspace.Cwd != "/testdata/go-module-with-testdata" { - t.Fatalf("unexpected nested Dagger workspace cwd: %q", result.Data.CurrentWorkspace.Cwd) + if result.Data.CurrentWorkspace.ID == "" { + t.Fatal("nested Dagger workspace did not return an ID") } if !contains(result.Data.CurrentWorkspace.Directory.Entries, "LICENSE") { t.Fatalf("unexpected nested Dagger workspace entries: %v", result.Data.CurrentWorkspace.Directory.Entries)