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 @@
1#!/bin/sh
2#
3# build-parser.sh — Compile the Nova-compatible tree-sitter-zig parser.
4#
5# Purpose:
6# Build vendor/tree-sitter-zig/src/parser.c into a universal macOS dylib
7# that Nova loads via its private SyntaxKit framework.
8#
9# What it does:
10# - clang -dynamiclib for arm64 + x86_64
11# - links against Nova’s SyntaxKit framework (from /Applications/Nova.app)
12# - sets rpath @loader_path/../Frameworks so the dylib finds SyntaxKit
13# when Nova loads it from the bundled extension
14# - ad-hoc codesigns the dylib (Gatekeeper requirement)
15# - writes the result to Syntaxes/libtree-sitter-zig.dylib
16#
17# Usage:
18# ./Scripts/build-parser.sh
19#
20# Environment overrides:
21# NOVA_APP Path to Nova.app (default: /Applications/Nova.app)
22# SDKROOT macOS SDK path (default: `xcrun --show-sdk-path`)
23#
24# Requirements:
25# macOS, Xcode Command Line Tools (clang + xcrun), Nova installed.
26
27set -eu
28
29ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
30VENDOR_DIR="$ROOT/vendor/tree-sitter-zig"
31BUILD_DIR="$ROOT/build"
32OUTPUT="$ROOT/Syntaxes/libtree-sitter-zig.dylib"
33NOVA_APP="${NOVA_APP:-/Applications/Nova.app}"
34SDKROOT="${SDKROOT:-$(xcrun --show-sdk-path)}"
35
36mkdir -p "$BUILD_DIR"
37
38clang \
39 -dynamiclib \
40 -O2 \
41 -fPIC \
42 -arch arm64 \
43 -arch x86_64 \
44 -isysroot "$SDKROOT" \
45 -I"$VENDOR_DIR/src" \
46 -F"$NOVA_APP/Contents/Frameworks" \
47 -framework SyntaxKit \
48 -Wl,-rpath,@loader_path/../Frameworks \
49 -o "$BUILD_DIR/libtree-sitter-zig.dylib" \
50 "$VENDOR_DIR/src/parser.c"
51
52codesign --force --sign - "$BUILD_DIR/libtree-sitter-zig.dylib"
53cp "$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 @@
1#!/usr/bin/perl
2use strict;
3use warnings;
4use IPC::Open3;
5use IO::Select;
6use JSON::PP;
7use Symbol 'gensym';
8
9my $adapter_path = $ARGV[0] // $ENV{NOVA_ZIG_LLDB_DAP_PATH};
10my $log_path = $ARGV[1] // $ENV{NOVA_ZIG_DEBUG_LOG};
11my @adapter_args = @ARGV > 2 ? @ARGV[2 .. $#ARGV] : ();
12
13sub log_msg {
14 return unless defined $log_path;
15 my ($msg) = @_;
16 open(my $fh, '>>', $log_path) or return;
17 my @t = gmtime(time);
18 printf $fh "[%04d-%02d-%02dT%02d:%02d:%02dZ] %s\n",
19 $t[5] + 1900, $t[4] + 1, $t[3], $t[2], $t[1], $t[0], $msg;
20 close($fh);
21}
22
23unless (defined $adapter_path) {
24 log_msg('missing adapter path');
25 print STDERR "lldb-dap proxy requires a target adapter path.\n";
26 exit(1);
27}
28
29log_msg("proxy start adapter=$adapter_path");
30
31# open3 treats an undef handle as "inherit parent STDIN/STDOUT", so pre-allocate
32# anonymous typeglobs to force pipe creation for all three streams.
33my ($child_in, $child_out, $child_err) = (gensym(), gensym(), gensym());
34my $pid = eval { open3($child_in, $child_out, $child_err, $adapter_path, @adapter_args) };
35if ($@) {
36 (my $err = $@) =~ s/\s+$//;
37 log_msg("adapter error $err");
38 print STDERR "$err\n";
39 exit(1);
40}
41
42binmode($_, ':raw') for \*STDIN, \*STDOUT, $child_in, $child_out, $child_err;
43
44$SIG{INT} = sub { kill 'INT', $pid };
45$SIG{TERM} = sub { kill 'TERM', $pid };
46
47my $json = JSON::PP->new->utf8;
48
49my ($next_client_seq, $next_adapter_seq) = (1, 1);
50my (%client_request_seq_map, %adapter_request_seq_map, %request_args_by_client_seq);
51my ($stdin_buf, $child_buf) = ('', '');
52
53my $stdin_fn = fileno(\*STDIN);
54my $child_out_fn = fileno($child_out);
55
56my $sel = IO::Select->new(\*STDIN, $child_out, $child_err);
57
58LOOP: while ($sel->count > 0) {
59 my @ready = $sel->can_read;
60 for my $fh (@ready) {
61 my ($chunk, $n);
62 $n = sysread($fh, $chunk, 65536);
63 unless (defined $n && $n > 0) {
64 $sel->remove($fh);
65 my $fn = fileno($fh);
66 close($child_in) if $fn == $stdin_fn;
67 last LOOP if $fn == $child_out_fn; # adapter stdout closing ends the session
68 next;
69 }
70 my $fn = fileno($fh);
71 if ($fn == $stdin_fn) {
72 $stdin_buf .= $chunk;
73 flush_dap(\$stdin_buf, $child_in, \&rewrite_client_message);
74 } elsif ($fn == $child_out_fn) {
75 $child_buf .= $chunk;
76 flush_dap(\$child_buf, \*STDOUT, \&rewrite_adapter_message);
77 } else {
78 log_msg('stderr ' . $chunk);
79 print STDERR $chunk;
80 }
81 }
82}
83
84waitpid($pid, 0);
85# $? encodes exit code in the high byte and terminating signal in the low 7 bits.
86# Re-raise the signal so the parent sees a signal-killed exit rather than exit(0).
87my $signal = $? & 127;
88if ($signal) {
89 $SIG{$_} = 'DEFAULT' for qw(INT TERM);
90 kill $signal, $$;
91 sleep 1;
92}
93exit($? >> 8);
94
95sub flush_dap {
96 my ($buf_ref, $dest, $rewrite_fn) = @_;
97 while (1) {
98 my $header_end = index($$buf_ref, "\r\n\r\n");
99 last if $header_end == -1;
100
101 my $header = substr($$buf_ref, 0, $header_end);
102 unless ($header =~ /Content-Length:\s*(\d+)/i) {
103 syswrite($dest, $$buf_ref);
104 $$buf_ref = '';
105 return;
106 }
107
108 my $body_len = 0 + $1;
109 my $frame_len = $header_end + 4 + $body_len;
110 last if length($$buf_ref) < $frame_len;
111
112 my $body = substr($$buf_ref, $header_end + 4, $body_len);
113 $$buf_ref = substr($$buf_ref, $frame_len);
114
115 my $msg = eval { $json->decode($body) };
116 if (!$@ && ref($msg) eq 'HASH') {
117 $rewrite_fn->($msg);
118 my $out = $json->encode($msg);
119 syswrite($dest, 'Content-Length: ' . length($out) . "\r\n\r\n");
120 syswrite($dest, $out);
121 } else {
122 log_msg('non-json message forwarded');
123 syswrite($dest, 'Content-Length: ' . $body_len . "\r\n\r\n");
124 syswrite($dest, $body);
125 }
126 }
127}
128
129sub rewrite_client_message {
130 my ($msg) = @_;
131 my $type = $msg->{type} // '';
132 my $command = $msg->{command} // '';
133 log_msg(sprintf 'client message type=%s command=%s seq_in=%s request_seq_in=%s',
134 $type, $command, $msg->{seq} // '', $msg->{request_seq} // '');
135
136 # Nova omits the filters array on setExceptionBreakpoints requests, which
137 # lldb-dap then rejects. Ensure the field is always present before forwarding.
138 if ($type eq 'request' && $command eq 'setExceptionBreakpoints') {
139 $msg->{arguments} = {} unless ref($msg->{arguments}) eq 'HASH';
140 $msg->{arguments}{filters} = [] unless ref($msg->{arguments}{filters}) eq 'ARRAY';
141 }
142
143 if ($type eq 'response' && defined $msg->{request_seq}) {
144 my $rseq = $msg->{request_seq};
145 if (exists $adapter_request_seq_map{$rseq}) {
146 $msg->{request_seq} = $adapter_request_seq_map{$rseq};
147 delete $adapter_request_seq_map{$rseq};
148 }
149 }
150
151 if (defined $msg->{seq}) {
152 my $orig = $msg->{seq};
153 my $rewrite = $next_client_seq++;
154 if ($type eq 'request') {
155 $client_request_seq_map{$rewrite} = $orig;
156 $request_args_by_client_seq{$rewrite} = {
157 command => $command,
158 arguments => $msg->{arguments},
159 };
160 }
161 $msg->{seq} = $rewrite;
162 }
163
164 log_msg(sprintf 'client message seq_out=%s request_seq_out=%s',
165 $msg->{seq} // '', $msg->{request_seq} // '');
166}
167
168sub rewrite_adapter_message {
169 my ($msg) = @_;
170 my $type = $msg->{type} // '';
171 my $command = $msg->{command} // '';
172 log_msg(sprintf 'adapter message type=%s command=%s seq_in=%s request_seq_in=%s',
173 $type, $command, $msg->{seq} // '', $msg->{request_seq} // '');
174
175 if ($type eq 'response' && defined $msg->{request_seq}) {
176 my $rseq = $msg->{request_seq};
177 my $orig_req = $request_args_by_client_seq{$rseq};
178 my $orig_seq = $client_request_seq_map{$rseq};
179
180 if (defined $orig_seq) {
181 $msg->{request_seq} = $orig_seq;
182 delete $client_request_seq_map{$rseq};
183 }
184
185 # lldb-dap returns a successful setExceptionBreakpoints response without
186 # the breakpoints array the DAP spec requires. Synthesise one verified
187 # entry per filter so Nova's client confirms each breakpoint as registered.
188 if (defined $orig_req
189 && ($orig_req->{command} // '') eq 'setExceptionBreakpoints'
190 && $msg->{success})
191 {
192 my $filters = ref($orig_req->{arguments}{filters}) eq 'ARRAY'
193 ? $orig_req->{arguments}{filters} : [];
194 $msg->{body} = {} unless ref($msg->{body}) eq 'HASH';
195 unless (ref($msg->{body}{breakpoints}) eq 'ARRAY') {
196 $msg->{body}{breakpoints} = [ map { +{ verified => JSON::PP::true } } @$filters ];
197 }
198 }
199
200 delete $request_args_by_client_seq{$rseq};
201 }
202
203 if (defined $msg->{seq}) {
204 my $orig = $msg->{seq};
205 my $rewrite = $next_adapter_seq++;
206 $adapter_request_seq_map{$rewrite} = $orig if $type eq 'request';
207 $msg->{seq} = $rewrite;
208 log_msg("adapter seq map $orig=>$rewrite");
209 }
210
211 log_msg(sprintf 'adapter message seq_out=%s request_seq_out=%s',
212 $msg->{seq} // '', $msg->{request_seq} // '');
213}
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 @@
1"use strict";
2
3const EXTENSION_ID = "at.dcz.nova-zig";
4const TASK_ASSISTANT_ID = `${EXTENSION_ID}.tasks`;
5const LANGUAGE_CLIENT_ID = `${EXTENSION_ID}.zls`;
6const ISSUE_MATCHER = "zig.compiler";
7
8const CONFIG_KEYS = {
9 zigPath: `${EXTENSION_ID}.toolchain.zig-path`,
10 zlsPath: `${EXTENSION_ID}.toolchain.zls-path`,
11 lldbDapPath: `${EXTENSION_ID}.toolchain.lldb-dap-path`,
12 zlsEnabled: `${EXTENSION_ID}.zls.enabled`,
13 zlsBuildOnSave: `${EXTENSION_ID}.zls.build-on-save`,
14 zlsDebug: `${EXTENSION_ID}.zls.debug`,
15};
16
17let languageServer = null;
18let taskAssistant = null;
19let issueAssistant = null;
20let commandRegistrations = [];
21
22exports.activate = function activate() {
23 registerCommands();
24 taskAssistant = new ZigTaskAssistant();
25 languageServer = new ZigLanguageServer();
26 issueAssistant = new ZigIssueAssistant();
27};
28
29exports.deactivate = function deactivate() {
30 if (languageServer) {
31 languageServer.dispose();
32 languageServer = null;
33 }
34
35 if (taskAssistant) {
36 taskAssistant.dispose();
37 taskAssistant = null;
38 }
39
40 if (issueAssistant) {
41 issueAssistant.dispose();
42 issueAssistant = null;
43 }
44
45 commandRegistrations.forEach((disposable) => {
46 if (disposable && typeof disposable.dispose === "function") {
47 disposable.dispose();
48 }
49 });
50 commandRegistrations = [];
51};
52
53function getConfigValue(key) {
54 const workspaceValue = nova.workspace.config.get(key);
55 if (workspaceValue !== undefined && workspaceValue !== null && workspaceValue !== "") {
56 return workspaceValue;
57 }
58
59 const globalValue = nova.config.get(key);
60 if (globalValue !== undefined && globalValue !== null && globalValue !== "") {
61 return globalValue;
62 }
63
64 return null;
65}
66
67function getBooleanConfigValue(key, fallback) {
68 const workspaceValue = nova.workspace.config.get(key);
69 if (typeof workspaceValue === "boolean") {
70 return workspaceValue;
71 }
72
73 const globalValue = nova.config.get(key);
74 if (typeof globalValue === "boolean") {
75 return globalValue;
76 }
77
78 return fallback;
79}
80
81function normalizeArray(value) {
82 if (!Array.isArray(value)) {
83 return [];
84 }
85
86 return value
87 .map((entry) => (entry === null || entry === undefined ? "" : String(entry).trim()))
88 .filter((entry) => entry.length > 0);
89}
90
91function resolveWorkspaceRelativePath(path) {
92 if (!path) {
93 return null;
94 }
95
96 if (path.startsWith("/")) {
97 return path;
98 }
99
100 if (nova.workspace.path) {
101 return nova.path.join(nova.workspace.path, path);
102 }
103
104 return path;
105}
106
107function resolvePathAgainstBase(path, base) {
108 if (!path) {
109 return null;
110 }
111
112 if (path.startsWith("/")) {
113 return path;
114 }
115
116 if (base) {
117 return nova.path.join(base, path);
118 }
119
120 return resolveWorkspaceRelativePath(path);
121}
122
123function getTaskConfigValue(config, key) {
124 if (!config) {
125 return null;
126 }
127
128 const value = config.get(key);
129 return value === undefined || value === null || value === "" ? null : value;
130}
131
132function getTaskArgs(config, key) {
133 return normalizeArray(getTaskConfigValue(config, key));
134}
135
136function getTaskCwd(config) {
137 const configured = getTaskConfigValue(config, "cwd");
138 if (configured) {
139 return resolveWorkspaceRelativePath(configured);
140 }
141
142 return nova.workspace.path || null;
143}
144
145function activeZigFilePath() {
146 const editor = nova.workspace.activeTextEditor;
147 if (!editor || !editor.document || !editor.document.path) {
148 return null;
149 }
150
151 if (editor.document.syntax !== "zig") {
152 return null;
153 }
154
155 return editor.document.path;
156}
157
158function activeZigFileDirectory() {
159 const filePath = activeZigFilePath();
160 if (!filePath) {
161 return null;
162 }
163
164 return nova.path.dirname(filePath);
165}
166
167function localizedText(key, fallback, variables) {
168 let text = nova.localize(key, fallback);
169
170 if (!variables || typeof variables !== "object") {
171 return text;
172 }
173
174 for (const [name, value] of Object.entries(variables)) {
175 text = text.split(`{${name}}`).join(String(value));
176 }
177
178 return text;
179}
180
181function runProcess(command, options) {
182 return new Promise((resolve) => {
183 const stdout = [];
184 const stderr = [];
185 const process = new Process(command, options || {});
186
187 process.onStdout((line) => stdout.push(line));
188 process.onStderr((line) => stderr.push(line));
189 process.onDidExit((status) => {
190 resolve({
191 status,
192 stdout: stdout.join(""),
193 stderr: stderr.join(""),
194 });
195 });
196
197 process.start();
198 });
199}
200
201async function findExecutableOnPath(commandName) {
202 const result = await runProcess("/usr/bin/env", {
203 args: ["which", commandName],
204 });
205
206 if (result.status !== 0) {
207 return null;
208 }
209
210 const path = result.stdout.trim();
211 return path.length > 0 ? path : null;
212}
213
214async function resolveExecutable(configKey, defaultCommand) {
215 const configuredPath = getConfigValue(configKey);
216 if (configuredPath) {
217 return configuredPath;
218 }
219
220 return findExecutableOnPath(defaultCommand);
221}
222
223async function findExecutableWithXcode(commandName) {
224 const result = await runProcess("/usr/bin/xcrun", {
225 args: ["--find", commandName],
226 });
227
228 if (result.status !== 0) {
229 return null;
230 }
231
232 const path = result.stdout.trim();
233 return path.length > 0 ? path : null;
234}
235
236async function resolveLLDBDAPExecutable() {
237 const configuredPath = getConfigValue(CONFIG_KEYS.lldbDapPath);
238 if (configuredPath) {
239 return configuredPath;
240 }
241
242 const xcodePath = await findExecutableWithXcode("lldb-dap");
243 if (xcodePath) {
244 return xcodePath;
245 }
246
247 return findExecutableOnPath("lldb-dap");
248}
249
250function executableDisplayName(path, fallback) {
251 return path || fallback;
252}
253
254function lldbFrameworkPaths() {
255 return [
256 "/Applications/Xcode-beta.app/Contents/SharedFrameworks/",
257 "/Applications/Xcode.app/Contents/SharedFrameworks/",
258 "/Library/Developer/CommandLineTools/Library/PrivateFrameworks/",
259 ];
260}
261
262function lldbDapProxyPath() {
263 return nova.path.join(nova.extension.path, "Scripts", "lldb-dap-proxy.pl");
264}
265
266function debugAdapterLogPath() {
267 return "/tmp/zig-lldb-dap-proxy.log";
268}
269
270function issueNormalizerScriptPath() {
271 return nova.path.join(nova.extension.path, "Scripts", "normalize-zig-issues.pl");
272}
273
274function quoteShellArgument(value) {
275 const text = value === null || value === undefined ? "" : String(value);
276 return `'${text.replace(/'/g, `'\\''`)}'`;
277}
278
279function escapeAppleScriptString(value) {
280 return String(value)
281 .replace(/\\/g, "\\\\")
282 .replace(/"/g, '\\"');
283}
284
285function buildShellCommand(command, args, cwd) {
286 const segments = [];
287 if (cwd) {
288 segments.push(`cd ${quoteShellArgument(cwd)}`);
289 }
290
291 segments.push([quoteShellArgument(command), ...(args || []).map(quoteShellArgument)].join(" "));
292 return segments.join("; ");
293}
294
295function buildIssueNormalizedCommand(command, args) {
296 const commandLine = [
297 quoteShellArgument(command),
298 ...(args || []).map(quoteShellArgument),
299 ].join(" ");
300 const rewriter = `/usr/bin/perl ${quoteShellArgument(issueNormalizerScriptPath())}`;
301 return `setopt pipefail; ${commandLine} 2>&1 | ${rewriter}`;
302}
303
304function launchInTerminal(commandLine) {
305 return new Promise((resolve) => {
306 const script = `tell application "Terminal"
307activate
308do script "${escapeAppleScriptString(commandLine)}"
309end tell`;
310 const process = new Process("/usr/bin/osascript", {
311 args: ["-e", script],
312 });
313
314 let stderr = "";
315 process.onStderr((line) => {
316 stderr += line;
317 });
318 process.onDidExit((status) => {
319 resolve({ status, stderr: stderr.trim() });
320 });
321 process.start();
322 });
323}
324
325function registerCommands() {
326 commandRegistrations.push(
327 nova.commands.register(`${EXTENSION_ID}.runInTerminal`, async (workspace, payload) => {
328 const command = payload && payload.command;
329 const args = (payload && payload.args) || [];
330 const cwd = (payload && payload.cwd) || workspace.path || null;
331
332 if (!command) {
333 workspace.showWarningMessage(
334 localizedText(
335 "warning.terminal.launch_failed",
336 "Unable to launch the Zig task in Terminal."
337 )
338 );
339 return;
340 }
341
342 const result = await launchInTerminal(buildShellCommand(command, args, cwd));
343 if (result.status !== 0) {
344 const prefix = localizedText(
345 "warning.terminal.open_failed",
346 "Unable to open Terminal for the Zig task."
347 );
348 const suffix = result.stderr ? ` ${result.stderr}` : "";
349 workspace.showWarningMessage(`${prefix}${suffix}`);
350 }
351 })
352 );
353}
354
355function syncWorkspaceZlsConfiguration(settings) {
356 const bridge = {
357 "zls.zig_exe_path": settings.zig_exe_path,
358 "zls.enable_build_on_save": settings.enable_build_on_save,
359 };
360
361 Object.entries(bridge).forEach(([key, value]) => {
362 if (value === undefined || value === null || value === "") {
363 nova.workspace.config.remove(key);
364 } else {
365 nova.workspace.config.set(key, value);
366 }
367 });
368}
369
370class ZigLanguageServer {
371 constructor() {
372 this.client = null;
373 this.clientStopDisposable = null;
374 this.restartGeneration = 0;
375 this.warnedMissing = new Set();
376 this.disposables = [];
377
378 this.observeConfig(CONFIG_KEYS.zigPath, true);
379 this.observeConfig(CONFIG_KEYS.zlsPath, true);
380 this.observeConfig(CONFIG_KEYS.zlsEnabled, true);
381 this.observeConfig(CONFIG_KEYS.zlsBuildOnSave, true);
382 this.observeConfig(CONFIG_KEYS.zlsDebug, true);
383 this.disposables.push(
384 nova.workspace.onDidChangePath(() => {
385 this.start();
386 })
387 );
388
389 this.start();
390 }
391
392 observeConfig(key, restart) {
393 this.disposables.push(
394 nova.config.onDidChange(key, () => {
395 if (restart) {
396 this.start();
397 } else {
398 void this.pushConfiguration();
399 }
400 })
401 );
402 this.disposables.push(
403 nova.workspace.config.onDidChange(key, () => {
404 if (restart) {
405 this.start();
406 } else {
407 void this.pushConfiguration();
408 }
409 })
410 );
411 }
412
413 dispose() {
414 this.stop();
415 this.disposables.forEach((disposable) => {
416 if (disposable && typeof disposable.dispose === "function") {
417 disposable.dispose();
418 }
419 });
420 this.disposables = [];
421 }
422
423 async start() {
424 // Guard against stale async continuations: if a config change triggers
425 // another start() while we are awaiting, the generation check below bails out.
426 const generation = ++this.restartGeneration;
427 this.stop();
428
429 if (!getBooleanConfigValue(CONFIG_KEYS.zlsEnabled, true)) {
430 return;
431 }
432
433 const zlsPath = await resolveExecutable(CONFIG_KEYS.zlsPath, "zls");
434 if (generation !== this.restartGeneration) {
435 return;
436 }
437
438 if (!zlsPath) {
439 this.warnMissingTool(
440 "zls",
441 localizedText(
442 "warning.zls.not_found",
443 "ZLS was not found. Install it or set a ZLS executable path in Zig extension settings."
444 )
445 );
446 return;
447 }
448
449 const { settings, zigPath } = await this.resolveSettings();
450 if (generation !== this.restartGeneration) {
451 return;
452 }
453
454 syncWorkspaceZlsConfiguration(settings);
455
456 const debugLogs = getBooleanConfigValue(CONFIG_KEYS.zlsDebug, false);
457
458 const serverOptions = {
459 path: zlsPath,
460 args: debugLogs ? [] : ["--disable-lsp-logs"],
461 };
462
463 const clientOptions = {
464 syntaxes: [{ syntax: "zig", languageId: "zig" }],
465 debug: debugLogs,
466 initializationOptions: {
467 zls: settings,
468 },
469 };
470
471 const client = new LanguageClient(
472 LANGUAGE_CLIENT_ID,
473 localizedText("name.language_server", "Zig Language Server"),
474 serverOptions,
475 clientOptions
476 );
477
478 this.clientStopDisposable = client.onDidStop((error) => {
479 if (error) {
480 console.error(`[${LANGUAGE_CLIENT_ID}] ${error.message}`);
481 nova.workspace.showWarningMessage(
482 localizedText(
483 "warning.zls.stopped_unexpectedly",
484 "The Zig language server stopped unexpectedly ({executable}).",
485 { executable: executableDisplayName(zlsPath, "zls") }
486 )
487 );
488 }
489 });
490
491 try {
492 client.start();
493 this.client = client;
494 nova.subscriptions.add(client);
495 void this.pushConfiguration();
496 this.warnedMissing.delete("zls");
497 if (zigPath) {
498 this.warnedMissing.delete("zig");
499 }
500 } catch (error) {
501 console.error(`[${LANGUAGE_CLIENT_ID}] Failed to start ZLS`, error);
502 nova.workspace.showWarningMessage(
503 localizedText(
504 "warning.zls.start_failed",
505 "Unable to start the Zig language server at {path}.",
506 { path: zlsPath }
507 )
508 );
509 this.stop();
510 }
511 }
512
513 async resolveSettings() {
514 const settings = {
515 enable_build_on_save: getBooleanConfigValue(CONFIG_KEYS.zlsBuildOnSave, false),
516 };
517 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
518 if (zigPath) {
519 settings.zig_exe_path = zigPath;
520 }
521
522 return { settings, zigPath };
523 }
524
525 async pushConfiguration() {
526 const generation = this.restartGeneration;
527 const { settings } = await this.resolveSettings();
528 if (generation !== this.restartGeneration || !this.client || !this.client.running) {
529 return;
530 }
531
532 syncWorkspaceZlsConfiguration(settings);
533
534 this.client.sendNotification("workspace/didChangeConfiguration", {
535 settings: {
536 zls: settings,
537 },
538 });
539 }
540
541 stop() {
542 if (this.clientStopDisposable && typeof this.clientStopDisposable.dispose === "function") {
543 this.clientStopDisposable.dispose();
544 this.clientStopDisposable = null;
545 }
546
547 if (this.client) {
548 this.client.stop();
549 nova.subscriptions.remove(this.client);
550 this.client = null;
551 }
552 }
553
554 warnMissingTool(tool, message) {
555 if (this.warnedMissing.has(tool)) {
556 return;
557 }
558
559 this.warnedMissing.add(tool);
560 nova.workspace.showWarningMessage(message);
561 }
562}
563
564class ZigTaskAssistant {
565 constructor() {
566 this.disposable = nova.assistants.registerTaskAssistant(this, {
567 identifier: TASK_ASSISTANT_ID,
568 name: localizedText("name.extension", "Zig"),
569 });
570 }
571
572 dispose() {
573 if (this.disposable && typeof this.disposable.dispose === "function") {
574 this.disposable.dispose();
575 this.disposable = null;
576 }
577 }
578
579 provideTasks() {
580 const task = new Task(localizedText("task.current_file.name", "Current Zig File"));
581 task.setAction(Task.Run, new TaskResolvableAction({
582 data: {
583 type: "current-file-run",
584 },
585 }));
586 task.setAction(Task.Clean, new TaskResolvableAction({
587 data: {
588 type: "current-file-clean",
589 },
590 }));
591 return [task];
592 }
593
594 async resolveTaskAction(context) {
595 const type = context.data && context.data.type;
596 const config = context.config;
597 const cwd = getTaskCwd(config);
598
599 if (type === "clean") {
600 return this.resolveCleanAction(cwd);
601 }
602
603 switch (type) {
604 case "build":
605 return this.resolveBuildAction(config, cwd);
606 case "build-debug":
607 return this.resolveDebugBuildAction(config, cwd);
608 case "build-run":
609 return this.resolveBuildRunAction(config, cwd);
610 case "build-run-terminal":
611 return this.resolveBuildRunTerminalAction(config, cwd);
612 case "build-test":
613 return this.resolveBuildTestAction(config, cwd);
614 case "current-file-run":
615 return this.resolveCurrentFileRunAction();
616 case "current-file-clean":
617 return this.resolveCurrentFileCleanAction();
618 case "debug":
619 return this.resolveDebugAction(config, cwd);
620 case "file-test":
621 return this.resolveFileTestAction(config, cwd);
622 default:
623 return null;
624 }
625 }
626
627 createAction(command, args, cwd) {
628 return new TaskProcessAction("/bin/zsh", {
629 args: ["-lc", buildIssueNormalizedCommand(command, args)],
630 cwd,
631 env: {
632 NOVA_ZIG_TASK_CWD: cwd || "",
633 },
634 matchers: [ISSUE_MATCHER],
635 });
636 }
637
638 resolveCleanAction(cwd) {
639 if (!cwd) {
640 nova.workspace.showWarningMessage(
641 localizedText(
642 "warning.clean.missing_cwd",
643 "Choose a workspace or working directory before cleaning Zig build artifacts."
644 )
645 );
646 return null;
647 }
648
649 return new TaskProcessAction("/bin/rm", {
650 args: ["-rf", ".zig-cache", "zig-cache", "zig-out"],
651 cwd,
652 });
653 }
654
655 async resolveBuildAction(config, cwd) {
656 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
657 if (!zigPath) {
658 nova.workspace.showWarningMessage(
659 localizedText(
660 "warning.zig.not_found",
661 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
662 )
663 );
664 return null;
665 }
666
667 return this.createAction(zigPath, ["build", ...getTaskArgs(config, "buildArgs")], cwd);
668 }
669
670 async resolveDebugBuildAction(config, cwd) {
671 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
672 if (!zigPath) {
673 nova.workspace.showWarningMessage(
674 localizedText(
675 "warning.zig.not_found",
676 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
677 )
678 );
679 return null;
680 }
681
682 return this.createAction(zigPath, ["build", "-Doptimize=Debug", ...getTaskArgs(config, "buildArgs")], cwd);
683 }
684
685 async resolveBuildRunAction(config, cwd) {
686 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
687 if (!zigPath) {
688 nova.workspace.showWarningMessage(
689 localizedText(
690 "warning.zig.not_found",
691 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
692 )
693 );
694 return null;
695 }
696
697 const step =
698 getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
699 const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
700 const runArgs = getTaskArgs(config, "runArgs");
701
702 if (runArgs.length > 0) {
703 args.push("--", ...runArgs);
704 }
705
706 return this.createAction(zigPath, args, cwd);
707 }
708
709 async resolveBuildRunTerminalAction(config, cwd) {
710 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
711 if (!zigPath) {
712 nova.workspace.showWarningMessage(
713 localizedText(
714 "warning.zig.not_found",
715 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
716 )
717 );
718 return null;
719 }
720
721 const step =
722 getTaskConfigValue(config, "runStep") || getTaskConfigValue(config, "step") || "run";
723 const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
724 const runArgs = getTaskArgs(config, "runArgs");
725
726 if (runArgs.length > 0) {
727 args.push("--", ...runArgs);
728 }
729
730 return new TaskCommandAction(`${EXTENSION_ID}.runInTerminal`, {
731 args: [
732 {
733 command: zigPath,
734 args,
735 cwd,
736 },
737 ],
738 });
739 }
740
741 async resolveBuildTestAction(config, cwd) {
742 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
743 if (!zigPath) {
744 nova.workspace.showWarningMessage(
745 localizedText(
746 "warning.zig.not_found",
747 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
748 )
749 );
750 return null;
751 }
752
753 const step =
754 getTaskConfigValue(config, "testStep") || getTaskConfigValue(config, "step") || "test";
755 const args = ["build", ...getTaskArgs(config, "buildArgs"), step];
756 const testArgs = getTaskArgs(config, "testArgs");
757
758 if (testArgs.length > 0) {
759 args.push("--", ...testArgs);
760 }
761
762 return this.createAction(zigPath, args, cwd);
763 }
764
765 async resolveCurrentFileRunAction() {
766 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
767 if (!zigPath) {
768 nova.workspace.showWarningMessage(
769 localizedText(
770 "warning.zig.not_found",
771 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
772 )
773 );
774 return null;
775 }
776
777 const filePath = activeZigFilePath();
778 const cwd = filePath ? nova.path.dirname(filePath) : null;
779 if (!filePath || !cwd) {
780 nova.workspace.showWarningMessage(
781 localizedText(
782 "warning.current_file.focus_editor_for_run",
783 "Focus a Zig editor before running Current Zig File."
784 )
785 );
786 return null;
787 }
788
789 return this.createAction(zigPath, ["run", filePath], cwd);
790 }
791
792 resolveCurrentFileCleanAction() {
793 const cwd = activeZigFileDirectory();
794 if (!cwd) {
795 nova.workspace.showWarningMessage(
796 localizedText(
797 "warning.current_file.focus_editor_for_clean",
798 "Focus a Zig editor before cleaning Current Zig File artifacts."
799 )
800 );
801 return null;
802 }
803
804 return this.resolveCleanAction(cwd);
805 }
806
807 async resolveDebugAction(config, cwd) {
808 const lldbDapPath = await resolveLLDBDAPExecutable();
809 if (!lldbDapPath) {
810 nova.workspace.showWarningMessage(
811 localizedText(
812 "warning.lldb_dap.not_found",
813 "lldb-dap was not found. Install Xcode Command Line Tools or set an LLDB DAP executable path in Zig extension settings."
814 )
815 );
816 return null;
817 }
818
819 const configuredProgramPath = getTaskConfigValue(config, "programPath");
820 const programPath = resolvePathAgainstBase(configuredProgramPath, cwd);
821 if (!programPath) {
822 nova.workspace.showWarningMessage(
823 localizedText(
824 "warning.debug.choose_program",
825 "Choose a program path before running Zig Debug."
826 )
827 );
828 return null;
829 }
830
831 const consoleMode = getTaskConfigValue(config, "console") || "internalConsole";
832 const stopOnEntry = Boolean(config && config.get("stopOnEntry"));
833
834 const action = new TaskDebugAdapterAction("zig-lldb-dap");
835 action.transport = "stdio";
836 action.command = "/usr/bin/perl";
837 action.args = [lldbDapProxyPath(), lldbDapPath, debugAdapterLogPath()];
838 action.debugRequest = "launch";
839 action.env = {
840 DYLD_FRAMEWORK_PATH: lldbFrameworkPaths().join(":"),
841 NOVA_ZIG_LLDB_DAP_PATH: lldbDapPath,
842 NOVA_ZIG_DEBUG_LOG: debugAdapterLogPath(),
843 };
844 action.debugArgs = {
845 program: programPath,
846 cwd,
847 args: getTaskArgs(config, "runArgs"),
848 stopOnEntry,
849 };
850
851 if (consoleMode !== "internalConsole") {
852 action.debugArgs.console = consoleMode;
853 }
854
855 return action;
856 }
857
858 async resolveFileTestAction(config, cwd) {
859 const zigPath = await resolveExecutable(CONFIG_KEYS.zigPath, "zig");
860 if (!zigPath) {
861 nova.workspace.showWarningMessage(
862 localizedText(
863 "warning.zig.not_found",
864 "Zig was not found. Install it or set a Zig executable path in Zig extension settings."
865 )
866 );
867 return null;
868 }
869
870 const configuredPath = getTaskConfigValue(config, "filePath");
871 const filePath = configuredPath
872 ? resolveWorkspaceRelativePath(configuredPath)
873 : activeZigFilePath();
874
875 if (!filePath) {
876 nova.workspace.showWarningMessage(
877 localizedText(
878 "warning.file_test.choose_file",
879 "Choose a Zig file for this task or focus a Zig editor before running Zig File Test."
880 )
881 );
882 return null;
883 }
884
885 return this.createAction(zigPath, ["test", filePath, ...getTaskArgs(config, "zigArgs")], cwd);
886 }
887}
888
889class ZigIssueAssistant {
890 constructor() {
891 this.disposable = nova.assistants.registerIssueAssistant(
892 [{ syntax: "zig" }, { syntax: "zig-package" }],
893 this,
894 { event: "onChange" }
895 );
896 }
897
898 dispose() {
899 if (this.disposable && typeof this.disposable.dispose === "function") {
900 this.disposable.dispose();
901 this.disposable = null;
902 }
903 }
904
905 provideIssues(editor) {
906 if (!editor || !editor.document) {
907 return [];
908 }
909
910 // Nova's LanguageClient already owns core LSP publishDiagnostics handling.
911 // Registering an issue assistant here tells Nova that Zig supports live
912 // checking, so the Problems UI doesn't show a misleading empty-state banner.
913 return [];
914 }
915}
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 @@
1use strict;
2use warnings;
3$| = 1;
4
5my $base = $ENV{NOVA_ZIG_TASK_CWD} || $ENV{PWD} || "";
6my %file_cache = ();
7
8sub normalize_path {
9 my ($path) = @_;
10 return $path if $path =~ m{^/} || $path =~ m{^[A-Za-z]:[\/\\]};
11
12 # The Zig compiler occasionally emits paths without a leading slash (e.g.
13 # "Users/..." instead of "/Users/...") when traversing relative directories.
14 # Detect known top-level directory names and restore the leading slash.
15 if ($path =~ m{^(?:Users|private|opt|Library|System|usr|var|tmp|etc|home)/}) {
16 return "/$path";
17 }
18
19 return $base eq "" ? $path : "$base/$path";
20}
21
22sub source_line_for_path {
23 my ($path, $line_number) = @_;
24 return undef if $line_number < 1;
25
26 if (!exists $file_cache{$path}) {
27 if (open my $fh, "<", $path) {
28 my @lines = <$fh>;
29 close $fh;
30 $file_cache{$path} = \@lines;
31 } else {
32 $file_cache{$path} = undef;
33 }
34 }
35
36 my $lines = $file_cache{$path};
37 return undef if !defined $lines;
38 return undef if $line_number > scalar(@$lines);
39
40 my $line = $lines->[$line_number - 1];
41 $line =~ s/\r?\n$//;
42 return $line;
43}
44
45# Zig reports argument-count errors at the column of the opening parenthesis,
46# which causes Nova to underline the entire argument list. For method-call
47# expressions (receiver.method(...)), shift the column back to the start of
48# the callee identifier so only the call site itself is highlighted.
49sub adjusted_call_column {
50 my ($source_line, $column) = @_;
51 return $column if !defined $source_line || $column < 1;
52
53 my $column_index = $column - 1;
54 my $open_paren = index($source_line, "(", $column_index);
55 return $column if $open_paren < 0;
56
57 my $prefix = substr($source_line, 0, $open_paren);
58 return $column if $prefix !~ /([A-Za-z_][A-Za-z0-9_]*)\s*$/;
59
60 my $callee = $1;
61 my $callee_index = rindex($prefix, $callee);
62 return $column if $callee_index < 0;
63
64 my $target_column = $callee_index + 1;
65 return $column if $target_column <= $column;
66
67 my $between = substr($source_line, $column_index, $target_column - $column);
68 return $column if $between !~ /\./;
69
70 return $target_column;
71}
72
73while (my $line = <STDIN>) {
74 my $newline = $line =~ s/\r?\n$// ? "\n" : "";
75
76 if ($line =~ m{^([^:\n]+\.zig):(\d+):(\d+):\s*(error|warning|note):\s*(.*)$}) {
77 my ($path, $line_number, $column, $severity, $message) = ($1, $2, $3, $4, $5);
78 my $normalized = normalize_path($path);
79
80 if ($message =~ /\bexpected\b.*argument\(s\).*\bfound\b/i) {
81 my $source_line = source_line_for_path($normalized, $line_number);
82 $column = adjusted_call_column($source_line, $column);
83 }
84
85 $line = "$normalized:$line_number:$column: $severity: $message";
86 } elsif ($line =~ m{^([^:\n]+\.zig):(\d+):(\d+):}) {
87 my ($path, $line_number, $column) = ($1, $2, $3);
88 my $normalized = normalize_path($path);
89 $line =~ s{^[^:\n]+\.zig:\d+:\d+:}{$normalized:$line_number:$column:};
90 }
91
92 print $line, $newline;
93}
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 @@
1#!/bin/sh
2#
3# update-parser.sh — Bump vendored tree-sitter-zig and rebuild the dylib.
4#
5# Purpose:
6# Refresh the vendored snapshot under vendor/tree-sitter-zig/ to a newer
7# upstream commit, update VENDORING.md, and rebuild the parser dylib.
8#
9# What it does:
10# - clones tree-sitter-grammars/tree-sitter-zig into a temp dir
11# - checks out the requested ref (default: HEAD of the default branch)
12# - exits early if the upstream SHA matches the pinned SHA
13# - replaces src/, queries/, grammar.js, tree-sitter.json, LICENSE
14# with the upstream copies; leaves VENDORING.md and README.upstream.md
15# untouched
16# - rewrites the "Pinned commit:" line in VENDORING.md
17# - invokes build-parser.sh to rebuild Syntaxes/libtree-sitter-zig.dylib
18# - prints a GitHub compare link for the diff
19#
20# Usage:
21# ./Scripts/update-parser.sh # bump to upstream HEAD
22# ./Scripts/update-parser.sh <ref> # bump to a tag, branch, or SHA
23#
24# Caveats:
25# - any local edits inside the listed paths are overwritten — review
26# `git diff vendor/` afterwards before committing
27# - parses VENDORING.md by an exact "Pinned commit: <sha>" line prefix;
28# keep that line format intact
29#
30# Requirements:
31# git, plus everything build-parser.sh needs.
32
33set -eu
34
35ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"
36VENDOR_DIR="$ROOT/vendor/tree-sitter-zig"
37UPSTREAM="https://github.com/tree-sitter-grammars/tree-sitter-zig.git"
38REF="${1:-HEAD}"
39
40TMP="$(mktemp -d)"
41trap 'rm -rf "$TMP"' EXIT
42
43git clone --quiet "$UPSTREAM" "$TMP/repo"
44git -C "$TMP/repo" checkout --quiet "$REF"
45SHA="$(git -C "$TMP/repo" rev-parse HEAD)"
46
47OLD_SHA="$(awk '/^Pinned commit:/ {print $3}' "$VENDOR_DIR/VENDORING.md")"
48if [ "$SHA" = "$OLD_SHA" ]; then
49 echo "Already at $SHA — nothing to do."
50 exit 0
51fi
52
53for path in src queries grammar.js tree-sitter.json LICENSE; do
54 rm -rf "$VENDOR_DIR/$path"
55 cp -R "$TMP/repo/$path" "$VENDOR_DIR/$path"
56done
57
58sed -i.bak "s/^Pinned commit: .*/Pinned commit: $SHA/" "$VENDOR_DIR/VENDORING.md"
59rm "$VENDOR_DIR/VENDORING.md.bak"
60
61"$ROOT/Scripts/build-parser.sh"
62
63echo "Updated $OLD_SHA -> $SHA"
64echo "Compare: https://github.com/tree-sitter-grammars/tree-sitter-zig/compare/$OLD_SHA...$SHA"