diff options
| author | David Czihak <git@dcz.at> | 2026-05-07 14:33:19 +0200 |
|---|---|---|
| committer | David Czihak <git@dcz.at> | 2026-05-07 14:33:19 +0200 |
| commit | ddf2de739068b5ff0866ccb1d067f3cb53a4fc55 (patch) | |
| tree | 1a77efe9d73a6172be3c37d29b321eadd4efe379 /Scripts | |
Initial commitv0.1.7
Diffstat (limited to 'Scripts')
| -rwxr-xr-x | Scripts/build-parser.sh | 53 | ||||
| -rw-r--r-- | Scripts/lldb-dap-proxy.pl | 213 | ||||
| -rw-r--r-- | Scripts/main.js | 915 | ||||
| -rw-r--r-- | Scripts/normalize-zig-issues.pl | 93 | ||||
| -rwxr-xr-x | Scripts/update-parser.sh | 64 |
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 | |||
| 27 | set -eu | ||
| 28 | |||
| 29 | ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" | ||
| 30 | VENDOR_DIR="$ROOT/vendor/tree-sitter-zig" | ||
| 31 | BUILD_DIR="$ROOT/build" | ||
| 32 | OUTPUT="$ROOT/Syntaxes/libtree-sitter-zig.dylib" | ||
| 33 | NOVA_APP="${NOVA_APP:-/Applications/Nova.app}" | ||
| 34 | SDKROOT="${SDKROOT:-$(xcrun --show-sdk-path)}" | ||
| 35 | |||
| 36 | mkdir -p "$BUILD_DIR" | ||
| 37 | |||
| 38 | clang \ | ||
| 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 | |||
| 52 | codesign --force --sign - "$BUILD_DIR/libtree-sitter-zig.dylib" | ||
| 53 | 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 @@ | |||
| 1 | #!/usr/bin/perl | ||
| 2 | use strict; | ||
| 3 | use warnings; | ||
| 4 | use IPC::Open3; | ||
| 5 | use IO::Select; | ||
| 6 | use JSON::PP; | ||
| 7 | use Symbol 'gensym'; | ||
| 8 | |||
| 9 | my $adapter_path = $ARGV[0] // $ENV{NOVA_ZIG_LLDB_DAP_PATH}; | ||
| 10 | my $log_path = $ARGV[1] // $ENV{NOVA_ZIG_DEBUG_LOG}; | ||
| 11 | my @adapter_args = @ARGV > 2 ? @ARGV[2 .. $#ARGV] : (); | ||
| 12 | |||
| 13 | sub 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 | |||
| 23 | unless (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 | |||
| 29 | log_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. | ||
| 33 | my ($child_in, $child_out, $child_err) = (gensym(), gensym(), gensym()); | ||
| 34 | my $pid = eval { open3($child_in, $child_out, $child_err, $adapter_path, @adapter_args) }; | ||
| 35 | if ($@) { | ||
| 36 | (my $err = $@) =~ s/\s+$//; | ||
| 37 | log_msg("adapter error $err"); | ||
| 38 | print STDERR "$err\n"; | ||
| 39 | exit(1); | ||
| 40 | } | ||
| 41 | |||
| 42 | binmode($_, ':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 | |||
| 47 | my $json = JSON::PP->new->utf8; | ||
| 48 | |||
| 49 | my ($next_client_seq, $next_adapter_seq) = (1, 1); | ||
| 50 | my (%client_request_seq_map, %adapter_request_seq_map, %request_args_by_client_seq); | ||
| 51 | my ($stdin_buf, $child_buf) = ('', ''); | ||
| 52 | |||
| 53 | my $stdin_fn = fileno(\*STDIN); | ||
| 54 | my $child_out_fn = fileno($child_out); | ||
| 55 | |||
| 56 | my $sel = IO::Select->new(\*STDIN, $child_out, $child_err); | ||
| 57 | |||
| 58 | LOOP: 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 | |||
| 84 | waitpid($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). | ||
| 87 | my $signal = $? & 127; | ||
| 88 | if ($signal) { | ||
| 89 | $SIG{$_} = 'DEFAULT' for qw(INT TERM); | ||
| 90 | kill $signal, $$; | ||
| 91 | sleep 1; | ||
| 92 | } | ||
| 93 | exit($? >> 8); | ||
| 94 | |||
| 95 | sub 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 | |||
| 129 | sub 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 | |||
| 168 | sub 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 | |||
| 3 | const EXTENSION_ID = "at.dcz.nova-zig"; | ||
| 4 | const TASK_ASSISTANT_ID = `${EXTENSION_ID}.tasks`; | ||
| 5 | const LANGUAGE_CLIENT_ID = `${EXTENSION_ID}.zls`; | ||
| 6 | const ISSUE_MATCHER = "zig.compiler"; | ||
| 7 | |||
| 8 | const 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 | |||
| 17 | let languageServer = null; | ||
| 18 | let taskAssistant = null; | ||
| 19 | let issueAssistant = null; | ||
| 20 | let commandRegistrations = []; | ||
| 21 | |||
| 22 | exports.activate = function activate() { | ||
| 23 | registerCommands(); | ||
| 24 | taskAssistant = new ZigTaskAssistant(); | ||
| 25 | languageServer = new ZigLanguageServer(); | ||
| 26 | issueAssistant = new ZigIssueAssistant(); | ||
| 27 | }; | ||
| 28 | |||
| 29 | exports.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 | |||
| 53 | function 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 | |||
| 67 | function 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 | |||
| 81 | function 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 | |||
| 91 | function 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 | |||
| 107 | function 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 | |||
| 123 | function 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 | |||
| 132 | function getTaskArgs(config, key) { | ||
| 133 | return normalizeArray(getTaskConfigValue(config, key)); | ||
| 134 | } | ||
| 135 | |||
| 136 | function 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 | |||
| 145 | function 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 | |||
| 158 | function activeZigFileDirectory() { | ||
| 159 | const filePath = activeZigFilePath(); | ||
| 160 | if (!filePath) { | ||
| 161 | return null; | ||
| 162 | } | ||
| 163 | |||
| 164 | return nova.path.dirname(filePath); | ||
| 165 | } | ||
| 166 | |||
| 167 | function 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 | |||
| 181 | function 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 | |||
| 201 | async 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 | |||
| 214 | async function resolveExecutable(configKey, defaultCommand) { | ||
| 215 | const configuredPath = getConfigValue(configKey); | ||
| 216 | if (configuredPath) { | ||
| 217 | return configuredPath; | ||
| 218 | } | ||
| 219 | |||
| 220 | return findExecutableOnPath(defaultCommand); | ||
| 221 | } | ||
| 222 | |||
| 223 | async 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 | |||
| 236 | async 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 | |||
| 250 | function executableDisplayName(path, fallback) { | ||
| 251 | return path || fallback; | ||
| 252 | } | ||
| 253 | |||
| 254 | function 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 | |||
| 262 | function lldbDapProxyPath() { | ||
| 263 | return nova.path.join(nova.extension.path, "Scripts", "lldb-dap-proxy.pl"); | ||
| 264 | } | ||
| 265 | |||
| 266 | function debugAdapterLogPath() { | ||
| 267 | return "/tmp/zig-lldb-dap-proxy.log"; | ||
| 268 | } | ||
| 269 | |||
| 270 | function issueNormalizerScriptPath() { | ||
| 271 | return nova.path.join(nova.extension.path, "Scripts", "normalize-zig-issues.pl"); | ||
| 272 | } | ||
| 273 | |||
| 274 | function quoteShellArgument(value) { | ||
| 275 | const text = value === null || value === undefined ? "" : String(value); | ||
| 276 | return `'${text.replace(/'/g, `'\\''`)}'`; | ||
| 277 | } | ||
| 278 | |||
| 279 | function escapeAppleScriptString(value) { | ||
| 280 | return String(value) | ||
| 281 | .replace(/\\/g, "\\\\") | ||
| 282 | .replace(/"/g, '\\"'); | ||
| 283 | } | ||
| 284 | |||
| 285 | function 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 | |||
| 295 | function 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 | |||
| 304 | function launchInTerminal(commandLine) { | ||
| 305 | return new Promise((resolve) => { | ||
| 306 | const script = `tell application "Terminal" | ||
| 307 | activate | ||
| 308 | do script "${escapeAppleScriptString(commandLine)}" | ||
| 309 | end 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 | |||
| 325 | function 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 | |||
| 355 | function 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 | |||
| 370 | class 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 | |||
| 564 | class 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 | |||
| 889 | class 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 @@ | |||
| 1 | use strict; | ||
| 2 | use warnings; | ||
| 3 | $| = 1; | ||
| 4 | |||
| 5 | my $base = $ENV{NOVA_ZIG_TASK_CWD} || $ENV{PWD} || ""; | ||
| 6 | my %file_cache = (); | ||
| 7 | |||
| 8 | sub 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 | |||
| 22 | sub 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. | ||
| 49 | sub 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 | |||
| 73 | while (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 | |||
| 33 | set -eu | ||
| 34 | |||
| 35 | ROOT="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" | ||
| 36 | VENDOR_DIR="$ROOT/vendor/tree-sitter-zig" | ||
| 37 | UPSTREAM="https://github.com/tree-sitter-grammars/tree-sitter-zig.git" | ||
| 38 | REF="${1:-HEAD}" | ||
| 39 | |||
| 40 | TMP="$(mktemp -d)" | ||
| 41 | trap 'rm -rf "$TMP"' EXIT | ||
| 42 | |||
| 43 | git clone --quiet "$UPSTREAM" "$TMP/repo" | ||
| 44 | git -C "$TMP/repo" checkout --quiet "$REF" | ||
| 45 | SHA="$(git -C "$TMP/repo" rev-parse HEAD)" | ||
| 46 | |||
| 47 | OLD_SHA="$(awk '/^Pinned commit:/ {print $3}' "$VENDOR_DIR/VENDORING.md")" | ||
| 48 | if [ "$SHA" = "$OLD_SHA" ]; then | ||
| 49 | echo "Already at $SHA — nothing to do." | ||
| 50 | exit 0 | ||
| 51 | fi | ||
| 52 | |||
| 53 | for 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" | ||
| 56 | done | ||
| 57 | |||
| 58 | sed -i.bak "s/^Pinned commit: .*/Pinned commit: $SHA/" "$VENDOR_DIR/VENDORING.md" | ||
| 59 | rm "$VENDOR_DIR/VENDORING.md.bak" | ||
| 60 | |||
| 61 | "$ROOT/Scripts/build-parser.sh" | ||
| 62 | |||
| 63 | echo "Updated $OLD_SHA -> $SHA" | ||
| 64 | echo "Compare: https://github.com/tree-sitter-grammars/tree-sitter-zig/compare/$OLD_SHA...$SHA" | ||
