Skip to content

Commit 8b83fcc

Browse files
committed
src/goTestExplorer: implement test api
This takes a dynamic approach to test discovery. Tree nodes will be populated as they are expanded in the UI. Tests in open files will be added. Fixes golang#1579
1 parent 1fa5e10 commit 8b83fcc

File tree

2 files changed

+287
-0
lines changed

2 files changed

+287
-0
lines changed

src/goMain.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ import { getFormatTool } from './goFormat';
114114
import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
115115
import { ExtensionAPI } from './export';
116116
import extensionAPI from './extensionAPI';
117+
import { setupTestExplorer } from './goTestExplorer';
117118

118119
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
119120
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -226,6 +227,9 @@ If you would like additional configuration for diagnostics from gopls, please se
226227
ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, testCodeLensProvider));
227228
ctx.subscriptions.push(vscode.languages.registerCodeLensProvider(GO_MODE, referencesCodeLensProvider));
228229

230+
// testing
231+
setupTestExplorer(ctx);
232+
229233
// debug
230234
ctx.subscriptions.push(
231235
vscode.debug.registerDebugConfigurationProvider('go', new GoDebugConfigurationProvider('go'))

src/goTestExplorer.ts

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
import {
2+
test,
3+
workspace,
4+
ExtensionContext,
5+
TestController,
6+
TestItem,
7+
TextDocument,
8+
Uri,
9+
DocumentSymbol,
10+
SymbolKind,
11+
FileType
12+
} from 'vscode';
13+
import path = require('path');
14+
import { getModFolderPath } from './goModules';
15+
import { getCurrentGoPath } from './util';
16+
import { GoDocumentSymbolProvider } from './goOutline';
17+
18+
export function setupTestExplorer(context: ExtensionContext) {
19+
const ctrl = test.createTestController('go');
20+
context.subscriptions.push(ctrl);
21+
ctrl.root.label = 'Go';
22+
ctrl.root.canResolveChildren = true;
23+
ctrl.resolveChildrenHandler = (item) => resolveChildren(ctrl, item);
24+
25+
context.subscriptions.push(
26+
workspace.onDidOpenTextDocument((e) => documentUpdate(ctrl, e).catch((err) => console.log(err)))
27+
);
28+
29+
context.subscriptions.push(
30+
workspace.onDidChangeTextDocument((e) => documentUpdate(ctrl, e.document).catch((err) => console.log(err)))
31+
);
32+
}
33+
34+
function testID(uri: Uri, kind: string, name?: string): string {
35+
uri = uri.with({ query: kind });
36+
if (name) uri = uri.with({ fragment: name });
37+
return uri.toString();
38+
}
39+
40+
function getItem(parent: TestItem, uri: Uri, kind: string, name?: string): TestItem | undefined {
41+
return parent.children.get(testID(uri, kind, name));
42+
}
43+
44+
function createItem(
45+
ctrl: TestController,
46+
parent: TestItem,
47+
label: string,
48+
uri: Uri,
49+
kind: string,
50+
name?: string
51+
): TestItem {
52+
const id = testID(uri, kind, name);
53+
const existing = parent.children.get(id);
54+
if (existing) {
55+
return existing;
56+
}
57+
58+
console.log(`Creating ${id}`);
59+
return ctrl.createTestItem(id, label, parent, uri);
60+
}
61+
62+
function removeIfEmpty(item: TestItem) {
63+
// Don't dispose of the root
64+
if (!item.parent) {
65+
return;
66+
}
67+
68+
// Don't dispose of empty modules
69+
const uri = Uri.parse(item.id);
70+
if (uri.query == 'module') {
71+
return;
72+
}
73+
74+
if (item.children.size) {
75+
return;
76+
}
77+
78+
item.dispose();
79+
removeIfEmpty(item.parent);
80+
}
81+
82+
async function getModule(ctrl: TestController, uri: Uri): Promise<TestItem> {
83+
const existing = getItem(ctrl.root, uri, 'module');
84+
if (existing) {
85+
return existing;
86+
}
87+
88+
// Use the module name as the label
89+
const goMod = Uri.joinPath(uri, 'go.mod');
90+
const contents = await workspace.fs.readFile(goMod);
91+
const modLine = contents.toString().split('\n', 2)[0];
92+
const match = modLine.match(/^module (?<name>.*?)(?:\s|\/\/|$)/);
93+
const item = createItem(ctrl, ctrl.root, match.groups.name, uri, 'module');
94+
item.canResolveChildren = true;
95+
return item;
96+
}
97+
98+
async function getPackage(ctrl: TestController, uri: Uri): Promise<TestItem> {
99+
// If the package is not in a module, add it as a child of the root
100+
const modDir = await getModFolderPath(uri, true);
101+
if (!modDir) {
102+
const existing = getItem(ctrl.root, uri, 'package');
103+
if (existing) {
104+
return existing;
105+
}
106+
107+
const srcPath = path.join(getCurrentGoPath(uri), 'src');
108+
const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path;
109+
const item = createItem(ctrl, ctrl.root, label, uri, 'package');
110+
item.canResolveChildren = true;
111+
return item;
112+
}
113+
114+
// Otherwise, add it as a child of the module
115+
const modUri = uri.with({ path: modDir });
116+
const module = await getModule(ctrl, modUri);
117+
const existing = getItem(module, uri, 'package');
118+
if (existing) {
119+
return existing;
120+
}
121+
122+
const label = uri.path.startsWith(modUri.path) ? uri.path.substring(modUri.path.length + 1) : uri.path;
123+
const item = createItem(ctrl, module, label, uri, 'package');
124+
item.canResolveChildren = true;
125+
return item;
126+
}
127+
128+
async function getFile(ctrl: TestController, uri: Uri): Promise<TestItem> {
129+
const dir = path.dirname(uri.path);
130+
const pkg = await getPackage(ctrl, uri.with({ path: dir }));
131+
const existing = getItem(pkg, uri, 'file');
132+
if (existing) {
133+
return existing;
134+
}
135+
136+
const label = path.basename(uri.path);
137+
const item = createItem(ctrl, pkg, label, uri, 'file');
138+
item.canResolveChildren = true;
139+
return item;
140+
}
141+
142+
async function processSymbol(
143+
ctrl: TestController,
144+
uri: Uri,
145+
file: TestItem,
146+
seen: Set<string>,
147+
symbol: DocumentSymbol
148+
) {
149+
// Skip TestMain(*testing.M)
150+
if (symbol.name === 'TestMain' || /\*testing.M\)/.test(symbol.detail)) {
151+
return;
152+
}
153+
154+
// Recursively process symbols that are nested
155+
if (symbol.kind !== SymbolKind.Function) {
156+
for (const sym of symbol.children) await processSymbol(ctrl, uri, file, seen, sym);
157+
return;
158+
}
159+
160+
const match = symbol.name.match(/^(?<type>Test|Example|Benchmark)/);
161+
if (!match) {
162+
return;
163+
}
164+
165+
seen.add(symbol.name);
166+
167+
const kind = match.groups.type.toLowerCase();
168+
const existing = getItem(file, uri, kind, symbol.name);
169+
if (existing) {
170+
return existing;
171+
}
172+
173+
const item = createItem(ctrl, file, symbol.name, uri, kind, symbol.name);
174+
item.range = symbol.range;
175+
item.runnable = true;
176+
item.debuggable = true;
177+
}
178+
179+
async function loadFileTests(ctrl: TestController, doc: TextDocument) {
180+
const seen = new Set<string>();
181+
const item = await getFile(ctrl, doc.uri);
182+
const symbols = await new GoDocumentSymbolProvider().provideDocumentSymbols(doc, null);
183+
for (const symbol of symbols) await processSymbol(ctrl, doc.uri, item, seen, symbol);
184+
185+
for (const child of item.children.values()) {
186+
const uri = Uri.parse(child.id);
187+
if (!seen.has(uri.fragment)) {
188+
child.dispose();
189+
}
190+
}
191+
192+
removeIfEmpty(item);
193+
}
194+
195+
async function containsGoFiles(uri: Uri): Promise<boolean> {
196+
for (const [file, type] of await workspace.fs.readDirectory(uri)) {
197+
if (file.startsWith('.')) {
198+
continue;
199+
}
200+
201+
switch (type) {
202+
case FileType.File:
203+
if (file.endsWith('.go')) {
204+
return true;
205+
}
206+
break;
207+
208+
case FileType.Directory:
209+
if (await containsGoFiles(Uri.joinPath(uri, file))) {
210+
return true;
211+
}
212+
break;
213+
}
214+
}
215+
}
216+
217+
async function walkPackages(uri: Uri, cb: (uri: Uri) => Promise<any>) {
218+
let called = false;
219+
for (const [file, type] of await workspace.fs.readDirectory(uri)) {
220+
if (file.startsWith('.')) {
221+
continue;
222+
}
223+
224+
switch (type) {
225+
case FileType.File:
226+
if (!called && file.endsWith('_test.go')) {
227+
called = true;
228+
await cb(uri);
229+
}
230+
break;
231+
232+
case FileType.Directory:
233+
await walkPackages(Uri.joinPath(uri, file), cb);
234+
break;
235+
}
236+
}
237+
}
238+
239+
async function resolveChildren(ctrl: TestController, item: TestItem) {
240+
if (!item.parent) {
241+
for (const folder of workspace.workspaceFolders || []) {
242+
if (await containsGoFiles(folder.uri)) {
243+
await getModule(ctrl, folder.uri);
244+
}
245+
}
246+
return;
247+
}
248+
249+
const uri = Uri.parse(item.id);
250+
switch (uri.query) {
251+
case 'module':
252+
await walkPackages(uri, (uri) => getPackage(ctrl, uri));
253+
break;
254+
255+
case 'package':
256+
for (const [file, type] of await workspace.fs.readDirectory(uri)) {
257+
if (type !== FileType.File || !file.endsWith('_test.go')) {
258+
continue;
259+
}
260+
261+
await getFile(ctrl, Uri.joinPath(uri, file));
262+
}
263+
break;
264+
265+
case 'file':
266+
const doc = await workspace.openTextDocument(uri);
267+
await loadFileTests(ctrl, doc);
268+
break;
269+
}
270+
}
271+
272+
async function documentUpdate(ctrl: TestController, doc: TextDocument) {
273+
if (!doc.uri.path.endsWith('_test.go')) {
274+
return;
275+
}
276+
277+
if (doc.uri.scheme === 'git') {
278+
// TODO(firelizzard18): When a workspace is reopened, VSCode passes us git: URIs. Why?
279+
return;
280+
}
281+
282+
await loadFileTests(ctrl, doc);
283+
}

0 commit comments

Comments
 (0)