Skip to content

Ensure document symbols are provided for folders in multi root workspaces #1668

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Jul 2, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

### Fixed

- Fix test explorer tests not updating on document modification ([#1663](https://github.com/swiftlang/vscode-swift/pull/1663))
- Fix improper parenting of tests w/ identical names in explorer ([#1664](https://github.com/swiftlang/vscode-swift/pull/1664))
- Ensure document symbols are provided for folders in multi root workspaces ([#1668](https://github.com/swiftlang/vscode-swift/pull/1668))

## 2.6.1 - 2025-06-27

### Fixed

- Cleanup Swift diagnostics when the source file is moved or deleted ([#1653](https://github.com/swiftlang/vscode-swift/pull/1653))
- Make sure newline starts with /// when splitting doc comment ([#1651](https://github.com/swiftlang/vscode-swift/pull/1651))
- Capture diagnostics with `Swift: Capture Diagnostic Bundle` to a .zip file ([#1656](https://github.com/swiftlang/vscode-swift/pull/1656))
Expand Down
23 changes: 21 additions & 2 deletions src/FolderContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,9 @@ export class FolderContext implements vscode.Disposable {
}

/** Refresh the tests in the test explorer for this folder */
refreshTestExplorer() {
async refreshTestExplorer() {
if (this.testExplorer?.controller.resolveHandler) {
void this.testExplorer.controller.resolveHandler(undefined);
return this.testExplorer.controller.resolveHandler(undefined);
}
}

Expand Down Expand Up @@ -211,6 +211,25 @@ export class FolderContext implements vscode.Disposable {
});
return target;
}

/**
* Called whenever we have new document symbols
*/
onDocumentSymbols(
document: vscode.TextDocument,
symbols: vscode.DocumentSymbol[] | null | undefined
) {
const uri = document?.uri;
if (
this.testExplorer &&
symbols &&
uri &&
uri.scheme === "file" &&
isPathInsidePath(uri.fsPath, this.folder.fsPath)
) {
void this.testExplorer.getDocumentTests(this, uri, symbols);
}
}
}

export interface EditedPackage {
Expand Down
131 changes: 56 additions & 75 deletions src/TestExplorer/TestExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import * as vscode from "vscode";
import { FolderContext } from "../FolderContext";
import { getErrorDescription } from "../utilities/utilities";
import { isPathInsidePath } from "../utilities/filesystem";
import { FolderOperation, WorkspaceContext } from "../WorkspaceContext";
import { TestRunProxy, TestRunner } from "./TestRunner";
import { LSPTestDiscovery } from "./LSPTestDiscovery";
Expand Down Expand Up @@ -136,45 +135,11 @@ export class TestExplorer {
const disposable = workspaceContext.onDidChangeFolders(({ folder, operation }) => {
switch (operation) {
case FolderOperation.add:
if (folder) {
void folder.swiftPackage.getTargets(TargetType.test).then(targets => {
if (targets.length === 0) {
return;
}

folder.addTestExplorer();
// discover tests in workspace but only if disableAutoResolve is not on.
// discover tests will kick off a resolve if required
if (!configuration.folder(folder.workspaceFolder).disableAutoResolve) {
void folder.testExplorer?.discoverTestsInWorkspace(
tokenSource.token
);
}
});
}
break;
case FolderOperation.packageUpdated:
if (folder) {
void folder.swiftPackage.getTargets(TargetType.test).then(targets => {
const hasTestTargets = targets.length > 0;
if (hasTestTargets && !folder.hasTestExplorer()) {
folder.addTestExplorer();
// discover tests in workspace but only if disableAutoResolve is not on.
// discover tests will kick off a resolve if required
if (
!configuration.folder(folder.workspaceFolder).disableAutoResolve
) {
void folder.testExplorer?.discoverTestsInWorkspace(
tokenSource.token
);
}
} else if (!hasTestTargets && folder.hasTestExplorer()) {
folder.removeTestExplorer();
} else if (folder.hasTestExplorer()) {
folder.refreshTestExplorer();
}
});
void this.setupTestExplorerForFolder(folder, tokenSource.token);
}
break;
}
});
return {
Expand All @@ -185,6 +150,38 @@ export class TestExplorer {
};
}

/**
* Configures a test explorer for the given folder.
* If the folder has test targets, and there is no existing test explorer,
* it will create a test explorer and discover tests.
* If the folder has no test targets, it will remove any existing test explorer.
* If the folder has test targets and an existing test explorer, it will refresh the tests.
*/
private static async setupTestExplorerForFolder(
folder: FolderContext,
token: vscode.CancellationToken
) {
const targets = await folder.swiftPackage.getTargets(TargetType.test);
const hasTestTargets = targets.length > 0;
if (hasTestTargets && !folder.hasTestExplorer()) {
const testExplorer = folder.addTestExplorer();
if (
configuration.folder(folder.workspaceFolder).disableAutoResolve &&
process.platform === "win32" &&
folder.swiftVersion.isLessThan(new Version(5, 10, 0))
) {
// On Windows 5.9 and earlier discoverTestsInWorkspace kicks off a build,
// which will perform a resolve.
return;
}
await testExplorer.discoverTestsInWorkspace(token);
} else if (hasTestTargets && folder.hasTestExplorer()) {
await folder.refreshTestExplorer();
} else if (!hasTestTargets && folder.hasTestExplorer()) {
folder.removeTestExplorer();
}
}

/**
* Sets the `swift.tests` context variable which is used by commands
* to determine if the test item belongs to the Swift extension.
Expand All @@ -196,45 +193,29 @@ export class TestExplorer {
});
}

/**
* Called whenever we have new document symbols
*/
static onDocumentSymbols(
async getDocumentTests(
folder: FolderContext,
document: vscode.TextDocument,
symbols: vscode.DocumentSymbol[] | null | undefined
) {
const uri = document?.uri;
const testExplorer = folder?.testExplorer;
if (testExplorer && symbols && uri && uri.scheme === "file") {
if (isPathInsidePath(uri.fsPath, folder.folder.fsPath)) {
void folder.swiftPackage.getTarget(uri.fsPath).then(target => {
if (target && target.type === "test") {
testExplorer.lspTestDiscovery
.getDocumentTests(folder.swiftPackage, uri)
.then(tests => {
TestDiscovery.updateTestsForTarget(
testExplorer.controller,
{ id: target.c99name, label: target.name },
tests,
uri
);
testExplorer.onTestItemsDidChangeEmitter.fire(
testExplorer.controller
);
})
// Fallback to parsing document symbols for XCTests only
.catch(() => {
const tests = parseTestsFromDocumentSymbols(
target.name,
symbols,
uri
);
testExplorer.updateTests(testExplorer.controller, tests, uri);
});
}
});
}
uri: vscode.Uri,
symbols: vscode.DocumentSymbol[]
): Promise<void> {
const target = await folder.swiftPackage.getTarget(uri.fsPath);
if (!target || target.type !== "test") {
return;
}

try {
const tests = await this.lspTestDiscovery.getDocumentTests(folder.swiftPackage, uri);
TestDiscovery.updateTestsForTarget(
this.controller,
{ id: target.c99name, label: target.name },
tests,
uri
);
this.onTestItemsDidChangeEmitter.fire(this.controller);
} catch {
// Fallback to parsing document symbols for XCTests only
const tests = parseTestsFromDocumentSymbols(target.name, symbols, uri);
this.updateTests(this.controller, tests, uri);
}
}

Expand Down
3 changes: 1 addition & 2 deletions src/WorkspaceContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,6 @@ import { isValidWorkspaceFolder, searchForPackages } from "./utilities/workspace
import { SwiftPluginTaskProvider } from "./tasks/SwiftPluginTaskProvider";
import { SwiftTaskProvider } from "./tasks/SwiftTaskProvider";
import { LLDBDebugConfigurationProvider } from "./debugger/debugAdapterFactory";
import { TestExplorer } from "./TestExplorer/TestExplorer";

/**
* Context for whole workspace. Holds array of contexts for each workspace folder
Expand Down Expand Up @@ -82,7 +81,7 @@ export class WorkspaceContext implements vscode.Disposable {
this.buildStatus = new SwiftBuildStatus(this.statusItem);
this.languageClientManager = new LanguageClientToolchainCoordinator(this, {
onDocumentSymbols: (folder, document, symbols) => {
TestExplorer.onDocumentSymbols(folder, document, symbols);
folder.onDocumentSymbols(document, symbols);
},
});
this.tasks = new TaskManager(this);
Expand Down
32 changes: 23 additions & 9 deletions src/sourcekit-lsp/LanguageClientManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,8 @@ export class LanguageClientManager implements vscode.Disposable {
private singleServerSupport: boolean;
// used by single server support to keep a record of the project folders
// that are not at the root of their workspace
public subFolderWorkspaces: vscode.Uri[];
public subFolderWorkspaces: FolderContext[] = [];
private addedFolders: FolderContext[] = [];
private namedOutputChannels: Map<string, LSPOutputChannel> = new Map();
private swiftVersion: Version;
private activeDocumentManager = new LSPActiveDocumentManager();
Expand All @@ -122,7 +123,6 @@ export class LanguageClientManager implements vscode.Disposable {
this.swiftVersion = folderContext.swiftVersion;
this.singleServerSupport = this.swiftVersion.isGreaterThanOrEqual(new Version(5, 7, 0));
this.subscriptions = [];
this.subFolderWorkspaces = [];

// on change config restart server
const onChangeConfig = vscode.workspace.onDidChangeConfiguration(event => {
Expand Down Expand Up @@ -244,9 +244,9 @@ export class LanguageClientManager implements vscode.Disposable {
async addFolder(folderContext: FolderContext) {
if (!folderContext.isRootFolder) {
await this.useLanguageClient(async client => {
const uri = folderContext.folder;
this.subFolderWorkspaces.push(folderContext.folder);
this.subFolderWorkspaces.push(folderContext);

const uri = folderContext.folder;
const workspaceFolder = {
uri: client.code2ProtocolConverter.asUri(uri),
name: FolderContext.uriName(uri),
Expand All @@ -256,13 +256,16 @@ export class LanguageClientManager implements vscode.Disposable {
});
});
}
this.addedFolders.push(folderContext);
}

async removeFolder(folderContext: FolderContext) {
if (!folderContext.isRootFolder) {
await this.useLanguageClient(async client => {
const uri = folderContext.folder;
this.subFolderWorkspaces = this.subFolderWorkspaces.filter(item => item !== uri);
this.subFolderWorkspaces = this.subFolderWorkspaces.filter(
item => item.folder !== uri
);

const workspaceFolder = {
uri: client.code2ProtocolConverter.asUri(uri),
Expand All @@ -273,13 +276,14 @@ export class LanguageClientManager implements vscode.Disposable {
});
});
}
this.addedFolders = this.addedFolders.filter(item => item.folder !== folderContext.folder);
}

private async addSubFolderWorkspaces(client: LanguageClient) {
for (const uri of this.subFolderWorkspaces) {
for (const folderContext of this.subFolderWorkspaces) {
const workspaceFolder = {
uri: client.code2ProtocolConverter.asUri(uri),
name: FolderContext.uriName(uri),
uri: client.code2ProtocolConverter.asUri(folderContext.folder),
name: FolderContext.uriName(folderContext.folder),
};
await client.sendNotification(DidChangeWorkspaceFoldersNotification.type, {
event: { added: [workspaceFolder], removed: [] },
Expand Down Expand Up @@ -440,7 +444,17 @@ export class LanguageClientManager implements vscode.Disposable {
this.activeDocumentManager,
errorHandler,
(document, symbols) => {
this.options.onDocumentSymbols?.(this.folderContext, document, symbols);
const documentFolderContext = [this.folderContext, ...this.addedFolders].find(
folderContext => document.uri.fsPath.startsWith(folderContext.folder.fsPath)
);
if (!documentFolderContext) {
this.languageClientOutputChannel?.log(
"Unable to find folder for document: " + document.uri.fsPath,
"WARN"
);
return;
}
this.options.onDocumentSymbols?.(documentFolderContext, document, symbols);
}
);

Expand Down
8 changes: 6 additions & 2 deletions test/integration-tests/ExtensionActivation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,9 @@ suite("Extension Activation/Deactivation Tests", () => {
assert(folder);

const languageClient = workspaceContext.languageClientManager.get(folder);
const lspWorkspaces = languageClient.subFolderWorkspaces.map(({ fsPath }) => fsPath);
const lspWorkspaces = languageClient.subFolderWorkspaces.map(
({ folder }) => folder.fsPath
);
assertContains(lspWorkspaces, testAssetUri("cmake").fsPath);
});

Expand All @@ -125,7 +127,9 @@ suite("Extension Activation/Deactivation Tests", () => {
assert(folder);

const languageClient = workspaceContext.languageClientManager.get(folder);
const lspWorkspaces = languageClient.subFolderWorkspaces.map(({ fsPath }) => fsPath);
const lspWorkspaces = languageClient.subFolderWorkspaces.map(
({ folder }) => folder.fsPath
);
assertContains(lspWorkspaces, testAssetUri("cmake-compile-flags").fsPath);
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ suite("Test Multiple Times Command Test Suite", () => {

activateExtensionForSuite({
async setup(ctx) {
folderContext = await folderInRootWorkspace("diagnostics", ctx);
folderContext = await folderInRootWorkspace("defaultPackage", ctx);
folderContext.addTestExplorer();

const item = folderContext.testExplorer?.controller.createTestItem(
Expand Down
Loading
Loading