From fc709c7251bfda89988bf4e1b874cf9f5f9c945c Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 15:58:02 -0700 Subject: [PATCH 01/15] Answer the Debug Console and Watch at a pause Type Python into the Debug Console at a breakpoint() or red dot and it runs in the frame selected in Call Stack, with that frame's locals over its own module's globals and the builtins. Names bound there last until the run resumes. A result opens like a Variables row, print() goes to the console, and an error reads as a Jupyter traceback, built from Grail's pythonTracebackFrames. A console line has no time limit and can be stopped through DAP cancel or a notification; a watch keeps the two-second budget. Code the console calls runs past breakpoint() and red dots. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 13 + README.md | 9 +- src/__integration__/debugConsole.test.ts | 801 +++++++++++++++++++++++ src/__mocks__/vscode.ts | 15 +- src/__tests__/debugConsole.test.ts | 466 +++++++++++++ src/consoleTraceback.ts | 101 +++ src/debugger.ts | 285 +++++++- src/pauseVariables.ts | 220 +++++++ src/session.ts | 71 +- 9 files changed, 1952 insertions(+), 29 deletions(-) create mode 100644 src/__integration__/debugConsole.test.ts create mode 100644 src/__tests__/debugConsole.test.ts create mode 100644 src/consoleTraceback.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index cc29765..e2f992d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **The Debug Console and Watch work at a `breakpoint()` or red dot.** Type Python into the Debug + Console and it runs in the frame selected in the Call Stack: its locals, closed-over names, its + module's globals (an imported module's own, not the notebook's) and the builtins, as the paused + line itself would see them. A result opens like a Variables row; `print()` shows in the console; + names you bind (`y = 5`, `import json`, a `def`) last until the run resumes. An error reads as a + Jupyter traceback, frame by frame with the line that raised marked. A line runs as long as it + takes, and after a few seconds a notification offers to stop it; a watch keeps a two-second limit. + Code the console calls runs straight past `breakpoint()` and red dots. Not yet: `cls` in a + classmethod's frame, and rebinding a local (`n = 99`) changes the console's copy, not the paused + function. + ## [1.6.0] - 2026-10-07 The engine is unchanged at GemStone 4.0.0.a4, but start with a fresh database: delete diff --git a/README.md b/README.md index d83a333..5de423d 100644 --- a/README.md +++ b/README.md @@ -415,8 +415,13 @@ database, select that kernel from the kernel picker at the top right. shows the Python frames, the paused line is highlighted, and Variables shows each frame's locals and the notebook's globals, expandable into attributes, items and entries, with classes and functions folded into their own rows. **Continue** resumes the cell where it paused; **Stop** - ends it, along with any cells queued after it. Stepping is not supported yet, and neither are red - dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it + ends it, along with any cells queued after it. The **Debug Console** and **Watch** work at the + pause: type Python and it runs in the frame selected in the Call Stack, with that frame's locals, + its module's globals and the builtins, and a result opens like a Variables row. Names you bind + there last until the run resumes; an error reads as a notebook traceback; a long-running line can + be stopped from the notification that appears. A change to an object made there is in the + notebook's transaction, as it would be from a cell. Stepping is not supported yet, and neither are + red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` holds; its title bar always has **Commit** and **Abort** for the notebook you are working in. **Add Stack to Persisted Objects…** on the Call Stack saves the whole paused stack the same diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts new file mode 100644 index 0000000..b013f68 --- /dev/null +++ b/src/__integration__/debugConsole.test.ts @@ -0,0 +1,801 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { + __debugFactories, + __debugStarts, + DebugAdapterInlineImplementation, + NotebookCellKind, + SourceBreakpoint, + debug, + workspace, +} from '../__mocks__/vscode'; +import { PauseDebugAdapter, registerBreakpointDebugger } from '../debugger'; +import { fileOwner } from '../fileOwner'; +import { stageGrail } from '../grail'; +import { abortQuery, needsCommitQuery } from '../pauseVariables'; +import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes'; +import { PyResult, runPython, runPythonFile } from '../pythonQueries'; +import { SessionOwner, closeSessionFor, logoutAll, sessionForIfOpen } from '../session'; +import { createDatabaseWithPython, Fixture, haveTestExtent, makeFixture } from './fixture'; + +/** + * The Debug Console and the Watch pane against a real database, through the + * adapter VS Code would talk to: a cell pauses at breakpoint(), the debugger + * opens on it as it does in the editor, and the test types into the console + * by sending `evaluate` requests. + * + * The bar is debugpy's console: whatever the paused line could have said, the + * console can say, against the same names, and it reads back the way Python + * would — an error as a Jupyter traceback. + */ + +const ext = process.cwd(); +const haveExtent = haveTestExtent(); + +/** A notebook of its own per test, so no test sees another's globals. */ +const notebook = (name: string): SessionOwner => ({ + key: `file:///${name}.ipynb`, + kind: 'notebook', + label: `${name}.ipynb`, +}); + +/** A module the notebooks import, with names of its own and a line to put a red dot on. */ +const DCMOD = [ + 'MODULE_ONLY = "mod"', + 'shared = "module"', + '', + 'def in_module(arg):', + ' breakpoint()', + ' return arg', + '', + 'def fail():', + ' return {}["missing"]', + '', + 'def dotted():', + ' x = 41', + ' return x + 1', +].join('\n'); + +/** The line of `x = 41` in DCMOD, 1-based. */ +const DOTTED_LINE = 12; + +let fixture: Fixture | undefined; + +beforeAll(async () => { + if (!haveExtent) return; + fixture = makeFixture(); + if (!fixture) return; + fs.writeFileSync(path.join(fixture.root, 'dcmod.py'), DCMOD); + createDatabaseWithPython(fixture); + stageGrail(ext); + await startStone(); + await startNetldi(); +}); + +afterAll(async () => { + if (!fixture) return; + logoutAll(); + try { + await stopNetldi(); + } finally { + if (isRunning()) await stopStone(true); + fixture.remove(); + } +}); + +/** See database.test.ts — skipIf is evaluated during collection. */ +function canMakeFixture(): boolean { + const probe = makeFixture(); + if (!probe) return false; + probe.remove(); + return true; +} + +/** The cell lines that make `dcmod` importable from the fixture's root. */ +const importDcmod = (): string => + `import sys\nsys.path.insert(0, ${JSON.stringify(fixture!.root)})\nimport dcmod`; + +// --------------------------------------------------------------------------- +// Talking to the adapter the way VS Code does. +// --------------------------------------------------------------------------- + +type Message = Record & { + type: string; + command?: string; + event?: string; + request_seq?: number; + success?: boolean; + message?: string; + body?: Record; +}; + +/** What one console line or watch came back with. */ +interface Evaluated { + ok: boolean; + /** The value as shown; '' for None or a statement. */ + result: string; + type?: string; + /** Opens the result like a Variables row; 0 for a leaf. */ + ref: number; + indexed?: number; + /** The failure's text: a traceback, or why the line was stopped. */ + message: string; + /** Lines of `message`. */ + lines: string[]; + /** What the line printed, from `output` events sent before the answer. */ + printed: string; + categories: string[]; + /** Whether the adapter told VS Code to read the variables again. */ + invalidated: boolean; + millis: number; +} + +class Dap { + readonly sent: Message[] = []; + private seq = 1; + private readonly waiting = new Map void>(); + + constructor(private readonly adapter: InstanceType) { + adapter.onDidSendMessage((raw) => { + const m = raw as Message; + this.sent.push(m); + if (m.type === 'response' && m.request_seq !== undefined) { + this.waiting.get(m.request_seq)?.(m); + this.waiting.delete(m.request_seq); + } + }); + } + + /** Send a request; its seq at once, its response when it comes. */ + start( + command: string, + args: Record = {}, + ): { seq: number; done: Promise } { + const seq = this.seq++; + const done = new Promise((resolve) => this.waiting.set(seq, resolve)); + this.adapter.handleMessage({ seq, type: 'request', command, arguments: args }); + return { seq, done }; + } + + request(command: string, args: Record = {}): Promise { + return this.start(command, args).done; + } + + /** Type `expression` into the console (or add it as a watch) and wait for the answer. */ + async evaluate( + expression: string, + { frameId, context = 'repl' }: { frameId?: number; context?: string } = {}, + ): Promise { + const from = this.sent.length; + const began = Date.now(); + const response = await this.request('evaluate', { + expression, + context, + ...(frameId === undefined ? {} : { frameId }), + }); + return this.read(response, from, began); + } + + /** Make an Evaluated of a response, from what was sent since `from`. */ + read(response: Message, from: number, began: number): Evaluated { + const millis = Date.now() - began; + const upToAnswer = this.sent.slice(from, this.sent.indexOf(response)); + const outputs = upToAnswer.filter((m) => m.event === 'output'); + const body = response.body ?? {}; + const message = response.success ? '' : String(response.message ?? ''); + return { + ok: response.success === true, + result: String(body.result ?? ''), + ...(body.type === undefined ? {} : { type: String(body.type) }), + ref: Number(body.variablesReference ?? 0), + ...(body.indexedVariables === undefined ? {} : { indexed: Number(body.indexedVariables) }), + message, + lines: message.split('\n'), + printed: outputs.map((m) => String(m.body?.output ?? '')).join(''), + categories: outputs.map((m) => String(m.body?.category ?? '')), + // Sent with the answer or just after it; read once the answer is in. + invalidated: this.sent.slice(from).some((m) => m.event === 'invalidated'), + millis, + }; + } + + /** The value an expression gives in the console, failing the test if it raised. */ + async value(expression: string, frameId?: number): Promise { + const r = await this.evaluate(expression, { frameId }); + if (!r.ok) throw new Error(`${expression} failed:\n${r.message}`); + return r.result; + } + + /** The last line of the error an expression raises — `NameError: …` and the like. */ + async raises(expression: string, frameId?: number): Promise { + const r = await this.evaluate(expression, { frameId }); + if (r.ok) throw new Error(`${expression} answered ${r.result} instead of raising`); + return r.lines.filter((l) => l.trim() !== '').pop() ?? ''; + } + + async frames(): Promise> { + const r = await this.request('stackTrace', { threadId: 1 }); + return (r.body?.stackFrames ?? []) as Array<{ id: number; name: string }>; + } + + async children( + ref: number, + ): Promise> { + const r = await this.request('variables', { variablesReference: ref }); + return (r.body?.variables ?? []) as Array<{ + name: string; + value: string; + variablesReference: number; + }>; + } + + /** A frame's Locals rows. */ + async locals( + frameId = 1, + ): Promise> { + const r = await this.request('scopes', { frameId }); + const scopes = (r.body?.scopes ?? []) as Array<{ name: string; variablesReference: number }>; + const locals = scopes.find((s) => s.name === 'Locals'); + if (!locals) throw new Error(`frame ${frameId} has no Locals`); + return this.children(locals.variablesReference); + } +} + +const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** The adapter VS Code would get for debug session `index` of `__debugStarts`. */ +function adapterFor(index: number): InstanceType { + const factory = __debugFactories.get('gemdb') as { + createDebugAdapterDescriptor: (s: unknown) => DebugAdapterInlineImplementation; + }; + return factory.createDebugAdapterDescriptor({ configuration: __debugStarts[index] }) + .implementation as InstanceType; +} + +/** + * Run `run` with the debugger registered, and at its Nth pause open the + * debugger, as VS Code does, and hand it to the Nth of `drivers`; then + * Continue. Answers the run's result and how many debug sessions it opened. + * A driver that fails still continues the run, so the run never strands. + */ +async function atPauses( + run: () => Promise, + ...drivers: Array<(dap: Dap) => Promise> +): Promise<{ result: PyResult; sessions: number }> { + const registration = registerBreakpointDebugger(); + const first = __debugStarts.length; + let ended = false; + const running = run().finally(() => (ended = true)); + let failure: unknown; + try { + for (let i = 0; i < drivers.length; i++) { + while (__debugStarts.length <= first + i) { + if (ended) throw new Error(`the run ended before pause ${i + 1}`); + await sleep(20); + } + const dap = new Dap(adapterFor(first + i)); + await dap.request('initialize', { adapterID: 'gemdb', supportsInvalidatedEvent: true }); + await dap.request('launch', { + gemdbPause: (__debugStarts[first + i] as { gemdbPause: string }).gemdbPause, + }); + await dap.request('configurationDone'); + try { + await drivers[i](dap); + } catch (e) { + failure ??= e; + } finally { + await dap.request('continue', { threadId: 1 }); + } + } + const result = await running; + if (failure) throw failure; + return { result, sessions: __debugStarts.length - first }; + } finally { + registration.dispose(); + } +} + +/** Run `source` in notebook `nb` with the debugger open at each pause. */ +const inCell = ( + nb: SessionOwner, + source: string, + ...drivers: Array<(dap: Dap) => Promise> +): Promise<{ result: PyResult; sessions: number }> => + atPauses(() => runPython(source, nb), ...drivers); + +/** + * Put notebook `nb` in the editor with these code cells, run in order (so the + * second reads `Cell [2]`), the way VS Code holds an open notebook. Answers + * how to close it. + */ +function showNotebook(nb: SessionOwner, cells: string[]): () => void { + const doc = { + uri: { toString: () => nb.key }, + getCells: () => + cells.map((text, index) => ({ + index, + kind: NotebookCellKind.Code, + document: { uri: { toString: () => `${nb.key}#cell${index}` }, getText: () => text }, + executionSummary: { executionOrder: index + 1 }, + })), + }; + workspace.notebookDocuments.push(doc); + return () => { + workspace.notebookDocuments.splice(workspace.notebookDocuments.indexOf(doc), 1); + }; +} + +/** A cell that pauses once, in a function with no locals. */ +const PAUSE_ONCE = 'def go():\n breakpoint()\ngo()'; + +/** Text in an error that only GemDB's own machinery could have put there. */ +const MACHINERY = + /ModuleAst|SymbolDictionary|___gemdb|evaluateSource|GsProcess|AbstractException|SessionTemps/; + +describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause', () => { + it('answers an expression with its repr and type, and a result that opens like a Variables row', async () => { + const nb = notebook('dc-values'); + const seen: Record = {}; + let kids: string[] = []; + let localRow = ''; + let pageOfBig: string[] = []; + + await inCell( + nb, + [ + 'class P:', + ' def __init__(self, name):', + ' self.name = name', + ' self.tags = {"a", "b"}', + ' def __repr__(self):', + ' return f"P({self.name!r})"', + 'def go(p):', + ' breakpoint()', + ' return p.name', + 'go(P("ann"))', + ].join('\n'), + async (dap) => { + seen.p = await dap.evaluate('p'); + kids = (await dap.children(seen.p.ref)).map((v) => v.name).sort(); + seen.watch = await dap.evaluate('p', { context: 'watch' }); + localRow = (await dap.locals()).find((v) => v.name === 'p')!.value; + seen.none = await dap.evaluate('None'); + seen.big = await dap.evaluate('list(range(1000))'); + pageOfBig = ( + ( + await dap.request('variables', { + variablesReference: seen.big.ref, + start: 998, + count: 2, + }) + ).body?.variables as Array<{ value: string }> + ).map((v) => v.value); + }, + ); + closeSessionFor(nb.key); + + // The console prints the repr, as debugpy and PyCharm do: no type badge. + expect(seen.p).toMatchObject({ ok: true, result: "P('ann')", type: 'P' }); + expect(seen.p.ref).toBeGreaterThan(0); + expect(kids).toEqual(['name', 'tags']); + // A watch is a row, so it reads as the Variables row for the same object does. + expect(seen.watch).toMatchObject({ ok: true, result: localRow }); + expect(seen.none).toMatchObject({ ok: true, result: '', ref: 0 }); + expect(seen.big.indexed).toBe(1000); + expect(pageOfBig).toEqual(['998', '999']); + }); + + it('keeps names typed in the console for the rest of the pause, and only that long', async () => { + const nb = notebook('dc-names'); + const first: Record = {}; + let afterResume = ''; + + await inCell( + nb, + [ + 'def go():', + ' n = 1', + ' breakpoint()', + ' breakpoint()', + ' return n', + 'go()', + ].join('\n'), + async (dap) => { + first.assign = await dap.value('y = 5'); + first.y = await dap.value('y + 1'); + first.import = await dap.value('import json'); + first.json = await dap.value('json.dumps([n, y])'); + first.def = await dap.value('def twice(v):\n return v * 2'); + first.call = await dap.value('twice(y)'); + first.block = await dap.value('total = 0\nfor i in range(4):\n total += i\ntotal'); + first.rebind = await dap.value('n = 99'); + first.n = await dap.value('n'); + }, + async (dap) => { + afterResume = await dap.raises('y'); + }, + ); + closeSessionFor(nb.key); + + expect(first).toEqual({ + assign: '', + y: '6', + import: '', + json: "'[1, 5]'", + def: '', + call: '10', + block: '6', + rebind: '', + n: '99', + }); + expect(afterResume).toBe("NameError: name 'y' is not defined"); + }); + + it.todo( + 'writes a local rebound in the console (n = 99) back into the paused frame, as debugpy does — not decided yet', + ); + + it('sends what a line prints to the console as stdout, not to the cell, even when the line then fails', async () => { + const nb = notebook('dc-print'); + const cellPrinted: string[] = []; + const seen: Record = {}; + + await atPauses( + () => + runPython('def go(p):\n breakpoint()\ngo("x")', nb, (chunk) => cellPrinted.push(chunk)), + async (dap) => { + seen.print = await dap.evaluate('print("hello", p)'); + seen.thenFail = await dap.evaluate('print("first")\n1/0'); + }, + ); + closeSessionFor(nb.key); + + expect(seen.print).toMatchObject({ ok: true, result: '', printed: 'hello x\n' }); + expect(seen.print.categories).toEqual(['stdout']); + expect(seen.thenFail).toMatchObject({ ok: false, printed: 'first\n' }); + expect(cellPrinted.join('')).toBe(''); + }); + + it('finds names as the paused line would: locals, closed-over names, the notebook, its imports, builtins', async () => { + const nb = notebook('dc-scope'); + await runPython('import math', nb); + const inner: Record = {}; + const outer: Record = {}; + let top = ''; + let frameNames: string[] = []; + + await inCell( + nb, + [ + 'nb_global = 10', + 'def outer():', + ' captured = "c"', + ' def inner(k):', + ' seen = captured', + ' breakpoint()', + ' return seen', + ' return inner(3)', + 'outer()', + ].join('\n'), + async (dap) => { + frameNames = (await dap.frames()).map((f) => f.name); + inner.local = await dap.value('k'); + inner.closedOver = await dap.value('captured'); + inner.global = await dap.value('nb_global'); + inner.earlierImport = await dap.value('math.pi'); + inner.builtin = await dap.value('len([k, k])'); + inner.isinstance = await dap.value('isinstance(k, int)'); + inner.comprehension = await dap.value('[k for _ in range(2)]'); + inner.lambda = await dap.value('(lambda: k + 1)()'); + inner.generator = await dap.value('sum(k for _ in range(2))'); + outer.local = await dap.value('captured', 2); + outer.notHere = await dap.raises('k', 2); + top = await dap.value('nb_global', 3); + }, + ); + closeSessionFor(nb.key); + + expect(frameNames[0]).toMatch(/inner$/); + expect(frameNames.slice(1, 3)).toEqual(['outer', '']); + expect(inner).toEqual({ + local: '3', + closedOver: "'c'", + global: '10', + earlierImport: '3.141592653589793', + builtin: '2', + isinstance: 'True', + // One scope, not locals over globals as two dicts: debugpy's comprehension trap. + comprehension: '[3, 3]', + lambda: '4', + generator: '6', + }); + // Each frame its own: the selected frame decides. + expect(outer).toEqual({ local: "'c'", notHere: "NameError: name 'k' is not defined" }); + expect(top).toBe('10'); + }); + + const ACCOUNT = [ + 'class Account:', + ' rate = 2', + ' def __init__(self):', + ' self.balance = 7', + ' def show(self):', + ' breakpoint()', + ' @classmethod', + ' def make(cls):', + ' breakpoint()', + ' return cls()', + ].join('\n'); + + it('binds self in a method', async () => { + const nb = notebook('dc-self'); + const seen: Record = {}; + + await inCell(nb, `${ACCOUNT}\nAccount().show()`, async (dap) => { + seen.balance = await dap.value('self.balance'); + seen.both = await dap.value('self.rate * self.balance'); + }); + closeSessionFor(nb.key); + + expect(seen).toEqual({ balance: '7', both: '14' }); + }); + + // Fails today, in Grail rather than here: a classmethod's frame carries no + // `cls` in its locals (Grail's PyFrame ___receiverNameForMethod___ answers + // nil for it), so the Locals pane shows none and the console has none to + // copy. When this starts passing, drop `.fails`. + it.fails('binds cls in a classmethod', async () => { + const nb = notebook('dc-cls'); + const seen: Record = {}; + + await inCell(nb, `${ACCOUNT}\nAccount.make()`, async (dap) => { + seen.rate = await dap.value('cls.rate'); + seen.name = await dap.value('cls.__name__'); + }); + closeSessionFor(nb.key); + + expect(seen).toEqual({ rate: '2', name: "'Account'" }); + }); + + it('gives a frame in an imported module that module’s globals, not the notebook’s', async () => { + const nb = notebook('dc-module'); + await runPython(importDcmod(), nb); + const inModule: Record = {}; + const inCellFrame: Record = {}; + + await inCell(nb, 'shared = "notebook"\nnb_only = 1\ndcmod.in_module(5)', async (dap) => { + inModule.own = await dap.value('MODULE_ONLY'); + inModule.shared = await dap.value('shared'); + inModule.arg = await dap.value('arg'); + inModule.notebookOnly = await dap.raises('nb_only'); + inCellFrame.shared = await dap.value('shared', 2); + inCellFrame.notebookOnly = await dap.value('nb_only', 2); + }); + closeSessionFor(nb.key); + + expect(inModule).toEqual({ + own: "'mod'", + shared: "'module'", + arg: '5', + notebookOnly: "NameError: name 'nb_only' is not defined", + }); + expect(inCellFrame).toEqual({ shared: "'notebook'", notebookOnly: '1' }); + }); + + it('gives a .py run’s frames that run’s __main__ globals', async () => { + const file = path.join(fixture!.root, 'dcjob.py'); + fs.writeFileSync( + file, + ['total = 10', 'def step(n):', ' breakpoint()', ' return n', 'step(3)'].join('\n'), + ); + const owner = fileOwner(file); + const seen: Record = {}; + + await atPauses( + () => runPythonFile(file, owner, () => {}), + async (dap) => { + seen.sum = await dap.value('total + n'); + seen.name = await dap.value('__name__'); + }, + ); + closeSessionFor(owner.key); + + expect(seen).toEqual({ sum: '13', name: "'__main__'" }); + }); + + it('shows an error as a Jupyter traceback: the console’s lines, then each frame it went through', async () => { + const nb = notebook('dc-errors'); + const cells = [importDcmod(), 'def boom(d):\n return 10 / d', PAUSE_ONCE]; + // Open, as it is in the editor: a cell's frame shows that cell's lines. + const close = showNotebook(nb, cells); + await runPython(cells[0], nb); + await runPython(cells[1], nb); + const seen: Record = {}; + const last: Record = {}; + let after = ''; + + await inCell(nb, cells[2], async (dap) => { + seen.here = await dap.evaluate('x = 1\n1/0'); + seen.called = await dap.evaluate('boom(0)'); + seen.module = await dap.evaluate('dcmod.fail()'); + last.name = await dap.raises('nope'); + seen.syntax = await dap.evaluate('1 +'); + after = await dap.value('6 * 7'); + }).finally(close); + closeSessionFor(nb.key); + + // As a notebook cell's error reads in Jupyter: + // ----------------------------------------------------------------- + // ZeroDivisionError Traceback (most recent call last) + // , line 2 + // 1 x = 1 + // ----> 2 1/0 + // + // ZeroDivisionError: division by zero + const here = seen.here.lines; + expect(seen.here.ok).toBe(false); + expect(here[0]).toMatch(/^-{40,}$/); + expect(here[1]).toMatch(/^ZeroDivisionError\s+Traceback \(most recent call last\)$/); + expect(here.some((l) => /line 2\b/.test(l))).toBe(true); + expect(here).toContainEqual(expect.stringMatching(/^\s+1 x = 1$/)); + expect(here).toContainEqual(expect.stringMatching(/^----> 2 1\/0$/)); + expect(here[here.length - 1]).toBe('ZeroDivisionError: division by zero'); + + // A function the console called: its own frame, after the console's, with its line marked. + const called = seen.called.lines; + const consoleFrame = called.findIndex((l) => /^----> 1 boom\(0\)$/.test(l)); + const boomFrame = called.findIndex((l) => /^Cell \[2\], line 2, in boom$/.test(l)); + expect(consoleFrame).toBeGreaterThan(0); + expect(boomFrame).toBeGreaterThan(consoleFrame); + expect(called).toContainEqual(expect.stringMatching(/^----> 2\s+return 10 \/ d$/)); + expect(called[called.length - 1]).toBe('ZeroDivisionError: division by zero'); + + // A frame in a .py module is named by its file. + const mod = seen.module.lines; + expect(mod.some((l) => l.includes('dcmod.py') && /\bin fail\b/.test(l))).toBe(true); + expect(mod).toContainEqual(expect.stringMatching(/^----> 9\s+return \{\}\["missing"\]$/)); + expect(mod[mod.length - 1]).toBe("KeyError: 'missing'"); + + expect(last.name).toBe("NameError: name 'nope' is not defined"); + // A SyntaxError shows the line it could not read, as Jupyter does. + expect(seen.syntax.ok).toBe(false); + expect(seen.syntax.lines).toContainEqual(expect.stringMatching(/^\s*1 \+$/)); + expect(seen.syntax.lines[seen.syntax.lines.length - 1]).toBe('SyntaxError: invalid syntax'); + + // Nothing of GemDB's own evaluation shows through. + for (const e of [seen.here, seen.called, seen.module, seen.syntax]) { + expect(e.message).not.toMatch(MACHINERY); + } + // And an error costs nothing: the pause goes on answering. + expect(after).toBe('42'); + }); + + it('lets a console line run past the two seconds a Variables row gets, but not a watch', async () => { + const nb = notebook('dc-time'); + const seen: Record = {}; + + await inCell(nb, PAUSE_ONCE, async (dap) => { + seen.slow = await dap.evaluate('import time\ntime.sleep(3)\n"slept"'); + seen.watch = await dap.evaluate('__import__("time").sleep(10) or "slept"', { + context: 'watch', + }); + seen.after = await dap.evaluate('1 + 1'); + }); + closeSessionFor(nb.key); + + expect(seen.slow).toMatchObject({ ok: true, result: "'slept'" }); + expect(seen.slow.millis).toBeGreaterThanOrEqual(3_000); + expect(seen.watch.ok).toBe(false); + expect(seen.watch.message).toMatch(/too long/i); + expect(seen.watch.millis).toBeLessThan(8_000); + expect(seen.after).toMatchObject({ ok: true, result: '2' }); + }); + + it('stops a runaway line on cancel, and the pause goes on answering', async () => { + const nb = notebook('dc-cancel'); + let stopped: Evaluated | undefined; + let after = ''; + + const { result } = await inCell(nb, `${PAUSE_ONCE}\n"finished"`, async (dap) => { + const from = dap.sent.length; + const began = Date.now(); + const spin = dap.start('evaluate', { expression: 'while True:\n pass', context: 'repl' }); + await sleep(500); + await dap.request('cancel', { requestId: spin.seq }); + stopped = dap.read(await spin.done, from, began); + after = await dap.value('2 + 2'); + }); + closeSessionFor(nb.key); + + expect(stopped).toMatchObject({ ok: false }); + expect(stopped!.message).toMatch(/interrupted|cancelled/i); + expect(stopped!.message).not.toMatch(/Traceback/); + expect(stopped!.millis).toBeLessThan(10_000); + expect(after).toBe('4'); + expect(result.value).toBe("'finished'"); + }); + + it('runs straight past breakpoint() and red dots in code the console calls', async () => { + const nb = notebook('dc-dots'); + await runPython(importDcmod(), nb); + const seen: Record = {}; + debug.breakpoints = [ + new SourceBreakpoint({ + uri: { scheme: 'file', fsPath: path.join(fixture!.root, 'dcmod.py') }, + range: { start: { line: DOTTED_LINE - 1 } }, + }), + ]; + + let sessions = 0; + try { + ({ sessions } = await inCell( + nb, + [ + 'def helper():', + ' breakpoint()', + ' return "h"', + 'def go():', + ' breakpoint()', + 'go()', + ].join('\n'), + async (dap) => { + seen.breakpoint = await dap.value('helper()'); + seen.dot = await dap.value('dcmod.dotted()'); + }, + )); + } finally { + debug.breakpoints = []; + } + closeSessionFor(nb.key); + + expect(seen).toEqual({ breakpoint: "'h'", dot: '42' }); + // The one pause the cell made; none from the console's calls. + expect(sessions).toBe(1); + }); + + it('tells VS Code to read the variables again, so a change made in the console shows', async () => { + const nb = notebook('dc-invalidate'); + let change: Evaluated | undefined; + let watched: Evaluated | undefined; + let x = ''; + + await inCell( + nb, + [ + 'class Box:', + ' def __init__(self):', + ' self.x = 1', + 'def go(b):', + ' breakpoint()', + 'go(Box())', + ].join('\n'), + async (dap) => { + change = await dap.evaluate('b.x = 10'); + watched = await dap.evaluate('b.x', { context: 'watch' }); + const box = (await dap.locals()).find((v) => v.name === 'b')!; + x = (await dap.children(box.variablesReference)).find((v) => v.name === 'x')!.value; + }, + ); + closeSessionFor(nb.key); + + expect(change).toMatchObject({ ok: true, invalidated: true }); + expect(watched).toMatchObject({ ok: true, result: '10', invalidated: false }); + expect(x).toBe('10'); + }); + + it('puts a change to a persistent object made in the console in the notebook’s transaction', async () => { + const nb = notebook('dc-transaction'); + + await inCell(nb, PAUSE_ONCE, async (dap) => { + await dap.value('import gemdb\ngemdb.root["dc_console"] = 1'); + }); + const session = sessionForIfOpen(nb.key)!; + const dirty = await session.executeAsync(needsCommitQuery()); + await session.executeAsync(abortQuery()); + closeSessionFor(nb.key); + + // As the same line in a cell would: the notebook's next commit writes it. + expect(dirty).toBe('true'); + }); +}); diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index d88502f..ecab482 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -255,18 +255,23 @@ export const window = { }, /** * Progress is a pass-through here: run the callback and return its result. - * Nothing asserts on the notification itself — that is the editor's job, - * not ours — and the token never reports cancelled, since nothing here can - * drive one. + * The token never reports cancelled; a test that needs to cancel spies on + * this and hands over a token it can drive. */ withProgress( _options: unknown, task: ( progress: { report: (value: { message?: string }) => void }, - token: { isCancellationRequested: boolean }, + token: { + isCancellationRequested: boolean; + onCancellationRequested: (listener: () => void) => { dispose(): void }; + }, ) => Thenable, ): Thenable { - return task({ report: () => {} }, { isCancellationRequested: false }); + return task( + { report: () => {} }, + { isCancellationRequested: false, onCancellationRequested: () => ({ dispose: () => {} }) }, + ); }, }; diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts new file mode 100644 index 0000000..20967cc --- /dev/null +++ b/src/__tests__/debugConsole.test.ts @@ -0,0 +1,466 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { window } from '../__mocks__/vscode'; +import type { ConsoleAnswer, Pause } from '../debugger'; +import type { HaltHandler, RedDotSource } from '../session'; + +/** + * The Debug Console and the Watch pane, without a database: what the adapter + * does with an `evaluate` request — which frame it asks for, what it sends + * back and in what order, how it is stopped — given a pause whose `evaluate` + * is a stub. What the expression actually does in the gem is + * `debugConsole.test.ts` in `__integration__`. + */ + +vi.mock('../session', () => ({ + setHaltHandler: (_handler: HaltHandler | undefined) => {}, + setRedDotSource: (_source: RedDotSource | undefined) => {}, +})); + +const { PauseDebugAdapter } = await import('../debugger'); + +/** Let the adapter's promises run. */ +const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +type Evaluate = NonNullable; + +/** A value answer: `value` reads as typed, nothing printed, not expandable. */ +const value = (text: string, more: Partial = {}): ConsoleAnswer => + ({ kind: 'value', output: '', value: text, variablesReference: 0, ...more }) as ConsoleAnswer; + +/** An adapter on a paused two-frame stack whose `evaluate` is `evaluate`. */ +function consoleOn(evaluate: Evaluate | undefined, more: Partial = {}) { + const sent: Array> = []; + const pause: Pause = { + label: 'a.ipynb', + frames: [ + { id: 1, name: 'inner', line: 4, column: 1 }, + { id: 2, name: 'outer', line: 9, column: 1 }, + ], + scopes: () => [], + variables: () => Promise.resolve([]), + answer: () => {}, + ...(evaluate ? { evaluate } : {}), + ...more, + }; + const adapter = new PauseDebugAdapter(() => pause); + adapter.onDidSendMessage((m) => sent.push(m as Record)); + let seq = 1; + const request = (command: string, args?: Record): number => { + const mine = seq++; + adapter.handleMessage({ seq: mine, type: 'request', command, arguments: args }); + return mine; + }; + const responseTo = (requestSeq: number) => + sent.find((m) => m.type === 'response' && m.request_seq === requestSeq); + const events = (name: string) => sent.filter((m) => m.type === 'event' && m.event === name); + return { adapter, sent, request, responseTo, events }; +} + +/** Initialize (as VS Code does, claiming what it supports), launch and stop. */ +function started( + evaluate: Evaluate | undefined, + client: Record = { supportsInvalidatedEvent: true }, + more: Partial = {}, +) { + const c = consoleOn(evaluate, more); + c.request('initialize', { adapterID: 'gemdb', ...client }); + c.request('launch', { gemdbPause: '1' }); + c.request('configurationDone'); + return c; +} + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('what the adapter claims', () => { + it('claims cancelling, so a runaway console line can be stopped', () => { + const { request, responseTo } = consoleOn(undefined); + const seq = request('initialize', { adapterID: 'gemdb' }); + + expect(responseTo(seq)?.body).toMatchObject({ supportsCancelRequest: true }); + }); + + it('does not claim hover evaluation: a hover must never change anything', () => { + const { request, responseTo } = consoleOn(undefined); + const seq = request('initialize', { adapterID: 'gemdb' }); + + const body = responseTo(seq)?.body as Record; + expect(body.supportsEvaluateForHovers).toBeFalsy(); + }); + + it('refuses a hover evaluate without running anything', async () => { + const evaluate = vi.fn(); + const { request, responseTo } = started(evaluate); + + const seq = request('evaluate', { expression: 'p', frameId: 1, context: 'hover' }); + await settle(); + + expect(responseTo(seq)?.success).toBe(false); + expect(evaluate).not.toHaveBeenCalled(); + }); +}); + +describe('the frame an expression runs in', () => { + it('runs in the frame VS Code names: the one selected in Call Stack', async () => { + const evaluate = vi.fn().mockResolvedValue(value('1')); + const { request } = started(evaluate); + + request('evaluate', { expression: 'n', frameId: 2, context: 'repl' }); + await settle(); + + expect(evaluate).toHaveBeenCalledWith('n', 2, 'repl', expect.anything()); + }); + + it('runs in the top frame when VS Code names none', async () => { + const evaluate = vi.fn().mockResolvedValue(value('1')); + const { request } = started(evaluate); + + request('evaluate', { expression: 'n', context: 'repl' }); + await settle(); + + expect(evaluate).toHaveBeenCalledWith('n', 1, 'repl', expect.anything()); + }); + + it('tells a watch from a console line, so a watch keeps the short time budget', async () => { + const evaluate = vi.fn().mockResolvedValue(value('1')); + const { request } = started(evaluate); + + request('evaluate', { expression: 'n', frameId: 1, context: 'watch' }); + await settle(); + + expect(evaluate).toHaveBeenCalledWith('n', 1, 'watch', expect.anything()); + }); + + it('answers with a message, not a hang, where the pause offers no evaluation', async () => { + const { request, responseTo } = started(undefined); + + const seq = request('evaluate', { expression: 'p', frameId: 1, context: 'repl' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ success: false }); + expect(responseTo(seq)?.message).toEqual(expect.any(String)); + }); +}); + +describe('what the console shows', () => { + it('answers the value as typed, with its type and a reference that opens it', async () => { + const { request, responseTo } = started(() => + Promise.resolve(value("P('ann')", { type: 'P', variablesReference: 12, namedVariables: 2 })), + ); + + const seq = request('evaluate', { expression: 'p', frameId: 1, context: 'repl' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ + success: true, + body: { result: "P('ann')", type: 'P', variablesReference: 12, namedVariables: 2 }, + }); + }); + + it('pages a big result the way the Variables pane does', async () => { + const { request, responseTo } = started(() => + Promise.resolve( + value('list with 1000 items', { + type: 'list', + variablesReference: 4, + indexedVariables: 1000, + }), + ), + ); + + const seq = request('evaluate', { expression: 'big', frameId: 1, context: 'repl' }); + await settle(); + + expect(responseTo(seq)?.body).toMatchObject({ variablesReference: 4, indexedVariables: 1000 }); + }); + + it('shows nothing for None or a statement', async () => { + const { request, responseTo } = started(() => Promise.resolve(value(''))); + + const seq = request('evaluate', { expression: 'y = 5', frameId: 1, context: 'repl' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ + success: true, + body: { result: '', variablesReference: 0 }, + }); + }); + + it('sends what the line printed as stdout output, before the answer', async () => { + const { request, sent, responseTo } = started(() => + Promise.resolve(value('', { output: "P('ann')\n" })), + ); + + const seq = request('evaluate', { expression: 'print(p)', frameId: 1, context: 'repl' }); + await settle(); + + const printed = sent.findIndex((m) => m.event === 'output'); + const answered = sent.indexOf(responseTo(seq)!); + expect(sent[printed]?.body).toMatchObject({ category: 'stdout', output: "P('ann')\n" }); + expect(printed).toBeGreaterThanOrEqual(0); + expect(printed).toBeLessThan(answered); + }); + + it('sends no output event when nothing was printed', async () => { + const { request, events } = started(() => Promise.resolve(value('1'))); + + request('evaluate', { expression: '1', frameId: 1, context: 'repl' }); + await settle(); + + expect(events('output')).toEqual([]); + }); +}); + +describe('errors', () => { + const TRACEBACK = [ + '---------------------------------------------------------------------------', + 'ZeroDivisionError Traceback (most recent call last)', + 'Debug Console, line 2', + ' 1 x = 1', + '----> 2 1/0', + '', + 'ZeroDivisionError: division by zero', + ].join('\n'); + + it('fails the request with the traceback as its message, not put to the user as a dialog', async () => { + const { request, responseTo } = started(() => + Promise.resolve({ kind: 'error', output: '', traceback: TRACEBACK } as ConsoleAnswer), + ); + + const seq = request('evaluate', { expression: 'x = 1\n1/0', frameId: 1, context: 'repl' }); + await settle(); + + const reply = responseTo(seq) as { + success: boolean; + message: string; + body: { error: { format: string; showUser: boolean } }; + }; + expect(reply.success).toBe(false); + expect(reply.message).toBe(TRACEBACK); + expect(reply.body.error.showUser).toBe(false); + }); + + it('still sends what was printed before the error, ahead of it', async () => { + const { request, sent, responseTo } = started(() => + Promise.resolve({ kind: 'error', output: 'first\n', traceback: TRACEBACK } as ConsoleAnswer), + ); + + const seq = request('evaluate', { + expression: 'print("first")\n1/0', + frameId: 1, + context: 'repl', + }); + await settle(); + + const printed = sent.findIndex((m) => m.event === 'output'); + expect(sent[printed]?.body).toMatchObject({ output: 'first\n' }); + expect(printed).toBeLessThan(sent.indexOf(responseTo(seq)!)); + }); + + it('shows a watch’s error in the watch, the same way', async () => { + const { request, responseTo } = started(() => + Promise.resolve({ kind: 'error', output: '', traceback: TRACEBACK } as ConsoleAnswer), + ); + + const seq = request('evaluate', { expression: '1/0', frameId: 1, context: 'watch' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ success: false, message: TRACEBACK }); + }); + + it('reads an interrupted line as interrupted, not as an error in the user’s code', async () => { + const { request, responseTo } = started(() => + Promise.resolve({ kind: 'interrupted', output: '' } as ConsoleAnswer), + ); + + const seq = request('evaluate', { + expression: 'while True: pass', + frameId: 1, + context: 'repl', + }); + await settle(); + + const reply = responseTo(seq) as { success: boolean; message: string }; + expect(reply.success).toBe(false); + expect(reply.message).toMatch(/interrupted/i); + expect(reply.message).not.toMatch(/^Error/); + }); + + it('answers a failure of the evaluation itself with a message, and keeps the pause', async () => { + const answer = vi.fn(); + const { request, responseTo } = started( + () => Promise.reject(new Error('the session went away')), + undefined, + { answer }, + ); + + const seq = request('evaluate', { expression: 'p', frameId: 1, context: 'repl' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ success: false }); + expect(answer).not.toHaveBeenCalled(); + }); +}); + +describe('the Variables pane after a console line', () => { + it('tells VS Code to read the variables again after a console line', async () => { + const { request, events } = started(() => Promise.resolve(value(''))); + + request('evaluate', { expression: 'p.x = 10', frameId: 1, context: 'repl' }); + await settle(); + + expect(events('invalidated').map((e) => e.body)).toEqual([ + expect.objectContaining({ areas: expect.arrayContaining(['variables']) }), + ]); + }); + + it('says so after a console line that failed too: it may have changed something first', async () => { + const { request, events } = started(() => + Promise.resolve({ kind: 'error', output: '', traceback: 'x' } as ConsoleAnswer), + ); + + request('evaluate', { expression: 'p.x = 10\n1/0', frameId: 1, context: 'repl' }); + await settle(); + + expect(events('invalidated')).toHaveLength(1); + }); + + it('never invalidates after a watch, which VS Code re-runs when the variables are read again', async () => { + const { request, events } = started(() => Promise.resolve(value('1'))); + + request('evaluate', { expression: 'p.x', frameId: 1, context: 'watch' }); + await settle(); + + expect(events('invalidated')).toEqual([]); + }); + + it('sends no invalidated event to a client that did not claim it', async () => { + const { request, events } = started(() => Promise.resolve(value('')), {}); + + request('evaluate', { expression: 'p.x = 10', frameId: 1, context: 'repl' }); + await settle(); + + expect(events('invalidated')).toEqual([]); + }); +}); + +describe('stopping a runaway line', () => { + /** An evaluate that runs until its signal aborts, then answers interrupted. */ + function runaway(): { evaluate: Evaluate; signals: AbortSignal[] } { + const signals: AbortSignal[] = []; + const evaluate: Evaluate = (_source, _frameId, _context, signal) => { + signals.push(signal); + return new Promise((resolve) => + signal.addEventListener('abort', () => + resolve({ kind: 'interrupted', output: '' } as ConsoleAnswer), + ), + ); + }; + return { evaluate, signals }; + } + + it('stops the line on DAP cancel, and answers both requests', async () => { + const { evaluate, signals } = runaway(); + const { request, responseTo } = started(evaluate); + + const running = request('evaluate', { + expression: 'while True: pass', + frameId: 1, + context: 'repl', + }); + await settle(); + const cancel = request('cancel', { requestId: running }); + await settle(); + + expect(signals[0].aborted).toBe(true); + expect(responseTo(cancel)?.success).toBe(true); + expect(responseTo(running)).toMatchObject({ success: false }); + expect((responseTo(running) as { message: string }).message).toMatch(/interrupted|cancelled/i); + }); + + it('leaves alone a line that a cancel for some other request names', async () => { + const { evaluate, signals } = runaway(); + const { request } = started(evaluate); + + const running = request('evaluate', { expression: 'spin()', frameId: 1, context: 'repl' }); + await settle(); + request('cancel', { requestId: running + 100 }); + await settle(); + + expect(signals[0].aborted).toBe(false); + }); + + it('offers a cancellable notification once a console line has run a few seconds', async () => { + vi.useFakeTimers(); + const { evaluate, signals } = runaway(); + let cancelRequested: (() => void) | undefined; + const shown: Array<{ cancellable?: boolean; title?: string }> = []; + vi.spyOn(window, 'withProgress').mockImplementation((( + options: { cancellable?: boolean; title?: string }, + task: ( + progress: unknown, + token: { isCancellationRequested: boolean; onCancellationRequested: unknown }, + ) => Thenable, + ) => { + shown.push(options); + return task( + { report: () => {} }, + { + isCancellationRequested: false, + onCancellationRequested: (listener: () => void) => { + cancelRequested = listener; + return { dispose: () => {} }; + }, + }, + ); + }) as typeof window.withProgress); + const { request, responseTo } = started(evaluate); + + const running = request('evaluate', { + expression: 'while True: pass', + frameId: 1, + context: 'repl', + }); + await vi.advanceTimersByTimeAsync(500); + const earlyNotifications = shown.length; + await vi.advanceTimersByTimeAsync(5_000); + cancelRequested?.(); + await vi.advanceTimersByTimeAsync(0); + + expect(earlyNotifications).toBe(0); + expect(shown).toEqual([expect.objectContaining({ cancellable: true })]); + expect(signals[0].aborted).toBe(true); + expect(responseTo(running)).toMatchObject({ success: false }); + }); + + it('shows no notification for a line that finishes quickly', async () => { + vi.useFakeTimers(); + const progress = vi.spyOn(window, 'withProgress'); + const { request } = started(() => Promise.resolve(value('1'))); + + request('evaluate', { expression: '1', frameId: 1, context: 'repl' }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(progress).not.toHaveBeenCalled(); + }); + + it('shows no notification for a watch, which has a budget of its own', async () => { + vi.useFakeTimers(); + const progress = vi.spyOn(window, 'withProgress'); + const { evaluate } = runaway(); + const { request } = started(evaluate); + + request('evaluate', { expression: 'spin()', frameId: 1, context: 'watch' }); + await vi.advanceTimersByTimeAsync(10_000); + + expect(progress).not.toHaveBeenCalled(); + }); +}); + +describe('a saved stack', () => { + it.todo( + 'offers the console on a stack opened from the database read-only, or refuses it with a message — not decided yet', + ); +}); diff --git a/src/consoleTraceback.ts b/src/consoleTraceback.ts new file mode 100644 index 0000000..ccdef28 --- /dev/null +++ b/src/consoleTraceback.ts @@ -0,0 +1,101 @@ +import { ConsoleError, TracebackFrame } from './pauseVariables'; + +/** + * A Debug Console error, written the way a Jupyter notebook shows one: + * + * ``` + * --------------------------------------------------------------------------- + * ZeroDivisionError Traceback (most recent call last) + * Debug Console, line 1 + * ----> 1 boom(0) + * + * Cell [2], line 2, in boom + * 1 def boom(d): + * ----> 2 return 10 / d + * + * ZeroDivisionError: division by zero + * ``` + * + * Python developers read tracebacks in that shape every day, and IPython's is + * the one they meet in notebooks, which is where GemDB's pauses mostly are. + * Each frame is placed by `place`: its label and, when the source can be + * found, its lines, so the frame shows a few lines around the one that + * raised. A frame whose source cannot be found shows the text Grail recorded + * for it. A SyntaxError has no frames, and shows the line it could not read + * with a caret, as IPython does. + */ + +/** Where a traceback frame's source is: what to call it, and its lines when they can be read. */ +export interface PlacedFrame { + /** `Debug Console`, `Cell [2]`, or for a file the path: `File /w/mod.py`. */ + label: string; + /** Whether `label` names a file, which takes `:line` rather than `, line N`. */ + isFile: boolean; + lines?: string[]; +} + +/** IPython's rule, the width of its header. */ +const RULE = '-'.repeat(75); + +/** Width of the line-number gutter, arrow included. */ +const GUTTER = 7; + +/** Lines shown on either side of the one that raised. */ +const CONTEXT = 2; + +export function jupyterTraceback( + error: ConsoleError, + place: (frame: TracebackFrame, index: number) => PlacedFrame, +): string { + const last = error.message ? `${error.type}: ${error.message}` : error.type; + if (error.syntax) { + const { line, offset, text } = error.syntax; + const where = place({ file: error.syntax.file || '', line, name: '', text }, 0); + const lineText = text.replace(/\r?\n$/, ''); + const indent = lineText.length - lineText.trimStart().length; + const caret = offset > 0 ? [` ${' '.repeat(Math.max(0, offset - 1 - indent))}^`] : []; + return [ + ` ${where.isFile ? `${where.label}:${line}` : `${where.label}, line ${line}`}`, + ` ${lineText.trim()}`, + ...caret, + last, + ].join('\n'); + } + const blocks = error.frames.map((frame, index) => frameBlock(frame, place(frame, index))); + return [ + RULE, + `${error.type.padEnd(41)} Traceback (most recent call last)`, + blocks.join('\n\n'), + '', + last, + ].join('\n'); +} + +function frameBlock(frame: TracebackFrame, placed: PlacedFrame): string { + const inName = frame.name === '' ? '' : `, in ${frame.name}`; + const header = placed.isFile + ? `${placed.label}:${frame.line}${inName}` + : `${placed.label}, line ${frame.line}${inName}`; + const shown: Array<[number, string]> = []; + const lines = placed.lines; + if (lines && frame.line >= 1 && frame.line <= lines.length) { + const from = Math.max(1, frame.line - CONTEXT); + const to = Math.min(lines.length, frame.line + CONTEXT); + for (let n = from; n <= to; n++) shown.push([n, lines[n - 1]]); + } else if (frame.line >= 1) { + shown.push([frame.line, frame.text.replace(/\r?\n[\s\S]*$/, '')]); + } + const body = shown.map(([n, text]) => `${gutter(n, n === frame.line)} ${text.trimEnd()}`); + return [header, ...body].join('\n'); +} + +/** + * IPython's line-number gutter: the number right-aligned in seven columns, + * and on the line that raised, an arrow filling the columns before it — + * `----> 9`, `---> 10`, beside ` 10`. + */ +function gutter(n: number, raised: boolean): string { + const number = String(n); + if (!raised) return number.padStart(GUTTER); + return `${'-'.repeat(Math.max(1, GUTTER - number.length - 2))}> ${number}`; +} diff --git a/src/debugger.ts b/src/debugger.ts index 178a704..5ea4b08 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -4,13 +4,18 @@ import * as vscode from 'vscode'; import { runFileOf } from './fileOwner'; import { CellText, PythonFrame, firstLineOf, locateCell } from './haltStack'; import { errorMessage, log } from './log'; +import { PlacedFrame, jupyterTraceback } from './consoleTraceback'; import { + ConsoleScope, MAIN_MODULE, PAGE, PauseVariable, + TracebackFrame, childrenQuery, clearRegistryQuery, + consoleQuery, parseChildren, + parseConsoleReply, parsePausedStack, pausedStackQuery, } from './pauseVariables'; @@ -37,10 +42,10 @@ import { HaltAnswer, HaltRequest, setHaltHandler, setRedDotSource } from './sess * * Deliberately small for now: the call stack, the highlight, each frame's * Locals and the notebook's Globals, Continue and Stop, red dots in `.py` - * files, and saving a row's object under gemdb.root (`savedObjects.ts`). - * Stepping, evaluation and Restart answer "not yet" rather than pretending, - * and so do red dots in notebook cells and dots with a condition, hit count - * or log message. + * files, the Debug Console and the Watch pane, and saving a row's object + * under gemdb.root (`savedObjects.ts`). Stepping and Restart answer "not + * yet" rather than pretending, and so do red dots in notebook cells and dots + * with a condition, hit count or log message. * * The adapter is inline (`DebugAdapterInlineImplementation`) and speaks the * Debug Adapter Protocol directly rather than through `@vscode/debugadapter`: @@ -83,6 +88,29 @@ export interface DapVariable { namedVariables?: number; } +/** Where an `evaluate` came from: the Debug Console, or the Watch pane. */ +export type ConsoleContext = 'repl' | 'watch'; + +/** + * What one Debug Console line or watch came back with. `output` is what it + * printed, sent to the console before the answer; on an error, `traceback` + * reads as a Jupyter notebook's does. + */ +export type ConsoleAnswer = + | { + kind: 'value'; + output: string; + /** The result's repr, without a type badge; '' for None or a statement. */ + value: string; + type?: string; + /** Opens the result like a Variables row; 0 for a leaf. */ + variablesReference: number; + indexedVariables?: number; + namedVariables?: number; + } + | { kind: 'error'; output: string; traceback: string } + | { kind: 'interrupted'; output: string }; + /** One evaluation sitting at a breakpoint(), waiting for the user. */ export interface Pause { label: string; @@ -102,6 +130,17 @@ export interface Pause { handleFor?(containerRef: number, name: string): number | undefined; /** Run a query in the paused session (see `HaltRequest.query`). */ query?(code: string): Promise; + /** + * Run Debug Console or watch input in frame `frameId`, with that frame's + * names in scope, until it finishes or `signal` aborts it. Absent where + * the pause offers no evaluation, such as a saved stack. + */ + evaluate?( + source: string, + frameId: number, + context: ConsoleContext, + signal: AbortSignal, + ): Promise; /** Each frame's whole cell or file, by frame index, so the stack can be saved. */ texts?: Array; /** The saved source behind a frame's `sourceReference`, for a stack opened from the database. */ @@ -467,6 +506,18 @@ interface DapRequest { } const NOT_YET_STEP = "Stepping isn't supported yet. Use Continue to resume or Stop to end the run."; +const NOT_HERE = 'Evaluating expressions is not available here.'; +const NO_HOVER = "GemDB doesn't evaluate on hover: a hover must not run code."; +const INTERRUPTED = 'Interrupted.'; +const WATCH_TOO_LONG = + 'Took too long for a watch (over 2 seconds). Run it in the Debug Console, which has no limit.'; + +/** + * How long a console line runs before a notification offers to stop it. The + * Debug Console has no stop button of its own, and VS Code shows no Pause in + * the toolbar while the program is paused. + */ +export const OFFER_STOP_AFTER_MS = 4_000; const NOT_YET_RESTART = "Restarting isn't supported yet. Use Continue to resume or Stop to end the run."; @@ -501,6 +552,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { private dotWork: Promise; /** Resolves at launch: VS Code may send setBreakpoints before it, and they wait. */ private markLaunched: () => void = () => {}; + /** Whether VS Code said, in `initialize`, that it reads the `invalidated` event. */ + private clientInvalidates = false; + /** Console lines still running, by request seq, so `cancel` can stop one. */ + private readonly running = new Map(); constructor( private readonly lookup: (id: string) => Pause | undefined, @@ -514,9 +569,12 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { if (request.type !== 'request') return; switch (request.command) { case 'initialize': + this.clientInvalidates = request.arguments?.supportsInvalidatedEvent === true; this.respond(request, { supportsConfigurationDoneRequest: true, supportsTerminateRequest: true, + // A console line can run for as long as the user's code does. + supportsCancelRequest: true, // Claimed so Restart arrives as a request this adapter can decline. // Unclaimed, VS Code restarts by disconnecting — which stops the // cell — and relaunching at a pause that no longer exists. @@ -629,8 +687,14 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { this.respond(request); return; case 'evaluate': - this.fail(request, "Evaluating expressions isn't supported yet.", false); + this.evaluate(request); + return; + case 'cancel': { + const id = Number(request.arguments?.requestId ?? 0); + this.running.get(id)?.abort(); + this.respond(request); return; + } case 'terminate': this.respond(request); this.settle('stop'); @@ -678,7 +742,99 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { this.respond(request, { breakpoints: verifiedDots(file, wanted, armed, live) }); } + /** + * Answer a Debug Console line or a watch. Hover is refused: `initialize` + * does not claim it, and a hover must never run code that could change + * something. + * + * What the line printed goes to the console first, then the answer: its + * value, which opens like a Variables row, or a failure whose message is a + * Jupyter traceback, or "Interrupted.". After a console line, failed or not + * — it may have changed something first — VS Code is told to read the + * variables again. Never after a watch: VS Code re-runs the watches when it + * re-reads, and each would invalidate the next. + * + * A console line runs as long as it takes; past `OFFER_STOP_AFTER_MS` a + * notification offers to stop it, as DAP's `cancel` does. A watch keeps the + * two-second budget the Variables rows have, since every watch re-runs at + * every stop. + */ + private evaluate(request: DapRequest): void { + const context = String(request.arguments?.context ?? 'repl'); + const source = String(request.arguments?.expression ?? ''); + const pause = this.pause; + if (context === 'hover') { + this.fail(request, NO_HOVER, false); + return; + } + if (!pause?.evaluate) { + this.fail(request, NOT_HERE, false); + return; + } + const kind: ConsoleContext = context === 'watch' ? 'watch' : 'repl'; + const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + const controller = new AbortController(); + this.running.set(request.seq, controller); + let finished = false; + let markDone: () => void = () => {}; + const done = new Promise((resolve) => (markDone = resolve)); + const offer = + kind === 'repl' + ? setTimeout(() => { + if (finished) return; + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'GemDB: the Debug Console is still running', + cancellable: true, + }, + (_progress, token) => { + token.onCancellationRequested(() => controller.abort()); + return done; + }, + ); + }, OFFER_STOP_AFTER_MS) + : undefined; + const finish = (): void => { + finished = true; + if (offer) clearTimeout(offer); + this.running.delete(request.seq); + markDone(); + }; + pause.evaluate(source, frameId, kind, controller.signal).then( + (answer) => { + finish(); + if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); + if (answer.kind === 'value') { + this.respond(request, { + result: answer.value, + ...(answer.type ? { type: answer.type } : {}), + variablesReference: answer.variablesReference, + ...(answer.indexedVariables ? { indexedVariables: answer.indexedVariables } : {}), + ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), + }); + } else if (answer.kind === 'error') { + this.fail(request, answer.traceback, false); + } else { + this.fail( + request, + kind === 'watch' && !controller.signal.aborted ? WATCH_TOO_LONG : INTERRUPTED, + false, + ); + } + if (kind === 'repl' && this.clientInvalidates) { + this.event('invalidated', { areas: ['variables'] }); + } + }, + (e: unknown) => { + finish(); + this.fail(request, errorMessage(e), false); + }, + ); + } + dispose(): void { + for (const controller of this.running.values()) controller.abort(); // The session went away without Continue or Stop — a closed window, say. // Leaving the evaluation paused would strand its cell, so stop it. this.settle('stop'); @@ -781,6 +937,8 @@ interface PauseView { frames: DapFrame[]; scopes: Map; texts: Array; + /** Where each frame's Debug Console names come from, by DAP frame id. */ + consoles: Map; } /** @@ -791,6 +949,8 @@ interface PauseView { * notebook's under a frame a cell compiled (``), a run file's under its * own frames. A frame in an imported module has that module's globals, which * Grail does not hand over here, and showing the run's under it would mislead. + * Its Debug Console does see them: the console finds the module by its file + * when the frame's first line is typed (`consoleQuery`). */ async function readPause(request: HaltRequest): Promise { try { @@ -804,15 +964,27 @@ async function readPause(request: HaltRequest): Promise { ); const ownFile = owner.kind === 'file' ? runFileOf(owner.key) : ''; const scopes = new Map(); - frames.forEach((frame, index) => - scopes.set(index + 1, scopesFor(frame.locals ?? 0, frame.file === ownFile ? globals : 0)), - ); + const consoles = new Map(); + frames.forEach((frame, index) => { + const own = frame.file === ownFile; + scopes.set(index + 1, scopesFor(frame.locals ?? 0, own ? globals : 0)); + consoles.set(index + 1, { + locals: frame.locals ?? 0, + globals: own ? globals : 0, + ...(!own && frame.file !== '' ? { moduleFile: frame.file } : {}), + }); + }); const { running, cells } = notebookCells(owner.key); const texts: Array = []; - return { frames: toDapFrames(frames, running, cells, readFileLines, texts), scopes, texts }; + return { + frames: toDapFrames(frames, running, cells, readFileLines, texts), + scopes, + texts, + consoles, + }; } catch (e) { log(`Could not read the stack at a pause: ${errorMessage(e)}`); - return { frames: [], scopes: new Map(), texts: [] }; + return { frames: [], scopes: new Map(), texts: [], consoles: new Map() }; } } @@ -844,6 +1016,93 @@ function handleKey(containerRef: number, name: string): string { return `${containerRef}\u0000${name}`; } +/** + * Where a console traceback's frame is, for `jupyterTraceback`: the first + * frame is the console's own input; a `` frame is in the notebook cell + * holding its line (`locateCell`, the call stack's own match); anything else + * is a file, read from disk. + */ +function placeFrame( + ownerKey: string, + source: string, +): (frame: TracebackFrame, index: number) => PlacedFrame { + const { running, cells } = notebookCells(ownerKey); + return (frame, index) => { + if (frame.file === '') { + if (index === 0 && frame.name === '') { + return { label: 'Debug Console', isFile: false, lines: source.split(/\r?\n/) }; + } + const uri = locateCell( + { ...frame, column: 0, endLine: frame.line, endColumn: 0, lineText: frame.text }, + running, + cells, + ); + const cell = cells.find((c) => c.uri === uri); + return cell + ? { label: cell.label ?? 'Cell', isFile: false, lines: cell.lines } + : { label: 'Cell', isFile: false }; + } + if (frame.file === '' || frame.file === '') { + return { label: 'Debug Console', isFile: false, lines: source.split(/\r?\n/) }; + } + return { label: `File ${frame.file}`, isFile: true, lines: readFileLines(frame.file) }; + }; +} + +/** + * Run a Debug Console line or a watch in one frame of a pause (`Pause.evaluate`). + * The frame's console scope is made by its first line and found again by + * `consoleRefs` after that. A console line runs without the paused queries' + * time budget, a watch with it; both run straight past a breakpoint() in + * code they call, as debugpy's evaluation does. + */ +async function evaluateAt( + request: HaltRequest, + view: PauseView, + consoleRefs: Map, + source: string, + frameId: number, + context: ConsoleContext, + signal: AbortSignal, +): Promise { + const scope = view.consoles.get(frameId) ?? { locals: 0, globals: 0 }; + const reply = parseConsoleReply( + await request.query(consoleQuery(scope, consoleRefs.get(frameId) ?? 0, source), { + budget: context === 'watch', + signal, + passHalts: true, + }), + ); + if (reply.consoleRef > 0) consoleRefs.set(frameId, reply.consoleRef); + const output = reply.output; + switch (reply.kind) { + case 'value': { + const row = toDapVariable(reply.row); + return { + kind: 'value', + output, + value: row.value, + ...(row.type ? { type: row.type } : {}), + variablesReference: row.variablesReference, + ...(row.indexedVariables ? { indexedVariables: row.indexedVariables } : {}), + ...(row.namedVariables ? { namedVariables: row.namedVariables } : {}), + }; + } + case 'none': + return { kind: 'value', output, value: '', variablesReference: 0 }; + case 'interrupted': + return { kind: 'interrupted', output }; + case 'error': + return { + kind: 'error', + output, + traceback: jupyterTraceback(reply.error, placeFrame(request.session.owner.key, source)), + }; + case 'failed': + return { kind: 'error', output, traceback: reply.message }; + } +} + /** * Answer every breakpoint() and red dot in this window with a debug session. * @@ -913,9 +1172,11 @@ export function registerBreakpointDebugger(): vscode.Disposable { const where = request.reason === 'red dot' ? 'a red dot' : 'breakpoint()'; log(`${where} in ${label}: paused`); - void readPause(request).then(({ frames, scopes, texts }) => { + void readPause(request).then((view) => { + const { frames, scopes, texts } = view; if (settled) return; const handles = new Map(); + const consoleRefs = new Map(); pauses.set(id, { label, ownerKey: request.session.owner.key, @@ -925,6 +1186,8 @@ export function registerBreakpointDebugger(): vscode.Disposable { answer, handleFor: (containerRef, name) => handles.get(handleKey(containerRef, name)), query: (code) => request.query(code), + evaluate: (source, frameId, context, signal) => + evaluateAt(request, view, consoleRefs, source, frameId, context, signal), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, reason: request.reason, diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index b4908d1..241e7bc 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -605,3 +605,223 @@ ${withQuietConsole(` ${LIST_CHILDREN}`)} out contents encodeAsUTF8]`; } + +// --------------------------------------------------------------------------- +// The Debug Console and the Watch pane. +// --------------------------------------------------------------------------- + +/** + * Where a frame's console names come from: its Locals (a registry position, + * 0 for none) over its globals. Those are the pause's Globals (a registry + * position) for a frame in the notebook's cells or the `.py` run's own file, + * and for a frame in an imported module, that module's, found by its file. + */ +export interface ConsoleScope { + locals: number; + globals: number; + moduleFile?: string; +} + +/** One frame of a console error's traceback, outermost first. */ +export interface TracebackFrame { + /** A real path, or `` for the console's input or a notebook cell. */ + file: string; + line: number; + name: string; + /** Text from the frame's line, as Grail's codegen recorded it — often a slice. */ + text: string; +} + +/** A console line's exception, as data. */ +export interface ConsoleError { + /** The type as a traceback's last line names it: `ZeroDivisionError`, `json.JSONDecodeError`. */ + type: string; + message: string; + /** Where a SyntaxError could not read: 1-based line and column, and the line. */ + syntax?: { file: string; line: number; offset: number; text: string }; + frames: TracebackFrame[]; +} + +/** What `consoleQuery` answered. */ +export type ConsoleReply = { consoleRef: number; output: string } & ( + | { kind: 'value'; row: PauseVariable } + | { kind: 'none' } + | { kind: 'error'; error: ConsoleError } + | { kind: 'interrupted' } + | { kind: 'failed'; message: string } +); + +/** + * Smalltalk that runs Debug Console or watch input `source` in a frame's + * console scope and answers what came of it (`parseConsoleReply`). + * + * The scope is made on a frame's first line and kept in the pause's registry + * as `{#gemdbConsole. dict}`, at `consoleRef` once made, so a name the + * console binds (`y = 5`, `import json`, a `def`) is there for the next line + * and gone with the registry at resume, as under debugpy. It is one + * SymbolDictionary run as a module scope: the frame's globals, copied, with + * its locals copied over them, so a local shadows a global as it does in the + * frame's own code, and a comprehension or lambda sees the locals — with + * locals in a dict of their own, as debugpy evaluates, it would not. + * Because they are copies, rebinding a name (`x = 5`) stays in the console; + * the objects are the real ones, so `p.x = 10` changes the program's `p`. + * A frame's locals are keyed by their Python spelling, but a scope evaluated + * this way carries the six names that are Smalltalk pseudo-variables — `self` + * above all — under Grail's own stand-in, which `NameAst doitScopeNameFor:` + * answers (`___pyresv_self___`), so they are copied in under that; copied as + * `self`, a method's `self` read as undefined (measured). + * + * What the input prints is captured, as `withQuietConsole` does for a + * `__repr__`, and answered with the result. An exception is caught from + * Smalltalk, where Python never gave it a `__traceback__`; Grail's + * `pythonTracebackFrames` walks the stack the raise captured instead, which + * is the same walk Python's own outermost handler would have made. A soft + * break — the watch's time budget, or the user cancelling — lands as a + * ControlInterrupt and is answered as interrupted, not as an error in the + * user's code. + */ +export function consoleQuery(scope: ConsoleScope, consoleRef: number, source: string): string { + const findModule = scope.moduleFile + ? `g := [dispatcher evaluateSource: '${escapeString( + [ + 'import sys as _gemdb_sys', + '_gemdb_found = None', + 'for _gemdb_m in list(_gemdb_sys.modules.values()):', + ` if getattr(_gemdb_m, "__file__", None) == ${pythonString(scope.moduleFile)}:`, + ' _gemdb_found = _gemdb_m.__dict__', + ' break', + '_gemdb_found', + ].join('\n'), + )}' usingModuleScope: SymbolDictionary new] + on: AbstractException do: [:e | e return: nil]. + (g notNil and: [g ~~ none and: [g respondsTo: #keysAndValuesDo:]]) + ifTrue: [g keysAndValuesDo: [:k :x | bind value: k value: x]].` + : ''; + return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst | +${PREAMBLE} +${HELPERS} +${CHILDREN_OF} +${DESCRIBE} +reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. +reg isNil ifTrue: [^ 'Error: the pause is over' encodeAsUTF8]. +dispatcher := System myUserProfile symbolList objectNamed: #'ModuleAst'. +cref := ${consoleRef}. +nameAst := System myUserProfile symbolList objectNamed: #'NameAst'. +bind := [:k :x | | nm | + nm := k asString asSymbol. + nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. + dict at: nm put: x]. +console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil]. +(console class == Array and: [console size = 2 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ + dict := SymbolDictionary new. + g := (${scope.globals} between: 1 and: reg size) ifTrue: [reg at: ${scope.globals}] ifFalse: [nil]. + (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) + ifTrue: [(g at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. + ${findModule} + "The frame's locals over its globals: a local shadows a global of its name." + l := (${scope.locals} between: 1 and: reg size) ifTrue: [reg at: ${scope.locals}] ifFalse: [nil]. + (l class == Array and: [l size >= 2 and: [(l at: 1) == #gemdbScope]]) + ifTrue: [(l at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. + console := { #gemdbConsole. dict }. + reg add: console. + cref := reg size]. +kind := #value. +exc := nil. +captured := WriteStream on: Unicode7 new. +saved := SessionTemps current at: #'GrailConsole' otherwise: nil. +SessionTemps current at: #'GrailConsole' put: (Array with: captured). +[r := [[dispatcher evaluateSource: '${escapeString(source)}' usingModuleScope: (console at: 2)] + on: AlmostOutOfStack do: [:e | kind := #stack. e return: nil]] + on: AbstractException do: [:e | + (e isKindOf: ControlInterrupt) + ifTrue: [kind := #interrupted] + ifFalse: [kind == #value ifTrue: [kind := #error. exc := e]]. + e return: nil]] + ensure: [ + saved isNil + ifTrue: [SessionTemps current removeKey: #'GrailConsole' ifAbsent: []] + ifFalse: [SessionTemps current at: #'GrailConsole' put: saved]]. +(kind == #value and: [r == none or: [r isNil]]) ifTrue: [kind := #none]. +out nextPutAll: kind; nextPut: field; print: cref; nextPut: field; nextPutAll: (clean value: captured contents); nextPut: record. +kind == #value ifTrue: [ + "The result's own row, as the Variables pane would write it, with its __repr__ quiet." + SessionTemps current at: #'GrailConsole' put: (Array with: (WriteStream on: Unicode7 new)). + [describe value: 'result' value: r] ensure: [ + saved isNil + ifTrue: [SessionTemps current removeKey: #'GrailConsole' ifAbsent: []] + ifFalse: [SessionTemps current at: #'GrailConsole' put: saved]]]. +kind == #stack ifTrue: [ + out nextPutAll: 'RecursionError'; nextPut: field; nextPutAll: 'maximum recursion depth exceeded'; nextPut: record]. +kind == #error ifTrue: [| payload frames tname msg attr sx | + attr := [:o :name | [o @env1:___pyAttrLoad___: name] on: AbstractException do: [:e | e return: nil]]. + payload := [exc ensurePythonTraceback] on: AbstractException do: [:e | e return: exc]. + frames := [exc pythonTracebackFrames] on: AbstractException do: [:e | e return: #()]. + tname := [payload ___pythonTypeNameForTraceback___] on: AbstractException do: [:e | e return: exc class name asString]. + msg := [payload ___safeStrForTraceback___] on: AbstractException do: [:e | e return: exc messageText asString]. + "A SyntaxError says where it could not read, and str() of one is a tuple: its msg is the message." + sx := ((attr value: payload value: #'lineno') isNumber and: [(attr value: payload value: #'text') isString]) + ifTrue: [{ attr value: payload value: #'filename'. attr value: payload value: #'lineno'. + attr value: payload value: #'offset'. attr value: payload value: #'text' }] + ifFalse: [nil]. + (sx notNil and: [(attr value: payload value: #'msg') isString]) ifTrue: [msg := attr value: payload value: #'msg']. + out nextPutAll: (clean value: tname asString); nextPut: field; nextPutAll: (clean value: msg asString). + sx notNil ifTrue: [ + out nextPut: field; nextPutAll: (clean value: ((sx at: 1) isString ifTrue: [sx at: 1] ifFalse: [''])); nextPut: field; + print: (sx at: 2); nextPut: field; + print: ((sx at: 3) isNumber ifTrue: [sx at: 3] ifFalse: [0]); nextPut: field; + nextPutAll: (clean value: (sx at: 4))]. + out nextPut: record. + frames do: [:f | + out nextPutAll: (clean value: ((f at: 1) ifNil: ['']) asString); nextPut: field; + print: ((f at: 2) isNumber ifTrue: [f at: 2] ifFalse: [0]); nextPut: field; + nextPutAll: (clean value: ((f at: 3) ifNil: ['?']) asString); nextPut: field; + nextPutAll: (clean value: ((f at: 4) isString ifTrue: [f at: 4] ifFalse: [''])); nextPut: record]]. +out contents encodeAsUTF8`; +} + +/** Parse `consoleQuery`'s answer. */ +export function parseConsoleReply(raw: string): ConsoleReply { + if (raw.startsWith('Error: ')) { + return { consoleRef: 0, output: '', kind: 'failed', message: raw.slice('Error: '.length) }; + } + const [head = '', ...rest] = raw.split(RECORD); + const [kind, ref, printed] = head.split(FIELD); + const consoleRef = Number.parseInt(ref ?? '', 10) || 0; + const output = (printed ?? '').replace(/\r\n?/g, '\n'); + const records = rest.filter((r) => r.length > 0); + if (kind === 'value') { + const [row] = parseChildren(records.join(RECORD) + RECORD); + if (row) return { consoleRef, output, kind, row }; + return { consoleRef, output, kind: 'failed', message: 'The result could not be read.' }; + } + if (kind === 'none' || kind === 'interrupted') return { consoleRef, output, kind }; + if (kind === 'error' || kind === 'stack') { + const [type = '', message = '', file, line, offset, text] = (records[0] ?? '').split(FIELD); + const int = (s: string | undefined): number => Number.parseInt(s ?? '', 10) || 0; + const frames = records.slice(1).map((record): TracebackFrame => { + const [frameFile, frameLine, name, frameText] = record.split(FIELD); + return { + file: frameFile || '', + line: int(frameLine), + name: name || '?', + text: frameText ?? '', + }; + }); + return { + consoleRef, + output, + kind: 'error', + error: { + type, + message, + ...(line === undefined + ? {} + : { + syntax: { file: file ?? '', line: int(line), offset: int(offset), text: text ?? '' }, + }), + frames, + }, + }; + } + return { consoleRef, output, kind: 'failed', message: raw || 'The console got no answer.' }; +} diff --git a/src/session.ts b/src/session.ts index 3ae3fde..e01198d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -304,15 +304,32 @@ export interface HaltRequest { /** * Run Smalltalk while the evaluation stays paused — what the debugger reads * the stack and the Variables with. Nonblocking, one query at a time, and - * soft-broken past a time budget; see `queryWhilePaused`. Refused once the - * pause is settled. + * soft-broken past a time budget unless `options` says otherwise; see + * `queryWhilePaused`. Refused once the pause is settled. */ - query(code: string): Promise; + query(code: string, options?: PausedQueryOptions): Promise; /** Runs if the evaluation is interrupted or its session closed while paused, * so the handler can take down its debugger (the halt is already answered). */ onCancel(callback: () => void): void; } +/** + * How a query while paused runs. The defaults suit a Variables row: a two-second + * budget, and a query that reaches breakpoint() fails. The Debug Console asks for + * the opposite of both. + */ +export interface PausedQueryOptions { + /** Soft-break the query every `PAUSED_QUERY_BUDGET_MS`. Default true. */ + budget?: boolean; + /** Soft-break the query when this aborts. */ + signal?: AbortSignal; + /** + * Run straight past a breakpoint() or red dot the query reaches, as + * debugpy and PyCharm do for an evaluation, rather than failing. Default false. + */ + passHalts?: boolean; +} + export type HaltHandler = (request: HaltRequest) => Promise; let haltHandler: HaltHandler | undefined; @@ -898,16 +915,19 @@ export class GciSession { * returned would freeze the extension host with no way to send a break. * Past `PAUSED_QUERY_BUDGET_MS` the query is soft-broken, again each budget * after; the Variables query catches that break around a `__repr__` and - * shows the rest without one. + * shows the rest without one. A Debug Console line runs without the budget + * and is broken only when its `signal` aborts (`PausedQueryOptions`). * * Queries run one at a time, chained, and the paused evaluation waits for * the chain before it resumes or ends. A query that itself stops — a * `__repr__` that reached breakpoint() or input(), or a break nothing * caught — has its stack cleared, which runs its `ensure:` blocks, and - * fails. + * fails; with `passHalts`, a breakpoint() is run past instead. */ - private queryWhilePaused(code: string): Promise { - const run = this.pausedQueries.then(() => this.runPausedQuery(code)); + private queryWhilePaused(code: string, options: PausedQueryOptions = {}): Promise { + const run = this.pausedQueries.then(() => + this.runPausedQuery(code, options.budget ?? true, options), + ); this.pausedQueries = run.then( () => undefined, () => undefined, @@ -915,16 +935,44 @@ export class GciSession { return run; } - private async runPausedQuery(code: string, softBreak = true): Promise { + private async runPausedQuery( + code: string, + softBreak = true, + { signal, passHalts = false }: PausedQueryOptions = {}, + ): Promise { const handle = this.requireHandle(); let deadline = Date.now() + PAUSED_QUERY_BUDGET_MS; this.pausedQueryRunning = true; + // A soft break, like the budget's: the query catches it where it can and + // says it was interrupted, rather than losing its stack. + const onAbort = (): void => { + if (this.handle !== undefined) this.gci.GciTsBreak(handle, false); + }; + if (signal?.aborted) onAbort(); + signal?.addEventListener('abort', onAbort); try { - const { result: oop, err } = await this.submitAndWait(handle, code, () => { + let { result: oop, err } = await this.submitAndWait(handle, code, () => { if (!softBreak || Date.now() < deadline) return; deadline = Date.now() + PAUSED_QUERY_BUDGET_MS; this.gci.GciTsBreak(handle, false); }); + // A breakpoint() or a red dot in code the query ran: go on past it. The + // run's flags stay 0, as every paused query's do, so no red dot fires and + // only breakpoint()'s Halt arrives here; BREAKPOINT is passed for safety. + while ( + passHalts && + oop === OOP_ILLEGAL && + (err.number === HALT || err.number === BREAKPOINT) && + isProcess(err.context) + ) { + ({ result: oop, err } = await this.gci.GciTsContinueWithAsync( + handle, + err.context, + OOP_ILLEGAL, + null, + 0, + )); + } if (oop === OOP_ILLEGAL) { if (isProcess(err.context)) this.gci.GciTsClearStack(handle, err.context); throw new SessionError(err.message || 'The query failed.'); @@ -933,6 +981,7 @@ export class GciSession { } catch (e) { throw this.asSessionError(e); } finally { + signal?.removeEventListener('abort', onAbort); this.pausedQueryRunning = false; } } @@ -1126,10 +1175,10 @@ export class GciSession { this.gci.GciTsBreak(this.handle, false); } }; - const query = (code: string): Promise => + const query = (code: string, options?: PausedQueryOptions): Promise => settled ? Promise.reject(new SessionError('The pause is over.')) - : this.queryWhilePaused(code); + : this.queryWhilePaused(code, options); handler({ session: this, process, From 2532b72287631e53fa1e4d3b3cb71fe7bebfacf1 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 16:13:22 -0700 Subject: [PATCH 02/15] Show a Variables row in the Debug Console, or evaluate with self Two items on a Variables row's menu. Show in Debug Console opens the console with the row's object in it, expandable like the row. Evaluate with self... asks for an expression and shows what it gives with self bound to the row's object, in the frame selected in Call Stack. Both find the object by its registry handle, as Add to Persisted Objects does, so a row deep in the tree needs no expression to name it, and self is bound in a copy of the frame's console scope, leaving the frame's own self alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 + README.md | 5 +- package.json | 30 +++- src/__integration__/debugConsole.test.ts | 73 ++++++++++ src/__mocks__/vscode.ts | 11 ++ src/__tests__/debugConsole.test.ts | 61 ++++++++ src/__tests__/variableCommands.test.ts | 163 ++++++++++++++++++++++ src/debugger.ts | 169 ++++++++++++++++++----- src/extension.ts | 2 + src/pauseVariables.ts | 55 +++++++- src/variableCommands.ts | 118 ++++++++++++++++ 11 files changed, 655 insertions(+), 36 deletions(-) create mode 100644 src/__tests__/variableCommands.test.ts create mode 100644 src/variableCommands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e2f992d..04104de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Code the console calls runs straight past `breakpoint()` and red dots. Not yet: `cls` in a classmethod's frame, and rebinding a local (`n = 99`) changes the console's copy, not the paused function. +- **Show in Debug Console and Evaluate with self… on a Variables row.** Right-click any row, however + deep in the tree, and **Show in Debug Console** opens the console with that object in it, + expandable like the row. **Evaluate with self…** asks for an expression and shows what it gives + with `self` bound to the row's object, in the frame selected in Call Stack. ## [1.6.0] - 2026-10-07 diff --git a/README.md b/README.md index 5de423d..62ced22 100644 --- a/README.md +++ b/README.md @@ -420,7 +420,10 @@ database, select that kernel from the kernel picker at the top right. its module's globals and the builtins, and a result opens like a Variables row. Names you bind there last until the run resumes; an error reads as a notebook traceback; a long-running line can be stopped from the notification that appears. A change to an object made there is in the - notebook's transaction, as it would be from a cell. Stepping is not supported yet, and neither are + notebook's transaction, as it would be from a cell. Right-click a Variables row for **Show in + Debug Console**, which puts that object in the console, expandable, or **Evaluate with self…**, + which runs an expression with `self` bound to it (`len(self.history)`) and shows the answer + there. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/package.json b/package.json index 77dbfff..f050cc0 100644 --- a/package.json +++ b/package.json @@ -397,6 +397,16 @@ "title": "Keep the Database Running After Logout", "category": "GemDB" }, + { + "command": "gemdb.showInDebugConsole", + "title": "Show in Debug Console", + "category": "GemDB" + }, + { + "command": "gemdb.evaluateWithSelf", + "title": "Evaluate with self…", + "category": "GemDB" + }, { "command": "gemdb.saveVariable", "title": "Add to Persisted Objects…", @@ -594,6 +604,14 @@ "command": "gemdb.configureRemoveIpc", "when": "isLinux" }, + { + "command": "gemdb.showInDebugConsole", + "when": "false" + }, + { + "command": "gemdb.evaluateWithSelf", + "when": "false" + }, { "command": "gemdb.saveVariable", "when": "false" @@ -637,9 +655,19 @@ ], "debug/variables/context": [ { - "command": "gemdb.saveVariable", + "command": "gemdb.showInDebugConsole", "when": "debugType == gemdb", "group": "z_gemdb@1" + }, + { + "command": "gemdb.evaluateWithSelf", + "when": "debugType == gemdb", + "group": "z_gemdb@2" + }, + { + "command": "gemdb.saveVariable", + "when": "debugType == gemdb", + "group": "z_gemdb@3" } ], "view/item/context": [ diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index b013f68..561b245 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -798,4 +798,77 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' // As the same line in a cell would: the notebook's next commit writes it. expect(dirty).toBe('true'); }); + it('puts a Variables row in the Debug Console, and evaluates with self bound to it', async () => { + const nb = notebook('dc-row'); + const seen: Record = {}; + + await inCell( + nb, + [ + 'class Box:', + ' def __init__(self, n):', + ' self.x = n', + ' self.items = [n, n]', + ' def look(self, other):', + ' breakpoint()', + 'Box(1).look(Box(5))', + ].join('\n'), + async (dap) => { + const outputs = async (args: Record) => { + const from = dap.sent.length; + const reply = await dap.request('gemdbShowRow', args); + return { + ok: reply.success, + out: dap.sent + .slice(from) + .filter((m) => m.event === 'output') + .map( + (m) => m.body as { category: string; output: string; variablesReference?: number }, + ), + }; + }; + const other = (await dap.locals()).find((v) => v.name === 'other')!; + const shown = await outputs({ handle: other.variablesReference, name: 'other' }); + seen.shown = shown; + const ref = shown.out[0]?.variablesReference ?? 0; + seen.kids = (await dap.children(ref)).map((v) => `${v.name}=${v.value}`).sort(); + seen.withSelf = await outputs({ + handle: other.variablesReference, + name: 'other', + expression: 'self.x * 10', + frameId: 1, + }); + // The frame's own self is still the Box it was: the binding was for that line alone. + seen.frameSelf = await dap.value('self.x'); + const scopes = (await dap.request('scopes', { frameId: 2 })).body?.scopes as Array<{ + name: string; + variablesReference: number; + }>; + const groups = await dap.children( + scopes.find((sc) => sc.name === 'Globals')!.variablesReference, + ); + const group = groups.find((v) => v.name === 'class variables')!; + seen.group = await outputs({ handle: group.variablesReference, name: 'class variables' }); + }, + ); + closeSessionFor(nb.key); + + expect(seen.shown).toMatchObject({ + ok: true, + out: [{ category: 'console', output: expect.stringMatching(/^other: .*Box/) }], + }); + expect(seen.kids).toEqual(['items=[5, 5]', 'x=5']); + expect(seen.withSelf).toMatchObject({ + ok: true, + out: [ + { category: 'console', output: '>>> self.x * 10 # self is other\n' }, + { category: 'console', output: '50\n' }, + ], + }); + expect(seen.frameSelf).toBe('1'); + expect(seen.group).toMatchObject({ + ok: true, + out: [{ category: 'stderr', output: expect.stringMatching(/not an object of its own/) }], + }); + }); }); diff --git a/src/__mocks__/vscode.ts b/src/__mocks__/vscode.ts index ecab482..c77bd82 100644 --- a/src/__mocks__/vscode.ts +++ b/src/__mocks__/vscode.ts @@ -421,11 +421,22 @@ export const debug = { }, stopDebugging: (): Promise => Promise.resolve(), activeDebugSession: undefined as { id: string; type: string } | undefined, + /** The frame selected in Call Stack; a test sets it to a `DebugStackFrame`. */ + activeStackItem: undefined as unknown, /** The gutter's breakpoints; a test fills it with `SourceBreakpoint`s. */ breakpoints: [] as unknown[], }; /** A gutter breakpoint, with just the location shape red dots read. */ +/** A frame selected in Call Stack. */ +export class DebugStackFrame { + constructor( + readonly session: { id: string; type: string; name?: string; customRequest: unknown }, + readonly threadId: number, + readonly frameId: number, + ) {} +} + export class SourceBreakpoint { constructor( readonly location: { diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index 20967cc..54d0ee7 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -464,3 +464,64 @@ describe('a saved stack', () => { 'offers the console on a stack opened from the database read-only, or refuses it with a message — not decided yet', ); }); + +describe('putting a Variables row in the Debug Console', () => { + const showRow = (answer: ConsoleAnswer) => vi.fn(() => Promise.resolve(answer)); + + it('shows the row’s object as an entry that expands like the row', async () => { + const shown = showRow(value("Account('ada', 10)", { variablesReference: 7 })); + const { request, responseTo, events } = started(undefined, undefined, { + showRow: shown, + evaluate: vi.fn(), + }); + + const seq = request('gemdbShowRow', { handle: 7, name: 'p' }); + await settle(); + + expect(shown).toHaveBeenCalledWith(7, 'p'); + expect(events('output').map((e) => e.body)).toEqual([ + { category: 'console', output: "p: Account('ada', 10)\n", variablesReference: 7 }, + ]); + expect(responseTo(seq)?.success).toBe(true); + // Showing changes nothing, so Variables is left alone. + expect(events('invalidated')).toEqual([]); + }); + + it('runs an expression with self bound to the row, echoing it, and refreshes Variables', async () => { + const evaluate = vi.fn().mockResolvedValue(value('2', { output: 'hi\n' })); + const { request, events } = started(evaluate, undefined, { showRow: vi.fn() }); + + request('gemdbShowRow', { handle: 7, name: 'p', expression: 'len(self.history)', frameId: 2 }); + await settle(); + + expect(evaluate).toHaveBeenCalledWith('len(self.history)', 2, 'repl', expect.anything(), 7); + expect(events('output').map((e) => e.body)).toEqual([ + { category: 'console', output: '>>> len(self.history) # self is p\n' }, + { category: 'stdout', output: 'hi\n' }, + { category: 'console', output: '2\n', variablesReference: 0 }, + ]); + expect(events('invalidated')).toHaveLength(1); + }); + + it('shows an error’s traceback in the console', async () => { + const evaluate = vi + .fn() + .mockResolvedValue({ kind: 'error', output: '', traceback: 'TB' } as ConsoleAnswer); + const { request, events, responseTo } = started(evaluate, undefined, { showRow: vi.fn() }); + + const seq = request('gemdbShowRow', { handle: 7, name: 'p', expression: 'self.nope' }); + await settle(); + + expect(events('output').pop()?.body).toEqual({ category: 'stderr', output: 'TB\n' }); + expect(responseTo(seq)?.success).toBe(true); + }); + + it('refuses where the pause has no rows to show, such as a saved stack', async () => { + const { request, responseTo } = started(undefined); + + const seq = request('gemdbShowRow', { handle: 7, name: 'p' }); + await settle(); + + expect(responseTo(seq)).toMatchObject({ success: false }); + }); +}); diff --git a/src/__tests__/variableCommands.test.ts b/src/__tests__/variableCommands.test.ts new file mode 100644 index 0000000..e504789 --- /dev/null +++ b/src/__tests__/variableCommands.test.ts @@ -0,0 +1,163 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { DebugStackFrame, commands, debug, window } from '../__mocks__/vscode'; +import type { Pause } from '../debugger'; + +/** + * Show in Debug Console and Evaluate with self… on a Variables row, without a + * database: which row's object they name, what they ask the paused session, + * and that they open the console. What the adapter does with the request is + * `debugConsole.test.ts`; the whole path at a real pause is the row-menu test + * in `__integration__/debugConsole.test.ts`. + */ + +let pause: Partial | undefined; +vi.mock('../debugger', () => ({ + DEBUG_TYPE: 'gemdb', + pauseForDebugSession: (id: string | undefined) => (id === 's1' ? pause : undefined), +})); + +const { evaluateWithSelf, showInDebugConsole } = await import('../variableCommands'); + +type CustomRequest = (command: string, args?: unknown) => Promise; + +/** A paused GemDB session whose rows under container 5 have handles by name. */ +function paused(customRequest: CustomRequest = () => Promise.resolve({})) { + const session = { id: 's1', type: 'gemdb', name: 'GemDB: a.ipynb', customRequest }; + debug.activeDebugSession = session; + pause = { handleFor: (container, name) => (container === 5 && name === 'n' ? 12 : undefined) }; + return session; +} + +/** Record the commands run and the errors shown, in order. */ +function watching() { + const seen: string[] = []; + vi.spyOn(commands, 'executeCommand').mockImplementation((id: string) => { + seen.push(`command ${id}`); + return undefined; + }); + vi.spyOn(window, 'showErrorMessage').mockImplementation((message: string) => { + seen.push(`error ${message}`); + return Promise.resolve(undefined); + }); + return seen; +} + +const row = (name: string, variablesReference: number) => ({ + sessionId: 's1', + container: { variablesReference: 5 }, + variable: { name, value: '…', variablesReference }, +}); + +afterEach(() => { + vi.restoreAllMocks(); + debug.activeDebugSession = undefined; + debug.activeStackItem = undefined; + pause = undefined; +}); + +describe('Show in Debug Console', () => { + it('opens the console, then asks the session to show the row’s object there', async () => { + const seen = watching(); + const customRequest = vi.fn(async (command, args) => { + seen.push(`request ${command} ${JSON.stringify(args)}`); + return {}; + }); + paused(customRequest); + + await showInDebugConsole(row('p', 7)); + + expect(seen).toEqual([ + 'command workbench.debug.action.focusRepl', + 'request gemdbShowRow {"handle":7,"name":"p"}', + ]); + }); + + it('finds a leaf row’s object by its handle, as Add to Persisted Objects does', async () => { + watching(); + const customRequest = vi.fn().mockResolvedValue({}); + paused(customRequest); + + await showInDebugConsole(row('n', 0)); + + expect(customRequest).toHaveBeenCalledWith('gemdbShowRow', { handle: 12, name: 'n' }); + }); + + it('says so, and asks nothing, for a row with no object of its own', async () => { + const seen = watching(); + const customRequest = vi.fn(); + paused(customRequest); + + await showInDebugConsole(row('...', 0)); + + expect(customRequest).not.toHaveBeenCalled(); + expect(seen).toEqual(['error "..." is not an object of its own.']); + }); + + it('says it needs a paused GemDB run when there is none', async () => { + const seen = watching(); + debug.activeDebugSession = { id: 'py', type: 'debugpy' }; + + await showInDebugConsole(row('p', 7)); + + expect(seen).toEqual([expect.stringMatching(/^error .*paused in the debugger/)]); + }); + + it('shows the error when the request fails', async () => { + const seen = watching(); + paused(() => Promise.reject(new Error('the pause is over'))); + + await showInDebugConsole(row('p', 7)); + + expect(seen).toContain('error the pause is over'); + }); +}); + +describe('Evaluate with self…', () => { + it('runs the expression typed with self bound to the row, in the frame selected in Call Stack', async () => { + watching(); + const customRequest = vi.fn().mockResolvedValue({}); + const session = paused(customRequest); + debug.activeStackItem = new DebugStackFrame(session, 1, 3); + vi.spyOn(window, 'showInputBox').mockResolvedValue('len(self.history)'); + + await evaluateWithSelf(row('p', 7)); + + expect(customRequest).toHaveBeenCalledWith('gemdbShowRow', { + handle: 7, + name: 'p', + expression: 'len(self.history)', + frameId: 3, + }); + }); + + it('leaves the frame to the adapter when none is selected in this session', async () => { + watching(); + const customRequest = vi.fn().mockResolvedValue({}); + paused(customRequest); + debug.activeStackItem = new DebugStackFrame( + { id: 'other', type: 'gemdb', customRequest }, + 1, + 3, + ); + vi.spyOn(window, 'showInputBox').mockResolvedValue('self'); + + await evaluateWithSelf(row('p', 7)); + + expect(customRequest.mock.calls[0][1]).not.toHaveProperty('frameId'); + }); + + it('does nothing when the input box is dismissed or left empty', async () => { + const seen = watching(); + const customRequest = vi.fn(); + paused(customRequest); + const input = vi.spyOn(window, 'showInputBox'); + + input.mockResolvedValueOnce(undefined); + await evaluateWithSelf(row('p', 7)); + input.mockResolvedValueOnce(' '); + await evaluateWithSelf(row('p', 7)); + + expect(customRequest).not.toHaveBeenCalled(); + expect(seen).toEqual([]); + }); +}); diff --git a/src/debugger.ts b/src/debugger.ts index 5ea4b08..4c58ad5 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -14,6 +14,7 @@ import { childrenQuery, clearRegistryQuery, consoleQuery, + describeRowQuery, parseChildren, parseConsoleReply, parsePausedStack, @@ -140,7 +141,11 @@ export interface Pause { frameId: number, context: ConsoleContext, signal: AbortSignal, + /** A registry entry to run with as `self`: a Variables row's object (`consoleQuery`). */ + selfHandle?: number, ): Promise; + /** The object of registry entry `handle`, as a console entry named `name`. */ + showRow?(handle: number, name: string): Promise; /** Each frame's whole cell or file, by frame index, so the stack can be saved. */ texts?: Array; /** The saved source behind a frame's `sourceReference`, for a stack opened from the database. */ @@ -689,6 +694,9 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { case 'evaluate': this.evaluate(request); return; + case 'gemdbShowRow': + this.showRow(request); + return; case 'cancel': { const id = Number(request.arguments?.requestId ?? 0); this.running.get(id)?.abort(); @@ -773,34 +781,7 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { } const kind: ConsoleContext = context === 'watch' ? 'watch' : 'repl'; const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); - const controller = new AbortController(); - this.running.set(request.seq, controller); - let finished = false; - let markDone: () => void = () => {}; - const done = new Promise((resolve) => (markDone = resolve)); - const offer = - kind === 'repl' - ? setTimeout(() => { - if (finished) return; - void vscode.window.withProgress( - { - location: vscode.ProgressLocation.Notification, - title: 'GemDB: the Debug Console is still running', - cancellable: true, - }, - (_progress, token) => { - token.onCancellationRequested(() => controller.abort()); - return done; - }, - ); - }, OFFER_STOP_AFTER_MS) - : undefined; - const finish = (): void => { - finished = true; - if (offer) clearTimeout(offer); - this.running.delete(request.seq); - markDone(); - }; + const { controller, finish } = this.track(request, kind === 'repl'); pause.evaluate(source, frameId, kind, controller.signal).then( (answer) => { finish(); @@ -833,6 +814,108 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { ); } + /** + * Note a running console line under its request, so `cancel` can stop it, + * and, when `offerStop`, offer to stop it from a notification once it has + * run `OFFER_STOP_AFTER_MS`. `finish` when it is done. + */ + private track( + request: DapRequest, + offerStop: boolean, + ): { controller: AbortController; finish: () => void } { + const controller = new AbortController(); + this.running.set(request.seq, controller); + let finished = false; + let markDone: () => void = () => {}; + const done = new Promise((resolve) => (markDone = resolve)); + const offer = offerStop + ? setTimeout(() => { + if (finished) return; + void vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: 'GemDB: the Debug Console is still running', + cancellable: true, + }, + (_progress, token) => { + token.onCancellationRequested(() => controller.abort()); + return done; + }, + ); + }, OFFER_STOP_AFTER_MS) + : undefined; + const finish = (): void => { + finished = true; + if (offer) clearTimeout(offer); + this.running.delete(request.seq); + markDone(); + }; + return { controller, finish }; + } + + /** + * Put a Variables row in the Debug Console (`gemdbShowRow`), for the + * row's menu (`variableCommands.ts`): its object, as an entry that expands + * like the row, or with `expression`, what that gives with `self` bound to + * the object, run in the frame `frameId` as a console line is. Shown as + * `output` events, since there is no console request to answer: an echo of + * the expression, what it printed, then the value, a traceback, or + * "Interrupted.". + */ + private showRow(request: DapRequest): void { + const pause = this.pause; + const handle = Number(request.arguments?.handle ?? 0); + const name = String(request.arguments?.name ?? ''); + const expression = + request.arguments?.expression === undefined + ? undefined + : String(request.arguments.expression); + if (!pause?.showRow || !pause.evaluate || handle <= 0) { + this.fail(request, NOT_HERE, true); + return; + } + const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + const { controller, finish } = this.track(request, expression !== undefined); + if (expression !== undefined) { + this.event('output', { + category: 'console', + output: `>>> ${expression} # self is ${name}\n`, + }); + } + const answered = + expression === undefined + ? pause.showRow(handle, name) + : pause.evaluate(expression, frameId, 'repl', controller.signal, handle); + answered.then( + (answer) => { + finish(); + if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); + if (answer.kind === 'value') { + if (answer.value !== '' || answer.variablesReference > 0) { + this.event('output', { + category: 'console', + output: `${expression === undefined ? `${name}: ` : ''}${answer.value}\n`, + variablesReference: answer.variablesReference, + }); + } + } else { + this.event('output', { + category: 'stderr', + output: `${answer.kind === 'error' ? answer.traceback : INTERRUPTED}\n`, + }); + } + if (expression !== undefined && this.clientInvalidates) { + this.event('invalidated', { areas: ['variables'] }); + } + this.respond(request); + }, + (e: unknown) => { + finish(); + this.fail(request, errorMessage(e), true); + }, + ); + } + dispose(): void { for (const controller of this.running.values()) controller.abort(); // The session went away without Continue or Stop — a closed window, say. @@ -1049,6 +1132,28 @@ function placeFrame( }; } +/** A Variables row's object as a console entry (`Pause.showRow`). */ +async function showRowAt( + request: HaltRequest, + handle: number, + name: string, +): Promise { + const raw = await request.query(describeRowQuery(handle, name)); + if (raw.startsWith('Error: ')) { + return { kind: 'error', output: '', traceback: raw.slice('Error: '.length) }; + } + const [row] = parseChildren(raw); + if (!row) return { kind: 'error', output: '', traceback: 'The row could not be read.' }; + const dap = toDapVariable(row); + return { + kind: 'value', + output: '', + value: dap.value, + ...(dap.type ? { type: dap.type } : {}), + variablesReference: dap.variablesReference, + }; +} + /** * Run a Debug Console line or a watch in one frame of a pause (`Pause.evaluate`). * The frame's console scope is made by its first line and found again by @@ -1064,10 +1169,11 @@ async function evaluateAt( frameId: number, context: ConsoleContext, signal: AbortSignal, + selfHandle = 0, ): Promise { const scope = view.consoles.get(frameId) ?? { locals: 0, globals: 0 }; const reply = parseConsoleReply( - await request.query(consoleQuery(scope, consoleRefs.get(frameId) ?? 0, source), { + await request.query(consoleQuery(scope, consoleRefs.get(frameId) ?? 0, source, selfHandle), { budget: context === 'watch', signal, passHalts: true, @@ -1186,8 +1292,9 @@ export function registerBreakpointDebugger(): vscode.Disposable { answer, handleFor: (containerRef, name) => handles.get(handleKey(containerRef, name)), query: (code) => request.query(code), - evaluate: (source, frameId, context, signal) => - evaluateAt(request, view, consoleRefs, source, frameId, context, signal), + evaluate: (source, frameId, context, signal, selfHandle) => + evaluateAt(request, view, consoleRefs, source, frameId, context, signal, selfHandle), + showRow: (handle, name) => showRowAt(request, handle, name), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, reason: request.reason, diff --git a/src/extension.ts b/src/extension.ts index 179e580..64798d1 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,6 +34,7 @@ import { import { collectGarbageCommand, startMaintenance, stopSessionCommand } from './maintenance'; import { isMcpRunning, startMcpServer, stopMcpServer } from './mcp'; import { registerBreakpointDebugger } from './debugger'; +import { registerVariableCommands } from './variableCommands'; import { registerSavedObjects } from './savedObjects'; import { onSavedStacksChanged } from './savedStacks'; import { cloneBrainFreeze, initPendingReadme, showPromisedReadme } from './demo'; @@ -192,6 +193,7 @@ export function activate(context: vscode.ExtensionContext): void { // paused cell — no Debug command, no launch configuration: the user just // runs the code. Continue resumes the cell; Stop ends it. context.subscriptions.push(registerBreakpointDebugger()); + context.subscriptions.push(registerVariableCommands()); // Every command that changes state refreshes the view afterwards, so the // status readout can never disagree with what just happened. diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index 241e7bc..0858e2d 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -679,8 +679,18 @@ export type ConsoleReply = { consoleRef: number; output: string } & ( * break — the watch's time budget, or the user cancelling — lands as a * ControlInterrupt and is answered as interrupted, not as an error in the * user's code. + * + * `selfHandle`, when given, is a registry entry to run with as `self` — a + * Variables row's object, for **Evaluate with self…**. It is bound in a copy + * of the console scope, used for this input alone, so the frame's own `self` + * and the names the console has bound are left as they were. */ -export function consoleQuery(scope: ConsoleScope, consoleRef: number, source: string): string { +export function consoleQuery( + scope: ConsoleScope, + consoleRef: number, + source: string, + selfHandle = 0, +): string { const findModule = scope.moduleFile ? `g := [dispatcher evaluateSource: '${escapeString( [ @@ -697,7 +707,7 @@ export function consoleQuery(scope: ConsoleScope, consoleRef: number, source: st (g notNil and: [g ~~ none and: [g respondsTo: #keysAndValuesDo:]]) ifTrue: [g keysAndValuesDo: [:k :x | bind value: k value: x]].` : ''; - return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst | + return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst target | ${PREAMBLE} ${HELPERS} ${CHILDREN_OF} @@ -725,12 +735,21 @@ console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil] console := { #gemdbConsole. dict }. reg add: console. cref := reg size]. +target := console at: 2. +${ + selfHandle > 0 + ? `${ROW_OBJECT(selfHandle, 'g')} +dict := target copy. +bind value: #self value: g. +target := dict.` + : '' +} kind := #value. exc := nil. captured := WriteStream on: Unicode7 new. saved := SessionTemps current at: #'GrailConsole' otherwise: nil. SessionTemps current at: #'GrailConsole' put: (Array with: captured). -[r := [[dispatcher evaluateSource: '${escapeString(source)}' usingModuleScope: (console at: 2)] +[r := [[dispatcher evaluateSource: '${escapeString(source)}' usingModuleScope: target] on: AlmostOutOfStack do: [:e | kind := #stack. e return: nil]] on: AbstractException do: [:e | (e isKindOf: ControlInterrupt) @@ -779,6 +798,36 @@ kind == #error ifTrue: [| payload frames tname msg attr sx | out contents encodeAsUTF8`; } +/** + * Smalltalk that puts in `into` the object of registry entry `handle`, the + * one a Variables row was registered with, or answers an `Error: …` line when + * there is none — a group row (`class variables`) or a scope is not an object. + */ +function ROW_OBJECT(handle: number, into: string): string { + return `${into} := (${handle} between: 1 and: reg size) ifTrue: [reg at: ${handle}] ifFalse: [nil]. +(${into} isNil or: [${into} class == Array and: [${into} size >= 2 + and: [#(#gemdbScope #gemdbGroup #gemdbConsole) includes: (${into} at: 1)]]]) + ifTrue: [^ 'Error: that row is not an object of its own' encodeAsUTF8].`; +} + +/** + * Smalltalk that writes one row, named `name`, for the object of registry + * entry `handle` — what **Show in Debug Console** puts in the console — in + * `childrenQuery`'s format, or answers an `Error: …` line. + */ +export function describeRowQuery(handle: number, name: string): string { + return `| reg obj out field record none saved describe childrenOf reprOf late clean isLeaf | +${PREAMBLE} +reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. +reg isNil ifTrue: [^ 'Error: the pause is over' encodeAsUTF8]. +${ROW_OBJECT(handle, 'obj')} +${HELPERS} +${CHILDREN_OF} +${DESCRIBE} +${withQuietConsole(`describe value: '${escapeString(name)}' value: obj`)} +out contents encodeAsUTF8`; +} + /** Parse `consoleQuery`'s answer. */ export function parseConsoleReply(raw: string): ConsoleReply { if (raw.startsWith('Error: ')) { diff --git a/src/variableCommands.ts b/src/variableCommands.ts new file mode 100644 index 0000000..1d744f4 --- /dev/null +++ b/src/variableCommands.ts @@ -0,0 +1,118 @@ +import * as vscode from 'vscode'; +import { DEBUG_TYPE, pauseForDebugSession } from './debugger'; +import { errorMessage } from './log'; + +/** + * Two items on a Variables row's menu, each ending in the Debug Console: + * + * - **Show in Debug Console** puts the row's object there, as an entry that + * expands like the row, so the next line can be typed beside it without + * first clicking over to the console. + * - **Evaluate with self…** asks for an expression and runs it with `self` + * bound to the row's object — `len(self.history)` on any row, however deep + * — in the frame selected in Call Stack, as a console line runs. + * + * The row's object is reached the way **Add to Persisted Objects…** reaches + * it: by its registry handle (`Pause.handleFor`), not by a Python path, so a + * row nested deep in the tree needs no expression to name it. The work is the + * adapter's `gemdbShowRow` request (`PauseDebugAdapter.showRow`), sent through + * the debug session so the output lands in that session's console. + */ + +export const SHOW_IN_CONSOLE = 'gemdb.showInDebugConsole'; +export const EVALUATE_WITH_SELF = 'gemdb.evaluateWithSelf'; + +/** What VS Code hands a `debug/variables/context` command. */ +export interface VariableContext { + sessionId?: string; + container?: { variablesReference?: number }; + variable?: { name: string; value: string; variablesReference: number }; +} + +const NOT_PAUSED = + 'This works on a variable in Run and Debug, while a GemDB notebook cell or Debug Python File ' + + 'run is paused in the debugger.'; + +/** The paused session, and the right-clicked row's name and registry handle; undefined after saying why. */ +function rowTarget( + context: VariableContext, +): { session: vscode.DebugSession; name: string; handle: number } | undefined { + const session = vscode.debug.activeDebugSession; + const pause = pauseForDebugSession(context.sessionId ?? session?.id); + const variable = context.variable; + if ( + !session || + session.type !== DEBUG_TYPE || + (context.sessionId !== undefined && session.id !== context.sessionId) || + !pause || + !variable + ) { + void vscode.window.showErrorMessage(NOT_PAUSED); + return undefined; + } + const handle = + variable.variablesReference > 0 + ? variable.variablesReference + : pause.handleFor?.(context.container?.variablesReference ?? 0, variable.name); + if (!handle) { + void vscode.window.showErrorMessage(`"${variable.name}" is not an object of its own.`); + return undefined; + } + return { session, name: variable.name, handle }; +} + +/** The frame selected in Call Stack, when it is in this session. */ +function selectedFrame(session: vscode.DebugSession): number | undefined { + const item = vscode.debug.activeStackItem; + return item instanceof vscode.DebugStackFrame && item.session.id === session.id + ? item.frameId + : undefined; +} + +async function showRow(session: vscode.DebugSession, args: Record): Promise { + // The console first, so a line that takes a while is seen to be running. + await vscode.commands.executeCommand('workbench.debug.action.focusRepl'); + try { + await session.customRequest('gemdbShowRow', args); + } catch (e) { + void vscode.window.showErrorMessage(errorMessage(e)); + } +} + +/** Show the right-clicked row's object in the Debug Console. */ +export async function showInDebugConsole(context: VariableContext): Promise { + const target = rowTarget(context); + if (!target) return; + await showRow(target.session, { handle: target.handle, name: target.name }); +} + +/** Ask for an expression and show what it gives with `self` bound to the right-clicked row's object. */ +export async function evaluateWithSelf(context: VariableContext): Promise { + const target = rowTarget(context); + if (!target) return; + const expression = await vscode.window.showInputBox({ + title: `Evaluate with self = ${target.name}`, + prompt: `Python, with self bound to ${target.name}. The result opens in the Debug Console.`, + value: 'self', + ignoreFocusOut: true, + }); + if (expression === undefined || !expression.trim()) return; + const frameId = selectedFrame(target.session); + await showRow(target.session, { + handle: target.handle, + name: target.name, + expression, + ...(frameId === undefined ? {} : { frameId }), + }); +} + +export function registerVariableCommands(): vscode.Disposable { + return vscode.Disposable.from( + vscode.commands.registerCommand(SHOW_IN_CONSOLE, (context: VariableContext) => + showInDebugConsole(context), + ), + vscode.commands.registerCommand(EVALUATE_WITH_SELF, (context: VariableContext) => + evaluateWithSelf(context), + ), + ); +} From 9a097da617702b0a64996221fd5165437a6b718a Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 16:19:52 -0700 Subject: [PATCH 03/15] Label a row shown in the Debug Console VS Code reads an output event's variablesReference as a list of values and shows each child with its name hidden, so Show in Debug Console on a dict printed its values unlabelled and no dict. Hand the console a one-row container instead, answered by the adapter: the row, labelled "entry: {...}", opening onto the object's own children. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/__integration__/debugConsole.test.ts | 14 ++++--- src/__tests__/debugConsole.test.ts | 35 ++++++++++++++--- src/debugger.ts | 48 ++++++++++++++++++++++-- 3 files changed, 83 insertions(+), 14 deletions(-) diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 561b245..a8c9294 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -830,8 +830,12 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' const other = (await dap.locals()).find((v) => v.name === 'other')!; const shown = await outputs({ handle: other.variablesReference, name: 'other' }); seen.shown = shown; - const ref = shown.out[0]?.variablesReference ?? 0; - seen.kids = (await dap.children(ref)).map((v) => `${v.name}=${v.value}`).sort(); + // One labelled row, which opens onto the object's own attributes. + const [entry] = await dap.children(shown.out[0]?.variablesReference ?? 0); + seen.entry = entry?.value; + seen.kids = (await dap.children(entry?.variablesReference ?? 0)) + .map((v) => `${v.name}=${v.value}`) + .sort(); seen.withSelf = await outputs({ handle: other.variablesReference, name: 'other', @@ -853,10 +857,8 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' ); closeSessionFor(nb.key); - expect(seen.shown).toMatchObject({ - ok: true, - out: [{ category: 'console', output: expect.stringMatching(/^other: .*Box/) }], - }); + expect(seen.shown).toMatchObject({ ok: true, out: [{ category: 'console', output: '' }] }); + expect(seen.entry).toMatch(/^other: .*Box/); expect(seen.kids).toEqual(['items=[5, 5]', 'x=5']); expect(seen.withSelf).toMatchObject({ ok: true, diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index 54d0ee7..a3ee3ad 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -468,7 +468,21 @@ describe('a saved stack', () => { describe('putting a Variables row in the Debug Console', () => { const showRow = (answer: ConsoleAnswer) => vi.fn(() => Promise.resolve(answer)); - it('shows the row’s object as an entry that expands like the row', async () => { + it('shows a leaf as a plain line, name and value', async () => { + const { request, events } = started(undefined, undefined, { + showRow: showRow(value('10')), + evaluate: vi.fn(), + }); + + request('gemdbShowRow', { handle: 3, name: 'limit' }); + await settle(); + + expect(events('output').map((e) => e.body)).toEqual([ + { category: 'console', output: 'limit: 10\n' }, + ]); + }); + + it('shows the row’s object as one labelled entry that expands like the row', async () => { const shown = showRow(value("Account('ada', 10)", { variablesReference: 7 })); const { request, responseTo, events } = started(undefined, undefined, { showRow: shown, @@ -479,9 +493,20 @@ describe('putting a Variables row in the Debug Console', () => { await settle(); expect(shown).toHaveBeenCalledWith(7, 'p'); - expect(events('output').map((e) => e.body)).toEqual([ - { category: 'console', output: "p: Account('ada', 10)\n", variablesReference: 7 }, - ]); + // VS Code lists an output's reference as values with their names hidden, + // so the console gets a one-row container: the row, labelled, opening onto + // the object's own children. + const [out] = events('output').map( + (e) => e.body as { output: string; variablesReference: number }, + ); + expect(out).toMatchObject({ category: 'console', output: '' }); + const listed = request('variables', { variablesReference: out.variablesReference }); + await settle(); + expect(responseTo(listed)?.body).toEqual({ + variables: [ + { name: "p: Account('ada', 10)", value: "p: Account('ada', 10)", variablesReference: 7 }, + ], + }); expect(responseTo(seq)?.success).toBe(true); // Showing changes nothing, so Variables is left alone. expect(events('invalidated')).toEqual([]); @@ -498,7 +523,7 @@ describe('putting a Variables row in the Debug Console', () => { expect(events('output').map((e) => e.body)).toEqual([ { category: 'console', output: '>>> len(self.history) # self is p\n' }, { category: 'stdout', output: 'hi\n' }, - { category: 'console', output: '2\n', variablesReference: 0 }, + { category: 'console', output: '2\n' }, ]); expect(events('invalidated')).toHaveLength(1); }); diff --git a/src/debugger.ts b/src/debugger.ts index 4c58ad5..8c8870b 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -523,6 +523,12 @@ const WATCH_TOO_LONG = * the toolbar while the program is paused. */ export const OFFER_STOP_AFTER_MS = 4_000; + +/** + * Where the references for `PauseDebugAdapter.wrap`'s containers start: far + * above any registry position, which is what every other reference is. + */ +const SHOWN_REFS = 1_000_000_000; const NOT_YET_RESTART = "Restarting isn't supported yet. Use Continue to resume or Stop to end the run."; @@ -561,6 +567,12 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { private clientInvalidates = false; /** Console lines still running, by request seq, so `cancel` can stop one. */ private readonly running = new Map(); + /** + * The one-row containers `showRow` hands the console, by their reference + * (`wrap`), each answered here rather than by the gem. + */ + private readonly shown = new Map(); + private nextShown = SHOWN_REFS; constructor( private readonly lookup: (id: string) => Pause | undefined, @@ -661,6 +673,11 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { // capping here never hides a row. const count = Number(request.arguments?.count ?? 0) || PAGE; const pause = this.pause; + const shown = this.shown.get(ref); + if (shown) { + this.respond(request, { variables: start === 0 ? [shown] : [] }); + return; + } if (!pause) { this.respond(request, { variables: [] }); return; @@ -891,12 +908,15 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { finish(); if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); if (answer.kind === 'value') { - if (answer.value !== '' || answer.variablesReference > 0) { + const text = `${expression === undefined ? `${name}: ` : ''}${answer.value}`; + if (answer.variablesReference > 0) { this.event('output', { category: 'console', - output: `${expression === undefined ? `${name}: ` : ''}${answer.value}\n`, - variablesReference: answer.variablesReference, + output: '', + variablesReference: this.wrap(text, answer), }); + } else if (answer.value !== '') { + this.event('output', { category: 'console', output: `${text}\n` }); } } else { this.event('output', { @@ -916,6 +936,28 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { ); } + /** + * A reference for the console whose one child is `answer`'s value, shown as + * `text`. An `output` event's `variablesReference` is read by VS Code as a + * list of values, as a JavaScript debugger's console.log arguments are: the + * console shows each child of it with its name hidden (measured: a dict's + * reference showed its three values, unlabelled, and no dict). So the + * console is handed this container instead, and shows the one row, labelled + * by its text and opening onto the object's own children. + */ + private wrap(text: string, answer: Extract): number { + const ref = this.nextShown++; + this.shown.set(ref, { + name: text, + value: text, + ...(answer.type ? { type: answer.type } : {}), + variablesReference: answer.variablesReference, + ...(answer.indexedVariables ? { indexedVariables: answer.indexedVariables } : {}), + ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), + }); + return ref; + } + dispose(): void { for (const controller of this.running.values()) controller.abort(); // The session went away without Continue or Stop — a closed window, say. From 8bf0fc2a9b32ed9cd0101f4477996c8e131ff22b Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 16:22:49 -0700 Subject: [PATCH 04/15] List a shown row's attributes in the Debug Console Show in Debug Console, and Evaluate with self... on a result with children, now write a line naming the object and then its attributes beneath, each by name, so the one thing looked for needs no click: all of them for an object or dict with up to 20, else the first five, and a list, tuple or set's first five, then a "... N more" row that opens onto the whole object. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 +- README.md | 3 +- src/__integration__/debugConsole.test.ts | 36 ++++--- src/__tests__/debugConsole.test.ts | 119 ++++++++++++++++++----- src/debugger.ts | 106 ++++++++++++-------- src/variableCommands.ts | 6 +- 6 files changed, 197 insertions(+), 78 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04104de..fc956ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,8 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 classmethod's frame, and rebinding a local (`n = 99`) changes the console's copy, not the paused function. - **Show in Debug Console and Evaluate with self… on a Variables row.** Right-click any row, however - deep in the tree, and **Show in Debug Console** opens the console with that object in it, - expandable like the row. **Evaluate with self…** asks for an expression and shows what it gives + deep in the tree, and **Show in Debug Console** opens the console with that object in it and its + attributes listed beneath, each by name; a list, or an object with more than 20, shows its first + five and a row that opens onto the rest. **Evaluate with self…** asks for an expression and shows what it gives with `self` bound to the row's object, in the frame selected in Call Stack. ## [1.6.0] - 2026-10-07 diff --git a/README.md b/README.md index 62ced22..575c51f 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,8 @@ database, select that kernel from the kernel picker at the top right. there last until the run resumes; an error reads as a notebook traceback; a long-running line can be stopped from the notification that appears. A change to an object made there is in the notebook's transaction, as it would be from a cell. Right-click a Variables row for **Show in - Debug Console**, which puts that object in the console, expandable, or **Evaluate with self…**, + Debug Console**, which puts that object in the console with its attributes listed (a list's + first few items), or **Evaluate with self…**, which runs an expression with `self` bound to it (`len(self.history)`) and shows the answer there. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index a8c9294..9862ecb 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -809,9 +809,9 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' ' def __init__(self, n):', ' self.x = n', ' self.items = [n, n]', - ' def look(self, other):', + ' def look(self, other, big):', ' breakpoint()', - 'Box(1).look(Box(5))', + 'Box(1).look(Box(5), list(range(1000)))', ].join('\n'), async (dap) => { const outputs = async (args: Record) => { @@ -830,12 +830,19 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' const other = (await dap.locals()).find((v) => v.name === 'other')!; const shown = await outputs({ handle: other.variablesReference, name: 'other' }); seen.shown = shown; - // One labelled row, which opens onto the object's own attributes. - const [entry] = await dap.children(shown.out[0]?.variablesReference ?? 0); - seen.entry = entry?.value; - seen.kids = (await dap.children(entry?.variablesReference ?? 0)) - .map((v) => `${v.name}=${v.value}`) - .sort(); + // A line naming it, then its attributes listed beneath, each with its name. + const listing = shown.out.find((o) => o.variablesReference)?.variablesReference ?? 0; + const listed = await dap.children(listing); + seen.kids = listed.map((v) => v.value).sort(); + const items = listed.find((v) => v.value.startsWith('items'))!; + seen.items = (await dap.children(items.variablesReference)).map((v) => v.value); + const big = await outputs({ + handle: (await dap.locals()).find((v) => v.name === 'big')!.variablesReference, + name: 'big', + }); + seen.big = ( + await dap.children(big.out.find((o) => o.variablesReference)!.variablesReference!) + ).map((v) => v.value); seen.withSelf = await outputs({ handle: other.variablesReference, name: 'other', @@ -857,9 +864,16 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' ); closeSessionFor(nb.key); - expect(seen.shown).toMatchObject({ ok: true, out: [{ category: 'console', output: '' }] }); - expect(seen.entry).toMatch(/^other: .*Box/); - expect(seen.kids).toEqual(['items=[5, 5]', 'x=5']); + expect(seen.shown).toMatchObject({ + ok: true, + out: [ + { category: 'console', output: expect.stringMatching(/^other: .*Box/) }, + { category: 'console', output: '' }, + ], + }); + expect(seen.kids).toEqual(['items: [5, 5]', 'x: 5']); + expect(seen.items).toEqual(['5', '5']); + expect(seen.big).toEqual(['[0]: 0', '[1]: 1', '[2]: 2', '[3]: 3', '[4]: 4', '… 995 more']); expect(seen.withSelf).toMatchObject({ ok: true, out: [ diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index a3ee3ad..aff6072 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -482,34 +482,109 @@ describe('putting a Variables row in the Debug Console', () => { ]); }); - it('shows the row’s object as one labelled entry that expands like the row', async () => { - const shown = showRow(value("Account('ada', 10)", { variablesReference: 7 })); - const { request, responseTo, events } = started(undefined, undefined, { - showRow: shown, + /** `count` children of a container, as `pause.variables` reads them: `[0]`, `[1]`, … */ + const items = (n: number, start = 0) => + Array.from({ length: n }, (_, i) => ({ + name: `[${start + i}]`, + value: String(start + i), + variablesReference: 0, + })); + + /** Show a row and answer what the console then lists under it. */ + async function shownUnder(answer: ConsoleAnswer, variables: Pause['variables']) { + const c = started(undefined, undefined, { + showRow: showRow(answer), evaluate: vi.fn(), + variables, }); - - const seq = request('gemdbShowRow', { handle: 7, name: 'p' }); + const seq = c.request('gemdbShowRow', { handle: 7, name: 'p' }); await settle(); + const outputs = c + .events('output') + .map((e) => e.body as { category: string; output: string; variablesReference?: number }); + const listing = outputs.find((o) => o.variablesReference); + const listed = c.request('variables', { variablesReference: listing?.variablesReference }); + await settle(); + return { + ok: c.responseTo(seq)?.success, + outputs, + rows: ( + c.responseTo(listed)?.body as { + variables: Array<{ + value: string; + variablesReference: number; + indexedVariables?: number; + }>; + } + ).variables, + invalidated: c.events('invalidated').length, + }; + } + + it('names the object, then lists its attributes beneath, each with its name', async () => { + const variables = vi.fn().mockResolvedValue([ + { name: 'owner', value: "'ada'", variablesReference: 0 }, + { name: 'history', value: '[10]', type: 'list', variablesReference: 9, indexedVariables: 1 }, + ]); - expect(shown).toHaveBeenCalledWith(7, 'p'); - // VS Code lists an output's reference as values with their names hidden, - // so the console gets a one-row container: the row, labelled, opening onto - // the object's own children. - const [out] = events('output').map( - (e) => e.body as { output: string; variablesReference: number }, + const shown = await shownUnder( + value("Account('ada', 10)", { variablesReference: 7, namedVariables: 2 }), + variables, ); - expect(out).toMatchObject({ category: 'console', output: '' }); - const listed = request('variables', { variablesReference: out.variablesReference }); - await settle(); - expect(responseTo(listed)?.body).toEqual({ - variables: [ - { name: "p: Account('ada', 10)", value: "p: Account('ada', 10)", variablesReference: 7 }, - ], - }); - expect(responseTo(seq)?.success).toBe(true); + + expect(variables).toHaveBeenCalledWith(7, 0, 2); + expect(shown.outputs[0]).toEqual({ category: 'console', output: "p: Account('ada', 10)\n" }); + // VS Code shows an output's reference as values with their names hidden, + // so each name is in its row's text; a row with children still opens. + expect(shown.rows).toEqual([ + { name: "owner: 'ada'", value: "owner: 'ada'", variablesReference: 0 }, + { + name: 'history: [10]', + value: 'history: [10]', + type: 'list', + variablesReference: 9, + indexedVariables: 1, + }, + ]); + expect(shown.ok).toBe(true); // Showing changes nothing, so Variables is left alone. - expect(events('invalidated')).toEqual([]); + expect(shown.invalidated).toBe(0); + }); + + it('lists a list’s first few items, then a row that opens onto all of it', async () => { + const variables = vi.fn((_ref, start, count) => + Promise.resolve(items(count, start)), + ); + + const shown = await shownUnder( + value('list with 1000 items', { variablesReference: 4, indexedVariables: 1000 }), + variables, + ); + + expect(variables).toHaveBeenCalledWith(4, 0, 5); + expect(shown.rows.map((r) => r.value)).toEqual([ + '[0]: 0', + '[1]: 1', + '[2]: 2', + '[3]: 3', + '[4]: 4', + '… 995 more', + ]); + expect(shown.rows[5]).toMatchObject({ variablesReference: 4, indexedVariables: 1000 }); + }); + + it('lists only the first few of an object with many attributes', async () => { + const variables = vi.fn((_ref, start, count) => + Promise.resolve(items(count, start)), + ); + + const shown = await shownUnder( + value('Wide()', { variablesReference: 4, namedVariables: 30 }), + variables, + ); + + expect(variables).toHaveBeenCalledWith(4, 0, 5); + expect(shown.rows.map((r) => r.value).pop()).toBe('… 25 more'); }); it('runs an expression with self bound to the row, echoing it, and refreshes Variables', async () => { diff --git a/src/debugger.ts b/src/debugger.ts index 8c8870b..e7b0c24 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -525,10 +525,16 @@ const WATCH_TOO_LONG = export const OFFER_STOP_AFTER_MS = 4_000; /** - * Where the references for `PauseDebugAdapter.wrap`'s containers start: far - * above any registry position, which is what every other reference is. + * Where the references for `PauseDebugAdapter.preview`'s containers start: + * far above any registry position, which is what every other reference is. */ const SHOWN_REFS = 1_000_000_000; + +/** An object or dict with up to this many children is listed whole in the console. */ +const SHOW_ALL = 20; + +/** Otherwise, and for a list, tuple or set always, the console lists this many first. */ +const SHOW_FEW = 5; const NOT_YET_RESTART = "Restarting isn't supported yet. Use Continue to resume or Stop to end the run."; @@ -568,10 +574,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { /** Console lines still running, by request seq, so `cancel` can stop one. */ private readonly running = new Map(); /** - * The one-row containers `showRow` hands the console, by their reference - * (`wrap`), each answered here rather than by the gem. + * The containers `showRow` hands the console, by their reference + * (`preview`), each answered here rather than by the gem. */ - private readonly shown = new Map(); + private readonly shown = new Map(); private nextShown = SHOWN_REFS; constructor( @@ -675,7 +681,7 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { const pause = this.pause; const shown = this.shown.get(ref); if (shown) { - this.respond(request, { variables: start === 0 ? [shown] : [] }); + this.respond(request, { variables: shown.slice(start, start + count) }); return; } if (!pause) { @@ -872,11 +878,12 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { /** * Put a Variables row in the Debug Console (`gemdbShowRow`), for the - * row's menu (`variableCommands.ts`): its object, as an entry that expands - * like the row, or with `expression`, what that gives with `self` bound to - * the object, run in the frame `frameId` as a console line is. Shown as - * `output` events, since there is no console request to answer: an echo of - * the expression, what it printed, then the value, a traceback, or + * row's menu (`variableCommands.ts`): its object — a line naming it, then + * its children (`preview`) — or with `expression`, what that gives with + * `self` bound to the object, run in the frame `frameId` as a console line + * is, shown the same way. Shown as `output` events, since there is no + * console request to answer: an echo of the expression, what it printed, + * then the value and its children, a traceback, or * "Interrupted.". */ private showRow(request: DapRequest): void { @@ -903,20 +910,20 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { expression === undefined ? pause.showRow(handle, name) : pause.evaluate(expression, frameId, 'repl', controller.signal, handle); - answered.then( - (answer) => { - finish(); + answered + .then(async (answer) => { if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); if (answer.kind === 'value') { const text = `${expression === undefined ? `${name}: ` : ''}${answer.value}`; + if (answer.value !== '' || answer.variablesReference > 0) { + this.event('output', { category: 'console', output: `${text}\n` }); + } if (answer.variablesReference > 0) { this.event('output', { category: 'console', output: '', - variablesReference: this.wrap(text, answer), + variablesReference: await this.preview(pause, answer), }); - } else if (answer.value !== '') { - this.event('output', { category: 'console', output: `${text}\n` }); } } else { this.event('output', { @@ -928,33 +935,52 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { this.event('invalidated', { areas: ['variables'] }); } this.respond(request); - }, - (e: unknown) => { - finish(); - this.fail(request, errorMessage(e), true); - }, - ); + }) + .catch((e: unknown) => this.fail(request, errorMessage(e), true)) + .finally(finish); } /** - * A reference for the console whose one child is `answer`'s value, shown as - * `text`. An `output` event's `variablesReference` is read by VS Code as a - * list of values, as a JavaScript debugger's console.log arguments are: the - * console shows each child of it with its name hidden (measured: a dict's - * reference showed its three values, unlabelled, and no dict). So the - * console is handed this container instead, and shows the one row, labelled - * by its text and opening onto the object's own children. + * A reference for the console listing an object's children under the line + * that names it: every attribute or entry of an object or dict with up to + * `SHOW_ALL`, else the first `SHOW_FEW` — and of a list, tuple or set always + * the first `SHOW_FEW` — then a `… N more` row that opens onto the whole + * object. Each row carries its name in its text (`owner: 'grace'`, `[0]: 1`) + * and opens as the Variables row would. + * + * The names are in the text because an `output` event's + * `variablesReference` is read by VS Code as a list of values, as a + * JavaScript debugger's console.log arguments are: it shows each child of + * the reference at the top level with its name hidden (measured: a dict's + * own reference showed its three values unlabelled). So the console is + * handed a container of the adapter's own (`shown`), answered here rather + * than by the gem. */ - private wrap(text: string, answer: Extract): number { + private async preview( + pause: Pause, + answer: Extract, + ): Promise { + const indexed = answer.indexedVariables ?? 0; + const total = indexed + (answer.namedVariables ?? 0); + const count = indexed > 0 || total > SHOW_ALL ? SHOW_FEW : total || SHOW_ALL; + const kids = await pause.variables(answer.variablesReference, 0, count); + const rows: DapVariable[] = kids.map((kid) => ({ + ...kid, + name: `${kid.name}: ${kid.value}`, + value: `${kid.name}: ${kid.value}`, + })); + if (total > kids.length) { + const more = `… ${total - kids.length} more`; + rows.push({ + name: more, + value: more, + variablesReference: answer.variablesReference, + ...(indexed ? { indexedVariables: indexed } : {}), + ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), + }); + } const ref = this.nextShown++; - this.shown.set(ref, { - name: text, - value: text, - ...(answer.type ? { type: answer.type } : {}), - variablesReference: answer.variablesReference, - ...(answer.indexedVariables ? { indexedVariables: answer.indexedVariables } : {}), - ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), - }); + this.shown.set(ref, rows); return ref; } @@ -1193,6 +1219,8 @@ async function showRowAt( value: dap.value, ...(dap.type ? { type: dap.type } : {}), variablesReference: dap.variablesReference, + ...(dap.indexedVariables ? { indexedVariables: dap.indexedVariables } : {}), + ...(dap.namedVariables ? { namedVariables: dap.namedVariables } : {}), }; } diff --git a/src/variableCommands.ts b/src/variableCommands.ts index 1d744f4..b26f0f8 100644 --- a/src/variableCommands.ts +++ b/src/variableCommands.ts @@ -5,9 +5,9 @@ import { errorMessage } from './log'; /** * Two items on a Variables row's menu, each ending in the Debug Console: * - * - **Show in Debug Console** puts the row's object there, as an entry that - * expands like the row, so the next line can be typed beside it without - * first clicking over to the console. + * - **Show in Debug Console** puts the row's object there — a line naming + * it, then its attributes, or a list's first items — so the next line can + * be typed beside it without first clicking over to the console. * - **Evaluate with self…** asks for an expression and runs it with `self` * bound to the row's object — `len(self.history)` on any row, however deep * — in the frame selected in Call Stack, as a console line runs. From e9cf74807168f369e9c21804d9ecc14ef93018b4 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 16:29:11 -0700 Subject: [PATCH 05/15] Show a container in the Debug Console as one row that opens A list, set or dict shown in the console printed its first five items above a "... N more" row, which opened onto VS Code's own [0..99] pages: one list read three ways, in four colours. Show a container, or an object with more than 20 attributes, as one closed row that opens like the Variables row, and keep listing a smaller object's attributes. Everything shown now goes out through the adapter's own container, so a plain value, a name line and an attribute all read alike. Each row now also says how its children are read, so a dict is told from an object. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +- README.md | 4 +- src/__integration__/debugConsole.test.ts | 64 ++++---- src/__tests__/debugConsole.test.ts | 196 ++++++++++++----------- src/debugger.ts | 91 ++++++----- src/pauseVariables.ts | 17 +- src/variableCommands.ts | 6 +- 7 files changed, 212 insertions(+), 172 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc956ea..a6e6b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,9 +20,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 classmethod's frame, and rebinding a local (`n = 99`) changes the console's copy, not the paused function. - **Show in Debug Console and Evaluate with self… on a Variables row.** Right-click any row, however - deep in the tree, and **Show in Debug Console** opens the console with that object in it and its - attributes listed beneath, each by name; a list, or an object with more than 20, shows its first - five and a row that opens onto the rest. **Evaluate with self…** asks for an expression and shows what it gives + deep in the tree, and **Show in Debug Console** opens the console with that object in it: an + object with its attributes listed beneath, each by name; a list, set or dict, or an object with + more than 20 attributes, as one row that opens like the Variables row. **Evaluate with self…** asks for an expression and shows what it gives with `self` bound to the row's object, in the frame selected in Call Stack. ## [1.6.0] - 2026-10-07 diff --git a/README.md b/README.md index 575c51f..8052d20 100644 --- a/README.md +++ b/README.md @@ -421,8 +421,8 @@ database, select that kernel from the kernel picker at the top right. there last until the run resumes; an error reads as a notebook traceback; a long-running line can be stopped from the notification that appears. A change to an object made there is in the notebook's transaction, as it would be from a cell. Right-click a Variables row for **Show in - Debug Console**, which puts that object in the console with its attributes listed (a list's - first few items), or **Evaluate with self…**, + Debug Console**, which puts that object in the console (an object with its attributes + listed, a list or dict as one row that opens), or **Evaluate with self…**, which runs an expression with `self` bound to it (`len(self.history)`) and shows the answer there. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 9862ecb..ec24be1 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -830,25 +830,37 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' const other = (await dap.locals()).find((v) => v.name === 'other')!; const shown = await outputs({ handle: other.variablesReference, name: 'other' }); seen.shown = shown; - // A line naming it, then its attributes listed beneath, each with its name. - const listing = shown.out.find((o) => o.variablesReference)?.variablesReference ?? 0; - const listed = await dap.children(listing); - seen.kids = listed.map((v) => v.value).sort(); - const items = listed.find((v) => v.value.startsWith('items'))!; + // Everything shown is listed from one reference, so it all reads alike. + const listed = async (shownRows: { out: Array<{ variablesReference?: number }> }) => + dap.children(shownRows.out.find((o) => o.variablesReference)?.variablesReference ?? 0); + // An object: a line naming it, then its attributes, each with its name. + const rows = await listed(shown); + seen.rows = rows.map((v) => v.value); + const items = rows.find((v) => v.value.startsWith('items'))!; seen.items = (await dap.children(items.variablesReference)).map((v) => v.value); - const big = await outputs({ - handle: (await dap.locals()).find((v) => v.name === 'big')!.variablesReference, - name: 'big', - }); - seen.big = ( - await dap.children(big.out.find((o) => o.variablesReference)!.variablesReference!) - ).map((v) => v.value); - seen.withSelf = await outputs({ + // A list: one closed row, which opens like the Variables row. + const big = await listed( + await outputs({ + handle: (await dap.locals()).find((v) => v.name === 'big')!.variablesReference, + name: 'big', + }), + ); + seen.big = big.map((v) => v.value); + seen.bigOpened = ( + await dap.request('variables', { + variablesReference: big[0].variablesReference, + start: 998, + count: 2, + }) + ).body?.variables; + const withSelf = await outputs({ handle: other.variablesReference, name: 'other', expression: 'self.x * 10', frameId: 1, }); + seen.echo = withSelf.out[0]; + seen.withSelf = (await listed(withSelf)).map((v) => v.value); // The frame's own self is still the Box it was: the binding was for that line alone. seen.frameSelf = await dap.value('self.x'); const scopes = (await dap.request('scopes', { frameId: 2 })).body?.scopes as Array<{ @@ -864,23 +876,19 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' ); closeSessionFor(nb.key); - expect(seen.shown).toMatchObject({ - ok: true, - out: [ - { category: 'console', output: expect.stringMatching(/^other: .*Box/) }, - { category: 'console', output: '' }, - ], - }); - expect(seen.kids).toEqual(['items: [5, 5]', 'x: 5']); + expect(seen.shown).toMatchObject({ ok: true, out: [{ category: 'console', output: '' }] }); + expect(seen.rows).toEqual([expect.stringMatching(/^other: .*Box/), 'x: 5', 'items: [5, 5]']); expect(seen.items).toEqual(['5', '5']); - expect(seen.big).toEqual(['[0]: 0', '[1]: 1', '[2]: 2', '[3]: 3', '[4]: 4', '… 995 more']); - expect(seen.withSelf).toMatchObject({ - ok: true, - out: [ - { category: 'console', output: '>>> self.x * 10 # self is other\n' }, - { category: 'console', output: '50\n' }, - ], + expect(seen.big).toEqual(['big: list with 1000 items']); + expect(seen.bigOpened).toEqual([ + expect.objectContaining({ name: '[998]', value: '998' }), + expect.objectContaining({ name: '[999]', value: '999' }), + ]); + expect(seen.echo).toEqual({ + category: 'console', + output: '>>> self.x * 10 # self is other\n', }); + expect(seen.withSelf).toEqual(['50']); expect(seen.frameSelf).toBe('1'); expect(seen.group).toMatchObject({ ok: true, diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index aff6072..c79a27e 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -468,75 +468,75 @@ describe('a saved stack', () => { describe('putting a Variables row in the Debug Console', () => { const showRow = (answer: ConsoleAnswer) => vi.fn(() => Promise.resolve(answer)); - it('shows a leaf as a plain line, name and value', async () => { - const { request, events } = started(undefined, undefined, { - showRow: showRow(value('10')), - evaluate: vi.fn(), - }); - - request('gemdbShowRow', { handle: 3, name: 'limit' }); - await settle(); - - expect(events('output').map((e) => e.body)).toEqual([ - { category: 'console', output: 'limit: 10\n' }, - ]); - }); - - /** `count` children of a container, as `pause.variables` reads them: `[0]`, `[1]`, … */ - const items = (n: number, start = 0) => - Array.from({ length: n }, (_, i) => ({ - name: `[${start + i}]`, - value: String(start + i), - variablesReference: 0, - })); - - /** Show a row and answer what the console then lists under it. */ - async function shownUnder(answer: ConsoleAnswer, variables: Pause['variables']) { - const c = started(undefined, undefined, { + type Listed = { value: string; variablesReference: number; indexedVariables?: number }; + + /** + * Show a row (or evaluate with self, given `expression`) and answer what was + * sent: the output events, and the rows the console lists from the one + * that carries a reference. + */ + async function shown( + answer: ConsoleAnswer, + { variables, expression }: { variables?: Pause['variables']; expression?: string } = {}, + ) { + const evaluate = vi.fn().mockResolvedValue(answer); + const c = started(evaluate, undefined, { showRow: showRow(answer), - evaluate: vi.fn(), - variables, + ...(variables ? { variables } : {}), + }); + const seq = c.request('gemdbShowRow', { + handle: 7, + name: 'p', + ...(expression === undefined ? {} : { expression, frameId: 2 }), }); - const seq = c.request('gemdbShowRow', { handle: 7, name: 'p' }); await settle(); const outputs = c .events('output') .map((e) => e.body as { category: string; output: string; variablesReference?: number }); const listing = outputs.find((o) => o.variablesReference); - const listed = c.request('variables', { variablesReference: listing?.variablesReference }); - await settle(); + let rows: Listed[] = []; + if (listing) { + const listed = c.request('variables', { variablesReference: listing.variablesReference }); + await settle(); + rows = (c.responseTo(listed)?.body as { variables: Listed[] }).variables; + } return { ok: c.responseTo(seq)?.success, outputs, - rows: ( - c.responseTo(listed)?.body as { - variables: Array<{ - value: string; - variablesReference: number; - indexedVariables?: number; - }>; - } - ).variables, + rows, + evaluate, invalidated: c.events('invalidated').length, }; } - it('names the object, then lists its attributes beneath, each with its name', async () => { + it('shows a value as one line, name and value', async () => { + const result = await shown(value('10')); + + expect(result.rows).toEqual([{ name: 'p: 10', value: 'p: 10', variablesReference: 0 }]); + // Every line goes out the same way, so it all reads alike in the console. + expect(result.outputs).toEqual([ + { category: 'console', output: '', variablesReference: expect.any(Number) }, + ]); + expect(result.ok).toBe(true); + // Showing changes nothing, so Variables is left alone. + expect(result.invalidated).toBe(0); + }); + + it('names an object, then lists its attributes beneath, each with its name', async () => { const variables = vi.fn().mockResolvedValue([ { name: 'owner', value: "'ada'", variablesReference: 0 }, { name: 'history', value: '[10]', type: 'list', variablesReference: 9, indexedVariables: 1 }, ]); - const shown = await shownUnder( - value("Account('ada', 10)", { variablesReference: 7, namedVariables: 2 }), - variables, + const result = await shown( + value("Account('ada', 10)", { variablesReference: 7, namedVariables: 2, children: 'attrs' }), + { variables }, ); expect(variables).toHaveBeenCalledWith(7, 0, 2); - expect(shown.outputs[0]).toEqual({ category: 'console', output: "p: Account('ada', 10)\n" }); - // VS Code shows an output's reference as values with their names hidden, - // so each name is in its row's text; a row with children still opens. - expect(shown.rows).toEqual([ + // VS Code shows these with their names hidden, so each name is in its text. + expect(result.rows).toEqual([ + { name: "p: Account('ada', 10)", value: "p: Account('ada', 10)", variablesReference: 0 }, { name: "owner: 'ada'", value: "owner: 'ada'", variablesReference: 0 }, { name: 'history: [10]', @@ -546,74 +546,86 @@ describe('putting a Variables row in the Debug Console', () => { indexedVariables: 1, }, ]); - expect(shown.ok).toBe(true); - // Showing changes nothing, so Variables is left alone. - expect(shown.invalidated).toBe(0); }); - it('lists a list’s first few items, then a row that opens onto all of it', async () => { - const variables = vi.fn((_ref, start, count) => - Promise.resolve(items(count, start)), - ); + it('shows a list as one closed row that opens like the Variables row', async () => { + const variables = vi.fn(); - const shown = await shownUnder( - value('list with 1000 items', { variablesReference: 4, indexedVariables: 1000 }), - variables, + const result = await shown( + value('list with 1000 items', { + type: 'list', + variablesReference: 4, + indexedVariables: 1000, + children: 'seq', + }), + { variables }, ); - expect(variables).toHaveBeenCalledWith(4, 0, 5); - expect(shown.rows.map((r) => r.value)).toEqual([ - '[0]: 0', - '[1]: 1', - '[2]: 2', - '[3]: 3', - '[4]: 4', - '… 995 more', + expect(variables).not.toHaveBeenCalled(); + expect(result.rows).toEqual([ + { + name: 'p: list with 1000 items', + value: 'p: list with 1000 items', + type: 'list', + variablesReference: 4, + indexedVariables: 1000, + }, ]); - expect(shown.rows[5]).toMatchObject({ variablesReference: 4, indexedVariables: 1000 }); }); - it('lists only the first few of an object with many attributes', async () => { - const variables = vi.fn((_ref, start, count) => - Promise.resolve(items(count, start)), + it('shows a dict as one closed row too, however few its entries', async () => { + const variables = vi.fn(); + + const result = await shown( + value("{'a': 1}", { variablesReference: 4, namedVariables: 1, children: 'dict' }), + { variables }, ); - const shown = await shownUnder( - value('Wide()', { variablesReference: 4, namedVariables: 30 }), - variables, + expect(variables).not.toHaveBeenCalled(); + expect(result.rows).toEqual([ + expect.objectContaining({ value: "p: {'a': 1}", variablesReference: 4, namedVariables: 1 }), + ]); + }); + + it('shows an object with more than twenty attributes as one closed row', async () => { + const variables = vi.fn(); + + const result = await shown( + value('Wide()', { variablesReference: 4, namedVariables: 21, children: 'attrs' }), + { variables }, ); - expect(variables).toHaveBeenCalledWith(4, 0, 5); - expect(shown.rows.map((r) => r.value).pop()).toBe('… 25 more'); + expect(variables).not.toHaveBeenCalled(); + expect(result.rows).toHaveLength(1); }); it('runs an expression with self bound to the row, echoing it, and refreshes Variables', async () => { - const evaluate = vi.fn().mockResolvedValue(value('2', { output: 'hi\n' })); - const { request, events } = started(evaluate, undefined, { showRow: vi.fn() }); - - request('gemdbShowRow', { handle: 7, name: 'p', expression: 'len(self.history)', frameId: 2 }); - await settle(); + const result = await shown(value('2', { output: 'hi\n' }), { + expression: 'len(self.history)', + }); - expect(evaluate).toHaveBeenCalledWith('len(self.history)', 2, 'repl', expect.anything(), 7); - expect(events('output').map((e) => e.body)).toEqual([ + expect(result.evaluate).toHaveBeenCalledWith( + 'len(self.history)', + 2, + 'repl', + expect.anything(), + 7, + ); + expect(result.outputs.slice(0, 2)).toEqual([ { category: 'console', output: '>>> len(self.history) # self is p\n' }, { category: 'stdout', output: 'hi\n' }, - { category: 'console', output: '2\n' }, ]); - expect(events('invalidated')).toHaveLength(1); + expect(result.rows.map((r) => r.value)).toEqual(['2']); + expect(result.invalidated).toBe(1); }); it('shows an error’s traceback in the console', async () => { - const evaluate = vi - .fn() - .mockResolvedValue({ kind: 'error', output: '', traceback: 'TB' } as ConsoleAnswer); - const { request, events, responseTo } = started(evaluate, undefined, { showRow: vi.fn() }); - - const seq = request('gemdbShowRow', { handle: 7, name: 'p', expression: 'self.nope' }); - await settle(); + const result = await shown({ kind: 'error', output: '', traceback: 'TB' } as ConsoleAnswer, { + expression: 'self.nope', + }); - expect(events('output').pop()?.body).toEqual({ category: 'stderr', output: 'TB\n' }); - expect(responseTo(seq)?.success).toBe(true); + expect(result.outputs.pop()).toEqual({ category: 'stderr', output: 'TB\n' }); + expect(result.ok).toBe(true); }); it('refuses where the pause has no rows to show, such as a saved stack', async () => { diff --git a/src/debugger.ts b/src/debugger.ts index e7b0c24..8f2d21f 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -6,6 +6,7 @@ import { CellText, PythonFrame, firstLineOf, locateCell } from './haltStack'; import { errorMessage, log } from './log'; import { PlacedFrame, jupyterTraceback } from './consoleTraceback'; import { + ChildKind, ConsoleScope, MAIN_MODULE, PAGE, @@ -108,6 +109,8 @@ export type ConsoleAnswer = variablesReference: number; indexedVariables?: number; namedVariables?: number; + /** How its children are read: a container's items, or an object's attributes. */ + children?: ChildKind; } | { kind: 'error'; output: string; traceback: string } | { kind: 'interrupted'; output: string }; @@ -530,11 +533,8 @@ export const OFFER_STOP_AFTER_MS = 4_000; */ const SHOWN_REFS = 1_000_000_000; -/** An object or dict with up to this many children is listed whole in the console. */ +/** An object with up to this many attributes has them listed in the console; one with more opens like a container. */ const SHOW_ALL = 20; - -/** Otherwise, and for a list, tuple or set always, the console lists this many first. */ -const SHOW_FEW = 5; const NOT_YET_RESTART = "Restarting isn't supported yet. Use Continue to resume or Stop to end the run."; @@ -878,12 +878,12 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { /** * Put a Variables row in the Debug Console (`gemdbShowRow`), for the - * row's menu (`variableCommands.ts`): its object — a line naming it, then - * its children (`preview`) — or with `expression`, what that gives with + * row's menu (`variableCommands.ts`): its object (`preview`), or with + * `expression`, what that gives with * `self` bound to the object, run in the frame `frameId` as a console line * is, shown the same way. Shown as `output` events, since there is no * console request to answer: an echo of the expression, what it printed, - * then the value and its children, a traceback, or + * then the value, a traceback, or * "Interrupted.". */ private showRow(request: DapRequest): void { @@ -916,13 +916,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { if (answer.kind === 'value') { const text = `${expression === undefined ? `${name}: ` : ''}${answer.value}`; if (answer.value !== '' || answer.variablesReference > 0) { - this.event('output', { category: 'console', output: `${text}\n` }); - } - if (answer.variablesReference > 0) { this.event('output', { category: 'console', output: '', - variablesReference: await this.preview(pause, answer), + variablesReference: await this.preview(pause, text, answer), }); } } else { @@ -941,44 +938,54 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { } /** - * A reference for the console listing an object's children under the line - * that names it: every attribute or entry of an object or dict with up to - * `SHOW_ALL`, else the first `SHOW_FEW` — and of a list, tuple or set always - * the first `SHOW_FEW` — then a `… N more` row that opens onto the whole - * object. Each row carries its name in its text (`owner: 'grace'`, `[0]: 1`) - * and opens as the Variables row would. + * A reference for the console holding what `showRow` shows, every line of + * it, so all of it reads alike. A list, tuple, set or dict is one row, + * labelled `text` and closed, that opens like the Variables row; so is an + * object with more than `SHOW_ALL` attributes. An object with fewer is a + * row for `text` and then a row for each attribute, so the one looked for + * needs no click; each that has children opens. A leaf is the one row. * - * The names are in the text because an `output` event's + * The labels are in the text because an `output` event's * `variablesReference` is read by VS Code as a list of values, as a * JavaScript debugger's console.log arguments are: it shows each child of - * the reference at the top level with its name hidden (measured: a dict's - * own reference showed its three values unlabelled). So the console is - * handed a container of the adapter's own (`shown`), answered here rather - * than by the gem. + * the reference with its name hidden (measured: a dict's own reference + * showed its three values unlabelled). So the console is handed a container + * of the adapter's own (`shown`), answered here rather than by the gem. */ private async preview( pause: Pause, + text: string, answer: Extract, ): Promise { - const indexed = answer.indexedVariables ?? 0; - const total = indexed + (answer.namedVariables ?? 0); - const count = indexed > 0 || total > SHOW_ALL ? SHOW_FEW : total || SHOW_ALL; - const kids = await pause.variables(answer.variablesReference, 0, count); - const rows: DapVariable[] = kids.map((kid) => ({ - ...kid, - name: `${kid.name}: ${kid.value}`, - value: `${kid.name}: ${kid.value}`, - })); - if (total > kids.length) { - const more = `… ${total - kids.length} more`; - rows.push({ - name: more, - value: more, - variablesReference: answer.variablesReference, - ...(indexed ? { indexedVariables: indexed } : {}), - ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), - }); - } + const counts = { + ...(answer.indexedVariables ? { indexedVariables: answer.indexedVariables } : {}), + ...(answer.namedVariables ? { namedVariables: answer.namedVariables } : {}), + }; + const total = (answer.indexedVariables ?? 0) + (answer.namedVariables ?? 0); + const listed = + answer.variablesReference > 0 && + (answer.children === 'attrs' || answer.children === 'ivars') && + total <= SHOW_ALL; + const rows: DapVariable[] = listed + ? [ + { name: text, value: text, variablesReference: 0 }, + ...(await pause.variables(answer.variablesReference, 0, total || SHOW_ALL)).map( + (kid) => ({ + ...kid, + name: `${kid.name}: ${kid.value}`, + value: `${kid.name}: ${kid.value}`, + }), + ), + ] + : [ + { + name: text, + value: text, + ...(answer.type ? { type: answer.type } : {}), + variablesReference: answer.variablesReference, + ...counts, + }, + ]; const ref = this.nextShown++; this.shown.set(ref, rows); return ref; @@ -1221,6 +1228,7 @@ async function showRowAt( variablesReference: dap.variablesReference, ...(dap.indexedVariables ? { indexedVariables: dap.indexedVariables } : {}), ...(dap.namedVariables ? { namedVariables: dap.namedVariables } : {}), + ...(row.kind ? { children: row.kind } : {}), }; } @@ -1262,6 +1270,7 @@ async function evaluateAt( variablesReference: row.variablesReference, ...(row.indexedVariables ? { indexedVariables: row.indexedVariables } : {}), ...(row.namedVariables ? { namedVariables: row.namedVariables } : {}), + ...(reply.row.kind ? { children: reply.row.kind } : {}), }; } case 'none': diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index 0858e2d..8ca4c49 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -63,8 +63,16 @@ export interface PauseVariable { * such as a group heading. */ handle: number; + /** + * How its children are read, when it has any (`CHILDREN_OF`): `seq`, `coll` + * and `dict` are containers, `attrs` and `ivars` an object's attributes. + * Absent on a row with no object, such as a group heading. + */ + kind?: ChildKind; } +export type ChildKind = 'seq' | 'coll' | 'dict' | 'attrs' | 'ivars'; + /** * Smalltalk that answers `{kind. source. size}` for an object's children, or * nil for a leaf. `kind` is `#seq` (read by position), `#coll` (a set: read in @@ -101,7 +109,8 @@ const CHILDREN_OF = ` /** * Smalltalk that writes one row for `v`, named `nm`, and registers `v` — as - * the row's `ref` when it has children, and always as its `handle`. + * the row's `ref` when it has children, and always as its `handle` — ending + * with the kind `childrenOf` reads its children by, or nothing for a leaf. * * Its repr is the user's `__repr__`, run through `reprOf`: a big container * shows its size instead, and a long string is asked for the repr of its @@ -134,7 +143,8 @@ const DESCRIBE = ` n > 0 ifTrue: [out print: reg size] ifFalse: [out nextPutAll: '0']. - out nextPut: field; print: idx; nextPut: field; print: named; nextPut: field; print: reg size; nextPut: record].`; + out nextPut: field; print: idx; nextPut: field; print: named; nextPut: field; print: reg size; + nextPut: field; nextPutAll: (kids isNil ifTrue: [''] ifFalse: [kids at: 1]); nextPut: record].`; /** * Smalltalk for the blocks every row needs. @@ -370,7 +380,7 @@ export function parseChildren(raw: string): PauseVariable[] { .split(RECORD) .filter((r) => r.length > 0) .map((record): PauseVariable => { - const [name, value, type, ref, indexed, named, handle] = record.split(FIELD); + const [name, value, type, ref, indexed, named, handle, kind] = record.split(FIELD); const int = (s: string | undefined): number => Number.parseInt(s ?? '', 10) || 0; return { name: name ?? '?', @@ -380,6 +390,7 @@ export function parseChildren(raw: string): PauseVariable[] { indexed: int(indexed), named: int(named), handle: int(handle), + ...(kind ? { kind: kind as ChildKind } : {}), }; }); } diff --git a/src/variableCommands.ts b/src/variableCommands.ts index b26f0f8..8068c14 100644 --- a/src/variableCommands.ts +++ b/src/variableCommands.ts @@ -5,9 +5,9 @@ import { errorMessage } from './log'; /** * Two items on a Variables row's menu, each ending in the Debug Console: * - * - **Show in Debug Console** puts the row's object there — a line naming - * it, then its attributes, or a list's first items — so the next line can - * be typed beside it without first clicking over to the console. + * - **Show in Debug Console** puts the row's object there — an object with + * its attributes listed, a container as one row that opens — so the next + * line can be typed beside it without first clicking over to the console. * - **Evaluate with self…** asks for an expression and runs it with `self` * bound to the row's object — `len(self.history)` on any row, however deep * — in the frame selected in Call Stack, as a console line runs. From 14050cc9979ac9d8cdda86f2efa61cc7741af067 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 16:48:55 -0700 Subject: [PATCH 06/15] Leave a shown row's name at the Debug Console prompt After Show in Debug Console, the console's input holds what reaches the object: the row's own name when it already means that object in the frame (a Locals or Globals row), else _, which the object is bound to (a list item, a dict entry). VS Code has no API to set the console's input, so the name is typed into it once focusRepl has focused it. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +- README.md | 3 +- src/__integration__/debugConsole.test.ts | 20 ++++ src/__tests__/debugConsole.test.ts | 25 +++++ src/__tests__/variableCommands.test.ts | 25 +++++ src/debugger.ts | 79 +++++++++++++- src/pauseVariables.ts | 127 ++++++++++++++++------- src/variableCommands.ts | 19 +++- 8 files changed, 254 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a6e6b08..3073d79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Show in Debug Console and Evaluate with self… on a Variables row.** Right-click any row, however deep in the tree, and **Show in Debug Console** opens the console with that object in it: an object with its attributes listed beneath, each by name; a list, set or dict, or an object with - more than 20 attributes, as one row that opens like the Variables row. **Evaluate with self…** asks for an expression and shows what it gives + more than 20 attributes, as one row that opens like the Variables row. Its name is left at the + console's prompt, or `_` bound to it for a row whose name means nothing there, such as a list item. **Evaluate with self…** asks for an expression and shows what it gives with `self` bound to the row's object, in the frame selected in Call Stack. ## [1.6.0] - 2026-10-07 diff --git a/README.md b/README.md index 8052d20..896fc0f 100644 --- a/README.md +++ b/README.md @@ -422,7 +422,8 @@ database, select that kernel from the kernel picker at the top right. be stopped from the notification that appears. A change to an object made there is in the notebook's transaction, as it would be from a cell. Right-click a Variables row for **Show in Debug Console**, which puts that object in the console (an object with its attributes - listed, a list or dict as one row that opens), or **Evaluate with self…**, + listed, a list or dict as one row that opens) and leaves its name at the prompt, or `_` for a + nested row, ready for the next line, or **Evaluate with self…**, which runs an expression with `self` bound to it (`len(self.history)`) and shows the answer there. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index ec24be1..9736fe5 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -861,6 +861,23 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' }); seen.echo = withSelf.out[0]; seen.withSelf = (await listed(withSelf)).map((v) => v.value); + // What Show leaves at the prompt: a Locals row's own name, which means + // it there; for a nested row, `_`, bound to it. + seen.typedLocal = ( + await dap.request('gemdbShowRow', { + handle: other.variablesReference, + name: 'other', + frameId: 1, + }) + ).body?.typed; + seen.typedNested = ( + await dap.request('gemdbShowRow', { + handle: items.variablesReference, + name: 'items', + frameId: 1, + }) + ).body?.typed; + seen.underscore = await dap.value('_', 1); // The frame's own self is still the Box it was: the binding was for that line alone. seen.frameSelf = await dap.value('self.x'); const scopes = (await dap.request('scopes', { frameId: 2 })).body?.scopes as Array<{ @@ -890,6 +907,9 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' }); expect(seen.withSelf).toEqual(['50']); expect(seen.frameSelf).toBe('1'); + expect(seen.typedLocal).toBe('other'); + expect(seen.typedNested).toBe('_'); + expect(seen.underscore).toBe('[5, 5]'); expect(seen.group).toMatchObject({ ok: true, out: [{ category: 'stderr', output: expect.stringMatching(/not an object of its own/) }], diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index c79a27e..71baaa1 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -628,6 +628,31 @@ describe('putting a Variables row in the Debug Console', () => { expect(result.ok).toBe(true); }); + it('answers what to type at the prompt for the object it showed', async () => { + const consoleName = vi.fn>().mockResolvedValue('_'); + const c = started(vi.fn(), undefined, { showRow: showRow(value('10')), consoleName }); + + const seq = c.request('gemdbShowRow', { handle: 7, name: "'owner'", frameId: 2 }); + await settle(); + + expect(consoleName).toHaveBeenCalledWith(2, "'owner'", 7); + expect(c.responseTo(seq)?.body).toEqual({ typed: '_' }); + }); + + it('answers nothing to type after Evaluate with self', async () => { + const consoleName = vi.fn>(); + const c = started(vi.fn().mockResolvedValue(value('2')), undefined, { + showRow: vi.fn(), + consoleName, + }); + + const seq = c.request('gemdbShowRow', { handle: 7, name: 'p', expression: 'self.x' }); + await settle(); + + expect(consoleName).not.toHaveBeenCalled(); + expect(c.responseTo(seq)?.body).toBeUndefined(); + }); + it('refuses where the pause has no rows to show, such as a saved stack', async () => { const { request, responseTo } = started(undefined); diff --git a/src/__tests__/variableCommands.test.ts b/src/__tests__/variableCommands.test.ts index e504789..a525be4 100644 --- a/src/__tests__/variableCommands.test.ts +++ b/src/__tests__/variableCommands.test.ts @@ -72,6 +72,31 @@ describe('Show in Debug Console', () => { ]); }); + it('leaves what to type at the console’s prompt, as the adapter answers it', async () => { + const seen = watching(); + vi.spyOn(commands, 'executeCommand').mockImplementation((id: string, ...args: unknown[]) => { + seen.push(`command ${id}${args.length ? ' ' + JSON.stringify(args[0]) : ''}`); + return undefined; + }); + paused(() => Promise.resolve({ typed: 'entry' })); + + await showInDebugConsole(row('entry', 7)); + + expect(seen).toEqual([ + 'command workbench.debug.action.focusRepl', + 'command type {"text":"entry"}', + ]); + }); + + it('types nothing when the adapter answers nothing to type', async () => { + const seen = watching(); + paused(() => Promise.resolve({})); + + await showInDebugConsole(row('p', 7)); + + expect(seen).toEqual(['command workbench.debug.action.focusRepl']); + }); + it('finds a leaf row’s object by its handle, as Add to Persisted Objects does', async () => { watching(); const customRequest = vi.fn().mockResolvedValue({}); diff --git a/src/debugger.ts b/src/debugger.ts index 8f2d21f..0cd2188 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -14,6 +14,7 @@ import { TracebackFrame, childrenQuery, clearRegistryQuery, + consoleNameQuery, consoleQuery, describeRowQuery, parseChildren, @@ -149,6 +150,12 @@ export interface Pause { ): Promise; /** The object of registry entry `handle`, as a console entry named `name`. */ showRow?(handle: number, name: string): Promise; + /** + * What to type in frame `frameId`'s console to reach registry entry + * `handle`'s object: `name` when it already means that object there, else + * `_`, which it is then bound to (`consoleNameQuery`). + */ + consoleName?(frameId: number, name: string, handle: number): Promise; /** Each frame's whole cell or file, by frame index, so the stack can be saved. */ texts?: Array; /** The saved source behind a frame's `sourceReference`, for a stack opened from the database. */ @@ -884,7 +891,8 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { * is, shown the same way. Shown as `output` events, since there is no * console request to answer: an echo of the expression, what it printed, * then the value, a traceback, or - * "Interrupted.". + * "Interrupted.". Showing answers `typed`: what to give the console's + * prompt to reach the object (`Pause.consoleName`). */ private showRow(request: DapRequest): void { const pause = this.pause; @@ -931,7 +939,12 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { if (expression !== undefined && this.clientInvalidates) { this.event('invalidated', { areas: ['variables'] }); } - this.respond(request); + // What the console's prompt should be given, so the next line can use the object. + const typed = + expression === undefined && answer.kind === 'value' && pause.consoleName + ? await pause.consoleName(frameId, name, handle).catch(() => undefined) + : undefined; + this.respond(request, typed === undefined ? undefined : { typed }); }) .catch((e: unknown) => this.fail(request, errorMessage(e), true)) .finally(finish); @@ -1207,6 +1220,66 @@ function placeFrame( }; } +/** A Python identifier, which a console line can use as a name. */ +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const KEYWORDS = new Set([ + 'False', + 'None', + 'True', + 'and', + 'as', + 'assert', + 'async', + 'await', + 'break', + 'class', + 'continue', + 'def', + 'del', + 'elif', + 'else', + 'except', + 'finally', + 'for', + 'from', + 'global', + 'if', + 'import', + 'in', + 'is', + 'lambda', + 'nonlocal', + 'not', + 'or', + 'pass', + 'raise', + 'return', + 'try', + 'while', + 'with', + 'yield', +]); + +/** What to type to reach a shown row's object in one frame's console (`Pause.consoleName`). */ +async function consoleNameAt( + request: HaltRequest, + view: PauseView, + consoleRefs: Map, + frameId: number, + name: string, + handle: number, +): Promise { + const scope = view.consoles.get(frameId) ?? { locals: 0, globals: 0 }; + const usable = IDENTIFIER.test(name) && !KEYWORDS.has(name) ? name : ''; + const raw = await request.query( + consoleNameQuery(scope, consoleRefs.get(frameId) ?? 0, usable, handle), + ); + if (raw.startsWith('Error: ')) throw new Error(raw.slice('Error: '.length)); + const [ref, typed] = raw.split(' '); + if (Number(ref) > 0) consoleRefs.set(frameId, Number(ref)); + return typed ?? '_'; +} + /** A Variables row's object as a console entry (`Pause.showRow`). */ async function showRowAt( request: HaltRequest, @@ -1374,6 +1447,8 @@ export function registerBreakpointDebugger(): vscode.Disposable { evaluate: (source, frameId, context, signal, selfHandle) => evaluateAt(request, view, consoleRefs, source, frameId, context, signal, selfHandle), showRow: (handle, name) => showRowAt(request, handle, name), + consoleName: (frameId, name, handle) => + consoleNameAt(request, view, consoleRefs, frameId, name, handle), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, reason: request.reason, diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index 8ca4c49..0d28855 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -662,6 +662,92 @@ export type ConsoleReply = { consoleRef: number; output: string } & ( | { kind: 'failed'; message: string } ); +/** + * Smalltalk that finds frame `scope`'s console scope at registry entry + * `consoleRef`, or makes it and registers it, leaving it in `console` and its + * position in `cref` (`consoleQuery` says what it holds). Needs the temps + * `reg dispatcher cref console dict g l bind nameAst none`. + */ +function consoleScopeSetup(scope: ConsoleScope, consoleRef: number): string { + const findModule = scope.moduleFile + ? `g := [dispatcher evaluateSource: '${escapeString( + [ + 'import sys as _gemdb_sys', + '_gemdb_found = None', + 'for _gemdb_m in list(_gemdb_sys.modules.values()):', + ` if getattr(_gemdb_m, "__file__", None) == ${pythonString(scope.moduleFile)}:`, + ' _gemdb_found = _gemdb_m.__dict__', + ' break', + '_gemdb_found', + ].join('\n'), + )}' usingModuleScope: SymbolDictionary new] + on: AbstractException do: [:e | e return: nil]. + (g notNil and: [g ~~ none and: [g respondsTo: #keysAndValuesDo:]]) + ifTrue: [g keysAndValuesDo: [:k :x | bind value: k value: x]].` + : ''; + return `reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. +reg isNil ifTrue: [^ 'Error: the pause is over' encodeAsUTF8]. +dispatcher := System myUserProfile symbolList objectNamed: #'ModuleAst'. +cref := ${consoleRef}. +nameAst := System myUserProfile symbolList objectNamed: #'NameAst'. +bind := [:k :x | | nm | + nm := k asString asSymbol. + nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. + dict at: nm put: x]. +console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil]. +(console class == Array and: [console size = 2 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ + dict := SymbolDictionary new. + g := (${scope.globals} between: 1 and: reg size) ifTrue: [reg at: ${scope.globals}] ifFalse: [nil]. + (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) + ifTrue: [(g at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. + ${findModule} + "The frame's locals over its globals: a local shadows a global of its name." + l := (${scope.locals} between: 1 and: reg size) ifTrue: [reg at: ${scope.locals}] ifFalse: [nil]. + (l class == Array and: [l size >= 2 and: [(l at: 1) == #gemdbScope]]) + ifTrue: [(l at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. + console := { #gemdbConsole. dict }. + reg add: console. + cref := reg size]. +`; +} + +/** + * Smalltalk for **Show in Debug Console**: what to type at the console's + * prompt to reach registry entry `handle`'s object. When `name` — a Python + * identifier — already means that very object in the frame's console scope + * (a Locals or Globals row), the answer is + * `name`; otherwise the object is bound to `_`, Python's name for the last + * result, and the answer is `_`. Answers ` `, or an + * `Error: …` line. + */ +export function consoleNameQuery( + scope: ConsoleScope, + consoleRef: number, + name: string, + handle: number, +): string { + return `| reg dispatcher cref console dict g l bind nameAst none field record out o key | +${PREAMBLE} +${consoleScopeSetup(scope, consoleRef)} +dict := console at: 2. +${ROW_OBJECT(handle, 'o')} +key := nil. +${ + name + ? `key := '${escapeString(name)}' asSymbol. +nameAst notNil ifTrue: [key := [nameAst doitScopeNameFor: key] on: Error do: [:e | e return: key]].` + : '' +} +((key notNil and: [dict includesKey: key]) and: [(dict at: key) == o]) + ifTrue: [^ (cref printString , ' ${escapeString(name)}') encodeAsUTF8]. +"Bound by Python itself: Grail carries \`_\` under a spelling of its own (measured: a read of a + dictionary's #'_' raised NameError for '___unused___')." +dict at: #'_gemdb_shown' put: o. +[dispatcher evaluateSource: '_ = _gemdb_shown' usingModuleScope: dict] + ensure: [dict removeKey: #'_gemdb_shown' ifAbsent: []]. +(cref printString , ' _') encodeAsUTF8`; +} + /** * Smalltalk that runs Debug Console or watch input `source` in a frame's * console scope and answers what came of it (`parseConsoleReply`). @@ -702,51 +788,12 @@ export function consoleQuery( source: string, selfHandle = 0, ): string { - const findModule = scope.moduleFile - ? `g := [dispatcher evaluateSource: '${escapeString( - [ - 'import sys as _gemdb_sys', - '_gemdb_found = None', - 'for _gemdb_m in list(_gemdb_sys.modules.values()):', - ` if getattr(_gemdb_m, "__file__", None) == ${pythonString(scope.moduleFile)}:`, - ' _gemdb_found = _gemdb_m.__dict__', - ' break', - '_gemdb_found', - ].join('\n'), - )}' usingModuleScope: SymbolDictionary new] - on: AbstractException do: [:e | e return: nil]. - (g notNil and: [g ~~ none and: [g respondsTo: #keysAndValuesDo:]]) - ifTrue: [g keysAndValuesDo: [:k :x | bind value: k value: x]].` - : ''; return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst target | ${PREAMBLE} ${HELPERS} ${CHILDREN_OF} ${DESCRIBE} -reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. -reg isNil ifTrue: [^ 'Error: the pause is over' encodeAsUTF8]. -dispatcher := System myUserProfile symbolList objectNamed: #'ModuleAst'. -cref := ${consoleRef}. -nameAst := System myUserProfile symbolList objectNamed: #'NameAst'. -bind := [:k :x | | nm | - nm := k asString asSymbol. - nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. - dict at: nm put: x]. -console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil]. -(console class == Array and: [console size = 2 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ - dict := SymbolDictionary new. - g := (${scope.globals} between: 1 and: reg size) ifTrue: [reg at: ${scope.globals}] ifFalse: [nil]. - (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) - ifTrue: [(g at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. - ${findModule} - "The frame's locals over its globals: a local shadows a global of its name." - l := (${scope.locals} between: 1 and: reg size) ifTrue: [reg at: ${scope.locals}] ifFalse: [nil]. - (l class == Array and: [l size >= 2 and: [(l at: 1) == #gemdbScope]]) - ifTrue: [(l at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. - console := { #gemdbConsole. dict }. - reg add: console. - cref := reg size]. -target := console at: 2. +${consoleScopeSetup(scope, consoleRef)}target := console at: 2. ${ selfHandle > 0 ? `${ROW_OBJECT(selfHandle, 'g')} diff --git a/src/variableCommands.ts b/src/variableCommands.ts index 8068c14..4390956 100644 --- a/src/variableCommands.ts +++ b/src/variableCommands.ts @@ -73,17 +73,30 @@ async function showRow(session: vscode.DebugSession, args: Record { const target = rowTarget(context); if (!target) return; - await showRow(target.session, { handle: target.handle, name: target.name }); + const frameId = selectedFrame(target.session); + await showRow(target.session, { + handle: target.handle, + name: target.name, + ...(frameId === undefined ? {} : { frameId }), + }); } /** Ask for an expression and show what it gives with `self` bound to the right-clicked row's object. */ From ba4ccdcd8986f085a263ddd88c1e9c205b0be0d1 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 15:58:04 -0700 Subject: [PATCH 07/15] Name a classmethod's frame by its class A @classmethod or @staticmethod is compiled on the class's metaclass, so the Call Stack read "Account class.open". Name the frame by the class itself, "Account.open", as CPython does. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 6 +++++ src/__integration__/breakpoint.test.ts | 32 ++++++++++++++++++++++++++ src/haltStack.ts | 6 ++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3073d79..e513377 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 console's prompt, or `_` bound to it for a row whose name means nothing there, such as a list item. **Evaluate with self…** asks for an expression and shows what it gives with `self` bound to the row's object, in the frame selected in Call Stack. +### Fixed + +- **A classmethod's or staticmethod's frame is named by its class.** The Call Stack read + `Account class.open` for a paused `@classmethod` or `@staticmethod`; it now reads `Account.open`, + as CPython names it. + ## [1.6.0] - 2026-10-07 The engine is unchanged at GemStone 4.0.0.a4, but start with a fresh database: delete diff --git a/src/__integration__/breakpoint.test.ts b/src/__integration__/breakpoint.test.ts index 8809084..60bcd80 100644 --- a/src/__integration__/breakpoint.test.ts +++ b/src/__integration__/breakpoint.test.ts @@ -747,6 +747,38 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { expect(result.value).toBe('7'); }); + it('names a classmethod’s and a staticmethod’s frame by their class, as CPython does', async () => { + const names: string[][] = []; + setHaltHandler(async (request) => { + names.push( + parsePythonStack(await request.query(pythonStackQuery(request.process))).map((f) => f.name), + ); + return 'continue'; + }); + + await runPython( + [ + 'class Account:', + ' @classmethod', + ' def open(cls):', + ' breakpoint()', + ' return cls()', + ' @staticmethod', + ' def check():', + ' breakpoint()', + ' return 1', + 'Account.open()', + 'Account.check()', + ].join('\n'), + NB, + ); + + expect(names).toEqual([ + ['Account.open', ''], + ['Account.check', ''], + ]); + }); + it('turns an interrupt while paused into KeyboardInterrupt and tells the handler', async () => { let cancelled = false; setHaltHandler( diff --git a/src/haltStack.ts b/src/haltStack.ts index 18cbe44..ed848cb 100644 --- a/src/haltStack.ts +++ b/src/haltStack.ts @@ -122,7 +122,9 @@ export interface StackQueryExtras { * source records (`___curPosPositionsFromSource___:`). A frame is * qualified with its class when its method belongs to a Python class rather * than to a module — the receiver-side test Grail's own walk makes, done on - * the defining class. + * the defining class. A classmethod or staticmethod is compiled on the + * class's metaclass, whose name reads `Account class`, so the class it + * belongs to names it instead: `Account.open`, as CPython says. * * Each frame is described inside its own error guard: a frame Grail cannot * place becomes a `?` row with no line, rather than failing the query and @@ -166,6 +168,8 @@ both isNil ifFalse: [ meth := p at: 1. home := [meth homeMethod] on: Error do: [:e | meth]. cls := home inClass. + "A classmethod or staticmethod is compiled class-side; its class, not the metaclass, names it." + (cls notNil and: [cls isMeta]) ifTrue: [cls := cls thisClass]. name := (p at: 3) asString. (cls notNil and: [modCls notNil and: [(cls inheritsFrom: modCls) not]]) ifTrue: [name := cls name asString , '.' , name]. From b1743d8d19d9ba63be64efc42ff95730bdeb2124 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 09:52:39 -0700 Subject: [PATCH 08/15] Add GemDB commands to the Debug Console A Debug Console line that starts with % is a GemDB command rather than Python. %persist p adds p to Persisted Objects (or %persist expr as key), shown there as added until the notebook commits; %commit commits the notebook as the check mark in Persisted Objects does; %names lists the names bound in the console at this pause; %help lists the commands. To make them findable without a hover that flashes while typing, the adapter answers completions for % lines only: typing % suggests the commands with a line each on what they do, and %persist suggests the console's names. The first pause in a window also says, once, that the commands exist. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 5 + README.md | 5 +- src/__integration__/debugConsole.test.ts | 85 ++++++++++- src/__tests__/consoleCommands.test.ts | 105 ++++++++++++++ src/__tests__/debugConsole.test.ts | 172 ++++++++++++++++++++++- src/consoleCommands.ts | 172 +++++++++++++++++++++++ src/debugger.ts | 171 ++++++++++++++++++++++ src/pauseVariables.ts | 55 +++++++- src/savedObjects.ts | 14 ++ 9 files changed, 776 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/consoleCommands.test.ts create mode 100644 src/consoleCommands.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efde9b8..280aad0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 continues with it. What Python refuses is refused with Python's reason, such as a property with no setter. A method's receiver (`self`, or whatever the def calls it), a tuple's items, group rows and a saved stack's values cannot be changed, and VS Code does not offer Set Value on them. +- **GemDB commands in the Debug Console.** A line starting with `%` is a GemDB command rather than + Python: `%persist p` adds `p` to Persisted Objects (or `%persist expr as key`), shown there as + added until the notebook commits; `%commit` commits the notebook; `%names` lists the names you + have bound in the console at this pause; `%help` lists the commands. Typing `%` suggests them, + and `%persist ` suggests your console names. The first pause in a window says so, once. ### Fixed diff --git a/README.md b/README.md index a541e74..f574607 100644 --- a/README.md +++ b/README.md @@ -432,7 +432,10 @@ database, select that kernel from the kernel picker at the top right. listed, a list or dict as one row that opens) and leaves its name at the prompt, or `_` for a nested row, ready for the next line, or **Evaluate with self…**, which runs an expression with `self` bound to it (`len(self.history)`) and shows the answer - there. Stepping is not supported yet, and neither are red + there. Lines starting with `%` are GemDB commands, not Python: `%persist p` adds `p` to + Persisted Objects (`%persist expr as key` for anything else), `%commit` commits the notebook, + `%names` lists what you have bound in the console, and `%help` lists them all. Type `%` and the + console suggests them. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 3530b4d..807f613 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -16,7 +16,13 @@ import { stageGrail } from '../grail'; import { abortQuery, needsCommitQuery } from '../pauseVariables'; import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes'; import { PyResult, runPython, runPythonFile } from '../pythonQueries'; -import { SessionOwner, closeSessionFor, logoutAll, sessionForIfOpen } from '../session'; +import { + SessionOwner, + closeSessionFor, + executeAsync, + logoutAll, + sessionForIfOpen, +} from '../session'; import { createDatabaseWithPython, Fixture, haveTestExtent, makeFixture } from './fixture'; /** @@ -918,4 +924,81 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' out: [{ category: 'stderr', output: expect.stringMatching(/not an object of its own/) }], }); }); + it('runs GemDB’s % commands: %persist, %names, %commit, and completes them', async () => { + const nb = notebook('dc-commands'); + const seen: Record = {}; + + await inCell( + nb, + [ + 'class Item:', + ' def __init__(self, sku, qty):', + ' self.sku, self.qty = sku, qty', + 'def go(items):', + ' breakpoint()', + 'go([Item("B400", "2"), Item("A200", 1)])', + ].join('\n'), + async (dap) => { + await dap.value('p = [(i.sku, i.qty, type(i.qty).__name__) for i in items]'); + const said = async (line: string) => { + const from = dap.sent.length; + const r = await dap.evaluate(line); + return { + ok: r.ok, + message: r.message, + text: dap.sent + .slice(from) + .filter((m) => m.event === 'output') + .map((m) => String(m.body?.output ?? '')) + .join(''), + }; + }; + seen.persist = await said('%persist p'); + seen.persistAs = await said('%persist items[0] as first_item'); + seen.noKey = await said('%persist items[0]'); + seen.names = await said('%names'); + seen.inRoot = await dap.value('__import__("gemdb").root["p"]'); + const complete = async (text: string) => + ( + (await dap.request('completions', { text, column: text.length + 1, frameId: 1 })).body + ?.targets as Array<{ label: string }> + ).map((t) => t.label); + seen.completeCommands = await complete('%'); + seen.completeNames = await complete('%persist '); + seen.commit = await said('%commit'); + }, + ); + // Committed: another session sees it. + seen.elsewhere = await executeAsync( + `System abortTransaction. ((System myUserProfile symbolList objectNamed: #'ModuleAst') evaluateSource: 'import gemdb\n(gemdb.root["p"], gemdb.root["first_item"].sku)' usingModuleScope: SymbolDictionary new) printString encodeAsUTF8`, + ); + closeSessionFor(nb.key); + + expect(seen.persist).toMatchObject({ + ok: true, + text: expect.stringMatching( + /^Added p to Persisted Objects as gemdb.root\["p"\]\. It is saved when dc-commands\.ipynb commits/, + ), + }); + expect(seen.persistAs).toMatchObject({ + ok: true, + text: expect.stringContaining('gemdb.root["first_item"]'), + }); + expect(seen.noKey).toMatchObject({ + ok: false, + message: 'Give it a key: %persist items[0] as some_key', + }); + expect(seen.names).toMatchObject({ + ok: true, + text: "p: list = [('B400', '2', 'str'), ('A200', 1, 'int')]\n", + }); + expect(seen.inRoot).toBe("[('B400', '2', 'str'), ('A200', 1, 'int')]"); + expect(seen.completeCommands).toEqual(['%persist', '%commit', '%names', '%help']); + expect(seen.completeNames).toEqual(['p']); + expect(seen.commit).toMatchObject({ + ok: true, + text: expect.stringMatching(/^Committed dc-commands\.ipynb/), + }); + expect(seen.elsewhere).toContain("'B400'"); + }); }); diff --git a/src/__tests__/consoleCommands.test.ts b/src/__tests__/consoleCommands.test.ts new file mode 100644 index 0000000..8eb6ae9 --- /dev/null +++ b/src/__tests__/consoleCommands.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import { + CONSOLE_COMMANDS, + commandCompletions, + helpText, + isConsoleCommand, + parseConsoleCommand, +} from '../consoleCommands'; + +/** GemDB's `%` commands in the Debug Console: reading a line, and what is suggested while typing one. */ + +describe('reading a % line', () => { + it('takes only a line that starts with %, so Python is never mistaken for a command', () => { + expect(isConsoleCommand('%persist p')).toBe(true); + expect(isConsoleCommand(' %help')).toBe(true); + expect(isConsoleCommand('x % 3')).toBe(false); + expect(isConsoleCommand('help')).toBe(false); + expect(parseConsoleCommand('p = 1')).toBeUndefined(); + }); + + it('persists a name under its own name, or under the key given after as', () => { + expect(parseConsoleCommand('%persist p')).toEqual({ + kind: 'persist', + expression: 'p', + key: 'p', + }); + expect(parseConsoleCommand('%persist p as qty_check')).toEqual({ + kind: 'persist', + expression: 'p', + key: 'qty_check', + }); + expect(parseConsoleCommand('%persist o.items[0] as "first item"')).toEqual({ + kind: 'persist', + expression: 'o.items[0]', + key: 'first item', + }); + }); + + it('asks for a key when what is persisted is not a plain name', () => { + expect(parseConsoleCommand('%persist o.items[0]')).toEqual({ + kind: 'invalid', + message: 'Give it a key: %persist o.items[0] as some_key', + }); + expect(parseConsoleCommand('%persist')).toMatchObject({ kind: 'invalid' }); + }); + + it('reads the commands that take nothing, and refuses extra words after them', () => { + expect(parseConsoleCommand('%commit')).toEqual({ kind: 'commit' }); + expect(parseConsoleCommand('%names')).toEqual({ kind: 'names' }); + expect(parseConsoleCommand('%help')).toEqual({ kind: 'help' }); + expect(parseConsoleCommand('%commit now')).toMatchObject({ kind: 'invalid' }); + }); + + it('names the commands when it does not know the one typed', () => { + const unknown = parseConsoleCommand('%abort'); + expect(unknown).toMatchObject({ kind: 'invalid' }); + expect((unknown as { message: string }).message).toMatch( + /%abort is not a GemDB command\. Commands: %persist, %commit, %names, %help\./, + ); + }); +}); + +describe('%help', () => { + it('lists every command with how to type it and what it does', () => { + const text = helpText(); + for (const c of CONSOLE_COMMANDS) { + expect(text).toContain(c.usage); + expect(text).toContain(c.summary); + } + }); +}); + +describe('autocomplete', () => { + it('lists the commands when % is typed, each with what it does, replacing the % onwards', () => { + expect(commandCompletions('%', [])).toEqual( + CONSOLE_COMMANDS.map((c) => ({ + label: c.name, + detail: c.summary, + type: 'keyword', + start: 1, + length: 1, + })), + ); + }); + + it('narrows to the commands that match what is typed', () => { + expect(commandCompletions('%pe', []).map((c) => c.label)).toEqual(['%persist']); + expect(commandCompletions(' %c', [])).toEqual([ + expect.objectContaining({ label: '%commit', start: 3, length: 2 }), + ]); + }); + + it('offers the console’s names after %persist', () => { + expect(commandCompletions('%persist ', ['bad', 'p'])).toEqual([ + { label: 'bad', type: 'variable', start: 10, length: 0 }, + { label: 'p', type: 'variable', start: 10, length: 0 }, + ]); + expect(commandCompletions('%persist b', ['bad', 'p']).map((c) => c.label)).toEqual(['bad']); + }); + + it('suggests nothing for Python', () => { + expect(commandCompletions('len(', ['p'])).toEqual([]); + expect(commandCompletions('x % ', ['p'])).toEqual([]); + }); +}); diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index 73cd0a5..0882c97 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { window } from '../__mocks__/vscode'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { commands, window } from '../__mocks__/vscode'; import type { ConsoleAnswer, Pause } from '../debugger'; import type { HaltHandler, RedDotSource } from '../session'; @@ -16,7 +16,8 @@ vi.mock('../session', () => ({ setRedDotSource: (_source: RedDotSource | undefined) => {}, })); -const { PauseDebugAdapter } = await import('../debugger'); +const { PauseDebugAdapter, consoleTip } = await import('../debugger'); +const { commitQuery } = await import('../pauseVariables'); /** Let the adapter's promises run. */ const settle = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); @@ -69,6 +70,11 @@ function started( return c; } +// The one-line tip about the % commands is tested on its own below; elsewhere it is already shown. +beforeEach(() => { + consoleTip.shown = true; +}); + afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); @@ -677,3 +683,163 @@ describe('putting a Variables row in the Debug Console', () => { expect(responseTo(seq)).toMatchObject({ success: false }); }); }); + +describe('GemDB’s % commands in the console', () => { + /** Run a % line and answer what came back. */ + async function command(line: string, more: Partial = {}, evaluate?: Evaluate) { + const evaluated = evaluate ?? vi.fn(); + const c = started(evaluated, undefined, more); + const seq = c.request('evaluate', { expression: line, frameId: 2, context: 'repl' }); + await settle(); + await settle(); + return { + ...c, + reply: c.responseTo(seq), + said: c + .events('output') + .map((e) => (e.body as { output: string }).output) + .join(''), + evaluated, + }; + } + + it('answers %help with the commands, running nothing in the database', async () => { + const { reply, said, evaluated } = await command('%help'); + + expect(reply?.success).toBe(true); + expect(said).toMatch(/%persist p {3}or {3}%persist p as key/); + expect(evaluated).not.toHaveBeenCalled(); + }); + + it('persists p by setting gemdb.root["p"] in the console’s own names, and shows it pending', async () => { + const executed = vi.spyOn(commands, 'executeCommand').mockReturnValue(Promise.resolve()); + const evaluate = vi.fn().mockResolvedValue(value("(False, None, 'list')")); + + const { reply, said } = await command( + '%persist p', + { ownerKey: 'nb', label: 'a.ipynb' }, + evaluate, + ); + + const [source, frameId, context] = evaluate.mock.calls[0]; + expect(frameId).toBe(2); + expect(context).toBe('repl'); + expect(source).toContain('__import__("gemdb").root, "p", (p)'); + expect(executed).toHaveBeenCalledWith('gemdb.savedObjects.notePersisted', { + key: 'p', + type: 'list', + ownerKey: 'nb', + notebook: 'a.ipynb', + }); + expect(said).toMatch( + /Added p to Persisted Objects as gemdb.root\["p"\]\. It is saved when a\.ipynb commits/, + ); + expect(reply?.success).toBe(true); + }); + + it('says so when %persist replaces what was there', async () => { + vi.spyOn(commands, 'executeCommand').mockReturnValue(Promise.resolve()); + const evaluate = vi.fn().mockResolvedValue(value("(True, None, 'int')")); + + const { said } = await command('%persist p as total', { ownerKey: 'nb' }, evaluate); + + expect(said).toMatch(/as gemdb.root\["total"\], replacing what was there/); + }); + + it('shows the traceback when what is persisted does not exist', async () => { + const executed = vi.spyOn(commands, 'executeCommand'); + const evaluate = vi.fn().mockResolvedValue({ + kind: 'error', + output: '', + traceback: "NameError: name 'q' is not defined", + } as ConsoleAnswer); + + const { reply } = await command('%persist q', {}, evaluate); + + expect(reply).toMatchObject({ success: false, message: "NameError: name 'q' is not defined" }); + expect(executed).not.toHaveBeenCalledWith( + 'gemdb.savedObjects.notePersisted', + expect.anything(), + ); + }); + + it('commits the notebook on %commit, as ✓ in Persisted Objects does', async () => { + const executed = vi.spyOn(commands, 'executeCommand').mockReturnValue(Promise.resolve()); + const query = vi.fn((code: string) => + Promise.resolve(code.includes('commit') ? "'committed'" : ''), + ); + + const { reply, said } = await command('%commit', { query, label: 'a.ipynb' }); + + expect(query).toHaveBeenCalledWith(commitQuery()); + expect(executed).toHaveBeenCalledWith('gemdb.savedObjects.refresh'); + expect(said).toMatch(/Committed a\.ipynb/); + expect(reply?.success).toBe(true); + }); + + it('lists what the console has bound on %names', async () => { + const consoleNames = vi + .fn() + .mockResolvedValue([{ name: 'p', type: 'list', repr: "[('B400', '2', 'str')]" }]); + + const { said } = await command('%names', { consoleNames }); + + expect(consoleNames).toHaveBeenCalledWith(2); + expect(said).toBe("p: list = [('B400', '2', 'str')]\n"); + }); + + it('fails a mistyped command in the console’s error colour, with the commands named', async () => { + const { reply } = await command('%persit p'); + + expect(reply).toMatchObject({ success: false }); + expect((reply as { message: string }).message).toMatch(/%persit is not a GemDB command/); + }); + + it('claims completions, triggered by %, and offers the commands and then the console’s names', async () => { + const consoleNames = vi.fn().mockResolvedValue([{ name: 'p', type: 'list', repr: '[]' }]); + const c = started(vi.fn(), undefined, { consoleNames }); + const init = c.request('initialize', { adapterID: 'gemdb' }); + const commandsListed = c.request('completions', { text: '%pe', column: 4, frameId: 1 }); + const namesListed = c.request('completions', { text: '%persist ', column: 10, frameId: 1 }); + const python = c.request('completions', { text: 'len(', column: 5, frameId: 1 }); + await settle(); + await settle(); + + expect(c.responseTo(init)?.body).toMatchObject({ + supportsCompletionsRequest: true, + completionTriggerCharacters: ['%'], + }); + expect( + (c.responseTo(commandsListed)?.body as { targets: Array<{ label: string }> }).targets.map( + (t) => t.label, + ), + ).toEqual(['%persist']); + expect( + (c.responseTo(namesListed)?.body as { targets: Array<{ label: string }> }).targets.map( + (t) => t.label, + ), + ).toEqual(['p']); + expect(c.responseTo(python)?.body).toEqual({ targets: [] }); + // Typing Python asks the database nothing. + expect(consoleNames).toHaveBeenCalledTimes(1); + }); + + it('shows the one-line tip about the commands at the first stop of a window, and only then', () => { + consoleTip.shown = false; + const first = started(vi.fn()); + const second = started(vi.fn()); + + const tip = (c: ReturnType) => + c.events('output').map((e) => (e.body as { output: string }).output); + expect(tip(first)).toEqual([expect.stringMatching(/type % in the Debug Console/)]); + expect(tip(second)).toEqual([]); + }); + + it('shows no tip on a saved stack, where the console does not run', () => { + consoleTip.shown = false; + const saved = started(undefined); + + expect(saved.events('output')).toEqual([]); + consoleTip.shown = true; + }); +}); diff --git a/src/consoleCommands.ts b/src/consoleCommands.ts new file mode 100644 index 0000000..e0acb67 --- /dev/null +++ b/src/consoleCommands.ts @@ -0,0 +1,172 @@ +/** + * GemDB's own commands in the Debug Console: a line that starts with `%`. + * + * Everything typed into the console reaches the debug adapter first, so it can + * take a `%` line as a command before treating it as Python — as CodeLLDB does + * with `/` and Ruby's debugger with `,`. `%` is IPython's and Jupyter's sign + * for the same idea (`%store`, `%timeit`), so a Python developer is likely to + * guess it, and no Python line can start with it, so nothing a user means as + * Python is ever taken for a command. + * + * This file only reads the line and describes the commands; the adapter runs + * them (`PauseDebugAdapter.runCommand`). + */ + +/** One command, as `%help` and autocomplete describe it. */ +export interface ConsoleCommandInfo { + name: string; + /** How it is typed. */ + usage: string; + /** What it does, in one line. */ + summary: string; +} + +export const CONSOLE_COMMANDS: ConsoleCommandInfo[] = [ + { + name: '%persist', + usage: '%persist p or %persist p as key', + summary: 'Add p to Persisted Objects (gemdb.root); it is saved when the notebook commits.', + }, + { + name: '%commit', + usage: '%commit', + summary: "Commit the notebook's changes, as ✓ in Persisted Objects does.", + }, + { + name: '%names', + usage: '%names', + summary: 'List the names you have bound in the Debug Console at this pause.', + }, + { + name: '%help', + usage: '%help', + summary: "List GemDB's Debug Console commands.", + }, +]; + +/** A `%` line, read. */ +export type ConsoleCommand = + | { kind: 'persist'; expression: string; key: string } + | { kind: 'commit' } + | { kind: 'names' } + | { kind: 'help' } + | { kind: 'invalid'; message: string }; + +/** A Python identifier: a name a console line can bind. */ +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Whether a console line is a GemDB command rather than Python. */ +export function isConsoleCommand(line: string): boolean { + return line.trimStart().startsWith('%'); +} + +/** Read a `%` line. Undefined for a line that is not one. */ +export function parseConsoleCommand(line: string): ConsoleCommand | undefined { + const text = line.trim(); + if (!text.startsWith('%')) return undefined; + const [, word = '', rest = ''] = /^%(\S*)\s*([\s\S]*)$/.exec(text) ?? []; + switch (word) { + case 'persist': { + if (!rest) return { kind: 'invalid', message: `Persist what? ${usageOf('%persist')}` }; + const as = /^([\s\S]+?)\s+as\s+(.+)$/.exec(rest); + const expression = (as ? as[1] : rest).trim(); + const key = as ? unquoted(as[2].trim()) : expression; + if (!as && !IDENTIFIER.test(expression)) { + return { + kind: 'invalid', + message: `Give it a key: %persist ${expression} as some_key`, + }; + } + if (!key) return { kind: 'invalid', message: `Give it a key. ${usageOf('%persist')}` }; + return { kind: 'persist', expression, key }; + } + case 'commit': + case 'names': + case 'help': + return rest + ? { kind: 'invalid', message: `%${word} takes nothing after it.` } + : { kind: word }; + default: + return { + kind: 'invalid', + message: `${word ? `%${word} is not a GemDB command.` : 'A command goes after the %.'} ${commandList()}`, + }; + } +} + +/** A key typed in quotes, `"orders"` or `'orders'`, without them. */ +function unquoted(text: string): string { + const quoted = /^(["'])(.*)\1$/.exec(text); + return quoted ? quoted[2] : text; +} + +function usageOf(name: string): string { + const info = CONSOLE_COMMANDS.find((c) => c.name === name); + return info ? `Type ${info.usage}.` : ''; +} + +function commandList(): string { + return `Commands: ${CONSOLE_COMMANDS.map((c) => c.name).join(', ')}.`; +} + +/** What `%help` prints. */ +export function helpText(): string { + const width = Math.max(...CONSOLE_COMMANDS.map((c) => c.usage.length)); + return [ + "GemDB's Debug Console commands (Python still works as usual):", + ...CONSOLE_COMMANDS.map((c) => ` ${c.usage.padEnd(width)} ${c.summary}`), + '', + ].join('\n'); +} + +/** The one-line tip the console shows at the first pause of a window. */ +export const CONSOLE_TIP = + 'GemDB: type % in the Debug Console for its commands. %persist p keeps p in Persisted Objects; %help lists them all.\n'; + +/** One autocomplete suggestion, as the Debug Adapter Protocol spells it. */ +export interface CompletionItem { + label: string; + text?: string; + detail?: string; + type?: 'function' | 'variable' | 'keyword'; + /** 1-based column in the typed text where the completion replaces from. */ + start?: number; + length?: number; +} + +/** + * What to suggest for the text before the cursor. Only after a `%`: a command + * name while it is being typed, and after `%persist `, the names bound in the + * console (`names`). Anything else is Python, which this does not complete. + */ +export function commandCompletions(beforeCursor: string, names: string[]): CompletionItem[] { + const word = /^(\s*)%([A-Za-z]*)$/.exec(beforeCursor); + if (word) { + const start = word[1].length + 1; + return CONSOLE_COMMANDS.filter((c) => c.name.startsWith(`%${word[2]}`)).map((c) => ({ + label: c.name, + detail: c.summary, + type: 'keyword', + start, + length: word[2].length + 1, + })); + } + const persisting = /^\s*%persist\s+([A-Za-z0-9_]*)$/.exec(beforeCursor); + if (persisting) { + const typed = persisting[1]; + return names + .filter((n) => n.startsWith(typed)) + .map((n) => ({ + label: n, + type: 'variable', + start: beforeCursor.length - typed.length + 1, + length: typed.length, + })); + } + return []; +} + +/** Whether completions are wanted at all: only for a `%` line, so Python typing costs nothing. */ +export function wantsCommandCompletions(beforeCursor: string): boolean { + return isConsoleCommand(beforeCursor); +} diff --git a/src/debugger.ts b/src/debugger.ts index 82713fa..6122656 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -4,6 +4,14 @@ import * as vscode from 'vscode'; import { runFileOf } from './fileOwner'; import { CellText, PythonFrame, firstLineOf, locateCell } from './haltStack'; import { errorMessage, log } from './log'; +import { + CONSOLE_TIP, + commandCompletions, + helpText, + isConsoleCommand, + parseConsoleCommand, + wantsCommandCompletions, +} from './consoleCommands'; import { PlacedFrame, jupyterTraceback } from './consoleTraceback'; import { ChildKind, @@ -14,10 +22,13 @@ import { TracebackFrame, childrenQuery, clearRegistryQuery, + commitQuery, consoleNameQuery, + consoleNamesQuery, consoleQuery, describeRowQuery, parseChildren, + parseConsoleNames, parseConsoleReply, parsePausedStack, pausedStackQuery, @@ -166,6 +177,8 @@ export interface Pause { * `_`, which it is then bound to (`consoleNameQuery`). */ consoleName?(frameId: number, name: string, handle: number): Promise; + /** The names bound in frame `frameId`'s console so far (`consoleNamesQuery`). */ + consoleNames?(frameId: number): Promise>; /** Each frame's whole cell or file, by frame index, so the stack can be saved. */ texts?: Array; /** The saved source behind a frame's `sourceReference`, for a stack opened from the database. */ @@ -263,6 +276,23 @@ export function toDapVariable(v: PauseVariable): DapVariable { }; } +/** + * Tell the Persisted Objects view (`savedObjects.ts`) about a `%` command's + * effect, through its command, so neither file imports the other. Never fails + * the command it reports on: the view's refresh is a courtesy. + */ +function tellView(command: string, ...args: unknown[]): void { + Promise.resolve(vscode.commands.executeCommand(command, ...args)).catch((e: unknown) => + log(`Could not update Persisted Objects: ${errorMessage(e)}`), + ); +} + +/** + * Whether this window has shown the console's one-line tip about GemDB's `%` + * commands. A test resets it. + */ +export const consoleTip = { shown: false }; + /** Pauses by id, so a launch request can find the one it was started for. */ const pauses = new Map(); let nextPauseId = 1; @@ -663,6 +693,9 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { supportsClipboardContext: true, // A console line can run for as long as the user's code does. supportsCancelRequest: true, + // For GemDB's own `%` commands only: typing `%` lists them. + supportsCompletionsRequest: true, + completionTriggerCharacters: ['%'], // Claimed so Restart arrives as a request this adapter can decline. // Unclaimed, VS Code restarts by disconnecting — which stops the // cell — and relaunching at a pause that no longer exists. @@ -809,6 +842,9 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { case 'gemdbShowRow': this.showRow(request); return; + case 'completions': + void this.complete(request); + return; case 'cancel': { const id = Number(request.arguments?.requestId ?? 0); this.running.get(id)?.abort(); @@ -902,6 +938,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { } const kind: ConsoleContext = context; const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + if (kind === 'repl' && isConsoleCommand(source)) { + void this.runCommand(request, source, frameId); + return; + } const { controller, finish } = this.track(request, kind === 'repl'); pause.evaluate(source, frameId, kind, controller.signal).then( (answer) => { @@ -1096,6 +1136,116 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { return ref; } + /** + * Run one of GemDB's `%` commands (`consoleCommands.ts`), typed in the + * Debug Console. Its answer goes to the console as text, and a mistake in + * the command fails the request, so it reads in the console's error colour. + */ + private async runCommand(request: DapRequest, line: string, frameId: number): Promise { + const pause = this.pause; + const command = parseConsoleCommand(line); + const say = (text: string): void => + this.event('output', { + category: 'console', + output: text.endsWith('\n') ? text : `${text}\n`, + }); + const done = (): void => this.respond(request, { result: '', variablesReference: 0 }); + if (!pause || !command) { + this.fail(request, NOT_HERE, false); + return; + } + try { + switch (command.kind) { + case 'invalid': + this.fail(request, command.message, false); + return; + case 'help': + say(helpText()); + done(); + return; + case 'names': { + const names = pause.consoleNames ? await pause.consoleNames(frameId) : []; + say( + names.length === 0 + ? 'Nothing bound in the Debug Console yet at this pause. Try p = …, then %names.' + : names.map((n) => `${n.name}: ${n.type} = ${n.repr}`).join('\n'), + ); + done(); + return; + } + case 'commit': { + if (!pause.query || pause.saved) throw new Error('There is nothing to commit here.'); + const answer = await pause.query(commitQuery()); + if (answer.startsWith('Error: ')) throw new Error(answer.slice('Error: '.length)); + tellView('gemdb.savedObjects.refresh'); + say(`Committed ${pause.label}: its changes are saved in the database.`); + done(); + return; + } + case 'persist': { + if (!pause.evaluate || pause.saved) + throw new Error('Nothing can be persisted from here.'); + // The expression is run once, in the console's own names, so p means what it means here. + const source = + `(lambda _gemdb_r, _gemdb_k, _gemdb_v: (_gemdb_k in _gemdb_r, ` + + `_gemdb_r.__setitem__(_gemdb_k, _gemdb_v), type(_gemdb_v).__name__))` + + `(__import__("gemdb").root, ${JSON.stringify(command.key)}, (${command.expression}))`; + const answer = await pause.evaluate( + source, + frameId, + 'repl', + new AbortController().signal, + ); + if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); + if (answer.kind !== 'value') { + this.fail(request, answer.kind === 'error' ? answer.traceback : INTERRUPTED, false); + return; + } + const replaced = answer.value.startsWith('(True'); + const type = /'([^']*)'\)$/.exec(answer.value)?.[1] ?? ''; + tellView('gemdb.savedObjects.notePersisted', { + key: command.key, + type, + ownerKey: pause.ownerKey, + notebook: pause.label, + }); + say( + `Added ${command.expression} to Persisted Objects as gemdb.root[${JSON.stringify(command.key)}]` + + `${replaced ? ', replacing what was there' : ''}. It is saved when ${pause.label} commits: ` + + 'type %commit, or click ✓ in Persisted Objects.', + ); + if (this.clientInvalidates) this.event('invalidated', { areas: ['variables'] }); + done(); + return; + } + } + } catch (e) { + this.fail(request, errorMessage(e), false); + } + } + + /** + * Autocomplete for GemDB's `%` commands, and for a name after `%persist `. + * Python is not completed here: any line that is not a `%` command answers + * nothing, without asking the database. + */ + private async complete(request: DapRequest): Promise { + const text = String(request.arguments?.text ?? ''); + const column = Number(request.arguments?.column ?? text.length + 1); + const before = text.slice(0, Math.max(0, column - 1)); + const pause = this.pause; + if (!pause || !wantsCommandCompletions(before)) { + this.respond(request, { targets: [] }); + return; + } + const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + const names = + /^\s*%persist\s+/.test(before) && pause.consoleNames + ? (await pause.consoleNames(frameId).catch(() => [])).map((n) => n.name) + : []; + this.respond(request, { targets: commandCompletions(before, names) }); + } + dispose(): void { for (const controller of this.running.values()) controller.abort(); // The session went away without Continue or Stop — a closed window, say. @@ -1114,6 +1264,11 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { threadId: THREAD_ID, allThreadsStopped: true, }); + // Once a window: where a developer will look next, say that GemDB's commands exist. + if (!consoleTip.shown && this.pause?.evaluate) { + consoleTip.shown = true; + this.event('output', { category: 'console', output: CONSOLE_TIP }); + } } /** The first of Continue, Stop or disposal decides; the rest are echoes. */ @@ -1415,6 +1570,21 @@ const KEYWORDS = new Set([ 'yield', ]); +/** The names bound in one frame's console so far (`Pause.consoleNames`). */ +async function consoleNamesAt( + request: HaltRequest, + view: PauseView, + consoleRefs: Map, + frameId: number, +): Promise> { + const scope = view.consoles.get(frameId) ?? { locals: 0, globals: 0 }; + const { consoleRef, names } = parseConsoleNames( + await request.query(consoleNamesQuery(scope, consoleRefs.get(frameId) ?? 0)), + ); + if (consoleRef > 0) consoleRefs.set(frameId, consoleRef); + return names; +} + /** What to type to reach a shown row's object in one frame's console (`Pause.consoleName`). */ async function consoleNameAt( request: HaltRequest, @@ -1609,6 +1779,7 @@ export function registerBreakpointDebugger(): vscode.Disposable { showRow: (handle, name) => showRowAt(request, handle, name), consoleName: (frameId, name, handle) => consoleNameAt(request, view, consoleRefs, frameId, name, handle), + consoleNames: (frameId) => consoleNamesAt(request, view, consoleRefs, frameId), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, reason: request.reason, diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index 71fd52f..eabcceb 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -1067,7 +1067,7 @@ bind := [:k :x | | nm | nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. dict at: nm put: x]. console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil]. -(console class == Array and: [console size = 2 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ +(console class == Array and: [console size = 3 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ dict := SymbolDictionary new. g := (${scope.globals} between: 1 and: reg size) ifTrue: [reg at: ${scope.globals}] ifFalse: [nil]. (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) @@ -1077,7 +1077,8 @@ console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil] l := (${scope.locals} between: 1 and: reg size) ifTrue: [reg at: ${scope.locals}] ifFalse: [nil]. (l class == Array and: [l size >= 2 and: [(l at: 1) == #gemdbScope]]) ifTrue: [(l at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. - console := { #gemdbConsole. dict }. + "The names it starts with, so the ones the console binds can be told apart (%names)." + console := { #gemdbConsole. dict. dict keys copy }. reg add: console. cref := reg size]. `; @@ -1125,7 +1126,7 @@ dict at: #'_gemdb_shown' put: o. * console scope and answers what came of it (`parseConsoleReply`). * * The scope is made on a frame's first line and kept in the pause's registry - * as `{#gemdbConsole. dict}`, at `consoleRef` once made, so a name the + * as `{#gemdbConsole. dict. startingNames}`, at `consoleRef` once made, so a name the * console binds (`y = 5`, `import json`, a `def`) is there for the next line * and gone with the registry at resume, as under debugpy. It is one * SymbolDictionary run as a module scope: the frame's globals, copied, with @@ -1258,6 +1259,54 @@ ${withQuietConsole(`describe value: '${escapeString(name)}' value: obj`)} out contents encodeAsUTF8`; } +/** + * Smalltalk answering the names bound in frame `scope`'s console since it was + * made — what the user typed, not the frame's own names — one per record, as + * `name`, field, `type`, field, short repr, in their Python spelling (`self`, + * not Grail's stand-in). GemDB's own `_gemdb…` names are left out. For + * `%names`, and for completing a name after `%persist `. + */ +export function consoleNamesQuery(scope: ConsoleScope, consoleRef: number): string { + return `| reg dispatcher cref console dict g l bind nameAst none field record out names | +${PREAMBLE} +${consoleScopeSetup(scope, consoleRef)} +dict := console at: 2. +names := (dict keys reject: [:k | (console at: 3) includes: k]) asSortedCollection: [:a :b | a <= b]. +names do: [:k | | v nm tname text | + nm := nameAst isNil ifTrue: [k asString] + ifFalse: [[(nameAst doitScopeNameToPythonName: k) asString] on: Error do: [:e | e return: k asString]]. + "Grail carries Python's _ under a spelling of its own (measured: ___unused___)." + nm = '___unused___' ifTrue: [nm := '_']. + (nm size >= 6 and: [(nm copyFrom: 1 to: 6) = '_gemdb']) ifFalse: [ + v := dict at: k. + tname := [((v @env1:___pyAttrLoad___: #'__class__') @env1:___pyAttrLoad___: #'__name__') asString] + on: AbstractException do: [:e | e return: v class name asString]. + text := [(v @env1:__repr__) asString] on: AbstractException do: [:e | e return: '?']. + text size > 80 ifTrue: [text := (text copyFrom: 1 to: 77) , '...']. + out nextPutAll: nm; nextPut: field; nextPutAll: tname; nextPut: field; + nextPutAll: (text collect: [:c | (c == field or: [c == record]) ifTrue: [$?] ifFalse: [c]]); + nextPut: record]]. +(cref printString , (String with: record) , out contents) encodeAsUTF8`; +} + +/** Parse `consoleNamesQuery`'s answer: the console's position, and its names. */ +export function parseConsoleNames(raw: string): { + consoleRef: number; + names: Array<{ name: string; type: string; repr: string }>; +} { + if (raw.startsWith('Error: ')) throw new Error(raw.slice('Error: '.length)); + const [ref = '', ...records] = raw.split(RECORD); + return { + consoleRef: Number.parseInt(ref, 10) || 0, + names: records + .filter((r) => r.length > 0) + .map((r) => { + const [name = '', type = '', repr = ''] = r.split(FIELD); + return { name, type, repr }; + }), + }; +} + /** Parse `consoleQuery`'s answer. */ export function parseConsoleReply(raw: string): ConsoleReply { if (raw.startsWith('Error: ')) { diff --git a/src/savedObjects.ts b/src/savedObjects.ts index ec4ac78..d4bed8d 100644 --- a/src/savedObjects.ts +++ b/src/savedObjects.ts @@ -1159,6 +1159,20 @@ export function registerSavedObjects(context: vscode.ExtensionContext): SavedObj saveVariable(view, treeViews, ctx), ), vscode.commands.registerCommand('gemdb.savedObjects.refresh', () => view.refresh()), + // Internal, for `%persist` in the Debug Console (`debugger.ts`): show the + // save as pending, as Add to Persisted Objects does. + vscode.commands.registerCommand( + 'gemdb.savedObjects.notePersisted', + (save: Omit) => { + view.notePending({ ...save, at: new Date().toISOString() }); + const shown = treeViews.get(DEBUG_VIEW_ID); + if (shown) { + view + .revealSaved(shown, save.key) + .catch((e: unknown) => log(`Could not show ${save.key}: ${errorMessage(e)}`)); + } + }, + ), vscode.commands.registerCommand('gemdb.saveStack', () => saveStack(view, treeViews)), vscode.commands.registerCommand('gemdb.restoreStack', () => restoreStack()), vscode.commands.registerCommand('gemdb.savedObjects.openStack', (row: SavedRow) => From a3664a68fdd921a77a7c5c802aa345f128d4cf25 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 09:56:58 -0700 Subject: [PATCH 09/15] Make gemdb available in the Debug Console without an import A line copied from Persisted Objects reads gemdb.root["key"], and in a program that never imported gemdb it failed with NameError. Each frame's console scope now starts with gemdb bound, unless the frame has a name of that spelling of its own, which wins. It is not listed by %names. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +++- README.md | 3 ++- src/__integration__/debugConsole.test.ts | 3 ++- src/pauseVariables.ts | 9 +++++++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280aad0..80c0a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,7 +49,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Python: `%persist p` adds `p` to Persisted Objects (or `%persist expr as key`), shown there as added until the notebook commits; `%commit` commits the notebook; `%names` lists the names you have bound in the console at this pause; `%help` lists the commands. Typing `%` suggests them, - and `%persist ` suggests your console names. The first pause in a window says so, once. + and `%persist ` suggests your console names. The first pause in a window says so, once. `gemdb` + is available in the console without an import, so a line copied from Persisted Objects + (`gemdb.root["orders"]`) works in any program. ### Fixed diff --git a/README.md b/README.md index f574607..251aa61 100644 --- a/README.md +++ b/README.md @@ -435,7 +435,8 @@ database, select that kernel from the kernel picker at the top right. there. Lines starting with `%` are GemDB commands, not Python: `%persist p` adds `p` to Persisted Objects (`%persist expr as key` for anything else), `%commit` commits the notebook, `%names` lists what you have bound in the console, and `%help` lists them all. Type `%` and the - console suggests them. Stepping is not supported yet, and neither are red + console suggests them. `gemdb` is always available in the console, so a line copied from Persisted + Objects, such as `gemdb.root["orders"]`, works even if the program never imported it. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 807f613..5965224 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -957,7 +957,8 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' seen.persistAs = await said('%persist items[0] as first_item'); seen.noKey = await said('%persist items[0]'); seen.names = await said('%names'); - seen.inRoot = await dap.value('__import__("gemdb").root["p"]'); + // As Persisted Objects copies it: the program never imported gemdb, and it still works. + seen.inRoot = await dap.value('gemdb.root["p"]'); const complete = async (text: string) => ( (await dap.request('completions', { text, column: text.length + 1, frameId: 1 })).body diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index eabcceb..acc84fa 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -1077,6 +1077,12 @@ console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil] l := (${scope.locals} between: 1 and: reg size) ifTrue: [reg at: ${scope.locals}] ifFalse: [nil]. (l class == Array and: [l size >= 2 and: [(l at: 1) == #gemdbScope]]) ifTrue: [(l at: 2) keysAndValuesDo: [:k :x | bind value: k value: x]]. + "gemdb itself, unless the frame has a name of its own spelled so: a line copied from + Persisted Objects (gemdb.root[...]) then works in any program, imported or not." + (dict includesKey: #gemdb) ifFalse: [ + g := [dispatcher evaluateSource: 'import gemdb +gemdb' usingModuleScope: SymbolDictionary new] on: AbstractException do: [:e | e return: nil]. + g notNil ifTrue: [dict at: #gemdb put: g]]. "The names it starts with, so the ones the console binds can be told apart (%names)." console := { #gemdbConsole. dict. dict keys copy }. reg add: console. @@ -1133,6 +1139,9 @@ dict at: #'_gemdb_shown' put: o. * its locals copied over them, so a local shadows a global as it does in the * frame's own code, and a comprehension or lambda sees the locals — with * locals in a dict of their own, as debugpy evaluates, it would not. + * `gemdb` is bound too when the frame has no name of that spelling, so a line + * copied from Persisted Objects (`gemdb.root["orders"]`) runs in a program + * that never imported it. * Because they are copies, rebinding a name (`x = 5`) stays in the console; * the objects are the real ones, so `p.x = 10` changes the program's `p`. * A frame's locals are keyed by their Python spelling, but a scope evaluated From 75c28b9fd8d1b0efe735182b1ebc0cd3532014c8 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 09:59:23 -0700 Subject: [PATCH 10/15] Make Persisted Objects' entries available by key in the Debug Console At a pause, each gemdb.root entry whose key is a Python name is there under that name: o for gemdb.root["o"]. The frame's own names win, a builtin (list, print) is never hidden, and %names leaves them out, since the user did not bind them at this pause. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 ++- README.md | 3 ++- src/__integration__/debugConsole.test.ts | 27 ++++++++++++++++++++++++ src/pauseVariables.ts | 13 +++++++++++- 4 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80c0a7a..14e4a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 have bound in the console at this pause; `%help` lists the commands. Typing `%` suggests them, and `%persist ` suggests your console names. The first pause in a window says so, once. `gemdb` is available in the console without an import, so a line copied from Persisted Objects - (`gemdb.root["orders"]`) works in any program. + (`gemdb.root["orders"]`) works in any program, and each persisted entry is there by its key + (`orders`) at every pause, unless the paused code has a name of its own spelled so. ### Fixed diff --git a/README.md b/README.md index 251aa61..4304f12 100644 --- a/README.md +++ b/README.md @@ -436,7 +436,8 @@ database, select that kernel from the kernel picker at the top right. Persisted Objects (`%persist expr as key` for anything else), `%commit` commits the notebook, `%names` lists what you have bound in the console, and `%help` lists them all. Type `%` and the console suggests them. `gemdb` is always available in the console, so a line copied from Persisted - Objects, such as `gemdb.root["orders"]`, works even if the program never imported it. Stepping is not supported yet, and neither are red + Objects, such as `gemdb.root["orders"]`, works even if the program never imported it. What Persisted Objects holds is available by its + key too (`o` for `gemdb.root["o"]`), unless the paused code has its own name `o`. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 5965224..876be83 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -1002,4 +1002,31 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' }); expect(seen.elsewhere).toContain("'B400'"); }); + it('makes what Persisted Objects holds available by its key at a later pause', async () => { + const nb = notebook('dc-persisted-names'); + // Persisted earlier, as %persist would leave it after %commit. + await runPython( + 'import gemdb\ngemdb.root["kept"] = [1, 2]\ngemdb.root["n"] = "persisted n"\ngemdb.root["list"] = "not the builtin"\ngemdb.root["two words"] = 2\ngemdb.commit()', + nb, + ); + const seen: Record = {}; + + await inCell(nb, 'def go(n):\n breakpoint()\ngo("the local n")', async (dap) => { + seen.kept = await dap.value('kept'); + // The frame's own name wins over a persisted one of the same name. + seen.n = await dap.value('n'); + // A builtin is never hidden. + seen.list = await dap.value('list((1, 2))'); + seen.names = (await dap.evaluate('%names')).printed; + }); + closeSessionFor(nb.key); + + expect(seen).toEqual({ + kept: '[1, 2]', + n: "'the local n'", + list: '[1, 2]', + // Persisted names are there to use, not names the user bound here. + names: 'Nothing bound in the Debug Console yet at this pause. Try p = …, then %names.\n', + }); + }); }); diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index acc84fa..f781fb0 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -1083,6 +1083,15 @@ console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil] g := [dispatcher evaluateSource: 'import gemdb gemdb' usingModuleScope: SymbolDictionary new] on: AbstractException do: [:e | e return: nil]. g notNil ifTrue: [dict at: #gemdb put: g]]. + "Then what Persisted Objects holds, each under its key: o for gemdb.root['o']. Never over a + name the frame has, nor over a builtin (list, print), and only keys that are Python names." + g := [dispatcher evaluateSource: 'import gemdb, builtins, keyword +[(k, v) for k, v in gemdb.root.items() if isinstance(k, str) and k.isidentifier() and not keyword.iskeyword(k) and not hasattr(builtins, k)]' usingModuleScope: SymbolDictionary new] + on: AbstractException do: [:e | e return: #()]. + g do: [:pair | | nm | + nm := (pair at: 1) asString asSymbol. + nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. + (dict includesKey: nm) ifFalse: [dict at: nm put: (pair at: 2)]]. "The names it starts with, so the ones the console binds can be told apart (%names)." console := { #gemdbConsole. dict. dict keys copy }. reg add: console. @@ -1141,7 +1150,9 @@ dict at: #'_gemdb_shown' put: o. * locals in a dict of their own, as debugpy evaluates, it would not. * `gemdb` is bound too when the frame has no name of that spelling, so a line * copied from Persisted Objects (`gemdb.root["orders"]`) runs in a program - * that never imported it. + * that never imported it; and so is each entry of `gemdb.root` under its key + * (`orders`), below the frame's own names and never over a builtin, so what a + * user persisted is there by name at every later pause. * Because they are copies, rebinding a name (`x = 5`) stays in the console; * the objects are the real ones, so `p.x = 10` changes the program's `p`. * A frame's locals are keyed by their Python spelling, but a scope evaluated From bb3f87fc02b75d1d9f5750f78e8f4b763f4c92ac Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 10:07:32 -0700 Subject: [PATCH 11/15] Label a persisted name in the Debug Console, and add persisted.o When a console line is just a name and its value is the Persisted Objects entry of that key, the console says so above the value: "o is gemdb.root[\"o\"], from Persisted Objects (also persisted.o)". A name of the paused code's own, or one bound in the console, gets no label. persisted is always in the console, below the frame's own names: persisted.o is gemdb.root["o"] whatever o means at the pause, and persisted alone lists what Persisted Objects holds. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 4 +- README.md | 3 +- src/__integration__/debugConsole.test.ts | 30 +++++++++- src/consoleCommands.ts | 3 + src/debugger.ts | 36 ++++++++++-- src/pauseVariables.ts | 73 ++++++++++++++++++++---- 6 files changed, 129 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14e4a7b..2e540ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `%persist ` suggests your console names. The first pause in a window says so, once. `gemdb` is available in the console without an import, so a line copied from Persisted Objects (`gemdb.root["orders"]`) works in any program, and each persisted entry is there by its key - (`orders`) at every pause, unless the paused code has a name of its own spelled so. + (`orders`) at every pause, unless the paused code has a name of its own spelled so, and is + labelled `orders is gemdb.root["orders"]` when typed; `persisted.orders` reaches it whatever + `orders` means at the pause. ### Fixed diff --git a/README.md b/README.md index 4304f12..91897e2 100644 --- a/README.md +++ b/README.md @@ -437,7 +437,8 @@ database, select that kernel from the kernel picker at the top right. `%names` lists what you have bound in the console, and `%help` lists them all. Type `%` and the console suggests them. `gemdb` is always available in the console, so a line copied from Persisted Objects, such as `gemdb.root["orders"]`, works even if the program never imported it. What Persisted Objects holds is available by its - key too (`o` for `gemdb.root["o"]`), unless the paused code has its own name `o`. Stepping is not supported yet, and neither are red + key too (`o` for `gemdb.root["o"]`), unless the paused code has its own name `o`; `persisted.o` reaches it whatever `o` means, and a + bare persisted name is labelled with where it came from. Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index 876be83..d9b57dd 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -1009,10 +1009,22 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' 'import gemdb\ngemdb.root["kept"] = [1, 2]\ngemdb.root["n"] = "persisted n"\ngemdb.root["list"] = "not the builtin"\ngemdb.root["two words"] = 2\ngemdb.commit()', nb, ); - const seen: Record = {}; + const seen: Record = {}; await inCell(nb, 'def go(n):\n breakpoint()\ngo("the local n")', async (dap) => { seen.kept = await dap.value('kept'); + // Said above a persisted value; nothing said for the paused code's own name. + seen.keptSaid = (await dap.evaluate('kept')).printed; + seen.nSaid = (await dap.evaluate('n')).printed; + await dap.value('kept2 = kept'); + seen.rebound = (await dap.evaluate('kept2')).printed; + // Rebind kept in the console: persisted.kept still reaches the persisted one. + await dap.value('kept = "mine now"'); + seen.keptMine = await dap.value('kept'); + seen.persistedKept = await dap.value('persisted.kept'); + seen.persistedN = await dap.value('persisted.n'); + seen.persistedList = await dap.value('persisted'); + seen.persistedNope = await dap.raises('persisted.nope'); // The frame's own name wins over a persisted one of the same name. seen.n = await dap.value('n'); // A builtin is never hidden. @@ -1023,10 +1035,22 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' expect(seen).toEqual({ kept: '[1, 2]', + keptSaid: 'kept is gemdb.root["kept"], from Persisted Objects (also persisted.kept)\n', + keptMine: "'mine now'", + persistedKept: '[1, 2]', + persistedN: "'persisted n'", + // Earlier tests in this file commit their own entries too. + persistedList: expect.stringMatching( + /^Persisted Objects: (.*, )?kept, list, n, (.*, )?two words$/, + ), + persistedNope: "AttributeError: Persisted Objects has no 'nope'", + nSaid: '', + // A name the user bound is theirs: nothing said. + rebound: '', n: "'the local n'", list: '[1, 2]', - // Persisted names are there to use, not names the user bound here. - names: 'Nothing bound in the Debug Console yet at this pause. Try p = …, then %names.\n', + // Persisted names are there to use, not names the user bound here: only kept2 is. + names: 'kept2: list = [1, 2]\n', }); }); }); diff --git a/src/consoleCommands.ts b/src/consoleCommands.ts index e0acb67..e71e602 100644 --- a/src/consoleCommands.ts +++ b/src/consoleCommands.ts @@ -116,6 +116,9 @@ export function helpText(): string { "GemDB's Debug Console commands (Python still works as usual):", ...CONSOLE_COMMANDS.map((c) => ` ${c.usage.padEnd(width)} ${c.summary}`), '', + 'What Persisted Objects holds is here by name (o for gemdb.root["o"]), and always as', + 'persisted.o, even when o means something else at this pause. Type persisted to list it.', + '', ].join('\n'); } diff --git a/src/debugger.ts b/src/debugger.ts index 6122656..40f4f00 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -16,6 +16,7 @@ import { PlacedFrame, jupyterTraceback } from './consoleTraceback'; import { ChildKind, ConsoleScope, + NameOrigin, MAIN_MODULE, PAGE, PauseVariable, @@ -127,6 +128,8 @@ export type ConsoleAnswer = namedVariables?: number; /** How its children are read: a container's items, or an object's attributes. */ children?: ChildKind; + /** For a bare name that is a Persisted Objects entry, `persisted` (`NameOrigin`). */ + origin?: NameOrigin; } | { kind: 'error'; output: string; traceback: string } | { kind: 'interrupted'; output: string }; @@ -947,6 +950,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { (answer) => { finish(); if (answer.output) this.event('output', { category: 'stdout', output: answer.output }); + // A bare name that is a Persisted Objects entry: say where it comes from. + if (answer.kind === 'value' && answer.origin === 'persisted') { + this.event('output', { category: 'console', output: originLine(source.trim()) }); + } if (answer.kind === 'value') { this.respond(request, { result: answer.value, @@ -1530,6 +1537,15 @@ function placeFrame( }; } +/** + * The line the console prints above a bare name's value when it is a Persisted + * Objects entry, so it is not mistaken for a name of the paused code's own. A + * name of the code's own gets no line: it is what a bare name always means. + */ +export function originLine(name: string): string { + return `${name} is gemdb.root[${JSON.stringify(name)}], from Persisted Objects (also persisted.${name})\n`; +} + /** A Python identifier, which a console line can use as a name. */ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; const KEYWORDS = new Set([ @@ -1650,11 +1666,20 @@ async function evaluateAt( ): Promise { const scope = view.consoles.get(frameId) ?? { locals: 0, globals: 0 }; const reply = parseConsoleReply( - await request.query(consoleQuery(scope, consoleRefs.get(frameId) ?? 0, source, selfHandle), { - budget: context === 'watch', - signal, - passHalts: true, - }), + await request.query( + consoleQuery( + scope, + consoleRefs.get(frameId) ?? 0, + source, + selfHandle, + context === 'repl' && IDENTIFIER.test(source.trim()) ? source.trim() : '', + ), + { + budget: context === 'watch', + signal, + passHalts: true, + }, + ), ); if (reply.consoleRef > 0) consoleRefs.set(frameId, reply.consoleRef); const output = reply.output; @@ -1672,6 +1697,7 @@ async function evaluateAt( ...(row.indexedVariables ? { indexedVariables: row.indexedVariables } : {}), ...(row.namedVariables ? { namedVariables: row.namedVariables } : {}), ...(reply.row.kind ? { children: reply.row.kind } : {}), + ...(reply.origin ? { origin: reply.origin } : {}), }; } case 'none': diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index f781fb0..45710a7 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -1025,9 +1025,16 @@ export interface ConsoleError { frames: TracebackFrame[]; } +/** + * For a console line that is a bare name: `persisted` when its value is the + * Persisted Objects entry of that key, rather than a name of the paused code's + * own or one the console bound. + */ +export type NameOrigin = 'persisted'; + /** What `consoleQuery` answered. */ export type ConsoleReply = { consoleRef: number; output: string } & ( - | { kind: 'value'; row: PauseVariable } + | { kind: 'value'; row: PauseVariable; origin?: NameOrigin } | { kind: 'none' } | { kind: 'error'; error: ConsoleError } | { kind: 'interrupted' } @@ -1067,7 +1074,9 @@ bind := [:k :x | | nm | nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. dict at: nm put: x]. console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil]. -(console class == Array and: [console size = 3 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [ +(console class == Array and: [console size = 4 and: [(console at: 1) == #gemdbConsole]]) ifFalse: [| persisted | + "Which names came from Persisted Objects, so a bare name typed later can say so." + persisted := SymbolDictionary new. dict := SymbolDictionary new. g := (${scope.globals} between: 1 and: reg size) ifTrue: [reg at: ${scope.globals}] ifFalse: [nil]. (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) @@ -1083,6 +1092,29 @@ console := (cref between: 1 and: reg size) ifTrue: [reg at: cref] ifFalse: [nil] g := [dispatcher evaluateSource: 'import gemdb gemdb' usingModuleScope: SymbolDictionary new] on: AbstractException do: [:e | e return: nil]. g notNil ifTrue: [dict at: #gemdb put: g]]. + "persisted, the same entries by attribute (persisted.o), there even when o means something else. + Made once a session: its class is compiled on first use, and that is a write." + (dict includesKey: #persisted) ifFalse: [ + g := SessionTemps current at: #'GemDbPersistedView' otherwise: nil. + g isNil ifTrue: [ + g := [dispatcher evaluateSource: 'import gemdb +class PersistedObjects: + """What Persisted Objects holds, by name: persisted.o is gemdb.root["o"].""" + def __getattr__(self, name): + try: + return gemdb.root[name] + except KeyError: + raise AttributeError(f"Persisted Objects has no {name!r}") from None + def __getitem__(self, key): + return gemdb.root[key] + def __dir__(self): + return sorted(k for k in gemdb.root.keys() if isinstance(k, str)) + def __repr__(self): + return "Persisted Objects: " + (", ".join(self.__dir__()) or "nothing yet") +PersistedObjects()' usingModuleScope: SymbolDictionary new] + on: AbstractException do: [:e | e return: nil]. + g notNil ifTrue: [SessionTemps current at: #'GemDbPersistedView' put: g]]. + g notNil ifTrue: [dict at: #persisted put: g]]. "Then what Persisted Objects holds, each under its key: o for gemdb.root['o']. Never over a name the frame has, nor over a builtin (list, print), and only keys that are Python names." g := [dispatcher evaluateSource: 'import gemdb, builtins, keyword @@ -1091,9 +1123,9 @@ gemdb' usingModuleScope: SymbolDictionary new] on: AbstractException do: [:e | e g do: [:pair | | nm | nm := (pair at: 1) asString asSymbol. nameAst notNil ifTrue: [nm := [nameAst doitScopeNameFor: nm] on: Error do: [:e | e return: nm]]. - (dict includesKey: nm) ifFalse: [dict at: nm put: (pair at: 2)]]. + (dict includesKey: nm) ifFalse: [dict at: nm put: (pair at: 2). persisted at: nm put: (pair at: 2)]]. "The names it starts with, so the ones the console binds can be told apart (%names)." - console := { #gemdbConsole. dict. dict keys copy }. + console := { #gemdbConsole. dict. dict keys copy. persisted }. reg add: console. cref := reg size]. `; @@ -1141,7 +1173,7 @@ dict at: #'_gemdb_shown' put: o. * console scope and answers what came of it (`parseConsoleReply`). * * The scope is made on a frame's first line and kept in the pause's registry - * as `{#gemdbConsole. dict. startingNames}`, at `consoleRef` once made, so a name the + * as `{#gemdbConsole. dict. startingNames. persisted}`, at `consoleRef` once made, so a name the * console binds (`y = 5`, `import json`, a `def`) is there for the next line * and gone with the registry at resume, as under debugpy. It is one * SymbolDictionary run as a module scope: the frame's globals, copied, with @@ -1152,7 +1184,9 @@ dict at: #'_gemdb_shown' put: o. * copied from Persisted Objects (`gemdb.root["orders"]`) runs in a program * that never imported it; and so is each entry of `gemdb.root` under its key * (`orders`), below the frame's own names and never over a builtin, so what a - * user persisted is there by name at every later pause. + * user persisted is there by name at every later pause. `persisted` reaches + * the same entries by attribute (`persisted.o`), so a persisted object stays + * reachable by a short name when `o` means something else here. * Because they are copies, rebinding a name (`x = 5`) stays in the console; * the objects are the real ones, so `p.x = 10` changes the program's `p`. * A frame's locals are keyed by their Python spelling, but a scope evaluated @@ -1180,8 +1214,16 @@ export function consoleQuery( consoleRef: number, source: string, selfHandle = 0, + bareName = '', ): string { - return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst target ${ROW_TEMPS} | + const origin = bareName + ? `kind == #value ifTrue: [| key | + key := '${escapeString(bareName)}' asSymbol. + nameAst notNil ifTrue: [key := [nameAst doitScopeNameFor: key] on: Error do: [:e | e return: key]]. + (((console at: 4) includesKey: key) and: [((console at: 4) at: key) == r]) + ifTrue: [origin := 'persisted']].` + : ''; + return `| reg out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher cref console dict g l r kind exc captured bind nameAst target origin ${ROW_TEMPS} | ${PREAMBLE} ${HELPERS} ${CHILDREN_OF} @@ -1212,7 +1254,10 @@ SessionTemps current at: #'GrailConsole' put: (Array with: captured). ifTrue: [SessionTemps current removeKey: #'GrailConsole' ifAbsent: []] ifFalse: [SessionTemps current at: #'GrailConsole' put: saved]]. (kind == #value and: [r == none or: [r isNil]]) ifTrue: [kind := #none]. -out nextPutAll: kind; nextPut: field; print: cref; nextPut: field; nextPutAll: (clean value: captured contents); nextPut: record. +origin := ''. +${origin} +out nextPutAll: kind; nextPut: field; print: cref; nextPut: field; nextPutAll: (clean value: captured contents); + nextPut: field; nextPutAll: origin; nextPut: record. kind == #value ifTrue: [ "The result's own row, as the Variables pane would write it, with its __repr__ quiet." SessionTemps current at: #'GrailConsole' put: (Array with: (WriteStream on: Unicode7 new)). @@ -1333,13 +1378,21 @@ export function parseConsoleReply(raw: string): ConsoleReply { return { consoleRef: 0, output: '', kind: 'failed', message: raw.slice('Error: '.length) }; } const [head = '', ...rest] = raw.split(RECORD); - const [kind, ref, printed] = head.split(FIELD); + const [kind, ref, printed, origin] = head.split(FIELD); const consoleRef = Number.parseInt(ref ?? '', 10) || 0; const output = (printed ?? '').replace(/\r\n?/g, '\n'); const records = rest.filter((r) => r.length > 0); if (kind === 'value') { const [row] = parseChildren(records.join(RECORD) + RECORD); - if (row) return { consoleRef, output, kind, row }; + if (row) { + return { + consoleRef, + output, + kind, + row, + ...(origin === 'persisted' ? { origin } : {}), + }; + } return { consoleRef, output, kind: 'failed', message: 'The result could not be read.' }; } if (kind === 'none' || kind === 'interrupted') return { consoleRef, output, kind }; From 0ee9661f99e5cece755ae0ca969a8bbf44d8275a Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 10:15:11 -0700 Subject: [PATCH 12/15] Offer Persisted Objects in the Debug Console's autocomplete While a name is typed, autocomplete offers persisted.key for each Persisted Objects entry the name starts, described as the gemdb.root entry it is, so a persisted object can be found without knowing that persisted exists; after persisted. it offers the keys. The keys are read once a pause and again only after a console line, so a keystroke costs the database nothing, and no user code runs. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 3 +- README.md | 3 +- src/__integration__/debugConsole.test.ts | 10 +++++ src/__tests__/consoleCommands.test.ts | 47 +++++++++++++++++++++++ src/__tests__/debugConsole.test.ts | 30 +++++++++++++++ src/consoleCommands.ts | 44 +++++++++++++++++++-- src/debugger.ts | 49 ++++++++++++++++++------ src/pauseVariables.ts | 21 ++++++++++ 8 files changed, 191 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e540ee..4436c88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,7 +54,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`gemdb.root["orders"]`) works in any program, and each persisted entry is there by its key (`orders`) at every pause, unless the paused code has a name of its own spelled so, and is labelled `orders is gemdb.root["orders"]` when typed; `persisted.orders` reaches it whatever - `orders` means at the pause. + `orders` means at the pause. Typing a name offers `persisted.orders` in autocomplete for each + persisted entry it starts, and `persisted.` offers the keys. ### Fixed diff --git a/README.md b/README.md index 91897e2..9f34bc7 100644 --- a/README.md +++ b/README.md @@ -438,7 +438,8 @@ database, select that kernel from the kernel picker at the top right. console suggests them. `gemdb` is always available in the console, so a line copied from Persisted Objects, such as `gemdb.root["orders"]`, works even if the program never imported it. What Persisted Objects holds is available by its key too (`o` for `gemdb.root["o"]`), unless the paused code has its own name `o`; `persisted.o` reaches it whatever `o` means, and a - bare persisted name is labelled with where it came from. Stepping is not supported yet, and neither are red + bare persisted name is labelled with where it came from. While you type a name, autocomplete + offers `persisted.o` for each persisted entry it starts (press Tab to take a suggestion). Stepping is not supported yet, and neither are red dots in a cell (they work in `.py` files; see above). Right-click a Variables row and choose **Add to Persisted Objects…** to keep that object: it goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's next commit. The **Persisted Objects** view, under GemDB and in Run and Debug, lists what `gemdb.root` diff --git a/src/__integration__/debugConsole.test.ts b/src/__integration__/debugConsole.test.ts index d9b57dd..88463f1 100644 --- a/src/__integration__/debugConsole.test.ts +++ b/src/__integration__/debugConsole.test.ts @@ -1025,6 +1025,13 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' seen.persistedN = await dap.value('persisted.n'); seen.persistedList = await dap.value('persisted'); seen.persistedNope = await dap.raises('persisted.nope'); + const complete = async (text: string) => + ( + (await dap.request('completions', { text, column: text.length + 1, frameId: 1 })).body + ?.targets as Array<{ label: string }> + ).map((t) => t.label); + seen.typingKe = await complete('ke'); + seen.afterDot = await complete('persisted.k'); // The frame's own name wins over a persisted one of the same name. seen.n = await dap.value('n'); // A builtin is never hidden. @@ -1044,6 +1051,9 @@ describe.skipIf(!haveExtent || !canMakeFixture())('the Debug Console at a pause' /^Persisted Objects: (.*, )?kept, list, n, (.*, )?two words$/, ), persistedNope: "AttributeError: Persisted Objects has no 'nope'", + // Found by typing: the persisted entry offered while its name is typed, and after persisted. + typingKe: ['persisted.kept'], + afterDot: ['kept'], nSaid: '', // A name the user bound is theirs: nothing said. rebound: '', diff --git a/src/__tests__/consoleCommands.test.ts b/src/__tests__/consoleCommands.test.ts index 8eb6ae9..d74bcf9 100644 --- a/src/__tests__/consoleCommands.test.ts +++ b/src/__tests__/consoleCommands.test.ts @@ -5,6 +5,7 @@ import { helpText, isConsoleCommand, parseConsoleCommand, + persistedCompletions, } from '../consoleCommands'; /** GemDB's `%` commands in the Debug Console: reading a line, and what is suggested while typing one. */ @@ -103,3 +104,49 @@ describe('autocomplete', () => { expect(commandCompletions('x % ', ['p'])).toEqual([]); }); }); + +describe('autocomplete for Persisted Objects while Python is typed', () => { + const keys = ['entry', 'o', 'orders', 'two words']; + + it('offers persisted.key for each persisted key the typed name starts, as the gemdb.root entry it is', () => { + expect(persistedCompletions('o', keys)).toEqual([ + { + label: 'persisted.o', + detail: 'gemdb.root["o"] in Persisted Objects', + type: 'variable', + start: 1, + length: 1, + }, + { + label: 'persisted.orders', + detail: 'gemdb.root["orders"] in Persisted Objects', + type: 'variable', + start: 1, + length: 1, + }, + ]); + }); + + it('replaces only the name being typed, inside a longer line', () => { + expect(persistedCompletions('len(ent', keys)).toEqual([ + expect.objectContaining({ label: 'persisted.entry', start: 5, length: 3 }), + ]); + }); + + it('offers the keys after persisted.', () => { + expect(persistedCompletions('persisted.', keys).map((c) => c.label)).toEqual([ + 'entry', + 'o', + 'orders', + ]); + expect(persistedCompletions('x = persisted.or', keys)).toEqual([ + expect.objectContaining({ label: 'orders', start: 15, length: 2 }), + ]); + }); + + it('offers nothing after another dot, or for a key that is not a Python name', () => { + expect(persistedCompletions('order.o', keys)).toEqual([]); + expect(persistedCompletions('two', keys)).toEqual([]); + expect(persistedCompletions('len(', keys)).toEqual([]); + }); +}); diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index 0882c97..c019b44 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -842,4 +842,34 @@ describe('GemDB’s % commands in the console', () => { expect(saved.events('output')).toEqual([]); consoleTip.shown = true; }); + + it('offers persisted.o while o is typed, reading the persisted keys once until a console line runs', async () => { + const persistedKeys = vi.fn().mockResolvedValue(['o', 'orders']); + const c = started(vi.fn().mockResolvedValue(value('1')), undefined, { + persistedKeys, + }); + const labels = async (text: string) => { + const seq = c.request('completions', { text, column: text.length + 1, frameId: 1 }); + await settle(); + await settle(); + return (c.responseTo(seq)?.body as { targets: Array<{ label: string }> }).targets.map( + (t) => t.label, + ); + }; + + const typingO = await labels('o'); + const typingOr = await labels('or'); + const afterDot = await labels('persisted.'); + const readsBefore = persistedKeys.mock.calls.length; + c.request('evaluate', { expression: 'o = 1', frameId: 1, context: 'repl' }); + await settle(); + await labels('o'); + + expect(typingO).toEqual(['persisted.o', 'persisted.orders']); + expect(typingOr).toEqual(['persisted.orders']); + expect(afterDot).toEqual(['o', 'orders']); + // Once a pause, not once a keystroke; again after a line that may have changed them. + expect(readsBefore).toBe(1); + expect(persistedKeys).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/consoleCommands.ts b/src/consoleCommands.ts index e71e602..152bf6f 100644 --- a/src/consoleCommands.ts +++ b/src/consoleCommands.ts @@ -169,7 +169,45 @@ export function commandCompletions(beforeCursor: string, names: string[]): Compl return []; } -/** Whether completions are wanted at all: only for a `%` line, so Python typing costs nothing. */ -export function wantsCommandCompletions(beforeCursor: string): boolean { - return isConsoleCommand(beforeCursor); +/** A Python name, the only kind of persisted key reachable as `persisted.key`. */ +const PYTHON_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Suggestions for Persisted Objects while Python is typed, so a persisted + * object can be found without knowing `persisted` exists: + * + * - after `persisted.`, the persisted keys; + * - while a name is typed (not after a dot), `persisted.key` for each + * persisted key it starts, described as the `gemdb.root` entry it is. + * + * `keys` is what Persisted Objects holds. Keys that are not Python names are + * left out: they are reachable only as `gemdb.root["…"]`. + */ +export function persistedCompletions(beforeCursor: string, keys: string[]): CompletionItem[] { + const names = keys.filter((k) => PYTHON_NAME.test(k)); + const after = /(?:^|[^A-Za-z0-9_.])persisted\.([A-Za-z0-9_]*)$/.exec(beforeCursor); + if (after) { + const typed = after[1]; + return names + .filter((k) => k.startsWith(typed)) + .map((k) => ({ + label: k, + detail: `gemdb.root[${JSON.stringify(k)}] in Persisted Objects`, + type: 'variable', + start: beforeCursor.length - typed.length + 1, + length: typed.length, + })); + } + const word = /(?:^|[^A-Za-z0-9_.])([A-Za-z_][A-Za-z0-9_]*)$/.exec(beforeCursor); + if (!word) return []; + const typed = word[1]; + return names + .filter((k) => k.startsWith(typed)) + .map((k) => ({ + label: `persisted.${k}`, + detail: `gemdb.root[${JSON.stringify(k)}] in Persisted Objects`, + type: 'variable', + start: beforeCursor.length - typed.length + 1, + length: typed.length, + })); } diff --git a/src/debugger.ts b/src/debugger.ts index 40f4f00..516d378 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -10,7 +10,7 @@ import { helpText, isConsoleCommand, parseConsoleCommand, - wantsCommandCompletions, + persistedCompletions, } from './consoleCommands'; import { PlacedFrame, jupyterTraceback } from './consoleTraceback'; import { @@ -32,7 +32,9 @@ import { parseConsoleNames, parseConsoleReply, parsePausedStack, + parsePersistedKeys, pausedStackQuery, + persistedKeysQuery, setVariableQuery, } from './pauseVariables'; import { DotsByFile, armedLinesQuery, parseArmed } from './redDots'; @@ -180,6 +182,8 @@ export interface Pause { * `_`, which it is then bound to (`consoleNameQuery`). */ consoleName?(frameId: number, name: string, handle: number): Promise; + /** What Persisted Objects holds: its string keys (`persistedKeysQuery`). */ + persistedKeys?(): Promise; /** The names bound in frame `frameId`'s console so far (`consoleNamesQuery`). */ consoleNames?(frameId: number): Promise>; /** Each frame's whole cell or file, by frame index, so the stack can be saved. */ @@ -673,6 +677,8 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { */ private readonly shown = new Map(); private nextShown = SHOWN_REFS; + /** Persisted Objects' keys for autocomplete; dropped after every console line. */ + private persistedKeysCache: Promise | undefined; constructor( private readonly lookup: (id: string) => Pause | undefined, @@ -941,6 +947,8 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { } const kind: ConsoleContext = context; const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + // Any console line may change what Persisted Objects holds. + if (kind === 'repl') this.persistedKeysCache = undefined; if (kind === 'repl' && isConsoleCommand(source)) { void this.runCommand(request, source, frameId); return; @@ -1232,25 +1240,42 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { } /** - * Autocomplete for GemDB's `%` commands, and for a name after `%persist `. - * Python is not completed here: any line that is not a `%` command answers - * nothing, without asking the database. + * Autocomplete. For a `%` line, GemDB's commands, and after `%persist ` the + * console's names. For Python, only Persisted Objects: `persisted.o` while + * `o` is typed, and the keys after `persisted.` (`persistedCompletions`). + * Python names themselves are not completed here. + * + * It runs on keystrokes, so it never runs the user's code: the persisted keys + * are read once a pause and read again only after a console line, which may + * have changed them (`persistedKeysCache`). */ private async complete(request: DapRequest): Promise { const text = String(request.arguments?.text ?? ''); const column = Number(request.arguments?.column ?? text.length + 1); const before = text.slice(0, Math.max(0, column - 1)); const pause = this.pause; - if (!pause || !wantsCommandCompletions(before)) { + if (!pause) { this.respond(request, { targets: [] }); return; } - const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); - const names = - /^\s*%persist\s+/.test(before) && pause.consoleNames - ? (await pause.consoleNames(frameId).catch(() => [])).map((n) => n.name) - : []; - this.respond(request, { targets: commandCompletions(before, names) }); + if (isConsoleCommand(before)) { + const frameId = Number(request.arguments?.frameId ?? pause.frames[0]?.id ?? 1); + const names = + /^\s*%persist\s+/.test(before) && pause.consoleNames + ? (await pause.consoleNames(frameId).catch(() => [])).map((n) => n.name) + : []; + this.respond(request, { targets: commandCompletions(before, names) }); + return; + } + this.respond(request, { targets: persistedCompletions(before, await this.persistedKeys()) }); + } + + /** What Persisted Objects holds, read once and kept until a console line runs. */ + private persistedKeys(): Promise { + const pause = this.pause; + if (!pause?.persistedKeys) return Promise.resolve([]); + this.persistedKeysCache ??= pause.persistedKeys().catch(() => []); + return this.persistedKeysCache; } dispose(): void { @@ -1806,6 +1831,8 @@ export function registerBreakpointDebugger(): vscode.Disposable { consoleName: (frameId, name, handle) => consoleNameAt(request, view, consoleRefs, frameId, name, handle), consoleNames: (frameId) => consoleNamesAt(request, view, consoleRefs, frameId), + persistedKeys: async () => + parsePersistedKeys(await request.query(persistedKeysQuery())), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, reason: request.reason, diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index 45710a7..7e47ca3 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -1372,6 +1372,27 @@ export function parseConsoleNames(raw: string): { }; } +/** + * Smalltalk answering what Persisted Objects holds: the `gemdb.root` keys that + * are strings, separated by the field character, for the console's + * autocomplete. A read, run in the paused session, so it sees what the run + * has added too. + */ +export function persistedKeysQuery(): string { + return `| keys out | +keys := [(System myUserProfile symbolList objectNamed: #'ModuleAst') evaluateSource: 'import gemdb +sorted(k for k in gemdb.root.keys() if isinstance(k, str))' usingModuleScope: SymbolDictionary new] + on: AbstractException do: [:e | e return: #()]. +out := WriteStream on: Unicode7 new. +keys do: [:k | out nextPutAll: k asString] separatedBy: [out nextPut: (Character codePoint: 31)]. +out contents encodeAsUTF8`; +} + +/** Parse `persistedKeysQuery`'s answer. */ +export function parsePersistedKeys(raw: string): string[] { + return raw.split(FIELD).filter((k) => k.length > 0); +} + /** Parse `consoleQuery`'s answer. */ export function parseConsoleReply(raw: string): ConsoleReply { if (raw.startsWith('Error: ')) { From 7af8a53ded0771deedf81c42e3df382536bd91be Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 10:22:08 -0700 Subject: [PATCH 13/15] Test the Debug Console's helpers directly Each function this branch adds now has a test of its own, beside the real-database suite that runs them end to end: - the Jupyter-style traceback: its header, the lines around the one that raised, the gutter at two-digit lines, the fallback to Grail's recorded text, a SyntaxError's caret, a file's label and an error with no message; - the readers of the console queries' answers, for every shape: a value with and without a persisted name's origin, None, interrupted, an error with its frames, a SyntaxError's position, a stack overflow, a failed query, the console's names and Persisted Objects' keys; - the label for a persisted name; the Variables menu commands' registration against package.json; %persist answering even when Persisted Objects cannot be told; and the view showing a %persist as waiting under its notebook and revealing it. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/__tests__/consoleReplies.test.ts | 126 +++++++++++++++++++++ src/__tests__/consoleTraceback.test.ts | 115 +++++++++++++++++++ src/__tests__/debugConsole.test.ts | 19 +++- src/__tests__/persistedObjectsView.test.ts | 29 +++++ src/__tests__/variableCommands.test.ts | 33 +++++- 5 files changed, 319 insertions(+), 3 deletions(-) create mode 100644 src/__tests__/consoleReplies.test.ts create mode 100644 src/__tests__/consoleTraceback.test.ts diff --git a/src/__tests__/consoleReplies.test.ts b/src/__tests__/consoleReplies.test.ts new file mode 100644 index 0000000..aeb668d --- /dev/null +++ b/src/__tests__/consoleReplies.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; +import { parseConsoleNames, parseConsoleReply, parsePersistedKeys } from '../pauseVariables'; + +/** + * The console queries' answers, read. The queries themselves run against a + * real database in `__integration__/debugConsole.test.ts`; this pins down the + * reading of each shape they answer in. + */ + +const F = '\u001f'; +const R = '\u001e'; +const rec = (...fields: Array): string => fields.join(F) + R; + +describe('reading a console line’s answer', () => { + it('reads a value: the row, what was printed, the console’s position, and a persisted name’s origin', () => { + const raw = + rec('value', 12, 'hi\r\n', 'persisted') + + rec('o', "Order(1013, 'katherine', 3/14)", 'Order', 40, 0, 3, 41, -1, 0, 'obj'); + + expect(parseConsoleReply(raw)).toEqual({ + consoleRef: 12, + output: 'hi\n', + kind: 'value', + origin: 'persisted', + row: expect.objectContaining({ + name: 'o', + value: "Order(1013, 'katherine', 3/14)", + type: 'Order', + ref: 40, + named: 3, + kind: 'obj', + }), + }); + }); + + it('reads a value with no origin when the name is not a persisted one', () => { + const reply = parseConsoleReply(rec('value', 3, '', '') + rec('n', '1', 'int', 0, 0, 0, 9)); + + expect(reply).not.toHaveProperty('origin'); + }); + + it('reads None or a statement, and an interrupted line', () => { + expect(parseConsoleReply(rec('none', 3, 'printed\n', ''))).toEqual({ + consoleRef: 3, + output: 'printed\n', + kind: 'none', + }); + expect(parseConsoleReply(rec('interrupted', 3, '', ''))).toMatchObject({ kind: 'interrupted' }); + }); + + it('reads an error: its type and message, then each frame outermost first', () => { + const raw = + rec('error', 3, '', '') + + rec('ZeroDivisionError', 'division by zero') + + rec('', 1, '', 'boom(0') + + rec('/w/mod.py', 2, 'boom', ' return 10 / '); + + expect(parseConsoleReply(raw)).toEqual({ + consoleRef: 3, + output: '', + kind: 'error', + error: { + type: 'ZeroDivisionError', + message: 'division by zero', + frames: [ + { file: '', line: 1, name: '', text: 'boom(0' }, + { file: '/w/mod.py', line: 2, name: 'boom', text: ' return 10 / ' }, + ], + }, + }); + }); + + it('reads a SyntaxError’s file, line, column and text', () => { + const raw = + rec('error', 3, '', '') + rec('SyntaxError', 'invalid syntax', '', 1, 4, '1 +'); + + expect(parseConsoleReply(raw)).toMatchObject({ + kind: 'error', + error: { syntax: { file: '', line: 1, offset: 4, text: '1 +' }, frames: [] }, + }); + }); + + it('reads a stack overflow as an error', () => { + const raw = rec('stack', 3, '', '') + rec('RecursionError', 'maximum recursion depth exceeded'); + + expect(parseConsoleReply(raw)).toMatchObject({ + kind: 'error', + error: { type: 'RecursionError' }, + }); + }); + + it('turns an Error: line from the query itself into a failure', () => { + expect(parseConsoleReply('Error: the pause is over')).toEqual({ + consoleRef: 0, + output: '', + kind: 'failed', + message: 'the pause is over', + }); + }); +}); + +describe('reading the console’s names', () => { + it('reads its position and each name with its type and repr', () => { + const raw = `7${R}` + rec('p', 'list', '[1, 2]') + rec('_', 'int', '3'); + + expect(parseConsoleNames(raw)).toEqual({ + consoleRef: 7, + names: [ + { name: 'p', type: 'list', repr: '[1, 2]' }, + { name: '_', type: 'int', repr: '3' }, + ], + }); + }); + + it('reads no names, and throws on an Error: line', () => { + expect(parseConsoleNames(`7${R}`)).toEqual({ consoleRef: 7, names: [] }); + expect(() => parseConsoleNames('Error: the pause is over')).toThrow('the pause is over'); + }); +}); + +describe('reading Persisted Objects’ keys', () => { + it('splits the keys, and reads an empty answer as none', () => { + expect(parsePersistedKeys(`entry${F}o${F}two words`)).toEqual(['entry', 'o', 'two words']); + expect(parsePersistedKeys('')).toEqual([]); + }); +}); diff --git a/src/__tests__/consoleTraceback.test.ts b/src/__tests__/consoleTraceback.test.ts new file mode 100644 index 0000000..7acb1b7 --- /dev/null +++ b/src/__tests__/consoleTraceback.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest'; +import { PlacedFrame, jupyterTraceback } from '../consoleTraceback'; +import type { ConsoleError, TracebackFrame } from '../pauseVariables'; + +/** + * A Debug Console error written as Jupyter writes one. Placing a frame — the + * console's input, a cell, a file — is the caller's (`debugger.ts`); here it + * is a stub, so the layout itself is what is tested. + */ + +const CELL = ['def boom(d):', ' return 10 / d']; +const FILE = Array.from({ length: 12 }, (_, i) => `line ${i + 1}`); + +/** The console's input is frame 0; a `` frame is the cell; anything else the file. */ +function placed(input: string[]) { + return (frame: TracebackFrame, index: number): PlacedFrame => + index === 0 + ? { label: 'Debug Console', isFile: false, lines: input } + : frame.file === '' + ? { label: 'Cell [2]', isFile: false, lines: CELL } + : { label: `File ${frame.file}`, isFile: true, lines: FILE }; +} + +const error = (frames: TracebackFrame[], more: Partial = {}): ConsoleError => ({ + type: 'ZeroDivisionError', + message: 'division by zero', + frames, + ...more, +}); + +describe('a Jupyter-style traceback', () => { + it('heads it as Jupyter does, then each frame outermost first, then the error', () => { + const text = jupyterTraceback( + error([ + { file: '', line: 1, name: '', text: 'boom(0' }, + { file: '', line: 2, name: 'boom', text: ' return 10 / ' }, + ]), + placed(['boom(0)']), + ); + + expect(text.split('\n')).toEqual([ + '-'.repeat(75), + 'ZeroDivisionError Traceback (most recent call last)', + 'Debug Console, line 1', + '----> 1 boom(0)', + '', + 'Cell [2], line 2, in boom', + ' 1 def boom(d):', + '----> 2 return 10 / d', + '', + 'ZeroDivisionError: division by zero', + ]); + }); + + it('shows two lines either side of the one that raised, and keeps the gutter seven wide at two digits', () => { + const text = jupyterTraceback( + error([{ file: '/w/mod.py', line: 10, name: 'fail', text: '' }], { + type: 'KeyError', + message: "'missing'", + }), + (frame) => placed([])(frame, 1), + ); + + expect(text.split('\n').slice(2, 8)).toEqual([ + 'File /w/mod.py:10, in fail', + ' 8 line 8', + ' 9 line 9', + '---> 10 line 10', + ' 11 line 11', + ' 12 line 12', + ]); + }); + + it('falls back to the text Grail recorded when a frame’s source cannot be found', () => { + const text = jupyterTraceback( + error([{ file: '', line: 4, name: 'helper', text: ' return x / y\nmore' }]), + () => ({ label: 'Cell', isFile: false }), + ); + + expect(text.split('\n').slice(2, 4)).toEqual([ + 'Cell, line 4, in helper', + '----> 4 return x / y', + ]); + }); + + it('shows a SyntaxError’s line with a caret under where it could not read, and no frames', () => { + const text = jupyterTraceback( + error([], { + type: 'SyntaxError', + message: 'invalid syntax', + syntax: { file: '', line: 1, offset: 4, text: '1 +' }, + }), + placed(['1 +']), + ); + + expect(text.split('\n')).toEqual([ + ' Debug Console, line 1', + ' 1 +', + ' ^', + 'SyntaxError: invalid syntax', + ]); + }); + + it('ends with the type alone when the error has no message', () => { + const text = jupyterTraceback( + error([{ file: '', line: 1, name: '', text: 'raise E' }], { + type: 'E', + message: '', + }), + placed(['raise E']), + ); + + expect(text.split('\n').pop()).toBe('E'); + }); +}); diff --git a/src/__tests__/debugConsole.test.ts b/src/__tests__/debugConsole.test.ts index c019b44..c6c56fa 100644 --- a/src/__tests__/debugConsole.test.ts +++ b/src/__tests__/debugConsole.test.ts @@ -16,7 +16,7 @@ vi.mock('../session', () => ({ setRedDotSource: (_source: RedDotSource | undefined) => {}, })); -const { PauseDebugAdapter, consoleTip } = await import('../debugger'); +const { PauseDebugAdapter, consoleTip, originLine } = await import('../debugger'); const { commitQuery } = await import('../pauseVariables'); /** Let the adapter's promises run. */ @@ -872,4 +872,21 @@ describe('GemDB’s % commands in the console', () => { expect(readsBefore).toBe(1); expect(persistedKeys).toHaveBeenCalledTimes(2); }); + + it('still answers %persist when Persisted Objects cannot be told about it', async () => { + vi.spyOn(commands, 'executeCommand').mockReturnValue(Promise.reject(new Error('no view'))); + const evaluate = vi.fn().mockResolvedValue(value("(False, None, 'list')")); + + const { reply, said } = await command('%persist p', { ownerKey: 'nb' }, evaluate); + await settle(); + + expect(reply?.success).toBe(true); + expect(said).toMatch(/^Added p to Persisted Objects/); + }); + + it('labels a persisted name with where it comes from', () => { + expect(originLine('o')).toBe( + 'o is gemdb.root["o"], from Persisted Objects (also persisted.o)\n', + ); + }); }); diff --git a/src/__tests__/persistedObjectsView.test.ts b/src/__tests__/persistedObjectsView.test.ts index 25f3f5c..c31425b 100644 --- a/src/__tests__/persistedObjectsView.test.ts +++ b/src/__tests__/persistedObjectsView.test.ts @@ -316,6 +316,35 @@ describe('adding an object from the debugger', () => { ); }); + it('shows a %persist from the Debug Console as waiting under its notebook, and reveals it', async () => { + pauseIn(NB); + connected.push({ key: NB, label: 'breakpoint.ipynb' }); + const { view } = setUp(); + // What %persist p did in the paused session, before telling the view. + gem.pending.set(NB, new Map([['p', 'list']])); + + await command('gemdb.savedObjects.notePersisted')({ + key: 'p', + type: 'list', + ownerKey: NB, + notebook: 'breakpoint.ipynb', + }); + await vi.waitFor(() => expect(__treeViews.get(DEBUG_VIEW_ID)!.reveals).toHaveLength(1)); + const rows = await topOf(view); + + const notebook = rows.find((r) => r.kind === 'notebook')!; + const [waiting] = view.getChildren(notebook); + expect(item(view, waiting)).toMatchObject({ + label: 'p', + description: 'list · added, not persisted yet', + contextValue: 'gemdbSavedPending', + }); + expect(__treeViews.get(DEBUG_VIEW_ID)!.reveals[0].element).toMatchObject({ + kind: 'pending', + save: { key: 'p' }, + }); + }); + it('opens Persisted Objects in Run and Debug on the new row, leaving focus in Variables', async () => { pauseIn(NB); connected.push({ key: NB, label: 'breakpoint.ipynb' }); diff --git a/src/__tests__/variableCommands.test.ts b/src/__tests__/variableCommands.test.ts index a525be4..7552064 100644 --- a/src/__tests__/variableCommands.test.ts +++ b/src/__tests__/variableCommands.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { DebugStackFrame, commands, debug, window } from '../__mocks__/vscode'; +import { DebugStackFrame, __commands, commands, debug, window } from '../__mocks__/vscode'; import type { Pause } from '../debugger'; /** @@ -16,7 +16,13 @@ vi.mock('../debugger', () => ({ pauseForDebugSession: (id: string | undefined) => (id === 's1' ? pause : undefined), })); -const { evaluateWithSelf, showInDebugConsole } = await import('../variableCommands'); +const { + EVALUATE_WITH_SELF, + SHOW_IN_CONSOLE, + evaluateWithSelf, + registerVariableCommands, + showInDebugConsole, +} = await import('../variableCommands'); type CustomRequest = (command: string, args?: unknown) => Promise; @@ -186,3 +192,26 @@ describe('Evaluate with self…', () => { expect(seen).toEqual([]); }); }); + +describe('registering the commands', () => { + it('registers both under the ids package.json puts on the Variables menu, and removes them on dispose', async () => { + const registration = registerVariableCommands(); + const { default: manifest } = (await import('../../package.json')) as unknown as { + default: { + contributes: { menus: Record> }; + }; + }; + + expect(__commands.has(SHOW_IN_CONSOLE)).toBe(true); + expect(__commands.has(EVALUATE_WITH_SELF)).toBe(true); + const onVariables = manifest.contributes.menus['debug/variables/context']; + expect(onVariables).toEqual( + expect.arrayContaining([ + expect.objectContaining({ command: SHOW_IN_CONSOLE, when: 'debugType == gemdb' }), + expect.objectContaining({ command: EVALUATE_WITH_SELF, when: 'debugType == gemdb' }), + ]), + ); + registration.dispose(); + expect(__commands.has(SHOW_IN_CONSOLE)).toBe(false); + }); +}); From 00d1fcba313db13995bfda851fc83bd28a7ef1bf Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Wed, 14 Oct 2026 13:14:12 -0700 Subject: [PATCH 14/15] Debug Console on a reopened saved stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stack opened with Restore a Saved Stack now has a Debug Console in each frame: evaluate, call methods on the saved objects, bind names, change objects, %names, %persist, %commit, Show in Debug Console and Evaluate with self. The stack opens in a database session of its own, so a change waits uncommitted as a notebook's does. When it opens, the console shows a SAVED STACK badge saying that %commit overwrites what was saved; closing the stack with changes pending asks whether to commit them. The console code now runs through a ConsoleHost, so a live pause and a saved stack share it. Each frame's console is made and undone when the stack opens, since making one writes, so the close prompt asks only about the user's own changes. %names hides Grail's ___…___ names. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 9 +- README.md | 6 +- src/__integration__/debugConsole.test.ts | 168 ++++++++++++++++++++- src/__tests__/debugConsole.test.ts | 93 +++++++++++- src/__tests__/persistedObjectsView.test.ts | 74 +++++++++ src/debugger.ts | 119 ++++++++++----- src/pauseVariables.ts | 8 +- src/savedObjects.ts | 162 +++++++++++++++++--- src/savedStacks.ts | 46 ++++-- src/session.ts | 5 +- 10 files changed, 613 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4436c88..238c9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 labelled `orders is gemdb.root["orders"]` when typed; `persisted.orders` reaches it whatever `orders` means at the pause. Typing a name offers `persisted.orders` in autocomplete for each persisted entry it starts, and `persisted.` offers the keys. +- **The Debug Console on a saved stack.** A stack reopened with **Restore a Saved Stack…** now has a + Debug Console, in each frame, on the saved objects: evaluate, call methods, bind names, change + objects, `%names`, `%persist`, `%commit`, Show in Debug Console and Evaluate with self…. The stack + opens in a database session of its own, so a change waits there uncommitted, as a notebook's + does. When the stack opens, the console warns that `%commit` (or ✓ in Persisted Objects) would + write a change into the saved stack, which then no longer holds the values it was saved with. Closing the stack + with changes pending asks whether to commit them; otherwise they are discarded. ### Fixed @@ -231,7 +238,7 @@ The engine is unchanged at GemStone 4.0.0.a4, so a database created by GemDB Saved Stack…** (a button in Run and Debug, and in the GemDB panel) lists the saved stacks, or opens the only one, in Run and Debug again; Persisted Objects can open it from its row too — after a restart, and with the notebook or file gone. Each frame shows the copy of its source saved with - the stack, marked "(saved