diff options
| author | David Czihak <git@dcz.at> | 2026-05-10 19:21:33 +0200 |
|---|---|---|
| committer | David Czihak <git@dcz.at> | 2026-05-10 19:21:33 +0200 |
| commit | b80b9c1f82585677a7c042557576c41b1670d259 (patch) | |
| tree | 9a741dfd7725205dba35b42bc6d5a6a7e084ced0 /Zig.novaextension/Scripts/main.js | |
| parent | 33ea57ddd69f35f3f2db64a1a2d31b410ed7afb2 (diff) | |
Chore: Move extension bundle into Zig.novaextension/ subdirectory
Separates Nova extension resources from development-only items.
Development items (ISSUES.md, vendor/, examples/) remain at the repo root.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'Zig.novaextension/Scripts/main.js')
| -rw-r--r-- | Zig.novaextension/Scripts/main.js | 1583 |
1 files changed, 1583 insertions, 0 deletions
diff --git a/Zig.novaextension/Scripts/main.js b/Zig.novaextension/Scripts/main.js new file mode 100644 index 0000000..7477993 --- /dev/null +++ b/Zig.novaextension/Scripts/main.js @@ -0,0 +1,1583 @@ +"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 = []; +// Tracks when the last ZLS client was stopped. Used by start() to wait for +// the old ZLS process to fully exit before spawning a new one — survives +// across instances when Nova auto-restarts the extension. +let lastZlsStopAt = 0; +const ZLS_RESTART_GRACE_MS = 500; + +exports.activate = function activate() { + // Nova may call activate() again without first calling deactivate() when a + // language server crashes and the extension is auto-restarted. Dispose any + // existing instances so their config observers and ZLS processes don't leak. + 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 = []; + + registerCommands(); + taskAssistant = new ZigTaskAssistant(); + languageServer = new ZigLanguageServer(); + console.log(`[${EXTENSION_ID}] activated`); +}; + +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 = []; + + console.log(`[${EXTENSION_ID}] deactivated`); +}; + +// --- 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 (workspaceValue === "enabled") return true; + if (workspaceValue === "disabled") return false; + 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<userOptions>, ...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) { + console.log(`[${TASK_ASSISTANT_ID}] stepCache.fetch: cwd=${cwd}`); + 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, null); + + if (key === text) { + return `Localization missing for ${key}`; + } + + 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 localize(key, variables) { + let text = nova.localize(key, null); + + if (key === text) { + console.warn(`[locales] Missing localization for ${key}`); + return text; + } + + 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<string|null>} - Path to the executable or null if not found + */ +async function findOnPath(executableName) { + // Pass Nova's own PATH explicitly — child processes do not inherit the parent + // environment automatically, so without this the subprocess sees a stripped PATH + // that misses entries like /opt/homebrew/bin. + const novaPath = (nova.environment && nova.environment.PATH) || ""; + + // Augment Nova's PATH with well-known prefixes that Homebrew and common + // package managers use but that may be absent when Nova is launched from the + // Dock (where launchd provides a narrower system PATH than a login shell). + const fallbackPrefixes = [ + "/opt/homebrew/bin", // Homebrew – Apple Silicon + "/usr/local/bin", // Homebrew – Intel / manual installs + `${nova.environment && nova.environment.HOME}/.local/bin`, // mise, cargo, etc. + ]; + const augmentedPath = [ + ...new Set([...novaPath.split(":").filter(Boolean), ...fallbackPrefixes]), + ].join(":"); + + const result = await runProcess("/usr/bin/env", { + args: ["which", executableName], + env: { PATH: augmentedPath }, + }); + const found = result.stdout.trim(); + console.log( + `[${EXTENSION_ID}] findOnPath: ${executableName} → ${result.status === 0 ? found : "not found"}`, + ); + + if (result.status !== 0) { + return null; + } + + return found.length > 0 ? found : 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<string|null>} - Path to the executable or null if not found + */ +async function resolveExecutable(configKey, defaultCommand) { + const configuredPath = getConfigValue(configKey); + if (configuredPath) { + console.log( + `[${EXTENSION_ID}] findOnPath: ${defaultCommand} → config: ${configuredPath}`, + ); + return configuredPath; + } + + return await findOnPath(defaultCommand); +} + +/** + * Resolve the zig executable, show warning if not found + * + * @returns {Promise<string|null>} - 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<string|null>} - 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<string|null>} - 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; + console.log( + `[${EXTENSION_ID}] runInTerminal: command=${command} cwd=${cwd}`, + ); + + 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) { + const onChange = () => { + if (restart) { + console.log( + `[${LANGUAGE_CLIENT_ID}] config changed (${key}) → restart`, + ); + this.start(); + } else { + console.log( + `[${LANGUAGE_CLIENT_ID}] config changed (${key}) → push configuration`, + ); + this.pushConfiguration().catch((error) => { + console.error( + `[${LANGUAGE_CLIENT_ID}] pushConfiguration failed`, + error, + ); + }); + } + }; + this.disposables.push(nova.config.onDidChange(key, onChange)); + this.disposables.push(nova.workspace.config.onDidChange(key, onChange)); + } + + dispose() { + this.stop(); + // Remove observers first so the workspace config cleanup below does not + // accidentally fire any remaining callbacks. + 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; + console.log(`[${LANGUAGE_CLIENT_ID}] start (generation ${generation})`); + this.stop(); + + // If a ZLS process was recently stopped (either on this instance or on a + // previous instance after Nova auto-restarted the extension), wait for it + // to fully exit before spawning a new one. Without this pause the two + // processes overlap, Nova sees two clients with the same ID, and kills + // the new one before the LSP handshake completes. + const sinceLastStop = Date.now() - lastZlsStopAt; + if (lastZlsStopAt > 0 && sinceLastStop < ZLS_RESTART_GRACE_MS) { + const wait = ZLS_RESTART_GRACE_MS - sinceLastStop; + console.log( + `[${LANGUAGE_CLIENT_ID}] waiting ${wait}ms for previous ZLS to exit`, + ); + await new Promise((resolve) => setTimeout(resolve, wait)); + if (generation !== this.restartGeneration) { + return; + } + } + + if (!getBooleanConfigValue(CONFIG_KEYS.zlsEnabled, true)) { + console.log(`[${LANGUAGE_CLIENT_ID}] start: ZLS disabled, skipping`); + 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; + } + + 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: 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" }, + ), + ); + } + }); + + console.log( + `[${LANGUAGE_CLIENT_ID}] starting client: zls=${zlsPath} zig=${zigPath || "not found"}`, + ); + try { + client.start(); + this.client = client; + nova.subscriptions.add(client); + this.pushConfiguration(settings).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(preResolvedSettings) { + console.log(`[${LANGUAGE_CLIENT_ID}] pushConfiguration`); + const generation = this.restartGeneration; + const { settings } = preResolvedSettings + ? { settings: preResolvedSettings } + : await this.resolveSettings(); + if ( + generation !== this.restartGeneration || + !this.client || + !this.client.running + ) { + return; + } + + this.client.sendNotification("workspace/didChangeConfiguration", { + settings, + }); + } + + stop() { + if (this.client) { + console.log(`[${LANGUAGE_CLIENT_ID}] 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; + lastZlsStopAt = Date.now(); + } + } + + 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: localize("autotasks.title"), + }); + } + + dispose() { + if (this.disposable && typeof this.disposable.dispose === "function") { + this.disposable.dispose(); + this.disposable = null; + } + } + + provideTasks() { + const tasks = []; + + const currentFile = new Task(localize("autotasks.current-file.name")); + currentFile.image = "zig-script"; + 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(localize("autotasks.buildstep.name", { step })); + task.image = "zig-hex"; + 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); + console.log( + `[${TASK_ASSISTANT_ID}] resolveTaskAction: type=${type} cwd=${cwd}`, + ); + + 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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveCleanAction: cwd=${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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveBuildAction: cwd=${cwd}`); + const zigPath = await resolveZigExecutable(); + if (!zigPath) return null; + + return this.createAction(zigPath, buildZigArgv(config), cwd); + } + + async resolveDebugBuildAction(config, cwd) { + console.log(`[${TASK_ASSISTANT_ID}] resolveDebugBuildAction: cwd=${cwd}`); + const zigPath = await resolveZigExecutable(); + if (!zigPath) return null; + + return this.createAction( + zigPath, + buildZigArgv(config, { defaultOptimize: "Debug" }), + cwd, + ); + } + + async resolveBuildRunAction(config, cwd, forceConsole) { + console.log(`[${TASK_ASSISTANT_ID}] resolveBuildRunAction: cwd=${cwd}`); + 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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveBuildStepAction: step=${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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveTestAction: cwd=${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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveWatchAction: cwd=${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() { + console.log(`[${TASK_ASSISTANT_ID}] 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() { + console.log(`[${TASK_ASSISTANT_ID}] 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) { + console.log(`[${TASK_ASSISTANT_ID}] resolveDebugAction: cwd=${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; + } +} |
