diff --git a/.vscodeignore b/.vscodeignore index 91b64a4..252cb12 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,8 +1,11 @@ +.github/** .vscode/** .vscode-test/** out/test/** src/** .gitignore +.prettierignore +eslint.config.mjs **/tsconfig.json **/tslint.json **/*.map diff --git a/package-lock.json b/package-lock.json index cbc0243..d1b8a85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,12 +11,14 @@ "dependencies": { "axios": "^1.7.4", "camelcase": "^7.0.1", + "libsodium-wrappers": "^0.7.15", "lodash-es": "^4.17.21", "semver": "^7.5.2", "vscode-languageclient": "8.0.2-next.5", "which": "^3.0.0" }, "devDependencies": { + "@types/libsodium-wrappers": "^0.7.14", "@types/lodash-es": "^4.17.12", "@types/mocha": "^2.2.48", "@types/node": "^18.0.0", @@ -183,6 +185,13 @@ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz", + "integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/lodash": { "version": "4.17.0", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.0.tgz", @@ -2039,6 +2048,21 @@ "node": ">= 0.8.0" } }, + "node_modules/libsodium": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium/-/libsodium-0.7.15.tgz", + "integrity": "sha512-sZwRknt/tUpE2AwzHq3jEyUU5uvIZHtSssktXq7owd++3CSgn8RGrv6UZJJBpP7+iBghBqe7Z06/2M31rI2NKw==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/libsodium-wrappers/-/libsodium-wrappers-0.7.15.tgz", + "integrity": "sha512-E4anqJQwcfiC6+Yrl01C1m8p99wEhLmJSs0VQqST66SbQXXBoaJY0pF4BNjRYa/sOQAxx6lXAaAFIlx+15tXJQ==", + "license": "ISC", + "dependencies": { + "libsodium": "^0.7.15" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", diff --git a/package.json b/package.json index 9f32a8d..54e218d 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,9 @@ ".zig", ".zon" ], + "aliases": [ + "Zig" + ], "configuration": "./language-configuration.json" } ], @@ -68,11 +71,6 @@ "type": "object", "title": "Zig", "properties": { - "zig.initialSetupDone": { - "type": "boolean", - "default": false, - "description": "Has the initial setup been done yet?" - }, "zig.buildOnSave": { "type": "boolean", "default": false, @@ -105,13 +103,12 @@ "zig.path": { "scope": "machine-overridable", "type": "string", - "description": "Set a custom path to the Zig binary. The string \"zig\" means lookup zig in PATH." + "description": "Set a custom path to the `zig` executable. Example: `C:/zig-windows-x86_64-0.13.0/zig.exe`. The string \"zig\" means lookup zig in PATH." }, - "zig.checkForUpdate": { + "zig.version": { "scope": "resource", - "type": "boolean", - "description": "Whether to automatically check for new updates", - "default": true + "type": "string", + "description": "Specify which Zig version should be installed. Takes priority over a `.zigversion` file or a `build.zig.zon` with `minimum_zig_version`." }, "zig.formattingProvider": { "scope": "resource", @@ -150,16 +147,21 @@ ], "default": "off" }, - "zig.zls.checkForUpdate": { + "zig.zls.enabled": { "scope": "resource", - "type": "boolean", - "description": "Whether to automatically check for new updates", - "default": true + "type": "string", + "description": "Whether to enable the optional ZLS Language Server", + "enum": [ + "ask", + "off", + "on" + ], + "default": "ask" }, "zig.zls.path": { "scope": "machine-overridable", "type": "string", - "description": "Path to `zls` executable. Example: `C:/zls/zig-cache/bin/zls.exe`. The string \"zls\" means lookup ZLS in PATH.", + "description": "Set a custom path to the `zls` executable. Example: `C:/zls/zig-cache/bin/zls.exe`. The string \"zls\" means lookup ZLS in PATH.", "format": "path" }, "zig.zls.enableSnippets": { @@ -336,28 +338,18 @@ "category": "Zig Setup" }, { - "command": "zig.update", - "title": "Check for Zig Updates", - "category": "Zig Setup" - }, - { - "command": "zig.zls.install", - "title": "Install Server", + "command": "zig.zls.enable", + "title": "Enable Language Server", "category": "Zig Language Server" }, { "command": "zig.zls.startRestart", - "title": "Start / Restart Server", + "title": "Start / Restart Language Server", "category": "Zig Language Server" }, { "command": "zig.zls.stop", - "title": "Stop Server", - "category": "Zig Language Server" - }, - { - "command": "zig.zls.update", - "title": "Check for Server Updates", + "title": "Stop Language Server", "category": "Zig Language Server" } ], @@ -379,23 +371,25 @@ "lint": "eslint ." }, "devDependencies": { + "@types/libsodium-wrappers": "^0.7.14", "@types/lodash-es": "^4.17.12", "@types/mocha": "^2.2.48", "@types/node": "^18.0.0", "@types/vscode": "^1.80.0", "@types/which": "^2.0.1", + "@vscode/test-electron": "^2.3.9", "@vscode/vsce": "^2.24.0", "esbuild": "^0.12.1", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "prettier": "3.2.5", "typescript": "^5.4.3", - "typescript-eslint": "^7.4.0", - "@vscode/test-electron": "^2.3.9" + "typescript-eslint": "^7.4.0" }, "dependencies": { "axios": "^1.7.4", "camelcase": "^7.0.1", + "libsodium-wrappers": "^0.7.15", "lodash-es": "^4.17.21", "semver": "^7.5.2", "vscode-languageclient": "8.0.2-next.5", diff --git a/src/extension.ts b/src/extension.ts index 07667ed..ba161e6 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,15 +1,15 @@ import vscode from "vscode"; import { activate as activateZls, deactivate as deactivateZls } from "./zls"; -import ZigCompilerProvider from "./zigCompilerProvider"; +import { deactivate as deactivateSetupZig, setupZig } from "./zigSetup"; +import ZigDiagnosticsProvider from "./zigDiagnosticsProvider"; import ZigMainCodeLensProvider from "./zigMainCodeLens"; import ZigTestRunnerProvider from "./zigTestRunnerProvider"; import { registerDocumentFormatting } from "./zigFormat"; -import { setupZig } from "./zigSetup"; export async function activate(context: vscode.ExtensionContext) { await setupZig(context).finally(() => { - const compiler = new ZigCompilerProvider(); + const compiler = new ZigDiagnosticsProvider(); compiler.activate(context.subscriptions); context.subscriptions.push(registerDocumentFormatting()); @@ -31,4 +31,5 @@ export async function activate(context: vscode.ExtensionContext) { export async function deactivate() { await deactivateZls(); + await deactivateSetupZig(); } diff --git a/src/minisign.ts b/src/minisign.ts new file mode 100644 index 0000000..eadad8a --- /dev/null +++ b/src/minisign.ts @@ -0,0 +1,92 @@ +/** + * Ported from: https://github.com/mlugg/setup-zig/blob/main/main.js (MIT) + */ + +import sodium from "libsodium-wrappers"; + +export interface Key { + id: Buffer; + key: Buffer; +} + +// Parse a minisign key represented as a base64 string. +// Throws exceptions on invalid keys. +export function parseKey(keyString: string): Key { + const keyInfo = Buffer.from(keyString, "base64"); + + const id = keyInfo.subarray(2, 10); + const key = keyInfo.subarray(10); + + if (key.byteLength !== sodium.crypto_sign_PUBLICKEYBYTES) { + throw new Error("invalid public key given"); + } + + return { + id: id, + key: key, + }; +} + +export interface Signature { + algorithm: Buffer; + keyID: Buffer; + signature: Buffer; +} + +// Parse a buffer containing the contents of a minisign signature file. +// Throws exceptions on invalid signature files. +export function parseSignature(sigBuf: Buffer): Signature { + const untrustedHeader = Buffer.from("untrusted comment: "); + + // Validate untrusted comment header, and skip + if (!sigBuf.subarray(0, untrustedHeader.byteLength).equals(untrustedHeader)) { + throw new Error("file format not recognised"); + } + sigBuf = sigBuf.subarray(untrustedHeader.byteLength); + + // Skip untrusted comment + sigBuf = sigBuf.subarray(sigBuf.indexOf("\n") + 1); + + // Read and skip signature info + const sigInfoEnd = sigBuf.indexOf("\n"); + const sigInfo = Buffer.from(sigBuf.subarray(0, sigInfoEnd).toString(), "base64"); + sigBuf = sigBuf.subarray(sigInfoEnd + 1); + + // Extract components of signature info + const algorithm = sigInfo.subarray(0, 2); + const keyID = sigInfo.subarray(2, 10); + const signature = sigInfo.subarray(10); + + // We don't look at the trusted comment or global signature, so we're done. + + return { + algorithm: algorithm, + keyID: keyID, + signature: signature, + }; +} + +// Given a parsed key, parsed signature file, and raw file content, verifies the +// signature. Does not throw. Returns 'true' if the signature is valid for this +// file, 'false' otherwise. +export function verifySignature(pubkey: Key, signature: Signature, fileContent: Buffer) { + let signedContent; + if (signature.algorithm.equals(Buffer.from("ED"))) { + signedContent = sodium.crypto_generichash(sodium.crypto_generichash_BYTES_MAX, fileContent); + } else { + signedContent = fileContent; + } + + if (!signature.keyID.equals(pubkey.id)) { + return false; + } + + if (!sodium.crypto_sign_verify_detached(signature.signature, signedContent, pubkey.key)) { + return false; + } + + // Since we don't use the trusted comment, we don't bother verifying the global signature. + // If we were to start using the trusted comment for any purpose, we must add this. + + return true; +} diff --git a/src/versionManager.ts b/src/versionManager.ts new file mode 100644 index 0000000..9bfd751 --- /dev/null +++ b/src/versionManager.ts @@ -0,0 +1,284 @@ +/** + * A version manager for Zig and ZLS. + * + * Expects a provider that follows the following scheme: + * `${PROVIDER_URL}/${NAME}-${OS}-${ARCH}-${VERSION}.${FILE_EXTENSION}` + * + * Example: + * - `https://ziglang.org/download/0.13.0/zig-windows-x86_64-0.13.0.zip` + * - `https://builds.zigtools.org/zls-linux-x86_64-0.13.0.tar.xz` + */ + +import vscode from "vscode"; + +import childProcess from "child_process"; +import fs from "fs"; +import util from "util"; +import which from "which"; + +import axios from "axios"; +import semver from "semver"; + +import * as minisign from "./minisign"; +import { getVersion, getZigArchName, getZigOSName } from "./zigUtil"; + +const execFile = util.promisify(childProcess.execFile); +const chmod = util.promisify(fs.chmod); + +/** The maxmimum number of installation that can be store until they will be removed */ +const maxInstallCount = 5; + +export interface Config { + context: vscode.ExtensionContext; + /** The name of the application. */ + title: string; + /** The name of the executable file. */ + exeName: string; + minisignKey: minisign.Key; + /** The command-line argument that should passed to `tar` to exact the tarball. */ + extraTarArgs: string[]; + /** + * The command-line argument that should passed to the executable to query the version. + * `"version"` for Zig, `"--version"` for ZLS + */ + versionArg: string; + mirrorUrls: vscode.Uri[]; + canonicalUrl: { + release: vscode.Uri; + nightly: vscode.Uri; + }; +} + +/** Returns the path to the executable */ +export async function install(config: Config, version: semver.SemVer): Promise { + const exeName = config.exeName + (process.platform === "win32" ? ".exe" : ""); + const subDirName = `${getZigOSName()}-${getZigArchName()}-${version.raw}`; + const exeUri = vscode.Uri.joinPath(config.context.globalStorageUri, config.exeName, subDirName, exeName); + + await setLastAccessTime(config, version); + + try { + await vscode.workspace.fs.stat(exeUri); + return exeUri.fsPath; + } catch (e) { + if (e instanceof vscode.FileSystemError) { + if (e.code !== "FileNotFound") { + throw e; + } + // go ahead an install + } else { + throw e; + } + } + + const mirrors = [...config.mirrorUrls] + .map((mirror) => ({ mirror, sort: Math.random() })) + .sort((a, b) => a.sort - b.sort) + .map(({ mirror }) => mirror); + + for (const mirrorUrl of mirrors) { + const mirrorName = new URL(mirrorUrl.toString()).host; + try { + return await installFromMirror(config, version, mirrorUrl, mirrorName); + } catch (err) { + if (err instanceof Error) { + void vscode.window.showWarningMessage( + `Failed to download ${config.exeName} from ${mirrorName}: ${err.message}, trying different mirror`, + ); + } else { + void vscode.window.showWarningMessage( + `Failed to download ${config.exeName} from ${mirrorName}, trying different mirror`, + ); + } + } + } + + const canonicalUrl = version.prerelease.length === 0 ? config.canonicalUrl.release : config.canonicalUrl.nightly; + const mirrorName = new URL(canonicalUrl.toString()).host; + return await installFromMirror(config, version, canonicalUrl, mirrorName); +} + +/** Returns the path to the executable */ +async function installFromMirror( + config: Config, + version: semver.SemVer, + mirrorUrl: vscode.Uri, + mirrorName: string, +): Promise { + const isWindows = process.platform === "win32"; + const fileExtension = isWindows ? "zip" : "tar.xz"; + const exeName = config.exeName + (isWindows ? ".exe" : ""); + const subDirName = `${getZigOSName()}-${getZigArchName()}-${version.raw}`; + const fileName = `${config.exeName}-${subDirName}.${fileExtension}`; + + const installDir = vscode.Uri.joinPath(config.context.globalStorageUri, config.exeName, subDirName); + const exeUri = vscode.Uri.joinPath(installDir, exeName); + const tarballUri = vscode.Uri.joinPath(installDir, fileName); + + const tarPath = await which("tar", { nothrow: true }); + if (!tarPath) { + throw new Error(`Downloaded ${config.title} tarball can't be extracted because 'tar' could not be found`); + } + + return await vscode.window.withProgress( + { + title: `Installing ${config.title} from ${mirrorName}`, + location: vscode.ProgressLocation.Notification, + }, + async (progress, cancelToken) => { + const abortController = new AbortController(); + cancelToken.onCancellationRequested(() => { + abortController.abort(); + }); + + const artifactUrl = vscode.Uri.joinPath(mirrorUrl, fileName); + /** https://github.com/mlugg/setup-zig adds a `?source=github-actions` query parameter so we add our own. */ + const artifactUrlWithQuery = artifactUrl.with({ query: "source=vscode-zig" }); + + const artifactMinisignUrl = vscode.Uri.joinPath(mirrorUrl, `${fileName}.minisig`); + const artifactMinisignUrlWithQuery = artifactMinisignUrl.with({ query: "source=vscode-zig" }); + + const signatureResponse = await axios.get(artifactMinisignUrlWithQuery.toString(), { + responseType: "arraybuffer", + signal: abortController.signal, + }); + const signatureData = Buffer.from(signatureResponse.data); + + const artifactResponse = await axios.get(artifactUrlWithQuery.toString(), { + responseType: "arraybuffer", + signal: abortController.signal, + onDownloadProgress: (progressEvent) => { + if (progressEvent.total) { + const increment = (progressEvent.bytes / progressEvent.total) * 100; + progress.report({ + message: progressEvent.progress + ? `downloading tarball ${(progressEvent.progress * 100).toFixed()}%` + : "downloading tarball...", + increment: increment, + }); + } + }, + }); + const artifactData = Buffer.from(artifactResponse.data); + + progress.report({ message: "Verifying Signature..." }); + + const signature = minisign.parseSignature(signatureData); + if (!minisign.verifySignature(config.minisignKey, signature, artifactData)) { + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + throw new Error(`signature verification failed for '${artifactUrl.toString()}'`); + } + + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + await vscode.workspace.fs.createDirectory(installDir); + await vscode.workspace.fs.writeFile(tarballUri, new Uint8Array(artifactData)); + + progress.report({ message: "Extracting..." }); + try { + await execFile( + tarPath, + ["-xf", tarballUri.fsPath, "-C", installDir.fsPath].concat(config.extraTarArgs), + { + signal: abortController.signal, + timeout: 60000, // 60 seconds + }, + ); + } catch (err) { + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + if (err instanceof Error) { + throw new Error(`Failed to extract ${config.title} tarball: ${err.message}`); + } else { + throw err; + } + } finally { + try { + await vscode.workspace.fs.delete(tarballUri, { useTrash: false }); + } catch {} + } + + const exeVersion = getVersion(exeUri.fsPath, config.versionArg); + if (!exeVersion || exeVersion.compare(version) !== 0) { + try { + await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); + } catch {} + // a mirror may provide the wrong version + throw new Error(`Failed to validate version of ${config.title} installation!`); + } + + await chmod(exeUri.fsPath, 0o755); + + return exeUri.fsPath; + }, + ); +} + +/** Returns all locally installed versions */ +export async function query(config: Config): Promise { + const available: semver.SemVer[] = []; + const prefix = `${getZigOSName()}-${getZigArchName()}`; + + const storageDir = vscode.Uri.joinPath(config.context.globalStorageUri, config.exeName); + try { + for (const [name] of await vscode.workspace.fs.readDirectory(storageDir)) { + if (name.startsWith(prefix)) { + available.push(new semver.SemVer(name.substring(prefix.length + 1))); + } + } + } catch (e) { + if (e instanceof vscode.FileSystemError && e.code === "FileNotFound") { + return []; + } + throw e; + } + + return available; +} + +/** Set the last access time of the (installed) version. */ +async function setLastAccessTime(config: Config, version: semver.SemVer): Promise { + await config.context.globalState.update( + `${config.exeName}-last-access-time-${getZigOSName()}-${getZigArchName()}-${version.raw}`, + Date.now(), + ); +} + +/** Remove installations with the oldest last access time until at most `VersionManager.maxInstallCount` versions remain. */ +export async function removeUnusedInstallations(config: Config) { + const storageDir = vscode.Uri.joinPath(config.context.globalStorageUri, config.exeName); + + const keys: { key: string; installDir: vscode.Uri; lastAccessTime: number }[] = []; + + try { + for (const [name, fileType] of await vscode.workspace.fs.readDirectory(storageDir)) { + const key = `${config.exeName}-last-access-time-${name}`; + const uri = vscode.Uri.joinPath(storageDir, name); + const lastAccessTime = config.context.globalState.get(key); + + if (!lastAccessTime || fileType !== vscode.FileType.Directory) { + await vscode.workspace.fs.delete(uri, { recursive: true, useTrash: false }); + } else { + keys.push({ + key: key, + installDir: uri, + lastAccessTime: lastAccessTime, + }); + } + } + } catch (e) { + if (e instanceof vscode.FileSystemError && e.code === "FileNotFound") return; + throw e; + } + + keys.sort((lhs, rhs) => lhs.lastAccessTime - rhs.lastAccessTime); + + for (const item of keys.slice(maxInstallCount)) { + await vscode.workspace.fs.delete(item.installDir, { recursive: true, useTrash: false }); + await config.context.globalState.update(item.key, undefined); + } +} diff --git a/src/zigCompilerProvider.ts b/src/zigDiagnosticsProvider.ts similarity index 96% rename from src/zigCompilerProvider.ts rename to src/zigDiagnosticsProvider.ts index 7d06633..9c4363f 100644 --- a/src/zigCompilerProvider.ts +++ b/src/zigDiagnosticsProvider.ts @@ -7,9 +7,10 @@ import path from "path"; import { DebouncedFunc, throttle } from "lodash-es"; import * as zls from "./zls"; -import { getZigPath, handleConfigOption } from "./zigUtil"; +import { handleConfigOption } from "./zigUtil"; +import { zigProvider } from "./zigSetup"; -export default class ZigCompilerProvider { +export default class ZigDiagnosticsProvider { private buildDiagnostics!: vscode.DiagnosticCollection; private astDiagnostics!: vscode.DiagnosticCollection; private dirtyChange = new WeakMap(); @@ -89,7 +90,8 @@ export default class ZigCompilerProvider { if (textDocument.languageId !== "zig") { return; } - const zigPath = getZigPath(); + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; const { error, stderr } = childProcess.spawnSync(zigPath, ["ast-check"], { input: textDocument.getText(), maxBuffer: 10 * 1024 * 1024, // 10MB @@ -134,7 +136,8 @@ export default class ZigCompilerProvider { private _doCompile(textDocument: vscode.TextDocument) { const config = vscode.workspace.getConfiguration("zig"); - const zigPath = getZigPath(); + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; const buildOption = config.get("buildOption", "build"); const processArg: string[] = [buildOption]; diff --git a/src/zigFormat.ts b/src/zigFormat.ts index f954100..72858ee 100644 --- a/src/zigFormat.ts +++ b/src/zigFormat.ts @@ -3,43 +3,48 @@ import vscode from "vscode"; import childProcess from "child_process"; import util from "util"; -import { getZigPath } from "./zigUtil"; +import { DocumentRangeFormattingRequest, TextDocumentIdentifier } from "vscode-languageclient"; + +import * as zls from "./zls"; +import { zigProvider } from "./zigSetup"; const execFile = util.promisify(childProcess.execFile); const ZIG_MODE: vscode.DocumentSelector = { language: "zig" }; export function registerDocumentFormatting(): vscode.Disposable { + const disposables: vscode.Disposable[] = []; let registeredFormatter: vscode.Disposable | null = null; preCompileZigFmt(); - vscode.workspace.onDidChangeConfiguration((change: vscode.ConfigurationChangeEvent) => { - if ( - change.affectsConfiguration("zig.path", undefined) || - change.affectsConfiguration("zig.formattingProvider", undefined) - ) { + zigProvider.onChange.event(() => { + preCompileZigFmt(); + }, disposables); + + const onformattingProviderChange = (change: vscode.ConfigurationChangeEvent | null) => { + if (!change || change.affectsConfiguration("zig.formattingProvider", undefined)) { preCompileZigFmt(); - } - }); - const onformattingProviderChange = () => { - if (vscode.workspace.getConfiguration("zig").get("formattingProvider") === "off") { - // Unregister the formatting provider - if (registeredFormatter !== null) registeredFormatter.dispose(); - registeredFormatter = null; - } else { - // register the formatting provider - registeredFormatter ??= vscode.languages.registerDocumentRangeFormattingEditProvider(ZIG_MODE, { - provideDocumentRangeFormattingEdits, - }); + if (vscode.workspace.getConfiguration("zig").get("formattingProvider") === "off") { + // Unregister the formatting provider + if (registeredFormatter !== null) registeredFormatter.dispose(); + registeredFormatter = null; + } else { + // register the formatting provider + registeredFormatter ??= vscode.languages.registerDocumentRangeFormattingEditProvider(ZIG_MODE, { + provideDocumentRangeFormattingEdits, + }); + } } }; - onformattingProviderChange(); - const registeredDidChangeEvent = vscode.workspace.onDidChangeConfiguration(onformattingProviderChange); + onformattingProviderChange(null); + vscode.workspace.onDidChangeConfiguration(onformattingProviderChange, disposables); return { dispose: () => { - registeredDidChangeEvent.dispose(); + for (const disposable of disposables) { + disposable.dispose(); + } if (registeredFormatter !== null) registeredFormatter.dispose(); }, }; @@ -50,12 +55,8 @@ function preCompileZigFmt() { // This pre-compiles even if "zig.formattingProvider" is "zls". if (vscode.workspace.getConfiguration("zig").get("formattingProvider") === "off") return; - let zigPath: string; - try { - zigPath = getZigPath(); - } catch { - return; - } + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; try { childProcess.execFile(zigPath, ["fmt", "--help"], { @@ -76,7 +77,22 @@ async function provideDocumentRangeFormattingEdits( options: vscode.FormattingOptions, token: vscode.CancellationToken, ): Promise { - const zigPath = getZigPath(); + if (vscode.workspace.getConfiguration("zig").get("formattingProvider") === "zls") { + if (zls.client !== null) { + return await (zls.client.sendRequest( + DocumentRangeFormattingRequest.type, + { + textDocument: TextDocumentIdentifier.create(document.uri.toString()), + range: range, + options: options, + }, + token, + ) as Promise); + } + } + + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; const abortController = new AbortController(); token.onCancellationRequested(() => { diff --git a/src/zigMainCodeLens.ts b/src/zigMainCodeLens.ts index 0aba94f..e82b819 100644 --- a/src/zigMainCodeLens.ts +++ b/src/zigMainCodeLens.ts @@ -5,7 +5,8 @@ import fs from "fs"; import path from "path"; import util from "util"; -import { getWorkspaceFolder, getZigPath, isWorkspaceFile } from "./zigUtil"; +import { getWorkspaceFolder, isWorkspaceFile } from "./zigUtil"; +import { zigProvider } from "./zigSetup"; const execFile = util.promisify(childProcess.execFile); @@ -39,15 +40,17 @@ export default class ZigMainCodeLensProvider implements vscode.CodeLensProvider function zigRun() { if (!vscode.window.activeTextEditor) return; + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return; const filePath = vscode.window.activeTextEditor.document.uri.fsPath; const terminal = vscode.window.createTerminal("Run Zig Program"); terminal.show(); const wsFolder = getWorkspaceFolder(filePath); if (wsFolder && isWorkspaceFile(filePath) && hasBuildFile(wsFolder.uri.fsPath)) { - terminal.sendText(`${getZigPath()} build run`); + terminal.sendText(`${zigPath} build run`); return; } - terminal.sendText(`${getZigPath()} run "${filePath}"`); + terminal.sendText(`${zigPath} run "${filePath}"`); } function hasBuildFile(workspaceFspath: string): boolean { @@ -60,12 +63,13 @@ async function zigDebug() { const filePath = vscode.window.activeTextEditor.document.uri.fsPath; try { const workspaceFolder = getWorkspaceFolder(filePath); - let binaryPath = ""; + let binaryPath; if (workspaceFolder && isWorkspaceFile(filePath) && hasBuildFile(workspaceFolder.uri.fsPath)) { binaryPath = await buildDebugBinaryWithBuildFile(workspaceFolder.uri.fsPath); } else { binaryPath = await buildDebugBinary(filePath); } + if (!binaryPath) return; const debugConfig: vscode.DebugConfiguration = { type: "lldb", @@ -81,11 +85,12 @@ async function zigDebug() { } } -async function buildDebugBinaryWithBuildFile(workspacePath: string): Promise { +async function buildDebugBinaryWithBuildFile(workspacePath: string): Promise { + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; // Workaround because zig build doesn't support specifying the output binary name // `zig run` does support -femit-bin, but preferring `zig build` if possible const outputDir = path.join(workspacePath, "zig-out", "tmp-debug-build"); - const zigPath = getZigPath(); await execFile(zigPath, ["build", "--prefix", outputDir], { cwd: workspacePath }); const dirFiles = await vscode.workspace.fs.readDirectory(vscode.Uri.file(path.join(outputDir, "bin"))); const files = dirFiles.find(([, type]) => type === vscode.FileType.File); @@ -95,8 +100,9 @@ async function buildDebugBinaryWithBuildFile(workspacePath: string): Promise { - const zigPath = getZigPath(); +async function buildDebugBinary(filePath: string): Promise { + const zigPath = zigProvider.getZigPath(); + if (!zigPath) return null; const fileDirectory = path.dirname(filePath); const binaryName = `debug-${path.basename(filePath, ".zig")}`; const binaryPath = path.join(fileDirectory, "zig-out", "bin", binaryName); diff --git a/src/zigProvider.ts b/src/zigProvider.ts new file mode 100644 index 0000000..3f05c2a --- /dev/null +++ b/src/zigProvider.ts @@ -0,0 +1,66 @@ +import vscode from "vscode"; + +import semver from "semver"; + +import { resolveExePathAndVersion } from "./zigUtil"; + +interface ExeWithVersion { + exe: string; + version: semver.SemVer; +} + +export class ZigProvider implements vscode.Disposable { + onChange: vscode.EventEmitter = new vscode.EventEmitter(); + private value: ExeWithVersion | null; + private disposables: vscode.Disposable[]; + + constructor() { + this.value = this.resolveZigPathConfigOption(); + this.disposables = [ + vscode.workspace.onDidChangeConfiguration((change) => { + if (change.affectsConfiguration("zig.path")) { + const newValue = this.resolveZigPathConfigOption(); + if (newValue) { + this.value = newValue; + this.set(this.value); + } + } + }), + ]; + } + + /** Returns the version of the Zig executable that is currently being used. */ + public getZigVersion(): semver.SemVer | null { + return this.value?.version ?? null; + } + + /** Returns the path to the Zig executable that is currently being used. */ + public getZigPath(): string | null { + return this.value?.exe ?? null; + } + + /** Override which zig executable should be used. The `zig.path` config option will be ignored */ + public set(value: ExeWithVersion | null) { + this.value = value; + this.onChange.fire(value); + } + + /** Resolves the `zig.path` configuration option */ + private resolveZigPathConfigOption(): ExeWithVersion | null { + const zigPath = vscode.workspace.getConfiguration("zig").get("path", ""); + if (!zigPath) return null; + const exePath = zigPath !== "zig" ? zigPath : null; // the string "zig" means lookup in PATH + const result = resolveExePathAndVersion(exePath, "zig", "zig.path", "version"); + if ("message" in result) { + void vscode.window.showErrorMessage(`'zig.path' is not valid: ${result.message}`); + return null; + } + return result; + } + + dispose() { + for (const disposable of this.disposables) { + disposable.dispose(); + } + } +} diff --git a/src/zigSetup.ts b/src/zigSetup.ts index f255c4f..20b20fc 100644 --- a/src/zigSetup.ts +++ b/src/zigSetup.ts @@ -1,64 +1,98 @@ +import vscode from "vscode"; + import path from "path"; import axios from "axios"; import semver from "semver"; -import vscode from "vscode"; -import { downloadAndExtractArtifact, getHostZigName, getVersion, getZigPath, shouldCheckUpdate } from "./zigUtil"; -import { installZLS } from "./zls"; - -const DOWNLOAD_INDEX = "https://ziglang.org/download/index.json"; - -function getNightlySemVer(url: string): string { - const matches = url.match(/-(\d+\.\d+\.\d+(-dev\.\d+\+\w+)?)\./); - if (!matches) throw new Error(`url '${url}' does not contain a semantic version!`); - return matches[1]; -} +import * as minisign from "./minisign"; +import * as versionManager from "./versionManager"; +import { VersionIndex, ZigVersion, getHostZigName, resolveExePathAndVersion } from "./zigUtil"; +import { ZigProvider } from "./zigProvider"; + +let statusItem: vscode.StatusBarItem; +let languageStatusItem: vscode.LanguageStatusItem; +let versionManagerConfig: versionManager.Config; +export let zigProvider: ZigProvider; + +/** Removes the `zig.path` config option. */ +async function installZig(context: vscode.ExtensionContext) { + const wantedZig = await getWantedZigVersion( + context, + Object.values(WantedZigVersionSource) as WantedZigVersionSource[], + ); + if (!wantedZig) { + await vscode.workspace.getConfiguration("zig").update("path", undefined, true); + zigProvider.set(null); + return; + } -type VersionIndex = Record>; + if (wantedZig.source === WantedZigVersionSource.workspaceBuildZigZon) { + wantedZig.version = await findClosestSatisfyingZigVersion(context, wantedZig.version); + } -interface ZigVersion { - name: string; - url: string; - sha: string; - notes?: string; + try { + const exePath = await versionManager.install(versionManagerConfig, wantedZig.version); + await vscode.workspace.getConfiguration("zig").update("path", undefined, true); + zigProvider.set({ exe: exePath, version: wantedZig.version }); + } catch (err) { + zigProvider.set(null); + if (err instanceof Error) { + void vscode.window.showErrorMessage( + `Failed to install Zig ${wantedZig.version.toString()}: ${err.message}`, + ); + } else { + void vscode.window.showErrorMessage(`Failed to install Zig ${wantedZig.version.toString()}!`); + } + } } -export async function installZig(context: vscode.ExtensionContext, version: ZigVersion) { - const zigPath = await downloadAndExtractArtifact( - "Zig", - "zig", - vscode.Uri.joinPath(context.globalStorageUri, "zig_install"), - version.url, - version.sha, - ["--strip-components=1"], - ); - if (zigPath !== null) { - const configuration = vscode.workspace.getConfiguration("zig"); - await configuration.update("path", zigPath, true); +async function findClosestSatisfyingZigVersion( + context: vscode.ExtensionContext, + version: semver.SemVer, +): Promise { + if (version.prerelease.length !== 0) return version; + const cacheKey = `zig-satisfying-version-${version.raw}`; - void vscode.window.showInformationMessage( - `Zig has been installed successfully. Relaunch your integrated terminal to make it available.`, - ); + try { + // We can't just return `version` because `0.12.0` should return `0.12.1`. + const availableVersions = (await getVersions()).map((item) => item.version); + const selectedVersion = semver.maxSatisfying(availableVersions, `^${version.toString()}`); + await context.globalState.update(cacheKey, selectedVersion ?? undefined); + return selectedVersion ?? version; + } catch { + const selectedVersion = context.globalState.get(cacheKey, null); + return selectedVersion ? new semver.SemVer(selectedVersion) : version; } } +/** + * Returns a sorted list of all versions that are provided by Zig's [index.json](https://ziglang.org/download/index.json) and Mach's [index.json](https://pkg.machengine.org/zig/index.json). + * [Nominated Zig versions](https://machengine.org/docs/nominated-zig/#nominated-zig-history) are sorted to the bottom. + * + * Throws an exception when no network connection is available. + */ async function getVersions(): Promise { + const [zigIndexJson, machIndexJson] = await Promise.all([ + axios.get("https://ziglang.org/download/index.json", {}), + axios.get("https://pkg.machengine.org/zig/index.json", {}), + ]); + const indexJson = { ...machIndexJson.data, ...zigIndexJson.data }; + const hostName = getHostZigName(); - const indexJson = (await axios.get(DOWNLOAD_INDEX, {})).data; const result: ZigVersion[] = []; - for (let key in indexJson) { - const value = indexJson[key]; - if (key === "master") { - key = "nightly"; - } + for (const [key, value] of Object.entries(indexJson)) { + const name = key === "master" ? "nightly" : key; + const version = new semver.SemVer(value.version ?? key); const release = value[hostName]; if (release) { result.push({ - name: key, + name: name, + version: version, url: release.tarball, sha: release.shasum, - notes: (value as { notes?: string }).notes, + notes: value.notes, + isMach: name.includes("mach"), }); } } @@ -67,225 +101,411 @@ async function getVersions(): Promise { `no pre-built Zig is available for your system '${hostName}', you can build it yourself using https://github.com/ziglang/zig-bootstrap`, ); } + sortVersions(result); return result; } +function sortVersions(versions: { name?: string; version: semver.SemVer; isMach: boolean }[]) { + versions.sort((lhs, rhs) => { + // Mach versions except `mach-latest` move to the end + if (lhs.name !== "mach-latest" && rhs.name !== "mach-latest" && lhs.isMach !== rhs.isMach) + return +lhs.isMach - +rhs.isMach; + return semver.compare(rhs.version, lhs.version); + }); +} + async function selectVersionAndInstall(context: vscode.ExtensionContext) { + const offlineVersions = await versionManager.query(versionManagerConfig); + + const versions: { + name?: string; + version: semver.SemVer; + /** Whether the version already installed in global extension storage */ + offline: boolean; + /** Whether is available in `index.json` */ + online: boolean; + /** Whether the version one of [Mach's nominated Zig versions](https://machengine.org/docs/nominated-zig/#nominated-zig-history) */ + isMach: boolean; + }[] = offlineVersions.map((version) => ({ + version: version, + offline: true, + online: false, + isMach: false /* We can't tell if a version is Mach while being offline */, + })); + try { - const available = await getVersions(); + const onlineVersions = await getVersions(); + outer: for (const onlineVersion of onlineVersions) { + for (const version of versions) { + if (semver.eq(version.version, onlineVersion.version)) { + version.name ??= onlineVersion.name; + version.online = true; + version.isMach = onlineVersion.isMach; + } + } - const items: vscode.QuickPickItem[] = []; - for (const option of available) { - items.push({ label: option.name }); - } - // Recommend latest stable release. - const placeHolder = available.length > 2 ? available[1].name : undefined; - const selection = await vscode.window.showQuickPick(items, { - title: "Select Zig version to install", - canPickMany: false, - placeHolder: placeHolder, - }); - if (selection === undefined) return; - for (const option of available) { - if (option.name === selection.label) { - await installZig(context, option); - return; + for (const version of versions) { + if (semver.eq(version.version, onlineVersion.version) && version.name === onlineVersion.name) { + continue outer; + } } + + versions.push({ + name: onlineVersion.name, + version: onlineVersion.version, + online: true, + offline: !!offlineVersions.find((item) => semver.eq(item.version, onlineVersion.version)), + isMach: onlineVersion.isMach, + }); } } catch (err) { - if (err instanceof Error) { - void vscode.window.showErrorMessage(`Unable to install Zig: ${err.message}`); + if (!offlineVersions.length) { + if (err instanceof Error) { + void vscode.window.showErrorMessage(`Failed to query available Zig version: ${err.message}`); + } else { + void vscode.window.showErrorMessage(`Failed to query available Zig version!`); + } + return; } else { - throw err; + // Only show the locally installed versions } } -} -async function checkUpdate(context: vscode.ExtensionContext) { - try { - const update = await getUpdatedVersion(context); - if (!update) return; + sortVersions(versions); + const placeholderVersion = versions.find((item) => item.version.prerelease.length === 0)?.version; + + const items: vscode.QuickPickItem[] = []; + + const workspaceZig = await getWantedZigVersion(context, [ + WantedZigVersionSource.workspaceZigVersionFile, + WantedZigVersionSource.workspaceBuildZigZon, + WantedZigVersionSource.zigVersionConfigOption, + ]); + if (workspaceZig !== null) { + const alreadyInstalled = offlineVersions.some((item) => semver.eq(item.version, workspaceZig.version)); + items.push({ + label: "Use Workspace Version", + description: alreadyInstalled ? "already installed" : undefined, + detail: workspaceZig.version.raw, + }); + } - const notes = update.notes ? ` [${update.notes}](${update.notes})` : ""; + const zigInPath = resolveExePathAndVersion(null, "zig", null, "version"); + if (!("message" in zigInPath)) { + items.push({ + label: "Use Zig in PATH", + description: zigInPath.exe, + detail: zigInPath.version.raw, + }); + } - const response = await vscode.window.showInformationMessage( - `New version of Zig available: ${update.name}${notes}`, - "Install", - "Ignore", - ); - switch (response) { - case "Install": - await installZig(context, update); - break; - case "Ignore": - case undefined: - break; - } - } catch (err) { - if (err instanceof Error) { - void vscode.window.showErrorMessage(`Unable to update Zig: ${err.message}`); - } else { - throw err; + items.push( + { + label: "Manually Specify Path", + }, + { + label: "", + kind: vscode.QuickPickItemKind.Separator, + }, + ); + + let seenMachVersion = false; + for (const item of versions) { + const useName = item.isMach || item.version.prerelease.length !== 0; + if (item.isMach && !seenMachVersion && item.name !== "mach-latest") { + seenMachVersion = true; + items.push({ + label: "Mach's Nominated Zig versions", + kind: vscode.QuickPickItemKind.Separator, + }); } + items.push({ + label: (useName ? item.name : null) ?? item.version.raw, + description: item.offline ? "already installed" : undefined, + detail: useName ? (item.name ? item.version.raw : undefined) : undefined, + }); + } + + const selection = await vscode.window.showQuickPick(items, { + title: "Select Zig version to install", + canPickMany: false, + placeHolder: placeholderVersion?.raw, + }); + if (selection === undefined) return; + + switch (selection.label) { + case "Use Workspace Version": + await context.workspaceState.update("zig-version", undefined); + await installZig(context); + break; + case "Use Zig in PATH": + await vscode.workspace.getConfiguration("zig").update("path", "zig", true); + break; + case "Manually Specify Path": + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + title: "Select Zig executable", + }); + if (!uris) return; + await vscode.workspace.getConfiguration("zig").update("path", uris[0].path, true); + break; + default: + const version = new semver.SemVer(selection.detail ?? selection.label); + await context.workspaceState.update("zig-version", version.raw); + await installZig(context); + break; } } -async function getUpdatedVersion(context: vscode.ExtensionContext): Promise { - const configuration = vscode.workspace.getConfiguration("zig"); - const zigPath = configuration.get("path"); - const zigBinPath = vscode.Uri.joinPath(context.globalStorageUri, "zig_install", "zig").fsPath; - if (!zigPath?.startsWith(zigBinPath)) return null; - - const curVersion = getVersion(zigPath, "version"); - if (!curVersion) return null; - - const available = await getVersions(); - if (curVersion.prerelease.length !== 0) { - if (available[0].name === "nightly") { - const newVersion = getNightlySemVer(available[0].url); - if (semver.gt(newVersion, curVersion)) { - available[0].name = `nightly-${newVersion}`; - return available[0]; +/** The order of these enums defines the default order in which these sources are executed. */ +enum WantedZigVersionSource { + workspaceState = "workspace-state", + /** `.zigversion` */ + workspaceZigVersionFile = ".zigversion", + /** The `minimum_zig_version` in `build.zig.zon` */ + workspaceBuildZigZon = "build.zig.zon", + /** `zig.version` */ + zigVersionConfigOption = "zig.version", + latestTagged = "latest-tagged", +} + +/** Try to resolve the (workspace-specific) Zig version. */ +async function getWantedZigVersion( + context: vscode.ExtensionContext, + /** List of "sources" that should are applied in the given order to resolve the wanted Zig version */ + sources: WantedZigVersionSource[], +): Promise<{ + version: semver.SemVer; + source: WantedZigVersionSource; +} | null> { + let workspace: vscode.WorkspaceFolder | null = null; + // Supporting multiple workspaces is significantly more complex so we just look for the first workspace. + if (vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { + workspace = vscode.workspace.workspaceFolders[0]; + } + + for (const source of sources) { + let result: semver.SemVer | null = null; + + try { + switch (source) { + case WantedZigVersionSource.workspaceState: + // `context.workspaceState` appears to behave like `context.globalState` when outside of a workspace + // There is currently no way to remove the specified zig version. + const wantedZigVersion = context.workspaceState.get("zig-version"); + result = wantedZigVersion ? new semver.SemVer(wantedZigVersion) : null; + break; + case WantedZigVersionSource.workspaceZigVersionFile: + if (workspace) { + const zigVersionString = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.uri, ".zigversion"), + ); + result = semver.parse(zigVersionString.toString().trim()); + } + break; + case WantedZigVersionSource.workspaceBuildZigZon: + if (workspace) { + const manifest = await vscode.workspace.fs.readFile( + vscode.Uri.joinPath(workspace.uri, "build.zig.zon"), + ); + // Not perfect, but good enough + const matches = /\n\s*\.minimum_zig_version\s=\s\"(.*)\"/.exec(manifest.toString()); + if (matches) { + result = semver.parse(matches[1]); + } + } + break; + case WantedZigVersionSource.zigVersionConfigOption: + const versionString = vscode.workspace.getConfiguration("zig").get("version"); + if (versionString) { + result = semver.parse(versionString); + if (!result) { + void vscode.window.showErrorMessage( + `Invalid 'zig.version' config option. '${versionString}' is not a valid Zig version`, + ); + } + } + break; + case WantedZigVersionSource.latestTagged: + const cacheKey = "zig-latest-tagged"; + try { + const zigVersion = await getVersions(); + const latestTagged = zigVersion.find((item) => item.version.prerelease.length === 0); + result = latestTagged?.version ?? null; + await context.globalState.update(cacheKey, latestTagged?.version.raw); + } catch { + const latestTagged = context.globalState.get(cacheKey, null); + if (latestTagged) { + result = new semver.SemVer(latestTagged); + } + } + break; } - } - } else if (available.length > 2 && semver.gt(available[1].name, curVersion)) { - return available[1]; + } catch {} + + if (!result) continue; + + return { + version: result, + source: source, + }; } return null; } -function updateZigEnvironmentVariableCollection(context: vscode.ExtensionContext) { - try { - const zigPath = getZigPath(); - const envValue = path.delimiter + path.dirname(zigPath); +function updateStatusItem(item: vscode.StatusBarItem, version: semver.SemVer | null) { + item.name = "Zig Version"; + item.text = version?.toString() ?? "not installed"; + item.tooltip = "Select Zig Version"; + item.command = { + title: "Select Version", + command: "zig.install", + }; + if (version) { + item.backgroundColor = undefined; + } else { + item.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground"); + } +} + +function updateLanguageStatusItem(item: vscode.LanguageStatusItem, version: semver.SemVer | null) { + item.name = "Zig"; + if (version) { + item.text = `Zig ${version.toString()}`; + item.detail = "Zig Version"; + item.severity = vscode.LanguageStatusSeverity.Information; + } else { + item.text = "Zig not installed"; + item.severity = vscode.LanguageStatusSeverity.Error; + } + item.command = { + title: "Select Version", + command: "zig.install", + }; +} + +function updateZigEnvironmentVariableCollection(context: vscode.ExtensionContext, zigExePath: string | null) { + if (zigExePath) { + const envValue = path.delimiter + path.dirname(zigExePath); // Calling `append` means that zig from a user-defined PATH value will take precedence. // The added value may have already been added by the user but since we // append, it doesn't have any observable. context.environmentVariableCollection.append("PATH", envValue); - } catch { + } else { context.environmentVariableCollection.delete("PATH"); } } export async function setupZig(context: vscode.ExtensionContext) { { - // convert an empty string for `zig.path` and `zig.zls.path` to `zig` and `zls` respectively. // This check can be removed once enough time has passed so that most users switched to the new value + // remove the `zig_install` directory from the global storage + try { + await vscode.workspace.fs.delete(vscode.Uri.joinPath(context.globalStorageUri, "zig_install"), { + recursive: true, + useTrash: false, + }); + } catch {} + + // remove a `zig.path` that points to the global storage. const zigConfig = vscode.workspace.getConfiguration("zig"); - const initialSetupDone = zigConfig.get("initialSetupDone", false); - const zigPath = zigConfig.get("path"); - if (zigPath === "" && initialSetupDone) { - await zigConfig.update("path", "zig", true); + const zigPath = zigConfig.get("path", ""); + if (zigPath.startsWith(context.globalStorageUri.fsPath)) { + await zigConfig.update("path", undefined, true); } - const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); - const zlsPath = zlsConfig.get("path"); - if (zlsPath === "" && initialSetupDone) { - await zlsConfig.update("path", "zls", true); - } + await zigConfig.update("initialSetupDone", undefined, true); } + versionManagerConfig = { + context: context, + title: "Zig", + exeName: "zig", + extraTarArgs: ["--strip-components=1"], + /** https://ziglang.org/download */ + minisignKey: minisign.parseKey("RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U"), + versionArg: "version", + // taken from https://github.com/mlugg/setup-zig/blob/main/mirrors.json + mirrorUrls: [ + vscode.Uri.parse("https://pkg.machengine.org/zig"), + vscode.Uri.parse("https://zigmirror.hryx.net/zig"), + vscode.Uri.parse("https://zig.linus.dev/zig"), + vscode.Uri.parse("https://fs.liujiacai.net/zigbuilds"), + vscode.Uri.parse("https://zigmirror.nesovic.dev/zig"), + ], + canonicalUrl: { + release: vscode.Uri.parse("https://ziglang.org/download"), + nightly: vscode.Uri.parse("https://ziglang.org/builds"), + }, + }; + + zigProvider = new ZigProvider(); + + /** There two status items because there doesn't seem to be a way to pin a language status item by default. */ + statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, -1); + languageStatusItem = vscode.languages.createLanguageStatusItem("zig.status", { language: "zig" }); + context.environmentVariableCollection.description = "Add Zig to PATH"; - updateZigEnvironmentVariableCollection(context); + + const watcher1 = vscode.workspace.createFileSystemWatcher("**/.zigversion"); + const watcher2 = vscode.workspace.createFileSystemWatcher("**/build.zig.zon"); + + const refreshZigInstallation = async () => { + if (!vscode.workspace.getConfiguration("zig").get("path")) { + await installZig(context); + } else { + updateStatusItem(statusItem, zigProvider.getZigVersion()); + updateLanguageStatusItem(languageStatusItem, zigProvider.getZigVersion()); + } + }; + + const onDidChangeActiveTextEditor = (editor: vscode.TextEditor | undefined) => { + if (editor?.document.languageId === "zig") { + statusItem.show(); + } else { + statusItem.hide(); + } + }; + onDidChangeActiveTextEditor(vscode.window.activeTextEditor); context.subscriptions.push( + zigProvider, + statusItem, + languageStatusItem, vscode.commands.registerCommand("zig.install", async () => { await selectVersionAndInstall(context); - await installZLS(context, true); }), - vscode.commands.registerCommand("zig.update", async () => { - await checkUpdate(context); - }), - vscode.workspace.onDidChangeConfiguration((change) => { - if (change.affectsConfiguration("zig.path")) { - updateZigEnvironmentVariableCollection(context); + vscode.workspace.onDidChangeConfiguration(async (change) => { + // The `zig.path` config option is handled by `zigProvider.onChange`. + if (change.affectsConfiguration("zig.version")) { + await refreshZigInstallation(); } }), - ); - - const configuration = vscode.workspace.getConfiguration("zig"); - if (!configuration.get("initialSetupDone")) { - await configuration.update("initialSetupDone", await initialSetup(context), true); - } - - if (!configuration.get("checkForUpdate")) return; - if (!(await shouldCheckUpdate(context, "zigUpdate"))) return; - await checkUpdate(context); -} + vscode.window.onDidChangeActiveTextEditor(onDidChangeActiveTextEditor), + zigProvider.onChange.event((result) => { + const { exe, version } = result ?? { exe: null, version: null }; -async function initialSetup(context: vscode.ExtensionContext): Promise { - const zigConfig = vscode.workspace.getConfiguration("zig"); + updateStatusItem(statusItem, version); + updateLanguageStatusItem(languageStatusItem, version); - if (!zigConfig.get("path")) { - const zigResponse = await vscode.window.showInformationMessage( - "Zig path hasn't been set, do you want to specify the path or install Zig?", - { modal: true }, - "Install", - "Specify path", - "Use Zig in PATH", - ); - switch (zigResponse) { - case "Install": - await selectVersionAndInstall(context); - const zigPath = vscode.workspace.getConfiguration("zig").get("path"); - if (!zigPath) return false; - break; - case "Specify path": - const uris = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - title: "Select Zig executable", - }); - if (!uris) return false; - - const version = getVersion(uris[0].path, "version"); - if (!version) return false; - - await zigConfig.update("path", uris[0].path, true); - break; - case "Use Zig in PATH": - await zigConfig.update("path", "zig", true); - break; - case undefined: - return false; - } - } - - const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); - - if (!zlsConfig.get("path")) { - const zlsResponse = await vscode.window.showInformationMessage( - "We recommend enabling ZLS (the Zig Language Server) for a better editing experience. Would you like to install it?", - { modal: true }, - "Install", - "Specify path", - "Use ZLS in PATH", - ); + updateZigEnvironmentVariableCollection(context, exe); + }), + watcher1.onDidCreate(refreshZigInstallation), + watcher1.onDidChange(refreshZigInstallation), + watcher1.onDidDelete(refreshZigInstallation), + watcher1, + watcher2.onDidCreate(refreshZigInstallation), + watcher2.onDidChange(refreshZigInstallation), + watcher2.onDidDelete(refreshZigInstallation), + watcher2, + ); - switch (zlsResponse) { - case "Install": - await installZLS(context, false); - break; - case "Specify path": - const uris = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - title: "Select Zig Language Server (ZLS) executable", - }); - if (!uris) return true; - - await zlsConfig.update("path", uris[0].path, true); - break; - case "Use ZLS in PATH": - await zlsConfig.update("path", "zls", true); - break; - case undefined: - break; - } - } + await refreshZigInstallation(); +} - return true; +export async function deactivate() { + await versionManager.removeUnusedInstallations(versionManagerConfig); } diff --git a/src/zigTestRunnerProvider.ts b/src/zigTestRunnerProvider.ts index ef0eae7..2c0af84 100644 --- a/src/zigTestRunnerProvider.ts +++ b/src/zigTestRunnerProvider.ts @@ -6,7 +6,8 @@ import util from "util"; import { DebouncedFunc, throttle } from "lodash-es"; -import { getWorkspaceFolder, getZigPath, isWorkspaceFile } from "./zigUtil"; +import { getWorkspaceFolder, isWorkspaceFile } from "./zigUtil"; +import { zigProvider } from "./zigSetup"; const execFile = util.promisify(childProcess.execFile); @@ -85,7 +86,7 @@ export default class ZigTestRunnerProvider { this.deleteTestForAFile(textDocument.uri); for (const match of matches) { - const testDesc = match[1] || match[2] || match [3]; + const testDesc = match[1] || match[2] || match[3]; const isDocTest = !match[1]; const position = textDocument.positionAt(match.index); const range = new vscode.Range(position, position.translate(0, match[0].length)); @@ -128,7 +129,10 @@ export default class ZigTestRunnerProvider { } private async runTest(test: vscode.TestItem): Promise<{ output: string; success: boolean }> { - const zigPath = getZigPath(); + const zigPath = zigProvider.getZigPath(); + if (!zigPath) { + return { output: "Unable to run test without Zig", success: false }; + } if (test.uri === undefined) { return { output: "Unable to determine file location", success: false }; } @@ -176,13 +180,17 @@ export default class ZigTestRunnerProvider { } private async buildTestBinary(run: vscode.TestRun, testFilePath: string, testDesc: string): Promise { + const zigPath = zigProvider.getZigPath(); + if (!zigPath) { + throw new Error("Unable to build test binary without Zig"); + } + const wsFolder = getWorkspaceFolder(testFilePath)?.uri.fsPath ?? path.dirname(testFilePath); const outputDir = path.join(wsFolder, "zig-out", "tmp-debug-build", "bin"); const binaryName = `test-${path.basename(testFilePath, ".zig")}`; const binaryPath = path.join(outputDir, binaryName); await vscode.workspace.fs.createDirectory(vscode.Uri.file(outputDir)); - const zigPath = getZigPath(); const { stdout, stderr } = await execFile(zigPath, [ "test", testFilePath, diff --git a/src/zigUtil.ts b/src/zigUtil.ts index 8d5ce53..33f2e3d 100644 --- a/src/zigUtil.ts +++ b/src/zigUtil.ts @@ -1,20 +1,14 @@ import vscode from "vscode"; import childProcess from "child_process"; -import crypto from "crypto"; import fs from "fs"; import os from "os"; import path from "path"; -import { promisify } from "util"; import assert from "assert"; -import axios from "axios"; import semver from "semver"; import which from "which"; -const execFile = promisify(childProcess.execFile); -const chmod = promisify(fs.chmod); - // Replace any references to predefined variables in config string. // https://code.visualstudio.com/docs/editor/variables-reference#_predefined-variables export function handleConfigOption(input: string): string { @@ -54,7 +48,22 @@ export function handleConfigOption(input: string): string { return input; } -export function getExePath(exePath: string | null | undefined, exeName: string, optionName: string): string { +/** Resolves the absolute executable path and version of a program like Zig or ZLS. */ +export function resolveExePathAndVersion( + /** `null` means lookup in PATH */ + exePath: string | null, + /** e.g. `zig` or `zig` */ + exeName: string, + /** e.g. `zig.path` or `zig.zls.path`. Can be null if `exePath === null` */ + optionName: string | null, + /** + * The command-line argument that is used to query the version of the executable. + * Zig uses `version`. ZLS uses `--version`. + */ + versionArg: string, +): { exe: string; version: semver.SemVer } | { message: string } { + /* `optionName === null` implies `exePath === null` */ + assert(optionName !== null || exePath === null); if (!exePath) { exePath = which.sync(exeName, { nothrow: true }); } else { @@ -68,28 +77,29 @@ export function getExePath(exePath: string | null | undefined, exeName: string, } } - let message; if (!exePath) { - message = `Could not find ${exeName} in PATH`; - } else if (!fs.existsSync(exePath)) { - message = `\`${optionName}\` ${exePath} does not exist`; - } else { - try { - fs.accessSync(exePath, fs.constants.R_OK | fs.constants.X_OK); - return exePath; - } catch { - message = `\`${optionName}\` ${exePath} is not an executable`; - } + return { message: `Could not find ${exeName} in PATH` }; + } + + if (!fs.existsSync(exePath)) { + return { + message: optionName ? `\`${optionName}\` ${exePath} does not exist` : `${exePath} does not exist`, + }; + } + + try { + fs.accessSync(exePath, fs.constants.R_OK | fs.constants.X_OK); + } catch { + return { + message: optionName + ? `\`${optionName}\` ${exePath} is not an executable` + : `${exePath} is not an executable`, + }; } - void vscode.window.showErrorMessage(message); - throw Error(message); -} -export function getZigPath(): string { - const configuration = vscode.workspace.getConfiguration("zig"); - const zigPath = configuration.get("path"); - const exePath = zigPath !== "zig" ? zigPath : null; // the string "zig" means lookup in PATH - return getExePath(exePath, "zig", "zig.path"); + const version = getVersion(exePath, versionArg); + if (!version) return { message: `Failed to run '${exePath} ${versionArg}'!` }; + return { exe: exePath, version: version }; } // Check timestamp `key` to avoid automatically checking for updates @@ -103,21 +113,47 @@ export async function shouldCheckUpdate(context: vscode.ExtensionContext, key: s return true; } +export function getZigArchName(): string { + switch (process.arch) { + case "ia32": + return "x86"; + case "x64": + return "x86_64"; + case "arm": + return "armv7a"; + case "arm64": + return "aarch64"; + case "ppc": + return "powerpc"; + case "ppc64": + return "powerpc64le"; + default: + return process.arch; + } +} +export function getZigOSName(): string { + switch (process.platform) { + case "darwin": + return "macos"; + case "win32": + return "windows"; + default: + return process.platform; + } +} + export function getHostZigName(): string { - let platform: string = process.platform; - if (platform === "darwin") platform = "macos"; - if (platform === "win32") platform = "windows"; - let arch: string = process.arch; - if (arch === "ia32") arch = "x86"; - if (arch === "x64") arch = "x86_64"; - if (arch === "arm") arch = "armv7a"; - if (arch === "arm64") arch = "aarch64"; - if (arch === "ppc") arch = "powerpc"; - if (arch === "ppc64") arch = "powerpc64le"; - return `${arch}-${platform}`; + return `${getZigArchName()}-${getZigOSName()}`; } -export function getVersion(filePath: string, arg: string): semver.SemVer | null { +export function getVersion( + filePath: string, + /** + * The command-line argument that is used to query the version of the executable. + * Zig uses `version`. ZLS uses `--version`. + */ + arg: string, +): semver.SemVer | null { try { const buffer = childProcess.execFileSync(filePath, [arg]); const versionString = buffer.toString("utf8").trim(); @@ -131,93 +167,23 @@ export function getVersion(filePath: string, arg: string): semver.SemVer | null } } -export async function downloadAndExtractArtifact( - /** e.g. `Zig` or `ZLS` */ - title: string, - /** e.g. `zig` or `zls` */ - executableName: string, - /** e.g. inside `context.globalStorageUri` */ - installDir: vscode.Uri, - artifactUrl: string, - /** The expected sha256 hash (in hex) of the artifact/tarball. */ - sha256: string, - /** Extract arguments that should be passed to `tar`. e.g. `--strip-components=1` */ - extraTarArgs: string[], -): Promise { - assert.strictEqual(sha256.length, 64); - - return await vscode.window.withProgress( - { - title: `Installing ${title}`, - location: vscode.ProgressLocation.Notification, - }, - async (progress) => { - progress.report({ message: `downloading ${title} tarball...` }); - const response = await axios.get(artifactUrl, { - responseType: "arraybuffer", - onDownloadProgress: (progressEvent) => { - if (progressEvent.total) { - const increment = (progressEvent.bytes / progressEvent.total) * 100; - progress.report({ - message: progressEvent.progress - ? `downloading tarball ${(progressEvent.progress * 100).toFixed()}%` - : "downloading tarball...", - increment: increment, - }); - } - }, - }); - const tarHash = crypto.createHash("sha256").update(response.data).digest("hex"); - if (tarHash !== sha256) { - throw Error(`hash of downloaded tarball ${tarHash} does not match expected hash ${sha256}`); - } - - const tarPath = await which("tar", { nothrow: true }); - if (!tarPath) { - void vscode.window.showErrorMessage( - `Downloaded ${title} tarball can't be extracted because 'tar' could not be found`, - ); - return null; - } - - const tarballUri = vscode.Uri.joinPath(installDir, path.basename(artifactUrl)); - - try { - await vscode.workspace.fs.delete(installDir, { recursive: true, useTrash: false }); - } catch {} - await vscode.workspace.fs.createDirectory(installDir); - await vscode.workspace.fs.writeFile(tarballUri, response.data); - - progress.report({ message: "Extracting..." }); - try { - await execFile(tarPath, ["-xf", tarballUri.fsPath, "-C", installDir.fsPath].concat(extraTarArgs), { - timeout: 60000, // 60 seconds - }); - } catch (err) { - if (err instanceof Error) { - void vscode.window.showErrorMessage(`Failed to extract ${title} tarball: ${err.message}`); - } else { - throw err; - } - return null; - } finally { - try { - await vscode.workspace.fs.delete(tarballUri, { useTrash: false }); - } catch {} - } - - progress.report({ message: "Installing..." }); - - const isWindows = process.platform === "win32"; - const exeName = `${executableName}${isWindows ? ".exe" : ""}`; - const exePath = vscode.Uri.joinPath(installDir, exeName).fsPath; - await chmod(exePath, 0o755); - - return exePath; - }, - ); +export interface ZigVersion { + name: string; + version: semver.SemVer; + url: string; + sha: string; + notes?: string; + isMach: boolean; } +export type VersionIndex = Record< + string, + { + version?: string; + notes?: string; + } & Record +>; + export function getWorkspaceFolder(filePath: string): vscode.WorkspaceFolder | undefined { const workspaceFolder = vscode.workspace.getWorkspaceFolder(vscode.Uri.file(filePath)); if (!workspaceFolder && vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0) { diff --git a/src/zls.ts b/src/zls.ts index 214f136..d8e15df 100644 --- a/src/zls.ts +++ b/src/zls.ts @@ -15,36 +15,66 @@ import axios from "axios"; import camelCase from "camelcase"; import semver from "semver"; -import { - downloadAndExtractArtifact, - getExePath, - getHostZigName, - getVersion, - getZigPath, - handleConfigOption, - shouldCheckUpdate, -} from "./zigUtil"; - -let outputChannel: vscode.OutputChannel; -export let client: LanguageClient | null = null; +import * as minisign from "./minisign"; +import * as versionManager from "./versionManager"; +import { getHostZigName, handleConfigOption, resolveExePathAndVersion } from "./zigUtil"; +import { zigProvider } from "./zigSetup"; const ZIG_MODE: DocumentSelector = [ { language: "zig", scheme: "file" }, { language: "zig", scheme: "untitled" }, ]; -async function startClient() { +let versionManagerConfig: versionManager.Config; +let statusItem: vscode.LanguageStatusItem; +let outputChannel: vscode.OutputChannel; +export let client: LanguageClient | null = null; + +export async function restartClient(context: vscode.ExtensionContext): Promise { + const result = await getZLSPath(context); + + if (!result) { + await stopClient(); + updateStatusItem(null); + return; + } + + try { + const newClient = await startClient(result.exe, result.version); + await stopClient(); + client = newClient; + updateStatusItem(result.version); + } catch (reason) { + if (reason instanceof Error) { + void vscode.window.showWarningMessage(`Failed to run Zig Language Server (ZLS): ${reason.message}`); + } else { + void vscode.window.showWarningMessage("Failed to run Zig Language Server (ZLS)"); + } + updateStatusItem(null); + } +} + +async function startClient(zlsPath: string, zlsVersion: semver.SemVer): Promise { const configuration = vscode.workspace.getConfiguration("zig.zls"); const debugLog = configuration.get("debugLog", false); - const zlsPath = getZLSPath(); + const args: string[] = []; + + if (debugLog) { + /** `--enable-debug-log` has been deprecated in favor of `--log-level`. https://github.com/zigtools/zls/pull/1957 */ + const zlsCLIRevampVersion = new semver.SemVer("0.14.0-50+3354fdc"); + if (semver.lt(zlsVersion, zlsCLIRevampVersion)) { + args.push("--enable-debug-log"); + } else { + args.push("--log-level", "debug"); + } + } const serverOptions: ServerOptions = { command: zlsPath, - args: debugLog ? ["--enable-debug-log"] : [], + args: args, }; - // Options to control the language client const clientOptions: LanguageClientOptions = { documentSelector: ZIG_MODE, outputChannel, @@ -55,42 +85,63 @@ async function startClient() { }, }; - // Create the language client and start the client. - client = new LanguageClient("zig.zls", "Zig Language Server", serverOptions, clientOptions); - - return client - .start() - .catch((reason: unknown) => { - if (reason instanceof Error) { - void vscode.window.showWarningMessage(`Failed to run Zig Language Server (ZLS): ${reason.message}`); - } else { - void vscode.window.showWarningMessage("Failed to run Zig Language Server (ZLS)"); - } - client = null; - }) - .then(() => { - if (client && vscode.workspace.getConfiguration("zig").get("formattingProvider") !== "zls") { - client.getFeature("textDocument/formatting").dispose(); - } - }); + const languageClient = new LanguageClient("zig.zls", "Zig Language Server", serverOptions, clientOptions); + await languageClient.start(); + // Formatting is handled by `zigFormat.ts` + languageClient.getFeature("textDocument/formatting").dispose(); + return languageClient; } -export async function stopClient() { - if (client) { - // The `stop` call will send the "shutdown" notification to the LSP - await client.stop(); - // The `dipose` call will send the "exit" request to the LSP which actually tells the child process to exit - await client.dispose(); - } +async function stopClient(): Promise { + if (!client) return; + // The `stop` call will send the "shutdown" notification to the LSP + await client.stop(); + // The `dipose` call will send the "exit" request to the LSP which actually tells the child process to exit + await client.dispose(); client = null; } /** returns the file system path to the zls executable */ -export function getZLSPath(): string { +async function getZLSPath(context: vscode.ExtensionContext): Promise<{ exe: string; version: semver.SemVer } | null> { const configuration = vscode.workspace.getConfiguration("zig.zls"); - const zlsPath = configuration.get("path"); - const exePath = zlsPath !== "zls" ? zlsPath : null; // the string "zls" means lookup in PATH - return getExePath(exePath, "zls", "zig.zls.path"); + let zlsExePath = configuration.get("path"); + let zlsVersion: semver.SemVer | null = null; + + if (!!zlsExePath) { + // This will fail on older ZLS version that do not support `zls --version`. + // It should be more likely that the given executable is invalid than someone using ZLS 0.9.0 or older. + const result = resolveExePathAndVersion(zlsExePath, "zls", "zig.zls.path", "--version"); + if ("message" in result) { + void vscode.window.showErrorMessage(result.message); + return null; + } + return result; + } + + if (configuration.get<"ask" | "off" | "on">("enabled", "ask") !== "on") return null; + + const zigVersion = zigProvider.getZigVersion(); + if (!zigVersion) return null; + + const result = await fetchVersion(context, zigVersion, true); + if (!result) return null; + + try { + zlsExePath = await versionManager.install(versionManagerConfig, result.version); + zlsVersion = result.version; + } catch (err) { + if (err instanceof Error) { + void vscode.window.showErrorMessage(`Failed to install ZLS ${result.version.toString()}: ${err.message}`); + } else { + void vscode.window.showErrorMessage(`Failed to install ZLS ${result.version.toString()}!`); + } + return null; + } + + return { + exe: zlsExePath, + version: zlsVersion, + }; } async function configurationMiddleware( @@ -129,13 +180,7 @@ async function configurationMiddleware( const indexOfZigPath = optionIndices["zig.path"]; if (indexOfZigPath !== undefined) { - try { - result[indexOfZigPath] = getZigPath(); - } catch { - // ZLS will try to find Zig by itself and likely fail as well. - // This will cause two "Zig can't be found in $PATH" error messages to be reported. - result[indexOfZigPath] = null; - } + result[indexOfZigPath] = zigProvider.getZigPath(); } const additionalOptions = configuration.get>("additionalOptions", {}); @@ -180,7 +225,7 @@ async function configurationMiddleware( } /** - * Similar to https://ziglang.org/download/index.json + * Similar to https://builds.zigtools.org/index.json */ interface SelectVersionResponse { /** The ZLS version */ @@ -190,7 +235,7 @@ interface SelectVersionResponse { [artifact: string]: ArtifactEntry | string | undefined; } -export interface SelectVersionFailureResponse { +interface SelectVersionFailureResponse { /** * The `code` **may** be one of `SelectVersionFailureCode`. Be aware that new * codes can be added over time. @@ -210,9 +255,14 @@ interface ArtifactEntry { } async function fetchVersion( + context: vscode.ExtensionContext, zigVersion: semver.SemVer, + useCache: boolean, ): Promise<{ version: semver.SemVer; artifact: ArtifactEntry } | null> { - let response: SelectVersionResponse | SelectVersionFailureResponse; + // Should the cache be periodically cleared? + const cacheKey = `zls-select-version-${zigVersion.raw}`; + + let response: SelectVersionResponse | SelectVersionFailureResponse | null = null; try { response = ( await axios.get( @@ -226,13 +276,25 @@ async function fetchVersion( }, ) ).data; + + // Cache the response + if (useCache) { + await context.globalState.update(cacheKey, response); + } } catch (err) { - if (err instanceof Error) { - void vscode.window.showErrorMessage(`Failed to query ZLS version: ${err.message}`); - } else { - throw err; + // Try to read the result from cache + if (useCache) { + response = context.globalState.get(cacheKey) ?? null; + } + + if (!response) { + if (err instanceof Error) { + void vscode.window.showErrorMessage(`Failed to query ZLS version: ${err.message}`); + } else { + throw err; + } + return null; } - return null; } if ("message" in response) { @@ -255,157 +317,146 @@ async function fetchVersion( }; } -// checks whether there is newer version on master -async function checkUpdate(context: vscode.ExtensionContext) { - const configuration = vscode.workspace.getConfiguration("zig.zls"); - const zlsPath = configuration.get("path"); - const zlsBinPath = vscode.Uri.joinPath(context.globalStorageUri, "zls_install", "zls").fsPath; - if (!zlsPath?.startsWith(zlsBinPath)) return; - - const zigVersion = getVersion(getZigPath(), "version"); - if (!zigVersion) return; - - const currentVersion = getVersion(zlsPath, "--version"); - if (!currentVersion) return; - - const result = await fetchVersion(zigVersion); - if (!result) return; - - if (semver.gte(currentVersion, result.version)) return; - - const response = await vscode.window.showInformationMessage("New version of ZLS available", "Install", "Ignore"); - switch (response) { - case "Install": - await installZLSVersion(context, result.artifact); - break; - case "Ignore": - case undefined: - break; +async function isEnabled(): Promise { + const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); + if (!!zlsConfig.get("path")) return true; + + switch (zlsConfig.get<"ask" | "off" | "on">("enabled", "ask")) { + case "on": + return true; + case "off": + return false; + case "ask": { + const response = await vscode.window.showInformationMessage( + "We recommend enabling the ZLS Language Server for a better editing experience. Would you like to install it?", + { modal: true }, + "Yes", + "No", + ); + switch (response) { + case "Yes": + await zlsConfig.update("enabled", "on", true); + return true; + case "No": + await zlsConfig.update("enabled", "off", true); + return false; + case undefined: + return false; + } + } } } -export async function installZLS(context: vscode.ExtensionContext, ask: boolean) { - const zigVersion = getVersion(getZigPath(), "version"); - if (!zigVersion) { - const zlsConfiguration = vscode.workspace.getConfiguration("zig.zls", null); - await zlsConfiguration.update("path", undefined, true); - return undefined; - } - - const result = await fetchVersion(zigVersion); - if (!result) return; - - if (ask) { - const selected = await vscode.window.showInformationMessage( - `Do you want to install ZLS (the Zig Language Server) for Zig version ${result.version.toString()}`, - "Install", - "Ignore", - ); - switch (selected) { - case "Install": - break; - case "Ignore": - const zlsConfiguration = vscode.workspace.getConfiguration("zig.zls", null); - await zlsConfiguration.update("path", undefined, true); - return; - case undefined: - return; +function updateStatusItem(version: semver.SemVer | null) { + if (version) { + statusItem.text = `ZLS ${version.toString()}`; + statusItem.detail = "ZLS Version"; + statusItem.severity = vscode.LanguageStatusSeverity.Information; + statusItem.command = { + title: "View Output", + command: "zig.zls.openOutput", + }; + } else { + statusItem.text = "ZLS not enabled"; + statusItem.detail = undefined; + statusItem.severity = vscode.LanguageStatusSeverity.Error; + const zigPath = zigProvider.getZigPath(); + const zigVersion = zigProvider.getZigVersion(); + if (zigPath !== null && zigVersion !== null) { + statusItem.command = { + title: "Enable", + command: "zig.zls.enable", + }; + } else { + statusItem.command = undefined; } } - - await installZLSVersion(context, result.artifact); } -async function installZLSVersion(context: vscode.ExtensionContext, artifact: ArtifactEntry) { - const zlsPath = await downloadAndExtractArtifact( - "ZLS", - "zls", - vscode.Uri.joinPath(context.globalStorageUri, "zls_install"), - artifact.tarball, - artifact.shasum, - [], - ); - - const zlsConfiguration = vscode.workspace.getConfiguration("zig.zls", null); - await zlsConfiguration.update("path", zlsPath ?? undefined, true); -} +export async function activate(context: vscode.ExtensionContext) { + { + // This check can be removed once enough time has passed so that most users switched to the new value -function checkInstalled(): boolean { - const zlsPath = vscode.workspace.getConfiguration("zig.zls").get("path"); - if (!zlsPath) { - void vscode.window.showErrorMessage("This command cannot be run without setting 'zig.zls.path'.", { - modal: true, - }); - return false; + // remove the `zls_install` directory from the global storage + try { + await vscode.workspace.fs.delete(vscode.Uri.joinPath(context.globalStorageUri, "zls_install"), { + recursive: true, + useTrash: false, + }); + } catch {} + + // convert a `zig.zls.path` that points to the global storage to `zig.zls.enabled == "on"` + const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); + const zlsPath = zlsConfig.get("path", ""); + if (zlsPath.startsWith(context.globalStorageUri.fsPath)) { + await zlsConfig.update("enabled", "on", true); + await zlsConfig.update("path", undefined, true); + } } - return true; -} -export async function activate(context: vscode.ExtensionContext) { + versionManagerConfig = { + context: context, + title: "ZLS", + exeName: "zls", + extraTarArgs: [], + /** https://github.com/zigtools/release-worker */ + minisignKey: minisign.parseKey("RWR+9B91GBZ0zOjh6Lr17+zKf5BoSuFvrx2xSeDE57uIYvnKBGmMjOex"), + versionArg: "--version", + mirrorUrls: [], + canonicalUrl: { + release: vscode.Uri.parse("https://builds.zigtools.org"), + nightly: vscode.Uri.parse("https://builds.zigtools.org"), + }, + }; + outputChannel = vscode.window.createOutputChannel("Zig Language Server"); + statusItem = vscode.languages.createLanguageStatusItem("zig.zls.status", ZIG_MODE); + statusItem.name = "ZLS"; + updateStatusItem(null); context.subscriptions.push( outputChannel, - vscode.commands.registerCommand("zig.zls.install", async () => { - try { - getZigPath(); - } catch { - void vscode.window.showErrorMessage("This command cannot be run without a valid zig path.", { - modal: true, - }); - return; - } - - await stopClient(); - await installZLS(context, false); + statusItem, + vscode.commands.registerCommand("zig.zls.enable", async () => { + const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); + await zlsConfig.update("enabled", "on"); }), vscode.commands.registerCommand("zig.zls.stop", async () => { - if (!checkInstalled()) return; - await stopClient(); }), vscode.commands.registerCommand("zig.zls.startRestart", async () => { - if (!checkInstalled()) return; - - await stopClient(); - await startClient(); + const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); + await zlsConfig.update("enabled", "on"); + await restartClient(context); }), - vscode.commands.registerCommand("zig.zls.update", async () => { - if (!checkInstalled()) return; - - await stopClient(); - await checkUpdate(context); + vscode.commands.registerCommand("zig.zls.openOutput", () => { + outputChannel.show(); }), + ); + + if (await isEnabled()) { + await restartClient(context); + } + + // These checks are added later to avoid ZLS be started twice because `isEnabled` sets `zig.zls.enabled`. + context.subscriptions.push( vscode.workspace.onDidChangeConfiguration(async (change) => { + // The `zig.path` config option is handled by `zigProvider.onChange`. if ( + change.affectsConfiguration("zig.zls.enabled", undefined) || change.affectsConfiguration("zig.zls.path", undefined) || change.affectsConfiguration("zig.zls.debugLog", undefined) ) { - await stopClient(); - const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); - if (zlsConfig.get("path")) { - await startClient(); - } - } - if (client && change.affectsConfiguration("zig.formattingProvider", undefined)) { - client.getFeature("textDocument/formatting").dispose(); - if (vscode.workspace.getConfiguration("zig").get("formattingProvider") === "zls") { - client - .getFeature("textDocument/formatting") - .initialize(client.initializeResult?.capabilities ?? {}, ZIG_MODE); - } + await restartClient(context); } }), + zigProvider.onChange.event(async () => { + await restartClient(context); + }), ); - - const zlsConfig = vscode.workspace.getConfiguration("zig.zls"); - if (!zlsConfig.get("path")) return; - if (zlsConfig.get("checkForUpdate") && (await shouldCheckUpdate(context, "zlsUpdate"))) { - await checkUpdate(context); - } - await startClient(); } -export function deactivate(): Thenable { - return stopClient(); +export async function deactivate(): Promise { + await stopClient(); + await versionManager.removeUnusedInstallations(versionManagerConfig); }