From ccb81a0acc0a28f7e2935e9fb0ca4df8aa99ccdb Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 12 Oct 2026 14:37:38 -0700 Subject: [PATCH] Show types, whole objects and Set Value in the debugger's Variables Every Variables row now leads with its type, and a container's with its size, as PyCharm shows them ({Point} Point(3, 4), {list: 3} [...]). Copy Value still copies the value alone: the adapter answers VS Code's clipboard evaluate with the badge taken off. An object opens on everything dir() lists - its own attributes, its class's, properties and __slots__ - with dunders folded under special variables and methods under function variables. dir() runs only when a row is opened, so listing a page costs what it did before. An instance whose data all comes from its class opens too; functions and empty objects stay leaves. Modules now land under module variables, and a getter that runs past the time budget reads instead of holding up the pause. Set Value changes a local, a global, an attribute, a list item or a dict entry. The value is a Python expression that can use the notebook's (or __main__'s) globals, with a frame's locals on top. A local is written back into the paused frame with GsProcess>>_frameAt:tempAt:put:, so the run continues with it. Statements and empty values are refused, and a method's receiver, a tuple's items, group rows and saved stacks are read-only. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.md | 23 ++ README.md | 8 +- docs/grail.md | 13 +- src/__integration__/breakpoint.test.ts | 484 ++++++++++++++++++++++- src/__tests__/debugger.test.ts | 286 ++++++++++++-- src/__tests__/savedObjects.test.ts | 14 +- src/debugger.ts | 204 ++++++++-- src/pauseVariables.ts | 522 +++++++++++++++++++++---- src/savedObjects.ts | 5 +- 9 files changed, 1422 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc29765..df952bb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Variables shows each value's type, and opens objects whole.** Every row starts with its type, + and a container's with its size too, as PyCharm shows them: `{Point} Point(3, 4)`, + `{list: 3} […]`, `{list: 150}` for one too big to show. **Copy Value** copies the value alone, + without the type. An object with attributes, its own or its class's, now opens on everything + `dir()` lists: its own attributes, its class's, its + properties (read through their getters) and its `__slots__`, which before did not open at all. + Its methods are folded under **function variables** and its dunders under + **special variables**, as in VS Code's Python debugger, so a class no longer buries its own + methods under `__module__`, `__dict__` and the rest. A getter that runs past the time limit reads + ``, and the getters after it are not run. Modules now land under + **module variables**; before, they were listed with the data. +- **Set Value in Variables.** Right-click a row while paused and choose **Set Value** to change a + local, a global, an attribute, a list item or a dict entry. The value is a Python expression, + and on any row it can use the notebook's globals, or a `.py` file's (`Point(1, 2)`, `g + 1`). In + Locals it can use the frame's locals too (`n + 1`), and a local wins over a global of the same + name. A statement such as `n = 5` is + refused rather than stored as None. A local is written into the paused frame, so the run + 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. + ## [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..ed54c9c 100644 --- a/README.md +++ b/README.md @@ -414,7 +414,13 @@ database, select that kernel from the kernel picker at the top right. - `breakpoint()` pauses the cell and opens VS Code's debugger on it, with no setup: the Call Stack 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** + functions folded into their own rows. Each value starts with its type, and a container's with its + size too (`{Point} Point(3, 4)`, `{list: 3} […]`); **Copy Value** copies the value without them. + An object with attributes, its own or its class's, opens on everything it has: its own attributes, its class's, its properties and slots, + with its methods under **function variables** and its dunders under **special variables**. Right-click a row and choose **Set Value** to change + a local, a global, an attribute, a list item or a dict entry. The value you type is a Python + expression, and the run continues with it. It can use the notebook's globals, or a `.py` file's, + on any row, and on a Locals row the frame's locals too. **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 goes in `gemdb.root` under the key you give (one is suggested) and is written at the notebook's diff --git a/docs/grail.md b/docs/grail.md index 1713ea6..6f0c8be 100644 --- a/docs/grail.md +++ b/docs/grail.md @@ -163,7 +163,18 @@ reads locals with `PyFrame ___pyLocalsFromFrameContentsList___:` (which merges a Python frame's Smalltalk frames and adds `self`), in the same walk as the stack so a frame cannot be paired with another's locals, and keeps every object it may expand in a per-pause registry in `SessionTemps`, dropped before -the evaluation resumes (`pauseVariables.ts`). Every query made while paused +the evaluation resumes (`pauseVariables.ts`). An object's rows are its +`__dir__` read through `___pyAttrLoad___:`, so a `__slots__` object (no +`__dict__`, no Smalltalk instance variables) opens too, and Grail names a bound +method's type `BoundMethod`, not `method`. Set Value writes a local back into +the paused frame with `GsProcess>>_frameAt:tempAt:put:`, at the levels the walk +recorded for that frame; `_frameAt:tempNamed:put:` fails on a local a block +closes over, which lives in a VariableContext (measured: +rtErrShouldNotImplement). Only a temp can be written that way, and two names +in Locals are not temps: a method's receiver, which Grail adds under whatever +name the def gave it; and a classmethod's `cls`, which Grail leaves out of +Locals altogether (measured). A parameter of a plain function +spelled `self` is left out too, by `___isInternalTempName___`. Every query made while paused goes through `queryWhilePaused` in `session.ts` — nonblocking, one at a time, soft-broken past a two-second budget — never `execute`, because a Variables row runs the user's own `__repr__`. Measured traps there: every Smalltalk diff --git a/src/__integration__/breakpoint.test.ts b/src/__integration__/breakpoint.test.ts index 8809084..05005d3 100644 --- a/src/__integration__/breakpoint.test.ts +++ b/src/__integration__/breakpoint.test.ts @@ -22,6 +22,7 @@ import { rootListingQuery, saveSuggestionQuery, saveToRootQuery, + setVariableQuery, unquote, } from '../pauseVariables'; import { isRunning, startNetldi, startStone, stopNetldi, stopStone } from '../processes'; @@ -229,10 +230,14 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { ['local', "[P('ann'), (1, 2)]", 'list'], ]), ); - expect(seen.p.map((v) => v.name).sort()).toEqual(['name', 'tags']); + // An object opens on its dunders, folded, then its data. + expect(seen.p.map((v) => v.name)).toEqual(['special variables', 'name', 'tags']); // A list reads by position from 0, not as Smalltalk's 1-based keys. expect(seen.local.map((v) => v.name)).toEqual(['[0]', '[1]']); - expect(seen.local[1]).toMatchObject({ value: '(1, 2)', type: 'tuple', indexed: 2 }); + expect(seen.local[1]).toMatchObject({ value: '(1, 2)', type: 'tuple', indexed: 2, size: 2 }); + // A container carries its size for the row's badge; anything else carries none. + expect(seen.go.find((v) => v.name === 'local')).toMatchObject({ size: 2 }); + expect(seen.go.find((v) => v.name === 'p')).toMatchObject({ size: -1 }); expect(seen.tags.map((v) => v.value).sort()).toEqual(["'a'", "'b'"]); // Globals are the notebook's names — this cell's and earlier cells' — without the dunder // noise, with classes and functions folded into groups at the top and the data sorted below. @@ -271,6 +276,404 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { expect(result.value).toBe('2'); }); + it('shows an object whole: dunders and methods folded, class attributes, properties and slots', async () => { + const seen: Record = {}; + const nb = notebook('bp-shape'); + setHaltHandler(async (request) => { + const children = async (ref: number) => + parseChildren(await request.query(childrenQuery(ref, 0, 500))); + const { globals } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + seen.globals = await children(globals); + const ref = (rows: PauseVariable[], name: string) => rows.find((v) => v.name === name)!.ref; + seen.p = await children(ref(seen.globals, 'p')); + seen.special = await children(ref(seen.p, 'special variables')); + seen.functions = await children(ref(seen.p, 'function variables')); + seen.v = await children(ref(seen.globals, 'v')); + seen.classes = await children(ref(seen.globals, 'class variables')); + seen.Point = await children(ref(seen.classes, 'Point')); + seen.modules = await children(ref(seen.globals, 'module variables')); + seen.globalFunctions = await children(ref(seen.globals, 'function variables')); + seen.t = await children(ref(seen.globals, 't')); + return 'continue'; + }); + + await runPython( + [ + 'class Point:', + ' kind = "pt"', + ' def __init__(self, x, y):', + ' self.x, self.y = x, y', + ' def distance(self):', + ' return 0', + ' @property', + ' def norm(self):', + ' return 5', + 'class Vec:', + ' __slots__ = ("x", "y")', + ' def __init__(self, x, y):', + ' self.x, self.y = x, y', + 'class Empty:', + ' pass', + 'class Temp:', + ' unit = "C"', + ' @property', + ' def kelvin(self):', + ' return 273', + 'def helper():', + ' return 1', + 'import os', + 'p = Point(3, 4)', + 'v = Vec(1, 2)', + 'e = Empty()', + 't = Temp()', + 'breakpoint()', + ].join('\n'), + nb, + ); + // The database allows ten sessions, and this file opens one per notebook. + closeSessionFor(nb.key); + + // Groups first, then the data — its own attributes, the class's, and a + // property's value, read through its getter — sorted by name. + expect(seen.p.map((v) => v.name)).toEqual([ + 'special variables', + 'function variables', + 'kind', + 'norm', + 'x', + 'y', + ]); + expect(seen.p.find((v) => v.name === 'norm')).toMatchObject({ value: '5', type: 'int' }); + expect(seen.special.map((v) => v.name)).toEqual( + expect.arrayContaining(['__class__', '__dict__', '__init__', '__module__']), + ); + expect(seen.functions.map((v) => v.name)).toEqual(['distance']); + // Something with nothing of its own to show stays a leaf: a method, an empty object. + expect(seen.functions[0].ref).toBe(0); + expect(seen.globals.find((v) => v.name === 'e')).toMatchObject({ ref: 0, type: 'Empty' }); + // An object with nothing of its own still opens when its class gives it data. + expect(seen.t.map((v) => [v.name, v.value])).toEqual( + expect.arrayContaining([ + ['kelvin', '273'], + ['unit', "'C'"], + ]), + ); + expect(seen.globalFunctions.find((v) => v.name === 'helper')).toMatchObject({ + ref: 0, + type: 'function', + }); + // Only a container trades its repr for a size: a module with hundreds of names keeps its own. + expect(seen.modules.find((v) => v.name === 'os')?.value).toMatch(/^ v.name === 'special variables')).toMatchObject({ readOnly: true }); + expect(seen.p.find((v) => v.name === 'x')).toMatchObject({ readOnly: false }); + // A __slots__ object has no __dict__ and no instance variables, and still opens. + expect( + seen.v.filter((v) => !v.name.endsWith('variables')).map((v) => [v.name, v.value]), + ).toEqual([ + ['x', '1'], + ['y', '2'], + ]); + // A class shows its own methods apart from the dunders it is full of. + const pointNames = seen.Point.map((v) => v.name); + expect(pointNames.slice(0, 2)).toEqual(['special variables', 'function variables']); + expect(pointNames.filter((n) => /^__.*__$/.test(n))).toEqual([]); + expect(pointNames).toEqual(expect.arrayContaining(['kind', 'norm'])); + }); + + it('sets a local, a global, an attribute, a list item and a dict entry, and the run sees them', async () => { + const seen: Record = {}; + const nb = notebook('bp-set'); + setHaltHandler(async (request) => { + const children = async (ref: number) => + parseChildren(await request.query(childrenQuery(ref, 0, 500))); + const set = (ref: number, name: string, value: string) => + request.query(setVariableQuery(request.process, ref, name, value)); + const { frames, globals } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const tour = frames.find((f) => f.name === 'tour')!.locals!; + const method = frames.find((f) => f.name === 'P.look')!.locals!; + const globalRows = await children(globals); + const ref = (rows: PauseVariable[], name: string) => rows.find((v) => v.name === name)!.ref; + const locals = await children(tour); + seen.selfReadOnly = String((await children(method)).find((v) => v.name === 'self')?.readOnly); + seen.n = await set(tour, 'n', 'n + 92'); + seen.label = await set(tour, 'label', '"changed"'); + seen.g = await set(globals, 'g', '5'); + seen.x = await set(ref(locals, 'p'), 'x', '10'); + seen.item = await set(ref(globalRows, 'items'), '[1]', '"two"'); + seen.entry = await set(ref(globalRows, 'd'), "'k'", '[1, 2]'); + seen.tuple = await set(ref(globalRows, 't'), '[0]', '9'); + seen.norm = await set(ref(locals, 'p'), 'norm', '1'); + seen.self = await set(method, 'self', '1'); + seen.syntax = await set(tour, 'n', '1 +'); + // An assignment is a statement, not a value: refused, where it would have stored None. + seen.statement = await set(tour, 'n', 'n = 5'); + seen.commented = await set(globals, 'g', '5 # a comment'); + seen.afterRows = JSON.stringify( + (await children(tour)).filter((v) => v.name === 'n').map((v) => v.value), + ); + return 'continue'; + }); + + const result = await runPython( + [ + 'class P:', + ' def __init__(self):', + ' self.x = 1', + ' @property', + ' def norm(self):', + ' return 5', + ' def look(self):', + ' return tour(self)', + 'g = 1', + 'items = [1, 2, 3]', + 'd = {"k": 0}', + 't = (1, 2)', + 'def tour(p):', + ' n = 7', + ' label = "in"', + ' breakpoint()', + ' return n, label, p.x', + '(P().look(), g, items, d, t)', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + const row = (raw: string) => parseChildren(raw)[0]; + expect(row(seen.n)).toMatchObject({ name: 'n', value: '99', type: 'int' }); + expect(row(seen.label)).toMatchObject({ value: "'changed'", type: 'str' }); + expect(row(seen.g)).toMatchObject({ value: '5' }); + expect(row(seen.x)).toMatchObject({ value: '10' }); + expect(row(seen.item)).toMatchObject({ value: "'two'" }); + expect(row(seen.entry)).toMatchObject({ value: '[1, 2]', size: 2 }); + // What Python refuses is refused, with Python's reason. + expect(seen.tuple).toMatch(/^Error: TypeError/); + expect(seen.norm).toMatch(/^Error: AttributeError/); + expect(seen.syntax).toMatch(/^Error: SyntaxError/); + expect(seen.statement).toMatch(/^Error: SyntaxError/); + expect(row(seen.commented)).toMatchObject({ value: '5' }); + // A method's self is its receiver, which no frame write can replace. + expect(seen.selfReadOnly).toBe('true'); + expect(seen.self).toBe('Error: self cannot be changed here'); + // The row reads the new value, and the run went on with every change. + expect(seen.afterRows).toBe('["99"]'); + expect(result.value).toBe("((99, 'changed', 10), 5, [1, 'two', 3], {'k': [1, 2]}, (1, 2))"); + }); + + it('writes a local in an outer frame, a closed-over local, and one set inside try:', async () => { + const nb = notebook('bp-set-frames'); + const answers: string[] = []; + setHaltHandler(async (request) => { + const set = (ref: number, name: string, value: string) => + request.query(setVariableQuery(request.process, ref, name, value)); + const { frames } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const outer = frames.find((f) => f.name === 'outer'); + if (outer) { + // Not the innermost frame: the paused one is inner(), called from inside try:. + answers.push(await set(outer.locals!, 'a', '10'), await set(outer.locals!, 'b', '20')); + } else { + answers.push(await set(frames.find((f) => f.name === 'tryer')!.locals!, 'c', '30')); + } + return 'continue'; + }); + + const result = await runPython( + [ + 'def inner():', + ' breakpoint()', + ' return 0', + 'def outer():', + ' a = 1', + ' seen = lambda: a', + ' try:', + ' b = 2', + ' inner()', + ' finally:', + ' pass', + ' return a, seen(), b', + 'def tryer():', + ' c = 1', + ' try:', + ' breakpoint()', + ' finally:', + ' pass', + ' return c', + '(outer(), tryer())', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + expect(answers.map((a) => parseChildren(a)[0]?.value ?? a)).toEqual(['10', '20', '30']); + // The run read every new value, the closure included. + expect(result.value).toBe('((10, 10, 20), 30)'); + }); + + it('offers Set Value on real locals only: not on a receiver, whatever the def calls it', async () => { + const nb = notebook('bp-receivers'); + const seen: Array> = []; + setHaltHandler(async (request) => { + const { frames } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const locals = frames[0].locals!; + const rows = parseChildren(await request.query(childrenQuery(locals, 0, 500))); + const set = (name: string, value: string) => + request.query(setVariableQuery(request.process, locals, name, value)); + const readOnly = (name: string) => String(rows.find((v) => v.name === name)?.readOnly); + if (frames[0].name.endsWith('mk')) { + // A value with a side effect must not run when its row cannot be changed. + seen.push({ + cls: readOnly('cls'), + k: readOnly('k'), + write: await set('cls', 'log.append(9)'), + }); + } else { + seen.push({ this: readOnly('this'), j: readOnly('j') }); + } + return 'continue'; + }); + + const result = await runPython( + [ + 'class A:', + ' @classmethod', + ' def mk(cls):', + ' k = 1', + ' log = []', + ' breakpoint()', + ' return log', + ' def see(this):', + ' j = 2', + ' breakpoint()', + ' return j', + '(A.mk(), A().see())', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + // Grail may leave a classmethod's cls out of Locals altogether; it is never offered as writable. + expect(seen[0].cls).not.toBe('false'); + expect(seen[0].write).toMatch(/^Error: cls cannot be changed here/); + // A method's receiver is read-only whatever the def calls it; its real locals are not. + expect(seen[1]).toEqual({ this: 'true', j: 'false' }); + // A real local beside the receiver stays writable. + expect(seen[0].k).toBe('false'); + // Refused before the value ran: log, which the value could see, is unchanged. + expect(result.value).toBe('([], 2)'); + }); + + it('reads a tuple’s and a tuple subclass’s items as read-only, and a list subclass’s as writable', async () => { + const nb = notebook('bp-tuples'); + const seen: Record = {}; + setHaltHandler(async (request) => { + const children = async (ref: number) => + parseChildren(await request.query(childrenQuery(ref, 0, 500))); + const { globals } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const rows = await children(globals); + seen.pt = await children(rows.find((v) => v.name === 'pt')!.ref); + seen.ll = await children(rows.find((v) => v.name === 'll')!.ref); + seen.tt = await children(rows.find((v) => v.name === 'tt')!.ref); + return 'continue'; + }); + + await runPython( + [ + 'from collections import namedtuple', + 'Pt = namedtuple("Pt", "x y")', + 'class L(list):', + ' pass', + 'pt = Pt(1, 2)', + 'll = L([1, 2])', + 'tt = (1, 2)', + 'breakpoint()', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + expect(seen.pt.filter((v) => v.name.startsWith('[')).map((v) => v.readOnly)).toEqual([ + true, + true, + ]); + expect(seen.ll.map((v) => v.readOnly)).toEqual([false, false]); + // And the common case, a plain tuple. + expect(seen.tt.map((v) => v.readOnly)).toEqual([true, true]); + }); + + it('lets every Set Value use the globals, with a frame’s locals over them', async () => { + const nb = notebook('bp-set-globals'); + const seen: Record = {}; + setHaltHandler(async (request) => { + const children = async (ref: number) => + parseChildren(await request.query(childrenQuery(ref, 0, 500))); + const { frames, globals } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const locals = frames[0].locals!; + const set = (ref: number, name: string, value: string) => + request.query(setVariableQuery(request.process, ref, name, value, globals)); + const rows = await children(globals); + const ref = (name: string) => rows.find((v) => v.name === name)!.ref; + // A notebook class on an attribute row, a global on a list item and a dict entry. + seen.attribute = await set(ref('holder'), 'x', 'Point(1, 2)'); + seen.item = await set(ref('items'), '[0]', 'g + 1'); + seen.entry = await set(ref('d'), "'k'", 'g + 1'); + // On a Locals row, a local and a global together. + seen.sum = await set(locals, 'n', 'n + g'); + // A local shadows the global of the same name. + seen.shadowed = await set(locals, 'y', 'x'); + // Still refused: a name that is neither. + seen.unknown = await set(locals, 'z', 'nope'); + return 'continue'; + }); + + const result = await runPython( + [ + 'class Point:', + ' def __init__(self, x, y):', + ' self.x, self.y = x, y', + ' def __repr__(self):', + ' return f"Point({self.x}, {self.y})"', + 'g = 5', + 'x = 100', + 'holder = Point(0, 0)', + 'items = [1, 2]', + 'd = {"k": 0}', + 'def f():', + ' n = 1', + ' x = 7', + ' y = 0', + ' z = 0', + ' breakpoint()', + ' return n, x, y, z', + '(f(), holder.x, items, d)', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + const value = (raw: string) => (raw.startsWith('Error: ') ? raw : parseChildren(raw)[0]?.value); + expect(value(seen.attribute)).toBe('Point(1, 2)'); + expect(value(seen.item)).toBe('6'); + expect(value(seen.entry)).toBe('6'); + expect(value(seen.sum)).toBe('6'); + expect(value(seen.shadowed)).toBe('7'); + expect(seen.unknown).toMatch(/^Error: NameError/); + // The run saw every change, and z was left alone. + expect(result.value).toBe("((6, 7, 7, 0), Point(1, 2), [6, 2], {'k': 6})"); + }); + it('reads dicts whole: every entry, a page at a time when big, keys told apart', async () => { const seen: Record = {}; const nb = notebook('bp-dicts'); @@ -316,6 +719,69 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { ); }); + it('stops running getters once one runs past the budget, and says which', async () => { + const nb = notebook('bp-getters'); + const seen: Record = {}; + let took = 0; + setHaltHandler(async (request) => { + const children = async (ref: number) => + parseChildren(await request.query(childrenQuery(ref, 0, 500))); + const { globals } = parsePausedStack( + await request.query(pausedStackQuery(request.process, nb.key)), + ); + const rows = await children(globals); + const started = Date.now(); + seen.s = await children(rows.find((v) => v.name === 's')!.ref); + took = Date.now() - started; + seen.d = await children(rows.find((v) => v.name === 'd')!.ref); + return 'continue'; + }); + + const result = await runPython( + [ + 'class Slow:', + ' def __init__(self):', + ' self.own = 2', + ' @property', + ' def a(self):', + ' while True:', + ' pass', + ' @property', + ' def b(self):', + ' while True:', + ' pass', + ' @property', + ' def fast(self):', + ' return 1', + 'class SpinDir:', + ' def __init__(self):', + ' self.x = 1', + ' def __dir__(self):', + ' while True:', + ' pass', + 's = Slow()', + 'd = SpinDir()', + 'breakpoint()', + '"went on"', + ].join('\n'), + nb, + ); + closeSessionFor(nb.key); + + const value = (rows: PauseVariable[], name: string) => rows.find((v) => v.name === name)?.value; + // The getter the break stopped, and the one after it, say so; neither is left out. + expect(value(seen.s, 'a')).toBe(''); + expect(value(seen.s, 'b')).toBe(''); + expect(seen.s.map((v) => v.name)).toContain('fast'); + // What the object holds itself needs no getter, so it still reads. + expect(value(seen.s, 'own')).toBe('2'); + // One budget, not one per slow getter. + expect(took).toBeLessThan(3500); + // A __dir__ that never returns leaves the object's own attributes, not nothing. + expect(value(seen.d, 'x')).toBe('1'); + expect(result.value).toBe("'went on'"); + }); + it('gives up on a __repr__ that never returns, keeping the other rows and the pause', async () => { let rows: PauseVariable[] = []; let took = 0; @@ -625,6 +1091,7 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { const lines = [ 'import gemdb', 'total = 10', + 'bonus = 0', 'def step(n):', ' x = n * 2', ' breakpoint()', @@ -632,6 +1099,7 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { 'print("as", __name__)', 'total = total + step(3)', 'print("total", total)', + 'print("bonus", bonus)', ]; fs.writeFileSync(file, lines.join('\n')); const owner = fileOwner(file); @@ -639,6 +1107,7 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { let locals: string[] = []; let globalNames: string[] = []; let saved = ''; + let setGlobal = ''; setHaltHandler(async (request) => { const stack = parsePausedStack( await request.query(pausedStackQuery(request.process, MAIN_MODULE)), @@ -668,6 +1137,10 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { ), ), ); + // A file run's globals are its __main__ module's: Set Value writes the module, and the run reads it. + setGlobal = await request.query( + setVariableQuery(request.process, stack.globals, 'bonus', '7'), + ); return 'continue'; }); const printed: string[] = []; @@ -685,14 +1158,15 @@ describe.skipIf(!haveExtent || !canMakeFixture())('breakpoint()', () => { closeSessionFor(owner.key); expect(frames).toEqual([ - ['step', file, 5], - ['', file, 8], + ['step', file, 6], + ['', file, 9], ]); expect(locals).toEqual(['n=3', 'x=6']); // The script's globals, data first; dunders such as __name__ are left out. expect(globalNames).toEqual(expect.arrayContaining(['total'])); expect(globalNames.filter((n) => n.startsWith('__') && n.endsWith('__'))).toEqual([]); - expect(printed.join('')).toBe('as __main__\ntotal 16\n'); + expect(parseChildren(setGlobal)[0]).toMatchObject({ name: 'bonus', value: '7' }); + expect(printed.join('')).toBe('as __main__\ntotal 16\nbonus 7\n'); expect(result.value).toBe(''); expect(saved).toBe('saved'); expect(opened.meta.frames.map((f) => f.name)).toEqual(['step', '']); diff --git a/src/__tests__/debugger.test.ts b/src/__tests__/debugger.test.ts index 4e75196..3003c67 100644 --- a/src/__tests__/debugger.test.ts +++ b/src/__tests__/debugger.test.ts @@ -9,6 +9,7 @@ import { debug, window, } from '../__mocks__/vscode'; +import type { PauseVariable } from '../pauseVariables'; import type { DotsByFile } from '../redDots'; import type { HaltAnswer, HaltHandler, HaltRequest, RedDotSource } from '../session'; @@ -44,6 +45,8 @@ const { toDapVariable, verifiedDots, withFileDots, + withoutBadge, + copyText, } = await import('../debugger'); const F = '\u001f'; @@ -313,34 +316,83 @@ describe('scopes and variable rows', () => { expect(scopesFor(0, 4).map((s) => s.name)).toEqual(['Globals']); }); + /** A row as the gem writes it, with the fields a test cares about. */ + const pauseRow = (fields: Partial & { name: string }): PauseVariable => ({ + value: '', + type: '', + ref: 0, + indexed: 0, + named: 0, + handle: 0, + size: -1, + readOnly: false, + ...fields, + }); + it('tells VS Code how many items a list has, so it pages them', () => { - const rows = toDapVariable({ - name: 'rows', - value: '[...]', - type: 'list', - ref: 7, - indexed: 10_000, - named: 0, - handle: 7, - }); - const leaf = toDapVariable({ - name: 'n', - value: '3', - type: 'int', - ref: 0, - indexed: 0, - named: 0, - handle: 8, - }); + const rows = toDapVariable( + pauseRow({ + name: 'rows', + value: '[...]', + type: 'list', + ref: 7, + indexed: 10_000, + size: 10_000, + }), + ); + const leaf = toDapVariable(pauseRow({ name: 'n', value: '3', type: 'int', handle: 8 })); expect(rows).toEqual({ name: 'rows', - value: '[...]', + value: '{list: 10000} [...]', type: 'list', variablesReference: 7, indexedVariables: 10_000, }); - expect(leaf).toEqual({ name: 'n', value: '3', type: 'int', variablesReference: 0 }); + expect(leaf).toEqual({ name: 'n', value: '{int} 3', type: 'int', variablesReference: 0 }); + }); + + it('leads every value with its type, and a container’s with its size, as PyCharm does', () => { + const value = (fields: Partial) => + toDapVariable(pauseRow({ name: 'x', ...fields })).value; + + expect(value({ value: 'Point(3, 4)', type: 'Point' })).toBe('{Point} Point(3, 4)'); + expect(value({ value: '[1, 2]', type: 'list', size: 2 })).toBe('{list: 2} [1, 2]'); + expect(value({ value: '[]', type: 'list', size: 0 })).toBe('{list: 0} []'); + // Too big for a repr, the gem writes its size in words; the badge says it already. + expect(value({ value: 'list with 150 items', type: 'list', size: 150 })).toBe('{list: 150}'); + // A group heading has no type, and no badge. + expect(value({ name: 'special variables', value: '' })).toBe(''); + }); + + it('takes the row’s own badge off a value edited in place, and nothing else', () => { + // VS Code opens Set Value's box on the value text, badge and all. + expect(withoutBadge('{int} 42', '{int}')).toBe('42'); + expect(withoutBadge('{list: 3} [1, 2, 3, 4]', '{list: 3}')).toBe('[1, 2, 3, 4]'); + // A value typed fresh, or a set expression that merely looks like a badge, is left alone. + expect(withoutBadge('42', '{int}')).toBe('42'); + expect(withoutBadge('{x} | {y}', '{int}')).toBe('{x} | {y}'); + expect(withoutBadge('{int} 42', undefined)).toBe('{int} 42'); + // A row too big for its repr shows the badge alone, and that is a dict display in Python: + // left in, Enter would replace a 150-item list with {list: 150}. + expect(withoutBadge('{list: 150}', '{list: 150}')).toBe(''); + }); + + it('copies a value without its type badge, as Copy Value should', () => { + expect(copyText("{str} 'hello'")).toBe("'hello'"); + expect(copyText('{Point} Point(3, 4)')).toBe('Point(3, 4)'); + expect(copyText('{list: 3} [1, 2, 3]')).toBe('[1, 2, 3]'); + // A container too big for its repr shows its badge alone; it copies as the gem wrote it. + expect(copyText('{list: 150}')).toBe('list with 150 items'); + // A group heading has no value and no badge. + expect(copyText('')).toBe(''); + }); + + it('marks a row that cannot be changed, so VS Code offers no Set Value on it', () => { + expect(toDapVariable(pauseRow({ name: 'self', readOnly: true })).presentationHint).toEqual({ + attributes: ['readOnly'], + }); + expect(toDapVariable(pauseRow({ name: 'n' })).presentationHint).toBeUndefined(); }); }); @@ -781,6 +833,76 @@ describe('the debug adapter', () => { expect(events()).toContain('stopped'); }); + it('offers Set Value, and answers it with the row as it reads now', async () => { + const setVariable = vi.fn(() => + Promise.resolve({ name: 'n', value: '{int} 99', type: 'int', variablesReference: 0 }), + ); + const { request, response } = adapterFor({ answer: () => {}, setVariable }); + request('initialize'); + request('launch', { gemdbPause: '1' }); + + request('setVariable', { variablesReference: 3, name: 'n', value: '99' }); + await settle(); + + expect(response('initialize')?.body).toMatchObject({ supportsSetVariable: true }); + expect(setVariable).toHaveBeenCalledWith(3, 'n', '99'); + expect(response('setVariable')).toMatchObject({ + success: true, + body: { value: '{int} 99', type: 'int', variablesReference: 0 }, + }); + }); + + it('shows the user why Set Value failed', async () => { + const setVariable = () => Promise.reject(new Error('TypeError - tuple is immutable')); + const { request, response } = adapterFor({ answer: () => {}, setVariable }); + request('initialize'); + request('launch', { gemdbPause: '1' }); + + request('setVariable', { variablesReference: 5, name: '[0]', value: '9' }); + await settle(); + + expect(response('setVariable')).toMatchObject({ + success: false, + message: 'Could not set [0]: TypeError - tuple is immutable', + body: { error: { showUser: true } }, + }); + }); + + it('changes nothing in a saved stack, saying so', () => { + const { request, response } = adapterFor({ answer: () => {}, saved: true }); + request('initialize'); + request('launch', { gemdbPause: '1' }); + + request('setVariable', { variablesReference: 3, name: 'n', value: '1' }); + + expect(response('setVariable')).toMatchObject({ + success: false, + message: 'This is a saved stack: its values cannot be changed.', + }); + }); + + it('answers Copy Value with the value alone, and still refuses other evaluation', () => { + const { request, response } = adapterFor({ answer: () => {} }); + request('initialize'); + request('launch', { gemdbPause: '1' }); + + request('evaluate', { expression: "{str} 'hello'", frameId: 1, context: 'clipboard' }); + const copied = response('evaluate'); + request('evaluate', { expression: 'p.x', frameId: 1, context: 'repl' }); + const repl = response('evaluate'); + // Only the clipboard: answering another context would echo text back as if evaluated. + request('evaluate', { expression: '{int} 3', frameId: 1, context: 'variables' }); + + expect(response('initialize')?.body).toMatchObject({ supportsClipboardContext: true }); + expect(copied).toMatchObject({ success: true, body: { result: "'hello'" } }); + for (const refused of [repl, response('evaluate')]) { + expect(refused).toMatchObject({ + success: false, + message: "Evaluating expressions isn't supported yet.", + }); + } + }); + it('declines Restart with a message, leaving the pause alone', () => { const answer = vi.fn(); const { request, response } = adapterFor({ answer }); @@ -1073,6 +1195,96 @@ describe('opening the debugger at a breakpoint()', () => { registration.dispose(); }); + it('sets a value in the paused session, and the new row’s handle replaces the old', async () => { + const registration = registerBreakpointDebugger(); + const request = haltRequest(); + request.query = (code) => { + request.queries.push(code); + if (code.includes('Suspended')) return Promise.resolve(PAUSED); + if (code.includes('_frameAt:')) return Promise.resolve(row('n', '99', 'int', 0, 0, 0, 21)); + return Promise.resolve(row('n', '7', 'int', 0, 0, 0, 20)); + }; + void installed!(request); + await settle(); + const adapter = adapterForLastStart(); + ask(adapter, 'launch', { gemdbPause: pauseIdOf(0) }); + ask(adapter, 'variables', { variablesReference: 3 }); + await settle(); + const sent: Array> = []; + adapter.onDidSendMessage((m) => sent.push(m as Record)); + + // Edited in place: the box held the row's value text, badge and all. + adapter.handleMessage({ + seq: 9, + type: 'request', + command: 'setVariable', + arguments: { variablesReference: 3, name: 'n', value: '{int} n + 92' }, + }); + await settle(); + + const query = request.queries[request.queries.length - 1]; + // The query knows the paused process, so a local can be written into its frame. + expect(query).toContain('_objectForOop: 42'); + // The pause's globals (registry position 9) are what every Set Value can see. + expect(query).toContain('(9 between: 1 and: reg size)'); + expect(query).toContain("evaluateSource: '(\nn + 92\n)'"); + expect(sent.find((m) => m.command === 'setVariable')).toMatchObject({ + success: true, + body: { value: '{int} 99', type: 'int' }, + }); + expect(pauseForOwner('file:///a.ipynb')!.handleFor?.(3, 'n')).toBe(21); + registration.dispose(); + }); + + it('says Set Value wants an expression when it was given a statement', async () => { + const registration = registerBreakpointDebugger(); + const request = haltRequest(); + request.query = (code) => { + request.queries.push(code); + if (code.includes('Suspended')) return Promise.resolve(PAUSED); + if (code.includes('_frameAt:')) { + return Promise.resolve('Error: SyntaxError - invalid syntax'); + } + return Promise.resolve(''); + }; + void installed!(request); + await settle(); + const pause = pauseForOwner('file:///a.ipynb')!; + + await expect(pause.setVariable!(3, 'n', 'n = 5')).rejects.toThrow( + 'Set Value takes a Python expression, such as 5 or n + 1 (SyntaxError - invalid syntax)', + ); + // Parenthesized on lines of their own, so a statement cannot run and a comment cannot + // swallow the closing parenthesis. + expect(request.queries[request.queries.length - 1]).toContain("'(\nn = 5\n)'"); + registration.dispose(); + }); + + it('refuses an empty Set Value rather than running it', async () => { + const registration = registerBreakpointDebugger(); + const request = haltRequest(); + request.query = (code) => { + request.queries.push(code); + if (code.includes('Suspended')) return Promise.resolve(PAUSED); + if (code.includes('_frameAt:')) return Promise.resolve(row('big', '{}', 'dict', 0, 0, 0, 1)); + return Promise.resolve(row('big', 'list with 150 items', 'list', 5, 150, 0, 5, 150, 0)); + }; + void installed!(request); + await settle(); + const adapter = adapterForLastStart(); + ask(adapter, 'launch', { gemdbPause: pauseIdOf(0) }); + ask(adapter, 'variables', { variablesReference: 3 }); + await settle(); + const pause = pauseForOwner('file:///a.ipynb')!; + + // The box held only the badge, and Enter was pressed. + await expect(pause.setVariable!(3, 'big', '{list: 150}')).rejects.toThrow( + 'Set Value takes a Python expression', + ); + expect(request.queries.some((q) => q.includes('_frameAt:'))).toBe(false); + registration.dispose(); + }); + it('keeps two pauses in one run apart: a late disconnect from the first settles nothing', async () => { const registration = registerBreakpointDebugger(); const first = installed!(haltRequest()); @@ -1166,8 +1378,38 @@ describe('the variables queries’ answers', () => { // A leaf has no ref — it cannot be expanded — but it has a handle, so it can be saved. expect(rows).toEqual([ - { name: 'tags', value: "{'a', 'b'}", type: 'set', ref: 8, indexed: 2, named: 0, handle: 8 }, - { name: 'n', value: '2', type: 'int', ref: 0, indexed: 0, named: 0, handle: 9 }, + { + name: 'tags', + value: "{'a', 'b'}", + type: 'set', + ref: 8, + indexed: 2, + named: 0, + handle: 8, + size: -1, + readOnly: false, + }, + { + name: 'n', + value: '2', + type: 'int', + ref: 0, + indexed: 0, + named: 0, + handle: 9, + size: -1, + readOnly: false, + }, + ]); + }); + + it('reads a row’s size and whether it can be changed', () => { + const raw = + row('items', '[1, 2]', 'list', 4, 2, 0, 4, 2, 0) + row('self', 'P()', 'P', 5, 0, 1, 5, -1, 1); + + expect(parseChildren(raw).map((v) => [v.name, v.size, v.readOnly])).toEqual([ + ['items', 2, false], + ['self', -1, true], ]); }); }); diff --git a/src/__tests__/savedObjects.test.ts b/src/__tests__/savedObjects.test.ts index b49b1db..caae989 100644 --- a/src/__tests__/savedObjects.test.ts +++ b/src/__tests__/savedObjects.test.ts @@ -142,6 +142,8 @@ describe('inspecting a saved object on hover', () => { indexed, named, handle: 1, + size: -1, + readOnly: false, }); it('shows the type, the text, and the first children with their values aligned', () => { @@ -304,7 +306,17 @@ describe('remembering where a persisted object came from', () => { const markdown = inspectMarkdown( 'barbara', true, - { name: 'value', value: '', type: 'Employee', ref: 0, indexed: 0, named: 0, handle: 1 }, + { + name: 'value', + value: '', + type: 'Employee', + ref: 0, + indexed: 0, + named: 0, + handle: 1, + size: -1, + readOnly: false, + }, [], { notebook: 'breakpoint.ipynb', type: 'Employee', at: '2026-10-01T16:20:00.000Z' }, ); diff --git a/src/debugger.ts b/src/debugger.ts index 178a704..16978b0 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -13,6 +13,7 @@ import { parseChildren, parsePausedStack, pausedStackQuery, + setVariableQuery, } from './pauseVariables'; import { DotsByFile, armedLinesQuery, parseArmed } from './redDots'; import { HaltAnswer, HaltRequest, setHaltHandler, setRedDotSource } from './session'; @@ -36,11 +37,12 @@ import { HaltAnswer, HaltRequest, setHaltHandler, setRedDotSource } from './sess * breakpoint() must not write the notebook to disk. * * 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. + * Locals and the notebook's Globals, Set Value on a row, 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. The one evaluation answered is Copy + * Value's, which only takes a row's type badge off its text (`copyText`). * * The adapter is inline (`DebugAdapterInlineImplementation`) and speaks the * Debug Adapter Protocol directly rather than through `@vscode/debugadapter`: @@ -81,6 +83,8 @@ export interface DapVariable { variablesReference: number; indexedVariables?: number; namedVariables?: number; + /** `readOnly` keeps VS Code from offering Set Value on the row. */ + presentationHint?: { attributes?: string[] }; } /** One evaluation sitting at a breakpoint(), waiting for the user. */ @@ -93,6 +97,12 @@ export interface Pause { scopes(frameId: number): DapScope[]; /** The children of one expandable row, `count` of them from `start`. */ variables(ref: number, start: number, count: number): Promise; + /** + * Give the row `name` under `ref` the value of the Python expression + * `value`, and answer the row as it reads now. Rejects with the reason it + * could not; absent where nothing can be changed (a saved stack). + */ + setVariable?(ref: number, name: string, value: string): Promise; /** Settle the pause. Only the first call counts. */ answer(choice: HaltAnswer): void; /** @@ -149,18 +159,53 @@ export function scopesFor(locals: number, globals: number): DapScope[] { } /** - * A row for the Variables view. A list or set — or a dict too big for one - * page — says how many items it has, so VS Code pages it rather than asking - * for ten million rows at once. + * A row's type badge, as PyCharm shows one: `{Point}`, or with a container's + * size, `{list: 3}`. Empty for a row with no type, such as a group heading. + */ +export function badgeOf(v: Pick): string { + if (!v.type) return ''; + return v.size >= 0 ? `{${v.type}: ${v.size}}` : `{${v.type}}`; +} + +/** + * What Copy Value copies for a row whose value text is `value`: the value + * without the type badge in front of it, so `{str} 'hello'` copies as + * `'hello'`. A container too big for its repr shows only its badge, + * `{list: 150}`, and copies as the gem wrote it, `list with 150 items`. + * + * VS Code's Copy Value sends the row's text (or its `evaluateName`, which + * GemDB rows do not have) as an `evaluate` request in the `clipboard` + * context, and copies the answer; refused, it copies the value text as shown, + * badge and all (read from VS Code 1.134's copy-value command). + */ +export function copyText(value: string): string { + const sized = /^\{([^{}:]+): (\d+)\}$/.exec(value); + if (sized) return `${sized[1]} with ${sized[2]} items`; + const badged = /^\{[^{}]*\} /.exec(value); + return badged ? value.slice(badged[0].length) : value; +} + +/** + * A row for the Variables view. Its value leads with its type badge, because + * VS Code shows a row's `type` only in a hover unless the user turns on + * `debug.showVariableTypes`. A container too big to show its repr, which reads + * `list with 150 items`, shows the badge alone, which says the same. + * + * A list or set — or a dict too big for one page — says how many items it + * has, so VS Code pages it rather than asking for ten million rows at once. */ export function toDapVariable(v: PauseVariable): DapVariable { + const badge = badgeOf(v); + const sized = v.size >= 0 && v.value === `${v.type} with ${v.size} items`; + const value = !badge ? v.value : sized ? badge : `${badge} ${v.value}`; return { name: v.name, - value: v.value, + value, ...(v.type ? { type: v.type } : {}), variablesReference: v.ref, ...(v.indexed > 0 ? { indexedVariables: v.indexed } : {}), ...(v.named > 0 ? { namedVariables: v.named } : {}), + ...(v.readOnly ? { presentationHint: { attributes: ['readOnly'] } } : {}), }; } @@ -467,6 +512,7 @@ interface DapRequest { } const NOT_YET_STEP = "Stepping isn't supported yet. Use Continue to resume or Stop to end the run."; +const NOT_CHANGEABLE = 'This is a saved stack: its values cannot be changed.'; const NOT_YET_RESTART = "Restarting isn't supported yet. Use Continue to resume or Stop to end the run."; @@ -517,6 +563,10 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { this.respond(request, { supportsConfigurationDoneRequest: true, supportsTerminateRequest: true, + supportsSetVariable: true, + // Copy Value asks for the row's text through evaluate in this + // context, and copies the answer: see copyText. + supportsClipboardContext: 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. @@ -593,9 +643,11 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { case 'variables': { const ref = Number(request.arguments?.variablesReference ?? 0); const start = Number(request.arguments?.start ?? 0); - // No count means "all". A row with more than a page reports its - // children as indexed, which VS Code asks for a page at a time, so - // capping here never hides a row. + // No count means "all". A container with more than a page reports + // its children as indexed, which VS Code asks for a page at a time, so + // capping here hides none of them. A scope or an object is grouped, + // and a group cannot be paged into, so past a page it ends in a + // "... N more not shown" row instead. const count = Number(request.arguments?.count ?? 0) || PAGE; const pause = this.pause; if (!pause) { @@ -607,6 +659,28 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { .then((variables) => this.respond(request, { variables })); return; } + case 'setVariable': { + const ref = Number(request.arguments?.variablesReference ?? 0); + const name = String(request.arguments?.name ?? ''); + const value = String(request.arguments?.value ?? ''); + const set = this.pause?.setVariable; + if (!set) { + this.fail(request, NOT_CHANGEABLE, true); + return; + } + void set(ref, name, value).then( + (v) => + this.respond(request, { + value: v.value, + ...(v.type ? { type: v.type } : {}), + variablesReference: v.variablesReference, + ...(v.indexedVariables ? { indexedVariables: v.indexedVariables } : {}), + ...(v.namedVariables ? { namedVariables: v.namedVariables } : {}), + }), + (e: unknown) => this.fail(request, `Could not set ${name}: ${errorMessage(e)}`, true), + ); + return; + } case 'continue': this.respond(request, { allThreadsContinued: true }); this.settle('continue'); @@ -628,9 +702,18 @@ export class PauseDebugAdapter implements vscode.DebugAdapter { // Only offered while running, and a paused evaluation is never running here. this.respond(request); return; - case 'evaluate': + case 'evaluate': { + const context = String(request.arguments?.context ?? ''); + if (context === 'clipboard') { + // Copy Value on a Variables row: the expression is the row's value + // text, and what is copied is the value without its type badge. + const expression = String(request.arguments?.expression ?? ''); + this.respond(request, { result: copyText(expression), variablesReference: 0 }); + return; + } this.fail(request, "Evaluating expressions isn't supported yet.", false); return; + } case 'terminate': this.respond(request); this.settle('stop'); @@ -781,6 +864,8 @@ interface PauseView { frames: DapFrame[]; scopes: Map; texts: Array; + /** Registry position of the notebook's or file run's globals; 0 for none. */ + globals: number; } /** @@ -809,37 +894,102 @@ async function readPause(request: HaltRequest): Promise { ); 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, + globals, + }; } 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: [], globals: 0 }; } } +/** + * What the pause remembers of each row it has listed, keyed by the row's + * container and name, which is all a Variables-row command or a Set Value + * request is handed: the row's handle, and the badge its value starts with. + */ +interface ListedRows { + handles: Map; + badges: Map; +} + +/** + * The value Set Value was given, without the row's own type badge in front. + * VS Code opens Set Value's box on the row's value text, so a value edited in + * place still starts with `{int} ` — which as Python is a set followed by a + * number, a syntax error. Only the row's exact badge is taken off, followed by + * a space or alone, so an expression the user typed is never changed. Alone + * matters most: a container too big for its repr shows only its badge, + * `{list: 150}`, which is a valid dict display; left in, accepting the box + * unchanged would replace the list with a dict. + */ +export function withoutBadge(value: string, badge: string | undefined): string { + if (!badge) return value; + if (value.trim() === badge) return ''; + return value.startsWith(`${badge} `) ? value.slice(badge.length + 1) : value; +} + +/** Note a listed row's handle and badge under its container and name. */ +function remember(listed: ListedRows, ref: number, row: PauseVariable): void { + const key = handleKey(ref, row.name); + if (row.handle > 0) listed.handles.set(key, row.handle); + const badge = badgeOf(row); + if (badge) listed.badges.set(key, badge); + else listed.badges.delete(key); +} + /** * The children of one registered object. A failure shows as no children, not - * an error. Each row's handle is noted under `handles`, keyed by its - * container and name, which is all a Variables-row command is handed. + * an error. Each row is remembered (`ListedRows`) for the commands and Set + * Value requests that name it later. */ async function readChildren( request: HaltRequest, - handles: Map, + listed: ListedRows, ref: number, start: number, count: number, ): Promise { try { const rows = parseChildren(await request.query(childrenQuery(ref, start, count))); - for (const row of rows) { - if (row.handle > 0) handles.set(handleKey(ref, row.name), row.handle); - } - return rows.map(toDapVariable); + for (const row of rows) remember(listed, ref, row); + return rows.map((row) => toDapVariable(row)); } catch (e) { log(`Could not read variable ${ref} at breakpoint(): ${errorMessage(e)}`); return []; } } +/** + * Set Value on a row: write it in the gem and answer the row as it reads now. + * The value can use the pause's globals, at registry position `globals`. The + * new row is remembered in place of the old, so a command on the row reaches + * the new object. Rejects with the gem's reason, which VS Code shows. + */ +async function writeVariable( + request: HaltRequest, + listed: ListedRows, + globals: number, + ref: number, + name: string, + value: string, +): Promise { + const source = withoutBadge(value, listed.badges.get(handleKey(ref, name))); + if (!source.trim()) throw new Error('Set Value takes a Python expression, such as 5 or n + 1'); + const raw = await request.query(setVariableQuery(request.process, ref, name, source, globals)); + if (raw.startsWith('Error: SyntaxError')) { + throw new Error(`Set Value takes a Python expression, such as 5 or n + 1 (${raw.slice(7)})`); + } + if (raw.startsWith('Error: ')) throw new Error(raw.slice('Error: '.length)); + const [row] = parseChildren(raw); + if (!row) throw new Error('nothing came back'); + remember(listed, ref, row); + return toDapVariable(row); +} + function handleKey(containerRef: number, name: string): string { return `${containerRef}\u0000${name}`; } @@ -913,17 +1063,19 @@ 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(({ frames, scopes, texts, globals }) => { if (settled) return; - const handles = new Map(); + const listed: ListedRows = { handles: new Map(), badges: new Map() }; pauses.set(id, { label, ownerKey: request.session.owner.key, frames, scopes: (frameId) => scopes.get(frameId) ?? [], - variables: (ref, start, count) => readChildren(request, handles, ref, start, count), + variables: (ref, start, count) => readChildren(request, listed, ref, start, count), + setVariable: (ref, name, value) => + writeVariable(request, listed, globals, ref, name, value), answer, - handleFor: (containerRef, name) => handles.get(handleKey(containerRef, name)), + handleFor: (containerRef, name) => listed.handles.get(handleKey(containerRef, name)), query: (code) => request.query(code), rearm: (dots) => request.rearm(dots), armedDots: request.armedDots, diff --git a/src/pauseVariables.ts b/src/pauseVariables.ts index b4908d1..ad6f621 100644 --- a/src/pauseVariables.ts +++ b/src/pauseVariables.ts @@ -63,23 +63,44 @@ export interface PauseVariable { * such as a group heading. */ handle: number; + /** + * How many items a container holds — a list's, a dict's, a set's — for the + * size in the row's badge; -1 for anything that is not a container. + */ + size: number; + /** The row cannot be changed with Set Value: a tuple's item, a group heading, a method's `self`. */ + readOnly: boolean; } /** * 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 - * order), `#dict` (entries named by their key's repr), `#attrs` (a Python - * instance's `__dict__`, named by attribute) or `#ivars` (an object with no - * `__dict__` — a Grail runtime object — shown by its Smalltalk instance - * variables, whose names are `source`). Nothing is copied: `source` is the - * object itself, or its `__dict__`. + * order), `#dict` (entries named by their key's repr), `#obj` (a Python + * object, read by attribute; `source` is its `__dict__`, or nil for a + * `__slots__` object, which has none) or `#ivars` (a Grail runtime object, with + * neither, shown by its Smalltalk instance variables, whose names are + * `source`). Nothing is copied. + * + * This runs for every row listed, to tell whether the row opens and how big + * it is, so it stays as cheap as it was before objects were read whole: one + * `__dict__` load, and for an object without one, one `__slots__` load. The + * object's whole `dir()` is read only when the row is opened (`LIST_CHILDREN`). + * Measured on a page of 500 objects: calling `dir()` here instead took the + * page from about 17 ms to about 980 ms, half the two-second budget. + * + * So an object's size is its own attributes. An object with none of its own + * still opens when its class gives it data — class attributes, properties — + * as a settings object or a singleton does; `classData` answers that, reading + * the class's `dir()` once per class per query, so a page of 500 instances of + * one class costs one `dir()`. An object with neither — a function, an + * instance of an empty class or of one with only methods — stays a leaf. * * Sequences are tested before dictionaries: every Smalltalk sequence also * answers `keysAndValuesDo:`, with 1-based integer keys, and a Python list * must read as `[0]`, `[1]`, … (measured: a list came out as entries 1, 2, 3). */ const CHILDREN_OF = ` - childrenOf := [:v | | d names | + childrenOf := [:v | | d ds slots names | (isLeaf value: v) ifTrue: [nil] ifFalse: [ @@ -94,30 +115,53 @@ const CHILDREN_OF = ` ifFalse: [ d := [v @env1:___pyAttrLoad___: #'__dict__'] on: AbstractException do: [:e | e return: nil]. (d notNil and: [d respondsTo: #keysAndValuesDo:]) - ifTrue: [{ #attrs. d. d size }] + ifTrue: [ + "Sized once: a Python object's __dict__ counts its attributes + each time it is asked, and this runs for every row." + ds := d size. + ds > 0 + ifTrue: [{ #obj. d. ds }] + ifFalse: [| fromClass | + fromClass := classData value: v. + fromClass > 0 ifTrue: [{ #obj. nil. fromClass }] ifFalse: [nil]]] ifFalse: [ - names := v class allInstVarNames. - names isEmpty ifTrue: [nil] ifFalse: [{ #ivars. names. names size }]]]]]]].`; + slots := [v @env1:___pyAttrLoad___: #'__slots__'] on: AbstractException do: [:e | e return: nil]. + (slots notNil and: [slots ~~ none and: [slots size > 0]]) + ifTrue: [{ #obj. nil. slots isString ifTrue: [1] ifFalse: [slots size] }] + ifFalse: [ + names := v class allInstVarNames. + names isEmpty ifTrue: [nil] ifFalse: [{ #ivars. names. names size }]]]]]]]].`; /** * 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`. Whether + * the row can be changed is `ro`, which the caller sets for each row. * * 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 + * shows its size instead (a container only: a module or an object with many + * attributes keeps its repr), and a long string is asked for the repr of its * first characters only. Strings are checked before collections (in * `childrenOf`) because a String is a SequenceableCollection, and a Python str * should read as text, not as a list of characters. */ const DESCRIBE = ` - describe := [:nm :v | | kids n text tname idx named | + describe := [:nm :v | | kids n text tname idx named container | + v == tooLong + ifTrue: [ + out nextPutAll: (clean value: nm asString); nextPut: field; nextPutAll: ''; + nextPut: field; nextPut: field; nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; + nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; nextPutAll: '-1'; nextPut: field; + nextPutAll: '1'; nextPut: record] + ifFalse: [ + "Inline rather than through typeNameOf: this runs for every row listed." tname := [((v @env1:___pyAttrLoad___: #'__class__') @env1:___pyAttrLoad___: #'__name__') asString] on: AbstractException do: [:e | e return: v class name asString]. kids := childrenOf value: v. n := kids isNil ifTrue: [0] ifFalse: [kids at: 3]. + container := kids notNil and: [(kids at: 1) ~~ #obj and: [(kids at: 1) ~~ #ivars]]. text := v isNil ifTrue: ['nil'] - ifFalse: [n > ${REPR_ITEMS} + ifFalse: [(container and: [n > ${REPR_ITEMS}]) ifTrue: [tname , ' with ' , n printString , ' items'] ifFalse: [(v isString and: [v size > ${VALUE_CHARS}]) ifTrue: [reprOf value: (v copyFrom: 1 to: ${VALUE_CHARS})] @@ -125,8 +169,10 @@ const DESCRIBE = ` text isNil ifTrue: [text := late ifTrue: ['<__repr__ took too long>'] ifFalse: [tname]]. text size > ${VALUE_CHARS} ifTrue: [text := (text copyFrom: 1 to: ${VALUE_CHARS - 3}) , '...']. idx := 0. named := 0. + "An object's rows are grouped, and a group cannot be paged into, so an + object never reports its children as indexed, however many it has." n > 0 ifTrue: [ - (((kids at: 1) == #seq or: [(kids at: 1) == #coll]) or: [n > ${PAGE}]) + (((kids at: 1) == #seq or: [(kids at: 1) == #coll]) or: [n > ${PAGE} and: [(kids at: 1) ~~ #obj]]) ifTrue: [idx := n] ifFalse: [named := n]]. out nextPutAll: (clean value: nm asString); nextPut: field; nextPutAll: (clean value: text); nextPut: field; nextPutAll: (clean value: tname); nextPut: field. @@ -134,7 +180,9 @@ 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. + out print: (container ifTrue: [n] ifFalse: [-1]); + nextPut: field; nextPutAll: (ro == true ifTrue: ['1'] ifFalse: ['0']); nextPut: record]].`; /** * Smalltalk for the blocks every row needs. @@ -149,9 +197,59 @@ const DESCRIBE = ` * * `clean` keeps the field and record separators out of a row: a name or * type could hold one, and it would shift every field after it. + * + * `isDunder` is a name like `__init__`: two underscores at each end. + * + * `typeNameOf` is the Python type name, `__class__.__name__`, or the Smalltalk + * class name for an object Python does not describe. + * + * `broke` is for a handler around the user's own code — a getter, a `__dir__` + * — that catches every exception: when the one caught is the soft break, it + * sets `late`, as `reprOf` does, so what follows stops running the user's + * code rather than spending another budget on each slow getter. + * + * `classData` counts the data a class gives its instances — class + * attributes, properties: names that are not dunders, methods or classes — + * cached per class in `classCounts`. `tooLong` stands in for an attribute + * whose getter ran past the budget, or was skipped after one did; `describe` + * writes it as such. */ const HELPERS = ` late := false. + ro := false. + isDunder := [:nm | + nm size > 4 and: [(nm copyFrom: 1 to: 2) = '__' and: [(nm copyFrom: nm size - 1 to: nm size) = '__']]]. + typeNameOf := [:v | + [((v @env1:___pyAttrLoad___: #'__class__') @env1:___pyAttrLoad___: #'__name__') asString] + on: AbstractException do: [:e | e return: v class name asString]]. + broke := [:e | (e isKindOf: ControlInterrupt) ifTrue: [late := true]. e]. + isMethodType := [:tname | + #('function' 'builtin_function_or_method' 'method' 'BoundMethod' 'staticmethod' 'classmethod' + 'method-wrapper' 'wrapper_descriptor' 'method_descriptor' 'classmethod_descriptor') includes: tname]. + classCounts := IdentityDictionary new. + classData := [:v | + late + ifTrue: [0] + ifFalse: [ + "Keyed by the Smalltalk class, a primitive, so a cached answer costs no + Python attribute load; the Python class is read only on a miss." + classCounts at: v class ifAbsent: [| n names cls | + n := 0. + cls := [v @env1:___pyAttrLoad___: #'__class__'] on: AbstractException do: [:e | e return: nil]. + names := cls isNil + ifTrue: [#()] + ifFalse: [[cls @env1:__dir__] on: AbstractException do: [:e | (broke value: e) return: #()]]. + names do: [:nm | | x ok | + (late or: [isDunder value: nm asString]) ifFalse: [ + ok := true. + x := [cls @env1:___pyAttrLoad___: nm asString asSymbol] + on: AbstractException do: [:e | ok := false. (broke value: e) return: nil]. + (ok and: [(x notNil and: [x isBehavior]) not + and: [(isMethodType value: (typeNameOf value: x)) not]]) + ifTrue: [n := n + 1]]]. + classCounts at: v class put: n. + n]]]. + tooLong := Object new. isLeaf := [:v | v isNil or: [v isString or: [v isSymbol or: [v isNumber or: [v isCharacter or: [v == true or: [v == false or: [v == none]]]]]]]]. @@ -209,7 +307,13 @@ sys.modules["__main__"]') @env1:__dict__] * Smalltalk that reads the paused stack and, in the same walk, starts a fresh * registry for the pause and registers each frame's locals, plus the * notebook's globals when `scopeKey` names a scope, or a file run's when it is - * `MAIN_MODULE`. + * `MAIN_MODULE`. A frame's locals are registered with the levels of the + * Smalltalk frames they were read from, innermost first, so Set Value can + * write a local back into the paused frame (`setVariableQuery`), and with the + * names of the temps those frames hold a value in: the locals a write can + * reach. The receiver is not among them. Grail adds it to the locals under + * the name the def gave it (`self`, `cls`, …), but it is the frame's + * receiver, not a temp. * * Answers `pythonStackQuery`'s records, each with one more field — the * registry position of that frame's locals, or 0 — and then a final record @@ -228,11 +332,20 @@ export function pausedStackQuery( setup: `reg := OrderedCollection new. SessionTemps current at: #'${REGISTRY}' put: reg.`, perFrame: `out nextPut: field. - [| locals | + [| locals levels temps | locals := [PyFrame ___pyLocalsFromFrameContentsList___: (p at: 5)] on: Error do: [:e | e return: nil]. + levels := [((p at: 5) collect: [:fc | (both at: 2) indexOfIdentical: fc]) + reject: [:lvl | lvl = 0]] + on: Error do: [:e | e return: #()]. + temps := Set new. + [(p at: 5) do: [:fc | | names | + names := fc at: 9. + names notNil ifTrue: [ + 1 to: names size do: [:i | (fc atOrNil: 10 + i) notNil ifTrue: [temps add: (names at: i) asString]]]]] + on: Error do: [:e | e return: nil]. (locals notNil and: [locals size > 0]) - ifTrue: [reg add: { #gemdbScope. locals }. out print: reg size] + ifTrue: [reg add: { #gemdbScope. locals. levels asArray. temps asArray }. out print: reg size] ifFalse: [out nextPutAll: '0']] value.`, after: `scope := nil. ${ @@ -259,31 +372,178 @@ export function parsePausedStack(raw: string): { frames: PythonFrame[]; globals: return { frames: withoutBreakpointStub(records.map(parseFrame)), globals }; } +/** + * Smalltalk for the rows of a namespace — a scope, or an object's attributes — + * folded the way VS Code's Python debugger folds them, so the values being + * debugged are not lost among definitions: dunders under `special variables`, + * then `class variables`, `function variables` and `module variables`, each a + * collapsed row, and the data after them, sorted by name. + * + * `emitGrouped value: pairs value: specials value: owner value: withGroups` + * writes `count` rows from `start` of `pairs` (name -> value associations). + * `specials` false leaves the dunders out altogether, as a scope does: there + * they are noise (`__builtins__`, `__name__`), where on an object they are + * what the object is. `withGroups` false writes only the data rows, for a + * hover. Each group is registered as `{#gemdbGroup. rows. owner}`, so a + * change to a row inside it reaches the scope or object it came from. + * + * A module is told by its class, not its type name: Grail gives each module a + * class of its own, so `os` reports its type as `os`, and only `isKindOf:` the + * `module` class finds it (as `pythonStackQuery` does). + * + * `roIn` says whether `owner`'s row `nm` cannot be changed. A frame's Locals + * are `{#gemdbScope. dict. levels. temps}`, the levels of the Smalltalk frames + * that hold them and the temps those frames hold values in: a local that is + * not a temp — the receiver, whether the def calls it `self`, `cls` or + * anything else — cannot be reassigned, and a frame whose levels were not + * found cannot be written at all. Globals, and an + * object's attributes, can be changed. + */ +const GROUPED = ` + roIn := [:c :nm | + (c class == Array and: [c size >= 2 and: [(c at: 1) == #gemdbScope]]) + ifTrue: [c size >= 3 and: [(c at: 3) isEmpty or: [c size < 4 or: [((c at: 4) includes: nm) not]]]] + ifFalse: [c isNil]]. + moduleClass := false. + groupOf := [:v | | tname | + (v notNil and: [v isBehavior]) + ifTrue: [2] + ifFalse: [ + tname := typeNameOf value: v. + (isMethodType value: tname) + ifTrue: [3] + ifFalse: [ + "Looked up on first use: a symbol-list search costs every listing that pays it." + moduleClass == false ifTrue: [moduleClass := System myUserProfile symbolList objectNamed: #'module']. + (tname = 'module' or: [moduleClass notNil and: [v isKindOf: moduleClass]]) + ifTrue: [4] + ifFalse: [0]]]]. + emitGrouped := [:pairs :specials :owner :withGroups | | groups data rows | + groups := { 'special variables' -> OrderedCollection new. + 'class variables' -> OrderedCollection new. + 'function variables' -> OrderedCollection new. + 'module variables' -> OrderedCollection new }. + data := OrderedCollection new. + pairs do: [:a | | g | + (isDunder value: a key) + ifTrue: [specials ifTrue: [(groups at: 1) value add: a]] + ifFalse: [ + g := groupOf value: a value. + g = 0 ifTrue: [data add: a] ifFalse: [(groups at: g) value add: a]]]. + rows := OrderedCollection new. + withGroups ifTrue: [groups do: [:g | g value isEmpty ifFalse: [rows add: g]]]. + rows addAll: (data asSortedCollection: [:x :y | x key <= y key]). + last := (start + count) min: rows size. + (start + 1) to: last do: [:i | | row n | + row := rows at: i. + (groups includesIdentical: row) + ifTrue: [ + n := row value size. + reg add: { #gemdbGroup. (row value asSortedCollection: [:x :y | x key <= y key]) asArray. owner }. + out nextPutAll: row key; nextPut: field; nextPut: field; nextPut: field; + print: reg size; nextPut: field; + print: (n > ${PAGE} ifTrue: [n] ifFalse: [0]); nextPut: field; + print: (n > ${PAGE} ifTrue: [0] ifFalse: [n]); nextPut: field; nextPutAll: '0'; nextPut: field; + nextPutAll: '-1'; nextPut: field; nextPutAll: '1'; nextPut: record] + ifFalse: [ + ro := roIn value: owner value: row key. + describe value: row key value: row value]]. + rows size > last ifTrue: [ + out nextPutAll: '...'; nextPut: field; + print: rows size - last; nextPutAll: ' more not shown'; nextPut: field; nextPut: field; + nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; + nextPutAll: '0'; nextPut: field; nextPutAll: '-1'; nextPut: field; nextPutAll: '1'; nextPut: record]].`; + /** * Smalltalk that writes a row for each of `obj`'s children, `count` of them - * from `start`. Expects `describe`, `childrenOf`, `nameOf`, `kids` and `last`. + * from `start`. Expects `describe`, `childrenOf`, `nameOf`, `emitGrouped`, + * `withGroups`, `kids` and `last`. + * + * Which rows Set Value may change follows the container: a list's items, a + * dict's entries and an object's attributes can be; a tuple's, a str's, a + * set's (which has no positions to assign to), a class's `__dict__` (a + * read-only mappingproxy) and a Grail runtime object's instance variables + * cannot. + * + * An opened object lists everything its `dir()` names — its own attributes, + * its class's, its properties and `__slots__`, and the dunders, which + * `emitGrouped` folds — read with getattr, so a property's getter runs, as it + * does in VS Code's and PyCharm's Python debuggers. An attribute that raises — + * an unset slot, a property that fails — is left out. Past the time budget no + * more getters run: the getter the soft break stopped, and every later + * attribute the object does not hold itself, reads ``; + * a `__dir__` the break stopped leaves the attributes the object holds + * itself. A hover + * (`withGroups` false) lists only what the object holds itself, its + * `__dict__`, which is what the row's size counted. */ const LIST_CHILDREN = ` kids := obj isNil ifTrue: [nil] ifFalse: [childrenOf value: obj]. - kids notNil ifTrue: [| kind src i | + kids notNil ifTrue: [| kind src i ctype pairs | kind := kids at: 1. src := kids at: 2. + ctype := typeNameOf value: obj. last := (start + count) min: (kids at: 3). kind == #seq ifTrue: [ + "A tuple, a namedtuple or another tuple subclass, bytes or a range: told + by the type's ancestry, not by its name. A plain list or tuple, the + common case, is answered without walking its ancestry." + ro := ctype = 'list' ifTrue: [false] ifFalse: [ctype = 'tuple' or: [ + ([(obj @env1:___pyAttrLoad___: #'__class__') @env1:___pyAttrLoad___: #'__mro__'] + on: AbstractException do: [:e | e return: #()]) + anySatisfy: [:c | | cn cm | + cn := [(c @env1:___pyAttrLoad___: #'__name__') asString] on: AbstractException do: [:e | e return: '']. + cm := [(c @env1:___pyAttrLoad___: #'__module__') asString] on: AbstractException do: [:e | e return: '']. + (#('tuple' 'bytes' 'range') includes: cn) and: [cm = 'builtins']]]]. (start + 1) to: last do: [:k | describe value: '[' , (k - 1) printString , ']' value: (src at: k)]]. kind == #ivars ifTrue: [ + ro := true. (start + 1) to: last do: [:k | describe value: (src at: k) value: (obj instVarAt: k)]]. kind == #coll ifTrue: [ + ro := true. i := 0. src do: [:x | i := i + 1. (i > start and: [i <= last]) ifTrue: [describe value: '[' , (i - 1) printString , ']' value: x]]]. - (kind == #dict or: [kind == #attrs]) ifTrue: [ + kind == #dict ifTrue: [ + ro := ctype = 'mappingproxy'. i := 0. src keysAndValuesDo: [:key :x | i := i + 1. - (i > start and: [i <= last]) ifTrue: [ - describe value: (kind == #dict ifTrue: [nameOf value: key] ifFalse: [key asString]) value: x]]]]`; + (i > start and: [i <= last]) ifTrue: [describe value: (nameOf value: key) value: x]]]. + kind == #obj ifTrue: [ + (withGroups not and: [src notNil]) + ifTrue: [ + "A hover lists what the object holds itself, which is what its size counts." + ro := false. + i := 0. + src keysAndValuesDo: [:key :x | + i := i + 1. + (i > start and: [i <= last]) ifTrue: [describe value: key asString value: x]]] + ifFalse: [| names own | + "What the object holds itself, readable without running its code." + own := Dictionary new. + src notNil ifTrue: [src keysAndValuesDo: [:key :x | own at: key asString put: x]]. + names := late + ifTrue: [nil] + ifFalse: [[obj @env1:__dir__] on: AbstractException do: [:e | (broke value: e) return: nil]]. + names isNil ifTrue: [names := own keys asSortedCollection asArray]. + pairs := OrderedCollection new. + names do: [:nm | | key x ok | + key := nm asString. + late + ifTrue: [ + (own includesKey: key) + ifTrue: [pairs add: key -> (own at: key)] + ifFalse: [(isDunder value: key) ifFalse: [pairs add: key -> tooLong]]] + ifFalse: [ + ok := true. + x := [obj @env1:___pyAttrLoad___: key asSymbol] + on: AbstractException do: [:e | ok := false. (broke value: e) return: nil]. + ok + ifTrue: [pairs add: key -> x] + ifFalse: [late ifTrue: [pairs add: key -> tooLong]]]]. + emitGrouped value: pairs value: true value: obj value: withGroups]]]`; /** * Smalltalk that lists the children of registry entry `ref`, `count` of them @@ -293,72 +553,40 @@ const LIST_CHILDREN = ` * `'1'` and `1` differ), a list item by `[i]`, an attribute by its name. * * A *scope* — a frame's Locals, the notebook's Globals — is registered as - * `{#gemdbScope. dict}` and reads differently: its data is listed by name, - * sorted, and its classes, functions and modules are folded into collapsed - * `class variables`, `function variables` and `module variables` rows at the - * top, as VS Code's Python debugger does, so the values being debugged are - * not lost among definitions. Only scopes are grouped, and only scopes leave - * out dunder names (`__builtins__`, `__name__` and friends), which are noise - * in a namespace; a key that merely starts with `__` is kept, and an ordinary - * dict or object shows every entry. + * `{#gemdbScope. dict}` (Locals add the frame's levels, see `GROUPED`) and is + * grouped like an object, except that its dunder names (`__builtins__`, + * `__name__` and friends) are left out rather than folded: in a namespace + * they are noise. A key that merely starts with `__` is kept, and an ordinary + * dict shows every entry, dunder keys included. */ export function childrenQuery(ref: number, start: number, count: number): string { - return `| reg obj kids out field record none saved describe childrenOf start count last isDunder nameOf reprOf late clean isLeaf | + return `| reg obj kids out field record none saved describe childrenOf start count last isDunder nameOf reprOf late clean isLeaf ro typeNameOf roIn groupOf emitGrouped withGroups moduleClass broke classCounts classData isMethodType tooLong | ${PREAMBLE} start := ${start}. count := ${count}. +withGroups := true. reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. obj := (reg notNil and: [${ref} between: 1 and: reg size]) ifTrue: [reg at: ${ref}] ifFalse: [nil]. ${HELPERS} ${CHILDREN_OF} ${DESCRIBE} +${GROUPED} ${withQuietConsole(` - isDunder := [:nm | - nm size > 4 and: [(nm copyFrom: 1 to: 2) = '__' and: [(nm copyFrom: nm size - 1 to: nm size) = '__']]]. nameOf := [:key | (reprOf value: key) ifNil: [key printString]]. - (obj class == Array and: [obj size = 2 and: [(obj at: 1) == #gemdbScope]]) - ifTrue: [| data groups rows | - "A scope: data first-class, the rest folded away by kind." - data := OrderedCollection new. - groups := { 'class variables' -> OrderedCollection new. - 'function variables' -> OrderedCollection new. - 'module variables' -> OrderedCollection new }. - (obj at: 2) keysAndValuesDo: [:key :v | | nm tname bucket | - nm := key asString. - (isDunder value: nm) ifFalse: [ - tname := [((v @env1:___pyAttrLoad___: #'__class__') @env1:___pyAttrLoad___: #'__name__') asString] - on: AbstractException do: [:e | e return: '']. - bucket := (v notNil and: [v isBehavior]) - ifTrue: [(groups at: 1) value] - ifFalse: [(#('function' 'builtin_function_or_method' 'method') includes: tname) - ifTrue: [(groups at: 2) value] - ifFalse: [tname = 'module' ifTrue: [(groups at: 3) value] ifFalse: [data]]]. - bucket add: nm -> v]]. - rows := OrderedCollection new. - groups do: [:g | g value isEmpty ifFalse: [rows add: g]]. - rows addAll: (data asSortedCollection: [:a :b | a key <= b key]). - last := (start + count) min: rows size. - (start + 1) to: last do: [:i | | row n | - row := rows at: i. - (groups includesIdentical: row) - ifTrue: [ - n := row value size. - reg add: { #gemdbGroup. (row value asSortedCollection: [:a :b | a key <= b key]) asArray }. - out nextPutAll: row key; nextPut: field; nextPut: field; nextPut: field; - print: reg size; nextPut: field; - print: (n > ${PAGE} ifTrue: [n] ifFalse: [0]); nextPut: field; - print: (n > ${PAGE} ifTrue: [0] ifFalse: [n]); nextPut: field; nextPutAll: '0'; nextPut: record] - ifFalse: [describe value: row key value: row value]]. - rows size > last ifTrue: [ - out nextPutAll: '...'; nextPut: field; - print: rows size - last; nextPutAll: ' more not shown'; nextPut: field; nextPut: field; - nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; nextPutAll: '0'; nextPut: field; - nextPutAll: '0'; nextPut: record]] + (obj class == Array and: [obj size >= 2 and: [(obj at: 1) == #gemdbScope]]) + ifTrue: [| pairs | + pairs := OrderedCollection new. + (obj at: 2) keysAndValuesDo: [:key :v | pairs add: key asString -> v]. + emitGrouped value: pairs value: false value: obj value: true] ifFalse: [ - (obj class == Array and: [obj size = 2 and: [(obj at: 1) == #gemdbGroup]]) - ifTrue: [ + (obj class == Array and: [obj size >= 2 and: [(obj at: 1) == #gemdbGroup]]) + ifTrue: [| owner | + owner := obj size >= 3 ifTrue: [obj at: 3] ifFalse: [nil]. last := (start + count) min: (obj at: 2) size. - (start + 1) to: last do: [:i | | a | a := (obj at: 2) at: i. describe value: a key value: a value]] + (start + 1) to: last do: [:i | | a | + a := (obj at: 2) at: i. + ro := roIn value: owner value: a key. + describe value: a key value: a value]] ifFalse: [ ${LIST_CHILDREN}]]`)} out contents encodeAsUTF8`; @@ -370,7 +598,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, size, readOnly] = record.split(FIELD); const int = (s: string | undefined): number => Number.parseInt(s ?? '', 10) || 0; return { name: name ?? '?', @@ -380,10 +608,142 @@ export function parseChildren(raw: string): PauseVariable[] { indexed: int(indexed), named: int(named), handle: int(handle), + size: size === undefined || size === '' ? -1 : int(size), + readOnly: readOnly === '1', }; }); } +/** + * Smalltalk that gives the row `name` under registry entry `ref` a new value, + * the value of the Python expression `source`, and answers the row as it reads + * now, in `childrenQuery`'s format, or an `Error: …` line. + * + * `source` must be an expression, so it runs inside parentheses, each on a line + * of its own (so a trailing `# comment` cannot swallow the closing one). A + * statement then fails as a SyntaxError. Unwrapped, `n = 5` — a habit from + * typing assignments — would run as a module, which Grail answers with None + * for a statement (`ModuleAst>>evaluateWithScope:as:`), and None would be + * stored without a word. + * + * The expression runs in a scratch scope holding the pause's globals — the + * notebook's, or a `.py` run's `__main__` — registry entry `globalsRef`, so + * `Point(1, 2)` or `g + 1` works on any row. On a Locals row the frame's + * locals go on top, so `n + 1` works there too, and a local shadows a global + * of the same name, as it does in the frame's own code. + * + * Where the value goes depends on the row's container: + * + * - **A frame's Locals**: into the paused Smalltalk frame, the innermost of + * the frame's levels that holds the name with a value (the same rule + * Grail's locals follow when one Python frame spans several Smalltalk + * frames), so the run continues with it. `_frameAt:tempAt:put:` and not + * `_frameAt:tempNamed:put:`: a local a nested block closes over lives in a + * VariableContext, and only the first writes one (measured: the second + * answered rtErrShouldNotImplement). The registry's snapshot of the locals + * is updated too, so the row reads the new value. + * - **Globals**: into the notebook's scope, or the file's `__main__`. + * - **An object, list or dict**: through Python — `setattr`, or + * `container[key] = value` — so a property without a setter, a tuple or a + * class's mappingproxy refuses exactly as it would in a cell. + * + * A row inside a group (`class variables` and the rest) changes in the scope + * or object the group was made from. + */ +export function setVariableQuery( + processOop: bigint, + ref: number, + name: string, + source: string, + globalsRef = 0, +): string { + const nm = `'${escapeString(name)}'`; + return `| reg c g owner isScope v failed problem dispatcher evalScope kind key found py s kids out field record none saved describe childrenOf start count last isDunder nameOf reprOf late clean isLeaf ro typeNameOf roIn groupOf emitGrouped withGroups moduleClass broke classCounts classData isMethodType tooLong | +${PREAMBLE} +start := 0. +count := 0. +reg := SessionTemps current at: #'${REGISTRY}' otherwise: nil. +c := (reg notNil and: [${ref} between: 1 and: reg size]) ifTrue: [reg at: ${ref}] ifFalse: [nil]. +c isNil ifTrue: [^ 'Error: that value is no longer available' encodeAsUTF8]. +${HELPERS} +${CHILDREN_OF} +${DESCRIBE} +${GROUPED} +problem := nil. +${withQuietConsole(` + nameOf := [:k | (reprOf value: k) ifNil: [k printString]]. + owner := c. + (c class == Array and: [c size >= 2 and: [(c at: 1) == #gemdbGroup]]) + ifTrue: [owner := c size >= 3 ifTrue: [c at: 3] ifFalse: [nil]]. + isScope := owner class == Array and: [owner size >= 2 and: [(owner at: 1) == #gemdbScope]]. + (roIn value: owner value: ${nm}) + ifTrue: [problem := ${nm} , ' cannot be changed here']. + problem isNil ifTrue: [ + dispatcher := System myUserProfile symbolList objectNamed: #'ModuleAst'. + evalScope := SymbolDictionary new. + g := (${globalsRef} between: 1 and: reg size) ifTrue: [reg at: ${globalsRef}] ifFalse: [nil]. + (g class == Array and: [g size >= 2 and: [(g at: 1) == #gemdbScope]]) + ifTrue: [(g at: 2) keysAndValuesDo: [:k :x | evalScope at: k asString asSymbol put: x]]. + "A row's own scope goes on top: a frame's local shadows a global of its name." + isScope ifTrue: [(owner at: 2) keysAndValuesDo: [:k :x | evalScope at: k asString asSymbol put: x]]. + failed := false. + v := [dispatcher evaluateSource: '${escapeString(`(\n${source}\n)`)}' usingModuleScope: evalScope] + on: AbstractException do: [:e | + failed := true. + e return: e class name , ' - ' , e messageText asString]. + failed ifTrue: [problem := v]]. + (problem isNil and: [isScope]) ifTrue: [| dict | + dict := owner at: 2. + found := false. + dict keysAndValuesDo: [:k :x | (found not and: [k asString = ${nm}]) ifTrue: [key := k. found := true]]. + found ifFalse: [problem := 'there is no variable called ' , ${nm}]. + (problem isNil and: [owner size >= 3]) ifTrue: [| proc done | + "A frame's local: into the paused frame first." + proc := Object _objectForOop: ${processOop}. + done := false. + (owner at: 3) do: [:lvl | | fc names | + done ifFalse: [ + fc := proc _frameContentsAt: lvl. + names := fc isNil ifTrue: [nil] ifFalse: [fc at: 9]. + names notNil ifTrue: [ + 1 to: names size do: [:i | + (done not and: [(names at: i) asString = ${nm} and: [(fc atOrNil: 10 + i) notNil]]) + ifTrue: [proc _frameAt: lvl tempAt: i put: v. done := true]]]]]. + done ifFalse: [problem := 'could not find ' , ${nm} , ' in the paused frame']]. + problem isNil ifTrue: [dict at: key put: v]]. + (problem isNil and: [isScope not]) ifTrue: [ + kids := owner isNil ifTrue: [nil] ifFalse: [childrenOf value: owner]. + kind := kids isNil ifTrue: [nil] ifFalse: [kids at: 1]. + py := nil. + found := false. + kind == #obj ifTrue: [py := 'setattr(_gemdb_c, _gemdb_k, _gemdb_v)'. key := ${nm}. found := true]. + kind == #seq ifTrue: [ + py := '_gemdb_c[_gemdb_k] = _gemdb_v'. + key := ((${nm} copyFrom: 2 to: (${nm} size - 1 max: 1)) select: [:ch | ch isDigit]) asNumber. + found := key notNil]. + kind == #dict ifTrue: [ + py := '_gemdb_c[_gemdb_k] = _gemdb_v'. + owner keysAndValuesDo: [:k :x | (found not and: [(nameOf value: k) = ${nm}]) ifTrue: [key := k. found := true]]]. + (py isNil or: [found not]) + ifTrue: [problem := ${nm} , ' cannot be changed here'] + ifFalse: [ + s := SymbolDictionary new. + s at: #'_gemdb_c' put: owner; at: #'_gemdb_k' put: key; at: #'_gemdb_v' put: v. + failed := false. + problem := [dispatcher evaluateSource: py usingModuleScope: s. nil] + on: AbstractException do: [:e | + failed := true. + e return: e class name , ' - ' , e messageText asString]. + (failed not and: [kind == #obj]) ifTrue: [ + "Read it back: a property's setter may store something else." + v := [owner @env1:___pyAttrLoad___: ${nm} asSymbol] on: AbstractException do: [:e | e return: v]]]]. + problem isNil ifTrue: [ + ro := roIn value: owner value: ${nm}. + describe value: ${nm} value: v]`)} +problem notNil ifTrue: [^ ('Error: ' , problem asString) encodeAsUTF8]. +out contents encodeAsUTF8`; +} + /** Smalltalk that drops the pause's registry, so nothing is kept alive past the pause. */ export function clearRegistryQuery(): string { return `SessionTemps current removeKey: #'${REGISTRY}' ifAbsent: []. 'cleared' encodeAsUTF8`; @@ -553,7 +913,7 @@ export const ROOT_LISTING_LIMIT = 200; * come from its own `items()`. */ export function rootListingQuery(): string { - return `| reg obj out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher root | + return `| reg obj out field record none saved describe childrenOf reprOf late clean isLeaf dispatcher root ro isDunder typeNameOf broke classCounts classData isMethodType tooLong | ${PREAMBLE} reg := OrderedCollection new. ${HELPERS} @@ -577,20 +937,24 @@ export const INSPECT_ROWS = 12; /** * Smalltalk that describes `gemdb.root[key]` for a hover: a first row for the * object itself (its counts say how many children it has), then its first - * `INSPECT_ROWS` children. `fresh` takes a new view first — right for the + * `INSPECT_ROWS` children. An object's children are its own attributes, its + * `__dict__`, as before objects were read whole: the methods, class + * attributes and dunders the Variables view adds are left out of a hover. `fresh` takes a new view first — right for the * extension's own session, which reads what is committed, and wrong for a * notebook's, whose uncommitted work is the very thing being looked at. * Answers an `Error: …` line when the key is not there. */ export function inspectQuery(key: string, fresh: boolean): string { - return `| reg obj kids out field record none saved describe childrenOf start count last nameOf reprOf late clean isLeaf dispatcher | + return `| reg obj kids out field record none saved describe childrenOf start count last nameOf reprOf late clean isLeaf dispatcher ro isDunder typeNameOf roIn groupOf emitGrouped withGroups moduleClass broke classCounts classData isMethodType tooLong | ${PREAMBLE} reg := OrderedCollection new. start := 0. count := ${INSPECT_ROWS}. +withGroups := false. ${HELPERS} ${CHILDREN_OF} ${DESCRIBE} +${GROUPED} ${fresh ? 'System abortTransaction.' : ''} dispatcher := System myUserProfile symbolList objectNamed: #'ModuleAst'. obj := [dispatcher evaluateSource: '${escapeString(`import gemdb\ngemdb.root[${pythonString(key)}]`)}' diff --git a/src/savedObjects.ts b/src/savedObjects.ts index 5b27923..ec4ac78 100644 --- a/src/savedObjects.ts +++ b/src/savedObjects.ts @@ -1055,8 +1055,9 @@ async function openSavedStack(key: string): Promise { scopes: (frameId) => scopes.get(frameId) ?? [], variables: async (ref, start, count) => { try { - return parseChildren(await extensionQuery(childrenQuery(ref, start, count))).map( - toDapVariable, + // Read-only: a saved stack's objects are committed copies, with no run to change. + return parseChildren(await extensionQuery(childrenQuery(ref, start, count))).map((row) => + toDapVariable({ ...row, readOnly: true }), ); } catch (e) { log(`Could not read a saved stack's variable ${ref}: ${errorMessage(e)}`);