fix(run): preserve Go declaration and subtest scopes (#1988)

Go gutter and context launches keep the exact scope of the declaration or nested subtest they were started from (aliased and multi-line declarations included), so running one nested subtest no longer widens to its siblings.

Co-authored-by: shohruh-genius <[email protected]>
This commit is contained in:
Shohruh Ismoilovandshohruh-genius authored and GitHub committed 2026-11-08 01:57:00 +08:00
1 parent 1b889a5147
commit a6d24e684f
3 files changed
+222 -72

No files matched your search

+144
View File
@@ -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<string, string>)[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<number, number>();
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);
}
+76
View File
@@ -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();
});
});
+2 -72
View File
@@ -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<string>();
const keys = new Set<string>();
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":