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)) 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..e60ed775a --- /dev/null +++ b/test/unit-tests/commands/generateSourcekitConfiguration.test.ts @@ -0,0 +1,235 @@ +//===----------------------------------------------------------------------===// +// +// 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 path from "path"; +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.normalize("/path/to/toolchain/share/sourcekit-lsp/config.schema.json") + ); + }); + + test("returns correct path for toolchain with trailing slash", () => { + mockFolderContext = createMockFolderContext( + path.normalize("/path/to/toolchain/"), + new Version(6, 3, 0) + ); + + const result = localSchemaPath(mockFolderContext); + + 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( + path.normalize("/usr/local/swift-6.3.0"), + new Version(6, 3, 0) + ); + + const result = localSchemaPath(mockFolderContext); + + expect(result).to.equal( + 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.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.normalize("/path/to/toolchain/share/sourcekit-lsp/config.schema.json") + ); + }); + + test("returns false when schema file doesn't exist", async () => { + mockFolderContext = createMockFolderContext( + path.normalize("/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.normalize("/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( + path.normalize("/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.normalize("/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.normalize("/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.normalize("/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"); + }); + }); +});