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 /Scripts | |
| 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 'Scripts')
| -rwxr-xr-x | Scripts/build-parser.sh | 53 | ||||
| -rw-r--r-- | Scripts/lldb-dap-proxy.pl | 224 | ||||
| -rw-r--r-- | Scripts/main.js | 1583 | ||||
| -rw-r--r-- | Scripts/normalize-zig-issues.pl | 93 | ||||
| -rwxr-xr-x | Scripts/update-parser.sh | 64 |
5 files changed, 0 insertions, 2017 deletions
diff --git a/Scripts/build-parser.sh b/Scripts/build-parser.sh deleted file mode 100755 index 5cd7039..0000000 --- a/Scripts/build-parser.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/sh -# -# build-parser.sh — Compile the Nova-compatible tree-sitter-zig parser. -# -# Purpose: -# Build vendor/tree-sitter-zig/src/parser.c into a universal macOS dylib -# that Nova loads via its private SyntaxKit framework. -# -# What it does: -# - clang -dynamiclib for arm64 + x86_64 -# - links against Nova’s SyntaxKit framework (from /Applications/Nova.app) -# - sets rpath @loader_path/../Frameworks so the dylib finds SyntaxKit -# when Nova loads it from the bundled extension -# - ad-hoc codesigns the dylib (Gatekeeper requirement) -# - writes the result to Syntaxes/libtree-sitter-zig.dylib -# -# Usage: -# ./Scripts/build-parser.sh -# -# Environment overrides: -# NOVA_APP Path to Nova.app (default: /Applications/Nova.app) -# SDKROOT macOS SDK path (default: `xcrun --show-sdk-path`) -# -# Requirements: -# macOS, Xcode Command Line Tools (clang + xcrun), Nova installed. - -set -eu - -ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" -VENDOR_DIR="$ROOT/vendor/tree-sitter-zig" -BUILD_DIR="$ROOT/build" -OUTPUT="$ROOT/Syntaxes/libtree-sitter-zig.dylib" -NOVA_APP="${NOVA_APP:-/Applications/Nova.app}" -SDKROOT="${SDKROOT:-$(xcrun --show-sdk-path)}" - -mkdir -p "$BUILD_DIR" - -clang \ - -dynamiclib \ - -O2 \ - -fPIC \ - -arch arm64 \ - -arch x86_64 \ - -isysroot "$SDKROOT" \ - -I"$VENDOR_DIR/src" \ - -F"$NOVA_APP/Contents/Frameworks" \ - -framework SyntaxKit \ - -Wl,-rpath,@loader_path/../Frameworks \ - -o "$BUILD_DIR/libtree-sitter-zig.dylib" \ - "$VENDOR_DIR/src/parser.c" - -codesign --force --sign - "$BUILD_DIR/libtree-sitter-zig.dylib" -cp "$BUILD_DIR/libtree-sitter-zig.dylib" "$OUTPUT" diff --git a/Scripts/lldb-dap-proxy.pl b/Scripts/lldb-dap-proxy.pl deleted file mode 100644 index beb6eaa..0000000 --- a/Scripts/lldb-dap-proxy.pl +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/perl -use strict; -use warnings; -use Fcntl qw(O_WRONLY O_APPEND O_CREAT O_NOFOLLOW); -use IPC::Open3; -use IO::Select; -use JSON::PP; -use Symbol 'gensym'; - -my $adapter_path = $ARGV[0] // $ENV{NOVA_ZIG_LLDB_DAP_PATH}; -my $log_path = $ARGV[1] // $ENV{NOVA_ZIG_DEBUG_LOG}; -my @adapter_args = @ARGV > 2 ? @ARGV[2 .. $#ARGV] : (); - -sub log_msg { - return unless defined $log_path; - my ($msg) = @_; - sysopen(my $fh, $log_path, O_WRONLY | O_APPEND | O_CREAT | O_NOFOLLOW, 0600) or return; - my @t = gmtime(time); - printf $fh "[%04d-%02d-%02dT%02d:%02d:%02dZ] %s\n", - $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0], $msg; - close($fh); -} - -unless (defined $adapter_path) { - log_msg('missing adapter path'); - print STDERR "lldb-dap proxy requires a target adapter path.\n"; - exit(1); -} - -log_msg("proxy start adapter=$adapter_path"); - -# open3 treats an undef handle as "inherit parent STDIN/STDOUT", so pre-allocate -# anonymous typeglobs to force pipe creation for all three streams. -my ($child_in, $child_out, $child_err) = (gensym(), gensym(), gensym()); -my $pid = eval { open3($child_in, $child_out, $child_err, $adapter_path, @adapter_args) }; -if ($@) { - (my $err = $@) =~ s/\s+$//; - log_msg("adapter error $err"); - print STDERR "$err\n"; - exit(1); -} - -binmode($_, ':raw') for \*STDIN, \*STDOUT, $child_in, $child_out, $child_err; - -$SIG{INT} = sub { log_msg('received SIGINT'); kill 'INT', $pid }; -$SIG{TERM} = sub { log_msg('received SIGTERM'); kill 'TERM', $pid }; - -my $json = JSON::PP->new->utf8; - -my ($next_client_seq, $next_adapter_seq) = (1, 1); -my (%client_request_seq_map, %adapter_request_seq_map, %request_args_by_client_seq); -my ($stdin_buf, $child_buf) = ('', ''); - -my $stdin_fn = fileno(\*STDIN); -my $child_out_fn = fileno($child_out); - -my $sel = IO::Select->new(\*STDIN, $child_out, $child_err); - -LOOP: while ($sel->count > 0) { - my @ready = $sel->can_read; - for my $fh (@ready) { - my ($chunk, $n); - $n = sysread($fh, $chunk, 65536); - unless (defined $n && $n > 0) { - $sel->remove($fh); - my $fn = fileno($fh); - if ($fn == $stdin_fn) { - log_msg('stdin closed'); - close($child_in); - } elsif ($fn == $child_out_fn) { - log_msg('adapter stdout closed'); - last LOOP; - } else { - log_msg('adapter stderr closed'); - } - next; - } - my $fn = fileno($fh); - if ($fn == $stdin_fn) { - $stdin_buf .= $chunk; - flush_dap(\$stdin_buf, $child_in, \&rewrite_client_message); - } elsif ($fn == $child_out_fn) { - $child_buf .= $chunk; - flush_dap(\$child_buf, \*STDOUT, \&rewrite_adapter_message); - } else { - log_msg('stderr ' . $chunk); - print STDERR $chunk; - } - } -} - -waitpid($pid, 0); -log_msg(sprintf 'adapter exited status=%d signal=%d', $? >> 8, $? & 127); -# $? encodes exit code in the high byte and terminating signal in the low 7 bits. -# Re-raise the signal so the parent sees a signal-killed exit rather than exit(0). -my $signal = $? & 127; -if ($signal) { - $SIG{$_} = 'DEFAULT' for qw(INT TERM); - kill $signal, $$; - sleep 1; -} -log_msg(sprintf 'proxy exit code=%d', $? >> 8); -exit($? >> 8); - -sub flush_dap { - my ($buf_ref, $dest, $rewrite_fn) = @_; - while (1) { - my $header_end = index($$buf_ref, "\r\n\r\n"); - last if $header_end == -1; - - my $header = substr($$buf_ref, 0, $header_end); - unless ($header =~ /Content-Length:\s*(\d+)/i) { - syswrite($dest, $$buf_ref); - $$buf_ref = ''; - return; - } - - my $body_len = 0 + $1; - my $frame_len = $header_end + 4 + $body_len; - last if length($$buf_ref) < $frame_len; - - my $body = substr($$buf_ref, $header_end + 4, $body_len); - $$buf_ref = substr($$buf_ref, $frame_len); - - my $msg = eval { $json->decode($body) }; - if (!$@ && ref($msg) eq 'HASH') { - $rewrite_fn->($msg); - my $out = $json->encode($msg); - syswrite($dest, 'Content-Length: ' . length($out) . "\r\n\r\n"); - syswrite($dest, $out); - } else { - log_msg(sprintf 'non-json message forwarded%s', $@ ? " error=$@" : ' reason=not-a-hash'); - syswrite($dest, 'Content-Length: ' . $body_len . "\r\n\r\n"); - syswrite($dest, $body); - } - } -} - -sub rewrite_client_message { - my ($msg) = @_; - my $type = $msg->{type} // ''; - my $command = $msg->{command} // ''; - log_msg(sprintf 'client message type=%s command=%s seq_in=%s request_seq_in=%s', - $type, $command, $msg->{seq} // '', $msg->{request_seq} // ''); - - # Nova omits the filters array on setExceptionBreakpoints requests, which - # lldb-dap then rejects. Ensure the field is always present before forwarding. - if ($type eq 'request' && $command eq 'setExceptionBreakpoints') { - $msg->{arguments} = {} unless ref($msg->{arguments}) eq 'HASH'; - $msg->{arguments}{filters} = [] unless ref($msg->{arguments}{filters}) eq 'ARRAY'; - } - - if ($type eq 'response' && defined $msg->{request_seq}) { - my $rseq = $msg->{request_seq}; - if (exists $adapter_request_seq_map{$rseq}) { - $msg->{request_seq} = $adapter_request_seq_map{$rseq}; - delete $adapter_request_seq_map{$rseq}; - } - } - - if (defined $msg->{seq}) { - my $orig = $msg->{seq}; - my $rewrite = $next_client_seq++; - if ($type eq 'request') { - $client_request_seq_map{$rewrite} = $orig; - $request_args_by_client_seq{$rewrite} = { - command => $command, - arguments => $msg->{arguments}, - }; - log_msg(sprintf 'client seq map %s=>%s', $orig, $rewrite); - } - $msg->{seq} = $rewrite; - } - - log_msg(sprintf 'client message seq_out=%s request_seq_out=%s', - $msg->{seq} // '', $msg->{request_seq} // ''); -} - -sub rewrite_adapter_message { - my ($msg) = @_; - my $type = $msg->{type} // ''; - my $command = $msg->{command} // ''; - log_msg(sprintf 'adapter message type=%s command=%s seq_in=%s request_seq_in=%s', - $type, $command, $msg->{seq} // '', $msg->{request_seq} // ''); - - if ($type eq 'response' && defined $msg->{request_seq}) { - my $rseq = $msg->{request_seq}; - my $orig_req = $request_args_by_client_seq{$rseq}; - my $orig_seq = $client_request_seq_map{$rseq}; - - if (defined $orig_seq) { - $msg->{request_seq} = $orig_seq; - delete $client_request_seq_map{$rseq}; - } - - # lldb-dap returns a successful setExceptionBreakpoints response without - # the breakpoints array the DAP spec requires. Synthesise one verified - # entry per filter so Nova's client confirms each breakpoint as registered. - if (defined $orig_req - && ($orig_req->{command} // '') eq 'setExceptionBreakpoints' - && $msg->{success}) - { - my $filters = ref($orig_req->{arguments}{filters}) eq 'ARRAY' - ? $orig_req->{arguments}{filters} : []; - $msg->{body} = {} unless ref($msg->{body}) eq 'HASH'; - unless (ref($msg->{body}{breakpoints}) eq 'ARRAY') { - $msg->{body}{breakpoints} = [ map { +{ verified => JSON::PP::true } } @$filters ]; - } - } - - delete $request_args_by_client_seq{$rseq}; - } - - if (defined $msg->{seq}) { - my $orig = $msg->{seq}; - my $rewrite = $next_adapter_seq++; - $adapter_request_seq_map{$rewrite} = $orig if $type eq 'request'; - $msg->{seq} = $rewrite; - log_msg("adapter seq map $orig=>$rewrite"); - } - - log_msg(sprintf 'adapter message seq_out=%s request_seq_out=%s', - $msg->{seq} // '', $msg->{request_seq} // ''); -} diff --git a/Scripts/main.js b/Scripts/main.js deleted file mode 100644 index 7477993..0000000 --- a/Scripts/main.js +++ /dev/null @@ -1,1583 +0,0 @@ -"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; - } -} diff --git a/Scripts/normalize-zig-issues.pl b/Scripts/normalize-zig-issues.pl deleted file mode 100644 index b6ff328..0000000 --- a/Scripts/normalize-zig-issues.pl +++ /dev/null @@ -1,93 +0,0 @@ -use strict; -use warnings; -$| = 1; - -my $base = $ENV{NOVA_ZIG_TASK_CWD} || $ENV{PWD} || ""; -my %file_cache = (); - -sub normalize_path { - my ($path) = @_; - return $path if $path =~ m{^/} || $path =~ m{^[A-Za-z]:[\/\\]}; - - # The Zig compiler occasionally emits paths without a leading slash (e.g. - # "Users/..." instead of "/Users/...") when traversing relative directories. - # Detect known top-level directory names and restore the leading slash. - if ($path =~ m{^(?:Users|private|opt|Library|System|usr|var|tmp|etc|home)/}) { - return "/$path"; - } - - return $base eq "" ? $path : "$base/$path"; -} - -sub source_line_for_path { - my ($path, $line_number) = @_; - return undef if $line_number < 1; - - if (!exists $file_cache{$path}) { - if (open my $fh, "<", $path) { - my @lines = <$fh>; - close $fh; - $file_cache{$path} = \@lines; - } else { - $file_cache{$path} = undef; - } - } - - my $lines = $file_cache{$path}; - return undef if !defined $lines; - return undef if $line_number > scalar(@$lines); - - my $line = $lines->[$line_number - 1]; - $line =~ s/\r?\n$//; - return $line; -} - -# Zig reports argument-count errors at the column of the opening parenthesis, -# which causes Nova to underline the entire argument list. For method-call -# expressions (receiver.method(...)), shift the column back to the start of -# the callee identifier so only the call site itself is highlighted. -sub adjusted_call_column { - my ($source_line, $column) = @_; - return $column if !defined $source_line || $column < 1; - - my $column_index = $column - 1; - my $open_paren = index($source_line, "(", $column_index); - return $column if $open_paren < 0; - - my $prefix = substr($source_line, 0, $open_paren); - return $column if $prefix !~ /([A-Za-z_][A-Za-z0-9_]*)\s*$/; - - my $callee = $1; - my $callee_index = rindex($prefix, $callee); - return $column if $callee_index < 0; - - my $target_column = $callee_index + 1; - return $column if $target_column <= $column; - - my $between = substr($source_line, $column_index, $target_column - $column); - return $column if $between !~ /\./; - - return $target_column; -} - -while (my $line = <STDIN>) { - my $newline = $line =~ s/\r?\n$// ? "\n" : ""; - - if ($line =~ m{^([^:\n]+\.zig):(\d+):(\d+):\s*(error|warning|note):\s*(.*)$}) { - my ($path, $line_number, $column, $severity, $message) = ($1, $2, $3, $4, $5); - my $normalized = normalize_path($path); - - if ($message =~ /\bexpected\b.*argument\(s\).*\bfound\b/i) { - my $source_line = source_line_for_path($normalized, $line_number); - $column = adjusted_call_column($source_line, $column); - } - - $line = "$normalized:$line_number:$column: $severity: $message"; - } elsif ($line =~ m{^([^:\n]+\.zig):(\d+):(\d+):}) { - my ($path, $line_number, $column) = ($1, $2, $3); - my $normalized = normalize_path($path); - $line =~ s{^[^:\n]+\.zig:\d+:\d+:}{$normalized:$line_number:$column:}; - } - - print $line, $newline; -} diff --git a/Scripts/update-parser.sh b/Scripts/update-parser.sh deleted file mode 100755 index 6e1fd63..0000000 --- a/Scripts/update-parser.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/bin/sh -# -# update-parser.sh — Bump vendored tree-sitter-zig and rebuild the dylib. -# -# Purpose: -# Refresh the vendored snapshot under vendor/tree-sitter-zig/ to a newer -# upstream commit, update VENDORING.md, and rebuild the parser dylib. -# -# What it does: -# - clones tree-sitter-grammars/tree-sitter-zig into a temp dir -# - checks out the requested ref (default: HEAD of the default branch) -# - exits early if the upstream SHA matches the pinned SHA -# - replaces src/, queries/, grammar.js, tree-sitter.json, LICENSE -# with the upstream copies; leaves VENDORING.md and README.upstream.md -# untouched -# - rewrites the "Pinned commit:" line in VENDORING.md -# - invokes build-parser.sh to rebuild Syntaxes/libtree-sitter-zig.dylib -# - prints a GitHub compare link for the diff -# -# Usage: -# ./Scripts/update-parser.sh # bump to upstream HEAD -# ./Scripts/update-parser.sh <ref> # bump to a tag, branch, or SHA -# -# Caveats: -# - any local edits inside the listed paths are overwritten — review -# `git diff vendor/` afterwards before committing -# - parses VENDORING.md by an exact "Pinned commit: <sha>" line prefix; -# keep that line format intact -# -# Requirements: -# git, plus everything build-parser.sh needs. - -set -eu - -ROOT="$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd)" -VENDOR_DIR="$ROOT/vendor/tree-sitter-zig" -UPSTREAM="https://github.com/tree-sitter-grammars/tree-sitter-zig.git" -REF="${1:-HEAD}" - -TMP="$(mktemp -d)" -trap 'rm -rf "$TMP"' EXIT - -git clone --quiet "$UPSTREAM" "$TMP/repo" -git -C "$TMP/repo" checkout --quiet "$REF" -SHA="$(git -C "$TMP/repo" rev-parse HEAD)" - -OLD_SHA="$(awk '/^Pinned commit:/ {print $3}' "$VENDOR_DIR/VENDORING.md")" -if [ "$SHA" = "$OLD_SHA" ]; then - echo "Already at $SHA — nothing to do." - exit 0 -fi - -for path in src queries grammar.js tree-sitter.json LICENSE; do - rm -rf "$VENDOR_DIR/$path" - cp -R "$TMP/repo/$path" "$VENDOR_DIR/$path" -done - -sed -i.bak "s/^Pinned commit: .*/Pinned commit: $SHA/" "$VENDOR_DIR/VENDORING.md" -rm "$VENDOR_DIR/VENDORING.md.bak" - -"$ROOT/Scripts/build-parser.sh" - -echo "Updated $OLD_SHA -> $SHA" -echo "Compare: https://github.com/tree-sitter-grammars/tree-sitter-zig/compare/$OLD_SHA...$SHA" |
