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/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 new file mode 100644 index 0000000..d9f3f94 --- /dev/null +++ b/client/src/debug/adapter/descriptor-factory.ts @@ -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; + +/** + * 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 { + 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 { + 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 { + 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/ir/ir-listing.ts b/client/src/debug/ir/ir-listing.ts new file mode 100644 index 0000000..6503087 --- /dev/null +++ b/client/src/debug/ir/ir-listing.ts @@ -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 `${escapeHtml(line)}\n`; + } + + return `${escapeHtml(line)}\n`; +} + +/** 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; +} diff --git a/client/src/debug/ir/ir-panel.html b/client/src/debug/ir/ir-panel.html new file mode 100644 index 0000000..1b20fa7 --- /dev/null +++ b/client/src/debug/ir/ir-panel.html @@ -0,0 +1,33 @@ + + + + + + + + {{status}} {{listing}} + + + 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/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..4c59bd1 --- /dev/null +++ b/client/src/debug/launch/configuration-provider.ts @@ -0,0 +1,88 @@ +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'; +import { resolveLaunchPaths } from './launch-paths'; + +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; + } + + 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()); + } +} + +/** + * 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..aa9f60d --- /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, + 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'; + +/** + * 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 (!needsArgumentPrompt(def, config.args)) { + 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/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/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..d4f9b4d --- /dev/null +++ b/client/src/debug/launch/script-signatures.ts @@ -0,0 +1,144 @@ +// 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' ? 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; + } + + 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/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. + } +} 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/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/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/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/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/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/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/fixtures/ast-defs.json b/client/src/debug/tests/fixtures/ast-defs.json new file mode 100644 index 0000000..ebf1e1c --- /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":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 new file mode 100644 index 0000000..c5e60c4 --- /dev/null +++ b/client/src/debug/tests/fixtures/ast-defs.nu @@ -0,0 +1,16 @@ +# 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 +@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/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/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/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'); +}); 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 new file mode 100644 index 0000000..ebbf764 --- /dev/null +++ b/client/src/debug/tests/script-signatures.test.ts @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +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 { + 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. +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', 'attributed', 'typed', 'multi', '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)) + 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] + }); +}); + +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', + ); + 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)); + } + + // 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']); +}); 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/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/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..864eadf --- /dev/null +++ b/client/src/debug/visualize/pinned-variable.ts @@ -0,0 +1,64 @@ +import * as vscode from 'vscode'; + +import { + isVisualizePanelOpen, + markVisualizePanelStale, + updateVisualizePanel, +} from './visualize-panel'; +import { LOCALS_REFERENCE, fetchVisualize } from './visualize-request'; +import { invalidateRequests, nextRequest } from './request-generation'; + +// 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; + +// 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; + invalidateRequests(); +} + +export function unpinVariable(): void { + pinnedName = undefined; + invalidateRequests(); +} + +/** 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 isCurrent = nextRequest(); + try { + const body = await fetchVisualize(session, { + containerReference: LOCALS_REFERENCE, + name, + }); + if (!isCurrent()) { + return; // a later stop or a new pick superseded this answer + } + + updateVisualizePanel(name, body); + } catch { + if (!isCurrent()) { + 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/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/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..83557e3 --- /dev/null +++ b/client/src/debug/visualize/visualize-command.ts @@ -0,0 +1,103 @@ +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, + 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 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)}`, + ); + 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