From ddf2de739068b5ff0866ccb1d067f3cb53a4fc55 Mon Sep 17 00:00:00 2001 From: David Czihak Date: Thu, 7 May 2026 14:33:19 +0200 Subject: Initial commit --- Scripts/main.js | 915 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 915 insertions(+) create mode 100644 Scripts/main.js (limited to 'Scripts/main.js') diff --git a/Scripts/main.js b/Scripts/main.js new file mode 100644 index 0000000..76bd816 --- /dev/null +++ b/Scripts/main.js @@ -0,0 +1,915 @@ +"use strict"; + +const EXTENSION_ID = "at.dcz.nova-zig"; +const TASK_ASSISTANT_ID = `${EXTENSION_ID}.tasks`; +const LANGUAGE_CLIENT_ID = `${EXTENSION_ID}.zls`; +const ISSUE_MATCHER = "zig.compiler"; + +const CONFIG_KEYS = { + zigPath: `${EXTENSION_ID}.toolchain.zig-path`, + zlsPath: `${EXTENSION_ID}.toolchain.zls-path`, + lldbDapPath: `${EXTENSION_ID}.toolchain.lldb-dap-path`, + zlsEnabled: `${EXTENSION_ID}.zls.enabled`, + zlsBuildOnSave: `${EXTENSION_ID}.zls.build-on-save`, + zlsDebug: `${EXTENSION_ID}.zls.debug`, +}; + +let languageServer = null; +let taskAssistant = null; +let issueAssistant = null; +let commandRegistrations = []; + +exports.activate = function activate() { + registerCommands(); + taskAssistant = new ZigTaskAssistant(); + languageServer = new ZigLanguageServer(); + issueAssistant = new ZigIssueAssistant(); +}; + +exports.deactivate = function deactivate() { + if (languageServer) { + languageServer.dispose(); + languageServer = null; + } + + if (taskAssistant) { + taskAssistant.dispose(); + taskAssistant = null; + } + + if (issueAssistant) { + issueAssistant.dispose(); + issueAssistant = null; + } + + commandRegistrations.forEach((disposable) => { + if (disposable && typeof disposable.dispose === "function") { + disposable.dispose(); + } + }); + commandRegistrations = []; +}; + +function getConfigValue(key) { + const workspaceValue = nova.workspace.config.get(key); + if (workspaceValue !== undefined && workspaceValue !== null && workspaceValue !== "") { + return workspaceValue; + } + + const globalValue = nova.config.get(key); + if (globalValue !== undefined && globalValue !== null && globalValue !== "") { + return globalValue; + } + + return null; +} + +function getBooleanConfigValue(key, fallback) { + const workspaceValue = nova.workspace.config.get(key); + if (typeof workspaceValue === "boolean") { + return workspaceValue; + } + + const globalValue = nova.config.get(key); + if (typeof globalValue === "boolean") { + return globalValue; + } + + return fallback; +} + +function normalizeArray(value) { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((entry) => (entry === null || entry === undefined ? "" : String(entry).trim())) + .filter((entry) => entry.length > 0); +} + +function resolveWorkspaceRelativePath(path) { + if (!path) { + return null; + } + + if (path.startsWith("/")) { + return path; + } + + if (nova.workspace.path) { + return nova.path.join(nova.workspace.path, path); + } + + return path; +} + +function resolvePathAgainstBase(path, base) { + if (!path) { + return null; + } + + if (path.startsWith("/")) { + return path; + } + + if (base) { + return nova.path.join(base, path); + } + + return resolveWorkspaceRelativePath(path); +} + +function getTaskConfigValue(config, key) { + if (!config) { + return null; + } + + const value = config.get(key); + return value === undefined || value === null || value === "" ? null : value; +} + +function getTaskArgs(config, key) { + return normalizeArray(getTaskConfigValue(config, key)); +} + +function getTaskCwd(config) { + const configured = getTaskConfigValue(config, "cwd"); + if (configured) { + return resolveWorkspaceRelativePath(configured); + } + + return nova.workspace.path || null; +} + +function activeZigFilePath() { + const editor = nova.workspace.activeTextEditor; + if (!editor || !editor.document || !editor.document.path) { + return null; + } + + if (editor.document.syntax !== "zig") { + return null; + } + + return editor.document.path; +} + +function activeZigFileDirectory() { + const filePath = activeZigFilePath(); + if (!filePath) { + return null; + } + + return nova.path.dirname(filePath); +} + +function localizedText(key, fallback, variables) { + let text = nova.localize(key, fallback); + + if (!variables || typeof variables !== "object") { + return text; + } + + for (const [name, value] of Object.entries(variables)) { + text = text.split(`{${name}}`).join(String(value)); + } + + return text; +} + +function runProcess(command, options) { + return new Promise((resolve) => { + const stdout = []; + const stderr = []; + const process = new Process(command, options || {}); + + process.onStdout((line) => stdout.push(line)); + process.onStderr((line) => stderr.push(line)); + process.onDidExit((status) => { + resolve({ + status, + stdout: stdout.join(""), + stderr: stderr.join(""), + }); + }); + + process.start(); + }); +} + +async function findExecutableOnPath(commandName) { + const result = await runProcess("/usr/bin/env", { + args: ["which", commandName], + }); + + if (result.status !== 0) { + return null; + } + + const path = result.stdout.trim(); + return path.length > 0 ? path : null; +} + +async function resolveExecutable(configKey, defaultCommand) { + const configuredPath = getConfigValue(configKey); + if (configuredPath) { + return configuredPath; + } + + return findExecutableOnPath(defaultCommand); +} + +async function findExecutableWithXcode(commandName) { + const result = await runProcess("/usr/bin/xcrun", { + args: ["--find", commandName], + }); + + if (result.status !== 0) { + return null; + } + + const path = result.stdout.trim(); + return path.length > 0 ? path : null; +} + +async function resolveLLDBDAPExecutable() { + const configuredPath = getConfigValue(CONFIG_KEYS.lldbDapPath); + if (configuredPath) { + return configuredPath; + } + + const xcodePath = await findExecutableWithXcode("lldb-dap"); + if (xcodePath) { + return xcodePath; + } + + return findExecutableOnPath("lldb-dap"); +} + +function executableDisplayName(path, fallback) { + return path || fallback; +} + +function lldbFrameworkPaths() { + return [ + "/Applications/Xcode-beta.app/Contents/SharedFrameworks/", + "/Applications/Xcode.app/Contents/SharedFrameworks/", + "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/", + ]; +} + +function lldbDapProxyPath() { + return nova.path.join(nova.extension.path, "Scripts", "lldb-dap-proxy.pl"); +} + +function debugAdapterLogPath() { + return "/tmp/zig-lldb-dap-proxy.log"; +} + +function issueNormalizerScriptPath() { + return nova.path.join(nova.extension.path, "Scripts", "normalize-zig-issues.pl"); +} + +function quoteShellArgument(value) { + const text = value === null || value === undefined ? "" : String(value); + return `'${text.replace(/'/g, `'\\''`)}'`; +} + +function escapeAppleScriptString(value) { + return String(value) + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"'); +} + +function buildShellCommand(command, args, cwd) { + const segments = []; + if (cwd) { + segments.push(`cd ${quoteShellArgument(cwd)}`); + } + + segments.push([quoteShellArgument(command), ...(args || []).map(quoteShellArgument)].join(" ")); + return segments.join("; "); +} + +function buildIssueNormalizedCommand(command, args) { + const commandLine = [ + quoteShellArgument(command), + ...(args || []).map(quoteShellArgument), + ].join(" "); + const rewriter = `/usr/bin/perl ${quoteShellArgument(issueNormalizerScriptPath())}`; + return `setopt pipefail; ${commandLine} 2>&1 | ${rewriter}`; +} + +function launchInTerminal(commandLine) { + return new Promise((resolve) => { + const script = `tell application "Terminal" +activate +do script "${escapeAppleScriptString(commandLine)}" +end tell`; + const process = new Process("/usr/bin/osascript", { + args: ["-e", script], + }); + + let stderr = ""; + process.onStderr((line) => { + stderr += line; + }); + process.onDidExit((status) => { + resolve({ status, stderr: stderr.trim() }); + }); + process.start(); + }); +} + +function registerCommands() { + commandRegistrations.push( + nova.commands.register(`${EXTENSION_ID}.runInTerminal`, async (workspace, payload) => { + const command = payload && payload.command; + const args = (payload && payload.args) || []; + const cwd = (payload && payload.cwd) || workspace.path || null; + + if (!command) { + workspace.showWarningMessage( + localizedText( + "warning.terminal.launch_failed", + "Unable to launch the Zig task in Terminal." + ) + ); + return; + } + + const result = await launchInTerminal(buildShellCommand(command, args, cwd)); + if (result.status !== 0) { + const prefix = localizedText( + "warning.terminal.open_failed", + "Unable to open Terminal for the Zig task." + ); + const suffix = result.stderr ? ` ${result.stderr}` : ""; + workspace.showWarningMessage(`${prefix}${suffix}`); + } + }) + ); +} + +function syncWorkspaceZlsConfiguration(settings) { + const bridge = { + "zls.zig_exe_path": settings.zig_exe_path, + "zls.enable_build_on_save": settings.enable_build_on_save, + }; + + Object.entries(bridge).forEach(([key, value]) => { + if (value === undefined || value === null || value === "") { + nova.workspace.config.remove(key); + } else { + nova.workspace.config.set(key, value); + } + }); +} + +class ZigLanguageServer { + constructor() { + this.client = null; + this.clientStopDisposable = null; + this.restartGeneration = 0; + this.warnedMissing = new Set(); + this.disposables = []; + + this.observeConfig(CONFIG_KEYS.zigPath, true); + this.observeConfig(CONFIG_KEYS.zlsPath, true); + this.observeConfig(CONFIG_KEYS.zlsEnabled, true); + this.observeConfig(CONFIG_KEYS.zlsBuildOnSave, true); + this.observeConfig(CONFIG_KEYS.zlsDebug, true); + this.disposables.push( + nova.workspace.onDidChangePath(() => { + this.start(); + }) + ); + + this.start(); + } + + observeConfig(key, restart) { + this.disposables.push( + nova.config.onDidChange(key, () => { + if (restart) { + this.start(); + } else { + void this.pushConfiguration(); + } + }) + ); + this.disposables.push( + nova.workspace.config.onDidChange(key, () => { + if (restart) { + this.start(); + } else { + void this.pushConfiguration(); + } + }) + ); + } + + dispose() { + this.stop(); + this.disposables.forEach((disposable) => { + if (disposable && typeof disposable.dispose === "function") { + disposable.dispose(); + } + }); + this.disposables = []; + } + + async start() { + // Guard against stale async continuations: if a config change triggers + // another start() while we are awaiting, the generation check below bails out. + const generation = ++this.restartGeneration; + this.stop(); + + if (!getBooleanConfigValue(CONFIG_KEYS.zlsEnabled, true)) { + return; + } + + const zlsPath = await resolveExecutable(CONFIG_KEYS.zlsPath, "zls"); + if (generation !== this.restartGeneration) { + return; + } + + if (!zlsPath) { + this.warnMissingTool( + "zls", + localizedText( + "warning.zls.not_found", + "ZLS was not found. Install it or set a ZLS executable path in Zig extension settings." + ) + ); + return; + } + + const { settings, zigPath } = await this.resolveSettings(); + if (generation !== this.restartGeneration) { + return; + } + + syncWorkspaceZlsConfiguration(settings); + + const debugLogs = getBooleanConfigValue(CONFIG_KEYS.zlsDebug, false); + + const serverOptions = { + path: zlsPath, + args: debugLogs ? [] : ["--disable-lsp-logs"], + }; + + const clientOptions = { + syntaxes: [{ syntax: "zig", languageId: "zig" }], + debug: debugLogs, + initializationOptions: { + zls: settings, + }, + }; + + const client = new LanguageClient( + LANGUAGE_CLIENT_ID, + localizedText("name.language_server", "Zig Language Server"), + serverOptions, + clientOptions + ); + + this.clientStopDisposable = client.onDidStop((error) => { + if (error) { + console.error(`[${LANGUAGE_CLIENT_ID}] ${error.message}`); + nova.workspace.showWarningMessage( + localizedText( + "warning.zls.stopped_unexpectedly", + "The Zig language server stopped unexpectedly ({executable}).", + { executable: executableDisplayName(zlsPath, "zls") } + ) + ); + } + }); + + try { + client.start(); + this.client = client; + nova.subscriptions.add(client); + void this.pushConfiguration(); + this.warnedMissing.delete("zls"); + if (zigPath) { + this.warnedMissing.delete("zig"); + } + } catch (error) { + console.error(`[${LANGUAGE_CLIENT_ID}] Failed to start ZLS`, error); + nova.workspace.showWarningMessage( + localizedText( + "warning.zls.start_failed", + "Unable to start the Zig language server at {path}.", + { path: zlsPath } + ) + ); + this.stop(); + } + } + + async resolveSettings() { + const settings = { + enable_build_on_save: getBooleanConfigValue(CONFIG_KEYS.zlsBuildOnSave, false), + }; + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (zigPath) { + settings.zig_exe_path = zigPath; + } + + return { settings, zigPath }; + } + + async pushConfiguration() { + const generation = this.restartGeneration; + const { settings } = await this.resolveSettings(); + if (generation !== this.restartGeneration || !this.client || !this.client.running) { + return; + } + + syncWorkspaceZlsConfiguration(settings); + + this.client.sendNotification("workspace/didChangeConfiguration", { + settings: { + zls: settings, + }, + }); + } + + stop() { + if (this.clientStopDisposable && typeof this.clientStopDisposable.dispose === "function") { + this.clientStopDisposable.dispose(); + this.clientStopDisposable = null; + } + + if (this.client) { + this.client.stop(); + nova.subscriptions.remove(this.client); + this.client = null; + } + } + + warnMissingTool(tool, message) { + if (this.warnedMissing.has(tool)) { + return; + } + + this.warnedMissing.add(tool); + nova.workspace.showWarningMessage(message); + } +} + +class ZigTaskAssistant { + constructor() { + this.disposable = nova.assistants.registerTaskAssistant(this, { + identifier: TASK_ASSISTANT_ID, + name: localizedText("name.extension", "Zig"), + }); + } + + dispose() { + if (this.disposable && typeof this.disposable.dispose === "function") { + this.disposable.dispose(); + this.disposable = null; + } + } + + provideTasks() { + const task = new Task(localizedText("task.current_file.name", "Current Zig File")); + task.setAction(Task.Run, new TaskResolvableAction({ + data: { + type: "current-file-run", + }, + })); + task.setAction(Task.Clean, new TaskResolvableAction({ + data: { + type: "current-file-clean", + }, + })); + return [task]; + } + + async resolveTaskAction(context) { + const type = context.data && context.data.type; + const config = context.config; + const cwd = getTaskCwd(config); + + if (type === "clean") { + return this.resolveCleanAction(cwd); + } + + switch (type) { + case "build": + return this.resolveBuildAction(config, cwd); + case "build-debug": + return this.resolveDebugBuildAction(config, cwd); + case "build-run": + return this.resolveBuildRunAction(config, cwd); + case "build-run-terminal": + return this.resolveBuildRunTerminalAction(config, cwd); + case "build-test": + return this.resolveBuildTestAction(config, cwd); + case "current-file-run": + return this.resolveCurrentFileRunAction(); + case "current-file-clean": + return this.resolveCurrentFileCleanAction(); + case "debug": + return this.resolveDebugAction(config, cwd); + case "file-test": + return this.resolveFileTestAction(config, cwd); + default: + return null; + } + } + + createAction(command, args, cwd) { + return new TaskProcessAction("/bin/zsh", { + args: ["-lc", buildIssueNormalizedCommand(command, args)], + cwd, + env: { + NOVA_ZIG_TASK_CWD: cwd || "", + }, + matchers: [ISSUE_MATCHER], + }); + } + + resolveCleanAction(cwd) { + if (!cwd) { + nova.workspace.showWarningMessage( + localizedText( + "warning.clean.missing_cwd", + "Choose a workspace or working directory before cleaning Zig build artifacts." + ) + ); + return null; + } + + return new TaskProcessAction("/bin/rm", { + args: ["-rf", ".zig-cache", "zig-cache", "zig-out"], + cwd, + }); + } + + async resolveBuildAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + return this.createAction(zigPath, ["build", ...getTaskArgs(config, "buildArgs")], cwd); + } + + async resolveDebugBuildAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + return this.createAction(zigPath, ["build", "-Doptimize=Debug", ...getTaskArgs(config, "buildArgs")], cwd); + } + + async resolveBuildRunAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + const step = + getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run"; + const args = ["build", ...getTaskArgs(config, "buildArgs"), step]; + const runArgs = getTaskArgs(config, "runArgs"); + + if (runArgs.length > 0) { + args.push("--", ...runArgs); + } + + return this.createAction(zigPath, args, cwd); + } + + async resolveBuildRunTerminalAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + const step = + getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run"; + const args = ["build", ...getTaskArgs(config, "buildArgs"), step]; + const runArgs = getTaskArgs(config, "runArgs"); + + if (runArgs.length > 0) { + args.push("--", ...runArgs); + } + + return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, { + args: [ + { + command: zigPath, + args, + cwd, + }, + ], + }); + } + + async resolveBuildTestAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + const step = + getTaskConfigValue(config, "testStep") || getTaskConfigValue(config, "step") || "test"; + const args = ["build", ...getTaskArgs(config, "buildArgs"), step]; + const testArgs = getTaskArgs(config, "testArgs"); + + if (testArgs.length > 0) { + args.push("--", ...testArgs); + } + + return this.createAction(zigPath, args, cwd); + } + + async resolveCurrentFileRunAction() { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + const filePath = activeZigFilePath(); + const cwd = filePath ? nova.path.dirname(filePath) : null; + if (!filePath || !cwd) { + nova.workspace.showWarningMessage( + localizedText( + "warning.current_file.focus_editor_for_run", + "Focus a Zig editor before running Current Zig File." + ) + ); + return null; + } + + return this.createAction(zigPath, ["run", filePath], cwd); + } + + resolveCurrentFileCleanAction() { + const cwd = activeZigFileDirectory(); + if (!cwd) { + nova.workspace.showWarningMessage( + localizedText( + "warning.current_file.focus_editor_for_clean", + "Focus a Zig editor before cleaning Current Zig File artifacts." + ) + ); + return null; + } + + return this.resolveCleanAction(cwd); + } + + async resolveDebugAction(config, cwd) { + const lldbDapPath = await resolveLLDBDAPExecutable(); + if (!lldbDapPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.lldb_dap.not_found", + "lldb-dap was not found. Install Xcode Command Line Tools or set an LLDB DAP executable path in Zig extension settings." + ) + ); + return null; + } + + const configuredProgramPath = getTaskConfigValue(config, "programPath"); + const programPath = resolvePathAgainstBase(configuredProgramPath, cwd); + if (!programPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.debug.choose_program", + "Choose a program path before running Zig Debug." + ) + ); + return null; + } + + const consoleMode = getTaskConfigValue(config, "console") || "internalConsole"; + const stopOnEntry = Boolean(config && config.get("stopOnEntry")); + + const action = new TaskDebugAdapterAction("zig-lldb-dap"); + action.transport = "stdio"; + action.command = "/usr/bin/perl"; + action.args = [lldbDapProxyPath(), lldbDapPath, debugAdapterLogPath()]; + action.debugRequest = "launch"; + action.env = { + DYLD_FRAMEWORK_PATH: lldbFrameworkPaths().join(":"), + NOVA_ZIG_LLDB_DAP_PATH: lldbDapPath, + NOVA_ZIG_DEBUG_LOG: debugAdapterLogPath(), + }; + action.debugArgs = { + program: programPath, + cwd, + args: getTaskArgs(config, "runArgs"), + stopOnEntry, + }; + + if (consoleMode !== "internalConsole") { + action.debugArgs.console = consoleMode; + } + + return action; + } + + async resolveFileTestAction(config, cwd) { + const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); + if (!zigPath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.zig.not_found", + "Zig was not found. Install it or set a Zig executable path in Zig extension settings." + ) + ); + return null; + } + + const configuredPath = getTaskConfigValue(config, "filePath"); + const filePath = configuredPath + ? resolveWorkspaceRelativePath(configuredPath) + : activeZigFilePath(); + + if (!filePath) { + nova.workspace.showWarningMessage( + localizedText( + "warning.file_test.choose_file", + "Choose a Zig file for this task or focus a Zig editor before running Zig File Test." + ) + ); + return null; + } + + return this.createAction(zigPath, ["test", filePath, ...getTaskArgs(config, "zigArgs")], cwd); + } +} + +class ZigIssueAssistant { + constructor() { + this.disposable = nova.assistants.registerIssueAssistant( + [{ syntax: "zig" }, { syntax: "zig-package" }], + this, + { event: "onChange" } + ); + } + + dispose() { + if (this.disposable && typeof this.disposable.dispose === "function") { + this.disposable.dispose(); + this.disposable = null; + } + } + + provideIssues(editor) { + if (!editor || !editor.document) { + return []; + } + + // Nova's LanguageClient already owns core LSP publishDiagnostics handling. + // Registering an issue assistant here tells Nova that Zig supports live + // checking, so the Problems UI doesn't show a misleading empty-state banner. + return []; + } +} -- cgit v1.3