diff --git a/src/features/run/goTargets.ts b/src/features/run/goTargets.ts new file mode 100644 index 000000000..f91ed2e2a --- /dev/null +++ b/src/features/run/goTargets.ts @@ -0,0 +1,144 @@ +import type { RunTarget } from "./targets"; + +interface Token { + text: string; + line: number; + literal?: string; +} + +const goSubtestName = (name: string) => name.replace(/\s/g, "_"); + +function stringValue(text: string): string { + if (text.startsWith("`")) return text.slice(1, -1).replace(/\r/g, ""); + return text + .slice(1, -1) + .replace(/\\(U[\da-fA-F]{8}|u[\da-fA-F]{4}|x[\da-fA-F]{2}|[0-7]{3}|.)/gs, (_, escape: string) => { + if (/^[Uux]/.test(escape)) { + const point = parseInt(escape.slice(1), 16); + return point <= 0x10ffff ? String.fromCodePoint(point) : escape; + } + if (/^[0-7]{3}$/.test(escape)) return String.fromCharCode(parseInt(escape, 8)); + return ( + ({ a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" } as Record)[escape] ?? + escape + ); + }); +} + +/** Ignore comments and literal contents when matching Go's declaration and callback boundaries. */ +export function goTargets(text: string): RunTarget[] { + const tokens: Token[] = []; + const out: RunTarget[] = []; + let line = 0; + for (const match of text.matchAll( + /\s+|\/\/[^\n]*|\/\*[\s\S]*?\*\/|"(?:[^"\\]|\\.)*"|`[^`]*`|'(?:[^'\\]|\\.)*'|[\p{L}_][\p{L}\p{N}_]*|:=|\S/gu, + )) { + const word = match[0]; + if (/^\/\/go:generate[ \t]+\S/.test(word) && (match.index === 0 || text[match.index - 1] === "\n")) + out.push({ + line, + kind: "generate", + name: word.replace(/^\/\/go:generate[ \t]+/, "").trim(), + path: [String(line)], + }); + if (!/^\s|^\/\//.test(word) && !word.startsWith("/*")) + tokens.push({ text: word, line, ...(/^["`]/.test(word) ? { literal: stringValue(word) } : {}) }); + line += (word.match(/\n/g) ?? []).length; + } + const pairs = new Map(); + const stack: number[] = []; + tokens.forEach((token, i) => { + if (["(", "{", "["].includes(token.text)) stack.push(i); + else if ([")", "}", "]"].includes(token.text)) { + const start = stack.pop(); + if (start !== undefined) pairs.set(start, i); + } + }); + const aliases = new Set(["testing"]); + tokens.forEach((token, i) => { + if (token.literal === "testing") { + const previous = tokens[i - 1]?.text; + if (previous && !["import", "("].includes(previous)) aliases.add(previous); + } + }); + const parameter = (start: number, end: number, type: string) => { + const params = tokens.slice(start + 1, end).filter((token) => token.text !== ","); + const star = params.findIndex((token) => token.text === "*"); + if (star < 0 || star > 1) return null; + const tail = params.slice(star + 1).map((token) => token.text); + const valid = tail.length === 3 && aliases.has(tail[0]!) && tail[1] === "." && tail[2] === type; + const dot = aliases.has(".") && tail.length === 1 && tail[0] === type; + return valid || dot ? (star === 1 ? params[0]!.text : "") : null; + }; + const add = (at: number, path: string[]) => { + if (!out.some((target) => target.line === at && target.path.join("/") === path.join("/"))) + out.push({ line: at, kind: "test", name: path.join("/"), path }); + }; + const subtests = (start: number, end: number, path: string[], receiver: string, type: string) => { + if (!receiver) return; + for (let i = start; i < end; i++) { + if ( + tokens[i]?.text !== receiver || + tokens[i + 1]?.text !== "." || + tokens[i + 2]?.text !== "Run" || + tokens[i + 3]?.text !== "(" + ) + continue; + const close = pairs.get(i + 3); + if (close === undefined) continue; + const arg = tokens[i + 4]!; + const names: { line: number; name: string }[] = []; + if (arg.literal !== undefined) names.push({ line: tokens[i]!.line, name: arg.literal }); + else { + const field = tokens[i + 5]?.text === "." ? tokens[i + 6]?.text : undefined; + const keyed = + !field && + tokens.slice(start, i).some((token, n, before) => token.text === "for" && before[n + 1]?.text === arg.text); + for (let n = start; n < end; n++) { + const value = tokens[n]!; + if (field && value.text === field && tokens[n + 1]?.text === ":" && tokens[n + 2]?.literal !== undefined) + names.push({ line: value.line, name: tokens[n + 2]!.literal! }); + if (keyed && value.literal !== undefined && tokens[n + 1]?.text === ":" && tokens[n + 2]?.text === "{") + names.push({ line: value.line, name: value.literal }); + } + } + const paths = names.map(({ line, name }) => { + const child = [...path, ...goSubtestName(name).split("/")]; + add(line, child); + return child; + }); + const relative = tokens.slice(i + 5, close).findIndex((token) => token.text === "func"); + const callback = relative < 0 ? -1 : i + 5 + relative; + const paramsEnd = pairs.get(callback + 1); + const body = paramsEnd === undefined ? undefined : paramsEnd + 1; + const bodyEnd = body === undefined ? undefined : pairs.get(body); + if (callback >= 0 && paramsEnd !== undefined && body !== undefined && bodyEnd !== undefined) { + const childReceiver = parameter(callback + 1, paramsEnd, type); + if (childReceiver !== null) paths.forEach((child) => subtests(body + 1, bodyEnd, child, childReceiver, type)); + } + i = close; + } + }; + const main = tokens.some((token, i) => token.text === "package" && tokens[i + 1]?.text === "main"); + for (let i = 0; i < tokens.length; i++) { + if (tokens[i]!.text !== "func" || tokens[i + 2]?.text !== "(") continue; + const name = tokens[i + 1]!.text; + const paramsEnd = pairs.get(i + 2); + if (paramsEnd === undefined || tokens[paramsEnd + 1]?.text !== "{") continue; + const end = pairs.get(paramsEnd + 1); + if (end === undefined) continue; + const prefix = /^(Test|Benchmark|Example|Fuzz)(.*)$/u.exec(name); + if (prefix && name !== "TestMain" && !/^\p{Ll}/u.test(prefix[2]!)) { + const type = prefix[1] === "Benchmark" ? "B" : prefix[1] === "Fuzz" ? "F" : "T"; + const receiver = parameter(i + 2, paramsEnd, type); + if ((prefix[1] === "Example" && paramsEnd === i + 3) || (prefix[1] !== "Example" && receiver !== null)) { + add(tokens[i]!.line, [name]); + if (prefix[1] === "Test" || prefix[1] === "Benchmark") + subtests(paramsEnd + 2, end, [name], receiver ?? "", type); + } + } else if (main && name === "main" && paramsEnd === i + 3) + out.push({ line: tokens[i]!.line, kind: "main", name: "main", path: [] }); + i = end; + } + return out.sort((a, b) => a.line - b.line); +} diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index 67d834102..bd6e91841 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -414,3 +414,79 @@ describe("php (Pest)", () => { ]); }); }); + +describe("Go declaration and callback scopes", () => { + it("finds aliased, dot-imported and multiline testing declarations", () => { + const text = [ + "package p", + 'import check "testing"', + "func TestAlias(", + " subject *check.T,", + ") {", + ' subject.Run("outer", func(child *check.T) {', + " child.Run(`nested/leaf case`, func(last *check.T) {})", + " })", + ' subject.Run("sibling", func(child *check.T) {})', + "}", + "func BenchmarkAlias(b *check.B) {}", + "func FuzzAlias(f *check.F) {}", + "func Example() {}", + "func TestMain(m *check.M) {}", + "func Testlower(t *check.T) {}", + "func (s Suite) TestMethod(t *check.T) {}", + ].join("\n"); + expect(detectRunTargets(text, "alias_test.go").map((target) => target.path)).toEqual([ + ["TestAlias"], + ["TestAlias", "outer"], + ["TestAlias", "outer", "nested", "leaf_case"], + ["TestAlias", "sibling"], + ["BenchmarkAlias"], + ["FuzzAlias"], + ["Example"], + ]); + expect( + detectRunTargets('package p\nimport . "testing"\nfunc TestDot(t *T) {}', "dot_test.go").map( + (target) => target.path, + ), + ).toEqual([["TestDot"]]); + }); + + it("ignores comments, strings, methods and unrelated Run receivers", () => { + const text = [ + "package p", + "/* func TestComment(t *testing.T) {} */", + "var fake = `", + "func TestString(t *testing.T) {}", + "//go:generate fake", + "`", + "func TestReal(t *testing.T) {", + ' // t.Run("comment", nil)', + ' _ = `t.Run("string", nil) }`', + ' other.Run("unrelated", nil)', + ' t.Run("a\\x20b", func(sub *testing.T) {', + ' sub.Run("quote\\\"brace}", nil)', + " })", + "}", + "//go:generate real", + ].join("\n"); + expect(detectRunTargets(text, "real_test.go").map((target) => [target.line, target.path])).toEqual([ + [6, ["TestReal"]], + [10, ["TestReal", "a_b"]], + [11, ["TestReal", "a_b", 'quote"brace}']], + [14, ["14"]], + ]); + }); + + it("keeps nested benchmark paths and tolerates incomplete source", () => { + const text = + 'package p\nfunc BenchmarkWork(b *testing.B) { b.Run("group", func(inner *testing.B) { inner.Run("leaf", nil) }) }'; + expect(detectRunTargets(text, "bench_test.go").map((target) => target.path)).toEqual([ + ["BenchmarkWork"], + ["BenchmarkWork", "group"], + ["BenchmarkWork", "group", "leaf"], + ]); + expect(() => + detectRunTargets('func TestBroken(t *testing.T) { t.Run("\\UFFFFFFFF", nil) }', "broken_test.go"), + ).not.toThrow(); + }); +}); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index af20d2060..b98f7837b 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -6,6 +6,7 @@ * Line-based regex detection (cheap, no parser), plus the run configuration for * one target derived from the project's detected test configurations. */ +import { goTargets } from "./goTargets"; import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; @@ -286,77 +287,6 @@ function rubyTargets(lines: string[]): RunTarget[] { return out.filter((t) => t.kind !== "suite" || t.frame?.hasTests).map(({ frame: _frame, ...t }) => t); } -/** `go test` reports subtest names with spaces as `_` (`t.Run("zero case")` → `TestX/zero_case`). */ -export const goSubtestName = (name: string) => name.replace(/\s/g, "_"); - -function goTargets(lines: string[]): RunTarget[] { - const out: RunTarget[] = []; - const isMain = lines.some((l) => /^package\s+main\b/.test(l)); - for (let i = 0; i < lines.length; i++) { - const line = lines[i]!; - if (/^\/\/go:generate[ \t]+\S/.test(line)) { - out.push({ - line: i, - kind: "generate", - name: line.replace(/^\/\/go:generate[ \t]+/, "").trim(), - path: [String(i)], - }); - continue; - } - const t = /^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?/.exec(line); - if (t && (/^Example/.test(t[1]!) || /\*testing\.[TFB]/.test(line))) { - out.push({ line: i, kind: "test", name: t[1]!, path: [t[1]!] }); - if (/\*testing\.[TB]\b/.test(line)) out.push(...goSubtests(lines, i, t[1]!)); - continue; - } - if (isMain && /^func\s+main\s*\(\s*\)/.test(line)) out.push({ line: i, kind: "main", name: "main", path: [] }); - } - return out; -} - -/** - * Subtests of the test function at `start` (GoLand's gutter on subtests and - * table-driven tests): `t.Run("name", …)`, and table entries run with - * `t.Run(tt.name, …)` (`{name: "…", …}`) or keyed by name - * (`for name, tc := range cases { t.Run(name, …) }` over `"…": {…}`). - */ -function goSubtests(lines: string[], start: number, test: string): RunTarget[] { - if (/\}\s*(?:\/\/.*)?$/.test(lines[start]!)) return []; - let end = start; - while (end + 1 < lines.length && !/^}/.test(lines[end]!)) end++; - const body = lines.slice(start + 1, end); - const out: RunTarget[] = []; - const add = (offset: number, name: string) => { - const sub = goSubtestName(name.replace(/\\(.)/g, "$1")); - if (!out.some((t) => t.path[1] === sub)) - out.push({ line: start + 1 + offset, kind: "test", name: `${test}/${sub}`, path: [test, sub] }); - }; - const fields = new Set(); - const keys = new Set(); - body.forEach((l, i) => { - const lit = /\b\w+\.Run\(\s*"((?:[^"\\]|\\.)*)"\s*,/.exec(l); - if (lit) add(i, lit[1]!); - const field = /\b\w+\.Run\(\s*\w+\.(\w+)\s*,/.exec(l); - if (field) fields.add(field[1]!); - const key = /\b\w+\.Run\(\s*(\w+)\s*,/.exec(l); - if (key && body.slice(0, i).some((b) => new RegExp(`\\bfor\\s+${key[1]}\\s*,\\s*\\w+\\s*:=\\s*range\\b`).test(b))) - keys.add(key[1]!); - }); - if (fields.size || keys.size) { - body.forEach((l, i) => { - for (const f of fields) { - const m = new RegExp(`\\b${f}\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(l); - if (m) add(i, m[1]!); - } - if (keys.size) { - const m = /^\s*"((?:[^"\\]|\\.)*)"\s*:\s*\{/.exec(l); - if (m) add(i, m[1]!); - } - }); - } - return out.sort((a, b) => a.line - b.line); -} - /** Java / Kotlin: classes and methods with binary class names (see `jvmTargets.ts`). */ function javaKotlinTargets(text: string, relPath: string): RunTarget[] { return scanJvmTargets(text, relPath, /\.kts?$/.test(relPath)).map((t): RunTarget => { @@ -441,7 +371,7 @@ export function detectRunTargets(text: string, relPath: string, django = false): ? [...phpTargets(lines), ...pestTargets(lines)].sort((a, b) => a.line - b.line) : []; case "go": - return goTargets(lines).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); + return goTargets(text).filter((t) => t.kind === "main" || t.kind === "generate" || /_test\.go$/.test(relPath)); case "jvm": return /\.(java|kts?)$/.test(relPath) ? javaKotlinTargets(text, relPath) : jvmTargets(lines, relPath); case "ruby":