aboutsummaryrefslogtreecommitdiff
path: root/Scripts
diff options
context:
space:
mode:
Diffstat (limited to 'Scripts')
-rwxr-xr-xScripts/build-parser.sh53
-rw-r--r--Scripts/lldb-dap-proxy.pl213
-rw-r--r--Scripts/main.js915
-rw-r--r--Scripts/normalize-zig-issues.pl93
-rwxr-xr-xScripts/update-parser.sh64
5 files changed, 1338 insertions, 0 deletions
diff --git a/Scripts/build-parser.sh b/Scripts/build-parser.sh
new file mode 100755
index 0000000..3b93a1c
--- /dev/null
+++ b/Scripts/build-parser.sh
@@ -0,0 +1,53 @@
+#!/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
new file mode 100644
index 0000000..5f545b1
--- /dev/null
+++ b/Scripts/lldb-dap-proxy.pl
@@ -0,0 +1,213 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+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) = @_;
+ open(my $fh, '>>', $log_path) 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 { kill 'INT', $pid };
+$SIG{TERM} = sub { 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);
+ close($child_in) if $fn == $stdin_fn;
+ last LOOP if $fn == $child_out_fn; # adapter stdout closing ends the session
+ 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);
+# $? 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;
+}
+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('non-json message forwarded');
+ 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},
+ };
+ }
+ $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
new file mode 100644
index 0000000..76bd816
--- /dev/null
+++ b/Scripts/main.js
@@ -0,0 +1,915 @@
+"use strict";
+
+const EXTENSION_ID = "at.dcz.nova-zig";
+const TASK_ASSISTANT_ID = `${EXTENSION_ID}.tasks`;
+const LANGUAGE_CLIENT_ID = `${EXTENSION_ID}.zls`;
+const ISSUE_MATCHER = "zig.compiler";
+
+const CONFIG_KEYS = {
+ zigPath: `${EXTENSION_ID}.toolchain.zig-path`,
+ zlsPath: `${EXTENSION_ID}.toolchain.zls-path`,
+ lldbDapPath: `${EXTENSION_ID}.toolchain.lldb-dap-path`,
+ zlsEnabled: `${EXTENSION_ID}.zls.enabled`,
+ zlsBuildOnSave: `${EXTENSION_ID}.zls.build-on-save`,
+ zlsDebug: `${EXTENSION_ID}.zls.debug`,
+};
+
+let languageServer = null;
+let taskAssistant = null;
+let issueAssistant = null;
+let commandRegistrations = [];
+
+exports.activate = function activate() {
+ registerCommands();
+ taskAssistant = new ZigTaskAssistant();
+ languageServer = new ZigLanguageServer();
+ issueAssistant = new ZigIssueAssistant();
+};
+
+exports.deactivate = function deactivate() {
+ if (languageServer) {
+ languageServer.dispose();
+ languageServer = null;
+ }
+
+ if (taskAssistant) {
+ taskAssistant.dispose();
+ taskAssistant = null;
+ }
+
+ if (issueAssistant) {
+ issueAssistant.dispose();
+ issueAssistant = null;
+ }
+
+ commandRegistrations.forEach((disposable) => {
+ if (disposable && typeof disposable.dispose === "function") {
+ disposable.dispose();
+ }
+ });
+ commandRegistrations = [];
+};
+
+function getConfigValue(key) {
+ const workspaceValue = nova.workspace.config.get(key);
+ if (workspaceValue !== undefined && workspaceValue !== null && workspaceValue !== "") {
+ return workspaceValue;
+ }
+
+ const globalValue = nova.config.get(key);
+ if (globalValue !== undefined && globalValue !== null && globalValue !== "") {
+ return globalValue;
+ }
+
+ return null;
+}
+
+function getBooleanConfigValue(key, fallback) {
+ const workspaceValue = nova.workspace.config.get(key);
+ if (typeof workspaceValue === "boolean") {
+ return workspaceValue;
+ }
+
+ const globalValue = nova.config.get(key);
+ if (typeof globalValue === "boolean") {
+ return globalValue;
+ }
+
+ return fallback;
+}
+
+function normalizeArray(value) {
+ if (!Array.isArray(value)) {
+ return [];
+ }
+
+ return value
+ .map((entry) => (entry === null || entry === undefined ? "" : String(entry).trim()))
+ .filter((entry) => entry.length > 0);
+}
+
+function resolveWorkspaceRelativePath(path) {
+ if (!path) {
+ return null;
+ }
+
+ if (path.startsWith("/")) {
+ return path;
+ }
+
+ if (nova.workspace.path) {
+ return nova.path.join(nova.workspace.path, path);
+ }
+
+ return path;
+}
+
+function resolvePathAgainstBase(path, base) {
+ if (!path) {
+ return null;
+ }
+
+ if (path.startsWith("/")) {
+ return path;
+ }
+
+ if (base) {
+ return nova.path.join(base, path);
+ }
+
+ return resolveWorkspaceRelativePath(path);
+}
+
+function getTaskConfigValue(config, key) {
+ if (!config) {
+ return null;
+ }
+
+ const value = config.get(key);
+ return value === undefined || value === null || value === "" ? null : value;
+}
+
+function getTaskArgs(config, key) {
+ return normalizeArray(getTaskConfigValue(config, key));
+}
+
+function getTaskCwd(config) {
+ const configured = getTaskConfigValue(config, "cwd");
+ if (configured) {
+ return resolveWorkspaceRelativePath(configured);
+ }
+
+ return nova.workspace.path || null;
+}
+
+function activeZigFilePath() {
+ const editor = nova.workspace.activeTextEditor;
+ if (!editor || !editor.document || !editor.document.path) {
+ return null;
+ }
+
+ if (editor.document.syntax !== "zig") {
+ return null;
+ }
+
+ return editor.document.path;
+}
+
+function activeZigFileDirectory() {
+ const filePath = activeZigFilePath();
+ if (!filePath) {
+ return null;
+ }
+
+ return nova.path.dirname(filePath);
+}
+
+function localizedText(key, fallback, variables) {
+ let text = nova.localize(key, fallback);
+
+ if (!variables || typeof variables !== "object") {
+ return text;
+ }
+
+ for (const [name, value] of Object.entries(variables)) {
+ text = text.split(`{${name}}`).join(String(value));
+ }
+
+ return text;
+}
+
+function runProcess(command, options) {
+ return new Promise((resolve) => {
+ const stdout = [];
+ const stderr = [];
+ const process = new Process(command, options || {});
+
+ process.onStdout((line) => stdout.push(line));
+ process.onStderr((line) => stderr.push(line));
+ process.onDidExit((status) => {
+ resolve({
+ status,
+ stdout: stdout.join(""),
+ stderr: stderr.join(""),
+ });
+ });
+
+ process.start();
+ });
+}
+
+async function findExecutableOnPath(commandName) {
+ const result = await runProcess("/usr/bin/env", {
+ args: ["which", commandName],
+ });
+
+ if (result.status !== 0) {
+ return null;
+ }
+
+ const path = result.stdout.trim();
+ return path.length > 0 ? path : null;
+}
+
+async function resolveExecutable(configKey, defaultCommand) {
+ const configuredPath = getConfigValue(configKey);
+ if (configuredPath) {
+ return configuredPath;
+ }
+
+ return findExecutableOnPath(defaultCommand);
+}
+
+async function findExecutableWithXcode(commandName) {
+ const result = await runProcess("/usr/bin/xcrun", {
+ args: ["--find", commandName],
+ });
+
+ if (result.status !== 0) {
+ return null;
+ }
+
+ const path = result.stdout.trim();
+ return path.length > 0 ? path : null;
+}
+
+async function resolveLLDBDAPExecutable() {
+ const configuredPath = getConfigValue(CONFIG_KEYS.lldbDapPath);
+ if (configuredPath) {
+ return configuredPath;
+ }
+
+ const xcodePath = await findExecutableWithXcode("lldb-dap");
+ if (xcodePath) {
+ return xcodePath;
+ }
+
+ return findExecutableOnPath("lldb-dap");
+}
+
+function executableDisplayName(path, fallback) {
+ return path || fallback;
+}
+
+function lldbFrameworkPaths() {
+ return [
+ "/Applications/Xcode-beta.app/Contents/SharedFrameworks/",
+ "/Applications/Xcode.app/Contents/SharedFrameworks/",
+ "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/",
+ ];
+}
+
+function lldbDapProxyPath() {
+ return nova.path.join(nova.extension.path, "Scripts", "lldb-dap-proxy.pl");
+}
+
+function debugAdapterLogPath() {
+ return "/tmp/zig-lldb-dap-proxy.log";
+}
+
+function issueNormalizerScriptPath() {
+ return nova.path.join(nova.extension.path, "Scripts", "normalize-zig-issues.pl");
+}
+
+function quoteShellArgument(value) {
+ const text = value === null || value === undefined ? "" : String(value);
+ return `'${text.replace(/'/g, `'\\''`)}'`;
+}
+
+function escapeAppleScriptString(value) {
+ return String(value)
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"');
+}
+
+function buildShellCommand(command, args, cwd) {
+ const segments = [];
+ if (cwd) {
+ segments.push(`cd ${quoteShellArgument(cwd)}`);
+ }
+
+ segments.push([quoteShellArgument(command), ...(args || []).map(quoteShellArgument)].join(" "));
+ return segments.join("; ");
+}
+
+function buildIssueNormalizedCommand(command, args) {
+ const commandLine = [
+ quoteShellArgument(command),
+ ...(args || []).map(quoteShellArgument),
+ ].join(" ");
+ const rewriter = `/usr/bin/perl ${quoteShellArgument(issueNormalizerScriptPath())}`;
+ return `setopt pipefail; ${commandLine} 2>&1 | ${rewriter}`;
+}
+
+function launchInTerminal(commandLine) {
+ return new Promise((resolve) => {
+ const script = `tell application "Terminal"
+activate
+do script "${escapeAppleScriptString(commandLine)}"
+end tell`;
+ const process = new Process("/usr/bin/osascript", {
+ args: ["-e", script],
+ });
+
+ let stderr = "";
+ process.onStderr((line) => {
+ stderr += line;
+ });
+ process.onDidExit((status) => {
+ resolve({ status, stderr: stderr.trim() });
+ });
+ process.start();
+ });
+}
+
+function registerCommands() {
+ commandRegistrations.push(
+ nova.commands.register(`${EXTENSION_ID}.runInTerminal`, async (workspace, payload) => {
+ const command = payload && payload.command;
+ const args = (payload && payload.args) || [];
+ const cwd = (payload && payload.cwd) || workspace.path || null;
+
+ if (!command) {
+ workspace.showWarningMessage(
+ localizedText(
+ "warning.terminal.launch_failed",
+ "Unable to launch the Zig task in Terminal."
+ )
+ );
+ return;
+ }
+
+ const result = await launchInTerminal(buildShellCommand(command, args, cwd));
+ if (result.status !== 0) {
+ const prefix = localizedText(
+ "warning.terminal.open_failed",
+ "Unable to open Terminal for the Zig task."
+ );
+ const suffix = result.stderr ? ` ${result.stderr}` : "";
+ workspace.showWarningMessage(`${prefix}${suffix}`);
+ }
+ })
+ );
+}
+
+function syncWorkspaceZlsConfiguration(settings) {
+ const bridge = {
+ "zls.zig_exe_path": settings.zig_exe_path,
+ "zls.enable_build_on_save": settings.enable_build_on_save,
+ };
+
+ Object.entries(bridge).forEach(([key, value]) => {
+ if (value === undefined || value === null || value === "") {
+ nova.workspace.config.remove(key);
+ } else {
+ nova.workspace.config.set(key, value);
+ }
+ });
+}
+
+class ZigLanguageServer {
+ constructor() {
+ this.client = null;
+ this.clientStopDisposable = null;
+ this.restartGeneration = 0;
+ this.warnedMissing = new Set();
+ this.disposables = [];
+
+ this.observeConfig(CONFIG_KEYS.zigPath, true);
+ this.observeConfig(CONFIG_KEYS.zlsPath, true);
+ this.observeConfig(CONFIG_KEYS.zlsEnabled, true);
+ this.observeConfig(CONFIG_KEYS.zlsBuildOnSave, true);
+ this.observeConfig(CONFIG_KEYS.zlsDebug, true);
+ this.disposables.push(
+ nova.workspace.onDidChangePath(() => {
+ this.start();
+ })
+ );
+
+ this.start();
+ }
+
+ observeConfig(key, restart) {
+ this.disposables.push(
+ nova.config.onDidChange(key, () => {
+ if (restart) {
+ this.start();
+ } else {
+ void this.pushConfiguration();
+ }
+ })
+ );
+ this.disposables.push(
+ nova.workspace.config.onDidChange(key, () => {
+ if (restart) {
+ this.start();
+ } else {
+ void this.pushConfiguration();
+ }
+ })
+ );
+ }
+
+ dispose() {
+ this.stop();
+ this.disposables.forEach((disposable) => {
+ if (disposable && typeof disposable.dispose === "function") {
+ disposable.dispose();
+ }
+ });
+ this.disposables = [];
+ }
+
+ async start() {
+ // Guard against stale async continuations: if a config change triggers
+ // another start() while we are awaiting, the generation check below bails out.
+ const generation = ++this.restartGeneration;
+ this.stop();
+
+ if (!getBooleanConfigValue(CONFIG_KEYS.zlsEnabled, true)) {
+ return;
+ }
+
+ const zlsPath = await resolveExecutable(CONFIG_KEYS.zlsPath, "zls");
+ if (generation !== this.restartGeneration) {
+ return;
+ }
+
+ if (!zlsPath) {
+ this.warnMissingTool(
+ "zls",
+ localizedText(
+ "warning.zls.not_found",
+ "ZLS was not found. Install it or set a ZLS executable path in Zig extension settings."
+ )
+ );
+ return;
+ }
+
+ const { settings, zigPath } = await this.resolveSettings();
+ if (generation !== this.restartGeneration) {
+ return;
+ }
+
+ syncWorkspaceZlsConfiguration(settings);
+
+ const debugLogs = getBooleanConfigValue(CONFIG_KEYS.zlsDebug, false);
+
+ const serverOptions = {
+ path: zlsPath,
+ args: debugLogs ? [] : ["--disable-lsp-logs"],
+ };
+
+ const clientOptions = {
+ syntaxes: [{ syntax: "zig", languageId: "zig" }],
+ debug: debugLogs,
+ initializationOptions: {
+ zls: settings,
+ },
+ };
+
+ const client = new LanguageClient(
+ LANGUAGE_CLIENT_ID,
+ localizedText("name.language_server", "Zig Language Server"),
+ serverOptions,
+ clientOptions
+ );
+
+ this.clientStopDisposable = client.onDidStop((error) => {
+ if (error) {
+ console.error(`[${LANGUAGE_CLIENT_ID}] ${error.message}`);
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zls.stopped_unexpectedly",
+ "The Zig language server stopped unexpectedly ({executable}).",
+ { executable: executableDisplayName(zlsPath, "zls") }
+ )
+ );
+ }
+ });
+
+ try {
+ client.start();
+ this.client = client;
+ nova.subscriptions.add(client);
+ void this.pushConfiguration();
+ this.warnedMissing.delete("zls");
+ if (zigPath) {
+ this.warnedMissing.delete("zig");
+ }
+ } catch (error) {
+ console.error(`[${LANGUAGE_CLIENT_ID}] Failed to start ZLS`, error);
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zls.start_failed",
+ "Unable to start the Zig language server at {path}.",
+ { path: zlsPath }
+ )
+ );
+ this.stop();
+ }
+ }
+
+ async resolveSettings() {
+ const settings = {
+ enable_build_on_save: getBooleanConfigValue(CONFIG_KEYS.zlsBuildOnSave, false),
+ };
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (zigPath) {
+ settings.zig_exe_path = zigPath;
+ }
+
+ return { settings, zigPath };
+ }
+
+ async pushConfiguration() {
+ const generation = this.restartGeneration;
+ const { settings } = await this.resolveSettings();
+ if (generation !== this.restartGeneration || !this.client || !this.client.running) {
+ return;
+ }
+
+ syncWorkspaceZlsConfiguration(settings);
+
+ this.client.sendNotification("workspace/didChangeConfiguration", {
+ settings: {
+ zls: settings,
+ },
+ });
+ }
+
+ stop() {
+ if (this.clientStopDisposable && typeof this.clientStopDisposable.dispose === "function") {
+ this.clientStopDisposable.dispose();
+ this.clientStopDisposable = null;
+ }
+
+ if (this.client) {
+ this.client.stop();
+ nova.subscriptions.remove(this.client);
+ this.client = null;
+ }
+ }
+
+ warnMissingTool(tool, message) {
+ if (this.warnedMissing.has(tool)) {
+ return;
+ }
+
+ this.warnedMissing.add(tool);
+ nova.workspace.showWarningMessage(message);
+ }
+}
+
+class ZigTaskAssistant {
+ constructor() {
+ this.disposable = nova.assistants.registerTaskAssistant(this, {
+ identifier: TASK_ASSISTANT_ID,
+ name: localizedText("name.extension", "Zig"),
+ });
+ }
+
+ dispose() {
+ if (this.disposable && typeof this.disposable.dispose === "function") {
+ this.disposable.dispose();
+ this.disposable = null;
+ }
+ }
+
+ provideTasks() {
+ const task = new Task(localizedText("task.current_file.name", "Current Zig File"));
+ task.setAction(Task.Run, new TaskResolvableAction({
+ data: {
+ type: "current-file-run",
+ },
+ }));
+ task.setAction(Task.Clean, new TaskResolvableAction({
+ data: {
+ type: "current-file-clean",
+ },
+ }));
+ return [task];
+ }
+
+ async resolveTaskAction(context) {
+ const type = context.data && context.data.type;
+ const config = context.config;
+ const cwd = getTaskCwd(config);
+
+ if (type === "clean") {
+ return this.resolveCleanAction(cwd);
+ }
+
+ switch (type) {
+ case "build":
+ return this.resolveBuildAction(config, cwd);
+ case "build-debug":
+ return this.resolveDebugBuildAction(config, cwd);
+ case "build-run":
+ return this.resolveBuildRunAction(config, cwd);
+ case "build-run-terminal":
+ return this.resolveBuildRunTerminalAction(config, cwd);
+ case "build-test":
+ return this.resolveBuildTestAction(config, cwd);
+ case "current-file-run":
+ return this.resolveCurrentFileRunAction();
+ case "current-file-clean":
+ return this.resolveCurrentFileCleanAction();
+ case "debug":
+ return this.resolveDebugAction(config, cwd);
+ case "file-test":
+ return this.resolveFileTestAction(config, cwd);
+ default:
+ return null;
+ }
+ }
+
+ createAction(command, args, cwd) {
+ return new TaskProcessAction("/bin/zsh", {
+ args: ["-lc", buildIssueNormalizedCommand(command, args)],
+ cwd,
+ env: {
+ NOVA_ZIG_TASK_CWD: cwd || "",
+ },
+ matchers: [ISSUE_MATCHER],
+ });
+ }
+
+ resolveCleanAction(cwd) {
+ if (!cwd) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.clean.missing_cwd",
+ "Choose a workspace or working directory before cleaning Zig build artifacts."
+ )
+ );
+ return null;
+ }
+
+ return new TaskProcessAction("/bin/rm", {
+ args: ["-rf", ".zig-cache", "zig-cache", "zig-out"],
+ cwd,
+ });
+ }
+
+ async resolveBuildAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ return this.createAction(zigPath, ["build", ...getTaskArgs(config, "buildArgs")], cwd);
+ }
+
+ async resolveDebugBuildAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ return this.createAction(zigPath, ["build", "-Doptimize=Debug", ...getTaskArgs(config, "buildArgs")], cwd);
+ }
+
+ async resolveBuildRunAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const step =
+ getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
+ const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
+ const runArgs = getTaskArgs(config, "runArgs");
+
+ if (runArgs.length > 0) {
+ args.push("--", ...runArgs);
+ }
+
+ return this.createAction(zigPath, args, cwd);
+ }
+
+ async resolveBuildRunTerminalAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const step =
+ getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
+ const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
+ const runArgs = getTaskArgs(config, "runArgs");
+
+ if (runArgs.length > 0) {
+ args.push("--", ...runArgs);
+ }
+
+ return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, {
+ args: [
+ {
+ command: zigPath,
+ args,
+ cwd,
+ },
+ ],
+ });
+ }
+
+ async resolveBuildTestAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const step =
+ getTaskConfigValue(config, "testStep") || getTaskConfigValue(config, "step") || "test";
+ const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
+ const testArgs = getTaskArgs(config, "testArgs");
+
+ if (testArgs.length > 0) {
+ args.push("--", ...testArgs);
+ }
+
+ return this.createAction(zigPath, args, cwd);
+ }
+
+ async resolveCurrentFileRunAction() {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const filePath = activeZigFilePath();
+ const cwd = filePath ? nova.path.dirname(filePath) : null;
+ if (!filePath || !cwd) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.current_file.focus_editor_for_run",
+ "Focus a Zig editor before running Current Zig File."
+ )
+ );
+ return null;
+ }
+
+ return this.createAction(zigPath, ["run", filePath], cwd);
+ }
+
+ resolveCurrentFileCleanAction() {
+ const cwd = activeZigFileDirectory();
+ if (!cwd) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.current_file.focus_editor_for_clean",
+ "Focus a Zig editor before cleaning Current Zig File artifacts."
+ )
+ );
+ return null;
+ }
+
+ return this.resolveCleanAction(cwd);
+ }
+
+ async resolveDebugAction(config, cwd) {
+ const lldbDapPath = await resolveLLDBDAPExecutable();
+ if (!lldbDapPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.lldb_dap.not_found",
+ "lldb-dap was not found. Install Xcode Command Line Tools or set an LLDB DAP executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const configuredProgramPath = getTaskConfigValue(config, "programPath");
+ const programPath = resolvePathAgainstBase(configuredProgramPath, cwd);
+ if (!programPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.debug.choose_program",
+ "Choose a program path before running Zig Debug."
+ )
+ );
+ return null;
+ }
+
+ const consoleMode = getTaskConfigValue(config, "console") || "internalConsole";
+ const stopOnEntry = Boolean(config && config.get("stopOnEntry"));
+
+ const action = new TaskDebugAdapterAction("zig-lldb-dap");
+ action.transport = "stdio";
+ action.command = "/usr/bin/perl";
+ action.args = [lldbDapProxyPath(), lldbDapPath, debugAdapterLogPath()];
+ action.debugRequest = "launch";
+ action.env = {
+ DYLD_FRAMEWORK_PATH: lldbFrameworkPaths().join(":"),
+ NOVA_ZIG_LLDB_DAP_PATH: lldbDapPath,
+ NOVA_ZIG_DEBUG_LOG: debugAdapterLogPath(),
+ };
+ action.debugArgs = {
+ program: programPath,
+ cwd,
+ args: getTaskArgs(config, "runArgs"),
+ stopOnEntry,
+ };
+
+ if (consoleMode !== "internalConsole") {
+ action.debugArgs.console = consoleMode;
+ }
+
+ return action;
+ }
+
+ async resolveFileTestAction(config, cwd) {
+ const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
+ if (!zigPath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.zig.not_found",
+ "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
+ )
+ );
+ return null;
+ }
+
+ const configuredPath = getTaskConfigValue(config, "filePath");
+ const filePath = configuredPath
+ ? resolveWorkspaceRelativePath(configuredPath)
+ : activeZigFilePath();
+
+ if (!filePath) {
+ nova.workspace.showWarningMessage(
+ localizedText(
+ "warning.file_test.choose_file",
+ "Choose a Zig file for this task or focus a Zig editor before running Zig File Test."
+ )
+ );
+ return null;
+ }
+
+ return this.createAction(zigPath, ["test", filePath, ...getTaskArgs(config, "zigArgs")], cwd);
+ }
+}
+
+class ZigIssueAssistant {
+ constructor() {
+ this.disposable = nova.assistants.registerIssueAssistant(
+ [{ syntax: "zig" }, { syntax: "zig-package" }],
+ this,
+ { event: "onChange" }
+ );
+ }
+
+ dispose() {
+ if (this.disposable && typeof this.disposable.dispose === "function") {
+ this.disposable.dispose();
+ this.disposable = null;
+ }
+ }
+
+ provideIssues(editor) {
+ if (!editor || !editor.document) {
+ return [];
+ }
+
+ // Nova's LanguageClient already owns core LSP publishDiagnostics handling.
+ // Registering an issue assistant here tells Nova that Zig supports live
+ // checking, so the Problems UI doesn't show a misleading empty-state banner.
+ return [];
+ }
+}
diff --git a/Scripts/normalize-zig-issues.pl b/Scripts/normalize-zig-issues.pl
new file mode 100644
index 0000000..b6ff328
--- /dev/null
+++ b/Scripts/normalize-zig-issues.pl
@@ -0,0 +1,93 @@
+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
new file mode 100755
index 0000000..f4fab95
--- /dev/null
+++ b/Scripts/update-parser.sh
@@ -0,0 +1,64 @@
+#!/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"