From c413425a6fcc63a92feb5f00925b50e0b141289b Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Fri, 21 Aug 2026 18:13:53 -0700 Subject: [PATCH 01/19] Breakpoints: aim at a step point, enable/disable, and a manager view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting a breakpoint meant a gutter click, which lands on the leftmost step point of the line. A Smalltalk line routinely holds several, so the one you wanted was often not the one you got — and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Make VS Code's own breakpoint list the durable model. GemStone method breakpoints are per-gem VM state: they do not survive logout and a commit does not persist them (verified against 3.6.2 and 3.7.5), so the stone can only ever say what one session currently has, never what the developer wants. With VS Code's list as the record, breakpoints survive a restart and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls all drive GemStone through onDidChangeBreakpoints. Jasper re-applies them on login and after a recompile. Step point precision rides on the breakpoint's column: a gutter click carries none and means the leftmost step point on the line, while an inline breakpoint or Toggle Breakpoint at Cursor (Shift+F9) carries the exact column and picks the step point nearest it. The token that will actually break is outlined, dashed when disabled. A disabled breakpoint is applied as set-then-disable, because disableBreakAtStepPoint: is a no-op on a step point with no breakpoint. Number step points as inlay hints — VS Code's own dim style, suppressed by its editor.inlayHints.* settings like any other — each clickable to toggle a breakpoint there. gemstone.stepPoints.display defaults to `debugging`, so the numbers appear while a debug session runs and stay out of the way while you are reading or writing code. Hovering a step point always reports its number and breakpoint state whatever the setting. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen. Add a Breakpoints view listing what the gem actually holds, grouped by class and method with the step point each breakpoint resolved to and a checkbox per row. It is the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set — from topaz, or a halt left in the code — which were invisible until execution stopped on one. Unify the two managers that fought each other: the cursor-toggle command kept its breakpoints in a private map, so they never appeared in the Breakpoints view and were silently wiped whenever a gutter breakpoint on the same method was applied. It now adds a normal VS Code breakpoint, so the two kinds are one kind. Two bugs found on the way: - Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. - _allMethodBreakpoints' tuple stride is version-dependent — 3 fields on 3.6.2, 4 on 3.7.5, which gained breakpointLevel — so decoding it by hand walks off the end of the array on the older release. getAllBreakpoints reads the kernel's own _breakReport: instead, which knows its own stride. It also skips breakpoints stranded on a superseded method version, which a recompile leaves behind and the gem goes on reporting. The GemStone semantics all of this rests on are pinned by an integration test against a live stone, since none of them is documented anywhere we control. Renames gemstone.toggleSelectorBreakpoint to gemstone.breakpoints.toggleAtCursor with no alias, so a custom keybinding naming the old id needs the new one. Closes #262 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 + README.md | 42 +- client/src/__mocks__/vscode.ts | 61 +- .../src/__tests__/breakpointManager.test.ts | 323 ++++++-- .../__tests__/breakpointTreeProvider.test.ts | 259 +++++++ .../__tests__/breakpoints.integration.test.ts | 233 ++++++ .../src/__tests__/editorContextMenu.test.ts | 20 +- .../selectorBreakpointManager.test.ts | 536 ------------- client/src/__tests__/stepPointHints.test.ts | 165 ++++ client/src/__tests__/stepPointHover.test.ts | 166 +++++ client/src/__tests__/stepPointModel.test.ts | 253 +++++++ .../src/__tests__/stepPointSelectors.test.ts | 217 ++++++ client/src/breakpointManager.ts | 703 +++++++++++++++--- client/src/breakpointTreeProvider.ts | 307 ++++++++ client/src/browserQueries.ts | 59 ++ client/src/extension.ts | 126 +++- client/src/gemstoneDebugSession.ts | 5 + .../__tests__/breakpointQueries.test.ts | 190 +++++ client/src/queries/breakpointGlobals.ts | 45 ++ client/src/queries/disableBreakAtStepPoint.ts | 26 + client/src/queries/getAllBreakpoints.ts | 121 +++ client/src/selectorBreakpointManager.ts | 341 --------- client/src/stepPointHints.ts | 126 ++++ client/src/stepPointHover.ts | 85 +++ client/src/stepPointModel.ts | 279 +++++++ client/src/stepPointSelectors.ts | 134 ++++ package.json | 233 +++++- 27 files changed, 4007 insertions(+), 1062 deletions(-) create mode 100644 client/src/__tests__/breakpointTreeProvider.test.ts create mode 100644 client/src/__tests__/breakpoints.integration.test.ts delete mode 100644 client/src/__tests__/selectorBreakpointManager.test.ts create mode 100644 client/src/__tests__/stepPointHints.test.ts create mode 100644 client/src/__tests__/stepPointHover.test.ts create mode 100644 client/src/__tests__/stepPointModel.test.ts create mode 100644 client/src/__tests__/stepPointSelectors.test.ts create mode 100644 client/src/breakpointTreeProvider.ts create mode 100644 client/src/queries/__tests__/breakpointQueries.test.ts create mode 100644 client/src/queries/breakpointGlobals.ts create mode 100644 client/src/queries/disableBreakAtStepPoint.ts create mode 100644 client/src/queries/getAllBreakpoints.ts delete mode 100644 client/src/selectorBreakpointManager.ts create mode 100644 client/src/stepPointHints.ts create mode 100644 client/src/stepPointHover.ts create mode 100644 client/src/stepPointModel.ts create mode 100644 client/src/stepPointSelectors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ad2a83c0..0352eff5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,20 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ## [Unreleased] +### Added + +- **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **A Breakpoints view in the GemStone sidebar.** Lists what the current session's gem actually holds — grouped by class and method, each row naming the step point the breakpoint resolved to, with an enable checkbox and a click that opens the method with the caret on that step point. It is deliberately the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set (from topaz, another tool, or a `halt` left in the code), which were previously invisible right up until execution stopped on one. ([#262](https://github.com/GemTalk/Jasper/issues/262)) + +### Changed + +- **`gemstone.toggleSelectorBreakpoint` is now `gemstone.breakpoints.toggleAtCursor`.** No alias is kept, so a **custom keybinding, task, or macro naming the old id will stop working** and needs the new one. The command also behaves differently in a way worth knowing: it used to track its breakpoints privately, which meant they never appeared in the Breakpoints view and were silently wiped whenever a gutter breakpoint on the same method was applied. It now adds a normal VS Code breakpoint at the step point, so the two kinds are the same kind and no longer erase each other. + +### Fixed + +- **A gutter breakpoint could be set one step point later than the line asked for.** Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. + ## [1.8.13] - 2026-08-20 A follow-up release for **GemStone Search**: correctness fixes for multi-session and multi-environment use, matching and debounce repairs found by a review pass over the feature, one naming pass, and the senders/implementors counts moving off the method source. diff --git a/README.md b/README.md index e45b9cc9..d281ee4c 100644 --- a/README.md +++ b/README.md @@ -233,8 +233,46 @@ When code execution hits an error, a **Debug** button opens the VS Code debugger ### Breakpoints -- **Line breakpoints** — click the editor gutter in a `gemstone://` method to set/clear breakpoints mapped to GemStone step points -- **Selector breakpoints** — right-click a selector and choose **Toggle Selector Breakpoint** to break whenever that selector is sent; breakpointed selectors are highlighted with a red border +Breakpoints live in VS Code's own breakpoint list, so they survive a restart and +the familiar gutter, checkbox and Enable/Disable/Remove All controls all drive +GemStone. Each one is applied to the session as a step-point break — GemStone +breakpoints are per-session gem state, so Jasper re-applies them on login and +after a recompile. + +- **Line breakpoints** — click the editor gutter in a `gemstone://` method. A + gutter click means "this line", and lands on the leftmost step point on it +- **Step-point breakpoints** — a Smalltalk line usually holds several step + points. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point + under the caret, not the first one on the line, and the token that will + actually break is outlined +- **Enable / disable** — per breakpoint from the Breakpoints view checkbox or + **Enable/Disable Breakpoint at Cursor**; a disabled breakpoint is kept in the + gem (drawn dashed) so re-arming it is instant +- **All at once** — **Enable All**, **Disable All** and **Remove All + Breakpoints** act on every GemStone breakpoint, including any set outside + Jasper by topaz or a `halt` left in the code +- **Clear All Breakpoints in Method** drops every breakpoint in the method you + are in + +#### Step points + +- **Numbered inline** — step point numbers are drawn as inlay hints, and each is + clickable to toggle a breakpoint there. `gemstone.stepPoints.display` controls + when: `debugging` (the default — visible while a debug session runs, out of the + way otherwise), `always`, or `off`. **Toggle Step Point Numbers** flips them +- **On hover** — hovering a step point always reports its number and breakpoint + state, with links to set, clear, enable or disable it, whatever the numbering + is set to +- Numbers are suppressed while a buffer has unsaved edits, since the stone's + offsets no longer line up with what you are looking at + +#### Breakpoint manager + +The **Breakpoints** view in the GemStone sidebar lists what the current session's +gem actually holds, grouped by class and method with the step point each +breakpoint resolved to. It shows breakpoints Jasper never set, which are +otherwise invisible until execution stops on one. Rows carry an enable checkbox; +clicking one opens the method with the caret on the step point. ### SUnit Test Runner diff --git a/client/src/__mocks__/vscode.ts b/client/src/__mocks__/vscode.ts index 1e628b4a..0899fca1 100644 --- a/client/src/__mocks__/vscode.ts +++ b/client/src/__mocks__/vscode.ts @@ -62,6 +62,7 @@ export class TreeItem { contextValue?: string; command?: unknown; collapsibleState?: number; + checkboxState?: number; constructor(label: string, collapsibleState?: number) { this.label = label; @@ -75,6 +76,11 @@ export const TreeItemCollapsibleState = { Expanded: 2, }; +export const TreeItemCheckboxState = { + Unchecked: 0, + Checked: 1, +}; + // ── ThemeIcon mock ───────────────────────────────────────── export class ThemeIcon { @@ -270,6 +276,8 @@ export const window = { showWarningMessage: vi.fn(), createTreeView: vi.fn(() => ({ onDidChangeVisibility: new EventEmitter<{ visible: boolean }>().event, + onDidChangeCheckboxState: new EventEmitter<{ items: [unknown, number][] }>().event, + reveal: vi.fn(), dispose: () => {}, })), registerFileDecorationProvider: vi.fn(() => ({ dispose: () => {} })), @@ -620,10 +628,18 @@ export class Selection extends Range { } export class Location { + public readonly range: Range; + // The real API normalizes a Position into an empty Range, and callers rely on + // `location.range.start` always being there — so the mock must too. constructor( public readonly uri: Uri, - public readonly range: Position | Range, - ) {} + rangeOrPosition: Position | Range, + ) { + this.range = + rangeOrPosition instanceof Range + ? rangeOrPosition + : new Range(rangeOrPosition, rangeOrPosition); + } } export class CodeActionKind { @@ -704,6 +720,7 @@ export const languages = { registerHoverProvider: vi.fn(() => ({ dispose: () => {} })), registerCompletionItemProvider: vi.fn(() => ({ dispose: () => {} })), registerCodeLensProvider: vi.fn(() => ({ dispose: () => {} })), + registerInlayHintsProvider: vi.fn(() => ({ dispose: () => {} })), setTextDocumentLanguage: vi.fn(), createDiagnosticCollection: vi.fn((_name?: string) => createMockDiagnosticCollection()), getDiagnostics: vi.fn((_uri?: unknown) => [] as Diagnostic[]), @@ -792,7 +809,18 @@ export const CompletionItemKind = { export const debug = { breakpoints: [] as unknown[], + activeDebugSession: undefined as unknown, onDidChangeBreakpoints: vi.fn(() => ({ dispose: () => {} })), + onDidStartDebugSession: vi.fn(() => ({ dispose: () => {} })), + onDidTerminateDebugSession: vi.fn(() => ({ dispose: () => {} })), + // Mirror the real API's side effect on `debug.breakpoints`, so a test can + // drive the manager the way VS Code does and then read the list back. + addBreakpoints: vi.fn((bps: unknown[]) => { + debug.breakpoints = [...debug.breakpoints, ...bps]; + }), + removeBreakpoints: vi.fn((bps: unknown[]) => { + debug.breakpoints = debug.breakpoints.filter((bp) => !bps.includes(bp)); + }), startDebugging: vi.fn(), registerDebugAdapterDescriptorFactory: vi.fn(() => ({ dispose: () => {} })), registerDebugConfigurationProvider: vi.fn(() => ({ dispose: () => {} })), @@ -810,12 +838,41 @@ export class SourceBreakpoint extends Breakpoint { constructor( public location: Location, enabled = true, + condition?: string, + hitCondition?: string, + logMessage?: string, ) { super(); this.enabled = enabled; + this.condition = condition; + this.hitCondition = hitCondition; + this.logMessage = logMessage; } } +// ── Inlay hint mock ────────────────────────────────────── + +export const InlayHintKind = { + Type: 1, + Parameter: 2, +}; + +export class InlayHintLabelPart { + tooltip?: unknown; + command?: unknown; + constructor(public value: string) {} +} + +export class InlayHint { + paddingLeft?: boolean; + paddingRight?: boolean; + constructor( + public position: Position, + public label: string | InlayHintLabelPart[], + public kind?: number, + ) {} +} + // ── Test API mock ──────────────────────────────────────── export class TestMessage { diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 83842d33..5b999a98 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -5,12 +5,14 @@ vi.mock('vscode', () => import('../__mocks__/vscode.js')); vi.mock('../browserQueries', () => ({ getMethodSource: vi.fn(() => ''), getSourceOffsets: vi.fn(() => []), + getStepPointSelectorRanges: vi.fn(() => []), setBreakAtStepPoint: vi.fn(), clearBreakAtStepPoint: vi.fn(), + disableBreakAtStepPoint: vi.fn(), clearAllBreaks: vi.fn(), })); -import { Uri } from '../__mocks__/vscode'; +import { Uri, debug, Location, Position, SourceBreakpoint } from '../__mocks__/vscode'; import { BreakpointManager, buildLineOffsets, @@ -18,10 +20,12 @@ import { mapOffsetToStepPoint, } from '../breakpointManager'; import { SessionManager } from '../sessionManager'; +import { StepPointModel } from '../stepPointModel'; import { getMethodSource, getSourceOffsets, setBreakAtStepPoint, + disableBreakAtStepPoint, clearAllBreaks, } from '../browserQueries'; @@ -29,6 +33,19 @@ const mockGetMethodSource = vi.mocked(getMethodSource); const mockGetSourceOffsets = vi.mocked(getSourceOffsets); const mockSetBreakAtStepPoint = vi.mocked(setBreakAtStepPoint); const mockClearAllBreaks = vi.mocked(clearAllBreaks); +const mockDisableBreakAtStepPoint = vi.mocked(disableBreakAtStepPoint); + +const METHOD_URI = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; + +/** A manager wired to a real StepPointModel over the mocked queries. */ +function makeManager(hasSession = true) { + const sessionManager = makeSessionManager(hasSession); + return new BreakpointManager(sessionManager, new StepPointModel(sessionManager)); +} + +function session() { + return makeSessionManager(true).getSelectedSession()!; +} function makeSessionManager(hasSession: boolean) { return { @@ -167,120 +184,284 @@ describe('BreakpointManager', () => { mockGetMethodSource.mockReset(); mockGetSourceOffsets.mockReset(); mockSetBreakAtStepPoint.mockReset(); + mockDisableBreakAtStepPoint.mockReset(); mockClearAllBreaks.mockReset(); + debug.breakpoints = []; + vi.mocked(debug.addBreakpoints).mockClear(); + vi.mocked(debug.removeBreakpoints).mockClear(); }); - describe('setBreakpointsForSource', () => { - it('returns unverified for non-gemstone URI', () => { - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('file:///test.tpz'); - const results = manager.setBreakpointsForSource(session, uri, [1]); + describe('applyToUri', () => { + it('returns unverified for a non-gemstone URI', () => { + const results = makeManager().applyToUri(session(), Uri.parse('file:///test.tpz'), [ + { line: 1, enabled: true }, + ]); expect(results).toHaveLength(1); expect(results[0].verified).toBe(false); }); - it('sets breakpoints and returns verified locations', () => { - mockGetMethodSource.mockReturnValue('at: index\n ^ self basicAt: index'); - mockGetSourceOffsets.mockReturnValue([0, 12]); + it('sets a breakpoint per requested line and reports where each landed', () => { + // GemStone _sourceOffsets are 1-based: step point 1 at source[0], 2 at source[11]. + mockGetMethodSource.mockReturnValue('at: index\n^self basicAt: index'); + mockGetSourceOffsets.mockReturnValue([1, 11]); - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A'); - const results = manager.setBreakpointsForSource(session, uri, [1, 2]); + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: true }, + { line: 2, enabled: true }, + ]); - expect(results).toHaveLength(2); - expect(results[0]).toEqual({ stepPoint: 1, actualLine: 1, verified: true }); - expect(results[1]).toEqual({ stepPoint: 2, actualLine: 2, verified: true }); + expect(results).toEqual([ + { stepPoint: 1, actualLine: 1, verified: true }, + { stepPoint: 2, actualLine: 2, verified: true }, + ]); expect(mockClearAllBreaks).toHaveBeenCalledTimes(1); expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(2); + expect(mockDisableBreakAtStepPoint).not.toHaveBeenCalled(); }); - it('clears all breakpoints when lines is empty', () => { - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A'); - const results = manager.setBreakpointsForSource(session, uri, []); - + it('clears the method and sets nothing when no breakpoints are wanted', () => { + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), []); expect(results).toHaveLength(0); expect(mockClearAllBreaks).toHaveBeenCalledTimes(1); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('applies a disabled breakpoint as set-then-disable', () => { + // disableBreakAtStepPoint: is a no-op on a step point with no breakpoint, + // so a disabled breakpoint has to be set first or it would not exist at all. + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 2, enabled: false }]); + + expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(1); + expect(mockDisableBreakAtStepPoint).toHaveBeenCalledTimes(1); + const setArgs = mockSetBreakAtStepPoint.mock.calls[0]; + const disableArgs = mockDisableBreakAtStepPoint.mock.calls[0]; + expect(setArgs[4]).toBe(2); // same step point + expect(disableArgs[4]).toBe(2); + }); + + it('resolves a column to the nearest step point on the line, not the leftmost', () => { + // 0 5 10 15 20 + // x := self foo bar + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + // step points (1-based): 8 -> 'self' area start, 13 -> 'foo' + mockGetSourceOffsets.mockReturnValue([8, 13]); + + // Line 2 starts at offset 2. Column 10 => offset 12, nearest step point is #2. + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 2, character: 10, enabled: true }, + ]); + expect(results[0].stepPoint).toBe(2); + }); + + it('a gutter click (no column) takes the leftmost step point on the line', () => { + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + mockGetSourceOffsets.mockReturnValue([8, 13]); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 2, enabled: true }, + ]); + expect(results[0].stepPoint).toBe(1); + }); + + it('collapses two requests that land on the same step point, keeping it armed', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([5]); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 2, enabled: false }, + { line: 2, character: 1, enabled: true }, + ]); + + // Both requests report the same step point... + expect(results.map((r) => r.stepPoint)).toEqual([1, 1]); + // ...but the gem gets one breakpoint, left enabled because one request wanted it. + expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(1); + expect(mockDisableBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('falls forward to the next step point when the line has none', () => { + mockGetMethodSource.mockReturnValue('foo\n"just a comment"\n^1'); + // Only one step point: the '^' at 0-based offset 21, so 22 1-based. + mockGetSourceOffsets.mockReturnValue([22]); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 2, enabled: true }, + ]); + expect(results[0]).toEqual({ stepPoint: 1, actualLine: 3, verified: true }); + }); + + it('returns unverified when the method has no step point at or after the line', () => { + mockGetMethodSource.mockReturnValue('foo\n^1\n'); + mockGetSourceOffsets.mockReturnValue([1]); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 3, enabled: true }, + ]); + expect(results[0].verified).toBe(false); }); - it('returns unverified when getMethodSource throws', () => { + it('returns unverified when the source cannot be fetched', () => { mockGetMethodSource.mockImplementation(() => { - throw new Error('fail'); + throw new Error('method gone'); }); - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/at%3A'); - const results = manager.setBreakpointsForSource(session, uri, [1]); - - expect(results).toHaveLength(1); + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: true }, + ]); expect(results[0].verified).toBe(false); }); - it('returns unverified when setBreakAtStepPoint throws', () => { - mockGetMethodSource.mockReturnValue('foo\n ^ 1'); - mockGetSourceOffsets.mockReturnValue([0, 6]); + it('returns unverified when setting the breakpoint throws', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); mockSetBreakAtStepPoint.mockImplementation(() => { throw new Error('fail'); }); - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/foo'); - const results = manager.setBreakpointsForSource(session, uri, [1]); - - expect(results).toHaveLength(1); + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: true }, + ]); expect(results[0].verified).toBe(false); }); - it('parses class-side URIs correctly', () => { - mockGetMethodSource.mockReturnValue('new\n ^ super new'); - mockGetSourceOffsets.mockReturnValue([0, 6]); + it('reads the class side and environment id out of the URI', () => { + mockGetMethodSource.mockReturnValue('new\n^super new'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + makeManager().applyToUri( + session(), + Uri.parse('gemstone://1/Globals/Array/class/creation/new?env=2'), + [{ line: 1, enabled: true }], + ); + + expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', true, 'new', 2); + }); + }); + + describe('setBreakpointsForSource', () => { + it("converts the debug adapter's 1-based columns to 0-based characters", () => { + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + mockGetSourceOffsets.mockReturnValue([8, 13]); + + // DAP column 11 == character 10 == offset 12 on line 2 => step point 2. + const results = makeManager().setBreakpointsForSource( + session(), + Uri.parse(METHOD_URI), + [2], + [11], + ); + expect(results[0].stepPoint).toBe(2); + }); + + it('treats a missing column as a whole-line request', () => { + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + mockGetSourceOffsets.mockReturnValue([8, 13]); + + const results = makeManager().setBreakpointsForSource( + session(), + Uri.parse(METHOD_URI), + [2], + [undefined], + ); + expect(results[0].stepPoint).toBe(1); + }); + }); + + describe('appliedFor', () => { + it('reports the step points now set on a method, with their enabled state', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + const manager = makeManager(); + manager.applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: true }, + { line: 2, enabled: false }, + ]); + + const applied = manager.appliedFor(Uri.parse(METHOD_URI)); + expect(applied.map((a) => [a.stepPoint, a.enabled])).toEqual([ + [1, true], + [2, false], + ]); + }); - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/class/creation/new'); - manager.setBreakpointsForSource(session, uri, [1]); + it('is empty again once the breakpoints are gone', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); - expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', true, 'new', 0); + const manager = makeManager(); + const uri = Uri.parse(METHOD_URI); + manager.applyToUri(session(), uri, [{ line: 1, enabled: true }]); + expect(manager.appliedFor(uri)).toHaveLength(1); + + manager.applyToUri(session(), uri, []); + expect(manager.appliedFor(uri)).toHaveLength(0); }); + }); + + describe('reapplyAll', () => { + it('re-applies every gemstone breakpoint, which is what a new login needs', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); - it('parses environment ID from query string', () => { - mockGetMethodSource.mockReturnValue('foo\n ^ 1'); - mockGetSourceOffsets.mockReturnValue([0, 6]); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))), + // A non-gemstone breakpoint must be left entirely alone. + new SourceBreakpoint(new Location(Uri.parse('file:///a.ts'), new Position(3, 0))), + ]; - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/foo?env=2'); - manager.setBreakpointsForSource(session, uri, [1]); + makeManager().reapplyAll(session()); - expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'foo', 2); + expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(1); + expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'at:', 0); }); }); describe('clearAllForSession', () => { - it('removes tracked breakpoints for the given session', () => { - mockGetMethodSource.mockReturnValue('foo\n ^ 1'); - mockGetSourceOffsets.mockReturnValue([0, 6]); + it('forgets a logged-out session, so nothing is re-pushed for it', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); - const manager = new BreakpointManager(makeSessionManager(true)); - const session = makeSessionManager(true).getSelectedSession()!; - const uri = Uri.parse('gemstone://1/Globals/Array/instance/accessing/foo'); - manager.setBreakpointsForSource(session, uri, [1]); + const manager = makeManager(); + const uri = Uri.parse(METHOD_URI); + manager.applyToUri(session(), uri, [{ line: 1, enabled: true }]); + expect(manager.appliedFor(uri)).toHaveLength(1); - // Verify tracked (indirectly: clearing and re-setting should not fail) manager.clearAllForSession(1); + expect(manager.appliedFor(uri)).toHaveLength(0); + }); + + it("leaves another session's breakpoints alone", () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + const manager = makeManager(); + const uri = Uri.parse(METHOD_URI); + manager.applyToUri(session(), uri, [{ line: 1, enabled: true }]); + + manager.clearAllForSession(2); + expect(manager.appliedFor(uri)).toHaveLength(1); + }); + }); - // After clearing, the internal map should be empty for this session - // We can verify by calling invalidateForUri which checks the map - manager.invalidateForUri(uri); - // getMethodSource should NOT be called again since tracking was cleared - mockGetMethodSource.mockReset(); - manager.invalidateForUri(uri); - expect(mockGetMethodSource).not.toHaveBeenCalled(); + describe('removeAll', () => { + it('drops gemstone breakpoints from VS Code and sweeps the gem', () => { + const gemstoneBp = new SourceBreakpoint( + new Location(Uri.parse(METHOD_URI), new Position(0, 0)), + ); + const fileBp = new SourceBreakpoint( + new Location(Uri.parse('file:///a.ts'), new Position(1, 0)), + ); + debug.breakpoints = [gemstoneBp, fileBp]; + + makeManager().removeAll(); + + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([gemstoneBp]); + // The file breakpoint survives — "all GemStone breakpoints" is not "all breakpoints". + expect(debug.breakpoints).toEqual([fileBp]); }); }); }); diff --git a/client/src/__tests__/breakpointTreeProvider.test.ts b/client/src/__tests__/breakpointTreeProvider.test.ts new file mode 100644 index 00000000..3e8b879b --- /dev/null +++ b/client/src/__tests__/breakpointTreeProvider.test.ts @@ -0,0 +1,259 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +vi.mock('../browserQueries', () => ({ + getAllBreakpoints: vi.fn(() => []), + getSourceOffsets: vi.fn(() => []), +})); + +import { TreeItemCheckboxState } from '../__mocks__/vscode'; +import { + BreakpointTreeProvider, + BreakpointNode, + classLabel, + groupBreakpoints, +} from '../breakpointTreeProvider'; +import { SessionManager } from '../sessionManager'; +import { BreakpointManager } from '../breakpointManager'; +import { getAllBreakpoints, GemStoneBreakpoint } from '../browserQueries'; + +const mockGetAll = vi.mocked(getAllBreakpoints); + +function bp(over: Partial = {}): GemStoneBreakpoint { + return { + breakNumber: 1, + className: 'Account', + isMeta: false, + selector: 'balance', + stepPoint: 3, + disabled: false, + environmentId: 0, + methodOop: '12345', + dictName: 'Globals', + category: 'accessing', + ...over, + }; +} + +describe('classLabel', () => { + it('names an instance-side class plainly', () => { + expect(classLabel('Account', false)).toBe('Account'); + }); + + it('names the metaclass the way Smalltalk writes it', () => { + expect(classLabel('Account', true)).toBe('Account class'); + }); + + it('labels a classless breakpoint as executed code', () => { + expect(classLabel('', false)).toBe('(executed code)'); + }); +}); + +describe('groupBreakpoints', () => { + it('groups by class and keeps the two sides of a class apart', () => { + const nodes = groupBreakpoints([ + bp({ className: 'Account', isMeta: false, selector: 'balance' }), + bp({ className: 'Account', isMeta: true, selector: 'new' }), + ]); + expect(nodes).toHaveLength(2); + expect(nodes.map((n) => (n.kind === 'class' ? classLabel(n.className, n.isMeta) : ''))).toEqual( + ['Account', 'Account class'], + ); + }); + + it('sorts classes alphabetically', () => { + const nodes = groupBreakpoints([ + bp({ className: 'Zebra' }), + bp({ className: 'Apple' }), + bp({ className: 'Mango' }), + ]); + expect(nodes.map((n) => (n.kind === 'class' ? n.className : ''))).toEqual([ + 'Apple', + 'Mango', + 'Zebra', + ]); + }); + + it('sorts within a class by selector, then step point', () => { + const nodes = groupBreakpoints([ + bp({ selector: 'zed', stepPoint: 1 }), + bp({ selector: 'abc', stepPoint: 5 }), + bp({ selector: 'abc', stepPoint: 2 }), + ]); + expect(nodes).toHaveLength(1); + const only = nodes[0]; + expect(only.kind).toBe('class'); + if (only.kind !== 'class') return; + expect(only.breakpoints.map((b) => `${b.selector}@${b.stepPoint}`)).toEqual([ + 'abc@2', + 'abc@5', + 'zed@1', + ]); + }); + + it('puts executed-code breakpoints last — nothing navigates to them', () => { + const nodes = groupBreakpoints([ + bp({ className: '', selector: '' }), + bp({ className: 'Account' }), + ]); + expect(nodes.map((n) => (n.kind === 'class' ? n.className : ''))).toEqual(['Account', '']); + }); + + it('returns nothing for no breakpoints', () => { + expect(groupBreakpoints([])).toEqual([]); + }); +}); + +describe('BreakpointTreeProvider', () => { + function makeSessionManager(hasSession = true) { + return { + getSelectedSession: vi.fn(() => + hasSession ? { id: 1, gci: {}, handle: 'h', login: {}, stoneVersion: '3.7.5' } : undefined, + ), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; + } + + const manager = { + setEnabledForStoneBreakpoint: vi.fn(), + removeStoneBreakpoint: vi.fn(), + onDidApply: vi.fn(() => ({ dispose: () => {} })), + } as unknown as BreakpointManager; + + beforeEach(() => { + mockGetAll.mockReset().mockReturnValue([]); + }); + + it('asks the developer to log in when there is no session', () => { + const provider = new BreakpointTreeProvider(makeSessionManager(false), manager); + const roots = provider.getChildren(); + expect(roots).toHaveLength(1); + expect(roots[0].kind).toBe('notice'); + expect(mockGetAll).not.toHaveBeenCalled(); + }); + + it('says so plainly when the session has no breakpoints', () => { + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const roots = provider.getChildren(); + expect(roots[0]).toMatchObject({ kind: 'notice' }); + }); + + it('shows the failure rather than an empty tree when the query throws', () => { + mockGetAll.mockImplementation(() => { + throw new Error('session busy'); + }); + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const roots = provider.getChildren(); + expect(roots).toHaveLength(1); + // Show the developer why the view is empty; a bare empty tree reads as + // "no breakpoints", which is a different and wrong answer. + expect(roots[0]).toMatchObject({ + kind: 'notice', + text: expect.stringContaining('session busy'), + }); + }); + + it('groups the gem breakpoints into class nodes', () => { + mockGetAll.mockReturnValue([bp(), bp({ selector: 'deposit:' })]); + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const roots = provider.getChildren(); + expect(roots).toHaveLength(1); + expect(roots[0].kind).toBe('class'); + }); + + it('coalesces a burst of refreshes into one redraw', () => { + vi.useFakeTimers(); + try { + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const fired = vi.fn(); + provider.onDidChangeTreeData(fired); + + // The manager applies breakpoints one method at a time, so a multi-method + // change arrives as a burst — each redraw would be its own GCI round trip. + provider.refresh(); + provider.refresh(); + provider.refresh(); + expect(fired).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(50); + expect(fired).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + } + }); + + it('refreshNow redraws without waiting', () => { + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const fired = vi.fn(); + provider.onDidChangeTreeData(fired); + provider.refreshNow(); + expect(fired).toHaveBeenCalledTimes(1); + }); + + it('lists a class node’s breakpoints as its children', () => { + mockGetAll.mockReturnValue([bp(), bp({ selector: 'deposit:' })]); + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + const [classNode] = provider.getChildren(); + const children = provider.getChildren(classNode); + expect(children).toHaveLength(2); + expect(children.every((c) => c.kind === 'breakpoint')).toBe(true); + }); + + describe('getTreeItem', () => { + const provider = new BreakpointTreeProvider(makeSessionManager(), manager); + + it('labels a breakpoint by selector and step point', () => { + const item = provider.getTreeItem({ kind: 'breakpoint', bp: bp() }); + expect(item.label).toBe('balance'); + expect(item.description).toBe('@ 3'); + }); + + it('checks the box for an enabled breakpoint', () => { + const item = provider.getTreeItem({ kind: 'breakpoint', bp: bp({ disabled: false }) }); + expect(item.checkboxState).toBe(TreeItemCheckboxState.Checked); + }); + + it('unchecks the box for a disabled breakpoint', () => { + const item = provider.getTreeItem({ kind: 'breakpoint', bp: bp({ disabled: true }) }); + expect(item.checkboxState).toBe(TreeItemCheckboxState.Unchecked); + }); + + it('gives a real method a reveal command', () => { + const item = provider.getTreeItem({ kind: 'breakpoint', bp: bp() }); + expect(item.command).toMatchObject({ command: 'gemstone.breakpoints.reveal' }); + expect(item.contextValue).toBe('gemstoneBreakpoint'); + }); + + it('gives executed code no reveal command — there is no source to open', () => { + const item = provider.getTreeItem({ + kind: 'breakpoint', + bp: bp({ className: '', selector: '' }), + }); + expect(item.command).toBeUndefined(); + expect(item.contextValue).toBe('gemstoneBreakpointDoit'); + }); + + it('counts a class node’s breakpoints, calling out the disabled ones', () => { + const node: BreakpointNode = { + kind: 'class', + className: 'Account', + isMeta: false, + breakpoints: [bp(), bp({ disabled: true }), bp({ disabled: true })], + }; + const item = provider.getTreeItem(node); + expect(item.label).toBe('Account'); + expect(item.description).toBe('3 · 2 disabled'); + }); + + it('omits the disabled count when none are disabled', () => { + const node: BreakpointNode = { + kind: 'class', + className: 'Account', + isMeta: false, + breakpoints: [bp(), bp()], + }; + expect(provider.getTreeItem(node).description).toBe('2'); + }); + }); +}); diff --git a/client/src/__tests__/breakpoints.integration.test.ts b/client/src/__tests__/breakpoints.integration.test.ts new file mode 100644 index 00000000..0227f0be --- /dev/null +++ b/client/src/__tests__/breakpoints.integration.test.ts @@ -0,0 +1,233 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +import { GciLibrary } from '../gciLibrary'; +import * as queries from '../browserQueries'; +import type { ActiveSession } from '../sessionManager'; +import { useIntegrationTest } from './useIntegrationTest'; +import { testActiveSession } from './testActiveSession'; + +/** + * Pins the GemStone breakpoint semantics the breakpoint manager is built on, + * against a live stone. Every one of these is a decision the design depends on + * and none of them is documented anywhere we control: + * + * - `disableBreakAtStepPoint:` is a **no-op** when nothing is set at that step + * point, which is why a disabled breakpoint has to be applied as + * set-then-disable rather than disable alone. + * - `setBreakAtStepPoint:` is also the **enable**; there is no separate enable + * primitive on the instance side. + * - An out-of-range step point is **silently ignored**, not an error — so step + * points must be validated client-side or a breakpoint vanishes without a word. + * - Breakpoints are **per-gem** state, outside the repository entirely: they are + * invisible to `commit` and, as this test's own `beforeEach` has to allow for, + * they survive a transaction **abort** too. + * - `_allMethodBreakpoints`' tuple stride differs across releases (3 fields on + * 3.6.2, 4 on 3.7.5), which is why `getAllBreakpoints` reads the kernel's own + * `_breakReport:` rather than decoding that primitive itself. These tests are + * the guard on that: they run against whichever stone CI provides. + * + * Ungated: needs only a running stone. The harness aborts afterward, so the + * throwaway class never reaches the repository, and the breakpoints go away with + * the session regardless. + */ +describe('GemStone breakpoint semantics (integration)', () => { + let gci: GciLibrary; + let handle: unknown; + + useIntegrationTest((testContext) => { + gci = testContext.gciLibrary; + handle = testContext.session; + }); + + const session = (): ActiveSession => testActiveSession(gci, handle); + + const TEST_CLASS = 'VsCodeBreakpointTest'; + const TEST_SELECTOR = 'vsCodeBreakpointFixture'; + + /** + * A method with several step points on purpose — `^a` at the end plus the + * assignments and the send — so a test can tell one step point from another. + */ + const fixture = (): void => { + const defined = queries.compileClassDefinition( + session(), + `Object subclass: '${TEST_CLASS}' + instVarNames: #() + classVars: #() + classInstVars: #() + poolDictionaries: #() + inDictionary: UserGlobals + options: #()`, + ); + expect(defined).toBe(TEST_CLASS); + + queries.compileMethod( + session(), + TEST_CLASS, + false, + 'test-vscode-extension', + `${TEST_SELECTOR}\n | a |\n a := 1.\n a := a + 2.\n ^ a printString`, + ); + expect(queries.getAllSelectors(session(), TEST_CLASS)).toContain(TEST_SELECTOR); + }; + + /** Breakpoints on the fixture method only, so a shared stone can't confuse us. */ + const onFixture = () => + queries + .getAllBreakpoints(session()) + .filter((b) => b.className === TEST_CLASS && b.selector === TEST_SELECTOR); + + const setBreak = (stepPoint: number) => + queries.setBreakAtStepPoint(session(), TEST_CLASS, false, TEST_SELECTOR, stepPoint); + const disableBreak = (stepPoint: number) => + queries.disableBreakAtStepPoint(session(), TEST_CLASS, false, TEST_SELECTOR, stepPoint); + const clearBreak = (stepPoint: number) => + queries.clearBreakAtStepPoint(session(), TEST_CLASS, false, TEST_SELECTOR, stepPoint); + + beforeEach(() => { + // Sweep the whole gem, not just this method. Breakpoints are gem state, so + // they survive the harness's per-test transaction abort — while the fixture + // class is rolled back and rebuilt as a *new* class object each test. A + // per-method clear would therefore miss the previous test's breakpoints, + // which are still reported under the same class name and selector. + queries.removeAllBreakpoints(session()); + fixture(); + }); + + it('the fixture method has step points to break at', () => { + const offsets = queries.getSourceOffsets(session(), TEST_CLASS, false, TEST_SELECTOR); + expect(offsets.length).toBeGreaterThan(2); + // _sourceOffsets is 1-based — the whole model converts on the way in. + expect(Math.min(...offsets)).toBeGreaterThanOrEqual(1); + }); + + it('reports a breakpoint it just set, resolved back to the step point', () => { + setBreak(1); + const found = onFixture(); + expect(found).toHaveLength(1); + expect(found[0].stepPoint).toBe(1); + expect(found[0].disabled).toBe(false); + expect(found[0].isMeta).toBe(false); + expect(found[0].dictName).toBe('UserGlobals'); + }); + + it('disableBreakAtStepPoint: does nothing when no breakpoint is set there', () => { + // The reason a disabled breakpoint must be applied as set-then-disable. + disableBreak(2); + expect(onFixture()).toHaveLength(0); + }); + + it('set-then-disable leaves a breakpoint that is present but disabled', () => { + setBreak(2); + disableBreak(2); + const found = onFixture(); + expect(found).toHaveLength(1); + expect(found[0].stepPoint).toBe(2); + expect(found[0].disabled).toBe(true); + }); + + it('setBreakAtStepPoint: re-enables a disabled breakpoint', () => { + setBreak(2); + disableBreak(2); + expect(onFixture()[0].disabled).toBe(true); + + setBreak(2); + const found = onFixture(); + expect(found).toHaveLength(1); + expect(found[0].disabled).toBe(false); + }); + + it('clearBreakAtStepPoint: removes it outright', () => { + setBreak(1); + clearBreak(1); + expect(onFixture()).toHaveLength(0); + }); + + it('clearAllBreaks drops every breakpoint on the method', () => { + setBreak(1); + setBreak(2); + expect(onFixture()).toHaveLength(2); + + queries.clearAllBreaks(session(), TEST_CLASS, false, TEST_SELECTOR); + expect(onFixture()).toHaveLength(0); + }); + + it('silently ignores an out-of-range step point', () => { + // No error is raised, so nothing downstream can notice — which is why step + // points are resolved against _sourceOffsets before they are ever sent. + const offsets = queries.getSourceOffsets(session(), TEST_CLASS, false, TEST_SELECTOR); + setBreak(offsets.length + 500); + expect(onFixture()).toHaveLength(0); + }); + + it('reports several breakpoints on one method separately', () => { + setBreak(1); + setBreak(2); + const found = onFixture().sort((a, b) => a.stepPoint - b.stepPoint); + expect(found.map((b) => b.stepPoint)).toEqual([1, 2]); + }); + + it('session-wide disable turns off a breakpoint without removing it', () => { + setBreak(1); + queries.disableAllBreakpoints(session()); + const found = onFixture(); + expect(found).toHaveLength(1); + expect(found[0].disabled).toBe(true); + }); + + it('session-wide enable turns a disabled breakpoint back on', () => { + setBreak(1); + queries.disableAllBreakpoints(session()); + queries.enableAllBreakpoints(session()); + expect(onFixture()[0].disabled).toBe(false); + }); + + it('session-wide remove clears the gem', () => { + setBreak(1); + setBreak(2); + queries.removeAllBreakpoints(session()); + expect(onFixture()).toHaveLength(0); + expect(queries.hasBreakpoints(session())).toBe(false); + }); + + it('hasBreakpoints tracks whether the gem holds any', () => { + queries.removeAllBreakpoints(session()); + expect(queries.hasBreakpoints(session())).toBe(false); + + setBreak(1); + expect(queries.hasBreakpoints(session())).toBe(true); + }); + + it('reaches a breakpoint by method OOP, the way a doit has to be reached', () => { + setBreak(1); + const oop = onFixture()[0].methodOop; + expect(oop).toMatch(/^\d+$/); + + queries.breakpointByOop(session(), oop, 'disableBreakAtStepPoint:', 1); + expect(onFixture()[0].disabled).toBe(true); + + queries.breakpointByOop(session(), oop, 'clearBreakAtStepPoint:', 1); + expect(onFixture()).toHaveLength(0); + }); + + it('reports a class-side breakpoint as isMeta with the base class name', () => { + queries.compileMethod( + session(), + TEST_CLASS, + true, + 'test-vscode-extension', + 'vsCodeBreakpointClassSide\n ^ 3 + 4', + ); + queries.setBreakAtStepPoint(session(), TEST_CLASS, true, 'vsCodeBreakpointClassSide', 1); + + const found = queries + .getAllBreakpoints(session()) + .filter((b) => b.className === TEST_CLASS && b.selector === 'vsCodeBreakpointClassSide'); + expect(found).toHaveLength(1); + expect(found[0].isMeta).toBe(true); + // The base name, not 'VsCodeBreakpointTest class' — the manager matches it + // against a method URI's className, which is always the base. + expect(found[0].className).toBe(TEST_CLASS); + }); +}); diff --git a/client/src/__tests__/editorContextMenu.test.ts b/client/src/__tests__/editorContextMenu.test.ts index 9eb8a65e..1c008d7f 100644 --- a/client/src/__tests__/editorContextMenu.test.ts +++ b/client/src/__tests__/editorContextMenu.test.ts @@ -29,7 +29,10 @@ describe('editor/context menu', () => { 'gemstone.runInNewGem', 'gemstone.sendersOf', 'gemstone.implementorsOf', - 'gemstone.toggleSelectorBreakpoint', + 'gemstone.breakpoints.toggleAtCursor', + 'gemstone.breakpoints.enableAtCursor', + 'gemstone.breakpoints.disableAtCursor', + 'gemstone.breakpoints.clearMethod', ]); }); @@ -78,9 +81,18 @@ describe('editor/context menu', () => { ); }); - it('shows "Toggle Selector Breakpoint" in gemstone documents', () => { - expect(getMenuItem('gemstone.toggleSelectorBreakpoint')?.when).toBe( - `editorTextFocus && resourceLangId == gemstone-smalltalk`, + it('shows the breakpoint actions in gemstone documents', () => { + const commands = [ + 'gemstone.breakpoints.toggleAtCursor', + 'gemstone.breakpoints.enableAtCursor', + 'gemstone.breakpoints.disableAtCursor', + 'gemstone.breakpoints.clearMethod', + ]; + // Compared as a map so a mismatch names the offending command itself. + expect(Object.fromEntries(commands.map((c) => [c, getMenuItem(c)?.when]))).toEqual( + Object.fromEntries( + commands.map((c) => [c, `editorTextFocus && resourceLangId == gemstone-smalltalk`]), + ), ); }); }); diff --git a/client/src/__tests__/selectorBreakpointManager.test.ts b/client/src/__tests__/selectorBreakpointManager.test.ts deleted file mode 100644 index 90e8626f..00000000 --- a/client/src/__tests__/selectorBreakpointManager.test.ts +++ /dev/null @@ -1,536 +0,0 @@ -import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; - -vi.mock('vscode', () => import('../__mocks__/vscode.js')); - -vi.mock('../browserQueries', () => ({ - getStepPointSelectorRanges: vi.fn(() => []), - setBreakAtStepPoint: vi.fn(), - clearBreakAtStepPoint: vi.fn(), - clearAllBreaks: vi.fn(), -})); - -import { Uri, window } from '../__mocks__/vscode'; -import type * as vscode from 'vscode'; -import { - SelectorBreakpointManager, - findNearestStepPoint, - expandKeywordParts, -} from '../selectorBreakpointManager'; -import { SessionManager } from '../sessionManager'; -import { - getStepPointSelectorRanges, - setBreakAtStepPoint, - clearBreakAtStepPoint, - StepPointSelectorInfo, -} from '../browserQueries'; - -const mockGetRanges = vi.mocked(getStepPointSelectorRanges); -const mockSetBreak = vi.mocked(setBreakAtStepPoint); -const mockClearBreak = vi.mocked(clearBreakAtStepPoint); - -function makeSessionManager(hasSession: boolean) { - return { - getSelectedSession: vi.fn(() => - hasSession - ? { id: 1, gci: {}, handle: 'h1', login: { label: 'Test' }, stoneVersion: '3.7.2' } - : undefined, - ), - onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), - } as unknown as SessionManager; -} - -function makeEditor(uriStr: string, source: string) { - const uri = Uri.parse(uriStr); - return { - document: { - uri, - getText: vi.fn(() => source), - offsetAt: vi.fn((pos: { line: number; character: number }) => { - // Simple: each line is 20 chars + newline - return pos.line * 21 + pos.character; - }), - positionAt: vi.fn((offset: number) => ({ - line: Math.floor(offset / 21), - character: offset % 21, - })), - }, - selection: { - active: { line: 0, character: 5 }, - }, - setDecorations: vi.fn(), - } as unknown as vscode.TextEditor & { - document: { offsetAt: Mock }; - setDecorations: Mock; - }; -} - -// ── findNearestStepPoint ────────────────────────────────── - -describe('findNearestStepPoint', () => { - it('returns null for empty list', () => { - expect(findNearestStepPoint([], 10)).toBeNull(); - }); - - it('returns exact match when cursor is within selector range', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 0, selectorLength: 4, selectorText: 'size' }, - { stepPoint: 2, selectorOffset: 20, selectorLength: 3, selectorText: 'at:' }, - ]; - const result = findNearestStepPoint(infos, 21); - expect(result).toEqual(infos[1]); - }); - - it('returns match when cursor is at selector start', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'size' }, - ]; - const result = findNearestStepPoint(infos, 10); - expect(result).toEqual(infos[0]); - }); - - it('returns match when cursor is at selector end', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'size' }, - ]; - const result = findNearestStepPoint(infos, 14); - expect(result).toEqual(infos[0]); - }); - - it('falls back to nearest by distance when not contained', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 0, selectorLength: 4, selectorText: 'foo' }, - { stepPoint: 2, selectorOffset: 50, selectorLength: 3, selectorText: 'bar' }, - ]; - // Cursor at 45 — closer to step 2 (midpoint 51.5) than step 1 (midpoint 2) - const result = findNearestStepPoint(infos, 45); - expect(result).toEqual(infos[1]); - }); - - it('handles cursor before all selectors', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 100, selectorLength: 4, selectorText: 'size' }, - { stepPoint: 2, selectorOffset: 200, selectorLength: 3, selectorText: 'at:' }, - ]; - const result = findNearestStepPoint(infos, 0); - expect(result).toEqual(infos[0]); - }); - - it('handles cursor after all selectors', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'foo' }, - { stepPoint: 2, selectorOffset: 30, selectorLength: 3, selectorText: 'bar' }, - ]; - const result = findNearestStepPoint(infos, 500); - expect(result).toEqual(infos[1]); - }); - - it('selects correct selector when cursor is on equals: not at:', () => { - // Simulates: "self at: idx equals: val" with 0-based offsets - // at: starts at offset 8, equals: starts at offset 16 - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 8, selectorLength: 3, selectorText: 'at:' }, - { stepPoint: 2, selectorOffset: 16, selectorLength: 7, selectorText: 'equals:' }, - ]; - // Cursor at offset 18 — within 'equals:' (16..23) - const result = findNearestStepPoint(infos, 18); - expect(result).toEqual(infos[1]); - }); - - it('selects at: when cursor is on at: not equals:', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 8, selectorLength: 3, selectorText: 'at:' }, - { stepPoint: 2, selectorOffset: 16, selectorLength: 7, selectorText: 'equals:' }, - ]; - // Cursor at offset 9 — within 'at:' (8..11) - const result = findNearestStepPoint(infos, 9); - expect(result).toEqual(infos[0]); - }); - - it('returns first contained match when cursor is in overlapping ranges', () => { - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 10, selectorText: 'longSelector:' }, - { stepPoint: 2, selectorOffset: 8, selectorLength: 4, selectorText: 'sel:' }, - ]; - // Cursor at 9 is within both — returns first match - const result = findNearestStepPoint(infos, 9); - expect(result).toEqual(infos[0]); - }); -}); - -// ── expandKeywordParts ────────────────────────────────── - -describe('expandKeywordParts', () => { - it('returns infos unchanged for unary messages', () => { - const source = 'self size'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'size' }, - ]; - expect(expandKeywordParts(source, infos)).toEqual(infos); - }); - - it('finds continuation keyword for assert:equals:', () => { - // 0 1 2 3 - // 0123456789012345678901234567890123456 - const source = 'self assert: (x at: 1) equals: true.'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 16, selectorLength: 3, selectorText: 'at:' }, - { stepPoint: 2, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, - ]; - const expanded = expandKeywordParts(source, infos); - expect(expanded).toHaveLength(3); - // at: has no continuation (argument is literal, then ) exits) - expect(expanded[0]).toEqual(infos[0]); - // assert: should get equals: as continuation - expect(expanded[1]).toEqual(infos[1]); - expect(expanded[2]).toEqual({ - stepPoint: 2, - selectorOffset: 23, - selectorLength: 7, - selectorText: 'equals:', - }); - }); - - it('finds continuation keywords for perform:env:', () => { - // 0123456789012345678901234567890 - const source = 'true perform: #foo env: 2'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 8, selectorText: 'perform:' }, - ]; - const expanded = expandKeywordParts(source, infos); - expect(expanded).toHaveLength(2); - expect(expanded[1]).toEqual({ - stepPoint: 1, - selectorOffset: 19, - selectorLength: 4, - selectorText: 'env:', - }); - }); - - it('skips keywords inside parenthesized arguments', () => { - // 01234567890123456789012345678901234567890 - const source = 'self assert: (x at: 1) equals: true.'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, - ]; - const expanded = expandKeywordParts(source, infos); - // Should find equals: but NOT at: (which is inside parens) - const continuations = expanded.filter((e) => e !== infos[0]); - expect(continuations).toHaveLength(1); - expect(continuations[0].selectorText).toBe('equals:'); - }); - - it('skips symbol literals', () => { - // 012345678901234567890123456 - const source = 'self foo: #bar: baz: 2'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, - ]; - const expanded = expandKeywordParts(source, infos); - // #bar: is a symbol literal, baz: is the continuation - const texts = expanded.map((e) => e.selectorText); - expect(texts).toContain('foo:'); - expect(texts).toContain('baz:'); - expect(texts).not.toContain('bar:'); - }); - - it('stops at period', () => { - const source = 'self foo: 1. self bar: 2'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, - ]; - const expanded = expandKeywordParts(source, infos); - // bar: is after period — should not be included - expect(expanded).toHaveLength(1); - }); - - it('stops at semicolon (cascade)', () => { - const source = 'self foo: 1; bar: 2'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, - ]; - const expanded = expandKeywordParts(source, infos); - expect(expanded).toHaveLength(1); - }); - - it('does not expand unary messages (no colon)', () => { - const source = 'self size printString'; - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'size' }, - ]; - const expanded = expandKeywordParts(source, infos); - expect(expanded).toHaveLength(1); - }); -}); - -// ── findNearestStepPoint with expanded keywords ───────── - -describe('findNearestStepPoint with keyword expansion', () => { - it('matches cursor on equals: to assert:equals: step point', () => { - // Simulates expanded infos for: self assert: (x at: 1) equals: true. - const infos: StepPointSelectorInfo[] = [ - { stepPoint: 1, selectorOffset: 14, selectorLength: 3, selectorText: 'at:' }, - { stepPoint: 2, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, - { stepPoint: 2, selectorOffset: 23, selectorLength: 7, selectorText: 'equals:' }, - ]; - // Cursor on equals: at offset 25 - const result = findNearestStepPoint(infos, 25); - expect(result!.stepPoint).toBe(2); - expect(result!.selectorText).toBe('equals:'); - }); -}); - -// ── SelectorBreakpointManager ──────────────────────────── - -describe('SelectorBreakpointManager', () => { - beforeEach(() => { - mockGetRanges.mockReset(); - mockSetBreak.mockReset(); - mockClearBreak.mockReset(); - mockGetRanges.mockReturnValue([]); - vi.mocked(window.showErrorMessage).mockReset(); - vi.mocked(window.showInformationMessage).mockReset(); - }); - - describe('toggleBreakpointAtCursor', () => { - it('ignores non-gemstone URIs', () => { - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('file:///test.tpz', 'foo'); - manager.toggleBreakpointAtCursor(editor); - - expect(mockGetRanges).not.toHaveBeenCalled(); - expect(mockSetBreak).not.toHaveBeenCalled(); - }); - - it('shows error when no session', () => { - const manager = new SelectorBreakpointManager(makeSessionManager(false)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - manager.toggleBreakpointAtCursor(editor); - - expect(window.showErrorMessage).toHaveBeenCalledWith('No active GemStone session.'); - }); - - it('shows info when no step points found', () => { - mockGetRanges.mockReturnValue([]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - manager.toggleBreakpointAtCursor(editor); - - expect(window.showInformationMessage).toHaveBeenCalledWith( - 'No breakpointable step points found in this method.', - ); - }); - - it('sets breakpoint and updates decorations', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 5, selectorLength: 3, selectorText: 'at:' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - editor.document.offsetAt.mockReturnValue(6); // cursor within 'at:' range - manager.toggleBreakpointAtCursor(editor); - - expect(mockSetBreak).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'at:', 1, 0); - expect(editor.setDecorations).toHaveBeenCalledTimes(1); - const ranges = editor.setDecorations.mock.calls[0][1]; - expect(ranges).toHaveLength(1); - }); - - it('clears breakpoint on second toggle of same step point', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 5, selectorLength: 3, selectorText: 'at:' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - editor.document.offsetAt.mockReturnValue(6); - - // First toggle: sets breakpoint - manager.toggleBreakpointAtCursor(editor); - expect(mockSetBreak).toHaveBeenCalledTimes(1); - - // Second toggle: clears breakpoint - manager.toggleBreakpointAtCursor(editor); - expect(mockClearBreak).toHaveBeenCalledTimes(1); - expect(mockClearBreak).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'at:', 1, 0); - - // Decorations should be cleared - const lastCall = - editor.setDecorations.mock.calls[editor.setDecorations.mock.calls.length - 1]; - expect(lastCall[1]).toHaveLength(0); - }); - - it('handles setBreakAtStepPoint throwing', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 5, selectorLength: 3, selectorText: 'at:' }, - ]); - mockSetBreak.mockImplementation(() => { - throw new Error('GCI error'); - }); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - editor.document.offsetAt.mockReturnValue(6); - manager.toggleBreakpointAtCursor(editor); - - expect(window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('GCI error')); - }); - - it('handles getStepPointSelectorRanges throwing', () => { - mockGetRanges.mockImplementation(() => { - throw new Error('AST error'); - }); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3A', ''); - manager.toggleBreakpointAtCursor(editor); - - expect(window.showErrorMessage).toHaveBeenCalledWith(expect.stringContaining('AST error')); - }); - - it('parses class-side URIs correctly', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'new' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/class/creation/new', ''); - editor.document.offsetAt.mockReturnValue(1); - manager.toggleBreakpointAtCursor(editor); - - expect(mockGetRanges).toHaveBeenCalledWith(expect.anything(), 'Array', true, 'new', 0); - expect(mockSetBreak).toHaveBeenCalledWith(expect.anything(), 'Array', true, 'new', 1, 0); - }); - - it('parses environment ID from query string', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo?env=2', ''); - editor.document.offsetAt.mockReturnValue(1); - manager.toggleBreakpointAtCursor(editor); - - expect(mockGetRanges).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'foo', 2); - }); - - it('sets breakpoint on correct selector when multiple step points exist', () => { - // Simulates a method with at: at offset 8 and equals: at offset 16 - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 8, selectorLength: 3, selectorText: 'at:' }, - { stepPoint: 2, selectorOffset: 16, selectorLength: 7, selectorText: 'equals:' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/at%3Aequals%3A', ''); - // Cursor at offset 18 — within 'equals:' range (16..23) - editor.document.offsetAt.mockReturnValue(18); - manager.toggleBreakpointAtCursor(editor); - - // Should set breakpoint on step point 2 (equals:), not step point 1 (at:) - expect(mockSetBreak).toHaveBeenCalledWith( - expect.anything(), - 'Array', - false, - 'at:equals:', - 2, - 0, - ); - }); - - it('caches selector info across toggles', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, - { stepPoint: 2, selectorOffset: 10, selectorLength: 4, selectorText: 'bar:' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo', ''); - editor.document.offsetAt.mockReturnValue(1); - - manager.toggleBreakpointAtCursor(editor); - manager.toggleBreakpointAtCursor(editor); - - // getStepPointSelectorRanges should only be called once (cached) - expect(mockGetRanges).toHaveBeenCalledTimes(1); - }); - }); - - describe('refreshDecorations', () => { - it('applies empty decorations for non-gemstone URIs', () => { - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('file:///test.tpz', ''); - manager.refreshDecorations(editor); - - expect(editor.setDecorations).not.toHaveBeenCalled(); - }); - - it('applies empty decorations when no breakpoints tracked', () => { - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo', ''); - manager.refreshDecorations(editor); - - expect(editor.setDecorations).toHaveBeenCalledTimes(1); - expect(editor.setDecorations.mock.calls[0][1]).toHaveLength(0); - }); - }); - - describe('clearAllForSession', () => { - it('removes tracked breakpoints for the session', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo', ''); - editor.document.offsetAt.mockReturnValue(1); - - manager.toggleBreakpointAtCursor(editor); - expect(mockSetBreak).toHaveBeenCalledTimes(1); - - manager.clearAllForSession(1); - - // After clearing, decorations should show nothing - manager.refreshDecorations(editor); - const lastCall = - editor.setDecorations.mock.calls[editor.setDecorations.mock.calls.length - 1]; - expect(lastCall[1]).toHaveLength(0); - }); - }); - - describe('invalidateForUri', () => { - it('clears breakpoints and cache on recompile', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo', ''); - editor.document.offsetAt.mockReturnValue(1); - - // Set a breakpoint - manager.toggleBreakpointAtCursor(editor); - expect(mockSetBreak).toHaveBeenCalledTimes(1); - - // Recompile replaces GsNMethod — breakpoints are gone - manager.invalidateForUri(Uri.parse('gemstone://1/Globals/Array/instance/accessing/foo')); - - // Decorations should be cleared - manager.refreshDecorations(editor); - const lastCall = - editor.setDecorations.mock.calls[editor.setDecorations.mock.calls.length - 1]; - expect(lastCall[1]).toHaveLength(0); - }); - - it('clears cache so next toggle re-fetches', () => { - mockGetRanges.mockReturnValue([ - { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, - ]); - const manager = new SelectorBreakpointManager(makeSessionManager(true)); - const editor = makeEditor('gemstone://1/Globals/Array/instance/accessing/foo', ''); - editor.document.offsetAt.mockReturnValue(1); - - // First toggle — caches selector info - manager.toggleBreakpointAtCursor(editor); - expect(mockGetRanges).toHaveBeenCalledTimes(1); - - // Invalidate clears cache - manager.invalidateForUri(Uri.parse('gemstone://1/Globals/Array/instance/accessing/foo')); - - // Next toggle should re-fetch - manager.toggleBreakpointAtCursor(editor); - expect(mockGetRanges).toHaveBeenCalledTimes(2); - }); - }); -}); diff --git a/client/src/__tests__/stepPointHints.test.ts b/client/src/__tests__/stepPointHints.test.ts new file mode 100644 index 00000000..7b118e52 --- /dev/null +++ b/client/src/__tests__/stepPointHints.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +vi.mock('../browserQueries', () => ({ + getMethodSource: vi.fn(() => ''), + getSourceOffsets: vi.fn(() => []), + getStepPointSelectorRanges: vi.fn(() => []), +})); + +import type * as vscode from 'vscode'; +import { Uri, Position, Range, debug, __setConfig, __resetConfig } from '../__mocks__/vscode'; +import { StepPointHintsProvider, shouldShow, readDisplaySetting } from '../stepPointHints'; +import { StepPointModel } from '../stepPointModel'; +import { SessionManager } from '../sessionManager'; +import { getMethodSource, getSourceOffsets, getStepPointSelectorRanges } from '../browserQueries'; + +const mockGetMethodSource = vi.mocked(getMethodSource); +const mockGetSourceOffsets = vi.mocked(getSourceOffsets); +const mockGetRanges = vi.mocked(getStepPointSelectorRanges); + +describe('shouldShow', () => { + it('never shows when off', () => { + expect(shouldShow('off', true)).toBe(false); + expect(shouldShow('off', false)).toBe(false); + }); + + it('always shows when always', () => { + expect(shouldShow('always', false)).toBe(true); + expect(shouldShow('always', true)).toBe(true); + }); + + it('shows while debugging, and not otherwise — the point of the default', () => { + expect(shouldShow('debugging', true)).toBe(true); + expect(shouldShow('debugging', false)).toBe(false); + }); +}); + +describe('readDisplaySetting', () => { + beforeEach(() => __resetConfig()); + + it('defaults to debugging when unset', () => { + expect(readDisplaySetting()).toBe('debugging'); + }); + + it('falls back to debugging for a value it does not recognise', () => { + __setConfig('gemstone', 'stepPoints.display', 'nonsense'); + expect(readDisplaySetting()).toBe('debugging'); + }); + + it('honours an explicit choice', () => { + __setConfig('gemstone', 'stepPoints.display', 'always'); + expect(readDisplaySetting()).toBe('always'); + }); +}); + +describe('StepPointHintsProvider', () => { + const METHOD_URI = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; + const SOURCE = 'at: index\n^self basicAt: index'; + + function makeSessionManager() { + return { + getSelectedSession: vi.fn(() => ({ + id: 1, + gci: {}, + handle: 'h', + login: {}, + stoneVersion: '3.7.5', + })), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; + } + + function makeDocument() { + return { + uri: Uri.parse(METHOD_URI), + isDirty: false, + getText: () => SOURCE, + offsetAt: (p: Position) => (p.line === 0 ? p.character : 10 + p.character), + positionAt: (offset: number) => + offset < 10 ? new Position(0, offset) : new Position(1, offset - 10), + } as unknown as import('vscode').TextDocument; + } + + /** The mock's Range is structurally distinct from vscode's; bridge it once. */ + const range = (l1: number, c1: number, l2: number, c2: number) => + new Range(new Position(l1, c1), new Position(l2, c2)) as unknown as vscode.Range; + + /** The whole document, as VS Code asks for on first render. */ + const WHOLE = range(0, 0, 1, 100); + + function makeProvider(display: string) { + __setConfig('gemstone', 'stepPoints.display', display); + return new StepPointHintsProvider(new StepPointModel(makeSessionManager())); + } + + beforeEach(() => { + __resetConfig(); + mockGetMethodSource.mockReset().mockReturnValue(SOURCE); + // step points at 0-based 10 ('^') and 16 ('basicAt:') + mockGetSourceOffsets.mockReset().mockReturnValue([11, 17]); + mockGetRanges.mockReset().mockReturnValue([]); + debug.activeDebugSession = undefined; + }); + + it('draws nothing when numbering is off', () => { + expect(makeProvider('off').provideInlayHints(makeDocument(), WHOLE)).toBeUndefined(); + }); + + it('draws nothing outside a debug session when set to debugging', () => { + expect(makeProvider('debugging').provideInlayHints(makeDocument(), WHOLE)).toBeUndefined(); + }); + + it('draws while a debug session is live when set to debugging', () => { + debug.activeDebugSession = { id: 'x' }; + const hints = makeProvider('debugging').provideInlayHints(makeDocument(), WHOLE); + expect(hints).toHaveLength(2); + }); + + it('numbers each step point from one', () => { + const hints = makeProvider('always').provideInlayHints(makeDocument(), WHOLE); + expect(hints?.map((h) => (h.label as { value: string }[])[0].value)).toEqual(['1', '2']); + }); + + it('makes each number a clickable breakpoint toggle for its own step point', () => { + const hints = makeProvider('always').provideInlayHints(makeDocument(), WHOLE); + const parts = hints!.map( + (h) => (h.label as { command?: { command: string; arguments: unknown[] } }[])[0], + ); + expect(parts[0].command?.command).toBe('gemstone.breakpoints.toggleAtStepPoint'); + expect(parts[0].command?.arguments).toEqual([{ uri: METHOD_URI, stepPoint: 1 }]); + expect(parts[1].command?.arguments).toEqual([{ uri: METHOD_URI, stepPoint: 2 }]); + }); + + it('only draws the step points inside the requested range', () => { + // Offsets 0..12 covers the '^' step point (10) but not 'basicAt:' (16). + const narrow = range(0, 0, 1, 2); + const hints = makeProvider('always').provideInlayHints(makeDocument(), narrow); + expect(hints?.map((h) => (h.label as { value: string }[])[0].value)).toEqual(['1']); + }); + + it('draws nothing for a method whose step points cannot be read', () => { + mockGetMethodSource.mockImplementation(() => { + throw new Error('gone'); + }); + expect(makeProvider('always').provideInlayHints(makeDocument(), WHOLE)).toBeUndefined(); + }); + + describe('visible', () => { + it('reports off correctly', () => { + expect(makeProvider('off').visible()).toBe(false); + }); + + it('reports always correctly', () => { + expect(makeProvider('always').visible()).toBe(true); + }); + + it('follows the debug session when set to debugging', () => { + const provider = makeProvider('debugging'); + expect(provider.visible()).toBe(false); + debug.activeDebugSession = { id: 'x' }; + expect(provider.visible()).toBe(true); + }); + }); +}); diff --git a/client/src/__tests__/stepPointHover.test.ts b/client/src/__tests__/stepPointHover.test.ts new file mode 100644 index 00000000..5a19fcc2 --- /dev/null +++ b/client/src/__tests__/stepPointHover.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +vi.mock('../browserQueries', () => ({ + getMethodSource: vi.fn(() => ''), + getSourceOffsets: vi.fn(() => []), + getStepPointSelectorRanges: vi.fn(() => []), +})); + +import type * as vscode from 'vscode'; +import { Uri, Position } from '../__mocks__/vscode'; +import { StepPointHoverProvider } from '../stepPointHover'; +import { StepPointModel } from '../stepPointModel'; +import { BreakpointManager, AppliedBreakpoint } from '../breakpointManager'; +import { SessionManager } from '../sessionManager'; +import { getMethodSource, getSourceOffsets, getStepPointSelectorRanges } from '../browserQueries'; + +const mockGetMethodSource = vi.mocked(getMethodSource); +const mockGetSourceOffsets = vi.mocked(getSourceOffsets); +const mockGetRanges = vi.mocked(getStepPointSelectorRanges); + +/** The mock's Position is structurally distinct from vscode's; bridge it once. */ +const pos = (line: number, char: number) => new Position(line, char) as unknown as vscode.Position; + +const METHOD_URI = 'gemstone://1/Globals/Account/instance/accessing/balance'; +// 0 1 +// 0123456789012345678 +const SOURCE = 'balance\n^self total'; +// 'self' at 0-based 9, 'total' at 14 + +function makeSessionManager() { + return { + getSelectedSession: vi.fn(() => ({ + id: 1, + gci: {}, + handle: 'h', + login: {}, + stoneVersion: '3.7.5', + })), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; +} + +function makeDocument(uriStr = METHOD_URI) { + return { + uri: Uri.parse(uriStr), + isDirty: false, + getText: () => SOURCE, + // Line 0 is 'balance' (8 chars incl. newline); line 1 starts at offset 8. + offsetAt: (p: Position) => (p.line === 0 ? p.character : 8 + p.character), + positionAt: (offset: number) => + offset < 8 ? new Position(0, offset) : new Position(1, offset - 8), + } as unknown as import('vscode').TextDocument; +} + +function makeManager(applied: AppliedBreakpoint[] = []) { + return { appliedFor: vi.fn(() => applied) } as unknown as BreakpointManager; +} + +function hoverText(hover: import('vscode').Hover | null): string { + if (!hover) return ''; + const contents = hover.contents as { value: string }[] | { value: string }; + return Array.isArray(contents) ? contents.map((c) => c.value).join('\n') : contents.value; +} + +describe('StepPointHoverProvider', () => { + beforeEach(() => { + mockGetMethodSource.mockReset().mockReturnValue(SOURCE); + // step points 1 and 2, at 0-based 9 ('self') and 14 ('total') + mockGetSourceOffsets.mockReset().mockReturnValue([10, 15]); + mockGetRanges.mockReset().mockReturnValue([ + { stepPoint: 1, selectorOffset: 9, selectorLength: 4, selectorText: 'self' }, + { stepPoint: 2, selectorOffset: 14, selectorLength: 5, selectorText: 'total' }, + ]); + }); + + it('reports the step point under the pointer', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + // character 6 on line 1 => offset 14 => 'total' => step point 2 + const hover = provider.provideHover(makeDocument(), pos(1, 6)); + expect(hoverText(hover)).toContain('Step point 2'); + }); + + it('reports how many step points the method has', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + const hover = provider.provideHover(makeDocument(), pos(1, 1)); + expect(hoverText(hover)).toContain('of 2'); + }); + + it('highlights the step point’s own token, so the reach is visible', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + const hover = provider.provideHover(makeDocument(), pos(1, 6)); + // 'total' spans offsets 14..19, i.e. characters 6..11 of line 1. + expect(hover?.range?.start).toMatchObject({ line: 1, character: 6 }); + expect(hover?.range?.end).toMatchObject({ line: 1, character: 11 }); + }); + + it('offers to set a breakpoint when there is none', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + const text = hoverText(provider.provideHover(makeDocument(), pos(1, 6))); + expect(text).toContain('gemstone.breakpoints.toggleAtStepPoint'); + expect(text).not.toContain('disableAtStepPoint'); + }); + + it('offers clear and disable for an enabled breakpoint', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager([{ stepPoint: 2, offset: 14, line: 2, enabled: true }]), + ); + const text = hoverText(provider.provideHover(makeDocument(), pos(1, 6))); + expect(text).toContain('Breakpoint set'); + expect(text).toContain('gemstone.breakpoints.clearAtStepPoint'); + expect(text).toContain('gemstone.breakpoints.disableAtStepPoint'); + }); + + it('offers enable for a disabled breakpoint', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager([{ stepPoint: 2, offset: 14, line: 2, enabled: false }]), + ); + const text = hoverText(provider.provideHover(makeDocument(), pos(1, 6))); + expect(text).toContain('disabled'); + expect(text).toContain('gemstone.breakpoints.enableAtStepPoint'); + }); + + it('says nothing when the pointer is not on a step point token', () => { + // Character 0 of line 1 is '^', which carries no selector range here — and + // the caret rule would otherwise fall forward and misreport a step point. + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + expect(provider.provideHover(makeDocument(), pos(1, 0))).toBeNull(); + }); + + it('says nothing for a non-gemstone document', () => { + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + expect(provider.provideHover(makeDocument('file:///a.st'), pos(1, 6))).toBeNull(); + }); + + it('says nothing for a method with no step points', () => { + mockGetSourceOffsets.mockReturnValue([]); + mockGetRanges.mockReturnValue([]); + const provider = new StepPointHoverProvider( + new StepPointModel(makeSessionManager()), + makeManager(), + ); + expect(provider.provideHover(makeDocument(), pos(1, 6))).toBeNull(); + }); +}); diff --git a/client/src/__tests__/stepPointModel.test.ts b/client/src/__tests__/stepPointModel.test.ts new file mode 100644 index 00000000..94cd4b1b --- /dev/null +++ b/client/src/__tests__/stepPointModel.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +vi.mock('../browserQueries', () => ({ + getMethodSource: vi.fn(() => ''), + getSourceOffsets: vi.fn(() => []), + getStepPointSelectorRanges: vi.fn(() => []), +})); + +import { Uri } from '../__mocks__/vscode'; +import { + StepPointModel, + StepPointInfo, + buildLineStarts, + lineOfOffset, + resolveStepPoint, + stepPointAtOffset, + rangesForStepPoint, +} from '../stepPointModel'; +import { SessionManager } from '../sessionManager'; +import { getMethodSource, getSourceOffsets, getStepPointSelectorRanges } from '../browserQueries'; + +const mockGetMethodSource = vi.mocked(getMethodSource); +const mockGetSourceOffsets = vi.mocked(getSourceOffsets); +const mockGetRanges = vi.mocked(getStepPointSelectorRanges); + +/** + * A StepPointInfo built the way the model builds one: GemStone hands back + * 1-based source offsets, which become 0-based here. + */ +function makeInfo(source: string, oneBasedOffsets: number[]): StepPointInfo { + return { + source, + offsets: oneBasedOffsets.map((o) => o - 1), + selectors: [], + lineStarts: buildLineStarts(source), + }; +} + +describe('buildLineStarts', () => { + it('pads index 0 so lines read 1-based', () => { + expect(buildLineStarts('abc')).toEqual([0, 0]); + }); + + it('records the offset after each newline', () => { + expect(buildLineStarts('abc\ndef\nghi')).toEqual([0, 0, 4, 8]); + }); + + it('counts a trailing newline as starting another line', () => { + expect(buildLineStarts('abc\n')).toEqual([0, 0, 4]); + }); +}); + +describe('lineOfOffset', () => { + const starts = buildLineStarts('abc\ndef\nghi'); + + it('finds the first line', () => { + expect(lineOfOffset(starts, 0)).toBe(1); + expect(lineOfOffset(starts, 3)).toBe(1); + }); + + it('finds a middle line', () => { + expect(lineOfOffset(starts, 4)).toBe(2); + expect(lineOfOffset(starts, 7)).toBe(2); + }); + + it('finds the last line', () => { + expect(lineOfOffset(starts, 8)).toBe(3); + expect(lineOfOffset(starts, 10)).toBe(3); + }); +}); + +describe('resolveStepPoint', () => { + // 'm\nx := self foo' + // 0 1 2345678901234 + // line 2 starts at offset 2; 'self' at 7, 'foo' at 12 + const info = makeInfo('m\nx := self foo', [8, 13]); + + it('with no column, takes the leftmost step point on the line', () => { + expect(resolveStepPoint(info, 2)).toEqual({ stepPoint: 1, offset: 7, line: 2 }); + }); + + it('treats column 0 as a whole-line request, like a gutter click', () => { + expect(resolveStepPoint(info, 2, 0)?.stepPoint).toBe(1); + }); + + it('with a column, takes the nearest step point on the line', () => { + // character 10 => offset 12, exactly 'foo' + expect(resolveStepPoint(info, 2, 10)).toEqual({ stepPoint: 2, offset: 12, line: 2 }); + }); + + it('picks the left step point for a column nearer to it', () => { + expect(resolveStepPoint(info, 2, 5)?.stepPoint).toBe(1); + }); + + it('returns null for a line outside the source', () => { + expect(resolveStepPoint(info, 99)).toBeNull(); + expect(resolveStepPoint(info, 0)).toBeNull(); + }); + + it('returns null when the method has no step points at all', () => { + expect(resolveStepPoint(makeInfo('m\n^1', []), 2)).toBeNull(); + }); + + it('falls forward, reporting the line it really landed on', () => { + // 'foo\n"a comment"\n^1' — only step point is the '^' at 0-based 16 + const commented = makeInfo('foo\n"a comment"\n^1', [17]); + expect(resolveStepPoint(commented, 2)).toEqual({ stepPoint: 1, offset: 16, line: 3 }); + }); + + it('returns null when nothing is at or after the requested line', () => { + const info2 = makeInfo('foo\n^1\n', [1]); + expect(resolveStepPoint(info2, 3)).toBeNull(); + }); +}); + +describe('stepPointAtOffset', () => { + const info = makeInfo('m\nx := self foo', [8, 13]); + + it('picks the step point the caret is sitting on', () => { + expect(stepPointAtOffset(info, 12)?.stepPoint).toBe(2); + expect(stepPointAtOffset(info, 7)?.stepPoint).toBe(1); + }); + + it('stays on the caret column rather than snapping to the leftmost step point', () => { + // Offset 14 is inside 'foo'; a whole-line reading would answer step point 1. + expect(stepPointAtOffset(info, 14)?.stepPoint).toBe(2); + }); + + it('returns null when the method has no step points', () => { + expect(stepPointAtOffset(makeInfo('m\n^1', []), 2)).toBeNull(); + }); +}); + +describe('rangesForStepPoint', () => { + it('returns every selector range recorded for the step point', () => { + const info: StepPointInfo = { + source: 'm\nself assert: 1 equals: 1', + offsets: [7], + selectors: [ + { stepPoint: 1, selectorOffset: 7, selectorLength: 7, selectorText: 'assert:' }, + { stepPoint: 1, selectorOffset: 17, selectorLength: 7, selectorText: 'equals:' }, + ], + lineStarts: buildLineStarts('m\nself assert: 1 equals: 1'), + }; + expect(rangesForStepPoint(info, 1)).toEqual([ + { start: 7, end: 14 }, + { start: 17, end: 24 }, + ]); + }); + + it('marks a single character for a step point with no selector token', () => { + // A step point on ':=' or '^' has no identifier for the query to report. + const info = makeInfo('m\n^1', [3]); + expect(rangesForStepPoint(info, 1)).toEqual([{ start: 2, end: 3 }]); + }); + + it('returns nothing for a step point that does not exist', () => { + expect(rangesForStepPoint(makeInfo('m\n^1', [3]), 9)).toEqual([]); + }); +}); + +describe('StepPointModel', () => { + function makeSessionManager(hasSession = true) { + return { + getSelectedSession: vi.fn(() => + hasSession ? { id: 1, gci: {}, handle: 'h', login: {}, stoneVersion: '3.7.5' } : undefined, + ), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; + } + + const METHOD_URI = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; + + function makeDocument(uriStr = METHOD_URI, isDirty = false) { + return { + uri: Uri.parse(uriStr), + isDirty, + getText: () => 'at: index\n^self basicAt: index', + } as unknown as import('vscode').TextDocument; + } + + beforeEach(() => { + mockGetMethodSource.mockReset().mockReturnValue('at: index\n^self basicAt: index'); + mockGetSourceOffsets.mockReset().mockReturnValue([11]); + mockGetRanges.mockReset().mockReturnValue([]); + }); + + it('converts GemStone 1-based offsets to 0-based', () => { + const model = new StepPointModel(makeSessionManager()); + expect(model.get(makeDocument())?.offsets).toEqual([10]); + }); + + it('queries a method once and serves the rest from cache', () => { + const model = new StepPointModel(makeSessionManager()); + model.get(makeDocument()); + model.get(makeDocument()); + expect(mockGetSourceOffsets).toHaveBeenCalledTimes(1); + }); + + it('re-queries after the method is invalidated', () => { + const model = new StepPointModel(makeSessionManager()); + model.get(makeDocument()); + model.invalidate(Uri.parse(METHOD_URI)); + model.get(makeDocument()); + expect(mockGetSourceOffsets).toHaveBeenCalledTimes(2); + }); + + it('refuses a dirty document, whose text no longer matches the offsets', () => { + const model = new StepPointModel(makeSessionManager()); + expect(model.get(makeDocument(METHOD_URI, true))).toBeNull(); + expect(mockGetSourceOffsets).not.toHaveBeenCalled(); + }); + + it('refuses a non-gemstone document', () => { + const model = new StepPointModel(makeSessionManager()); + expect(model.get(makeDocument('file:///a.st'))).toBeNull(); + }); + + it('refuses a diff view, which must never be given a breakpoint', () => { + const model = new StepPointModel(makeSessionManager()); + const diff = makeDocument( + 'gemstone://1/Globals/Array/instance/accessing/at%3A%20(base)?base=1', + ); + expect(model.get(diff)).toBeNull(); + }); + + it('refuses when no session is selected', () => { + const model = new StepPointModel(makeSessionManager(false)); + expect(model.get(makeDocument())).toBeNull(); + }); + + it('returns null rather than throwing when the method is gone', () => { + mockGetMethodSource.mockImplementation(() => { + throw new Error('not found'); + }); + const model = new StepPointModel(makeSessionManager()); + expect(model.get(makeDocument())).toBeNull(); + }); + + it('invalidateSession drops only that session', () => { + const model = new StepPointModel(makeSessionManager()); + model.get(makeDocument()); + model.invalidateSession(2); + model.get(makeDocument()); + expect(mockGetSourceOffsets).toHaveBeenCalledTimes(1); + + model.invalidateSession(1); + model.get(makeDocument()); + expect(mockGetSourceOffsets).toHaveBeenCalledTimes(2); + }); +}); diff --git a/client/src/__tests__/stepPointSelectors.test.ts b/client/src/__tests__/stepPointSelectors.test.ts new file mode 100644 index 00000000..05645e9b --- /dev/null +++ b/client/src/__tests__/stepPointSelectors.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect } from 'vitest'; + +import { findNearestStepPoint, expandKeywordParts } from '../stepPointSelectors'; +import type { StepPointSelectorInfo } from '../browserQueries'; + +describe('findNearestStepPoint', () => { + it('returns null for empty list', () => { + expect(findNearestStepPoint([], 10)).toBeNull(); + }); + + it('returns exact match when cursor is within selector range', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 0, selectorLength: 4, selectorText: 'size' }, + { stepPoint: 2, selectorOffset: 20, selectorLength: 3, selectorText: 'at:' }, + ]; + const result = findNearestStepPoint(infos, 21); + expect(result).toEqual(infos[1]); + }); + + it('returns match when cursor is at selector start', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'size' }, + ]; + const result = findNearestStepPoint(infos, 10); + expect(result).toEqual(infos[0]); + }); + + it('returns match when cursor is at selector end', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'size' }, + ]; + const result = findNearestStepPoint(infos, 14); + expect(result).toEqual(infos[0]); + }); + + it('falls back to nearest by distance when not contained', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 0, selectorLength: 4, selectorText: 'foo' }, + { stepPoint: 2, selectorOffset: 50, selectorLength: 3, selectorText: 'bar' }, + ]; + // Cursor at 45 — closer to step 2 (midpoint 51.5) than step 1 (midpoint 2) + const result = findNearestStepPoint(infos, 45); + expect(result).toEqual(infos[1]); + }); + + it('handles cursor before all selectors', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 100, selectorLength: 4, selectorText: 'size' }, + { stepPoint: 2, selectorOffset: 200, selectorLength: 3, selectorText: 'at:' }, + ]; + const result = findNearestStepPoint(infos, 0); + expect(result).toEqual(infos[0]); + }); + + it('handles cursor after all selectors', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 10, selectorLength: 4, selectorText: 'foo' }, + { stepPoint: 2, selectorOffset: 30, selectorLength: 3, selectorText: 'bar' }, + ]; + const result = findNearestStepPoint(infos, 500); + expect(result).toEqual(infos[1]); + }); + + it('selects correct selector when cursor is on equals: not at:', () => { + // Simulates: "self at: idx equals: val" with 0-based offsets + // at: starts at offset 8, equals: starts at offset 16 + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 8, selectorLength: 3, selectorText: 'at:' }, + { stepPoint: 2, selectorOffset: 16, selectorLength: 7, selectorText: 'equals:' }, + ]; + // Cursor at offset 18 — within 'equals:' (16..23) + const result = findNearestStepPoint(infos, 18); + expect(result).toEqual(infos[1]); + }); + + it('selects at: when cursor is on at: not equals:', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 8, selectorLength: 3, selectorText: 'at:' }, + { stepPoint: 2, selectorOffset: 16, selectorLength: 7, selectorText: 'equals:' }, + ]; + // Cursor at offset 9 — within 'at:' (8..11) + const result = findNearestStepPoint(infos, 9); + expect(result).toEqual(infos[0]); + }); + + it('returns first contained match when cursor is in overlapping ranges', () => { + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 10, selectorText: 'longSelector:' }, + { stepPoint: 2, selectorOffset: 8, selectorLength: 4, selectorText: 'sel:' }, + ]; + // Cursor at 9 is within both — returns first match + const result = findNearestStepPoint(infos, 9); + expect(result).toEqual(infos[0]); + }); +}); + +// ── expandKeywordParts ────────────────────────────────── + +describe('expandKeywordParts', () => { + it('returns infos unchanged for unary messages', () => { + const source = 'self size'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'size' }, + ]; + expect(expandKeywordParts(source, infos)).toEqual(infos); + }); + + it('finds continuation keyword for assert:equals:', () => { + // 0 1 2 3 + // 0123456789012345678901234567890123456 + const source = 'self assert: (x at: 1) equals: true.'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 16, selectorLength: 3, selectorText: 'at:' }, + { stepPoint: 2, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(3); + // at: has no continuation (argument is literal, then ) exits) + expect(expanded[0]).toEqual(infos[0]); + // assert: should get equals: as continuation + expect(expanded[1]).toEqual(infos[1]); + expect(expanded[2]).toEqual({ + stepPoint: 2, + selectorOffset: 23, + selectorLength: 7, + selectorText: 'equals:', + }); + }); + + it('finds continuation keywords for perform:env:', () => { + // 0123456789012345678901234567890 + const source = 'true perform: #foo env: 2'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 8, selectorText: 'perform:' }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(2); + expect(expanded[1]).toEqual({ + stepPoint: 1, + selectorOffset: 19, + selectorLength: 4, + selectorText: 'env:', + }); + }); + + it('skips keywords inside parenthesized arguments', () => { + // 01234567890123456789012345678901234567890 + const source = 'self assert: (x at: 1) equals: true.'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, + ]; + const expanded = expandKeywordParts(source, infos); + // Should find equals: but NOT at: (which is inside parens) + const continuations = expanded.filter((e) => e !== infos[0]); + expect(continuations).toHaveLength(1); + expect(continuations[0].selectorText).toBe('equals:'); + }); + + it('skips symbol literals', () => { + // 012345678901234567890123456 + const source = 'self foo: #bar: baz: 2'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, + ]; + const expanded = expandKeywordParts(source, infos); + // #bar: is a symbol literal, baz: is the continuation + const texts = expanded.map((e) => e.selectorText); + expect(texts).toContain('foo:'); + expect(texts).toContain('baz:'); + expect(texts).not.toContain('bar:'); + }); + + it('stops at period', () => { + const source = 'self foo: 1. self bar: 2'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, + ]; + const expanded = expandKeywordParts(source, infos); + // bar: is after period — should not be included + expect(expanded).toHaveLength(1); + }); + + it('stops at semicolon (cascade)', () => { + const source = 'self foo: 1; bar: 2'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'foo:' }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(1); + }); + + it('does not expand unary messages (no colon)', () => { + const source = 'self size printString'; + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 5, selectorLength: 4, selectorText: 'size' }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(1); + }); +}); + +// ── findNearestStepPoint with expanded keywords ───────── + +describe('findNearestStepPoint with keyword expansion', () => { + it('matches cursor on equals: to assert:equals: step point', () => { + // Simulates expanded infos for: self assert: (x at: 1) equals: true. + const infos: StepPointSelectorInfo[] = [ + { stepPoint: 1, selectorOffset: 14, selectorLength: 3, selectorText: 'at:' }, + { stepPoint: 2, selectorOffset: 5, selectorLength: 7, selectorText: 'assert:' }, + { stepPoint: 2, selectorOffset: 23, selectorLength: 7, selectorText: 'equals:' }, + ]; + // Cursor on equals: at offset 25 + const result = findNearestStepPoint(infos, 25); + expect(result!.stepPoint).toBe(2); + expect(result!.selectorText).toBe('equals:'); + }); +}); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index de051163..c9cddcdc 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -2,6 +2,14 @@ import * as vscode from 'vscode'; import { SessionManager, ActiveSession } from './sessionManager'; import { parseMethodUri } from './gemstoneFileSystemProvider'; import * as queries from './browserQueries'; +import { GemStoneBreakpoint } from './browserQueries'; +import { + StepPointModel, + StepPointInfo, + resolveStepPoint, + stepPointAtOffset, + rangesForStepPoint, +} from './stepPointModel'; export interface VerifiedBreakpoint { stepPoint: number; @@ -9,38 +17,114 @@ export interface VerifiedBreakpoint { verified: boolean; } -interface TrackedBreakpoint { +/** A breakpoint as it now stands in the gem, for one method. */ +export interface AppliedBreakpoint { stepPoint: number; - actualLine: number; + /** 0-based offset into the stone's source. */ + offset: number; + /** 1-based line in the stone's source. */ + line: number; + enabled: boolean; } +const enabledDecoration = vscode.window.createTextEditorDecorationType({ + borderWidth: '1px', + borderStyle: 'solid', + borderColor: new vscode.ThemeColor('debugIcon.breakpointForeground'), + borderRadius: '2px', + overviewRulerColor: new vscode.ThemeColor('debugIcon.breakpointForeground'), + overviewRulerLane: vscode.OverviewRulerLane.Left, +}); + +// Dashed and drawn in the "unverified" grey so a disabled breakpoint reads as +// present-but-inert at a glance, the way the gutter dot hollows out. +const disabledDecoration = vscode.window.createTextEditorDecorationType({ + borderWidth: '1px', + borderStyle: 'dashed', + borderColor: new vscode.ThemeColor('debugIcon.breakpointDisabledForeground'), + borderRadius: '2px', +}); + +/** + * Applies Jasper's breakpoints to a GemStone session and keeps the two in step. + * + * **The durable model is VS Code's own breakpoint list.** GemStone method + * breakpoints are per-gem VM state: they do not survive logout, and a `commit` + * does not persist them (verified against 3.7.5). So the stone can never be the + * record of what the developer wants — only of what one session currently has. + * Keeping `vscode.debug.breakpoints` as the record means VS Code persists + * breakpoints across restarts, and the gutter, the per-breakpoint enable + * checkbox and the built-in Enable/Disable/Remove All commands all drive + * GemStone for free, arriving here as `onDidChangeBreakpoints`. + * + * Step point precision rides on the breakpoint's **column**: a gutter click has + * none and means "the leftmost step point on this line", while an inline + * breakpoint or Jasper's toggle-at-cursor carries the exact column and picks the + * step point nearest it. See `resolveStepPoint`. + * + * A *disabled* breakpoint is applied as set-then-disabled rather than left off + * the stone, so stepping past it is instant to re-arm and the breakpoint + * manager view can show it. `disableBreakAtStepPoint:` is a no-op on a step + * point with no breakpoint, hence the two calls. + */ export class BreakpointManager { - private tracked = new Map(); + /** What we last applied, per method URI — drives decorations and re-apply. */ + private applied = new Map(); + + private _onDidApply = new vscode.EventEmitter(); + /** Fires after breakpoints are pushed to the gem, so views can refresh. */ + readonly onDidApply = this._onDidApply.event; - constructor(private sessionManager: SessionManager) {} + constructor( + private sessionManager: SessionManager, + private stepPoints: StepPointModel, + ) {} register(context: vscode.ExtensionContext): void { context.subscriptions.push( + this._onDidApply, vscode.debug.onDidChangeBreakpoints((e) => this.onBreakpointsChanged(e)), + vscode.window.onDidChangeActiveTextEditor((editor) => { + if (editor) this.refreshDecorations(editor); + }), + vscode.window.onDidChangeVisibleTextEditors((editors) => { + for (const editor of editors) this.refreshDecorations(editor); + }), ); } + // ── Applying ───────────────────────────────────────────── + /** - * Set breakpoints for a method source. Clears any existing breakpoints on - * the method first, then sets the requested ones. - * Returns verified breakpoint locations (line may differ from requested). + * Push every VS Code breakpoint on `uri` to the gem, replacing whatever the + * method had. Returns one verified result per requested line, in order, for + * the debug adapter's `setBreakpoints` response. + * + * `requests` carries the raw line/column pairs. When omitted, they are read + * from `vscode.debug.breakpoints` — the absolute model: whatever is in VS + * Code's list right now is exactly what the method ends up with. */ - setBreakpointsForSource( + applyToUri( session: ActiveSession, uri: vscode.Uri, - lines: number[], + requests?: { line: number; character?: number; enabled: boolean }[], ): VerifiedBreakpoint[] { const method = parseMethodUri(uri); - if (!method || method.diffView) - return lines.map(() => ({ stepPoint: 0, actualLine: 0, verified: false })); + if (!method || method.diffView) { + return (requests ?? []).map(() => ({ stepPoint: 0, actualLine: 0, verified: false })); + } + + const wanted = requests ?? readVsCodeBreakpoints(uri); + // Always clear first: this is an absolute model, and a step point that used + // to hold a breakpoint has to lose it even when nothing replaces it. + // + // This does take out a breakpoint on this method that Jasper did not set — + // one from topaz, say. That is the cost of VS Code's list being the record: + // there is no way to ask GemStone for "the breakpoints other than mine", and + // leaving stale ones behind would be the worse failure, since a breakpoint + // the developer removed would keep stopping execution. try { - // Clear existing breakpoints on this method queries.clearAllBreaks( session, method.className, @@ -49,138 +133,569 @@ export class BreakpointManager { method.environmentId, ); } catch { - /* method may not exist */ + /* the method may no longer exist — nothing to clear */ } - if (lines.length === 0) { - this.tracked.delete(uri.toString()); + if (wanted.length === 0) { + this.applied.delete(uri.toString()); + this.refreshEditorsFor(uri); + this._onDidApply.fire(); return []; } - let source: string; - let sourceOffsets: number[]; - try { - source = queries.getMethodSource( - session, - method.className, - method.isMeta, - method.selector, - method.environmentId, - ); - sourceOffsets = queries.getSourceOffsets( - session, - method.className, - method.isMeta, - method.selector, - method.environmentId, - ); - } catch { - return lines.map(() => ({ stepPoint: 0, actualLine: 0, verified: false })); + const info = this.stepPoints.fetch(session, uri, method); + if (!info) { + return wanted.map((r) => ({ stepPoint: 0, actualLine: r.line, verified: false })); } - const lineOffsets = buildLineOffsets(source); const results: VerifiedBreakpoint[] = []; - const tracked: TrackedBreakpoint[] = []; + // Two requests can land on the same step point — a gutter click and an + // inline breakpoint on the same line, say. The gem has one breakpoint per + // step point, so they collapse, and the step point stays armed if *any* of + // them is enabled. + const byStepPoint = new Map(); + + for (const req of wanted) { + const resolved = resolveStepPoint(info, req.line, req.character); + if (!resolved) { + results.push({ stepPoint: 0, actualLine: req.line, verified: false }); + continue; + } + results.push({ + stepPoint: resolved.stepPoint, + actualLine: resolved.line, + verified: true, + }); + const existing = byStepPoint.get(resolved.stepPoint); + byStepPoint.set(resolved.stepPoint, { + stepPoint: resolved.stepPoint, + offset: resolved.offset, + line: resolved.line, + enabled: (existing?.enabled ?? false) || req.enabled, + }); + } - for (const line of lines) { - const result = mapLineToStepPoint(line, lineOffsets, sourceOffsets); - if (result) { - try { - queries.setBreakAtStepPoint( + const applied: AppliedBreakpoint[] = []; + for (const bp of byStepPoint.values()) { + try { + queries.setBreakAtStepPoint( + session, + method.className, + method.isMeta, + method.selector, + bp.stepPoint, + method.environmentId, + ); + if (!bp.enabled) { + queries.disableBreakAtStepPoint( session, method.className, method.isMeta, method.selector, - result.stepPoint, + bp.stepPoint, method.environmentId, ); - results.push({ - stepPoint: result.stepPoint, - actualLine: result.actualLine, - verified: true, - }); - tracked.push({ stepPoint: result.stepPoint, actualLine: result.actualLine }); - } catch { - results.push({ stepPoint: 0, actualLine: line, verified: false }); } - } else { - results.push({ stepPoint: 0, actualLine: line, verified: false }); + applied.push(bp); + } catch { + // Mark every result that resolved to this step point unverified. + for (const r of results) { + if (r.stepPoint === bp.stepPoint) r.verified = false; + } } } - this.tracked.set(uri.toString(), tracked); + if (applied.length > 0) this.applied.set(uri.toString(), applied); + else this.applied.delete(uri.toString()); + + this.refreshEditorsFor(uri); + this._onDidApply.fire(); return results; } /** - * Called after a method is recompiled — re-applies tracked breakpoints. + * The debug adapter's entry point: apply breakpoints given as lines (and + * optional columns), which is all the Debug Adapter Protocol carries. */ - invalidateForUri(uri: vscode.Uri): void { - const key = uri.toString(); - const existing = this.tracked.get(key); - if (!existing || existing.length === 0) return; + setBreakpointsForSource( + session: ActiveSession, + uri: vscode.Uri, + lines: number[], + columns?: (number | undefined)[], + ): VerifiedBreakpoint[] { + return this.applyToUri( + session, + uri, + lines.map((line, i) => ({ + line, + // DAP columns are 1-based; our resolver takes a 0-based character. + character: columns?.[i] === undefined ? undefined : Math.max(columns[i] - 1, 0), + enabled: true, + })), + ); + } + + /** Re-apply every gemstone breakpoint to `session` — after a login, or on demand. */ + reapplyAll(session: ActiveSession): void { + const uris = new Set(); + for (const bp of vscode.debug.breakpoints) { + if (bp instanceof vscode.SourceBreakpoint && bp.location.uri.scheme === 'gemstone') { + uris.add(bp.location.uri.toString()); + } + } + for (const uriStr of uris) { + this.applyToUri(session, vscode.Uri.parse(uriStr)); + } + } + + // ── Editor commands ────────────────────────────────────── + + /** + * Toggle a breakpoint at the caret's step point. + * + * Adds or removes a *native* VS Code breakpoint rather than tracking one + * privately, so it shows up in the Breakpoints view with its enable checkbox + * and is picked up by Enable/Disable/Remove All like any other. The position + * is the step point's own offset, which is what makes it an inline breakpoint + * VS Code will hand back to us with a column. + */ + toggleAtCursor(editor: vscode.TextEditor): void { + const found = this.stepPointAtCursor(editor); + if (!found) return; + const { info, resolved } = found; + + const existing = this.vsCodeBreakpointFor(editor.document.uri, info, resolved.stepPoint); + if (existing) { + vscode.debug.removeBreakpoints([existing]); + } else { + vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(editor.document.uri, positionOf(editor.document, resolved.offset)), + ), + ]); + } + } + + /** + * Enable or disable the breakpoint at the caret's step point. + * + * VS Code makes `Breakpoint.enabled` read-only, so flipping it means removing + * the breakpoint and adding an equivalent one — carrying the condition, hit + * condition and log message across so an enable/disable round trip doesn't + * quietly discard them. + */ + setEnabledAtCursor(editor: vscode.TextEditor, enabled: boolean): void { + const found = this.stepPointAtCursor(editor); + if (!found) return; + const { info, resolved } = found; + + const existing = this.vsCodeBreakpointFor(editor.document.uri, info, resolved.stepPoint); + if (!existing) { + if (!enabled) return; // nothing there to disable + vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(editor.document.uri, positionOf(editor.document, resolved.offset)), + ), + ]); + return; + } + if (existing.enabled === enabled) return; + replaceEnabled([existing], enabled); + } + + /** + * Toggle the breakpoint at a step point named outright, rather than found from + * the caret — what a click on an inlay hint number or a hover link does. The + * developer pointed at a specific step point, so there is nothing to resolve. + */ + toggleAtStepPoint(uri: vscode.Uri, stepPoint: number): void { + const ctx = this.contextFor(uri); + if (!ctx) return; + const existing = this.vsCodeBreakpointFor(uri, ctx.info, stepPoint); + if (existing) { + vscode.debug.removeBreakpoints([existing]); + return; + } + const at = ctx.info.offsets[stepPoint - 1]; + if (at === undefined) return; + vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint(new vscode.Location(uri, ctx.document.positionAt(at))), + ]); + } + + /** Enable or disable the breakpoint at a named step point. */ + setEnabledAtStepPoint(uri: vscode.Uri, stepPoint: number, enabled: boolean): void { + const ctx = this.contextFor(uri); + if (!ctx) return; + const existing = this.vsCodeBreakpointFor(uri, ctx.info, stepPoint); + if (!existing) { + if (enabled) this.toggleAtStepPoint(uri, stepPoint); + return; + } + if (existing.enabled !== enabled) replaceEnabled([existing], enabled); + } + + /** Clear the breakpoint at a named step point. */ + clearAtStepPoint(uri: vscode.Uri, stepPoint: number): void { + const ctx = this.contextFor(uri); + if (!ctx) return; + const existing = this.vsCodeBreakpointFor(uri, ctx.info, stepPoint); + if (existing) vscode.debug.removeBreakpoints([existing]); + } + + /** + * The open document for `uri` and its step points. Only an *open* document + * will do — these entry points are all driven by a click in one, and the + * document is what turns a step point offset back into a position. + */ + private contextFor( + uri: vscode.Uri, + ): { document: vscode.TextDocument; info: StepPointInfo } | null { + const uriStr = uri.toString(); + const document = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriStr); + if (!document) return null; + const info = this.stepPoints.get(document); + if (!info) return null; + return { document, info }; + } + + /** Clear every breakpoint in the method the caret is in. */ + clearMethodBreakpoints(editor: vscode.TextEditor): void { + const uriStr = editor.document.uri.toString(); + const mine = vscode.debug.breakpoints.filter( + (bp) => bp instanceof vscode.SourceBreakpoint && bp.location.uri.toString() === uriStr, + ); + if (mine.length > 0) vscode.debug.removeBreakpoints(mine); + } + + /** The step point under the caret, with the method's step point info. */ + stepPointAtCursor( + editor: vscode.TextEditor, + ): { info: StepPointInfo; resolved: NonNullable> } | null { + if (editor.document.uri.scheme !== 'gemstone') return null; + if (!this.sessionManager.getSelectedSession()) { + vscode.window.showErrorMessage('No active GemStone session.'); + return null; + } + const info = this.stepPoints.get(editor.document); + if (!info) return null; + if (info.offsets.length === 0) { + vscode.window.showInformationMessage('This method has no step points to break at.'); + return null; + } + const resolved = stepPointAtOffset(info, editor.document.offsetAt(editor.selection.active)); + if (!resolved) return null; + return { info, resolved }; + } + + // ── Session-wide operations ────────────────────────────── + + /** + * Enable or disable every GemStone breakpoint. + * + * Flips Jasper's own breakpoints in VS Code's model first — that's the durable + * record, and it re-applies them through `onDidChangeBreakpoints` — then + * sweeps the gem, which also catches breakpoints Jasper never set (from topaz, + * another tool, or a `halt` in the code). "All" has to mean all of them. + */ + setAllEnabled(enabled: boolean): void { + const mine = gemstoneBreakpoints().filter((bp) => bp.enabled !== enabled); + if (mine.length > 0) replaceEnabled(mine, enabled); const session = this.sessionManager.getSelectedSession(); if (!session) return; + try { + if (enabled) queries.enableAllBreakpoints(session); + else queries.disableAllBreakpoints(session); + } catch (e) { + vscode.window.showErrorMessage( + `Could not ${enabled ? 'enable' : 'disable'} breakpoints: ${message(e)}`, + ); + return; + } + this._onDidApply.fire(); + } - // Get current VS Code breakpoints for this URI - const vsBps = vscode.debug.breakpoints.filter( - (bp) => - bp instanceof vscode.SourceBreakpoint && bp.enabled && bp.location.uri.toString() === key, - ) as vscode.SourceBreakpoint[]; + /** Remove every GemStone breakpoint, in VS Code's model and in the gem. */ + removeAll(): void { + const mine = gemstoneBreakpoints(); + if (mine.length > 0) vscode.debug.removeBreakpoints(mine); - if (vsBps.length > 0) { - const lines = vsBps.map((bp) => bp.location.range.start.line + 1); // VS Code is 0-based - this.setBreakpointsForSource(session, uri, lines); - } else { - this.tracked.delete(key); + const session = this.sessionManager.getSelectedSession(); + if (session) { + try { + queries.removeAllBreakpoints(session); + } catch (e) { + vscode.window.showErrorMessage(`Could not remove breakpoints: ${message(e)}`); + return; + } } + this.applied.clear(); + for (const editor of vscode.window.visibleTextEditors) this.refreshDecorations(editor); + this._onDidApply.fire(); } + // ── Acting on what the gem reports ─────────────────────── + /** - * Called when a session logs out — clear tracking for that session. + * Enable or disable a breakpoint the gem reported. + * + * Prefers to flip the *VS Code* breakpoint behind it, when there is one: that + * is the durable record, so flipping the gem alone would be undone the next + * time the method's breakpoints were re-applied. A breakpoint Jasper didn't + * set has no VS Code counterpart, so it is flipped in the gem by OOP — which + * also means it reverts at logout, as any gem-only breakpoint does. */ + setEnabledForStoneBreakpoint(bp: GemStoneBreakpoint, enabled: boolean): void { + const owned = this.ownedBreakpoint(bp); + if (owned) { + if (owned.enabled !== enabled) replaceEnabled([owned], enabled); + return; + } + this.byOop(bp, enabled ? 'setBreakAtStepPoint:' : 'disableBreakAtStepPoint:'); + } + + /** Remove a breakpoint the gem reported — from VS Code's list when it's ours. */ + removeStoneBreakpoint(bp: GemStoneBreakpoint): void { + const owned = this.ownedBreakpoint(bp); + if (owned) { + vscode.debug.removeBreakpoints([owned]); + return; + } + this.byOop(bp, 'clearBreakAtStepPoint:'); + } + + /** + * The VS Code breakpoint behind a gem-reported one, or undefined when Jasper + * didn't set it. Matched on the method coordinates we applied plus the step + * point, rather than on the gem's dictionary/category strings, so it still + * matches when the same class name is bound in more than one dictionary. + */ + private ownedBreakpoint(bp: GemStoneBreakpoint): vscode.SourceBreakpoint | undefined { + const session = this.sessionManager.getSelectedSession(); + if (!session) return undefined; + + for (const [uriStr, applied] of this.applied) { + if (!applied.some((a) => a.stepPoint === bp.stepPoint)) continue; + const uri = vscode.Uri.parse(uriStr); + const method = parseMethodUri(uri); + if (!method) continue; + if ( + method.className !== bp.className || + method.isMeta !== bp.isMeta || + method.selector !== bp.selector || + method.environmentId !== bp.environmentId + ) { + continue; + } + const info = this.stepPoints.fetch(session, uri, method); + if (!info) continue; + return this.vsCodeBreakpointFor(uri, info, bp.stepPoint); + } + return undefined; + } + + private byOop( + bp: GemStoneBreakpoint, + op: 'setBreakAtStepPoint:' | 'disableBreakAtStepPoint:' | 'clearBreakAtStepPoint:', + ): void { + const session = this.sessionManager.getSelectedSession(); + if (!session) return; + try { + queries.breakpointByOop(session, bp.methodOop, op, bp.stepPoint); + } catch (e) { + vscode.window.showErrorMessage(`Breakpoint operation failed: ${message(e)}`); + return; + } + this._onDidApply.fire(); + } + + // ── Lifecycle ──────────────────────────────────────────── + + /** + * Called after a method is recompiled. Recompiling replaces the `GsNMethod`, + * so the gem's breakpoints on the old one are gone and its step point offsets + * may have moved — drop the cache and re-apply from VS Code's model. + */ + invalidateForUri(uri: vscode.Uri): void { + this.stepPoints.invalidate(uri); + this.applied.delete(uri.toString()); + + const session = this.sessionManager.getSelectedSession(); + if (!session) return; + if (readVsCodeBreakpoints(uri).length === 0) { + this.refreshEditorsFor(uri); + return; + } + this.applyToUri(session, uri); + } + + /** Called when a session logs out — its gem, and our view of it, are gone. */ clearAllForSession(sessionId: number): void { - // Remove tracked breakpoints whose URI belongs to this session - for (const [key] of this.tracked) { - if (key.startsWith(`gemstone://${sessionId}/`)) { - this.tracked.delete(key); + const prefix = `gemstone://${sessionId}/`; + for (const key of [...this.applied.keys()]) { + if (key.startsWith(prefix)) this.applied.delete(key); + } + this.stepPoints.invalidateSession(sessionId); + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString().startsWith(prefix)) { + editor.setDecorations(enabledDecoration, []); + editor.setDecorations(disabledDecoration, []); } } + this._onDidApply.fire(); + } + + // ── Decorations ────────────────────────────────────────── + + /** + * Mark the exact token each breakpoint sits on. The gutter dot already says + * "this line has a breakpoint"; a Smalltalk line routinely holds several step + * points, so the token marker is what says *which one*. + */ + refreshDecorations(editor: vscode.TextEditor): void { + if (editor.document.uri.scheme !== 'gemstone') return; + + const applied = this.applied.get(editor.document.uri.toString()); + if (!applied || applied.length === 0) { + editor.setDecorations(enabledDecoration, []); + editor.setDecorations(disabledDecoration, []); + return; + } + + const info = this.stepPoints.get(editor.document); + if (!info) return; + + const on: vscode.Range[] = []; + const off: vscode.Range[] = []; + for (const bp of applied) { + for (const r of rangesForStepPoint(info, bp.stepPoint)) { + const range = new vscode.Range( + positionOf(editor.document, r.start), + positionOf(editor.document, r.end), + ); + (bp.enabled ? on : off).push(range); + } + } + editor.setDecorations(enabledDecoration, on); + editor.setDecorations(disabledDecoration, off); + } + + /** What we last applied to `uri`, for the breakpoint manager view. */ + appliedFor(uri: vscode.Uri): AppliedBreakpoint[] { + return this.applied.get(uri.toString()) ?? []; + } + + // ── Internals ──────────────────────────────────────────── + + /** + * The VS Code breakpoint on `uri` that resolves to `stepPoint`. Resolution + * runs through the same rule the applier uses, so a gutter breakpoint on a + * line whose leftmost step point is the caret's is recognised as *the same + * breakpoint* — otherwise toggling at the caret would stack a second + * breakpoint on a step point that already has one. + */ + private vsCodeBreakpointFor( + uri: vscode.Uri, + info: StepPointInfo, + stepPoint: number, + ): vscode.SourceBreakpoint | undefined { + const uriStr = uri.toString(); + for (const bp of vscode.debug.breakpoints) { + if (!(bp instanceof vscode.SourceBreakpoint)) continue; + if (bp.location.uri.toString() !== uriStr) continue; + const start = bp.location.range.start; + const resolved = resolveStepPoint( + info, + start.line + 1, + start.character === 0 ? undefined : start.character, + ); + if (resolved?.stepPoint === stepPoint) return bp; + } + return undefined; } private onBreakpointsChanged(event: vscode.BreakpointsChangeEvent): void { const session = this.sessionManager.getSelectedSession(); if (!session) return; - // Collect all gemstone:// URIs that were affected - const affectedUris = new Set(); - + const affected = new Set(); for (const bp of [...event.added, ...event.removed, ...event.changed]) { - if (bp instanceof vscode.SourceBreakpoint) { - const uri = bp.location.uri; - if (uri.scheme === 'gemstone') { - affectedUris.add(uri.toString()); - } + if (bp instanceof vscode.SourceBreakpoint && bp.location.uri.scheme === 'gemstone') { + affected.add(bp.location.uri.toString()); } } + for (const uriStr of affected) { + this.applyToUri(session, vscode.Uri.parse(uriStr)); + } + } - // For each affected URI, recompute all breakpoints (absolute model) - for (const uriStr of affectedUris) { - const uri = vscode.Uri.parse(uriStr); - const allBps = vscode.debug.breakpoints.filter( - (bp) => - bp instanceof vscode.SourceBreakpoint && - bp.enabled && - bp.location.uri.toString() === uriStr, - ) as vscode.SourceBreakpoint[]; - - const lines = allBps.map((bp) => bp.location.range.start.line + 1); // 0-based → 1-based - this.setBreakpointsForSource(session, uri, lines); + private refreshEditorsFor(uri: vscode.Uri): void { + const uriStr = uri.toString(); + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.toString() === uriStr) this.refreshDecorations(editor); } } } +// ── Helpers ──────────────────────────────────────────────── + +/** Every VS Code source breakpoint on a `gemstone://` URI. */ +export function gemstoneBreakpoints(): vscode.SourceBreakpoint[] { + return vscode.debug.breakpoints.filter( + (bp) => bp instanceof vscode.SourceBreakpoint && bp.location.uri.scheme === 'gemstone', + ) as vscode.SourceBreakpoint[]; +} + +/** + * VS Code's breakpoints on one method, as apply requests. Lines come out 1-based + * (VS Code counts from 0) and a column-0 breakpoint reports no column at all, so + * a gutter click stays distinguishable from an inline breakpoint in column 0. + */ +function readVsCodeBreakpoints( + uri: vscode.Uri, +): { line: number; character?: number; enabled: boolean }[] { + const uriStr = uri.toString(); + return gemstoneBreakpoints() + .filter((bp) => bp.location.uri.toString() === uriStr) + .map((bp) => { + const start = bp.location.range.start; + return { + line: start.line + 1, + character: start.character === 0 ? undefined : start.character, + enabled: bp.enabled, + }; + }); +} + +/** + * Re-add `breakpoints` with a new enabled flag. `Breakpoint.enabled` is + * read-only in the VS Code API, so this is the only way to flip it; condition, + * hit condition and log message ride along so they survive the round trip. + */ +function replaceEnabled(breakpoints: vscode.SourceBreakpoint[], enabled: boolean): void { + const replacements = breakpoints.map( + (bp) => + new vscode.SourceBreakpoint( + bp.location, + enabled, + bp.condition, + bp.hitCondition, + bp.logMessage, + ), + ); + vscode.debug.removeBreakpoints(breakpoints); + vscode.debug.addBreakpoints(replacements); +} + +function positionOf(document: vscode.TextDocument, offset: number): vscode.Position { + return document.positionAt(offset); +} + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + /** * Build a table of character offsets for the start of each line (1-based). * lineOffsets[1] = 0 (first line starts at offset 0) @@ -201,7 +716,7 @@ export function buildLineOffsets(source: string): number[] { /** * Map a precise 0-based cursor offset to a step point — column-aware, for "Run to - * Cursor" (#2). Prefers the step point on the cursor's OWN line that is nearest the + * Cursor". Prefers the step point on the cursor's OWN line that is nearest the * cursor column, so a cursor on `asInteger` in `x := (...) asInteger` breaks at * `asInteger` (not the leftmost `:=` store), and a cursor inside a one-line block * (`self do: [:e | body ]`) breaks INSIDE the block (not at the `do:` send). When diff --git a/client/src/breakpointTreeProvider.ts b/client/src/breakpointTreeProvider.ts new file mode 100644 index 00000000..cdda7758 --- /dev/null +++ b/client/src/breakpointTreeProvider.ts @@ -0,0 +1,307 @@ +import * as vscode from 'vscode'; +import { SessionManager } from './sessionManager'; +import { buildMethodUri } from './gemstoneFileSystemProvider'; +import * as queries from './browserQueries'; +import { GemStoneBreakpoint } from './browserQueries'; +import { BreakpointManager } from './breakpointManager'; + +/** A class (or metaclass) heading, or one breakpoint under it. */ +export type BreakpointNode = + | { kind: 'class'; className: string; isMeta: boolean; breakpoints: GemStoneBreakpoint[] } + | { kind: 'breakpoint'; bp: GemStoneBreakpoint } + | { kind: 'notice'; text: string; icon?: string }; + +/** Label for a class heading — `Foo class` for the metaclass, as Smalltalk writes it. */ +export function classLabel(className: string, isMeta: boolean): string { + if (className === '') return '(executed code)'; + return isMeta ? `${className} class` : className; +} + +/** + * Group the gem's flat breakpoint list into class headings, sorted the way a + * developer scans for one: classes alphabetically, and within a class by + * selector then step point. Instance side sorts before class side for the same + * name, so the two halves of a class stay adjacent rather than interleaving. + * + * Breakpoints in executed code (a doit) carry no class, and collect under a + * single heading at the end — they can't be navigated to, but they still have to + * be visible and clearable, since a stray one silently stops every evaluation. + */ +export function groupBreakpoints(breakpoints: GemStoneBreakpoint[]): BreakpointNode[] { + // Keyed by class *and* side, with the parts carried in the value rather than + // encoded into the key and split back out — a class name and a boolean have no + // separator that is obviously safe, and there is no need to invent one. + const groups = new Map< + string, + { className: string; isMeta: boolean; list: GemStoneBreakpoint[] } + >(); + for (const bp of breakpoints) { + const key = `${bp.isMeta ? 'meta' : 'inst'}:${bp.className}`; + const group = groups.get(key); + if (group) group.list.push(bp); + else groups.set(key, { className: bp.className, isMeta: bp.isMeta, list: [bp] }); + } + + const nodes: BreakpointNode[] = []; + for (const { className, isMeta, list } of groups.values()) { + nodes.push({ + kind: 'class', + className, + isMeta, + breakpoints: [...list].sort( + (a, b) => a.selector.localeCompare(b.selector) || a.stepPoint - b.stepPoint, + ), + }); + } + + nodes.sort((a, b) => { + if (a.kind !== 'class' || b.kind !== 'class') return 0; + // Doits last — they're the odd ones out and nothing navigates to them. + if (a.className === '') return b.className === '' ? 0 : 1; + if (b.className === '') return -1; + return a.className.localeCompare(b.className) || Number(a.isMeta) - Number(b.isMeta); + }); + return nodes; +} + +/** + * The breakpoint manager: every breakpoint in the current session's gem, with + * the operations that act on all of them at once. + * + * Deliberately shows the **gem's** breakpoints rather than mirroring VS Code's + * Breakpoints view. The two answer different questions. VS Code's view is + * Jasper's durable model — what you asked for, per file, surviving restarts. This + * one is ground truth for the session you are actually debugging: it resolves + * each breakpoint to the step point it really landed on, shows which are + * disabled, and surfaces breakpoints Jasper never set — from topaz, another + * tool, or a `halt` left in the code — which the VS Code view cannot know about + * and which are otherwise invisible right up until execution stops on one. + * + * Enable/disable is a checkbox per row, matching how VS Code's own Breakpoints + * view works; the manager routes each flip to VS Code's model or straight to the + * gem depending on whether Jasper owns that breakpoint. + */ +export class BreakpointTreeProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData = new vscode.EventEmitter(); + readonly onDidChangeTreeData = this._onDidChangeTreeData.event; + + /** Last fetch, so a checkbox flip doesn't have to re-query to find its row. */ + private lastFetch: GemStoneBreakpoint[] = []; + + /** Pending coalesced refresh — see `refresh`. */ + private refreshTimer: ReturnType | undefined; + + constructor( + private sessionManager: SessionManager, + private breakpoints: BreakpointManager, + ) {} + + register(context: vscode.ExtensionContext): void { + const view = vscode.window.createTreeView('gemstoneBreakpoints', { + treeDataProvider: this, + showCollapseAll: true, + }); + context.subscriptions.push( + view, + this._onDidChangeTreeData, + // A pending refresh must not outlive the view it would redraw. + { + dispose: () => { + if (this.refreshTimer) clearTimeout(this.refreshTimer); + }, + }, + view.onDidChangeCheckboxState((e) => { + for (const [node, state] of e.items) { + if (node.kind !== 'breakpoint') continue; + this.breakpoints.setEnabledForStoneBreakpoint( + node.bp, + state === vscode.TreeItemCheckboxState.Checked, + ); + } + this.refresh(); + }), + // Re-read the gem whenever breakpoints are pushed to it, so the view is + // never stale after a gutter click or a global enable/disable. + this.breakpoints.onDidApply(() => this.refresh()), + this.sessionManager.onDidChangeSelection(() => this.refresh()), + ); + } + + /** + * Redraw the view, coalescing bursts. + * + * Every redraw costs a GCI round trip, and the events that trigger one arrive + * in batches — the manager applies breakpoints one method at a time, so + * changing five methods (or re-applying them all after a login) would + * otherwise mean five queries for the same answer. One tick's delay collapses + * a batch into a single fetch and is imperceptible in a tree view. + */ + refresh(): void { + if (this.refreshTimer) clearTimeout(this.refreshTimer); + this.refreshTimer = setTimeout(() => { + this.refreshTimer = undefined; + this._onDidChangeTreeData.fire(); + }, 50); + } + + /** Redraw now, for a test that must not wait on the coalescing timer. */ + refreshNow(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = undefined; + } + this._onDidChangeTreeData.fire(); + } + + getTreeItem(element: BreakpointNode): vscode.TreeItem { + if (element.kind === 'notice') { + const item = new vscode.TreeItem(element.text); + item.iconPath = new vscode.ThemeIcon(element.icon ?? 'info'); + item.contextValue = 'gemstoneBreakpointNotice'; + return item; + } + + if (element.kind === 'class') { + const item = new vscode.TreeItem( + classLabel(element.className, element.isMeta), + vscode.TreeItemCollapsibleState.Expanded, + ); + const n = element.breakpoints.length; + const disabled = element.breakpoints.filter((b) => b.disabled).length; + item.description = disabled > 0 ? `${n} · ${disabled} disabled` : `${n}`; + item.iconPath = new vscode.ThemeIcon(element.className === '' ? 'terminal' : 'symbol-class'); + item.contextValue = 'gemstoneBreakpointClass'; + return item; + } + + const bp = element.bp; + const item = new vscode.TreeItem(bp.selector === '' ? '(executed code)' : bp.selector); + item.description = `@ ${bp.stepPoint}`; + item.checkboxState = bp.disabled + ? vscode.TreeItemCheckboxState.Unchecked + : vscode.TreeItemCheckboxState.Checked; + item.tooltip = breakpointTooltip(bp); + // Only a real method can be opened; a doit's source is long gone. + item.contextValue = bp.selector === '' ? 'gemstoneBreakpointDoit' : 'gemstoneBreakpoint'; + if (bp.selector !== '') { + item.command = { + title: 'Reveal in source', + command: 'gemstone.breakpoints.reveal', + arguments: [element], + }; + } + return item; + } + + getChildren(element?: BreakpointNode): BreakpointNode[] { + if (element?.kind === 'class') { + return element.breakpoints.map((bp) => ({ kind: 'breakpoint' as const, bp })); + } + if (element) return []; + + const session = this.sessionManager.getSelectedSession(); + if (!session) { + this.lastFetch = []; + return [{ kind: 'notice', text: 'Log in to a GemStone session to see breakpoints.' }]; + } + + try { + this.lastFetch = queries.getAllBreakpoints(session); + } catch (e) { + this.lastFetch = []; + return [ + { + kind: 'notice', + text: `Could not read breakpoints: ${e instanceof Error ? e.message : String(e)}`, + icon: 'warning', + }, + ]; + } + + if (this.lastFetch.length === 0) { + return [ + { + kind: 'notice', + text: 'No breakpoints set in this session.', + }, + ]; + } + return groupBreakpoints(this.lastFetch); + } + + /** Every breakpoint the last fetch saw — for commands that act on all of them. */ + all(): GemStoneBreakpoint[] { + return this.lastFetch; + } +} + +function breakpointTooltip(bp: GemStoneBreakpoint): vscode.MarkdownString { + const md = new vscode.MarkdownString(); + const where = + bp.className === '' + ? 'executed code' + : `${classLabel(bp.className, bp.isMeta)} >> ${bp.selector}`; + md.appendMarkdown(`**${where}**\n\nStep point ${bp.stepPoint}`); + if (bp.disabled) md.appendMarkdown(' — disabled'); + if (bp.dictName) md.appendMarkdown(`\n\nDictionary: ${bp.dictName}`); + if (bp.environmentId > 0) md.appendMarkdown(`\n\nEnvironment: ${bp.environmentId}`); + return md; +} + +/** + * Open the method a breakpoint is in and put the caret on the step point. + * + * The step point is resolved from the *stone's* offsets for the method just + * opened, rather than trusting the line the gem reported, so the selection lands + * on the token that will actually break. + */ +export async function revealBreakpoint( + sessionManager: SessionManager, + node?: BreakpointNode, +): Promise { + if (node?.kind !== 'breakpoint') return; + const bp = node.bp; + if (bp.selector === '' || bp.className === '') return; + + const session = sessionManager.getSelectedSession(); + if (!session) return; + + const uri = buildMethodUri({ + kind: 'method', + sessionId: session.id, + dictName: bp.dictName || 'Globals', + className: bp.className, + isMeta: bp.isMeta, + // The category only labels the URI path; the file system provider resolves + // the method by class and selector. 'other' keeps the path well-formed when + // the gem couldn't name a category (an inherited or removed method). + category: bp.category || 'other', + selector: bp.selector, + environmentId: bp.environmentId, + }); + + const document = await vscode.workspace.openTextDocument(uri); + const editor = await vscode.window.showTextDocument(document, { preview: false }); + + let offsets: number[]; + try { + offsets = queries.getSourceOffsets( + session, + bp.className, + bp.isMeta, + bp.selector, + bp.environmentId, + ); + } catch { + return; + } + const at = offsets[bp.stepPoint - 1]; + if (at === undefined) return; + + const position = document.positionAt(at - 1); // _sourceOffsets is 1-based + editor.selection = new vscode.Selection(position, position); + editor.revealRange( + new vscode.Range(position, position), + vscode.TextEditorRevealType.InCenterIfOutsideViewport, + ); +} diff --git a/client/src/browserQueries.ts b/client/src/browserQueries.ts index ee527fc7..f047c63e 100644 --- a/client/src/browserQueries.ts +++ b/client/src/browserQueries.ts @@ -242,6 +242,15 @@ import { moveDictionaryDown as sharedMoveDictionaryDown } from './queries/moveDi import { setBreakAtStepPoint as sharedSetBreakAtStepPoint } from './queries/setBreakAtStepPoint'; import { clearBreakAtStepPoint as sharedClearBreakAtStepPoint } from './queries/clearBreakAtStepPoint'; import { clearAllBreaks as sharedClearAllBreaks } from './queries/clearAllBreaks'; +import { disableBreakAtStepPoint as sharedDisableBreakAtStepPoint } from './queries/disableBreakAtStepPoint'; +import { getAllBreakpoints as sharedGetAllBreakpoints } from './queries/getAllBreakpoints'; +import { + enableAllBreakpoints as sharedEnableAllBreakpoints, + disableAllBreakpoints as sharedDisableAllBreakpoints, + removeAllBreakpoints as sharedRemoveAllBreakpoints, + hasBreakpoints as sharedHasBreakpoints, + breakpointByOop as sharedBreakpointByOop, +} from './queries/breakpointGlobals'; // Re-export shared types so existing callers (extension.ts, systemBrowser.ts, etc.) // can continue to import them from './browserQueries'. @@ -256,6 +265,7 @@ export type { DescendantClass } from './refactoring/queries/getClassDescendantNa export type { MoveArgs } from './refactoring/queries/previewInstVarStructure'; export type { MethodEntry } from './queries/getMethodList'; export type { StepPointSelectorInfo } from './queries/getStepPointSelectorRanges'; +export type { GemStoneBreakpoint } from './queries/getAllBreakpoints'; export type { MethodSearchResult } from './queries/methodSearch'; export type { RowanProject, RowanProjectList } from './queries/rowan/listRowanProjects'; export type { RowanExportResult } from './queries/rowan/exportRowanProject'; @@ -2211,3 +2221,52 @@ export function clearAllBreaks( dict, ); } + +export function disableBreakAtStepPoint( + session: ActiveSession, + className: string, + isMeta: boolean, + selector: string, + stepPoint: number, + environmentId: number = 0, + dict?: number | string, +): string { + return sharedDisableBreakAtStepPoint( + defaultQueryExecutorUsing(session), + className, + isMeta, + selector, + stepPoint, + environmentId, + dict, + ); +} + +export function getAllBreakpoints(session: ActiveSession) { + return sharedGetAllBreakpoints(defaultQueryExecutorUsing(session)); +} + +export function enableAllBreakpoints(session: ActiveSession): string { + return sharedEnableAllBreakpoints(defaultQueryExecutorUsing(session)); +} + +export function disableAllBreakpoints(session: ActiveSession): string { + return sharedDisableAllBreakpoints(defaultQueryExecutorUsing(session)); +} + +export function removeAllBreakpoints(session: ActiveSession): string { + return sharedRemoveAllBreakpoints(defaultQueryExecutorUsing(session)); +} + +export function hasBreakpoints(session: ActiveSession): boolean { + return sharedHasBreakpoints(defaultQueryExecutorUsing(session)); +} + +export function breakpointByOop( + session: ActiveSession, + methodOop: string, + op: 'setBreakAtStepPoint:' | 'disableBreakAtStepPoint:' | 'clearBreakAtStepPoint:', + stepPoint: number, +): string { + return sharedBreakpointByOop(defaultQueryExecutorUsing(session), methodOop, op, stepPoint); +} diff --git a/client/src/extension.ts b/client/src/extension.ts index 42f7f786..b6dfc2ed 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -116,7 +116,10 @@ import { GemStoneDefinitionProvider } from './gemstoneDefinitionProvider'; import { GemStoneHoverProvider } from './gemstoneHoverProvider'; import { GemStoneCompletionProvider } from './gemstoneCompletionProvider'; import { BreakpointManager } from './breakpointManager'; -import { SelectorBreakpointManager } from './selectorBreakpointManager'; +import { StepPointModel } from './stepPointModel'; +import { StepPointHintsProvider } from './stepPointHints'; +import { StepPointHoverProvider } from './stepPointHover'; +import { BreakpointTreeProvider, BreakpointNode, revealBreakpoint } from './breakpointTreeProvider'; import { SunitTestController } from './sunitTestController'; import { GrailNotebookController } from './grailNotebookController'; import { SmalltalkNotebookController } from './smalltalkNotebookController'; @@ -889,11 +892,25 @@ export function activate(context: vscode.ExtensionContext) { ); // ── Breakpoints + Debugger ─────────────────────────────── - const breakpointManager = new BreakpointManager(sessionManager); + const stepPointModel = new StepPointModel(sessionManager); + const breakpointManager = new BreakpointManager(sessionManager, stepPointModel); breakpointManager.register(context); - const selectorBreakpointManager = new SelectorBreakpointManager(sessionManager); - selectorBreakpointManager.register(context); + const stepPointHints = new StepPointHintsProvider(stepPointModel); + stepPointHints.register(context); + + const breakpointTree = new BreakpointTreeProvider(sessionManager, breakpointManager); + breakpointTree.register(context); + + context.subscriptions.push( + vscode.languages.registerHoverProvider( + [{ scheme: 'gemstone' }], + new StepPointHoverProvider(stepPointModel, breakpointManager), + ), + // Step point offsets come from the stone, so they only line up with a saved + // buffer — redraw the numbers once an edit is saved or reverted. + vscode.workspace.onDidSaveTextDocument(() => stepPointHints.refresh()), + ); // Re-apply breakpoints and refresh browser method list after method recompilation context.subscriptions.push( @@ -901,7 +918,7 @@ export function activate(context: vscode.ExtensionContext) { for (const event of events) { if (event.type === vscode.FileChangeType.Changed) { breakpointManager.invalidateForUri(event.uri); - selectorBreakpointManager.invalidateForUri(event.uri); + stepPointHints.refresh(); const uri = event.uri; if (uri.scheme === 'gemstone') { @@ -2054,7 +2071,7 @@ export function activate(context: vscode.ExtensionContext) { treeProvider.refresh(); inspectorProvider.removeSessionItems(session.id); breakpointManager.clearAllForSession(session.id); - selectorBreakpointManager.clearAllForSession(session.id); + stepPointHints.refresh(); vscode.window.showInformationMessage(`Session ${session.id}: Logged out.`); }, ), @@ -2604,10 +2621,101 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.executeCommand('gemstone.openDocument', uri); }), - vscode.commands.registerCommand('gemstone.toggleSelectorBreakpoint', () => { + vscode.commands.registerCommand('gemstone.breakpoints.toggleAtCursor', () => { const editor = vscode.window.activeTextEditor; - if (!editor) return; - selectorBreakpointManager.toggleBreakpointAtCursor(editor); + if (editor) breakpointManager.toggleAtCursor(editor); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.enableAtCursor', () => { + const editor = vscode.window.activeTextEditor; + if (editor) breakpointManager.setEnabledAtCursor(editor, true); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.disableAtCursor', () => { + const editor = vscode.window.activeTextEditor; + if (editor) breakpointManager.setEnabledAtCursor(editor, false); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.clearMethod', () => { + const editor = vscode.window.activeTextEditor; + if (editor) breakpointManager.clearMethodBreakpoints(editor); + }), + + // The step-point commands take their target from the click that fired them — + // an inlay hint number or a hover link — rather than from the caret, so they + // act on the step point the developer actually pointed at. + vscode.commands.registerCommand( + 'gemstone.breakpoints.toggleAtStepPoint', + (arg: { uri: string; stepPoint: number }) => + breakpointManager.toggleAtStepPoint(vscode.Uri.parse(arg.uri), arg.stepPoint), + ), + + vscode.commands.registerCommand( + 'gemstone.breakpoints.enableAtStepPoint', + (arg: { uri: string; stepPoint: number }) => + breakpointManager.setEnabledAtStepPoint(vscode.Uri.parse(arg.uri), arg.stepPoint, true), + ), + + vscode.commands.registerCommand( + 'gemstone.breakpoints.disableAtStepPoint', + (arg: { uri: string; stepPoint: number }) => + breakpointManager.setEnabledAtStepPoint(vscode.Uri.parse(arg.uri), arg.stepPoint, false), + ), + + vscode.commands.registerCommand( + 'gemstone.breakpoints.clearAtStepPoint', + (arg: { uri: string; stepPoint: number }) => + breakpointManager.clearAtStepPoint(vscode.Uri.parse(arg.uri), arg.stepPoint), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.enableAll', () => + breakpointManager.setAllEnabled(true), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.disableAll', () => + breakpointManager.setAllEnabled(false), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.removeAll', () => + breakpointManager.removeAll(), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.reapply', () => { + const session = sessionManager.getSelectedSession(); + if (!session) { + vscode.window.showErrorMessage('No active GemStone session.'); + return; + } + breakpointManager.reapplyAll(session); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.refresh', () => breakpointTree.refresh()), + + vscode.commands.registerCommand('gemstone.breakpoints.toggleStepPoints', () => + stepPointHints.toggle(), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.reveal', (node?: BreakpointNode) => + revealBreakpoint(sessionManager, node), + ), + + vscode.commands.registerCommand('gemstone.breakpoints.remove', (node?: BreakpointNode) => { + if (node?.kind === 'breakpoint') breakpointManager.removeStoneBreakpoint(node.bp); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.enable', (node?: BreakpointNode) => { + if (node?.kind === 'breakpoint') + breakpointManager.setEnabledForStoneBreakpoint(node.bp, true); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.disable', (node?: BreakpointNode) => { + if (node?.kind === 'breakpoint') + breakpointManager.setEnabledForStoneBreakpoint(node.bp, false); + }), + + vscode.commands.registerCommand('gemstone.breakpoints.clearClass', (node?: BreakpointNode) => { + if (node?.kind !== 'class') return; + for (const bp of node.breakpoints) breakpointManager.removeStoneBreakpoint(bp); }), vscode.commands.registerCommand('gemstone.findClass', async () => { diff --git a/client/src/gemstoneDebugSession.ts b/client/src/gemstoneDebugSession.ts index 66d65e79..7d20df65 100644 --- a/client/src/gemstoneDebugSession.ts +++ b/client/src/gemstoneDebugSession.ts @@ -156,6 +156,10 @@ export class GemStoneDebugSession extends DebugSession { } const requestedLines = args.breakpoints.map((bp) => bp.line); + // DAP carries an optional column for an inline breakpoint; forwarding it is what + // lets a breakpoint mid-line resolve to the step point the developer clicked + // rather than the leftmost one on the line. + const requestedColumns = args.breakpoints.map((bp) => bp.column); // Try to resolve from source path (gemstone:// URI) if available if (args.source.path && this.breakpointManager) { @@ -178,6 +182,7 @@ export class GemStoneDebugSession extends DebugSession { this.session, actualUri, requestedLines, + requestedColumns, ); for (let i = 0; i < results.length; i++) { breakpoints.push({ diff --git a/client/src/queries/__tests__/breakpointQueries.test.ts b/client/src/queries/__tests__/breakpointQueries.test.ts new file mode 100644 index 00000000..88772681 --- /dev/null +++ b/client/src/queries/__tests__/breakpointQueries.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect, vi } from 'vitest'; +import { QueryExecutor } from '../types'; + +import { disableBreakAtStepPoint } from '../disableBreakAtStepPoint'; +import { getAllBreakpoints } from '../getAllBreakpoints'; +import { + enableAllBreakpoints, + disableAllBreakpoints, + removeAllBreakpoints, + hasBreakpoints, + breakpointByOop, +} from '../breakpointGlobals'; + +describe('disableBreakAtStepPoint', () => { + it('sends GsNMethod>>disableBreakAtStepPoint: to the compiled method', () => { + const execute = vi.fn(() => 'ok'); + disableBreakAtStepPoint(execute, 'Account', false, 'balance', 7); + const code = execute.mock.calls[0][0]; + expect(code).toContain("compiledMethodAt: #'balance'"); + expect(code).toContain('disableBreakAtStepPoint: 7'); + }); + + it('targets the metaclass for a class-side method', () => { + const execute = vi.fn(() => 'ok'); + disableBreakAtStepPoint(execute, 'Account', true, 'new', 1); + expect(execute.mock.calls[0][0]).toContain('Account class compiledMethodAt:'); + }); + + it('passes the environment id through', () => { + const execute = vi.fn(() => 'ok'); + disableBreakAtStepPoint(execute, 'Account', false, 'balance', 2, 3); + expect(execute.mock.calls[0][0]).toContain('environmentId: 3'); + }); +}); + +describe('session-wide breakpoint operations', () => { + it('enable-all uses the gem primitive, not a loop over our own list', () => { + const execute = vi.fn(() => 'ok'); + enableAllBreakpoints(execute); + expect(execute.mock.calls[0][0]).toContain('GsNMethod _enableAllBreaks'); + }); + + it('disable-all keeps the breakpoints, rather than deleting them', () => { + const execute = vi.fn(() => 'ok'); + disableAllBreakpoints(execute); + const code = execute.mock.calls[0][0]; + expect(code).toContain('GsNMethod _disableAllBreaks'); + expect(code).not.toContain('_deleteAllBreaks'); + }); + + it('remove-all deletes them', () => { + const execute = vi.fn(() => 'ok'); + removeAllBreakpoints(execute); + expect(execute.mock.calls[0][0]).toContain('GsNMethod _deleteAllBreaks'); + }); + + it('hasBreakpoints reads the primitive as a boolean', () => { + expect(hasBreakpoints(vi.fn(() => 'true'))).toBe(true); + expect(hasBreakpoints(vi.fn(() => 'false'))).toBe(false); + }); + + it('hasBreakpoints tolerates trailing whitespace from the fetch', () => { + expect(hasBreakpoints(vi.fn(() => 'true\n'))).toBe(true); + }); +}); + +describe('breakpointByOop', () => { + it('reaches a method by OOP, for a doit that has no class or selector', () => { + const execute = vi.fn(() => 'ok'); + breakpointByOop(execute, '405428225', 'clearBreakAtStepPoint:', 4); + const code = execute.mock.calls[0][0]; + expect(code).toContain('Object _objectForOop: 405428225'); + expect(code).toContain('clearBreakAtStepPoint: 4'); + }); +}); + +describe('getAllBreakpoints', () => { + /** One row exactly as the query's Smalltalk emits it (verified on 3.7.5). */ + const row = (over: Partial> = {}) => { + const f = { + breakNumber: '1', + className: 'Account', + isMeta: 'false', + selector: 'balance', + stepPoint: '3', + disabled: 'false', + environmentId: '0', + methodOop: '405428225', + dictName: 'Globals', + category: 'accessing', + ...over, + }; + return [ + f.breakNumber, + f.className, + f.isMeta, + f.selector, + f.stepPoint, + f.disabled, + f.environmentId, + f.methodOop, + f.dictName, + f.category, + ].join('\t'); + }; + + it('lets the kernel decode its own primitive, whose stride is version-dependent', () => { + // _allMethodBreakpoints has 3-field tuples on 3.6.2 and 4-field on 3.7.5, + // so hand-decoding it runs off the end of the array on the older release. + const execute = vi.fn(() => ''); + getAllBreakpoints(execute); + const code = execute.mock.calls[0][0]; + expect(code).toContain('GsNMethod _breakReport: true'); + expect(code).not.toContain('_allMethodBreakpoints'); + }); + + it('skips breakpoints left on a superseded version of a method', () => { + const execute = vi.fn(() => ''); + getAllBreakpoints(execute); + // Identity against the currently installed method is what tells a live + // breakpoint from a ghost a recompile left behind. + expect(execute.mock.calls[0][0]).toContain('compiledMethodAt: sel environmentId:'); + }); + + it('resolves the dictionary by class identity, not by name', () => { + const execute = vi.fn(() => ''); + getAllBreakpoints(execute); + // `== base` is what makes a name shadowed in two dictionaries resolve to the + // dictionary actually holding this class. + expect(execute.mock.calls[0][0]).toContain('== base'); + }); + + it('parses a breakpoint row', () => { + const result = getAllBreakpoints(vi.fn(() => row() + '\n')); + expect(result).toEqual([ + { + breakNumber: 1, + className: 'Account', + isMeta: false, + selector: 'balance', + stepPoint: 3, + disabled: false, + environmentId: 0, + methodOop: '405428225', + dictName: 'Globals', + category: 'accessing', + }, + ]); + }); + + it('reads the disabled flag', () => { + const result = getAllBreakpoints(vi.fn(() => row({ disabled: 'true' }) + '\n')); + expect(result[0].disabled).toBe(true); + }); + + it('reads a class-side breakpoint as isMeta with the base class name', () => { + const result = getAllBreakpoints( + vi.fn(() => row({ isMeta: 'true', selector: 'new' }) + '\n'), + ); + expect(result[0].isMeta).toBe(true); + expect(result[0].className).toBe('Account'); + }); + + it('parses a doit row, whose class and selector are empty', () => { + const result = getAllBreakpoints( + vi.fn( + () => row({ className: '', selector: '', dictName: '', category: '' }) + '\n', + ), + ); + expect(result[0].className).toBe(''); + expect(result[0].selector).toBe(''); + expect(result[0].stepPoint).toBe(3); + }); + + it('parses several rows', () => { + const raw = [row(), row({ breakNumber: '2', selector: 'deposit:' })].join('\n') + '\n'; + const result = getAllBreakpoints(vi.fn(() => raw)); + expect(result.map((b) => b.selector)).toEqual(['balance', 'deposit:']); + }); + + it('returns nothing when no breakpoints are set', () => { + expect(getAllBreakpoints(vi.fn(() => ''))).toEqual([]); + }); + + it('skips a truncated row rather than yielding a half-parsed breakpoint', () => { + const raw = row() + '\n' + '9\tAccount\tfalse\n'; + const result = getAllBreakpoints(vi.fn(() => raw)); + expect(result).toHaveLength(1); + }); +}); diff --git a/client/src/queries/breakpointGlobals.ts b/client/src/queries/breakpointGlobals.ts new file mode 100644 index 00000000..29567b07 --- /dev/null +++ b/client/src/queries/breakpointGlobals.ts @@ -0,0 +1,45 @@ +import { QueryExecutor } from './types'; + +/** + * Session-wide breakpoint operations — the "global functions" of the breakpoint + * manager. Each acts on every method breakpoint the gem holds, including ones + * Jasper did not set. + * + * These reach for the `GsNMethod class` primitives rather than looping over + * Jasper's own model on purpose: the gem is the only thing that knows about + * breakpoints set outside Jasper, and "disable all" has to mean all of them. + */ + +/** Re-enable every breakpoint in the gem, disabled ones included. */ +export function enableAllBreakpoints(execute: QueryExecutor): string { + return execute(`GsNMethod _enableAllBreaks. 'ok'`); +} + +/** Disable every breakpoint in the gem, but keep them so they can be re-enabled. */ +export function disableAllBreakpoints(execute: QueryExecutor): string { + return execute(`GsNMethod _disableAllBreaks. 'ok'`); +} + +/** Remove every breakpoint in the gem outright. */ +export function removeAllBreakpoints(execute: QueryExecutor): string { + return execute(`GsNMethod _deleteAllBreaks. 'ok'`); +} + +/** Whether the gem currently holds any method breakpoint at all. */ +export function hasBreakpoints(execute: QueryExecutor): boolean { + return execute(`GsNMethod _hasBreakpoints printString`).trim() === 'true'; +} + +/** + * Operate on a breakpoint in a method we can only name by OOP — a doit's + * "executed code", or a method whose class has since been renamed out from + * under the breakpoint. `op` is the `GsNMethod` selector to send. + */ +export function breakpointByOop( + execute: QueryExecutor, + methodOop: string, + op: 'setBreakAtStepPoint:' | 'disableBreakAtStepPoint:' | 'clearBreakAtStepPoint:', + stepPoint: number, +): string { + return execute(`(Object _objectForOop: ${methodOop}) ${op} ${stepPoint}. 'ok'`); +} diff --git a/client/src/queries/disableBreakAtStepPoint.ts b/client/src/queries/disableBreakAtStepPoint.ts new file mode 100644 index 00000000..8d2420df --- /dev/null +++ b/client/src/queries/disableBreakAtStepPoint.ts @@ -0,0 +1,26 @@ +import { QueryExecutor } from './types'; +import { compiledMethodExpr } from './util'; + +/** + * Disable — but keep — the breakpoint at `stepPoint`. + * + * `GsNMethod >> disableBreakAtStepPoint:` is a no-op when no breakpoint is set + * there yet, so a disabled breakpoint can only exist as "set, then disabled". + * Callers that want a disabled breakpoint where there is none must + * `setBreakAtStepPoint` first; `setBreakAtStepPoint` is also what re-enables one + * (there is no separate enable primitive — `GsNMethod class >> + * enableBreakInClass:selector:stepPoint:` just sends `setBreakAtStepPoint:`). + */ +export function disableBreakAtStepPoint( + execute: QueryExecutor, + className: string, + isMeta: boolean, + selector: string, + stepPoint: number, + environmentId: number = 0, + dict?: number | string, +): string { + const method = compiledMethodExpr(className, isMeta, selector, environmentId, dict); + const code = `${method} disableBreakAtStepPoint: ${stepPoint}. 'ok'`; + return execute(code); +} diff --git a/client/src/queries/getAllBreakpoints.ts b/client/src/queries/getAllBreakpoints.ts new file mode 100644 index 00000000..a6502034 --- /dev/null +++ b/client/src/queries/getAllBreakpoints.ts @@ -0,0 +1,121 @@ +import { QueryExecutor } from './types'; + +/** One method breakpoint as the gem currently holds it. */ +export interface GemStoneBreakpoint { + /** The gem's own breakpoint number, as topaz reports it. */ + breakNumber: number; + /** Base class name; '' for a breakpoint in executed code (a doit). */ + className: string; + isMeta: boolean; + /** '' for a breakpoint in executed code (a doit). */ + selector: string; + stepPoint: number; + /** Set but not currently signalling — the gem stores this as a negative ip. */ + disabled: boolean; + environmentId: number; + /** OOP of the home method, for operating on a method we can't name. */ + methodOop: string; + /** Symbol dictionary binding the class; '' when it isn't in the symbol list. */ + dictName: string; + /** Method category, for building the method's editor URI; '' when unknown. */ + category: string; +} + +/** + * Every method breakpoint set in this session's gem. + * + * Breakpoints are per-gem VM state: they do not survive logout and a `commit` + * does not persist them, so this is a view of one session, never of the + * repository. Jasper's durable model is the VS Code breakpoint list, which + * `BreakpointManager` applies to each session; this query is the other + * direction — what the gem actually has right now, including breakpoints Jasper + * did not set (topaz, another tool, or a `halt` compiled into the code). + * + * Reads `GsNMethod class >> _breakReport: true`, whose second element is one + * descriptor per breakpoint: `{ breakNumber . class . selector . stepPoint . + * method . disabled }`. Going through `_breakReport:` rather than decoding + * `_allMethodBreakpoints` directly is deliberate — **that primitive's tuple + * stride changes between GemStone releases** (3 fields on 3.6.2, 4 on 3.7.5, + * which gained `breakpointLevel`), so hand-decoding it walks off the end of the + * array on the older release. `_breakReport:` is part of the same kernel as the + * primitive and always knows its own stride; the six descriptor slots this reads + * are identical on both. + * + * Breakpoints on a **superseded** version of a method are left out. Recompiling + * does not clear the old `GsNMethod`'s breakpoints, and the gem keeps reporting + * them under the same class and selector — so a method edited twice with a + * breakpoint in it accumulates duplicate rows that can never fire, since nothing + * will execute that method object again. They are noise in a breakpoint manager, + * and indistinguishable from the live one to anyone reading the list. + * + * Dictionary and category come back too, so a caller can open the method in an + * editor without a second round trip per breakpoint. The dictionary is matched + * by class *identity*, not by name, so a class name shadowed in two + * dictionaries resolves to the one actually holding this class. + */ +export function getAllBreakpoints(execute: QueryExecutor): GemStoneBreakpoint[] { + const code = `| ws sl dictOf isCurrent | +ws := WriteStream on: String new. +sl := System myUserProfile symbolList. +dictOf := [:aCls | | base found | + base := aCls isMeta ifTrue: [aCls thisClass] ifFalse: [aCls]. + found := ''. + 1 to: sl size do: [:i | + (found isEmpty and: [((sl at: i) at: base name asSymbol ifAbsent: [nil]) == base]) + ifTrue: [found := ((sl at: i) name ifNil: ['']) asString]]. + found]. +"Is this GsNMethod still the one installed for its class and selector? A + recompile leaves the old method object holding its breakpoints, and the gem + goes on reporting them." +isCurrent := [:meth :cls :sel | + (cls isNil or: [sel isNil]) + ifTrue: [true] + ifFalse: [[(cls compiledMethodAt: sel environmentId: meth environmentId) == meth] + on: Error do: [:ex | false]]]. +((GsNMethod _breakReport: true) at: 2) do: [:d | + | brkNum cls sel stepPt home disabled | + brkNum := d at: 1. + cls := d at: 2. + sel := d at: 3. + stepPt := d at: 4. + home := d at: 5. + disabled := d at: 6. + (isCurrent value: home value: cls value: sel) ifTrue: [ + ws nextPutAll: brkNum printString; tab; + nextPutAll: (cls + ifNil: [''] + ifNotNil: [:c | c isMeta ifTrue: [c thisClass name asString] ifFalse: [c name asString]]); tab; + nextPutAll: (cls ifNil: ['false'] ifNotNil: [:c | c isMeta printString]); tab; + nextPutAll: (sel ifNil: [''] ifNotNil: [:s | s asString]); tab; + nextPutAll: stepPt printString; tab; + nextPutAll: disabled printString; tab; + nextPutAll: home environmentId printString; tab; + nextPutAll: home asOop printString; tab; + nextPutAll: (cls ifNil: [''] ifNotNil: [:c | dictOf value: c]); tab; + nextPutAll: ((cls isNil or: [sel isNil]) + ifTrue: [''] + ifFalse: [(cls categoryOfSelector: sel) ifNil: [''] ifNotNil: [:c | c asString]]); lf]]. +ws contents`; + + const raw = execute(code); + + const results: GemStoneBreakpoint[] = []; + for (const line of raw.split('\n')) { + if (line.length === 0) continue; + const parts = line.split('\t'); + if (parts.length < 10) continue; + results.push({ + breakNumber: parseInt(parts[0], 10), + className: parts[1], + isMeta: parts[2] === 'true', + selector: parts[3], + stepPoint: parseInt(parts[4], 10), + disabled: parts[5] === 'true', + environmentId: parseInt(parts[6], 10), + methodOop: parts[7], + dictName: parts[8], + category: parts[9], + }); + } + return results; +} diff --git a/client/src/selectorBreakpointManager.ts b/client/src/selectorBreakpointManager.ts deleted file mode 100644 index cecdf93e..00000000 --- a/client/src/selectorBreakpointManager.ts +++ /dev/null @@ -1,341 +0,0 @@ -import * as vscode from 'vscode'; -import { SessionManager, ActiveSession } from './sessionManager'; -import { parseMethodUri, MethodUriRef } from './gemstoneFileSystemProvider'; -import * as queries from './browserQueries'; -import { StepPointSelectorInfo } from './browserQueries'; - -interface TrackedSelectorBreakpoint { - stepPoint: number; - selectorOffset: number; - selectorLength: number; - selectorText: string; -} - -const decorationType = vscode.window.createTextEditorDecorationType({ - borderWidth: '1px', - borderStyle: 'solid', - borderColor: new vscode.ThemeColor('debugIcon.breakpointForeground'), - overviewRulerColor: new vscode.ThemeColor('debugIcon.breakpointForeground'), - overviewRulerLane: vscode.OverviewRulerLane.Left, -}); - -export class SelectorBreakpointManager { - private breakpoints = new Map(); - private selectorInfoCache = new Map(); - - constructor(private sessionManager: SessionManager) {} - - register(context: vscode.ExtensionContext): void { - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => { - if (editor) this.refreshDecorations(editor); - }), - vscode.window.onDidChangeVisibleTextEditors((editors) => { - for (const editor of editors) this.refreshDecorations(editor); - }), - ); - } - - toggleBreakpointAtCursor(editor: vscode.TextEditor): void { - if (editor.document.uri.scheme !== 'gemstone') return; - - const session = this.sessionManager.getSelectedSession(); - if (!session) { - vscode.window.showErrorMessage('No active GemStone session.'); - return; - } - - const uri = editor.document.uri; - const uriKey = uri.toString(); - const method = parseMethodUri(uri); - if (!method || method.diffView) return; - - const infos = this.getSelectorInfos(session, uri, method, editor.document.getText()); - if (!infos || infos.length === 0) { - vscode.window.showInformationMessage('No breakpointable step points found in this method.'); - return; - } - - const cursorOffset = editor.document.offsetAt(editor.selection.active); - const target = findNearestStepPoint(infos, cursorOffset); - if (!target) return; - - // Use the primary (first) entry for this step point for tracking - const primary = infos.find((i) => i.stepPoint === target.stepPoint) ?? target; - - const tracked = this.breakpoints.get(uriKey) ?? []; - const existingIdx = tracked.findIndex((bp) => bp.stepPoint === target.stepPoint); - - try { - if (existingIdx >= 0) { - queries.clearBreakAtStepPoint( - session, - method.className, - method.isMeta, - method.selector, - target.stepPoint, - method.environmentId, - ); - tracked.splice(existingIdx, 1); - } else { - queries.setBreakAtStepPoint( - session, - method.className, - method.isMeta, - method.selector, - target.stepPoint, - method.environmentId, - ); - tracked.push({ - stepPoint: primary.stepPoint, - selectorOffset: primary.selectorOffset, - selectorLength: primary.selectorLength, - selectorText: primary.selectorText, - }); - } - } catch (e) { - vscode.window.showErrorMessage( - `Breakpoint operation failed: ${e instanceof Error ? e.message : String(e)}`, - ); - return; - } - - if (tracked.length > 0) { - this.breakpoints.set(uriKey, tracked); - } else { - this.breakpoints.delete(uriKey); - } - - this.refreshDecorations(editor); - } - - refreshDecorations(editor: vscode.TextEditor): void { - if (editor.document.uri.scheme !== 'gemstone') return; - const uriKey = editor.document.uri.toString(); - const tracked = this.breakpoints.get(uriKey) ?? []; - const cached = this.selectorInfoCache.get(uriKey); - - const ranges: vscode.Range[] = []; - for (const bp of tracked) { - if (cached) { - // Highlight all keyword parts (e.g., both assert: and equals:) - for (const info of cached) { - if (info.stepPoint === bp.stepPoint) { - ranges.push( - new vscode.Range( - editor.document.positionAt(info.selectorOffset), - editor.document.positionAt(info.selectorOffset + info.selectorLength), - ), - ); - } - } - } else { - // Fallback: use the tracked entry's own range - ranges.push( - new vscode.Range( - editor.document.positionAt(bp.selectorOffset), - editor.document.positionAt(bp.selectorOffset + bp.selectorLength), - ), - ); - } - } - editor.setDecorations(decorationType, ranges); - } - - /** - * Called when a method is recompiled. Recompiling replaces the GsNMethod, - * so any breakpoints on the old method are gone. Clear tracking and cache. - */ - invalidateForUri(uri: vscode.Uri): void { - const uriKey = uri.toString(); - this.selectorInfoCache.delete(uriKey); - this.breakpoints.delete(uriKey); - this.refreshVisibleEditorsForUri(uri); - } - - clearAllForSession(sessionId: number): void { - const prefix = `gemstone://${sessionId}/`; - for (const [key] of this.breakpoints) { - if (key.startsWith(prefix)) { - this.breakpoints.delete(key); - this.selectorInfoCache.delete(key); - } - } - for (const editor of vscode.window.visibleTextEditors) { - if (editor.document.uri.toString().startsWith(prefix)) { - editor.setDecorations(decorationType, []); - } - } - } - - private getSelectorInfos( - session: ActiveSession, - uri: vscode.Uri, - method: MethodUriRef, - source?: string, - ): StepPointSelectorInfo[] | null { - const uriKey = uri.toString(); - const cached = this.selectorInfoCache.get(uriKey); - if (cached) return cached; - - try { - const rawInfos = queries.getStepPointSelectorRanges( - session, - method.className, - method.isMeta, - method.selector, - method.environmentId, - ); - const infos = source ? expandKeywordParts(source, rawInfos) : rawInfos; - this.selectorInfoCache.set(uriKey, infos); - return infos; - } catch (e) { - vscode.window.showErrorMessage( - `Could not fetch step points: ${e instanceof Error ? e.message : String(e)}`, - ); - return null; - } - } - - private refreshVisibleEditorsForUri(uri: vscode.Uri): void { - const uriStr = uri.toString(); - for (const editor of vscode.window.visibleTextEditors) { - if (editor.document.uri.toString() === uriStr) { - this.refreshDecorations(editor); - } - } - } -} - -/** - * Find the step point whose selector range contains the cursor offset, - * or failing that the one whose selector start is nearest to the cursor. - */ -export function findNearestStepPoint( - infos: StepPointSelectorInfo[], - cursorOffset: number, -): StepPointSelectorInfo | null { - if (infos.length === 0) return null; - - // First: exact containment — cursor is within a selector token - for (const info of infos) { - if ( - cursorOffset >= info.selectorOffset && - cursorOffset <= info.selectorOffset + info.selectorLength - ) { - return info; - } - } - - // Second: nearest by absolute distance to selector midpoint - let nearest = infos[0]; - let minDist = Math.abs(cursorOffset - (infos[0].selectorOffset + infos[0].selectorLength / 2)); - for (let i = 1; i < infos.length; i++) { - const mid = infos[i].selectorOffset + infos[i].selectorLength / 2; - const dist = Math.abs(cursorOffset - mid); - if (dist < minDist) { - minDist = dist; - nearest = infos[i]; - } - } - return nearest; -} - -function isIdentStart(ch: string): boolean { - return /[a-zA-Z_]/.test(ch); -} - -function isTokenChar(ch: string): boolean { - return /[a-zA-Z0-9_:]/.test(ch); -} - -/** - * For keyword messages (e.g., `assert:equals:`), the GCI query only returns - * the first keyword (`assert:`) at the step point offset. This function scans - * the source text to find continuation keywords (`equals:`) at the same - * nesting depth and adds them as additional entries with the same step point. - */ -export function expandKeywordParts( - source: string, - infos: StepPointSelectorInfo[], -): StepPointSelectorInfo[] { - const expanded: StepPointSelectorInfo[] = []; - for (const info of infos) { - expanded.push(info); - if (!info.selectorText.endsWith(':')) continue; - - let pos = info.selectorOffset + info.selectorLength; - let depth = 0; - - while (pos < source.length && depth >= 0) { - const ch = source[pos]; - - if (ch === '(' || ch === '[' || ch === '{') { - depth++; - pos++; - continue; - } - if (ch === ')' || ch === ']' || ch === '}') { - depth--; - if (depth < 0) break; - pos++; - continue; - } - if (ch === '.' || ch === ';') break; - - // Skip string literals (handle embedded '' quotes) - if (ch === "'") { - pos++; - while (pos < source.length) { - if (source[pos] === "'") { - pos++; - if (pos >= source.length || source[pos] !== "'") break; - } - pos++; - } - continue; - } - - // Skip comments - if (ch === '"') { - pos++; - while (pos < source.length && source[pos] !== '"') pos++; - if (pos < source.length) pos++; - continue; - } - - // Skip symbol literals (#word or #'string') - if (ch === '#') { - pos++; - if (pos < source.length && source[pos] === "'") { - pos++; - while (pos < source.length && source[pos] !== "'") pos++; - if (pos < source.length) pos++; - } else if (pos < source.length && isIdentStart(source[pos])) { - while (pos < source.length && isTokenChar(source[pos])) pos++; - } - continue; - } - - // At depth 0, check for continuation keyword - if (depth === 0 && isIdentStart(ch)) { - const start = pos; - pos++; - while (pos < source.length && isTokenChar(source[pos])) pos++; - const token = source.substring(start, pos); - if (token.endsWith(':')) { - expanded.push({ - stepPoint: info.stepPoint, - selectorOffset: start, - selectorLength: token.length, - selectorText: token, - }); - } - continue; - } - - pos++; - } - } - return expanded; -} diff --git a/client/src/stepPointHints.ts b/client/src/stepPointHints.ts new file mode 100644 index 00000000..94268438 --- /dev/null +++ b/client/src/stepPointHints.ts @@ -0,0 +1,126 @@ +import * as vscode from 'vscode'; +import { StepPointModel } from './stepPointModel'; + +/** When the numbers are drawn. */ +export type StepPointDisplay = 'off' | 'debugging' | 'always'; + +export function readDisplaySetting(): StepPointDisplay { + const value = vscode.workspace + .getConfiguration('gemstone') + .get('stepPoints.display', 'debugging'); + return value === 'off' || value === 'always' || value === 'debugging' ? value : 'debugging'; +} + +/** + * Whether step point numbers should be drawn right now. + * + * The default, `debugging`, is the whole point of the setting: step point + * numbers matter when you are reasoning about where execution is, and are + * clutter when you are just reading or writing code. So they appear while a + * debug session is live and stay out of the way the rest of the time. + */ +export function shouldShow(display: StepPointDisplay, debugSessionActive: boolean): boolean { + if (display === 'off') return false; + if (display === 'always') return true; + return debugSessionActive; +} + +/** + * Numbers every step point of a method, as inlay hints. + * + * Inlay hints rather than text decorations on purpose: VS Code already renders + * them in a dim, deliberately recessive style, they never change the document, + * and a developer who finds them noisy can turn them off with the editor's own + * `editor.inlayHints.*` settings instead of hunting for ours. Each number is + * clickable and toggles the breakpoint at that step point, which makes the + * numbering useful rather than merely informative. + * + * Numbers come from `GsNMethod >> _sourceOffsets`, so every step point gets one + * — including those starting at `:=`, `^` or a block bracket, which have no + * selector token to hang off. + */ +export class StepPointHintsProvider implements vscode.InlayHintsProvider { + private _onDidChangeInlayHints = new vscode.EventEmitter(); + readonly onDidChangeInlayHints = this._onDidChangeInlayHints.event; + + private display: StepPointDisplay = readDisplaySetting(); + + constructor(private stepPoints: StepPointModel) {} + + register(context: vscode.ExtensionContext): void { + context.subscriptions.push( + this._onDidChangeInlayHints, + vscode.languages.registerInlayHintsProvider([{ scheme: 'gemstone' }], this), + // The hints are gated on whether a debug session is live, so both edges + // have to redraw them. + vscode.debug.onDidStartDebugSession(() => this.refresh()), + vscode.debug.onDidTerminateDebugSession(() => this.refresh()), + vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration('gemstone.stepPoints.display')) { + this.display = readDisplaySetting(); + this.refresh(); + } + }), + ); + } + + /** Flip the numbers on or off, and remember it in the user's settings. */ + async toggle(): Promise { + const next: StepPointDisplay = this.visible() ? 'off' : 'always'; + await vscode.workspace + .getConfiguration('gemstone') + .update('stepPoints.display', next, vscode.ConfigurationTarget.Global); + // The configuration listener redraws; setting it here keeps `visible()` + // honest if the update event is slow to arrive. + this.display = next; + this.refresh(); + } + + /** Whether numbers are showing at this moment. */ + visible(): boolean { + return shouldShow(this.display, vscode.debug.activeDebugSession !== undefined); + } + + refresh(): void { + this._onDidChangeInlayHints.fire(); + } + + provideInlayHints( + document: vscode.TextDocument, + range: vscode.Range, + ): vscode.InlayHint[] | undefined { + if (!this.visible()) return undefined; + + const info = this.stepPoints.get(document); + if (!info) return undefined; + + const from = document.offsetAt(range.start); + const to = document.offsetAt(range.end); + + const hints: vscode.InlayHint[] = []; + for (let i = 0; i < info.offsets.length; i++) { + const at = info.offsets[i]; + if (at < from || at > to) continue; + + const stepPoint = i + 1; + const part = new vscode.InlayHintLabelPart(String(stepPoint)); + part.tooltip = new vscode.MarkdownString( + `Step point **${stepPoint}** of ${info.offsets.length} — click to toggle a breakpoint here.`, + ); + part.command = { + title: `Toggle breakpoint at step point ${stepPoint}`, + command: 'gemstone.breakpoints.toggleAtStepPoint', + arguments: [{ uri: document.uri.toString(), stepPoint }], + }; + + const hint = new vscode.InlayHint( + document.positionAt(at), + [part], + vscode.InlayHintKind.Parameter, + ); + hint.paddingRight = true; + hints.push(hint); + } + return hints; + } +} diff --git a/client/src/stepPointHover.ts b/client/src/stepPointHover.ts new file mode 100644 index 00000000..fc296c94 --- /dev/null +++ b/client/src/stepPointHover.ts @@ -0,0 +1,85 @@ +import * as vscode from 'vscode'; +import { StepPointModel, stepPointAtOffset, rangesForStepPoint } from './stepPointModel'; +import { BreakpointManager } from './breakpointManager'; + +/** + * Tells you the step point under the pointer, and lets you act on it. + * + * This is the answer to "show me step points without putting them in my face": + * it costs nothing visually and is always available, whether or not the inlay + * hint numbers are switched on. The hovered range is the step point's own token, + * so the highlight itself shows how far the step point reaches. + * + * Registered separately from `GemStoneHoverProvider` rather than folded into it + * — VS Code merges hovers from every provider, and keeping this one free of that + * provider's senders/implementors queries means a hover over a method still + * reports its step point when the LSP is not ready or the selector can't be + * resolved. + */ +export class StepPointHoverProvider implements vscode.HoverProvider { + constructor( + private stepPoints: StepPointModel, + private breakpoints: BreakpointManager, + ) {} + + provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | null { + if (document.uri.scheme !== 'gemstone') return null; + + const info = this.stepPoints.get(document); + if (!info || info.offsets.length === 0) return null; + + const offset = document.offsetAt(position); + const resolved = stepPointAtOffset(info, offset); + if (!resolved) return null; + + // Only speak up when the pointer is actually on the step point's token. The + // caret rule behind `stepPointAtOffset` deliberately falls forward to the + // next step point, which is right for "run to cursor" but would make a hover + // anywhere on a blank line claim to describe a step point elsewhere. + const spans = rangesForStepPoint(info, resolved.stepPoint); + const span = spans.find((s) => offset >= s.start && offset <= s.end); + if (!span) return null; + + const applied = this.breakpoints + .appliedFor(document.uri) + .find((bp) => bp.stepPoint === resolved.stepPoint); + + const md = new vscode.MarkdownString(); + md.isTrusted = true; + md.supportThemeIcons = true; + md.appendMarkdown(`**Step point ${resolved.stepPoint}** of ${info.offsets.length}`); + + if (applied) { + md.appendMarkdown( + applied.enabled + ? `\n\n$(debug-breakpoint) Breakpoint set` + : `\n\n$(debug-breakpoint-disabled) Breakpoint set but disabled`, + ); + } + + const arg = encodeURIComponent( + JSON.stringify([{ uri: document.uri.toString(), stepPoint: resolved.stepPoint }]), + ); + const links: string[] = []; + if (applied) { + links.push( + `[$(debug-breakpoint-unsupported) Clear](command:gemstone.breakpoints.clearAtStepPoint?${arg} "Clear the breakpoint at step point ${resolved.stepPoint}")`, + ); + links.push( + applied.enabled + ? `[$(debug-breakpoint-disabled) Disable](command:gemstone.breakpoints.disableAtStepPoint?${arg} "Disable the breakpoint at step point ${resolved.stepPoint}")` + : `[$(debug-breakpoint) Enable](command:gemstone.breakpoints.enableAtStepPoint?${arg} "Enable the breakpoint at step point ${resolved.stepPoint}")`, + ); + } else { + links.push( + `[$(debug-breakpoint) Set breakpoint](command:gemstone.breakpoints.toggleAtStepPoint?${arg} "Set a breakpoint at step point ${resolved.stepPoint}")`, + ); + } + md.appendMarkdown(`\n\n${links.join(' · ')}`); + + return new vscode.Hover( + md, + new vscode.Range(document.positionAt(span.start), document.positionAt(span.end)), + ); + } +} diff --git a/client/src/stepPointModel.ts b/client/src/stepPointModel.ts new file mode 100644 index 00000000..fa6afa12 --- /dev/null +++ b/client/src/stepPointModel.ts @@ -0,0 +1,279 @@ +import * as vscode from 'vscode'; +import { SessionManager, ActiveSession } from './sessionManager'; +import { parseMethodUri, MethodUriRef } from './gemstoneFileSystemProvider'; +import * as queries from './browserQueries'; +import { StepPointSelectorInfo } from './browserQueries'; +import { expandKeywordParts } from './stepPointSelectors'; + +/** + * Where every step point of one compiled method sits in its source, as the + * stone reports it. + */ +export interface StepPointInfo { + /** The stone's copy of the method source — what `offsets` index into. */ + source: string; + /** + * 0-based source offsets, one per step point: `offsets[i]` is step point + * `i + 1`. This is the complete list — `GsNMethod >> _sourceOffsets` has an + * entry for every step point, including ones starting at `:=`, `^`, a literal + * or a block bracket rather than at a selector. + */ + offsets: number[]; + /** + * Selector token ranges, for the subset of step points beginning on an + * identifier, with a keyword message's continuation keywords expanded in (so + * `assert:equals:` contributes both parts under one step point). Only decides + * what to *underline*; `offsets` decides numbering. + */ + selectors: StepPointSelectorInfo[]; + /** 0-based offset where each 1-based line starts; `lineStarts[1]` is 0. */ + lineStarts: number[]; +} + +/** + * Per-method step point positions, fetched once and cached. + * + * The single home for "where are this method's step points" — the breakpoint + * manager, the inlay hints, the hover and the cursor-toggle command all read it + * from here, so a method is queried once per open rather than once per feature, + * and they cannot disagree about which token is step point 7. + * + * Everything is expressed against the *stone's* source rather than an editor + * buffer, so the debug adapter (which only ever has line numbers) and the editor + * features resolve step points through the same code. `get` refuses a dirty + * document because the stone's offsets would then point at the wrong tokens — + * a wrong step point number is worse than none, and it would send a breakpoint + * somewhere the developer didn't ask for. + */ +export class StepPointModel { + private cache = new Map(); + + constructor(private sessionManager: SessionManager) {} + + /** + * Step points for `document`, or null when they can't be trusted or fetched: + * a non-method URI, a diff view, no session, an unsaved edit, or a method the + * stone no longer has. + */ + get(document: vscode.TextDocument): StepPointInfo | null { + if (document.uri.scheme !== 'gemstone') return null; + if (document.isDirty) return null; + + const method = parseMethodUri(document.uri); + if (!method || method.diffView) return null; + + const session = this.sessionManager.getSelectedSession(); + if (!session) return null; + + return this.fetch(session, document.uri, method); + } + + /** + * Step points for a method identified by coordinates rather than an open + * document — the path the debug adapter and the breakpoint applier take. + */ + fetch(session: ActiveSession, uri: vscode.Uri, method: MethodUriRef): StepPointInfo | null { + const key = uri.toString(); + const cached = this.cache.get(key); + if (cached) return cached; + + let source: string; + let rawOffsets: number[]; + let rawSelectors: StepPointSelectorInfo[]; + try { + source = queries.getMethodSource( + session, + method.className, + method.isMeta, + method.selector, + method.environmentId, + ); + rawOffsets = queries.getSourceOffsets( + session, + method.className, + method.isMeta, + method.selector, + method.environmentId, + ); + rawSelectors = queries.getStepPointSelectorRanges( + session, + method.className, + method.isMeta, + method.selector, + method.environmentId, + ); + } catch { + // The method may have been removed, or its class renamed, since the editor + // opened. Callers treat null as "no step points known". + return null; + } + + const info: StepPointInfo = { + source, + // _sourceOffsets is 1-based; every consumer here works in 0-based offsets. + offsets: rawOffsets.map((o) => o - 1), + selectors: expandKeywordParts(source, rawSelectors), + lineStarts: buildLineStarts(source), + }; + this.cache.set(key, info); + return info; + } + + /** Drop the cache for one method — call after it is recompiled. */ + invalidate(uri: vscode.Uri): void { + this.cache.delete(uri.toString()); + } + + /** Drop every cached method belonging to a session that has logged out. */ + invalidateSession(sessionId: number): void { + const prefix = `gemstone://${sessionId}/`; + for (const key of [...this.cache.keys()]) { + if (key.startsWith(prefix)) this.cache.delete(key); + } + } + + /** Drop everything — e.g. when the selected session changes. */ + clear(): void { + this.cache.clear(); + } +} + +/** + * 0-based offset of the start of each 1-based line. Index 0 is unused padding so + * `lineStarts[n]` reads as "line n", matching how GemStone and the debug adapter + * both count lines. + */ +export function buildLineStarts(source: string): number[] { + const starts = [0, 0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') starts.push(i + 1); + } + return starts; +} + +/** The 1-based line containing `offset`. */ +export function lineOfOffset(lineStarts: number[], offset: number): number { + let line = 1; + for (let l = 1; l < lineStarts.length; l++) { + if (lineStarts[l] <= offset) line = l; + else break; + } + return line; +} + +/** A resolved breakpoint position: which step point, and where it actually is. */ +export interface ResolvedStepPoint { + stepPoint: number; + /** 0-based source offset of the step point. */ + offset: number; + /** 1-based line the step point is on — may differ from the line asked for. */ + line: number; +} + +/** + * The step point a breakpoint request lands on. + * + * `line` is 1-based. `character` is a 0-based column, or undefined for a plain + * gutter click, which carries no column at all: + * + * - **No column** (or column 0) — the *leftmost* step point on the line. A + * gutter click means "this line", and the leftmost step point is the only + * defensible reading of that. + * - **A column** — from an inline breakpoint or Jasper's own toggle-at-cursor — + * the step point on that line nearest the column, so a caret on `asInteger` + * in `x := (...) asInteger` breaks at `asInteger`, not at the leftmost store, + * and a caret inside a one-line block breaks inside the block. + * + * Either way, a line with no step point of its own falls forward to the next + * step point after it, and the returned `line` says where the breakpoint really + * ended up so VS Code can show it moved. Returns null when there is no step + * point at or after the request — a breakpoint past the last statement. + */ +export function resolveStepPoint( + info: StepPointInfo, + line: number, + character?: number, +): ResolvedStepPoint | null { + const { offsets, lineStarts } = info; + if (offsets.length === 0) return null; + if (line < 1 || line >= lineStarts.length) return null; + + const lineStart = lineStarts[line]; + const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1] : info.source.length + 1; + + let best: { stepPoint: number; offset: number } | null = null; + + if (character === undefined || character <= 0) { + // Leftmost step point on the line. + for (let i = 0; i < offsets.length; i++) { + const at = offsets[i]; + if (at >= lineStart && at < lineEnd && (best === null || at < best.offset)) { + best = { stepPoint: i + 1, offset: at }; + } + } + } else { + // Nearest step point on the line by column. + const target = lineStart + character; + let bestDist = Infinity; + for (let i = 0; i < offsets.length; i++) { + const at = offsets[i]; + if (at >= lineStart && at < lineEnd) { + const dist = Math.abs(at - target); + if (dist < bestDist) { + bestDist = dist; + best = { stepPoint: i + 1, offset: at }; + } + } + } + } + + if (best) { + return { stepPoint: best.stepPoint, offset: best.offset, line }; + } + + // Nothing on this line — fall forward to the next step point after it. + let after: { stepPoint: number; offset: number } | null = null; + for (let i = 0; i < offsets.length; i++) { + const at = offsets[i]; + if (at >= lineStart && (after === null || at < after.offset)) { + after = { stepPoint: i + 1, offset: at }; + } + } + if (!after) return null; + return { + stepPoint: after.stepPoint, + offset: after.offset, + line: lineOfOffset(lineStarts, after.offset), + }; +} + +/** + * The step point nearest a caret at `offset`, for toggle-at-cursor and hover. + * Prefers a step point on the caret's own line, then falls forward — the same + * rule `resolveStepPoint` applies with a column. + */ +export function stepPointAtOffset(info: StepPointInfo, offset: number): ResolvedStepPoint | null { + const line = lineOfOffset(info.lineStarts, offset); + const character = offset - info.lineStarts[line]; + // A caret in column 0 still means "where the caret is", not "this whole + // line", so keep it on the column path by nudging it off zero. + return resolveStepPoint(info, line, Math.max(character, 1)); +} + +/** Every source range that should be marked for `stepPoint`, in offset pairs. */ +export function rangesForStepPoint( + info: StepPointInfo, + stepPoint: number, +): { start: number; end: number }[] { + const ranges = info.selectors + .filter((s) => s.stepPoint === stepPoint) + .map((s) => ({ start: s.selectorOffset, end: s.selectorOffset + s.selectorLength })); + if (ranges.length > 0) return ranges; + + // A step point that doesn't start on an identifier (`:=`, `^`, a literal, a + // block bracket) has no selector range — mark the single character at it so + // the breakpoint is still visible. + const at = info.offsets[stepPoint - 1]; + if (at === undefined) return []; + return [{ start: at, end: at + 1 }]; +} diff --git a/client/src/stepPointSelectors.ts b/client/src/stepPointSelectors.ts new file mode 100644 index 00000000..a44b0367 --- /dev/null +++ b/client/src/stepPointSelectors.ts @@ -0,0 +1,134 @@ +import { StepPointSelectorInfo } from './browserQueries'; + +/** + * Find the step point whose selector range contains the cursor offset, + * or failing that the one whose selector start is nearest to the cursor. + */ +export function findNearestStepPoint( + infos: StepPointSelectorInfo[], + cursorOffset: number, +): StepPointSelectorInfo | null { + if (infos.length === 0) return null; + + // First: exact containment — cursor is within a selector token + for (const info of infos) { + if ( + cursorOffset >= info.selectorOffset && + cursorOffset <= info.selectorOffset + info.selectorLength + ) { + return info; + } + } + + // Second: nearest by absolute distance to selector midpoint + let nearest = infos[0]; + let minDist = Math.abs(cursorOffset - (infos[0].selectorOffset + infos[0].selectorLength / 2)); + for (let i = 1; i < infos.length; i++) { + const mid = infos[i].selectorOffset + infos[i].selectorLength / 2; + const dist = Math.abs(cursorOffset - mid); + if (dist < minDist) { + minDist = dist; + nearest = infos[i]; + } + } + return nearest; +} + +function isIdentStart(ch: string): boolean { + return /[a-zA-Z_]/.test(ch); +} + +function isTokenChar(ch: string): boolean { + return /[a-zA-Z0-9_:]/.test(ch); +} + +/** + * For keyword messages (e.g., `assert:equals:`), the GCI query only returns + * the first keyword (`assert:`) at the step point offset. This function scans + * the source text to find continuation keywords (`equals:`) at the same + * nesting depth and adds them as additional entries with the same step point. + */ +export function expandKeywordParts( + source: string, + infos: StepPointSelectorInfo[], +): StepPointSelectorInfo[] { + const expanded: StepPointSelectorInfo[] = []; + for (const info of infos) { + expanded.push(info); + if (!info.selectorText.endsWith(':')) continue; + + let pos = info.selectorOffset + info.selectorLength; + let depth = 0; + + while (pos < source.length && depth >= 0) { + const ch = source[pos]; + + if (ch === '(' || ch === '[' || ch === '{') { + depth++; + pos++; + continue; + } + if (ch === ')' || ch === ']' || ch === '}') { + depth--; + if (depth < 0) break; + pos++; + continue; + } + if (ch === '.' || ch === ';') break; + + // Skip string literals (handle embedded '' quotes) + if (ch === "'") { + pos++; + while (pos < source.length) { + if (source[pos] === "'") { + pos++; + if (pos >= source.length || source[pos] !== "'") break; + } + pos++; + } + continue; + } + + // Skip comments + if (ch === '"') { + pos++; + while (pos < source.length && source[pos] !== '"') pos++; + if (pos < source.length) pos++; + continue; + } + + // Skip symbol literals (#word or #'string') + if (ch === '#') { + pos++; + if (pos < source.length && source[pos] === "'") { + pos++; + while (pos < source.length && source[pos] !== "'") pos++; + if (pos < source.length) pos++; + } else if (pos < source.length && isIdentStart(source[pos])) { + while (pos < source.length && isTokenChar(source[pos])) pos++; + } + continue; + } + + // At depth 0, check for continuation keyword + if (depth === 0 && isIdentStart(ch)) { + const start = pos; + pos++; + while (pos < source.length && isTokenChar(source[pos])) pos++; + const token = source.substring(start, pos); + if (token.endsWith(':')) { + expanded.push({ + stepPoint: info.stepPoint, + selectorOffset: start, + selectorLength: token.length, + selectorText: token, + }); + } + continue; + } + + pos++; + } + } + return expanded; +} diff --git a/package.json b/package.json index 43c4e72a..3380ea78 100644 --- a/package.json +++ b/package.json @@ -247,6 +247,17 @@ { "title": "GemStone", "properties": { + "gemstone.stepPoints.display": { + "type": "string", + "enum": ["off", "debugging", "always"], + "enumDescriptions": [ + "Never number step points.", + "Number step points while a debug session is running (default) \u2014 they are what you want when reasoning about where execution is, and clutter the rest of the time.", + "Always number step points in GemStone method source." + ], + "default": "debugging", + "markdownDescription": "When to number step points in GemStone method source, drawn as inlay hints. Each number is clickable and toggles a breakpoint at that step point. Whatever this is set to, hovering a step point always reports its number, and `#gemstone.breakpoints.toggleStepPoints#` flips the numbers on or off. Use the editor's own `#editor.inlayHints.enabled#` to restyle or suppress inlay hints everywhere." + }, "gemstone.gciLibraries": { "type": "object", "default": {}, @@ -608,6 +619,12 @@ "icon": "$(remote-explorer)", "visibility": "collapsed" }, + { + "id": "gemstoneBreakpoints", + "name": "Breakpoints", + "icon": "$(debug-breakpoint)", + "type": "tree" + }, { "id": "gemstoneInspector", "name": "Inspector", @@ -1139,11 +1156,115 @@ "icon": "$(search)" }, { - "command": "gemstone.toggleSelectorBreakpoint", - "title": "Toggle Selector Breakpoint", + "command": "gemstone.breakpoints.toggleAtCursor", + "title": "Toggle Breakpoint at Cursor", + "category": "GemStone", + "icon": "$(debug-breakpoint)" + }, + { + "command": "gemstone.breakpoints.enableAtCursor", + "title": "Enable Breakpoint at Cursor", + "category": "GemStone", + "icon": "$(debug-breakpoint)" + }, + { + "command": "gemstone.breakpoints.disableAtCursor", + "title": "Disable Breakpoint at Cursor", + "category": "GemStone", + "icon": "$(debug-breakpoint-disabled)" + }, + { + "command": "gemstone.breakpoints.clearMethod", + "title": "Clear All Breakpoints in Method", + "category": "GemStone", + "icon": "$(debug-breakpoint-unsupported)" + }, + { + "command": "gemstone.breakpoints.enableAll", + "title": "Enable All Breakpoints", "category": "GemStone", "icon": "$(debug-breakpoint)" }, + { + "command": "gemstone.breakpoints.disableAll", + "title": "Disable All Breakpoints", + "category": "GemStone", + "icon": "$(debug-breakpoint-disabled)" + }, + { + "command": "gemstone.breakpoints.removeAll", + "title": "Remove All Breakpoints", + "category": "GemStone", + "icon": "$(clear-all)" + }, + { + "command": "gemstone.breakpoints.reapply", + "title": "Re-apply Breakpoints to Session", + "category": "GemStone", + "icon": "$(debug-restart)" + }, + { + "command": "gemstone.breakpoints.refresh", + "title": "Refresh Breakpoints", + "category": "GemStone", + "icon": "$(refresh)" + }, + { + "command": "gemstone.breakpoints.toggleStepPoints", + "title": "Toggle Step Point Numbers", + "category": "GemStone", + "icon": "$(symbol-numeric)" + }, + { + "command": "gemstone.breakpoints.reveal", + "title": "Reveal Breakpoint in Source", + "category": "GemStone", + "icon": "$(go-to-file)" + }, + { + "command": "gemstone.breakpoints.remove", + "title": "Remove Breakpoint", + "category": "GemStone", + "icon": "$(close)" + }, + { + "command": "gemstone.breakpoints.enable", + "title": "Enable Breakpoint", + "category": "GemStone", + "icon": "$(debug-breakpoint)" + }, + { + "command": "gemstone.breakpoints.disable", + "title": "Disable Breakpoint", + "category": "GemStone", + "icon": "$(debug-breakpoint-disabled)" + }, + { + "command": "gemstone.breakpoints.clearClass", + "title": "Clear Breakpoints in Class", + "category": "GemStone", + "icon": "$(clear-all)" + }, + { + "command": "gemstone.breakpoints.toggleAtStepPoint", + "title": "Toggle Breakpoint at Step Point", + "category": "GemStone" + }, + { + "command": "gemstone.breakpoints.enableAtStepPoint", + "title": "Enable Breakpoint at Step Point", + "category": "GemStone" + }, + { + "command": "gemstone.breakpoints.disableAtStepPoint", + "title": "Disable Breakpoint at Step Point", + "category": "GemStone" + }, + { + "command": "gemstone.breakpoints.clearAtStepPoint", + "title": "Clear Breakpoint at Step Point", + "category": "GemStone" + }, { "command": "gemstone.refreshVersions", "title": "Refresh Versions", @@ -1882,6 +2003,42 @@ ], "menus": { "commandPalette": [ + { + "command": "gemstone.breakpoints.toggleAtStepPoint", + "when": "false" + }, + { + "command": "gemstone.breakpoints.enableAtStepPoint", + "when": "false" + }, + { + "command": "gemstone.breakpoints.disableAtStepPoint", + "when": "false" + }, + { + "command": "gemstone.breakpoints.clearAtStepPoint", + "when": "false" + }, + { + "command": "gemstone.breakpoints.reveal", + "when": "false" + }, + { + "command": "gemstone.breakpoints.remove", + "when": "false" + }, + { + "command": "gemstone.breakpoints.enable", + "when": "false" + }, + { + "command": "gemstone.breakpoints.disable", + "when": "false" + }, + { + "command": "gemstone.breakpoints.clearClass", + "when": "false" + }, { "command": "gemstone.explorer.classClicked", "when": "false" @@ -2104,6 +2261,31 @@ } ], "view/title": [ + { + "command": "gemstone.breakpoints.enableAll", + "when": "view == gemstoneBreakpoints", + "group": "navigation@0" + }, + { + "command": "gemstone.breakpoints.disableAll", + "when": "view == gemstoneBreakpoints", + "group": "navigation@1" + }, + { + "command": "gemstone.breakpoints.removeAll", + "when": "view == gemstoneBreakpoints", + "group": "navigation@2" + }, + { + "command": "gemstone.breakpoints.refresh", + "when": "view == gemstoneBreakpoints", + "group": "navigation@3" + }, + { + "command": "gemstone.breakpoints.reapply", + "when": "view == gemstoneBreakpoints", + "group": "1_gemstoneBreakpoints@0" + }, { "command": "gemstone.clearTestResults", "when": "view == gemstoneExplorerClasses" @@ -2293,6 +2475,31 @@ } ], "view/item/context": [ + { + "command": "gemstone.breakpoints.reveal", + "when": "view == gemstoneBreakpoints && viewItem == gemstoneBreakpoint", + "group": "inline@0" + }, + { + "command": "gemstone.breakpoints.remove", + "when": "view == gemstoneBreakpoints && viewItem =~ /^gemstoneBreakpoint(Doit)?$/", + "group": "inline@1" + }, + { + "command": "gemstone.breakpoints.enable", + "when": "view == gemstoneBreakpoints && viewItem =~ /^gemstoneBreakpoint(Doit)?$/", + "group": "1_state@0" + }, + { + "command": "gemstone.breakpoints.disable", + "when": "view == gemstoneBreakpoints && viewItem =~ /^gemstoneBreakpoint(Doit)?$/", + "group": "1_state@1" + }, + { + "command": "gemstone.breakpoints.clearClass", + "when": "view == gemstoneBreakpoints && viewItem == gemstoneBreakpointClass", + "group": "inline@0" + }, { "command": "gemstone.explorer.renameClassCategory", "when": "view == gemstoneExplorerCategories && viewItem == explorerCategory", @@ -2883,9 +3090,24 @@ "group": "2_navigation@1" }, { - "command": "gemstone.toggleSelectorBreakpoint", + "command": "gemstone.breakpoints.toggleAtCursor", "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", "group": "3_gemstoneBreakpoints@0" + }, + { + "command": "gemstone.breakpoints.enableAtCursor", + "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", + "group": "3_gemstoneBreakpoints@1" + }, + { + "command": "gemstone.breakpoints.disableAtCursor", + "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", + "group": "3_gemstoneBreakpoints@2" + }, + { + "command": "gemstone.breakpoints.clearMethod", + "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", + "group": "3_gemstoneBreakpoints@3" } ] }, @@ -2928,6 +3150,11 @@ } ], "keybindings": [ + { + "command": "gemstone.breakpoints.toggleAtCursor", + "key": "shift+f9", + "when": "editorTextFocus && resourceScheme == gemstone && gemstone.hasActiveSession" + }, { "command": "gemstone.explorer.openSelectedMethodToSide", "key": "ctrl+enter", From 99a414c0cc8b4f0ba62ed1d7b0c5155aa80b2ea8 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 09:25:25 -0700 Subject: [PATCH 02/19] Say why a breakpoint command did nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Toggle/Enable/Disable Breakpoint at Cursor returned silently in four cases — a non-method editor, an unsaved buffer, a diff view, and a method whose step points could not be read. These are all invoked deliberately, from a keystroke or a menu, so a silent no-op is unreadable: it looks exactly like a dead keybinding, and the developer has no way to tell "your buffer is unsaved" from "the command never ran". Give StepPointModel an `explain` that answers either the step points or the reason there are none, phrased as something the developer can act on, and report it. `get` stays as the quiet wrapper for the hover and the inlay hints, which have nothing useful to say about a method without step points and must not nag. The unsaved-buffer case is the one most likely to be hit and least likely to be guessed: step point offsets describe the compiled method, so acting on a modified buffer would put the breakpoint somewhere other than where the caret is pointing. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/breakpointManager.test.ts | 96 ++++++++++++++++++- client/src/__tests__/stepPointModel.test.ts | 59 ++++++++++++ client/src/breakpointManager.ts | 23 +++-- client/src/stepPointModel.ts | 68 ++++++++++--- 4 files changed, 224 insertions(+), 22 deletions(-) diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 5b999a98..42ca7a16 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -12,7 +12,7 @@ vi.mock('../browserQueries', () => ({ clearAllBreaks: vi.fn(), })); -import { Uri, debug, Location, Position, SourceBreakpoint } from '../__mocks__/vscode'; +import { Uri, debug, window, Location, Position, SourceBreakpoint } from '../__mocks__/vscode'; import { BreakpointManager, buildLineOffsets, @@ -20,7 +20,7 @@ import { mapOffsetToStepPoint, } from '../breakpointManager'; import { SessionManager } from '../sessionManager'; -import { StepPointModel } from '../stepPointModel'; +import { StepPointModel, buildLineStarts } from '../stepPointModel'; import { getMethodSource, getSourceOffsets, @@ -447,6 +447,98 @@ describe('BreakpointManager', () => { }); }); + describe('toggleAtCursor', () => { + /** + * An editor whose caret is at `offset` in the fixture source. Positions map + * through the source's real line geometry, because the product relies on the + * editor's offsets agreeing with the stone's — which they do whenever the + * buffer is saved, and which is exactly what `explain` refuses to assume + * when it isn't. + */ + function makeEditor(source: string, offset: number, isDirty = false) { + const starts = buildLineStarts(source); // 1-based; [0, 0, ...] + const positionAt = (o: number) => { + let line = 1; + for (let l = 1; l < starts.length; l++) { + if (starts[l] <= o) line = l; + else break; + } + return new Position(line - 1, o - starts[line]); + }; + return { + document: { + uri: Uri.parse(METHOD_URI), + isDirty, + getText: () => source, + offsetAt: () => offset, + positionAt, + }, + selection: { active: positionAt(offset) }, + } as unknown as import('vscode').TextEditor; + } + + const warn = () => vi.mocked(window.showWarningMessage); + + beforeEach(() => { + warn().mockClear(); + }); + + it('adds a VS Code breakpoint at the caret’s step point', () => { + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + mockGetSourceOffsets.mockReturnValue([8, 13]); + + makeManager().toggleAtCursor(makeEditor('m\nx := self foo', 12)); + + expect(vi.mocked(debug.addBreakpoints)).toHaveBeenCalledTimes(1); + expect(warn()).not.toHaveBeenCalled(); + }); + + it('says why nothing happened when the buffer is unsaved', () => { + // The failure a developer is most likely to hit and least likely to guess: + // a silent no-op here is indistinguishable from a dead keybinding. + makeManager().toggleAtCursor(makeEditor('m\n^1', 2, true)); + + expect(vi.mocked(debug.addBreakpoints)).not.toHaveBeenCalled(); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('Save the method first')); + }); + + it('says why nothing happened when the method has no step points', () => { + mockGetMethodSource.mockReturnValue('m\n^1'); + mockGetSourceOffsets.mockReturnValue([]); + + makeManager().toggleAtCursor(makeEditor('m\n^1', 2)); + + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('no step points')); + }); + + it('says why nothing happened when the method cannot be read', () => { + mockGetMethodSource.mockImplementation(() => { + throw new Error('method not found'); + }); + + makeManager().toggleAtCursor(makeEditor('m\n^1', 2)); + + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('method not found')); + }); + + it('removes the breakpoint again on a second toggle at the same step point', () => { + mockGetMethodSource.mockReturnValue('m\nx := self foo'); + mockGetSourceOffsets.mockReturnValue([8, 13]); + + // Offset 12 in 'm\nx := self foo' is line 1, column 10 — the same place + // the caret is, so the toggle must recognise it as the same breakpoint. + const existing = new SourceBreakpoint( + new Location(Uri.parse(METHOD_URI), new Position(1, 10)), + ); + debug.breakpoints = [existing]; + + makeManager().toggleAtCursor(makeEditor('m\nx := self foo', 12)); + + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([existing]); + expect(vi.mocked(debug.addBreakpoints)).not.toHaveBeenCalled(); + }); + }); + describe('removeAll', () => { it('drops gemstone breakpoints from VS Code and sweeps the gem', () => { const gemstoneBp = new SourceBreakpoint( diff --git a/client/src/__tests__/stepPointModel.test.ts b/client/src/__tests__/stepPointModel.test.ts index 94cd4b1b..84b67b57 100644 --- a/client/src/__tests__/stepPointModel.test.ts +++ b/client/src/__tests__/stepPointModel.test.ts @@ -239,6 +239,65 @@ describe('StepPointModel', () => { expect(model.get(makeDocument())).toBeNull(); }); + describe('explain', () => { + /** The reason `explain` gave, or '' when it produced step points. */ + const problemFor = (doc: import('vscode').TextDocument) => { + const result = new StepPointModel(makeSessionManager()).explain(doc); + return 'problem' in result ? result.problem : ''; + }; + + it('names the unsaved buffer, the case a developer can actually fix', () => { + expect(problemFor(makeDocument(METHOD_URI, true))).toContain('Save the method first'); + }); + + it('says breakpoints need GemStone method source for another scheme', () => { + expect(problemFor(makeDocument('file:///a.st'))).toContain('GemStone method source'); + }); + + it('points at the real method for a comparison view', () => { + const diff = makeDocument( + 'gemstone://1/Globals/Array/instance/accessing/at%3A%20(base)?base=1', + ); + expect(problemFor(diff)).toContain('comparison view'); + }); + + it('reports a missing session', () => { + const model = new StepPointModel(makeSessionManager(false)); + const result = model.explain(makeDocument()); + expect(result).toEqual({ problem: 'No active GemStone session.' }); + }); + + it("passes the stone's own words along when the query fails", () => { + mockGetMethodSource.mockImplementation(() => { + throw new Error('method not found'); + }); + const problem = problemFor(makeDocument()); + expect(problem).toContain('Array>>at:'); + expect(problem).toContain('method not found'); + }); + + it('says so when the method compiles but has no step points', () => { + mockGetSourceOffsets.mockReturnValue([]); + expect(problemFor(makeDocument())).toContain('no step points'); + }); + + it('returns the step points when there is nothing wrong', () => { + const result = new StepPointModel(makeSessionManager()).explain(makeDocument()); + expect('info' in result && result.info.offsets).toEqual([10]); + }); + + it('does not leak a stale error into a later successful fetch', () => { + const model = new StepPointModel(makeSessionManager()); + mockGetMethodSource.mockImplementationOnce(() => { + throw new Error('transient'); + }); + expect('problem' in model.explain(makeDocument())).toBe(true); + + model.invalidate(Uri.parse(METHOD_URI)); + expect('info' in model.explain(makeDocument())).toBe(true); + }); + }); + it('invalidateSession drops only that session', () => { const model = new StepPointModel(makeSessionManager()); model.get(makeDocument()); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index c9cddcdc..2038dbb3 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -372,19 +372,24 @@ export class BreakpointManager { stepPointAtCursor( editor: vscode.TextEditor, ): { info: StepPointInfo; resolved: NonNullable> } | null { - if (editor.document.uri.scheme !== 'gemstone') return null; - if (!this.sessionManager.getSelectedSession()) { - vscode.window.showErrorMessage('No active GemStone session.'); + // Every failure here says so. These commands are invoked deliberately — from + // a keystroke, a menu, or the palette — and a silent no-op is unreadable: + // "nothing happened" looks exactly like a broken keybinding, so the developer + // has no way to tell an unsaved buffer from a command that never fired. + const result = this.stepPoints.explain(editor.document); + if ('problem' in result) { + vscode.window.showWarningMessage(result.problem); return null; } - const info = this.stepPoints.get(editor.document); - if (!info) return null; - if (info.offsets.length === 0) { - vscode.window.showInformationMessage('This method has no step points to break at.'); + const info = result.info; + + const resolved = stepPointAtOffset(info, editor.document.offsetAt(editor.selection.active)); + if (!resolved) { + vscode.window.showWarningMessage( + 'No step point at or after the cursor — put it on the code you want to break at.', + ); return null; } - const resolved = stepPointAtOffset(info, editor.document.offsetAt(editor.selection.active)); - if (!resolved) return null; return { info, resolved }; } diff --git a/client/src/stepPointModel.ts b/client/src/stepPointModel.ts index fa6afa12..45ca165e 100644 --- a/client/src/stepPointModel.ts +++ b/client/src/stepPointModel.ts @@ -30,6 +30,9 @@ export interface StepPointInfo { lineStarts: number[]; } +/** Either a method's step points, or why it hasn't got any we can use. */ +export type StepPointResult = { info: StepPointInfo } | { problem: string }; + /** * Per-method step point positions, fetched once and cached. * @@ -40,7 +43,7 @@ export interface StepPointInfo { * * Everything is expressed against the *stone's* source rather than an editor * buffer, so the debug adapter (which only ever has line numbers) and the editor - * features resolve step points through the same code. `get` refuses a dirty + * features resolve step points through the same code. It refuses a dirty * document because the stone's offsets would then point at the wrong tokens — * a wrong step point number is worse than none, and it would send a breakpoint * somewhere the developer didn't ask for. @@ -48,24 +51,64 @@ export interface StepPointInfo { export class StepPointModel { private cache = new Map(); + /** Why the last `fetch` returned null, for `explain` to pass on. */ + private lastError: string | undefined; + constructor(private sessionManager: SessionManager) {} /** - * Step points for `document`, or null when they can't be trusted or fetched: - * a non-method URI, a diff view, no session, an unsaved edit, or a method the - * stone no longer has. + * Step points for `document`, or null when they can't be trusted or fetched. + * + * For the quiet consumers — the hover and the inlay hints — which have nothing + * useful to say about a document that has no step points and must not nag. A + * command the developer invoked deliberately should use `explain` instead, so + * "nothing happened" can be told apart from "nothing was supposed to happen". */ get(document: vscode.TextDocument): StepPointInfo | null { - if (document.uri.scheme !== 'gemstone') return null; - if (document.isDirty) return null; + const result = this.explain(document); + return 'info' in result ? result.info : null; + } + + /** + * Step points for `document`, or the reason there are none — phrased for the + * developer, because every one of these is something they can act on. + */ + explain(document: vscode.TextDocument): StepPointResult { + if (document.uri.scheme !== 'gemstone') { + return { problem: 'Breakpoints can only be set in GemStone method source.' }; + } + if (document.isDirty) { + // Step point offsets come from the compiled method, so they describe the + // saved source, not what is on screen. Acting on them now would put the + // breakpoint somewhere the developer didn't point at. + return { problem: 'Save the method first — step points come from the compiled method.' }; + } const method = parseMethodUri(document.uri); - if (!method || method.diffView) return null; + if (!method) { + return { problem: 'This editor is not a saved method, so it has no step points.' }; + } + if (method.diffView) { + return { + problem: 'This is a read-only comparison view — set the breakpoint in the method itself.', + }; + } const session = this.sessionManager.getSelectedSession(); - if (!session) return null; + if (!session) return { problem: 'No active GemStone session.' }; - return this.fetch(session, document.uri, method); + const info = this.fetch(session, document.uri, method); + if (!info) { + return { + problem: `Could not read step points for ${method.className}>>${method.selector}${ + this.lastError ? ` — ${this.lastError}` : '' + }`, + }; + } + if (info.offsets.length === 0) { + return { problem: 'This method has no step points to break at.' }; + } + return { info }; } /** @@ -102,12 +145,15 @@ export class StepPointModel { method.selector, method.environmentId, ); - } catch { + } catch (e) { // The method may have been removed, or its class renamed, since the editor - // opened. Callers treat null as "no step points known". + // opened. Callers treat null as "no step points known"; `explain` reports + // the stone's own words, which say which of those it was. + this.lastError = e instanceof Error ? e.message : String(e); return null; } + this.lastError = undefined; const info: StepPointInfo = { source, // _sourceOffsets is 1-based; every consumer here works in 0-based offsets. From ee0f1dffb553b1e845d0c4e301e2d92686859d14 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 09:54:20 -0700 Subject: [PATCH 03/19] Re-apply breakpoints when a session is selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code persists its breakpoint list across restarts and restores it at startup — silently, without firing onDidChangeBreakpoints, and before any session exists. Nothing then pushed those breakpoints into a gem, so reopening the window left a red gutter marker with nothing behind it: the marker claimed execution would stop somewhere it wouldn't, and running the method quietly did not break. reapplyAll existed for exactly this but was only ever reachable from the manual "Re-apply Breakpoints to Session" command. Hook it to onDidChangeSelection, which a login fires, and scope it to breakpoints whose URI names that session. Method URIs carry the session id, so an unscoped re-apply would push one session's breakpoints into another session's gem — a breakpoint in a stone the developer never asked about. Session ids restart at 1 per window, so the usual first login reclaims a restored breakpoint; one naming a session that doesn't exist waits rather than leaking. reapplyAll now returns the number of methods it applied, so the manual command can say what it did instead of appearing to do nothing when there was nothing to do. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../src/__tests__/breakpointManager.test.ts | 47 +++++++++++++++++-- client/src/breakpointManager.ts | 30 +++++++++--- client/src/extension.ts | 19 +++++++- 4 files changed, 87 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0352eff5..e1ca26d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Fixed +- **A restored breakpoint showed a gutter marker with nothing behind it.** VS Code persists its breakpoint list across restarts and restores it at startup — silently, without firing `onDidChangeBreakpoints`, and before any session exists. Nothing then pushed those breakpoints into a gem, so after reopening the window the marker claimed execution would stop somewhere it wouldn't. Selecting a session (which a login does) now re-applies that session's breakpoints, scoped by the session id in the method URI so one session's breakpoints are never pushed into another's gem. - **A gutter breakpoint could be set one step point later than the line asked for.** Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. ## [1.8.13] - 2026-08-20 diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 42ca7a16..1eb35942 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -403,7 +403,9 @@ describe('BreakpointManager', () => { }); describe('reapplyAll', () => { - it('re-applies every gemstone breakpoint, which is what a new login needs', () => { + it('re-applies this session’s breakpoints, which is what a login needs', () => { + // VS Code restores its list at startup without firing onDidChangeBreakpoints + // and before any session exists, so without this the gutter marker is a lie. mockGetMethodSource.mockReturnValue('foo\n^1'); mockGetSourceOffsets.mockReturnValue([1, 5]); @@ -413,11 +415,50 @@ describe('BreakpointManager', () => { new SourceBreakpoint(new Location(Uri.parse('file:///a.ts'), new Position(3, 0))), ]; - makeManager().reapplyAll(session()); - + expect(makeManager().reapplyAll(session())).toBe(1); expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(1); expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'at:', 0); }); + + it('ignores a breakpoint belonging to another session', () => { + // Method URIs carry the session id. Pushing session 2's breakpoint into + // session 1's gem would set a breakpoint in a stone nobody asked about. + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + debug.breakpoints = [ + new SourceBreakpoint( + new Location( + Uri.parse('gemstone://2/Globals/Array/instance/accessing/at%3A'), + new Position(0, 0), + ), + ), + ]; + + expect(makeManager().reapplyAll(session())).toBe(0); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + }); + + it('counts methods, not breakpoints, so two breaks in one method are one apply', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))), + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))), + ]; + + expect(makeManager().reapplyAll(session())).toBe(1); + // One clear for the method, both breakpoints set within it. + expect(mockClearAllBreaks).toHaveBeenCalledTimes(1); + expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(2); + }); + + it('reports nothing to do when the session has no breakpoints', () => { + debug.breakpoints = []; + expect(makeManager().reapplyAll(session())).toBe(0); + }); }); describe('clearAllForSession', () => { diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 2038dbb3..88f416af 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -235,17 +235,35 @@ export class BreakpointManager { ); } - /** Re-apply every gemstone breakpoint to `session` — after a login, or on demand. */ - reapplyAll(session: ActiveSession): void { + /** + * Push this session's breakpoints into its gem. Returns how many methods were + * (re)applied, so a caller can tell whether anything happened. + * + * **Required on login, not just on demand.** VS Code persists its breakpoint + * list across restarts and restores it at startup — silently, without firing + * `onDidChangeBreakpoints`, and before any session exists. So a restored + * breakpoint has a red dot in the gutter and nothing whatsoever in the gem: + * the marker claims execution will stop somewhere it won't. Re-applying when a + * session is selected is what makes the marker true again. + * + * Scoped to breakpoints whose URI names *this* session. Method URIs carry the + * session id (`gemstone:///…`), and a breakpoint recorded against one + * session must not be pushed into another session's gem — that would set a + * breakpoint in a stone the developer never asked about. A restored breakpoint + * whose session id no longer exists simply waits: ids restart at 1 in each + * window, so the usual first login reclaims it. + */ + reapplyAll(session: ActiveSession): number { + const prefix = `gemstone://${session.id}/`; const uris = new Set(); - for (const bp of vscode.debug.breakpoints) { - if (bp instanceof vscode.SourceBreakpoint && bp.location.uri.scheme === 'gemstone') { - uris.add(bp.location.uri.toString()); - } + for (const bp of gemstoneBreakpoints()) { + const uriStr = bp.location.uri.toString(); + if (uriStr.startsWith(prefix)) uris.add(uriStr); } for (const uriStr of uris) { this.applyToUri(session, vscode.Uri.parse(uriStr)); } + return uris.size; } // ── Editor commands ────────────────────────────────────── diff --git a/client/src/extension.ts b/client/src/extension.ts index b6dfc2ed..65ac90ed 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -899,6 +899,18 @@ export function activate(context: vscode.ExtensionContext) { const stepPointHints = new StepPointHintsProvider(stepPointModel); stepPointHints.register(context); + context.subscriptions.push( + // Breakpoints are per-gem state, but VS Code persists its own list across + // restarts and restores it before any session exists — so a restored + // breakpoint shows a gutter marker with nothing behind it until it is pushed + // into a gem. Selecting a session (which a login does) is when that happens. + sessionManager.onDidChangeSelection((id) => { + if (id == null) return; + const session = sessionManager.getSession(id); + if (session) breakpointManager.reapplyAll(session); + }), + ); + const breakpointTree = new BreakpointTreeProvider(sessionManager, breakpointManager); breakpointTree.register(context); @@ -2686,7 +2698,12 @@ export function activate(context: vscode.ExtensionContext) { vscode.window.showErrorMessage('No active GemStone session.'); return; } - breakpointManager.reapplyAll(session); + const count = breakpointManager.reapplyAll(session); + vscode.window.showInformationMessage( + count === 0 + ? 'No GemStone breakpoints to re-apply to this session.' + : `Re-applied breakpoints in ${count} method${count === 1 ? '' : 's'}.`, + ); }), vscode.commands.registerCommand('gemstone.breakpoints.refresh', () => breakpointTree.refresh()), From 8cc003821e596bd509f781a54cfedffd8d840edd Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:02:22 -0700 Subject: [PATCH 04/19] Breakpoints die with their GemStone session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A GemStone breakpoint lives in the gem, so it cannot outlive the session. VS Code persists its breakpoint list across restarts anyway — right for a file, wrong for a gem — so a reopened window showed a red marker for a breakpoint that existed nowhere. The previous commit made that marker true by re-applying it to whichever session logged in next; that was the wrong fix. It resurrects a breakpoint into a gem the developer never pointed it at, and it treats a transient thing as durable. Drop them instead: - logging out removes that session's breakpoints from VS Code's list, scoped by the session id in the method URI so another session's are untouched; - activation prunes anything a startup restore brought back, and an `added` event prunes a restore that lands later or a gutter click in a stale editor. Pruning is idempotent, so the removal it triggers re-enters the handler with nothing left to prune and cannot loop. Removes "Re-apply Breakpoints to Session", whose only purpose was to serve the persistence model being dropped. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- README.md | 14 ++- .../src/__tests__/breakpointManager.test.ts | 115 ++++++++++-------- client/src/breakpointManager.ts | 78 +++++++----- client/src/extension.ts | 30 +---- package.json | 11 -- 6 files changed, 126 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1ca26d0..3a97ee49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Added +- **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) - **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A Breakpoints view in the GemStone sidebar.** Lists what the current session's gem actually holds — grouped by class and method, each row naming the step point the breakpoint resolved to, with an enable checkbox and a click that opens the method with the caret on that step point. It is deliberately the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set (from topaz, another tool, or a `halt` left in the code), which were previously invisible right up until execution stopped on one. ([#262](https://github.com/GemTalk/Jasper/issues/262)) @@ -16,7 +17,6 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Fixed -- **A restored breakpoint showed a gutter marker with nothing behind it.** VS Code persists its breakpoint list across restarts and restores it at startup — silently, without firing `onDidChangeBreakpoints`, and before any session exists. Nothing then pushed those breakpoints into a gem, so after reopening the window the marker claimed execution would stop somewhere it wouldn't. Selecting a session (which a login does) now re-applies that session's breakpoints, scoped by the session id in the method URI so one session's breakpoints are never pushed into another's gem. - **A gutter breakpoint could be set one step point later than the line asked for.** Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. ## [1.8.13] - 2026-08-20 diff --git a/README.md b/README.md index d281ee4c..16fe2356 100644 --- a/README.md +++ b/README.md @@ -233,11 +233,15 @@ When code execution hits an error, a **Debug** button opens the VS Code debugger ### Breakpoints -Breakpoints live in VS Code's own breakpoint list, so they survive a restart and -the familiar gutter, checkbox and Enable/Disable/Remove All controls all drive -GemStone. Each one is applied to the session as a step-point break — GemStone -breakpoints are per-session gem state, so Jasper re-applies them on login and -after a recompile. +Breakpoints live in VS Code's own breakpoint list, so the familiar gutter, +checkbox and Enable/Disable/Remove All controls all drive GemStone. Each one is +applied to the session as a step-point break. + +**A GemStone breakpoint dies with its session.** It lives in the gem, not the +repository — no `commit` persists it — so logging out clears the breakpoint from +VS Code's list too, rather than leaving a marker for something that no longer +exists. Unlike a breakpoint on a file, it does not come back when you reopen the +window. Within a session they are re-applied after a recompile. - **Line breakpoints** — click the editor gutter in a `gemstone://` method. A gutter click means "this line", and lands on the leftmost step point on it diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 1eb35942..bd26e074 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -47,13 +47,20 @@ function session() { return makeSessionManager(true).getSelectedSession()!; } +const TEST_SESSION = { + id: 1, + gci: {}, + handle: 'h1', + login: { label: 'Test' }, + stoneVersion: '3.7.2', +}; + function makeSessionManager(hasSession: boolean) { return { - getSelectedSession: vi.fn(() => - hasSession - ? { id: 1, gci: {}, handle: 'h1', login: { label: 'Test' }, stoneVersion: '3.7.2' } - : undefined, - ), + getSelectedSession: vi.fn(() => (hasSession ? TEST_SESSION : undefined)), + // pruneOrphans asks which sessions are logged in, to tell a live breakpoint + // from one whose gem is gone. + getSessions: vi.fn(() => (hasSession ? [TEST_SESSION] : [])), onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), } as unknown as SessionManager; } @@ -402,62 +409,53 @@ describe('BreakpointManager', () => { }); }); - describe('reapplyAll', () => { - it('re-applies this session’s breakpoints, which is what a login needs', () => { - // VS Code restores its list at startup without firing onDidChangeBreakpoints - // and before any session exists, so without this the gutter marker is a lie. - mockGetMethodSource.mockReturnValue('foo\n^1'); - mockGetSourceOffsets.mockReturnValue([1, 5]); + describe('pruneOrphans', () => { + it('drops a restored breakpoint whose session is gone', () => { + // VS Code persists its list across restarts; a GemStone breakpoint lives in + // the gem and dies with it, so a restored marker points at nothing. + const orphan = new SourceBreakpoint( + new Location( + Uri.parse('gemstone://7/Globals/Array/instance/accessing/at%3A'), + new Position(0, 0), + ), + ); + debug.breakpoints = [orphan]; - debug.breakpoints = [ - new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))), - // A non-gemstone breakpoint must be left entirely alone. - new SourceBreakpoint(new Location(Uri.parse('file:///a.ts'), new Position(3, 0))), - ]; + expect(makeManager().pruneOrphans()).toBe(1); + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([orphan]); + expect(debug.breakpoints).toEqual([]); + }); - expect(makeManager().reapplyAll(session())).toBe(1); - expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(1); - expect(mockGetMethodSource).toHaveBeenCalledWith(expect.anything(), 'Array', false, 'at:', 0); + it('keeps a breakpoint whose session is logged in', () => { + const live = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))); + debug.breakpoints = [live]; + + expect(makeManager().pruneOrphans()).toBe(0); + expect(debug.breakpoints).toEqual([live]); }); - it('ignores a breakpoint belonging to another session', () => { - // Method URIs carry the session id. Pushing session 2's breakpoint into - // session 1's gem would set a breakpoint in a stone nobody asked about. - mockGetMethodSource.mockReturnValue('foo\n^1'); - mockGetSourceOffsets.mockReturnValue([1, 5]); + it('never touches a non-gemstone breakpoint', () => { + const fileBp = new SourceBreakpoint( + new Location(Uri.parse('file:///a.ts'), new Position(1, 0)), + ); + debug.breakpoints = [fileBp]; + + expect(makeManager().pruneOrphans()).toBe(0); + expect(debug.breakpoints).toEqual([fileBp]); + }); + it('is idempotent, so the removal it triggers cannot loop', () => { debug.breakpoints = [ new SourceBreakpoint( new Location( - Uri.parse('gemstone://2/Globals/Array/instance/accessing/at%3A'), + Uri.parse('gemstone://7/Globals/Array/instance/accessing/at%3A'), new Position(0, 0), ), ), ]; - - expect(makeManager().reapplyAll(session())).toBe(0); - expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); - expect(mockClearAllBreaks).not.toHaveBeenCalled(); - }); - - it('counts methods, not breakpoints, so two breaks in one method are one apply', () => { - mockGetMethodSource.mockReturnValue('foo\n^1'); - mockGetSourceOffsets.mockReturnValue([1, 5]); - - debug.breakpoints = [ - new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))), - new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))), - ]; - - expect(makeManager().reapplyAll(session())).toBe(1); - // One clear for the method, both breakpoints set within it. - expect(mockClearAllBreaks).toHaveBeenCalledTimes(1); - expect(mockSetBreakAtStepPoint).toHaveBeenCalledTimes(2); - }); - - it('reports nothing to do when the session has no breakpoints', () => { - debug.breakpoints = []; - expect(makeManager().reapplyAll(session())).toBe(0); + const manager = makeManager(); + expect(manager.pruneOrphans()).toBe(1); + expect(manager.pruneOrphans()).toBe(0); }); }); @@ -475,6 +473,25 @@ describe('BreakpointManager', () => { expect(manager.appliedFor(uri)).toHaveLength(0); }); + it('removes the session’s breakpoints from VS Code too, so none outlive the gem', () => { + const mine = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))); + const other = new SourceBreakpoint( + new Location( + Uri.parse('gemstone://2/Globals/Array/instance/accessing/at%3A'), + new Position(0, 0), + ), + ); + const fileBp = new SourceBreakpoint( + new Location(Uri.parse('file:///a.ts'), new Position(1, 0)), + ); + debug.breakpoints = [mine, other, fileBp]; + + makeManager().clearAllForSession(1); + + // Session 1's breakpoint is gone; session 2's and the file's survive. + expect(debug.breakpoints).toEqual([other, fileBp]); + }); + it("leaves another session's breakpoints alone", () => { mockGetMethodSource.mockReturnValue('foo\n^1'); mockGetSourceOffsets.mockReturnValue([1, 5]); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 88f416af..6ae1b966 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -48,14 +48,20 @@ const disabledDecoration = vscode.window.createTextEditorDecorationType({ /** * Applies Jasper's breakpoints to a GemStone session and keeps the two in step. * - * **The durable model is VS Code's own breakpoint list.** GemStone method + * **VS Code's breakpoint list is the working model, for the life of a session.** + * Expressing breakpoints as `vscode.debug.breakpoints` is what makes the gutter, + * the per-breakpoint enable checkbox and the built-in Enable/Disable/Remove All + * commands drive GemStone for free — they arrive here as + * `onDidChangeBreakpoints`. + * + * It is **not** a durable record, though, and deliberately so. GemStone method * breakpoints are per-gem VM state: they do not survive logout, and a `commit` - * does not persist them (verified against 3.7.5). So the stone can never be the - * record of what the developer wants — only of what one session currently has. - * Keeping `vscode.debug.breakpoints` as the record means VS Code persists - * breakpoints across restarts, and the gutter, the per-breakpoint enable - * checkbox and the built-in Enable/Disable/Remove All commands all drive - * GemStone for free, arriving here as `onDidChangeBreakpoints`. + * does not persist them (verified against 3.6.2 and 3.7.5). A breakpoint that + * outlived its session would be a marker pointing at a gem that no longer + * exists — promising to stop execution it cannot stop. So logging out takes the + * session's breakpoints out of VS Code's list with it, and anything VS Code's + * own cross-restart persistence brings back is pruned. See `pruneOrphans` and + * `clearAllForSession`. * * Step point precision rides on the breakpoint's **column**: a gutter click has * none and means "the leftmost step point on this line", while an inline @@ -68,7 +74,7 @@ const disabledDecoration = vscode.window.createTextEditorDecorationType({ * point with no breakpoint, hence the two calls. */ export class BreakpointManager { - /** What we last applied, per method URI — drives decorations and re-apply. */ + /** What we last applied, per method URI — drives decorations and re-application. */ private applied = new Map(); private _onDidApply = new vscode.EventEmitter(); @@ -236,34 +242,25 @@ export class BreakpointManager { } /** - * Push this session's breakpoints into its gem. Returns how many methods were - * (re)applied, so a caller can tell whether anything happened. + * Drop every GemStone breakpoint with no live session behind it. * - * **Required on login, not just on demand.** VS Code persists its breakpoint - * list across restarts and restores it at startup — silently, without firing - * `onDidChangeBreakpoints`, and before any session exists. So a restored - * breakpoint has a red dot in the gutter and nothing whatsoever in the gem: - * the marker claims execution will stop somewhere it won't. Re-applying when a - * session is selected is what makes the marker true again. + * VS Code persists its breakpoint list and restores it at startup, which is + * right for a file but wrong for us: a GemStone breakpoint lives in the gem, + * so it dies with the session. A restored marker would point at a gem that no + * longer exists — a red dot promising to stop execution that cannot stop + * anything. Rather than re-apply it to whatever session logs in next (which + * would resurrect a breakpoint in a stone nobody asked about), it goes. * - * Scoped to breakpoints whose URI names *this* session. Method URIs carry the - * session id (`gemstone:///…`), and a breakpoint recorded against one - * session must not be pushed into another session's gem — that would set a - * breakpoint in a stone the developer never asked about. A restored breakpoint - * whose session id no longer exists simply waits: ids restart at 1 in each - * window, so the usual first login reclaims it. + * Method URIs carry the session id, so "live" means a session of that id is + * logged in right now. Returns how many were dropped. */ - reapplyAll(session: ActiveSession): number { - const prefix = `gemstone://${session.id}/`; - const uris = new Set(); - for (const bp of gemstoneBreakpoints()) { - const uriStr = bp.location.uri.toString(); - if (uriStr.startsWith(prefix)) uris.add(uriStr); - } - for (const uriStr of uris) { - this.applyToUri(session, vscode.Uri.parse(uriStr)); - } - return uris.size; + pruneOrphans(): number { + const live = new Set(this.sessionManager.getSessions().map((s) => `gemstone://${s.id}/`)); + const orphans = gemstoneBreakpoints().filter( + (bp) => ![...live].some((prefix) => bp.location.uri.toString().startsWith(prefix)), + ); + if (orphans.length > 0) vscode.debug.removeBreakpoints(orphans); + return orphans.length; } // ── Editor commands ────────────────────────────────────── @@ -556,6 +553,15 @@ export class BreakpointManager { /** Called when a session logs out — its gem, and our view of it, are gone. */ clearAllForSession(sessionId: number): void { const prefix = `gemstone://${sessionId}/`; + + // The gem is gone, so its breakpoints are gone — including VS Code's record + // of them. Leaving those behind would show a marker for a breakpoint that no + // longer exists anywhere, and VS Code would then persist it past this window. + const stale = gemstoneBreakpoints().filter((bp) => + bp.location.uri.toString().startsWith(prefix), + ); + if (stale.length > 0) vscode.debug.removeBreakpoints(stale); + for (const key of [...this.applied.keys()]) { if (key.startsWith(prefix)) this.applied.delete(key); } @@ -639,6 +645,12 @@ export class BreakpointManager { } private onBreakpointsChanged(event: vscode.BreakpointsChangeEvent): void { + // Catches a startup restore that lands after activation, and a gutter click + // in a stale editor from a session that has since logged out. Pruning is + // idempotent, and the removal it triggers re-enters here with nothing left + // to prune, so this does not loop. + if (event.added.length > 0) this.pruneOrphans(); + const session = this.sessionManager.getSelectedSession(); if (!session) return; diff --git a/client/src/extension.ts b/client/src/extension.ts index 65ac90ed..31d78297 100644 --- a/client/src/extension.ts +++ b/client/src/extension.ts @@ -899,17 +899,11 @@ export function activate(context: vscode.ExtensionContext) { const stepPointHints = new StepPointHintsProvider(stepPointModel); stepPointHints.register(context); - context.subscriptions.push( - // Breakpoints are per-gem state, but VS Code persists its own list across - // restarts and restores it before any session exists — so a restored - // breakpoint shows a gutter marker with nothing behind it until it is pushed - // into a gem. Selecting a session (which a login does) is when that happens. - sessionManager.onDidChangeSelection((id) => { - if (id == null) return; - const session = sessionManager.getSession(id); - if (session) breakpointManager.reapplyAll(session); - }), - ); + // A GemStone breakpoint lives in the gem, so it dies with the session. VS Code + // persists its breakpoint list across restarts regardless, so anything it just + // restored belongs to a gem that no longer exists — drop it rather than show a + // marker that cannot stop execution. + breakpointManager.pruneOrphans(); const breakpointTree = new BreakpointTreeProvider(sessionManager, breakpointManager); breakpointTree.register(context); @@ -2692,20 +2686,6 @@ export function activate(context: vscode.ExtensionContext) { breakpointManager.removeAll(), ), - vscode.commands.registerCommand('gemstone.breakpoints.reapply', () => { - const session = sessionManager.getSelectedSession(); - if (!session) { - vscode.window.showErrorMessage('No active GemStone session.'); - return; - } - const count = breakpointManager.reapplyAll(session); - vscode.window.showInformationMessage( - count === 0 - ? 'No GemStone breakpoints to re-apply to this session.' - : `Re-applied breakpoints in ${count} method${count === 1 ? '' : 's'}.`, - ); - }), - vscode.commands.registerCommand('gemstone.breakpoints.refresh', () => breakpointTree.refresh()), vscode.commands.registerCommand('gemstone.breakpoints.toggleStepPoints', () => diff --git a/package.json b/package.json index 3380ea78..b3b62a23 100644 --- a/package.json +++ b/package.json @@ -1197,12 +1197,6 @@ "category": "GemStone", "icon": "$(clear-all)" }, - { - "command": "gemstone.breakpoints.reapply", - "title": "Re-apply Breakpoints to Session", - "category": "GemStone", - "icon": "$(debug-restart)" - }, { "command": "gemstone.breakpoints.refresh", "title": "Refresh Breakpoints", @@ -2281,11 +2275,6 @@ "when": "view == gemstoneBreakpoints", "group": "navigation@3" }, - { - "command": "gemstone.breakpoints.reapply", - "when": "view == gemstoneBreakpoints", - "group": "1_gemstoneBreakpoints@0" - }, { "command": "gemstone.clearTestResults", "when": "view == gemstoneExplorerClasses" From dc1bfde98ca561f0c02485644c293912b5071483 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:17:13 -0700 Subject: [PATCH 05/19] Make a disabled breakpoint's token marker legible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The marker that says *which* step point a breakpoint sits on distinguished disabled from enabled by border style alone — dashed versus solid, 1px, in a grey that some themes render nearly invisibly. Nobody notices that, so "is this breakpoint live?" — the one question the marker exists to answer — was unanswerable by looking. Lead with colour instead, the same red-versus-grey pair VS Code uses for the gutter dot, and fade the disabled token so the cue survives a theme where the grey border is lost. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 5 +++-- client/src/breakpointManager.ts | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 16fe2356..01ae7d3c 100644 --- a/README.md +++ b/README.md @@ -250,8 +250,9 @@ window. Within a session they are re-applied after a recompile. under the caret, not the first one on the line, and the token that will actually break is outlined - **Enable / disable** — per breakpoint from the Breakpoints view checkbox or - **Enable/Disable Breakpoint at Cursor**; a disabled breakpoint is kept in the - gem (drawn dashed) so re-arming it is instant + **Enable/Disable Breakpoint at Cursor**. A disabled breakpoint stays set in the + gem so re-arming it is instant; its token marker turns grey and faded, matching + the grey the gutter dot goes - **All at once** — **Enable All**, **Disable All** and **Remove All Breakpoints** act on every GemStone breakpoint, including any set outside Jasper by topaz or a `halt` left in the code diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 6ae1b966..a450f01e 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -27,6 +27,18 @@ export interface AppliedBreakpoint { enabled: boolean; } +/** + * Marks the exact token a breakpoint sits on. + * + * The gutter dot only says "this line"; a Smalltalk line usually holds several + * step points, so this is what says *which one*. + * + * Enabled and disabled are told apart mainly by **colour** — the same + * red-versus-grey pair VS Code uses for the gutter dot itself — because a + * dashed-versus-solid 1px border is not a difference anyone notices. The + * disabled marker also fades its token, which reads as inert without needing the + * border to be seen at all. + */ const enabledDecoration = vscode.window.createTextEditorDecorationType({ borderWidth: '1px', borderStyle: 'solid', @@ -36,13 +48,15 @@ const enabledDecoration = vscode.window.createTextEditorDecorationType({ overviewRulerLane: vscode.OverviewRulerLane.Left, }); -// Dashed and drawn in the "unverified" grey so a disabled breakpoint reads as -// present-but-inert at a glance, the way the gutter dot hollows out. const disabledDecoration = vscode.window.createTextEditorDecorationType({ borderWidth: '1px', borderStyle: 'dashed', borderColor: new vscode.ThemeColor('debugIcon.breakpointDisabledForeground'), borderRadius: '2px', + // Fading the token is the cue that survives a theme where the grey border is + // nearly invisible — and "is this breakpoint live?" is exactly the question + // the marker exists to answer. + opacity: '0.75', }); /** From 1239f7ba29ba119960f7384607face14ba59ddd8 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:29:13 -0700 Subject: [PATCH 06/19] Reach the step-point toggle without leaving the method Toggle Step Point Numbers was Command Palette only, so turning the numbers on meant leaving the source you were reading them against. Put it on the editor title bar as a one-click button and on the editor right-click menu, next to the other breakpoint actions. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 2 ++ client/src/__tests__/editorContextMenu.test.ts | 2 ++ package.json | 12 ++++++++++++ 3 files changed, 16 insertions(+) diff --git a/README.md b/README.md index 01ae7d3c..2c2d16a9 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,8 @@ window. Within a session they are re-applied after a recompile. clickable to toggle a breakpoint there. `gemstone.stepPoints.display` controls when: `debugging` (the default — visible while a debug session runs, out of the way otherwise), `always`, or `off`. **Toggle Step Point Numbers** flips them + without leaving the editor — it's the `123` button in the editor title bar, and + on the right-click menu - **On hover** — hovering a step point always reports its number and breakpoint state, with links to set, clear, enable or disable it, whatever the numbering is set to diff --git a/client/src/__tests__/editorContextMenu.test.ts b/client/src/__tests__/editorContextMenu.test.ts index 1c008d7f..2df493b0 100644 --- a/client/src/__tests__/editorContextMenu.test.ts +++ b/client/src/__tests__/editorContextMenu.test.ts @@ -33,6 +33,7 @@ describe('editor/context menu', () => { 'gemstone.breakpoints.enableAtCursor', 'gemstone.breakpoints.disableAtCursor', 'gemstone.breakpoints.clearMethod', + 'gemstone.breakpoints.toggleStepPoints', ]); }); @@ -87,6 +88,7 @@ describe('editor/context menu', () => { 'gemstone.breakpoints.enableAtCursor', 'gemstone.breakpoints.disableAtCursor', 'gemstone.breakpoints.clearMethod', + 'gemstone.breakpoints.toggleStepPoints', ]; // Compared as a map so a mismatch names the offending command itself. expect(Object.fromEntries(commands.map((c) => [c, getMenuItem(c)?.when]))).toEqual( diff --git a/package.json b/package.json index b3b62a23..9746697d 100644 --- a/package.json +++ b/package.json @@ -3042,6 +3042,13 @@ "when": "view == gemstoneDatabases && viewItem == gemstoneDb" } ], + "editor/title": [ + { + "command": "gemstone.breakpoints.toggleStepPoints", + "when": "resourceScheme == gemstone && resourceLangId == gemstone-smalltalk", + "group": "navigation@0" + } + ], "editor/context": [ { "command": "gemstone.displayIt", @@ -3097,6 +3104,11 @@ "command": "gemstone.breakpoints.clearMethod", "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", "group": "3_gemstoneBreakpoints@3" + }, + { + "command": "gemstone.breakpoints.toggleStepPoints", + "when": "editorTextFocus && resourceLangId == gemstone-smalltalk", + "group": "3_gemstoneBreakpoints@4" } ] }, From 34c9d0b6c09e5db6182f00fd8c079ba00b652e36 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:37:24 -0700 Subject: [PATCH 07/19] Warn that a function breakpoint will never fire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button in VS Code's Breakpoints panel creates a function breakpoint — named rather than located. Jasper only implements source breakpoints, so one sat in the panel looking exactly like a working breakpoint and never fired. Unsaid, that reads as "breakpoints are broken" rather than "this kind isn't wired up". Warn on add. Deliberately does not delete it: the developer typed it on purpose, and silently removing what someone just typed is its own kind of confusing. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 4 ++ client/src/__mocks__/vscode.ts | 24 ++++++++- .../src/__tests__/breakpointManager.test.ts | 51 ++++++++++++++++++- client/src/breakpointManager.ts | 21 ++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a97ee49..43a6d200 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Added +- **Adding a function breakpoint now says it won't work.** The `+` button in VS Code's Breakpoints panel creates a breakpoint named rather than located, which Jasper doesn't implement — so it sat in the list indistinguishable from a working one and never fired, which reads as "breakpoints are broken" rather than "this kind isn't wired up". It's left in place rather than deleted, since silently removing what someone just typed is its own kind of confusing. - **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) - **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) diff --git a/README.md b/README.md index 2c2d16a9..87cf4d0b 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,10 @@ window. Within a session they are re-applied after a recompile. Jasper by topaz or a `halt` left in the code - **Clear All Breakpoints in Method** drops every breakpoint in the method you are in +- **Not supported:** *function* breakpoints — the ones the `+` button in VS + Code's Breakpoints panel creates, named rather than located. Jasper warns if + you add one, since it would otherwise sit in the list looking live and never + fire #### Step points diff --git a/client/src/__mocks__/vscode.ts b/client/src/__mocks__/vscode.ts index 0899fca1..24511f23 100644 --- a/client/src/__mocks__/vscode.ts +++ b/client/src/__mocks__/vscode.ts @@ -810,7 +810,17 @@ export const CompletionItemKind = { export const debug = { breakpoints: [] as unknown[], activeDebugSession: undefined as unknown, - onDidChangeBreakpoints: vi.fn(() => ({ dispose: () => {} })), + // Declares its listener parameter so a test can recover the registered handler + // from mock.calls and drive the manager the way VS Code does. + onDidChangeBreakpoints: vi.fn( + ( + _listener: (e: { + added: readonly unknown[]; + removed: readonly unknown[]; + changed: readonly unknown[]; + }) => void, + ) => ({ dispose: () => {} }), + ), onDidStartDebugSession: vi.fn(() => ({ dispose: () => {} })), onDidTerminateDebugSession: vi.fn(() => ({ dispose: () => {} })), // Mirror the real API's side effect on `debug.breakpoints`, so a test can @@ -850,6 +860,18 @@ export class SourceBreakpoint extends Breakpoint { } } +// A breakpoint named rather than located — what the Breakpoints panel's `+` +// button creates. Jasper only implements SourceBreakpoint, so it warns on these. +export class FunctionBreakpoint extends Breakpoint { + constructor( + public functionName: string, + enabled = true, + ) { + super(); + this.enabled = enabled; + } +} + // ── Inlay hint mock ────────────────────────────────────── export const InlayHintKind = { diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index bd26e074..75508953 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -12,7 +12,15 @@ vi.mock('../browserQueries', () => ({ clearAllBreaks: vi.fn(), })); -import { Uri, debug, window, Location, Position, SourceBreakpoint } from '../__mocks__/vscode'; +import { + Uri, + debug, + window, + Location, + Position, + SourceBreakpoint, + FunctionBreakpoint, +} from '../__mocks__/vscode'; import { BreakpointManager, buildLineOffsets, @@ -409,6 +417,47 @@ describe('BreakpointManager', () => { }); }); + describe('function breakpoints', () => { + /** Drive the manager the way VS Code does, through the change event. */ + function fireAdded(added: unknown[]) { + const manager = makeManager(); + const context = { + subscriptions: [] as unknown[], + } as unknown as import('vscode').ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + const handler = calls[calls.length - 1][0]; + handler({ added, removed: [], changed: [] }); + return manager; + } + + beforeEach(() => { + vi.mocked(window.showWarningMessage).mockClear(); + vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + }); + + it('warns that a function breakpoint will never fire', () => { + // The + button in the Breakpoints panel makes one of these. Jasper only + // implements source breakpoints, so it would sit there looking live. + fireAdded([new FunctionBreakpoint('breakpointTesting')]); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( + expect.stringContaining('does not support function breakpoints'), + ); + }); + + it('leaves it in place rather than deleting what the developer typed', () => { + const named = new FunctionBreakpoint('breakpointTesting'); + debug.breakpoints = [named]; + fireAdded([named]); + expect(debug.breakpoints).toContain(named); + }); + + it('says nothing for an ordinary source breakpoint', () => { + fireAdded([new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0)))]); + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + }); + describe('pruneOrphans', () => { it('drops a restored breakpoint whose session is gone', () => { // VS Code persists its list across restarts; a GemStone breakpoint lives in diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index a450f01e..758cac34 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -664,6 +664,7 @@ export class BreakpointManager { // idempotent, and the removal it triggers re-enters here with nothing left // to prune, so this does not loop. if (event.added.length > 0) this.pruneOrphans(); + this.warnAboutFunctionBreakpoints(event.added); const session = this.sessionManager.getSelectedSession(); if (!session) return; @@ -679,6 +680,26 @@ export class BreakpointManager { } } + /** + * Say so when a *function* breakpoint is added — the kind VS Code's `+` button + * in the Breakpoints panel creates, named rather than located. + * + * Jasper only implements source breakpoints, so a function breakpoint sits in + * the list looking exactly like a working one and never fires. Left unsaid, + * that reads as "breakpoints are broken" rather than "this kind isn't wired + * up". Warn rather than delete it: the developer typed it deliberately, and + * silently removing what someone just typed is its own kind of confusing. + */ + private warnAboutFunctionBreakpoints(added: readonly vscode.Breakpoint[]): void { + const named = added.filter((bp) => bp instanceof vscode.FunctionBreakpoint); + if (named.length === 0) return; + vscode.window.showWarningMessage( + 'Jasper does not support function breakpoints (the + button in the Breakpoints panel) — ' + + 'they will never fire. Set a breakpoint in the method source instead: click the gutter, ' + + 'or put the caret on a step point and use Toggle Breakpoint at Cursor.', + ); + } + private refreshEditorsFor(uri: vscode.Uri): void { const uriStr = uri.toString(); for (const editor of vscode.window.visibleTextEditors) { From 9ea7cff95cd07d48572910a9b66e2a6304809fbb Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:51:32 -0700 Subject: [PATCH 08/19] Break on entry by naming a method in the Breakpoints panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The + button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one sat in the panel indistinguishable from a working breakpoint and never fired. The previous commit only warned about it; naming a method you want to stop in, without going to find it first, is worth actually supporting. A bare selector is looked up across the image and, when more than one class implements it, the developer picks which one. A qualified `Account>>balance` or `Account class>>new` skips the prompt, but is still checked against the image rather than trusted — a typo would otherwise set a breakpoint that silently never fires, which is the failure this whole change is about. The name is then **converted** into an ordinary SourceBreakpoint on the method's first step point, rather than carried through the model as a second kind of breakpoint. That gives it a real location and red dot, enable/disable, death with its session and a row in the GemStone Breakpoints view, all for free — and avoids a named and a located breakpoint on one method fighting over which of them owns its breaks, since applying a method's breakpoints clears the method first. A name that resolves to nothing is dropped with an explanation rather than left sitting there, which is the same reasoning: an inert breakpoint that looks live is worse than no breakpoint. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- README.md | 11 +- .../src/__tests__/breakpointManager.test.ts | 29 +- .../src/__tests__/functionBreakpoints.test.ts | 304 ++++++++++++++++++ client/src/breakpointManager.ts | 37 +-- client/src/functionBreakpoints.ts | 264 +++++++++++++++ 6 files changed, 606 insertions(+), 41 deletions(-) create mode 100644 client/src/__tests__/functionBreakpoints.test.ts create mode 100644 client/src/functionBreakpoints.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a6d200..9ccbb684 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Added -- **Adding a function breakpoint now says it won't work.** The `+` button in VS Code's Breakpoints panel creates a breakpoint named rather than located, which Jasper doesn't implement — so it sat in the list indistinguishable from a working one and never fired, which reads as "breakpoints are broken" rather than "this kind isn't wired up". It's left in place rather than deleted, since silently removing what someone just typed is its own kind of confusing. +- **Break on entry to a method by naming it.** The `+` button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one previously sat in the list indistinguishable from a working breakpoint and never fired. Typing a bare selector now looks up its implementors and asks which class you meant when there is more than one; a qualified `Account>>balance` or `Account class>>new` is taken at its word, though still checked against the image rather than trusted, since a typo would otherwise set a breakpoint that silently never fires. The name is then *converted* into an ordinary breakpoint on the method's first step point rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the GemStone Breakpoints view, and cannot fight a source breakpoint over which of them owns a method's breaks. A name that resolves to nothing is dropped with an explanation instead of left sitting there. - **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) - **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) diff --git a/README.md b/README.md index 87cf4d0b..3114f30f 100644 --- a/README.md +++ b/README.md @@ -258,10 +258,13 @@ window. Within a session they are re-applied after a recompile. Jasper by topaz or a `halt` left in the code - **Clear All Breakpoints in Method** drops every breakpoint in the method you are in -- **Not supported:** *function* breakpoints — the ones the `+` button in VS - Code's Breakpoints panel creates, named rather than located. Jasper warns if - you add one, since it would otherwise sit in the list looking live and never - fire +- **Break on entry by name** — the `+` button in VS Code's Breakpoints panel + takes a method name instead of a location. Type a selector (`balance`) and + Jasper finds the implementors, asking which class you meant when there is more + than one; or qualify it yourself (`Account>>balance`, `Account class>>new`). + The name is then replaced by an ordinary breakpoint on the method's first step + point, so it gets a real location, a red dot, and everything else breakpoints + do — handy for stopping in a method without going to find it first #### Step points diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 75508953..943e4330 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -432,27 +432,28 @@ describe('BreakpointManager', () => { } beforeEach(() => { - vi.mocked(window.showWarningMessage).mockClear(); vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + // Tests run in random order, so a warning from an earlier one would + // otherwise be counted here. + vi.mocked(window.showWarningMessage).mockClear(); + mockGetMethodSource.mockReturnValue('balance\n^total'); + mockGetSourceOffsets.mockReturnValue([9]); }); - it('warns that a function breakpoint will never fire', () => { - // The + button in the Breakpoints panel makes one of these. Jasper only - // implements source breakpoints, so it would sit there looking live. - fireAdded([new FunctionBreakpoint('breakpointTesting')]); - expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( - expect.stringContaining('does not support function breakpoints'), - ); - }); - - it('leaves it in place rather than deleting what the developer typed', () => { - const named = new FunctionBreakpoint('breakpointTesting'); + it('hands a named breakpoint to the resolver, which replaces it', async () => { + // The + button in the Breakpoints panel makes one of these — a name with no + // location. It is converted to a located breakpoint on the method's entry; + // functionBreakpoints.test.ts covers the resolution itself. + const named = new FunctionBreakpoint('at:'); debug.breakpoints = [named]; fireAdded([named]); - expect(debug.breakpoints).toContain(named); + + // Resolution is async (it may prompt), so let it settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([named]); }); - it('says nothing for an ordinary source breakpoint', () => { + it('applies an ordinary source breakpoint without involving the resolver', () => { fireAdded([new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0)))]); expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); }); diff --git a/client/src/__tests__/functionBreakpoints.test.ts b/client/src/__tests__/functionBreakpoints.test.ts new file mode 100644 index 00000000..e5d6a73d --- /dev/null +++ b/client/src/__tests__/functionBreakpoints.test.ts @@ -0,0 +1,304 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('vscode', () => import('../__mocks__/vscode.js')); + +vi.mock('../browserQueries', () => ({ + implementorsOf: vi.fn(() => []), + getSourceOffsets: vi.fn(() => []), + getMethodSource: vi.fn(() => ''), +})); + +import { debug, window, FunctionBreakpoint, SourceBreakpoint } from '../__mocks__/vscode'; +import { + FunctionBreakpointResolver, + parseFunctionName, + qualifiedName, +} from '../functionBreakpoints'; +import { SessionManager } from '../sessionManager'; +import { implementorsOf, getSourceOffsets, getMethodSource } from '../browserQueries'; + +const mockImplementors = vi.mocked(implementorsOf); +const mockOffsets = vi.mocked(getSourceOffsets); +const mockSource = vi.mocked(getMethodSource); + +describe('parseFunctionName', () => { + it('reads a bare selector as "whoever implements it"', () => { + expect(parseFunctionName('balance')).toEqual({ isMeta: false, selector: 'balance' }); + }); + + it('reads a keyword selector', () => { + expect(parseFunctionName('at:put:')).toEqual({ isMeta: false, selector: 'at:put:' }); + }); + + it('reads a binary selector, which looks nothing like an identifier', () => { + expect(parseFunctionName('//')).toEqual({ isMeta: false, selector: '//' }); + expect(parseFunctionName(',')).toEqual({ isMeta: false, selector: ',' }); + }); + + it('reads an instance-side qualified name', () => { + expect(parseFunctionName('Account>>balance')).toEqual({ + className: 'Account', + isMeta: false, + selector: 'balance', + }); + }); + + it('reads a class-side qualified name', () => { + expect(parseFunctionName('Account class>>new')).toEqual({ + className: 'Account', + isMeta: true, + selector: 'new', + }); + }); + + it('tolerates the spacing a Smalltalker actually types', () => { + expect(parseFunctionName(' Account class >> at:put: ')).toEqual({ + className: 'Account', + isMeta: true, + selector: 'at:put:', + }); + }); + + it('tolerates a # on the selector', () => { + expect(parseFunctionName('Account>>#balance')).toEqual({ + className: 'Account', + isMeta: false, + selector: 'balance', + }); + expect(parseFunctionName('#balance')).toEqual({ isMeta: false, selector: 'balance' }); + }); + + it('keeps a qualified keyword selector whole', () => { + expect(parseFunctionName('Dictionary>>at:ifAbsent:')?.selector).toBe('at:ifAbsent:'); + }); + + it('rejects empty input', () => { + expect(parseFunctionName(' ')).toBeNull(); + }); + + it('rejects a malformed qualified name rather than reading it as a selector', () => { + // '>>' present but the class half is not a class name — treating the whole + // string as a selector would look up something that cannot exist. + expect(parseFunctionName('123>>balance')).toBeNull(); + expect(parseFunctionName('>>balance')).toBeNull(); + }); +}); + +describe('qualifiedName', () => { + const target = { + dictName: 'Globals', + className: 'Account', + isMeta: false, + selector: 'balance', + category: 'accessing', + }; + + it('names the instance side plainly', () => { + expect(qualifiedName(target)).toBe('Account>>balance'); + }); + + it('names the class side the way Smalltalk writes it', () => { + expect(qualifiedName({ ...target, isMeta: true, selector: 'new' })).toBe('Account class>>new'); + }); +}); + +describe('FunctionBreakpointResolver', () => { + function makeSessionManager(hasSession = true) { + return { + getSelectedSession: vi.fn(() => + hasSession ? { id: 1, gci: {}, handle: 'h', login: {}, stoneVersion: '3.7.5' } : undefined, + ), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; + } + + const account = { + dictName: 'Globals', + className: 'Account', + isMeta: false, + selector: 'balance', + category: 'accessing', + }; + const savings = { ...account, className: 'SavingsAccount' }; + + const warn = () => vi.mocked(window.showWarningMessage); + const added = () => vi.mocked(debug.addBreakpoints); + const removed = () => vi.mocked(debug.removeBreakpoints); + + beforeEach(() => { + debug.breakpoints = []; + added().mockClear(); + removed().mockClear(); + warn().mockClear(); + vi.mocked(window.showQuickPick).mockReset(); + mockImplementors.mockReset().mockReturnValue([]); + // 'balance\n^total' — first step point at 1-based 9, i.e. line 2 column 0. + mockOffsets.mockReset().mockReturnValue([9]); + mockSource.mockReset().mockReturnValue('balance\n^total'); + }); + + /** The SourceBreakpoint the resolver added, if any. */ + const addedSourceBreakpoint = () => + added().mock.calls.at(-1)?.[0]?.[0] as SourceBreakpoint | undefined; + + it('converts a single implementor into a located breakpoint on entry', async () => { + mockImplementors.mockReturnValue([account]); + const bp = new FunctionBreakpoint('balance'); + + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + + // The named breakpoint is replaced, not kept alongside. + expect(removed()).toHaveBeenCalledWith([bp]); + const source = addedSourceBreakpoint(); + expect(source).toBeInstanceOf(SourceBreakpoint); + // Offset 8 in 'balance\n^total' is line 1 (0-based), column 0. + expect(source?.location.range.start).toMatchObject({ line: 1, character: 0 }); + expect(source?.location.uri.toString()).toContain( + '/Globals/Account/instance/accessing/balance', + ); + }); + + it('does not prompt when only one class implements the selector', async () => { + mockImplementors.mockReturnValue([account]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('balance'), + ]); + expect(vi.mocked(window.showQuickPick)).not.toHaveBeenCalled(); + }); + + it('asks which class when several implement the selector', async () => { + mockImplementors.mockReturnValue([savings, account]); + vi.mocked(window.showQuickPick).mockResolvedValue({ target: savings }); + + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('balance'), + ]); + + const items = vi.mocked(window.showQuickPick).mock.calls[0][0] as { label: string }[]; + // Sorted, so the list doesn't reorder between invocations. + expect(items.map((i) => i.label)).toEqual(['Account', 'SavingsAccount']); + expect(addedSourceBreakpoint()?.location.uri.toString()).toContain('/SavingsAccount/'); + }); + + it('drops the breakpoint when the class picker is dismissed', async () => { + mockImplementors.mockReturnValue([savings, account]); + vi.mocked(window.showQuickPick).mockResolvedValue(undefined); + const bp = new FunctionBreakpoint('balance'); + + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + + // Leaving an unresolved one in the panel is the dead-breakpoint problem again. + expect(removed()).toHaveBeenCalledWith([bp]); + expect(added()).not.toHaveBeenCalled(); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('No class chosen')); + }); + + it('takes a qualified name at its word without prompting', async () => { + mockImplementors.mockReturnValue([savings, account]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('Account>>balance'), + ]); + expect(vi.mocked(window.showQuickPick)).not.toHaveBeenCalled(); + expect(addedSourceBreakpoint()?.location.uri.toString()).toContain( + '/Globals/Account/instance/', + ); + }); + + it('resolves a class-side qualified name to the metaclass', async () => { + const meta = { ...account, isMeta: true, selector: 'new', category: 'instance creation' }; + mockImplementors.mockReturnValue([meta]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('Account class>>new'), + ]); + expect(addedSourceBreakpoint()?.location.uri.toString()).toContain('/Account/class/'); + }); + + it('refuses a qualified name whose class does not implement it', async () => { + // Trusting the typing would set a breakpoint that silently never fires. + mockImplementors.mockReturnValue([savings]); + const bp = new FunctionBreakpoint('Account>>balance'); + + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + + expect(removed()).toHaveBeenCalledWith([bp]); + expect(added()).not.toHaveBeenCalled(); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('Nothing implements')); + }); + + it('says nothing implements an unknown selector', async () => { + mockImplementors.mockReturnValue([]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('noSuchThing'), + ]); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('Nothing implements #noSuchThing')); + }); + + it('explains a name that is not a method name at all', async () => { + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('>>oops'), + ]); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('is not a method name')); + }); + + it('asks for a login rather than failing silently', async () => { + await new FunctionBreakpointResolver(makeSessionManager(false)).handleAdded([ + new FunctionBreakpoint('balance'), + ]); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('No active GemStone session')); + }); + + it('refuses a method with no step points', async () => { + mockImplementors.mockReturnValue([account]); + mockOffsets.mockReturnValue([]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('balance'), + ]); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('no step points')); + }); + + it('reports a lookup that throws instead of swallowing it', async () => { + mockImplementors.mockImplementation(() => { + throw new Error('session busy'); + }); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('balance'), + ]); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('session busy')); + }); + + it('carries the enabled flag across the conversion', async () => { + mockImplementors.mockReturnValue([account]); + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new FunctionBreakpoint('balance', false), + ]); + expect(addedSourceBreakpoint()?.enabled).toBe(false); + }); + + it('ignores an ordinary source breakpoint', async () => { + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + new SourceBreakpoint({ uri: 'x', range: { start: {} } } as never), + ]); + expect(added()).not.toHaveBeenCalled(); + expect(removed()).not.toHaveBeenCalled(); + }); + + it('resolves the same name once when events overlap', async () => { + // Choosing a class is a prompt, so a second event can land mid-await. + mockImplementors.mockReturnValue([savings, account]); + let release: (v: unknown) => void = () => {}; + vi.mocked(window.showQuickPick).mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const resolver = new FunctionBreakpointResolver(makeSessionManager()); + const first = resolver.handleAdded([new FunctionBreakpoint('balance')]); + const second = resolver.handleAdded([new FunctionBreakpoint('balance')]); + + release({ target: account }); + await Promise.all([first, second]); + + expect(added()).toHaveBeenCalledTimes(1); + }); +}); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 758cac34..46aaa571 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -3,6 +3,7 @@ import { SessionManager, ActiveSession } from './sessionManager'; import { parseMethodUri } from './gemstoneFileSystemProvider'; import * as queries from './browserQueries'; import { GemStoneBreakpoint } from './browserQueries'; +import { FunctionBreakpointResolver } from './functionBreakpoints'; import { StepPointModel, StepPointInfo, @@ -95,10 +96,19 @@ export class BreakpointManager { /** Fires after breakpoints are pushed to the gem, so views can refresh. */ readonly onDidApply = this._onDidApply.event; + /** + * Turns a named (function) breakpoint into a located one on the method's + * entry. Kept here rather than wired separately so the conversion happens on + * the same event that applies everything else. + */ + private functionBreakpoints: FunctionBreakpointResolver; + constructor( private sessionManager: SessionManager, private stepPoints: StepPointModel, - ) {} + ) { + this.functionBreakpoints = new FunctionBreakpointResolver(sessionManager); + } register(context: vscode.ExtensionContext): void { context.subscriptions.push( @@ -664,7 +674,10 @@ export class BreakpointManager { // idempotent, and the removal it triggers re-enters here with nothing left // to prune, so this does not loop. if (event.added.length > 0) this.pruneOrphans(); - this.warnAboutFunctionBreakpoints(event.added); + // Resolving a name can need a prompt, so this runs on its own; the + // SourceBreakpoint it produces comes back through this handler and is + // applied like any other. + void this.functionBreakpoints.handleAdded(event.added); const session = this.sessionManager.getSelectedSession(); if (!session) return; @@ -680,26 +693,6 @@ export class BreakpointManager { } } - /** - * Say so when a *function* breakpoint is added — the kind VS Code's `+` button - * in the Breakpoints panel creates, named rather than located. - * - * Jasper only implements source breakpoints, so a function breakpoint sits in - * the list looking exactly like a working one and never fires. Left unsaid, - * that reads as "breakpoints are broken" rather than "this kind isn't wired - * up". Warn rather than delete it: the developer typed it deliberately, and - * silently removing what someone just typed is its own kind of confusing. - */ - private warnAboutFunctionBreakpoints(added: readonly vscode.Breakpoint[]): void { - const named = added.filter((bp) => bp instanceof vscode.FunctionBreakpoint); - if (named.length === 0) return; - vscode.window.showWarningMessage( - 'Jasper does not support function breakpoints (the + button in the Breakpoints panel) — ' + - 'they will never fire. Set a breakpoint in the method source instead: click the gutter, ' + - 'or put the caret on a step point and use Toggle Breakpoint at Cursor.', - ); - } - private refreshEditorsFor(uri: vscode.Uri): void { const uriStr = uri.toString(); for (const editor of vscode.window.visibleTextEditors) { diff --git a/client/src/functionBreakpoints.ts b/client/src/functionBreakpoints.ts new file mode 100644 index 00000000..f29cb83a --- /dev/null +++ b/client/src/functionBreakpoints.ts @@ -0,0 +1,264 @@ +import * as vscode from 'vscode'; +import { SessionManager, ActiveSession } from './sessionManager'; +import { buildMethodUri } from './gemstoneFileSystemProvider'; +import * as queries from './browserQueries'; +import { MethodSearchResult } from './browserQueries'; +import { buildLineStarts, lineOfOffset } from './stepPointModel'; + +/** A method name typed into the Breakpoints panel, taken apart. */ +export interface ParsedFunctionName { + /** Class the name named, or undefined when it named a bare selector. */ + className?: string; + isMeta: boolean; + selector: string; +} + +/** + * Read a name typed into VS Code's Breakpoints panel as Smalltalk method + * coordinates. + * + * Accepts what a Smalltalker would actually type: `Account>>balance`, + * `Account class>>new`, either with a `#` on the selector, and a bare `balance` + * meaning "whoever implements it". The selector half is taken verbatim rather + * than pattern-matched, because binary selectors (`+`, `,`, `//`) and keyword + * selectors (`at:put:`) are all legal and none of them look like an identifier. + * + * Returns null for something that can't be a method name at all. + */ +export function parseFunctionName(raw: string): ParsedFunctionName | null { + const name = raw.trim(); + if (name.length === 0) return null; + + // `Account class >> new` — metaclass first, since its class name half would + // otherwise match the instance-side pattern with 'Account class' left over. + const meta = name.match(/^([A-Za-z_]\w*)\s+class\s*>>\s*#?\s*(.+)$/); + if (meta) return { className: meta[1], isMeta: true, selector: meta[2].trim() }; + + const inst = name.match(/^([A-Za-z_]\w*)\s*>>\s*#?\s*(.+)$/); + if (inst) return { className: inst[1], isMeta: false, selector: inst[2].trim() }; + + // A bare selector. Reject anything holding '>>', which was a qualified name + // the patterns above failed on — a malformed class half, most likely. + if (name.includes('>>')) return null; + return { isMeta: false, selector: name.replace(/^#\s*/, '') }; +} + +/** How a name should be shown once it has been pinned to one class. */ +export function qualifiedName(target: MethodSearchResult): string { + return `${target.className}${target.isMeta ? ' class' : ''}>>${target.selector}`; +} + +/** + * Turns a *function* breakpoint — the kind VS Code's `+` button creates, named + * rather than located — into an ordinary breakpoint on the method's entry. + * + * Function breakpoints are the natural way to say "stop when this method runs" + * without going and finding it first, and GemStone can honour that: break at + * step point 1 and execution stops on entry. But VS Code leaves resolving the + * *name* entirely to the debugger, so unresolved it is inert. + * + * Rather than carry a second, parallel kind of breakpoint through the whole + * model, each one is **converted**: resolve the name to a class, work out where + * that method's first step point is, and replace it with a `SourceBreakpoint` + * there. It then behaves like every other breakpoint — a real red dot with a + * location, enable/disable, dying with its session, and showing up in the + * GemStone Breakpoints view — with no chance of the two kinds fighting over + * which one owns a method's breakpoints. + */ +export class FunctionBreakpointResolver { + /** + * Names being resolved right now. Choosing a class is a prompt, so a second + * change event can arrive mid-await; without this the same name would be + * resolved twice and set two breakpoints. + */ + private inFlight = new Set(); + + constructor(private sessionManager: SessionManager) {} + + /** Resolve and convert every function breakpoint among `added`. */ + async handleAdded(added: readonly vscode.Breakpoint[]): Promise { + const named = added.filter( + (bp): bp is vscode.FunctionBreakpoint => bp instanceof vscode.FunctionBreakpoint, + ); + for (const bp of named) { + if (this.inFlight.has(bp.functionName)) continue; + this.inFlight.add(bp.functionName); + try { + await this.convert(bp); + } finally { + this.inFlight.delete(bp.functionName); + } + } + } + + private async convert(bp: vscode.FunctionBreakpoint): Promise { + const session = this.sessionManager.getSelectedSession(); + if (!session) { + this.reject(bp, 'No active GemStone session — log in, then add the breakpoint again.'); + return; + } + + const parsed = parseFunctionName(bp.functionName); + if (!parsed) { + this.reject( + bp, + `"${bp.functionName}" is not a method name. Use a selector (balance), ` + + 'or qualify it (Account>>balance, Account class>>new).', + ); + return; + } + + let candidates: MethodSearchResult[]; + try { + candidates = this.findCandidates(session, parsed); + } catch (e) { + this.reject(bp, `Could not look up ${bp.functionName}: ${message(e)}`); + return; + } + + if (candidates.length === 0) { + this.reject(bp, `Nothing implements ${describe(parsed)}.`); + return; + } + + const target = + candidates.length === 1 ? candidates[0] : await this.chooseClass(candidates, parsed.selector); + if (!target) { + // The developer dismissed the picker. Drop the breakpoint rather than + // leave an unresolved one sitting in the panel looking live. + this.reject(bp, `No class chosen — breakpoint for ${parsed.selector} not set.`); + return; + } + + const entry = this.entryPosition(session, target); + if (!entry) { + this.reject(bp, `${qualifiedName(target)} has no step points to break at.`); + return; + } + + const uri = buildMethodUri({ + kind: 'method', + sessionId: session.id, + dictName: target.dictName, + className: target.className, + isMeta: target.isMeta, + category: target.category || 'other', + selector: target.selector, + environmentId: entry.environmentId, + }); + + // Replace, don't add alongside: the function breakpoint has done its job as + // a way of naming a method, and leaving it would show two rows for one break. + vscode.debug.removeBreakpoints([bp]); + vscode.debug.addBreakpoints([ + new vscode.SourceBreakpoint( + new vscode.Location(uri, new vscode.Position(entry.line - 1, entry.character)), + bp.enabled, + bp.condition, + bp.hitCondition, + bp.logMessage, + ), + ]); + } + + /** + * Every method the name could mean. A qualified name is taken at its word — a + * developer who wrote `Account>>balance` does not want a list — while a bare + * selector is looked up across the image. + */ + private findCandidates(session: ActiveSession, parsed: ParsedFunctionName): MethodSearchResult[] { + const environmentId = maxEnvironment(); + if (parsed.className === undefined) { + return queries + .implementorsOf(session, parsed.selector, environmentId) + .filter((m) => m.selector === parsed.selector); + } + // Confirm the named class really implements it, rather than trusting the + // typing and setting a breakpoint that silently never fires. + return queries + .implementorsOf(session, parsed.selector, environmentId) + .filter( + (m) => + m.className === parsed.className && + m.isMeta === parsed.isMeta && + m.selector === parsed.selector, + ); + } + + private async chooseClass( + candidates: MethodSearchResult[], + selector: string, + ): Promise { + const items = candidates + .map((target) => ({ + label: `${target.className}${target.isMeta ? ' class' : ''}`, + description: target.dictName, + detail: target.category, + target, + })) + .sort((a, b) => a.label.localeCompare(b.label)); + + const picked = await vscode.window.showQuickPick(items, { + title: `Break on entry to #${selector}`, + placeHolder: `${candidates.length} classes implement #${selector} — choose one`, + matchOnDescription: true, + }); + return picked?.target; + } + + /** + * Where the method's first step point sits, as a 1-based line and a 0-based + * column. Computed against the *stone's* source, which is the only copy + * available for a method whose editor was never opened. + */ + private entryPosition( + session: ActiveSession, + target: MethodSearchResult, + ): { line: number; character: number; environmentId: number } | null { + const environmentId = maxEnvironment(); + try { + const offsets = queries.getSourceOffsets( + session, + target.className, + target.isMeta, + target.selector, + environmentId, + ); + if (offsets.length === 0) return null; + + const source = queries.getMethodSource( + session, + target.className, + target.isMeta, + target.selector, + environmentId, + ); + const lineStarts = buildLineStarts(source); + const offset = offsets[0] - 1; // _sourceOffsets is 1-based + const line = lineOfOffset(lineStarts, offset); + return { line, character: offset - lineStarts[line], environmentId }; + } catch { + return null; + } + } + + /** Drop a function breakpoint that cannot be honoured, and say why. */ + private reject(bp: vscode.FunctionBreakpoint, reason: string): void { + vscode.debug.removeBreakpoints([bp]); + vscode.window.showWarningMessage(reason); + } +} + +function describe(parsed: ParsedFunctionName): string { + return parsed.className === undefined + ? `#${parsed.selector}` + : `${parsed.className}${parsed.isMeta ? ' class' : ''}>>${parsed.selector}`; +} + +function maxEnvironment(): number { + return vscode.workspace.getConfiguration('gemstone').get('maxEnvironment', 0); +} + +function message(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} From 66ca961e171620c723f7554b0a70f19b3f3e751b Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 10:57:30 -0700 Subject: [PATCH 09/19] Surface a function-breakpoint resolution that fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleAdded is fired without awaiting, since resolving a name can prompt. Any error inside it therefore became an unhandled rejection: the breakpoint stayed in the panel, unconverted and inert, with nothing said — the very failure the resolver exists to remove. Catch and report, and trace each decision (parsed name, candidates found, chosen class, resulting URI) to the GemStone log so a failure in the field is diagnosable without a debugger. Also guard the one input that would build a broken URI: implementorsOf reports no dictionary for a class not bound under its own name in the symbol list, and an empty dictionary segment yields a URI the file system provider cannot resolve. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/functionBreakpoints.test.ts | 29 +++++++++++++++ client/src/breakpointManager.ts | 3 +- client/src/functionBreakpoints.ts | 36 ++++++++++++++++++- 3 files changed, 66 insertions(+), 2 deletions(-) diff --git a/client/src/__tests__/functionBreakpoints.test.ts b/client/src/__tests__/functionBreakpoints.test.ts index e5d6a73d..f497e0fc 100644 --- a/client/src/__tests__/functionBreakpoints.test.ts +++ b/client/src/__tests__/functionBreakpoints.test.ts @@ -266,6 +266,35 @@ describe('FunctionBreakpointResolver', () => { expect(warn()).toHaveBeenCalledWith(expect.stringContaining('session busy')); }); + it('refuses a class whose dictionary could not be determined', async () => { + // implementorsOf reports '' for a class not bound under its own name; an + // empty dictionary segment builds a URI that resolves to nothing. + mockImplementors.mockReturnValue([{ ...account, dictName: '' }]); + const bp = new FunctionBreakpoint('balance'); + + await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + + expect(removed()).toHaveBeenCalledWith([bp]); + expect(added()).not.toHaveBeenCalled(); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('which dictionary')); + }); + + it('reports rather than swallows an unexpected failure', async () => { + // handleAdded is fired without await, so a rejection would otherwise vanish + // and leave the breakpoint sitting there doing nothing. + mockImplementors.mockReturnValue([account]); + mockSource.mockImplementation(() => { + throw new Error('boom'); + }); + const bp = new FunctionBreakpoint('balance'); + + await expect( + new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]), + ).resolves.toBeUndefined(); + expect(warn()).toHaveBeenCalled(); + expect(removed()).toHaveBeenCalledWith([bp]); + }); + it('carries the enabled flag across the conversion', async () => { mockImplementors.mockReturnValue([account]); await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 46aaa571..a8b2b152 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -676,7 +676,8 @@ export class BreakpointManager { if (event.added.length > 0) this.pruneOrphans(); // Resolving a name can need a prompt, so this runs on its own; the // SourceBreakpoint it produces comes back through this handler and is - // applied like any other. + // applied like any other. handleAdded never rejects — it reports its own + // failures — so there is nothing here for a caller to handle. void this.functionBreakpoints.handleAdded(event.added); const session = this.sessionManager.getSelectedSession(); diff --git a/client/src/functionBreakpoints.ts b/client/src/functionBreakpoints.ts index f29cb83a..5f45344e 100644 --- a/client/src/functionBreakpoints.ts +++ b/client/src/functionBreakpoints.ts @@ -4,6 +4,7 @@ import { buildMethodUri } from './gemstoneFileSystemProvider'; import * as queries from './browserQueries'; import { MethodSearchResult } from './browserQueries'; import { buildLineStarts, lineOfOffset } from './stepPointModel'; +import { logInfo } from './gciLog'; /** A method name typed into the Breakpoints panel, taken apart. */ export interface ParsedFunctionName { @@ -75,7 +76,14 @@ export class FunctionBreakpointResolver { constructor(private sessionManager: SessionManager) {} - /** Resolve and convert every function breakpoint among `added`. */ + /** + * Resolve and convert every function breakpoint among `added`. + * + * Never rejects. The caller fires this without awaiting (resolution can + * prompt), so a thrown error would otherwise vanish into an unhandled + * rejection and the breakpoint would just sit there doing nothing — the exact + * failure this class exists to remove. + */ async handleAdded(added: readonly vscode.Breakpoint[]): Promise { const named = added.filter( (bp): bp is vscode.FunctionBreakpoint => bp instanceof vscode.FunctionBreakpoint, @@ -85,6 +93,9 @@ export class FunctionBreakpointResolver { this.inFlight.add(bp.functionName); try { await this.convert(bp); + } catch (e) { + logInfo(`[breakpoints] resolving "${bp.functionName}" failed: ${message(e)}`); + this.reject(bp, `Could not set a breakpoint for ${bp.functionName}: ${message(e)}`); } finally { this.inFlight.delete(bp.functionName); } @@ -116,6 +127,11 @@ export class FunctionBreakpointResolver { return; } + logInfo( + `[breakpoints] "${bp.functionName}" parsed as ${describe(parsed)}; ` + + `${candidates.length} candidate(s): ${candidates.map(qualifiedName).join(', ') || 'none'}`, + ); + if (candidates.length === 0) { this.reject(bp, `Nothing implements ${describe(parsed)}.`); return; @@ -136,6 +152,19 @@ export class FunctionBreakpointResolver { return; } + // implementorsOf reports no dictionary for a class not bound under its own + // name in the symbol list. The method URI needs one, and an empty segment + // builds a URI the file system provider cannot resolve — so say so rather + // than hand back a breakpoint that silently points nowhere. + if (target.dictName === '') { + this.reject( + bp, + `Could not tell which dictionary holds ${target.className} — ` + + 'set the breakpoint from the method source instead.', + ); + return; + } + const uri = buildMethodUri({ kind: 'method', sessionId: session.id, @@ -147,6 +176,11 @@ export class FunctionBreakpointResolver { environmentId: entry.environmentId, }); + logInfo( + `[breakpoints] ${qualifiedName(target)} entry is line ${entry.line} col ${entry.character}; ` + + `converting to ${uri.toString()}`, + ); + // Replace, don't add alongside: the function breakpoint has done its job as // a way of naming a method, and leaving it would show two rows for one break. vscode.debug.removeBreakpoints([bp]); From 94d20fcd9c8c0b907496c736b5b6f56f71fbe0ab Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 11:14:39 -0700 Subject: [PATCH 10/19] Let the developer finish typing a function breakpoint's name VS Code's + button in the Breakpoints panel creates the function breakpoint *first*, with an empty name, and only then opens it for inline editing. Two consequences, both wrong here: - the empty name arrived as an addition and was rejected as unresolvable, which removed the row and popped a warning before a single character could be typed; - the name the developer actually types arrives as a **change**, not an addition, so watching additions alone never saw it. Treat a blank name as "still being typed" and leave it strictly alone, and resolve from added and changed together. Editing an existing breakpoint's name now works for the same reason. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/breakpointManager.test.ts | 18 +++++ .../src/__tests__/functionBreakpoints.test.ts | 68 ++++++++++++++----- client/src/breakpointManager.ts | 8 ++- client/src/functionBreakpoints.ts | 18 +++-- 4 files changed, 88 insertions(+), 24 deletions(-) diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 943e4330..003c71e2 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -440,6 +440,24 @@ describe('BreakpointManager', () => { mockGetSourceOffsets.mockReturnValue([9]); }); + it('hands a name arriving as a change to the resolver, not just an addition', async () => { + // VS Code's + creates the breakpoint blank and opens it for editing, so the + // typed name arrives in `changed`. + const named = new FunctionBreakpoint('at:'); + debug.breakpoints = [named]; + + const manager = makeManager(); + const context = { + subscriptions: [] as unknown[], + } as unknown as import('vscode').ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ added: [], removed: [], changed: [named] }); + + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([named]); + }); + it('hands a named breakpoint to the resolver, which replaces it', async () => { // The + button in the Breakpoints panel makes one of these — a name with no // location. It is converted to a located breakpoint on the method's entry; diff --git a/client/src/__tests__/functionBreakpoints.test.ts b/client/src/__tests__/functionBreakpoints.test.ts index f497e0fc..bc85c40e 100644 --- a/client/src/__tests__/functionBreakpoints.test.ts +++ b/client/src/__tests__/functionBreakpoints.test.ts @@ -145,7 +145,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockReturnValue([account]); const bp = new FunctionBreakpoint('balance'); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); // The named breakpoint is replaced, not kept alongside. expect(removed()).toHaveBeenCalledWith([bp]); @@ -160,7 +160,7 @@ describe('FunctionBreakpointResolver', () => { it('does not prompt when only one class implements the selector', async () => { mockImplementors.mockReturnValue([account]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('balance'), ]); expect(vi.mocked(window.showQuickPick)).not.toHaveBeenCalled(); @@ -170,7 +170,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockReturnValue([savings, account]); vi.mocked(window.showQuickPick).mockResolvedValue({ target: savings }); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('balance'), ]); @@ -185,7 +185,7 @@ describe('FunctionBreakpointResolver', () => { vi.mocked(window.showQuickPick).mockResolvedValue(undefined); const bp = new FunctionBreakpoint('balance'); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); // Leaving an unresolved one in the panel is the dead-breakpoint problem again. expect(removed()).toHaveBeenCalledWith([bp]); @@ -195,7 +195,7 @@ describe('FunctionBreakpointResolver', () => { it('takes a qualified name at its word without prompting', async () => { mockImplementors.mockReturnValue([savings, account]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('Account>>balance'), ]); expect(vi.mocked(window.showQuickPick)).not.toHaveBeenCalled(); @@ -207,7 +207,7 @@ describe('FunctionBreakpointResolver', () => { it('resolves a class-side qualified name to the metaclass', async () => { const meta = { ...account, isMeta: true, selector: 'new', category: 'instance creation' }; mockImplementors.mockReturnValue([meta]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('Account class>>new'), ]); expect(addedSourceBreakpoint()?.location.uri.toString()).toContain('/Account/class/'); @@ -218,7 +218,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockReturnValue([savings]); const bp = new FunctionBreakpoint('Account>>balance'); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); expect(removed()).toHaveBeenCalledWith([bp]); expect(added()).not.toHaveBeenCalled(); @@ -227,21 +227,21 @@ describe('FunctionBreakpointResolver', () => { it('says nothing implements an unknown selector', async () => { mockImplementors.mockReturnValue([]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('noSuchThing'), ]); expect(warn()).toHaveBeenCalledWith(expect.stringContaining('Nothing implements #noSuchThing')); }); it('explains a name that is not a method name at all', async () => { - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('>>oops'), ]); expect(warn()).toHaveBeenCalledWith(expect.stringContaining('is not a method name')); }); it('asks for a login rather than failing silently', async () => { - await new FunctionBreakpointResolver(makeSessionManager(false)).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager(false)).handle([ new FunctionBreakpoint('balance'), ]); expect(warn()).toHaveBeenCalledWith(expect.stringContaining('No active GemStone session')); @@ -250,7 +250,7 @@ describe('FunctionBreakpointResolver', () => { it('refuses a method with no step points', async () => { mockImplementors.mockReturnValue([account]); mockOffsets.mockReturnValue([]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('balance'), ]); expect(warn()).toHaveBeenCalledWith(expect.stringContaining('no step points')); @@ -260,7 +260,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockImplementation(() => { throw new Error('session busy'); }); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('balance'), ]); expect(warn()).toHaveBeenCalledWith(expect.stringContaining('session busy')); @@ -272,7 +272,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockReturnValue([{ ...account, dictName: '' }]); const bp = new FunctionBreakpoint('balance'); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]); + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); expect(removed()).toHaveBeenCalledWith([bp]); expect(added()).not.toHaveBeenCalled(); @@ -289,22 +289,54 @@ describe('FunctionBreakpointResolver', () => { const bp = new FunctionBreakpoint('balance'); await expect( - new FunctionBreakpointResolver(makeSessionManager()).handleAdded([bp]), + new FunctionBreakpointResolver(makeSessionManager()).handle([bp]), ).resolves.toBeUndefined(); expect(warn()).toHaveBeenCalled(); expect(removed()).toHaveBeenCalledWith([bp]); }); + it('leaves a blank name alone — the developer is still typing', async () => { + // VS Code's + button creates the breakpoint empty and *then* opens it for + // editing. Rejecting the blank deleted the row before it could be typed in. + const bp = new FunctionBreakpoint(''); + + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); + + expect(removed()).not.toHaveBeenCalled(); + expect(added()).not.toHaveBeenCalled(); + expect(warn()).not.toHaveBeenCalled(); + }); + + it('leaves a whitespace-only name alone too', async () => { + await new FunctionBreakpointResolver(makeSessionManager()).handle([ + new FunctionBreakpoint(' '), + ]); + expect(warn()).not.toHaveBeenCalled(); + expect(removed()).not.toHaveBeenCalled(); + }); + + it('resolves the name that arrives as a change, not an addition', async () => { + // The typed name reaches us through onDidChangeBreakpoints' `changed` list; + // the manager passes added and changed together, so `handle` sees both. + mockImplementors.mockReturnValue([account]); + const typed = new FunctionBreakpoint('balance'); + + await new FunctionBreakpointResolver(makeSessionManager()).handle([typed]); + + expect(removed()).toHaveBeenCalledWith([typed]); + expect(addedSourceBreakpoint()).toBeInstanceOf(SourceBreakpoint); + }); + it('carries the enabled flag across the conversion', async () => { mockImplementors.mockReturnValue([account]); - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new FunctionBreakpoint('balance', false), ]); expect(addedSourceBreakpoint()?.enabled).toBe(false); }); it('ignores an ordinary source breakpoint', async () => { - await new FunctionBreakpointResolver(makeSessionManager()).handleAdded([ + await new FunctionBreakpointResolver(makeSessionManager()).handle([ new SourceBreakpoint({ uri: 'x', range: { start: {} } } as never), ]); expect(added()).not.toHaveBeenCalled(); @@ -322,8 +354,8 @@ describe('FunctionBreakpointResolver', () => { ); const resolver = new FunctionBreakpointResolver(makeSessionManager()); - const first = resolver.handleAdded([new FunctionBreakpoint('balance')]); - const second = resolver.handleAdded([new FunctionBreakpoint('balance')]); + const first = resolver.handle([new FunctionBreakpoint('balance')]); + const second = resolver.handle([new FunctionBreakpoint('balance')]); release({ target: account }); await Promise.all([first, second]); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index a8b2b152..ef98be54 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -674,11 +674,15 @@ export class BreakpointManager { // idempotent, and the removal it triggers re-enters here with nothing left // to prune, so this does not loop. if (event.added.length > 0) this.pruneOrphans(); + // Added *and* changed: VS Code's `+` button creates a function breakpoint + // with an empty name and only then opens it for editing, so the name the + // developer types arrives as a change rather than an addition. + // // Resolving a name can need a prompt, so this runs on its own; the // SourceBreakpoint it produces comes back through this handler and is - // applied like any other. handleAdded never rejects — it reports its own + // applied like any other. `handle` never rejects — it reports its own // failures — so there is nothing here for a caller to handle. - void this.functionBreakpoints.handleAdded(event.added); + void this.functionBreakpoints.handle([...event.added, ...event.changed]); const session = this.sessionManager.getSelectedSession(); if (!session) return; diff --git a/client/src/functionBreakpoints.ts b/client/src/functionBreakpoints.ts index 5f45344e..d89c0d96 100644 --- a/client/src/functionBreakpoints.ts +++ b/client/src/functionBreakpoints.ts @@ -77,16 +77,26 @@ export class FunctionBreakpointResolver { constructor(private sessionManager: SessionManager) {} /** - * Resolve and convert every function breakpoint among `added`. + * Resolve and convert every function breakpoint among `breakpoints`. + * + * Callers must pass **both** the added and the changed breakpoints. VS Code's + * `+` button creates the breakpoint *first*, with an empty name, and only then + * opens it for editing — so the name a developer types arrives as a *change*, + * not as an addition. Watching additions alone sees nothing but the blank. + * + * A blank name is therefore left strictly alone: it means "still being typed", + * and treating it as unresolvable deleted the row out from under the developer + * before they could type into it. * * Never rejects. The caller fires this without awaiting (resolution can * prompt), so a thrown error would otherwise vanish into an unhandled * rejection and the breakpoint would just sit there doing nothing — the exact * failure this class exists to remove. */ - async handleAdded(added: readonly vscode.Breakpoint[]): Promise { - const named = added.filter( - (bp): bp is vscode.FunctionBreakpoint => bp instanceof vscode.FunctionBreakpoint, + async handle(breakpoints: readonly vscode.Breakpoint[]): Promise { + const named = breakpoints.filter( + (bp): bp is vscode.FunctionBreakpoint => + bp instanceof vscode.FunctionBreakpoint && bp.functionName.trim().length > 0, ); for (const bp of named) { if (this.inFlight.has(bp.functionName)) continue; From f81aafcececb192b9152908d8ec526fcd155003d Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 11:21:17 -0700 Subject: [PATCH 11/19] Document that VS Code's Deactivate Breakpoints cannot reach GemStone The filled-dot button in the Breakpoints panel header greys every breakpoint out, which reads as "these are off now". But `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe the flag: Jasper's breakpoints stay armed in the gem and execution still stops on them. It sits one icon away from controls that do work, so it is worth naming rather than leaving to be discovered mid-debug. Disable All Breakpoints is the one that disarms them. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ccbb684..efd98ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Added - **Break on entry to a method by naming it.** The `+` button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one previously sat in the list indistinguishable from a working breakpoint and never fired. Typing a bare selector now looks up its implementors and asks which class you meant when there is more than one; a qualified `Account>>balance` or `Account class>>new` is taken at its word, though still checked against the image rather than trusted, since a typo would otherwise set a breakpoint that silently never fires. The name is then *converted* into an ordinary breakpoint on the method's first step point rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the GemStone Breakpoints view, and cannot fight a source breakpoint over which of them owns a method's breaks. A name that resolves to nothing is dropped with an explanation instead of left sitting there. +- **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. - **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) - **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) diff --git a/README.md b/README.md index 3114f30f..bf71d0ce 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,11 @@ window. Within a session they are re-applied after a recompile. Jasper by topaz or a `halt` left in the code - **Clear All Breakpoints in Method** drops every breakpoint in the method you are in +- **Avoid VS Code's own "Deactivate Breakpoints"** button (the filled-dot icon in + the Breakpoints panel header). It greys the breakpoints out in the panel, but + the VS Code API exposes no way for an extension to observe that state — so + Jasper never hears about it and GemStone keeps stopping on them. Use + **Disable All Breakpoints** instead, which disarms them in the gem - **Break on entry by name** — the `+` button in VS Code's Breakpoints panel takes a method name instead of a location. Type a selector (`balance`) and Jasper finds the implementors, asking which class you meant when there is more From ab269a2fd79c45bf0b6ccb46dde1e7f1d06d21f5 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 11:39:17 -0700 Subject: [PATCH 12/19] Search every method environment for a named breakpoint's implementors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `gemstone.maxEnvironment` is a ceiling, not a selection, and the resolver passed it straight to implementorsOf as the environment to search. On a stone configured above 0 that skipped environment 0 — where practically every method lives — so a perfectly ordinary selector came back as "Nothing implements #usesPrimary" while the Explorer listed it two panes away. Sweep 0..maxEnvironment and dedupe by class and selector, which is what the standalone senders and implementors commands already do. Each candidate now carries the environment it was found in, so the breakpoint is set against that environment rather than the configured ceiling — the same bug one step later. Verified against the dev stone: implementorsOf itself was answering correctly all along. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/__tests__/functionBreakpoints.test.ts | 55 ++++++++++++++++- client/src/functionBreakpoints.ts | 59 ++++++++++++------- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/client/src/__tests__/functionBreakpoints.test.ts b/client/src/__tests__/functionBreakpoints.test.ts index bc85c40e..031e2eba 100644 --- a/client/src/__tests__/functionBreakpoints.test.ts +++ b/client/src/__tests__/functionBreakpoints.test.ts @@ -8,7 +8,14 @@ vi.mock('../browserQueries', () => ({ getMethodSource: vi.fn(() => ''), })); -import { debug, window, FunctionBreakpoint, SourceBreakpoint } from '../__mocks__/vscode'; +import { + debug, + window, + FunctionBreakpoint, + SourceBreakpoint, + __setConfig, + __resetConfig, +} from '../__mocks__/vscode'; import { FunctionBreakpointResolver, parseFunctionName, @@ -126,6 +133,7 @@ describe('FunctionBreakpointResolver', () => { const removed = () => vi.mocked(debug.removeBreakpoints); beforeEach(() => { + __resetConfig(); debug.breakpoints = []; added().mockClear(); removed().mockClear(); @@ -327,6 +335,51 @@ describe('FunctionBreakpointResolver', () => { expect(addedSourceBreakpoint()).toBeInstanceOf(SourceBreakpoint); }); + it('finds an environment-0 method when maxEnvironment is above 0', async () => { + // gemstone.maxEnvironment is a ceiling, not a selection. Searching only that + // number skipped environment 0, where practically every method lives, so on + // such a stone nothing was ever found. + __setConfig('gemstone', 'maxEnvironment', 2); + mockImplementors.mockImplementation((_session, _selector, env) => (env === 0 ? [account] : [])); + + await new FunctionBreakpointResolver(makeSessionManager()).handle([ + new FunctionBreakpoint('balance'), + ]); + + expect(mockImplementors.mock.calls.map((c) => c[2])).toEqual([0, 1, 2]); + expect(warn()).not.toHaveBeenCalled(); + expect(addedSourceBreakpoint()).toBeInstanceOf(SourceBreakpoint); + }); + + it('sets the breakpoint against the environment the method was found in', async () => { + __setConfig('gemstone', 'maxEnvironment', 2); + mockImplementors.mockImplementation((_session, _selector, env) => (env === 1 ? [account] : [])); + + await new FunctionBreakpointResolver(makeSessionManager()).handle([ + new FunctionBreakpoint('balance'), + ]); + + // Not the configured ceiling of 2 — the environment that actually had it. + expect(mockOffsets).toHaveBeenCalledWith(expect.anything(), 'Account', false, 'balance', 1); + // Decoded, because the URI library percent-encodes '=' in the query. + expect(decodeURIComponent(addedSourceBreakpoint()?.location.uri.toString() ?? '')).toContain( + 'env=1', + ); + }); + + it('does not offer the same class twice when it appears in two environments', async () => { + __setConfig('gemstone', 'maxEnvironment', 2); + mockImplementors.mockReturnValue([account]); + + await new FunctionBreakpointResolver(makeSessionManager()).handle([ + new FunctionBreakpoint('balance'), + ]); + + // Three passes all report Account; one candidate means no needless picker. + expect(vi.mocked(window.showQuickPick)).not.toHaveBeenCalled(); + expect(added()).toHaveBeenCalledTimes(1); + }); + it('carries the enabled flag across the conversion', async () => { mockImplementors.mockReturnValue([account]); await new FunctionBreakpointResolver(makeSessionManager()).handle([ diff --git a/client/src/functionBreakpoints.ts b/client/src/functionBreakpoints.ts index d89c0d96..1b9ecf39 100644 --- a/client/src/functionBreakpoints.ts +++ b/client/src/functionBreakpoints.ts @@ -44,6 +44,14 @@ export function parseFunctionName(raw: string): ParsedFunctionName | null { return { isMeta: false, selector: name.replace(/^#\s*/, '') }; } +/** + * An implementor, together with the method-dictionary environment it was found + * in. `MethodSearchResult` has no environment field, but the search runs once + * per environment, so the caller has to remember which pass produced a hit — + * without it the breakpoint would be set against the wrong environment. + */ +export type Candidate = MethodSearchResult & { environmentId: number }; + /** How a name should be shown once it has been pinned to one class. */ export function qualifiedName(target: MethodSearchResult): string { return `${target.className}${target.isMeta ? ' class' : ''}>>${target.selector}`; @@ -129,7 +137,7 @@ export class FunctionBreakpointResolver { return; } - let candidates: MethodSearchResult[]; + let candidates: Candidate[]; try { candidates = this.findCandidates(session, parsed); } catch (e) { @@ -210,29 +218,36 @@ export class FunctionBreakpointResolver { * developer who wrote `Account>>balance` does not want a list — while a bare * selector is looked up across the image. */ - private findCandidates(session: ActiveSession, parsed: ParsedFunctionName): MethodSearchResult[] { - const environmentId = maxEnvironment(); - if (parsed.className === undefined) { - return queries - .implementorsOf(session, parsed.selector, environmentId) - .filter((m) => m.selector === parsed.selector); + private findCandidates(session: ActiveSession, parsed: ParsedFunctionName): Candidate[] { + // Sweep environments 0..maxEnvironment rather than searching the maximum + // alone. `gemstone.maxEnvironment` is a ceiling, not a selection — querying + // only that number skips environment 0, where practically every method + // lives, so on a stone configured above 0 nothing would ever be found. + const maxEnv = maxEnvironment(); + const found: Candidate[] = []; + const seen = new Set(); + + for (let environmentId = 0; environmentId <= maxEnv; environmentId++) { + for (const m of queries.implementorsOf(session, parsed.selector, environmentId)) { + if (m.selector !== parsed.selector) continue; + // Confirm a named class really implements it, rather than trusting the + // typing and setting a breakpoint that silently never fires. + if (parsed.className !== undefined) { + if (m.className !== parsed.className || m.isMeta !== parsed.isMeta) continue; + } + const key = `${m.className}|${m.isMeta}|${m.selector}`; + if (seen.has(key)) continue; + seen.add(key); + found.push({ ...m, environmentId }); + } } - // Confirm the named class really implements it, rather than trusting the - // typing and setting a breakpoint that silently never fires. - return queries - .implementorsOf(session, parsed.selector, environmentId) - .filter( - (m) => - m.className === parsed.className && - m.isMeta === parsed.isMeta && - m.selector === parsed.selector, - ); + return found; } private async chooseClass( - candidates: MethodSearchResult[], + candidates: Candidate[], selector: string, - ): Promise { + ): Promise { const items = candidates .map((target) => ({ label: `${target.className}${target.isMeta ? ' class' : ''}`, @@ -257,9 +272,11 @@ export class FunctionBreakpointResolver { */ private entryPosition( session: ActiveSession, - target: MethodSearchResult, + target: Candidate, ): { line: number; character: number; environmentId: number } | null { - const environmentId = maxEnvironment(); + // The environment the method was actually found in — not the configured + // ceiling, which is very likely a different one. + const environmentId = target.environmentId; try { const offsets = queries.getSourceOffsets( session, From 61f361757eca5f0e56c740862adbf575f368bbd8 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 11:59:50 -0700 Subject: [PATCH 13/19] Recompiling a method clears its breakpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applying them to the new method was wrong twice over. The recompile replaces the GsNMethod, so the gem's breakpoints on the old one are unreachable — and the new method's step point 4 may be an entirely different expression after an edit, so re-applying moves the breakpoint somewhere the developer never put it. The expectation is simply that a recompile clears them. Drop them from VS Code's list as well as the gem, so the gutter, the Breakpoints panel and the GemStone Breakpoints view agree — the same rule that already applies when a session logs out. The step point cache is still invalidated, since the offsets have moved either way. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 +- README.md | 15 ++-- .../src/__tests__/breakpointManager.test.ts | 69 +++++++++++++++++++ client/src/breakpointManager.ts | 42 +++++++---- 4 files changed, 108 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efd98ac1..4c8d61f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,9 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i - **Break on entry to a method by naming it.** The `+` button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one previously sat in the list indistinguishable from a working breakpoint and never fired. Typing a bare selector now looks up its implementors and asks which class you meant when there is more than one; a qualified `Account>>balance` or `Account class>>new` is taken at its word, though still checked against the image rather than trusted, since a typo would otherwise set a breakpoint that silently never fires. The name is then *converted* into an ordinary breakpoint on the method's first step point rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the GemStone Breakpoints view, and cannot fight a source breakpoint over which of them owns a method's breaks. A name that resolves to nothing is dropped with an explanation instead of left sitting there. - **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. +- **A GemStone breakpoint goes away when the method it was set in is recompiled.** The recompile replaces the `GsNMethod`, so the gem's breakpoints on the old one are unreachable — and after an edit the same step point number may be a different expression, so re-applying would move the breakpoint somewhere the developer never put it. It is dropped from VS Code's list too, so the gutter, the Breakpoints panel and the GemStone Breakpoints view agree. - **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) -- **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. GemStone breakpoints are per-gem state that no `commit` persists, so Jasper re-applies them on login and after a recompile. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A Breakpoints view in the GemStone sidebar.** Lists what the current session's gem actually holds — grouped by class and method, each row naming the step point the breakpoint resolved to, with an enable checkbox and a click that opens the method with the caret on that step point. It is deliberately the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set (from topaz, another tool, or a `halt` left in the code), which were previously invisible right up until execution stopped on one. ([#262](https://github.com/GemTalk/Jasper/issues/262)) diff --git a/README.md b/README.md index bf71d0ce..d0da0720 100644 --- a/README.md +++ b/README.md @@ -237,11 +237,16 @@ Breakpoints live in VS Code's own breakpoint list, so the familiar gutter, checkbox and Enable/Disable/Remove All controls all drive GemStone. Each one is applied to the session as a step-point break. -**A GemStone breakpoint dies with its session.** It lives in the gem, not the -repository — no `commit` persists it — so logging out clears the breakpoint from -VS Code's list too, rather than leaving a marker for something that no longer -exists. Unlike a breakpoint on a file, it does not come back when you reopen the -window. Within a session they are re-applied after a recompile. +**A GemStone breakpoint is tied to the compiled method it was set in.** It lives +in the gem, not the repository — no `commit` persists it — so it goes away when +the thing it was set in goes away: + +- **logging out** clears it, from VS Code's list as well as the gem, so no marker + is left for something that no longer exists. Unlike a breakpoint on a file, it + does not come back when you reopen the window; +- **recompiling the method** clears it too. The recompile replaces the method, and + after an edit the same step point may be a different expression — so the + breakpoint is dropped rather than quietly moved somewhere you didn't put it. - **Line breakpoints** — click the editor gutter in a `gemstone://` method. A gutter click means "this line", and lands on the leftmost step point on it diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 003c71e2..ceec25a5 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -527,6 +527,75 @@ describe('BreakpointManager', () => { }); }); + describe('invalidateForUri', () => { + it('drops the method’s breakpoints when it is recompiled', () => { + // A breakpoint belongs to the code it was set in. After an edit, "step + // point 4" may be a different expression, so moving it silently would be + // worse than losing it — and a recompiled method's old breaks are + // unreachable in the gem anyway. + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + const mine = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))); + const other = new SourceBreakpoint( + new Location( + Uri.parse('gemstone://1/Globals/Array/instance/accessing/size'), + new Position(0, 0), + ), + ); + const fileBp = new SourceBreakpoint( + new Location(Uri.parse('file:///a.ts'), new Position(1, 0)), + ); + debug.breakpoints = [mine, other, fileBp]; + + const manager = makeManager(); + manager.applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 1, enabled: true }]); + expect(manager.appliedFor(Uri.parse(METHOD_URI))).toHaveLength(1); + + manager.invalidateForUri(Uri.parse(METHOD_URI)); + + // Gone from VS Code's list, and only this method's — another method's + // breakpoint and a file breakpoint are untouched. + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([mine]); + expect(debug.breakpoints).toEqual([other, fileBp]); + expect(manager.appliedFor(Uri.parse(METHOD_URI))).toHaveLength(0); + }); + + it('does not re-set the breakpoints on the new method', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))), + ]; + + const manager = makeManager(); + mockSetBreakAtStepPoint.mockClear(); + manager.invalidateForUri(Uri.parse(METHOD_URI)); + + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('is harmless for a method that had no breakpoints', () => { + debug.breakpoints = []; + makeManager().invalidateForUri(Uri.parse(METHOD_URI)); + expect(vi.mocked(debug.removeBreakpoints)).not.toHaveBeenCalled(); + }); + + it('re-queries step points afterwards, since the offsets may have moved', () => { + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + + const manager = makeManager(); + manager.applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 1, enabled: true }]); + const before = mockGetSourceOffsets.mock.calls.length; + + manager.invalidateForUri(Uri.parse(METHOD_URI)); + manager.applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 1, enabled: true }]); + + expect(mockGetSourceOffsets.mock.calls.length).toBeGreaterThan(before); + }); + }); + describe('clearAllForSession', () => { it('forgets a logged-out session, so nothing is re-pushed for it', () => { mockGetMethodSource.mockReturnValue('foo\n^1'); diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index ef98be54..b82383ff 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -72,11 +72,14 @@ const disabledDecoration = vscode.window.createTextEditorDecorationType({ * It is **not** a durable record, though, and deliberately so. GemStone method * breakpoints are per-gem VM state: they do not survive logout, and a `commit` * does not persist them (verified against 3.6.2 and 3.7.5). A breakpoint that - * outlived its session would be a marker pointing at a gem that no longer - * exists — promising to stop execution it cannot stop. So logging out takes the - * session's breakpoints out of VS Code's list with it, and anything VS Code's - * own cross-restart persistence brings back is pruned. See `pruneOrphans` and - * `clearAllForSession`. + * outlived the thing it was set in would be a marker promising to stop execution + * it cannot stop. So a breakpoint is dropped from VS Code's list — not just the + * gem — when either of those things goes away: + * + * - the **session** logs out (`clearAllForSession`), and anything VS Code's own + * cross-restart persistence brings back is pruned (`pruneOrphans`); + * - the **method** is recompiled (`invalidateForUri`), since the new method is a + * different object and the same step point may now be different code. * * Step point precision rides on the breakpoint's **column**: a gutter click has * none and means "the leftmost step point on this line", while an inline @@ -557,21 +560,30 @@ export class BreakpointManager { // ── Lifecycle ──────────────────────────────────────────── /** - * Called after a method is recompiled. Recompiling replaces the `GsNMethod`, - * so the gem's breakpoints on the old one are gone and its step point offsets - * may have moved — drop the cache and re-apply from VS Code's model. + * Called after a method is recompiled. Its breakpoints go away. + * + * Recompiling replaces the `GsNMethod`, so the gem's breakpoints on the old + * one are unreachable and its step point offsets may have moved. They are + * dropped rather than re-applied to the new method: a breakpoint belongs to + * the code it was set in, and after an edit "step point 4" may be a different + * expression entirely — silently moving it is worse than losing it. Removing + * them from VS Code's list as well is what makes the gutter, the Breakpoints + * panel and the GemStone Breakpoints view all agree, which is the same rule + * that applies when a session logs out. */ invalidateForUri(uri: vscode.Uri): void { this.stepPoints.invalidate(uri); this.applied.delete(uri.toString()); - const session = this.sessionManager.getSelectedSession(); - if (!session) return; - if (readVsCodeBreakpoints(uri).length === 0) { - this.refreshEditorsFor(uri); - return; - } - this.applyToUri(session, uri); + // Removing these re-enters onBreakpointsChanged with none left for the + // method, which clears the gem's breaks and refreshes the view. + const stale = gemstoneBreakpoints().filter( + (bp) => bp.location.uri.toString() === uri.toString(), + ); + if (stale.length > 0) vscode.debug.removeBreakpoints(stale); + + this.refreshEditorsFor(uri); + this._onDidApply.fire(); } /** Called when a session logs out — its gem, and our view of it, are gone. */ From e571af7357017f763818f95bf08e6bcb0b7422bc Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 12:11:10 -0700 Subject: [PATCH 14/19] Bring the changelog in line with what shipped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two claims were left over from the first design, both now false: that VS Code's breakpoint list is a durable record surviving a restart (breakpoints die with the session), and that a disabled breakpoint's marker is dashed (it is grey and faded — the dashed border was too subtle to see). Also reorder the section so the headline feature leads rather than sitting mid-list behind the lifecycle rules, and move the Deactivate Breakpoints limitation under Documentation, which is what it is. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8d61f6..10c6dcf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,13 +6,12 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Added -- **Break on entry to a method by naming it.** The `+` button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one previously sat in the list indistinguishable from a working breakpoint and never fired. Typing a bare selector now looks up its implementors and asks which class you meant when there is more than one; a qualified `Account>>balance` or `Account class>>new` is taken at its word, though still checked against the image rather than trusted, since a typo would otherwise set a breakpoint that silently never fires. The name is then *converted* into an ordinary breakpoint on the method's first step point rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the GemStone Breakpoints view, and cannot fight a source breakpoint over which of them owns a method's breaks. A name that resolves to nothing is dropped with an explanation instead of left sitting there. -- **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. -- **A GemStone breakpoint goes away when the method it was set in is recompiled.** The recompile replaces the `GsNMethod`, so the gem's breakpoints on the old one are unreachable — and after an edit the same step point number may be a different expression, so re-applying would move the breakpoint somewhere the developer never put it. It is dropped from VS Code's list too, so the gutter, the Breakpoints panel and the GemStone Breakpoints view agree. -- **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) -- **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, which makes it the durable record: it survives a restart, and the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls drive GemStone through it. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — dashed when disabled. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Breakpoints you can set, aim, disable and manage.** Setting a breakpoint used to mean a gutter click, which lands on the leftmost step point of the line — but a Smalltalk line routinely holds several, so the one you wanted was often not the one you got, and there was no way to disable a breakpoint, act on all of them at once, or see what the session actually had. Breakpoints now live in VS Code's own breakpoint list, so the gutter, the per-breakpoint checkbox and the built-in Enable/Disable/Remove All controls all drive GemStone through it. **Toggle Breakpoint at Cursor** (`Shift+F9`) breaks at the step point under the caret rather than the line's first, and the token that will actually break is outlined — grey and faded when disabled, matching the grey the gutter dot goes. A disabled breakpoint is kept in the gem as set-then-disabled, so re-arming it is instant. **Enable All**, **Disable All** and **Remove All Breakpoints** cover every GemStone breakpoint, including ones set outside Jasper. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Step points are numbered where they are, without getting in the way.** Step point numbers are drawn as inlay hints — VS Code's own dim, recessive style, suppressed or restyled by its `editor.inlayHints.*` settings like any other — and each number is clickable to toggle a breakpoint at that step point. `gemstone.stepPoints.display` decides when they show: `debugging` (the default, so they appear while a debug session runs and stay out of the way while you are reading or writing code), `always`, or `off`, with **Toggle Step Point Numbers** to flip them. Whatever the setting, hovering a step point reports its number and breakpoint state with links to set, clear, enable or disable it. Numbers are withheld while a buffer has unsaved edits, since the stone's offsets no longer describe the text on screen and a wrong number is worse than none. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A Breakpoints view in the GemStone sidebar.** Lists what the current session's gem actually holds — grouped by class and method, each row naming the step point the breakpoint resolved to, with an enable checkbox and a click that opens the method with the caret on that step point. It is deliberately the gem's truth rather than a copy of VS Code's list, so it also surfaces breakpoints Jasper never set (from topaz, another tool, or a `halt` left in the code), which were previously invisible right up until execution stopped on one. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Break on entry to a method by naming it.** The `+` button in VS Code's Breakpoints panel takes a method name rather than a location, and VS Code leaves resolving that name entirely to the debugger — so one previously sat in the list indistinguishable from a working breakpoint and never fired. Typing a bare selector now looks up its implementors and asks which class you meant when there is more than one; a qualified `Account>>balance` or `Account class>>new` is taken at its word, though still checked against the image rather than trusted, since a typo would otherwise set a breakpoint that silently never fires. The name is then *converted* into an ordinary breakpoint on the method's first step point rather than carried as a second kind of breakpoint — so it gains a real location and red dot, enable/disable, and a row in the GemStone Breakpoints view, and cannot fight a source breakpoint over which of them owns a method's breaks. A name that resolves to nothing is dropped with an explanation instead of left sitting there. +- **A GemStone breakpoint dies with its session.** VS Code persists its breakpoint list across restarts, which is right for a file and wrong for a gem: a GemStone breakpoint lives in the gem, so a restored marker points at a session that no longer exists — a red dot promising to stop execution that cannot stop anything. Breakpoints are now dropped from VS Code's list when their session logs out, and any that a startup restore brings back are pruned. (Method URIs carry the session id, so this is scoped per session — logging one session out leaves another's alone.) +- **A GemStone breakpoint goes away when the method it was set in is recompiled.** The recompile replaces the `GsNMethod`, so the gem's breakpoints on the old one are unreachable — and after an edit the same step point number may be a different expression, so re-applying would move the breakpoint somewhere the developer never put it. It is dropped from VS Code's list too, so the gutter, the Breakpoints panel and the GemStone Breakpoints view agree. ### Changed @@ -22,6 +21,10 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i - **A gutter breakpoint could be set one step point later than the line asked for.** Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. +### Documentation + +- **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. + ## [1.8.13] - 2026-08-20 A follow-up release for **GemStone Search**: correctness fixes for multi-session and multi-environment use, matching and debounce repairs found by a review pass over the feature, one naming pass, and the senders/implementors counts moving off the method source. From 38c1dc971a7fa72f214b96027b0102594b6a3e78 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Mon, 24 Aug 2026 12:38:18 -0700 Subject: [PATCH 15/19] Warn that a breakpoint condition will not be honoured MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit VS Code's Edit Breakpoint accepts a condition, a hit count or a log message, and honouring them is the debugger's job. Jasper does not implement any of the three, so the breakpoint stopped every time it was reached with nothing said — the worst shape this failure takes, because the developer wrote down a precise intent and the UI accepted it. Warn instead. The fields keep riding along through enable/disable and name-conversion, so nothing is lost when conditional breakpoints arrive (#277). Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + README.md | 5 + .../src/__tests__/breakpointManager.test.ts | 97 +++++++++++++++++++ client/src/breakpointManager.ts | 30 ++++++ 4 files changed, 133 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 10c6dcf1..6066e560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Documentation +- **Setting a breakpoint condition now says it will not be honoured.** VS Code's *Edit Breakpoint* accepts a condition, a hit count or a log message, and all three are the debugger's job — Jasper does not implement them, so the breakpoint stopped every time it was reached with nothing to say so. That is the worst shape this failure takes: the developer wrote down a precise intent, the UI accepted it, and execution ignored it. It now warns. The fields are still carried across enable/disable and name-conversion, so nothing is lost when conditional breakpoints do arrive ([#277](https://github.com/GemTalk/Jasper/issues/277)). - **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. ## [1.8.13] - 2026-08-20 diff --git a/README.md b/README.md index d0da0720..5a6ec508 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,11 @@ the thing it was set in goes away: Jasper by topaz or a `halt` left in the code - **Clear All Breakpoints in Method** drops every breakpoint in the method you are in +- **Not honoured: conditions, hit counts and log messages.** VS Code's *Edit + Breakpoint* accepts all three; GemStone breakpoints stop every time the step + point is reached, so Jasper warns when you set one rather than quietly ignoring + it. Conditional breakpoints are tracked under + [#277](https://github.com/GemTalk/Jasper/issues/277) - **Avoid VS Code's own "Deactivate Breakpoints"** button (the filled-dot icon in the Breakpoints panel header). It greys the breakpoints out in the panel, but the VS Code API exposes no way for an extension to observe that state — so diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index ceec25a5..5bf539f2 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -477,6 +477,103 @@ describe('BreakpointManager', () => { }); }); + describe('conditions, hit counts and log messages', () => { + /** Drive the manager through the change event, the way VS Code does. */ + function fire(event: { added?: unknown[]; removed?: unknown[]; changed?: unknown[] }) { + const manager = makeManager(); + const context = { + subscriptions: [] as unknown[], + } as unknown as import('vscode').ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ + added: event.added ?? [], + removed: event.removed ?? [], + changed: event.changed ?? [], + }); + } + + const withFields = (fields: { + condition?: string; + hitCondition?: string; + logMessage?: string; + }) => + new SourceBreakpoint( + new Location(Uri.parse(METHOD_URI), new Position(0, 0)), + true, + fields.condition, + fields.hitCondition, + fields.logMessage, + ); + + beforeEach(() => { + vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + vi.mocked(window.showWarningMessage).mockClear(); + mockGetMethodSource.mockReturnValue('foo\n^1'); + mockGetSourceOffsets.mockReturnValue([1, 5]); + }); + + it('warns that a condition is ignored, rather than silently not honouring it', () => { + fire({ added: [withFields({ condition: 'x > 3' })] }); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( + expect.stringContaining('ignore conditions'), + ); + }); + + it('warns for a hit count', () => { + fire({ added: [withFields({ hitCondition: '5' })] }); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalled(); + }); + + it('warns for a log message (a logpoint)', () => { + fire({ added: [withFields({ logMessage: 'here' })] }); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalled(); + }); + + it('warns when a condition is added to an existing breakpoint', () => { + // Edit Breakpoint on an existing one arrives as a change, not an addition. + fire({ changed: [withFields({ condition: 'x > 3' })] }); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalled(); + }); + + it('says nothing for a plain breakpoint', () => { + fire({ + added: [new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0)))], + }); + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + + it('warns once for several conditional breakpoints, not once each', () => { + fire({ added: [withFields({ condition: 'a' }), withFields({ condition: 'b' })] }); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledTimes(1); + }); + + it('ignores a conditional breakpoint on a non-gemstone file', () => { + const fileBp = new SourceBreakpoint( + new Location(Uri.parse('file:///a.ts'), new Position(0, 0)), + true, + 'x > 3', + ); + fire({ added: [fileBp] }); + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + + it('still carries the fields across an enable/disable round trip', () => { + // Nothing is lost if conditions are honoured later. + const bp = withFields({ condition: 'x > 3', hitCondition: '2', logMessage: 'hi' }); + debug.breakpoints = [bp]; + + makeManager().setAllEnabled(false); + + const replacement = vi.mocked(debug.addBreakpoints).mock.calls.at(-1)?.[0][0] as + SourceBreakpoint | undefined; + expect(replacement?.condition).toBe('x > 3'); + expect(replacement?.hitCondition).toBe('2'); + expect(replacement?.logMessage).toBe('hi'); + expect(replacement?.enabled).toBe(false); + }); + }); + describe('pruneOrphans', () => { it('drops a restored breakpoint whose session is gone', () => { // VS Code persists its list across restarts; a GemStone breakpoint lives in diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index b82383ff..06620beb 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -686,6 +686,8 @@ export class BreakpointManager { // idempotent, and the removal it triggers re-enters here with nothing left // to prune, so this does not loop. if (event.added.length > 0) this.pruneOrphans(); + this.warnAboutUnsupportedFields([...event.added, ...event.changed]); + // Added *and* changed: VS Code's `+` button creates a function breakpoint // with an empty name and only then opens it for editing, so the name the // developer types arrives as a change rather than an addition. @@ -710,6 +712,34 @@ export class BreakpointManager { } } + /** + * Say so when a breakpoint carries a condition, hit count or log message. + * + * VS Code offers all three through *Edit Breakpoint*, and they are honoured + * entirely by the debugger — Jasper does not implement them, so the breakpoint + * stops every time it is reached. Left unsaid, that is the worst kind of + * failure this feature has: the developer has written down a precise intent, + * the UI accepts it, and execution quietly ignores it. The fields are still + * carried across enable/disable and name-conversion, so nothing is lost if + * they are honoured later. + */ + private warnAboutUnsupportedFields(breakpoints: readonly vscode.Breakpoint[]): void { + const ignored = breakpoints.filter( + (bp) => + bp instanceof vscode.SourceBreakpoint && + bp.location.uri.scheme === 'gemstone' && + (bp.condition !== undefined || + bp.hitCondition !== undefined || + bp.logMessage !== undefined), + ); + if (ignored.length === 0) return; + vscode.window.showWarningMessage( + 'GemStone breakpoints ignore conditions, hit counts and log messages — ' + + `${ignored.length === 1 ? 'this breakpoint' : 'these breakpoints'} will stop every time ` + + 'the step point is reached.', + ); + } + private refreshEditorsFor(uri: vscode.Uri): void { const uriStr = uri.toString(); for (const editor of vscode.window.visibleTextEditors) { From 746162fcfe3ef2fb04b5e9f9fc1e8e427cb6920e Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Thu, 27 Aug 2026 12:13:00 -0700 Subject: [PATCH 16/19] Set breakpoints only where the editor is a compiled method's source A breakpoint names a step point in a method that lives in the gem, so the positions it is placed by only mean something where the editor's text *is* that compiled method's. Two paths did not hold to that. Ad-hoc executed code. A stack frame with no gemstone:// path is either an "Executed Code" doit or a method whose class is not bound in the symbol list. Neither has anything durable to arm -- a doit's method is compiled for one execution and gone afterwards -- but the debug adapter tried anyway, on a best-effort line match that ignored the requested column, bypassed BreakpointManager entirely (so the result was invisible to the breakpoint view, Enable All, Disable All and pruning), and answered an unknown source reference with an empty list, which VS Code reads as acceptance. It is now refused per breakpoint with a reason, so the marker greys out and hovering it says why. mapLineToStepPoint went with it; the branch was its only caller. Unsaved edits. Step point offsets describe the compiled method while VS Code shifts its own breakpoints as the buffer is edited, so a dirty editor makes the two describe different code. Toggle Breakpoint at Cursor already refused on that basis; the gutter and the debug adapter did not. Because breakpoints are applied as an absolute model -- clear the method, then re-arm the whole list by position -- adding one to a dirty editor silently re-resolved every breakpoint on that method against offsets that no longer matched, moving ones the developer had not touched. A dirty editor now refuses a new breakpoint, taking it back out of the list, and the gem is left untouched meanwhile, so reverting the editor leaves the original breakpoints exactly where they were. Once it is clean again the gem catches up with anything the list did during the hold. Both refusals name the two ways on: save the method, or File: Revert File. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 + README.md | 15 ++ .../src/__tests__/breakpointManager.test.ts | 240 ++++++++++++------ .../__tests__/gemstoneDebugSession.test.ts | 107 ++------ client/src/__tests__/stepPointModel.test.ts | 7 +- client/src/breakpointManager.ts | 190 +++++++++----- client/src/gemstoneDebugSession.ts | 84 +++--- client/src/stepPointModel.ts | 6 +- 8 files changed, 380 insertions(+), 271 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ec3f25f..939e5b32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Documentation - **Setting a breakpoint condition now says it will not be honoured.** VS Code's *Edit Breakpoint* accepts a condition, a hit count or a log message, and all three are the debugger's job — Jasper does not implement them, so the breakpoint stopped every time it was reached with nothing to say so. That is the worst shape this failure takes: the developer wrote down a precise intent, the UI accepted it, and execution ignored it. It now warns. The fields are still carried across enable/disable and name-conversion, so nothing is lost when conditional breakpoints do arrive ([#277](https://github.com/GemTalk/Jasper/issues/277)). +- **Documented and enforced: a breakpoint can only be set in a compiled method's source.** A GemStone breakpoint names a step point in a method that lives in the gem, so an **Executed Code** (doit) frame cannot take one — the doit's method is compiled for that single execution and is gone afterwards, so the breakpoint could never be hit again — and neither can a frame whose class is not bound in the symbol list. Such a request used to be answered by arming *something* on a best-effort line match, or by returning nothing at all, either of which left VS Code showing a solid marker as though it had been accepted. It is now refused explicitly: the marker greys out and hovering it says why, pointing at the method source instead. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **A method with unsaved edits no longer takes new breakpoints, and no longer moves the ones it has.** Step points are the compiled method's, while VS Code shifts its own breakpoints as the buffer is edited — so once an editor is dirty, a position and an offset describe different code. **Toggle Breakpoint at Cursor** already refused on that basis; the gutter and the debug adapter did not, and because breakpoints are applied as an absolute model (clear the method, then re-arm VS Code's whole list by position), adding one to a dirty editor silently re-resolved *every* breakpoint on the method against offsets that no longer matched — moving breakpoints the developer had not touched. A dirty editor now refuses a new breakpoint, taking it back out of the list and naming both ways on (save the method, or **File: Revert File**), and holds the gem untouched meanwhile — so reverting leaves the original breakpoints exactly where they were. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. ## [1.8.14] - 2026-08-26 diff --git a/README.md b/README.md index a912fd91..c315ea66 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,21 @@ the thing it was set in goes away: after an edit the same step point may be a different expression — so the breakpoint is dropped rather than quietly moved somewhere you didn't put it. +- **Only where a compiled method's source is.** A breakpoint names a step point + in a method that lives in the gem, so a `gemstone://` method editor with no + unsaved edits is the only place one can be set. An **Executed Code** (doit) + frame cannot take one — its method exists for that single execution and is + gone afterwards, so the breakpoint could never be hit again — and neither can + a frame whose class is not in the symbol list. VS Code usually will not offer + the gutter there at all; where it does, the breakpoint is refused and greyed, + with the reason on hover +- **Unsaved edits hold a method's breakpoints as they are.** Step point numbers + come from the compiled method, and VS Code moves its breakpoints as you type, + so while an editor is dirty the two describe different code. No new breakpoint + is accepted — it is taken back out of the list, with a message naming the two + ways on: save the method, or **File: Revert File** to drop the edits. What is + already set is left alone rather than re-applied against positions that have + moved, so it is still there, on the same step points, once the editor is clean - **Line breakpoints** — click the editor gutter in a `gemstone://` method. A gutter click means "this line", and lands on the leftmost step point on it - **Step-point breakpoints** — a Smalltalk line usually holds several step diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 5bf539f2..12c0a644 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -16,17 +16,13 @@ import { Uri, debug, window, + workspace, Location, Position, SourceBreakpoint, FunctionBreakpoint, } from '../__mocks__/vscode'; -import { - BreakpointManager, - buildLineOffsets, - mapLineToStepPoint, - mapOffsetToStepPoint, -} from '../breakpointManager'; +import { BreakpointManager, buildLineOffsets, mapOffsetToStepPoint } from '../breakpointManager'; import { SessionManager } from '../sessionManager'; import { StepPointModel, buildLineStarts } from '../stepPointModel'; import { @@ -97,71 +93,8 @@ describe('buildLineOffsets', () => { }); }); -describe('mapLineToStepPoint', () => { - // Source: - // Line 1: "at: index" (offset 0-9) - // Line 2: " ^ self basicAt: index" (offset 10-33) - const lineOffsets = [0, 0, 10, 34]; // dummy, line1, line2, (end) - // Step points: step 1 at offset 0, step 2 at offset 14 - const sourceOffsets = [0, 14]; - - it('maps line 1 to step point 1', () => { - const result = mapLineToStepPoint(1, lineOffsets, sourceOffsets); - expect(result).toEqual({ stepPoint: 1, actualLine: 1 }); - }); - - it('maps line 2 to step point 2', () => { - const result = mapLineToStepPoint(2, lineOffsets, sourceOffsets); - expect(result).toEqual({ stepPoint: 2, actualLine: 2 }); - }); - - it('adjusts to nearest following step point when no step on target line', () => { - // Source with 4 lines, step points on lines 1 and 3 - const lo = [0, 0, 10, 20, 30]; - const so = [0, 22]; // step 1 at line 1, step 2 at line 3 - - const result = mapLineToStepPoint(2, lo, so); - // Line 2 has no step point, nearest after is step 2 at offset 22 → line 3 - expect(result).toEqual({ stepPoint: 2, actualLine: 3 }); - }); - - it('returns null for empty sourceOffsets', () => { - const result = mapLineToStepPoint(1, [0, 0], []); - expect(result).toBeNull(); - }); - - it('returns null for invalid line number', () => { - const result = mapLineToStepPoint(0, [0, 0], [0]); - expect(result).toBeNull(); - }); - - it('returns null for line beyond source', () => { - const result = mapLineToStepPoint(5, [0, 0, 10], [0]); - expect(result).toBeNull(); - }); - - it('handles unsorted source offsets', () => { - // Step points not in source order (blocks can cause this) - const lo = [0, 0, 10, 20, 30]; - const so = [25, 5, 15]; // step 1 at offset 25 (line 3), step 2 at 5 (line 1), step 3 at 15 (line 2) - - const result = mapLineToStepPoint(2, lo, so); - // Line 2 (offset 10-19), step 3 at offset 15 is on line 2 - expect(result).toEqual({ stepPoint: 3, actualLine: 2 }); - }); - - it('picks earliest step point when multiple on same line', () => { - const lo = [0, 0, 20]; - const so = [10, 5, 15]; // step 1 at 10, step 2 at 5, step 3 at 15 — all on line 1 - - const result = mapLineToStepPoint(1, lo, so); - // Step 2 has smallest offset (5) on line 1 - expect(result).toEqual({ stepPoint: 2, actualLine: 1 }); - }); -}); - -// Column-aware mapping for "Run to Cursor" (#2): unlike mapLineToStepPoint, the -// cursor's column chooses among several step points on the same line. +// Column-aware mapping for "Run to Cursor": the cursor's column chooses among +// several step points on the same line, rather than taking the leftmost one. describe('mapOffsetToStepPoint', () => { // `x := a asInteger` — 1-based source offsets: sp1@1 (x), sp2@6 (a), sp3@8 (asInteger). const so = [1, 6, 8]; @@ -202,6 +135,9 @@ describe('BreakpointManager', () => { mockDisableBreakAtStepPoint.mockReset(); mockClearAllBreaks.mockReset(); debug.breakpoints = []; + // Shared mock state: a dirty document left behind by one test makes the next + // one's breakpoints be refused, which reads as an unrelated regression. + workspace.textDocuments = []; vi.mocked(debug.addBreakpoints).mockClear(); vi.mocked(debug.removeBreakpoints).mockClear(); }); @@ -417,6 +353,166 @@ describe('BreakpointManager', () => { }); }); + // Eric's rule: a breakpoint can only be set in an editor whose text is the + // compiled method's. While it has unsaved edits nothing new is accepted, and — + // just as important — nothing already armed is disturbed, so reverting the + // editor leaves the original breakpoints exactly where they were. + describe('an editor with unsaved edits', () => { + const DIRTY_DOC = { uri: Uri.parse(METHOD_URI), isDirty: true }; + const CLEAN_DOC = { uri: Uri.parse(METHOD_URI), isDirty: false }; + + /** Drive the manager the way VS Code does, through the change event. */ + function fire(event: { + added?: unknown[]; + removed?: unknown[]; + changed?: unknown[]; + }): BreakpointManager { + const manager = makeManager(); + const context = { + subscriptions: [] as unknown[], + } as unknown as import('vscode').ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ + added: event.added ?? [], + removed: event.removed ?? [], + changed: event.changed ?? [], + }); + return manager; + } + + /** + * The most recent `onDidChangeTextDocument` listener the manager registered. + * The mock declares no parameters, so the listener has to be recovered as a + * callable rather than through its (empty) argument tuple. + */ + function fireDocumentChanged(document: unknown): void { + const calls = vi.mocked(workspace.onDidChangeTextDocument).mock.calls as unknown as ((e: { + document: unknown; + }) => void)[][]; + calls[calls.length - 1][0]({ document }); + } + + const bpAt = (line: number) => + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(line, 0))); + + beforeEach(() => { + vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + vi.mocked(workspace.onDidChangeTextDocument).mockClear(); + vi.mocked(window.showWarningMessage).mockClear(); + mockGetMethodSource.mockReturnValue('at: index\n^ self basicAt: index'); + mockGetSourceOffsets.mockReturnValue([1, 13]); + }); + + it('refuses a breakpoint added while the editor is dirty, and says why', () => { + workspace.textDocuments = [DIRTY_DOC]; + const added = bpAt(1); + debug.breakpoints = [added]; + + fire({ added: [added] }); + + // Taken back out, so no red dot is left arming nothing. + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([added]); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( + expect.stringContaining('unsaved edits'), + ); + // Both ways back to a compiled method are named. + const said = vi.mocked(window.showWarningMessage).mock.calls[0][0] as string; + expect(said).toContain('Save the method'); + expect(said).toContain('Revert File'); + }); + + it('leaves the breakpoints already armed alone while the editor is dirty', () => { + // The heart of the rule. `applyToUri` is an absolute model — it clears the + // method and re-arms VS Code's whole list by position — and VS Code shifts + // those positions as the buffer is edited. Running it now would move + // breakpoints the developer never touched, so it must not run at all. + workspace.textDocuments = [DIRTY_DOC]; + const existing = bpAt(1); + const added = bpAt(0); + debug.breakpoints = [existing, added]; + + fire({ added: [added] }); + + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('does not touch the gem when a breakpoint is removed while the editor is dirty', () => { + workspace.textDocuments = [DIRTY_DOC]; + const removed = bpAt(1); + debug.breakpoints = []; + + fire({ removed: [removed] }); + + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + // Nothing was added, so there is nothing to take back out and nothing to say. + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + + it('applies normally once the editor is clean again', () => { + // Reverting the editor is the ordinary way out, and this is where the gem + // catches up with anything the list did during the hold. + workspace.textDocuments = [DIRTY_DOC]; + const existing = bpAt(1); + debug.breakpoints = [existing]; + fire({ added: [existing] }); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + + workspace.textDocuments = [CLEAN_DOC]; + debug.breakpoints = [existing]; + fireDocumentChanged(CLEAN_DOC); + + expect(mockSetBreakAtStepPoint).toHaveBeenCalled(); + }); + + it('ignores a document change that leaves the editor still dirty', () => { + workspace.textDocuments = [DIRTY_DOC]; + debug.breakpoints = [bpAt(1)]; + fire({ added: [bpAt(1)] }); + + fireDocumentChanged(DIRTY_DOC); + + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('reports the gem as it stands, without arming, on the debug adapter path', () => { + // A live debug session re-sends the whole list for a source. Anything + // already armed stays verified; a new one is refused with the reason. + workspace.textDocuments = [CLEAN_DOC]; + const manager = makeManager(); + manager.applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 1, enabled: true }]); + mockClearAllBreaks.mockClear(); + mockSetBreakAtStepPoint.mockClear(); + + workspace.textDocuments = [DIRTY_DOC]; + const results = manager.setBreakpointsForSource( + session(), + Uri.parse(METHOD_URI), + [1, 2], + [undefined, undefined], + ); + + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + expect(results[0].verified).toBe(true); + expect(results[0].message).toBeUndefined(); + expect(results[1].verified).toBe(false); + expect(results[1].message).toContain('unsaved edits'); + }); + + it('applies normally when the editor has no unsaved edits', () => { + workspace.textDocuments = [CLEAN_DOC]; + const added = bpAt(1); + debug.breakpoints = [added]; + + fire({ added: [added] }); + + expect(mockSetBreakAtStepPoint).toHaveBeenCalled(); + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + }); + describe('function breakpoints', () => { /** Drive the manager the way VS Code does, through the change event. */ function fireAdded(added: unknown[]) { @@ -791,7 +887,7 @@ describe('BreakpointManager', () => { makeManager().toggleAtCursor(makeEditor('m\n^1', 2, true)); expect(vi.mocked(debug.addBreakpoints)).not.toHaveBeenCalled(); - expect(warn()).toHaveBeenCalledWith(expect.stringContaining('Save the method first')); + expect(warn()).toHaveBeenCalledWith(expect.stringContaining('unsaved edits')); }); it('says why nothing happened when the method has no step points', () => { diff --git a/client/src/__tests__/gemstoneDebugSession.test.ts b/client/src/__tests__/gemstoneDebugSession.test.ts index 7bbf6c22..fde5b70a 100644 --- a/client/src/__tests__/gemstoneDebugSession.test.ts +++ b/client/src/__tests__/gemstoneDebugSession.test.ts @@ -667,8 +667,11 @@ describe('GemStoneDebugSession', () => { expect(body.breakpoints).toHaveLength(0); }); - it('sets breakpoints via sourceReference path', () => { - // getMethodSource returns two-line method, getMethodInfo provides class/selector + // A frame with no gemstone:// path is an ad-hoc execution ('Executed Code') + // or a method whose class is not in the symbol list. Neither is a saved, + // compiled method the developer can point at, so the request is refused with + // a reason rather than reported as a verified breakpoint that never fires. + it('refuses a breakpoint on a frame that has no method source of its own', () => { vi.mocked(debugQueries.getMethodSource).mockReturnValue('at: index\n ^ self basicAt: index'); vi.mocked(debugQueries.getMethodInfo).mockReturnValue({ className: 'Array', @@ -689,69 +692,42 @@ describe('GemStoneDebugSession', () => { breakpoints: [{ line: 1 }, { line: 2 }], }); - const body = response.body as { breakpoints: Array<{ verified: boolean; line: number }> }; + const body = response.body as { + breakpoints: Array<{ verified: boolean; line: number; reason?: string; message?: string }>; + }; expect(body.breakpoints).toHaveLength(2); - expect(body.breakpoints[0].verified).toBe(true); - expect(body.breakpoints[0].line).toBe(1); - expect(body.breakpoints[1].verified).toBe(true); - expect(body.breakpoints[1].line).toBe(2); + for (const bp of body.breakpoints) { + expect(bp.verified).toBe(false); + expect(bp.reason).toBe('failed'); + expect(bp.message).toMatch(/compiled method/i); + } + // The marker stays where the developer put it, so the refusal is legible. + expect(body.breakpoints.map((bp) => bp.line)).toEqual([1, 2]); - expect(browserQueries.clearAllBreaks).toHaveBeenCalledTimes(1); - expect(browserQueries.setBreakAtStepPoint).toHaveBeenCalledTimes(2); + // Nothing is armed in the gem, and nothing already armed is cleared. + expect(browserQueries.clearAllBreaks).not.toHaveBeenCalled(); + expect(browserQueries.setBreakAtStepPoint).not.toHaveBeenCalled(); }); - it('returns unverified when setBreakAtStepPoint fails via sourceReference', () => { - vi.mocked(debugQueries.getMethodSource).mockReturnValue('foo\n ^ 1'); - vi.mocked(debugQueries.getMethodInfo).mockReturnValue({ - className: 'Foo', - selector: 'foo', - }); - vi.mocked(browserQueries.setBreakAtStepPoint).mockImplementation(() => { - throw new Error('GCI error'); - }); - + it('refuses rather than answering nothing when the sourceReference is unknown', () => { const { session } = createTestSession(); callRequest(session, 'attachRequest', makeResponse('attach'), { sessionId: 1, gsProcess: '12345', }); - callRequest(session, 'stackTraceRequest', makeResponse('stackTrace'), { threadId: 1 }); const response = makeResponse('setBreakpoints'); callRequest(session, 'setBreakpointsRequest', response, { - source: { sourceReference: 1 }, + source: { sourceReference: 999 }, breakpoints: [{ line: 1 }], }); - const body = response.body as { breakpoints: Array<{ verified: boolean }> }; + // One answer per request: a silent empty list leaves VS Code showing a + // solid marker as though the breakpoint had been accepted. + const body = response.body as { breakpoints: Array<{ verified: boolean; reason?: string }> }; expect(body.breakpoints).toHaveLength(1); expect(body.breakpoints[0].verified).toBe(false); - }); - - it('returns unverified for all lines when getMethodSource throws via sourceReference', () => { - const { session } = createTestSession(); - callRequest(session, 'attachRequest', makeResponse('attach'), { - sessionId: 1, - gsProcess: '12345', - }); - // stackTraceRequest populates sourceRefMap (doesn't call getMethodSource) - callRequest(session, 'stackTraceRequest', makeResponse('stackTrace'), { threadId: 1 }); - - // Now make getMethodSource throw for setBreakpointsRequest - vi.mocked(debugQueries.getMethodSource).mockImplementation(() => { - throw new Error('source not available'); - }); - - const response = makeResponse('setBreakpoints'); - callRequest(session, 'setBreakpointsRequest', response, { - source: { sourceReference: 1 }, - breakpoints: [{ line: 1 }, { line: 2 }], - }); - - const body = response.body as { breakpoints: Array<{ verified: boolean }> }; - expect(body.breakpoints).toHaveLength(2); - expect(body.breakpoints[0].verified).toBe(false); - expect(body.breakpoints[1].verified).toBe(false); + expect(body.breakpoints[0].reason).toBe('failed'); }); it('delegates to breakpointManager for gemstone:// path', () => { @@ -780,40 +756,5 @@ describe('GemStoneDebugSession', () => { expect(body.breakpoints[1]).toMatchObject({ verified: true, line: 3 }); expect(mockBPManager.setBreakpointsForSource).toHaveBeenCalledTimes(1); }); - - it('handles class-side methods via sourceReference', () => { - vi.mocked(debugQueries.getMethodSource).mockReturnValue('new\n ^ super new'); - vi.mocked(debugQueries.getMethodInfo).mockReturnValue({ - className: 'Array class', - selector: 'new', - }); - vi.mocked(browserQueries.getSourceOffsets).mockReturnValue([0, 6]); - - const { session } = createTestSession(); - callRequest(session, 'attachRequest', makeResponse('attach'), { - sessionId: 1, - gsProcess: '12345', - }); - callRequest(session, 'stackTraceRequest', makeResponse('stackTrace'), { threadId: 1 }); - - const response = makeResponse('setBreakpoints'); - callRequest(session, 'setBreakpointsRequest', response, { - source: { sourceReference: 1 }, - breakpoints: [{ line: 1 }], - }); - - expect(browserQueries.getSourceOffsets).toHaveBeenCalledWith( - expect.anything(), - 'Array', - true, - 'new', - ); - expect(browserQueries.clearAllBreaks).toHaveBeenCalledWith( - expect.anything(), - 'Array', - true, - 'new', - ); - }); }); }); diff --git a/client/src/__tests__/stepPointModel.test.ts b/client/src/__tests__/stepPointModel.test.ts index 84b67b57..a68e4e42 100644 --- a/client/src/__tests__/stepPointModel.test.ts +++ b/client/src/__tests__/stepPointModel.test.ts @@ -247,7 +247,12 @@ describe('StepPointModel', () => { }; it('names the unsaved buffer, the case a developer can actually fix', () => { - expect(problemFor(makeDocument(METHOD_URI, true))).toContain('Save the method first'); + const problem = problemFor(makeDocument(METHOD_URI, true)); + expect(problem).toContain('unsaved edits'); + // Both ways back to a compiled method, since saving a half-finished edit + // is not always what the developer wants. + expect(problem).toContain('Save the method'); + expect(problem).toContain('Revert File'); }); it('says breakpoints need GemStone method source for another scheme', () => { diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 06620beb..bf9d64f5 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -16,6 +16,8 @@ export interface VerifiedBreakpoint { stepPoint: number; actualLine: number; verified: boolean; + /** Why an unverified breakpoint was refused, for the debug adapter to relay. */ + message?: string; } /** A breakpoint as it now stands in the gem, for one method. */ @@ -86,6 +88,10 @@ const disabledDecoration = vscode.window.createTextEditorDecorationType({ * breakpoint or Jasper's toggle-at-cursor carries the exact column and picks the * step point nearest it. See `resolveStepPoint`. * + * A breakpoint can only be set where the editor's text *is* the compiled + * method's, so a method with unsaved edits takes no new ones and has the ones it + * has held exactly as they are until it is clean again (`holdWhileDirty`). + * * A *disabled* breakpoint is applied as set-then-disabled rather than left off * the stone, so stepping past it is instant to re-arm and the breakpoint * manager view can show it. `disableBreakAtStepPoint:` is a no-op on a step @@ -95,6 +101,12 @@ export class BreakpointManager { /** What we last applied, per method URI — drives decorations and re-application. */ private applied = new Map(); + /** + * Method URIs held still because their editor has unsaved edits, so the gem + * can catch up once it is clean again. See `holdWhileDirty`. + */ + private frozen = new Set(); + private _onDidApply = new vscode.EventEmitter(); /** Fires after breakpoints are pushed to the gem, so views can refresh. */ readonly onDidApply = this._onDidApply.event; @@ -117,6 +129,7 @@ export class BreakpointManager { context.subscriptions.push( this._onDidApply, vscode.debug.onDidChangeBreakpoints((e) => this.onBreakpointsChanged(e)), + vscode.workspace.onDidChangeTextDocument((e) => this.thawIfClean(e.document)), vscode.window.onDidChangeActiveTextEditor((editor) => { if (editor) this.refreshDecorations(editor); }), @@ -136,6 +149,10 @@ export class BreakpointManager { * `requests` carries the raw line/column pairs. When omitted, they are read * from `vscode.debug.breakpoints` — the absolute model: whatever is in VS * Code's list right now is exactly what the method ends up with. + * + * Callers are responsible for not running this while the method's editor has + * unsaved edits, when a position in VS Code's list and an offset in the + * compiled method no longer describe the same code — see `holdWhileDirty`. */ applyToUri( session: ActiveSession, @@ -256,6 +273,12 @@ export class BreakpointManager { lines: number[], columns?: (number | undefined)[], ): VerifiedBreakpoint[] { + // Held still while the editor has unsaved edits — see `holdWhileDirty`. + // Report what the gem already holds instead of arming anything: a + // breakpoint set before the edits is still armed and still verified, and a + // new one is refused with the reason. + if (isDirty(uri)) return this.frozenResults(uri, lines, columns); + return this.applyToUri( session, uri, @@ -268,6 +291,90 @@ export class BreakpointManager { ); } + /** + * Hold a method's breakpoints still while its editor has unsaved edits, and + * refuse any new one. + * + * `applyToUri` is an absolute model: clear the method, then re-arm everything + * in VS Code's list by resolving each position against the *compiled* method's + * step point offsets. VS Code moves its own breakpoints as the buffer is + * edited, so the moment the text and the compiled method disagree, re-applying + * would resolve every breakpoint on the method — including ones the developer + * never touched — against offsets that no longer describe it, and silently + * move them. Nothing is pushed to the gem until the editor is clean again, so + * what was already armed stays exactly as it was and is still there after a + * revert. + * + * A breakpoint just *added* is a different matter: leaving it in the list + * would show a red dot arming nothing, so it is taken back out and the reason + * given. `Shift+F9` refuses the same edit for the same reason, one step + * earlier (`StepPointModel.explain`). + */ + private holdWhileDirty(uri: vscode.Uri, added: readonly vscode.Breakpoint[]): void { + this.frozen.add(uri.toString()); + + const uriStr = uri.toString(); + const rejected = added.filter( + (bp) => bp instanceof vscode.SourceBreakpoint && bp.location.uri.toString() === uriStr, + ); + if (rejected.length === 0) return; + + vscode.debug.removeBreakpoints(rejected); + vscode.window.showWarningMessage(DIRTY_REFUSAL); + } + + /** + * The gem's state for a method being held still, phrased as breakpoint + * results — verified for what is actually armed, refused for anything else. + */ + private frozenResults( + uri: vscode.Uri, + lines: number[], + columns?: (number | undefined)[], + ): VerifiedBreakpoint[] { + this.frozen.add(uri.toString()); + + const method = parseMethodUri(uri); + const session = this.sessionManager.getSelectedSession(); + const applied = this.applied.get(uri.toString()) ?? []; + const info = method && session ? this.stepPoints.fetch(session, uri, method) : null; + + return lines.map((line, i) => { + const column = columns?.[i]; + // DAP columns are 1-based; our resolver takes a 0-based character. + const character = column === undefined ? undefined : Math.max(column - 1, 0); + const resolved = info ? resolveStepPoint(info, line, character) : null; + const armed = resolved !== null && applied.some((a) => a.stepPoint === resolved.stepPoint); + return { + stepPoint: resolved?.stepPoint ?? 0, + actualLine: resolved?.line ?? line, + verified: armed, + message: armed ? undefined : DIRTY_REFUSAL, + }; + }); + } + + /** + * Let the gem catch up once a held method's editor is clean again. + * + * Reverting (`File: Revert File`) is the ordinary way out, and usually there + * is nothing to do — the text is the compiled method's again, and so are VS + * Code's breakpoint positions, so re-applying arms exactly what was already + * armed. It matters when the list moved during the hold: a breakpoint disabled + * or removed while the editor was dirty was deliberately left alone in the + * gem, and this is where the gem catches up with it. Saving takes the other + * route entirely — the recompile drops the method's breakpoints + * (`invalidateForUri`). + */ + private thawIfClean(document: vscode.TextDocument): void { + const uriStr = document.uri.toString(); + if (!this.frozen.has(uriStr) || document.isDirty) return; + this.frozen.delete(uriStr); + + const session = this.sessionManager.getSelectedSession(); + if (session) this.applyToUri(session, document.uri); + } + /** * Drop every GemStone breakpoint with no live session behind it. * @@ -708,7 +815,12 @@ export class BreakpointManager { } } for (const uriStr of affected) { - this.applyToUri(session, vscode.Uri.parse(uriStr)); + const uri = vscode.Uri.parse(uriStr); + if (isDirty(uri)) { + this.holdWhileDirty(uri, event.added); + continue; + } + this.applyToUri(session, uri); } } @@ -802,6 +914,24 @@ function positionOf(document: vscode.TextDocument, offset: number): vscode.Posit return document.positionAt(offset); } +/** + * Why a breakpoint is refused, and cleared, while the method's editor has + * unsaved edits. Names both ways out: the edits can be compiled, or dropped. + */ +const DIRTY_REFUSAL = + 'This method has unsaved edits, so its breakpoints are held as they are — ' + + 'step points come from the compiled method, not the text on screen. ' + + 'Save the method, or run "File: Revert File", and set the breakpoint then.'; + +/** + * Whether `uri` is open with unsaved edits. A breakpoint is placed by position, + * and only the compiled method's positions mean anything to the gem. + */ +function isDirty(uri: vscode.Uri): boolean { + const uriStr = uri.toString(); + return vscode.workspace.textDocuments.some((d) => d.uri.toString() === uriStr && d.isDirty); +} + function message(e: unknown): string { return e instanceof Error ? e.message : String(e); } @@ -865,61 +995,3 @@ export function mapOffsetToStepPoint( } return bestAfter; } - -/** - * Map a source line number (1-based) to a step point. - * Returns the step point number and the actual line it maps to, - * or null if no valid step point can be found. - */ -export function mapLineToStepPoint( - targetLine: number, - lineOffsets: number[], - sourceOffsets: number[], -): { stepPoint: number; actualLine: number } | null { - if (sourceOffsets.length === 0) return null; - if (targetLine < 1 || targetLine >= lineOffsets.length) return null; - - const targetStart = lineOffsets[targetLine]; - const targetEnd = targetLine + 1 < lineOffsets.length ? lineOffsets[targetLine + 1] : Infinity; - - // Find step points on the target line - let bestOnLine: { stepPoint: number; offset: number } | null = null; - for (let i = 0; i < sourceOffsets.length; i++) { - const offset = sourceOffsets[i]; - if (offset >= targetStart && offset < targetEnd) { - if (!bestOnLine || offset < bestOnLine.offset) { - bestOnLine = { stepPoint: i + 1, offset }; // step points are 1-based - } - } - } - - if (bestOnLine) { - return { stepPoint: bestOnLine.stepPoint, actualLine: targetLine }; - } - - // No step point on target line — find nearest step point after targetStart - let bestAfter: { stepPoint: number; offset: number } | null = null; - for (let i = 0; i < sourceOffsets.length; i++) { - const offset = sourceOffsets[i]; - if (offset >= targetStart) { - if (!bestAfter || offset < bestAfter.offset) { - bestAfter = { stepPoint: i + 1, offset }; - } - } - } - - if (bestAfter) { - // Find the line number for this offset - let actualLine = 1; - for (let l = 1; l < lineOffsets.length; l++) { - if (lineOffsets[l] <= bestAfter.offset) { - actualLine = l; - } else { - break; - } - } - return { stepPoint: bestAfter.stepPoint, actualLine }; - } - - return null; -} diff --git a/client/src/gemstoneDebugSession.ts b/client/src/gemstoneDebugSession.ts index 7d20df65..c57df17f 100644 --- a/client/src/gemstoneDebugSession.ts +++ b/client/src/gemstoneDebugSession.ts @@ -15,11 +15,14 @@ import type * as vscode from 'vscode'; import { SessionManager, ActiveSession } from './sessionManager'; import { OOP_NIL } from './gciConstants'; import * as debug from './debugQueries'; -import { BreakpointManager, buildLineOffsets, mapLineToStepPoint } from './breakpointManager'; -import * as queries from './browserQueries'; +import { BreakpointManager } from './breakpointManager'; import { logInfo, logError } from './gciLog'; const THREAD_ID = 1; +/** Why a breakpoint on a frame with no method source of its own is refused. */ +const UNBREAKABLE_SOURCE = + 'Breakpoints can only be set in the source of a compiled method. ' + + 'Ad-hoc executed code cannot be broken at — set the breakpoint in the method instead.'; const MAX_PRINT_STRING = 1024; // Variable reference kinds @@ -189,6 +192,11 @@ export class GemStoneDebugSession extends DebugSession { verified: results[i].verified, line: results[i].actualLine, id: i + 1, + // Set when the manager refused rather than merely failed to + // resolve — the developer can act on the reason. + ...(results[i].message + ? { reason: 'failed' as const, message: results[i].message } + : {}), }); } response.body = { breakpoints }; @@ -200,60 +208,26 @@ export class GemStoneDebugSession extends DebugSession { } } - // Try source reference → method OOP + // No gemstone:// path means the frame is not a saved, compiled method the + // developer can point at: either an ad-hoc execution ('Executed Code') or a + // method whose class is not bound in the symbol list. Neither has anything + // durable to arm — a doit's compiled method is gone once the execution ends, + // so a breakpoint here could never be hit again. Refuse it out loud rather + // than report a verified breakpoint that silently never fires. + // + // VS Code will not usually offer the gutter here anyway (the source comes + // back over `sourceRequest` with no path, so the document opens as plain + // text, and `contributes.breakpoints` covers gemstone-smalltalk only), but + // `debug.allowBreakpointsEverywhere` reopens the door. if (args.source.sourceReference && args.source.sourceReference > 0) { - const methodOop = this.sourceRefMap.get(args.source.sourceReference); - if (methodOop) { - try { - const source = debug.getMethodSource(this.session, methodOop); - const lineOffsets = buildLineOffsets(source); - - // Get method info to resolve the class/selector - const methodInfo = debug.getMethodInfo(this.session, methodOop); - const isMeta = methodInfo.className.endsWith(' class'); - const className = isMeta - ? methodInfo.className.replace(/ class$/, '') - : methodInfo.className; - - const sourceOffsets = queries.getSourceOffsets( - this.session, - className, - isMeta, - methodInfo.selector, - ); - - // Clear existing breakpoints on this method - try { - queries.clearAllBreaks(this.session, className, isMeta, methodInfo.selector); - } catch { - /* ignore */ - } - - for (let i = 0; i < requestedLines.length; i++) { - const result = mapLineToStepPoint(requestedLines[i], lineOffsets, sourceOffsets); - if (result) { - try { - queries.setBreakAtStepPoint( - this.session, - className, - isMeta, - methodInfo.selector, - result.stepPoint, - ); - breakpoints.push({ verified: true, line: result.actualLine, id: i + 1 }); - } catch { - breakpoints.push({ verified: false, line: requestedLines[i], id: i + 1 }); - } - } else { - breakpoints.push({ verified: false, line: requestedLines[i], id: i + 1 }); - } - } - } catch (e) { - logError(this.session.id, `setBreakpoints sourceRef error: ${e}`); - for (let i = 0; i < requestedLines.length; i++) { - breakpoints.push({ verified: false, line: requestedLines[i], id: i + 1 }); - } - } + for (let i = 0; i < requestedLines.length; i++) { + breakpoints.push({ + verified: false, + reason: 'failed', + line: requestedLines[i], + id: i + 1, + message: UNBREAKABLE_SOURCE, + }); } } diff --git a/client/src/stepPointModel.ts b/client/src/stepPointModel.ts index 45ca165e..e1dc6544 100644 --- a/client/src/stepPointModel.ts +++ b/client/src/stepPointModel.ts @@ -81,7 +81,11 @@ export class StepPointModel { // Step point offsets come from the compiled method, so they describe the // saved source, not what is on screen. Acting on them now would put the // breakpoint somewhere the developer didn't point at. - return { problem: 'Save the method first — step points come from the compiled method.' }; + return { + problem: + 'This method has unsaved edits — step points come from the compiled method, ' + + 'not the text on screen. Save the method, or run "File: Revert File", and try again.', + }; } const method = parseMethodUri(document.uri); From 30c86eb6e6825a922b41227400e6c832104c7c5a Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Thu, 27 Aug 2026 12:40:27 -0700 Subject: [PATCH 17/19] Stop offering the breakpoint gutter where a breakpoint cannot be honoured VS Code decides where its breakpoint gutter appears by language, with no way to narrow that by document -- and one language, gemstone-smalltalk, serves four different things: gemstone:// method editors, workspaces, .gst files, and the read-only source of a stack frame. Only the first can carry a breakpoint. The gutter was offered in all four, and a breakpoint set in the other three was dropped on the floor: a solid red dot that armed nothing, stopped nothing, and said nothing, which is indistinguishable from a breakpoint that simply never gets hit. The stack frame source no longer claims a language. sourceRequest used to answer with mimeType 'text/x-gemstone-smalltalk', which the language declares in package.json, so the document resolved to gemstone-smalltalk and got a gutter. Withholding it costs syntax highlighting in that read-only view -- the debugger panel's own source pane still highlights -- and buys a gutter that never invites a breakpoint it must refuse. The debug adapter still refuses such a request outright, as the backstop for debug.allowBreakpointsEverywhere or a client that is not VS Code. A breakpoint set in a workspace or a .gst file is taken back out of VS Code's list with a message naming where it belongs. The test for that is deliberately narrow: onDidChangeBreakpoints reports every extension's breakpoints, so a rule of "not a gemstone:// URI" would have taken a Python file's breakpoint out of the developer's Breakpoints panel -- a worse bug than the one being fixed. It matches on the language instead, falling back to the file extension for one restored before its editor is open, and leaves everything else alone. Also corrects the comment, README bullet and changelog entry that claimed VS Code would not offer the gutter on a doit frame. It did. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 2 +- README.md | 16 ++-- .../src/__tests__/breakpointManager.test.ts | 88 +++++++++++++++++++ .../__tests__/gemstoneDebugSession.test.ts | 13 ++- client/src/breakpointManager.ts | 63 +++++++++++++ client/src/gemstoneDebugSession.ts | 17 ++-- 6 files changed, 182 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 939e5b32..481cc1fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Documentation - **Setting a breakpoint condition now says it will not be honoured.** VS Code's *Edit Breakpoint* accepts a condition, a hit count or a log message, and all three are the debugger's job — Jasper does not implement them, so the breakpoint stopped every time it was reached with nothing to say so. That is the worst shape this failure takes: the developer wrote down a precise intent, the UI accepted it, and execution ignored it. It now warns. The fields are still carried across enable/disable and name-conversion, so nothing is lost when conditional breakpoints do arrive ([#277](https://github.com/GemTalk/Jasper/issues/277)). -- **Documented and enforced: a breakpoint can only be set in a compiled method's source.** A GemStone breakpoint names a step point in a method that lives in the gem, so an **Executed Code** (doit) frame cannot take one — the doit's method is compiled for that single execution and is gone afterwards, so the breakpoint could never be hit again — and neither can a frame whose class is not bound in the symbol list. Such a request used to be answered by arming *something* on a best-effort line match, or by returning nothing at all, either of which left VS Code showing a solid marker as though it had been accepted. It is now refused explicitly: the marker greys out and hovering it says why, pointing at the method source instead. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **A breakpoint can now only be set where one can actually be honoured — a method editor.** VS Code decides where its breakpoint gutter appears by *language*, with no way to narrow that by document, and Jasper gives one language to four different things: `gemstone://` method editors, workspaces, `.gst` files, and the read-only source of a stack frame. Only the first can hold a breakpoint, but the gutter was offered in all of them — and a breakpoint set in the other three was silently dropped, leaving a solid red dot that armed nothing, stopped nothing and said nothing. The stack frame's source no longer claims a language at all, so no gutter is offered there (its syntax highlighting was the price; the debugger panel's own source pane still highlights). A breakpoint set in a workspace or a `.gst` file is taken back out of the list with a message naming where it belongs. And an **Executed Code** (doit) frame is refused outright even if a request reaches the debug adapter another way — a doit's method is compiled for one execution and is gone afterwards, so a breakpoint on it could never be hit again. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A method with unsaved edits no longer takes new breakpoints, and no longer moves the ones it has.** Step points are the compiled method's, while VS Code shifts its own breakpoints as the buffer is edited — so once an editor is dirty, a position and an offset describe different code. **Toggle Breakpoint at Cursor** already refused on that basis; the gutter and the debug adapter did not, and because breakpoints are applied as an absolute model (clear the method, then re-arm VS Code's whole list by position), adding one to a dirty editor silently re-resolved *every* breakpoint on the method against offsets that no longer matched — moving breakpoints the developer had not touched. A dirty editor now refuses a new breakpoint, taking it back out of the list and naming both ways on (save the method, or **File: Revert File**), and holds the gem untouched meanwhile — so reverting leaves the original breakpoints exactly where they were. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Known limitation: VS Code's "Deactivate Breakpoints" does not reach GemStone.** That button greys the breakpoints out in the Breakpoints panel, but `vscode.debug` exposes no activation state and no event for it, so an extension cannot observe it — Jasper's breakpoints stay armed in the gem and execution still stops. **Disable All Breakpoints** does the right thing and should be used instead. diff --git a/README.md b/README.md index c315ea66..3e4a64c6 100644 --- a/README.md +++ b/README.md @@ -271,14 +271,14 @@ the thing it was set in goes away: after an edit the same step point may be a different expression — so the breakpoint is dropped rather than quietly moved somewhere you didn't put it. -- **Only where a compiled method's source is.** A breakpoint names a step point - in a method that lives in the gem, so a `gemstone://` method editor with no - unsaved edits is the only place one can be set. An **Executed Code** (doit) - frame cannot take one — its method exists for that single execution and is - gone afterwards, so the breakpoint could never be hit again — and neither can - a frame whose class is not in the symbol list. VS Code usually will not offer - the gutter there at all; where it does, the breakpoint is refused and greyed, - with the reason on hover +- **Only in a method editor.** A breakpoint names a step point in a method that + lives in the gem, so a `gemstone://` method editor with no unsaved edits is + the only place one can be set. Not a workspace, not a `.gst` file, and not an + **Executed Code** (doit) frame in the call stack — a doit's method is compiled + for that one execution and gone afterwards, so a breakpoint on it could never + be hit again. VS Code offers its gutter per *language*, which is the same for + all four, so a breakpoint set in the wrong one is taken back out with a message + saying where it belongs - **Unsaved edits hold a method's breakpoints as they are.** Step point numbers come from the compiled method, and VS Code moves its breakpoints as you type, so while an editor is dirty the two describe different code. No new breakpoint diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 12c0a644..392ac34c 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -353,6 +353,94 @@ describe('BreakpointManager', () => { }); }); + // VS Code offers the breakpoint gutter wherever the gemstone-smalltalk language + // is — a workspace and a .gst file as well as a gemstone:// method editor — + // because `contributes.breakpoints` names a language and cannot be narrowed by + // URI scheme. Only the method editor can carry a breakpoint. + describe('a breakpoint set outside a method editor', () => { + function fireAdded(added: unknown[]): void { + const manager = makeManager(); + const context = { + subscriptions: [] as unknown[], + } as unknown as import('vscode').ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ added, removed: [], changed: [] }); + } + + const bpOn = (uri: string) => + new SourceBreakpoint(new Location(Uri.parse(uri), new Position(0, 0))); + + beforeEach(() => { + vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + vi.mocked(window.showWarningMessage).mockClear(); + mockGetMethodSource.mockReturnValue('at: index\n^ self basicAt: index'); + mockGetSourceOffsets.mockReturnValue([1, 13]); + }); + + it('takes back a breakpoint set in a workspace, and says where it belongs', () => { + const stray = bpOn('untitled:Workspace'); + workspace.textDocuments = [ + { uri: Uri.parse('untitled:Workspace'), languageId: 'gemstone-smalltalk' }, + ]; + debug.breakpoints = [stray]; + + fireAdded([stray]); + + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([stray]); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( + expect.stringContaining('compiled GemStone method'), + ); + }); + + it('takes back one on a .gst file that is not open, as a restore brings back', () => { + const stray = bpOn('file:///tmp/scratch.gst'); + workspace.textDocuments = []; + debug.breakpoints = [stray]; + + fireAdded([stray]); + + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([stray]); + }); + + it("never touches another extension's breakpoints", () => { + // The guard that matters most. `onDidChangeBreakpoints` reports every + // extension's breakpoints, so a rule of "not a gemstone:// URI" would take + // a Python file's breakpoint out of the developer's Breakpoints panel. + const foreign = bpOn('file:///tmp/app.py'); + workspace.textDocuments = [{ uri: Uri.parse('file:///tmp/app.py'), languageId: 'python' }]; + debug.breakpoints = [foreign]; + + fireAdded([foreign]); + + expect(vi.mocked(debug.removeBreakpoints)).not.toHaveBeenCalled(); + expect(vi.mocked(window.showWarningMessage)).not.toHaveBeenCalled(); + }); + + it('leaves an unopened file of no interest alone', () => { + const foreign = bpOn('file:///tmp/app.py'); + workspace.textDocuments = []; + debug.breakpoints = [foreign]; + + fireAdded([foreign]); + + expect(vi.mocked(debug.removeBreakpoints)).not.toHaveBeenCalled(); + }); + + it('leaves a real method editor alone', () => { + const real = bpOn(METHOD_URI); + workspace.textDocuments = [ + { uri: Uri.parse(METHOD_URI), languageId: 'gemstone-smalltalk', isDirty: false }, + ]; + debug.breakpoints = [real]; + + fireAdded([real]); + + expect(vi.mocked(debug.removeBreakpoints)).not.toHaveBeenCalled(); + expect(mockSetBreakAtStepPoint).toHaveBeenCalled(); + }); + }); + // Eric's rule: a breakpoint can only be set in an editor whose text is the // compiled method's. While it has unsaved edits nothing new is accepted, and — // just as important — nothing already armed is disturbed, so reverting the diff --git a/client/src/__tests__/gemstoneDebugSession.test.ts b/client/src/__tests__/gemstoneDebugSession.test.ts index fde5b70a..eb0d9e47 100644 --- a/client/src/__tests__/gemstoneDebugSession.test.ts +++ b/client/src/__tests__/gemstoneDebugSession.test.ts @@ -324,12 +324,19 @@ describe('GemStoneDebugSession', () => { expect(body.content).toContain('_primitiveDivide'); }); - it('returns mimeType registered for gemstone-smalltalk language', () => { + it('withholds the mime type, so no breakpoint gutter is offered here', () => { + // package.json registers 'text/x-gemstone-smalltalk' as the mime type of + // the gemstone-smalltalk language, and `contributes.breakpoints` names + // that language — so returning it resolved this read-only frame source to + // a language VS Code will offer the breakpoint gutter for, on a frame that + // cannot hold a breakpoint. The cost is syntax highlighting in this view, + // which the debugger panel's own source pane provides. const response = makeResponse('source'); callRequest(session, 'sourceRequest', response, { sourceReference: 1 }); - const body = response.body as { mimeType?: string }; - expect(body.mimeType).toBe('text/x-gemstone-smalltalk'); + const body = response.body as { content: string; mimeType?: string }; + expect(body.mimeType).toBeUndefined(); + expect(body.content).toContain('_primitiveDivide'); }); it('returns placeholder for unknown sourceReference', () => { diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index bf9d64f5..59371205 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -291,6 +291,26 @@ export class BreakpointManager { ); } + /** + * Take back a breakpoint set somewhere a breakpoint cannot mean anything. + * + * A GemStone breakpoint is a step point in a compiled method, so only a + * `gemstone://` method editor can carry one — but VS Code offers the gutter + * anywhere the gemstone-smalltalk language is, which includes a workspace and + * a `.gst` file. Those breakpoints were previously dropped on the floor: the + * dot stayed in the gutter, armed nothing, and said nothing, which is + * indistinguishable from a breakpoint that simply never gets hit. + */ + private refuseOutsideMethodSource(added: readonly vscode.Breakpoint[]): void { + const stray = added.filter( + (bp) => bp instanceof vscode.SourceBreakpoint && inviteWeCannotHonour(bp.location.uri), + ); + if (stray.length === 0) return; + + vscode.debug.removeBreakpoints(stray); + vscode.window.showWarningMessage(NOT_A_METHOD_REFUSAL); + } + /** * Hold a method's breakpoints still while its editor has unsaved edits, and * refuse any new one. @@ -805,6 +825,11 @@ export class BreakpointManager { // failures — so there is nothing here for a caller to handle. void this.functionBreakpoints.handle([...event.added, ...event.changed]); + // Before anything needing a session: a breakpoint set in a workspace is + // wrong whether or not one is logged in, and left alone it would sit in the + // gutter as a solid red dot arming nothing and saying nothing. + this.refuseOutsideMethodSource(event.added); + const session = this.sessionManager.getSelectedSession(); if (!session) return; @@ -923,6 +948,44 @@ const DIRTY_REFUSAL = 'step points come from the compiled method, not the text on screen. ' + 'Save the method, or run "File: Revert File", and set the breakpoint then.'; +/** + * Why a breakpoint set outside a method editor is refused. + * + * Named as what the editor *is* rather than what it is not, since the developer + * is looking at a workspace or a `.gst` file and has to be told where the + * breakpoint does belong. + */ +const NOT_A_METHOD_REFUSAL = + 'A breakpoint can only be set in the source of a compiled GemStone method. ' + + 'This editor is not one — open the method and set the breakpoint there.'; + +/** + * Whether VS Code offered a breakpoint here only because of *our* language + * contribution, on a document that cannot hold one. + * + * `contributes.breakpoints` names a language, and VS Code gives no way to narrow + * it by URI scheme — so the gutter is offered wherever gemstone-smalltalk is: + * a workspace, a `.gst` file on disk, as well as the `gemstone://` method + * editors that are the only documents a breakpoint means anything in. + * + * The language test is what keeps this honest. `onDidChangeBreakpoints` reports + * every extension's breakpoints, so "not a gemstone:// URI" would also match a + * Python file's — and taking those out of the developer's Breakpoints panel + * would be a far worse bug than the one being fixed. A document that is not open + * has no language to read, so it falls back to the extension VS Code would have + * used itself; anything else is left alone. + */ +function inviteWeCannotHonour(uri: vscode.Uri): boolean { + if (uri.scheme === 'gemstone') return false; + + const uriStr = uri.toString(); + const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriStr); + if (open) return open.languageId === 'gemstone-smalltalk'; + + // Restored across a restart, before its editor is opened. + return uri.path.endsWith('.gst'); +} + /** * Whether `uri` is open with unsaved edits. A breakpoint is placed by position, * and only the compiled method's positions mean anything to the gem. diff --git a/client/src/gemstoneDebugSession.ts b/client/src/gemstoneDebugSession.ts index c57df17f..55eef06f 100644 --- a/client/src/gemstoneDebugSession.ts +++ b/client/src/gemstoneDebugSession.ts @@ -215,10 +215,10 @@ export class GemStoneDebugSession extends DebugSession { // so a breakpoint here could never be hit again. Refuse it out loud rather // than report a verified breakpoint that silently never fires. // - // VS Code will not usually offer the gutter here anyway (the source comes - // back over `sourceRequest` with no path, so the document opens as plain - // text, and `contributes.breakpoints` covers gemstone-smalltalk only), but - // `debug.allowBreakpointsEverywhere` reopens the door. + // `sourceRequest` withholds the mime type that used to resolve this document + // to the gemstone-smalltalk language, so VS Code no longer offers the gutter + // here. This stays as the backstop for the ways a request can still arrive — + // `debug.allowBreakpointsEverywhere`, or a client that is not VS Code. if (args.source.sourceReference && args.source.sourceReference > 0) { for (let i = 0; i < requestedLines.length; i++) { breakpoints.push({ @@ -339,7 +339,14 @@ export class GemStoneDebugSession extends DebugSession { try { const source = debug.getMethodSource(this.session, methodOop); - response.body = { content: source, mimeType: 'text/x-gemstone-smalltalk' }; + // Deliberately no `mimeType`. Returning 'text/x-gemstone-smalltalk' resolved + // this document to the gemstone-smalltalk language (the language declares + // that mime type in package.json), and `contributes.breakpoints` names that + // language — so VS Code offered the breakpoint gutter on a frame that cannot + // hold a breakpoint. Withholding it costs syntax highlighting in this + // read-only view, which the debugger panel's own source pane provides + // anyway, and buys a gutter that never invites a breakpoint it must refuse. + response.body = { content: source }; } catch (e) { response.body = { content: `// Error fetching source: ${e}` }; } From 5e3d0c756582e3a7b54cac4a82a9dcfa79976318 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Thu, 27 Aug 2026 14:25:17 -0700 Subject: [PATCH 18/19] Address review on the breakpoints work Correctness, in the order it matters: - A breakpoint is applied to the session its method belongs to, not to whichever session is selected. A method editor stays bound to the session it was opened from, so with two sessions live the selected one is routinely not the gem holding the method: the break landed in the wrong stone, the method on screen never stopped, and the clear that precedes re-arming took out breakpoints the other session had set. Enable/Disable/Remove All sweep every live gem for the same reason, since the rows they flip are one list spanning all sessions, and one gem refusing no longer abandons the rest. A row in the breakpoints view can only match a method from the session it was read from. - A GCI failure while arming is no longer swallowed. The stone's message is shown and carried back in the breakpoint result, and a step point that armed but could not be disabled is cleared rather than left stopping execution behind a marker that reads "disabled". When a method's step points cannot be read after its breaks were cleared, the stale record is dropped so no marker, hover or view row outlives it. - A keyword message's continuation keywords are found past a separator nested in parentheses or a block, so the trailing part of a send keeps its step point number, hover and breakpoint. - A named breakpoint deleted while its class picker was open is no longer resurrected when the picker answers. - The step point numbers toggle claims its new value before writing the setting, so a fast second click flips back instead of being lost. Reuse and cost: - The duplicated step point resolver is gone: Run to Cursor now goes through the shared resolver, so it and a breakpoint set in an editor cannot disagree about which token a step point is. - Method names, error text and the symbol list dictionary lookup all use the existing shared helpers instead of local copies. The shared method-name helper takes just the three parts of a name so every caller can reach it. - Step points are read in one round trip to the stone instead of three, which is extension host time the editor was not drawing in. A stale comment claiming Run to Cursor is unavailable on Executed Code frames is corrected: such a frame is breakable by method OOP. Manually verified against a live stone by Eric: the session a breakpoint is applied to, the view row matching only its own session, Disable All across sessions, the keyword scan, the numbers toggle, and Run to Cursor through the shared resolver. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 3 + .../src/__tests__/breakpointManager.test.ts | 381 ++++++++++++++---- .../src/__tests__/functionBreakpoints.test.ts | 68 ++-- .../src/__tests__/getStepPointBundle.test.ts | 38 ++ client/src/__tests__/stepPointHints.test.ts | 46 ++- client/src/__tests__/stepPointHover.test.ts | 23 +- client/src/__tests__/stepPointModel.test.ts | 23 +- .../src/__tests__/stepPointSelectors.test.ts | 54 +++ client/src/breakpointManager.ts | 256 +++++++----- client/src/browserQueries.ts | 20 + client/src/debuggerPanel.ts | 30 +- client/src/debuggerView.js | 8 +- client/src/functionBreakpoints.ts | 34 +- client/src/methodResultsPicker.ts | 15 +- client/src/queries/getAllBreakpoints.ts | 15 +- client/src/queries/getStepPointBundle.ts | 111 +++++ client/src/stepPointHints.ts | 19 +- client/src/stepPointModel.ts | 32 +- client/src/stepPointSelectors.ts | 14 +- 19 files changed, 919 insertions(+), 271 deletions(-) create mode 100644 client/src/__tests__/getStepPointBundle.test.ts create mode 100644 client/src/queries/getStepPointBundle.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 481cc1fb..7cbbd24c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Fixed +- **Breakpoints go to the session the method belongs to.** With more than one session live (`gemstone.sessionMode: "multiple"`), a method editor stays bound to the session it was opened from — so setting a breakpoint in it no longer clears and arms step points in whichever session happens to be *selected*, which left the method on screen never stopping while another stone stopped in code nobody asked about. **Enable All**, **Disable All** and **Remove All** now sweep every live session's gem rather than only the selected one, so a row that reads "disabled" is disabled everywhere; if one gem refuses, the others are still swept and the failure names the session. A row in the **GemStone Breakpoints** view can only match a method from the session the view read it from, so two sessions holding the same class, selector and step point no longer collide. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **A breakpoint the gem refuses now says so.** A failed set or disable was swallowed, leaving a hollow marker that looks exactly like a breakpoint on a line with no step point; the stone's own message is now shown and carried back to the debugger. A breakpoint that armed but could not then be disabled is taken back out rather than left stopping execution behind a marker that reads "disabled" — and if it cannot be removed either, the message says it is still armed. When a method's step points cannot be read at all, its stale markers, hover text and view rows are dropped instead of being left pointing at breakpoints that exist in no gem. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Half of a keyword message could be left with no step point.** The stone reports only the *first* keyword of a keyword send at a step point, so Jasper scans the source for the rest — but the scan stopped at the first `.` or `;` regardless of how deeply nested it was. A separator inside parentheses or a block belongs to an inner expression, not to the send being scanned, so `self foo: (s add: 1; yourself) bar: 2` and `self foo: [a bar. c baz] qux: 2` each lost everything after the bracket: no number on the trailing keyword, nothing on hover, and no way to aim a breakpoint at it. The scan now stops only at a top-level separator, matching the identifier scan beside it, which had always been depth-gated. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A gutter breakpoint could be set one step point later than the line asked for.** Line-to-step-point mapping compared GemStone's 1-based source offsets against 0-based line offsets, so a step point sitting exactly on a line boundary was read as belonging to the previous line. Run to Cursor already corrected for this; the gutter path did not. ### Documentation diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 392ac34c..1fe738c6 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -2,15 +2,33 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; vi.mock('vscode', () => import('../__mocks__/vscode.js')); -vi.mock('../browserQueries', () => ({ - getMethodSource: vi.fn(() => ''), - getSourceOffsets: vi.fn(() => []), - getStepPointSelectorRanges: vi.fn(() => []), - setBreakAtStepPoint: vi.fn(), - clearBreakAtStepPoint: vi.fn(), - disableBreakAtStepPoint: vi.fn(), - clearAllBreaks: vi.fn(), -})); +vi.mock('../browserQueries', () => { + // The step point model reads source, offsets and selector ranges in ONE round + // trip. These three stay as the knobs the tests turn, with the bundle built + // from them, so a test still says "this method's source is X" and the args it + // asserts on still arrive. + const getMethodSource = vi.fn((..._args: unknown[]) => ''); + const getSourceOffsets = vi.fn((..._args: unknown[]) => [] as number[]); + const getStepPointSelectorRanges = vi.fn((..._args: unknown[]) => [] as unknown[]); + return { + getMethodSource, + getSourceOffsets, + getStepPointSelectorRanges, + getStepPointBundle: vi.fn((...args: unknown[]) => ({ + source: getMethodSource(...args), + offsets: getSourceOffsets(...args), + selectors: getStepPointSelectorRanges(...args), + })), + setBreakAtStepPoint: vi.fn(), + clearBreakAtStepPoint: vi.fn(), + disableBreakAtStepPoint: vi.fn(), + clearAllBreaks: vi.fn(), + enableAllBreakpoints: vi.fn(), + disableAllBreakpoints: vi.fn(), + removeAllBreakpoints: vi.fn(), + breakpointByOop: vi.fn(), + }; +}); import { Uri, @@ -22,7 +40,8 @@ import { SourceBreakpoint, FunctionBreakpoint, } from '../__mocks__/vscode'; -import { BreakpointManager, buildLineOffsets, mapOffsetToStepPoint } from '../breakpointManager'; +import type * as vscodeApi from 'vscode'; +import { BreakpointManager } from '../breakpointManager'; import { SessionManager } from '../sessionManager'; import { StepPointModel, buildLineStarts } from '../stepPointModel'; import { @@ -30,7 +49,13 @@ import { getSourceOffsets, setBreakAtStepPoint, disableBreakAtStepPoint, + clearBreakAtStepPoint, clearAllBreaks, + getStepPointBundle, + enableAllBreakpoints, + disableAllBreakpoints, + removeAllBreakpoints, + breakpointByOop, } from '../browserQueries'; const mockGetMethodSource = vi.mocked(getMethodSource); @@ -38,6 +63,11 @@ const mockGetSourceOffsets = vi.mocked(getSourceOffsets); const mockSetBreakAtStepPoint = vi.mocked(setBreakAtStepPoint); const mockClearAllBreaks = vi.mocked(clearAllBreaks); const mockDisableBreakAtStepPoint = vi.mocked(disableBreakAtStepPoint); +const mockClearBreakAtStepPoint = vi.mocked(clearBreakAtStepPoint); +const mockEnableAll = vi.mocked(enableAllBreakpoints); +const mockDisableAll = vi.mocked(disableAllBreakpoints); +const mockRemoveAll = vi.mocked(removeAllBreakpoints); +const mockByOop = vi.mocked(breakpointByOop); const METHOD_URI = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; @@ -69,64 +99,6 @@ function makeSessionManager(hasSession: boolean) { } as unknown as SessionManager; } -describe('buildLineOffsets', () => { - it('returns offsets for a single-line source', () => { - const offsets = buildLineOffsets('hello'); - // offsets[0] = 0 (dummy), offsets[1] = 0 (line 1 starts at 0) - expect(offsets[1]).toBe(0); - expect(offsets.length).toBe(2); - }); - - it('returns offsets for multi-line source', () => { - const offsets = buildLineOffsets('abc\ndef\nghi'); - // Line 1: offset 0, Line 2: offset 4, Line 3: offset 8 - expect(offsets[1]).toBe(0); - expect(offsets[2]).toBe(4); - expect(offsets[3]).toBe(8); - expect(offsets.length).toBe(4); - }); - - it('handles empty source', () => { - const offsets = buildLineOffsets(''); - expect(offsets[1]).toBe(0); - expect(offsets.length).toBe(2); - }); -}); - -// Column-aware mapping for "Run to Cursor": the cursor's column chooses among -// several step points on the same line, rather than taking the leftmost one. -describe('mapOffsetToStepPoint', () => { - // `x := a asInteger` — 1-based source offsets: sp1@1 (x), sp2@6 (a), sp3@8 (asInteger). - const so = [1, 6, 8]; - const lineStart = 0; - const lineEnd = 16; // whole single line - - it('picks the step point nearest the cursor column, not the leftmost on the line', () => { - // Cursor on `asInteger` (offset 7) → sp3, NOT the leftmost sp1 (the := store). - expect(mapOffsetToStepPoint(7, so, lineStart, lineEnd)).toEqual({ stepPoint: 3, offset: 8 }); - }); - - it('picks the leftmost when the cursor is at the start of the line', () => { - expect(mapOffsetToStepPoint(0, so, lineStart, lineEnd)).toEqual({ stepPoint: 1, offset: 1 }); - }); - - it('breaks inside a one-line block when the cursor is in the block body', () => { - // `self do: [:e | body ]` style: sp1@5 (self), sp2@10 (do:), sp3@20 (body). - const blk = [5, 10, 20]; - // Cursor at offset 19 (on `body`) → sp3, not the do:/self sends. - expect(mapOffsetToStepPoint(19, blk, 0, 30)).toEqual({ stepPoint: 3, offset: 20 }); - }); - - it('falls back to the nearest step point AFTER the cursor when its line has none', () => { - // Cursor on a blank line [10, 20) with no step point → nearest after (offset 25). - expect(mapOffsetToStepPoint(12, [5, 25, 40], 10, 20)).toEqual({ stepPoint: 2, offset: 25 }); - }); - - it('returns null when the cursor is past every step point', () => { - expect(mapOffsetToStepPoint(100, [5, 10], 90, 110)).toBeNull(); - }); -}); - describe('BreakpointManager', () => { beforeEach(() => { mockGetMethodSource.mockReset(); @@ -1032,4 +1004,277 @@ describe('BreakpointManager', () => { expect(debug.breakpoints).toEqual([fileBp]); }); }); + + // A method editor stays bound to the session it was opened from while the + // developer switches the active session (README: "Single vs. multiple + // sessions"), so with `gemstone.sessionMode: multiple` the selected session is + // routinely NOT the one holding the method on screen. + describe('with a second session live and the other one selected', () => { + const SESSION_ONE = { + id: 1, + gci: {}, + handle: 'gem-one', + login: { label: 'One' }, + stoneVersion: '3.7.2', + }; + const SESSION_TWO = { + id: 2, + gci: {}, + handle: 'gem-two', + login: { label: 'Two' }, + stoneVersion: '3.7.2', + }; + /** Both sessions live, session TWO selected; the method URI names session one. */ + function twoSessions() { + return { + getSelectedSession: vi.fn(() => SESSION_TWO), + getSessions: vi.fn(() => [SESSION_ONE, SESSION_TWO]), + onDidChangeSelection: vi.fn(() => ({ dispose: () => {} })), + } as unknown as SessionManager; + } + function managerOverTwo(sessionManager = twoSessions()) { + const manager = new BreakpointManager(sessionManager, new StepPointModel(sessionManager)); + manager.register({ subscriptions: [] as unknown[] } as unknown as vscodeApi.ExtensionContext); + return manager; + } + function fire(event: Partial<{ added: unknown[]; removed: unknown[]; changed: unknown[] }>) { + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ added: [], removed: [], changed: [], ...event }); + } + const handlesSetIn = () => mockSetBreakAtStepPoint.mock.calls.map((c) => c[0].handle); + + beforeEach(() => { + vi.mocked(debug.onDidChangeBreakpoints).mockClear(); + mockSetBreakAtStepPoint.mockClear(); + mockClearAllBreaks.mockClear(); + mockByOop.mockClear(); + vi.mocked(debug.addBreakpoints).mockClear(); + mockGetMethodSource.mockReturnValue('at: index\n^ self basicAt: index'); + mockGetSourceOffsets.mockReturnValue([1, 13]); + workspace.textDocuments = [ + { uri: Uri.parse(METHOD_URI), languageId: 'gemstone-smalltalk', isDirty: false }, + ]; + }); + + it('arms a breakpoint in the gem the method was opened from, not the selected one', () => { + const bp = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))); + debug.breakpoints = [bp]; + + managerOverTwo(); + fire({ added: [bp] }); + + // Armed in session one — the method on screen belongs to its gem. + expect(handlesSetIn()).toEqual(['gem-one']); + }); + + it("does not clear the selected session's breakpoints on the same method", () => { + const bp = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))); + debug.breakpoints = [bp]; + + managerOverTwo(); + fire({ added: [bp] }); + + // `applyToUri` clears the method before re-arming it. Aimed at the wrong + // gem, that clear would take out breakpoints the other session had set. + expect(mockClearAllBreaks.mock.calls.map((c) => c[0].handle)).toEqual(['gem-one']); + }); + + it('leaves a breakpoint whose session has logged out alone', () => { + const dead = new SourceBreakpoint( + new Location( + Uri.parse('gemstone://9/Globals/Array/instance/accessing/at%3A'), + new Position(1, 0), + ), + ); + debug.breakpoints = [dead]; + workspace.textDocuments = [ + { uri: dead.location.uri, languageId: 'gemstone-smalltalk', isDirty: false }, + ]; + + managerOverTwo(); + fire({ changed: [dead] }); + + // No gem gets it: pruning removes the row, and pushing it at whichever + // session happens to be selected would arm a stone nobody asked about. + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('does not mistake another session\u2019s method for the row the gem reported', () => { + // The breakpoint view reads its rows out of the SELECTED session's gem, so + // a row can only ever be about that session's method. Two sessions holding + // the same class, selector and step point must not collide. + const manager = managerOverTwo(); + // Session one's own VS Code breakpoint, the one that must NOT be flipped. + const theirs = new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(0, 0))); + debug.breakpoints = [theirs]; + manager.applyToUri(SESSION_ONE as never, Uri.parse(METHOD_URI), [{ line: 1, enabled: true }]); + vi.mocked(debug.addBreakpoints).mockClear(); + + manager.setEnabledForStoneBreakpoint( + { + breakNumber: 1, + className: 'Array', + isMeta: false, + selector: 'at:', + stepPoint: 1, + disabled: false, + environmentId: 0, + methodOop: '1234', + dictName: 'Globals', + category: 'accessing', + }, + false, + ); + + // Session one's VS Code breakpoint is not touched; the row is flipped in + // the selected gem by OOP instead. + expect(vi.mocked(debug.addBreakpoints)).not.toHaveBeenCalled(); + expect(mockByOop).toHaveBeenCalled(); + }); + + it('sweeps every live gem when all breakpoints are disabled', () => { + mockDisableAll.mockClear(); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))), + ]; + + managerOverTwo().setAllEnabled(false); + + // "All" spans one breakpoint list across every session, so a gem left + // un-swept keeps stopping execution behind a row that reads "disabled". + expect(mockDisableAll.mock.calls.map((c) => c[0].handle)).toEqual(['gem-one', 'gem-two']); + }); + + it('sweeps every live gem when all breakpoints are enabled again', () => { + mockEnableAll.mockClear(); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0)), false), + ]; + + managerOverTwo().setAllEnabled(true); + + expect(mockEnableAll.mock.calls.map((c) => c[0].handle)).toEqual(['gem-one', 'gem-two']); + }); + + it('sweeps every live gem when all breakpoints are removed', () => { + mockRemoveAll.mockClear(); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))), + ]; + + managerOverTwo().removeAll(); + + expect(mockRemoveAll.mock.calls.map((c) => c[0].handle)).toEqual(['gem-one', 'gem-two']); + }); + + it('keeps sweeping the other gems when one fails, and says which failed', () => { + mockRemoveAll.mockClear(); + mockRemoveAll.mockImplementationOnce(() => { + throw new Error('gem is busy'); + }); + debug.breakpoints = [ + new SourceBreakpoint(new Location(Uri.parse(METHOD_URI), new Position(1, 0))), + ]; + + managerOverTwo().removeAll(); + + expect(mockRemoveAll).toHaveBeenCalledTimes(2); + expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith( + expect.stringContaining('gem is busy'), + ); + }); + }); + + describe('when the gem refuses a breakpoint', () => { + beforeEach(() => { + mockSetBreakAtStepPoint.mockReset(); + mockDisableBreakAtStepPoint.mockReset(); + mockClearBreakAtStepPoint.mockReset(); + vi.mocked(window.showErrorMessage).mockClear(); + vi.mocked(window.showWarningMessage).mockClear(); + mockGetMethodSource.mockReturnValue('at: index\n^ self basicAt: index'); + mockGetSourceOffsets.mockReturnValue([1, 13]); + }); + + it('says so out loud, and carries the reason back for the debug adapter', () => { + // An unverified marker on its own is unreadable: it looks exactly like a + // breakpoint on a line with no step point. + mockSetBreakAtStepPoint.mockImplementation(() => { + throw new Error('GCI error 2010'); + }); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: true }, + ]); + + expect(results[0].verified).toBe(false); + expect(results[0].message).toContain('GCI error 2010'); + expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith( + expect.stringContaining('GCI error 2010'), + ); + }); + + it('takes the break back out when it armed but could not be disabled', () => { + // A disabled breakpoint is applied as set-then-disable. If the disable + // fails, the step point is armed while the marker says it is off — the + // worst state available, so the break is removed instead. + mockDisableBreakAtStepPoint.mockImplementation(() => { + throw new Error('GCI error 2010'); + }); + + const results = makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [ + { line: 1, enabled: false }, + ]); + + expect(mockClearBreakAtStepPoint).toHaveBeenCalled(); + expect(results[0].verified).toBe(false); + expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith( + expect.stringContaining('GCI error 2010'), + ); + }); + + it('says the step point is still armed when it cannot be taken back out either', () => { + mockDisableBreakAtStepPoint.mockImplementation(() => { + throw new Error('disable failed'); + }); + mockClearBreakAtStepPoint.mockImplementation(() => { + throw new Error('clear failed too'); + }); + + makeManager().applyToUri(session(), Uri.parse(METHOD_URI), [{ line: 1, enabled: false }]); + + expect(vi.mocked(window.showErrorMessage)).toHaveBeenCalledWith( + expect.stringContaining('still armed'), + ); + }); + + it('forgets the method when its step points cannot be read, since they were just cleared', () => { + // `applyToUri` clears the method first. If the step points then cannot be + // read, the record left behind would draw markers, hover text and view + // rows for breakpoints that exist in no gem. + const sessionManager = makeSessionManager(true); + const model = new StepPointModel(sessionManager); + const manager = new BreakpointManager(sessionManager, model); + const uri = Uri.parse(METHOD_URI); + manager.applyToUri(session(), uri, [{ line: 1, enabled: true }]); + expect(manager.appliedFor(uri)).toHaveLength(1); + + let fired = 0; + manager.onDidApply(() => fired++); + // Step points are cached per method; the cache is dropped when the + // selected session changes, so the next apply goes back to the stone — + // which is where a method that has since been removed fails. + model.clear(); + vi.mocked(getStepPointBundle).mockImplementationOnce(() => { + throw new Error('method not found'); + }); + + const results = manager.applyToUri(session(), uri, [{ line: 1, enabled: true }]); + + expect(manager.appliedFor(uri)).toHaveLength(0); + expect(fired).toBeGreaterThan(0); + expect(results[0].verified).toBe(false); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalled(); + }); + }); }); diff --git a/client/src/__tests__/functionBreakpoints.test.ts b/client/src/__tests__/functionBreakpoints.test.ts index 25916999..094bdfdc 100644 --- a/client/src/__tests__/functionBreakpoints.test.ts +++ b/client/src/__tests__/functionBreakpoints.test.ts @@ -16,11 +16,7 @@ import { __setConfig, __resetConfig, } from '../__mocks__/vscode'; -import { - FunctionBreakpointResolver, - parseFunctionName, - qualifiedName, -} from '../functionBreakpoints'; +import { FunctionBreakpointResolver, parseFunctionName } from '../functionBreakpoints'; import { SessionManager } from '../sessionManager'; import { implementorsOf, getSourceOffsets, getMethodSource } from '../browserQueries'; @@ -91,25 +87,6 @@ describe('parseFunctionName', () => { }); }); -describe('qualifiedName', () => { - const target = { - dictName: 'Globals', - className: 'Account', - isMeta: false, - selector: 'balance', - category: 'accessing', - environmentId: 0, - }; - - it('names the instance side plainly', () => { - expect(qualifiedName(target)).toBe('Account>>balance'); - }); - - it('names the class side the way Smalltalk writes it', () => { - expect(qualifiedName({ ...target, isMeta: true, selector: 'new' })).toBe('Account class>>new'); - }); -}); - describe('FunctionBreakpointResolver', () => { function makeSessionManager(hasSession = true) { return { @@ -179,10 +156,12 @@ describe('FunctionBreakpointResolver', () => { it('asks which class when several implement the selector', async () => { mockImplementors.mockReturnValue([savings, account]); vi.mocked(window.showQuickPick).mockResolvedValue({ target: savings }); + const bp = new FunctionBreakpoint('balance'); + // The resolver only ever sees breakpoints VS Code holds — the event fires + // *because* they are in the list — and it now checks they survived the picker. + debug.breakpoints = [bp]; - await new FunctionBreakpointResolver(makeSessionManager()).handle([ - new FunctionBreakpoint('balance'), - ]); + await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); const items = vi.mocked(window.showQuickPick).mock.calls[0][0] as { label: string }[]; // Sorted, so the list doesn't reorder between invocations. @@ -194,6 +173,7 @@ describe('FunctionBreakpointResolver', () => { mockImplementors.mockReturnValue([savings, account]); vi.mocked(window.showQuickPick).mockResolvedValue(undefined); const bp = new FunctionBreakpoint('balance'); + debug.breakpoints = [bp]; await new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); @@ -203,6 +183,33 @@ describe('FunctionBreakpointResolver', () => { expect(warn()).toHaveBeenCalledWith(expect.stringContaining('No class chosen')); }); + it('does not resurrect a breakpoint deleted while the class picker was open', async () => { + // The picker is the only point where this waits, so it is the only window in + // which the developer can delete the row out from under it. Answering the + // picker afterwards must not put back what they just removed. + mockImplementors.mockReturnValue([savings, account]); + const bp = new FunctionBreakpoint('balance'); + debug.breakpoints = [bp]; + + let release: (v: unknown) => void = () => {}; + vi.mocked(window.showQuickPick).mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = new FunctionBreakpointResolver(makeSessionManager()).handle([bp]); + debug.breakpoints = []; // the developer deletes the row + release({ target: savings }); // ...and only then picks a class + await pending; + + expect(added()).not.toHaveBeenCalled(); + // Nothing to take back out, and nothing to complain about — they got what + // they asked for. + expect(removed()).not.toHaveBeenCalled(); + expect(warn()).not.toHaveBeenCalled(); + }); + it('takes a qualified name at its word without prompting', async () => { mockImplementors.mockReturnValue([savings, account]); await new FunctionBreakpointResolver(makeSessionManager()).handle([ @@ -416,8 +423,11 @@ describe('FunctionBreakpointResolver', () => { ); const resolver = new FunctionBreakpointResolver(makeSessionManager()); - const first = resolver.handle([new FunctionBreakpoint('balance')]); - const second = resolver.handle([new FunctionBreakpoint('balance')]); + const one = new FunctionBreakpoint('balance'); + const two = new FunctionBreakpoint('balance'); + debug.breakpoints = [one, two]; + const first = resolver.handle([one]); + const second = resolver.handle([two]); release({ target: account }); await Promise.all([first, second]); diff --git a/client/src/__tests__/getStepPointBundle.test.ts b/client/src/__tests__/getStepPointBundle.test.ts new file mode 100644 index 00000000..052790e6 --- /dev/null +++ b/client/src/__tests__/getStepPointBundle.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { parseStepPointBundle } from '../queries/getStepPointBundle'; + +// The reply frames the source LAST and counts the rows ahead of it, so nothing in +// the source has to be escaped. These pin that framing: the source is whatever is +// left after the counted rows, byte for byte. +describe('parseStepPointBundle', () => { + it('splits offsets, selector rows and source', () => { + const raw = ['1,9,14', '2', '1\t0\t3\tfoo', '2\t8\t4\tbar:', 'foo\n ^ self bar: 1'].join('\n'); + const bundle = parseStepPointBundle(raw); + + expect(bundle.offsets).toEqual([1, 9, 14]); + expect(bundle.selectors).toEqual([ + { stepPoint: 1, selectorOffset: 0, selectorLength: 3, selectorText: 'foo' }, + { stepPoint: 2, selectorOffset: 8, selectorLength: 4, selectorText: 'bar:' }, + ]); + expect(bundle.source).toBe('foo\n ^ self bar: 1'); + }); + + it('keeps a source that looks like its own header rows', () => { + // The whole reason the count is sent: a method whose text is "2" or + // "103foo" must not be re-read as framing. + const source = '2\n1\t0\t3\tfoo\nstill source'; + const raw = ['5', '1', '1\t0\t3\tfoo', source].join('\n'); + expect(parseStepPointBundle(raw).source).toBe(source); + }); + + it('handles a method with no step points', () => { + const bundle = parseStepPointBundle(['', '0', 'comment\n ^ 1'].join('\n')); + expect(bundle.offsets).toEqual([]); + expect(bundle.selectors).toEqual([]); + expect(bundle.source).toBe('comment\n ^ 1'); + }); + + it('keeps a trailing newline in the source', () => { + expect(parseStepPointBundle(['1', '0', 'foo\n'].join('\n')).source).toBe('foo\n'); + }); +}); diff --git a/client/src/__tests__/stepPointHints.test.ts b/client/src/__tests__/stepPointHints.test.ts index 7b118e52..4735c713 100644 --- a/client/src/__tests__/stepPointHints.test.ts +++ b/client/src/__tests__/stepPointHints.test.ts @@ -2,11 +2,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('vscode', () => import('../__mocks__/vscode.js')); -vi.mock('../browserQueries', () => ({ - getMethodSource: vi.fn(() => ''), - getSourceOffsets: vi.fn(() => []), - getStepPointSelectorRanges: vi.fn(() => []), -})); +// `StepPointModel.fetch` asks for all three in one query now. The bundle mock +// delegates to the three separate mocks so every test keeps setting up its +// method the same way, one fact at a time. +vi.mock('../browserQueries', () => { + const getMethodSource = vi.fn(() => ''); + const getSourceOffsets = vi.fn((): number[] => []); + const getStepPointSelectorRanges = vi.fn((): unknown[] => []); + return { + getMethodSource, + getSourceOffsets, + getStepPointSelectorRanges, + getStepPointBundle: vi.fn((...args: unknown[]) => ({ + source: (getMethodSource as (...a: unknown[]) => string)(...args), + offsets: (getSourceOffsets as (...a: unknown[]) => number[])(...args), + selectors: (getStepPointSelectorRanges as (...a: unknown[]) => unknown[])(...args), + })), + }; +}); import type * as vscode from 'vscode'; import { Uri, Position, Range, debug, __setConfig, __resetConfig } from '../__mocks__/vscode'; @@ -146,6 +159,29 @@ describe('StepPointHintsProvider', () => { expect(makeProvider('always').provideInlayHints(makeDocument(), WHOLE)).toBeUndefined(); }); + // Writing a setting is slow enough that a second call lands mid-await — a + // double-click on the editor-title icon, or a held keybinding. + describe('toggle', () => { + it('flips back when invoked twice before the settings write lands', async () => { + const provider = makeProvider('always'); + expect(provider.visible()).toBe(true); + + // Both started before either await resolves, which is the race. + await Promise.all([provider.toggle(), provider.toggle()]); + + // Two toggles from 'always' is 'always' again. Reading the flag only after + // the write meant both calls saw 'always', both computed 'off', and the + // pair collapsed into one net change. + expect(provider.visible()).toBe(true); + }); + + it('flips once for a single invocation', async () => { + const provider = makeProvider('always'); + await provider.toggle(); + expect(provider.visible()).toBe(false); + }); + }); + describe('visible', () => { it('reports off correctly', () => { expect(makeProvider('off').visible()).toBe(false); diff --git a/client/src/__tests__/stepPointHover.test.ts b/client/src/__tests__/stepPointHover.test.ts index 5a19fcc2..c28ba6c3 100644 --- a/client/src/__tests__/stepPointHover.test.ts +++ b/client/src/__tests__/stepPointHover.test.ts @@ -2,11 +2,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('vscode', () => import('../__mocks__/vscode.js')); -vi.mock('../browserQueries', () => ({ - getMethodSource: vi.fn(() => ''), - getSourceOffsets: vi.fn(() => []), - getStepPointSelectorRanges: vi.fn(() => []), -})); +// `StepPointModel.fetch` asks for all three in one query now. The bundle mock +// delegates to the three separate mocks so every test keeps setting up its +// method the same way, one fact at a time. +vi.mock('../browserQueries', () => { + const getMethodSource = vi.fn(() => ''); + const getSourceOffsets = vi.fn((): number[] => []); + const getStepPointSelectorRanges = vi.fn((): unknown[] => []); + return { + getMethodSource, + getSourceOffsets, + getStepPointSelectorRanges, + getStepPointBundle: vi.fn((...args: unknown[]) => ({ + source: (getMethodSource as (...a: unknown[]) => string)(...args), + offsets: (getSourceOffsets as (...a: unknown[]) => number[])(...args), + selectors: (getStepPointSelectorRanges as (...a: unknown[]) => unknown[])(...args), + })), + }; +}); import type * as vscode from 'vscode'; import { Uri, Position } from '../__mocks__/vscode'; diff --git a/client/src/__tests__/stepPointModel.test.ts b/client/src/__tests__/stepPointModel.test.ts index a68e4e42..349dc540 100644 --- a/client/src/__tests__/stepPointModel.test.ts +++ b/client/src/__tests__/stepPointModel.test.ts @@ -2,11 +2,24 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('vscode', () => import('../__mocks__/vscode.js')); -vi.mock('../browserQueries', () => ({ - getMethodSource: vi.fn(() => ''), - getSourceOffsets: vi.fn(() => []), - getStepPointSelectorRanges: vi.fn(() => []), -})); +// `StepPointModel.fetch` asks for all three in one query now. The bundle mock +// delegates to the three separate mocks so every test keeps setting up its +// method the same way, one fact at a time. +vi.mock('../browserQueries', () => { + const getMethodSource = vi.fn(() => ''); + const getSourceOffsets = vi.fn((): number[] => []); + const getStepPointSelectorRanges = vi.fn((): unknown[] => []); + return { + getMethodSource, + getSourceOffsets, + getStepPointSelectorRanges, + getStepPointBundle: vi.fn((...args: unknown[]) => ({ + source: (getMethodSource as (...a: unknown[]) => string)(...args), + offsets: (getSourceOffsets as (...a: unknown[]) => number[])(...args), + selectors: (getStepPointSelectorRanges as (...a: unknown[]) => unknown[])(...args), + })), + }; +}); import { Uri } from '../__mocks__/vscode'; import { diff --git a/client/src/__tests__/stepPointSelectors.test.ts b/client/src/__tests__/stepPointSelectors.test.ts index 05645e9b..c00b582a 100644 --- a/client/src/__tests__/stepPointSelectors.test.ts +++ b/client/src/__tests__/stepPointSelectors.test.ts @@ -189,6 +189,60 @@ describe('expandKeywordParts', () => { expect(expanded).toHaveLength(1); }); + // A separator only ends the keyword message when it is at the top level. Inside + // parentheses or a block it belongs to an inner expression, and the scan has to + // carry on past it or the rest of the keyword is never found — leaving that part + // of the send with no step point to hover, number, or aim a breakpoint at. + it('keeps scanning past a cascade inside parentheses', () => { + const source = 'self foo: (s add: 1; yourself) bar: 2.'; + const infos: StepPointSelectorInfo[] = [ + { + stepPoint: 1, + selectorOffset: source.indexOf('foo:'), + selectorLength: 4, + selectorText: 'foo:', + }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(2); + expect(expanded[1]).toEqual({ + stepPoint: 1, + selectorOffset: source.indexOf('bar:'), + selectorLength: 4, + selectorText: 'bar:', + }); + }); + + it('keeps scanning past a period inside a block argument', () => { + const source = 'self foo: [ a bar. c baz ] qux: 2.'; + const infos: StepPointSelectorInfo[] = [ + { + stepPoint: 1, + selectorOffset: source.indexOf('foo:'), + selectorLength: 4, + selectorText: 'foo:', + }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded).toHaveLength(2); + expect(expanded[1].selectorText).toBe('qux:'); + expect(expanded[1].selectorOffset).toBe(source.indexOf('qux:')); + }); + + it('keeps scanning past a cascade inside a block argument', () => { + const source = 'coll do: [:e | e foo; bar ] separatedBy: 2.'; + const infos: StepPointSelectorInfo[] = [ + { + stepPoint: 1, + selectorOffset: source.indexOf('do:'), + selectorLength: 3, + selectorText: 'do:', + }, + ]; + const expanded = expandKeywordParts(source, infos); + expect(expanded.map((e) => e.selectorText)).toEqual(['do:', 'separatedBy:']); + }); + it('does not expand unary messages (no colon)', () => { const source = 'self size printString'; const infos: StepPointSelectorInfo[] = [ diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index 59371205..bfee5f10 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -4,6 +4,8 @@ import { parseMethodUri } from './gemstoneFileSystemProvider'; import * as queries from './browserQueries'; import { GemStoneBreakpoint } from './browserQueries'; import { FunctionBreakpointResolver } from './functionBreakpoints'; +import { messageOf } from './serverPlugin/installHelpers'; +import { describeMethodResult } from './methodResultsPicker'; import { StepPointModel, StepPointInfo, @@ -141,6 +143,27 @@ export class BreakpointManager { // ── Applying ───────────────────────────────────────────── + /** + * The live session a `gemstone://` URI belongs to — the gem that actually + * holds this method — or undefined when no such session is logged in. + * + * Not `getSelectedSession()`. A method editor stays bound to the session it + * was opened from while the developer switches the active one (see "Single vs. + * multiple sessions" in the README), so with more than one session live the + * selected session is routinely *not* the one holding the method on screen. + * Applying a breakpoint against it would clear and arm step points in the + * wrong stone: the method on screen would never stop, and a method the + * developer never touched in the other session would. + * + * The URI's session id is the authority, the same rule `pruneOrphans` and + * `clearAllForSession` already use. + */ + private sessionForUri(uri: vscode.Uri): ActiveSession | undefined { + const method = parseMethodUri(uri); + if (!method) return undefined; + return this.sessionManager.getSessions().find((s) => s.id === method.sessionId); + } + /** * Push every VS Code breakpoint on `uri` to the gem, replacing whatever the * method had. Returns one verified result per requested line, in order, for @@ -195,7 +218,25 @@ export class BreakpointManager { const info = this.stepPoints.fetch(session, uri, method); if (!info) { - return wanted.map((r) => ({ stepPoint: 0, actualLine: r.line, verified: false })); + // `clearAllBreaks` above has already emptied the method, so anything this + // manager still remembers for it is a lie: left in place it keeps drawing + // token markers, hover text and breakpoint-view rows for breaks that exist + // in no gem. Drop the record and let the views redraw — the same thing the + // "nothing wanted" path does — rather than returning early and leaving the + // developer looking at markers for breakpoints that are gone. + const reason = + `The breakpoints in ${describeMethodResult(method)} were cleared: its step points ` + + `could not be read. Reopen the method to set them again.`; + this.applied.delete(uri.toString()); + this.refreshEditorsFor(uri); + this._onDidApply.fire(); + vscode.window.showWarningMessage(reason); + return wanted.map((r) => ({ + stepPoint: 0, + actualLine: r.line, + verified: false, + message: reason, + })); } const results: VerifiedBreakpoint[] = []; @@ -226,6 +267,24 @@ export class BreakpointManager { } const applied: AppliedBreakpoint[] = []; + const failures: string[] = []; + + /** + * Refuse one step point, out loud. The marker going hollow is not enough on + * its own: an unverified marker looks exactly like a breakpoint on a line + * with no step point, so the reason has to be both said to the developer and + * carried back in the result for the debug adapter to relay. + */ + const refuse = (stepPoint: number, reason: string): void => { + failures.push(reason); + for (const r of results) { + if (r.stepPoint === stepPoint) { + r.verified = false; + r.message = reason; + } + } + }; + for (const bp of byStepPoint.values()) { try { queries.setBreakAtStepPoint( @@ -236,8 +295,37 @@ export class BreakpointManager { bp.stepPoint, method.environmentId, ); - if (!bp.enabled) { - queries.disableBreakAtStepPoint( + } catch (e) { + refuse( + bp.stepPoint, + `Could not set the breakpoint at step point ${bp.stepPoint} in ` + + `${describeMethodResult(method)}: ${messageOf(e)}`, + ); + continue; + } + + if (bp.enabled) { + applied.push(bp); + continue; + } + + // A disabled breakpoint is applied as set-then-disabled, so a failure here + // leaves the step point ARMED while the developer asked for it off. A + // marker reading "disabled" over a break that still stops execution is the + // worst state this code can produce, so take the break back out. + try { + queries.disableBreakAtStepPoint( + session, + method.className, + method.isMeta, + method.selector, + bp.stepPoint, + method.environmentId, + ); + } catch (e) { + let stillArmed = ' It is still armed in the gem and will stop execution.'; + try { + queries.clearBreakAtStepPoint( session, method.className, method.isMeta, @@ -245,14 +333,26 @@ export class BreakpointManager { bp.stepPoint, method.environmentId, ); + stillArmed = ''; + } catch { + /* Both calls failed; the message says the break is still armed. */ } - applied.push(bp); - } catch { - // Mark every result that resolved to this step point unverified. - for (const r of results) { - if (r.stepPoint === bp.stepPoint) r.verified = false; - } + refuse( + bp.stepPoint, + `Could not disable the breakpoint at step point ${bp.stepPoint} in ` + + `${describeMethodResult(method)}: ${messageOf(e)}.${stillArmed}`, + ); + continue; } + applied.push(bp); + } + + if (failures.length > 0) { + vscode.window.showErrorMessage( + failures.length === 1 + ? failures[0] + : `${failures.length} breakpoints could not be applied. ${failures.join(' ')}`, + ); } if (applied.length > 0) this.applied.set(uri.toString(), applied); @@ -355,7 +455,7 @@ export class BreakpointManager { this.frozen.add(uri.toString()); const method = parseMethodUri(uri); - const session = this.sessionManager.getSelectedSession(); + const session = this.sessionForUri(uri); const applied = this.applied.get(uri.toString()) ?? []; const info = method && session ? this.stepPoints.fetch(session, uri, method) : null; @@ -391,7 +491,7 @@ export class BreakpointManager { if (!this.frozen.has(uriStr) || document.isDirty) return; this.frozen.delete(uriStr); - const session = this.sessionManager.getSelectedSession(); + const session = this.sessionForUri(document.uri); if (session) this.applyToUri(session, document.uri); } @@ -576,16 +676,17 @@ export class BreakpointManager { const mine = gemstoneBreakpoints().filter((bp) => bp.enabled !== enabled); if (mine.length > 0) replaceEnabled(mine, enabled); - const session = this.sessionManager.getSelectedSession(); - if (!session) return; - try { - if (enabled) queries.enableAllBreakpoints(session); - else queries.disableAllBreakpoints(session); - } catch (e) { + // Every live session's gem, not just the selected one. The rows just flipped + // are one list spanning all of them, so sweeping a single gem would leave + // another session's breaks armed behind rows that read "disabled" — the + // gutter would say one thing and execution would do another. + const failures = this.sweepEveryGem((session) => + enabled ? queries.enableAllBreakpoints(session) : queries.disableAllBreakpoints(session), + ); + if (failures.length > 0) { vscode.window.showErrorMessage( - `Could not ${enabled ? 'enable' : 'disable'} breakpoints: ${message(e)}`, + `Could not ${enabled ? 'enable' : 'disable'} breakpoints in ${failures.join('; ')}`, ); - return; } this._onDidApply.fire(); } @@ -595,20 +696,39 @@ export class BreakpointManager { const mine = gemstoneBreakpoints(); if (mine.length > 0) vscode.debug.removeBreakpoints(mine); - const session = this.sessionManager.getSelectedSession(); - if (session) { - try { - queries.removeAllBreakpoints(session); - } catch (e) { - vscode.window.showErrorMessage(`Could not remove breakpoints: ${message(e)}`); - return; - } + // Every live gem, for the same reason as `setAllEnabled`: the rows removed + // above span all of them, and a gem left un-swept would keep stopping + // execution at a breakpoint with no marker left anywhere to explain it. + const failures = this.sweepEveryGem((session) => queries.removeAllBreakpoints(session)); + if (failures.length > 0) { + vscode.window.showErrorMessage(`Could not remove breakpoints in ${failures.join('; ')}`); } this.applied.clear(); for (const editor of vscode.window.visibleTextEditors) this.refreshDecorations(editor); this._onDidApply.fire(); } + /** + * Run a gem-wide breakpoint operation on every live session, and answer the + * ones that failed, already phrased for a message. + * + * One failing session does not stop the others: with several sessions live, + * abandoning the sweep half way would leave the remaining gems armed behind + * rows that say otherwise, which is the very state the caller is trying to + * avoid. + */ + private sweepEveryGem(operation: (session: ActiveSession) => void): string[] { + const failures: string[] = []; + for (const session of this.sessionManager.getSessions()) { + try { + operation(session); + } catch (e) { + failures.push(`session ${session.id}: ${messageOf(e)}`); + } + } + return failures; + } + // ── Acting on what the gem reports ─────────────────────── /** @@ -648,8 +768,16 @@ export class BreakpointManager { private ownedBreakpoint(bp: GemStoneBreakpoint): vscode.SourceBreakpoint | undefined { const session = this.sessionManager.getSelectedSession(); if (!session) return undefined; + // The rows this is matching against were read out of the selected session's + // gem, so only a method from that session can be behind one. Without this, + // two sessions holding the same class and selector at the same step point + // would collide, and toggling a row here would flip a breakpoint belonging + // to a method opened from the other session — the same prefix rule + // `pruneOrphans` and `clearAllForSession` apply. + const prefix = `gemstone://${session.id}/`; for (const [uriStr, applied] of this.applied) { + if (!uriStr.startsWith(prefix)) continue; if (!applied.some((a) => a.stepPoint === bp.stepPoint)) continue; const uri = vscode.Uri.parse(uriStr); const method = parseMethodUri(uri); @@ -678,7 +806,7 @@ export class BreakpointManager { try { queries.breakpointByOop(session, bp.methodOop, op, bp.stepPoint); } catch (e) { - vscode.window.showErrorMessage(`Breakpoint operation failed: ${message(e)}`); + vscode.window.showErrorMessage(`Breakpoint operation failed: ${messageOf(e)}`); return; } this._onDidApply.fire(); @@ -830,9 +958,6 @@ export class BreakpointManager { // gutter as a solid red dot arming nothing and saying nothing. this.refuseOutsideMethodSource(event.added); - const session = this.sessionManager.getSelectedSession(); - if (!session) return; - const affected = new Set(); for (const bp of [...event.added, ...event.removed, ...event.changed]) { if (bp instanceof vscode.SourceBreakpoint && bp.location.uri.scheme === 'gemstone') { @@ -841,6 +966,11 @@ export class BreakpointManager { } for (const uriStr of affected) { const uri = vscode.Uri.parse(uriStr); + // Each method goes to its OWN session's gem, not to whichever session is + // selected — see `sessionForUri`. A URI with no live session behind it is + // left alone: `pruneOrphans` above has already taken its row out. + const session = this.sessionForUri(uri); + if (!session) continue; if (isDirty(uri)) { this.holdWhileDirty(uri, event.added); continue; @@ -994,67 +1124,3 @@ function isDirty(uri: vscode.Uri): boolean { const uriStr = uri.toString(); return vscode.workspace.textDocuments.some((d) => d.uri.toString() === uriStr && d.isDirty); } - -function message(e: unknown): string { - return e instanceof Error ? e.message : String(e); -} - -/** - * Build a table of character offsets for the start of each line (1-based). - * lineOffsets[1] = 0 (first line starts at offset 0) - * lineOffsets[2] = position after first newline - * etc. - */ -export function buildLineOffsets(source: string): number[] { - const offsets: number[] = [0]; // dummy at index 0 - offsets.push(0); // line 1 starts at offset 0 - - for (let i = 0; i < source.length; i++) { - if (source[i] === '\n') { - offsets.push(i + 1); - } - } - return offsets; -} - -/** - * Map a precise 0-based cursor offset to a step point — column-aware, for "Run to - * Cursor". Prefers the step point on the cursor's OWN line that is nearest the - * cursor column, so a cursor on `asInteger` in `x := (...) asInteger` breaks at - * `asInteger` (not the leftmost `:=` store), and a cursor inside a one-line block - * (`self do: [:e | body ]`) breaks INSIDE the block (not at the `do:` send). When - * the cursor's line has no step point, falls back to the nearest step point at or - * after the cursor (run forward). Returns null when nothing is at/after it. - * - * `sourceOffsets` are GemStone 1-based source positions; `lineStart`/`lineEnd` are - * the 0-based char offsets bounding the cursor's line (end exclusive). - */ -export function mapOffsetToStepPoint( - cursorOffset: number, - sourceOffsets: number[], - lineStart: number, - lineEnd: number, -): { stepPoint: number; offset: number } | null { - // 1) Nearest step point on the cursor's own line (by column distance). - let bestOnLine: { stepPoint: number; offset: number; dist: number } | null = null; - for (let i = 0; i < sourceOffsets.length; i++) { - const off0 = sourceOffsets[i] - 1; // 1-based source position → 0-based char offset - if (off0 >= lineStart && off0 < lineEnd) { - const dist = Math.abs(off0 - cursorOffset); - if (bestOnLine === null || dist < bestOnLine.dist) { - bestOnLine = { stepPoint: i + 1, offset: sourceOffsets[i], dist }; - } - } - } - if (bestOnLine) return { stepPoint: bestOnLine.stepPoint, offset: bestOnLine.offset }; - - // 2) No step point on this line — run forward to the nearest one after the cursor. - let bestAfter: { stepPoint: number; offset: number } | null = null; - for (let i = 0; i < sourceOffsets.length; i++) { - const off0 = sourceOffsets[i] - 1; - if (off0 >= cursorOffset && (bestAfter === null || sourceOffsets[i] < bestAfter.offset)) { - bestAfter = { stepPoint: i + 1, offset: sourceOffsets[i] }; - } - } - return bestAfter; -} diff --git a/client/src/browserQueries.ts b/client/src/browserQueries.ts index 63707df8..6e4491f7 100644 --- a/client/src/browserQueries.ts +++ b/client/src/browserQueries.ts @@ -198,6 +198,7 @@ import { getAllSelectors as sharedGetAllSelectors } from './queries/getAllSelect import { getMethodList as sharedGetMethodList } from './queries/getMethodList'; import { getSourceOffsets as sharedGetSourceOffsets } from './queries/getSourceOffsets'; import { getStepPointSelectorRanges as sharedGetStepPointSelectorRanges } from './queries/getStepPointSelectorRanges'; +import { getStepPointBundle as sharedGetStepPointBundle } from './queries/getStepPointBundle'; import { listRowanProjects as sharedListRowanProjects } from './queries/rowan/listRowanProjects'; import { getGemCacheKB as sharedGetGemCacheKB } from './queries/rowan/getGemCacheKB'; import { exportRowanProject as sharedExportRowanProject } from './queries/rowan/exportRowanProject'; @@ -270,6 +271,7 @@ export type { DescendantClass } from './refactoring/queries/getClassDescendantNa export type { MoveArgs } from './refactoring/queries/previewInstVarStructure'; export type { MethodEntry } from './queries/getMethodList'; export type { StepPointSelectorInfo } from './queries/getStepPointSelectorRanges'; +export type { StepPointBundle } from './queries/getStepPointBundle'; export type { GemStoneBreakpoint } from './queries/getAllBreakpoints'; export type { MethodSearchResult } from './queries/methodSearch'; export type { RowanProject, RowanProjectList } from './queries/rowan/listRowanProjects'; @@ -1955,6 +1957,24 @@ export function getSourceOffsets( ); } +export function getStepPointBundle( + session: ActiveSession, + className: string, + isMeta: boolean, + selector: string, + environmentId: number = 0, + dict?: number | string, +) { + return sharedGetStepPointBundle( + defaultQueryExecutorUsing(session), + className, + isMeta, + selector, + environmentId, + dict, + ); +} + export function getStepPointSelectorRanges( session: ActiveSession, className: string, diff --git a/client/src/debuggerPanel.ts b/client/src/debuggerPanel.ts index 6ee08ae3..77ace084 100644 --- a/client/src/debuggerPanel.ts +++ b/client/src/debuggerPanel.ts @@ -7,7 +7,7 @@ import * as debug from './debugQueries'; import * as queries from './browserQueries'; import { drainTranscript } from './transcriptSink'; import { appendTranscriptOutput } from './transcriptChannel'; -import { buildLineOffsets, mapOffsetToStepPoint } from './breakpointManager'; +import { buildLineStarts, stepPointAtOffset, StepPointInfo } from './stepPointModel'; import { EnhancedInspector } from './enhancedInspector/enhancedInspector'; import { InspectorTreeProvider } from './inspectorTreeProvider'; import { routeInspect } from './inspectRouter'; @@ -2804,23 +2804,27 @@ export class DebuggerPanel { // Source is always shown 1:1 — doits run the user's raw code (no wrapper // glue), so displayed and stored coordinates coincide. - const dispLineOffsets = buildLineOffsets(rawSource); + // + // Resolved through the shared step point resolver, the one home for "which + // step point is this position", so Run to Cursor here and a breakpoint set + // in an editor cannot disagree about which token step point 7 is. The + // stone's offsets are 1-based and everything in that model is 0-based. + const info: StepPointInfo = { + source: rawSource, + offsets: offsets.map((o) => o - 1), + selectors: [], // only used for underlining, which Run to Cursor never does + lineStarts: buildLineStarts(rawSource), + }; + const dispLineOffsets = info.lineStarts; const pos = editor.selection.active; const dispLineStart = dispLineOffsets[pos.line + 1]; if (dispLineStart === undefined) return undefined; // cursor past the source (stale editor) const cursorOffset = dispLineStart + pos.character; - // Column-aware map needs the cursor's line bounds in STORED coords. - const storedLineOffsets = dispLineOffsets; - let storedLine = 1; - for (let l = 1; l < storedLineOffsets.length; l++) { - if (storedLineOffsets[l] <= cursorOffset) storedLine = l; - else break; - } - const lineStart = storedLineOffsets[storedLine]; - const lineEnd = storedLineOffsets[storedLine + 1] ?? rawSource.length; // end exclusive - const mapped = mapOffsetToStepPoint(cursorOffset, offsets, lineStart, lineEnd); - if (!mapped) return undefined; + const resolved = stepPointAtOffset(info, cursorOffset); + if (!resolved) return undefined; + // Back to the stone's 1-based position the rest of this method works in. + const mapped = { stepPoint: resolved.stepPoint, offset: resolved.offset + 1 }; if (!home.uriInfo) { // Doit / non-symbol-list: break by the method's OOP (no class>>selector). diff --git a/client/src/debuggerView.js b/client/src/debuggerView.js index b3bab31f..e5927898 100644 --- a/client/src/debuggerView.js +++ b/client/src/debuggerView.js @@ -478,9 +478,11 @@ setActiveEditor: setActiveVarEditor, }; - // Enable "Run to Cursor" only when the selected frame is breakable (an editable - // method we can set a step-point break in). A doit / "Executed Code" frame has - // no such method, so the button is disabled there (host also guards). + // Enable "Run to Cursor" only when the selected frame is breakable — one whose + // home method the host resolved, so it has somewhere to set a step-point + // break. A doit / "Executed Code" frame IS breakable: it has an anonymous + // home method, and the host breaks in it by method OOP rather than by + // class>>selector. Only an unresolvable `` disables the button. function updateRunToCursor(level) { if (!runToCursorBtn) return; const frame = currentStack.find(function (f) { diff --git a/client/src/functionBreakpoints.ts b/client/src/functionBreakpoints.ts index 2b53f9f2..340c75ad 100644 --- a/client/src/functionBreakpoints.ts +++ b/client/src/functionBreakpoints.ts @@ -4,6 +4,7 @@ import { buildMethodUri } from './gemstoneFileSystemProvider'; import * as queries from './browserQueries'; import { MethodSearchResult } from './browserQueries'; import { buildLineStarts, lineOfOffset } from './stepPointModel'; +import { describeMethodResult } from './methodResultsPicker'; import { logInfo } from './gciLog'; /** A method name typed into the Breakpoints panel, taken apart. */ @@ -44,11 +45,6 @@ export function parseFunctionName(raw: string): ParsedFunctionName | null { return { isMeta: false, selector: name.replace(/^#\s*/, '') }; } -/** How a name should be shown once it has been pinned to one class. */ -export function qualifiedName(target: MethodSearchResult): string { - return `${target.className}${target.isMeta ? ' class' : ''}>>${target.selector}`; -} - /** * Turns a *function* breakpoint — the kind VS Code's `+` button creates, named * rather than located — into an ordinary breakpoint on the method's entry. @@ -139,7 +135,7 @@ export class FunctionBreakpointResolver { logInfo( `[breakpoints] "${bp.functionName}" parsed as ${describe(parsed)}; ` + - `${candidates.length} candidate(s): ${candidates.map(qualifiedName).join(', ') || 'none'}`, + `${candidates.length} candidate(s): ${candidates.map(describeMethodResult).join(', ') || 'none'}`, ); if (candidates.length === 0) { @@ -147,8 +143,26 @@ export class FunctionBreakpointResolver { return; } - const target = - candidates.length === 1 ? candidates[0] : await this.chooseClass(candidates, parsed.selector); + let target: MethodSearchResult | undefined; + if (candidates.length === 1) { + target = candidates[0]; + } else { + target = await this.chooseClass(candidates, parsed.selector); + + // The picker is the only place this method waits, and VS Code's breakpoint + // list is free to change while it does. A developer who deletes the row + // instead of answering has said what they want, so going on to add a + // located breakpoint would put back the thing they just removed. Checked + // only on this path because the single-candidate one never yields, so the + // list cannot have moved under it. `inFlight` does not cover this — it + // stops the same *name* being resolved twice, not the breakpoint + // disappearing mid-await. + if (!vscode.debug.breakpoints.includes(bp)) { + logInfo(`[breakpoints] "${bp.functionName}" was removed while the picker was open`); + return; + } + } + if (!target) { // The developer dismissed the picker. Drop the breakpoint rather than // leave an unresolved one sitting in the panel looking live. @@ -158,7 +172,7 @@ export class FunctionBreakpointResolver { const entry = this.entryPosition(session, target); if (!entry) { - this.reject(bp, `${qualifiedName(target)} has no step points to break at.`); + this.reject(bp, `${describeMethodResult(target)} has no step points to break at.`); return; } @@ -187,7 +201,7 @@ export class FunctionBreakpointResolver { }); logInfo( - `[breakpoints] ${qualifiedName(target)} entry is line ${entry.line} col ${entry.character}; ` + + `[breakpoints] ${describeMethodResult(target)} entry is line ${entry.line} col ${entry.character}; ` + `converting to ${uri.toString()}`, ); diff --git a/client/src/methodResultsPicker.ts b/client/src/methodResultsPicker.ts index 439e60fd..ed2f4940 100644 --- a/client/src/methodResultsPicker.ts +++ b/client/src/methodResultsPicker.ts @@ -10,8 +10,19 @@ import { MethodSearchResult } from './queries/methodSearch'; import { SystemBrowser } from './systemBrowser'; import { buildMethodUri } from './gemstoneFileSystemProvider'; -/** How a found method reads in a list or a sentence: `Account class >> #reset`. */ -export function describeMethodResult(result: MethodSearchResult): string { +/** + * How a method reads in a list or a sentence: `Account class >> #reset`. + * + * Takes only the three parts of the name so it serves anything that names a + * method — a search result, a method URI's coordinates, a breakpoint's — rather + * than each caller growing its own copy of the format and drifting apart from + * this one. + */ +export function describeMethodResult(result: { + className: string; + isMeta: boolean; + selector: string; +}): string { return `${result.className}${result.isMeta ? ' class' : ''} >> #${result.selector}`; } diff --git a/client/src/queries/getAllBreakpoints.ts b/client/src/queries/getAllBreakpoints.ts index a6502034..ba9eb456 100644 --- a/client/src/queries/getAllBreakpoints.ts +++ b/client/src/queries/getAllBreakpoints.ts @@ -1,4 +1,5 @@ import { QueryExecutor } from './types'; +import { symbolListIndexOfClassExpr } from './util'; /** One method breakpoint as the gem currently holds it. */ export interface GemStoneBreakpoint { @@ -50,20 +51,18 @@ export interface GemStoneBreakpoint { * * Dictionary and category come back too, so a caller can open the method in an * editor without a second round trip per breakpoint. The dictionary is matched - * by class *identity*, not by name, so a class name shadowed in two - * dictionaries resolves to the one actually holding this class. + * by class *identity* through the shared `symbolListIndexOfClassExpr`, not by + * name, so a class name shadowed in two dictionaries resolves to the one + * actually holding this class. */ export function getAllBreakpoints(execute: QueryExecutor): GemStoneBreakpoint[] { const code = `| ws sl dictOf isCurrent | ws := WriteStream on: String new. sl := System myUserProfile symbolList. -dictOf := [:aCls | | base found | +dictOf := [:aCls | | base idx | base := aCls isMeta ifTrue: [aCls thisClass] ifFalse: [aCls]. - found := ''. - 1 to: sl size do: [:i | - (found isEmpty and: [((sl at: i) at: base name asSymbol ifAbsent: [nil]) == base]) - ifTrue: [found := ((sl at: i) name ifNil: ['']) asString]]. - found]. + idx := ${symbolListIndexOfClassExpr('base')}. + idx = 0 ifTrue: [''] ifFalse: [((sl at: idx) name ifNil: ['']) asString]]. "Is this GsNMethod still the one installed for its class and selector? A recompile leaves the old method object holding its breakpoints, and the gem goes on reporting them." diff --git a/client/src/queries/getStepPointBundle.ts b/client/src/queries/getStepPointBundle.ts new file mode 100644 index 00000000..998d25d3 --- /dev/null +++ b/client/src/queries/getStepPointBundle.ts @@ -0,0 +1,111 @@ +import { QueryExecutor } from './types'; +import { compiledMethodExpr } from './util'; +import { StepPointSelectorInfo } from './getStepPointSelectorRanges'; + +/** Everything the step point model needs about one method, from one round trip. */ +export interface StepPointBundle { + source: string; + /** GemStone's 1-based source positions, exactly as `_sourceOffsets` gives them. */ + offsets: number[]; + selectors: StepPointSelectorInfo[]; +} + +/** + * Source, step point offsets and selector ranges for one method, in a single + * query. + * + * These three were fetched separately, which cost three sequential GCI round + * trips per method. They are read on the extension host from `provideInlayHints` + * and `provideHover` — synchronously, because the GCI binding is synchronous — + * so the first hover on a freshly opened method stalled the host for all three + * in a row. The stone already computes the first two to answer the third, so + * asking once is strictly less work for it as well. + * + * The reply puts the variable-length, anything-goes part last so nothing has to + * be escaped: line 1 is the offsets, line 2 is how many selector rows follow, + * then that many rows, and **everything after them is the source verbatim** — + * newlines, tabs and all. + */ +export function getStepPointBundle( + execute: QueryExecutor, + className: string, + isMeta: boolean, + selector: string, + environmentId: number = 0, + dict?: number | string, +): StepPointBundle { + const method = compiledMethodExpr(className, isMeta, selector, environmentId, dict); + // _sourceOffsets is 1-based; selectorOffset is emitted 0-based for JS callers, + // matching what getStepPointSelectorRanges has always returned. + const code = `| method source offsets ws rows count | +method := ${method}. +source := method sourceString. +offsets := method _sourceOffsets. +ws := WriteStream on: String new. +1 to: offsets size do: [:i | + i > 1 ifTrue: [ws nextPut: $,]. + ws nextPutAll: (offsets at: i) printString]. +ws lf. +rows := WriteStream on: String new. +count := 0. +1 to: offsets size do: [:stepIdx | + | offset1 end ch | + offset1 := offsets at: stepIdx. + (offset1 >= 1 and: [offset1 <= source size]) ifTrue: [ + ch := source at: offset1. + (ch isLetter or: [ch = $_]) ifTrue: [ + end := offset1 + 1. + [end <= source size and: [ + | c | + c := source at: end. + c isLetter or: [c isDigit or: [c = $: or: [c = $_]]]]] + whileTrue: [end := end + 1]. + count := count + 1. + rows nextPutAll: stepIdx printString; tab; + nextPutAll: (offset1 - 1) printString; tab; + nextPutAll: (end - offset1) printString; tab; + nextPutAll: (source copyFrom: offset1 to: end - 1); lf]]]. +ws nextPutAll: count printString; lf. +ws nextPutAll: rows contents. +ws nextPutAll: source. +ws contents`; + + return parseStepPointBundle(execute(code)); +} + +/** + * Split the reply back into its three parts. Exported for the tests, which pin + * the framing — a source whose own text looks like a header row is exactly what + * a length-counted format has to survive. + */ +export function parseStepPointBundle(raw: string): StepPointBundle { + const nl = (from: number) => { + const at = raw.indexOf('\n', from); + return at === -1 ? raw.length : at; + }; + + const offsetsEnd = nl(0); + const offsetsLine = raw.slice(0, offsetsEnd); + const offsets = + offsetsLine.length === 0 ? [] : offsetsLine.split(',').map((n) => parseInt(n, 10)); + + const countEnd = nl(offsetsEnd + 1); + const count = parseInt(raw.slice(offsetsEnd + 1, countEnd), 10) || 0; + + const selectors: StepPointSelectorInfo[] = []; + let pos = countEnd + 1; + for (let i = 0; i < count; i++) { + const end = nl(pos); + const parts = raw.slice(pos, end).split('\t'); + pos = end + 1; + if (parts.length < 4) continue; + selectors.push({ + stepPoint: parseInt(parts[0], 10), + selectorOffset: parseInt(parts[1], 10), + selectorLength: parseInt(parts[2], 10), + selectorText: parts[3], + }); + } + + return { source: raw.slice(Math.min(pos, raw.length)), offsets, selectors }; +} diff --git a/client/src/stepPointHints.ts b/client/src/stepPointHints.ts index 94268438..55bb9046 100644 --- a/client/src/stepPointHints.ts +++ b/client/src/stepPointHints.ts @@ -64,16 +64,25 @@ export class StepPointHintsProvider implements vscode.InlayHintsProvider { ); } - /** Flip the numbers on or off, and remember it in the user's settings. */ + /** + * Flip the numbers on or off, and remember it in the user's settings. + * + * `display` is claimed *before* the settings write, not after. Writing a + * setting is slow enough that a double-click on the editor-title icon, or a + * held keybinding, gets a second call in while the first is still awaiting — + * and if the flag were still the old value then, both calls would compute the + * same `next` and two toggles would collapse into one net change. Claiming it + * up front makes the second call read the first one's answer and flip back, + * which is what the developer asked for. The configuration listener still + * redraws when the write lands; this only decides what the *next* call sees. + */ async toggle(): Promise { const next: StepPointDisplay = this.visible() ? 'off' : 'always'; + this.display = next; + this.refresh(); await vscode.workspace .getConfiguration('gemstone') .update('stepPoints.display', next, vscode.ConfigurationTarget.Global); - // The configuration listener redraws; setting it here keeps `visible()` - // honest if the update event is slow to arrive. - this.display = next; - this.refresh(); } /** Whether numbers are showing at this moment. */ diff --git a/client/src/stepPointModel.ts b/client/src/stepPointModel.ts index e1dc6544..8882e2eb 100644 --- a/client/src/stepPointModel.ts +++ b/client/src/stepPointModel.ts @@ -124,25 +124,13 @@ export class StepPointModel { const cached = this.cache.get(key); if (cached) return cached; - let source: string; - let rawOffsets: number[]; - let rawSelectors: StepPointSelectorInfo[]; + // One round trip, not three. This runs synchronously on the extension host + // from `provideInlayHints` and `provideHover`, so each extra GCI call is + // host time the editor is not drawing in — and the stone computes the source + // and the offsets anyway in order to answer the selector ranges. + let bundle: queries.StepPointBundle; try { - source = queries.getMethodSource( - session, - method.className, - method.isMeta, - method.selector, - method.environmentId, - ); - rawOffsets = queries.getSourceOffsets( - session, - method.className, - method.isMeta, - method.selector, - method.environmentId, - ); - rawSelectors = queries.getStepPointSelectorRanges( + bundle = queries.getStepPointBundle( session, method.className, method.isMeta, @@ -159,11 +147,11 @@ export class StepPointModel { this.lastError = undefined; const info: StepPointInfo = { - source, + source: bundle.source, // _sourceOffsets is 1-based; every consumer here works in 0-based offsets. - offsets: rawOffsets.map((o) => o - 1), - selectors: expandKeywordParts(source, rawSelectors), - lineStarts: buildLineStarts(source), + offsets: bundle.offsets.map((o) => o - 1), + selectors: expandKeywordParts(bundle.source, bundle.selectors), + lineStarts: buildLineStarts(bundle.source), }; this.cache.set(key, info); return info; diff --git a/client/src/stepPointSelectors.ts b/client/src/stepPointSelectors.ts index a44b0367..1cf4e7a3 100644 --- a/client/src/stepPointSelectors.ts +++ b/client/src/stepPointSelectors.ts @@ -47,6 +47,11 @@ function isTokenChar(ch: string): boolean { * the first keyword (`assert:`) at the step point offset. This function scans * the source text to find continuation keywords (`equals:`) at the same * nesting depth and adds them as additional entries with the same step point. + * + * The scan runs to the end of the statement, which is the next `.` or `;` *at + * the top level*. Separators nested inside parentheses or a block belong to an + * inner expression and are scanned past, since the keyword send continues after + * the bracket closes. */ export function expandKeywordParts( source: string, @@ -74,7 +79,14 @@ export function expandKeywordParts( pos++; continue; } - if (ch === '.' || ch === ';') break; + // A statement or cascade separator ends the keyword message only at the top + // level. Inside parentheses or a block it belongs to an inner expression — + // `self foo: (s add: 1; yourself) bar: 2` and `self foo: [a bar. c] qux: 2` + // are each one keyword send — so stopping there would lose every keyword + // after it, leaving that part of the send with no step point to hover, + // number or aim a breakpoint at. Depth-gated to match the identifier scan + // below, which has always only looked at depth 0. + if (depth === 0 && (ch === '.' || ch === ';')) break; // Skip string literals (handle embedded '' quotes) if (ch === "'") { From 20f30fdb1f1d97e80336383781ed91fd890dd670 Mon Sep 17 00:00:00 2001 From: Eric Winger Date: Thu, 27 Aug 2026 14:38:04 -0700 Subject: [PATCH 19/19] Close the gaps a second review found in the breakpoint work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tests first, since three of the six findings were untested claims: - The one-round-trip step point query had never executed against a stone. It replaced three proven queries on the hover and inlay-hint path, and every test mocked it away, so a syntax error or any difference in sourceString / _sourceOffsets between releases would have stopped step point numbers entirely with the suite still green. Three integration cases now compare its three parts against the three queries it replaced, prove the reply is not merely empty by landing a selector range on real source text, and pin the framing with a method whose source carries tabs, blank lines and text shaped like the reply's own header rows. - The debug adapter's relay of a refusal reason was untested on the branch that carries it: a refused breakpoint reports the reason, an accepted one reports neither reason nor message, and the column of an inline breakpoint is forwarded. - A workspace breakpoint is refused whether or not a session is logged in. The refusal deliberately runs before the session lookup, and nothing pinned that. One leak, with the behaviour it could reach: - A method held still while its editor was dirty was only ever released by a revert. A save arrives as a recompile instead, and VS Code fires no text change for it, so the URI stayed held for the life of the window and a later unrelated clean edit would re-apply the method. Released on recompile, and swept when the session logs out. Documentation: - README says a breakpoint belongs to the session its method was opened from, and that Enable/Disable/Remove All sweep every logged-in session — a real behaviour change for anyone running concurrent sessions, previously written down only in the CHANGELOG. - CHANGELOG entries for four fixes that had none: the resurrected named breakpoint, the one-round-trip step point read, the method-name format, and the step point numbers toggle. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 + README.md | 17 +++- .../src/__tests__/breakpointManager.test.ts | 70 +++++++++++++++ .../__tests__/breakpoints.integration.test.ts | 89 +++++++++++++++++++ .../__tests__/gemstoneDebugSession.test.ts | 80 +++++++++++++++++ client/src/breakpointManager.ts | 10 +++ 6 files changed, 268 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7cbbd24c..ba3e8eeb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,10 @@ All notable changes to the **GemStone Smalltalk** extension will be documented i ### Fixed +- **A named breakpoint deleted while Jasper was still asking which class no longer comes back.** A function breakpoint typed as a bare selector has to be pinned to one class, and where several implement it Jasper asks. Deleting the row instead of answering left the picker still open, and answering it then re-created the breakpoint that had just been removed. The breakpoint is checked for still being there before it is converted. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Step points are read in one round trip instead of three.** Source, step point offsets and selector ranges were three sequential calls to the stone, made synchronously on the extension host from the hover and the inlay hints — so the first hover on a freshly opened method stalled the editor for all three in a row. They now arrive in one reply, which is also less work for the stone, since it computed the first two to answer the third anyway. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **A method is named the same way everywhere.** Function-breakpoint messages and log lines wrote `Account class>>reset` while every other method list — Senders, Implementors, References, safe delete — wrote `Account class >> #reset`. They all use the one shared helper now. ([#262](https://github.com/GemTalk/Jasper/issues/262)) +- **Toggling step point numbers twice in quick succession no longer counts as once.** The toggle read the current setting before writing the new one, so a double-click or a held keybinding computed the same answer twice and the second flip was lost. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Breakpoints go to the session the method belongs to.** With more than one session live (`gemstone.sessionMode: "multiple"`), a method editor stays bound to the session it was opened from — so setting a breakpoint in it no longer clears and arms step points in whichever session happens to be *selected*, which left the method on screen never stopping while another stone stopped in code nobody asked about. **Enable All**, **Disable All** and **Remove All** now sweep every live session's gem rather than only the selected one, so a row that reads "disabled" is disabled everywhere; if one gem refuses, the others are still swept and the failure names the session. A row in the **GemStone Breakpoints** view can only match a method from the session the view read it from, so two sessions holding the same class, selector and step point no longer collide. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **A breakpoint the gem refuses now says so.** A failed set or disable was swallowed, leaving a hollow marker that looks exactly like a breakpoint on a line with no step point; the stone's own message is now shown and carried back to the debugger. A breakpoint that armed but could not then be disabled is taken back out rather than left stopping execution behind a marker that reads "disabled" — and if it cannot be removed either, the message says it is still armed. When a method's step points cannot be read at all, its stale markers, hover text and view rows are dropped instead of being left pointing at breakpoints that exist in no gem. ([#262](https://github.com/GemTalk/Jasper/issues/262)) - **Half of a keyword message could be left with no step point.** The stone reports only the *first* keyword of a keyword send at a step point, so Jasper scans the source for the rest — but the scan stopped at the first `.` or `;` regardless of how deeply nested it was. A separator inside parentheses or a block belongs to an inner expression, not to the send being scanned, so `self foo: (s add: 1; yourself) bar: 2` and `self foo: [a bar. c baz] qux: 2` each lost everything after the bracket: no number on the trailing keyword, nothing on hover, and no way to aim a breakpoint at it. The scan now stops only at a top-level separator, matching the identifier scan beside it, which had always been depth-gated. ([#262](https://github.com/GemTalk/Jasper/issues/262)) diff --git a/README.md b/README.md index 3e4a64c6..df98f809 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,8 @@ The only difference is cardinality: a login may now have several session childre > **Note:** In multiple-session mode, an open workspace/editor stays bound to the session that opened it even after you switch the active session, so the active session, the Explorer, and an open editor can point at different sessions at once. If you use a custom `gemstone.exportPath`, include the `{session}` variable so concurrent sessions don't overwrite each other's exported files. +> **Breakpoints follow the editor, not the active session.** Because an editor stays bound to the session that opened it, a breakpoint set in that editor is armed in that session's gem — so it stops the code you are looking at rather than the session that happens to be selected. **Enable All**, **Disable All** and **Remove All Breakpoints** go the other way and sweep *every* logged-in session, because VS Code keeps a single breakpoint list for the whole window. See [Breakpoints](#breakpoints). + ### Code Execution With an active session, execute Smalltalk code from any editor: @@ -296,9 +298,20 @@ the thing it was set in goes away: **Enable/Disable Breakpoint at Cursor**. A disabled breakpoint stays set in the gem so re-arming it is instant; its token marker turns grey and faded, matching the grey the gutter dot goes +- **A breakpoint belongs to the session its method came from.** A method editor + stays bound to the session that opened it, so a breakpoint set in it is armed + in *that* session's gem — not in whichever session happens to be active. With + one session live there is no difference; with several, this is what makes the + breakpoint stop the code you were looking at. A row in the **GemStone + Breakpoints** view can likewise only act on a method from the session the view + is reading - **All at once** — **Enable All**, **Disable All** and **Remove All - Breakpoints** act on every GemStone breakpoint, including any set outside - Jasper by topaz or a `halt` left in the code + Breakpoints** act on every GemStone breakpoint in **every logged-in session**, + including any set outside Jasper by topaz or a `halt` left in the code. VS Code + keeps one breakpoint list for the window, so "all" means all of it: sweeping + only the active session would leave rows reading *disabled* over breakpoints + that still stop execution. If one session's gem refuses, the others are still + swept and the message names the one that failed - **Clear All Breakpoints in Method** drops every breakpoint in the method you are in - **Not honoured: conditions, hit counts and log messages.** VS Code's *Edit diff --git a/client/src/__tests__/breakpointManager.test.ts b/client/src/__tests__/breakpointManager.test.ts index 1fe738c6..11502d57 100644 --- a/client/src/__tests__/breakpointManager.test.ts +++ b/client/src/__tests__/breakpointManager.test.ts @@ -375,6 +375,30 @@ describe('BreakpointManager', () => { expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([stray]); }); + it('takes one back with nobody logged in, since it is wrong either way', () => { + // The refusal deliberately runs BEFORE any session lookup: a breakpoint in + // a workspace is wrong whether or not a session is live, and a + // logged-out developer is the one most likely to click that gutter. With + // the lookup first, this breakpoint would sit there arming nothing and + // saying nothing. + const stray = bpOn('untitled:Workspace'); + workspace.textDocuments = [ + { uri: Uri.parse('untitled:Workspace'), languageId: 'gemstone-smalltalk' }, + ]; + debug.breakpoints = [stray]; + + const manager = makeManager(false); // no session, and none logged in + const context = { subscriptions: [] as unknown[] } as unknown as vscodeApi.ExtensionContext; + manager.register(context); + const calls = vi.mocked(debug.onDidChangeBreakpoints).mock.calls; + calls[calls.length - 1][0]({ added: [stray], removed: [], changed: [] }); + + expect(vi.mocked(debug.removeBreakpoints)).toHaveBeenCalledWith([stray]); + expect(vi.mocked(window.showWarningMessage)).toHaveBeenCalledWith( + expect.stringContaining('compiled GemStone method'), + ); + }); + it("never touches another extension's breakpoints", () => { // The guard that matters most. `onDidChangeBreakpoints` reports every // extension's breakpoints, so a rule of "not a gemstone:// URI" would take @@ -526,6 +550,52 @@ describe('BreakpointManager', () => { expect(mockSetBreakAtStepPoint).toHaveBeenCalled(); }); + it('stops holding a method once it is saved, so a later edit cannot re-apply it', () => { + // Saving is the ordinary way out of a dirty editor, and it never reaches + // `thawIfClean` — VS Code fires no text-document change for a save, only + // the recompile that arrives as `invalidateForUri`. A method left held + // would be re-applied by the next unrelated clean edit, which nobody asked + // for, and would stay held for the life of the window. + workspace.textDocuments = [DIRTY_DOC]; + const held = bpAt(1); + debug.breakpoints = [held]; + const manager = fire({ added: [held] }); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + + // The save: the recompile drops the method's breakpoints. + manager.invalidateForUri(Uri.parse(METHOD_URI)); + mockSetBreakAtStepPoint.mockClear(); + mockClearAllBreaks.mockClear(); + + // A later clean change to the same document — an edit that leaves no + // unsaved state, e.g. an undo back to the saved text. + workspace.textDocuments = [CLEAN_DOC]; + debug.breakpoints = []; + fireDocumentChanged(CLEAN_DOC); + + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + + it('stops holding a method when its session logs out', () => { + workspace.textDocuments = [DIRTY_DOC]; + const held = bpAt(1); + debug.breakpoints = [held]; + const manager = fire({ added: [held] }); + + manager.clearAllForSession(1); + mockSetBreakAtStepPoint.mockClear(); + mockClearAllBreaks.mockClear(); + + // Nothing is left to catch up to — the gem is gone. + workspace.textDocuments = [CLEAN_DOC]; + debug.breakpoints = []; + fireDocumentChanged(CLEAN_DOC); + + expect(mockClearAllBreaks).not.toHaveBeenCalled(); + expect(mockSetBreakAtStepPoint).not.toHaveBeenCalled(); + }); + it('ignores a document change that leaves the editor still dirty', () => { workspace.textDocuments = [DIRTY_DOC]; debug.breakpoints = [bpAt(1)]; diff --git a/client/src/__tests__/breakpoints.integration.test.ts b/client/src/__tests__/breakpoints.integration.test.ts index 0227f0be..b95f4f9d 100644 --- a/client/src/__tests__/breakpoints.integration.test.ts +++ b/client/src/__tests__/breakpoints.integration.test.ts @@ -95,6 +95,95 @@ describe('GemStone breakpoint semantics (integration)', () => { fixture(); }); + /** + * The one-round-trip step point query, against a real stone. + * + * `getStepPointBundle` replaced three proven queries — `getMethodSource`, + * `getSourceOffsets` and `getStepPointSelectorRanges` — with a single doit that + * frames all three answers into one reply. Every other test in the suite mocks + * it away, so nothing else would notice a syntax error in that doit, a + * `WriteStream` method missing on an older boundary, or a difference in what + * `sourceString` / `_sourceOffsets` return between releases. It is the hot path + * for hover and inlay hints: if it fails, step point numbers stop entirely. + * + * So these compare the bundle's three parts against the three queries it + * replaced, on whichever stone is running. + */ + describe('the one-round-trip step point bundle', () => { + const FRAMING_SELECTOR = 'vsCodeBundleFramingFixture'; + + /** + * A method whose source is hostile to the reply's framing: a tab-indented + * line, blank lines, a comma-separated string that looks like the offsets + * header, and a tab inside a string literal that looks like a selector row. + * The format is line-framed and length-counted precisely so source text + * cannot be mistaken for a header — this is what proves it. + */ + const framingFixture = (): void => { + queries.compileMethod( + session(), + TEST_CLASS, + false, + 'test-vscode-extension', + `${FRAMING_SELECTOR}\n\t"tab-indented comment"\n\n | a b |\n a := '1,2,3'.\n b := '4\t5\t6'.\n\n ^ a size + b size`, + ); + expect(queries.getAllSelectors(session(), TEST_CLASS)).toContain(FRAMING_SELECTOR); + }; + + it('answers exactly what the three separate queries answer', () => { + const bundle = queries.getStepPointBundle(session(), TEST_CLASS, false, TEST_SELECTOR); + + expect(bundle.source).toBe( + queries.getMethodSource(session(), TEST_CLASS, false, TEST_SELECTOR), + ); + expect(bundle.offsets).toEqual( + queries.getSourceOffsets(session(), TEST_CLASS, false, TEST_SELECTOR), + ); + expect(bundle.selectors).toEqual( + queries.getStepPointSelectorRanges(session(), TEST_CLASS, false, TEST_SELECTOR), + ); + }); + + it('answers a usable bundle at all, not an empty one', () => { + // A doit that fails to parse, or a stone that answers something + // unexpected, would parse into empty parts rather than throw — so equality + // with the other queries is not enough on its own. + const bundle = queries.getStepPointBundle(session(), TEST_CLASS, false, TEST_SELECTOR); + + expect(bundle.source).toContain(TEST_SELECTOR); + expect(bundle.offsets.length).toBeGreaterThan(2); + expect(Math.min(...bundle.offsets)).toBeGreaterThanOrEqual(1); + // `printString` starts on an identifier, so it must come back as a + // selector range; the offsets it reports must land on that text. + const printString = bundle.selectors.find((r) => r.selectorText === 'printString'); + expect(printString).toBeDefined(); + expect( + bundle.source.slice( + printString!.selectorOffset, + printString!.selectorOffset + printString!.selectorLength, + ), + ).toBe('printString'); + }); + + it('carries source containing tabs, blank lines and header-shaped text verbatim', () => { + framingFixture(); + + const bundle = queries.getStepPointBundle(session(), TEST_CLASS, false, FRAMING_SELECTOR); + const source = queries.getMethodSource(session(), TEST_CLASS, false, FRAMING_SELECTOR); + + expect(bundle.source).toBe(source); + expect(bundle.source).toContain('\t"tab-indented comment"'); + expect(bundle.source).toContain("'1,2,3'"); + expect(bundle.source).toContain('\n\n'); + expect(bundle.offsets).toEqual( + queries.getSourceOffsets(session(), TEST_CLASS, false, FRAMING_SELECTOR), + ); + expect(bundle.selectors).toEqual( + queries.getStepPointSelectorRanges(session(), TEST_CLASS, false, FRAMING_SELECTOR), + ); + }); + }); + it('the fixture method has step points to break at', () => { const offsets = queries.getSourceOffsets(session(), TEST_CLASS, false, TEST_SELECTOR); expect(offsets.length).toBeGreaterThan(2); diff --git a/client/src/__tests__/gemstoneDebugSession.test.ts b/client/src/__tests__/gemstoneDebugSession.test.ts index eb0d9e47..c5275a3f 100644 --- a/client/src/__tests__/gemstoneDebugSession.test.ts +++ b/client/src/__tests__/gemstoneDebugSession.test.ts @@ -674,6 +674,86 @@ describe('GemStoneDebugSession', () => { expect(body.breakpoints).toHaveLength(0); }); + /** + * A breakpoint the manager refused on a real method — a GCI failure while + * arming, or an editor with unsaved edits. The manager produces the reason; + * this is the only thing that carries it out to the developer, so without a + * test here the reasons could stop arriving and nothing would fail. + */ + function managerReturning(results: unknown[]): BreakpointManager { + return { + setBreakpointsForSource: vi.fn(() => results), + } as unknown as BreakpointManager; + } + + const attached = (manager: BreakpointManager) => { + const { session } = createTestSession(manager); + callRequest(session, 'attachRequest', makeResponse('attach'), { + sessionId: 1, + gsProcess: '12345', + }); + return session; + }; + + const METHOD_PATH = 'gemstone://1/Globals/Array/instance/accessing/at%3A'; + + it("relays the manager's refusal reason for a method the developer pointed at", () => { + const session = attached( + managerReturning([ + { + stepPoint: 0, + actualLine: 2, + verified: false, + message: 'Could not set the breakpoint at step point 2: GCI error 2010', + }, + ]), + ); + + const response = makeResponse('setBreakpoints'); + callRequest(session, 'setBreakpointsRequest', response, { + source: { path: METHOD_PATH }, + breakpoints: [{ line: 2 }], + }); + + const body = response.body as { + breakpoints: { verified: boolean; reason?: string; message?: string; line: number }[]; + }; + expect(body.breakpoints[0]).toMatchObject({ verified: false, reason: 'failed', line: 2 }); + expect(body.breakpoints[0].message).toContain('GCI error 2010'); + }); + + it('says nothing extra for a breakpoint that was accepted', () => { + // `reason: 'failed'` is only for a refusal. An ordinary verified + // breakpoint must not carry one, or every breakpoint would look refused. + const session = attached(managerReturning([{ stepPoint: 1, actualLine: 1, verified: true }])); + + const response = makeResponse('setBreakpoints'); + callRequest(session, 'setBreakpointsRequest', response, { + source: { path: METHOD_PATH }, + breakpoints: [{ line: 1 }], + }); + + const body = response.body as { breakpoints: Record[] }; + expect(body.breakpoints[0]).toMatchObject({ verified: true, line: 1 }); + expect(body.breakpoints[0]).not.toHaveProperty('reason'); + expect(body.breakpoints[0]).not.toHaveProperty('message'); + }); + + it('forwards the column of an inline breakpoint, so it aims at the right step point', () => { + const manager = managerReturning([{ stepPoint: 3, actualLine: 2, verified: true }]); + const session = attached(manager); + + const response = makeResponse('setBreakpoints'); + callRequest(session, 'setBreakpointsRequest', response, { + source: { path: METHOD_PATH }, + breakpoints: [{ line: 2, column: 14 }], + }); + + const forwarded = vi.mocked(manager.setBreakpointsForSource).mock.calls[0]; + expect(forwarded[2]).toEqual([2]); + expect(forwarded[3]).toEqual([14]); + }); + // A frame with no gemstone:// path is an ad-hoc execution ('Executed Code') // or a method whose class is not in the symbol list. Neither is a saved, // compiled method the developer can point at, so the request is refused with diff --git a/client/src/breakpointManager.ts b/client/src/breakpointManager.ts index bfee5f10..a299c624 100644 --- a/client/src/breakpointManager.ts +++ b/client/src/breakpointManager.ts @@ -829,6 +829,12 @@ export class BreakpointManager { invalidateForUri(uri: vscode.Uri): void { this.stepPoints.invalidate(uri); this.applied.delete(uri.toString()); + // Saving is the ordinary way out of a dirty editor, and it arrives here + // rather than through `thawIfClean` — VS Code fires no text-document change + // for a save. Without this the URI would stay held for the life of the + // window, and a later unrelated clean edit would re-apply a method nobody + // asked about. + this.frozen.delete(uri.toString()); // Removing these re-enters onBreakpointsChanged with none left for the // method, which clears the gem's breaks and refreshes the view. @@ -856,6 +862,10 @@ export class BreakpointManager { for (const key of [...this.applied.keys()]) { if (key.startsWith(prefix)) this.applied.delete(key); } + // Held methods go with the gem too: nothing is left to catch up to. + for (const key of [...this.frozen]) { + if (key.startsWith(prefix)) this.frozen.delete(key); + } this.stepPoints.invalidateSession(sessionId); for (const editor of vscode.window.visibleTextEditors) { if (editor.document.uri.toString().startsWith(prefix)) {