@@ -23,7 +23,13 @@ import {
2323import { DebugProtocol } from '@vscode/debugprotocol' ;
2424import * as path from 'path' ;
2525import { TraceModel } from './TraceModel' ;
26- import { classifyLineRole , computeDepths , computeRunStarts , statementStops } from './stops' ;
26+ import {
27+ classifyLineRole ,
28+ computeDepths ,
29+ computeRunStarts ,
30+ firstNonWhitespaceColumn ,
31+ statementStops ,
32+ } from './stops' ;
2733import { SourceMapper } from '../sourcemap/SourceMapper' ;
2834import { Disassembly } from '../wasm/Disassembly' ;
2935import { ResolvedTrace , SessionBackend , SorobanLaunchArgs } from './types' ;
@@ -304,9 +310,18 @@ export class SorobanDebugSession extends DebugSession {
304310 const frameName = `${ renderInstr ( rec . instr ) } [${ this . model . cursor } /${ this . model . length - 1 } ]` ;
305311
306312 // Unmapped records get no Source at all (and line 0): the client keeps
307- // showing the frame name instead of opening a wrong file.
313+ // showing the frame name instead of opening a wrong file. S19: a mapped
314+ // frame reports the line's first non-whitespace column, not the arbitrary
315+ // DWARF sub-expression column; fall back to the DWARF column when the line
316+ // text is unavailable or all-whitespace.
308317 const frame : DebugProtocol . StackFrame = loc
309- ? new StackFrame ( FRAME_ID , frameName , new Source ( path . basename ( loc . path ) , loc . path ) , loc . line , loc . column ?? 0 )
318+ ? new StackFrame (
319+ FRAME_ID ,
320+ frameName ,
321+ new Source ( path . basename ( loc . path ) , loc . path ) ,
322+ loc . line ,
323+ firstNonWhitespaceColumn ( this . source . sourceTextForIndex ( this . model . cursor ) ) ?? loc . column ?? 0 ,
324+ )
310325 : new StackFrame ( FRAME_ID , frameName ) ;
311326 const reference = this . instructionPointerReference ( ) ;
312327 if ( reference !== undefined ) {
@@ -443,7 +458,7 @@ export class SorobanDebugSession extends DebugSession {
443458 ) : void {
444459 this . sendResponse ( response ) ;
445460 // S5/S10: step over — the next stop point not in a deeper frame.
446- this . stopAfter ( ( ) => this . moveCursor ( this . stopPoints ( args . granularity ) , 1 , this . currentDepth ( ) ) ) ;
461+ this . stepForward ( args . granularity , this . currentDepth ( ) ) ;
447462 }
448463
449464 protected stepInRequest (
@@ -452,7 +467,7 @@ export class SorobanDebugSession extends DebugSession {
452467 ) : void {
453468 this . sendResponse ( response ) ;
454469 // S4/S10: step in — the next stop point regardless of depth.
455- this . stopAfter ( ( ) => this . moveCursor ( this . stopPoints ( args . granularity ) , 1 , Infinity ) ) ;
470+ this . stepForward ( args . granularity , Infinity ) ;
456471 }
457472
458473 protected stepOutRequest (
@@ -461,8 +476,9 @@ export class SorobanDebugSession extends DebugSession {
461476 ) : void {
462477 this . sendResponse ( response ) ;
463478 // S7: step out — the next stop point in a shallower frame; at the
464- // outermost recorded depth this exhausts and clamps like S2.
465- this . stopAfter ( ( ) => this . moveCursor ( this . stopPoints ( args . granularity ) , 1 , this . currentDepth ( ) - 1 ) ) ;
479+ // outermost recorded depth this exhausts and terminates (S20) at statement
480+ // granularity, or clamps like S2 at instruction granularity.
481+ this . stepForward ( args . granularity , this . currentDepth ( ) - 1 ) ;
466482 }
467483
468484 // --- Reverse stepping (time travel) ----------------------------------
@@ -472,8 +488,10 @@ export class SorobanDebugSession extends DebugSession {
472488 args : DebugProtocol . StepBackArguments ,
473489 ) : void {
474490 this . sendResponse ( response ) ;
475- // S8/S10: reverse step over — the previous stop point not in a deeper frame.
476- this . stopAfter ( ( ) => this . moveCursor ( this . stopPoints ( args . granularity ) , - 1 , this . currentDepth ( ) ) ) ;
491+ // S8/S10: reverse step over — the previous stop point not in a deeper
492+ // frame. Reverse steps always CLAMP to the first stop (S3/S8) and never
493+ // terminate; S20 is forward-only.
494+ this . stopAfter ( ( ) => this . moveCursorBackward ( this . stopPoints ( args . granularity ) , this . currentDepth ( ) ) ) ;
477495 }
478496
479497 protected reverseContinueRequest (
@@ -563,34 +581,65 @@ export class SorobanDebugSession extends DebugSession {
563581 }
564582
565583 /**
566- * Move the cursor to the nearest stop point in `direction` whose depth is
567- * <= `maxDepth`. With none ahead/behind, clamp to the last/first stop point
568- * (S2/S3/S7) — staying put when already there. An empty stop-point list
569- * (trace with no visible record at all) leaves the cursor where it is.
584+ * Forward step (S2/S5/S7/S20): seek to the first stop point after the cursor
585+ * whose depth is <= `maxDepth`, then report a stop. With no such stop ahead,
586+ * a STATEMENT step (granularity !== 'instruction' over the source run-start
587+ * stop set) TERMINATES the session (S20) — the replayed contract has returned
588+ * from its outermost recorded frame — while an INSTRUCTION step, or a
589+ * wasm-less replay with no run starts, clamps to the last stop point and
590+ * reports a stop (S2). Emits its own event, so it is called directly (not via
591+ * stopAfter): a termination must NOT be followed by a StoppedEvent.
570592 */
571- private moveCursor ( points : readonly number [ ] , direction : 1 | - 1 , maxDepth : number ) : void {
572- if ( ! this . model || points . length === 0 ) {
593+ private stepForward (
594+ granularity : DebugProtocol . SteppingGranularity | undefined ,
595+ maxDepth : number ,
596+ ) : void {
597+ if ( ! this . model ) {
573598 return ;
574599 }
600+ const points = this . stopPoints ( granularity ) ;
575601 const cursor = this . model . cursor ;
576- if ( direction > 0 ) {
577- for ( const i of points ) {
578- if ( i > cursor && this . depths [ i ] <= maxDepth ) {
579- this . model . seek ( i ) ;
580- return ;
581- }
602+ for ( const i of points ) {
603+ if ( i > cursor && this . depths [ i ] <= maxDepth ) {
604+ this . model . seek ( i ) ;
605+ this . sendEvent ( new StoppedEvent ( 'step' , THREAD_ID ) ) ;
606+ return ;
582607 }
608+ }
609+ // Nothing qualifying ahead.
610+ if ( granularity !== 'instruction' && this . runStarts . length > 0 ) {
611+ // S20: a forward source step past the last statement ends the session.
612+ this . sendEvent ( new TerminatedEvent ( ) ) ;
613+ return ;
614+ }
615+ // S2: instruction granularity / wasm-less replay clamps to the last stop
616+ // point (staying put when already there) and still reports a stop.
617+ if ( points . length > 0 ) {
583618 this . model . seek ( points [ points . length - 1 ] ) ;
584- } else {
585- for ( let k = points . length - 1 ; k >= 0 ; k -- ) {
586- const i = points [ k ] ;
587- if ( i < cursor && this . depths [ i ] <= maxDepth ) {
588- this . model . seek ( i ) ;
589- return ;
590- }
619+ }
620+ this . sendEvent ( new StoppedEvent ( 'step' , THREAD_ID ) ) ;
621+ }
622+
623+ /**
624+ * Reverse step (S3/S8): move the cursor to the nearest earlier stop point
625+ * whose depth is <= `maxDepth`. With none behind, clamp to the first stop
626+ * point (staying put when already there). An empty stop-point list (trace
627+ * with no visible record at all) leaves the cursor where it is. Reverse steps
628+ * always clamp and never terminate.
629+ */
630+ private moveCursorBackward ( points : readonly number [ ] , maxDepth : number ) : void {
631+ if ( ! this . model || points . length === 0 ) {
632+ return ;
633+ }
634+ const cursor = this . model . cursor ;
635+ for ( let k = points . length - 1 ; k >= 0 ; k -- ) {
636+ const i = points [ k ] ;
637+ if ( i < cursor && this . depths [ i ] <= maxDepth ) {
638+ this . model . seek ( i ) ;
639+ return ;
591640 }
592- this . model . seek ( points [ 0 ] ) ;
593641 }
642+ this . model . seek ( points [ 0 ] ) ;
594643 }
595644
596645 /** The trace's first stop point: run start, else visible record, else 0. */
0 commit comments