aboutsummaryrefslogtreecommitdiff
path: root/Zig.novaextension/Scripts/main.js
diff options
context:
space:
mode:
authorDavid Czihak <git@dcz.at>2026-05-10 19:21:33 +0200
committerDavid Czihak <git@dcz.at>2026-05-10 19:21:33 +0200
commitb80b9c1f82585677a7c042557576c41b1670d259 (patch)
tree9a741dfd7725205dba35b42bc6d5a6a7e084ced0 /Zig.novaextension/Scripts/main.js
parent33ea57ddd69f35f3f2db64a1a2d31b410ed7afb2 (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.js1583
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;
+ }
+}