diff --git a/package.json b/package.json index 61717c0..17c4aa0 100644 --- a/package.json +++ b/package.json @@ -240,7 +240,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/features.test.ts", + "test": "node --test -r ts-node/register src/contracts.test.ts src/find_executable.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/client.ts b/src/client.ts deleted file mode 100644 index 2fb20f6..0000000 --- a/src/client.ts +++ /dev/null @@ -1,135 +0,0 @@ -// LSP client for SimplicityHL language server. -// Manages connection lifecycle and integrates with status bar. - -import * as fs from "node:fs"; -import process from "node:process"; -import { - ExtensionContext, - window, - workspace, -} from "vscode"; -import { - Executable, - LanguageClient, - LanguageClientOptions, - ServerOptions, -} from "vscode-languageclient/node"; -import { ensureExecutable } from "./find_server"; -import { lspInitializationOptions } from "./settings"; -import { getStatusBar } from "./statusBar"; - -export class LspClient { - private client: LanguageClient | undefined; - - public constructor(context: ExtensionContext) { - context.subscriptions.push( - workspace.onDidChangeConfiguration((event) => { - if (!event.affectsConfiguration("simplicityhl")) { - return; - } - if (event.affectsConfiguration("simplicityhl.server.path")) { - void this.restart(); - } - }), - ); - } - - public async start(): Promise { - const statusBar = getStatusBar(); - statusBar.update("starting"); - statusBar.show(); - - const configuration = workspace.getConfiguration("simplicityhl"); - const configuredPath = configuration.get("server.path", "").trim(); - let execPath: string | null; - if (configuredPath) { - if (!fs.existsSync(configuredPath)) { - statusBar.update("error"); - window.showErrorMessage( - `Configured SimplicityHL language server does not exist: ${configuredPath}`, - ); - return; - } - execPath = configuredPath; - } else { - execPath = await ensureExecutable("simplicityhl-lsp"); - } - - if (!execPath) { - statusBar.update("disconnected"); - return; - } - - const run: Executable = { - command: execPath, - options: { - env: { - ...process.env, - }, - }, - }; - const serverOptions: ServerOptions = { - run, - debug: run, - }; - - const clientOptions: LanguageClientOptions = { - documentSelector: [ - { scheme: "file", language: "simplicityhl" }, - { scheme: "file", language: "simplicityhl-witness" }, - ], - initializationOptions: lspInitializationOptions(), - synchronize: { - configurationSection: "simplicityhl", - }, - }; - - this.client = new LanguageClient( - "simplicityhlLspClient", - "SimplicityHL LSP", - serverOptions, - clientOptions, - ); - - try { - await this.client.start(); - statusBar.update("connected"); - window.showInformationMessage("SimplicityHL Language Server activated!"); - } catch (e) { - this.client = undefined; - statusBar.update("error"); - window.showErrorMessage( - `Failed to start SimplicityHL Language Server: ${e}`, - ); - } - } - - public async stop(): Promise { - if (!this.client) { - return; - } - await this.client.stop(); - this.client = undefined; - getStatusBar().update("disconnected"); - } - - public async restart(): Promise { - const statusBar = getStatusBar(); - - if (!this.client) { - // Try to start even if not previously initialized - await this.start(); - return; - } - - try { - statusBar.update("starting"); - await this.stop(); - await this.start(); - window.showInformationMessage("SimplicityHL Language Server restarted successfully!"); - } catch (e) { - statusBar.update("error"); - window.showErrorMessage(`Failed to restart LSP: ${e}`); - } - } -} diff --git a/src/commands.ts b/src/commands.ts deleted file mode 100644 index 5b30505..0000000 --- a/src/commands.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { ExtensionContext, commands } from "vscode"; -import { LspClient } from "./client"; - -export function registerRestartCommand( - context: ExtensionContext, - lspClient: LspClient -) { - const command = commands.registerCommand( - "simplicityhl.restartServer", - async () => { - await lspClient.restart(); - } - ); - - context.subscriptions.push(command); -} diff --git a/src/compile_commands.ts b/src/commands/compile.ts similarity index 75% rename from src/compile_commands.ts rename to src/commands/compile.ts index 52f3e14..66e0887 100644 --- a/src/compile_commands.ts +++ b/src/commands/compile.ts @@ -2,8 +2,15 @@ // Registers commands accessible via Command Palette and keybindings. import * as vscode from "vscode"; -import * as path from "path"; -import { getCompiler } from "./compile"; +import * as path from "node:path"; +import type { CompileResult, SimplicityHLCompiler } from "../compiler"; +import type { CompileOptions } from "../compiler/args"; +import { + COMMAND_IDS, + CONFIGURATION_SECTION, + LANGUAGE_IDS, + SETTINGS, +} from "../contracts"; function showCompilationFailed(): void { vscode.window.showErrorMessage( @@ -20,14 +27,17 @@ async function getSimplicityHLDocument(): Promise("build.autoSaveBeforeCompile", true); + 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) { @@ -41,17 +51,24 @@ async function getSimplicityHLDocument(): Promise SimplicityHLCompiler, + options: CompileOptions = {}, +): Promise { + const document = await getSimplicityHLDocument(); + return document && compiler().compileFile(document.uri.fsPath, options); +} + // Register all compile-related commands -export function registerCompileCommands(context: vscode.ExtensionContext): void { - // Basic compile - compiles the current .simf file +export function registerCompileCommands( + context: vscode.ExtensionContext, + compiler: () => SimplicityHLCompiler, +): void { const compileFileCommand = vscode.commands.registerCommand( - "simplicityhl.compileFile", + COMMAND_IDS.compileFile, async () => { - const document = await getSimplicityHLDocument(); - if (!document) return; - - const compiler = getCompiler(); - const result = await compiler.compileFile(document.uri.fsPath); + const result = await compileActiveDocument(compiler); + if (!result) return; if (result.success) { // Offer to copy output to clipboard @@ -71,15 +88,11 @@ export function registerCompileCommands(context: vscode.ExtensionContext): void } ); - // Compile with debug symbols - includes debug info in output const compileDebugCommand = vscode.commands.registerCommand( - "simplicityhl.compileFileDebug", + COMMAND_IDS.compileFileDebug, async () => { - const document = await getSimplicityHLDocument(); - if (!document) return; - - const compiler = getCompiler(); - const result = await compiler.compileFile(document.uri.fsPath, { debug: true }); + const result = await compileActiveDocument(compiler, { debug: true }); + if (!result) return; if (result.success) { vscode.window.showInformationMessage("Compiled with debug symbols!"); @@ -89,9 +102,8 @@ export function registerCompileCommands(context: vscode.ExtensionContext): void } ); - // Compile with witness - satisfies the program with witness data const compileWithWitnessCommand = vscode.commands.registerCommand( - "simplicityhl.compileWithWitness", + COMMAND_IDS.compileWithWitness, async () => { const document = await getSimplicityHLDocument(); if (!document) return; @@ -125,8 +137,7 @@ export function registerCompileCommands(context: vscode.ExtensionContext): void return; } - const compiler = getCompiler(); - const result = await compiler.compileFile(simfPath, { witnessFile }); + const result = await compiler().compileFile(simfPath, { witnessFile }); if (result.success) { const action = await vscode.window.showInformationMessage( @@ -149,15 +160,11 @@ export function registerCompileCommands(context: vscode.ExtensionContext): void } ); - // Compile to JSON - outputs result in JSON format in a new editor const compileJsonCommand = vscode.commands.registerCommand( - "simplicityhl.compileJson", + COMMAND_IDS.compileJson, async () => { - const document = await getSimplicityHLDocument(); - if (!document) return; - - const compiler = getCompiler(); - const result = await compiler.compileFile(document.uri.fsPath, { json: true }); + const result = await compileActiveDocument(compiler, { json: true }); + if (!result) return; if (result.success && result.program) { // Show JSON output in a new untitled document @@ -184,6 +191,6 @@ export function registerCompileCommands(context: vscode.ExtensionContext): void compileFileCommand, compileDebugCommand, compileWithWitnessCommand, - compileJsonCommand + compileJsonCommand, ); } diff --git a/src/compiler/args.ts b/src/compiler/args.ts new file mode 100644 index 0000000..09630d3 --- /dev/null +++ b/src/compiler/args.ts @@ -0,0 +1,56 @@ +import type { ExperimentalFeatures, TaskCommand } from "../contracts"; + +export interface CompileOptions { + debug?: boolean; + witnessFile?: string; + json?: boolean; +} + +export function compilerFeatureArguments( + features: ExperimentalFeatures, +): string[] { + const args: string[] = []; + if (features.imports) { + args.push("-Z", "imports"); + } + if (features.enums) { + args.push("-Z", "enums"); + } + return args; +} + +export function compilerArguments( + file: string, + features: ExperimentalFeatures, + options: CompileOptions = {}, +): string[] { + const args = [file, ...compilerFeatureArguments(features)]; + if (options.witnessFile) { + args.push("-w", options.witnessFile); + } + if (options.debug) { + args.push("--debug"); + } + if (options.json) { + args.push("--json"); + } + return args; +} + +export function taskCompilerArguments( + command: TaskCommand, + file: string, + witnessFile: string | undefined, + features: ExperimentalFeatures, +): string[] { + switch (command) { + case "compile": + return compilerArguments(file, features); + case "compile-debug": + return compilerArguments(file, features, { debug: true }); + case "compile-with-witness": + return compilerArguments(file, features, { + witnessFile: witnessFile || "${file/.simf/.wit/}", + }); + } +} diff --git a/src/compile.ts b/src/compiler/index.ts similarity index 55% rename from src/compile.ts rename to src/compiler/index.ts index ec6b579..eee8b4f 100644 --- a/src/compile.ts +++ b/src/compiler/index.ts @@ -2,18 +2,13 @@ // Wraps the `simc` binary and parses its output for VSCode integration. import * as vscode from "vscode"; -import * as cp from "child_process"; +import * as cp from "node:child_process"; import * as path from "node:path"; -import { compilerFeatureArguments } from "./features"; -import { findExecutable } from "./find_server"; -import { getExperimentalFeatures } from "./settings"; - -// Options for compilation -export interface CompileOptions { - debug?: boolean; // Include debug symbols (--debug flag) - witnessFile?: string; // Path to witness file for satisfaction - json?: boolean; // Output in JSON format (--json flag) -} +import { compilerArguments, type CompileOptions } from "./args"; +import { parseCompilerOutput } from "./output"; +import { CONFIGURATION_SECTION, SETTINGS } from "../contracts"; +import { findExecutable } from "../find_executable"; +import { getExperimentalFeatures } from "../settings"; // Result of a compilation attempt export interface CompileResult { @@ -25,7 +20,9 @@ export interface CompileResult { // Main compiler class - manages simc invocations export class SimplicityHLCompiler { - private outputChannel: vscode.OutputChannel; + private readonly outputChannel: vscode.OutputChannel; + private readonly activeChildren = new Set(); + private disposed = false; constructor() { // Output channel shows raw compiler output @@ -37,6 +34,9 @@ export class SimplicityHLCompiler { filePath: string, options: CompileOptions = {} ): Promise { + if (this.disposed) { + return { success: false, error: "SimplicityHL compiler is disposed" }; + } this.outputChannel.clear(); this.outputChannel.show(true); let simcPath: string; @@ -47,23 +47,11 @@ export class SimplicityHLCompiler { this.outputChannel.appendLine(`Unable to prepare compilation: ${message}`); return { success: false, error: message }; } - const args: string[] = [ + const args = compilerArguments( filePath, - ...compilerFeatureArguments(getExperimentalFeatures()), - ]; - - // Add optional witness file - if (options.witnessFile) { - args.push("-w"); - args.push(options.witnessFile); - } - // Add optional flags - if (options.debug) { - args.push("--debug"); - } - if (options.json) { - args.push("--json"); - } + getExperimentalFeatures(), + options, + ); // Show compilation info in output channel this.outputChannel.appendLine(`Compiling: ${filePath}`); @@ -75,53 +63,58 @@ export class SimplicityHLCompiler { return new Promise((resolve) => { const proc = cp.spawn(simcPath, args, { cwd: path.dirname(filePath), + shell: false, }); + this.activeChildren.add(proc); let stdout = ""; let stderr = ""; + let settled = false; + const finish = (result: CompileResult): void => { + if (!settled) { + settled = true; + resolve(result); + } + }; proc.stdout?.on("data", (data) => { stdout += data.toString(); - this.outputChannel.append(data.toString()); + if (!this.disposed) { + this.outputChannel.append(data.toString()); + } }); proc.stderr?.on("data", (data) => { stderr += data.toString(); - this.outputChannel.append(data.toString()); + if (!this.disposed) { + this.outputChannel.append(data.toString()); + } }); proc.on("close", (code) => { + this.activeChildren.delete(proc); + if (settled) { + return; + } + if (this.disposed) { + finish({ + success: false, + error: "Compilation canceled during extension shutdown", + }); + return; + } if (code === 0) { this.outputChannel.appendLine("\nCompilation successful!"); + const { program, witness } = parseCompilerOutput(stdout, Boolean(options.json)); - // Parse output based on format - let program: string | undefined; - let witness: string | undefined; - - if (options.json) { - try { - const output = JSON.parse(stdout); - program = output.program; - witness = output.witness; - } catch { - program = stdout; - } - } else { - // Text format: "Program:\n\nWitness:\n" - const programMatch = stdout.match(/Program:\s*\n(.+)/); - const witnessMatch = stdout.match(/Witness:\s*\n(.+)/); - program = programMatch?.[1]?.trim(); - witness = witnessMatch?.[1]?.trim(); - } - - resolve({ + finish({ success: true, program, witness, }); } else { this.outputChannel.appendLine(`\nCompilation failed with exit code ${code}`); - resolve({ + finish({ success: false, error: stderr || stdout, }); @@ -129,8 +122,10 @@ export class SimplicityHLCompiler { }); proc.on("error", (err) => { - this.outputChannel.appendLine(`\nFailed to start compiler: ${err.message}`); - resolve({ + if (!this.disposed) { + this.outputChannel.appendLine(`\nFailed to start compiler: ${err.message}`); + } + finish({ success: false, error: err.message, }); @@ -138,8 +133,17 @@ export class SimplicityHLCompiler { }); } - // Clean up resources + // Clean up resources and signal each compiler process 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(); } } @@ -148,28 +152,16 @@ function shellDisplay(argument: string): string { return /[\s"']/u.test(argument) ? JSON.stringify(argument) : argument; } -// Singleton instance for extension lifetime -let compiler: SimplicityHLCompiler | undefined; - -export function getCompiler(): SimplicityHLCompiler { - if (!compiler) { - compiler = new SimplicityHLCompiler(); - } - return compiler; -} - -export function disposeCompiler(): void { - compiler?.dispose(); - compiler = undefined; -} - // Locate the simc compiler binary export function getSimcPath(): string { // Check user-configured path first - const config = vscode.workspace.getConfiguration("simplicityhl"); - const configuredPath = config.get("compiler.path"); - if (configuredPath && configuredPath.trim()) { - return configuredPath; + const config = vscode.workspace.getConfiguration(CONFIGURATION_SECTION); + const configuredPath = config.get( + SETTINGS.compilerPath.key, + SETTINGS.compilerPath.default, + ).trim(); + if (configuredPath) { + return path.resolve(configuredPath); } // Search in PATH and common locations @@ -180,6 +172,6 @@ export function getSimcPath(): string { throw new Error( "simc compiler not found. See https://github.com/BlockstreamResearch/SimplicityHL#installation " + - "or set simplicityhl.compiler.path in settings." + `or set ${CONFIGURATION_SECTION}.${SETTINGS.compilerPath.key} in settings.` ); } diff --git a/src/compiler/output.ts b/src/compiler/output.ts new file mode 100644 index 0000000..ff4a41a --- /dev/null +++ b/src/compiler/output.ts @@ -0,0 +1,29 @@ +/** Parsed payload emitted by `simc`. */ +export interface CompilerOutput { + program?: string; + witness?: string; +} + +export function parseCompilerOutput( + stdout: string, + json: boolean, +): CompilerOutput { + if (json) { + try { + const output = JSON.parse(stdout); + return { + program: output.program, + witness: output.witness, + }; + } catch { + return { program: stdout }; + } + } + + const programMatch = stdout.match(/Program:\s*\n(.+)/); + const witnessMatch = stdout.match(/Witness:\s*\n(.+)/); + return { + program: programMatch?.[1]?.trim(), + witness: witnessMatch?.[1]?.trim(), + }; +} diff --git a/src/contracts.test.ts b/src/contracts.test.ts new file mode 100644 index 0000000..67f1ac5 --- /dev/null +++ b/src/contracts.test.ts @@ -0,0 +1,35 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { test } from "node:test"; + +import { SETTINGS, languageClientOptions } from "./contracts"; + +void test("client languages and consumed settings match package contributions", () => { + const manifest = JSON.parse( + fs.readFileSync(path.join(__dirname, "../package.json"), "utf8"), + ); + const contributions = manifest.contributes; + const clientOptions = languageClientOptions({ imports: true, enums: false }); + + assert.deepEqual( + clientOptions.documentSelector.map(({ language }) => language), + contributions.languages.map((language: { id: string }) => language.id), + ); + + const contributedSettings = Object.assign( + {}, + ...contributions.configuration.map( + (section: { properties: Record }) => section.properties, + ), + ) as Record; + + for (const setting of [SETTINGS.serverPath, SETTINGS.imports, SETTINGS.enums]) { + const contribution = + contributedSettings[ + `${clientOptions.synchronize.configurationSection}.${setting.key}` + ]; + assert.ok(contribution, `Missing package contribution for ${setting.key}`); + assert.equal(contribution.default, setting.default); + } +}); diff --git a/src/contracts.ts b/src/contracts.ts new file mode 100644 index 0000000..dc1356b --- /dev/null +++ b/src/contracts.ts @@ -0,0 +1,79 @@ +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 LANGUAGE_IDS = { + source: "simplicityhl", + witness: "simplicityhl-witness", +} as const; + +export const COMMAND_IDS = { + restartServer: "simplicityhl.restartServer", + compileFile: "simplicityhl.compileFile", + compileFileDebug: "simplicityhl.compileFileDebug", + compileWithWitness: "simplicityhl.compileWithWitness", + compileJson: "simplicityhl.compileJson", +} as const; + +export const TASK_COMMANDS = [ + "compile", + "compile-debug", + "compile-with-witness", +] as const; + +export const SETTINGS = { + suppressMissingLspWarning: { + key: "suppressMissingLspWarning", + default: false, + }, + disableAutoupdate: { + key: "disableAutoupdate", + default: false, + }, + serverPath: { + key: "server.path", + default: "", + }, + compilerPath: { + key: "compiler.path", + default: "", + }, + autoSaveBeforeCompile: { + key: "build.autoSaveBeforeCompile", + default: true, + }, + imports: { + key: "experimentalFeatures.imports", + default: false, + }, + enums: { + key: "experimentalFeatures.enums", + default: false, + }, +} as const; + +export type TaskCommand = (typeof TASK_COMMANDS)[number]; + +export interface ExperimentalFeatures { + imports: boolean; + enums: boolean; +} + +export function languageClientOptions(features: ExperimentalFeatures) { + return { + documentSelector: [ + { scheme: "file", language: LANGUAGE_IDS.source }, + { scheme: "file", language: LANGUAGE_IDS.witness }, + ], + initializationOptions: { + simplicityhl: { + experimentalFeatures: features, + }, + }, + synchronize: { + configurationSection: CONFIGURATION_SECTION, + }, + }; +} diff --git a/src/extension.ts b/src/extension.ts index d90a4a3..b312087 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,33 +1,44 @@ // SimplicityHL VSCode Extension entry point. // Initializes LSP client and registers all extension features. -import { ExtensionContext } from "vscode"; +import { ExtensionContext, commands } from "vscode"; -import { LspClient } from "./client"; -import { registerRestartCommand } from "./commands"; -import { disposeCompiler } from "./compile"; -import { registerCompileCommands } from "./compile_commands"; -import { disposeStatusBar } from "./statusBar"; -import { registerTaskProvider } from "./tasks"; +import { LspClient } from "./lsp/client"; +import { SimplicityHLCompiler } from "./compiler"; +import { registerCompileCommands } from "./commands/compile"; +import { COMMAND_IDS } from "./contracts"; +import { registerTaskProvider } from "./tasks/provider"; -let client: LspClient; +let client: LspClient | undefined; +let compiler: SimplicityHLCompiler | undefined; export function activate(context: ExtensionContext): void { // Initialize LSP client for language intelligence (also shows status bar) - client = new LspClient(context); - void client.start(); + const lspClient = new LspClient(context); + client = lspClient; + void lspClient.start(); // Register all commands and providers - registerRestartCommand(context, client); - registerCompileCommands(context); // Compile commands (Cmd+Shift+B, etc.) + context.subscriptions.push(commands.registerCommand( + COMMAND_IDS.restartServer, + () => lspClient.restart(), + )); + // Compile commands (Cmd+Shift+B, etc.) + registerCompileCommands(context, () => { + if (!compiler) { + compiler = new SimplicityHLCompiler(); + context.subscriptions.push(compiler); + } + return compiler; + }); registerTaskProvider(context); // Task integration (Tasks: Run Task) } export async function deactivate(): Promise { - disposeCompiler(); - try { - await client?.stop(); - } finally { - disposeStatusBar(); - } + const activeClient = client; + const activeCompiler = compiler; + client = undefined; + compiler = undefined; + activeCompiler?.dispose(); + await activeClient?.shutdown(); } diff --git a/src/features.test.ts b/src/features.test.ts deleted file mode 100644 index 60ffe52..0000000 --- a/src/features.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import * as assert from "node:assert/strict"; -import { test } from "node:test"; - -import { compilerFeatureArguments } from "./features"; - -void test("does not enable experimental compiler features by default", () => { - assert.deepEqual( - compilerFeatureArguments({ imports: false, enums: false }), - [], - ); -}); - -void test("maps each experimental setting to its simc flag", () => { - assert.deepEqual( - compilerFeatureArguments({ imports: true, enums: false }), - ["-Z", "imports"], - ); - assert.deepEqual( - compilerFeatureArguments({ imports: false, enums: true }), - ["-Z", "enums"], - ); - assert.deepEqual( - compilerFeatureArguments({ imports: true, enums: true }), - ["-Z", "imports", "-Z", "enums"], - ); -}); diff --git a/src/features.ts b/src/features.ts deleted file mode 100644 index 3c2311a..0000000 --- a/src/features.ts +++ /dev/null @@ -1,17 +0,0 @@ -export interface ExperimentalFeatures { - imports: boolean; - enums: boolean; -} - -export function compilerFeatureArguments( - features: ExperimentalFeatures, -): string[] { - const args: string[] = []; - if (features.imports) { - args.push("-Z", "imports"); - } - if (features.enums) { - args.push("-Z", "enums"); - } - return args; -} diff --git a/src/find_executable.test.ts b/src/find_executable.test.ts new file mode 100644 index 0000000..dec1986 --- /dev/null +++ b/src/find_executable.test.ts @@ -0,0 +1,79 @@ +import * as assert from "node:assert/strict"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { test } from "node:test"; + +import { findExecutable } from "./find_executable"; + +void test("Windows fallback finds a regular Cargo-bin executable outside PATH", async () => { + const profile = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "simplicityhl-windows-profile-"), + ); + try { + const cargoBin = path.join(profile, ".cargo", "bin"); + await fs.promises.mkdir(cargoBin, { recursive: true }); + const executable = path.join(cargoBin, "simplicityhl-lsp.exe"); + await fs.promises.writeFile(executable, "test executable"); + await fs.promises.mkdir(path.join(cargoBin, "cargo.exe")); + await fs.promises.writeFile(path.join(cargoBin, "cargo.cmd"), "echo cargo"); + + const options = { + environment: { USERPROFILE: profile, PATHEXT: ".EXE;.CMD", PATH: "" }, + homeDirectory: profile, + platform: "win32" as const, + }; + assert.equal(findExecutable("simplicityhl-lsp", options), executable); + assert.equal(findExecutable("cargo", options), null); + } finally { + await fs.promises.rm(profile, { recursive: true, force: true }); + } +}); + +void test("PATH results are validated and returned as one absolute path", async () => { + const directory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "simplicityhl-relative-locator-"), + ); + try { + const executable = path.join(directory, "simc"); + await fs.promises.writeFile(executable, "#!/bin/sh\n"); + await fs.promises.chmod(executable, 0o755); + const relative = path.relative(process.cwd(), executable); + + assert.equal(findExecutable("simc", { + environment: { PATH: path.dirname(relative) }, + platform: process.platform, + }), executable); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +}); + +void test("Windows locator ignores command scripts that direct spawning cannot run", async () => { + const directory = await fs.promises.mkdtemp( + path.join(os.tmpdir(), "simplicityhl-windows-locator-"), + ); + try { + const commandScript = path.join(directory, "cargo.cmd"); + const executable = path.join(directory, "cargo.exe"); + await Promise.all([ + fs.promises.writeFile(commandScript, "echo cargo"), + fs.promises.writeFile(executable, "executable"), + ]); + const options = { + environment: { + USERPROFILE: path.join(directory, "empty-profile"), + PATHEXT: ".EXE;.CMD", + PATH: directory, + }, + homeDirectory: directory, + platform: "win32" as const, + }; + + assert.equal(findExecutable("cargo", options), executable); + await fs.promises.rm(executable); + assert.equal(findExecutable("cargo", options), null); + } finally { + await fs.promises.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/src/find_executable.ts b/src/find_executable.ts new file mode 100644 index 0000000..e73c893 --- /dev/null +++ b/src/find_executable.ts @@ -0,0 +1,109 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import process from "node:process"; + +const DEFAULT_WINDOWS_EXTENSIONS = ".COM;.EXE"; +const DIRECT_WINDOWS_EXTENSIONS = new Set([".com", ".exe"]); + +export interface ExecutableSearchOptions { + readonly environment?: NodeJS.ProcessEnv; + readonly homeDirectory?: string; + readonly platform?: NodeJS.Platform; +} + +/** Candidate file names that Windows would derive through PATHEXT. */ +export function executableNames( + command: string, + platform: NodeJS.Platform, + pathExt = DEFAULT_WINDOWS_EXTENSIONS, +): readonly string[] { + if (platform !== "win32" || path.extname(command)) { + return [command]; + } + const extensions = pathExt + .split(";") + .map((extension) => extension.trim()) + .filter(Boolean) + .map((extension) => extension.startsWith(".") ? extension : `.${extension}`) + .map((extension) => extension.toLowerCase()) + .filter((extension) => DIRECT_WINDOWS_EXTENSIONS.has(extension)); + return [ + command, + ...extensions.map((extension) => `${command}${extension}`), + ]; +} + +function environmentValue( + environment: NodeJS.ProcessEnv, + name: string, + platform: NodeJS.Platform, +): string | undefined { + if (platform !== "win32") { + return environment[name]; + } + return Object.entries(environment).find(([key]) => + key.toUpperCase() === name)?.[1]; +} + +function isUsableExecutable(candidate: string, platform: NodeJS.Platform): boolean { + try { + if (!fs.statSync(candidate).isFile()) { + return false; + } + if (platform === "win32") { + const extension = path.extname(candidate).toLowerCase(); + if (extension && !DIRECT_WINDOWS_EXTENSIONS.has(extension)) { + return false; + } + } else { + fs.accessSync(candidate, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +/** Find an executable in `PATH` or the common user installation directories. */ +export function findExecutable( + command: string, + options: ExecutableSearchOptions = {}, +): string | null { + const platform = options.platform ?? process.platform; + const environment = options.environment ?? process.env; + const home = options.homeDirectory ?? os.homedir(); + const pathSeparator = platform === "win32" ? ";" : path.delimiter; + const pathDirectories = (environmentValue(environment, "PATH", platform) ?? "") + .split(pathSeparator) + .map((directory) => directory.trim().replace(/^"|"$/gu, "")) + .filter(Boolean); + const commonDirectories = platform === "win32" + ? [path.join( + environmentValue(environment, "USERPROFILE", platform) ?? "C:\\Users\\Default", + ".cargo", + "bin", + )] + : [ + path.join(home, ".cargo", "bin"), + "/usr/local/bin", + "/usr/bin", + path.join(home, ".local", "bin"), + ]; + const names = executableNames( + command, + platform, + environmentValue(environment, "PATHEXT", platform) ?? DEFAULT_WINDOWS_EXTENSIONS, + ); + + for (const directory of [...pathDirectories, ...commonDirectories]) { + for (const name of names) { + const candidate = path.join(directory, name); + const resolved = path.resolve(candidate); + if (isUsableExecutable(resolved, platform)) { + return resolved; + } + } + } + return null; +} diff --git a/src/lsp/client.ts b/src/lsp/client.ts new file mode 100644 index 0000000..8ec4593 --- /dev/null +++ b/src/lsp/client.ts @@ -0,0 +1,223 @@ +// LSP client for SimplicityHL language server. +// Manages connection lifecycle and integrates with status bar. + +import * as fs from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; +import { + ExtensionContext, + window, + workspace, +} from "vscode"; +import { + Executable, + LanguageClient, + ServerOptions, +} from "vscode-languageclient/node"; +import { + CONFIGURATION_SECTION, + LANGUAGE_CLIENT_ID, + LANGUAGE_CLIENT_NAME, + SERVER_BINARY, + SETTINGS, + languageClientOptions, +} from "../contracts"; +import { getExperimentalFeatures } from "../settings"; +import { ensureExecutable } from "./install"; +import { StatusBar } from "./status"; + +function workspaceWorkingDirectory(): string | undefined { + const folder = workspace.workspaceFolders?.[0]; + if (folder?.uri.scheme !== "file") { + return undefined; + } + + try { + return fs.statSync(folder.uri.fsPath).isDirectory() + ? folder.uri.fsPath + : undefined; + } catch { + return undefined; + } +} + +export class LspClient { + private client: LanguageClient | undefined; + private lifecycle: Promise = Promise.resolve(); + private lifecycleRequest = 0; + private readonly statusBar = new StatusBar(); + + public constructor(context: ExtensionContext) { + context.subscriptions.push( + workspace.onDidChangeConfiguration((event) => { + if (!event.affectsConfiguration(CONFIGURATION_SECTION)) { + return; + } + if ( + event.affectsConfiguration( + `${CONFIGURATION_SECTION}.${SETTINGS.serverPath.key}`, + ) + ) { + void this.restart(); + } + }), + ); + } + + public start(): Promise { + const request = ++this.lifecycleRequest; + return this.serialize(() => this.startNow(request)); + } + + private async startNow(request: number): Promise { + if (!this.isCurrent(request) || this.client) { + return; + } + const statusBar = this.statusBar; + statusBar.update("starting"); + statusBar.show(); + + const configuration = workspace.getConfiguration(CONFIGURATION_SECTION); + const configuredPath = configuration + .get(SETTINGS.serverPath.key, SETTINGS.serverPath.default) + .trim(); + let execPath: string | null; + if (configuredPath) { + const resolvedPath = path.resolve(configuredPath); + if (!fs.existsSync(resolvedPath)) { + statusBar.update("error"); + window.showErrorMessage( + `Configured SimplicityHL language server does not exist: ${configuredPath}`, + ); + return; + } + execPath = resolvedPath; + } else { + execPath = await ensureExecutable(SERVER_BINARY); + } + + if (!this.isCurrent(request)) { + return; + } + if (!execPath) { + statusBar.update("disconnected"); + return; + } + + const run: Executable = { + command: execPath, + options: { + cwd: workspaceWorkingDirectory(), + env: { + ...process.env, + }, + }, + }; + const serverOptions: ServerOptions = { + run, + debug: run, + }; + + const clientOptions = languageClientOptions(getExperimentalFeatures()); + + this.client = new LanguageClient( + LANGUAGE_CLIENT_ID, + LANGUAGE_CLIENT_NAME, + serverOptions, + clientOptions, + ); + + try { + await this.client.start(); + if (!this.isCurrent(request)) { + await this.stopNow(); + return; + } + statusBar.update("connected"); + window.showInformationMessage("SimplicityHL Language Server activated!"); + } catch (e) { + this.client = undefined; + if (!this.isCurrent(request)) { + return; + } + statusBar.update("error"); + window.showErrorMessage( + `Failed to start SimplicityHL Language Server: ${e}`, + ); + } + } + + public stop(): Promise { + ++this.lifecycleRequest; + return this.serialize(() => this.stopNow()); + } + + private async stopNow(): Promise { + const client = this.client; + if (!client) { + return; + } + try { + await client.stop(); + } finally { + if (this.client === client) { + this.client = undefined; + } + this.statusBar.update("disconnected"); + } + } + + public restart(): Promise { + const request = ++this.lifecycleRequest; + return this.serialize(() => this.restartNow(request)); + } + + private async restartNow(request: number): Promise { + if (!this.isCurrent(request)) { + return; + } + const statusBar = this.statusBar; + + if (!this.client) { + // Try to start even if not previously initialized + await this.startNow(request); + return; + } + + try { + statusBar.update("starting"); + await this.stopNow(); + if (!this.isCurrent(request)) { + return; + } + await this.startNow(request); + if (this.isCurrent(request) && this.client) { + window.showInformationMessage("SimplicityHL Language Server restarted successfully!"); + } + } catch (e) { + if (!this.isCurrent(request)) { + return; + } + statusBar.update("error"); + window.showErrorMessage(`Failed to restart LSP: ${e}`); + } + } + + private isCurrent(request: number): boolean { + return request === this.lifecycleRequest; + } + + private serialize(operation: () => Promise): Promise { + const result = this.lifecycle.then(operation, operation); + this.lifecycle = result.catch(() => undefined); + return result; + } + + public async shutdown(): Promise { + try { + await this.stop(); + } finally { + this.statusBar.dispose(); + } + } +} diff --git a/src/find_server.ts b/src/lsp/install.ts similarity index 52% rename from src/find_server.ts rename to src/lsp/install.ts index 17a1a3e..78fe88c 100644 --- a/src/find_server.ts +++ b/src/lsp/install.ts @@ -1,59 +1,9 @@ -import * as os from "os"; -import * as fs from "fs"; -import * as path from "path"; - -import process from "node:process"; -import * as cp from "child_process"; +import * as cp from "node:child_process"; import { env, ProgressLocation, Uri, window, workspace } from "vscode"; -// Searches for an executable in PATH and common installation directories. -// Used by both LSP client and compiler to locate binaries (simplicityhl-lsp, simc). -export function findExecutable(command: string): string | null { - try { - const resolved = cp - .execSync( - process.platform === "win32" ? `where ${command}` : `which ${command}`, - ) - .toString() - .split(/\r?\n/)[0] - .trim(); - if (resolved && fs.existsSync(resolved)) { - return resolved; - } - } catch { - // Not found in PATH - } - - const commonDirs: string[] = []; - - if (process.platform === "win32") { - commonDirs.push( - path.join( - process.env["USERPROFILE"] ?? "C:\\Users\\Default", - ".cargo", - "bin", - ), - ); - } else { - commonDirs.push(path.join(os.homedir(), ".cargo", "bin")); - - commonDirs.push( - "/usr/local/bin", - "/usr/bin", - path.join(os.homedir(), ".local", "bin"), - ); - } - - for (const dir of commonDirs) { - const candidate = path.join(dir, command); - if (fs.existsSync(candidate)) { - return candidate; - } - } - - return null; -} +import { CONFIGURATION_SECTION, SETTINGS } from "../contracts"; +import { findExecutable } from "../find_executable"; async function installServer(command: string) { const cargoPath = findExecutable("cargo"); @@ -69,12 +19,28 @@ async function installServer(command: string) { cancellable: true }, async (progress, token) => { return new Promise((resolve, reject) => { - const installProcess = cp.spawn(cargoPath!, ["install", "--color", "never", command]); - - token.onCancellationRequested(() => { - installProcess.kill("SIGTERM"); - reject(new Error("Installation canceled")); + 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() @@ -91,16 +57,26 @@ async function installServer(command: string) { installProcess.stderr?.on('data', reportProgress); installProcess.on('close', (code) => { + if (cancellation.signal.aborted) { + finish(new Error("Installation canceled")); + return; + } if (code === 0) { - resolve(); + finish(); } else { - reject(new Error(`Installation failed with exit code ${code}`)); + finish(new Error(`Installation failed with exit code ${code}`)); } }); installProcess.on('error', (err) => { - reject(new Error(`Failed to start cargo process: ${err.message}`)); + if (!cancellation.signal.aborted) { + finish(new Error(`Failed to start cargo process: ${err.message}`)); + } }); + + if (token.isCancellationRequested) { + cancellation.abort(); + } }); }); } @@ -109,14 +85,14 @@ export async function ensureExecutable( command: string, ): Promise { const cargoPath = findExecutable("cargo"); - const config = workspace.getConfiguration("simplicityhl"); + const config = workspace.getConfiguration(CONFIGURATION_SECTION); let serverPath = findExecutable(command); if (!cargoPath && !serverPath) { const suppressWarning = config.get( - "suppressMissingLspWarning", - false, + SETTINGS.suppressMissingLspWarning.key, + SETTINGS.suppressMissingLspWarning.default, ); if (suppressWarning) { return null; @@ -132,8 +108,11 @@ export async function ensureExecutable( const url = "https://rust-lang.org/tools/install"; await env.openExternal(Uri.parse(url)); } else if (choice === "Don't show again") { - const config = workspace.getConfiguration("simplicityhl"); - await config.update("suppressMissingLspWarning", true, true); + await config.update( + SETTINGS.suppressMissingLspWarning.key, + true, + true, + ); } return null; @@ -143,7 +122,10 @@ export async function ensureExecutable( return serverPath; } - const disableAutoupdate = config.get("disableAutoupdate", false); + const disableAutoupdate = config.get( + SETTINGS.disableAutoupdate.key, + SETTINGS.disableAutoupdate.default, + ); if (serverPath && disableAutoupdate) { return serverPath; diff --git a/src/statusBar.ts b/src/lsp/status.ts similarity index 76% rename from src/statusBar.ts rename to src/lsp/status.ts index c072e24..2e7ca9e 100644 --- a/src/statusBar.ts +++ b/src/lsp/status.ts @@ -2,14 +2,14 @@ // Shows LSP connection status and provides quick access to commands. import * as vscode from "vscode"; +import { COMMAND_IDS } from "../contracts"; // Connection states for the status bar export type LspStatus = "starting" | "connected" | "disconnected" | "error"; // Manages the status bar item showing LSP state export class StatusBar { - private statusBarItem: vscode.StatusBarItem; - private status: LspStatus = "disconnected"; + private readonly statusBarItem: vscode.StatusBarItem; constructor() { // Create status bar item on the left side @@ -18,15 +18,13 @@ export class StatusBar { 100 ); // Clicking restarts the LSP server - this.statusBarItem.command = "simplicityhl.restartServer"; + this.statusBarItem.command = COMMAND_IDS.restartServer; this.statusBarItem.tooltip = "SimplicityHL Language Server - Click to restart"; this.update("disconnected"); } // Update the status bar display based on LSP state public update(status: LspStatus): void { - this.status = status; - switch (status) { case "starting": this.statusBarItem.text = "$(sync~spin) SimplicityHL"; @@ -60,33 +58,8 @@ export class StatusBar { this.statusBarItem.show(); } - // Hide the status bar item - public hide(): void { - this.statusBarItem.hide(); - } - - // Get current status - public getStatus(): LspStatus { - return this.status; - } - // Clean up resources public dispose(): void { this.statusBarItem.dispose(); } } - -// Singleton instance -let statusBar: StatusBar | undefined; - -export function getStatusBar(): StatusBar { - if (!statusBar) { - statusBar = new StatusBar(); - } - return statusBar; -} - -export function disposeStatusBar(): void { - statusBar?.dispose(); - statusBar = undefined; -} diff --git a/src/settings.ts b/src/settings.ts index a0583b6..82533ec 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,19 +1,15 @@ import * as vscode from "vscode"; -import type { ExperimentalFeatures } from "./features"; +import { + CONFIGURATION_SECTION, + SETTINGS, + type ExperimentalFeatures, +} from "./contracts"; export function getExperimentalFeatures(): ExperimentalFeatures { - const configuration = vscode.workspace.getConfiguration("simplicityhl"); + const configuration = vscode.workspace.getConfiguration(CONFIGURATION_SECTION); return { - imports: configuration.get("experimentalFeatures.imports", false), - enums: configuration.get("experimentalFeatures.enums", false), - }; -} - -export function lspInitializationOptions(): object { - return { - simplicityhl: { - experimentalFeatures: getExperimentalFeatures(), - }, + imports: configuration.get(SETTINGS.imports.key, SETTINGS.imports.default), + enums: configuration.get(SETTINGS.enums.key, SETTINGS.enums.default), }; } diff --git a/src/tasks.ts b/src/tasks/provider.ts similarity index 53% rename from src/tasks.ts rename to src/tasks/provider.ts index 4042925..168d02e 100644 --- a/src/tasks.ts +++ b/src/tasks/provider.ts @@ -2,32 +2,34 @@ // Integrates with VSCode's "Tasks: Run Task" command for build automation. import * as vscode from "vscode"; -import { getSimcPath } from "./compile"; -import { compilerFeatureArguments } from "./features"; -import { getExperimentalFeatures } from "./settings"; +import { getSimcPath } from "../compiler"; +import { taskCompilerArguments } from "../compiler/args"; +import { + TASK_COMMANDS, + TASK_TYPE, + type TaskCommand, +} from "../contracts"; +import { getExperimentalFeatures } from "../settings"; // Task definition schema - matches taskDefinitions in package.json export interface SimplicityHLTaskDefinition extends vscode.TaskDefinition { - type: "simplicityhl"; - command: "compile" | "compile-debug" | "compile-with-witness"; + type: typeof TASK_TYPE; + command: TaskCommand; file?: string; // Override file to compile (defaults to ${file}) witnessFile?: string; // Witness file for compile-with-witness } // Provides tasks to VSCode's task system export class SimplicityHLTaskProvider implements vscode.TaskProvider { - static TaskType = "simplicityhl"; - // Called by VSCode to get list of available tasks public async provideTasks(): Promise { try { const simcPath = getSimcPath(); - const featureArgs = compilerFeatureArguments(getExperimentalFeatures()); - const commands = ["compile", "compile-debug", "compile-with-witness"] as const; - return commands.map((command) => this.createTask( - { type: "simplicityhl", command }, + const features = getExperimentalFeatures(); + return TASK_COMMANDS.map((command) => this.createTask( + { type: TASK_TYPE, command }, simcPath, - featureArgs, + features, )); } catch (error) { showTaskError(error); @@ -38,12 +40,12 @@ export class SimplicityHLTaskProvider implements vscode.TaskProvider { // Called when user runs a task from tasks.json public async resolveTask(task: vscode.Task): Promise { const definition = task.definition as SimplicityHLTaskDefinition; - if (definition.type === SimplicityHLTaskProvider.TaskType) { + if (definition.type === TASK_TYPE) { try { return this.createTask( definition, getSimcPath(), - compilerFeatureArguments(getExperimentalFeatures()), + getExperimentalFeatures(), ); } catch (error) { showTaskError(error); @@ -56,41 +58,27 @@ export class SimplicityHLTaskProvider implements vscode.TaskProvider { private createTask( definition: SimplicityHLTaskDefinition, simcPath: string, - featureArgs: string[], + features: ReturnType, ): vscode.Task { - let args: string[] = []; - let taskName: string; - - // Build command line based on task type - switch (definition.command) { - case "compile": - taskName = "Compile SimplicityHL"; - args = [definition.file || "${file}", ...featureArgs]; - break; - case "compile-debug": - taskName = "Compile SimplicityHL (Debug)"; - args = [definition.file || "${file}", ...featureArgs, "--debug"]; - break; - case "compile-with-witness": - taskName = "Compile with Witness"; - args = [definition.file || "${file}", ...featureArgs]; - args.push("-w"); - if (definition.witnessFile) { - args.push(definition.witnessFile); - } else { - // Default: replace .simf with .wit - args.push("${file/.simf/.wit/}"); - } - break; - } + const taskNames: Record = { + compile: "Compile SimplicityHL", + "compile-debug": "Compile SimplicityHL (Debug)", + "compile-with-witness": "Compile with Witness", + }; + const args = taskCompilerArguments( + definition.command, + definition.file || "${file}", + definition.witnessFile, + features, + ); - const execution = new vscode.ShellExecution(simcPath, args); + const execution = new vscode.ProcessExecution(simcPath, args); const task = new vscode.Task( definition, vscode.TaskScope.Workspace, - taskName, - "simplicityhl", + taskNames[definition.command], + TASK_TYPE, execution, "$simplicityhl" // Problem matcher name from package.json ); @@ -116,10 +104,8 @@ function showTaskError(error: unknown): void { // Register the task provider with VSCode export function registerTaskProvider(context: vscode.ExtensionContext): void { - const taskProvider = vscode.tasks.registerTaskProvider( - SimplicityHLTaskProvider.TaskType, - new SimplicityHLTaskProvider() - ); - - context.subscriptions.push(taskProvider); + context.subscriptions.push(vscode.tasks.registerTaskProvider( + TASK_TYPE, + new SimplicityHLTaskProvider(), + )); }