Skip to content

Commit 9f874ec

Browse files
authored
Add a new Generate SourceKit-LSP Configuration command (#1726)
* Add a new `Generate SourceKit-LSP Configuration` command * Add CHANGELOG entry * Add documentation * Fix failing test * Fix review comment
1 parent 58ad058 commit 9f874ec

File tree

11 files changed

+353
-36
lines changed

11 files changed

+353
-36
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Added Swiftly toolchain management support `.swift-version` files, and integration with the toolchain selection UI ([#1717](https://github.com/swiftlang/vscode-swift/pull/1717)
88
- Added code lenses to run suites/tests, configurable with the `swift.showTestCodeLenses` setting ([#1698](https://github.com/swiftlang/vscode-swift/pull/1698))
99
- New `swift.excludePathsFromActivation` setting to ignore specified sub-folders from being activated as projects ([#1693](https://github.com/swiftlang/vscode-swift/pull/1693))
10+
- 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))
1011

1112
### Fixed
1213

package.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,11 @@
332332
"title": "Open Documentation",
333333
"category": "Swift",
334334
"icon": "$(book)"
335+
},
336+
{
337+
"command": "swift.generateSourcekitConfiguration",
338+
"title": "Generate SourceKit-LSP Configuration",
339+
"category": "Swift"
335340
}
336341
],
337342
"configuration": [
@@ -744,6 +749,10 @@
744749
"order": 6,
745750
"scope": "machine-overridable"
746751
},
752+
"swift.sourcekit-lsp.configurationBranch": {
753+
"type": "string",
754+
"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."
755+
},
747756
"sourcekit-lsp.inlayHints.enabled": {
748757
"type": "boolean",
749758
"default": true,
@@ -1739,6 +1748,12 @@
17391748
}
17401749
]
17411750
}
1751+
],
1752+
"jsonValidation": [
1753+
{
1754+
"fileMatch": "**/.sourcekit-lsp/config.json",
1755+
"url": "https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/main/config.schema.json"
1756+
}
17421757
]
17431758
},
17441759
"extensionDependencies": [

src/commands.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { openDocumentation } from "./commands/openDocumentation";
4949
import restartLSPServer from "./commands/restartLSPServer";
5050
import { generateLaunchConfigurations } from "./commands/generateLaunchConfigurations";
5151
import { runTest } from "./commands/runTest";
52+
import { generateSourcekitConfiguration } from "./commands/generateSourcekitConfiguration";
5253

5354
/**
5455
* References:
@@ -105,6 +106,7 @@ export enum Commands {
105106
OPEN_MANIFEST = "swift.openManifest",
106107
RESTART_LSP = "swift.restartLSPServer",
107108
SELECT_TOOLCHAIN = "swift.selectToolchain",
109+
GENERATE_SOURCEKIT_CONFIG = "swift.generateSourcekitConfiguration",
108110
}
109111

110112
/**
@@ -273,6 +275,10 @@ export function register(ctx: WorkspaceContext): vscode.Disposable[] {
273275
await vscode.commands.executeCommand("vscode.open", vscode.Uri.file(packagePath));
274276
}),
275277
vscode.commands.registerCommand("swift.openDocumentation", () => openDocumentation()),
278+
vscode.commands.registerCommand(
279+
Commands.GENERATE_SOURCEKIT_CONFIG,
280+
async () => await generateSourcekitConfiguration(ctx)
281+
),
276282
];
277283
}
278284

src/commands/generateLaunchConfigurations.ts

Lines changed: 6 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414

1515
import { makeDebugConfigurations } from "../debugger/launch";
1616
import { FolderContext } from "../FolderContext";
17+
import { selectFolder } from "../ui/SelectFolderQuickPick";
1718
import { WorkspaceContext } from "../WorkspaceContext";
18-
import * as vscode from "vscode";
1919

2020
export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promise<boolean> {
2121
if (ctx.folders.length === 0) {
@@ -26,29 +26,14 @@ export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promi
2626
return await makeDebugConfigurations(ctx.folders[0], { force: true, yes: true });
2727
}
2828

29-
const quickPickItems: SelectFolderQuickPick[] = ctx.folders.map(folder => ({
30-
type: "folder",
31-
folder,
32-
label: folder.name,
33-
detail: folder.workspaceFolder.uri.fsPath,
34-
}));
35-
quickPickItems.push({ type: "all", label: "Generate For All Folders" });
36-
const selection = await vscode.window.showQuickPick(quickPickItems, {
37-
matchOnDetail: true,
38-
placeHolder: "Select a folder to generate launch configurations for",
39-
});
40-
41-
if (!selection) {
29+
const foldersToUpdate: FolderContext[] = await selectFolder(
30+
ctx,
31+
"Select a folder to generate launch configurations for"
32+
);
33+
if (!foldersToUpdate.length) {
4234
return false;
4335
}
4436

45-
const foldersToUpdate: FolderContext[] = [];
46-
if (selection.type === "all") {
47-
foldersToUpdate.push(...ctx.folders);
48-
} else {
49-
foldersToUpdate.push(selection.folder);
50-
}
51-
5237
return (
5338
await Promise.all(
5439
foldersToUpdate.map(folder =>
@@ -57,14 +42,3 @@ export async function generateLaunchConfigurations(ctx: WorkspaceContext): Promi
5742
)
5843
).reduceRight((prev, curr) => prev || curr);
5944
}
60-
61-
type SelectFolderQuickPick = AllQuickPickItem | FolderQuickPickItem;
62-
63-
interface AllQuickPickItem extends vscode.QuickPickItem {
64-
type: "all";
65-
}
66-
67-
interface FolderQuickPickItem extends vscode.QuickPickItem {
68-
type: "folder";
69-
folder: FolderContext;
70-
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import { join } from "path";
16+
import * as vscode from "vscode";
17+
import { FolderContext } from "../FolderContext";
18+
import { selectFolder } from "../ui/SelectFolderQuickPick";
19+
import { WorkspaceContext } from "../WorkspaceContext";
20+
import configuration from "../configuration";
21+
22+
export async function generateSourcekitConfiguration(ctx: WorkspaceContext): Promise<boolean> {
23+
if (ctx.folders.length === 0) {
24+
return false;
25+
}
26+
27+
if (ctx.folders.length === 1) {
28+
const folder = ctx.folders[0];
29+
const success = await createSourcekitConfiguration(ctx, folder);
30+
void vscode.window.showTextDocument(vscode.Uri.file(sourcekitConfigFilePath(folder)));
31+
return success;
32+
}
33+
34+
const foldersToGenerate: FolderContext[] = await selectFolder(
35+
ctx,
36+
"Select a folder to generate a SourceKit-LSP configuration for"
37+
);
38+
if (!foldersToGenerate.length) {
39+
return false;
40+
}
41+
42+
return (
43+
await Promise.all(
44+
foldersToGenerate.map(folder => createSourcekitConfiguration(ctx, folder))
45+
)
46+
).reduceRight((prev, curr) => prev || curr);
47+
}
48+
49+
export const sourcekitFolderPath = (f: FolderContext) => join(f.folder.fsPath, ".sourcekit-lsp");
50+
export const sourcekitConfigFilePath = (f: FolderContext) =>
51+
join(sourcekitFolderPath(f), "config.json");
52+
53+
async function createSourcekitConfiguration(
54+
workspaceContext: WorkspaceContext,
55+
folderContext: FolderContext
56+
): Promise<boolean> {
57+
const sourcekitFolder = vscode.Uri.file(sourcekitFolderPath(folderContext));
58+
const sourcekitConfigFile = vscode.Uri.file(sourcekitConfigFilePath(folderContext));
59+
60+
try {
61+
await vscode.workspace.fs.stat(sourcekitConfigFile);
62+
return true;
63+
} catch (error) {
64+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
65+
workspaceContext.outputChannel.appendLine(
66+
`Failed to read file at ${sourcekitConfigFile.fsPath}: ${error}`
67+
);
68+
}
69+
// Ignore, don't care if the file doesn't exist yet
70+
}
71+
72+
try {
73+
const stats = await vscode.workspace.fs.stat(sourcekitFolder);
74+
if (stats.type !== vscode.FileType.Directory) {
75+
void vscode.window.showErrorMessage(
76+
`File ${sourcekitFolder.fsPath} already exists but is not a directory`
77+
);
78+
return false;
79+
}
80+
} catch (error) {
81+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
82+
workspaceContext.outputChannel.appendLine(
83+
`Failed to read folder at ${sourcekitFolder.fsPath}: ${error}`
84+
);
85+
}
86+
await vscode.workspace.fs.createDirectory(sourcekitFolder);
87+
}
88+
const version = folderContext.toolchain.swiftVersion;
89+
const versionString = `${version.major}.${version.minor}`;
90+
let branch =
91+
configuration.lsp.configurationBranch ||
92+
(version.dev ? "main" : `release/${versionString}`);
93+
if (!(await checkURLExists(schemaURL(branch)))) {
94+
branch = "main";
95+
}
96+
await vscode.workspace.fs.writeFile(
97+
sourcekitConfigFile,
98+
Buffer.from(
99+
JSON.stringify(
100+
{
101+
$schema: schemaURL(branch),
102+
},
103+
undefined,
104+
2
105+
)
106+
)
107+
);
108+
return true;
109+
}
110+
111+
const schemaURL = (branch: string) =>
112+
`https://raw.githubusercontent.com/swiftlang/sourcekit-lsp/refs/heads/${branch}/config.schema.json`;
113+
114+
async function checkURLExists(url: string): Promise<boolean> {
115+
try {
116+
const response = await fetch(url, { method: "HEAD" });
117+
return response.ok;
118+
} catch {
119+
return false;
120+
}
121+
}

src/configuration.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@ export interface LSPConfiguration {
5555
readonly supportedLanguages: string[];
5656
/** Is SourceKit-LSP disabled */
5757
readonly disable: boolean;
58+
/** Configuration branch to use when setting $schema */
59+
readonly configurationBranch: string;
5860
}
5961

6062
/** debugger configuration */
@@ -150,6 +152,11 @@ const configuration = {
150152
.getConfiguration("swift.sourcekit-lsp")
151153
.get<boolean>("disable", false);
152154
},
155+
get configurationBranch(): string {
156+
return vscode.workspace
157+
.getConfiguration("swift.sourcekit-lsp")
158+
.get<string>("configurationBranch", "");
159+
},
153160
};
154161
},
155162

src/ui/SelectFolderQuickPick.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the VS Code Swift open source project
4+
//
5+
// Copyright (c) 2025 the VS Code Swift project authors
6+
// Licensed under Apache License v2.0
7+
//
8+
// See LICENSE.txt for license information
9+
// See CONTRIBUTORS.txt for the list of VS Code Swift project authors
10+
//
11+
// SPDX-License-Identifier: Apache-2.0
12+
//
13+
//===----------------------------------------------------------------------===//
14+
15+
import { WorkspaceContext } from "../WorkspaceContext";
16+
import * as vscode from "vscode";
17+
import { FolderContext } from "../FolderContext";
18+
19+
type SelectFolderQuickPick = AllQuickPickItem | FolderQuickPickItem;
20+
21+
interface AllQuickPickItem extends vscode.QuickPickItem {
22+
type: "all";
23+
}
24+
25+
interface FolderQuickPickItem extends vscode.QuickPickItem {
26+
type: "folder";
27+
folder: FolderContext;
28+
}
29+
30+
/**
31+
* Select a folder from the workspace context
32+
* @param ctx
33+
* @param labels Map "type" to the display label
34+
* @returns The selected folder or undefined if there was no selection
35+
*/
36+
export async function selectFolder(
37+
ctx: WorkspaceContext,
38+
placeHolder: string,
39+
labels: Record<string, string> = {}
40+
): Promise<FolderContext[]> {
41+
const quickPickItems: SelectFolderQuickPick[] = ctx.folders.map(folder => ({
42+
type: "folder",
43+
folder,
44+
label: folder.name,
45+
detail: folder.workspaceFolder.uri.fsPath,
46+
}));
47+
quickPickItems.push({ type: "all", label: labels["all"] || "Generate For All Folders" });
48+
const selection = await vscode.window.showQuickPick(quickPickItems, {
49+
matchOnDetail: true,
50+
placeHolder,
51+
});
52+
53+
const folders: FolderContext[] = [];
54+
if (!selection) {
55+
return folders;
56+
}
57+
58+
if (selection.type === "all") {
59+
folders.push(...ctx.folders);
60+
} else {
61+
folders.push(selection.folder);
62+
}
63+
return folders;
64+
}

src/utilities/version.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,19 +22,21 @@ export class Version implements VersionInterface {
2222
constructor(
2323
readonly major: number,
2424
readonly minor: number,
25-
readonly patch: number
25+
readonly patch: number,
26+
readonly dev: boolean = false
2627
) {}
2728

2829
static fromString(s: string): Version | undefined {
29-
const numbers = s.match(/(\d+).(\d+)(?:.(\d+))?/);
30+
const numbers = s.match(/(\d+).(\d+)(?:.(\d+))?(-dev)?/);
3031
if (numbers) {
3132
const major = parseInt(numbers[1]);
3233
const minor = parseInt(numbers[2]);
34+
const dev = numbers[4] === "-dev";
3335
if (numbers[3] === undefined) {
34-
return new Version(major, minor, 0);
36+
return new Version(major, minor, 0, dev);
3537
} else {
3638
const patch = parseInt(numbers[3]);
37-
return new Version(major, minor, patch);
39+
return new Version(major, minor, patch, dev);
3840
}
3941
}
4042
return undefined;

0 commit comments

Comments
 (0)