"use strict"; // --- CONSTANTS --------------------------------------------------------------- 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 USER_OPTION_REGEX = /^[A-Za-z_][A-Za-z0-9_-]*(=.*)?$/; const STEP_CACHE_TTL_MS = 5 * 60 * 1000; 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`, discoverSteps: `${EXTENSION_ID}.tasks.discover-steps`, }; // --- LIFECYCLE --------------------------------------------------------------- let languageServer = null; let taskAssistant = null; let commandRegistrations = []; exports.activate = function activate() { registerCommands(); taskAssistant = new ZigTaskAssistant(); languageServer = new ZigLanguageServer(); }; exports.deactivate = function deactivate() { if (languageServer) { languageServer.dispose(); languageServer = null; } if (taskAssistant) { taskAssistant.dispose(); taskAssistant = null; } commandRegistrations.forEach((disposable) => { if (disposable && typeof disposable.dispose === "function") { try { disposable.dispose(); } catch (error) { console.error(`[${EXTENSION_ID}] Failed to dispose registration`, error); } } }); commandRegistrations = []; }; // --- CONFIG HELPERS ---------------------------------------------------------- /** * Resolve a configuration value respecting precedence * * @param {string} key - Configuration key */ 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; } /** * Resolve a configuration boolean value respecting precedence * * @param {string} key - Configuration key * @param {boolean} fallback - Fallback value */ 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; } /** * Normalize a config array value by converting to array, filtering nulls, * trimming whitespace, and removing empty entries * * @param {*} value - Any value * @returns {string[]} - Clean array of non-empty strings */ function normalizeArray(value) { if (!Array.isArray(value)) { return []; } return value .map((entry) => entry === null || entry === undefined ? "" : String(entry).trim(), ) .filter((entry) => entry.length > 0); } /** * Resolve a relative path to an absolute path, using a provided base directory * or workspace root as fallback * * @param {string} path - Relative path * @param {string|null} base - Base path or null to use the workspace as base */ function resolvePathAgainstBase(path, base) { if (!path) { return null; } if (path.startsWith("/")) { return path; } if (base) { return nova.path.join(base, path); } if (nova.workspace.path) { return nova.path.join(nova.workspace.path, path); } return path; } /** * Safely retrieve a task config value * * @param {Object} config - Nova task configuration object * @param {string} key - Key * @returns {string} - Value */ function getTaskConfigValue(config, key) { if (!config) { return null; } const value = config.get(key); return value === undefined || value === null || value === "" ? null : value; } /** * Safely retrieve a task config argument list * * Specialized version of {@link getTaskConfigValue} * * @param {Object} config - Nova task configuration object * @param {string} key - Key * @returns {string[]} - Argument list */ function getTaskArgs(config, key) { return normalizeArray(getTaskConfigValue(config, key)); } /** * Resolves the task working directory from config, falling back to workspace root. * * @param {Object} config - Nova task configuration object * @returns {string|null} - Absolute working directory path or null if no workspace is open */ function getTaskCwd(config) { const configured = getTaskConfigValue(config, "cwd"); if (configured) { return resolvePathAgainstBase(configured, null); } return nova.workspace.path || null; } // --- TASK ARG HELPERS -------------------------------------------------------- // Returns the configured step string, or null if the step should be omitted. // An explicit empty string means "no step argument" — `zig build` then runs the // default install step (Ziglings-style projects rely on this). Missing/null // returns the caller-supplied fallback. function resolveStep(config, key, fallback) { const raw = config ? config.get(key) : undefined; if (raw === undefined || raw === null) { return fallback === undefined ? null : fallback; } const trimmed = String(raw).trim(); return trimmed.length > 0 ? trimmed : null; } // Backwards-compatible wrapper: zigBuildRun's `runStep` field. The legacy // `step` key is still honored for users on pre-0.1 task configs. function resolveRunStep(config) { const raw = config ? config.get("runStep") : undefined; if (raw === undefined || raw === null) { const legacy = getTaskConfigValue(config, "step"); return legacy ? legacy : null; } const trimmed = String(raw).trim(); return trimmed.length > 0 ? trimmed : null; } // Builds the `zig build` argv prefix shared by Build/Run/Test/Watch resolvers. // Order: ["build", -Doptimize, -Dtarget, -D, ...buildArgs, step?]. // `runArgs` are the caller's responsibility — they go after `--`. function buildZigArgv(config, options) { const opts = options || {}; const argv = ["build"]; const optimize = getTaskConfigValue(config, "optimize") || opts.defaultOptimize || null; if (optimize) argv.push(`-Doptimize=${optimize}`); const target = getTaskConfigValue(config, "target"); if (target) argv.push(`-Dtarget=${target}`); for (const entry of getTaskArgs(config, "userOptions")) { if (USER_OPTION_REGEX.test(entry)) { argv.push(`-D${entry}`); } else { console.warn(`[zig-task] Skipping invalid userOptions entry: ${entry}`); } } argv.push(...getTaskArgs(config, "buildArgs")); if (opts.step) argv.push(opts.step); return argv; } // --- PATH & WORKSPACE HELPERS ------------------------------------------------ /** * Validate cwd is safe to clean (absolute, inside workspace, not root or home), * returning directories or null * * @param {string} cwd - Directory to clean * @returns {string[]|null} - Directories to remove */ function resolveCleanPaths(cwd) { const showWarning = () => { nova.workspace.showWarningMessage( localizeText( "warning.clean.unsafe_cwd", "Refusing to clean: the working directory must be inside this workspace.", ), ); }; if (!cwd || typeof cwd !== "string" || !cwd.startsWith("/")) { showWarning(); return null; } const normalized = nova.path.normalize(cwd); if (normalized === "/" || normalized === "") { showWarning(); return null; } const home = nova.environment ? nova.environment.HOME : null; if ( home && (normalized === home || normalized === nova.path.normalize(home)) ) { showWarning(); return null; } const workspacePath = nova.workspace.path; if (!workspacePath) { showWarning(); return null; } const workspaceNormalized = nova.path.normalize(workspacePath); if ( normalized !== workspaceNormalized && !normalized.startsWith(workspaceNormalized + "/") ) { showWarning(); return null; } return [".zig-cache", "zig-cache", "zig-out"]; } /** * Walk up from startDir to find the nearest ancestor containing build.zig, * stopping at workspace root * * @param {string} startDir - Start directory */ function findNearestZigBuildDir(startDir) { const workspacePath = nova.workspace.path ? nova.path.normalize(nova.workspace.path) : null; if (!startDir) return workspacePath || null; let current = nova.path.normalize(startDir); for (let i = 0; i < 64; i++) { if (nova.fs.stat(nova.path.join(current, "build.zig"))) { return current; } if (current === "/" || (workspacePath && current === workspacePath)) { return workspacePath || null; } const parent = nova.path.dirname(current); if (!parent || parent === current) break; current = parent; } return workspacePath || null; } /** * Extract the package name from `build.zig.zon` by regex, * returning null if absent or unreadable * * @param {string} cwd - Directory in which to search for build.zig.zon * @returns {string|null} - Package name or null if parsing fails */ function parseProjectName(cwd) { if (!cwd) return null; const zonPath = nova.path.join(cwd, "build.zig.zon"); if (!nova.fs.stat(zonPath)) return null; let content = ""; try { const file = nova.fs.open(zonPath, "r"); try { content = file.read() || ""; } finally { file.close(); } } catch (error) { console.warn(`[zig] Failed to read ${zonPath}: ${error}`); return null; } if (typeof content !== "string" || content.length === 0) return null; const stripped = content.replace(/\/\/[^\n]*/g, ""); const match = stripped.match( /\.name\s*=\s*(?:"([^"]+)"|\.([A-Za-z_][\w]*))/, ); if (!match) return null; return match[1] || match[2] || null; } /** * Resolve the path of the active zig file * * @returns {string|null} - Path or null if active editor is not a zig file */ 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; } /** * Resolve the directory of the active zig file * * @returns {string|null} - Directory or null if active editor is not a zig file */ function activeZigFileDirectory() { const filePath = activeZigFilePath(); if (!filePath) { return null; } return nova.path.dirname(filePath); } // --- STEP CACHE -------------------------------------------------------------- // Per-cwd cache for `zig build --list-steps`. Invalidated by mtime changes on // build.zig / build.zig.zon and a soft 5-minute TTL. `getOrFetch` returns // cached steps synchronously and kicks off a background refresh when stale, // nudging Nova to reload tasks once the refresh lands. const STEP_DISCOVERY_WARN_THROTTLE_MS = 5 * 60 * 1000; const stepCache = { entries: new Map(), pending: new Map(), lastWarnedAt: new Map(), getOrFetch(cwd) { if (!cwd) return null; const buildZigPath = nova.path.join(cwd, "build.zig"); const buildZigStat = nova.fs.stat(buildZigPath); if (!buildZigStat) return null; const buildZonStat = nova.fs.stat(nova.path.join(cwd, "build.zig.zon")); const buildZigMtimeMs = buildZigStat.mtime ? buildZigStat.mtime.getTime() : 0; const buildZonMtimeMs = buildZonStat && buildZonStat.mtime ? buildZonStat.mtime.getTime() : 0; const cached = this.entries.get(cwd); const fresh = cached && cached.buildZigMtimeMs === buildZigMtimeMs && cached.buildZonMtimeMs === buildZonMtimeMs && Date.now() - cached.fetchedAt < STEP_CACHE_TTL_MS; if (fresh) return cached.steps; if (!this.pending.has(cwd)) { const promise = this.fetch(cwd, buildZigMtimeMs, buildZonMtimeMs).finally( () => { this.pending.delete(cwd); if (typeof nova.workspace.reloadTasks === "function") { try { nova.workspace.reloadTasks(TASK_ASSISTANT_ID); } catch (_) {} } }, ); this.pending.set(cwd, promise); } return cached ? cached.steps : null; }, async fetch(cwd, buildZigMtimeMs, buildZonMtimeMs) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const result = await runProcess(zigPath, { args: ["build", "--list-steps"], cwd, timeoutMs: 60000, }); if (result.status !== 0) { const last = this.lastWarnedAt.get(cwd) || 0; if (Date.now() - last >= STEP_DISCOVERY_WARN_THROTTLE_MS) { this.lastWarnedAt.set(cwd, Date.now()); const detail = (result.stderr || "").trim().slice(0, 500); console.warn( `[zig-task] Step discovery failed in ${cwd} (status ${result.status})${detail ? ": " + detail : ""}`, ); } return null; } const steps = result.stdout .split("\n") .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => line.split(/\s+/, 1)[0]) .filter((name) => /^[A-Za-z_][\w-]*$/.test(name)); this.entries.set(cwd, { steps, buildZigMtimeMs, buildZonMtimeMs, fetchedAt: Date.now(), }); return steps; }, invalidate(cwd) { if (cwd) { this.entries.delete(cwd); this.lastWarnedAt.delete(cwd); } else { this.entries.clear(); this.lastWarnedAt.clear(); } }, }; // --- LOCALIZATION ------------------------------------------------------------- /** * Resolve a localized string, substituting `{variable}` placeholders * * @param {string} key - Key * @param {string} fallback - Fallback text * @param {*} variables - Dictionary of variable names and values */ function localizeText(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; } // --- PROCESS ----------------------------------------------------------------- /** * Wraps Nova's Process API in a Promise, resolving with stdout, stderr, and exit status. * * @param {string} command - Absolute path to the executable * @param {Object} options - Options passed to Nova's Process constructor (args, cwd, env, …); * the optional `timeoutMs` is consumed here and removed before construction * @returns {Promise<{status: number, stdout: string, stderr: string}>} */ function runProcess(command, options) { return new Promise((resolve, reject) => { const stdout = []; const stderr = []; const { timeoutMs, ...processOptions } = options || {}; const process = new Process(command, processOptions); let settled = false; let timer = null; const settle = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); resolve(result); }; process.onStdout((line) => stdout.push(line)); process.onStderr((line) => stderr.push(line)); process.onDidExit((status) => { settle({ status, stdout: stdout.join(""), stderr: stderr.join(""), }); }); try { process.start(); } catch (error) { if (timer) clearTimeout(timer); reject(error); return; } if (typeof timeoutMs === "number" && timeoutMs > 0) { timer = setTimeout(() => { try { if (typeof process.signal === "function") { process.signal("SIGTERM"); } } catch (_) {} settle({ status: -1, stdout: stdout.join(""), stderr: `${stderr.join("")}\n[timeout after ${timeoutMs}ms]`, }); }, timeoutMs); } }); } // --- EXECUTABLE RESOLVERS ---------------------------------------------------- /** * Find executable on PATH using `which` * * @param {string} executableName - Executable name * @returns {Promise} - Path to the executable or null if not found */ async function findOnPath(executableName) { const result = await runProcess("/usr/bin/env", { args: ["which", executableName], }); if (result.status !== 0) { return null; } const path = result.stdout.trim(); return path.length > 0 ? path : null; } /** * Resolves an executable path from config first, falling back to PATH search * * @param {string} configKey - Configuration key * @param {string} defaultCommand - Default command to be searched on PATH * @returns {Promise} - Path to the executable or null if not found */ async function resolveExecutable(configKey, defaultCommand) { const configuredPath = getConfigValue(configKey); if (configuredPath) { return configuredPath; } return await findOnPath(defaultCommand); } /** * Resolve the zig executable, show warning if not found * * @returns {Promise} - Path to the zig executable or null if not found */ async function resolveZigExecutable() { const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig"); if (!zigPath) { nova.workspace.showWarningMessage( localizeText( "warning.zig.not_found", "Zig was not found. Install it or set a Zig executable path in Zig extension settings.", ), ); return null; } return zigPath; } /** * Resolve executable using Xcode `xcrun` * * @param {string} executableName - Executable name * @returns {Promise} - Path to the executable or null if not found */ async function findWithXcode(executableName) { const result = await runProcess("/usr/bin/xcrun", { args: ["--find", executableName], }); if (result.status !== 0) { return null; } const path = result.stdout.trim(); return path.length > 0 ? path : null; } /** * Resolve the lldb-dap executable * * @returns {Promise} - Path to the lldb-dap executable or null if not found */ async function resolveLldbDapExecutable() { const configuredPath = getConfigValue(CONFIG_KEYS.lldbDapPath); if (configuredPath) { return configuredPath; } const xcodePath = await findWithXcode("lldb-dap"); if (xcodePath) { return xcodePath; } return await findOnPath("lldb-dap"); } /** * Return Dynamic Linker (DYLD) framework search paths for LLDB * * @returns {string[]} - Search paths */ function lldbFrameworkPaths() { return [ "/Applications/Xcode-beta.app/Contents/SharedFrameworks/", "/Applications/Xcode.app/Contents/SharedFrameworks/", "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/", ]; } /** * Return the absolute path to the lldb-dap proxy Perl script bundled * with the extension * * @returns {string} - Path */ function lldbDapProxyPath() { return nova.path.join(nova.extension.path, "Scripts", "lldb-dap-proxy.pl"); } /** * Return the path to the debug adapter log file in extension global storage * * @returns {string} - Path */ function debugAdapterLogPath() { return nova.path.join(nova.extension.globalStoragePath, "lldb-dap-proxy.log"); } /** * Return the absolute path to the issue normalizer Perl script bundled * with the extension * * @returns {string} - Path */ function issueNormalizerScriptPath() { return nova.path.join( nova.extension.path, "Scripts", "normalize-zig-issues.pl", ); } // --- SHELL UTILITIES --------------------------------------------------------- /** * Wrap a value in single quotes with embedded single quotes escaped, * safe for POSIX shell * * @param {string} value - String * @returns {string} - Quoted string */ function quoteShellArgument(value) { const text = value === null || value === undefined ? "" : String(value); return `'${text.replace(/'/g, `'\\''`)}'`; } /** * Escape backslashes and double quotes for safe embedding in AppleScript * * @param {string} value - String * @returns {string} - Escaped string */ function escapeAppleScriptString(value) { const text = value === null || value === undefined ? "" : String(value); return text.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } /** * Build a safe shell command string with cd preamble * * @param {string} command - Command * @param {string[]} args - Arguments * @param {string} cwd - Working directory * @returns {string} - Safe shell command */ 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("; "); } /** * Build a shell command that pipes zig output through the issue normalizer * * @param {string} command - Command * @param {string[]} args - Arguments * @returns {string} - Piped command */ 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}`; } /** * Runs a shell command string in a new macOS Terminal window. * * @param {string} commandLine - Command to run * @returns {Promise<{status: number, stderr: string}>} */ 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() }); }); try { process.start(); } catch (error) { resolve({ status: -1, stderr: String(error) }); } }); } // --- COMMANDS ---------------------------------------------------------------- /** Registers the internal runInTerminal command used by external-terminal task actions. */ 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( localizeText( "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 = localizeText( "warning.terminal.open_failed", "Unable to open Terminal for the Zig task.", ); const suffix = result.stderr ? ` ${result.stderr}` : ""; workspace.showWarningMessage(`${prefix}${suffix}`); } }, ), ); } // --- LANGUAGE SERVER --------------------------------------------------------- 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 { this.pushConfiguration().catch((error) => { console.error( `[${LANGUAGE_CLIENT_ID}] pushConfiguration failed`, error, ); }); } }), ); this.disposables.push( nova.workspace.config.onDidChange(key, () => { if (restart) { this.start(); } else { this.pushConfiguration().catch((error) => { console.error( `[${LANGUAGE_CLIENT_ID}] pushConfiguration failed`, error, ); }); } }), ); } dispose() { this.stop(); this.disposables.forEach((disposable) => { if (disposable && typeof disposable.dispose === "function") { try { disposable.dispose(); } catch (error) { console.error( `[${LANGUAGE_CLIENT_ID}] Failed to dispose subscription`, error, ); } } }); 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", localizeText( "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, localizeText("name.language_server", "Zig Language Server"), serverOptions, clientOptions, ); client.onNotification("window/logMessage", ({ type, message }) => { // type: 1=Error, 2=Warning, 3=Info, 4=Log const enriched = message === "ParseError" ? "ParseError — ZLS could not fully parse the Zig source (normal while editing)" : message; if (type === 1) { console.error(`[ZLS] ${enriched}`); } else if (type === 2) { console.warn(`[ZLS] ${enriched}`); } else if (debugLogs) { console.log(`[ZLS] ${enriched}`); } }); this.clientStopDisposable = client.onDidStop((error) => { if (error) { console.error(`[${LANGUAGE_CLIENT_ID}] ${error.message}`); nova.workspace.showWarningMessage( localizeText( "warning.zls.stopped_unexpectedly", "The Zig Language Server stopped unexpectedly ({executable}).", { executable: zlsPath || "zls" }, ), ); } }); try { client.start(); this.client = client; nova.subscriptions.add(client); this.pushConfiguration().catch((error) => { console.error( `[${LANGUAGE_CLIENT_ID}] pushConfiguration failed`, error, ); }); 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( localizeText( "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); } } // --- TASK ASSISTANT ---------------------------------------------------------- class ZigTaskAssistant { constructor() { this.disposable = nova.assistants.registerTaskAssistant(this, { identifier: TASK_ASSISTANT_ID, name: localizeText("name.extension", "Zig"), }); } dispose() { if (this.disposable && typeof this.disposable.dispose === "function") { this.disposable.dispose(); this.disposable = null; } } provideTasks() { const tasks = []; const currentFile = new Task( localizeText("task.current_file.name", "Current Zig File"), ); currentFile.setAction( Task.Run, new TaskResolvableAction({ data: { type: "current-file-run", }, }), ); currentFile.setAction( Task.Clean, new TaskResolvableAction({ data: { type: "current-file-clean", }, }), ); tasks.push(currentFile); const workspacePath = nova.workspace.path; if ( workspacePath && getBooleanConfigValue(CONFIG_KEYS.discoverSteps, true) ) { const steps = stepCache.getOrFetch(workspacePath); if (steps && steps.length > 0) { for (const step of steps) { const task = new Task( localizeText("task.build_step.name", "Zig Build: {step}", { step, }), ); task.setAction( Task.Run, new TaskResolvableAction({ data: { type: "build-step", step, }, }), ); tasks.push(task); } } } return tasks; } 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-step": return this.resolveBuildStepAction(context.data && context.data.step); case "current-file-run": return this.resolveCurrentFileRunAction(); case "current-file-clean": return this.resolveCurrentFileCleanAction(); case "debug": return this.resolveDebugAction(config, cwd); case "test-build": case "test-run": return this.resolveTestAction(config, cwd); case "watch": return this.resolveWatchAction(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], }); } async resolveCleanAction(cwd) { if (!cwd) { nova.workspace.showWarningMessage( localizeText( "warning.clean.missing_cwd", "Choose a workspace or working directory before cleaning Zig build artifacts.", ), ); return null; } const paths = resolveCleanPaths(cwd); if (!paths) return null; // If the project exposes an `uninstall` step, prefer running it before // wiping the cache directories. Use only what's already cached — don't // block clean on a fresh `--list-steps` invocation. const cached = stepCache.entries.get(cwd); const args = ["-rf", ...paths]; if ( cached && Array.isArray(cached.steps) && cached.steps.includes("uninstall") ) { const zigPath = await resolveZigExecutable(); if (zigPath) { // `;` (not `&&`) is intentional: rm should run even if uninstall fails. const uninstall = buildShellCommand(zigPath, ["build", "uninstall"]); const remove = buildShellCommand("/bin/rm", args); return new TaskProcessAction("/bin/zsh", { args: ["-lc", `${uninstall}; ${remove}`], cwd, matchers: [ISSUE_MATCHER], }); } } return new TaskProcessAction("/bin/rm", { args, cwd }); } async resolveBuildAction(config, cwd) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; return this.createAction(zigPath, buildZigArgv(config), cwd); } async resolveDebugBuildAction(config, cwd) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; return this.createAction( zigPath, buildZigArgv(config, { defaultOptimize: "Debug" }), cwd, ); } async resolveBuildRunAction(config, cwd, forceConsole) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const step = resolveRunStep(config); const argv = buildZigArgv(config, { step }); const runArgs = getTaskArgs(config, "runArgs"); if (runArgs.length > 0) argv.push("--", ...runArgs); const consoleMode = forceConsole || getTaskConfigValue(config, "console") || "internalConsole"; if (consoleMode === "externalTerminal") { return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, { args: [ { command: zigPath, args: argv, cwd, }, ], }); } return this.createAction(zigPath, argv, cwd); } async resolveBuildStepAction(step) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const cwd = nova.workspace.path || null; if (!cwd || !step || !/^[A-Za-z_][\w-]*$/.test(step)) return null; return this.createAction(zigPath, ["build", step], cwd); } async resolveTestAction(config, cwd) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const argv = buildZigArgv(config, { step: "test" }); const summary = getTaskConfigValue(config, "summary"); if (summary) argv.push(`--summary=${summary}`); const testFilter = getTaskConfigValue(config, "testFilter"); if (testFilter) argv.push("--test-filter", testFilter); const runArgs = getTaskArgs(config, "runArgs"); if (runArgs.length > 0) argv.push("--", ...runArgs); return this.createAction(zigPath, argv, cwd); } async resolveWatchAction(config, cwd) { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const step = resolveStep(config, "step", null); const argv = buildZigArgv(config, { step }); argv.push("--watch"); const debounce = getTaskConfigValue(config, "debounceMs"); if (debounce !== null && debounce !== undefined && debounce !== "") { const n = Number(debounce); if (Number.isFinite(n) && n >= 0) argv.push("--debounce", String(Math.floor(n))); } const incremental = getTaskConfigValue(config, "incremental"); if (incremental === "on") argv.push("-fincremental"); else if (incremental === "off") argv.push("-fno-incremental"); return this.createAction(zigPath, argv, cwd); } async resolveCurrentFileRunAction() { const zigPath = await resolveZigExecutable(); if (!zigPath) return null; const filePath = activeZigFilePath(); const cwd = filePath ? nova.path.dirname(filePath) : null; if (!filePath || !cwd) { nova.workspace.showWarningMessage( localizeText( "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); } async resolveCurrentFileCleanAction() { const startDir = activeZigFileDirectory(); if (!startDir) { nova.workspace.showWarningMessage( localizeText( "warning.current_file.focus_editor_for_clean", "Focus a Zig editor before cleaning Current Zig File artifacts.", ), ); return null; } const cwd = findNearestZigBuildDir(startDir); return this.resolveCleanAction(cwd); } async resolveDebugAction(config, cwd) { const lldbDapPath = await resolveLldbDapExecutable(); if (!lldbDapPath) { nova.workspace.showWarningMessage( localizeText( "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"); let programPath = resolvePathAgainstBase(configuredProgramPath, cwd); if (!programPath) { const projectName = parseProjectName(cwd); if (projectName) { const candidate = nova.path.join(cwd, "zig-out", "bin", projectName); if (nova.fs.stat(candidate)) { programPath = candidate; } } } if (!programPath) { nova.workspace.showWarningMessage( localizeText( "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; } }