Skip to content

Commit 13e4a4c

Browse files
Filter declaration and brace lines from statement stops
Source stepping came to rest on lines a user does not think of as steppable: `fn`/`impl` headers, the `#[contractimpl]` export shim, and the closing brace the compiler attributes a function's return to. At opt-level 0 every such line carries its own line-table row, so step-in / next / continue / step-back all stopped on them. Add two statement-stop rules (docs/stepping.md S17/S18), applied only at statement granularity — instruction stepping and breakpoints are unchanged: - S17 (declaration suppression): drop run starts on attribute lines (`#[...]`, always glue) and `fn`/`impl`/`trait`/`mod` headers. Step-in and the entry stop now land on a function's first real body statement. Exception: a header that is its frame's sole run start (a fully collapsed one-line function) is kept so step-in still has a target. - S18 (brace suppression): drop run starts on a bare `}` unless it is the function's final brace — the epilogue the return is attributed to, kept as the one place to inspect the return value. Inner braces never rest. Implementation: pure `classifyLineRole` + `statementStops` in stops.ts; `SourceMapper.sourceTextForIndex` (DwarfSourceMapper reads/caches the file, NullSourceMapper returns null); the session filters the raw run starts at the single chokepoint. Breakpoints keep narrowing against the raw run starts, so a breakpoint on a filtered line still fires per S12. Developed test-first via the TDD agent workflow (scripts/wf-stop-filtering.js): new test/stops.test.ts unit-pins classifyLineRole/statementStops, and the control/dap suites are updated to the post-filter ground truth. Reviewer approved; 270 passing, types/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ab53508 commit 13e4a4c

12 files changed

Lines changed: 1084 additions & 207 deletions

docs/stepping.md

Lines changed: 85 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -45,19 +45,25 @@ Every stepping decision is derived from four per-record facts:
4545
the initial loop entry plus one per re-test of the `while` condition.
4646

4747
**Stop points.** At instruction granularity the stop points are the visible
48-
records. At statement granularity the stop points are the run starts. The
49-
cursor must only ever come to rest on a stop point of the active granularity —
50-
never on an invisible record, and (at statement granularity) never on an
51-
unmapped one, except when the trace contains no stop point at all.
48+
records. At statement granularity the stop points are the run starts that
49+
survive *declaration/brace filtering* (S17, S18): a debugger user expects to
50+
rest on statements and expressions, not on `fn`/`impl` headers, the
51+
`#[contractimpl]` export shim, or block-closing braces. The cursor must only
52+
ever come to rest on a stop point of the active granularity — never on an
53+
invisible record, and (at statement granularity) never on an unmapped or
54+
filtered-out one, except when filtering would leave the trace (or a function
55+
frame) with no stop point at all.
5256

5357
## Rules
5458

5559
### Session start and run boundaries
5660

5761
- **S1** (entry stop): the initial stop lands on the first stop point of the
58-
trace — the first run start when line info exists, else the first visible
59-
record, else index 0. The user's first view is never a sourceless,
60-
addressless frame.
62+
trace — the first statement stop (the first declaration/brace-filtered run
63+
start, S17/S18) when line info exists, else the first visible record, else
64+
index 0. The user's first view is a real statement inside the invoked
65+
function, never the `#[contractimpl]` export shim or a bare signature line,
66+
and never a sourceless, addressless frame.
6167
- **S2** (forward exhaustion): a forward step (any kind, any granularity) with
6268
no further stop point ahead moves to the **last** stop point of the trace
6369
(staying put when already there) and still reports a stop. It never
@@ -125,6 +131,43 @@ unmapped one, except when the trace contains no stop point at all.
125131
sourceless only when the cursor legitimately rests on an unmapped stop point
126132
(instruction granularity, or no line info at all).
127133

134+
### Statement stops: declarations and braces
135+
136+
Optimization-level 0 preserves a line-table row for lines a user does not think
137+
of as steppable — the `fn`/`impl` header a function is entered "at", the
138+
`#[contractimpl]` macro shim that wraps every exported entry point, and the
139+
closing brace the compiler attributes a function's return/epilogue to. Left
140+
unfiltered these produce stops on declarations and lone braces, which read as
141+
noise. Statement-granularity stop points are therefore the run starts minus the
142+
following (instruction granularity is unaffected — every visible record remains
143+
an instruction stop):
144+
145+
- **S17** (declaration suppression): a run start whose source line is a
146+
*declaration* — an attribute line (trimmed text begins `#[` or `#![`,
147+
including the `#[contractimpl]` export shim) or an item header (`fn`, with any
148+
`pub`/`const`/`async`/`unsafe`/`extern` qualifiers; `impl`; `trait`; `mod`) —
149+
is not a statement stop. Step-in therefore lands on a function's first real
150+
body statement, and the session's entry stop (S1) never rests on the export
151+
shim or a signature line. **Exception:** when a declaration line is the *only*
152+
run start of its function-body frame — a fully collapsed one-line function
153+
whose body shares the signature's line (common above opt-0) — it is kept, so
154+
step-in still has a target and S4/S16 hold. Attribute lines are never kept by
155+
this exception (the export shim is always glue); only `fn`/`impl` headers are.
156+
- **S18** (brace suppression): a run start whose source line is a bare closing
157+
brace (trimmed text is `}`, `};`, or `},`) is not a statement stop **unless**
158+
it is the function's *final* brace — the epilogue the return is attributed to,
159+
recognized as a brace run start immediately followed (in run order) by a
160+
shallower-depth run start, or by nothing. That single epilogue brace is kept
161+
as the one place to inspect the return value before the frame unwinds; every
162+
inner block-closing brace is suppressed. When a frame's epilogue is instead
163+
attributed to a declaration line (macro-generated export wrappers map their
164+
return to `#[contractimpl]`), S17 governs and no epilogue stop is kept for it.
165+
166+
Filtering never empties the trace: if it would remove every stop, the unfiltered
167+
run starts stand (preserving S1/S2/S3). Breakpoints and instruction-granularity
168+
stepping are unchanged — S17/S18 shape only where statement stepping comes to
169+
rest.
170+
128171
## Build prerequisite: optimization level
129172

130173
Statement stepping is only as good as the DWARF line table the build emits, and
@@ -157,40 +200,46 @@ crate's production `opt-level` for the debug build only — the crate's
157200
- `test/fixtures/stepper-debug.{wasm,trace.jsonl}``sum_triples(3)` on
158201
`examples/stepper`: a real `call` to an `#[inline(never)]` helper **with
159202
implicit return** (no `return` record — S5, S7, S8), and a 3-iteration
160-
`while` loop (S6, S9, S12, S13, S15). Ground truth (record ↦ line):
161-
entry shim = lib.rs:20, loop condition = lib.rs:25 (records 21–26, 39–43,
162-
56–60, 73–77), call line = lib.rs:26 (27–28, 44–45, 61–62), callee body =
163-
lib.rs:15 (29–31, 46–48, 63–65), epilogue `return` = lib.rs:20 (84).
164-
Correct depths: callee records 29–31/46–48/63–65 are one frame deeper than
165-
the surrounding loop records; the trace both enters and leaves `triple`
166-
three times.
203+
`while` loop (S6, S9, S12, S13, S15). Built above opt-0, so `triple`'s body
204+
collapses onto its signature line. Statement stops (trace index ↦ line):
205+
21 (:25 `while`), 27 (:26 call line), **29 (:15 `triple`)**, 39/44 (:25/:26),
206+
46 (:15), 56/61 (:25/:26), 63 (:15), 73 (:25). The `#[contractimpl]` shim
207+
(record 5, :20) and the epilogue that maps back to it (record 84, :20) are
208+
dropped by S17; `triple`'s only line is its `fn` signature, kept by the S17
209+
sole-frame-stop exception so step-in still lands (records 29/46/63 are one
210+
frame deeper — the trace enters and leaves `triple` three times).
167211

168212
- `test/fixtures/control-debug.wasm` + `control-{seq,branch,count,while_call,choose}.trace.jsonl`
169213
— one opt-0 wasm (`examples/control`) shared by five traces, each isolating a
170214
Rust construct so the suite pins how source stepping crosses it. All five
171-
functions live in `impl Control` (the `#[contractimpl]` export shim maps to
172-
`lib.rs:20`, entered at depths 0 then 1; the function body runs at depth 2, a
173-
called `bump` at depth 3). Statement-stop ground truth (trace index ↦ line):
174-
- **seq(7)** — pure sequence: 6/21 (:20 shim), 219 (:23 sig), 236 (:24 `let a`),
175-
249 (:25 `let b`), 262 (:26 `let c`), 265 (:28 return). Lines strictly
176-
increase; one stop each (S4/S5 basics, no re-execution).
177-
- **branch(3)** — if/else, `3 <= 10` so the **else** arm runs: 219 (:31 sig),
178-
226 (:33 `if`), 244 (:36 else arm), 245 (:33 merge), 246 (:38 `r`), 248
179-
(:39). The **then** arm (`:34`) never appears — stepping enters only the
180-
taken arm.
181-
- **count(3)**`for i in 0..3`: 219 (:42 sig), 228 (:43 `let acc`), then the
182-
`for` header (:44) and the body `acc += i` (:45) alternate per iteration (:44
183-
at 233/414/561/708 — three iterations plus the terminating `next() -> None`
184-
check; :45 at 400/547/694 — the body), 805 (:47 `acc`), 808 (:48). The body
185-
line stops exactly once per iteration (S6/S12) — the defining loop behavior.
186-
- **while_call(3)**`while` + a real `bump` call: 52/53/54 setup, then per
187-
iteration 55 (`while`), 56 (`acc += bump(i)`), **15/16/18 = `bump` body at
188-
depth 3**, 57 (`i += 1`); three times; then 55 (final test), 59/60. `stepIn`
189-
at :56 descends to :15; `next` at :56 steps over to :57 (S4 vs S5); the
215+
functions live in `impl Control`; the `#[contractimpl]` export shim (:20, at
216+
depths 0 then 1) and each `pub fn` signature are dropped by S17, so the body
217+
(depth 2, a called `bump` at depth 3) is where stepping rests. Statement-stop
218+
ground truth (trace index ↦ line, post-S17/S18):
219+
- **seq(7)** — pure sequence: 236 (:24 `let a`), 249 (:25 `let b`), 262 (:26
220+
`let c`), 265 (:28 `}` epilogue kept by S18). Body lines strictly increase;
221+
one stop each (S4/S5 basics, no re-execution). Dropped: 6/21 (:20 shim),
222+
219 (:23 sig).
223+
- **branch(3)** — if/else, `3 <= 10` so the **else** arm runs: 226 (:33 `if`),
224+
244 (:36 else arm), 245 (:33 merge), 246 (:38 `r`), 248 (:39 `}`). The
225+
**then** arm (`:34`) never appears — stepping enters only the taken arm.
226+
- **count(3)**`for i in 0..3`: 228 (:43 `let acc`), then the `for` header
227+
(:44) and the body `acc += i` (:45) alternate per iteration (:44 at
228+
233/414/561/708 — three iterations plus the terminating `next() -> None`
229+
check; :45 at 400/547/694 — the body), 805 (:47 `acc`), 808 (:48 `}`). The
230+
body line stops exactly once per iteration (S6/S12) — the defining loop
231+
behavior.
232+
- **while_call(3)**`while` + a real `bump` call: 228/231 (:53/:54 setup),
233+
then per iteration 234 (:55 `while`), 243 (:56 `acc += bump(i)`), **266
234+
(:16 `bump` body) / 278 (:18 `bump` `}` epilogue) at depth 3**, 291 (:57
235+
`i += 1`); three times (266/278, 337/349, 408/420); then 447 (:55 final
236+
test), 456/459 (:59 `acc` / :60 `}`). `stepIn` at :56 descends to the first
237+
`bump` **statement** :16 — the `fn bump` signature (records 249/320/391,
238+
:15) is dropped by S17; `next` at :56 steps over to :57 (S4 vs S5); the
190239
callee returns implicitly (no `return` record — S5/S7/S8).
191-
- **choose(7)**`match x % 3`, `7 % 3 == 1` so arm **`1 => 200`** runs: 219
192-
(:63 sig), 228 (:64 `match`), 240 (:66 arm), 243 (:69 `r`), 245 (:70). Arms
193-
`0 =>` (:65) and `_ =>` (:67) never appear.
240+
- **choose(7)**`match x % 3`, `7 % 3 == 1` so arm **`1 => 200`** runs: 228
241+
(:64 `match`), 240 (:66 arm), 243 (:69 `r`), 245 (:70 `}`). Arms `0 =>`
242+
(:65) and `_ =>` (:67) never appear.
194243

195244
Regenerate all pairs together with `scripts/make-fixtures.sh`.
196245

0 commit comments

Comments
 (0)