Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8702533
feat: Add Nushell debugger example scripts
ronaldvanhelden Oct 9, 2026
101a900
feat: Add Nushell debugger time travel, restart-on-save, and DAP tracing
ronaldvanhelden Oct 9, 2026
190995c
feat: Implement Nushell debugger variable visualizer
ronaldvanhelden Oct 9, 2026
14b63ac
feat: Implement Nushell debugger IR panel
ronaldvanhelden Oct 9, 2026
f29abb8
feat: Implement Nushell debugger UI prompts (input/input list)
ronaldvanhelden Oct 9, 2026
761acd7
feat: Implement Nushell debugger launch configuration
ronaldvanhelden Oct 9, 2026
84464f5
feat: Add core Nushell debugger infrastructure and build setup
ronaldvanhelden Oct 9, 2026
90b0a2b
refactor: Centralize Nushell executable resolution
ronaldvanhelden Oct 9, 2026
61f7edb
feat: Add Show IR editor toolbar action
ronaldvanhelden Oct 9, 2026
70c22a0
test: Add failing tests for the second review and real-world scripts
ronaldvanhelden Oct 11, 2026
75266ce
fix: Read defs that have attributes in front of them
ronaldvanhelden Oct 11, 2026
4298181
fix: Build the argument prompt label from the parsed signature
ronaldvanhelden Oct 11, 2026
ae8e184
fix: Ignore EPIPE when nu exits before reading the script
ronaldvanhelden Oct 11, 2026
834c482
fix: Analyse large scripts by leaving the compiled IR out of the AST
ronaldvanhelden Oct 11, 2026
414d17a
fix: Read \" inside double quotes at the argument prompt as a quote
ronaldvanhelden Oct 11, 2026
e62cb52
fix: Warn about a stale nushellExecutablePath once, from ensureNushell
ronaldvanhelden Oct 11, 2026
aad5d3e
fix: Debounce language server restarts on nushellExecutablePath changes
ronaldvanhelden Oct 11, 2026
328ff33
fix: Pass a workspace-relative cwd to the adapter as an absolute path
ronaldvanhelden Oct 11, 2026
1f83a27
fix: Prompt for arguments when the launch config has "args": []
ronaldvanhelden Oct 11, 2026
246e691
fix: Read entryPoint like nu-dap: trimmed, and blank means main
ronaldvanhelden Oct 11, 2026
ce33038
fix: Check the nu version before the entry-point and argument prompts
ronaldvanhelden Oct 11, 2026
fca32c4
fix: Drop a visualize answer that a newer pick overtook
ronaldvanhelden Oct 11, 2026
00c9a53
docs: Use real Nushell argument syntax in the main entrypoint example
ronaldvanhelden Oct 11, 2026
e1e8523
build: Require Node 20 LTS and say so
ronaldvanhelden Oct 11, 2026
1766730
ci: Package with the vsce from devDependencies
ronaldvanhelden Oct 11, 2026
f1384c1
fix: Restart the language server on every nushellExecutablePath change
ronaldvanhelden Oct 11, 2026
06944d1
fix: Generate the webview CSP nonce with crypto.randomBytes
ronaldvanhelden Oct 24, 2026
5721554
fix: Show the IR button only during a Nushell debug session
ronaldvanhelden Oct 24, 2026
1833c8e
docs: Point the LOCALS_REFERENCE comment at upstream Nushell
ronaldvanhelden Oct 24, 2026
d3dafbb
refactor: Drop the redundant isNuDocument check on restart-on-save
ronaldvanhelden Oct 24, 2026
7498756
refactor: Pass ensureNushell to the debugger as a synchronous resolver
ronaldvanhelden Oct 24, 2026
feb8452
fix: Tell the user to start the language server after installing nu
ronaldvanhelden Oct 24, 2026
f1ab13a
fix: Restart the language server only when it is running
ronaldvanhelden Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,14 @@ jobs:
# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2
- run: npm install
- uses: lannonbr/vsce-action@master
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
args: 'package'
node-version: 20
- run: npm install
- run: npm run typecheck:webview
- run: npm run test:unit
- run: npx @vscode/vsce package

# Runs a single command using the runners shell
# - name: Run a one-line script
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ client/tsconfig.tsbuildinfo
.vscode-test
out/
server/tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
tsconfig.tsbuildinfo
client/out-unit
3 changes: 2 additions & 1 deletion .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
build
coverage
out
used_keywords.jsonc
used_keywords.jsonc
client/src/debug/tests/fixtures/
3 changes: 2 additions & 1 deletion .vscodeignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ scripts/**
node_modules/**

.gitignore
.eslintignore
.eslintignore
examples/**
13 changes: 13 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,19 @@ 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

Requires Node 20 LTS or newer (see `engines.node` in `package.json`).

- `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.
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
113 changes: 113 additions & 0 deletions client/src/debug/adapter/descriptor-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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 = () => string | undefined;

/**
* 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 {
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<DebugAdapterDescriptor> {
const nu = this.resolveNushell();
if (!nu) {
throw new CancellationError();
}

// Normally a cache hit: the launch configuration provider checked first.
if (!(await confirmDapSupport(nu))) {
throw new CancellationError();
}

return new DebugAdapterExecutable(nu, ['--dap']);
}
}

/** `nu` paths already known to support `--dap`. */
const supported = new Set<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.
*
* False for a too old `nu`, after offering the Nushell download page; the
* caller cancels the launch. VS Code doesn't show an error for a cancelled
* launch, so this is the only message the user sees.
*/
export async function confirmDapSupport(nu: string): Promise<boolean> {
if (supported.has(nu)) {
return true;
}

const version = await nuVersion(nu);
if (!version) {
return true; // could not ask: let `nu --dap` speak for itself
}

if (compareVersions(version, MIN_DAP_VERSION) >= 0) {
supported.add(nu);
return true;
}

void offerDownloadPage(nu, version);
return false;
}

async function offerDownloadPage(nu: string, version: string): Promise<void> {
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<string | undefined> {
try {
const { stdout } = await execFileAsync(nu, ['--version'], {
timeout: 10000,
});
return parseNuVersion(stdout);
} catch {
return undefined;
}
}
16 changes: 16 additions & 0 deletions client/src/debug/adapter/register.ts
Original file line number Diff line number Diff line change
@@ -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),
),
];
}
20 changes: 20 additions & 0 deletions client/src/debug/ir/ir-listing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { escapeHtml } from '../shared/escape-html';

/** One IR line as HTML; the instruction we are paused on is marked so the page can scroll to it. */
export function irLine(line: string, currentIndex: number): string {
if (!isInstructionAt(line, currentIndex)) {
return `<span>${escapeHtml(line)}\n</span>`;
}

return `<span class="current" id="cur">${escapeHtml(line)}\n</span>`;
}

/** IR lines are numbered ` 12: instruction`; is this the one at `index`? */
export function isInstructionAt(line: string, index: number): boolean {
const match = /^\s*(\d+):/.exec(line);
if (!match) {
return false;
}

return Number(match[1]) === index;
}
33 changes: 33 additions & 0 deletions client/src/debug/ir/ir-panel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<!doctype html>
<html>
<head>
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-{{nonce}}';"
/>
<style>
body {
font-family: var(--vscode-editor-font-family, monospace);
font-size: var(--vscode-editor-font-size, 13px);
}
pre {
margin: 0;
}
.current {
display: inline-block;
width: 100%;
background: var(--vscode-editor-selectionBackground, #264f78);
font-weight: bold;
}
.hint {
color: var(--vscode-descriptionForeground, #999);
}
</style>
</head>
<body>
{{status}} {{listing}}
<script nonce="{{nonce}}">
document.getElementById('cur')?.scrollIntoView({ block: 'center' });
</script>
</body>
</html>
86 changes: 86 additions & 0 deletions client/src/debug/ir/ir-panel.ts
Original file line number Diff line number Diff line change
@@ -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 `<pre>${lines.join('')}</pre>`;
}
Loading
Loading