diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fd73ffc4..7c43b21f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Added Swiftly toolchain management support `.swift-version` files, and integration with the toolchain selection UI ([#1717](https://github.com/swiftlang/vscode-swift/pull/1717) - 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)) +- Add a `Generate SourceKit-LSP Configuration` command that creates the configuration file with versioned schema pre-populated ([#1726](https://github.com/swiftlang/vscode-swift/pull/1716)) ### Fixed diff --git a/package.json b/package.json index db6b27c4f..e5ed1254c 100644 --- a/package.json +++ b/package.json @@ -332,6 +332,11 @@ "title": "Open Documentation", "category": "Swift", "icon": "$(book)" + }, + { + "command": "swift.generateSourcekitConfiguration", + "title": "Generate SourceKit-LSP Configuration", + "category": "Swift" } ], "configuration": [ @@ -744,6 +749,10 @@ "order": 6, "scope": "machine-overridable" }, + "swift.sourcekit-lsp.configurationBranch": { + "type": "string", + "markdownDescription": "Set the branch to use when setting the `$schema` property of the SourceKit-LSP configuration. For example: \"release/6.1\" or \"main\". When this setting is unset, the extension will determine the branch based on the version of the toolchain that is in use." + }, "sourcekit-lsp.inlayHints.enabled": { "type": "boolean", "default": true, @@ -1739,6 +1748,12 @@ } ] } + ], + "jsonValidation": [ + { + "fileMatch": "**/.sourcekit-lsp/config.json", + "url": "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json" + } ] }, "extensionDependencies": [ diff --git a/src/commands.ts b/src/commands.ts index 36b3cfb3f..772725020 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -49,6 +49,7 @@ import { openDocumentation } from "./commands/openDocumentation"; import restartLSPServer from "./commands/restartLSPServer"; import { generateLaunchConfigurations } from "./commands/generateLaunchConfigurations"; import { runTest } from "./commands/runTest"; +import { generateSourcekitConfiguration } from "./commands/generateSourcekitConfiguration"; /** * References: @@ -105,6 +106,7 @@ export enum Commands { OPEN_MANIFEST = "swift.openManifest", RESTART_LSP = "swift.restartLSPServer", SELECT_TOOLCHAIN = "swift.selectToolchain", + GENERATE_SOURCEKIT_CONFIG = "swift.generateSourcekitConfiguration", } /** @@ -273,6 +275,10 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] { await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(packagePath)); }), vscode.commands.registerCommand("swift.openDocumentation", () => openDocumentation()), + vscode.commands.registerCommand( + Commands.GENERATE_SOURCEKIT_CONFIG, + async () => await generateSourcekitConfiguration(ctx) + ), ]; } diff --git a/src/commands/generateLaunchConfigurations.ts b/src/commands/generateLaunchConfigurations.ts index d90e623db..59313d810 100644 --- a/src/commands/generateLaunchConfigurations.ts +++ b/src/commands/generateLaunchConfigurations.ts @@ -14,8 +14,8 @@ import { makeDebugConfigurations } from "../debugger/launch"; import { FolderContext } from "../FolderContext"; +import { selectFolder } from "../ui/SelectFolderQuickPick"; import { WorkspaceContext } from "../WorkspaceContext"; -import * as vscode from "vscode"; export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promise { if (ctx.folders.length === 0) { @@ -26,29 +26,14 @@ export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promi return await makeDebugConfigurations(ctx.folders[0], { force: true, yes: true }); } - const quickPickItems: SelectFolderQuickPick[] = ctx.folders.map(folder => ({ - type: "folder", - folder, - label: folder.name, - detail: folder.workspaceFolder.uri.fsPath, - })); - quickPickItems.push({ type: "all", label: "Generate For All Folders" }); - const selection = await vscode.window.showQuickPick(quickPickItems, { - matchOnDetail: true, - placeHolder: "Select a folder to generate launch configurations for", - }); - - if (!selection) { + const foldersToUpdate: FolderContext[] = await selectFolder( + ctx, + "Select a folder to generate launch configurations for" + ); + if (!foldersToUpdate.length) { return false; } - const foldersToUpdate: FolderContext[] = []; - if (selection.type === "all") { - foldersToUpdate.push(...ctx.folders); - } else { - foldersToUpdate.push(selection.folder); - } - return ( await Promise.all( foldersToUpdate.map(folder => @@ -57,14 +42,3 @@ export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promi ) ).reduceRight((prev, curr) => prev || curr); } - -type SelectFolderQuickPick = AllQuickPickItem | FolderQuickPickItem; - -interface AllQuickPickItem extends vscode.QuickPickItem { - type: "all"; -} - -interface FolderQuickPickItem extends vscode.QuickPickItem { - type: "folder"; - folder: FolderContext; -} diff --git a/src/commands/generateSourcekitConfiguration.ts b/src/commands/generateSourcekitConfiguration.ts new file mode 100644 index 000000000..a1d76333f --- /dev/null +++ b/src/commands/generateSourcekitConfiguration.ts @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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 { join } from "path"; +import * as vscode from "vscode"; +import { FolderContext } from "../FolderContext"; +import { selectFolder } from "../ui/SelectFolderQuickPick"; +import { WorkspaceContext } from "../WorkspaceContext"; +import configuration from "../configuration"; + +export async function generateSourcekitConfiguration(ctx: WorkspaceContext): Promise { + if (ctx.folders.length === 0) { + return false; + } + + if (ctx.folders.length === 1) { + const folder = ctx.folders[0]; + const success = await createSourcekitConfiguration(ctx, folder); + void vscode.window.showTextDocument(vscode.Uri.file(sourcekitConfigFilePath(folder))); + return success; + } + + const foldersToGenerate: FolderContext[] = await selectFolder( + ctx, + "Select a folder to generate a SourceKit-LSP configuration for" + ); + if (!foldersToGenerate.length) { + return false; + } + + return ( + await Promise.all( + foldersToGenerate.map(folder => createSourcekitConfiguration(ctx, folder)) + ) + ).reduceRight((prev, curr) => prev || curr); +} + +export const sourcekitFolderPath = (f: FolderContext) => join(f.folder.fsPath, ".sourcekit-lsp"); +export const sourcekitConfigFilePath = (f: FolderContext) => + join(sourcekitFolderPath(f), "config.json"); + +async function createSourcekitConfiguration( + workspaceContext: WorkspaceContext, + folderContext: FolderContext +): Promise { + const sourcekitFolder = vscode.Uri.file(sourcekitFolderPath(folderContext)); + const sourcekitConfigFile = vscode.Uri.file(sourcekitConfigFilePath(folderContext)); + + try { + await vscode.workspace.fs.stat(sourcekitConfigFile); + return true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + workspaceContext.outputChannel.appendLine( + `Failed to read file at ${sourcekitConfigFile.fsPath}: ${error}` + ); + } + // Ignore, don't care if the file doesn't exist yet + } + + try { + const stats = await vscode.workspace.fs.stat(sourcekitFolder); + if (stats.type !== vscode.FileType.Directory) { + void vscode.window.showErrorMessage( + `File ${sourcekitFolder.fsPath} already exists but is not a directory` + ); + return false; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + workspaceContext.outputChannel.appendLine( + `Failed to read folder at ${sourcekitFolder.fsPath}: ${error}` + ); + } + await vscode.workspace.fs.createDirectory(sourcekitFolder); + } + const version = folderContext.toolchain.swiftVersion; + const versionString = `${version.major}.${version.minor}`; + let branch = + configuration.lsp.configurationBranch || + (version.dev ? "main" : `release/${versionString}`); + if (!(await checkURLExists(schemaURL(branch)))) { + branch = "main"; + } + await vscode.workspace.fs.writeFile( + sourcekitConfigFile, + Buffer.from( + JSON.stringify( + { + $schema: schemaURL(branch), + }, + undefined, + 2 + ) + ) + ); + return true; +} + +const schemaURL = (branch: string) => + `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/${branch}/config.schema.json`; + +async function checkURLExists(url: string): Promise { + try { + const response = await fetch(url, { method: "HEAD" }); + return response.ok; + } catch { + return false; + } +} diff --git a/src/configuration.ts b/src/configuration.ts index 01fd705b0..3b1212edc 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -55,6 +55,8 @@ export interface LSPConfiguration { readonly supportedLanguages: string[]; /** Is SourceKit-LSP disabled */ readonly disable: boolean; + /** Configuration branch to use when setting $schema */ + readonly configurationBranch: string; } /** debugger configuration */ @@ -150,6 +152,11 @@ const configuration = { .getConfiguration("swift.sourcekit-lsp") .get("disable", false); }, + get configurationBranch(): string { + return vscode.workspace + .getConfiguration("swift.sourcekit-lsp") + .get("configurationBranch", ""); + }, }; }, diff --git a/src/ui/SelectFolderQuickPick.ts b/src/ui/SelectFolderQuickPick.ts new file mode 100644 index 000000000..ed97d8cf7 --- /dev/null +++ b/src/ui/SelectFolderQuickPick.ts @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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 { WorkspaceContext } from "../WorkspaceContext"; +import * as vscode from "vscode"; +import { FolderContext } from "../FolderContext"; + +type SelectFolderQuickPick = AllQuickPickItem | FolderQuickPickItem; + +interface AllQuickPickItem extends vscode.QuickPickItem { + type: "all"; +} + +interface FolderQuickPickItem extends vscode.QuickPickItem { + type: "folder"; + folder: FolderContext; +} + +/** + * Select a folder from the workspace context + * @param ctx + * @param labels Map "type" to the display label + * @returns The selected folder or undefined if there was no selection + */ +export async function selectFolder( + ctx: WorkspaceContext, + placeHolder: string, + labels: Record = {} +): Promise { + const quickPickItems: SelectFolderQuickPick[] = ctx.folders.map(folder => ({ + type: "folder", + folder, + label: folder.name, + detail: folder.workspaceFolder.uri.fsPath, + })); + quickPickItems.push({ type: "all", label: labels["all"] || "Generate For All Folders" }); + const selection = await vscode.window.showQuickPick(quickPickItems, { + matchOnDetail: true, + placeHolder, + }); + + const folders: FolderContext[] = []; + if (!selection) { + return folders; + } + + if (selection.type === "all") { + folders.push(...ctx.folders); + } else { + folders.push(selection.folder); + } + return folders; +} diff --git a/src/utilities/version.ts b/src/utilities/version.ts index d9214a82f..2209e92c4 100644 --- a/src/utilities/version.ts +++ b/src/utilities/version.ts @@ -22,19 +22,21 @@ export class Version implements VersionInterface { constructor( readonly major: number, readonly minor: number, - readonly patch: number + readonly patch: number, + readonly dev: boolean = false ) {} static fromString(s: string): Version | undefined { - const numbers = s.match(/(\d+).(\d+)(?:.(\d+))?/); + const numbers = s.match(/(\d+).(\d+)(?:.(\d+))?(-dev)?/); if (numbers) { const major = parseInt(numbers[1]); const minor = parseInt(numbers[2]); + const dev = numbers[4] === "-dev"; if (numbers[3] === undefined) { - return new Version(major, minor, 0); + return new Version(major, minor, 0, dev); } else { const patch = parseInt(numbers[3]); - return new Version(major, minor, patch); + return new Version(major, minor, patch, dev); } } return undefined; diff --git a/test/integration-tests/commands/generateSourcekitConfiguration.test.ts b/test/integration-tests/commands/generateSourcekitConfiguration.test.ts new file mode 100644 index 000000000..dd0b9783e --- /dev/null +++ b/test/integration-tests/commands/generateSourcekitConfiguration.test.ts @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the VS Code Swift open source project +// +// Copyright (c) 2025 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 { expect } from "chai"; +import { FolderContext } from "../../../src/FolderContext"; +import { WorkspaceContext } from "../../../src/WorkspaceContext"; +import { Commands } from "../../../src/commands"; +import { + activateExtensionForSuite, + folderInRootWorkspace, + updateSettings, +} from "../utilities/testutilities"; +import { closeAllEditors } from "../../utilities/commands"; +import { + sourcekitConfigFilePath, + sourcekitFolderPath, +} from "../../../src/commands/generateSourcekitConfiguration"; +import { Version } from "../../../src/utilities/version"; + +suite("Generate SourceKit-LSP configuration Command", function () { + let folderContext: FolderContext; + let workspaceContext: WorkspaceContext; + let resetSettings: (() => Promise) | undefined; + + activateExtensionForSuite({ + async setup(ctx) { + workspaceContext = ctx; + folderContext = await folderInRootWorkspace("defaultPackage", workspaceContext); + await workspaceContext.focusFolder(folderContext); + }, + }); + + teardown(async () => { + if (resetSettings) { + await resetSettings(); + } + await vscode.workspace.fs.delete(vscode.Uri.file(sourcekitFolderPath(folderContext)), { + recursive: true, + }); + }); + + suiteTeardown(async () => { + await closeAllEditors(); + }); + + test("Calculates branch based on toolchain", async () => { + const result = await vscode.commands.executeCommand(Commands.GENERATE_SOURCEKIT_CONFIG); + expect(result).to.be.true; + const contents = Buffer.from( + await vscode.workspace.fs.readFile( + vscode.Uri.file(sourcekitConfigFilePath(folderContext)) + ) + ).toString("utf-8"); + const config = JSON.parse(contents); + const version = folderContext.swiftVersion; + let branch: string; + if (folderContext.swiftVersion.isGreaterThanOrEqual(new Version(6, 1, 0))) { + branch = version.dev ? "main" : `release/${version.major}.${version.minor}`; + } else { + branch = "main"; + } + expect(config).to.have.property( + "$schema", + `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/${branch}/config.schema.json` + ); + }); + + test("Uses hardcoded path", async () => { + resetSettings = await updateSettings({ + "swift.sourcekit-lsp.configurationBranch": "release/6.1", + }); + const result = await vscode.commands.executeCommand(Commands.GENERATE_SOURCEKIT_CONFIG); + expect(result).to.be.true; + const contents = Buffer.from( + await vscode.workspace.fs.readFile( + vscode.Uri.file(sourcekitConfigFilePath(folderContext)) + ) + ).toString("utf-8"); + const config = JSON.parse(contents); + expect(config).to.have.property( + "$schema", + `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/release/6.1/config.schema.json` + ); + }); + + test('Fallsback to "main" when path does not exist', async () => { + resetSettings = await updateSettings({ + "swift.sourcekit-lsp.configurationBranch": "totally-invalid-branch", + }); + const result = await vscode.commands.executeCommand(Commands.GENERATE_SOURCEKIT_CONFIG); + expect(result).to.be.true; + const contents = Buffer.from( + await vscode.workspace.fs.readFile( + vscode.Uri.file(sourcekitConfigFilePath(folderContext)) + ) + ).toString("utf-8"); + const config = JSON.parse(contents); + expect(config).to.have.property( + "$schema", + `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json` + ); + }); +}); diff --git a/test/unit-tests/utilities/version.test.ts b/test/unit-tests/utilities/version.test.ts index 3de7f5a27..b930dbe4d 100644 --- a/test/unit-tests/utilities/version.test.ts +++ b/test/unit-tests/utilities/version.test.ts @@ -23,6 +23,7 @@ suite("Version Suite", () => { expect(version?.major).to.equal(5); expect(version?.minor).to.equal(10); expect(version?.patch).to.equal(0); + expect(version?.dev).to.be.false; }); test("parses major.minor.patch", () => { @@ -31,6 +32,16 @@ suite("Version Suite", () => { expect(version?.major).to.equal(5); expect(version?.minor).to.equal(10); expect(version?.patch).to.equal(1); + expect(version?.dev).to.be.false; + }); + + test("parses -dev suffix", () => { + const version = Version.fromString("5.10.1-dev"); + + expect(version?.major).to.equal(5); + expect(version?.minor).to.equal(10); + expect(version?.patch).to.equal(1); + expect(version?.dev).to.be.true; }); test("ignores extra digits", () => { diff --git a/userdocs/userdocs.docc/Articles/Reference/commands.md b/userdocs/userdocs.docc/Articles/Reference/commands.md index 64e137ec8..7dc9c0d53 100644 --- a/userdocs/userdocs.docc/Articles/Reference/commands.md +++ b/userdocs/userdocs.docc/Articles/Reference/commands.md @@ -10,6 +10,7 @@ The Swift extension adds the following commands, each prefixed with `"Swift: "` - **`Create New Project...`** - Create a new Swift project using a template. This opens a dialog to guide you through creating a new project structure. - **`Create New Swift File...`** - Create a new `.swift` file in the current workspace. - **`Select Toolchain...`** - Select the locally installed Swift toolchain (including Xcode toolchains on macOS) that you want to use Swift tools from. +- **`Generate SourceKit-LSP Configuration`** - Generate the `.sourcekit-lsp/config.json` file for the selected project(s). The generated configuration file will be pre-populated with the JSON schema for the version of the Swift toolchain that is being used. Use the `swift.sourcekit-lsp.configurationBranch` setting to pin the SourceKit-LSP branch that the schema comes from. The following command is only available on macOS: