diff --git a/docs/commands.md b/docs/commands.md
index 23e0821ffd..9c0a92576e 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -59,6 +59,10 @@ Runs all unit tests in the current file.
Runs all unit tests in the package of the current file.
+### `Go Test: Refresh`
+
+Refresh a test in the test explorer. Only available as a context menu option in the test explorer.
+
### `Go: Benchmark Package`
Runs all benchmarks in the package of the current file.
diff --git a/docs/settings.md b/docs/settings.md
index 2b8d0c04eb..bd599c8e05 100644
--- a/docs/settings.md
+++ b/docs/settings.md
@@ -393,6 +393,22 @@ Absolute path to a file containing environment variables definitions. File conte
### `go.testEnvVars`
Environment variables that will be passed to the process that runs the Go tests
+### `go.testExplorerConcatenateMessages`
+
+If true, test log messages associated with a given location will be shown as a single message.
+
+Default: `true`
+### `go.testExplorerPackages`
+
+Control whether packages in the test explorer are presented flat or nested.
+Allowed Options: `flat`, `nested`
+
+Default: `"flat"`
+### `go.testExplorerRunBenchmarks`
+
+Include benchmarks when running all tests in a group.
+
+Default: `false`
### `go.testFlags`
Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.
diff --git a/package-lock.json b/package-lock.json
index 28c3c3f984..cd32598e12 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,7 +32,7 @@
"@types/node": "^13.11.1",
"@types/semver": "^7.1.0",
"@types/sinon": "^9.0.0",
- "@types/vscode": "^1.52.0",
+ "@types/vscode": "^1.59.0",
"adm-zip": "^0.4.14",
"fs-extra": "^9.0.0",
"get-port": "^5.1.1",
@@ -49,7 +49,7 @@
"yarn": "^1.22.4"
},
"engines": {
- "vscode": "^1.59.0"
+ "vscode": "^1.58.0"
}
},
"node_modules/@babel/code-frame": {
@@ -387,9 +387,9 @@
"dev": true
},
"node_modules/@types/vscode": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
- "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+ "version": "1.59.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+ "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
"dev": true
},
"node_modules/@typescript-eslint/eslint-plugin": {
@@ -7276,9 +7276,9 @@
"dev": true
},
"@types/vscode": {
- "version": "1.54.0",
- "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.54.0.tgz",
- "integrity": "sha512-sHHw9HG4bTrnKhLGgmEiOS88OLO/2RQytUN4COX9Djv81zc0FSZsSiYaVyjNidDzUSpXsySKBkZ31lk2/FbdCg==",
+ "version": "1.59.0",
+ "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.59.0.tgz",
+ "integrity": "sha512-Zg38rusx2nU6gy6QdF7v4iqgxNfxzlBlDhrRCjOiPQp+sfaNrp3f9J6OHIhpGNN1oOAca4+9Hq0+8u3jwzPMlQ==",
"dev": true
},
"@typescript-eslint/eslint-plugin": {
diff --git a/package.json b/package.json
index 9439ec4d64..25a71b50b0 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,8 @@
"Snippets",
"Linters",
"Debuggers",
- "Formatters"
+ "Formatters",
+ "Testing"
],
"galleryBanner": {
"color": "#F2F2F2",
@@ -70,7 +71,7 @@
"@types/node": "^13.11.1",
"@types/semver": "^7.1.0",
"@types/sinon": "^9.0.0",
- "@types/vscode": "^1.52.0",
+ "@types/vscode": "^1.59.0",
"adm-zip": "^0.4.14",
"fs-extra": "^9.0.0",
"get-port": "^5.1.1",
@@ -232,6 +233,13 @@
"title": "Go: Test Package",
"description": "Runs all unit tests in the package of the current file."
},
+ {
+ "command": "go.test.refresh",
+ "title": "Go Test: Refresh",
+ "description": "Refresh a test in the test explorer. Only available as a context menu option in the test explorer.",
+ "category": "Test",
+ "icon": "$(refresh)"
+ },
{
"command": "go.benchmark.package",
"title": "Go: Benchmark Package",
@@ -1282,6 +1290,28 @@
"description": "Flags to pass to `go test`. If null, then buildFlags will be used. This is not propagated to the language server.",
"scope": "resource"
},
+ "go.testExplorerPackages": {
+ "type": "string",
+ "enum": [
+ "flat",
+ "nested"
+ ],
+ "default": "flat",
+ "description": "Control whether packages in the test explorer are presented flat or nested.",
+ "scope": "resource"
+ },
+ "go.testExplorerRunBenchmarks": {
+ "type": "boolean",
+ "default": false,
+ "description": "Include benchmarks when running all tests in a group.",
+ "scope": "resource"
+ },
+ "go.testExplorerConcatenateMessages": {
+ "type": "boolean",
+ "default": true,
+ "description": "If true, test log messages associated with a given location will be shown as a single message.",
+ "scope": "resource"
+ },
"go.generateTestsFlags": {
"type": "array",
"items": {
@@ -2356,6 +2386,12 @@
}
},
"menus": {
+ "commandPalette": [
+ {
+ "command": "go.test.refresh",
+ "when": "false"
+ }
+ ],
"editor/context": [
{
"when": "editorTextFocus && config.go.editorContextMenuCommands.toggleTestFile && resourceLangId == go",
@@ -2437,6 +2473,13 @@
"command": "go.show.commands",
"group": "Go group 2"
}
+ ],
+ "testing/item/context": [
+ {
+ "command": "go.test.refresh",
+ "when": "testId =~ /_test\\.go/",
+ "group": "inline"
+ }
]
}
}
diff --git a/src/goLanguageServer.ts b/src/goLanguageServer.ts
index 317687d717..2698dc13d9 100644
--- a/src/goLanguageServer.ts
+++ b/src/goLanguageServer.ts
@@ -631,7 +631,9 @@ export async function buildLanguageClient(cfg: BuildLanguageClientOption): Promi
// cause to reorder candiates, which is not ideal.
// Force to use non-empty `label`.
// https://github.com/golang/vscode-go/issues/441
- hardcodedFilterText = items[0].label;
+ let { label } = items[0];
+ if (typeof label !== 'string') label = label.label;
+ hardcodedFilterText = label;
}
for (const item of items) {
item.filterText = hardcodedFilterText;
diff --git a/src/goMain.ts b/src/goMain.ts
index 541ad64729..5f7d26d46f 100644
--- a/src/goMain.ts
+++ b/src/goMain.ts
@@ -114,6 +114,7 @@ import { getFormatTool } from './goFormat';
import { resetSurveyConfig, showSurveyConfig, timeMinute } from './goSurvey';
import { ExtensionAPI } from './export';
import extensionAPI from './extensionAPI';
+import { isVscodeTestingAPIAvailable, TestExplorer } from './goTestExplorer';
export let buildDiagnosticCollection: vscode.DiagnosticCollection;
export let lintDiagnosticCollection: vscode.DiagnosticCollection;
@@ -335,6 +336,16 @@ If you would like additional configuration for diagnostics from gopls, please se
})
);
+ if (isVscodeTestingAPIAvailable) {
+ const testExplorer = TestExplorer.setup(ctx);
+
+ ctx.subscriptions.push(
+ vscode.commands.registerCommand('go.test.refresh', (args) => {
+ if (args) testExplorer.resolve(args);
+ })
+ );
+ }
+
ctx.subscriptions.push(
vscode.commands.registerCommand('go.subtest.cursor', (args) => {
const goConfig = getGoConfig();
diff --git a/src/goModules.ts b/src/goModules.ts
index a0b6f51d16..ab0dd04fae 100644
--- a/src/goModules.ts
+++ b/src/goModules.ts
@@ -36,7 +36,8 @@ async function runGoModEnv(folderPath: string): Promise {
return resolve('');
}
const [goMod] = stdout.split('\n');
- resolve(goMod);
+ if (goMod === '/dev/null' || goMod === 'NUL') resolve('');
+ else resolve(goMod);
});
});
}
diff --git a/src/goSuggest.ts b/src/goSuggest.ts
index 22abcc08e0..1f3361a106 100644
--- a/src/goSuggest.ts
+++ b/src/goSuggest.ts
@@ -122,11 +122,14 @@ export class GoCompletionItemProvider implements vscode.CompletionItemProvider,
return;
}
+ let { label } = item;
+ if (typeof label !== 'string') label = label.label;
+
return runGodoc(
path.dirname(item.fileName),
item.package || path.dirname(item.fileName),
item.receiver,
- item.label,
+ label,
token
)
.then((doc) => {
@@ -358,7 +361,9 @@ export class GoCompletionItemProvider implements vscode.CompletionItemProvider,
areCompletionsForPackageSymbols = true;
}
if (suggest.class === 'package') {
- const possiblePackageImportPaths = this.getPackageImportPath(item.label);
+ let { label } = item;
+ if (typeof label !== 'string') label = label.label;
+ const possiblePackageImportPaths = this.getPackageImportPath(label);
if (possiblePackageImportPaths.length === 1) {
item.detail = possiblePackageImportPaths[0];
}
diff --git a/src/goTestExplorer.ts b/src/goTestExplorer.ts
new file mode 100644
index 0000000000..94a74a94e1
--- /dev/null
+++ b/src/goTestExplorer.ts
@@ -0,0 +1,1097 @@
+/*---------------------------------------------------------
+ * Copyright 2021 The Go Authors. All rights reserved.
+ * Licensed under the MIT License. See LICENSE in the project root for license information.
+ *--------------------------------------------------------*/
+import {
+ CancellationToken,
+ ConfigurationChangeEvent,
+ DocumentSymbol,
+ ExtensionContext,
+ FileType,
+ Location,
+ OutputChannel,
+ Position,
+ Range,
+ SymbolKind,
+ TestController,
+ TestItem,
+ TestItemCollection,
+ TestMessage,
+ TestRun,
+ TestRunProfileKind,
+ TestRunRequest,
+ TextDocument,
+ TextDocumentChangeEvent,
+ Uri,
+ workspace,
+ WorkspaceFolder,
+ WorkspaceFoldersChangeEvent
+} from 'vscode';
+import vscode = require('vscode');
+import path = require('path');
+import { getModFolderPath, isModSupported } from './goModules';
+import { getCurrentGoPath } from './util';
+import { GoDocumentSymbolProvider } from './goOutline';
+import { getGoConfig } from './config';
+import { getTestFlags, goTest, GoTestOutput } from './testUtils';
+import { outputChannel } from './goStatus';
+
+// Set true only if the Testing API is available (VSCode version >= 1.59).
+export const isVscodeTestingAPIAvailable =
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ 'object' === typeof (vscode as any).tests && 'function' === typeof (vscode as any).tests.createTestController;
+
+// eslint-disable-next-line @typescript-eslint/no-namespace
+export namespace TestExplorer {
+ // exported for tests
+
+ export type FileSystem = Pick;
+
+ export interface Workspace
+ extends Pick {
+ // use custom FS type
+ readonly fs: FileSystem;
+
+ // only include one overload
+ openTextDocument(uri: Uri): Thenable;
+ }
+}
+
+async function doSafe(context: string, p: Thenable | (() => T | Thenable), onError?: T): Promise {
+ try {
+ if (typeof p === 'function') {
+ return await p();
+ } else {
+ return await p;
+ }
+ } catch (error) {
+ if (process.env.VSCODE_GO_IN_TEST === '1') {
+ throw error;
+ }
+
+ // TODO internationalization?
+ if (context === 'resolveHandler') {
+ const m = 'Failed to resolve tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ } else if (context === 'runHandler') {
+ const m = 'Failed to execute tests';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ } else if (/^did/.test(context)) {
+ outputChannel.appendLine(`Failed while handling '${context}': ${error}`);
+ } else {
+ const m = 'An unknown error occurred';
+ outputChannel.appendLine(`${m}: ${error}`);
+ await vscode.window.showErrorMessage(m);
+ }
+ return onError;
+ }
+}
+
+export class TestExplorer {
+ static setup(context: ExtensionContext): TestExplorer {
+ if (!isVscodeTestingAPIAvailable) throw new Error('VSCode Testing API is unavailable');
+
+ const ctrl = vscode.tests.createTestController('go', 'Go');
+ const getSym = new GoDocumentSymbolProvider().provideDocumentSymbols;
+ const inst = new this(ctrl, workspace, getSym);
+
+ context.subscriptions.push(
+ workspace.onDidChangeConfiguration((x) =>
+ doSafe('onDidChangeConfiguration', inst.didChangeConfiguration(x))
+ )
+ );
+
+ context.subscriptions.push(
+ workspace.onDidOpenTextDocument((x) => doSafe('onDidOpenTextDocument', inst.didOpenTextDocument(x)))
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeTextDocument((x) => doSafe('onDidChangeTextDocument', inst.didChangeTextDocument(x)))
+ );
+
+ context.subscriptions.push(
+ workspace.onDidChangeWorkspaceFolders((x) =>
+ doSafe('onDidChangeWorkspaceFolders', inst.didChangeWorkspaceFolders(x))
+ )
+ );
+
+ const watcher = workspace.createFileSystemWatcher('**/*_test.go', false, true, false);
+ context.subscriptions.push(watcher);
+ context.subscriptions.push(watcher.onDidCreate((x) => doSafe('onDidCreate', inst.didCreateFile(x))));
+ context.subscriptions.push(watcher.onDidDelete((x) => doSafe('onDidDelete', inst.didDeleteFile(x))));
+
+ return inst;
+ }
+
+ constructor(
+ public ctrl: TestController,
+ public ws: TestExplorer.Workspace,
+ public provideDocumentSymbols: (doc: TextDocument, token: CancellationToken) => Thenable
+ ) {
+ ctrl.resolveHandler = (item) => this.resolve(item);
+ ctrl.createRunProfile('go test', TestRunProfileKind.Run, (rq, tok) => this.run(rq, tok), true);
+ }
+
+ /* ***** Interface (external) ***** */
+
+ resolve(item?: TestItem) {
+ return doSafe('resolveHandler', resolve(this, item));
+ }
+
+ run(request: TestRunRequest, token: CancellationToken) {
+ return doSafe('runHandler', runTests(this, request, token));
+ }
+
+ /* ***** Interface (internal) ***** */
+
+ // Create an item.
+ createItem(label: string, uri: Uri, kind: string, name?: string): TestItem {
+ return this.ctrl.createTestItem(testID(uri, kind, name), label, uri.with({ query: '', fragment: '' }));
+ }
+
+ // Retrieve an item.
+ getItem(parent: TestItem | undefined, uri: Uri, kind: string, name?: string): TestItem {
+ const items = getChildren(parent || this.ctrl.items);
+ return items.get(testID(uri, kind, name));
+ }
+
+ // Create or retrieve an item.
+ getOrCreateItem(parent: TestItem | undefined, label: string, uri: Uri, kind: string, name?: string): TestItem {
+ const existing = this.getItem(parent, uri, kind, name);
+ if (existing) return existing;
+
+ const item = this.createItem(label, uri, kind, name);
+ getChildren(parent || this.ctrl.items).add(item);
+ return item;
+ }
+
+ // Create or Retrieve a sub test or benchmark. The ID will be of the form:
+ // file:///path/to/mod/file.go?test#TestXxx/A/B/C
+ getOrCreateSubTest(item: TestItem, name: string): TestItem {
+ const { fragment: parentName, query: kind } = Uri.parse(item.id);
+ const existing = this.getItem(item, item.uri, kind, `${parentName}/${name}`);
+ if (existing) return existing;
+
+ item.canResolveChildren = true;
+ const sub = this.createItem(name, item.uri, kind, `${parentName}/${name}`);
+ item.children.add(sub);
+ sub.range = item.range;
+ return sub;
+ }
+
+ /* ***** Listeners ***** */
+
+ protected async didOpenTextDocument(doc: TextDocument) {
+ await documentUpdate(this, doc);
+ }
+
+ protected async didChangeTextDocument(e: TextDocumentChangeEvent) {
+ await documentUpdate(
+ this,
+ e.document,
+ e.contentChanges.map((x) => x.range)
+ );
+ }
+
+ protected async didChangeWorkspaceFolders(e: WorkspaceFoldersChangeEvent) {
+ if (e.removed.length > 0) {
+ for (const item of collect(this.ctrl.items)) {
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'package') {
+ continue;
+ }
+
+ const ws = this.ws.getWorkspaceFolder(uri);
+ if (!ws) {
+ dispose(item);
+ }
+ }
+ }
+
+ if (e.added) {
+ await resolve(this);
+ }
+ }
+
+ protected async didCreateFile(file: Uri) {
+ await documentUpdate(this, await this.ws.openTextDocument(file));
+ }
+
+ protected async didDeleteFile(file: Uri) {
+ const id = testID(file, 'file');
+ function find(children: TestItemCollection): TestItem {
+ for (const item of collect(children)) {
+ if (item.id === id) {
+ return item;
+ }
+
+ const uri = Uri.parse(item.id);
+ if (!file.path.startsWith(uri.path)) {
+ continue;
+ }
+
+ const found = find(item.children);
+ if (found) {
+ return found;
+ }
+ }
+ }
+
+ const found = find(this.ctrl.items);
+ if (found) {
+ dispose(found);
+ disposeIfEmpty(found.parent);
+ }
+ }
+
+ protected async didChangeConfiguration(e: ConfigurationChangeEvent) {
+ let update = false;
+ for (const item of collect(this.ctrl.items)) {
+ if (e.affectsConfiguration('go.testExplorerPackages', item.uri)) {
+ dispose(item);
+ update = true;
+ }
+ }
+
+ if (update) {
+ resolve(this);
+ }
+ }
+}
+
+// Construct an ID for an item. Exported for tests.
+// - Module: file:///path/to/mod?module
+// - Package: file:///path/to/mod/pkg?package
+// - File: file:///path/to/mod/file.go?file
+// - Test: file:///path/to/mod/file.go?test#TestXxx
+// - Benchmark: file:///path/to/mod/file.go?benchmark#BenchmarkXxx
+// - Example: file:///path/to/mod/file.go?example#ExampleXxx
+export function testID(uri: Uri, kind: string, name?: string): string {
+ uri = uri.with({ query: kind });
+ if (name) uri = uri.with({ fragment: name });
+ return uri.toString();
+}
+
+function collect(items: TestItemCollection): TestItem[] {
+ const r: TestItem[] = [];
+ items.forEach((i) => r.push(i));
+ return r;
+}
+
+function getChildren(parent: TestItem | TestItemCollection): TestItemCollection {
+ if ('children' in parent) {
+ return parent.children;
+ }
+ return parent;
+}
+
+function dispose(item: TestItem) {
+ item.parent.children.delete(item.id);
+}
+
+// Dispose of the item if it has no children, recursively. This facilitates
+// cleaning up package/file trees that contain no tests.
+function disposeIfEmpty(item: TestItem) {
+ // Don't dispose of empty top-level items
+ const uri = Uri.parse(item.id);
+ if (uri.query === 'module' || uri.query === 'workspace' || (uri.query === 'package' && !item.parent)) {
+ return;
+ }
+
+ if (item.children.size > 0) {
+ return;
+ }
+
+ dispose(item);
+ disposeIfEmpty(item.parent);
+}
+
+// Dispose of the children of a test. Sub-tests and sub-benchmarks are
+// discovered emperically (from test output) not semantically (from code), so
+// there are situations where they must be discarded.
+function discardChildren(item: TestItem) {
+ item.canResolveChildren = false;
+ item.children.forEach(dispose);
+}
+
+// If a test/benchmark with children is relocated, update the children's
+// location.
+function relocateChildren(item: TestItem) {
+ for (const child of collect(item.children)) {
+ child.range = item.range;
+ relocateChildren(child);
+ }
+}
+
+// Retrieve or create an item for a Go module.
+async function getModule(expl: TestExplorer, uri: Uri): Promise {
+ const existing = expl.getItem(null, uri, 'module');
+ if (existing) {
+ return existing;
+ }
+
+ // Use the module name as the label
+ const goMod = Uri.joinPath(uri, 'go.mod');
+ const contents = await expl.ws.fs.readFile(goMod);
+ const modLine = contents.toString().split('\n', 2)[0];
+ const match = modLine.match(/^module (?.*?)(?:\s|\/\/|$)/);
+ const item = expl.getOrCreateItem(null, match.groups.name, uri, 'module');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a workspace folder that is not a module.
+async function getWorkspace(expl: TestExplorer, ws: WorkspaceFolder): Promise {
+ const existing = expl.getItem(null, ws.uri, 'workspace');
+ if (existing) {
+ return existing;
+ }
+
+ // Use the workspace folder name as the label
+ const item = expl.getOrCreateItem(null, ws.name, ws.uri, 'workspace');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a Go package.
+async function getPackage(expl: TestExplorer, uri: Uri): Promise {
+ let item: TestItem;
+
+ const nested = getGoConfig(uri).get('testExplorerPackages') === 'nested';
+ const modDir = await getModFolderPath(uri, true);
+ const wsfolder = workspace.getWorkspaceFolder(uri);
+ if (modDir) {
+ // If the package is in a module, add it as a child of the module
+ let parent = await getModule(expl, uri.with({ path: modDir, query: '', fragment: '' }));
+ if (uri.path === parent.uri.path) {
+ return parent;
+ }
+
+ if (nested) {
+ const bits = path.relative(parent.uri.path, uri.path).split(path.sep);
+ while (bits.length > 1) {
+ const dir = bits.shift();
+ const dirUri = uri.with({ path: path.join(parent.uri.path, dir), query: '', fragment: '' });
+ parent = expl.getOrCreateItem(parent, dir, dirUri, 'package');
+ }
+ }
+
+ const label = uri.path.startsWith(parent.uri.path) ? uri.path.substring(parent.uri.path.length + 1) : uri.path;
+ item = expl.getOrCreateItem(parent, label, uri, 'package');
+ } else if (wsfolder) {
+ // If the package is in a workspace folder, add it as a child of the workspace
+ const workspace = await getWorkspace(expl, wsfolder);
+ const existing = expl.getItem(workspace, uri, 'package');
+ if (existing) {
+ return existing;
+ }
+
+ const label = uri.path.startsWith(wsfolder.uri.path)
+ ? uri.path.substring(wsfolder.uri.path.length + 1)
+ : uri.path;
+ item = expl.getOrCreateItem(workspace, label, uri, 'package');
+ } else {
+ // Otherwise, add it directly to the root
+ const existing = expl.getItem(null, uri, 'package');
+ if (existing) {
+ return existing;
+ }
+
+ const srcPath = path.join(getCurrentGoPath(uri), 'src');
+ const label = uri.path.startsWith(srcPath) ? uri.path.substring(srcPath.length + 1) : uri.path;
+ item = expl.getOrCreateItem(null, label, uri, 'package');
+ }
+
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Retrieve or create an item for a Go file.
+async function getFile(expl: TestExplorer, uri: Uri): Promise {
+ const dir = path.dirname(uri.path);
+ const pkg = await getPackage(expl, uri.with({ path: dir, query: '', fragment: '' }));
+ const existing = expl.getItem(pkg, uri, 'file');
+ if (existing) {
+ return existing;
+ }
+
+ const label = path.basename(uri.path);
+ const item = expl.getOrCreateItem(pkg, label, uri, 'file');
+ item.canResolveChildren = true;
+ return item;
+}
+
+// Recursively process a Go AST symbol. If the symbol represents a test,
+// benchmark, or example function, a test item will be created for it, if one
+// does not already exist. If the symbol is not a function and contains
+// children, those children will be processed recursively.
+async function processSymbol(expl: TestExplorer, uri: Uri, file: TestItem, seen: Set, symbol: DocumentSymbol) {
+ // Skip TestMain(*testing.M) - allow TestMain(*testing.T)
+ if (symbol.name === 'TestMain' && /\*testing.M\)/.test(symbol.detail)) {
+ return;
+ }
+
+ // Recursively process symbols that are nested
+ if (symbol.kind !== SymbolKind.Function) {
+ for (const sym of symbol.children) await processSymbol(expl, uri, file, seen, sym);
+ return;
+ }
+
+ const match = symbol.name.match(/^(?Test|Example|Benchmark)/);
+ if (!match) {
+ return;
+ }
+
+ seen.add(symbol.name);
+
+ const kind = match.groups.type.toLowerCase();
+ const existing = expl.getItem(file, uri, kind, symbol.name);
+ if (existing) {
+ if (!existing.range.isEqual(symbol.range)) {
+ existing.range = symbol.range;
+ relocateChildren(existing);
+ }
+ return existing;
+ }
+
+ const item = expl.getOrCreateItem(file, symbol.name, uri, kind, symbol.name);
+ item.range = symbol.range;
+}
+
+// Processes a Go document, calling processSymbol for each symbol in the
+// document.
+//
+// Any previously existing tests that no longer have a corresponding symbol in
+// the file will be disposed. If the document contains no tests, it will be
+// disposed.
+async function processDocument(expl: TestExplorer, doc: TextDocument, ranges?: Range[]) {
+ const seen = new Set();
+ const item = await getFile(expl, doc.uri);
+ const symbols = await expl.provideDocumentSymbols(doc, null);
+ for (const symbol of symbols) await processSymbol(expl, doc.uri, item, seen, symbol);
+
+ for (const child of collect(item.children)) {
+ const uri = Uri.parse(child.id);
+ if (!seen.has(uri.fragment)) {
+ dispose(child);
+ continue;
+ }
+
+ if (ranges?.some((r) => !!child.range.intersection(r))) {
+ discardChildren(child);
+ }
+ }
+
+ disposeIfEmpty(item);
+}
+
+// Reasons to stop walking
+enum WalkStop {
+ None = 0, // Don't stop
+ Abort, // Abort the walk
+ Current, // Stop walking the current directory
+ Files, // Skip remaining files
+ Directories // Skip remaining directories
+}
+
+// Recursively walk a directory, breadth first.
+async function walk(
+ fs: TestExplorer.FileSystem,
+ uri: Uri,
+ cb: (dir: Uri, file: string, type: FileType) => Promise
+): Promise {
+ let dirs = [uri];
+
+ // While there are directories to be scanned
+ while (dirs.length > 0) {
+ const d = dirs;
+ dirs = [];
+
+ outer: for (const uri of d) {
+ const dirs2 = [];
+ let skipFiles = false,
+ skipDirs = false;
+
+ // Scan the directory
+ inner: for (const [file, type] of await fs.readDirectory(uri)) {
+ if ((skipFiles && type === FileType.File) || (skipDirs && type === FileType.Directory)) {
+ continue;
+ }
+
+ // Ignore all dotfiles
+ if (file.startsWith('.')) {
+ continue;
+ }
+
+ if (type === FileType.Directory) {
+ dirs2.push(Uri.joinPath(uri, file));
+ }
+
+ const s = await cb(uri, file, type);
+ switch (s) {
+ case WalkStop.Abort:
+ // Immediately abort the entire walk
+ return;
+
+ case WalkStop.Current:
+ // Immediately abort the current directory
+ continue outer;
+
+ case WalkStop.Files:
+ // Skip all subsequent files in the current directory
+ skipFiles = true;
+ if (skipFiles && skipDirs) {
+ break inner;
+ }
+ break;
+
+ case WalkStop.Directories:
+ // Skip all subsequent directories in the current directory
+ skipDirs = true;
+ if (skipFiles && skipDirs) {
+ break inner;
+ }
+ break;
+ }
+ }
+
+ // Add subdirectories to the recursion list
+ dirs.push(...dirs2);
+ }
+ }
+}
+
+// Walk the workspace, looking for Go modules. Returns a map indicating paths
+// that are modules (value == true) and paths that are not modules but contain
+// Go files (value == false).
+async function walkWorkspaces(fs: TestExplorer.FileSystem, uri: Uri): Promise