mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10:00
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:
1 parent
1b889a5147
commit
a6d24e684f
3 files changed
+222
-72
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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":
|
||||
|
||||
Reference in new issue
Block a user