plotnik_lib/engine/
trace.rs

1//! Tracing infrastructure for debugging VM execution.
2//!
3//! # Design: Zero-Cost Abstraction
4//!
5//! The tracer is designed as a zero-cost abstraction. When `NoopTracer` is used:
6//! - All trait methods are `#[inline(always)]` empty functions
7//! - The compiler eliminates all tracer calls and their arguments
8//! - No tracing-related state exists in core execution structures
9//!
10//! # Design: Tracer-Owned State
11//!
12//! Tracing-only state (like checkpoint creation IPs for backtrack display) is
13//! maintained by the tracer itself, not in core structures like `Checkpoint`.
14//! This keeps execution structures minimal and avoids "spilling" tracing concerns
15//! into `exec`. For example:
16//! - `trace_checkpoint_created(ip)` - tracer pushes to its own stack
17//! - `trace_backtrack()` - tracer pops its stack to get the display IP
18//!
19//! `NoopTracer` ignores these calls (optimized away), while `PrintTracer`
20//! maintains parallel state for display purposes.
21
22use std::num::NonZeroU16;
23
24use arborium_tree_sitter::Node;
25
26use crate::Colors;
27use crate::bytecode::{
28    EffectOpcode, Instruction, LineBuilder, Match, Module, Nav, NodeTypeIR, Symbol, cols,
29    format_effect, trace, truncate_text, width_for_count,
30};
31
32use super::effect::RuntimeEffect;
33
34/// Verbosity level for trace output.
35///
36/// Controls which sub-lines are shown and whether node text is included.
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
38pub enum Verbosity {
39    /// Default: match, backtrack, call/return. Kind only, no text.
40    #[default]
41    Default,
42    /// Verbose (-v): all sub-lines. Text on match/failure.
43    Verbose,
44    /// Very verbose (-vv): all sub-lines. Text on everything including nav.
45    VeryVerbose,
46}
47
48/// Tracer trait for VM execution instrumentation.
49///
50/// All methods receive raw data (IDs, nodes) that the VM already has.
51/// Formatting and name resolution happen in the tracer implementation.
52///
53/// Each method is called at a specific point during execution:
54/// - `trace_instruction` - before executing an instruction
55/// - `trace_nav` - after navigation succeeds
56/// - `trace_match_success/failure` - after type check
57/// - `trace_field_success/failure` - after field check
58/// - `trace_effect` - after emitting an effect
59/// - `trace_call` - when entering a definition
60/// - `trace_return` - when returning from a definition
61/// - `trace_checkpoint_created` - when a checkpoint is pushed
62/// - `trace_backtrack` - when restoring a checkpoint
63/// - `trace_enter_entrypoint` - when entering an entrypoint (for labels)
64pub trait Tracer {
65    /// Called before executing an instruction.
66    fn trace_instruction(&mut self, ip: u16, instr: &Instruction<'_>);
67
68    /// Called after navigation succeeds.
69    fn trace_nav(&mut self, nav: Nav, node: Node<'_>);
70
71    /// Called when navigation fails (no child/sibling exists).
72    fn trace_nav_failure(&mut self, nav: Nav);
73
74    /// Called after type check succeeds.
75    fn trace_match_success(&mut self, node: Node<'_>);
76
77    /// Called after type check fails.
78    fn trace_match_failure(&mut self, node: Node<'_>);
79
80    /// Called after field check succeeds.
81    fn trace_field_success(&mut self, field_id: NonZeroU16);
82
83    /// Called after field check fails.
84    fn trace_field_failure(&mut self, node: Node<'_>);
85
86    /// Called after emitting an effect.
87    fn trace_effect(&mut self, effect: &RuntimeEffect<'_>);
88
89    /// Called when an effect is suppressed (inside @_ capture).
90    fn trace_effect_suppressed(&mut self, opcode: EffectOpcode, payload: usize);
91
92    /// Called for SuppressBegin/SuppressEnd control effects.
93    /// `suppressed` is true if already inside another suppress scope.
94    fn trace_suppress_control(&mut self, opcode: EffectOpcode, suppressed: bool);
95
96    /// Called when entering a definition via Call.
97    fn trace_call(&mut self, target_ip: u16);
98
99    /// Called when returning from a definition.
100    fn trace_return(&mut self);
101
102    /// Called when a checkpoint is created.
103    fn trace_checkpoint_created(&mut self, ip: u16);
104
105    /// Called when backtracking occurs.
106    fn trace_backtrack(&mut self);
107
108    /// Called when entering an entrypoint (for section labels).
109    fn trace_enter_entrypoint(&mut self, target_ip: u16);
110
111    /// Called when entering the preamble (bootstrap wrapper).
112    fn trace_enter_preamble(&mut self);
113}
114
115/// No-op tracer that gets optimized away completely.
116pub struct NoopTracer;
117
118impl Tracer for NoopTracer {
119    #[inline(always)]
120    fn trace_instruction(&mut self, _ip: u16, _instr: &Instruction<'_>) {}
121
122    #[inline(always)]
123    fn trace_nav(&mut self, _nav: Nav, _node: Node<'_>) {}
124
125    #[inline(always)]
126    fn trace_nav_failure(&mut self, _nav: Nav) {}
127
128    #[inline(always)]
129    fn trace_match_success(&mut self, _node: Node<'_>) {}
130
131    #[inline(always)]
132    fn trace_match_failure(&mut self, _node: Node<'_>) {}
133
134    #[inline(always)]
135    fn trace_field_success(&mut self, _field_id: NonZeroU16) {}
136
137    #[inline(always)]
138    fn trace_field_failure(&mut self, _node: Node<'_>) {}
139
140    #[inline(always)]
141    fn trace_effect(&mut self, _effect: &RuntimeEffect<'_>) {}
142
143    #[inline(always)]
144    fn trace_effect_suppressed(&mut self, _opcode: EffectOpcode, _payload: usize) {}
145
146    #[inline(always)]
147    fn trace_suppress_control(&mut self, _opcode: EffectOpcode, _suppressed: bool) {}
148
149    #[inline(always)]
150    fn trace_call(&mut self, _target_ip: u16) {}
151
152    #[inline(always)]
153    fn trace_return(&mut self) {}
154
155    #[inline(always)]
156    fn trace_checkpoint_created(&mut self, _ip: u16) {}
157
158    #[inline(always)]
159    fn trace_backtrack(&mut self) {}
160
161    #[inline(always)]
162    fn trace_enter_entrypoint(&mut self, _target_ip: u16) {}
163
164    #[inline(always)]
165    fn trace_enter_preamble(&mut self) {}
166}
167
168use std::collections::BTreeMap;
169
170/// Tracer that collects execution trace for debugging.
171pub struct PrintTracer<'s> {
172    /// Source code for extracting node text.
173    pub(crate) source: &'s [u8],
174    /// Verbosity level for output filtering.
175    pub(crate) verbosity: Verbosity,
176    /// Collected trace lines.
177    pub(crate) lines: Vec<String>,
178    /// Line builder for formatting.
179    pub(crate) builder: LineBuilder,
180    /// Maps node type ID to name.
181    pub(crate) node_type_names: BTreeMap<u16, String>,
182    /// Maps node field ID to name.
183    pub(crate) node_field_names: BTreeMap<u16, String>,
184    /// Maps member index to name (for Set/Enum effect display).
185    pub(crate) member_names: Vec<String>,
186    /// Maps entrypoint target IP to name (for labels and call/return).
187    pub(crate) entrypoint_by_ip: BTreeMap<u16, String>,
188    /// Parallel stack of checkpoint creation IPs (for backtrack display).
189    pub(crate) checkpoint_ips: Vec<u16>,
190    /// Stack of definition names (for return display).
191    pub(crate) definition_stack: Vec<String>,
192    /// Pending return instruction IP (for consolidated return line).
193    pub(crate) pending_return_ip: Option<u16>,
194    /// Step width for formatting.
195    pub(crate) step_width: usize,
196    /// Color palette.
197    pub(crate) colors: Colors,
198}
199
200/// Builder for `PrintTracer`.
201pub struct PrintTracerBuilder<'s, 'm> {
202    source: &'s str,
203    module: &'m Module,
204    verbosity: Verbosity,
205    colors: Colors,
206}
207
208impl<'s, 'm> PrintTracerBuilder<'s, 'm> {
209    /// Create a new builder with required parameters.
210    pub fn new(source: &'s str, module: &'m Module) -> Self {
211        Self {
212            source,
213            module,
214            verbosity: Verbosity::Default,
215            colors: Colors::OFF,
216        }
217    }
218
219    /// Set the verbosity level.
220    pub fn verbosity(mut self, verbosity: Verbosity) -> Self {
221        self.verbosity = verbosity;
222        self
223    }
224
225    /// Set whether to use colored output.
226    pub fn colored(mut self, enabled: bool) -> Self {
227        self.colors = Colors::new(enabled);
228        self
229    }
230
231    /// Build the PrintTracer.
232    pub fn build(self) -> PrintTracer<'s> {
233        let header = self.module.header();
234        let strings = self.module.strings();
235        let types = self.module.types();
236        let node_types = self.module.node_types();
237        let node_fields = self.module.node_fields();
238        let entrypoints = self.module.entrypoints();
239
240        let mut node_type_names = BTreeMap::new();
241        for i in 0..node_types.len() {
242            let t = node_types.get(i);
243            node_type_names.insert(t.id, strings.get(t.name).to_string());
244        }
245
246        let mut node_field_names = BTreeMap::new();
247        for i in 0..node_fields.len() {
248            let f = node_fields.get(i);
249            node_field_names.insert(f.id, strings.get(f.name).to_string());
250        }
251
252        // Build member names lookup (index -> name)
253        let member_names: Vec<String> = (0..types.members_count())
254            .map(|i| strings.get(types.get_member(i).name).to_string())
255            .collect();
256
257        // Build entrypoint IP -> name lookup
258        let mut entrypoint_by_ip = BTreeMap::new();
259        for i in 0..entrypoints.len() {
260            let e = entrypoints.get(i);
261            entrypoint_by_ip.insert(e.target, strings.get(e.name).to_string());
262        }
263
264        let step_width = width_for_count(header.transitions_count as usize);
265
266        PrintTracer {
267            source: self.source.as_bytes(),
268            verbosity: self.verbosity,
269            lines: Vec::new(),
270            builder: LineBuilder::new(step_width),
271            node_type_names,
272            node_field_names,
273            member_names,
274            entrypoint_by_ip,
275            checkpoint_ips: Vec::new(),
276            definition_stack: Vec::new(),
277            pending_return_ip: None,
278            step_width,
279            colors: self.colors,
280        }
281    }
282}
283
284impl<'s> PrintTracer<'s> {
285    /// Create a builder for PrintTracer.
286    pub fn builder<'m>(source: &'s str, module: &'m Module) -> PrintTracerBuilder<'s, 'm> {
287        PrintTracerBuilder::new(source, module)
288    }
289
290    fn node_type_name(&self, id: u16) -> &str {
291        self.node_type_names.get(&id).map_or("?", |s| s.as_str())
292    }
293
294    fn node_field_name(&self, id: u16) -> &str {
295        self.node_field_names.get(&id).map_or("?", |s| s.as_str())
296    }
297
298    fn member_name(&self, idx: u16) -> &str {
299        self.member_names
300            .get(idx as usize)
301            .map_or("?", |s| s.as_str())
302    }
303
304    fn entrypoint_name(&self, ip: u16) -> &str {
305        self.entrypoint_by_ip.get(&ip).map_or("?", |s| s.as_str())
306    }
307
308    /// Format kind without text content.
309    ///
310    /// - Named nodes: `kind` (e.g., `identifier`)
311    /// - Anonymous nodes: `kind` dim green (e.g., `let`)
312    fn format_kind_simple(&self, kind: &str, is_named: bool) -> String {
313        if is_named {
314            kind.to_string()
315        } else {
316            let c = &self.colors;
317            format!("{}{}{}{}", c.dim, c.green, kind, c.reset)
318        }
319    }
320
321    /// Format kind with source text, dynamically truncated to fit content width.
322    ///
323    /// - Named nodes: `kind text` (e.g., `identifier fetchData`)
324    /// - Anonymous nodes: just `text` in green (kind == text, no redundancy)
325    fn format_kind_with_text(&self, kind: &str, text: &str, is_named: bool) -> String {
326        let c = &self.colors;
327
328        // Available content width = TOTAL_WIDTH - prefix_width + step_width
329        // prefix_width = INDENT + step_width + GAP + SYMBOL + GAP = 9 + step_width
330        // +step_width because ellipsis can extend into the successors column
331        // (sub-lines have no successors, so we use that space)
332        // This simplifies to: TOTAL_WIDTH - 9 = 35
333        let available = cols::TOTAL_WIDTH - 9;
334
335        if is_named {
336            // Named: show kind + text
337            let text_budget = available.saturating_sub(kind.len() + 1).max(12);
338            let truncated = truncate_text(text, text_budget);
339            format!("{} {}{}{}{}", kind, c.dim, c.green, truncated, c.reset)
340        } else {
341            // Anonymous: just text dim green (kind == text, no redundancy)
342            let truncated = truncate_text(text, available);
343            format!("{}{}{}{}", c.dim, c.green, truncated, c.reset)
344        }
345    }
346
347    /// Format a runtime effect for display.
348    fn format_effect(&self, effect: &RuntimeEffect<'_>) -> String {
349        use RuntimeEffect::*;
350        match effect {
351            Node(_) => "Node".to_string(),
352            Text(_) => "Text".to_string(),
353            Arr => "Arr".to_string(),
354            Push => "Push".to_string(),
355            EndArr => "EndArr".to_string(),
356            Obj => "Obj".to_string(),
357            EndObj => "EndObj".to_string(),
358            Set(idx) => format!("Set \"{}\"", self.member_name(*idx)),
359            Enum(idx) => format!("Enum \"{}\"", self.member_name(*idx)),
360            EndEnum => "EndEnum".to_string(),
361            Clear => "Clear".to_string(),
362            Null => "Null".to_string(),
363        }
364    }
365
366    /// Format a suppressed effect from opcode and payload.
367    fn format_effect_from_opcode(&self, opcode: EffectOpcode, payload: usize) -> String {
368        use EffectOpcode::*;
369        match opcode {
370            Node => "Node".to_string(),
371            Text => "Text".to_string(),
372            Arr => "Arr".to_string(),
373            Push => "Push".to_string(),
374            EndArr => "EndArr".to_string(),
375            Obj => "Obj".to_string(),
376            EndObj => "EndObj".to_string(),
377            Set => format!("Set \"{}\"", self.member_name(payload as u16)),
378            Enum => format!("Enum \"{}\"", self.member_name(payload as u16)),
379            EndEnum => "EndEnum".to_string(),
380            Clear => "Clear".to_string(),
381            Null => "Null".to_string(),
382            SuppressBegin | SuppressEnd => unreachable!(),
383        }
384    }
385
386    /// Format match content for instruction line (matches dump format exactly).
387    ///
388    /// Order: [pre-effects] !neg_fields field: (type) [post-effects]
389    fn format_match_content(&self, m: &Match<'_>) -> String {
390        let mut parts = Vec::new();
391
392        // Pre-effects: [Effect1 Effect2]
393        let pre: Vec<_> = m.pre_effects().map(|e| format_effect(&e)).collect();
394        if !pre.is_empty() {
395            parts.push(format!("[{}]", pre.join(" ")));
396        }
397
398        // Skip neg_fields and node pattern for epsilon (no node interaction)
399        if !m.is_epsilon() {
400            // Negated fields: !field1 !field2
401            for field_id in m.neg_fields() {
402                let name = self.node_field_name(field_id);
403                parts.push(format!("!{name}"));
404            }
405
406            // Node pattern: field: (type) / (type) / field: _ / empty
407            let node_part = self.format_node_pattern(m);
408            if !node_part.is_empty() {
409                parts.push(node_part);
410            }
411        }
412
413        // Post-effects: [Effect1 Effect2]
414        let post: Vec<_> = m.post_effects().map(|e| format_effect(&e)).collect();
415        if !post.is_empty() {
416            parts.push(format!("[{}]", post.join(" ")));
417        }
418
419        parts.join(" ")
420    }
421
422    /// Format node pattern: `field: (type)` or `(type)` or `field: _` or `"text"` or empty.
423    fn format_node_pattern(&self, m: &Match<'_>) -> String {
424        let mut result = String::new();
425
426        if let Some(f) = m.node_field {
427            result.push_str(self.node_field_name(f.get()));
428            result.push_str(": ");
429        }
430
431        match m.node_type {
432            NodeTypeIR::Any => {
433                // Any node wildcard: `_`
434                result.push('_');
435            }
436            NodeTypeIR::Named(None) => {
437                // Named wildcard: any named node
438                result.push_str("(_)");
439            }
440            NodeTypeIR::Named(Some(t)) => {
441                // Specific named node type
442                result.push('(');
443                result.push_str(self.node_type_name(t.get()));
444                result.push(')');
445            }
446            NodeTypeIR::Anonymous(None) => {
447                // Anonymous wildcard: any anonymous node
448                result.push_str("\"_\"");
449            }
450            NodeTypeIR::Anonymous(Some(t)) => {
451                // Specific anonymous node (literal token)
452                result.push('"');
453                result.push_str(self.node_type_name(t.get()));
454                result.push('"');
455            }
456        }
457
458        result
459    }
460
461    /// Print all trace lines.
462    pub fn print(&self) {
463        for line in &self.lines {
464            println!("{}", line);
465        }
466    }
467
468    /// Add an instruction line.
469    fn add_instruction(&mut self, ip: u16, symbol: Symbol, content: &str, successors: &str) {
470        let prefix = format!("  {:0sw$} {} ", ip, symbol.format(), sw = self.step_width);
471        let line = self
472            .builder
473            .pad_successors(format!("{prefix}{content}"), successors);
474        self.lines.push(line);
475    }
476
477    /// Add a sub-line (blank step area + symbol + content).
478    fn add_subline(&mut self, symbol: Symbol, content: &str) {
479        let step_area = 2 + self.step_width + 1;
480        let prefix = format!("{:step_area$}{} ", "", symbol.format());
481        self.lines.push(format!("{prefix}{content}"));
482    }
483
484    /// Format definition name (blue). User definitions get parentheses, preamble doesn't.
485    fn format_def_name(&self, name: &str) -> String {
486        let c = self.colors;
487        if name.starts_with('_') {
488            // Preamble/internal names: no parentheses
489            format!("{}{}{}", c.blue, name, c.reset)
490        } else {
491            // User definitions: wrap in parentheses
492            format!("({}{}{})", c.blue, name, c.reset)
493        }
494    }
495
496    /// Format definition label with colon (blue).
497    fn format_def_label(&self, name: &str) -> String {
498        let c = self.colors;
499        format!("{}{}{}:", c.blue, name, c.reset)
500    }
501
502    /// Push a definition label, with empty line separator (except for first label).
503    fn push_def_label(&mut self, name: &str) {
504        if !self.lines.is_empty() {
505            self.lines.push(String::new());
506        }
507        self.lines.push(self.format_def_label(name));
508    }
509}
510
511impl Tracer for PrintTracer<'_> {
512    fn trace_instruction(&mut self, ip: u16, instr: &Instruction<'_>) {
513        match instr {
514            Instruction::Match(m) => {
515                // Show ε for epsilon transitions, empty otherwise (nav shown in sublines)
516                let symbol = if m.is_epsilon() {
517                    Symbol::EPSILON
518                } else {
519                    Symbol::EMPTY
520                };
521                let content = self.format_match_content(m);
522                let successors = format_match_successors(m);
523                self.add_instruction(ip, symbol, &content, &successors);
524            }
525            Instruction::Call(c) => {
526                let name = self.entrypoint_name(c.target.get());
527                let content = self.format_def_name(name);
528                let successors = format!("{:02} : {:02}", c.target.get(), c.next.get());
529                self.add_instruction(ip, Symbol::EMPTY, &content, &successors);
530            }
531            Instruction::Return(_) => {
532                self.pending_return_ip = Some(ip);
533            }
534            Instruction::Trampoline(t) => {
535                // Trampoline shows as a call to the entrypoint target
536                let content = "Trampoline";
537                let successors = format!("{:02}", t.next.get());
538                self.add_instruction(ip, Symbol::EMPTY, content, &successors);
539            }
540        }
541    }
542
543    fn trace_nav(&mut self, nav: Nav, node: Node<'_>) {
544        // Navigation sub-lines hidden in default verbosity
545        if self.verbosity == Verbosity::Default {
546            return;
547        }
548
549        let kind = node.kind();
550        let symbol = match nav {
551            Nav::Epsilon => Symbol::EPSILON,
552            Nav::Down | Nav::DownSkip | Nav::DownExact => trace::NAV_DOWN,
553            Nav::Next | Nav::NextSkip | Nav::NextExact => trace::NAV_NEXT,
554            Nav::Up(_) | Nav::UpSkipTrivia(_) | Nav::UpExact(_) => trace::NAV_UP,
555            Nav::Stay | Nav::StayExact => Symbol::EMPTY,
556        };
557
558        // Text only in VeryVerbose
559        if self.verbosity == Verbosity::VeryVerbose {
560            let text = node.utf8_text(self.source).unwrap_or("?");
561            let content = self.format_kind_with_text(kind, text, node.is_named());
562            self.add_subline(symbol, &content);
563        } else {
564            let content = self.format_kind_simple(kind, node.is_named());
565            self.add_subline(symbol, &content);
566        }
567    }
568
569    fn trace_nav_failure(&mut self, nav: Nav) {
570        // Navigation failure sub-lines hidden in default verbosity
571        if self.verbosity == Verbosity::Default {
572            return;
573        }
574
575        // Show the failed navigation direction
576        let nav_symbol = match nav {
577            Nav::Down | Nav::DownSkip | Nav::DownExact => "▽",
578            Nav::Next | Nav::NextSkip | Nav::NextExact => "▷",
579            Nav::Up(_) | Nav::UpSkipTrivia(_) | Nav::UpExact(_) => "△",
580            Nav::Stay | Nav::StayExact | Nav::Epsilon => "·",
581        };
582
583        self.add_subline(trace::MATCH_FAILURE, nav_symbol);
584    }
585
586    fn trace_match_success(&mut self, node: Node<'_>) {
587        let kind = node.kind();
588
589        // Text on match/failure in Verbose+
590        if self.verbosity != Verbosity::Default {
591            let text = node.utf8_text(self.source).unwrap_or("?");
592            let content = self.format_kind_with_text(kind, text, node.is_named());
593            self.add_subline(trace::MATCH_SUCCESS, &content);
594        } else {
595            let content = self.format_kind_simple(kind, node.is_named());
596            self.add_subline(trace::MATCH_SUCCESS, &content);
597        }
598    }
599
600    fn trace_match_failure(&mut self, node: Node<'_>) {
601        let kind = node.kind();
602
603        // Text on match/failure in Verbose+
604        if self.verbosity != Verbosity::Default {
605            let text = node.utf8_text(self.source).unwrap_or("?");
606            let content = self.format_kind_with_text(kind, text, node.is_named());
607            self.add_subline(trace::MATCH_FAILURE, &content);
608        } else {
609            let content = self.format_kind_simple(kind, node.is_named());
610            self.add_subline(trace::MATCH_FAILURE, &content);
611        }
612    }
613
614    fn trace_field_success(&mut self, field_id: NonZeroU16) {
615        // Field success sub-lines hidden in default verbosity
616        if self.verbosity == Verbosity::Default {
617            return;
618        }
619
620        let name = self.node_field_name(field_id.get());
621        self.add_subline(trace::MATCH_SUCCESS, &format!("{}:", name));
622    }
623
624    fn trace_field_failure(&mut self, _node: Node<'_>) {
625        // Field failures are silent - we just backtrack
626    }
627
628    fn trace_effect(&mut self, effect: &RuntimeEffect<'_>) {
629        // Effect sub-lines hidden in default verbosity
630        if self.verbosity == Verbosity::Default {
631            return;
632        }
633
634        let effect_str = self.format_effect(effect);
635        self.add_subline(trace::EFFECT, &effect_str);
636    }
637
638    fn trace_effect_suppressed(&mut self, opcode: EffectOpcode, payload: usize) {
639        // Effect sub-lines hidden in default verbosity
640        if self.verbosity == Verbosity::Default {
641            return;
642        }
643
644        let effect_str = self.format_effect_from_opcode(opcode, payload);
645        self.add_subline(trace::EFFECT_SUPPRESSED, &effect_str);
646    }
647
648    fn trace_suppress_control(&mut self, opcode: EffectOpcode, suppressed: bool) {
649        // Effect sub-lines hidden in default verbosity
650        if self.verbosity == Verbosity::Default {
651            return;
652        }
653
654        let name = match opcode {
655            EffectOpcode::SuppressBegin => "SuppressBegin",
656            EffectOpcode::SuppressEnd => "SuppressEnd",
657            _ => unreachable!(),
658        };
659        let symbol = if suppressed {
660            trace::EFFECT_SUPPRESSED
661        } else {
662            trace::EFFECT
663        };
664        self.add_subline(symbol, name);
665    }
666
667    fn trace_call(&mut self, target_ip: u16) {
668        let name = self.entrypoint_name(target_ip).to_string();
669        self.add_subline(trace::CALL, &self.format_def_name(&name));
670        self.push_def_label(&name);
671        self.definition_stack.push(name);
672    }
673
674    fn trace_return(&mut self) {
675        let ip = self
676            .pending_return_ip
677            .take()
678            .expect("trace_return without trace_instruction");
679        let name = self.definition_stack.pop().unwrap_or_default();
680        let content = self.format_def_name(&name);
681        // Show ◼ when returning from top-level (stack now empty)
682        let is_top_level = self.definition_stack.is_empty();
683        let successor = if is_top_level { "◼" } else { "" };
684        self.add_instruction(ip, trace::RETURN, &content, successor);
685        // Print caller's label after return (if not top-level)
686        if let Some(caller) = self.definition_stack.last().cloned() {
687            self.push_def_label(&caller);
688        }
689    }
690
691    fn trace_checkpoint_created(&mut self, ip: u16) {
692        self.checkpoint_ips.push(ip);
693    }
694
695    fn trace_backtrack(&mut self) {
696        let created_at = self
697            .checkpoint_ips
698            .pop()
699            .expect("backtrack without checkpoint");
700        let line = format!(
701            "  {:0sw$} {}",
702            created_at,
703            trace::BACKTRACK.format(),
704            sw = self.step_width
705        );
706        self.lines.push(line);
707    }
708
709    fn trace_enter_entrypoint(&mut self, target_ip: u16) {
710        let name = self.entrypoint_name(target_ip).to_string();
711        self.push_def_label(&name);
712        self.definition_stack.push(name);
713    }
714
715    fn trace_enter_preamble(&mut self) {
716        const PREAMBLE_NAME: &str = "_ObjWrap";
717        self.push_def_label(PREAMBLE_NAME);
718        self.definition_stack.push(PREAMBLE_NAME.to_string());
719    }
720}
721
722/// Format match successors for instruction line.
723fn format_match_successors(m: &Match<'_>) -> String {
724    if m.is_terminal() {
725        "◼".to_string()
726    } else if m.succ_count() == 1 {
727        format!("{:02}", m.successor(0).get())
728    } else {
729        let succs: Vec<_> = m.successors().map(|s| format!("{:02}", s.get())).collect();
730        succs.join(", ")
731    }
732}