diff --git a/CHANGELOG.md b/CHANGELOG.md index d58a58e8d..23fba3e4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Added code lenses to run suites/tests, configurable with the `swift.showTestCodeLenses` setting ([#1698](https://github.com/swiftlang/vscode-swift/pull/1698)) +- New `swift.excludePathsFromActivation` setting to ignore specified sub-folders from being activated as projects ([#1693](https://github.com/swiftlang/vscode-swift/pull/1693)) ## 2.8.0 - 2025-07-14 diff --git a/assets/test/.vscode/settings.json b/assets/test/.vscode/settings.json index 357204d75..3962271c2 100644 --- a/assets/test/.vscode/settings.json +++ b/assets/test/.vscode/settings.json @@ -10,5 +10,8 @@ "lldb.verboseLogging": true, "lldb.launch.terminal": "external", "lldb-dap.detachOnError": true, - "swift.sourcekit-lsp.backgroundIndexing": "off" + "swift.sourcekit-lsp.backgroundIndexing": "off", + "swift.excludePathsFromActivation": { + "**/excluded": true + } } \ No newline at end of file diff --git a/assets/test/excluded/Package.swift b/assets/test/excluded/Package.swift new file mode 100644 index 000000000..96bc3519e --- /dev/null +++ b/assets/test/excluded/Package.swift @@ -0,0 +1,15 @@ +// swift-tools-version: 5.6 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "excluded", + products: [ + .library(name: "excluded", targets: ["excluded"]), + ], + dependencies: [], + targets: [ + .target(name: "excluded"), + ] +) diff --git a/assets/test/excluded/Sources/excluded/excluded.swift b/assets/test/excluded/Sources/excluded/excluded.swift new file mode 100644 index 000000000..d31c717c5 --- /dev/null +++ b/assets/test/excluded/Sources/excluded/excluded.swift @@ -0,0 +1,6 @@ +public struct excluded { + public private(set) var text = "Hello, World!" + + public init() { + } +} diff --git a/package-lock.json b/package-lock.json index 0ba253a54..25ac10b92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@types/lcov-parse": "^1.0.2", "@types/lodash.debounce": "^4.0.9", "@types/lodash.throttle": "^4.1.9", + "@types/micromatch": "^4.0.9", "@types/mocha": "^10.0.10", "@types/mock-fs": "^4.13.4", "@types/node": "^20.19.7", @@ -54,6 +55,7 @@ "lint-staged": "^16.1.2", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", + "micromatch": "^4.0.8", "mocha": "^11.7.1", "mock-fs": "^5.5.0", "node-pty": "^1.0.0", @@ -1874,6 +1876,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/btoa-lite": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", @@ -1967,6 +1976,16 @@ "@types/lodash": "*" } }, + "node_modules/@types/micromatch": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/braces": "*" + } + }, "node_modules/@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", @@ -7283,6 +7302,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" @@ -12016,6 +12036,12 @@ "integrity": "sha512-NXSZIhfJjnXqJgtS7IwutqIF/SOy1Wz5Px4gUY1RWITp3AYTyuJS4xaXr/bIJY1v15XMzrJ5soGnPM+7uigZjA==", "dev": true }, + "@types/braces": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/braces/-/braces-3.0.5.tgz", + "integrity": "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w==", + "dev": true + }, "@types/btoa-lite": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@types/btoa-lite/-/btoa-lite-1.0.2.tgz", @@ -12099,6 +12125,15 @@ "@types/lodash": "*" } }, + "@types/micromatch": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/micromatch/-/micromatch-4.0.9.tgz", + "integrity": "sha512-7V+8ncr22h4UoYRLnLXSpTxjQrNUXtWHGeMPRJt1nULXI57G9bIcpyrHlmrQ7QK24EyyuXvYcSSWAM8GA9nqCg==", + "dev": true, + "requires": { + "@types/braces": "*" + } + }, "@types/mocha": { "version": "10.0.10", "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", diff --git a/package.json b/package.json index 112b4b594..b83eec5b7 100644 --- a/package.json +++ b/package.json @@ -796,6 +796,13 @@ "default": false, "markdownDescription": "Disable the running of SourceKit-LSP.", "markdownDeprecationMessage": "**Deprecated**: Please use `#swift.sourcekit-lsp.disable#` instead." + }, + "swift.excludePathsFromActivation": { + "type": "object", + "additionalProperties": { + "type": "boolean" + }, + "markdownDescription": "Configure glob patterns for excluding Swift package folders from getting activated. This will take precedence over the glob patterns provided to `files.exclude`." } } }, @@ -1778,6 +1785,7 @@ "@types/lcov-parse": "^1.0.2", "@types/lodash.debounce": "^4.0.9", "@types/lodash.throttle": "^4.1.9", + "@types/micromatch": "^4.0.9", "@types/mocha": "^10.0.10", "@types/mock-fs": "^4.13.4", "@types/node": "^20.19.7", @@ -1806,6 +1814,7 @@ "lint-staged": "^16.1.2", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", + "micromatch": "^4.0.8", "mocha": "^11.7.1", "mock-fs": "^5.5.0", "node-pty": "^1.0.0", diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index e565daa68..e9907c945 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -18,7 +18,7 @@ import { FolderContext } from "./FolderContext"; import { StatusItem } from "./ui/StatusItem"; import { SwiftOutputChannel } from "./ui/SwiftOutputChannel"; import { swiftLibraryPathKey } from "./utilities/utilities"; -import { isPathInsidePath } from "./utilities/filesystem"; +import { isExcluded, isPathInsidePath } from "./utilities/filesystem"; import { LanguageClientToolchainCoordinator } from "./sourcekit-lsp/LanguageClientToolchainCoordinator"; import { TemporaryFolder } from "./utilities/tempFolder"; import { TaskManager } from "./tasks/TaskManager"; @@ -509,6 +509,9 @@ export class WorkspaceContext implements vscode.Disposable { /** set focus based on the file */ async focusPackageUri(uri: vscode.Uri) { + if (isExcluded(uri)) { + return; + } const packageFolder = await this.getPackageFolder(uri); if (packageFolder instanceof FolderContext) { await this.focusFolder(packageFolder); diff --git a/src/configuration.ts b/src/configuration.ts index e6cae9def..01fd705b0 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -490,6 +490,12 @@ const configuration = { get disableSandbox(): boolean { return vscode.workspace.getConfiguration("swift").get("disableSandbox", false); }, + /** Workspace folder glob patterns to exclude */ + get excludePathsFromActivation(): Record { + return vscode.workspace + .getConfiguration("swift") + .get>("excludePathsFromActivation", {}); + }, }; const vsCodeVariableRegex = new RegExp(/\$\{(.+?)\}/g); diff --git a/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts b/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts index 5517ad9d5..348184807 100644 --- a/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts +++ b/src/sourcekit-lsp/LanguageClientToolchainCoordinator.ts @@ -18,6 +18,7 @@ import { FolderContext } from "../FolderContext"; import { LanguageClientFactory } from "./LanguageClientFactory"; import { LanguageClientManager } from "./LanguageClientManager"; import { FolderOperation, WorkspaceContext } from "../WorkspaceContext"; +import { isExcluded } from "../utilities/filesystem"; /** * Manages the creation of LanguageClient instances for workspace folders. @@ -64,6 +65,9 @@ export class LanguageClientToolchainCoordinator implements vscode.Disposable { if (!folder) { return; } + if (isExcluded(folder.workspaceFolder.uri)) { + return; + } const singleServer = folder.swiftVersion.isGreaterThanOrEqual(new Version(5, 7, 0)); switch (operation) { case FolderOperation.add: { diff --git a/src/utilities/filesystem.ts b/src/utilities/filesystem.ts index 37c772f7c..2dce7fe89 100644 --- a/src/utilities/filesystem.ts +++ b/src/utilities/filesystem.ts @@ -12,8 +12,12 @@ // //===----------------------------------------------------------------------===// +import { contains } from "micromatch"; import * as fs from "fs/promises"; import * as path from "path"; +import * as vscode from "vscode"; +import { convertPathToPattern, glob as fastGlob, Options } from "fast-glob"; +import configuration from "../configuration"; export const validFileTypes = ["swift", "c", "cpp", "h", "hpp", "m", "mm"]; @@ -79,3 +83,76 @@ export function expandFilePathTilde( } return path.join(directory, filepath.slice(1)); } + +function getDefaultExcludeList(): Record { + const config = vscode.workspace.getConfiguration("files"); + const vscodeExcludeList = config.get<{ [key: string]: boolean }>("exclude", {}); + const swiftExcludeList = configuration.excludePathsFromActivation; + return { ...vscodeExcludeList, ...swiftExcludeList }; +} + +function getGlobPattern(excludeList: Record): { + include: string[]; + exclude: string[]; +} { + const exclude: string[] = []; + const include: string[] = []; + for (const key of Object.keys(excludeList)) { + if (excludeList[key]) { + exclude.push(key); + } else { + include.push(key); + } + } + return { include, exclude }; +} + +export function isIncluded( + uri: vscode.Uri, + excludeList: Record = getDefaultExcludeList() +): boolean { + let notExcluded = true; + let included = true; + for (const key of Object.keys(excludeList)) { + if (excludeList[key]) { + if (contains(uri.fsPath, key, { contains: true })) { + notExcluded = false; + included = false; + } + } else { + if (contains(uri.fsPath, key, { contains: true })) { + included = true; + } + } + } + if (notExcluded) { + return true; + } + return included; +} + +export function isExcluded( + uri: vscode.Uri, + excludeList: Record = getDefaultExcludeList() +): boolean { + return !isIncluded(uri, excludeList); +} + +export async function globDirectory(uri: vscode.Uri, options?: Options): Promise { + const { include, exclude } = getGlobPattern(getDefaultExcludeList()); + const matches: string[] = await fastGlob(`${convertPathToPattern(uri.fsPath)}/*`, { + ignore: exclude, + absolute: true, + ...options, + }); + if (include.length > 0) { + matches.push( + ...(await fastGlob(include, { + absolute: true, + cwd: uri.fsPath, + ...options, + })) + ); + } + return matches; +} diff --git a/src/utilities/workspace.ts b/src/utilities/workspace.ts index 73d49bf91..97d6b8c4d 100644 --- a/src/utilities/workspace.ts +++ b/src/utilities/workspace.ts @@ -13,8 +13,7 @@ //===----------------------------------------------------------------------===// import * as vscode from "vscode"; -import { pathExists } from "./filesystem"; -import { convertPathToPattern, glob } from "fast-glob"; +import { globDirectory, pathExists } from "./filesystem"; import { basename } from "path"; export async function searchForPackages( @@ -34,13 +33,7 @@ export async function searchForPackages( return; } - const config = vscode.workspace.getConfiguration("files"); - const vscodeExcludeList = config.get<{ [key: string]: boolean }>("exclude", {}); - await glob(`${convertPathToPattern(folder.fsPath)}/*`, { - ignore: [...Object.keys(vscodeExcludeList).filter(k => vscodeExcludeList[k])], - absolute: true, - onlyDirectories: true, - }).then(async entries => { + await globDirectory(folder, { onlyDirectories: true }).then(async entries => { for (const entry of entries) { if (basename(entry) !== "." && basename(entry) !== "Packages") { await search(vscode.Uri.file(entry)); diff --git a/test/integration-tests/utilities/workspace.test.ts b/test/integration-tests/utilities/workspace.test.ts new file mode 100644 index 000000000..8093186e0 --- /dev/null +++ b/test/integration-tests/utilities/workspace.test.ts @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2024 the VS Code Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of VS Code Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import * as vscode from "vscode"; +import { searchForPackages } from "../../../src/utilities/workspace"; +import { expect } from "chai"; + +suite("Workspace Utilities Test Suite", () => { + suite("searchForPackages", () => { + test("ignores excluded file", async () => { + const folders = await searchForPackages( + (vscode.workspace.workspaceFolders ?? [])[0]!.uri, + false, + true + ); + + expect(folders.find(f => f.fsPath.includes("defaultPackage"))).to.not.be.undefined; + expect(folders.find(f => f.fsPath.includes("excluded"))).to.be.undefined; + }); + }); +}); diff --git a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts index d434a8074..85151a22c 100644 --- a/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts +++ b/test/unit-tests/sourcekit-lsp/LanguageClientManager.test.ts @@ -69,6 +69,7 @@ suite("LanguageClientManager Suite", () => { const mockedVSCodeWindow = mockGlobalObject(vscode, "window"); const mockedVSCodeExtensions = mockGlobalObject(vscode, "extensions"); const mockedVSCodeWorkspace = mockGlobalObject(vscode, "workspace"); + const excludeConfig = mockGlobalValue(configuration, "excludePathsFromActivation"); let changeConfigEmitter: AsyncEventEmitter; let createFilesEmitter: AsyncEventEmitter; let deleteFilesEmitter: AsyncEventEmitter; @@ -92,6 +93,9 @@ suite("LanguageClientManager Suite", () => { mockedVSCodeWorkspace.onDidCreateFiles.callsFake(createFilesEmitter.event); deleteFilesEmitter = new AsyncEventEmitter(); mockedVSCodeWorkspace.onDidDeleteFiles.callsFake(deleteFilesEmitter.event); + mockedVSCodeWorkspace.getConfiguration + .withArgs("files") + .returns({ get: () => ({}) } as any); // Mock the WorkspaceContext and SwiftToolchain mockedBuildFlags = mockObject({ buildPathFlags: mockFn(s => s.returns([])), @@ -208,6 +212,8 @@ suite("LanguageClientManager Suite", () => { mockedLspConfig.serverArguments = []; // Process environment variables mockedEnvironment.setValue({}); + // Exclusion + excludeConfig.setValue({}); }); suite("LanguageClientToolchainCoordinator", () => { diff --git a/test/unit-tests/utilities/filesystem.test.ts b/test/unit-tests/utilities/filesystem.test.ts index 4bd084372..231a23101 100644 --- a/test/unit-tests/utilities/filesystem.test.ts +++ b/test/unit-tests/utilities/filesystem.test.ts @@ -13,7 +13,13 @@ //===----------------------------------------------------------------------===// import * as path from "path"; -import { isPathInsidePath, expandFilePathTilde } from "../../../src/utilities/filesystem"; +import { Uri } from "vscode"; +import { + isPathInsidePath, + expandFilePathTilde, + isExcluded, + isIncluded, +} from "../../../src/utilities/filesystem"; import { expect } from "chai"; suite("File System Utilities Unit Test Suite", () => { @@ -55,4 +61,58 @@ suite("File System Utilities Unit Test Suite", () => { expect(expandFilePathTilde("~/Test", "C:\\Users\\John", "win32")).to.equal("~/Test"); }); }); + + suite("isExcluded()", () => { + const uri = Uri.file("path/to/foo/bar/file.swift"); + + test("excluded", () => { + expect(isExcluded(uri, { "/path": true })).to.be.true; + expect(isExcluded(uri, { "**/foo": true })).to.be.true; + expect(isExcluded(uri, { "**/foo/**": true })).to.be.true; + }); + + test("excluded, overwriting patterns", () => { + expect(isExcluded(uri, { "**/foo": false, "**/foo/bar": true })).to.be.true; + }); + + test("NOT excluded", () => { + expect(isExcluded(uri, { "**/qux/**": false })).to.be.false; + expect(isExcluded(uri, { "**/foo": false, "**/foo/qux": true })).to.be.false; + expect( + isExcluded(uri, { + "**/foo": false, + "**/foo/bar": true, + "**/foo/bar/file.swift": false, + }) + ).to.be.false; + }); + }); + + suite("isIncluded()", () => { + const uri = Uri.file("path/to/foo/bar/file.swift"); + + test("included", () => { + expect(isIncluded(uri, {})).to.be.true; + expect(isIncluded(uri, { "/path": false })).to.be.true; + expect(isIncluded(uri, { "**/foo": false })).to.be.true; + expect(isIncluded(uri, { "**/foo/**": false })).to.be.true; + expect(isIncluded(uri, { "**/qux/**": true })).to.be.true; + }); + + test("included, overwriting patterns", () => { + expect(isIncluded(uri, { "**/foo": true, "**/foo/bar": false })).to.be.true; + }); + + test("NOT included", () => { + expect(isIncluded(uri, { "**/foo": true })).to.be.false; + expect(isIncluded(uri, { "**/foo": true, "**/foo/qux": false })).to.be.false; + expect( + isIncluded(uri, { + "**/foo": true, + "**/foo/bar": false, + "**/foo/bar/file.swift": true, + }) + ).to.be.false; + }); + }); }); diff --git a/userdocs/userdocs.docc/Articles/Reference/settings.md b/userdocs/userdocs.docc/Articles/Reference/settings.md index 7de1e3615..eb3f4807a 100644 --- a/userdocs/userdocs.docc/Articles/Reference/settings.md +++ b/userdocs/userdocs.docc/Articles/Reference/settings.md @@ -8,6 +8,64 @@ The Swift extension comes with a number of settings you can use to control how i This document outlines useful configuration options not covered by the settings descriptions in the extension settings page. +## Workspace Setup + +### Multiple packages in workspace folder + +If the workspace folder you open in VS Code contains multiple Swift packages: +``` + + /proj1 + /Package.swift + /proj2 + /Package.swift + /aSubfolder + /proj3 + /Package.swift +``` + +You can enable the `searchSubfoldersForPackages` setting so the Swift extension can initializing all these projects. +```json +{ + "swift.searchSubfoldersForPackages": true, +} +``` + +Additionally you can exclude individual packages from initializing: +```json +{ + "swift.excludePathsFromActivation": { + "**/proj2": true, + "**/aSubfolder": true, + "**/aSubfolder/proj3": false, + }, +} +``` + +### Multi-root Workspaces + +As an alternative to opening [a single workspace folder with multiple packages in it](#multiple-packages-in-workspace-folder), VS Code has a concept of [multi-root workspaces](https://code.visualstudio.com/docs/editing/workspaces/multi-root-workspaces) which the Swift extension supports. + +Ex. myProj.code-workspace +```json +{ + "folders": [ + { + "name": "proj1", + "path": "./proj1" + }, + { + "name": "proj3", + "path": "./aSubfolder/proj3" + }, + ], + "settings": { + "swift.autoGenerateLaunchConfigurations": false, + "swift.debugger.debugAdapter": "lldb-dap", + } +} +``` + ## Command Plugins Swift packages can define [command plugins](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/Plugins.md) that can perform arbitrary tasks. For example, the [swift-format](https://github.com/swiftlang/swift-format) package exposes a `format-source-code` command which will use swift-format to format source code in a folder. These plugin commands can be invoked from VS Code using `> Swift: Run Command Plugin`.