From 4e46f456addeb04675527fe6441ed4384e7de8f1 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Wed, 3 Dec 2025 11:12:34 -0500 Subject: [PATCH 1/3] Add dynamic local schema detection for SourceKit-LSP Implement support for using local schema files from the Swift toolchain when available, with automatic fallback to remote GitHub schemas for older toolchains. This enables the extension to use the accurate schema for the users toolchain, if its present, while maintaining backward compatibility with older toolchains. --- src/WorkspaceContext.ts | 5 + .../generateSourcekitConfiguration.ts | 32 ++- src/sourcekit-lsp/SourcekitSchemaRegistry.ts | 91 ++++++++ .../generateSourcekitConfiguration.test.ts | 215 ++++++++++++++++++ 4 files changed, 341 insertions(+), 2 deletions(-) create mode 100644 src/sourcekit-lsp/SourcekitSchemaRegistry.ts create mode 100644 test/unit-tests/commands/generateSourcekitConfiguration.test.ts diff --git a/src/WorkspaceContext.ts b/src/WorkspaceContext.ts index 525a5c1dd..7feef65a6 100644 --- a/src/WorkspaceContext.ts +++ b/src/WorkspaceContext.ts @@ -28,6 +28,7 @@ import { CommentCompletionProviders } from "./editor/CommentCompletion"; import { SwiftLogger } from "./logging/SwiftLogger"; import { SwiftLoggerFactory } from "./logging/SwiftLoggerFactory"; import { LanguageClientToolchainCoordinator } from "./sourcekit-lsp/LanguageClientToolchainCoordinator"; +import { SourcekitSchemaRegistry } from "./sourcekit-lsp/SourcekitSchemaRegistry"; import { DocCDocumentationRequest, ReIndexProjectRequest } from "./sourcekit-lsp/extensions"; import { SwiftPluginTaskProvider } from "./tasks/SwiftPluginTaskProvider"; import { SwiftTaskProvider } from "./tasks/SwiftTaskProvider"; @@ -62,6 +63,7 @@ export class WorkspaceContext implements vscode.Disposable { public documentation: DocumentationManager; public testRunManager: TestRunManager; public projectPanel: ProjectPanelProvider; + private sourcekitSchemaRegistry: SourcekitSchemaRegistry; private lastFocusUri: vscode.Uri | undefined; private initialisationFinished = false; @@ -105,6 +107,7 @@ export class WorkspaceContext implements vscode.Disposable { this.currentDocument = null; this.commentCompletionProvider = new CommentCompletionProviders(); this.projectPanel = new ProjectPanelProvider(this); + this.sourcekitSchemaRegistry = new SourcekitSchemaRegistry(this); const onChangeConfig = vscode.workspace.onDidChangeConfiguration(async event => { // Clear build path cache when build-related configurations change @@ -229,6 +232,7 @@ export class WorkspaceContext implements vscode.Disposable { this.statusItem, this.buildStatus, this.projectPanel, + this.sourcekitSchemaRegistry.register(), ]; this.lastFocusUri = vscode.window.activeTextEditor?.document.uri; @@ -244,6 +248,7 @@ export class WorkspaceContext implements vscode.Disposable { } dispose() { + this.sourcekitSchemaRegistry.dispose(); this.folders.forEach(f => f.dispose()); this.folders.length = 0; this.subscriptions.forEach(item => item.dispose()); diff --git a/src/commands/generateSourcekitConfiguration.ts b/src/commands/generateSourcekitConfiguration.ts index 8ea269d29..1d38aae33 100644 --- a/src/commands/generateSourcekitConfiguration.ts +++ b/src/commands/generateSourcekitConfiguration.ts @@ -18,10 +18,11 @@ import { FolderContext } from "../FolderContext"; import { WorkspaceContext } from "../WorkspaceContext"; import configuration from "../configuration"; import { selectFolder } from "../ui/SelectFolderQuickPick"; +import { fileExists } from "../utilities/filesystem"; import restartLSPServer from "./restartLSPServer"; -export const sourcekitDotFolder: string = ".sourcekit-lsp"; -export const sourcekitConfigFileName: string = "config.json"; +const sourcekitDotFolder: string = ".sourcekit-lsp"; +const sourcekitConfigFileName: string = "config.json"; export async function generateSourcekitConfiguration(ctx: WorkspaceContext): Promise { if (ctx.folders.length === 0) { @@ -128,6 +129,13 @@ async function checkURLExists(url: string): Promise { } export async function determineSchemaURL(folderContext: FolderContext): Promise { + // Check if local schema exists first + if (await hasLocalSchema(folderContext)) { + const localPath = localSchemaPath(folderContext); + return vscode.Uri.file(localPath).toString(); + } + + // Fall back to remote URL for older toolchains const version = folderContext.toolchain.swiftVersion; const versionString = `${version.major}.${version.minor}`; let branch = @@ -138,6 +146,26 @@ export async function determineSchemaURL(folderContext: FolderContext): Promise< return schemaURL(branch); } +/** + * Returns the path to the local sourcekit-lsp schema file in the toolchain. + * This file only exists in newer toolchains (approximately Swift 6.3+). + */ +export function localSchemaPath(folderContext: FolderContext): string { + return join( + folderContext.toolchain.toolchainPath, + "share", + "sourcekit-lsp", + "config.schema.json" + ); +} + +/** + * Checks if the local schema file exists in the toolchain. + */ +export async function hasLocalSchema(folderContext: FolderContext): Promise { + return await fileExists(localSchemaPath(folderContext)); +} + async function getValidatedFolderContext( uri: vscode.Uri, workspaceContext: WorkspaceContext diff --git a/src/sourcekit-lsp/SourcekitSchemaRegistry.ts b/src/sourcekit-lsp/SourcekitSchemaRegistry.ts new file mode 100644 index 000000000..640617989 --- /dev/null +++ b/src/sourcekit-lsp/SourcekitSchemaRegistry.ts @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// +// 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 { FolderContext } from "../FolderContext"; +import { WorkspaceContext } from "../WorkspaceContext"; +import { + determineSchemaURL, + sourcekitConfigFilePath, +} from "../commands/generateSourcekitConfiguration"; + +/** + * Manages dynamic JSON schema associations for sourcekit-lsp config files. + * This allows VS Code to provide validation and IntelliSense using the + * appropriate schema (local or remote) based on the toolchain. + */ +export class SourcekitSchemaRegistry { + private disposables: vscode.Disposable[] = []; + + constructor(private workspaceContext: WorkspaceContext) {} + + /** + * Registers event handlers to dynamically configure JSON schemas + * for sourcekit-lsp config documents. + */ + register(): vscode.Disposable { + // Handle documents that are already open + vscode.workspace.textDocuments.forEach(doc => { + void this.configureSchemaForDocument(doc); + }); + + // Handle newly opened documents + const onDidOpenDisposable = vscode.workspace.onDidOpenTextDocument(doc => { + void this.configureSchemaForDocument(doc); + }); + + this.disposables.push(onDidOpenDisposable); + + return vscode.Disposable.from(...this.disposables); + } + + /** + * Configures the JSON schema for a document if it's a sourcekit-lsp config file. + */ + private async configureSchemaForDocument(document: vscode.TextDocument): Promise { + if (document.languageId !== "json") { + return; + } + + const folderContext = await this.getFolderContextForDocument(document); + if (!folderContext) { + return; + } + + const schemaUrl = await determineSchemaURL(folderContext); + + // Use VS Code's JSON language configuration API to associate the schema + await vscode.commands.executeCommand("json.setSchema", document.uri.toString(), schemaUrl); + } + + /** + * Gets the FolderContext for a document if it's a sourcekit-lsp config file. + */ + private async getFolderContextForDocument( + document: vscode.TextDocument + ): Promise { + for (const folderContext of this.workspaceContext.folders) { + const configPath = sourcekitConfigFilePath(folderContext); + if (document.uri.fsPath === configPath) { + return folderContext; + } + } + return null; + } + + dispose() { + this.disposables.forEach(d => d.dispose()); + this.disposables = []; + } +} diff --git a/test/unit-tests/commands/generateSourcekitConfiguration.test.ts b/test/unit-tests/commands/generateSourcekitConfiguration.test.ts new file mode 100644 index 000000000..270f58875 --- /dev/null +++ b/test/unit-tests/commands/generateSourcekitConfiguration.test.ts @@ -0,0 +1,215 @@ +//===----------------------------------------------------------------------===// +// +// 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 { expect } from "chai"; +import * as sinon from "sinon"; +import * as vscode from "vscode"; + +import { FolderContext } from "@src/FolderContext"; +import { + determineSchemaURL, + hasLocalSchema, + localSchemaPath, +} from "@src/commands/generateSourcekitConfiguration"; +import configuration from "@src/configuration"; +import { SwiftToolchain } from "@src/toolchain/toolchain"; +import * as filesystemModule from "@src/utilities/filesystem"; +import { Version } from "@src/utilities/version"; + +import { mockGlobalModule } from "../../MockUtils"; + +suite("generateSourcekitConfiguration - Schema Detection", () => { + let sandbox: sinon.SinonSandbox; + let mockFolderContext: FolderContext; + let fileExistsStub: sinon.SinonStub; + const mockedConfiguration = mockGlobalModule(configuration); + + function createMockFolderContext(toolchainPath: string, version: Version): FolderContext { + const mockToolchain = { + toolchainPath, + swiftVersion: version, + } as SwiftToolchain; + + return { + folder: vscode.Uri.file("/test/workspace"), + swiftVersion: version, + toolchain: mockToolchain, + name: "TestFolder", + } as FolderContext; + } + + setup(() => { + sandbox = sinon.createSandbox(); + fileExistsStub = sandbox.stub(filesystemModule, "fileExists"); + mockedConfiguration.lspConfigurationBranch = ""; + }); + + teardown(() => { + sandbox.restore(); + }); + + suite("localSchemaPath()", () => { + test("returns correct path for toolchain", () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 3, 0)); + + const result = localSchemaPath(mockFolderContext); + + expect(result).to.equal("/path/to/toolchain/share/sourcekit-lsp/config.schema.json"); + }); + + test("returns correct path for toolchain with trailing slash", () => { + mockFolderContext = createMockFolderContext( + "/path/to/toolchain/", + new Version(6, 3, 0) + ); + + const result = localSchemaPath(mockFolderContext); + + expect(result).to.equal("/path/to/toolchain/share/sourcekit-lsp/config.schema.json"); + }); + + test("returns correct path for nested toolchain directory", () => { + mockFolderContext = createMockFolderContext( + "/usr/local/swift-6.3.0", + new Version(6, 3, 0) + ); + + const result = localSchemaPath(mockFolderContext); + + expect(result).to.equal( + "/usr/local/swift-6.3.0/share/sourcekit-lsp/config.schema.json" + ); + }); + }); + + suite("hasLocalSchema()", () => { + test("returns true when schema file exists", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 3, 0)); + fileExistsStub.resolves(true); + + const result = await hasLocalSchema(mockFolderContext); + + expect(result).to.be.true; + expect(fileExistsStub).to.have.been.calledWith( + "/path/to/toolchain/share/sourcekit-lsp/config.schema.json" + ); + }); + + test("returns false when schema file doesn't exist", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 1, 0)); + fileExistsStub.resolves(false); + + const result = await hasLocalSchema(mockFolderContext); + + expect(result).to.be.false; + }); + + test("returns false on filesystem errors", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 3, 0)); + fileExistsStub.resolves(false); + + const result = await hasLocalSchema(mockFolderContext); + + expect(result).to.be.false; + }); + }); + + suite("determineSchemaURL()", () => { + test("returns file:// URL when local schema exists", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 3, 0)); + fileExistsStub.resolves(true); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.match(/^file:\/\//); + expect(result).to.include("config.schema.json"); + expect(result).to.include("sourcekit-lsp"); + }); + + test("returns https:// URL when local schema doesn't exist", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 1, 0)); + fileExistsStub.resolves(false); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.match(/^https:\/\//); + expect(result).to.include("githubusercontent.com"); + expect(result).to.include("sourcekit-lsp"); + }); + + test("local schema path includes share/sourcekit-lsp/config.schema.json", async () => { + mockFolderContext = createMockFolderContext( + "/usr/local/swift-6.3", + new Version(6, 3, 0) + ); + fileExistsStub.resolves(true); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.include("/usr/local/swift-6.3"); + expect(result).to.include("share/sourcekit-lsp/config.schema.json"); + }); + + test("remote URL uses correct branch for release version", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 2, 0)); + fileExistsStub.resolves(false); + + const fetchStub = sandbox.stub(globalThis, "fetch"); + fetchStub.resolves({ + ok: true, + status: 200, + } as Response); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.include("release/6.2"); + }); + + test("remote URL uses main for dev version", async () => { + mockFolderContext = createMockFolderContext( + "/path/to/toolchain", + new Version(6, 3, 0, true) + ); + fileExistsStub.resolves(false); + + const fetchStub = sandbox.stub(globalThis, "fetch"); + fetchStub.resolves({ + ok: true, + status: 200, + } as Response); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.include("main"); + }); + + test("falls back to main when branch doesn't exist", async () => { + mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(5, 9, 0)); + fileExistsStub.resolves(false); + + const fetchStub = sandbox.stub(globalThis, "fetch"); + fetchStub.onFirstCall().resolves({ + ok: false, + status: 404, + } as Response); + fetchStub.onSecondCall().resolves({ + ok: true, + status: 200, + } as Response); + + const result = await determineSchemaURL(mockFolderContext); + + expect(result).to.include("main"); + }); + }); +}); From c3e5737d679f6d0459dfb5e4a7ebff3a039cfc48 Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Wed, 3 Dec 2025 12:17:45 -0500 Subject: [PATCH 2/3] Normalize paths in tests --- .../generateSourcekitConfiguration.test.ts | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) diff --git a/test/unit-tests/commands/generateSourcekitConfiguration.test.ts b/test/unit-tests/commands/generateSourcekitConfiguration.test.ts index 270f58875..e60ed775a 100644 --- a/test/unit-tests/commands/generateSourcekitConfiguration.test.ts +++ b/test/unit-tests/commands/generateSourcekitConfiguration.test.ts @@ -12,6 +12,7 @@ // //===----------------------------------------------------------------------===// import { expect } from "chai"; +import * as path from "path"; import * as sinon from "sinon"; import * as vscode from "vscode"; @@ -64,49 +65,59 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { const result = localSchemaPath(mockFolderContext); - expect(result).to.equal("/path/to/toolchain/share/sourcekit-lsp/config.schema.json"); + expect(result).to.equal( + path.normalize("/path/to/toolchain/share/sourcekit-lsp/config.schema.json") + ); }); test("returns correct path for toolchain with trailing slash", () => { mockFolderContext = createMockFolderContext( - "/path/to/toolchain/", + path.normalize("/path/to/toolchain/"), new Version(6, 3, 0) ); const result = localSchemaPath(mockFolderContext); - expect(result).to.equal("/path/to/toolchain/share/sourcekit-lsp/config.schema.json"); + expect(result).to.equal( + path.normalize("/path/to/toolchain/share/sourcekit-lsp/config.schema.json") + ); }); test("returns correct path for nested toolchain directory", () => { mockFolderContext = createMockFolderContext( - "/usr/local/swift-6.3.0", + path.normalize("/usr/local/swift-6.3.0"), new Version(6, 3, 0) ); const result = localSchemaPath(mockFolderContext); expect(result).to.equal( - "/usr/local/swift-6.3.0/share/sourcekit-lsp/config.schema.json" + path.normalize("/usr/local/swift-6.3.0/share/sourcekit-lsp/config.schema.json") ); }); }); suite("hasLocalSchema()", () => { test("returns true when schema file exists", async () => { - mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 3, 0)); + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain"), + new Version(6, 3, 0) + ); fileExistsStub.resolves(true); const result = await hasLocalSchema(mockFolderContext); expect(result).to.be.true; expect(fileExistsStub).to.have.been.calledWith( - "/path/to/toolchain/share/sourcekit-lsp/config.schema.json" + path.normalize("/path/to/toolchain/share/sourcekit-lsp/config.schema.json") ); }); test("returns false when schema file doesn't exist", async () => { - mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 1, 0)); + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain"), + new Version(6, 1, 0) + ); fileExistsStub.resolves(false); const result = await hasLocalSchema(mockFolderContext); @@ -137,7 +148,10 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { }); test("returns https:// URL when local schema doesn't exist", async () => { - mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 1, 0)); + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain"), + new Version(6, 1, 0) + ); fileExistsStub.resolves(false); const result = await determineSchemaURL(mockFolderContext); @@ -149,7 +163,7 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { test("local schema path includes share/sourcekit-lsp/config.schema.json", async () => { mockFolderContext = createMockFolderContext( - "/usr/local/swift-6.3", + path.normalize("/usr/local/swift-6.3"), new Version(6, 3, 0) ); fileExistsStub.resolves(true); @@ -161,7 +175,10 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { }); test("remote URL uses correct branch for release version", async () => { - mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(6, 2, 0)); + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain"), + new Version(6, 2, 0) + ); fileExistsStub.resolves(false); const fetchStub = sandbox.stub(globalThis, "fetch"); @@ -177,7 +194,7 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { test("remote URL uses main for dev version", async () => { mockFolderContext = createMockFolderContext( - "/path/to/toolchain", + path.normalize("/path/to/toolchain"), new Version(6, 3, 0, true) ); fileExistsStub.resolves(false); @@ -194,7 +211,10 @@ suite("generateSourcekitConfiguration - Schema Detection", () => { }); test("falls back to main when branch doesn't exist", async () => { - mockFolderContext = createMockFolderContext("/path/to/toolchain", new Version(5, 9, 0)); + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain"), + new Version(5, 9, 0) + ); fileExistsStub.resolves(false); const fetchStub = sandbox.stub(globalThis, "fetch"); From 85d5132ed277fde61ec883ebfbf177a2310b155b Mon Sep 17 00:00:00 2001 From: Paul LeMarquand Date: Wed, 3 Dec 2025 15:11:27 -0500 Subject: [PATCH 3/3] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c23e8dd4f..7b9f03b82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## {{releaseVersion}} - {{releaseDate}} +### Added + +- When editing .sourcekit-lsp/config.json use the JSON schema from the toolchain ([#1979](https://github.com/swiftlang/vscode-swift/pull/1979)) + ### Fixed - Fix extension failing to activate when Swiftly was installed via Homebrew ([#1975](https://github.com/swiftlang/vscode-swift/pull/1975))