diff --git a/.gitignore b/.gitignore index 6d88433..41c8fa3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ node_modules/ out/ +# jetbrains ides cache +.idea/ + .pnpm-debug.log *.ast diff --git a/README.md b/README.md index 5b3d27a..fd2386d 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,10 @@ SimplicityHL is a high-level language for writing Simplicity smart contracts. Si - Syntax highlighting and snippets for `.simf` and `.wit` files - Compiler diagnostics, completion, hover, signature help, symbols, references, and go to definition - Opt-in imports and enums support in the language server and direct compiler commands +- Formatting of SimplicityHL code using default VSCode bindings -The extension installs or connects to the [SimplicityHL language server][4], which provides language intelligence: +The extension installs or connects to the [SimplicityHL language server][4], which provides language intelligence. +To provide concise and clear code you can also use the [simfmt][5] formatter. - Error diagnostics ![diagnostics][6] @@ -20,6 +22,9 @@ The extension installs or connects to the [SimplicityHL language server][4], whi - Completion of user-defined functions, imported items, built-ins, and jets ![completion][7] +- Formatting of SimplicityHL code +![formatting][8] + ## Experimental features Open Settings and search for `SimplicityHL: Experimental Features`. `Imports` and `Enums` are independent checkboxes and both are disabled by default. @@ -43,4 +48,5 @@ To install the extension manually or hack on the source code see [development.md [5]: https://github.com/BlockstreamResearch/simfmt [6]: https://raw.githubusercontent.com/BlockstreamResearch/simplicityhl-vscode/master/assets/error_diagnostic.gif [7]: https://raw.githubusercontent.com/BlockstreamResearch/simplicityhl-vscode/master/assets/completion.gif -[8]: https://raw.githubusercontent.com/BlockstreamResearch/simplicityhl-vscode/master/docs/development.md \ No newline at end of file +[8]: https://raw.githubusercontent.com/BlockstreamResearch/simplicityhl-vscode/master/assets/formatting.gif +[9]: https://raw.githubusercontent.com/BlockstreamResearch/simplicityhl-vscode/master/docs/development.md \ No newline at end of file diff --git a/assets/formatting.gif b/assets/formatting.gif new file mode 100644 index 0000000..e182eb3 Binary files /dev/null and b/assets/formatting.gif differ diff --git a/package-lock.json b/package-lock.json index b3d1631..9ecd02d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,11 +9,13 @@ "version": "0.4.0", "license": "MIT", "dependencies": { + "semver": "^7.8.5", "vscode-languageclient": "^10.1.0" }, "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^22", + "@types/semver": "^7.8.0", "@types/vscode": "1.91.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.25.9", @@ -1266,7 +1268,6 @@ "integrity": "sha512-Gd33J2XIrXurb+eT2ktze3rJAfAp9ZNjlBdh4SVgyrKEOADwCbdUDaK7QgJno8Ue4kcajscsKqu6n8OBG3hhCQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -1285,6 +1286,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-1mAINjtQCXXeLkJ9ehXkwOcBpqtLxiVtKhpUf83DdRNdQKV0iXZpaHYqRr7nj+wvxuJzoAmAwXI+sCNMv1CzLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/vscode": { "version": "1.91.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.91.0.tgz", @@ -1327,7 +1335,6 @@ "integrity": "sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.65.0", "@typescript-eslint/types": "8.65.0", @@ -1726,7 +1733,6 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2633,7 +2639,6 @@ "integrity": "sha512-nuKKvN+oIBO0koN7Tm7dlkmnkc21mtt0QJLwAKzjLq14y6lRTdVG36MZHJ8eQHwdJMwZbQNMlPOYedMq/oVJvQ==", "dev": true, "license": "MIT", - "peer": true, "workspaces": [ "packages/*" ], @@ -3519,7 +3524,6 @@ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "jiti": "lib/jiti-cli.mjs" } @@ -5515,7 +5519,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index 17c4aa0..8a556f4 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,9 @@ "Programming Languages" ], "main": "dist/extension.js", + "activationEvents": [ + "onStartupFinished" + ], "contributes": { "languages": [ { @@ -95,6 +98,27 @@ "default": "", "description": "Path to the simc compiler. If empty, searches PATH and common locations." }, + "simplicityhl.suppressMissingFormatterWarning": { + "type": "boolean", + "default": false, + "description": "Do not show missing formatter executable warning." + }, + "simplicityhl.formatter.path": { + "type": "string", + "default": "", + "scope": "machine-overridable", + "description": "Path to the simfmt formatter. If empty, searches PATH and common locations." + }, + "simplicityhl.formatter.autoSaveBeforeFormat": { + "type": "boolean", + "default": true, + "description": "Automatically save the file before formatting." + }, + "simplicityhl.formatter.disableAutoupdate": { + "type": "boolean", + "default": false, + "description": "Do not autoupdate simfmt formatter." + }, "simplicityhl.build.autoSaveBeforeCompile": { "type": "boolean", "default": true, @@ -118,6 +142,11 @@ } } ], + "configurationDefaults": { + "[simplicityhl]": { + "editor.defaultFormatter": "Blockstream.simplicityhl" + } + }, "commands": [ { "command": "simplicityhl.restartServer", @@ -144,6 +173,11 @@ "command": "simplicityhl.compileJson", "title": "Compile to JSON", "category": "SimplicityHL" + }, + { + "command": "simplicityhl.formatFile", + "title": "Format Current File", + "category": "SimplicityHL" } ], "menus": { @@ -164,6 +198,11 @@ "when": "resourceLangId == simplicityhl", "command": "simplicityhl.compileWithWitness", "group": "1_modification" + }, + { + "when": "resourceLangId == simplicityhl", + "command": "simplicityhl.formatFile", + "group": "1_modification" } ], "commandPalette": [ @@ -182,6 +221,10 @@ { "command": "simplicityhl.compileJson", "when": "resourceLangId == simplicityhl" + }, + { + "command": "simplicityhl.formatFile", + "when": "resourceLangId == simplicityhl" } ] }, @@ -240,7 +283,7 @@ "compile": "npm run check-types && node esbuild.mjs --production", "check": "npm test && npm run eslint-check && npm run compile", "check-types": "tsc --noEmit", - "test": "node --test -r ts-node/register src/contracts.test.ts src/find_executable.test.ts", + "test": "node --test -r ts-node/register src/contracts.test.ts src/find_executable.test.ts src/update/binary_update_state.test.ts src/update/crates_io.test.ts src/format/updater.test.ts src/format/output.test.ts", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.mjs --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", @@ -254,6 +297,7 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@types/node": "^22", + "@types/semver": "^7.8.0", "@types/vscode": "1.91.0", "@vscode/vsce": "^3.9.2", "esbuild": "^0.25.9", @@ -266,6 +310,7 @@ "typescript-eslint": "^8.65.0" }, "dependencies": { + "semver": "^7.8.5", "vscode-languageclient": "^10.1.0" } } diff --git a/src/commands/format.ts b/src/commands/format.ts new file mode 100644 index 0000000..cf97335 --- /dev/null +++ b/src/commands/format.ts @@ -0,0 +1,36 @@ +// Command and native provider registration for SimplicityHL formatting. + +import * as vscode from "vscode"; +import { COMMAND_IDS, LANGUAGE_IDS } from "../contracts"; + +import { getActiveSimplicityHLDocument, DocumentAction } from "../format/document"; +import type { SimplicityHLFormatter } from "../format"; + +export function registerFormattingCommands( + context: vscode.ExtensionContext, + formatter: () => SimplicityHLFormatter, +): void { + const formatFileCommand = vscode.commands.registerCommand( + COMMAND_IDS.formatFile, + async () => { + const document = await getActiveSimplicityHLDocument({ + action: DocumentAction.Format, + }); + if (!document) return; + + await formatter().formatDocument(document); + }, + ); + + const provider = vscode.languages.registerDocumentFormattingEditProvider( + { language: LANGUAGE_IDS.source }, + { + provideDocumentFormattingEdits(document, options, token) { + if (token.isCancellationRequested) return undefined; + return formatter().provideDocumentFormattingEdits(document, options, token); + }, + }, + ); + + context.subscriptions.push(formatFileCommand, provider); +} diff --git a/src/contracts.test.ts b/src/contracts.test.ts index 67f1ac5..2a0b6f4 100644 --- a/src/contracts.test.ts +++ b/src/contracts.test.ts @@ -3,7 +3,7 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { test } from "node:test"; -import { SETTINGS, languageClientOptions } from "./contracts"; +import { COMMAND_IDS, SETTINGS, languageClientOptions } from "./contracts"; void test("client languages and consumed settings match package contributions", () => { const manifest = JSON.parse( @@ -24,7 +24,15 @@ void test("client languages and consumed settings match package contributions", ), ) as Record; - for (const setting of [SETTINGS.serverPath, SETTINGS.imports, SETTINGS.enums]) { + for (const setting of [ + SETTINGS.serverPath, + SETTINGS.imports, + SETTINGS.enums, + SETTINGS.suppressMissingFormatterWarning, + SETTINGS.formatterPath, + SETTINGS.autoSaveBeforeFormat, + SETTINGS.formatterDisableAutoupdate, + ]) { const contribution = contributedSettings[ `${clientOptions.synchronize.configurationSection}.${setting.key}` @@ -32,4 +40,9 @@ void test("client languages and consumed settings match package contributions", assert.ok(contribution, `Missing package contribution for ${setting.key}`); assert.equal(contribution.default, setting.default); } + + assert.ok(contributions.commands.some( + ({ command }: { command: string }) => command === COMMAND_IDS.formatFile, + )); + assert.deepEqual(manifest.activationEvents, ["onStartupFinished"]); }); diff --git a/src/contracts.ts b/src/contracts.ts index dc1356b..193d8b3 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -1,20 +1,54 @@ export const CONFIGURATION_SECTION = "simplicityhl"; export const LANGUAGE_CLIENT_ID = "simplicityhlLspClient"; export const LANGUAGE_CLIENT_NAME = "SimplicityHL LSP"; -export const SERVER_BINARY = "simplicityhl-lsp"; export const TASK_TYPE = "simplicityhl"; +export const OUTPUT_CHANNEL_NAMES = { + formatter: "SimplicityHL Formatter", +} as const; + export const LANGUAGE_IDS = { source: "simplicityhl", witness: "simplicityhl-witness", } as const; +export enum ManagedBinary { + Simfmt = "simfmt", + LanguageServer = "simplicityhl-lsp", +} + +export enum CrateName { + Simfmt = "simfmt", + LanguageServer = "simplicityhl-lsp", +} + +export const MANAGED_BINARY_CRATE_NAMES: Record< + ManagedBinary, + CrateName +> = { + [ManagedBinary.Simfmt]: CrateName.Simfmt, + [ManagedBinary.LanguageServer]: CrateName.LanguageServer, +}; + +export const MANAGED_BINARY_INFO: Record< + ManagedBinary, + { displayName: string } +> = { + [ManagedBinary.Simfmt]: { + displayName: "SimplicityHL formatter", + }, + [ManagedBinary.LanguageServer]: { + displayName: "SimplicityHL language server", + }, +}; + export const COMMAND_IDS = { restartServer: "simplicityhl.restartServer", compileFile: "simplicityhl.compileFile", compileFileDebug: "simplicityhl.compileFileDebug", compileWithWitness: "simplicityhl.compileWithWitness", compileJson: "simplicityhl.compileJson", + formatFile: "simplicityhl.formatFile", } as const; export const TASK_COMMANDS = [ @@ -40,6 +74,22 @@ export const SETTINGS = { key: "compiler.path", default: "", }, + suppressMissingFormatterWarning: { + key: "suppressMissingFormatterWarning", + default: false, + }, + formatterPath: { + key: "formatter.path", + default: "", + }, + autoSaveBeforeFormat: { + key: "formatter.autoSaveBeforeFormat", + default: true, + }, + formatterDisableAutoupdate: { + key: "formatter.disableAutoupdate", + default: false, + }, autoSaveBeforeCompile: { key: "build.autoSaveBeforeCompile", default: true, diff --git a/src/extension.ts b/src/extension.ts index b312087..e814c55 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,23 +6,31 @@ import { ExtensionContext, commands } from "vscode"; import { LspClient } from "./lsp/client"; import { SimplicityHLCompiler } from "./compiler"; import { registerCompileCommands } from "./commands/compile"; -import { COMMAND_IDS } from "./contracts"; +import { SimplicityHLFormatter } from "./format"; +import { getSimfmtPath } from "./format/install"; +import { registerFormattingCommands } from "./commands/format"; +import { COMMAND_IDS, ManagedBinary } from "./contracts"; import { registerTaskProvider } from "./tasks/provider"; +import { BinaryUpdateEventState, } from "./update/binary_update_state"; let client: LspClient | undefined; let compiler: SimplicityHLCompiler | undefined; +let formatter: SimplicityHLFormatter | undefined; export function activate(context: ExtensionContext): void { + const updateState = updateSimfmt(context); + // Initialize LSP client for language intelligence (also shows status bar) const lspClient = new LspClient(context); client = lspClient; void lspClient.start(); // Register all commands and providers - context.subscriptions.push(commands.registerCommand( - COMMAND_IDS.restartServer, - () => lspClient.restart(), - )); + context.subscriptions.push( + commands.registerCommand(COMMAND_IDS.restartServer, () => + lspClient.restart(), + ), + ); // Compile commands (Cmd+Shift+B, etc.) registerCompileCommands(context, () => { if (!compiler) { @@ -32,13 +40,37 @@ export function activate(context: ExtensionContext): void { return compiler; }); registerTaskProvider(context); // Task integration (Tasks: Run Task) + + // Formatting commands (Cmd+Shift+F, etc.) + registerFormattingCommands(context, () => { + if (!formatter) { + formatter = new SimplicityHLFormatter(updateState); + context.subscriptions.push(formatter); + } + return formatter; + }); } export async function deactivate(): Promise { const activeClient = client; const activeCompiler = compiler; + const activeFormatter = formatter; client = undefined; compiler = undefined; + formatter = undefined; activeCompiler?.dispose(); + activeFormatter?.dispose(); await activeClient?.shutdown(); } + +function updateSimfmt(context: ExtensionContext): BinaryUpdateEventState { + const updateState = new BinaryUpdateEventState(context.globalState); + void updateState + .markPending(ManagedBinary.Simfmt) + .then(() => getSimfmtPath(updateState)) + .catch((error) => { + console.warn("Unable to update simfmt during extension activation.", error); + }); + + return updateState; +} diff --git a/src/format/document.ts b/src/format/document.ts new file mode 100644 index 0000000..9af4f55 --- /dev/null +++ b/src/format/document.ts @@ -0,0 +1,94 @@ +// Shared helpers for selecting and preparing SimplicityHL documents. + +import * as vscode from "vscode"; +import { LANGUAGE_IDS } from "../contracts"; + +export enum DocumentAction { + Compile = "compile", + Format = "format", +} + +interface ActiveDocumentOptions { + action: DocumentAction; + saveBeforeAction?: boolean; + requireFilePath?: boolean; + failIfSaveFails?: boolean; +} + +interface DocumentPreparationOptions { + action: DocumentAction; + saveBeforeAction?: boolean; + requireFilePath?: boolean; + failIfSaveFails?: boolean; + // Reject dirty documents when autosave is disabled or saving fails. + requireSaved?: boolean; +} + +type DocumentPreparationResult = + | { document: vscode.TextDocument } + | { error: string }; + +export async function getActiveSimplicityHLDocument( + options: ActiveDocumentOptions, +): Promise { + const editor = vscode.window.activeTextEditor; + if (!editor) { + void vscode.window.showWarningMessage(`No active file to ${options.action}`); + return undefined; + } + + const preparationOptions: DocumentPreparationOptions = { + action: options.action, + saveBeforeAction: options.saveBeforeAction, + requireFilePath: options.requireFilePath, + failIfSaveFails: options.failIfSaveFails, + }; + const result = await prepareSimplicityHLDocument(editor.document, preparationOptions); + if ("error" in result) { + void vscode.window.showWarningMessage(result.error); + return undefined; + } + + return result.document; +} + +// Validates and optionally saves the supplied document without displaying notifications. +export async function prepareSimplicityHLDocument( + document: vscode.TextDocument, + options: DocumentPreparationOptions, +): Promise { + if (document.languageId !== LANGUAGE_IDS.source) { + return { error: "Current file is not a SimplicityHL file (.simf)" }; + } + + if (options.requireFilePath && (document.uri.scheme !== "file" || !document.uri.fsPath)) { + return { error: getSaveWarningMessage(options.action) }; + } + + if (options.requireSaved && document.isDirty && !options.saveBeforeAction) { + return { error: getSaveWarningMessage(options.action) }; + } + + if (options.saveBeforeAction && document.isDirty) { + const saved = await document.save(); + if (!saved && (options.failIfSaveFails || options.requireSaved)) { + return { error: getSaveWarningMessage(options.action) }; + } + } + + return { document }; +} + +function getSaveWarningMessage(action: DocumentPreparationOptions["action"]): string { + let actionName: string; + switch (action) { + case DocumentAction.Compile: + actionName = "compiling"; + break; + case DocumentAction.Format: + actionName = "formatting"; + break; + } + + return `Save the SimplicityHL document before ${actionName} it.`; +} diff --git a/src/format/index.ts b/src/format/index.ts new file mode 100644 index 0000000..e82e308 --- /dev/null +++ b/src/format/index.ts @@ -0,0 +1,211 @@ +// Formatter interface for SimplicityHL. +// Wraps the `simfmt` binary and reports its output for VS Code integration. + +import * as vscode from "vscode"; +import * as cp from "node:child_process"; +import * as path from "node:path"; +import { getSimfmtPath } from "./install"; +import { getFailureNotification } from "./output"; +import { + CONFIGURATION_SECTION, + OUTPUT_CHANNEL_NAMES, + SETTINGS, +} from "../contracts"; +import { prepareSimplicityHLDocument, DocumentAction, } from "./document"; +import type { BinaryUpdateEventState } from "../update/binary_update_state"; + +const FORMATTER_ARGS = ["--color", "never"]; +const SHUTDOWN_MESSAGE = "Formatting canceled during extension shutdown"; +const SHOW_OUTPUT_ACTION = "Show Output"; + +export interface FormatResult { + success: boolean; + output: string; +} + +export class SimplicityHLFormatter implements vscode.DocumentFormattingEditProvider, vscode.Disposable { + private readonly outputChannel = vscode.window.createOutputChannel( + OUTPUT_CHANNEL_NAMES.formatter, + ); + private readonly activeChildren = new Set(); + private disposed = false; + + public constructor(private readonly updateState: BinaryUpdateEventState) {} + + // Release resources and stop formatter processes owned by this instance. + public dispose(): void { + if (this.disposed) return; + this.disposed = true; + for (const child of this.activeChildren) { + if (child.exitCode === null && child.signalCode === null) { + child.kill(); + } + } + this.outputChannel.dispose(); + } + + // Handles VS Code's native Format Document request for SimplicityHL files. + public async provideDocumentFormattingEdits( + document: vscode.TextDocument, + _options: vscode.FormattingOptions, + token: vscode.CancellationToken, + ): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const result = await this.formatDocument(document); + return result.success ? [] : undefined; + } + + // Saves, validates, and formats a single SimplicityHL document. + public async formatDocument(document: vscode.TextDocument): Promise { + if (this.disposed) { + return { success: false, output: SHUTDOWN_MESSAGE }; + } + this.outputChannel.clear(); + + const config = vscode.workspace.getConfiguration(CONFIGURATION_SECTION); + const prepared = await prepareSimplicityHLDocument(document, { + action: DocumentAction.Format, + saveBeforeAction: config.get( + SETTINGS.autoSaveBeforeFormat.key, + SETTINGS.autoSaveBeforeFormat.default, + ), + requireFilePath: true, + requireSaved: true, + }); + if (this.disposed) { + return { success: false, output: SHUTDOWN_MESSAGE }; + } + if ("error" in prepared) { + return this.failShort(prepared.error); + } + + let formatterPath: string; + try { + formatterPath = await getSimfmtPath(this.updateState); + } catch (error) { + return this.fail(getErrorMessage(error)); + } + if (this.disposed) { + return { success: false, output: SHUTDOWN_MESSAGE }; + } + + const filePath = prepared.document.uri.fsPath; + const args = [filePath, ...FORMATTER_ARGS]; + this.outputChannel.appendLine(`Formatting: ${filePath}`); + this.outputChannel.appendLine(`Command: ${formatCommand(formatterPath, args)}`); + this.outputChannel.appendLine(""); + + const result = await this.runFormatter(formatterPath, args, path.dirname(filePath)); + if (this.disposed) return result; + if (result.success) { + this.outputChannel.appendLine(`Formatting successful: ${filePath}`); + return result; + } + + this.outputChannel.appendLine("Formatting failed. See the diagnostics above for details."); + this.showFailureNotification(result.output); + + return result; + } + + // Runs simfmt and captures its combined stdout/stderr output. + private async runFormatter( + formatterPath: string, + args: string[], + cwd: string, + ): Promise { + return new Promise((resolve) => { + let output = ""; + let settled = false; + + const finish = (success: boolean, message?: string) => { + if (settled) return; + settled = true; + + if (message) { + output += message; + if (!this.disposed) this.outputChannel.append(message); + } + + resolve({ success, output }); + }; + + try { + const process = cp.spawn(formatterPath, args, { cwd, shell: false }); + this.activeChildren.add(process); + const appendOutput = (data: Buffer) => { + const text = data.toString(); + output += text; + if (!this.disposed) this.outputChannel.append(text); + }; + + process.stdout?.on("data", appendOutput); + process.stderr?.on("data", appendOutput); + + process.on("error", (error) => { + finish(false, `Unable to start simfmt: ${error.message}\n`); + }); + + process.on("close", (code) => { + this.activeChildren.delete(process); + if (this.disposed) { + finish(false, `${SHUTDOWN_MESSAGE}\n`); + return; + } + if (code === 0) { + finish(true); + return; + } + + finish(false, `simfmt exited with code ${code ?? "unknown"}.\n`); + }); + } catch (error) { + finish(false, `Unable to start simfmt: ${getErrorMessage(error)}\n`); + } + }); + } + + // Reports a self-contained validation message without additional context. + private failShort(message: string): FormatResult { + if (!this.disposed) { + this.outputChannel.appendLine(message); + void vscode.window.showErrorMessage(message); + } + + return { success: false, output: message }; + } + + // Reports a pre-run formatting failure to the user and output channel. + private fail(message: string): FormatResult { + if (!this.disposed) { + this.outputChannel.appendLine(`Formatting failed: "${message}".`); + this.showFailureNotification(message); + } + + return { success: false, output: message }; + } + + // Offers full details without waiting for the user to dismiss the notification. + private showFailureNotification(output: string): void { + void vscode.window.showErrorMessage(getFailureNotification(output), SHOW_OUTPUT_ACTION).then((action) => { + if (action === SHOW_OUTPUT_ACTION && !this.disposed) { + this.outputChannel.show(true); + } + }); + } +} + +// Builds a copyable command string for the formatter output channel. +function formatCommand(command: string, args: string[]): string { + return [command, ...args] + .map((argument) => (/\s/.test(argument) ? JSON.stringify(argument) : argument)) + .join(" "); +} + +// Normalizes thrown values into user-readable strings. +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/format/install.ts b/src/format/install.ts new file mode 100644 index 0000000..a148ba7 --- /dev/null +++ b/src/format/install.ts @@ -0,0 +1,37 @@ +// Formatter executable resolution and automatic-update policy. + +import { workspace } from "vscode"; +import {CONFIGURATION_SECTION, SETTINGS, ManagedBinary, MANAGED_BINARY_INFO} from "../contracts"; +import { ensureExecutableInner } from "../lsp/install"; +import { + type BinaryUpdateEventState, +} from "../update/binary_update_state"; +import { SimfmtUpdater } from "./updater"; + +export async function getSimfmtPath(updateState: BinaryUpdateEventState): Promise { + const config = workspace.getConfiguration(CONFIGURATION_SECTION); + const configuredPath = config.get( + SETTINGS.formatterPath.key, + SETTINGS.formatterPath.default, + ).trim(); + if (configuredPath) { + return configuredPath; + } + + const updater = new SimfmtUpdater(updateState); + const command = ManagedBinary.Simfmt; + const formatterPath = await ensureExecutableInner(command, { + displayName: MANAGED_BINARY_INFO[command].displayName, + disableAutoupdateSetting: SETTINGS.formatterDisableAutoupdate, + suppressMissingWarningSetting: SETTINGS.suppressMissingFormatterWarning, + update: (executableExists, install) => updater.run(executableExists, install), + }); + if (formatterPath) { + return formatterPath; + } + + throw new Error( + "simfmt formatter not found. Install simfmt and add it to PATH, " + + `or set ${CONFIGURATION_SECTION}.${SETTINGS.formatterPath.key}.`, + ); +} diff --git a/src/format/output.test.ts b/src/format/output.test.ts new file mode 100644 index 0000000..37d7cec --- /dev/null +++ b/src/format/output.test.ts @@ -0,0 +1,53 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { getFailureNotification, parseFormatterDiagnostics } from "./output"; + +void test("parses multiple formatter diagnostics with Windows and Unix file paths", () => { + const output = [ + "error[E001]: incorrect type", + " --> C:\\contracts\\main.simf:12:5", + "", + "error: missing expression", + " --> /contracts/other.simf:3:9", + "", + "error: compilation failed", + ].join("\r\n"); + + assert.deepEqual(parseFormatterDiagnostics(output), [ + { message: "incorrect type", filePath: "C:\\contracts\\main.simf", line: 12, column: 5 }, + { message: "missing expression", filePath: "/contracts/other.simf", line: 3, column: 9 }, + ]); +}); + +void test("summarizes the first located formatter diagnostic", () => { + assert.equal( + getFailureNotification("simfmt starting\nerror: incorrect type\n --> /contracts/main.simf:12:5\n"), + "Formatting failed: incorrect type", + ); +}); + +void test("uses the first output line or an empty-output fallback when diagnostics have no location", () => { + assert.deepEqual(parseFormatterDiagnostics("error: unable to read file"), []); + assert.equal( + getFailureNotification("error: unable to read file\nmore details"), + "Formatting failed: error: unable to read file", + ); + assert.equal( + getFailureNotification("\n"), + "Formatting failed: simfmt failed without reporting an error", + ); +}); + +void test("limits unexpected-error notifications only when they exceed 200 characters", () => { + const prefix = "Formatting failed: "; + for (const length of [199, 200]) { + const message = "x".repeat(length - prefix.length); + assert.equal(getFailureNotification(message), prefix + message); + } + + const message = "x".repeat(1000); + const expected = prefix + "x".repeat(199 - prefix.length) + "…"; + assert.equal(getFailureNotification(message), expected); + assert.equal(getFailureNotification(`error: ${message}\n --> /test/main.simf:1:1\n`), expected); +}); diff --git a/src/format/output.ts b/src/format/output.ts new file mode 100644 index 0000000..c423056 --- /dev/null +++ b/src/format/output.ts @@ -0,0 +1,44 @@ +// Parses simfmt diagnostics and builds concise failure notifications. + +const DIAGNOSTIC_HEADER = /^error(?:\[[^\]]+\])?:\s*(.+)$/gm; +const DIAGNOSTIC_LOCATION = /^\s*-->\s+(.+):(\d+):(\d+)\s*$/m; +const MAX_NOTIFICATION_LENGTH = 200; + +export interface FormatterDiagnostic { + message: string; + filePath: string; + line: number; + column: number; +} + +export function parseFormatterDiagnostics(output: string): FormatterDiagnostic[] { + const headers = [...output.matchAll(DIAGNOSTIC_HEADER)]; + + return headers.flatMap((header, index) => { + const blockStart = (header.index ?? 0) + header[0].length; + const blockEnd = headers[index + 1]?.index ?? output.length; + const block = output.slice(blockStart, blockEnd); + const location = block.match(DIAGNOSTIC_LOCATION); + + if (!location) return []; + + return [{ + message: header[1].trim(), + filePath: location[1], + line: Number(location[2]), + column: Number(location[3]), + }]; + }); +} + +export function getFailureNotification(output: string): string { + const message = parseFormatterDiagnostics(output)[0]?.message + || output.trim().split(/\r?\n/)[0] + || "simfmt failed without reporting an error"; + const notification = `Formatting failed: ${message}`; + + // Limit only the notification preview; callers retain the complete output. + return notification.length > MAX_NOTIFICATION_LENGTH + ? `${notification.slice(0, MAX_NOTIFICATION_LENGTH - 1).trimEnd()}…` + : notification; +} diff --git a/src/format/updater.test.ts b/src/format/updater.test.ts new file mode 100644 index 0000000..ee1cf05 --- /dev/null +++ b/src/format/updater.test.ts @@ -0,0 +1,191 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { ManagedBinary } from "../contracts"; +import { + BinaryUpdateEventState, + BinaryUpdateResult, + UpdateEventStatus, + STATE_KEY, +} from "../update/binary_update_state"; +import type { CrateVersion } from "../update/crates_io"; +import { SimfmtUpdater } from "./updater"; + +const NOW = 1_750_000_000_000; + +function version(value: string): CrateVersion { + return value as CrateVersion; +} + +function createFixture(record: unknown = { event: UpdateEventStatus.Pending }) { + const values = new Map([ + [STATE_KEY, { [ManagedBinary.Simfmt]: record }], + ]); + const state = { + get(_key: string, defaultValue?: T): T { + return values.has(STATE_KEY) ? values.get(STATE_KEY) as T : defaultValue as T; + }, + async update(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; + const updateState = new BinaryUpdateEventState(state); + const installs: Array = []; + const install = async (requestedVersion?: CrateVersion): Promise => { + installs.push(requestedVersion); + }; + return { values, updateState, installs, install }; +} + +void test("does not look up or install when simfmt has no pending event", async () => { + const { updateState, installs, install } = createFixture({ event: UpdateEventStatus.Consumed }); + let lookups = 0; + const updater = new SimfmtUpdater(updateState, async () => { + lookups += 1; + return version("1.2.3"); + }); + + assert.equal(await updater.run(false, install), BinaryUpdateResult.Skipped); + assert.equal(lookups, 0); + assert.deepEqual(installs, []); +}); + +void test("consumes without installing when an existing executable has the fetched version", async () => { + const { updateState, installs, install } = createFixture({ + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.2.3" }, + }); + let lookups = 0; + const updater = new SimfmtUpdater(updateState, async () => { + lookups += 1; + return version("1.2.3"); + }); + + assert.equal(await updater.run(true, install), BinaryUpdateResult.Executed); + assert.equal(lookups, 1); + assert.deepEqual(installs, []); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.2.3" }, + }); +}); + +void test("installs the exact fetched version for a missing or outdated executable", async (t) => { + await t.test("missing executable", async () => { + const { updateState, installs, install } = createFixture(); + const updater = new SimfmtUpdater(updateState, async () => version("2.0.0"), () => NOW); + + assert.equal(await updater.run(false, install), BinaryUpdateResult.Executed); + assert.deepEqual(installs, [version("2.0.0")]); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: NOW, version: "2.0.0" }, + }); + }); + + await t.test("different version", async () => { + const { updateState, installs, install } = createFixture({ + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.0.0" }, + }); + const updater = new SimfmtUpdater(updateState, async () => version("2.0.0"), () => NOW); + + assert.equal(await updater.run(true, install), BinaryUpdateResult.Executed); + assert.deepEqual(installs, [version("2.0.0")]); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt)?.lastSuccessfulUpdate, { + at: NOW, + version: "2.0.0", + }); + }); +}); + +void test("installs and records a fetched version when previous metadata has no version", async () => { + const { updateState, installs, install } = createFixture({ + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: NOW - 100 }, + }); + const updater = new SimfmtUpdater(updateState, async () => version("3.0.0"), () => NOW); + + assert.equal(await updater.run(true, install), BinaryUpdateResult.Executed); + assert.deepEqual(installs, [version("3.0.0")]); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt)?.lastSuccessfulUpdate, { + at: NOW, + version: "3.0.0", + }); +}); + +void test("keeps an existing executable and prior metadata when version lookup fails", async (t) => { + const { updateState, installs, install } = createFixture({ + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.0.0" }, + }); + const failure = new Error("network unavailable"); + const warn = t.mock.method(console, "warn", () => {}); + const updater = new SimfmtUpdater(updateState, async () => { throw failure; }, () => NOW); + + assert.equal(await updater.run(true, install), BinaryUpdateResult.Executed); + assert.deepEqual(installs, []); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.0.0" }, + }); + assert.equal(warn.mock.callCount(), 1); + assert.equal(warn.mock.calls[0].arguments[1], failure); +}); + +void test("falls back to an unversioned install when lookup fails and executable is missing", async () => { + const { updateState, installs, install } = createFixture(); + const failure = new Error("network unavailable"); + const updater = new SimfmtUpdater(updateState, async () => { throw failure; }, () => NOW); + + assert.equal(await updater.run(false, install), BinaryUpdateResult.Executed); + assert.deepEqual(installs, [undefined]); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: NOW }, + }); +}); + +void test("keeps consumed status and prior metadata when installation fails", async () => { + const { updateState } = createFixture({ + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.0.0" }, + }); + const failure = new Error("installation cancelled"); + const updater = new SimfmtUpdater(updateState, async () => version("2.0.0"), () => NOW); + + await assert.rejects( + updater.run(true, async () => { throw failure; }), + failure, + ); + assert.deepEqual(updateState.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: NOW - 100, version: "1.0.0" }, + }); + assert.equal(await updater.run(true, async () => {}), BinaryUpdateResult.Skipped); +}); + +void test("shares the activation state's in-flight operation for concurrent calls", async () => { + const { updateState } = createFixture(); + let lookups = 0; + let installs = 0; + let releaseInstall!: () => void; + const installDone = new Promise((resolve) => { releaseInstall = resolve; }); + const updater = new SimfmtUpdater(updateState, async () => { + lookups += 1; + return version("2.0.0"); + }); + const install = async (): Promise => { + installs += 1; + await installDone; + }; + + const first = updater.run(false, install); + const second = updater.run(false, async () => {}); + assert.strictEqual(second, first); + releaseInstall(); + assert.equal(await first, BinaryUpdateResult.Executed); + assert.equal(await second, BinaryUpdateResult.Executed); + assert.equal(lookups, 1); + assert.equal(installs, 1); +}); diff --git a/src/format/updater.ts b/src/format/updater.ts new file mode 100644 index 0000000..4562cd8 --- /dev/null +++ b/src/format/updater.ts @@ -0,0 +1,48 @@ +import { ManagedBinary } from "../contracts"; +import { + BinaryUpdateResult, + type BinaryUpdateEventState, +} from "../update/binary_update_state"; +import { getCrateVersion } from "../update/crates_io"; +import type { CrateVersion } from "../update/crates_io"; + +export type SimfmtVersionLookup = () => Promise; +export type SimfmtInstall = (version?: CrateVersion) => Promise; + +/** Coordinates one startup simfmt update event with crates.io and installation. */ +export class SimfmtUpdater { + public constructor( + private readonly state: BinaryUpdateEventState, + private readonly getVersion: SimfmtVersionLookup = () => getCrateVersion(ManagedBinary.Simfmt), + private readonly now: () => number = Date.now, + ) {} + + /** + * Consume the pending simfmt event and update when the installed binary is + * absent or does not match the current crates.io default version. + */ + public run(executableExists: boolean, install: SimfmtInstall): Promise { + return this.state.runIfPending(ManagedBinary.Simfmt, async () => { + const previousVersion = this.state.getRecord(ManagedBinary.Simfmt)?.lastSuccessfulUpdate?.version; + + let version: CrateVersion; + try { + version = await this.getVersion(); + } catch (error) { + if (executableExists) { + console.warn("Unable to determine the latest simfmt version; keeping the existing executable.", error); + return; + } + + await install(); + await this.state.recordSuccessfulUpdate(ManagedBinary.Simfmt, this.now()); + return; + } + + if (executableExists && previousVersion === version) return; + + await install(version); + await this.state.recordSuccessfulUpdate(ManagedBinary.Simfmt, this.now(), version); + }); + } +} diff --git a/src/lsp/client.ts b/src/lsp/client.ts index 8ec4593..4100ed7 100644 --- a/src/lsp/client.ts +++ b/src/lsp/client.ts @@ -18,9 +18,9 @@ import { CONFIGURATION_SECTION, LANGUAGE_CLIENT_ID, LANGUAGE_CLIENT_NAME, - SERVER_BINARY, SETTINGS, languageClientOptions, + ManagedBinary, } from "../contracts"; import { getExperimentalFeatures } from "../settings"; import { ensureExecutable } from "./install"; @@ -93,7 +93,7 @@ export class LspClient { } execPath = resolvedPath; } else { - execPath = await ensureExecutable(SERVER_BINARY); + execPath = await ensureExecutable(ManagedBinary.LanguageServer); } if (!this.isCurrent(request)) { @@ -192,7 +192,9 @@ export class LspClient { } await this.startNow(request); if (this.isCurrent(request) && this.client) { - window.showInformationMessage("SimplicityHL Language Server restarted successfully!"); + window.showInformationMessage( + "SimplicityHL Language Server restarted successfully!", + ); } } catch (e) { if (!this.isCurrent(request)) { diff --git a/src/lsp/install.ts b/src/lsp/install.ts index 78fe88c..98a42a6 100644 --- a/src/lsp/install.ts +++ b/src/lsp/install.ts @@ -1,105 +1,134 @@ +// Shared executable discovery, Cargo installation, and update checks. + import * as cp from "node:child_process"; import { env, ProgressLocation, Uri, window, workspace } from "vscode"; -import { CONFIGURATION_SECTION, SETTINGS } from "../contracts"; import { findExecutable } from "../find_executable"; +import { CONFIGURATION_SECTION, SETTINGS, ManagedBinary, MANAGED_BINARY_INFO } from "../contracts"; +import { + BinaryUpdateResult, +} from "../update/binary_update_state"; + +// TODO: move it to cargo/install.ts + +interface EnsureExecutableOptions { + displayName: string; + disableAutoupdateSetting: { key: string; default: boolean }; + suppressMissingWarningSetting: { key: string; default: boolean }; + update?: ( + executableExists: boolean, + install: (version?: string) => Promise, + ) => Promise; +} -async function installServer(command: string) { +async function installCargoExecutable( + command: ManagedBinary, + version?: string, +) { const cargoPath = findExecutable("cargo"); if (!cargoPath) { - throw new Error("Unable to find 'cargo'. Please ensure Rust is installed and in your PATH."); + throw new Error( + "Unable to find 'cargo'. Please ensure Rust is installed and in your PATH.", + ); } const action = findExecutable(command) ? "Updating" : "Installing"; - return window.withProgress({ - location: ProgressLocation.Notification, - title: `${action} ${command}`, - cancellable: true - }, async (progress, token) => { - return new Promise((resolve, reject) => { - const cancellation = new AbortController(); - const installProcess = cp.spawn( - cargoPath, - ["install", "--color", "never", command], - { shell: false, signal: cancellation.signal }, - ); - let settled = false; - const progressCancellation = token.onCancellationRequested(() => { - cancellation.abort(); - }); - const finish = (error?: Error): void => { - if (settled) { - return; - } - settled = true; - progressCancellation.dispose(); - if (error) { - reject(error); - } else { - resolve(); - } - }; + return window.withProgress( + { + location: ProgressLocation.Notification, + title: `${action} ${command}`, + cancellable: true, + }, + async (progress, token) => { + return new Promise((resolve, reject) => { + const cancellation = new AbortController(); + const packageSpec = + version === undefined ? command : `${command}@${version}`; + const installProcess = cp.spawn( + cargoPath, + ["install", "--color", "never", packageSpec], + { shell: false, signal: cancellation.signal }, + ); + let settled = false; + const progressCancellation = token.onCancellationRequested(() => { + cancellation.abort(); + }); + const finish = (error?: Error): void => { + if (settled) { + return; + } + settled = true; + progressCancellation.dispose(); + if (error) { + reject(error); + } else { + resolve(); + } + }; + + const reportProgress = (data: Buffer) => { + const lines = data + .toString() + .split(/\r?\n/) + .map((l) => l.trim()); + + for (const line of lines) { + if (line.startsWith("Compiling") && line !== "Compiling") { + progress.report({ message: line }); + } + } + }; - const reportProgress = (data: Buffer) => { - const lines = data.toString() - .split(/\r?\n/) - .map(l => l.trim()) + installProcess.stderr?.on("data", reportProgress); - for (const line of lines) { - if (line.startsWith("Compiling") && line !== "Compiling") { - progress.report({ message: line }); + installProcess.on("close", (code) => { + if (cancellation.signal.aborted) { + finish(new Error("Installation canceled")); + return; } - } - }; - - installProcess.stderr?.on('data', reportProgress); + if (code === 0) { + finish(); + } else { + finish(new Error(`Installation failed with exit code ${code}`)); + } + }); - installProcess.on('close', (code) => { - if (cancellation.signal.aborted) { - finish(new Error("Installation canceled")); - return; - } - if (code === 0) { - finish(); - } else { - finish(new Error(`Installation failed with exit code ${code}`)); - } - }); + installProcess.on("error", (err) => { + if (!cancellation.signal.aborted) { + finish(new Error(`Failed to start cargo process: ${err.message}`)); + } + }); - installProcess.on('error', (err) => { - if (!cancellation.signal.aborted) { - finish(new Error(`Failed to start cargo process: ${err.message}`)); + if (token.isCancellationRequested) { + cancellation.abort(); } }); - - if (token.isCancellationRequested) { - cancellation.abort(); - } - }); - }); + }, + ); } -export async function ensureExecutable( - command: string, +export async function ensureExecutableInner( + command: ManagedBinary, + options: EnsureExecutableOptions, ): Promise { const cargoPath = findExecutable("cargo"); const config = workspace.getConfiguration(CONFIGURATION_SECTION); - let serverPath = findExecutable(command); + let executablePath = findExecutable(command); - if (!cargoPath && !serverPath) { + if (!cargoPath && !executablePath) { const suppressWarning = config.get( - SETTINGS.suppressMissingLspWarning.key, - SETTINGS.suppressMissingLspWarning.default, + options.suppressMissingWarningSetting.key, + options.suppressMissingWarningSetting.default, ); if (suppressWarning) { return null; } const choice = await window.showWarningMessage( - `To use SimplicityHL language server, please install cargo`, + `To use ${options.displayName}, please install cargo`, "Learn more", "Don't show again", ); @@ -109,7 +138,7 @@ export async function ensureExecutable( await env.openExternal(Uri.parse(url)); } else if (choice === "Don't show again") { await config.update( - SETTINGS.suppressMissingLspWarning.key, + options.suppressMissingWarningSetting.key, true, true, ); @@ -119,26 +148,55 @@ export async function ensureExecutable( } if (!cargoPath) { - return serverPath; + return executablePath; } const disableAutoupdate = config.get( - SETTINGS.disableAutoupdate.key, - SETTINGS.disableAutoupdate.default, + options.disableAutoupdateSetting.key, + options.disableAutoupdateSetting.default, ); - if (serverPath && disableAutoupdate) { - return serverPath; + if (executablePath && disableAutoupdate) { + return executablePath; } try { - await installServer(command); + if (options.update && (!disableAutoupdate || !executablePath)) { + const result = await options.update(executablePath !== null, (version) => + installCargoExecutable(command, version), + ); + if (result === BinaryUpdateResult.Executed) { + executablePath = findExecutable(command); + } + // A consumed event must never prevent installing a missing executable. + if (executablePath) { + return executablePath; + } + } + + await installCargoExecutable(command); - serverPath = findExecutable(command); + executablePath = findExecutable(command); } catch (err) { window.showErrorMessage(err); + // A failed optional update must not hide the executable that was already available + if (options.update && executablePath) { + return executablePath; + } return null; } - return serverPath; + return executablePath; +} + +// Language-server installation policy; Cargo process handling is shared. + +export function ensureExecutable( + command: ManagedBinary, +): Promise { + return ensureExecutableInner(command, { + displayName: MANAGED_BINARY_INFO[command].displayName, + disableAutoupdateSetting: SETTINGS.disableAutoupdate, + suppressMissingWarningSetting: SETTINGS.suppressMissingLspWarning, + }); } diff --git a/src/update/binary_update_state.test.ts b/src/update/binary_update_state.test.ts new file mode 100644 index 0000000..e43b262 --- /dev/null +++ b/src/update/binary_update_state.test.ts @@ -0,0 +1,215 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Memento } from "vscode"; + +import { ManagedBinary } from "../contracts"; +import { + BinaryUpdateEventState, + BinaryUpdateResult, + UpdateEventStatus, +} from "./binary_update_state"; + +const KEY = "simplicityhl.binaryUpdateState.v1"; + +function createFixture(initialValue?: unknown) { + const values = new Map(); + if (initialValue !== undefined) values.set(KEY, initialValue); + const state: Pick = { + get(key: string, defaultValue?: T): T { + return values.has(key) ? (values.get(key) as T) : (defaultValue as T); + }, + async update(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; + return { values, state, tracker: new BinaryUpdateEventState(state) }; +} + +void test("validates persisted state and safely ignores malformed records and metadata", () => { + const { tracker } = createFixture({ + [ManagedBinary.Simfmt]: { + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: 123, version: "0.2.0", ignored: true }, + ignored: true, + }, + [ManagedBinary.LanguageServer]: { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: "not a timestamp", version: 42 }, + }, + unknown: { event: UpdateEventStatus.Pending }, + malformed: "record", + }); + + assert.deepEqual(tracker.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: 123, version: "0.2.0" }, + }); + assert.deepEqual(tracker.getRecord(ManagedBinary.LanguageServer), { + event: UpdateEventStatus.Consumed, + }); +}); + +void test("markPending persists the event and preserves valid success metadata", async () => { + const { tracker, values } = createFixture({ + [ManagedBinary.Simfmt]: { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: 456, version: "1.0.0" }, + }, + }); + + await tracker.markPending(ManagedBinary.Simfmt); + assert.deepEqual(tracker.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: 456, version: "1.0.0" }, + }); + assert.deepEqual(values.get(KEY), { + [ManagedBinary.Simfmt]: { + event: UpdateEventStatus.Pending, + lastSuccessfulUpdate: { at: 456, version: "1.0.0" }, + }, + }); +}); + +void test("persistence failures retain in-memory state and do not block operations", async (t) => { + const failure = new Error("storage unavailable"); + const { tracker, state } = createFixture(); + state.update = async () => { + throw failure; + }; + const warn = t.mock.method(console, "warn", () => {}); + + await tracker.markPending(ManagedBinary.Simfmt); + assert.deepEqual(tracker.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Pending, + }); + + let invoked = false; + assert.equal( + await tracker.runIfPending(ManagedBinary.Simfmt, () => { + invoked = true; + }), + BinaryUpdateResult.Executed, + ); + assert.equal(invoked, true); + assert.equal(warn.mock.callCount(), 2); + assert.equal(warn.mock.calls[0].arguments[1], failure); +}); + +void test("runIfPending skips non-pending events and consumes before invoking operation", async () => { + const { tracker, state, values } = createFixture({ + [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Pending }, + }); + let invoked = false; + const result = await tracker.runIfPending(ManagedBinary.Simfmt, () => { + invoked = true; + assert.deepEqual(values.get(KEY), { + [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Consumed }, + }); + }); + + assert.equal(result, BinaryUpdateResult.Executed); + assert.equal(invoked, true); + assert.deepEqual(tracker.getRecord(ManagedBinary.Simfmt), { + event: UpdateEventStatus.Consumed, + }); + assert.equal( + await tracker.runIfPending(ManagedBinary.Simfmt, () => {}), + BinaryUpdateResult.Skipped, + ); + assert.notEqual(state, undefined); +}); + +void test("runIfPending keeps consumed status when operation fails", async () => { + const { tracker } = createFixture({ + [ManagedBinary.LanguageServer]: { event: UpdateEventStatus.Pending }, + }); + const failure = new Error("update failed"); + + await assert.rejects( + tracker.runIfPending(ManagedBinary.LanguageServer, () => { + throw failure; + }), + failure, + ); + assert.deepEqual(tracker.getRecord(ManagedBinary.LanguageServer), { + event: UpdateEventStatus.Consumed, + }); + assert.equal( + await tracker.runIfPending(ManagedBinary.LanguageServer, () => {}), + BinaryUpdateResult.Skipped, + ); +}); + +void test("concurrent calls share one in-flight operation promise per binary", async () => { + const { tracker } = createFixture({ + [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Pending }, + }); + let invoked = 0; + let release!: () => void; + const operationDone = new Promise((resolve) => { + release = resolve; + }); + const operation = async () => { + invoked += 1; + await operationDone; + }; + + const first = tracker.runIfPending(ManagedBinary.Simfmt, operation); + const second = tracker.runIfPending(ManagedBinary.Simfmt, () => {}); + assert.strictEqual(second, first); + assert.equal(invoked, 0); + release(); + assert.equal(await first, BinaryUpdateResult.Executed); + assert.equal(await second, BinaryUpdateResult.Executed); + assert.equal(invoked, 1); +}); + +void test("serializes pending and consumed writes triggered during activation", async () => { + const values = new Map(); + const writes: unknown[] = []; + let releaseFirstWrite!: () => void; + const firstWrite = new Promise((resolve) => { + releaseFirstWrite = resolve; + }); + const state: Pick = { + get(_key: string, defaultValue?: T): T { + return defaultValue as T; + }, + async update(key: string, value: unknown): Promise { + writes.push(value); + if (writes.length === 1) await firstWrite; + values.set(key, value); + }, + }; + const tracker = new BinaryUpdateEventState(state); + + const pendingWrite = tracker.markPending(ManagedBinary.Simfmt); + const operation = tracker.runIfPending(ManagedBinary.Simfmt, () => {}); + assert.equal(writes.length, 0); + + await Promise.resolve(); + assert.equal(writes.length, 1); + releaseFirstWrite(); + await pendingWrite; + assert.equal(await operation, BinaryUpdateResult.Executed); + assert.deepEqual(writes, [ + { [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Pending } }, + { [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Consumed } }, + ]); + assert.deepEqual(values.get(KEY), { + [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Consumed }, + }); +}); + +void test("recordSuccessfulUpdate persists timestamp and optional version", async () => { + const { tracker, values } = createFixture({ + [ManagedBinary.Simfmt]: { event: UpdateEventStatus.Consumed }, + }); + await tracker.recordSuccessfulUpdate(ManagedBinary.Simfmt, 789, "2.0.0"); + assert.deepEqual(values.get(KEY), { + [ManagedBinary.Simfmt]: { + event: UpdateEventStatus.Consumed, + lastSuccessfulUpdate: { at: 789, version: "2.0.0" }, + }, + }); +}); diff --git a/src/update/binary_update_state.ts b/src/update/binary_update_state.ts new file mode 100644 index 0000000..36767be --- /dev/null +++ b/src/update/binary_update_state.ts @@ -0,0 +1,260 @@ +import type { Memento } from "vscode"; +import { ManagedBinary } from "../contracts"; + +export enum UpdateEventStatus { + Pending = "pending", + Consumed = "consumed", +} + +export enum BinaryUpdateResult { + Skipped = "skipped", + Executed = "executed", +} + +export interface SuccessfulUpdate { + at: number; + version?: string; +} + +export interface BinaryUpdateRecord { + event: UpdateEventStatus; + lastSuccessfulUpdate?: SuccessfulUpdate; +} + +/** The shape stored under the module's versioned Memento key. */ +export type BinaryUpdateState = Partial< + Record +>; + +// Keep the key private so consumers cannot accidentally couple themselves to the +// persistence format. Bumping the key version permits a future format migration. +export const STATE_KEY = "simplicityhl.binaryUpdateState.v1"; + +type UnknownRecord = Record; + +function hasOwn(value: UnknownRecord, key: string): boolean { + return Object.prototype.hasOwnProperty.call(value, key); +} + +function isObject(value: unknown): value is UnknownRecord { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isManagedBinary(value: unknown): value is ManagedBinary { + return ( + value === ManagedBinary.Simfmt || value === ManagedBinary.LanguageServer + ); +} + +function isEventStatus(value: unknown): value is UpdateEventStatus { + return ( + value === UpdateEventStatus.Pending || value === UpdateEventStatus.Consumed + ); +} + +function isValidTimestamp(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function cloneSuccessfulUpdate(value: SuccessfulUpdate): SuccessfulUpdate { + return value.version === undefined + ? { at: value.at } + : { at: value.at, version: value.version }; +} + +function cloneRecord(value: BinaryUpdateRecord): BinaryUpdateRecord { + return value.lastSuccessfulUpdate === undefined + ? { event: value.event } + : { + event: value.event, + lastSuccessfulUpdate: cloneSuccessfulUpdate(value.lastSuccessfulUpdate), + }; +} + +function parseSuccessfulUpdate(value: unknown): SuccessfulUpdate | undefined { + if (!isObject(value) || !hasOwn(value, "at") || !isValidTimestamp(value.at)) + return undefined; + const version = hasOwn(value, "version") ? value.version : undefined; + if (version === undefined) return { at: value.at }; + if (typeof version !== "string" || version.length === 0) return undefined; + return { at: value.at, version }; +} + +function parseRecord(value: unknown): BinaryUpdateRecord | undefined { + if ( + !isObject(value) || + !hasOwn(value, "event") || + !isEventStatus(value.event) + ) + return undefined; + + // A malformed metadata object is discarded, while its valid event is retained. + const metadata = + !hasOwn(value, "lastSuccessfulUpdate") || + value.lastSuccessfulUpdate === undefined + ? undefined + : parseSuccessfulUpdate(value.lastSuccessfulUpdate); + return metadata === undefined + ? { event: value.event } + : { event: value.event, lastSuccessfulUpdate: metadata }; +} + +function parseState(value: unknown): Map { + const records = new Map(); + if (!isObject(value)) return records; + + for (const binary of Object.values(ManagedBinary)) { + if (!Object.prototype.hasOwnProperty.call(value, binary)) continue; + const record = parseRecord(value[binary]); + if (record !== undefined) records.set(binary, record); + } + return records; +} + +function serializeState( + records: Map, +): BinaryUpdateState { + const state: BinaryUpdateState = {}; + for (const [binary, record] of records) state[binary] = cloneRecord(record); + return state; +} + +export class BinaryUpdateEventState { + private readonly records: Map; + private readonly inFlight = new Map< + ManagedBinary, + Promise + >(); + private persistence = Promise.resolve(); + + public constructor(private readonly state: Pick) { + this.records = parseState(this.state.get(STATE_KEY)); + } + + /** Mark an update event pending, retaining any valid success metadata. */ + public async markPending(binary: ManagedBinary): Promise { + this.assertBinary(binary); + const existing = this.records.get(binary); + this.records.set(binary, { + event: UpdateEventStatus.Pending, + ...(existing?.lastSuccessfulUpdate === undefined + ? {} + : { + lastSuccessfulUpdate: cloneSuccessfulUpdate( + existing.lastSuccessfulUpdate, + ), + }), + }); + await this.persist(); + } + + /** + * Consume and execute a pending event exactly once per in-flight attempt. + * Consumption is persisted before the operation starts and is retained when + * the operation rejects. + */ + public runIfPending( + binary: ManagedBinary, + operation: () => void | PromiseLike, + ): Promise { + this.assertBinary(binary); + + const current = this.inFlight.get(binary); + if (current !== undefined) return current; + if (this.records.get(binary)?.event !== UpdateEventStatus.Pending) { + return Promise.resolve(BinaryUpdateResult.Skipped); + } + + const attempt = this.consumeAndRun(binary, operation); + this.inFlight.set(binary, attempt); + void attempt.then( + () => this.clearInFlight(binary, attempt), + () => this.clearInFlight(binary, attempt), + ); + return attempt; + } + + /** Record the timestamp and optional version of a successful update. */ + public async recordSuccessfulUpdate( + binary: ManagedBinary, + at: number, + version?: string, + ): Promise { + this.assertBinary(binary); + if (!isValidTimestamp(at)) + throw new RangeError( + "Successful update time must be a finite non-negative number.", + ); + if ( + version !== undefined && + (typeof version !== "string" || version.length === 0) + ) { + throw new TypeError( + "Successful update version must be a non-empty string when provided.", + ); + } + + const existing = this.records.get(binary); + const lastSuccessfulUpdate: SuccessfulUpdate = + version === undefined ? { at } : { at, version }; + this.records.set(binary, { + event: existing?.event ?? UpdateEventStatus.Consumed, + lastSuccessfulUpdate, + }); + await this.persist(); + } + + /** Return a defensive copy of a binary's current in-memory record. */ + public getRecord(binary: ManagedBinary): BinaryUpdateRecord | undefined { + this.assertBinary(binary); + const record = this.records.get(binary); + return record === undefined ? undefined : cloneRecord(record); + } + + private async consumeAndRun( + binary: ManagedBinary, + operation: () => void | PromiseLike, + ): Promise { + const existing = this.records.get(binary); + this.records.set(binary, { + event: UpdateEventStatus.Consumed, + ...(existing?.lastSuccessfulUpdate === undefined + ? {} + : { + lastSuccessfulUpdate: cloneSuccessfulUpdate( + existing.lastSuccessfulUpdate, + ), + }), + }); + await this.persist(); + await operation(); + return BinaryUpdateResult.Executed; + } + + private clearInFlight( + binary: ManagedBinary, + attempt: Promise, + ): void { + if (this.inFlight.get(binary) === attempt) this.inFlight.delete(binary); + } + + private async persist(): Promise { + const snapshot = serializeState(this.records); + this.persistence = this.persistence.then(async () => { + try { + await this.state.update(STATE_KEY, snapshot); + } catch (error) { + console.warn( + "Unable to persist binary update state; using session state.", + error, + ); + } + }); + await this.persistence; + } + + private assertBinary(binary: ManagedBinary): void { + if (!isManagedBinary(binary)) + throw new TypeError(`Unknown managed binary: ${String(binary)}`); + } +} diff --git a/src/update/crates_io.test.ts b/src/update/crates_io.test.ts new file mode 100644 index 0000000..cc5c308 --- /dev/null +++ b/src/update/crates_io.test.ts @@ -0,0 +1,104 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; + +import { + CRATES_IO_USER_AGENT, + getCrateVersion, +} from "./crates_io"; +import type { CrateVersion } from "./crates_io"; +import { ManagedBinary } from "../contracts"; + +interface Request { + readonly input: string; + readonly init: RequestInit; +} + +function response(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +void test("requests simfmt metadata with the crates.io headers", async () => { + const requests: Request[] = []; + const version = await getCrateVersion(ManagedBinary.Simfmt, async (input, init = {}) => { + requests.push({ input: String(input), init }); + return response(200, { crate: { default_version: "1.2.3" } }); + }); + + assert.equal(version, "1.2.3"); + assert.deepEqual(requests, [ + { + input: "https://crates.io/api/v1/crates/simfmt", + init: { + headers: { + Accept: "application/json", + "User-Agent": CRATES_IO_USER_AGENT, + }, + }, + }, + ]); +}); + +void test("accepts SemVer prerelease and build metadata", async () => { + const version = await getCrateVersion(ManagedBinary.Simfmt, async () => + response(200, { crate: { default_version: "1.2.3-rc.1+build.7" } }), + ); + + const typedVersion: CrateVersion = version; + assert.equal(typedVersion, "1.2.3-rc.1+build.7"); +}); + +void test("rejects malformed crates.io response bodies", async () => { + for (const body of [ + null, + [], + {}, + { crate: null }, + { crate: {} }, + { crate: { default_version: 123 } }, + ]) { + await assert.rejects( + () => getCrateVersion(ManagedBinary.Simfmt, async () => response(200, body)), + /invalid (?:response body|crate default_version)/, + ); + } +}); + +void test("rejects versions that are not safe SemVer-like Cargo arguments", async () => { + for (const defaultVersion of [ + "1.2", + "v1.2.3", + "1.2.3 foo", + "1.2.3; touch /tmp/pwned", + "1.2.3\n--config", + "01.2.3", + ]) { + await assert.rejects( + () => + getCrateVersion(ManagedBinary.Simfmt, async () => + response(200, { crate: { default_version: defaultVersion } }), + ), + /invalid crate default_version/, + ); + } +}); + +void test("rejects non-2xx responses", async () => { + await assert.rejects( + () => getCrateVersion(ManagedBinary.Simfmt, async () => response(503, {})), + /HTTP status 503/, + ); +}); + +void test("propagates fetch failures", async () => { + const failure = new Error("network unavailable"); + await assert.rejects( + () => + getCrateVersion(ManagedBinary.Simfmt, async () => { + throw failure; + }), + failure, + ); +}); diff --git a/src/update/crates_io.ts b/src/update/crates_io.ts new file mode 100644 index 0000000..b60ca1d --- /dev/null +++ b/src/update/crates_io.ts @@ -0,0 +1,56 @@ +import { parse } from "semver"; + +import { + MANAGED_BINARY_CRATE_NAMES, + ManagedBinary, +} from "../contracts"; + +/** A SemVer value that has been checked to be safe as a Cargo argument. */ +export type CrateVersion = string & { readonly __crateVersion: unique symbol }; + +export type FetchLike = typeof globalThis.fetch; + +export const CRATES_IO_API_URL = "https://crates.io/api/v1/crates"; +export const CRATES_IO_USER_AGENT = + "simplicityhl-vscode (https://github.com/BlockstreamResearch/simplicityhl-vscode)"; + +function asCrateVersion(value: unknown): CrateVersion { + if ( + typeof value !== "string" || + parse(value)?.version !== value.split("+", 1)[0] + ) { + throw new Error("crates.io returned an invalid crate default_version"); + } + + return value as CrateVersion; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** Fetch the default version of the crates.io crate managed by `binary`. */ +export async function getCrateVersion( + binary: ManagedBinary, + fetchFunction: FetchLike = globalThis.fetch, +): Promise { + const crateName = MANAGED_BINARY_CRATE_NAMES[binary]; + + const response = await fetchFunction(`${CRATES_IO_API_URL}/${crateName}`, { + headers: { + Accept: "application/json", + "User-Agent": CRATES_IO_USER_AGENT, + }, + }); + + if (!response.ok) { + throw new Error(`crates.io request failed with HTTP status ${String(response.status)}`); + } + + const body: unknown = await response.json(); + if (!isRecord(body) || !isRecord(body.crate)) { + throw new Error("crates.io returned an invalid response body"); + } + + return asCrateVersion(body.crate.default_version); +}