diff --git a/package.json b/package.json index c856dc7fc..df4b71609 100644 --- a/package.json +++ b/package.json @@ -674,6 +674,16 @@ "default": "prompt", "markdownDescription": "Controls whether to open a swift project automatically after creating it.", "scope": "application" + }, + "swift.lspConfigurationBranch": { + "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." + }, + "swift.checkLspConfigurationSchema": { + "type": "boolean", + "default": true, + "markdownDescription": "When opening a .sourckit-lsp/config.json configuration file, whether or not to check if the $schema matches the version of Swift you are using.", + "scope": "machine-overridable" } } }, @@ -749,10 +759,6 @@ "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, diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index e9907c945..c8eb004c5 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -569,9 +569,7 @@ export class WorkspaceContext implements vscode.Disposable { * one of those. If not then it searches up the tree to find the uppermost folder in the * workspace that contains a Package.swift */ - private async getPackageFolder( - url: vscode.Uri - ): Promise { + async getPackageFolder(url: vscode.Uri): Promise { // is editor document in any of the current FolderContexts const folder = this.folders.find(context => { return isPathInsidePath(url.fsPath, context.folder.fsPath); diff --git a/src/commands/generateSourcekitConfiguration.ts b/src/commands/generateSourcekitConfiguration.ts index a1d76333f..3a6582d79 100644 --- a/src/commands/generateSourcekitConfiguration.ts +++ b/src/commands/generateSourcekitConfiguration.ts @@ -12,7 +12,7 @@ // //===----------------------------------------------------------------------===// -import { join } from "path"; +import { basename, dirname, join } from "path"; import * as vscode from "vscode"; import { FolderContext } from "../FolderContext"; import { selectFolder } from "../ui/SelectFolderQuickPick"; @@ -85,26 +85,24 @@ async function createSourcekitConfiguration( } 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 + try { + const url = await determineSchemaURL(folderContext); + await vscode.workspace.fs.writeFile( + sourcekitConfigFile, + Buffer.from( + JSON.stringify( + { + $schema: url, + }, + undefined, + 2 + ) ) - ) - ); + ); + } catch (e) { + void vscode.window.showErrorMessage(`${e}`); + return false; + } return true; } @@ -114,8 +112,105 @@ const schemaURL = (branch: string) => async function checkURLExists(url: string): Promise { try { const response = await fetch(url, { method: "HEAD" }); - return response.ok; + if (response.ok) { + return true; + } else if (response.status !== 404) { + throw new Error(`Received exit code ${response.status} when trying to fetch ${url}`); + } + return false; } catch { return false; } } + +export async function determineSchemaURL(folderContext: FolderContext): Promise { + const version = folderContext.toolchain.swiftVersion; + const versionString = `${version.major}.${version.minor}`; + let branch = + configuration.lspConfigurationBranch || (version.dev ? "main" : `release/${versionString}`); + if (!(await checkURLExists(schemaURL(branch)))) { + branch = "main"; + } + return schemaURL(branch); +} + +async function checkDocumentSchema(doc: vscode.TextDocument, workspaceContext: WorkspaceContext) { + const folder = await workspaceContext.getPackageFolder(doc.uri); + if (!folder) { + return; + } + const folderContext = folder as FolderContext; + if (!folderContext.name) { + return; // Not a FolderContext if no "name" + } + let buffer: Uint8Array; + try { + buffer = await vscode.workspace.fs.readFile(doc.uri); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + workspaceContext.outputChannel.appendLine( + `Failed to read file at ${doc.uri.fsPath}: ${error}` + ); + } + return; + } + let config; + try { + const contents = Buffer.from(buffer).toString("utf-8"); + config = JSON.parse(contents); + } catch (error) { + workspaceContext.outputChannel.appendLine( + `Failed to parse JSON from ${doc.uri.fsPath}: ${error}` + ); + return; + } + const schema = config.$schema; + if (!schema) { + return; + } + const newUrl = await determineSchemaURL(folderContext); + if (newUrl === schema) { + return; + } + const result = await vscode.window.showInformationMessage( + `The $schema property for ${doc.uri.fsPath} is not set to the version of the Swift toolchain that you are using. Would you like to update the $schema property?`, + "Yes", + "No", + "Don't Ask Again" + ); + if (result === "Yes") { + config.$schema = newUrl; + await vscode.workspace.fs.writeFile( + doc.uri, + Buffer.from(JSON.stringify(config, undefined, 2)) + ); + return; + } else if (result === "Don't Ask Again") { + configuration.checkLspConfigurationSchema = false; + return; + } +} + +export async function handleSchemaUpdate( + doc: vscode.TextDocument, + workspaceContext: WorkspaceContext +) { + if ( + !configuration.checkLspConfigurationSchema || + !( + basename(dirname(doc.uri.fsPath)) === ".sourcekit-lsp" && + basename(doc.uri.fsPath) === "config.json" + ) + ) { + return; + } + await checkDocumentSchema(doc, workspaceContext); +} + +export function registerSourceKitSchemaWatcher( + workspaceContext: WorkspaceContext +): vscode.Disposable { + return vscode.workspace.onDidOpenTextDocument(doc => { + void handleSchemaUpdate(doc, workspaceContext); + }); +} diff --git a/src/configuration.ts b/src/configuration.ts index 3b1212edc..e781738d9 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -55,8 +55,6 @@ 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 */ @@ -152,11 +150,6 @@ const configuration = { .getConfiguration("swift.sourcekit-lsp") .get("disable", false); }, - get configurationBranch(): string { - return vscode.workspace - .getConfiguration("swift.sourcekit-lsp") - .get("configurationBranch", ""); - }, }; }, @@ -503,6 +496,22 @@ const configuration = { .getConfiguration("swift") .get>("excludePathsFromActivation", {}); }, + get lspConfigurationBranch(): string { + return vscode.workspace.getConfiguration("swift").get("lspConfigurationBranch", ""); + }, + get checkLspConfigurationSchema(): boolean { + return vscode.workspace + .getConfiguration("swift") + .get("checkLspConfigurationSchema", true); + }, + set checkLspConfigurationSchema(value: boolean) { + void vscode.workspace + .getConfiguration("swift") + .update("checkLspConfigurationSchema", value) + .then(() => { + /* Put in worker queue */ + }); + }, }; const vsCodeVariableRegex = new RegExp(/\$\{(.+?)\}/g); diff --git a/src/extension.ts b/src/extension.ts index 33ef8a0b3..86506cc4f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -36,6 +36,7 @@ import { resolveFolderDependencies } from "./commands/dependencies/resolve"; import { SelectedXcodeWatcher } from "./toolchain/SelectedXcodeWatcher"; import configuration, { handleConfigurationChangeEvent } from "./configuration"; import contextKeys from "./contextKeys"; +import { registerSourceKitSchemaWatcher } from "./commands/generateSourcekitConfiguration"; /** * External API as exposed by the extension. Can be queried by other extensions @@ -136,6 +137,8 @@ export async function activate(context: vscode.ExtensionContext): Promise { ); context.subscriptions.push(TestExplorer.observeFolders(workspaceContext)); + context.subscriptions.push(registerSourceKitSchemaWatcher(workspaceContext)); + // setup workspace context with initial workspace folders void workspaceContext.addWorkspaceFolders(); diff --git a/test/integration-tests/commands/generateSourcekitConfiguration.test.ts b/test/integration-tests/commands/generateSourcekitConfiguration.test.ts index dd0b9783e..745939eed 100644 --- a/test/integration-tests/commands/generateSourcekitConfiguration.test.ts +++ b/test/integration-tests/commands/generateSourcekitConfiguration.test.ts @@ -24,20 +24,32 @@ import { } from "../utilities/testutilities"; import { closeAllEditors } from "../../utilities/commands"; import { + determineSchemaURL, + handleSchemaUpdate, sourcekitConfigFilePath, sourcekitFolderPath, } from "../../../src/commands/generateSourcekitConfiguration"; import { Version } from "../../../src/utilities/version"; +import { mockGlobalObject } from "../../MockUtils"; suite("Generate SourceKit-LSP configuration Command", function () { let folderContext: FolderContext; + let configFileUri: vscode.Uri; let workspaceContext: WorkspaceContext; let resetSettings: (() => Promise) | undefined; + async function getSchema() { + const contents = Buffer.from(await vscode.workspace.fs.readFile(configFileUri)).toString( + "utf-8" + ); + return JSON.parse(contents); + } + activateExtensionForSuite({ async setup(ctx) { workspaceContext = ctx; folderContext = await folderInRootWorkspace("defaultPackage", workspaceContext); + configFileUri = vscode.Uri.file(sourcekitConfigFilePath(folderContext)); await workspaceContext.focusFolder(folderContext); }, }); @@ -58,12 +70,7 @@ suite("Generate SourceKit-LSP configuration Command", function () { 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 config = await getSchema(); const version = folderContext.swiftVersion; let branch: string; if (folderContext.swiftVersion.isGreaterThanOrEqual(new Version(6, 1, 0))) { @@ -79,16 +86,11 @@ suite("Generate SourceKit-LSP configuration Command", function () { test("Uses hardcoded path", async () => { resetSettings = await updateSettings({ - "swift.sourcekit-lsp.configurationBranch": "release/6.1", + "swift.lspConfigurationBranch": "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); + const config = await getSchema(); expect(config).to.have.property( "$schema", `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/release/6.1/config.schema.json` @@ -97,19 +99,86 @@ suite("Generate SourceKit-LSP configuration Command", function () { test('Fallsback to "main" when path does not exist', async () => { resetSettings = await updateSettings({ - "swift.sourcekit-lsp.configurationBranch": "totally-invalid-branch", + "swift.lspConfigurationBranch": "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); + const config = await getSchema(); expect(config).to.have.property( "$schema", `https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json` ); }); + + suite("handleSchemaUpdate", async () => { + const mockWindow = mockGlobalObject(vscode, "window"); + + test("Updates to new schema version", async () => { + await vscode.workspace.fs.writeFile( + configFileUri, + Buffer.from( + JSON.stringify({ + $schema: + "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json", + }) + ) + ); + mockWindow.showInformationMessage.resolves("Yes" as any); + const document = await vscode.workspace.openTextDocument(configFileUri); + + await handleSchemaUpdate(document, workspaceContext); + + const config = await getSchema(); + 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("Schema version still the same", async () => { + await vscode.workspace.fs.writeFile( + configFileUri, + Buffer.from( + JSON.stringify({ + $schema: await determineSchemaURL(folderContext), + }) + ) + ); + mockWindow.showInformationMessage.resolves("Yes" as any); + const document = await vscode.workspace.openTextDocument(configFileUri); + + await handleSchemaUpdate(document, workspaceContext); + + expect(mockWindow.showInformationMessage).to.have.not.been.called; + }); + + test("Don't update schema version", async () => { + await vscode.workspace.fs.writeFile( + configFileUri, + Buffer.from( + JSON.stringify({ + $schema: + "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json", + }) + ) + ); + mockWindow.showInformationMessage.resolves("No" as any); + const document = await vscode.workspace.openTextDocument(configFileUri); + + await handleSchemaUpdate(document, workspaceContext); + + const config = await getSchema(); + expect(config).to.have.property( + "$schema", + "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json" + ); + }); + }); });