Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .github/workflows/package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
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/**
11 changes: 11 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
48 changes: 48 additions & 0 deletions client/src/debug/adapter/dap-support.ts
Original file line number Diff line number Diff line change
@@ -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<string | undefined>;

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<string>();
private readonly readVersion: VersionReader;

constructor(readVersion: VersionReader) {
this.readVersion = readVersion;
}

async check(nu: string): Promise<DapSupportResult> {
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 };
}
}
88 changes: 88 additions & 0 deletions client/src/debug/adapter/descriptor-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { execFile } from 'child_process';
import { promisify } from 'util';
import {
CancellationError,
DebugAdapterDescriptor,
DebugAdapterDescriptorFactory,
DebugAdapterExecutable,
Uri,
env,
window,
} from 'vscode';

import { NUSHELL_DOWNLOAD_PAGE, parseNuVersion } from '../../nushell/version';
import { DapSupport, MIN_DAP_VERSION } from './dap-support';

const execFileAsync = promisify(execFile);

/** Finds the `nu` to run; undefined when there is none, after telling the user. */
export type NushellResolver = () => Promise<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 dapSupport = new DapSupport(nuVersion);
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 = await this.resolveNushell();
if (!nu) {
throw new CancellationError();
}

await this.checkDapSupport(nu);
return new DebugAdapterExecutable(nu, ['--dap']);
}

/**
* 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<void> {
const support = await this.dapSupport.check(nu);
if (support.kind !== 'too-old') {
return;
}

void offerDownloadPage(nu, support.version);
throw new CancellationError();
}
}

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>
Loading
Loading