diff options
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 @@ +#!/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" |
