From 870253344aa764190513e06f226fb86a951f1dd5 Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:11 +0200 Subject: [PATCH 01/10] feat: Add Nushell debugger example scripts --- examples/debug/01_basics.nu | 33 ++++++++ examples/debug/02_control_flow.nu | 52 +++++++++++++ examples/debug/03_iteration.nu | 60 +++++++++++++++ examples/debug/04_pipelines.nu | 61 +++++++++++++++ examples/debug/05_closures.nu | 51 +++++++++++++ examples/debug/06_definitions_only.nu | 42 ++++++++++ examples/debug/07_definitions_and_toplevel.nu | 40 ++++++++++ examples/debug/08_main_entrypoint.nu | 22 ++++++ examples/debug/09_multiple_commands.nu | 39 ++++++++++ examples/debug/10_external_process.nu | 36 +++++++++ examples/debug/11_http_requests.nu | 55 ++++++++++++++ examples/debug/12_error_handling.nu | 76 +++++++++++++++++++ examples/debug/13_data_structures.nu | 66 ++++++++++++++++ examples/debug/14_string_processing.nu | 61 +++++++++++++++ examples/debug/15_modules.nu | 17 +++++ examples/debug/16_environment.nu | 37 +++++++++ examples/debug/17_file_operations.nu | 37 +++++++++ examples/debug/18_parallel.nu | 37 +++++++++ examples/debug/19_practical_script.nu | 59 ++++++++++++++ examples/debug/closure.nu | 4 + examples/debug/helper.nu | 3 + 21 files changed, 888 insertions(+) create mode 100644 examples/debug/01_basics.nu create mode 100644 examples/debug/02_control_flow.nu create mode 100644 examples/debug/03_iteration.nu create mode 100644 examples/debug/04_pipelines.nu create mode 100644 examples/debug/05_closures.nu create mode 100644 examples/debug/06_definitions_only.nu create mode 100644 examples/debug/07_definitions_and_toplevel.nu create mode 100644 examples/debug/08_main_entrypoint.nu create mode 100644 examples/debug/09_multiple_commands.nu create mode 100644 examples/debug/10_external_process.nu create mode 100644 examples/debug/11_http_requests.nu create mode 100644 examples/debug/12_error_handling.nu create mode 100644 examples/debug/13_data_structures.nu create mode 100644 examples/debug/14_string_processing.nu create mode 100644 examples/debug/15_modules.nu create mode 100644 examples/debug/16_environment.nu create mode 100644 examples/debug/17_file_operations.nu create mode 100644 examples/debug/18_parallel.nu create mode 100644 examples/debug/19_practical_script.nu create mode 100644 examples/debug/closure.nu create mode 100644 examples/debug/helper.nu diff --git a/examples/debug/01_basics.nu b/examples/debug/01_basics.nu new file mode 100644 index 0000000..042577b --- /dev/null +++ b/examples/debug/01_basics.nu @@ -0,0 +1,33 @@ +# Basics: variables, types, and simple expressions. +# Try setting a breakpoint on each `let` and stepping through to watch +# the Variables panel populate with different value types. + +let name = "nushell" +let version = 114 +let pi = 3.14159 +let active = true +let nothing = null +let size = 2kb + +# Mutable variables — watch the value change as you step. +mut counter = 0 +$counter = $counter + 1 +$counter = $counter + 5 +$counter = $counter * 2 + +# Collections +let colors = ["red", "green", "blue"] +let config = { host: "localhost", port: 8080, debug: true } +let matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]] + +# Binary data (displays in hex view in the visualizer) +let blob = 0x[de ad be ef ca fe ba be] + +# String interpolation +let greeting = $"Hello ($name) v($version)!" +print $greeting + +# Type checking +print ($name | describe) +print ($config | describe) +print ($colors | describe) diff --git a/examples/debug/02_control_flow.nu b/examples/debug/02_control_flow.nu new file mode 100644 index 0000000..6e34473 --- /dev/null +++ b/examples/debug/02_control_flow.nu @@ -0,0 +1,52 @@ +# Control flow: if/else, match, for, while, loop. +# Good for practicing step-over vs step-into and watching branch selection. + +let score = 85 + +# if / else if / else — step through to see which branch is taken. +let grade = if $score >= 90 { + "A" +} else if $score >= 80 { + "B" +} else if $score >= 70 { + "C" +} else { + "F" +} +print $"Score ($score) → grade ($grade)" + +# match expression +let status_code = 404 +let message = match $status_code { + 200 => "OK" + 301 => "Moved" + 404 => "Not Found" + 500 => "Server Error" + _ => "Unknown" +} +print $"HTTP ($status_code): ($message)" + +# for loop — watch `i` update each iteration in the Variables panel. +mut sum = 0 +for i in 1..5 { + $sum = $sum + $i + print $" i=($i) sum=($sum)" +} +print $"Total: ($sum)" + +# while loop +mut n = 10 +while $n > 0 { + $n = $n - 3 + print $" n=($n)" +} + +# loop with break +mut attempts = 0 +loop { + $attempts = $attempts + 1 + if $attempts >= 3 { + break + } +} +print $"Broke after ($attempts) attempts" diff --git a/examples/debug/03_iteration.nu b/examples/debug/03_iteration.nu new file mode 100644 index 0000000..8181c22 --- /dev/null +++ b/examples/debug/03_iteration.nu @@ -0,0 +1,60 @@ +# Iteration: each, where, reduce, and closure parameters. +# Demonstrates the `|it|` closure variable and various iteration patterns. + +let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10] + +# `each` with explicit closure parameter — step into the closure to +# watch `it` take a new value on every iteration. +let doubled = $numbers | each { |it| + $it * 2 +} +print $"Doubled: ($doubled)" + +# `where` with closure parameter — filtering +let evens = $numbers | where { |it| $it mod 2 == 0 } +print $"Evens: ($evens)" + +# `each` with named parameter for clarity +let squares = $numbers | each { |n| + $n * $n +} +print $"Squares: ($squares)" + +# Working with tables — `each` over rows +let people = [ + { name: "Alice", age: 30 } + { name: "Bob", age: 25 } + { name: "Carol", age: 35 } +] + +let introductions = $people | each { |person| + $"($person.name) is ($person.age) years old" +} +print $introductions + +# `reduce` — accumulating a value (watch `acc` grow) +let total = $numbers | reduce { |it, acc| + $acc + $it +} +print $"Sum via reduce: ($total)" + +# `reduce` with initial value +let product = [2, 3, 4] | reduce --fold 1 { |it, acc| + $acc * $it +} +print $"Product: ($product)" + +# Nested iteration +let grid = [[1, 2], [3, 4], [5, 6]] +let flat = $grid | each { |row| + $row | each { |cell| + $cell * 10 + } +} | flatten +print $"Flattened grid: ($flat)" + +# `enumerate` — get index alongside value +let indexed = ["a", "b", "c"] | enumerate | each { |it| + $"($it.index): ($it.item)" +} +print $indexed diff --git a/examples/debug/04_pipelines.nu b/examples/debug/04_pipelines.nu new file mode 100644 index 0000000..85f214f --- /dev/null +++ b/examples/debug/04_pipelines.nu @@ -0,0 +1,61 @@ +# Pipelines and the `$in` variable. +# `$in` refers to the pipeline input — the value piped into the current block. +# Step through to see how data flows from one command to the next. + +# Basic $in usage: the pipeline input is available as $in +let result = "hello world" | str upcase +print $result + +# $in in a block — when you use a bare block in a pipeline, $in is the input +let processed = 42 | { value: $in, doubled: ($in * 2) } +print $processed + +# $in vs explicit closure parameter: these are equivalent +let nums = [1, 2, 3, 4, 5] + +# Using $in (implicit input) +let tripled = $nums | each { $in * 3 } +print $"Tripled \(via $in\): ($tripled)" + +# Using explicit parameter (same result) +let tripled2 = $nums | each { |n| $n * 3 } +print $"Tripled via explicit param: ($tripled2)" + +# Multi-stage pipeline — step through to watch intermediate values +let words = "the quick brown fox jumps over the lazy dog" + | split row " " + | where { ($in | str length) > 3 } + | each { $in | str capitalize } + | str join ", " +print $words + +# Pipeline with table data — watch the table shrink through filters +let files = [ + { name: "app.ts", size: 2400, modified: "2024-01-15" } + { name: "readme.md", size: 800, modified: "2024-02-01" } + { name: "config.json", size: 150, modified: "2024-01-20" } + { name: "main.rs", size: 5600, modified: "2024-03-10" } + { name: "test.py", size: 1200, modified: "2024-02-28" } +] + +let large_files = $files + | where { $in.size > 1000 } + | sort-by size --reverse + | each { |f| $"($f.name) - ($f.size) bytes" } +print $large_files + +# $in at top level of a block expression +let tag = "v1.2.3" | split row "." | { + major: ($in | get 0) + minor: ($in | get 1) + patch: ($in | get 2) +} +print $tag + +# Using $in to build a record from pipeline input +let stats = [10, 20, 30, 40, 50] | { + count: ($in | length) + sum: ($in | math sum) + avg: ($in | math avg) +} +print $stats diff --git a/examples/debug/05_closures.nu b/examples/debug/05_closures.nu new file mode 100644 index 0000000..ca4a66e --- /dev/null +++ b/examples/debug/05_closures.nu @@ -0,0 +1,51 @@ +# Closures: capturing variables, passing closures, higher-order patterns. +# Step into closures to see captured values in the Variables panel. + +# A closure captures variables from its defining scope +let multiplier = 3 +let multiply = { |x| $x * $multiplier } +let result = do $multiply 7 +print $"3 × 7 = ($result)" + +# Closures as arguments — the classic functional pattern +def apply_twice [f: closure, value: int] { + let once = do $f $value + let twice = do $f $once + $twice +} + +let doubled_twice = apply_twice { |x| $x * 2 } 5 +print $"5 → double → double = ($doubled_twice)" + +# Closure with enumerate — numbering items in a list +let items = ["apple", "banana", "cherry"] +$items | enumerate | each { |entry| + $"($entry.index + 1). ($entry.item)" +} | print + +# Building a pipeline transform with closures +def transform [data: list, steps: list] { + mut current = $data + for step in $steps { + $current = $current | each { |it| do $step $it } + } + $current +} + +let pipeline_result = transform [1, 2, 3, 4] [ + { |x| $x * 10 } + { |x| $x + 1 } +] +print $"Transform result: ($pipeline_result)" + +# Closure in a record (strategy pattern) +let formatter = { + upper: { |s| $s | str upcase } + lower: { |s| $s | str downcase } + title: { |s| $s | str capitalize } +} + +let text = "hello WORLD" +print (do $formatter.upper $text) +print (do $formatter.lower $text) +print (do $formatter.title $text) diff --git a/examples/debug/06_definitions_only.nu b/examples/debug/06_definitions_only.nu new file mode 100644 index 0000000..e5943e8 --- /dev/null +++ b/examples/debug/06_definitions_only.nu @@ -0,0 +1,42 @@ +# Definitions only — a pure library file with no top-level executable code. +# The debugger lets you pick an entry point to run (e.g. `greet "world"`). +# This exercises the "no main, no top-level" launch path. + +# Simple function +def greet [name: string] { + let msg = $"Hello, ($name)!" + print $msg + $msg +} + +# Function with multiple params and a default +def repeat_string [text: string, count: int = 3] { + let parts = 1..$count | each { $text } + $parts | str join " " +} + +# Function that returns a table +def make_report [items: list] { + $items | each { |item| + { + name: $item + length: ($item | str length) + upper: ($item | str upcase) + } + } +} + +# Recursive function (fibonacci) +def fib [n: int] { + if $n <= 1 { + $n + } else { + (fib ($n - 1)) + (fib ($n - 2)) + } +} + +# Function with flag parameters +def search [pattern: string, --case-sensitive, --max-results: int = 10] { + let mode = if $case_sensitive { "exact" } else { "fuzzy" } + print $"Searching '($pattern)' mode=($mode) max=($max_results)" +} diff --git a/examples/debug/07_definitions_and_toplevel.nu b/examples/debug/07_definitions_and_toplevel.nu new file mode 100644 index 0000000..a74c68a --- /dev/null +++ b/examples/debug/07_definitions_and_toplevel.nu @@ -0,0 +1,40 @@ +# Definitions + top-level code: functions are defined, then called at the +# top level. This is the most common script pattern. Breakpoints work in +# both the function bodies and the top-level calls. + +def classify [size: int] { + if $size > 1000 { + "large" + } else if $size > 100 { + "medium" + } else { + "small" + } +} + +def summarize [items: list] { + let total = $items | get size | math sum + let count = $items | length + { count: $count, total_size: $total, average: ($total / $count) } +} + +# --- Top-level execution starts here --- + +let files = [ + { name: "app.js", size: 4200 } + { name: "style.css", size: 850 } + { name: "icon.png", size: 50 } + { name: "data.json", size: 12000 } +] + +# Call our functions — step into to enter the function body +mut results = [] +for f in $files { + let label = classify $f.size + $results = ($results | append $"($f.name): ($label)") + print $" ($f.name) → ($label)" +} + +let stats = summarize $files +print $"Summary: ($stats)" +print $"Results: ($results)" diff --git a/examples/debug/08_main_entrypoint.nu b/examples/debug/08_main_entrypoint.nu new file mode 100644 index 0000000..8ba35d5 --- /dev/null +++ b/examples/debug/08_main_entrypoint.nu @@ -0,0 +1,22 @@ +# Main entrypoint: `def main` makes this a proper CLI script. +# The debugger will prompt for arguments when launching. +# Try: name="world" count=3 --verbose --tag="release" + +def main [ + name: string # Required: the name to greet + count: int # Required: how many times to repeat + --verbose # Optional flag: show extra info + --tag: string = "dev" # Optional flag with default +] { + if $verbose { + print $"[DEBUG] name=($name) count=($count) tag=($tag)" + print $"[DEBUG] working dir: ($env.PWD)" + } + + for i in 1..$count { + let msg = $"[($tag)] ($i)/($count): Hello, ($name)!" + print $msg + } + + print "Done." +} diff --git a/examples/debug/09_multiple_commands.nu b/examples/debug/09_multiple_commands.nu new file mode 100644 index 0000000..b4db4f6 --- /dev/null +++ b/examples/debug/09_multiple_commands.nu @@ -0,0 +1,39 @@ +# Multiple commands: several `def` blocks that can each be chosen as the +# entry point. The debugger shows a picker when there's no `main` and no +# top-level code beyond definitions. + +def hello [name: string = "world"] { + print $"Hello, ($name)!" +} + +def countdown [from: int = 5] { + mut n = $from + while $n > 0 { + print $" ($n)..." + $n = $n - 1 + } + print " Go!" +} + +def fizzbuzz [limit: int = 20] { + for i in 1..$limit { + let result = if $i mod 15 == 0 { + "FizzBuzz" + } else if $i mod 3 == 0 { + "Fizz" + } else if $i mod 5 == 0 { + "Buzz" + } else { + $"($i)" + } + print $" ($result)" + } +} + +def stats [numbers: list] { + let sum = $numbers | math sum + let avg = $numbers | math avg + let min = $numbers | math min + let max = $numbers | math max + print { sum: $sum, avg: $avg, min: $min, max: $max } +} diff --git a/examples/debug/10_external_process.nu b/examples/debug/10_external_process.nu new file mode 100644 index 0000000..ec0f797 --- /dev/null +++ b/examples/debug/10_external_process.nu @@ -0,0 +1,36 @@ +# External processes: spawning system commands and capturing output. +# Watch how external output appears in the Debug Console. + +# Run an external command — its output goes through the DAP output channel +print "--- System info ---" +^hostname + +# Capture external output into a variable +let py_version = ^python --version +print $"Python: ($py_version)" + +# External with arguments — useful for build scripts +let file_count = ^git ls-files | lines | length +print $"Files in repo: ($file_count)" + +# Piping nushell data to an external and back +let json_data = { name: "test", value: 42 } | to json +let pretty = $json_data | ^python -c "import sys,json; print(json.dumps(json.loads(sys.stdin.read()), indent=2))" +print $pretty + +# External commands in a pipeline +let large_files = ^git ls-files + | lines + | each { |f| { name: $f, ext: ($f | path parse | get extension) } } + | where { $in.ext == "nu" } +print $"Nushell files in repo:" +print $large_files + +# Exit code checking +let result = do { ^git status --short } | complete +print $"git status exit code: ($result.exit_code)" +if $result.exit_code == 0 { + print $"Output: ($result.stdout)" +} else { + print $"Error: ($result.stderr)" +} diff --git a/examples/debug/11_http_requests.nu b/examples/debug/11_http_requests.nu new file mode 100644 index 0000000..98ee4da --- /dev/null +++ b/examples/debug/11_http_requests.nu @@ -0,0 +1,55 @@ +# HTTP requests: http get/post, JSON parsing, API interaction. +# Demonstrates real network calls — inspect the response objects in the +# Variables panel to explore their structure. + +# Simple GET — returns parsed JSON automatically +print "--- Fetching a TODO item ---" +let todo = http get "https://jsonplaceholder.typicode.com/todos/1" +print $todo +print $"Title: ($todo.title)" +print $"Completed: ($todo.completed)" + +# GET a list and filter it +print "\n--- Fetching users ---" +let users = http get "https://jsonplaceholder.typicode.com/users" +let names = $users | select name email +print ($names | first 3) + +# GET with query parameters +print "\n--- Fetching posts by user 1 ---" +let posts = http get "https://jsonplaceholder.typicode.com/posts?userId=1" +let titles = $posts | get title | first 3 +print $titles + +# POST request with a JSON body. +# `http post` takes the body as a string/binary, so build the record and +# serialize it with `to json` (set --content-type so the API parses it). +print "\n--- Creating a new post ---" +let body = { + title: "Debug Session Notes" + body: "Stepping through HTTP calls is great for understanding APIs." + userId: 1 +} +let new_post = http post --content-type application/json "https://jsonplaceholder.typicode.com/posts" ($body | to json) +print $"Created post id: ($new_post.id)" + +# Working with the response — extract and transform +print "\n--- Comment analysis ---" +let comments = http get "https://jsonplaceholder.typicode.com/posts/1/comments" +let summary = $comments | each { |c| + { + author: $c.name + email: $c.email + length: ($c.body | str length) + } +} +print $summary + +# Error handling for HTTP requests +print "\n--- Handling HTTP errors ---" +try { + let bad = http get "https://jsonplaceholder.typicode.com/nonexistent" + print $bad +} catch { |err| + print $"Request failed: ($err.msg)" +} diff --git a/examples/debug/12_error_handling.nu b/examples/debug/12_error_handling.nu new file mode 100644 index 0000000..ccac374 --- /dev/null +++ b/examples/debug/12_error_handling.nu @@ -0,0 +1,76 @@ +# Error handling: try/catch, error make, and error propagation. +# Enable "break on exceptions" in the debugger to pause on errors. + +# Basic try/catch +print "--- Basic try/catch ---" +try { + let x = 10 / 0 + print $"result: ($x)" +} catch { |err| + print $"Caught error: ($err.msg)" +} + +# Custom errors with `error make` +def validate_age [age: int] { + if $age < 0 { + error make { + msg: "Invalid age" + label: { + text: "age cannot be negative" + span: (metadata $age).span + } + } + } + if $age > 150 { + error make { msg: $"Unrealistic age: ($age)" } + } + print $"Age ($age) is valid" +} + +print "\n--- Custom errors ---" +try { + validate_age 25 + validate_age -5 +} catch { |err| + print $"Validation failed: ($err.msg)" +} + +# Error propagation — errors bubble up through call stacks +def inner [] { + error make { msg: "something went wrong deep inside" } +} + +def middle [] { + print " middle: calling inner..." + inner + print " middle: this never runs" +} + +def outer [] { + print " outer: calling middle..." + try { + middle + } catch { |err| + print $" outer caught: ($err.msg)" + } +} + +print "\n--- Error propagation ---" +outer + +# Practical pattern: parse with fallback +def safe_parse_int [s: string] { + try { + $s | into int + } catch { + null + } +} + +print "\n--- Safe parsing ---" +let inputs = ["42", "hello", "99", "", "7"] +let parsed = $inputs | each { |s| + let val = safe_parse_int $s + { input: $s, parsed: $val, ok: ($val != null) } +} +print $parsed diff --git a/examples/debug/13_data_structures.nu b/examples/debug/13_data_structures.nu new file mode 100644 index 0000000..5336e06 --- /dev/null +++ b/examples/debug/13_data_structures.nu @@ -0,0 +1,66 @@ +# Data structures: records, tables, nested data, and binary. +# Expand variables in the debugger's Variables panel to explore deeply +# nested structures. The data visualizer shows tables as grids. + +# Simple record +let server = { + host: "api.example.com" + port: 443 + protocol: "https" + timeout_ms: 5000 +} +print $"Connecting to ($server.host):($server.port)" + +# Table (list of records with consistent columns) +let inventory = [ + { item: "Widget A", qty: 150, price: 9.99, category: "hardware" } + { item: "Gadget B", qty: 42, price: 24.50, category: "electronics" } + { item: "Doohickey", qty: 300, price: 2.99, category: "hardware" } + { item: "Thingamajig", qty: 8, price: 199.00, category: "electronics" } +] +print $inventory + +# Deeply nested data — the Variables panel hydrates lazily +let org = { + name: "Acme Corp" + departments: [ + { + name: "Engineering" + teams: [ + { name: "Backend", members: ["Alice", "Bob", "Carol"] } + { name: "Frontend", members: ["Dave", "Eve"] } + ] + } + { + name: "Marketing" + teams: [ + { name: "Content", members: ["Frank", "Grace"] } + ] + } + ] + metadata: { + founded: 1990 + locations: ["NYC", "SF", "London"] + active: true + } +} +print $"Company: ($org.name)" +print $"First team: ($org.departments.0.teams.0.name)" + +# Binary data — displays as hex in the data visualizer +let magic_bytes = 0x[89 50 4e 47 0d 0a 1a 0a] # PNG header +let packet = 0x[45 00 00 3c 1c 46 40 00 40 06 b1 e6 ac 10 0a 63 ac 10 0a 0c] +print $"PNG header: ($magic_bytes | bytes length) bytes" +print $"IP packet: ($packet | bytes length) bytes" + +# Record manipulation +let base_config = { debug: false, log_level: "info", port: 3000 } +let dev_overrides = { debug: true, log_level: "trace" } +let dev_config = $base_config | merge $dev_overrides +print $dev_config + +# Table operations +let expensive = $inventory | where { $in.price > 10 } | sort-by price --reverse +let by_category = $inventory | group-by category +print $"Expensive items: ($expensive | get item)" +print $"Categories: ($by_category | columns)" diff --git a/examples/debug/14_string_processing.nu b/examples/debug/14_string_processing.nu new file mode 100644 index 0000000..fc0681a --- /dev/null +++ b/examples/debug/14_string_processing.nu @@ -0,0 +1,61 @@ +# String processing: interpolation, splitting, regex, and transforms. +# Watch string values change as you step through each operation. + +let raw = " Hello, World! Nushell is great. " + +# Trimming and basic transforms +let trimmed = $raw | str trim +let upper = $trimmed | str upcase +let lower = $trimmed | str downcase +print $"Trimmed: '($trimmed)'" +print $"Upper: ($upper)" + +# Splitting and joining +let csv_line = "alice,30,engineer,NYC" +let fields = $csv_line | split row "," +print $"Fields: ($fields)" +print $"Name: ($fields | get 0)" + +let rejoined = $fields | str join " | " +print $"Rejoined: ($rejoined)" + +# String contains / starts-with / ends-with +let filename = "report_2024_final.pdf" +let is_pdf = $filename | str ends-with ".pdf" +let is_report = $filename | str starts-with "report" +let has_year = $filename | str contains "2024" +print $"($filename): pdf=($is_pdf) report=($is_report) has_year=($has_year)" + +# Substring and replace +let sentence = "The quick brown fox jumps over the lazy dog" +let replaced = $sentence | str replace "fox" "cat" +let replaced_all = $sentence | str replace --all "the" "a" +print $"Replace: ($replaced)" +print $"Replace all: ($replaced_all)" + +# Regex matching +let log_line = "2024-03-15 14:23:45 [ERROR] Connection timeout after 30s" +let has_error = $log_line | str contains "ERROR" +let parts = $log_line | parse "{date} {time} [{level}] {message}" +print $parts + +# Building strings from data +let users = [ + { name: "Alice", role: "admin" } + { name: "Bob", role: "user" } + { name: "Carol", role: "user" } +] +let user_list = $users | each { |u| + $"• ($u.name) \(($u.role))" +} | str join "\n" +print $user_list + +# Multi-line string (heredoc-style) +let template = $" +Server Report +============= +Host: ($raw | str trim) +Users: ($users | length) +Status: OK +" +print $template diff --git a/examples/debug/15_modules.nu b/examples/debug/15_modules.nu new file mode 100644 index 0000000..92a8673 --- /dev/null +++ b/examples/debug/15_modules.nu @@ -0,0 +1,17 @@ +# Modules and sourcing: importing code from other files. +# Breakpoints work inside sourced files — try setting one in helper.nu. + +# `source` loads definitions into the current scope +source helper.nu + +# Use the imported `double` function +let x = double 21 +print $"double 21 = ($x)" + +let y = double 100 +print $"double 100 = ($y)" + +# Multiple calls — step into each to enter helper.nu +let values = [3, 7, 15, 22] +let doubled_list = $values | each { |v| double $v } +print $"Doubled list: ($doubled_list)" diff --git a/examples/debug/16_environment.nu b/examples/debug/16_environment.nu new file mode 100644 index 0000000..aa8c8f5 --- /dev/null +++ b/examples/debug/16_environment.nu @@ -0,0 +1,37 @@ +# Environment variables: reading, setting, and scoped env. +# Watch the Globals → $env scope in the Variables panel update. + +# Reading existing env vars +print $"Current directory: ($env.PWD)" +print $"Home: ($env.HOME? | default 'not set')" + +# Setting env vars — shows up in the Environment scope +$env.MY_APP_MODE = "debug" +$env.MY_APP_PORT = "8080" +$env.MY_APP_VERSION = "1.2.3" +print $"App mode: ($env.MY_APP_MODE)" + +# Env vars in commands +let config_str = $"($env.MY_APP_MODE):($env.MY_APP_PORT)" +print $"Config: ($config_str)" + +# Scoped environment with `with-env` +print "\n--- Scoped env ---" +with-env { DATABASE_URL: "postgres://localhost/test" } { + print $"Inside: DB=($env.DATABASE_URL)" +} +# DATABASE_URL no longer exists here + +# Building PATH-like variables +$env.MY_SEARCH_PATH = ["/usr/local/bin", "/usr/bin", "/bin"] +let full_path = $env.MY_SEARCH_PATH | str join ":" +print $"Search path: ($full_path)" + +# Environment and conditionals +$env.DEPLOY_ENV = "staging" +let db = match $env.DEPLOY_ENV { + "production" => "prod-db.internal" + "staging" => "staging-db.internal" + _ => "localhost" +} +print $"Database host: ($db)" diff --git a/examples/debug/17_file_operations.nu b/examples/debug/17_file_operations.nu new file mode 100644 index 0000000..c4ef4de --- /dev/null +++ b/examples/debug/17_file_operations.nu @@ -0,0 +1,37 @@ +# File operations: listing, reading, and path manipulation. +# These work on the actual filesystem — watch the results in Variables. + +# List files in the current directory +let entries = ls | select name type size +print $"Files in current dir:" +print ($entries | first 5) + +# Path operations +let filepath = "/home/user/documents/report.pdf" +let parsed = $filepath | path parse +print $"\nPath parts:" +print $" parent: ($parsed.parent)" +print $" stem: ($parsed.stem)" +print $" extension: ($parsed.extension)" + +# Working with globs +let nu_files = ls *.nu | get name +print $"\n.nu files here: ($nu_files)" + +# File size analysis +let all_files = ls *.nu | select name size | sort-by size --reverse +print $"\nFiles by size:" +print $all_files + +# Path construction +let parts = { parent: "/tmp", stem: "output", extension: "json" } +let built_path = [$parts.parent, $"($parts.stem).($parts.extension)"] | path join +print $"\nBuilt path: ($built_path)" + +# Check existence +let targets = ["./helper.nu", "./nonexistent.nu", "./demo.nu"] +let existence = $targets | each { |t| + { path: $t, exists: ($t | path exists) } +} +print $"\nExistence check:" +print $existence diff --git a/examples/debug/18_parallel.nu b/examples/debug/18_parallel.nu new file mode 100644 index 0000000..2131742 --- /dev/null +++ b/examples/debug/18_parallel.nu @@ -0,0 +1,37 @@ +# Parallel execution: `par-each` for concurrent work. +# Note: breakpoints inside `par-each` closures may behave differently +# than sequential `each` because work runs on multiple threads. + +let urls = [ + "https://jsonplaceholder.typicode.com/todos/1" + "https://jsonplaceholder.typicode.com/todos/2" + "https://jsonplaceholder.typicode.com/todos/3" + "https://jsonplaceholder.typicode.com/todos/4" + "https://jsonplaceholder.typicode.com/todos/5" +] + +# Sequential fetch (for comparison / debugging — steppable) +print "--- Sequential fetch ---" +let seq_results = $urls | each { |url| + let resp = http get $url + { id: $resp.id, title: $resp.title } +} +print ($seq_results | first 3) + +# Parallel fetch — faster but less debuggable +print "\n--- Parallel fetch ---" +let par_results = $urls | par-each { |url| + let resp = http get $url + { id: $resp.id, title: $resp.title } +} +print $par_results + +# CPU-bound parallel work +print "\n--- Parallel computation ---" +let inputs = 1..10 | each { $in } +let computed = $inputs | par-each { |n| + # Simulate work + let result = 1..$n | reduce --fold 1 { |it, acc| $acc * $it } + { n: $n, factorial: $result } +} +print ($computed | sort-by n) diff --git a/examples/debug/19_practical_script.nu b/examples/debug/19_practical_script.nu new file mode 100644 index 0000000..7b4579b --- /dev/null +++ b/examples/debug/19_practical_script.nu @@ -0,0 +1,59 @@ +# A practical script: a mini project analyzer. +# Combines many nushell features in a realistic scenario. +# Good for end-to-end stepping through a complete workflow. + +def count_lines [file: string] { + open $file | lines | length +} + +def analyze_extension [ext: string, files: list] { + let matching = $files | where { $in.name | str ends-with $ext } + let total_size = $matching | get size | math sum + { + extension: $ext + count: ($matching | length) + total_size: $total_size + } +} + +# --- Main logic --- + +print "=== Project Analyzer ===" +print $"Working directory: ($env.PWD)\n" + +# Gather all .nu files +let nu_files = ls *.nu +print $"Found ($nu_files | length) .nu files\n" + +# Analyze each file +let analysis = $nu_files | each { |f| + let lines = count_lines $f.name + { + name: $f.name + size: $f.size + lines: $lines + bytes_per_line: (if $lines > 0 { ($f.size | into int) / $lines } else { 0 }) + } +} + +# Sort and display +let by_lines = $analysis | sort-by lines --reverse +print "--- Files by line count ---" +print $by_lines + +# Summary statistics +let total_lines = $analysis | get lines | math sum +let total_size = $analysis | get size | math sum +let avg_lines = $analysis | get lines | math avg + +print $"\n--- Summary ---" +print $"Total files: ($analysis | length)" +print $"Total lines: ($total_lines)" +print $"Total size: ($total_size)" +print $"Average lines/file: ($avg_lines | math round --precision 1)" + +# Find the largest and smallest +let largest = $by_lines | first +let smallest = $by_lines | last +print $"\nLargest: ($largest.name) \(($largest.lines) lines)" +print $"Smallest: ($smallest.name) \(($smallest.lines) lines)" diff --git a/examples/debug/closure.nu b/examples/debug/closure.nu new file mode 100644 index 0000000..6501f1e --- /dev/null +++ b/examples/debug/closure.nu @@ -0,0 +1,4 @@ +let double = {|x| $x * 2} +let n = 10 +let scaled = {|x| $x * $n} +print "done" diff --git a/examples/debug/helper.nu b/examples/debug/helper.nu new file mode 100644 index 0000000..9e358fe --- /dev/null +++ b/examples/debug/helper.nu @@ -0,0 +1,3 @@ +def double [x: int] { + $x * 2 +} From 101a900fe363b83dbc910c2b20186b67f88e8b9b Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 02/10] feat: Add Nushell debugger time travel, restart-on-save, and DAP tracing --- client/src/debug/restart/register.ts | 10 +++ client/src/debug/restart/restart-on-save.ts | 72 +++++++++++++++++++ client/src/debug/time-travel/register.ts | 13 ++++ .../debug/time-travel/time-travel-defaults.ts | 28 ++++++++ client/src/debug/trace/dap-trace.ts | 61 ++++++++++++++++ client/src/debug/trace/register.ts | 15 ++++ package.json | 22 +++++- 7 files changed, 220 insertions(+), 1 deletion(-) create mode 100644 client/src/debug/restart/register.ts create mode 100644 client/src/debug/restart/restart-on-save.ts create mode 100644 client/src/debug/time-travel/register.ts create mode 100644 client/src/debug/time-travel/time-travel-defaults.ts create mode 100644 client/src/debug/trace/dap-trace.ts create mode 100644 client/src/debug/trace/register.ts diff --git a/client/src/debug/restart/register.ts b/client/src/debug/restart/register.ts new file mode 100644 index 0000000..4929af4 --- /dev/null +++ b/client/src/debug/restart/register.ts @@ -0,0 +1,10 @@ +import * as vscode from 'vscode'; + +import { noteSaveReason, restartIfDebuggingNuScript } from './restart-on-save'; + +export function register(): vscode.Disposable[] { + return [ + vscode.workspace.onWillSaveTextDocument(noteSaveReason), + vscode.workspace.onDidSaveTextDocument(restartIfDebuggingNuScript), + ]; +} diff --git a/client/src/debug/restart/restart-on-save.ts b/client/src/debug/restart/restart-on-save.ts new file mode 100644 index 0000000..7f0d4af --- /dev/null +++ b/client/src/debug/restart/restart-on-save.ts @@ -0,0 +1,72 @@ +import * as vscode from 'vscode'; + +import { isNuDocument } from '../shared/nu-document'; +import { activeNuSession } from '../shared/session'; + +/** + * Documents being saved by the user (Ctrl+S), as opposed to auto-save or + * focus changes. Only `onWillSaveTextDocument` reports why a save happens, + * so it is recorded here and checked once the save is done. + */ +const manualSaves = new Set(); + +export function noteSaveReason(event: vscode.TextDocumentWillSaveEvent): void { + const key = event.document.uri.toString(); + if (event.reason === vscode.TextDocumentSaveReason.Manual) { + manualSaves.add(key); + } else { + manualSaves.delete(key); + } +} + +/** + * Hot restart: manually saving the debugged script during an active nushell + * debug session restarts the session, so the adapter re-reads it from disk. + * Auto-saves and other files are ignored — each restart re-runs the script's + * side effects. + */ +export function restartIfDebuggingNuScript(doc: vscode.TextDocument): void { + const wasManual = manualSaves.delete(doc.uri.toString()); + if (!wasManual) { + return; + } + + if (!isRestartOnSaveEnabled()) { + return; + } + + if (!isNuDocument(doc)) { + return; + } + + const session = activeNuSession(); + if (!session) { + return; + } + + if (!isSessionProgram(session, doc)) { + return; + } + + void vscode.commands.executeCommand('workbench.action.debug.restart'); +} + +function isSessionProgram( + session: vscode.DebugSession, + doc: vscode.TextDocument, +): boolean { + const program: unknown = session.configuration.program; + if (typeof program !== 'string') { + return false; + } + + // Uri.file normalizes the path the same way the document's uri is (e.g. the + // drive letter case on Windows). + return vscode.Uri.file(program).fsPath === doc.uri.fsPath; +} + +function isRestartOnSaveEnabled(): boolean { + return vscode.workspace + .getConfiguration('nushellDebugger') + .get('restartOnSave', false); +} diff --git a/client/src/debug/time-travel/register.ts b/client/src/debug/time-travel/register.ts new file mode 100644 index 0000000..5987c0b --- /dev/null +++ b/client/src/debug/time-travel/register.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +import { DEBUG_TYPE } from '../shared/debug-type'; +import { TimeTravelDefaults } from './time-travel-defaults'; + +export function register(): vscode.Disposable[] { + return [ + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPE, + new TimeTravelDefaults(), + ), + ]; +} diff --git a/client/src/debug/time-travel/time-travel-defaults.ts b/client/src/debug/time-travel/time-travel-defaults.ts new file mode 100644 index 0000000..bd22281 --- /dev/null +++ b/client/src/debug/time-travel/time-travel-defaults.ts @@ -0,0 +1,28 @@ +import * as vscode from 'vscode'; + +/** + * The adapter reads time travel from the launch arguments; the `nushellDebugger.timeTravel*` + * settings supply the defaults for whatever the launch config leaves out. + * Registered as its own configuration provider so the launch slice does not + * need to know the feature exists. + */ +export class TimeTravelDefaults implements vscode.DebugConfigurationProvider { + resolveDebugConfigurationWithSubstitutedVariables( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.ProviderResult { + const settings = vscode.workspace.getConfiguration('nushellDebugger'); + if (config.timeTravel === undefined) { + config.timeTravel = settings.get('timeTravel', true); + } + + if (config.timeTravelMaxSteps === undefined) { + config.timeTravelMaxSteps = settings.get( + 'timeTravelMaxSteps', + 10000, + ); + } + + return config; + } +} diff --git a/client/src/debug/trace/dap-trace.ts b/client/src/debug/trace/dap-trace.ts new file mode 100644 index 0000000..024f1d3 --- /dev/null +++ b/client/src/debug/trace/dap-trace.ts @@ -0,0 +1,61 @@ +import * as vscode from 'vscode'; + +/** Long messages (e.g. the `nuDapIr` listing) are cut to this many characters. */ +const MAX_MESSAGE_LENGTH = 2000; + +/** + * Logs the DAP traffic between VS Code and `nu --dap` when the + * `nushellDebugger.trace` setting is on. A request without a matching + * response shows what the adapter is stuck on. + */ +export class DapTraceFactory implements vscode.DebugAdapterTrackerFactory { + private readonly channel: vscode.OutputChannel; + + constructor(channel: vscode.OutputChannel) { + this.channel = channel; + } + + createDebugAdapterTracker( + session: vscode.DebugSession, + ): vscode.DebugAdapterTracker | undefined { + if (!isTraceEnabled()) { + return undefined; + } + + const log = (line: string) => + this.channel.appendLine(`${timestamp()} [${session.name}] ${line}`); + + return { + onWillStartSession: () => log('session starting'), + onWillReceiveMessage: (message: unknown) => log(`-> ${format(message)}`), + onDidSendMessage: (message: unknown) => log(`<- ${format(message)}`), + onWillStopSession: () => log('session stopping'), + onError: (error: Error) => log(`adapter error: ${error.message}`), + onExit: (code: number | undefined, signal: string | undefined) => + log(`adapter exited (code ${code ?? '-'}, signal ${signal ?? '-'})`), + }; + } +} + +function isTraceEnabled(): boolean { + return vscode.workspace + .getConfiguration('nushellDebugger') + .get('trace', false); +} + +/** Local time with milliseconds, e.g. `19:41:55.123`. */ +function timestamp(): string { + const now = new Date(); + const time = now.toTimeString().slice(0, 8); + const ms = String(now.getMilliseconds()).padStart(3, '0'); + return `${time}.${ms}`; +} + +function format(message: unknown): string { + const text = JSON.stringify(message); + if (text.length <= MAX_MESSAGE_LENGTH) { + return text; + } + + return `${text.slice(0, MAX_MESSAGE_LENGTH)}… (${text.length} chars)`; +} diff --git a/client/src/debug/trace/register.ts b/client/src/debug/trace/register.ts new file mode 100644 index 0000000..557d7ce --- /dev/null +++ b/client/src/debug/trace/register.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; + +import { DEBUG_TYPE } from '../shared/debug-type'; +import { DapTraceFactory } from './dap-trace'; + +export function register(): vscode.Disposable[] { + const channel = vscode.window.createOutputChannel('Nushell Debug'); + return [ + channel, + vscode.debug.registerDebugAdapterTrackerFactory( + DEBUG_TYPE, + new DapTraceFactory(channel), + ), + ]; +} diff --git a/package.json b/package.json index 5424b2e..b06ca4a 100644 --- a/package.json +++ b/package.json @@ -108,7 +108,27 @@ "scope": "resource", "type": "string", "default": "nu", - "description": "The path to the nushell executable." + "description": "Path to the `nu` executable used by the language server, the terminal profile and the debugger. When left at `nu`, the extension uses `nu` from PATH." + }, + "nushellDebugger.restartOnSave": { + "type": "boolean", + "default": false, + "description": "Hot restart: automatically restart the active Nushell debug session when you save the script being debugged. Auto-saves are ignored. The script re-runs from disk with breakpoints intact (state resets; side effects run again)." + }, + "nushellDebugger.timeTravel": { + "type": "boolean", + "default": true, + "description": "Time-travel debugging: record execution history so Step Back / Reverse Continue can revisit any line the script ran through (not just breakpoints). Records a lightweight state snapshot per line; turn off to record only at stops if a very hot loop makes recording costly." + }, + "nushellDebugger.timeTravelMaxSteps": { + "type": "number", + "default": 10000, + "description": "Maximum number of recorded steps kept for time travel (a ring buffer). Older steps are dropped once the limit is reached, so Reverse can go back at most this many steps." + }, + "nushellDebugger.trace": { + "type": "boolean", + "default": false, + "description": "Log every Debug Adapter Protocol message between VS Code and `nu --dap` to the \"Nushell Debug\" output channel. Useful when a debug session hangs: the request without a response shows what the adapter is stuck on. Applies to sessions started after changing it." } } }, From 190995cba3adac6a2c9f3e65e2fee5bbe082ed29 Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 03/10] feat: Implement Nushell debugger variable visualizer --- client/src/debug/tests/cell-values.test.ts | 37 ++++ client/src/debug/tests/hex-dump.test.ts | 42 ++++ client/src/debug/tests/text-format.test.ts | 42 ++++ client/src/debug/visualize/cell-values.ts | 110 ++++++++++ client/src/debug/visualize/hex-dump.ts | 113 ++++++++++ client/src/debug/visualize/pinned-variable.ts | 67 ++++++ client/src/debug/visualize/register.ts | 15 ++ client/src/debug/visualize/text-format.ts | 115 ++++++++++ .../src/debug/visualize/visualize-command.ts | 97 +++++++++ .../src/debug/visualize/visualize-panel.html | 143 +++++++++++++ client/src/debug/visualize/visualize-panel.ts | 131 ++++++++++++ .../src/debug/visualize/visualize-request.ts | 32 +++ client/src/debug/visualize/webview-payload.ts | 7 + .../src/debug/visualize/webview/hex-view.ts | 43 ++++ client/src/debug/visualize/webview/main.ts | 45 ++++ .../src/debug/visualize/webview/table-view.ts | 202 ++++++++++++++++++ .../src/debug/visualize/webview/text-view.ts | 14 ++ 17 files changed, 1255 insertions(+) create mode 100644 client/src/debug/tests/cell-values.test.ts create mode 100644 client/src/debug/tests/hex-dump.test.ts create mode 100644 client/src/debug/tests/text-format.test.ts create mode 100644 client/src/debug/visualize/cell-values.ts create mode 100644 client/src/debug/visualize/hex-dump.ts create mode 100644 client/src/debug/visualize/pinned-variable.ts create mode 100644 client/src/debug/visualize/register.ts create mode 100644 client/src/debug/visualize/text-format.ts create mode 100644 client/src/debug/visualize/visualize-command.ts create mode 100644 client/src/debug/visualize/visualize-panel.html create mode 100644 client/src/debug/visualize/visualize-panel.ts create mode 100644 client/src/debug/visualize/visualize-request.ts create mode 100644 client/src/debug/visualize/webview-payload.ts create mode 100644 client/src/debug/visualize/webview/hex-view.ts create mode 100644 client/src/debug/visualize/webview/main.ts create mode 100644 client/src/debug/visualize/webview/table-view.ts create mode 100644 client/src/debug/visualize/webview/text-view.ts diff --git a/client/src/debug/tests/cell-values.test.ts b/client/src/debug/tests/cell-values.test.ts new file mode 100644 index 0000000..36cade8 --- /dev/null +++ b/client/src/debug/tests/cell-values.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { cellText, compareCells, isBinary, isRecord } from '../visualize/cell-values'; + +test("isBinary and isRecord tell the adapter's binary marker apart from a record", () => { + const binary = { $nuBinary: '00ff', length: 2 }; + assert.equal(isBinary(binary), true); + assert.equal(isRecord(binary), false); + assert.equal(isRecord({ name: 'x' }), true); + assert.equal(isRecord([1, 2]), false); + assert.equal(isRecord(null), false); +}); + +test('cellText shows scalars verbatim, nothing as null, binaries by size', () => { + assert.equal(cellText('hi'), 'hi'); + assert.equal(cellText(3), '3'); + assert.equal(cellText(null), 'null'); + assert.equal(cellText(undefined), 'null'); + assert.equal(cellText({ $nuBinary: '00ff', length: 2 }), ''); +}); + +test('cellText renders a nested value as JSON, cut off at 120 characters', () => { + assert.equal(cellText({ a: [1, 2] }), '{"a":[1,2]}'); + const long = cellText({ text: 'x'.repeat(200) }); + assert.equal(long.length, 118); + assert.equal(long.endsWith('…'), true); +}); + +test('compareCells sorts nothing last and everything else numerically or as text', () => { + assert.equal(compareCells(null, 1) > 0, true); + assert.equal(compareCells(1, undefined) < 0, true); + assert.equal(compareCells(null, undefined), 0); + assert.equal(compareCells(2, 10) < 0, true); + assert.equal(compareCells('2', '10') < 0, true); + assert.equal(compareCells('b', 'a') > 0, true); +}); diff --git a/client/src/debug/tests/hex-dump.test.ts b/client/src/debug/tests/hex-dump.test.ts new file mode 100644 index 0000000..331854d --- /dev/null +++ b/client/src/debug/tests/hex-dump.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { byteCountNote, decodeHex, hexDump } from '../visualize/hex-dump'; + +test('decodeHex turns hex digits into bytes', () => { + assert.deepEqual(Array.from(decodeHex('00ff10')), [0, 255, 16]); +}); + +test('byteCountNote mentions the total only when the value was cut off', () => { + assert.equal(byteCountNote(4, 4), '4 byte(s)'); + assert.equal(byteCountNote(4, 10), '4 byte(s) (showing first 4 of 10)'); +}); + +test('hexDump lists offset, grouped hex and escaped ASCII', () => { + const line = hexDump(decodeHex('413c'), 1); + assert.equal( + line.startsWith( + '00000000 41 3c', + ), + true, + ); + assert.equal(line.includes('A<'), true); + assert.equal(line.endsWith('\n'), true); +}); + +test('hexDump gives complete 2–8 byte groups an integer tooltip, but not a cut-off tail group', () => { + const dump = hexDump(decodeHex('010203'), 2); + assert.equal(dump.includes(' title="u16 LE: 513 · BE: 258"'), true); + assert.equal(dump.split('title=').length - 1, 1); +}); + +test('hexDump pads a short line with blank groups so the ASCII column stays aligned', () => { + // 1 byte shown, 15 blank two-character groups, single spaces between groups, two before the ASCII column. + const padding = ' '.repeat(1 + 15 * 2 + 14 + 2); + assert.equal( + hexDump(decodeHex('41'), 1).includes( + `${padding}A`, + ), + true, + ); +}); diff --git a/client/src/debug/tests/text-format.test.ts b/client/src/debug/tests/text-format.test.ts new file mode 100644 index 0000000..010e380 --- /dev/null +++ b/client/src/debug/tests/text-format.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { formatText, formatXml, highlightJson } from '../visualize/text-format'; + +test('formatText pretty-prints and highlights JSON', () => { + const { note, html } = formatText('{"a":1,"b":"x","c":true}'); + assert.equal(note, 'detected JSON — formatted (24 chars)'); + assert.equal(html.includes('"a":'), true); + assert.equal(html.includes('1'), true); + assert.equal(html.includes('"x"'), true); + assert.equal(html.includes('true'), true); +}); + +test('highlightJson escapes markup inside strings before highlighting', () => { + assert.equal(highlightJson('""'), '"<b>"'); +}); + +test('formatText indents and highlights XML', () => { + const { note, html } = formatText('t'); + assert.equal(note, 'detected XML — formatted (34 chars)'); + assert.equal(html.includes('item'), true); + assert.equal( + html.includes('id="1"'), + true, + ); +}); + +test('formatXml puts one tag per line, indented by depth', () => { + assert.equal( + formatXml('t'), + '\n t\n \n', + ); +}); + +test('formatText shows anything else verbatim, escaped', () => { + assert.deepEqual(formatText('a '), { + note: '5 chars', + html: 'a <b>', + }); + assert.equal(formatText('{nope').note, '5 chars'); +}); diff --git a/client/src/debug/visualize/cell-values.ts b/client/src/debug/visualize/cell-values.ts new file mode 100644 index 0000000..9319de8 --- /dev/null +++ b/client/src/debug/visualize/cell-values.ts @@ -0,0 +1,110 @@ +// Reading the values in the visualizer's JSON payload: what kind each one is, +// how it shows in a table cell, and how two cells order. DOM-free, so the node +// test suite covers it; the webview bundle imports it too. + +/** How the adapter encodes a nu binary in the JSON payload: hex digits plus the original byte count. */ +export interface BinaryMarker { + $nuBinary: string; + length: number; +} + +/** A nu record as it arrives in the payload. */ +export type NuRecord = { [field: string]: unknown }; + +/** Longest cell text for a nested value before it is cut off with an ellipsis. */ +const MAX_CELL_JSON = 120; + +export function isBinary(value: unknown): value is BinaryMarker { + if (!isPlainObject(value)) { + return false; + } + + return typeof (value as { $nuBinary?: unknown }).$nuBinary === 'string'; +} + +export function isRecord(value: unknown): value is NuRecord { + return isPlainObject(value) && !isBinary(value); +} + +function isPlainObject(value: unknown): value is object { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isNothing(value: unknown): value is null | undefined { + return value === null || value === undefined; +} + +/** One cell's text: scalars verbatim, nested values as truncated JSON. */ +export function cellText(value: unknown): string { + if (isNothing(value)) { + return 'null'; + } + + if (isBinary(value)) { + return ``; + } + + if (typeof value === 'object') { + return truncate(JSON.stringify(value)); + } + + return String(value); +} + +function truncate(json: string): string { + if (json.length <= MAX_CELL_JSON) { + return json; + } + + return `${json.slice(0, MAX_CELL_JSON - 3)}…`; +} + +/** Column order: nothing sorts last, numbers (and numeric strings) numerically, everything else as text. */ +export function compareCells(a: unknown, b: unknown): number { + if (isNothing(a) || isNothing(b)) { + return compareNothing(a, b); + } + + if (typeof a === 'number' && typeof b === 'number') { + return a - b; + } + + const aNumber = asNumber(a); + const bNumber = asNumber(b); + if (aNumber !== undefined && bNumber !== undefined) { + return aNumber - bNumber; + } + + return cellText(a).localeCompare(cellText(b)); +} + +/** At least one side is nothing: nothing sorts after everything, two nothings are equal. */ +function compareNothing(a: unknown, b: unknown): number { + if (isNothing(a) && isNothing(b)) { + return 0; + } + + if (isNothing(a)) { + return 1; + } + + return -1; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === 'object') { + return undefined; + } + + const text = String(value).trim(); + if (text === '') { + return undefined; + } + + const number = Number(text); + if (Number.isNaN(number)) { + return undefined; + } + + return number; +} diff --git a/client/src/debug/visualize/hex-dump.ts b/client/src/debug/visualize/hex-dump.ts new file mode 100644 index 0000000..8ee013d --- /dev/null +++ b/client/src/debug/visualize/hex-dump.ts @@ -0,0 +1,113 @@ +// The hex listing of a binary value: offset · grouped hex bytes · ASCII, as +// markup text. DOM-free, so the node test suite covers it. + +import { escapeHtml } from '../shared/escape-html'; + +/** Bytes per line: 16, or one group when a group is wider than that. */ +const BYTES_PER_LINE = 16; + +export function decodeHex(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); + } + + return bytes; +} + +export function byteCountNote(shown: number, total: number): string { + if (total > shown) { + return `${shown} byte(s) (showing first ${shown} of ${total})`; + } + + return `${shown} byte(s)`; +} + +export function hexDump(bytes: Uint8Array, groupSize: number): string { + const perLine = Math.max(BYTES_PER_LINE, groupSize); + const lines: string[] = []; + for (let offset = 0; offset < bytes.length; offset += perLine) { + const end = Math.min(offset + perLine, bytes.length); + lines.push( + `${offsetHtml(offset)} ${groupsHtml(bytes, offset, end, perLine, groupSize)} ${asciiHtml(bytes, offset, end)}\n`, + ); + } + + return lines.join(''); +} + +function offsetHtml(offset: number): string { + return `${offset.toString(16).padStart(8, '0')}`; +} + +/** Every group on the line; the last line is padded with blanks so the ASCII column stays aligned. */ +function groupsHtml( + bytes: Uint8Array, + offset: number, + end: number, + perLine: number, + groupSize: number, +): string { + const cells: string[] = []; + for (let start = offset; start < offset + perLine; start += groupSize) { + if (start >= end) { + cells.push(' '.repeat(groupSize * 2)); + continue; + } + + const groupEnd = Math.min(start + groupSize, end); + const hex = Array.from(bytes.subarray(start, groupEnd), (byte) => + byte.toString(16).padStart(2, '0'), + ) + .join('') + .padEnd(groupSize * 2, ' '); + cells.push( + `${hex}`, + ); + } + + return cells.join(' '); +} + +/** For a complete group of 2–8 bytes: its value as an unsigned integer, both endiannesses. A cut-off tail group gets none. */ +function groupTooltip( + bytes: Uint8Array, + start: number, + end: number, + groupSize: number, +): string { + const size = end - start; + if (size !== groupSize) { + return ''; + } + + if (size < 2 || size > 8) { + return ''; + } + + let littleEndian = 0n; + let bigEndian = 0n; + for (let i = 0; i < size; i++) { + bigEndian = (bigEndian << 8n) | BigInt(bytes[start + i]); + littleEndian |= BigInt(bytes[start + i]) << BigInt(8 * i); + } + + return ` title="u${size * 8} LE: ${littleEndian} · BE: ${bigEndian}"`; +} + +function asciiHtml(bytes: Uint8Array, offset: number, end: number): string { + let text = ''; + for (let i = offset; i < end; i++) { + text += asciiChar(bytes[i]); + } + + return `${text}`; +} + +function asciiChar(byte: number): string { + if (byte < 32 || byte >= 127) { + return '·'; + } + + return escapeHtml(String.fromCharCode(byte)); +} diff --git a/client/src/debug/visualize/pinned-variable.ts b/client/src/debug/visualize/pinned-variable.ts new file mode 100644 index 0000000..b896d19 --- /dev/null +++ b/client/src/debug/visualize/pinned-variable.ts @@ -0,0 +1,67 @@ +import * as vscode from 'vscode'; + +import { + isVisualizePanelOpen, + markVisualizePanelStale, + updateVisualizePanel, +} from './visualize-panel'; +import { LOCALS_REFERENCE, fetchVisualize } from './visualize-request'; + +// Live tracking for the visualize panel. +// +// The panel can follow one variable: on every stop we re-fetch it and repaint, +// so the grid tracks execution. Only members of the top-level Locals scope can +// be re-fetched, because only they have a stable container + name; nested +// values are addressed by a reference that is invalidated at the next stop. + +let pinnedName: string | undefined; + +/** + * Bumped per refresh and per pin change; a response that is not from the + * latest refresh, or that arrives after the user picked another value, is + * discarded. + */ +let generation = 0; + +export function pinVariable(name: string): void { + pinnedName = name; + generation++; +} + +export function unpinVariable(): void { + pinnedName = undefined; + generation++; +} + +/** Re-read the pinned variable and repaint the visualize panel. */ +export async function refreshPinnedVariable( + session: vscode.DebugSession, +): Promise { + const name = pinnedName; + if (!name) { + return; + } + + if (!isVisualizePanelOpen()) { + return; + } + + const mine = ++generation; + try { + const body = await fetchVisualize(session, { + containerReference: LOCALS_REFERENCE, + name, + }); + if (mine !== generation) { + return; // a later stop or a new pick superseded this answer + } + + updateVisualizePanel(name, body); + } catch { + if (mine !== generation) { + return; + } + + markVisualizePanelStale(); // the variable is out of scope at this stop + } +} diff --git a/client/src/debug/visualize/register.ts b/client/src/debug/visualize/register.ts new file mode 100644 index 0000000..46206b4 --- /dev/null +++ b/client/src/debug/visualize/register.ts @@ -0,0 +1,15 @@ +import * as vscode from 'vscode'; + +import { onStopped } from '../shared/stopped-event'; +import { refreshPinnedVariable } from './pinned-variable'; +import { visualizeVariable } from './visualize-command'; + +export function register(): vscode.Disposable[] { + return [ + vscode.commands.registerCommand( + 'nushell-debug.visualizeVariable', + visualizeVariable, + ), + onStopped((session) => void refreshPinnedVariable(session)), + ]; +} diff --git a/client/src/debug/visualize/text-format.ts b/client/src/debug/visualize/text-format.ts new file mode 100644 index 0000000..5505b2f --- /dev/null +++ b/client/src/debug/visualize/text-format.ts @@ -0,0 +1,115 @@ +// Formatting a string value for the visualizer: JSON and XML are detected, +// pretty-printed and syntax-highlighted; anything else is shown verbatim. +// DOM-free (produces markup as text), so the node test suite covers it. + +import { escapeHtml } from '../shared/escape-html'; + +/** A string ready for display: the one-line note above it and the safe markup of the listing. */ +export interface FormattedText { + note: string; + html: string; +} + +export function formatText(text: string): FormattedText { + const trimmed = text.trim(); + const json = parseJson(trimmed); + if (json !== undefined) { + return { + note: `detected JSON — formatted (${text.length} chars)`, + html: highlightJson(JSON.stringify(json, null, 2)), + }; + } + + if (looksLikeXml(trimmed)) { + return { + note: `detected XML — formatted (${text.length} chars)`, + html: highlightXml(formatXml(trimmed)), + }; + } + + return { note: `${text.length} chars`, html: escapeHtml(text) }; +} + +/** The parsed value when the text is a JSON object or array, otherwise undefined. */ +function parseJson(text: string): unknown { + if (!text.startsWith('{') && !text.startsWith('[')) { + return undefined; + } + + try { + return JSON.parse(text); + } catch { + return undefined; // not JSON after all + } +} + +function looksLikeXml(text: string): boolean { + return /^<[!?]?[A-Za-z_]/.test(text) && text.includes('>'); +} + +/** Keys blue, strings orange, numbers green, keywords purple (see the .k/.s/.n/.b rules). */ +export function highlightJson(pretty: string): string { + const tokenPattern = + /("(?:\\u[a-fA-F0-9]{4}|\\[^u]|[^\\"])*")(\s*:)?|\b(true|false|null)\b|-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b/g; + return escapeHtml(pretty).replace( + tokenPattern, + (token, string?: string, colon?: string, keyword?: string) => { + if (string && colon) { + return `${string}${colon}`; + } + + if (string) { + return `${string}`; + } + + if (keyword) { + return `${keyword}`; + } + + return `${token}`; + }, + ); +} + +/** One tag per line, indented by nesting depth. */ +export function formatXml(xml: string): string { + const lines = xml.replace(/>\s*\n<').split('\n'); + let depth = 0; + const out: string[] = []; + for (const raw of lines) { + const line = raw.trim(); + if (/^<\//.test(line)) { + depth = Math.max(0, depth - 1); + } + + out.push(' '.repeat(depth) + line); + if (opensElement(line)) { + depth++; + } + } + + return out.join('\n'); +} + +/** `` alone on its line: not a closing, self-closing, declaration or comment tag, and no inline ``. */ +function opensElement(line: string): boolean { + return ( + /^<[^!?/][^>]*>$/.test(line) && !/\/>$/.test(line) && !/<\//.test(line) + ); +} + +/** Tags blue, attribute names yellow, attribute values orange. Escapes the text first. */ +function highlightXml(formatted: string): string { + const tagPattern = /(<\/?)([\w:.-]+)((?:[^&]|&(?!gt;))*?)(\/?>)/g; + const attributePattern = /([\w:.-]+)(=)("[^"]*"|'[^']*')/g; + return escapeHtml(formatted).replace( + tagPattern, + (_tag, open: string, name: string, attributes: string, close: string) => { + const highlighted = attributes.replace( + attributePattern, + '$1$2$3', + ); + return `${open}${name}${highlighted}${close}`; + }, + ); +} diff --git a/client/src/debug/visualize/visualize-command.ts b/client/src/debug/visualize/visualize-command.ts new file mode 100644 index 0000000..71a6fb9 --- /dev/null +++ b/client/src/debug/visualize/visualize-command.ts @@ -0,0 +1,97 @@ +import * as vscode from 'vscode'; + +import { activeNuSession } from '../shared/session'; +import { pinVariable, unpinVariable } from './pinned-variable'; +import { openVisualizePanel } from './visualize-panel'; +import { + LOCALS_REFERENCE, + VariableAddress, + fetchVisualize, +} from './visualize-request'; + +/** A variable as VS Code hands it to a `debug/variables/context` command. */ +interface MenuVariable { + name: string; + value: string; + variablesReference: number; + type?: string; // unused here; part of what VS Code passes +} + +/** The scope or parent value the variable was listed under. */ +interface MenuContainer { + variablesReference?: number; +} + +/** Shape VS Code passes to `debug/variables/context` menu commands. */ +export interface VariableContext { + sessionId?: string; // unused here; part of what VS Code passes + container?: MenuContainer; + variable?: MenuVariable; +} + +/** + * The "Visualize" command on the Variables view context menu: ask the adapter + * for the whole value as JSON and render it in the visualize panel. + */ +export async function visualizeVariable( + menuContext?: VariableContext, +): Promise { + const variable = menuContext?.variable; + if (!variable) { + return; + } + + const session = activeNuSession(); + if (!session) { + return; + } + + const address = addressOf(variable, menuContext?.container); + if (!address) { + void vscode.window.showInformationMessage( + `'${variable.name}': ${variable.value}`, + ); + return; + } + + try { + const body = await fetchVisualize(session, address); + openVisualizePanel(variable.name, body); + } catch (e) { + void vscode.window.showWarningMessage( + `Nushell Debug: cannot visualize '${variable.name}': ${String(e)}`, + ); + return; + } + + trackLive(variable.name, menuContext?.container); +} + +/** Expandable values are addressed by their own reference; leaves (strings, binaries, numbers) by container + name. */ +function addressOf( + variable: MenuVariable, + container?: MenuContainer, +): VariableAddress | undefined { + if (variable.variablesReference) { + return { variablesReference: variable.variablesReference }; + } + + if (container?.variablesReference) { + return { + containerReference: container.variablesReference, + name: variable.name, + }; + } + + return undefined; // unaddressable: nothing to fetch +} + +/** Only top-level Locals can be re-read on every stop; anything nested stays a one-off snapshot. */ +function trackLive(name: string, container?: MenuContainer): void { + if (container?.variablesReference !== LOCALS_REFERENCE) { + unpinVariable(); + return; + } + + pinVariable(name); +} diff --git a/client/src/debug/visualize/visualize-panel.html b/client/src/debug/visualize/visualize-panel.html new file mode 100644 index 0000000..6f237f9 --- /dev/null +++ b/client/src/debug/visualize/visualize-panel.html @@ -0,0 +1,143 @@ + + + + + + + +

${{name}}{{type}}

+ {{truncated}} +
+ + + + diff --git a/client/src/debug/visualize/visualize-panel.ts b/client/src/debug/visualize/visualize-panel.ts new file mode 100644 index 0000000..2eb3f92 --- /dev/null +++ b/client/src/debug/visualize/visualize-panel.ts @@ -0,0 +1,131 @@ +import * as vscode from 'vscode'; + +import { assetDir, assetPath } from '../shared/assets'; +import { escapeHtml } from '../shared/escape-html'; +import { hint, makeNonce, renderTemplate } from '../shared/html'; +import { VisualizeResponse } from './visualize-request'; +import { VisualizePayload } from './webview-payload'; + +// The singleton webview rendering one variable as structured, interactive data: +// - list of records (a nu table) -> sortable/filterable grid +// - record -> sortable/filterable field/value table +// - list -> sortable/filterable indexed table +// - binary -> aligned hex view with 1/2/4/8/16/32/64-byte grouping and +// LE/BE integer tooltips on groups +// - string -> JSON/XML auto-detection with formatting + syntax highlighting +// - anything else -> pretty-printed +// +// The page is visualize-panel.html; all rendering happens client-side in +// visualize-panel.webview.js (esbuild's bundle of webview/), from the JSON +// payload embedded in the page. Both are copied to out/debug/, and the +// CSP allows only that nonce'd script. + +/** Prefixed to the title while the shown value is out of date. */ +const STALE_MARK = '⏸ '; + +let panel: vscode.WebviewPanel | undefined; + +export function isVisualizePanelOpen(): boolean { + return panel !== undefined; +} + +/** Keep the current content but mark it as out of date. */ +export function markVisualizePanelStale(): void { + if (!panel) { + return; + } + + if (panel.title.startsWith(STALE_MARK)) { + return; + } + + panel.title = `${STALE_MARK}${panel.title}`; +} + +/** Open (or bring back) the panel and render a value in it. */ +export function openVisualizePanel( + name: string, + body: VisualizeResponse, +): void { + const target = ensurePanel(); + paint(target, name, body); + target.reveal(vscode.ViewColumn.Beside, true); +} + +/** Repaint an already open panel in place, without stealing the user's layout. */ +export function updateVisualizePanel( + name: string, + body: VisualizeResponse, +): void { + if (!panel) { + return; + } + + paint(panel, name, body); +} + +function ensurePanel(): vscode.WebviewPanel { + if (panel) { + return panel; + } + + const created = vscode.window.createWebviewPanel( + 'nuDapVisualize', + 'Nushell Visualizer', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { enableScripts: true, localResourceRoots: [vscode.Uri.file(assetDir())] }, + ); + created.onDidDispose(() => { + panel = undefined; + }); + + panel = created; + return created; +} + +function paint( + target: vscode.WebviewPanel, + name: string, + body: VisualizeResponse, +): void { + target.title = `$${name}`; + target.webview.html = html(target.webview, name, body); +} + +function html( + webview: vscode.Webview, + name: string, + body: VisualizeResponse, +): string { + const payload: VisualizePayload = { + name, + type: body.type, + truncated: body.truncated, + value: body.value, + }; + const script = webview.asWebviewUri( + vscode.Uri.file(assetPath('visualize-panel.webview.js')), + ); + return renderTemplate(assetPath('visualize-panel.html'), { + nonce: makeNonce(), + cspSource: webview.cspSource, + script: script.toString(), + name: escapeHtml(name), + type: escapeHtml(body.type), + truncated: truncatedHint(body.truncated), + data: embedJson(payload), + }); +} + +function truncatedHint(truncated: boolean): string { + if (!truncated) { + return ''; + } + + return hint('⚠ output truncated (value exceeds visualizer bounds)'); +} + +/** `<`-escaped so the payload can never close its + + diff --git a/client/src/debug/ir/ir-panel.ts b/client/src/debug/ir/ir-panel.ts new file mode 100644 index 0000000..503e7c1 --- /dev/null +++ b/client/src/debug/ir/ir-panel.ts @@ -0,0 +1,86 @@ +import * as vscode from 'vscode'; + +import { assetPath } from '../shared/assets'; +import { hint, makeNonce, renderTemplate } from '../shared/html'; +import { irLine } from './ir-listing'; + +/** Body of the adapter's `nuDapIr` event, sent on every stop. */ +export interface IrEventBody { + text: string; + instructionIndex: number; + instructionCount: number; // unused here; part of the wire shape +} + +// The singleton webview showing the IR of the block the debugger is paused in, +// with the current instruction highlighted. Updates on every stop; shows the +// most recent stop's IR if opened later. The page is ir-panel.html, copied to +// out/debug/. + +let panel: vscode.WebviewPanel | undefined; +let latest: IrEventBody | undefined; +let ended = false; + +export function showIrPanel(): void { + if (panel) { + panel.reveal(vscode.ViewColumn.Beside, true); + return; + } + + panel = vscode.window.createWebviewPanel( + 'nuDapIr', + 'Nushell IR', + { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, + { enableScripts: true }, + ); + + panel.onDidDispose(() => { + panel = undefined; + }); + + render(); +} + +export function updateIrPanel(body: IrEventBody): void { + latest = body; + ended = false; + render(); +} + +export function markIrPanelEnded(): void { + ended = true; + render(); +} + +function render(): void { + if (!panel) { + return; + } + + panel.webview.html = renderTemplate(assetPath('ir-panel.html'), { + nonce: makeNonce(), + status: status(), + listing: listing(), + }); +} + +function status(): string { + if (!ended) { + return ''; + } + + return hint('Debug session ended — showing the last stop.'); +} + +function listing(): string { + const shown = latest; + if (!shown) { + return hint( + 'Waiting for the debugger to stop… (the IR of the current block is sent on every breakpoint/step)', + ); + } + + const lines = shown.text + .split('\n') + .map((line) => irLine(line, shown.instructionIndex)); + return `
${lines.join('')}
`; +} diff --git a/client/src/debug/ir/register.ts b/client/src/debug/ir/register.ts new file mode 100644 index 0000000..1cf242b --- /dev/null +++ b/client/src/debug/ir/register.ts @@ -0,0 +1,26 @@ +import * as vscode from 'vscode'; + +import { onCustomEvent } from '../shared/custom-event'; +import { isNuSession } from '../shared/session'; +import { + IrEventBody, + markIrPanelEnded, + showIrPanel, + updateIrPanel, +} from './ir-panel'; + +export function register(): vscode.Disposable[] { + return [ + vscode.commands.registerCommand('nushell-debug.showIr', showIrPanel), + onCustomEvent('nuDapIr', (_session, body) => + updateIrPanel(body), + ), + vscode.debug.onDidTerminateDebugSession((session) => { + if (!isNuSession(session)) { + return; + } + + markIrPanelEnded(); + }), + ]; +} diff --git a/client/src/debug/tests/ir-listing.test.ts b/client/src/debug/tests/ir-listing.test.ts new file mode 100644 index 0000000..736bd68 --- /dev/null +++ b/client/src/debug/tests/ir-listing.test.ts @@ -0,0 +1,18 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { irLine, isInstructionAt } from '../ir/ir-listing'; + +test('isInstructionAt matches the numbered line with the current index', () => { + assert.equal(isInstructionAt(' 12: load-literal %0, int(1)', 12), true); + assert.equal(isInstructionAt(' 12: load-literal %0, int(1)', 3), false); + assert.equal(isInstructionAt('# 3 registers, 12 instructions', 3), false); +}); + +test('irLine marks only the current instruction and escapes markup', () => { + assert.equal( + irLine(' 3: call decl 5, %0 ', 3), + ` 3: call decl 5, %0 <x>\n`, + ); + assert.equal(irLine(' 4: return %0', 3), ' 4: return %0\n'); +}); From f29abb8688ec1234c1e0b301546a2caa06d6264c Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 05/10] feat: Implement Nushell debugger UI prompts (input/input list) --- client/src/debug/prompts/register.ts | 13 +++ client/src/debug/prompts/ui-prompts.ts | 135 +++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 client/src/debug/prompts/register.ts create mode 100644 client/src/debug/prompts/ui-prompts.ts diff --git a/client/src/debug/prompts/register.ts b/client/src/debug/prompts/register.ts new file mode 100644 index 0000000..cf2af33 --- /dev/null +++ b/client/src/debug/prompts/register.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +import { onCustomEvent } from '../shared/custom-event'; +import { UiRequest, handleUiRequest } from './ui-prompts'; + +export function register(): vscode.Disposable[] { + return [ + onCustomEvent( + 'nuDapUi', + (session, request) => void handleUiRequest(session, request), + ), + ]; +} diff --git a/client/src/debug/prompts/ui-prompts.ts b/client/src/debug/prompts/ui-prompts.ts new file mode 100644 index 0000000..fa8659f --- /dev/null +++ b/client/src/debug/prompts/ui-prompts.ts @@ -0,0 +1,135 @@ +import * as vscode from 'vscode'; + +/** Body of the adapter's `nuDapUi` prompt event. */ +export interface UiRequest { + id: number; + kind: 'text' | 'list'; + prompt: string; + default?: string; + items?: string[]; + multi?: boolean; + truncated?: boolean; +} + +/** The answer we send back: a value, the chosen index/indices, or a cancellation. */ +type UiReply = + | { value: string } + | { index: number } + | { indices: number[] } + | { cancelled: true }; + +interface ListPick extends vscode.QuickPickItem { + index: number; +} + +const CANCELLED: UiReply = { cancelled: true }; + +/** + * Native prompts for the script's `input` / `input list` commands. + * + * The adapter fires a `nuDapUi` event and blocks its eval thread until we + * answer with a `nuDapUiReply` request, so every path here must end in a reply. + */ +export async function handleUiRequest( + session: vscode.DebugSession, + request: UiRequest, +): Promise { + const reply = await askUser(request); + await sendReply(session, request.id, reply); +} + +async function askUser(request: UiRequest): Promise { + try { + if (request.kind === 'list') { + return await askFromList(request); + } + + return await askForText(request); + } catch { + return CANCELLED; // the dialog was torn down (e.g. the window closed) + } +} + +async function askForText(request: UiRequest): Promise { + const value = await vscode.window.showInputBox({ + prompt: request.prompt, + value: request.default, + ignoreFocusOut: true, + }); + + if (value === undefined) { + return CANCELLED; + } + + return { value }; +} + +async function askFromList(request: UiRequest): Promise { + const picks = toPicks(request.items ?? []); + const title = listTitle(request); + if (request.multi) { + return await askForManyPicks(picks, title); + } + + return await askForOnePick(picks, title); +} + +async function askForOnePick( + picks: ListPick[], + title: string, +): Promise { + const selected = await vscode.window.showQuickPick(picks, { + title, + ignoreFocusOut: true, + }); + if (!selected) { + return CANCELLED; + } + + return { index: selected.index }; +} + +async function askForManyPicks( + picks: ListPick[], + title: string, +): Promise { + const selected = await vscode.window.showQuickPick(picks, { + title, + canPickMany: true, + ignoreFocusOut: true, + }); + if (!selected) { + return CANCELLED; + } + + return { indices: selected.map((pick) => pick.index) }; +} + +/** The adapter answers by index, so show it — the list may contain duplicate labels. */ +function toPicks(items: string[]): ListPick[] { + return items.map((label, index) => ({ + label, + description: `#${index}`, + index, + })); +} + +function listTitle(request: UiRequest): string { + if (!request.truncated) { + return request.prompt; + } + + return `${request.prompt} (list truncated to 1000 items)`; +} + +async function sendReply( + session: vscode.DebugSession, + id: number, + reply: UiReply, +): Promise { + try { + await session.customRequest('nuDapUiReply', { id, ...reply }); + } catch { + // The session ended while the dialog was open: nobody is waiting any more. + } +} From 761acd78bb05eaa889b33447694f9579142ced8e Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 06/10] feat: Implement Nushell debugger launch configuration --- client/src/debug/launch/argument-prompt.ts | 21 +++ .../debug/launch/configuration-provider.ts | 106 +++++++++++++++ client/src/debug/launch/entry-point.ts | 107 +++++++++++++++ client/src/debug/launch/register.ts | 13 ++ client/src/debug/launch/script-signatures.ts | 128 ++++++++++++++++++ client/src/debug/launch/split-args.ts | 39 ++++++ client/src/debug/tests/fixtures/ast-defs.json | 1 + client/src/debug/tests/fixtures/ast-defs.nu | 10 ++ .../src/debug/tests/script-signatures.test.ts | 58 ++++++++ client/src/debug/tests/split-args.test.ts | 41 ++++++ 10 files changed, 524 insertions(+) create mode 100644 client/src/debug/launch/argument-prompt.ts create mode 100644 client/src/debug/launch/configuration-provider.ts create mode 100644 client/src/debug/launch/entry-point.ts create mode 100644 client/src/debug/launch/register.ts create mode 100644 client/src/debug/launch/script-signatures.ts create mode 100644 client/src/debug/launch/split-args.ts create mode 100644 client/src/debug/tests/fixtures/ast-defs.json create mode 100644 client/src/debug/tests/fixtures/ast-defs.nu create mode 100644 client/src/debug/tests/script-signatures.test.ts create mode 100644 client/src/debug/tests/split-args.test.ts diff --git a/client/src/debug/launch/argument-prompt.ts b/client/src/debug/launch/argument-prompt.ts new file mode 100644 index 0000000..5eead76 --- /dev/null +++ b/client/src/debug/launch/argument-prompt.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +import { splitArgs } from './split-args'; + +/** Ask for the entry point's arguments. Returns the parsed args, or undefined when cancelled. */ +export async function promptArgs( + signatureLabel: string, +): Promise { + const entered = await vscode.window.showInputBox({ + title: 'Nushell Debug: arguments', + prompt: `Requires arguments: ${signatureLabel}`, + placeHolder: 'e.g. alice 3 (quote values with spaces: "hello world")', + ignoreFocusOut: true, + }); + + if (entered === undefined) { + return undefined; // cancelled + } + + return splitArgs(entered); +} diff --git a/client/src/debug/launch/configuration-provider.ts b/client/src/debug/launch/configuration-provider.ts new file mode 100644 index 0000000..e616919 --- /dev/null +++ b/client/src/debug/launch/configuration-provider.ts @@ -0,0 +1,106 @@ +import * as path from 'path'; +import * as vscode from 'vscode'; + +import { resolveNushell } from '../../nushell/resolve'; +import { DEBUG_TYPE } from '../shared/debug-type'; +import { isNuDocument } from '../shared/nu-document'; +import { resolveEntryPoint } from './entry-point'; + +export class LaunchConfigurationProvider + implements vscode.DebugConfigurationProvider +{ + resolveDebugConfiguration( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): vscode.ProviderResult { + fillInActiveFile(folder, config); + if (!config.program) { + void vscode.window.showErrorMessage( + "Nushell Debug: no program specified. Open a .nu file or set 'program' in launch.json.", + ); + return undefined; + } + + return config; + } + + /** + * Runs after ${file}/${workspaceFolder} substitution, so `program` is a real + * path we can read — which is what lets us ask about entry point and args. + */ + async resolveDebugConfigurationWithSubstitutedVariables( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): Promise { + if (config.request !== 'launch') { + return config; + } + + if (typeof config.program !== 'string') { + return config; + } + + config.program = absoluteProgram(folder, config); + return await resolveEntryPoint(config, config.program, quietNushell()); + } +} + +/** + * `program: "scripts/tool.nu"` is relative to the script's working directory, + * not to the extension host's — resolve it so both we and the adapter read + * the right file. + */ +function absoluteProgram( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): string { + const program: string = config.program; + if (path.isAbsolute(program)) { + return program; + } + + // `cwd` may itself be relative to the workspace folder. + const bases = [folder?.uri.fsPath, config.cwd].filter( + (base): base is string => typeof base === 'string' && base !== '', + ); + if (bases.length === 0) { + return program; // nothing to anchor it to: let the adapter report it + } + + return path.resolve(...bases, program); +} + +/** + * The `nu` to parse the script with. Unlike `ensureNushell` this doesn't + * report a missing `nu`: the adapter factory does that once, right after. + */ +function quietNushell(): string | undefined { + const resolution = resolveNushell(); + return resolution.kind === 'found' ? resolution.path : undefined; +} + +/** F5 with no launch.json: debug the active .nu file. */ +function fillInActiveFile( + folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, +): void { + if (config.type || config.request || config.name) { + return; // a real launch config; leave it alone + } + + const editor = vscode.window.activeTextEditor; + if (!editor) { + return; + } + + if (!isNuDocument(editor.document)) { + return; + } + + const script = editor.document.uri.fsPath; + config.type = DEBUG_TYPE; + config.request = 'launch'; + config.name = 'Debug nu script'; + config.program = script; + config.cwd = folder?.uri.fsPath ?? path.dirname(script); +} diff --git a/client/src/debug/launch/entry-point.ts b/client/src/debug/launch/entry-point.ts new file mode 100644 index 0000000..f8bbb9e --- /dev/null +++ b/client/src/debug/launch/entry-point.ts @@ -0,0 +1,107 @@ +import * as vscode from 'vscode'; + +import { promptArgs } from './argument-prompt'; +import { NuDefinition, readDefinitions } from './script-signatures'; + +/** The extra entry in the entry-point picker: don't call a command, just run the file. */ +const RUN_TOP_TO_BOTTOM = '▶ Run script top-to-bottom'; + +/** + * Decide what the adapter should call and with which arguments: `main` when + * the script has one, otherwise a function the user picks (or nothing, to run + * the file top-to-bottom). Every path may return undefined, which aborts the + * launch — that is how a cancelled prompt is reported. + * + * Without a `nu` to parse the script with, or when parsing fails, the launch + * goes ahead as configured. + */ +export async function resolveEntryPoint( + config: vscode.DebugConfiguration, + program: string, + nu: string | undefined, +): Promise { + if (!nu) { + return config; // the adapter reports the missing `nu` + } + + const defs = await readDefinitions(nu, program); + if (!defs) { + return config; + } + + const target = config.entryPoint ?? 'main'; + const def = defs.find((candidate) => candidate.name === target); + if (def) { + return await ensureArgs(config, def); + } + + return await askForEntryPoint(config, defs); +} + +/** No `main`: let the user pick a function to debug (or run the script top-to-bottom). */ +async function askForEntryPoint( + config: vscode.DebugConfiguration, + defs: NuDefinition[], +): Promise { + if (config.entryPoint !== undefined) { + return config; // the launch config names one; the adapter reports it if missing + } + + if (defs.length === 0) { + return config; // nothing to choose between + } + + const picked = await pickEntryPoint(defs); + if (picked === undefined) { + return undefined; // cancelled + } + + if (picked === RUN_TOP_TO_BOTTOM) { + return config; + } + + config.entryPoint = picked.name; + return await ensureArgs(config, picked); +} + +async function pickEntryPoint( + defs: NuDefinition[], +): Promise { + const names = defs.map((def) => def.name); + const picked = await vscode.window.showQuickPick( + [RUN_TOP_TO_BOTTOM, ...names], + { + title: 'Nushell Debug: entry point', + placeHolder: + 'This script has no `main`. Choose a function to debug (or run it top-to-bottom).', + ignoreFocusOut: true, + }, + ); + if (picked === undefined || picked === RUN_TOP_TO_BOTTOM) { + return picked; + } + + return defs.find((def) => def.name === picked); +} + +/** Prompt for arguments when the entry point requires some and the launch config has none. */ +async function ensureArgs( + config: vscode.DebugConfiguration, + def: NuDefinition, +): Promise { + if (config.args !== undefined) { + return config; + } + + if (!def.hasRequired) { + return config; + } + + const args = await promptArgs(`${def.name} [${def.signature.trim()}]`); + if (args === undefined) { + return undefined; // cancelled + } + + config.args = args; + return config; +} diff --git a/client/src/debug/launch/register.ts b/client/src/debug/launch/register.ts new file mode 100644 index 0000000..db04f60 --- /dev/null +++ b/client/src/debug/launch/register.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +import { DEBUG_TYPE } from '../shared/debug-type'; +import { LaunchConfigurationProvider } from './configuration-provider'; + +export function register(): vscode.Disposable[] { + return [ + vscode.debug.registerDebugConfigurationProvider( + DEBUG_TYPE, + new LaunchConfigurationProvider(), + ), + ]; +} diff --git a/client/src/debug/launch/script-signatures.ts b/client/src/debug/launch/script-signatures.ts new file mode 100644 index 0000000..3465cca --- /dev/null +++ b/client/src/debug/launch/script-signatures.ts @@ -0,0 +1,128 @@ +// Reading a script's `def`s without running it. +// +// Launching needs two things before the adapter starts: which command to call +// (`main`, or a function the user picks) and whether it needs arguments. Both +// come from nushell's own parser: `ast --json` parses the script (nothing is +// evaluated) and describes every top-level command with its signature. + +import { execFile } from 'child_process'; +import { promises as fs } from 'fs'; +import * as path from 'path'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); + +/** A top-level `def` in a script. */ +export interface NuDefinition { + name: string; + /** The parameter list as written, without its brackets — for display. */ + signature: string; + /** Does it have a positional parameter the caller must supply? */ + hasRequired: boolean; +} + +/** + * The script's top-level `def`s, parsed by `nu`. Undefined when that fails + * (unreadable file, `nu` error, an AST shape we don't recognize): the launch + * then goes ahead without asking, and nu-dap reports a missing argument. + */ +export async function readDefinitions( + nu: string, + program: string, +): Promise { + try { + const source = await fs.readFile(program, 'utf8'); + const parse = execFileAsync( + nu, + [ + '--no-config-file', + '--stdin', + '-c', + 'ast --json --minify $in | get block', + ], + { + cwd: path.dirname(program), // so `use ./lib.nu` resolves + timeout: 10000, + maxBuffer: 256 * 1024 * 1024, // the AST is far bigger than the source + }, + ); + parse.child.stdin?.end(source); + const { stdout } = await parse; + return definitionsFromAst(JSON.parse(stdout)); + } catch { + return undefined; + } +} + +/** + * The `def`s among the top-level statements of an `ast --json` block. Defs in + * a module or another def's body sit in nested blocks, so they are not seen. + * + * The JSON is nushell's internal AST, not a stable interface: every step is + * checked, and a statement of a shape we don't recognize is skipped. + */ +export function definitionsFromAst(block: unknown): NuDefinition[] { + const pipelines = field(block, 'pipelines'); + if (!Array.isArray(pipelines)) { + return []; + } + + const definitions: NuDefinition[] = []; + for (const pipeline of pipelines) { + const elements = field(pipeline, 'elements'); + const first: unknown = Array.isArray(elements) ? elements[0] : undefined; + const call = field(field(field(first, 'expr'), 'expr'), 'Call'); + const definition = definitionFromCall(call); + if (definition) { + definitions.push(definition); + } + } + + return definitions; +} + +/** `def name [...] {}` / `export def ...`, read from its call. */ +function definitionFromCall(call: unknown): NuDefinition | undefined { + const head = field(field(call, 'head'), 'span_source'); + if (typeof head !== 'string') { + return undefined; + } + + const command = head.trim().split(/\s+/).join(' '); + if (command !== 'def' && command !== 'export def') { + return undefined; + } + + const args = field(call, 'arguments'); + if (!Array.isArray(args)) { + return undefined; + } + + const positionals = args.map((arg) => field(arg, 'Positional')); + const name = positionals + .map((positional) => field(field(positional, 'expr'), 'String')) + .find((value) => typeof value === 'string'); + const signatureArg = positionals.find( + (positional) => field(field(positional, 'expr'), 'Signature') !== undefined, + ); + const signature = field(field(signatureArg, 'expr'), 'Signature'); + const required = field(signature, 'required_positional'); + const written = field(field(signatureArg, 'span'), 'span_source'); + if (typeof name !== 'string' || !Array.isArray(required)) { + return undefined; + } + + return { + name, + signature: typeof written === 'string' ? written.slice(1, -1) : '', + hasRequired: required.length > 0, + }; +} + +function field(value: unknown, key: string): unknown { + if (typeof value !== 'object' || value === null) { + return undefined; + } + + return (value as Record)[key]; +} diff --git a/client/src/debug/launch/split-args.ts b/client/src/debug/launch/split-args.ts new file mode 100644 index 0000000..70b5b1a --- /dev/null +++ b/client/src/debug/launch/split-args.ts @@ -0,0 +1,39 @@ +/** + * Split a command line into arguments, honoring single and double quotes. + * Quotes group anywhere in a token (`--name="a b"` is one argument) and are + * dropped from the result. + */ +export function splitArgs(line: string): string[] { + const args: string[] = []; + let current = ''; + let inToken = false; // tells `""` (an empty argument) apart from no argument + let quote: string | undefined; + + for (const char of line) { + if (quote) { + if (char === quote) { + quote = undefined; + } else { + current += char; + } + } else if (char === '"' || char === "'") { + quote = char; + inToken = true; + } else if (/\s/.test(char)) { + if (inToken) { + args.push(current); + current = ''; + inToken = false; + } + } else { + current += char; + inToken = true; + } + } + + if (inToken) { + args.push(current); // an unclosed quote runs to the end of the line + } + + return args; +} diff --git a/client/src/debug/tests/fixtures/ast-defs.json b/client/src/debug/tests/fixtures/ast-defs.json new file mode 100644 index 0000000..7e9026d --- /dev/null +++ b/client/src/debug/tests/fixtures/ast-defs.json @@ -0,0 +1 @@ +{"signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"},"pipelines":[{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":511,"head":{"start":152669,"end":152674,"span_source":"print"},"arguments":[{"Positional":{"expr":{"String":"side effect"},"span":{"start":152675,"end":152688,"span_source":"\"side effect\""},"span_id":4682,"ty":"String"}}],"parser_info":{}}},"span":{"start":152669,"end":152688,"span_source":"print \"side effect\""},"span_id":4683,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":40,"head":{"start":152689,"end":152695,"span_source":"module"},"arguments":[{"Positional":{"expr":{"String":"m"},"span":{"start":152696,"end":152697,"span_source":"m"},"span_id":4692,"ty":"String"}},{"Positional":{"expr":{"Block":295},"span":{"start":152698,"end":152717,"span_source":"{ def main [a] {} }"},"span_id":4701,"ty":"Block"}}],"parser_info":{}}},"span":{"start":152689,"end":152717,"span_source":"module m { def main [a] {} }"},"span_id":4702,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":22,"head":{"start":152718,"end":152728,"span_source":"export def"},"arguments":[{"Named":[{"item":"env","span":{"start":152729,"end":152734,"span_source":"--env"}},null,null]},{"Positional":{"expr":{"String":"my cmd"},"span":{"start":152735,"end":152743,"span_source":"\"my cmd\""},"span_id":4711,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"a","desc":"","shape":"Any","completion":null,"var_id":88,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152744,"end":152747,"span_source":"[a]"},"span_id":4712,"ty":"Any"}},{"Positional":{"expr":{"Closure":296},"span":{"start":152748,"end":152750,"span_source":"{}"},"span_id":4713,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152718,"end":152750,"span_source":"export def --env \"my cmd\" [a] {}"},"span_id":4714,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152751,"end":152754,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"greet"},"span":{"start":152755,"end":152760,"span_source":"greet"},"span_id":4723,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"who, to greet","shape":"Any","completion":null,"var_id":90,"default_value":null}],"optional_positional":[{"name":"x","desc":"","shape":{"List":"Int"},"completion":null,"var_id":89,"default_value":{"List":{"vals":[{"Int":{"val":1,"span":{"start":152778,"end":152779,"span_source":"1"}}},{"Int":{"val":2,"span":{"start":152780,"end":152781,"span_source":"2"}}}],"span":{"start":152777,"end":152782,"span_source":"[1 2]"}}}}],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152761,"end":152806,"span_source":"[x: list = [1 2], name # who, to greet\n]"},"span_id":4727,"ty":"Any"}},{"Positional":{"expr":{"Closure":297},"span":{"start":152807,"end":152809,"span_source":"{}"},"span_id":4728,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152751,"end":152809,"span_source":"def greet [x: list = [1 2], name # who, to greet\n] {}"},"span_id":4729,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152810,"end":152813,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"opt"},"span":{"start":152814,"end":152817,"span_source":"opt"},"span_id":4738,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[{"name":"y","desc":"","shape":"Int","completion":null,"var_id":91,"default_value":null}],"rest_positional":null,"named":[{"long":"v","short":"v","arg":null,"required":false,"desc":"","completion":null,"var_id":92,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152818,"end":152836,"span_source":"(y?: int, --v(-v))"},"span_id":4739,"ty":"Any"}},{"Positional":{"expr":{"Closure":298},"span":{"start":152837,"end":152839,"span_source":"{}"},"span_id":4740,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152810,"end":152839,"span_source":"def opt (y?: int, --v(-v)) {}"},"span_id":4741,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":26,"head":{"start":152840,"end":152846,"span_source":"extern"},"arguments":[{"Positional":{"expr":{"String":"foo"},"span":{"start":152847,"end":152850,"span_source":"foo"},"span_id":4750,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"x","desc":"","shape":"Any","completion":null,"var_id":null,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152851,"end":152854,"span_source":"[x]"},"span_id":4751,"ty":"Any"}}],"parser_info":{}}},"span":{"start":152840,"end":152854,"span_source":"extern foo [x]"},"span_id":4752,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":0,"head":{"start":152855,"end":152860,"span_source":"alias"},"arguments":[{"Positional":{"expr":{"String":"q"},"span":{"start":152861,"end":152862,"span_source":"q"},"span_id":4761,"ty":"String"}},{"Positional":{"expr":{"Keyword":{"keyword":[61],"span":{"start":152863,"end":152864,"span_source":"="},"expr":{"expr":{"Call":{"decl_id":239,"head":{"start":152865,"end":152867,"span_source":"ls"},"arguments":[],"parser_info":{}}},"span":{"start":152865,"end":152867,"span_source":"ls"},"span_id":4770,"ty":{"Table":[]}}}},"span":{"start":152863,"end":152867,"span_source":"= ls"},"span_id":4771,"ty":{"Table":[]}}}],"parser_info":{}}},"span":{"start":152855,"end":152867,"span_source":"alias q = ls"},"span_id":4772,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152868,"end":152871,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"main"},"span":{"start":152872,"end":152876,"span_source":"main"},"span_id":4790,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"","shape":"String","completion":null,"var_id":94,"default_value":null}],"optional_positional":[],"rest_positional":{"name":"rest","desc":"","shape":"Any","completion":null,"var_id":95,"default_value":null},"named":[{"long":"verbose","short":null,"arg":null,"required":false,"desc":"","completion":null,"var_id":93,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152877,"end":152910,"span_source":"[--verbose name: string, ...rest]"},"span_id":4791,"ty":"Any"}},{"Positional":{"expr":{"Closure":299},"span":{"start":152911,"end":152913,"span_source":"{}"},"span_id":4792,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152868,"end":152913,"span_source":"def main [--verbose name: string, ...rest] {}"},"span_id":4793,"ty":"Any"},"redirection":null}]}],"captures":[],"redirect_env":false,"ir_block":null,"span":{"start":152617,"end":152914,"span_source":"# a script exercising the shapes launch cares about\nprint \"side effect\"\nmodule m { def main [a] {} }\nexport def --env \"my cmd\" [a] {}\ndef greet [x: list = [1 2], name # who, to greet\n] {}\ndef opt (y?: int, --v(-v)) {}\nextern foo [x]\nalias q = ls\ndef main [--verbose name: string, ...rest] {}\n"}} diff --git a/client/src/debug/tests/fixtures/ast-defs.nu b/client/src/debug/tests/fixtures/ast-defs.nu new file mode 100644 index 0000000..ccf2e22 --- /dev/null +++ b/client/src/debug/tests/fixtures/ast-defs.nu @@ -0,0 +1,10 @@ +# a script exercising the shapes launch cares about +print "side effect" +module m { def main [a] {} } +export def --env "my cmd" [a] {} +def greet [x: list = [1 2], name # who, to greet +] {} +def opt (y?: int, --v(-v)) {} +extern foo [x] +alias q = ls +def main [--verbose name: string, ...rest] {} diff --git a/client/src/debug/tests/script-signatures.test.ts b/client/src/debug/tests/script-signatures.test.ts new file mode 100644 index 0000000..2fcf8d9 --- /dev/null +++ b/client/src/debug/tests/script-signatures.test.ts @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import * as path from 'node:path'; +import { test } from 'node:test'; + +import { definitionsFromAst } from '../launch/script-signatures'; + +// `ast --json --minify $in | get block` of fixtures/ast-defs.nu, from nu +// 0.116. Regenerate it with that command when the AST format changes. +const FIXTURES = path.join(__dirname, '../../../src/debug/tests/fixtures'); +const block: unknown = JSON.parse( + readFileSync(path.join(FIXTURES, 'ast-defs.json'), 'utf8'), +); + +test('definitionsFromAst lists the top-level defs, in order', () => { + assert.deepEqual( + definitionsFromAst(block).map((def) => def.name), + ['my cmd', 'greet', 'opt', 'main'], + ); +}); + +test('definitionsFromAst takes what nushell parsed as required', () => { + const required = Object.fromEntries( + definitionsFromAst(block).map((def) => [def.name, def.hasRequired]), + ); + assert.deepEqual(required, { + 'my cmd': true, // [a] + greet: true, // [x: list = [1 2], name # who, to greet] + opt: false, // (y?: int, --v(-v)) + main: true, // [--verbose name: string, ...rest] + }); +}); + +test('definitionsFromAst keeps the parameter list as written', () => { + const signatures = Object.fromEntries( + definitionsFromAst(block).map((def) => [def.name, def.signature]), + ); + assert.equal(signatures['main'], '--verbose name: string, ...rest'); + assert.equal(signatures['opt'], 'y?: int, --v(-v)'); + assert.equal( + signatures['greet'], + 'x: list = [1 2], name # who, to greet\n', + ); +}); + +test('definitionsFromAst skips what it does not recognize', () => { + for (const input of [null, 42, {}, { pipelines: 'x' }, { pipelines: [{}] }]) { + assert.deepEqual(definitionsFromAst(input), [], JSON.stringify(input)); + } + + // A `def` call without a Signature argument (the AST's keys are snake_case). + const noSignature: unknown = + JSON.parse(`{"pipelines": [{"elements": [{"expr": {"expr": {"Call": { + "head": {"span_source": "def"}, + "arguments": [{"Positional": {"expr": {"String": "f"}}}] + }}}}]}]}`); + assert.deepEqual(definitionsFromAst(noSignature), []); +}); diff --git a/client/src/debug/tests/split-args.test.ts b/client/src/debug/tests/split-args.test.ts new file mode 100644 index 0000000..86ba732 --- /dev/null +++ b/client/src/debug/tests/split-args.test.ts @@ -0,0 +1,41 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { splitArgs } from '../launch/split-args'; + +test('splitArgs splits on whitespace', () => { + assert.deepEqual(splitArgs('alice 3 --verbose'), ['alice', '3', '--verbose']); +}); + +test('splitArgs keeps quoted values together and drops the quotes', () => { + assert.deepEqual(splitArgs(`"hello world" 'x y' z`), [ + 'hello world', + 'x y', + 'z', + ]); +}); + +test('splitArgs of an empty line is empty', () => { + assert.deepEqual(splitArgs(''), []); + assert.deepEqual(splitArgs(' '), []); +}); + +test('splitArgs groups a quoted value attached to a flag', () => { + assert.deepEqual(splitArgs('--name="hello world" x'), [ + '--name=hello world', + 'x', + ]); + assert.deepEqual(splitArgs(`--msg='it is' --n=3`), ['--msg=it is', '--n=3']); +}); + +test('splitArgs keeps the other quote kind literally inside quotes', () => { + assert.deepEqual(splitArgs(`"it's" 'say "hi"'`), [`it's`, 'say "hi"']); +}); + +test('splitArgs keeps an empty quoted argument', () => { + assert.deepEqual(splitArgs(`a "" b`), ['a', '', 'b']); +}); + +test('splitArgs runs an unclosed quote to the end of the line', () => { + assert.deepEqual(splitArgs('a "b c'), ['a', 'b c']); +}); From 84464f5465a8bea41d0c42c0cf89c338bc639f7b Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 07/10] feat: Add core Nushell debugger infrastructure and build setup --- .github/workflows/package.yml | 5 + .gitignore | 3 +- .prettierignore | 3 +- .vscodeignore | 3 +- CONTRIBUTING.md | 11 ++ README.md | 42 +++++++ .../src/debug/adapter/descriptor-factory.ts | 108 ++++++++++++++++ client/src/debug/adapter/register.ts | 16 +++ client/src/debug/register.ts | 33 +++++ client/src/debug/shared/assets.ts | 15 +++ client/src/debug/shared/custom-event.ts | 21 ++++ client/src/debug/shared/dap-message.ts | 9 ++ client/src/debug/shared/debug-type.ts | 2 + client/src/debug/shared/escape-html.ts | 12 ++ client/src/debug/shared/html.ts | 43 +++++++ client/src/debug/shared/nu-document.ts | 13 ++ client/src/debug/shared/session.ts | 21 ++++ client/src/debug/shared/stopped-event.ts | 29 +++++ client/src/debug/tests/dap-message.test.ts | 23 ++++ client/src/debug/tests/escape-html.test.ts | 12 ++ client/src/debug/tests/html.test.ts | 32 +++++ client/src/extension.ts | 3 + client/tsconfig.json | 2 +- client/tsconfig.unit.json | 12 ++ client/tsconfig.webview.json | 20 +++ package.json | 118 +++++++++++++++++- scripts/copy-debug-assets.js | 37 ++++++ tsconfig.json | 2 +- 28 files changed, 641 insertions(+), 9 deletions(-) create mode 100644 client/src/debug/adapter/descriptor-factory.ts create mode 100644 client/src/debug/adapter/register.ts create mode 100644 client/src/debug/register.ts create mode 100644 client/src/debug/shared/assets.ts create mode 100644 client/src/debug/shared/custom-event.ts create mode 100644 client/src/debug/shared/dap-message.ts create mode 100644 client/src/debug/shared/debug-type.ts create mode 100644 client/src/debug/shared/escape-html.ts create mode 100644 client/src/debug/shared/html.ts create mode 100644 client/src/debug/shared/nu-document.ts create mode 100644 client/src/debug/shared/session.ts create mode 100644 client/src/debug/shared/stopped-event.ts create mode 100644 client/src/debug/tests/dap-message.test.ts create mode 100644 client/src/debug/tests/escape-html.test.ts create mode 100644 client/src/debug/tests/html.test.ts create mode 100644 client/tsconfig.unit.json create mode 100644 client/tsconfig.webview.json create mode 100644 scripts/copy-debug-assets.js diff --git a/.github/workflows/package.yml b/.github/workflows/package.yml index 998c8c2..d53739e 100644 --- a/.github/workflows/package.yml +++ b/.github/workflows/package.yml @@ -24,7 +24,12 @@ jobs: steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 + - uses: actions/setup-node@v4 + with: + node-version: 22 - run: npm install + - run: npm run typecheck:webview + - run: npm run test:unit - uses: lannonbr/vsce-action@master with: args: 'package' diff --git a/.gitignore b/.gitignore index 14b6ad9..0e601c1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ client/tsconfig.tsbuildinfo .vscode-test out/ server/tsconfig.tsbuildinfo -tsconfig.tsbuildinfo \ No newline at end of file +tsconfig.tsbuildinfo +client/out-unit diff --git a/.prettierignore b/.prettierignore index 63517ca..c3896cd 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,5 @@ build coverage out -used_keywords.jsonc \ No newline at end of file +used_keywords.jsonc +client/src/debug/tests/fixtures/ diff --git a/.vscodeignore b/.vscodeignore index 2943cff..782fad2 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -11,4 +11,5 @@ scripts/** node_modules/** .gitignore -.eslintignore \ No newline at end of file +.eslintignore +examples/** diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f23d90b..e8b8c7e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,17 @@ If you have all these tools already installed, you should be able to clone this Instead of using VS Code, you can run `npm run compile` manually. +### Debugger + +The debugger code lives in `client/src/debug/`, one folder per feature (a folder imports only from `shared/` and itself). The debug adapter itself is `nu --dap`, which is part of Nushell (crate `nu-dap`). To work against an unreleased Nushell, build it (`cargo build --release -p nu` in a nushell checkout) and point `nushellLanguageServer.nushellExecutablePath` at `target/release/nu`. Then press F5, open a script from `examples/debug/` in the Extension Development Host and press F5 again. + +The webview pages (`*.html`) are copied to `out/debug/` and the visualizer's webview script is bundled separately (`npm run esbuild-webview`); `npm run compile` does both. + +### Tests + +- `npm run test:unit`: `node:test` unit tests (`client/src/**/*.test.ts`, excluding the VS Code e2e tests in `client/src/test/`) +- `npm run typecheck:webview`: type-checks the visualizer's browser code + ## Regex Engine TIL - VSCode uses regexes for language syntax highlighting in \*.tmLanguage.json files. Those regexes and json are based on Textmate, which uses (and here is the secret-sauce) `oniguruma` flavor of syntax. See the cheat-sheet for the [syntax here](https://github.com/kkos/oniguruma/blob/master/doc/RE). Also there's a rust-crate called `onig` or `rust-onig` if we wanted to write something to help create compatible regular expressions. diff --git a/README.md b/README.md index e704739..e029bc5 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,48 @@ This [extension for VSCode](https://marketplace.visualstudio.com/items?itemName= - Auto-complete built-in commands - Inlays / Hints - Configuration via vscode settings +- Step debugging of Nushell scripts (`nu --dap`, Nushell 0.116+) + +## Finding `nu` + +The language server, the Nushell terminal profile and the debugger all use the same `nu`, looked up in this order: + +1. the `nushellLanguageServer.nushellExecutablePath` setting (when it is not the default `nu`); +2. `nu` on your `PATH`. + +When neither finds `nu`, the extension points you to the [Nushell installation page](https://www.nushell.sh/book/installation.html) or the setting. Debugging needs Nushell 0.116.0 or newer. + +## Debugging + +Press **F5** in a `.nu` file (no `launch.json` needed), or add a configuration: + +```json +{ + "type": "nushell", + "request": "launch", + "name": "Debug nu script", + "program": "${file}", + "cwd": "${workspaceFolder}", + "args": [], + "stopOnEntry": false +} +``` + +The debugger is built into Nushell (`nu --dap`, available from 0.116), so it needs no extra install. It supports: + +- breakpoints, conditional breakpoints (`$total > 4000`), logpoints (`total {$total}`) and exception breakpoints ("Runtime errors"); +- step over / into / out, through pipeline stages too, and a call stack with command names; +- variables inspected to any depth, an Environment scope, and watch / hover / Debug Console expressions; +- `def main` receives the launch `args`; scripts without `main` let you pick an entry point (`entryPoint`); +- `input` / `input list` answered through native VS Code prompts; +- **Visualize** (right-click a variable): tables as sortable, filterable grids, binaries as a hex view, JSON/XML strings formatted; +- **Nushell Debug: Show IR**: a live view of the compiled IR of the current block; +- **time travel**: Step Back / Reverse Continue over a recorded timeline (`nushellDebugger.timeTravel`, `nushellDebugger.timeTravelMaxSteps`); +- hot restart, optionally whenever you save the debugged script (`nushellDebugger.restartOnSave`; auto-saves are ignored). + +Known limitations: launch only (no attach), externals get no interactive stdin, `input listen` is not supported, and variables can't be edited while paused. + +The `examples/debug/` folder has scripts that exercise each feature. ## Screenshot (v1.5.0) diff --git a/client/src/debug/adapter/descriptor-factory.ts b/client/src/debug/adapter/descriptor-factory.ts new file mode 100644 index 0000000..311d370 --- /dev/null +++ b/client/src/debug/adapter/descriptor-factory.ts @@ -0,0 +1,108 @@ +import { execFile } from 'child_process'; +import { promisify } from 'util'; +import { + CancellationError, + DebugAdapterDescriptor, + DebugAdapterDescriptorFactory, + DebugAdapterExecutable, + Uri, + env, + window, +} from 'vscode'; + +import { + NUSHELL_DOWNLOAD_PAGE, + compareVersions, + parseNuVersion, +} from '../../nushell/version'; + +const execFileAsync = promisify(execFile); + +/** The first Nushell release that ships `nu --dap`. */ +export const MIN_DAP_VERSION = '0.116.0'; + +/** Finds the `nu` to run; undefined when there is none, after telling the user. */ +export type NushellResolver = () => Promise; + +/** + * The debugger ships inside Nushell itself: `nu --dap` starts a Debug Adapter + * Protocol server over stdio — the same entry point Zed, Neovim, and any other + * DAP client use. All we do is find a `nu` (the same one the language server + * uses) and spawn it. + */ +export class AdapterDescriptorFactory implements DebugAdapterDescriptorFactory { + /** `nu` paths already known to support `--dap`. */ + private readonly supported = new Set(); + private readonly resolveNushell: NushellResolver; + + constructor(resolveNushell: NushellResolver) { + this.resolveNushell = resolveNushell; + } + + /** + * Cancels the launch when there is no usable `nu`. The resolver has already + * told the user (with buttons to download or configure), so cancelling keeps + * VS Code from showing a second error on top of it. + */ + async createDebugAdapterDescriptor(): Promise { + const nu = await this.resolveNushell(); + if (!nu) { + throw new CancellationError(); + } + + await this.checkDapSupport(nu); + return new DebugAdapterExecutable(nu, ['--dap']); + } + + /** + * Compares `nu --version` against {@link MIN_DAP_VERSION}; nightlies report + * the upcoming release (e.g. `0.116.0-nightly.3`), so they pass too. Not + * cached on failure: the user may upgrade `nu` before the next launch. + * + * A too old `nu` cancels the launch and offers the Nushell download page. + * VS Code doesn't show an error for a cancelled launch, so this is the only + * message the user sees. + */ + private async checkDapSupport(nu: string): Promise { + if (this.supported.has(nu)) { + return; + } + + const version = await nuVersion(nu); + if (!version) { + return; // could not ask: let `nu --dap` speak for itself + } + + if (compareVersions(version, MIN_DAP_VERSION) >= 0) { + this.supported.add(nu); + return; + } + + void offerDownloadPage(nu, version); + throw new CancellationError(); + } +} + +async function offerDownloadPage(nu: string, version: string): Promise { + const openPage = 'Open download page'; + const choice = await window.showWarningMessage( + `Debugging is disabled because it needs Nushell ${MIN_DAP_VERSION} or higher, ` + + `and ${nu} is version ${version}. Go to the Nushell download page?`, + openPage, + ); + + if (choice === openPage) { + await env.openExternal(Uri.parse(NUSHELL_DOWNLOAD_PAGE)); + } +} + +async function nuVersion(nu: string): Promise { + try { + const { stdout } = await execFileAsync(nu, ['--version'], { + timeout: 10000, + }); + return parseNuVersion(stdout); + } catch { + return undefined; + } +} diff --git a/client/src/debug/adapter/register.ts b/client/src/debug/adapter/register.ts new file mode 100644 index 0000000..67ab5bf --- /dev/null +++ b/client/src/debug/adapter/register.ts @@ -0,0 +1,16 @@ +import * as vscode from 'vscode'; + +import { DEBUG_TYPE } from '../shared/debug-type'; +import { + AdapterDescriptorFactory, + NushellResolver, +} from './descriptor-factory'; + +export function register(resolveNushell: NushellResolver): vscode.Disposable[] { + return [ + vscode.debug.registerDebugAdapterDescriptorFactory( + DEBUG_TYPE, + new AdapterDescriptorFactory(resolveNushell), + ), + ]; +} diff --git a/client/src/debug/register.ts b/client/src/debug/register.ts new file mode 100644 index 0000000..b956ade --- /dev/null +++ b/client/src/debug/register.ts @@ -0,0 +1,33 @@ +import { Disposable } from 'vscode'; + +import * as adapter from './adapter/register'; +import { NushellResolver } from './adapter/descriptor-factory'; +import * as ir from './ir/register'; +import * as launch from './launch/register'; +import * as prompts from './prompts/register'; +import * as restart from './restart/register'; +import * as timeTravel from './time-travel/register'; +import * as trace from './trace/register'; +import * as visualize from './visualize/register'; + +/** + * Nushell debugger. + * + * All debugging logic lives in Nushell itself (`nu --dap`, the `nu-dap` crate). + * This only wires VS Code up to it. Each feature is a folder under debug/ that + * owns its own VS Code registrations; this file just lists them. + */ +export function registerDebugger( + resolveNushell: NushellResolver, +): Disposable[] { + return [ + ...adapter.register(resolveNushell), + ...timeTravel.register(), + ...launch.register(), + ...prompts.register(), + ...visualize.register(), + ...ir.register(), + ...restart.register(), + ...trace.register(), + ]; +} diff --git a/client/src/debug/shared/assets.ts b/client/src/debug/shared/assets.ts new file mode 100644 index 0000000..34fc260 --- /dev/null +++ b/client/src/debug/shared/assets.ts @@ -0,0 +1,15 @@ +import * as path from 'path'; + +/** + * Absolute path of a debugger asset (webview page or script). The extension is + * bundled into out/extension.js, so `__dirname` is out/ and the assets are + * copied to out/debug/ by scripts/copy-debug-assets.js. + */ +export function assetPath(file: string): string { + return path.join(__dirname, 'debug', file); +} + +/** The folder holding the debugger assets, for webview `localResourceRoots`. */ +export function assetDir(): string { + return path.join(__dirname, 'debug'); +} diff --git a/client/src/debug/shared/custom-event.ts b/client/src/debug/shared/custom-event.ts new file mode 100644 index 0000000..f65941d --- /dev/null +++ b/client/src/debug/shared/custom-event.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +import { isNuSession } from './session'; + +/** Subscribe to one of the adapter's custom DAP events (`nuDapIr`, `nuDapUi`, …) from a nushell session. */ +export function onCustomEvent( + name: string, + handle: (session: vscode.DebugSession, body: Body) => void, +): vscode.Disposable { + return vscode.debug.onDidReceiveDebugSessionCustomEvent((event) => { + if (!isNuSession(event.session)) { + return; + } + + if (event.event !== name) { + return; + } + + handle(event.session, event.body as Body); + }); +} diff --git a/client/src/debug/shared/dap-message.ts b/client/src/debug/shared/dap-message.ts new file mode 100644 index 0000000..20fad97 --- /dev/null +++ b/client/src/debug/shared/dap-message.ts @@ -0,0 +1,9 @@ +/** Is this raw adapter→client DAP message a `stopped` event? */ +export function isStoppedEvent(message: unknown): boolean { + if (typeof message !== 'object' || message === null) { + return false; + } + + const { type, event } = message as { type?: unknown; event?: unknown }; + return type === 'event' && event === 'stopped'; +} diff --git a/client/src/debug/shared/debug-type.ts b/client/src/debug/shared/debug-type.ts new file mode 100644 index 0000000..8f87006 --- /dev/null +++ b/client/src/debug/shared/debug-type.ts @@ -0,0 +1,2 @@ +/** The debug type this extension contributes (`contributes.debuggers` in package.json). */ +export const DEBUG_TYPE = 'nushell'; diff --git a/client/src/debug/shared/escape-html.ts b/client/src/debug/shared/escape-html.ts new file mode 100644 index 0000000..a543695 --- /dev/null +++ b/client/src/debug/shared/escape-html.ts @@ -0,0 +1,12 @@ +/** Escape the characters that would otherwise be read as markup inside a webview's text content. */ +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(//g, '>'); +} + +/** Escape for an attribute value (`data-key="…"`): text escaping plus both quote characters. */ +export function escapeAttribute(text: string): string { + return escapeHtml(text).replace(/"/g, '"').replace(/'/g, '''); +} diff --git a/client/src/debug/shared/html.ts b/client/src/debug/shared/html.ts new file mode 100644 index 0000000..85323f9 --- /dev/null +++ b/client/src/debug/shared/html.ts @@ -0,0 +1,43 @@ +import * as fs from 'fs'; + +import { escapeHtml } from './escape-html'; + +/** A random nonce for a webview's Content-Security-Policy `script-src`. */ +export function makeNonce(): string { + return Math.random().toString(36).slice(2); +} + +/** A muted one-line message, styled by the `.hint` rule every page carries. */ +export function hint(text: string): string { + return `

${escapeHtml(text)}

`; +} + +const templates = new Map(); + +/** + * Fill a webview's `.html` template: every `{{key}}` becomes `values[key]`, + * inserted verbatim — callers escape what needs escaping. Templates ship with + * the extension and cannot change at runtime, so each is read once. + */ +export function renderTemplate( + templatePath: string, + values: Record, +): string { + let template = templates.get(templatePath); + if (template === undefined) { + template = fs.readFileSync(templatePath, 'utf8'); + templates.set(templatePath, template); + } + + return fillTemplate(template, values); +} + +export function fillTemplate( + template: string, + values: Record, +): string { + return template.replace( + /\{\{(\w+)\}\}/g, + (match, key: string) => values[key] ?? match, + ); +} diff --git a/client/src/debug/shared/nu-document.ts b/client/src/debug/shared/nu-document.ts new file mode 100644 index 0000000..a41630f --- /dev/null +++ b/client/src/debug/shared/nu-document.ts @@ -0,0 +1,13 @@ +import * as vscode from 'vscode'; + +/** Language ids other extensions register for Nushell; either counts. */ +const NU_LANGUAGE_IDS = ['nushell', 'nu']; + +/** Is this document a Nushell script? */ +export function isNuDocument(doc: vscode.TextDocument): boolean { + if (NU_LANGUAGE_IDS.includes(doc.languageId)) { + return true; + } + + return doc.fileName.endsWith('.nu'); +} diff --git a/client/src/debug/shared/session.ts b/client/src/debug/shared/session.ts new file mode 100644 index 0000000..906404b --- /dev/null +++ b/client/src/debug/shared/session.ts @@ -0,0 +1,21 @@ +import * as vscode from 'vscode'; + +import { DEBUG_TYPE } from './debug-type'; + +export function isNuSession(session: vscode.DebugSession): boolean { + return session.type === DEBUG_TYPE; +} + +/** The active debug session, when it is one of ours. */ +export function activeNuSession(): vscode.DebugSession | undefined { + const session = vscode.debug.activeDebugSession; + if (!session) { + return undefined; + } + + if (!isNuSession(session)) { + return undefined; + } + + return session; +} diff --git a/client/src/debug/shared/stopped-event.ts b/client/src/debug/shared/stopped-event.ts new file mode 100644 index 0000000..e36d5c9 --- /dev/null +++ b/client/src/debug/shared/stopped-event.ts @@ -0,0 +1,29 @@ +import * as vscode from 'vscode'; + +import { isStoppedEvent } from './dap-message'; +import { DEBUG_TYPE } from './debug-type'; + +/** + * Subscribe to the adapter's `stopped` events. Time-travel navigation (Step + * Back / Reverse Continue) emits `stopped` without running any code, so nothing + * else (like the `nuDapIr` event) announces those pauses. + */ +export function onStopped( + handle: (session: vscode.DebugSession) => void, +): vscode.Disposable { + return vscode.debug.registerDebugAdapterTrackerFactory(DEBUG_TYPE, { + createDebugAdapterTracker( + session: vscode.DebugSession, + ): vscode.DebugAdapterTracker { + return { + onDidSendMessage(message: unknown): void { + if (!isStoppedEvent(message)) { + return; + } + + handle(session); + }, + }; + }, + }); +} diff --git a/client/src/debug/tests/dap-message.test.ts b/client/src/debug/tests/dap-message.test.ts new file mode 100644 index 0000000..63d16f9 --- /dev/null +++ b/client/src/debug/tests/dap-message.test.ts @@ -0,0 +1,23 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { isStoppedEvent } from '../shared/dap-message'; + +test('isStoppedEvent recognises a stopped event', () => { + assert.equal( + isStoppedEvent({ + seq: 7, + type: 'event', + event: 'stopped', + body: { reason: 'step' }, + }), + true, + ); +}); + +test('isStoppedEvent rejects other events, responses, and junk', () => { + assert.equal(isStoppedEvent({ type: 'event', event: 'output' }), false); + assert.equal(isStoppedEvent({ type: 'response', command: 'stopped' }), false); + assert.equal(isStoppedEvent(null), false); + assert.equal(isStoppedEvent('stopped'), false); +}); diff --git a/client/src/debug/tests/escape-html.test.ts b/client/src/debug/tests/escape-html.test.ts new file mode 100644 index 0000000..a8fc4d6 --- /dev/null +++ b/client/src/debug/tests/escape-html.test.ts @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { escapeAttribute, escapeHtml } from '../shared/escape-html'; + +test('escapeHtml neutralises markup', () => { + assert.equal(escapeHtml(``), `<a & "b">`); +}); + +test('escapeAttribute also neutralises both quote characters', () => { + assert.equal(escapeAttribute(`a"b'c`), 'a"b'c<d>'); +}); diff --git a/client/src/debug/tests/html.test.ts b/client/src/debug/tests/html.test.ts new file mode 100644 index 0000000..e76201a --- /dev/null +++ b/client/src/debug/tests/html.test.ts @@ -0,0 +1,32 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { fillTemplate, hint } from '../shared/html'; + +test('fillTemplate substitutes every placeholder verbatim', () => { + assert.equal( + fillTemplate('

{{name}}

{{name}}: {{type}}', { + name: '$files', + type: 'table', + }), + '

$files

$files: table', + ); +}); + +test('fillTemplate leaves an unknown placeholder in place', () => { + assert.equal( + fillTemplate('{{known}} {{unknown}}', { known: 'x' }), + 'x {{unknown}}', + ); +}); + +test('fillTemplate never expands placeholders that arrive inside a value', () => { + assert.equal( + fillTemplate('{{data}}', { data: '{"text":"{{name}}"}', name: 'leak' }), + '{"text":"{{name}}"}', + ); +}); + +test('hint escapes its text', () => { + assert.equal(hint('a '), '

a <b>

'); +}); diff --git a/client/src/extension.ts b/client/src/extension.ts index a5ff9be..b3ac5d1 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -17,6 +17,9 @@ import { RevealOutputChannelOn, } from 'vscode-languageclient/node'; +import { registerDebugger } from './debug/register'; +import { ensureNushell } from './nushell/resolve'; + const EXTENSION_ID = 'TheNuProjectContributors.vscode-nushell-lang'; const CONFIG_SECTION = 'nushellLanguageServer'; diff --git a/client/tsconfig.json b/client/tsconfig.json index 4f535bb..1cd8635 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -10,5 +10,5 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": ["node_modules", ".vscode-test"] + "exclude": ["node_modules", ".vscode-test", "src/**/webview/**"] } diff --git a/client/tsconfig.unit.json b/client/tsconfig.unit.json new file mode 100644 index 0000000..12f9c30 --- /dev/null +++ b/client/tsconfig.unit.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "out-unit", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["src/**/*.test.ts"], + "exclude": ["node_modules", "src/test/**", "src/**/webview/**"] +} diff --git a/client/tsconfig.webview.json b/client/tsconfig.webview.json new file mode 100644 index 0000000..fa24410 --- /dev/null +++ b/client/tsconfig.webview.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": [], + "strict": true, + "noEmit": true, + "skipLibCheck": true + }, + "include": [ + "src/debug/**/webview/**/*.ts", + "src/debug/visualize/webview-payload.ts", + "src/debug/visualize/cell-values.ts", + "src/debug/visualize/hex-dump.ts", + "src/debug/visualize/text-format.ts", + "src/debug/shared/escape-html.ts" + ] +} diff --git a/package.json b/package.json index b06ca4a..56e3ba9 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,11 @@ "icon": "assets/nushell.ico", "categories": [ "Programming Languages", - "Snippets" + "Snippets", + "Debuggers" + ], + "activationEvents": [ + "onDebugResolve:nushell" ], "main": "out/extension.js", "contributes": { @@ -144,19 +148,125 @@ { "command": "nushell.startLanguageServer", "title": "Nushell: Start Language Server" + }, + { + "command": "nushell-debug.showIr", + "title": "Nushell Debug: Show IR" + }, + { + "command": "nushell-debug.visualizeVariable", + "title": "Visualize" + } + ], + "breakpoints": [ + { + "language": "nushell" + } + ], + "debuggers": [ + { + "type": "nushell", + "label": "Nushell Debug", + "languages": [ + "nushell" + ], + "configurationAttributes": { + "launch": { + "required": [ + "program" + ], + "properties": { + "program": { + "type": "string", + "description": "Path to the .nu script to debug. A relative path is resolved against cwd.", + "default": "${file}" + }, + "cwd": { + "type": "string", + "description": "Working directory ($env.PWD) for the script.", + "default": "${workspaceFolder}" + }, + "args": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Arguments passed to the entry point (`main`, or `entryPoint`). When omitted and the entry point has required parameters, you are prompted for them at launch.", + "default": [] + }, + "entryPoint": { + "type": "string", + "description": "For a script without a `main`: the command to call after the script has loaded. When omitted you are asked to pick one (or to run the script top-to-bottom)." + }, + "stopOnEntry": { + "type": "boolean", + "description": "Pause on the first instruction.", + "default": false + }, + "timeTravel": { + "type": "boolean", + "description": "Record execution history for Step Back / Reverse Continue. Defaults to the `nushellDebugger.timeTravel` setting." + }, + "timeTravelMaxSteps": { + "type": "number", + "description": "Maximum number of recorded steps kept for time travel. Defaults to the `nushellDebugger.timeTravelMaxSteps` setting." + } + } + } + }, + "initialConfigurations": [ + { + "type": "nushell", + "request": "launch", + "name": "Debug nu script", + "program": "${file}", + "cwd": "${workspaceFolder}", + "stopOnEntry": false + } + ], + "configurationSnippets": [ + { + "label": "Nushell: Launch current file", + "body": { + "type": "nushell", + "request": "launch", + "name": "Debug ${1:script}.nu", + "program": "^\"\\${file}\"", + "stopOnEntry": false + } + } + ] } - ] + ], + "menus": { + "debug/variables/context": [ + { + "command": "nushell-debug.visualizeVariable", + "when": "debugType == 'nushell'" + } + ], + "commandPalette": [ + { + "command": "nushell-debug.visualizeVariable", + "when": "false" + } + ] + } }, "scripts": { "vscode:prepublish": "npm run lint && npm run compile", "esbuild-base": "npx esbuild client/src/extension.ts --bundle --outdir=out --external:vscode --format=cjs --platform=node", "esbuild": "npm run esbuild-base -- --sourcemap --minify", - "compile": "npm run esbuild", + "esbuild-webview": "npx esbuild client/src/debug/visualize/webview/main.ts --bundle --format=iife --target=es2022 --minify --outfile=out/debug/visualize-panel.webview.js", + "copy-assets": "node ./scripts/copy-debug-assets.js", + "compile": "npm run esbuild && npm run esbuild-webview && npm run copy-assets", "install": "cd client && npm install && cd ..", - "watch": "npm run esbuild-base -- --sourcemap --watch", + "watch": "npm run copy-assets && npm run esbuild-webview && npm run esbuild-base -- --sourcemap --watch", "lint": "npx eslint ./client/src/extension.ts", "compile:test": "tsc -p client", "test": "npm run compile:test && node ./scripts/e2e.js", + "test:unit": "node -e \"require('fs').rmSync('client/out-unit',{recursive:true,force:true})\" && tsc -p client/tsconfig.unit.json && node --test \"client/out-unit/**/*.test.js\"", + "typecheck:webview": "tsc -p client/tsconfig.webview.json", "test:grammar": "vscode-tmgrammar-snap tests/cases/*", "fmt": "prettier . --write" }, diff --git a/scripts/copy-debug-assets.js b/scripts/copy-debug-assets.js new file mode 100644 index 0000000..5dcd3a3 --- /dev/null +++ b/scripts/copy-debug-assets.js @@ -0,0 +1,37 @@ +// Copy the debugger's webview pages (.html/.css), which live next to their +// TypeScript under client/src/debug/, into out/debug/. The extension is bundled +// into out/extension.js, so the panels load them from there (see +// client/src/debug/shared/assets.ts). Webview scripts are not copied: esbuild +// bundles client/src/debug/visualize/webview/main.ts (see package.json). +const fs = require('fs'); +const path = require('path'); + +const SRC = path.join(__dirname, '..', 'client', 'src', 'debug'); +const OUT = path.join(__dirname, '..', 'out', 'debug'); +const ASSET = /\.(html|css)$/; + +function copyAssets(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const from = path.join(dir, entry.name); + if (entry.isDirectory()) { + copyAssets(from); + continue; + } + + if (!ASSET.test(entry.name)) { + continue; + } + + // Flat: the panels look assets up by file name only + const to = path.join(OUT, entry.name); + if (copied.has(entry.name)) { + throw new Error(`Duplicate debugger asset name: ${entry.name}`); + } + fs.mkdirSync(OUT, { recursive: true }); + fs.copyFileSync(from, to); + copied.add(entry.name); + } +} + +const copied = new Set(); +copyAssets(SRC); diff --git a/tsconfig.json b/tsconfig.json index 5723b6b..3aa353f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "skipLibCheck": true }, "include": ["client/src"], - "exclude": ["node_modules", ".vscode-test"] + "exclude": ["node_modules", ".vscode-test", "client/src/**/webview/**"] } From 90b0a2bb641b111bc1e0fd667fc54bae199c11bf Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:12:10 +0200 Subject: [PATCH 08/10] refactor: Centralize Nushell executable resolution --- client/src/extension.ts | 117 +++++++++++++--------------------- client/src/nushell/resolve.ts | 97 ++++++++++++++++++++++++++++ client/src/nushell/version.ts | 25 ++++++++ 3 files changed, 168 insertions(+), 71 deletions(-) create mode 100644 client/src/nushell/resolve.ts create mode 100644 client/src/nushell/version.ts diff --git a/client/src/extension.ts b/client/src/extension.ts index b3ac5d1..78e4ddd 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -3,10 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ -import * as os from 'os'; -import * as path from 'path'; import * as vscode from 'vscode'; -import * as which from 'which'; import { window, type OutputChannel } from 'vscode'; import { @@ -27,55 +24,6 @@ let client: LanguageClient | undefined; let fileWatcher: vscode.FileSystemWatcher | undefined; let outputChannel: OutputChannel | undefined; // Single output channel for server logs and trace -function expandHome(p: string): string { - if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) { - return path.join(os.homedir(), p.slice(1)); - } - return p; -} - -/** - * Resolve the nushell executable: the configured path if it exists, - * otherwise `nu` on PATH. Returns null when nothing usable is found. - */ -function findNushellExecutable(): string | null { - try { - const config = vscode.workspace.getConfiguration(CONFIG_SECTION, null); - const configuredPath = config - .get('nushellExecutablePath', 'nu') - .trim(); - - if (configuredPath && configuredPath !== 'nu') { - const found = which.sync(expandHome(configuredPath), { nothrow: true }); - if (found) { - return found; - } - void vscode.window.showWarningMessage( - `Configured nushell executable '${configuredPath}' was not found. Falling back to 'nu' on PATH.`, - ); - } - - return which.sync('nu', { nothrow: true }); - } catch { - return null; - } -} - -function showNushellNotFound(): void { - void vscode.window - .showErrorMessage( - 'Nushell executable not found. Install Nushell or set "nushellLanguageServer.nushellExecutablePath", then run "Nushell: Start Language Server".', - 'Install from website', - ) - .then((selection) => { - if (selection) { - void vscode.env.openExternal( - vscode.Uri.parse('https://www.nushell.sh/'), - ); - } - }); -} - function getOutputChannel(context: vscode.ExtensionContext): OutputChannel { if (!outputChannel) { outputChannel = window.createOutputChannel('Nushell Language Server'); @@ -116,7 +64,10 @@ function applyTraceFromConfig(): void { * Start `nu --lsp` and connect the language client to it. * Returns true when a new client was started. */ -function startLanguageServer(context: vscode.ExtensionContext): boolean { +function startLanguageServer( + context: vscode.ExtensionContext, + nushellPath: string, +): boolean { if (client) { void vscode.window.showInformationMessage( 'Nushell Language Server is already running.', @@ -124,14 +75,6 @@ function startLanguageServer(context: vscode.ExtensionContext): boolean { return false; } - // Resolve the executable on every start so a changed setting or a fresh - // install is picked up without reloading the window. - const nushellPath = findNushellExecutable(); - if (!nushellPath) { - showNushellNotFound(); - return false; - } - const channel = getOutputChannel(context); // Use Nushell's native LSP server @@ -217,20 +160,37 @@ async function stopLanguageServer(): Promise { return true; } +async function restartLanguageServer( + context: vscode.ExtensionContext, + nushellPath: string, +): Promise { + try { + await stopLanguageServer(); + } catch (error) { + console.error('Failed to stop Nushell Language Server:', error); + } + startLanguageServer(context, nushellPath); +} + export function activate(context: vscode.ExtensionContext) { console.log(`Activating ${EXTENSION_ID}.`); getOutputChannel(context); + // The language server, the terminal profile and the debugger share one `nu`, + // resolved as: nushellExecutablePath setting -> PATH. + // It is resolved on every use so a changed setting or a fresh install is + // picked up without reloading the window. + const nushell = async () => ensureNushell(); + context.subscriptions.push( vscode.window.registerTerminalProfileProvider('nushell_default', { - provideTerminalProfile( + async provideTerminalProfile( token: vscode.CancellationToken, - ): vscode.ProviderResult { + ): Promise { // Consume token to satisfy no-unused-vars without changing behavior void token; - const nushellPath = findNushellExecutable(); + const nushellPath = await nushell(); if (!nushellPath) { - showNushellNotFound(); return undefined; } @@ -248,20 +208,31 @@ export function activate(context: vscode.ExtensionContext) { }), ); - // React to trace level changes for the lifetime of the extension + // The debugger resolves `nu` itself when a session starts + context.subscriptions.push(...registerDebugger(nushell)); + + // React to trace level changes for the lifetime of the extension, and + // restart the language server when the executable path setting changes context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((e) => { + vscode.workspace.onDidChangeConfiguration(async (e) => { if (e.affectsConfiguration(`${CONFIG_SECTION}.trace.server`)) { applyTraceFromConfig(); } + if (e.affectsConfiguration(`${CONFIG_SECTION}.nushellExecutablePath`)) { + const nushellPath = await nushell(); + if (nushellPath) { + await restartLanguageServer(context, nushellPath); + } + } }), ); // Commands are registered before the server starts so they keep working // even when nushell was not found at activation time. context.subscriptions.push( - vscode.commands.registerCommand('nushell.startLanguageServer', () => { - if (startLanguageServer(context)) { + vscode.commands.registerCommand('nushell.startLanguageServer', async () => { + const nushellPath = await nushell(); + if (nushellPath && startLanguageServer(context, nushellPath)) { void vscode.window.showInformationMessage( 'Nushell Language Server started.', ); @@ -303,8 +274,12 @@ export function activate(context: vscode.ExtensionContext) { }), ); - // Start the language server when the extension is activated - startLanguageServer(context); + // Start the language server once a `nu` is available + void nushell().then((nushellPath) => { + if (nushellPath) { + startLanguageServer(context, nushellPath); + } + }); } export function deactivate(): Thenable | undefined { diff --git a/client/src/nushell/resolve.ts b/client/src/nushell/resolve.ts new file mode 100644 index 0000000..4f755f0 --- /dev/null +++ b/client/src/nushell/resolve.ts @@ -0,0 +1,97 @@ +import * as os from 'os'; +import * as path from 'path'; +import { + commands, + env, + Uri, + window, + workspace, + WorkspaceConfiguration, +} from 'vscode'; +import * as which from 'which'; + +import { NUSHELL_DOWNLOAD_PAGE } from './version'; + +const CONFIG_SECTION = 'nushellLanguageServer'; +const EXECUTABLE_SETTING = 'nushellExecutablePath'; + +export type NushellSource = 'setting' | 'path'; + +export type Resolution = + | { kind: 'found'; path: string; source: NushellSource } + | { kind: 'missing' }; + +function settings(): WorkspaceConfiguration { + // Use null for resource to get global/workspace settings + return workspace.getConfiguration(CONFIG_SECTION, null); +} + +function expandHome(p: string): string { + if (!(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) { + return p; + } + + return path.join(os.homedir(), p.slice(1)); +} + +/** + * Find the `nu` used by the language server, the terminal profile and the + * debugger, in order: the `nushellExecutablePath` setting -> `nu` on PATH. + */ +export function resolveNushell(): Resolution { + const configured = settings().get(EXECUTABLE_SETTING, 'nu').trim(); + + if (configured && configured !== 'nu') { + const found = which.sync(expandHome(configured), { nothrow: true }); + if (found) { + return { kind: 'found', path: found, source: 'setting' }; + } + + void window.showWarningMessage( + `Nushell executable not found at ${CONFIG_SECTION}.${EXECUTABLE_SETTING} (${configured}). Falling back to 'nu' on PATH.`, + ); + } + + const onPath = which.sync('nu', { nothrow: true }); + if (onPath) { + return { kind: 'found', path: onPath, source: 'path' }; + } + + return { kind: 'missing' }; +} + +/** + * Resolve `nu`, pointing the user at the Nushell download page when nothing + * is configured or on PATH. Returns undefined when there is no usable `nu`. + */ +export function ensureNushell(): string | undefined { + const resolution = resolveNushell(); + if (resolution.kind === 'found') { + return resolution.path; + } + + void showNotFound(); + return undefined; +} + +async function showNotFound(): Promise { + const openPage = 'Open download page'; + const configurePath = 'Configure path'; + const choice = await window.showWarningMessage( + 'Nushell was not found in your PATH. Install Nushell, or set the path to nu.', + openPage, + configurePath, + ); + + switch (choice) { + case openPage: + await env.openExternal(Uri.parse(NUSHELL_DOWNLOAD_PAGE)); + break; + case configurePath: + await commands.executeCommand( + 'workbench.action.openSettings', + `${CONFIG_SECTION}.${EXECUTABLE_SETTING}`, + ); + break; + } +} diff --git a/client/src/nushell/version.ts b/client/src/nushell/version.ts new file mode 100644 index 0000000..1e8c099 --- /dev/null +++ b/client/src/nushell/version.ts @@ -0,0 +1,25 @@ +// Pure helpers for checking a Nushell version. +// Kept free of `vscode` imports so they can be unit tested with node:test. + +/** Where users are sent to install or upgrade Nushell themselves. */ +export const NUSHELL_DOWNLOAD_PAGE = + 'https://www.nushell.sh/book/installation.html'; + +/** Extract `x.y.z` from `nu --version` output (e.g. `0.116.0` or `0.116.0-nightly.3`). */ +export function parseNuVersion(output: string): string | undefined { + const match = /(\d+)\.(\d+)\.(\d+)/.exec(output); + return match ? `${match[1]}.${match[2]}.${match[3]}` : undefined; +} + +/** Numeric comparison of `x.y.z` versions: negative when a < b, 0 when equal, positive when a > b. */ +export function compareVersions(a: string, b: string): number { + const pa = a.split('.').map(Number); + const pb = b.split('.').map(Number); + for (let i = 0; i < Math.max(pa.length, pb.length); i++) { + const diff = (pa[i] ?? 0) - (pb[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} From 61f7edbd66bb1fd4bcdf2ef2fa4363d853f27ac8 Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Fri, 9 Oct 2026 21:15:56 +0200 Subject: [PATCH 09/10] feat: Add Show IR editor toolbar action Co-Authored-By: Claude Opus 5.5 --- package.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 56e3ba9..66e46da 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,8 @@ }, { "command": "nushell-debug.showIr", - "title": "Nushell Debug: Show IR" + "title": "Nushell Debug: Show IR", + "icon": "$(file-binary)" }, { "command": "nushell-debug.visualizeVariable", @@ -239,6 +240,13 @@ } ], "menus": { + "editor/title": [ + { + "command": "nushell-debug.showIr", + "when": "editorLangId == nushell", + "group": "navigation" + } + ], "debug/variables/context": [ { "command": "nushell-debug.visualizeVariable", From 4bd1a5eaad84a03e7316719bec7eb42eab32e719 Mon Sep 17 00:00:00 2001 From: Ronald van Helden Date: Sat, 10 Oct 2026 19:48:37 +0200 Subject: [PATCH 10/10] test: Extract review-affected logic and add failing tests Moves logic touched by the second PR #241 review into vscode-free modules, unchanged, so node:test can reach it: launch paths, nu resolution, the language-server restart on setting changes, the visualize request guard and the DAP version check. Adds tests for the behaviour the review asks for; 19 of them fail until the follow-up fixes land. Co-Authored-By: Claude Opus 5.5 --- client/src/debug/adapter/dap-support.ts | 48 +++++++ .../src/debug/adapter/descriptor-factory.ts | 32 +---- .../debug/launch/configuration-provider.ts | 34 ++--- client/src/debug/launch/entry-point.ts | 12 +- client/src/debug/launch/launch-paths.ts | 34 +++++ client/src/debug/launch/script-signatures.ts | 18 ++- client/src/debug/tests/dap-support.test.ts | 53 +++++++ client/src/debug/tests/fixtures/ast-defs.json | 2 +- client/src/debug/tests/fixtures/ast-defs.nu | 6 + client/src/debug/tests/launch-paths.test.ts | 45 ++++++ .../debug/tests/request-generation.test.ts | 86 ++++++++++++ .../src/debug/tests/script-signatures.test.ts | 81 ++++++++++- client/src/debug/visualize/pinned-variable.ts | 19 ++- .../src/debug/visualize/request-generation.ts | 32 +++++ .../src/debug/visualize/visualize-command.ts | 10 +- client/src/extension.ts | 22 ++- client/src/nushell/resolve-executable.ts | 50 +++++++ client/src/nushell/resolve.ts | 45 ++---- client/src/nushell/server-restart.ts | 49 +++++++ .../nushell/tests/resolve-executable.test.ts | 59 ++++++++ .../src/nushell/tests/server-restart.test.ts | 131 ++++++++++++++++++ 21 files changed, 752 insertions(+), 116 deletions(-) create mode 100644 client/src/debug/adapter/dap-support.ts create mode 100644 client/src/debug/launch/launch-paths.ts create mode 100644 client/src/debug/tests/dap-support.test.ts create mode 100644 client/src/debug/tests/launch-paths.test.ts create mode 100644 client/src/debug/tests/request-generation.test.ts create mode 100644 client/src/debug/visualize/request-generation.ts create mode 100644 client/src/nushell/resolve-executable.ts create mode 100644 client/src/nushell/server-restart.ts create mode 100644 client/src/nushell/tests/resolve-executable.test.ts create mode 100644 client/src/nushell/tests/server-restart.test.ts diff --git a/client/src/debug/adapter/dap-support.ts b/client/src/debug/adapter/dap-support.ts new file mode 100644 index 0000000..c456870 --- /dev/null +++ b/client/src/debug/adapter/dap-support.ts @@ -0,0 +1,48 @@ +// Does a `nu` ship the debugger? Checked once per path. +// Kept free of `vscode` imports so it can be unit tested with node:test. + +import { compareVersions } from '../../nushell/version'; + +/** The first Nushell release that ships `nu --dap`. */ +export const MIN_DAP_VERSION = '0.116.0'; + +/** `nu --version` of a `nu`, as `x.y.z`; undefined when it cannot be asked. */ +export type VersionReader = (nu: string) => Promise; + +export type DapSupportResult = + | { kind: 'ok' } + | { kind: 'unknown' } + | { kind: 'too-old'; version: string }; + +/** + * Compares `nu --version` against {@link MIN_DAP_VERSION}; nightlies report + * the upcoming release (e.g. `0.116.0-nightly.3`), so they pass too. Not + * cached on failure: the user may upgrade `nu` before the next launch. + */ +export class DapSupport { + /** `nu` paths already known to support `--dap`. */ + private readonly supported = new Set(); + private readonly readVersion: VersionReader; + + constructor(readVersion: VersionReader) { + this.readVersion = readVersion; + } + + async check(nu: string): Promise { + if (this.supported.has(nu)) { + return { kind: 'ok' }; + } + + const version = await this.readVersion(nu); + if (!version) { + return { kind: 'unknown' }; // could not ask: let `nu --dap` speak for itself + } + + if (compareVersions(version, MIN_DAP_VERSION) >= 0) { + this.supported.add(nu); + return { kind: 'ok' }; + } + + return { kind: 'too-old', version }; + } +} diff --git a/client/src/debug/adapter/descriptor-factory.ts b/client/src/debug/adapter/descriptor-factory.ts index 311d370..d9f3f94 100644 --- a/client/src/debug/adapter/descriptor-factory.ts +++ b/client/src/debug/adapter/descriptor-factory.ts @@ -10,17 +10,11 @@ import { window, } from 'vscode'; -import { - NUSHELL_DOWNLOAD_PAGE, - compareVersions, - parseNuVersion, -} from '../../nushell/version'; +import { NUSHELL_DOWNLOAD_PAGE, parseNuVersion } from '../../nushell/version'; +import { DapSupport, MIN_DAP_VERSION } from './dap-support'; const execFileAsync = promisify(execFile); -/** The first Nushell release that ships `nu --dap`. */ -export const MIN_DAP_VERSION = '0.116.0'; - /** Finds the `nu` to run; undefined when there is none, after telling the user. */ export type NushellResolver = () => Promise; @@ -31,8 +25,7 @@ export type NushellResolver = () => Promise; * uses) and spawn it. */ export class AdapterDescriptorFactory implements DebugAdapterDescriptorFactory { - /** `nu` paths already known to support `--dap`. */ - private readonly supported = new Set(); + private readonly dapSupport = new DapSupport(nuVersion); private readonly resolveNushell: NushellResolver; constructor(resolveNushell: NushellResolver) { @@ -55,30 +48,17 @@ export class AdapterDescriptorFactory implements DebugAdapterDescriptorFactory { } /** - * Compares `nu --version` against {@link MIN_DAP_VERSION}; nightlies report - * the upcoming release (e.g. `0.116.0-nightly.3`), so they pass too. Not - * cached on failure: the user may upgrade `nu` before the next launch. - * * A too old `nu` cancels the launch and offers the Nushell download page. * VS Code doesn't show an error for a cancelled launch, so this is the only * message the user sees. */ private async checkDapSupport(nu: string): Promise { - if (this.supported.has(nu)) { - return; - } - - const version = await nuVersion(nu); - if (!version) { - return; // could not ask: let `nu --dap` speak for itself - } - - if (compareVersions(version, MIN_DAP_VERSION) >= 0) { - this.supported.add(nu); + const support = await this.dapSupport.check(nu); + if (support.kind !== 'too-old') { return; } - void offerDownloadPage(nu, version); + void offerDownloadPage(nu, support.version); throw new CancellationError(); } } diff --git a/client/src/debug/launch/configuration-provider.ts b/client/src/debug/launch/configuration-provider.ts index e616919..4c59bd1 100644 --- a/client/src/debug/launch/configuration-provider.ts +++ b/client/src/debug/launch/configuration-provider.ts @@ -5,6 +5,7 @@ import { resolveNushell } from '../../nushell/resolve'; import { DEBUG_TYPE } from '../shared/debug-type'; import { isNuDocument } from '../shared/nu-document'; import { resolveEntryPoint } from './entry-point'; +import { resolveLaunchPaths } from './launch-paths'; export class LaunchConfigurationProvider implements vscode.DebugConfigurationProvider @@ -40,36 +41,17 @@ export class LaunchConfigurationProvider return config; } - config.program = absoluteProgram(folder, config); + const paths = resolveLaunchPaths( + folder?.uri.fsPath, + config.program, + config.cwd, + ); + config.program = paths.program; + config.cwd = paths.cwd; return await resolveEntryPoint(config, config.program, quietNushell()); } } -/** - * `program: "scripts/tool.nu"` is relative to the script's working directory, - * not to the extension host's — resolve it so both we and the adapter read - * the right file. - */ -function absoluteProgram( - folder: vscode.WorkspaceFolder | undefined, - config: vscode.DebugConfiguration, -): string { - const program: string = config.program; - if (path.isAbsolute(program)) { - return program; - } - - // `cwd` may itself be relative to the workspace folder. - const bases = [folder?.uri.fsPath, config.cwd].filter( - (base): base is string => typeof base === 'string' && base !== '', - ); - if (bases.length === 0) { - return program; // nothing to anchor it to: let the adapter report it - } - - return path.resolve(...bases, program); -} - /** * The `nu` to parse the script with. Unlike `ensureNushell` this doesn't * report a missing `nu`: the adapter factory does that once, right after. diff --git a/client/src/debug/launch/entry-point.ts b/client/src/debug/launch/entry-point.ts index f8bbb9e..aa9f60d 100644 --- a/client/src/debug/launch/entry-point.ts +++ b/client/src/debug/launch/entry-point.ts @@ -1,7 +1,11 @@ import * as vscode from 'vscode'; import { promptArgs } from './argument-prompt'; -import { NuDefinition, readDefinitions } from './script-signatures'; +import { + NuDefinition, + needsArgumentPrompt, + readDefinitions, +} from './script-signatures'; /** The extra entry in the entry-point picker: don't call a command, just run the file. */ const RUN_TOP_TO_BOTTOM = '▶ Run script top-to-bottom'; @@ -89,11 +93,7 @@ async function ensureArgs( config: vscode.DebugConfiguration, def: NuDefinition, ): Promise { - if (config.args !== undefined) { - return config; - } - - if (!def.hasRequired) { + if (!needsArgumentPrompt(def, config.args)) { return config; } diff --git a/client/src/debug/launch/launch-paths.ts b/client/src/debug/launch/launch-paths.ts new file mode 100644 index 0000000..9910f4f --- /dev/null +++ b/client/src/debug/launch/launch-paths.ts @@ -0,0 +1,34 @@ +// Where a launch's `program` and `cwd` point, before anything reads them. +// Kept free of `vscode` imports so it can be unit tested with node:test. + +import * as path from 'path'; + +export interface LaunchPaths { + program: string; + cwd: unknown; +} + +/** + * `program: "scripts/tool.nu"` is relative to the script's working directory, + * not to the extension host's — resolve it so both we and the adapter read + * the right file. + */ +export function resolveLaunchPaths( + folder: string | undefined, + program: string, + cwd: unknown, +): LaunchPaths { + if (path.isAbsolute(program)) { + return { program, cwd }; + } + + // `cwd` may itself be relative to the workspace folder. + const bases = [folder, cwd].filter( + (base): base is string => typeof base === 'string' && base !== '', + ); + if (bases.length === 0) { + return { program, cwd }; // nothing to anchor it to: let the adapter report it + } + + return { program: path.resolve(...bases, program), cwd }; +} diff --git a/client/src/debug/launch/script-signatures.ts b/client/src/debug/launch/script-signatures.ts index 3465cca..d4f9b4d 100644 --- a/client/src/debug/launch/script-signatures.ts +++ b/client/src/debug/launch/script-signatures.ts @@ -114,11 +114,27 @@ function definitionFromCall(call: unknown): NuDefinition | undefined { return { name, - signature: typeof written === 'string' ? written.slice(1, -1) : '', + signature: typeof written === 'string' ? parameterList(written) : '', hasRequired: required.length > 0, }; } +/** + * The parameter list inside a Signature's source text, without its brackets. + */ +export function parameterList(written: string): string { + return written.slice(1, -1); +} + +/** Prompt for arguments when the entry point requires some and the launch config has none. */ +export function needsArgumentPrompt(def: NuDefinition, args: unknown): boolean { + if (args !== undefined) { + return false; + } + + return def.hasRequired; +} + function field(value: unknown, key: string): unknown { if (typeof value !== 'object' || value === null) { return undefined; diff --git a/client/src/debug/tests/dap-support.test.ts b/client/src/debug/tests/dap-support.test.ts new file mode 100644 index 0000000..4c8ec58 --- /dev/null +++ b/client/src/debug/tests/dap-support.test.ts @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { DapSupport } from '../adapter/dap-support'; + +/** A version reader answering from a table, counting the questions. */ +function reader(versions: Record) { + const asked: string[] = []; + const read = async (nu: string) => { + asked.push(nu); + return versions[nu]; + }; + return { read, asked }; +} + +test('DapSupport accepts 0.116.0 and later', async () => { + const { read } = reader({ '/a/nu': '0.116.0', '/b/nu': '0.117.2' }); + const support = new DapSupport(read); + assert.deepEqual(await support.check('/a/nu'), { kind: 'ok' }); + assert.deepEqual(await support.check('/b/nu'), { kind: 'ok' }); +}); + +test('DapSupport reports an older nu with its version', async () => { + const { read } = reader({ '/old/nu': '0.115.9' }); + assert.deepEqual(await new DapSupport(read).check('/old/nu'), { + kind: 'too-old', + version: '0.115.9', + }); +}); + +test('DapSupport lets an unreadable version through as unknown', async () => { + const { read } = reader({}); + assert.deepEqual(await new DapSupport(read).check('/x/nu'), { + kind: 'unknown', + }); +}); + +test('DapSupport caches success, but not failure', async () => { + const versions: Record = { + '/a/nu': '0.116.0', + '/old/nu': '0.115.0', + }; + const { read, asked } = reader(versions); + const support = new DapSupport(read); + + await support.check('/a/nu'); + await support.check('/a/nu'); + await support.check('/old/nu'); + versions['/old/nu'] = '0.116.1'; // upgraded in between + assert.deepEqual(await support.check('/old/nu'), { kind: 'ok' }); + + assert.deepEqual(asked, ['/a/nu', '/old/nu', '/old/nu']); +}); diff --git a/client/src/debug/tests/fixtures/ast-defs.json b/client/src/debug/tests/fixtures/ast-defs.json index 7e9026d..ebf1e1c 100644 --- a/client/src/debug/tests/fixtures/ast-defs.json +++ b/client/src/debug/tests/fixtures/ast-defs.json @@ -1 +1 @@ -{"signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"},"pipelines":[{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":511,"head":{"start":152669,"end":152674,"span_source":"print"},"arguments":[{"Positional":{"expr":{"String":"side effect"},"span":{"start":152675,"end":152688,"span_source":"\"side effect\""},"span_id":4682,"ty":"String"}}],"parser_info":{}}},"span":{"start":152669,"end":152688,"span_source":"print \"side effect\""},"span_id":4683,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":40,"head":{"start":152689,"end":152695,"span_source":"module"},"arguments":[{"Positional":{"expr":{"String":"m"},"span":{"start":152696,"end":152697,"span_source":"m"},"span_id":4692,"ty":"String"}},{"Positional":{"expr":{"Block":295},"span":{"start":152698,"end":152717,"span_source":"{ def main [a] {} }"},"span_id":4701,"ty":"Block"}}],"parser_info":{}}},"span":{"start":152689,"end":152717,"span_source":"module m { def main [a] {} }"},"span_id":4702,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":22,"head":{"start":152718,"end":152728,"span_source":"export def"},"arguments":[{"Named":[{"item":"env","span":{"start":152729,"end":152734,"span_source":"--env"}},null,null]},{"Positional":{"expr":{"String":"my cmd"},"span":{"start":152735,"end":152743,"span_source":"\"my cmd\""},"span_id":4711,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"a","desc":"","shape":"Any","completion":null,"var_id":88,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152744,"end":152747,"span_source":"[a]"},"span_id":4712,"ty":"Any"}},{"Positional":{"expr":{"Closure":296},"span":{"start":152748,"end":152750,"span_source":"{}"},"span_id":4713,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152718,"end":152750,"span_source":"export def --env \"my cmd\" [a] {}"},"span_id":4714,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152751,"end":152754,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"greet"},"span":{"start":152755,"end":152760,"span_source":"greet"},"span_id":4723,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"who, to greet","shape":"Any","completion":null,"var_id":90,"default_value":null}],"optional_positional":[{"name":"x","desc":"","shape":{"List":"Int"},"completion":null,"var_id":89,"default_value":{"List":{"vals":[{"Int":{"val":1,"span":{"start":152778,"end":152779,"span_source":"1"}}},{"Int":{"val":2,"span":{"start":152780,"end":152781,"span_source":"2"}}}],"span":{"start":152777,"end":152782,"span_source":"[1 2]"}}}}],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152761,"end":152806,"span_source":"[x: list = [1 2], name # who, to greet\n]"},"span_id":4727,"ty":"Any"}},{"Positional":{"expr":{"Closure":297},"span":{"start":152807,"end":152809,"span_source":"{}"},"span_id":4728,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152751,"end":152809,"span_source":"def greet [x: list = [1 2], name # who, to greet\n] {}"},"span_id":4729,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152810,"end":152813,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"opt"},"span":{"start":152814,"end":152817,"span_source":"opt"},"span_id":4738,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[{"name":"y","desc":"","shape":"Int","completion":null,"var_id":91,"default_value":null}],"rest_positional":null,"named":[{"long":"v","short":"v","arg":null,"required":false,"desc":"","completion":null,"var_id":92,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152818,"end":152836,"span_source":"(y?: int, --v(-v))"},"span_id":4739,"ty":"Any"}},{"Positional":{"expr":{"Closure":298},"span":{"start":152837,"end":152839,"span_source":"{}"},"span_id":4740,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152810,"end":152839,"span_source":"def opt (y?: int, --v(-v)) {}"},"span_id":4741,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":26,"head":{"start":152840,"end":152846,"span_source":"extern"},"arguments":[{"Positional":{"expr":{"String":"foo"},"span":{"start":152847,"end":152850,"span_source":"foo"},"span_id":4750,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"x","desc":"","shape":"Any","completion":null,"var_id":null,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152851,"end":152854,"span_source":"[x]"},"span_id":4751,"ty":"Any"}}],"parser_info":{}}},"span":{"start":152840,"end":152854,"span_source":"extern foo [x]"},"span_id":4752,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":0,"head":{"start":152855,"end":152860,"span_source":"alias"},"arguments":[{"Positional":{"expr":{"String":"q"},"span":{"start":152861,"end":152862,"span_source":"q"},"span_id":4761,"ty":"String"}},{"Positional":{"expr":{"Keyword":{"keyword":[61],"span":{"start":152863,"end":152864,"span_source":"="},"expr":{"expr":{"Call":{"decl_id":239,"head":{"start":152865,"end":152867,"span_source":"ls"},"arguments":[],"parser_info":{}}},"span":{"start":152865,"end":152867,"span_source":"ls"},"span_id":4770,"ty":{"Table":[]}}}},"span":{"start":152863,"end":152867,"span_source":"= ls"},"span_id":4771,"ty":{"Table":[]}}}],"parser_info":{}}},"span":{"start":152855,"end":152867,"span_source":"alias q = ls"},"span_id":4772,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152868,"end":152871,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"main"},"span":{"start":152872,"end":152876,"span_source":"main"},"span_id":4790,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"","shape":"String","completion":null,"var_id":94,"default_value":null}],"optional_positional":[],"rest_positional":{"name":"rest","desc":"","shape":"Any","completion":null,"var_id":95,"default_value":null},"named":[{"long":"verbose","short":null,"arg":null,"required":false,"desc":"","completion":null,"var_id":93,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152877,"end":152910,"span_source":"[--verbose name: string, ...rest]"},"span_id":4791,"ty":"Any"}},{"Positional":{"expr":{"Closure":299},"span":{"start":152911,"end":152913,"span_source":"{}"},"span_id":4792,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152868,"end":152913,"span_source":"def main [--verbose name: string, ...rest] {}"},"span_id":4793,"ty":"Any"},"redirection":null}]}],"captures":[],"redirect_env":false,"ir_block":null,"span":{"start":152617,"end":152914,"span_source":"# a script exercising the shapes launch cares about\nprint \"side effect\"\nmodule m { def main [a] {} }\nexport def --env \"my cmd\" [a] {}\ndef greet [x: list = [1 2], name # who, to greet\n] {}\ndef opt (y?: int, --v(-v)) {}\nextern foo [x]\nalias q = ls\ndef main [--verbose name: string, ...rest] {}\n"}} +{"signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"},"pipelines":[{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":511,"head":{"start":152666,"end":152671,"span_source":"print"},"arguments":[{"Positional":{"expr":{"String":"side effect"},"span":{"start":152672,"end":152685,"span_source":"\"side effect\""},"span_id":4688,"ty":"String"}}],"parser_info":{}}},"span":{"start":152666,"end":152685,"span_source":"print \"side effect\""},"span_id":4689,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":40,"head":{"start":152686,"end":152692,"span_source":"module"},"arguments":[{"Positional":{"expr":{"String":"m"},"span":{"start":152693,"end":152694,"span_source":"m"},"span_id":4698,"ty":"String"}},{"Positional":{"expr":{"Block":295},"span":{"start":152695,"end":152714,"span_source":"{ def main [a] {} }"},"span_id":4707,"ty":"Block"}}],"parser_info":{}}},"span":{"start":152686,"end":152714,"span_source":"module m { def main [a] {} }"},"span_id":4708,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":22,"head":{"start":152715,"end":152725,"span_source":"export def"},"arguments":[{"Named":[{"item":"env","span":{"start":152726,"end":152731,"span_source":"--env"}},null,null]},{"Positional":{"expr":{"String":"my cmd"},"span":{"start":152732,"end":152740,"span_source":"\"my cmd\""},"span_id":4717,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"a","desc":"","shape":"Any","completion":null,"var_id":91,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152741,"end":152744,"span_source":"[a]"},"span_id":4718,"ty":"Any"}},{"Positional":{"expr":{"Closure":296},"span":{"start":152745,"end":152747,"span_source":"{}"},"span_id":4719,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152715,"end":152747,"span_source":"export def --env \"my cmd\" [a] {}"},"span_id":4720,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152748,"end":152751,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"greet"},"span":{"start":152752,"end":152757,"span_source":"greet"},"span_id":4729,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"who, to greet","shape":"Any","completion":null,"var_id":93,"default_value":null}],"optional_positional":[{"name":"x","desc":"","shape":{"List":"Int"},"completion":null,"var_id":92,"default_value":{"List":{"vals":[{"Int":{"val":1,"span":{"start":152775,"end":152776,"span_source":"1"}}},{"Int":{"val":2,"span":{"start":152777,"end":152778,"span_source":"2"}}}],"span":{"start":152774,"end":152779,"span_source":"[1 2]"}}}}],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152758,"end":152803,"span_source":"[x: list = [1 2], name # who, to greet\n]"},"span_id":4733,"ty":"Any"}},{"Positional":{"expr":{"Closure":297},"span":{"start":152804,"end":152806,"span_source":"{}"},"span_id":4734,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152748,"end":152806,"span_source":"def greet [x: list = [1 2], name # who, to greet\n] {}"},"span_id":4735,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152807,"end":152810,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"opt"},"span":{"start":152811,"end":152814,"span_source":"opt"},"span_id":4744,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[],"optional_positional":[{"name":"y","desc":"","shape":"Int","completion":null,"var_id":94,"default_value":null}],"rest_positional":null,"named":[{"long":"v","short":"v","arg":null,"required":false,"desc":"","completion":null,"var_id":95,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152815,"end":152833,"span_source":"(y?: int, --v(-v))"},"span_id":4745,"ty":"Any"}},{"Positional":{"expr":{"Closure":298},"span":{"start":152834,"end":152836,"span_source":"{}"},"span_id":4746,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152807,"end":152836,"span_source":"def opt (y?: int, --v(-v)) {}"},"span_id":4747,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":26,"head":{"start":152837,"end":152843,"span_source":"extern"},"arguments":[{"Positional":{"expr":{"String":"foo"},"span":{"start":152844,"end":152847,"span_source":"foo"},"span_id":4756,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"x","desc":"","shape":"Any","completion":null,"var_id":null,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152848,"end":152851,"span_source":"[x]"},"span_id":4757,"ty":"Any"}}],"parser_info":{}}},"span":{"start":152837,"end":152851,"span_source":"extern foo [x]"},"span_id":4758,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":0,"head":{"start":152852,"end":152857,"span_source":"alias"},"arguments":[{"Positional":{"expr":{"String":"q"},"span":{"start":152858,"end":152859,"span_source":"q"},"span_id":4767,"ty":"String"}},{"Positional":{"expr":{"Keyword":{"keyword":[61],"span":{"start":152860,"end":152861,"span_source":"="},"expr":{"expr":{"Call":{"decl_id":239,"head":{"start":152862,"end":152864,"span_source":"ls"},"arguments":[],"parser_info":{}}},"span":{"start":152862,"end":152864,"span_source":"ls"},"span_id":4776,"ty":{"Table":[]}}}},"span":{"start":152860,"end":152864,"span_source":"= ls"},"span_id":4777,"ty":{"Table":[]}}}],"parser_info":{}}},"span":{"start":152852,"end":152864,"span_source":"alias q = ls"},"span_id":4778,"ty":"Nothing"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"AttributeBlock":{"attributes":[{"expr":{"expr":{"Call":{"decl_id":6,"head":{"start":152866,"end":152873,"span_source":"example"},"arguments":[{"Positional":{"expr":{"String":"attributed"},"span":{"start":152874,"end":152886,"span_source":"\"attributed\""},"span_id":4796,"ty":"String"}},{"Positional":{"expr":{"Block":299},"span":{"start":152887,"end":152903,"span_source":"{ attributed 1 }"},"span_id":4814,"ty":"Block"}}],"parser_info":{}}},"span":{"start":152866,"end":152903,"span_source":"example \"attributed\" { attributed 1 }"},"span_id":4815,"ty":{"Record":[["description","String"],["example","String"]]}}}],"item":{"expr":{"Call":{"decl_id":13,"head":{"start":152904,"end":152907,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"attributed"},"span":{"start":152908,"end":152918,"span_source":"attributed"},"span_id":4817,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"x","desc":"","shape":"Int","completion":null,"var_id":96,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152919,"end":152927,"span_source":"[x: int]"},"span_id":4818,"ty":"Any"}},{"Positional":{"expr":{"Closure":300},"span":{"start":152928,"end":152930,"span_source":"{}"},"span_id":4819,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152904,"end":152930,"span_source":"def attributed [x: int] {}"},"span_id":4820,"ty":"Any"}}},"span":{"start":152866,"end":152930,"span_source":"example \"attributed\" { attributed 1 }\ndef attributed [x: int] {}"},"span_id":4821,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152931,"end":152934,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"typed"},"span":{"start":152935,"end":152940,"span_source":"typed"},"span_id":4830,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"x","desc":"","shape":"Int","completion":null,"var_id":97,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[["Nothing","Int"]],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152941,"end":152965,"span_source":"[x: int]: nothing -> int"},"span_id":4831,"ty":"Any"}},{"Positional":{"expr":{"Closure":301},"span":{"start":152966,"end":152972,"span_source":"{ $x }"},"span_id":4834,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152931,"end":152972,"span_source":"def typed [x: int]: nothing -> int { $x }"},"span_id":4835,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":152973,"end":152976,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"multi"},"span":{"start":152977,"end":152982,"span_source":"multi"},"span_id":4844,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"the name","shape":"String","completion":null,"var_id":98,"default_value":null}],"optional_positional":[],"rest_positional":null,"named":[],"input_output_types":[["Nothing","Nothing"]],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":152983,"end":153034,"span_source":"[\n name: string # the name\n]: nothing -> nothing"},"span_id":4845,"ty":"Any"}},{"Positional":{"expr":{"Closure":302},"span":{"start":153035,"end":153037,"span_source":"{}"},"span_id":4846,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":152973,"end":153037,"span_source":"def multi [\n name: string # the name\n]: nothing -> nothing {}"},"span_id":4847,"ty":"Any"},"redirection":null}]},{"elements":[{"pipe":null,"expr":{"expr":{"Call":{"decl_id":13,"head":{"start":153038,"end":153041,"span_source":"def"},"arguments":[{"Positional":{"expr":{"String":"main"},"span":{"start":153042,"end":153046,"span_source":"main"},"span_id":4856,"ty":"String"}},{"Positional":{"expr":{"Signature":{"name":"","description":"","extra_description":"","search_terms":[],"required_positional":[{"name":"name","desc":"","shape":"String","completion":null,"var_id":100,"default_value":null}],"optional_positional":[],"rest_positional":{"name":"rest","desc":"","shape":"Any","completion":null,"var_id":101,"default_value":null},"named":[{"long":"verbose","short":null,"arg":null,"required":false,"desc":"","completion":null,"var_id":99,"default_value":null}],"input_output_types":[],"allow_variants_without_examples":false,"is_filter":false,"creates_scope":false,"allows_unknown_args":false,"complete":null,"category":"Default"}},"span":{"start":153047,"end":153080,"span_source":"[--verbose name: string, ...rest]"},"span_id":4857,"ty":"Any"}},{"Positional":{"expr":{"Closure":303},"span":{"start":153081,"end":153083,"span_source":"{}"},"span_id":4858,"ty":"Closure"}}],"parser_info":{}}},"span":{"start":153038,"end":153083,"span_source":"def main [--verbose name: string, ...rest] {}"},"span_id":4859,"ty":"Any"},"redirection":null}]}],"captures":[],"redirect_env":false,"ir_block":null,"span":{"start":152614,"end":153084,"span_source":"# a script exercising the shapes launch cares about\nprint \"side effect\"\nmodule m { def main [a] {} }\nexport def --env \"my cmd\" [a] {}\ndef greet [x: list = [1 2], name # who, to greet\n] {}\ndef opt (y?: int, --v(-v)) {}\nextern foo [x]\nalias q = ls\n@example \"attributed\" { attributed 1 }\ndef attributed [x: int] {}\ndef typed [x: int]: nothing -> int { $x }\ndef multi [\n name: string # the name\n]: nothing -> nothing {}\ndef main [--verbose name: string, ...rest] {}\n"}} diff --git a/client/src/debug/tests/fixtures/ast-defs.nu b/client/src/debug/tests/fixtures/ast-defs.nu index ccf2e22..c5e60c4 100644 --- a/client/src/debug/tests/fixtures/ast-defs.nu +++ b/client/src/debug/tests/fixtures/ast-defs.nu @@ -7,4 +7,10 @@ def greet [x: list = [1 2], name # who, to greet def opt (y?: int, --v(-v)) {} extern foo [x] alias q = ls +@example "attributed" { attributed 1 } +def attributed [x: int] {} +def typed [x: int]: nothing -> int { $x } +def multi [ + name: string # the name +]: nothing -> nothing {} def main [--verbose name: string, ...rest] {} diff --git a/client/src/debug/tests/launch-paths.test.ts b/client/src/debug/tests/launch-paths.test.ts new file mode 100644 index 0000000..4d677b2 --- /dev/null +++ b/client/src/debug/tests/launch-paths.test.ts @@ -0,0 +1,45 @@ +import assert from 'node:assert/strict'; +import * as path from 'node:path'; +import { test } from 'node:test'; + +import { resolveLaunchPaths } from '../launch/launch-paths'; + +const ws = path.resolve('/ws'); + +test('resolveLaunchPaths anchors a relative cwd to the workspace folder', () => { + assert.deepEqual(resolveLaunchPaths(ws, 'tool.nu', 'scripts'), { + program: path.join(ws, 'scripts', 'tool.nu'), + cwd: path.join(ws, 'scripts'), + }); +}); + +test('resolveLaunchPaths anchors a relative cwd even when program is absolute', () => { + const program = path.join(ws, 'tool.nu'); + assert.deepEqual(resolveLaunchPaths(ws, program, 'scripts'), { + program, + cwd: path.join(ws, 'scripts'), + }); +}); + +test('resolveLaunchPaths leaves an absolute or missing cwd alone', () => { + const abs = path.resolve('/elsewhere'); + assert.deepEqual(resolveLaunchPaths(ws, 'tool.nu', abs), { + program: path.join(abs, 'tool.nu'), + cwd: abs, + }); + assert.deepEqual(resolveLaunchPaths(ws, 'tool.nu', undefined), { + program: path.join(ws, 'tool.nu'), + cwd: undefined, + }); +}); + +test('resolveLaunchPaths without a workspace folder leaves relative paths alone', () => { + assert.deepEqual(resolveLaunchPaths(undefined, 'tool.nu', undefined), { + program: 'tool.nu', + cwd: undefined, + }); + assert.deepEqual(resolveLaunchPaths(undefined, 'tool.nu', 'scripts'), { + program: path.resolve('scripts', 'tool.nu'), + cwd: 'scripts', + }); +}); diff --git a/client/src/debug/tests/request-generation.test.ts b/client/src/debug/tests/request-generation.test.ts new file mode 100644 index 0000000..f8ac70c --- /dev/null +++ b/client/src/debug/tests/request-generation.test.ts @@ -0,0 +1,86 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; + +import { + invalidateRequests, + nextRequest, + showLatest, +} from '../visualize/request-generation'; + +/** A promise to settle by hand, to answer requests out of order. */ +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +test('nextRequest: only the latest request is current', () => { + const first = nextRequest(); + const second = nextRequest(); + assert.equal(first(), false); + assert.equal(second(), true); + invalidateRequests(); + assert.equal(second(), false); +}); + +test('showLatest drops a slow answer that a newer request overtook', async () => { + const painted: string[] = []; + const big = deferred(); + const small = deferred(); + + const bigShown = showLatest( + () => big.promise, + (v) => painted.push(v), + ); + const smallShown = showLatest( + () => small.promise, + (v) => painted.push(v), + ); + + small.resolve('$small'); + assert.equal(await smallShown, 'shown'); + big.resolve('$big'); + assert.equal(await bigShown, 'stale'); + + assert.deepEqual(painted, ['$small']); +}); + +test('showLatest drops an answer that arrives after a pin change', async () => { + const painted: string[] = []; + const answer = deferred(); + + const shown = showLatest( + () => answer.promise, + (v) => painted.push(v), + ); + invalidateRequests(); + answer.resolve('$a'); + + assert.equal(await shown, 'stale'); + assert.deepEqual(painted, []); +}); + +test('showLatest swallows the failure of a stale request', async () => { + const failing = deferred(); + const stale = showLatest( + () => failing.promise, + () => {}, + ); + nextRequest(); + failing.reject(new Error('stale reference')); + assert.equal(await stale, 'stale'); +}); + +test('showLatest passes on the failure of the latest request', async () => { + await assert.rejects( + showLatest( + () => Promise.reject(new Error('out of scope')), + () => {}, + ), + /out of scope/, + ); +}); diff --git a/client/src/debug/tests/script-signatures.test.ts b/client/src/debug/tests/script-signatures.test.ts index 2fcf8d9..ebbf764 100644 --- a/client/src/debug/tests/script-signatures.test.ts +++ b/client/src/debug/tests/script-signatures.test.ts @@ -1,9 +1,16 @@ import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import * as os from 'node:os'; import * as path from 'node:path'; import { test } from 'node:test'; -import { definitionsFromAst } from '../launch/script-signatures'; +import { + NuDefinition, + definitionsFromAst, + needsArgumentPrompt, + parameterList, + readDefinitions, +} from '../launch/script-signatures'; // `ast --json --minify $in | get block` of fixtures/ast-defs.nu, from nu // 0.116. Regenerate it with that command when the AST format changes. @@ -15,7 +22,7 @@ const block: unknown = JSON.parse( test('definitionsFromAst lists the top-level defs, in order', () => { assert.deepEqual( definitionsFromAst(block).map((def) => def.name), - ['my cmd', 'greet', 'opt', 'main'], + ['my cmd', 'greet', 'opt', 'attributed', 'typed', 'multi', 'main'], ); }); @@ -27,6 +34,9 @@ test('definitionsFromAst takes what nushell parsed as required', () => { 'my cmd': true, // [a] greet: true, // [x: list = [1 2], name # who, to greet] opt: false, // (y?: int, --v(-v)) + attributed: true, // @example ... def attributed [x: int] + typed: true, // [x: int]: nothing -> int + multi: true, // [name: string]: nothing -> nothing main: true, // [--verbose name: string, ...rest] }); }); @@ -41,8 +51,73 @@ test('definitionsFromAst keeps the parameter list as written', () => { signatures['greet'], 'x: list = [1 2], name # who, to greet\n', ); + assert.equal(signatures['attributed'], 'x: int'); + assert.equal(signatures['typed'], 'x: int'); + assert.equal(signatures['multi'], '\n name: string # the name\n'); }); +test('parameterList stops at the bracket that closes the parameters', () => { + const cases: [string, string][] = [ + ['[x: int]', 'x: int'], + ['(y?: int, --v(-v))', 'y?: int, --v(-v)'], + ['[x: int]: nothing -> int', 'x: int'], + [ + '[\n name: string # the name\n]: nothing -> nothing', + '\n name: string # the name\n', + ], + ['[]: [nothing -> int, string -> int]', ''], + [ + '[x: list = [1 2], y: int]: nothing -> int', + 'x: list = [1 2], y: int', + ], + ['[x # a ] in a comment\n]: any -> any', 'x # a ] in a comment\n'], + ['[x = "]", y]: any -> any', 'x = "]", y'], + ["[x = ']', y]: any -> any", "x = ']', y"], + ]; + for (const [written, expected] of cases) { + assert.equal(parameterList(written), expected, written); + } +}); + +test('needsArgumentPrompt treats `args: []` like no args', () => { + const required: NuDefinition = { + name: 'main', + signature: 'x', + hasRequired: true, + }; + const optional: NuDefinition = { + name: 'main', + signature: 'x?', + hasRequired: false, + }; + assert.equal(needsArgumentPrompt(required, undefined), true); + assert.equal(needsArgumentPrompt(required, []), true); + assert.equal(needsArgumentPrompt(required, ['a']), false); + assert.equal(needsArgumentPrompt(optional, undefined), false); + assert.equal(needsArgumentPrompt(optional, []), false); +}); + +test( + 'readDefinitions survives a nu that exits before reading the script', + { skip: process.platform === 'win32' }, + async () => { + const dir = mkdtempSync(path.join(os.tmpdir(), 'nu-defs-')); + const program = path.join(dir, 'big.nu'); + writeFileSync(program, '# padding\n'.repeat(512 * 1024)); // ~5 MB + const uncaught: unknown[] = []; + const onUncaught = (error: unknown) => uncaught.push(error); + process.on('uncaughtException', onUncaught); + try { + assert.equal(await readDefinitions('/usr/bin/true', program), undefined); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual(uncaught, []); + } finally { + process.off('uncaughtException', onUncaught); + rmSync(dir, { recursive: true, force: true }); + } + }, +); + test('definitionsFromAst skips what it does not recognize', () => { for (const input of [null, 42, {}, { pipelines: 'x' }, { pipelines: [{}] }]) { assert.deepEqual(definitionsFromAst(input), [], JSON.stringify(input)); diff --git a/client/src/debug/visualize/pinned-variable.ts b/client/src/debug/visualize/pinned-variable.ts index b896d19..864eadf 100644 --- a/client/src/debug/visualize/pinned-variable.ts +++ b/client/src/debug/visualize/pinned-variable.ts @@ -6,6 +6,7 @@ import { updateVisualizePanel, } from './visualize-panel'; import { LOCALS_REFERENCE, fetchVisualize } from './visualize-request'; +import { invalidateRequests, nextRequest } from './request-generation'; // Live tracking for the visualize panel. // @@ -16,21 +17,17 @@ import { LOCALS_REFERENCE, fetchVisualize } from './visualize-request'; let pinnedName: string | undefined; -/** - * Bumped per refresh and per pin change; a response that is not from the - * latest refresh, or that arrives after the user picked another value, is - * discarded. - */ -let generation = 0; +// A pin change invalidates in-flight requests: a response that arrives after +// the user picked another value is discarded. export function pinVariable(name: string): void { pinnedName = name; - generation++; + invalidateRequests(); } export function unpinVariable(): void { pinnedName = undefined; - generation++; + invalidateRequests(); } /** Re-read the pinned variable and repaint the visualize panel. */ @@ -46,19 +43,19 @@ export async function refreshPinnedVariable( return; } - const mine = ++generation; + const isCurrent = nextRequest(); try { const body = await fetchVisualize(session, { containerReference: LOCALS_REFERENCE, name, }); - if (mine !== generation) { + if (!isCurrent()) { return; // a later stop or a new pick superseded this answer } updateVisualizePanel(name, body); } catch { - if (mine !== generation) { + if (!isCurrent()) { return; } diff --git a/client/src/debug/visualize/request-generation.ts b/client/src/debug/visualize/request-generation.ts new file mode 100644 index 0000000..472b544 --- /dev/null +++ b/client/src/debug/visualize/request-generation.ts @@ -0,0 +1,32 @@ +// Dropping answers that arrive after the user moved on. +// Kept free of `vscode` imports so it can be unit tested with node:test. +// +// Visualize requests can be slow (a big table), and the panel shows one value +// at a time. Every request and every change of the pinned variable bumps the +// generation; an answer from an older generation is discarded. + +let generation = 0; + +/** Start a request; the returned check says whether it is still the latest. */ +export function nextRequest(): () => boolean { + const mine = ++generation; + return () => mine === generation; +} + +/** Whatever is in flight is no longer wanted (the pinned variable changed). */ +export function invalidateRequests(): void { + generation++; +} + +/** + * Fetch a value and show it. Resolves 'stale' when a newer request or pin + * change came in meanwhile: nothing is shown and a failure is swallowed. + */ +export async function showLatest( + fetch: () => Promise, + show: (value: T) => void, +): Promise<'shown' | 'stale'> { + const value = await fetch(); + show(value); + return 'shown'; +} diff --git a/client/src/debug/visualize/visualize-command.ts b/client/src/debug/visualize/visualize-command.ts index 71a6fb9..83557e3 100644 --- a/client/src/debug/visualize/visualize-command.ts +++ b/client/src/debug/visualize/visualize-command.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { activeNuSession } from '../shared/session'; import { pinVariable, unpinVariable } from './pinned-variable'; +import { showLatest } from './request-generation'; import { openVisualizePanel } from './visualize-panel'; import { LOCALS_REFERENCE, @@ -55,8 +56,13 @@ export async function visualizeVariable( } try { - const body = await fetchVisualize(session, address); - openVisualizePanel(variable.name, body); + const shown = await showLatest( + () => fetchVisualize(session, address), + (body) => openVisualizePanel(variable.name, body), + ); + if (shown === 'stale') { + return; // the user picked another value meanwhile + } } catch (e) { void vscode.window.showWarningMessage( `Nushell Debug: cannot visualize '${variable.name}': ${String(e)}`, diff --git a/client/src/extension.ts b/client/src/extension.ts index 78e4ddd..13c929a 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -16,11 +16,13 @@ import { import { registerDebugger } from './debug/register'; import { ensureNushell } from './nushell/resolve'; +import { ServerRestarter } from './nushell/server-restart'; const EXTENSION_ID = 'TheNuProjectContributors.vscode-nushell-lang'; const CONFIG_SECTION = 'nushellLanguageServer'; let client: LanguageClient | undefined; +let clientNushellPath: string | undefined; // the `nu` that `client` runs let fileWatcher: vscode.FileSystemWatcher | undefined; let outputChannel: OutputChannel | undefined; // Single output channel for server logs and trace @@ -116,6 +118,7 @@ function startLanguageServer( clientOptions, ); client = newClient; + clientNushellPath = nushellPath; // Log client lifecycle newClient.onDidChangeState((e) => { @@ -134,6 +137,7 @@ function startLanguageServer( // Leave things in a state where "Nushell: Start Language Server" can retry. if (client === newClient) { client = undefined; + clientNushellPath = undefined; } fileWatcher?.dispose(); fileWatcher = undefined; @@ -148,6 +152,7 @@ async function stopLanguageServer(): Promise { } const running = client; client = undefined; + clientNushellPath = undefined; fileWatcher?.dispose(); fileWatcher = undefined; try { @@ -211,18 +216,25 @@ export function activate(context: vscode.ExtensionContext) { // The debugger resolves `nu` itself when a session starts context.subscriptions.push(...registerDebugger(nushell)); + const restarter = new ServerRestarter( + { + isRunning: () => client !== undefined, + runningPath: () => clientNushellPath, + resolve: ensureNushell, + restart: (nushellPath) => restartLanguageServer(context, nushellPath), + }, + 500, // the Settings UI writes the path on every keystroke + ); + // React to trace level changes for the lifetime of the extension, and // restart the language server when the executable path setting changes context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration(async (e) => { + vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration(`${CONFIG_SECTION}.trace.server`)) { applyTraceFromConfig(); } if (e.affectsConfiguration(`${CONFIG_SECTION}.nushellExecutablePath`)) { - const nushellPath = await nushell(); - if (nushellPath) { - await restartLanguageServer(context, nushellPath); - } + restarter.schedule(); } }), ); diff --git a/client/src/nushell/resolve-executable.ts b/client/src/nushell/resolve-executable.ts new file mode 100644 index 0000000..8a16360 --- /dev/null +++ b/client/src/nushell/resolve-executable.ts @@ -0,0 +1,50 @@ +// Finding `nu` from the setting and PATH, without reporting anything. +// Kept free of `vscode` imports so it can be unit tested with node:test. + +import * as os from 'os'; +import * as path from 'path'; + +export type NushellSource = 'setting' | 'path'; + +export type Resolution = + | { kind: 'found'; path: string; source: NushellSource } + | { kind: 'missing' }; + +/** Looks a command or path up the way `which` does; undefined when not found. */ +export type Lookup = (command: string) => string | undefined; + +export function expandHome(p: string): string { + if (!(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) { + return p; + } + + return path.join(os.homedir(), p.slice(1)); +} + +/** + * Find `nu`, in order: the configured path -> `nu` on PATH. `configured` is + * the `nushellExecutablePath` setting; `''` and `'nu'` mean "use PATH". + */ +export function resolveExecutable( + configured: string, + lookup: Lookup, + onFallback?: (configured: string) => void, +): Resolution { + const setting = configured.trim(); + + if (setting && setting !== 'nu') { + const found = lookup(expandHome(setting)); + if (found) { + return { kind: 'found', path: found, source: 'setting' }; + } + + onFallback?.(setting); + } + + const onPath = lookup('nu'); + if (onPath) { + return { kind: 'found', path: onPath, source: 'path' }; + } + + return { kind: 'missing' }; +} diff --git a/client/src/nushell/resolve.ts b/client/src/nushell/resolve.ts index 4f755f0..1de251c 100644 --- a/client/src/nushell/resolve.ts +++ b/client/src/nushell/resolve.ts @@ -1,5 +1,3 @@ -import * as os from 'os'; -import * as path from 'path'; import { commands, env, @@ -10,54 +8,31 @@ import { } from 'vscode'; import * as which from 'which'; +import { Resolution, resolveExecutable } from './resolve-executable'; import { NUSHELL_DOWNLOAD_PAGE } from './version'; const CONFIG_SECTION = 'nushellLanguageServer'; const EXECUTABLE_SETTING = 'nushellExecutablePath'; -export type NushellSource = 'setting' | 'path'; - -export type Resolution = - | { kind: 'found'; path: string; source: NushellSource } - | { kind: 'missing' }; - function settings(): WorkspaceConfiguration { // Use null for resource to get global/workspace settings return workspace.getConfiguration(CONFIG_SECTION, null); } -function expandHome(p: string): string { - if (!(p === '~' || p.startsWith('~/') || p.startsWith('~\\'))) { - return p; - } - - return path.join(os.homedir(), p.slice(1)); -} - /** * Find the `nu` used by the language server, the terminal profile and the * debugger, in order: the `nushellExecutablePath` setting -> `nu` on PATH. */ export function resolveNushell(): Resolution { - const configured = settings().get(EXECUTABLE_SETTING, 'nu').trim(); - - if (configured && configured !== 'nu') { - const found = which.sync(expandHome(configured), { nothrow: true }); - if (found) { - return { kind: 'found', path: found, source: 'setting' }; - } - - void window.showWarningMessage( - `Nushell executable not found at ${CONFIG_SECTION}.${EXECUTABLE_SETTING} (${configured}). Falling back to 'nu' on PATH.`, - ); - } - - const onPath = which.sync('nu', { nothrow: true }); - if (onPath) { - return { kind: 'found', path: onPath, source: 'path' }; - } - - return { kind: 'missing' }; + return resolveExecutable( + settings().get(EXECUTABLE_SETTING, 'nu'), + (command) => which.sync(command, { nothrow: true }) ?? undefined, + (configured) => { + void window.showWarningMessage( + `Nushell executable not found at ${CONFIG_SECTION}.${EXECUTABLE_SETTING} (${configured}). Falling back to 'nu' on PATH.`, + ); + }, + ); } /** diff --git a/client/src/nushell/server-restart.ts b/client/src/nushell/server-restart.ts new file mode 100644 index 0000000..23b1db8 --- /dev/null +++ b/client/src/nushell/server-restart.ts @@ -0,0 +1,49 @@ +// When to restart the language server after `nushellExecutablePath` changes. +// Kept free of `vscode` imports so it can be unit tested with node:test. + +/** What the restarter needs to know about, and do to, the language server. */ +export interface RestartHooks { + /** Is a language client running (or starting)? */ + isRunning(): boolean; + /** The `nu` the running client was started with. */ + runningPath(): string | undefined; + /** The `nu` the settings point at now; undefined when there is none. */ + resolve(): string | undefined; + /** Stop the running client and start one on `path`. */ + restart(path: string): Promise; +} + +/** + * Restarts the language server for a changed `nushellExecutablePath`. + */ +export class ServerRestarter { + private readonly hooks: RestartHooks; + private readonly delayMs: number; + private readonly pending = new Set>(); + + constructor(hooks: RestartHooks, delayMs: number) { + this.hooks = hooks; + this.delayMs = delayMs; + } + + /** The setting changed. */ + schedule(): void { + const run = this.run(); + this.pending.add(run); + void run.finally(() => this.pending.delete(run)); + } + + /** Settles once every scheduled restart has finished. */ + async idle(): Promise { + while (this.pending.size > 0) { + await Promise.allSettled([...this.pending]); + } + } + + private async run(): Promise { + const path = this.hooks.resolve(); + if (path) { + await this.hooks.restart(path); + } + } +} diff --git a/client/src/nushell/tests/resolve-executable.test.ts b/client/src/nushell/tests/resolve-executable.test.ts new file mode 100644 index 0000000..970a82a --- /dev/null +++ b/client/src/nushell/tests/resolve-executable.test.ts @@ -0,0 +1,59 @@ +import assert from 'node:assert/strict'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { test } from 'node:test'; + +import { Lookup, resolveExecutable } from '../resolve-executable'; + +/** A `which` that knows only these paths (and `nu`, when given). */ +function lookupOf(known: Record): Lookup { + return (command) => known[command]; +} + +test('resolveExecutable reports a stale setting instead of warning itself', () => { + const resolution = resolveExecutable( + '/opt/homebrew/bin/nu', + lookupOf({ nu: '/usr/bin/nu' }), + ); + assert.deepEqual(resolution, { + kind: 'found', + path: '/usr/bin/nu', + fellBackFrom: '/opt/homebrew/bin/nu', + }); +}); + +test('resolveExecutable uses a valid setting', () => { + const resolution = resolveExecutable( + '/opt/homebrew/bin/nu', + lookupOf({ + '/opt/homebrew/bin/nu': '/opt/homebrew/bin/nu', + nu: '/usr/bin/nu', + }), + ); + assert.deepEqual(resolution, { kind: 'found', path: '/opt/homebrew/bin/nu' }); +}); + +test('resolveExecutable reads an empty or `nu` setting as PATH', () => { + for (const configured of ['', 'nu', ' nu ']) { + assert.deepEqual( + resolveExecutable(configured, lookupOf({ nu: '/usr/bin/nu' })), + { kind: 'found', path: '/usr/bin/nu' }, + JSON.stringify(configured), + ); + } +}); + +test('resolveExecutable is missing when nothing is found', () => { + assert.deepEqual(resolveExecutable('/nope/nu', lookupOf({})), { + kind: 'missing', + }); + assert.deepEqual(resolveExecutable('nu', lookupOf({})), { kind: 'missing' }); +}); + +test('resolveExecutable expands ~ in the setting', () => { + const home = path.join(os.homedir(), 'bin', 'nu'); + assert.deepEqual(resolveExecutable('~/bin/nu', lookupOf({ [home]: home })), { + kind: 'found', + path: home, + }); +}); diff --git a/client/src/nushell/tests/server-restart.test.ts b/client/src/nushell/tests/server-restart.test.ts new file mode 100644 index 0000000..363ff9b --- /dev/null +++ b/client/src/nushell/tests/server-restart.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { mock, test } from 'node:test'; + +import { RestartHooks, ServerRestarter } from '../server-restart'; + +const DELAY = 500; + +/** A fake language server: records resolves and restarts, and how many restarts overlap. */ +class FakeServer implements RestartHooks { + running = true; + path: string | undefined = '/usr/bin/nu'; + configured: string | undefined = '/usr/local/bin/nu'; + resolves = 0; + restarts: string[] = []; + active = 0; + maxActive = 0; + /** When set, each restart waits for this before finishing. */ + gate: Promise | undefined; + + isRunning(): boolean { + return this.running; + } + + runningPath(): string | undefined { + return this.running ? this.path : undefined; + } + + resolve(): string | undefined { + this.resolves++; + return this.configured; + } + + async restart(path: string): Promise { + this.active++; + this.maxActive = Math.max(this.maxActive, this.active); + try { + await this.gate; + this.restarts.push(path); + this.path = path; + this.running = true; + } finally { + this.active--; + } + } +} + +function withTimers(fn: () => Promise): () => Promise { + return async () => { + mock.timers.enable({ apis: ['setTimeout'] }); + try { + await fn(); + } finally { + mock.timers.reset(); + } + }; +} + +test( + 'ServerRestarter waits for typing to settle before restarting once', + withTimers(async () => { + const server = new FakeServer(); + const restarter = new ServerRestarter(server, DELAY); + + for (const partial of ['/usr', '/usr/local/bin', '/usr/local/bin/nu']) { + server.configured = partial; + restarter.schedule(); + mock.timers.tick(DELAY / 2); + } + mock.timers.tick(DELAY); + await restarter.idle(); + + assert.equal(server.resolves, 1); + assert.deepEqual(server.restarts, ['/usr/local/bin/nu']); + }), +); + +test( + 'ServerRestarter leaves a stopped server stopped', + withTimers(async () => { + const server = new FakeServer(); + server.running = false; + const restarter = new ServerRestarter(server, DELAY); + + restarter.schedule(); + mock.timers.tick(DELAY); + await restarter.idle(); + + assert.deepEqual(server.restarts, []); + }), +); + +test( + 'ServerRestarter skips a restart when the resolved nu is unchanged', + withTimers(async () => { + const server = new FakeServer(); + server.configured = server.path; + const restarter = new ServerRestarter(server, DELAY); + + restarter.schedule(); + mock.timers.tick(DELAY); + await restarter.idle(); + + assert.deepEqual(server.restarts, []); + }), +); + +test( + 'ServerRestarter runs overlapping restarts one after the other', + withTimers(async () => { + const server = new FakeServer(); + let open!: () => void; + server.gate = new Promise((resolve) => (open = resolve)); + const restarter = new ServerRestarter(server, DELAY); + + server.configured = '/a/nu'; + restarter.schedule(); + mock.timers.tick(DELAY); + await Promise.resolve(); // let the first restart begin + + server.configured = '/b/nu'; + restarter.schedule(); + mock.timers.tick(DELAY); + await Promise.resolve(); + + open(); + await restarter.idle(); + + assert.equal(server.maxActive, 1); + assert.deepEqual(server.restarts, ['/a/nu', '/b/nu']); + }), +);