"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`, lldbDapDebug: `${EXTENSION_ID}.debug-adapter.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 nova.path.join(nova.extension.globalStoragePath, "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 "current-file-run": return this.resolveCurrentFileRunAction(); case "current-file-clean": return this.resolveCurrentFileCleanAction(); case "debug": return this.resolveDebugAction(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 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"; const debugLog = getBooleanConfigValue(CONFIG_KEYS.lldbDapDebug, false); let logPath; if (debugLog) { try { const p = debugAdapterLogPath(); const dir = nova.path.dirname(p); if (!nova.fs.stat(dir)) nova.fs.mkdir(dir); logPath = p; } catch (_) {} } action.args = logPath ? [lldbDapProxyPath(), lldbDapPath, logPath] : [lldbDapProxyPath(), lldbDapPath]; action.debugRequest = "launch"; action.env = { DYLD_FRAMEWORK_PATH: lldbFrameworkPaths().join(":"), NOVA_ZIG_LLDB_DAP_PATH: lldbDapPath, ...(logPath ? { NOVA_ZIG_DEBUG_LOG: logPath } : {}), }; action.debugArgs = { program: programPath, cwd, args: getTaskArgs(config, "runArgs"), stopOnEntry, }; if (consoleMode !== "internalConsole") { action.debugArgs.console = consoleMode; } return action; } } 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 []; } }