diff --git a/.gitignore b/.gitignore index 6d88433..aad6345 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # client executables files node_modules/ out/ +.idea/ .pnpm-debug.log diff --git a/.vscodeignore b/.vscodeignore index 6d93587..18bed61 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,3 +11,4 @@ package-lock.json eslint.config.ts *.ts dist/**/*.map +assets \ No newline at end of file diff --git a/README.md b/README.md index 600278b..e3a63c6 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,29 @@ # SimplicityHL extension for VSCode -VSCode extension that provides syntax highlighting for the [SimplicityHL](https://github.com/BlockstreamResearch/SimplicityHL) programming language. +VSCode extension that provides syntax highlighting for the [SimplicityHL][1] programming language. -[Simplicity](https://github.com/BlockstreamResearch/simplicity) is a typed, combinator-based, functional language without loops or recursion, developed to be an alternative to Bitcoin script that is formally specified, and can be statically analyzed with upper bounds on computation resources prior to execution. +[Simplicity][2] is a typed, combinator-based, functional language without loops or recursion, developed to be an alternative to Bitcoin script that is formally specified, and can be statically analyzed with upper bounds on computation resources prior to execution. -SimplicityHL is a high-level language for writing Simplicity smart contracts. SimplicityHL looks and feels like [Rust](https://www.rust-lang.org), but is compiled to Simplicity bytecode. Developers write SimplicityHL transactions, which Bitcoin/Liquid nodes verify with the Simplicity script interpreter. +SimplicityHL is a high-level language for writing Simplicity smart contracts. SimplicityHL looks and feels like [Rust][3], but is compiled to Simplicity bytecode. Developers write SimplicityHL transactions, which Bitcoin/Liquid nodes verify with the Simplicity script interpreter. ## Features - 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](https://github.com/BlockstreamResearch/simplicityhl-lsp), 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](https://github.com/user-attachments/assets/54315645-464b-40c3-bb72-c6e8c4bc0ad5) +![diagnostics][6] - Completion of user-defined functions, imported items, built-ins, and jets -![completion](https://github.com/user-attachments/assets/bbc2b9de-c286-4d31-b47e-ac95885f8916) +![completion][7] + +- Formatting of SimplicityHL code +![formatting][8] ## Experimental features @@ -34,4 +39,14 @@ For local extension development, `simplicityhl.server.path` can point to a local ### Development -To install the extension manually or hack on the source code see [development.md](docs/development.md) +To install the extension manually or hack on the source code see [development.md][9] + +[1]: https://github.com/BlockstreamResearch/SimplicityHL +[2]: https://github.com/BlockstreamResearch/simplicity +[3]: https://www.rust-lang.org +[4]: https://github.com/BlockstreamResearch/simplicityhl-lsp +[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/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/completion.gif b/assets/completion.gif new file mode 100644 index 0000000..306d878 Binary files /dev/null and b/assets/completion.gif differ diff --git a/assets/error_diagnostic.gif b/assets/error_diagnostic.gif new file mode 100644 index 0000000..eddc166 Binary files /dev/null and b/assets/error_diagnostic.gif differ 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..8348034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "simplicityhl", - "version": "0.4.0", + "version": "0.4.1", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 17c4aa0..c09196e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "simplicityhl", "displayName": "SimplicityHL Language Support", "description": "Syntax highlighting and autocompletion for SimplicityHL (Simfony) language", - "version": "0.4.0", + "version": "0.4.1", "publisher": "Blockstream", "repository": { "type": "git", @@ -95,6 +95,22 @@ "default": "", "description": "Path to the simc compiler. If empty, searches PATH and common locations." }, + "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 +134,11 @@ } } ], + "configurationDefaults": { + "[simplicityhl]": { + "editor.defaultFormatter": "Blockstream.simplicityhl" + } + }, "commands": [ { "command": "simplicityhl.restartServer", @@ -144,6 +165,11 @@ "command": "simplicityhl.compileJson", "title": "Compile to JSON", "category": "SimplicityHL" + }, + { + "command": "simplicityhl.formatFile", + "title": "Format Current File", + "category": "SimplicityHL" } ], "menus": { @@ -164,6 +190,11 @@ "when": "resourceLangId == simplicityhl", "command": "simplicityhl.compileWithWitness", "group": "1_modification" + }, + { + "when": "resourceLangId == simplicityhl", + "command": "simplicityhl.formatFile", + "group": "1_modification" } ], "commandPalette": [ @@ -182,6 +213,10 @@ { "command": "simplicityhl.compileJson", "when": "resourceLangId == simplicityhl" + }, + { + "command": "simplicityhl.formatFile", + "when": "resourceLangId == simplicityhl" } ] }, @@ -240,7 +275,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_cache.test.ts src/find_server.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", diff --git a/src/commands/compile.ts b/src/commands/compile.ts index 66e0887..cba305f 100644 --- a/src/commands/compile.ts +++ b/src/commands/compile.ts @@ -8,9 +8,9 @@ import type { CompileOptions } from "../compiler/args"; import { COMMAND_IDS, CONFIGURATION_SECTION, - LANGUAGE_IDS, SETTINGS, } from "../contracts"; +import { getActiveSimplicityHLDocument } from "../document"; function showCompilationFailed(): void { vscode.window.showErrorMessage( @@ -18,37 +18,18 @@ function showCompilationFailed(): void { ); } -// Validates that the active editor contains a SimplicityHL file +// Gets the active SimplicityHL file and auto-saves it when configured. async function getSimplicityHLDocument(): Promise { - const editor = vscode.window.activeTextEditor; - if (!editor) { - vscode.window.showWarningMessage("No active file to compile"); - return undefined; - } - - const document = editor.document; - if (document.languageId !== LANGUAGE_IDS.source) { - vscode.window.showWarningMessage("Current file is not a SimplicityHL file (.simf)"); - return undefined; - } - - // Auto-save before compile if enabled const config = vscode.workspace.getConfiguration(CONFIGURATION_SECTION); const autoSave = config.get( SETTINGS.autoSaveBeforeCompile.key, SETTINGS.autoSaveBeforeCompile.default, ); - if (autoSave && document.isDirty) { - const saved = await document.save(); - if (!saved) { - void vscode.window.showWarningMessage( - "SimplicityHL compilation canceled because the file could not be saved.", - ); - return undefined; - } - } - - return document; + return getActiveSimplicityHLDocument({ + action: "compile", + saveBeforeAction: autoSave, + failIfSaveFails: true, + }); } async function compileActiveDocument( diff --git a/src/commands/format.ts b/src/commands/format.ts new file mode 100644 index 0000000..f736e74 --- /dev/null +++ b/src/commands/format.ts @@ -0,0 +1,33 @@ +// Command and native provider registration for SimplicityHL formatting. + +import * as vscode from "vscode"; +import { COMMAND_IDS, LANGUAGE_IDS } from "../contracts"; +import { getActiveSimplicityHLDocument } from "../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: "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..ee4485b 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,14 @@ 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.formatterPath, + SETTINGS.autoSaveBeforeFormat, + SETTINGS.formatterDisableAutoupdate, + ]) { const contribution = contributedSettings[ `${clientOptions.synchronize.configurationSection}.${setting.key}` @@ -32,4 +39,8 @@ 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, + )); }); diff --git a/src/contracts.ts b/src/contracts.ts index dc1356b..642bcac 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -2,6 +2,8 @@ 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 FORMATTER_BINARY = "simfmt"; +export const FORMATTER_UPDATE_CACHE_KEY = "simfmt.lastUpdateAttemptAt"; export const TASK_TYPE = "simplicityhl"; export const LANGUAGE_IDS = { @@ -15,6 +17,7 @@ export const COMMAND_IDS = { compileFileDebug: "simplicityhl.compileFileDebug", compileWithWitness: "simplicityhl.compileWithWitness", compileJson: "simplicityhl.compileJson", + formatFile: "simplicityhl.formatFile", } as const; export const TASK_COMMANDS = [ @@ -40,6 +43,18 @@ export const SETTINGS = { key: "compiler.path", default: "", }, + 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/document.ts b/src/document.ts new file mode 100644 index 0000000..91d783b --- /dev/null +++ b/src/document.ts @@ -0,0 +1,89 @@ +// Shared helpers for selecting and preparing SimplicityHL documents. + +import * as vscode from "vscode"; +import { LANGUAGE_IDS } from "./contracts"; + +interface ActiveDocumentOptions { + action: "compile" | "format"; + saveBeforeAction?: boolean; + requireFilePath?: boolean; + failIfSaveFails?: boolean; +} + +interface DocumentPreparationOptions { + action: "compile" | "format"; + 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 "compile": + actionName = "compiling"; + break; + case "format": + actionName = "formatting"; + break; + } + + return `Save the SimplicityHL document before ${actionName} it.`; +} diff --git a/src/extension.ts b/src/extension.ts index b312087..5e60bb1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -5,12 +5,16 @@ import { ExtensionContext, commands } from "vscode"; import { LspClient } from "./lsp/client"; import { SimplicityHLCompiler } from "./compiler"; +import { SimplicityHLFormatter } from "./format"; import { registerCompileCommands } from "./commands/compile"; -import { COMMAND_IDS } from "./contracts"; +import { registerFormattingCommands } from "./commands/format"; +import { COMMAND_IDS, FORMATTER_UPDATE_CACHE_KEY } from "./contracts"; import { registerTaskProvider } from "./tasks/provider"; +import { DailyUpdateCache } from "./update_cache"; let client: LspClient | undefined; let compiler: SimplicityHLCompiler | undefined; +let formatter: SimplicityHLFormatter | undefined; export function activate(context: ExtensionContext): void { // Initialize LSP client for language intelligence (also shows status bar) @@ -32,13 +36,25 @@ export function activate(context: ExtensionContext): void { return compiler; }); registerTaskProvider(context); // Task integration (Tasks: Run Task) + + const updateCache = new DailyUpdateCache(context.globalState, FORMATTER_UPDATE_CACHE_KEY); + registerFormattingCommands(context, () => { + if (!formatter) { + formatter = new SimplicityHLFormatter(updateCache); + 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(); } diff --git a/src/find_server.test.ts b/src/find_server.test.ts new file mode 100644 index 0000000..e3ee723 --- /dev/null +++ b/src/find_server.test.ts @@ -0,0 +1,678 @@ +import * as assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { createRequire, Module } from "node:module"; +import { afterEach, beforeEach, test } from "node:test"; +import type * as vscode from "vscode"; + +import { DailyUpdateCache, type UpdateCache } from "./update_cache"; +import { COMMAND_IDS } from "./contracts"; + +const CACHE_KEY = "simfmt.lastUpdateAttemptAt"; +const DAY_MS = 24 * 60 * 60 * 1000; +const disposable = { dispose() {} }; +const document = { + uri: { scheme: "file", fsPath: "/test/example.simf" }, + languageId: "simplicityhl", + isDirty: false, +} as vscode.TextDocument; + +interface MockChild extends EventEmitter { + stdout: EventEmitter; + stderr: EventEmitter; + exitCode: number | null; + signalCode: NodeJS.Signals | null; + kill(): boolean; +} + +function createHost() { + const values = new Map(); + const globalState: Pick = { + get(key: string, defaultValue?: T): T { + return values.has(key) ? values.get(key) as T : defaultValue; + }, + async update(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; + + return { + values, + globalState, + settings: { "formatter.disableAutoupdate": false } as Record, + executables: new Set(["cargo", "simfmt", "simplicityhl-lsp"]), + outcome: "success" as "success" | "failure" | "cancel", + installs: [] as string[], + notifications: [] as string[], + errors: [] as unknown[], + errorActions: [] as string[][], + errorSelection: undefined as string | Promise | undefined, + warnings: [] as string[], + formatRuns: 0, + formatArgs: [] as string[], + formatOutcome: "success" as "success" | "failure" | "pending", + formatOutput: "error: invalid expression\n --> /test/example.simf:2:3\n", + onFormatSpawn: undefined as ((child: MockChild) => void) | undefined, + cancellations: 0, + kills: 0, + outputChannelsCreated: 0, + outputChannelsDisposed: 0, + outputChannelsShown: [] as boolean[], + output: [] as string[], + activeTextEditor: { document } as Pick | undefined, + contexts: [] as vscode.ExtensionContext[], + command: undefined as (() => Promise) | undefined, + provider: undefined as vscode.DocumentFormattingEditProvider | undefined, + }; +} + +let host = createHost(); +beforeEach(() => { host = createHost(); }); +afterEach(async () => { + await extensionModule.deactivate(); + for (const context of host.contexts) { + for (const subscription of context.subscriptions) subscription.dispose(); + } +}); + +const vscodeMock = { + ProgressLocation: { Notification: 15 }, + workspace: { + getConfiguration: () => ({ + get: (key: string, fallback?: unknown) => host.settings[key] ?? fallback, + }), + }, + commands: { + registerCommand: (name: string, callback: () => Promise) => { + if (name === COMMAND_IDS.formatFile) host.command = callback; + return disposable; + }, + }, + languages: { + registerDocumentFormattingEditProvider: (_selector: unknown, provider: vscode.DocumentFormattingEditProvider) => { + host.provider = provider; + return disposable; + }, + }, + window: { + get activeTextEditor() { return host.activeTextEditor; }, + createOutputChannel: () => { + host.outputChannelsCreated += 1; + let disposed = false; + const append = (text: string) => { + assert.equal(disposed, false, "Cannot write to a disposed output channel"); + host.output.push(text); + }; + return { + clear() { + assert.equal(disposed, false, "Cannot clear a disposed output channel"); + host.output = []; + }, + append, + appendLine: append, + show(preserveFocus: boolean) { + assert.equal(disposed, false, "Cannot show a disposed output channel"); + host.outputChannelsShown.push(preserveFocus); + }, + dispose() { + assert.equal(disposed, false, "Output channel must only be disposed once"); + disposed = true; + host.outputChannelsDisposed += 1; + }, + }; + }, + showErrorMessage: (error: unknown, ...actions: string[]) => { + host.errors.push(error); + host.errorActions.push(actions); + return Promise.resolve(host.errorSelection); + }, + showWarningMessage: async (message: string) => { host.warnings.push(message); }, + withProgress: async ( + options: { title: string }, + task: (progress: { report(): void }, token: { onCancellationRequested(callback: () => void): typeof disposable }) => Promise, + ) => { + host.notifications.push(options.title); + return task({ report() {} }, { + onCancellationRequested(callback) { + if (host.outcome === "cancel") queueMicrotask(callback); + return disposable; + }, + }); + }, + }, +}; + +const processMock = { + spawn: (_command: string, args: string[], options: { signal?: AbortSignal; shell?: boolean } = {}) => { + assert.equal(options.shell, false); + const child: MockChild = Object.assign(new EventEmitter(), { + stdout: new EventEmitter(), + stderr: new EventEmitter(), + exitCode: null, + signalCode: null, + kill: () => { + host.kills += 1; + queueMicrotask(() => close(null, "SIGTERM")); + return true; + }, + }); + const close = (code: number | null, signal: NodeJS.Signals | null = null) => { + child.exitCode = code; + child.signalCode = signal; + child.emit("close", code, signal); + }; + options.signal?.addEventListener("abort", () => { + host.cancellations += 1; + queueMicrotask(() => { + child.emit("error", new Error("The operation was aborted")); + close(null); + }); + }, { once: true }); + if (args[0] === "install") { + const command = args.at(-1); + host.installs.push(command); + if (host.outcome !== "cancel") { + queueMicrotask(() => { + const succeeded = host.outcome === "success"; + if (succeeded) host.executables.add(command); + close(succeeded ? 0 : 1); + }); + } + } else { + host.formatRuns += 1; + host.formatArgs = args; + host.onFormatSpawn?.(child); + if (host.formatOutcome !== "pending") { + queueMicrotask(() => { + if (host.formatOutcome === "failure") { + child.stderr.emit("data", Buffer.from(host.formatOutput)); + } + close(host.formatOutcome === "success" ? 0 : 1); + }); + } + } + return child; + }, +}; + +// VS Code exists only in the extension host. Replace host/process dependencies +// while loading these modules, then restore Node's loader before running tests. +const loadModule = createRequire(__filename); +const sourceFiles = new Set([ + "./format", "./format/install", "./commands/format", "./install_executable", + "./lsp/install", "./document", "./extension", +].map((name) => loadModule.resolve(name))); +const mocks: Record = { + vscode: vscodeMock, + "node:child_process": processMock, + "./find_executable": { + findExecutable: (command: string) => host.executables.has(command) ? `/test/bin/${command}` : null, + }, + "./lsp/client": { + LspClient: class { + async start() {} + async restart() {} + async shutdown() {} + }, + }, + "./compiler": { SimplicityHLCompiler: class {} }, + "./commands/compile": { registerCompileCommands() {} }, + "./tasks/provider": { registerTaskProvider() {} }, +}; +const loader = Module as unknown as { + _load(request: string, parent?: NodeJS.Module, isMain?: boolean): unknown; +}; +const originalLoad = loader._load; +let formatterModule: typeof import("./format"); +let formatterInstall: typeof import("./format/install"); +let commandsModule: typeof import("./commands/format"); +let documentModule: typeof import("./document"); +let extensionModule: typeof import("./extension"); +let serverModule: typeof import("./lsp/install"); +try { + loader._load = function (request, parent, isMain) { + if (sourceFiles.has(parent?.filename) && Object.hasOwn(mocks, request)) return mocks[request]; + return originalLoad.call(this, request, parent, isMain); + }; + formatterModule = loadModule("./format"); + formatterInstall = loadModule("./format/install"); + commandsModule = loadModule("./commands/format"); + documentModule = loadModule("./document"); + extensionModule = loadModule("./extension"); + serverModule = loadModule("./lsp/install"); +} finally { + loader._load = originalLoad; +} + +function context(): vscode.ExtensionContext { + const context = { globalState: host.globalState, subscriptions: [] } as unknown as vscode.ExtensionContext; + host.contexts.push(context); + return context; +} + +async function formatDocument(): Promise { + const edits = await host.provider.provideDocumentFormattingEdits( + document, + { tabSize: 2, insertSpaces: true }, + { isCancellationRequested: false } as vscode.CancellationToken, + ); + assert.deepEqual(edits, []); +} + +void test("active document selection preserves warnings and leaves preparation to the formatter", async (t) => { + extensionModule.activate(context()); + host.activeTextEditor = undefined; + await host.command(); + + const save = t.mock.fn(async () => true); + host.activeTextEditor = { document: { ...document, languageId: "plaintext", isDirty: true, save } }; + await host.command(); + + assert.deepEqual(host.warnings, [ + "No active file to format", + "Current file is not a SimplicityHL file (.simf)", + ]); + assert.equal(save.mock.callCount(), 0); + assert.equal(host.outputChannelsCreated, 0); + assert.equal(host.formatRuns, 0); +}); + +void test("the formatting command follows the shared save policy and saves only once", async (t) => { + const save = t.mock.fn(async () => true); + host.activeTextEditor = { document: { ...document, isDirty: true, save } }; + extensionModule.activate(context()); + + host.settings["formatter.autoSaveBeforeFormat"] = false; + await host.command(); + assert.equal(save.mock.callCount(), 0); + assert.equal(host.formatRuns, 0); + assert.deepEqual(host.errors, ["Save the SimplicityHL document before formatting it."]); + assert.deepEqual(host.warnings, []); + + host.settings["formatter.autoSaveBeforeFormat"] = true; + await host.command(); + assert.equal(save.mock.callCount(), 1); + assert.equal(host.formatRuns, 1); + assert.equal(host.errors.length, 1); +}); + +void test("the formatting provider prepares its supplied document independently of the active editor", async (t) => { + const saveActive = t.mock.fn(async () => true); + const saveTarget = t.mock.fn(async () => true); + const target: vscode.TextDocument = { + ...document, + uri: { scheme: "file", fsPath: "/test/other.simf" } as vscode.Uri, + isDirty: true, + save: saveTarget, + }; + host.settings["formatter.autoSaveBeforeFormat"] = true; + extensionModule.activate(context()); + + for (const activeTextEditor of [ + undefined, + { document: { ...document, languageId: "plaintext", isDirty: true, save: saveActive } }, + ]) { + host.activeTextEditor = activeTextEditor; + assert.deepEqual(await host.provider.provideDocumentFormattingEdits( + target, + { tabSize: 2, insertSpaces: true }, + { isCancellationRequested: false } as vscode.CancellationToken, + ), []); + assert.equal(host.formatArgs[0], target.uri.fsPath); + } + + assert.equal(saveTarget.mock.callCount(), 2); + assert.equal(saveActive.mock.callCount(), 0); + assert.deepEqual(host.errors, []); + assert.deepEqual(host.warnings, []); +}); + +void test("shared preparation returns validation errors without displaying notifications", async (t) => { + const save = t.mock.fn(async () => true); + const invalidDocument = { ...document, languageId: "plaintext", isDirty: true, save }; + const options = { action: "format", saveBeforeAction: true, requireFilePath: true, requireSaved: true } as const; + + assert.deepEqual(await documentModule.prepareSimplicityHLDocument(invalidDocument, options), { + error: "Current file is not a SimplicityHL file (.simf)", + }); + assert.equal(save.mock.callCount(), 0); + assert.deepEqual(host.errors, []); + assert.deepEqual(host.warnings, []); + + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + assert.deepEqual(await formatter.formatDocument(invalidDocument), { + success: false, + output: "Current file is not a SimplicityHL file (.simf)", + }); + assert.deepEqual(host.errors, ["Current file is not a SimplicityHL file (.simf)"]); + assert.deepEqual(host.errorActions, [[]]); + assert.equal(host.formatRuns, 0); +}); + +void test("active document preparation preserves compilation save behavior", async (t) => { + const save = t.mock.fn(async () => false); + const dirtyDocument = { ...document, isDirty: true, save }; + host.activeTextEditor = { document: dirtyDocument }; + + assert.equal(await documentModule.getActiveSimplicityHLDocument({ + action: "compile", saveBeforeAction: false, failIfSaveFails: true, + }), dirtyDocument); + assert.equal(save.mock.callCount(), 0); + + assert.equal(await documentModule.getActiveSimplicityHLDocument({ + action: "compile", saveBeforeAction: true, failIfSaveFails: true, + }), undefined); + assert.deepEqual(host.warnings, ["Save the SimplicityHL document before compiling it."]); + + assert.equal(await documentModule.getActiveSimplicityHLDocument({ + action: "compile", saveBeforeAction: true, failIfSaveFails: false, + }), dirtyDocument); + assert.equal(save.mock.callCount(), 2); + assert.equal(host.warnings.length, 1); +}); + +void test("active document selection does not forward preparation-only options", async () => { + const dirtyDocument = { ...document, isDirty: true }; + host.activeTextEditor = { document: dirtyDocument }; + const options = { action: "compile", saveBeforeAction: false, requireSaved: true } as const; + + assert.equal(await documentModule.getActiveSimplicityHLDocument(options), dirtyDocument); + assert.deepEqual(await documentModule.prepareSimplicityHLDocument(dirtyDocument, options), { + error: "Save the SimplicityHL document before compiling it.", + }); + assert.deepEqual(host.warnings, []); +}); + +void test("lazily shares the formatter between entry points and persists its cache across activation", async (t) => { + let now = 1_750_000_000_000; + t.mock.method(Date, "now", () => now); + extensionModule.activate(context()); + assert.equal(host.outputChannelsCreated, 0); + assert.equal(await host.provider.provideDocumentFormattingEdits( + document, + { tabSize: 2, insertSpaces: true }, + { isCancellationRequested: true } as vscode.CancellationToken, + ), undefined); + assert.equal(host.outputChannelsCreated, 0); + await formatDocument(); + await host.command(); + assert.equal(host.outputChannelsCreated, 1); + assert.deepEqual(host.formatArgs, [document.uri.fsPath, "--color", "never"]); + assert.equal(host.values.get(CACHE_KEY), now); + + await extensionModule.deactivate(); + assert.equal(host.outputChannelsDisposed, 1); + extensionModule.activate(context()); + await formatDocument(); + assert.deepEqual(host.installs, ["simfmt"]); + assert.deepEqual(host.notifications, ["Updating simfmt"]); + assert.equal(host.formatRuns, 3); + + now += DAY_MS; + await formatDocument(); + assert.deepEqual(host.installs, ["simfmt", "simfmt"]); + assert.equal(host.values.get(CACHE_KEY), now); +}); + +void test("uses an injected cache for update decisions without creating persistent state", async (t) => { + let allowUpdate = false; + const cache: UpdateCache = { shouldUpdate: t.mock.fn(async () => allowUpdate) }; + const formatter = new formatterModule.SimplicityHLFormatter(cache); + const extensionContext = context(); + extensionContext.subscriptions.push(formatter); + commandsModule.registerFormattingCommands(extensionContext, () => formatter); + await formatDocument(); + assert.deepEqual(host.installs, []); + + allowUpdate = true; + await formatDocument(); + assert.deepEqual(host.installs, ["simfmt"]); + assert.equal(host.values.size, 0); +}); + +void test("custom formatter paths bypass the cache and automatic updates", async (t) => { + host.settings["formatter.path"] = " /custom/simfmt "; + const shouldUpdate = t.mock.fn(async () => true); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/custom/simfmt"); + assert.equal(shouldUpdate.mock.callCount(), 0); + assert.deepEqual(host.installs, []); +}); + +void test("disabled autoupdate bypasses the cache for an installed formatter", async (t) => { + host.settings["formatter.disableAutoupdate"] = true; + const shouldUpdate = t.mock.fn(async () => true); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/test/bin/simfmt"); + assert.equal(shouldUpdate.mock.callCount(), 0); + assert.deepEqual(host.installs, []); +}); + +void test("formatter installation enables autoupdate by default", async (t) => { + delete host.settings["formatter.disableAutoupdate"]; + const shouldUpdate = t.mock.fn(async () => true); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/test/bin/simfmt"); + assert.equal(shouldUpdate.mock.callCount(), 1); + assert.deepEqual(host.installs, ["simfmt"]); +}); + +void test("missing Cargo bypasses the cache and preserves executable discovery", async (t) => { + host.executables.delete("cargo"); + const shouldUpdate = t.mock.fn(async () => true); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/test/bin/simfmt"); + + host.executables.delete("simfmt"); + await assert.rejects(formatterInstall.getSimfmtPath({ shouldUpdate }), /simfmt formatter not found/); + assert.equal(shouldUpdate.mock.callCount(), 0); + assert.deepEqual(host.installs, []); + assert.equal(host.warnings.length, 1); +}); + +void test("installs a missing formatter even when the cache declines an update", async (t) => { + host.executables.delete("simfmt"); + const shouldUpdate = t.mock.fn(async () => false); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/test/bin/simfmt"); + assert.equal(shouldUpdate.mock.callCount(), 1); + assert.deepEqual(host.notifications, ["Installing simfmt"]); + assert.deepEqual(host.installs, ["simfmt"]); +}); + +void test("initial installation with autoupdate enabled reserves the daily cooldown", async () => { + host.executables.delete("simfmt"); + extensionModule.activate(context()); + await formatDocument(); + await formatDocument(); + assert.equal(typeof host.values.get(CACHE_KEY), "number"); + assert.deepEqual(host.notifications, ["Installing simfmt"]); + assert.deepEqual(host.installs, ["simfmt"]); +}); + +void test("installs a missing formatter with autoupdate disabled without consulting the cache", async (t) => { + host.executables.delete("simfmt"); + host.settings["formatter.disableAutoupdate"] = true; + const shouldUpdate = t.mock.fn(async () => true); + assert.equal(await formatterInstall.getSimfmtPath({ shouldUpdate }), "/test/bin/simfmt"); + assert.equal(shouldUpdate.mock.callCount(), 0); + assert.deepEqual(host.installs, ["simfmt"]); +}); + +for (const outcome of ["failure", "cancel"] as const) { + void test(`keeps the daily cooldown after update ${outcome}`, async () => { + let now = 1_750_000_000_000; + host.outcome = outcome; + const cache = new DailyUpdateCache(host.globalState, CACHE_KEY, () => now); + await assert.rejects(formatterInstall.getSimfmtPath(cache), /simfmt formatter not found/); + assert.equal(host.errors.length, 1); + assert.equal(host.cancellations, outcome === "cancel" ? 1 : 0); + assert.equal(await formatterInstall.getSimfmtPath(cache), "/test/bin/simfmt"); + + const reloadedCache = new DailyUpdateCache(host.globalState, CACHE_KEY, () => now); + assert.equal(await formatterInstall.getSimfmtPath(reloadedCache), "/test/bin/simfmt"); + assert.deepEqual(host.installs, ["simfmt"]); + + now += DAY_MS; + host.outcome = "success"; + assert.equal(await formatterInstall.getSimfmtPath(reloadedCache), "/test/bin/simfmt"); + assert.deepEqual(host.installs, ["simfmt", "simfmt"]); + }); +} + +void test("LSP callers without a cache retain their existing update behavior", async () => { + await serverModule.ensureExecutable("simplicityhl-lsp"); + await serverModule.ensureExecutable("simplicityhl-lsp"); + assert.deepEqual(host.installs, ["simplicityhl-lsp", "simplicityhl-lsp"]); + assert.equal(host.values.size, 0); + + host.settings.disableAutoupdate = true; + assert.equal(await serverModule.ensureExecutable("simplicityhl-lsp"), "/test/bin/simplicityhl-lsp"); + assert.equal(host.installs.length, 2); +}); + +void test("formatter failures offer output without opening it when the notification is dismissed", async () => { + host.formatOutcome = "failure"; + extensionModule.activate(context()); + const result = await host.provider.provideDocumentFormattingEdits( + document, + { tabSize: 2, insertSpaces: true }, + { isCancellationRequested: false } as vscode.CancellationToken, + ); + assert.equal(result, undefined); + assert.deepEqual(host.errors, ["Formatting failed: invalid expression"]); + assert.deepEqual(host.errorActions, [["Show Output"]]); + assert.deepEqual(host.outputChannelsShown, []); + assert.ok(host.output.join("\n").includes("invalid expression")); +}); + +void test("formatter setup failures preserve complete errors and can show their output", async (t) => { + const message = `Unable to prepare simfmt: ${"details ".repeat(100)}\nUnderlying error`; + t.mock.method(formatterInstall, "getSimfmtPath", async () => { throw new Error(message); }); + host.errorSelection = "Show Output"; + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + + assert.deepEqual(await formatter.formatDocument(document), { success: false, output: message }); + assert.ok(host.output.join("\n").includes(message)); + assert.equal(host.errors.length, 1); + const notification = String(host.errors[0]); + assert.ok(notification.length <= 200); + assert.match(notification, /^Formatting failed: Unable to prepare simfmt: .*…$/); + assert.deepEqual(host.errorActions, [["Show Output"]]); + assert.deepEqual(host.outputChannelsShown, [true]); + assert.equal(host.formatRuns, 0); +}); + +for (const disposeBeforeAction of [false, true]) { + void test(`formatter errors return before the output action, with disposal ${disposeBeforeAction}`, async () => { + host.formatOutcome = "failure"; + host.formatOutput = `error: ${"invalid expression ".repeat(100)}\n --> /test/example.simf:2:3\n`; + let selectAction: (action: string) => void; + host.errorSelection = new Promise((resolve) => { selectAction = resolve; }); + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + + const result = await formatter.formatDocument(document); + assert.equal(result.success, false); + assert.equal(result.output, host.formatOutput + "simfmt exited with code 1.\n"); + assert.ok(host.output.join("\n").includes(host.formatOutput)); + assert.match(String(host.errors[0]), /^Formatting failed: invalid expression .*…$/); + assert.deepEqual(host.errorActions, [["Show Output"]]); + assert.deepEqual(host.outputChannelsShown, []); + + if (disposeBeforeAction) formatter.dispose(); + selectAction("Show Output"); + await host.errorSelection; + assert.deepEqual(host.outputChannelsShown, disposeBeforeAction ? [] : [true]); + }); +} + +void test("formatter disposal stops active children and prevents further formatting", async () => { + host.formatOutcome = "pending"; + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + const started = new Promise((resolve) => { host.onFormatSpawn = resolve; }); + const formatting = formatter.formatDocument(document); + const child = await started; + + formatter.dispose(); + formatter.dispose(); + const result = await formatting; + child.stdout.emit("data", Buffer.from("late output")); + assert.equal(result.success, false); + assert.match(result.output, /extension shutdown/); + assert.equal(child.signalCode, "SIGTERM"); + assert.equal(host.kills, 1); + assert.equal(host.outputChannelsDisposed, 1); + assert.deepEqual(host.errors, []); + + assert.equal((await formatter.formatDocument(document)).success, false); + assert.equal(host.formatRuns, 1); +}); + +void test("formatter disposal during executable preparation prevents a later process launch", async () => { + let finishDecision: (allowed: boolean) => void; + const decision = new Promise((resolve) => { finishDecision = resolve; }); + let markStarted: () => void; + const started = new Promise((resolve) => { markStarted = resolve; }); + const formatter = new formatterModule.SimplicityHLFormatter({ + shouldUpdate: async () => { + markStarted(); + return decision; + }, + }); + context().subscriptions.push(formatter); + const formatting = formatter.formatDocument(document); + await started; + formatter.dispose(); + finishDecision(false); + + const result = await formatting; + assert.equal(result.success, false); + assert.match(result.output, /extension shutdown/); + assert.equal(host.formatRuns, 0); + assert.deepEqual(host.errors, []); +}); + +void test("formatter preserves autosave settings for dirty documents", async (t) => { + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + const save = t.mock.fn(async () => true); + const dirtyDocument = { ...document, isDirty: true, save } as vscode.TextDocument; + + host.settings["formatter.autoSaveBeforeFormat"] = false; + assert.deepEqual(await formatter.formatDocument(dirtyDocument), { + success: false, + output: "Save the SimplicityHL document before formatting it.", + }); + assert.deepEqual(host.errors, ["Save the SimplicityHL document before formatting it."]); + assert.deepEqual(host.errorActions, [[]]); + assert.equal(save.mock.callCount(), 0); + assert.equal(host.formatRuns, 0); + + host.settings["formatter.autoSaveBeforeFormat"] = true; + assert.equal((await formatter.formatDocument(dirtyDocument)).success, true); + assert.equal(save.mock.callCount(), 1); + assert.equal(host.formatRuns, 1); +}); + +void test("formatter uses the same save warning for missing paths and failed saves", async () => { + host.settings["formatter.autoSaveBeforeFormat"] = true; + const formatter = new formatterModule.SimplicityHLFormatter({ shouldUpdate: async () => false }); + context().subscriptions.push(formatter); + const invalidDocuments = [ + { ...document, uri: { scheme: "untitled", fsPath: "/test/example.simf" } }, + { ...document, uri: { scheme: "file", fsPath: "" } }, + { ...document, isDirty: true, save: async () => false }, + ] as vscode.TextDocument[]; + + for (const invalidDocument of invalidDocuments) { + assert.deepEqual(await formatter.formatDocument(invalidDocument), { + success: false, + output: "Save the SimplicityHL document before formatting it.", + }); + assert.deepEqual(host.output, ["Save the SimplicityHL document before formatting it."]); + } + assert.deepEqual(host.errors, invalidDocuments.map(() => + "Save the SimplicityHL document before formatting it.", + )); + assert.deepEqual(host.errorActions, invalidDocuments.map(() => [])); + assert.deepEqual(host.outputChannelsShown, []); + assert.equal(host.formatRuns, 0); +}); diff --git a/src/format/index.ts b/src/format/index.ts new file mode 100644 index 0000000..a20fd34 --- /dev/null +++ b/src/format/index.ts @@ -0,0 +1,205 @@ +// 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, SETTINGS } from "../contracts"; +import { prepareSimplicityHLDocument } from "../document"; +import type { UpdateCache } from "../update_cache"; + +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("SimplicityHL Formatter"); + private readonly activeChildren = new Set(); + private disposed = false; + + public constructor(private readonly updateCache: UpdateCache) {} + + // 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: "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.updateCache); + } 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..5272dc3 --- /dev/null +++ b/src/format/install.ts @@ -0,0 +1,31 @@ +// Formatter executable resolution and automatic-update policy. + +import { workspace } from "vscode"; +import { CONFIGURATION_SECTION, FORMATTER_BINARY, SETTINGS } from "../contracts"; +import { ensureExecutable } from "../install_executable"; +import type { UpdateCache } from "../update_cache"; + +export async function getSimfmtPath(updateCache: UpdateCache): Promise { + const config = workspace.getConfiguration(CONFIGURATION_SECTION); + const configuredPath = config.get( + SETTINGS.formatterPath.key, + SETTINGS.formatterPath.default, + ).trim(); + if (configuredPath) { + return configuredPath; + } + + const formatterPath = await ensureExecutable(FORMATTER_BINARY, { + displayName: "SimplicityHL formatter", + disableAutoupdateSetting: SETTINGS.formatterDisableAutoupdate, + updateCache, + }); + 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/install_executable.ts b/src/install_executable.ts new file mode 100644 index 0000000..eb7f6fd --- /dev/null +++ b/src/install_executable.ts @@ -0,0 +1,162 @@ +// Shared executable discovery, Cargo installation, and update checks. + +import * as cp from "node:child_process"; + +import { env, ProgressLocation, Uri, window, workspace } from "vscode"; +import type { UpdateCache } from "./update_cache"; + +import { CONFIGURATION_SECTION, SETTINGS } from "./contracts"; +import { findExecutable } from "./find_executable"; + +interface EnsureExecutableOptions { + displayName: string; + disableAutoupdateSetting: { key: string; default: boolean }; + updateCache?: UpdateCache; +} + +async function installExecutable(command: string) { + const cargoPath = findExecutable("cargo"); + if (!cargoPath) { + 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(); + } + }; + + 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 }); + } + } + }; + + installProcess.stderr?.on('data', reportProgress); + + 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}`)); + } + }); + + if (token.isCancellationRequested) { + cancellation.abort(); + } + }); + }); +} + +export async function ensureExecutable( + command: string, + options: EnsureExecutableOptions, +): Promise { + const cargoPath = findExecutable("cargo"); + const config = workspace.getConfiguration(CONFIGURATION_SECTION); + + let executablePath = findExecutable(command); + + if (!cargoPath && !executablePath) { + const suppressWarning = config.get( + SETTINGS.suppressMissingLspWarning.key, + SETTINGS.suppressMissingLspWarning.default, + ); + if (suppressWarning) { + return null; + } + + const choice = await window.showWarningMessage( + `To use ${options.displayName}, please install cargo`, + "Learn more", + "Don't show again", + ); + + if (choice === "Learn more") { + const url = "https://rust-lang.org/tools/install"; + await env.openExternal(Uri.parse(url)); + } else if (choice === "Don't show again") { + await config.update( + SETTINGS.suppressMissingLspWarning.key, + true, + true, + ); + } + + return null; + } + + if (!cargoPath) { + return executablePath; + } + + const disableAutoupdate = config.get( + options.disableAutoupdateSetting.key, + options.disableAutoupdateSetting.default, + ); + + if (executablePath && disableAutoupdate) { + return executablePath; + } + + try { + if (!disableAutoupdate && options.updateCache) { + const shouldUpdate = await options.updateCache.shouldUpdate(); + // A cached update must never prevent installing a missing executable. + if (executablePath && !shouldUpdate) { + return executablePath; + } + } + + await installExecutable(command); + + executablePath = findExecutable(command); + } catch (err) { + window.showErrorMessage(err); + return null; + } + + return executablePath; +} diff --git a/src/lsp/install.ts b/src/lsp/install.ts index 78fe88c..a6012ff 100644 --- a/src/lsp/install.ts +++ b/src/lsp/install.ts @@ -1,144 +1,11 @@ -import * as cp from "node:child_process"; +// Language-server installation policy; Cargo process handling is shared. -import { env, ProgressLocation, Uri, window, workspace } from "vscode"; +import { SETTINGS } from "../contracts"; +import { ensureExecutable as ensureInstalledExecutable } from "../install_executable"; -import { CONFIGURATION_SECTION, SETTINGS } from "../contracts"; -import { findExecutable } from "../find_executable"; - -async function installServer(command: string) { - const cargoPath = findExecutable("cargo"); - if (!cargoPath) { - 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(); - } - }; - - 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 }); - } - } - }; - - installProcess.stderr?.on('data', reportProgress); - - 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}`)); - } - }); - - if (token.isCancellationRequested) { - cancellation.abort(); - } - }); +export function ensureExecutable(command: string): Promise { + return ensureInstalledExecutable(command, { + displayName: "SimplicityHL language server", + disableAutoupdateSetting: SETTINGS.disableAutoupdate, }); } - -export async function ensureExecutable( - command: string, -): Promise { - const cargoPath = findExecutable("cargo"); - const config = workspace.getConfiguration(CONFIGURATION_SECTION); - - let serverPath = findExecutable(command); - - if (!cargoPath && !serverPath) { - const suppressWarning = config.get( - SETTINGS.suppressMissingLspWarning.key, - SETTINGS.suppressMissingLspWarning.default, - ); - if (suppressWarning) { - return null; - } - - const choice = await window.showWarningMessage( - `To use SimplicityHL language server, please install cargo`, - "Learn more", - "Don't show again", - ); - - if (choice === "Learn more") { - const url = "https://rust-lang.org/tools/install"; - await env.openExternal(Uri.parse(url)); - } else if (choice === "Don't show again") { - await config.update( - SETTINGS.suppressMissingLspWarning.key, - true, - true, - ); - } - - return null; - } - - if (!cargoPath) { - return serverPath; - } - - const disableAutoupdate = config.get( - SETTINGS.disableAutoupdate.key, - SETTINGS.disableAutoupdate.default, - ); - - if (serverPath && disableAutoupdate) { - return serverPath; - } - - try { - await installServer(command); - - serverPath = findExecutable(command); - } catch (err) { - window.showErrorMessage(err); - return null; - } - - return serverPath; -} diff --git a/src/update_cache.test.ts b/src/update_cache.test.ts new file mode 100644 index 0000000..6a9bafe --- /dev/null +++ b/src/update_cache.test.ts @@ -0,0 +1,116 @@ +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import type { Memento } from "vscode"; + +import { DailyUpdateCache } from "./update_cache"; + +const KEY = "test.lastUpdateAttemptAt"; +const DAY_MS = 24 * 60 * 60 * 1000; +const START_TIME = 1_750_000_000_000; + +function createFixture(initialValue?: unknown) { + const clock = { now: START_TIME }; + 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; + }, + async update(key: string, value: unknown): Promise { + values.set(key, value); + }, + }; + const cache = new DailyUpdateCache(state, KEY, () => clock.now); + return { cache, clock, state, values }; +} + +void test("persists the first attempt and leaves the timestamp unchanged during cooldown", async (t) => { + const { cache, clock, state, values } = createFixture(); + const update = t.mock.method(state, "update"); + + assert.equal(await cache.shouldUpdate(), true); + assert.equal(values.get(KEY), clock.now); + clock.now += 1000; + assert.equal(await cache.shouldUpdate(), false); + assert.equal(values.get(KEY), START_TIME); + assert.equal(update.mock.callCount(), 1); +}); + +void test("allows the next attempt exactly 24 hours later", async () => { + const { cache, clock, values } = createFixture(); + assert.equal(await cache.shouldUpdate(), true); + + clock.now += DAY_MS - 1; + assert.equal(await cache.shouldUpdate(), false); + clock.now += 1; + assert.equal(await cache.shouldUpdate(), true); + assert.equal(values.get(KEY), clock.now); +}); + +void test("remembers attempts across cache instances and reads newer persisted attempts", async () => { + const { cache, clock, state } = createFixture(); + assert.equal(await cache.shouldUpdate(), true); + + const reloadedCache = new DailyUpdateCache(state, KEY, () => clock.now); + assert.equal(await reloadedCache.shouldUpdate(), false); + clock.now += DAY_MS; + assert.equal(await reloadedCache.shouldUpdate(), true); + assert.equal(await cache.shouldUpdate(), false); +}); + +void test("reserves concurrent attempts before persistence and awaits the write before approval", async (t) => { + const { cache, state, values } = createFixture(); + let finishWrite: () => void; + const pendingWrite = new Promise((resolve) => { finishWrite = resolve; }); + const update = t.mock.method(state, "update", async (key, value) => { + await pendingWrite; + values.set(key, value); + }); + + let approved = false; + const firstAttempt = cache.shouldUpdate().then((result) => { + approved = result; + return result; + }); + assert.equal(await cache.shouldUpdate(), false); + assert.equal(approved, false); + assert.equal(values.has(KEY), false); + + finishWrite(); + assert.equal(await firstAttempt, true); + assert.equal(values.get(KEY), START_TIME); + assert.equal(update.mock.callCount(), 1); +}); + +void test("treats missing, invalid, and future timestamps as eligible", async (t) => { + const invalidValues = [undefined, null, -1, NaN, Infinity, -Infinity, "invalid", {}, START_TIME + 1]; + for (const value of invalidValues) { + const { cache, values } = createFixture(value); + assert.equal(await cache.shouldUpdate(), true, `timestamp: ${String(value)}`); + assert.equal(values.get(KEY), START_TIME); + } + + // Zero is a valid timestamp, not an empty-cache sentinel. + await t.test("accepts a timestamp at the Unix epoch", async () => { + const { cache, clock } = createFixture(0); + clock.now = 1; + assert.equal(await cache.shouldUpdate(), false); + }); +}); + +void test("logs persistence failures and retains the cooldown in memory", async (t) => { + const { cache, clock, state } = createFixture(); + const failure = new Error("Storage unavailable"); + const update = t.mock.method(state, "update", async () => { throw failure; }); + const warn = t.mock.method(console, "warn", () => {}); + + assert.equal(await cache.shouldUpdate(), true); + assert.equal(await cache.shouldUpdate(), false); + assert.equal(update.mock.callCount(), 1); + assert.equal(warn.mock.calls[0].arguments[1], failure); + + clock.now += DAY_MS; + assert.equal(await cache.shouldUpdate(), true); + assert.equal(update.mock.callCount(), 2); +}); diff --git a/src/update_cache.ts b/src/update_cache.ts new file mode 100644 index 0000000..99b8dde --- /dev/null +++ b/src/update_cache.ts @@ -0,0 +1,47 @@ +import type { Memento } from "vscode"; + +const UPDATE_INTERVAL_MS = 24 * 60 * 60 * 1000; + +export interface UpdateCache { + /** + * Returning true reserves the update attempt before resolving, even if the + * update later fails or is canceled. Returning false leaves the cache unchanged. + */ + shouldUpdate(): Promise; +} + +export class DailyUpdateCache implements UpdateCache { + private lastAttemptAt: number | undefined; + + public constructor( + private readonly state: Pick, + private readonly key: string, + private readonly now: () => number = Date.now, + ) {} + + public async shouldUpdate(): Promise { + const now = this.now(); + const previousAttempts = [this.lastAttemptAt, this.state.get(this.key)]; + const recentlyAttempted = previousAttempts.some((timestamp) => + typeof timestamp === "number" && + Number.isFinite(timestamp) && + timestamp >= 0 && + timestamp <= now && + now - timestamp < UPDATE_INTERVAL_MS, + ); + + if (recentlyAttempted) { + return false; + } + + // Reserve synchronously so concurrent calls and failed writes stay throttled. + this.lastAttemptAt = now; + try { + await this.state.update(this.key, now); + } catch (error) { + console.warn(`Unable to persist update cache '${this.key}'; using session state.`, error); + } + + return true; + } +}