plotnik_lib/bytecode/
format.rs

1//! Shared formatting utilities for bytecode dump and execution trace.
2//!
3//! Both dump and trace use the same column layout:
4//! ```text
5//! | 2 | step | 1 |   5   | 1 | content              | 1 | succ |
6//! |   | pad  |   | (sym) |   |                      |   |      |
7//! ```
8
9use super::EffectOp;
10use super::effects::EffectOpcode;
11use super::nav::Nav;
12
13/// Column widths for instruction line formatting.
14pub mod cols {
15    /// Leading indentation (2 spaces).
16    pub const INDENT: usize = 2;
17    /// Gap between columns (1 space).
18    pub const GAP: usize = 1;
19    /// Symbol column width (5 chars: 2 left + 1 center + 2 right).
20    pub const SYMBOL: usize = 5;
21    /// Total width before successors are right-aligned.
22    pub const TOTAL_WIDTH: usize = 44;
23}
24
25/// Symbols for the 5-character symbol column.
26///
27/// Format: `| left(2) | center(1) | right(2) |`
28///
29/// Used in both dump (nav symbols) and trace (nav, match, effect symbols).
30#[derive(Clone, Copy, Debug)]
31pub struct Symbol {
32    /// Left modifier (2 chars): mode indicator or spaces.
33    /// Examples: "  ", " !", "!!"
34    pub left: &'static str,
35    /// Center symbol (1 char): direction or status.
36    /// Examples: "ε", "▽", "▷", "△", "●", "○", "⬥", "▶", "◀"
37    pub center: &'static str,
38    /// Right suffix (2 chars): level or spaces.
39    /// Examples: "  ", "¹ ", "¹²"
40    pub right: &'static str,
41}
42
43impl Default for Symbol {
44    fn default() -> Self {
45        Self::EMPTY
46    }
47}
48
49impl Symbol {
50    /// Create a new symbol with all parts.
51    pub const fn new(left: &'static str, center: &'static str, right: &'static str) -> Self {
52        Self {
53            left,
54            center,
55            right,
56        }
57    }
58
59    /// Empty symbol (5 spaces).
60    pub const EMPTY: Symbol = Symbol::new("  ", " ", "  ");
61
62    /// Epsilon symbol for unconditional transitions.
63    pub const EPSILON: Symbol = Symbol::new("  ", "ε", "  ");
64
65    /// Format as a 5-character string.
66    pub fn format(&self) -> String {
67        format!("{}{}{}", self.left, self.center, self.right)
68    }
69}
70
71/// Format navigation command as a Symbol using the doc-specified triangles.
72///
73/// | Nav             | Symbol  | Notes                               |
74/// | --------------- | ------- | ----------------------------------- |
75/// | Epsilon         | ε       | Pure control flow, no cursor check  |
76/// | Stay            | (blank) | No movement, 5 spaces               |
77/// | StayExact       | !       | Stay at position, exact match only  |
78/// | Down            | ▽       | First child, skip any               |
79/// | DownSkip        | !▽      | First child, skip trivia            |
80/// | DownExact       | !!▽     | First child, exact                  |
81/// | Next            | ▷       | Next sibling, skip any              |
82/// | NextSkip        | !▷      | Next sibling, skip trivia           |
83/// | NextExact       | !!▷     | Next sibling, exact                 |
84/// | Up(n)           | △ⁿ      | Ascend n levels, skip any           |
85/// | UpSkipTrivia(n) | !△ⁿ     | Ascend n, must be last non-trivia   |
86/// | UpExact(n)      | !!△ⁿ    | Ascend n, must be last child        |
87pub fn nav_symbol(nav: Nav) -> Symbol {
88    match nav {
89        Nav::Epsilon => Symbol::EPSILON,
90        Nav::Stay => Symbol::EMPTY,
91        Nav::StayExact => Symbol::new("  ", "!", "  "),
92        Nav::Down => Symbol::new("  ", "▽", "  "),
93        Nav::DownSkip => Symbol::new(" !", "▽", "  "),
94        Nav::DownExact => Symbol::new("!!", "▽", "  "),
95        Nav::Next => Symbol::new("  ", "▷", "  "),
96        Nav::NextSkip => Symbol::new(" !", "▷", "  "),
97        Nav::NextExact => Symbol::new("!!", "▷", "  "),
98        Nav::Up(n) => Symbol::new("  ", "△", superscript_suffix(n)),
99        Nav::UpSkipTrivia(n) => Symbol::new(" !", "△", superscript_suffix(n)),
100        Nav::UpExact(n) => Symbol::new("!!", "△", superscript_suffix(n)),
101    }
102}
103
104/// Trace sub-line symbols.
105pub mod trace {
106    use super::Symbol;
107
108    /// Navigation: descended to child.
109    pub const NAV_DOWN: Symbol = Symbol::new("  ", "▽", "  ");
110    /// Navigation: moved to sibling.
111    pub const NAV_NEXT: Symbol = Symbol::new("  ", "▷", "  ");
112    /// Navigation: ascended to parent.
113    pub const NAV_UP: Symbol = Symbol::new("  ", "△", "  ");
114
115    /// Match: success.
116    pub const MATCH_SUCCESS: Symbol = Symbol::new("  ", "●", "  ");
117    /// Match: failure.
118    pub const MATCH_FAILURE: Symbol = Symbol::new("  ", "○", "  ");
119
120    /// Effect: data capture or structure.
121    pub const EFFECT: Symbol = Symbol::new("  ", "⬥", "  ");
122    /// Effect: suppressed (inside @_ capture).
123    pub const EFFECT_SUPPRESSED: Symbol = Symbol::new("  ", "⬦", "  ");
124
125    /// Call: entering definition.
126    pub const CALL: Symbol = Symbol::new("  ", "▶", "  ");
127    /// Return: back from definition.
128    pub const RETURN: Symbol = Symbol::new("  ", "◀", "  ");
129
130    /// Backtrack symbol (centered in 5 chars).
131    pub const BACKTRACK: Symbol = Symbol::new(" ", "❮❮❮", " ");
132}
133
134const SUPERSCRIPT_DIGITS: &[char] = &['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
135
136/// Convert a number to superscript digits.
137pub fn superscript(n: u8) -> String {
138    if n < 10 {
139        SUPERSCRIPT_DIGITS[n as usize].to_string()
140    } else {
141        n.to_string()
142            .chars()
143            .map(|c| SUPERSCRIPT_DIGITS[c.to_digit(10).unwrap() as usize])
144            .collect()
145    }
146}
147
148/// Convert a number to a 2-char superscript suffix for the symbol right column.
149/// Level 1 shows no superscript (blank), levels 2+ show superscript.
150fn superscript_suffix(n: u8) -> &'static str {
151    match n {
152        1 => "  ",
153        2 => "² ",
154        3 => "³ ",
155        4 => "⁴ ",
156        5 => "⁵ ",
157        6 => "⁶ ",
158        7 => "⁷ ",
159        8 => "⁸ ",
160        9 => "⁹ ",
161        // For 10+, we'd need dynamic allocation. Rare in practice.
162        _ => "ⁿ ",
163    }
164}
165
166/// Calculate minimum width needed to display numbers up to `count - 1`.
167pub fn width_for_count(count: usize) -> usize {
168    if count <= 1 {
169        1
170    } else {
171        ((count - 1) as f64).log10().floor() as usize + 1
172    }
173}
174
175/// Truncate text to max length with ellipsis.
176///
177/// Used for displaying node text in traces.
178pub fn truncate_text(s: &str, max_len: usize) -> String {
179    if s.chars().count() <= max_len {
180        s.to_string()
181    } else {
182        let truncated: String = s.chars().take(max_len - 1).collect();
183        format!("{}…", truncated)
184    }
185}
186
187/// Builder for formatted output lines.
188///
189/// Constructs lines following the column layout:
190/// `<indent><step><gap><symbol><gap><content>...<successors>`
191pub struct LineBuilder {
192    step_width: usize,
193}
194
195impl LineBuilder {
196    /// Create a new line builder with the given step width.
197    pub fn new(step_width: usize) -> Self {
198        Self { step_width }
199    }
200
201    /// Build an instruction line prefix: `  <step> <symbol> `
202    pub fn instruction_prefix(&self, step: u16, symbol: Symbol) -> String {
203        format!(
204            "{:indent$}{:0sw$} {} ",
205            "",
206            step,
207            symbol.format(),
208            indent = cols::INDENT,
209            sw = self.step_width,
210        )
211    }
212
213    /// Build a sub-line prefix (blank step area): `       <symbol> `
214    pub fn subline_prefix(&self, symbol: Symbol) -> String {
215        let step_area = cols::INDENT + self.step_width + cols::GAP;
216        format!("{:step_area$}{} ", "", symbol.format())
217    }
218
219    /// Build a backtrack line: `  <step>  ❮❮❮`
220    pub fn backtrack_line(&self, step: u16) -> String {
221        format!(
222            "{:indent$}{:0sw$} {}",
223            "",
224            step,
225            trace::BACKTRACK.format(),
226            indent = cols::INDENT,
227            sw = self.step_width,
228        )
229    }
230
231    /// Pad content to total width and append successors.
232    ///
233    /// Ensures at least 2 spaces between content and successors.
234    pub fn pad_successors(&self, base: String, successors: &str) -> String {
235        let padding = cols::TOTAL_WIDTH
236            .saturating_sub(display_width(&base))
237            .max(2);
238        format!("{base}{:padding$}{successors}", "")
239    }
240}
241
242/// Calculate display width of a string, ignoring ANSI escape sequences.
243///
244/// ANSI sequences have the form `\x1b[...m` and render as zero-width.
245fn display_width(s: &str) -> usize {
246    let mut width = 0;
247    let mut in_escape = false;
248
249    for c in s.chars() {
250        if in_escape {
251            if c == 'm' {
252                in_escape = false;
253            }
254        } else if c == '\x1b' {
255            in_escape = true;
256        } else {
257            width += 1;
258        }
259    }
260
261    width
262}
263
264/// Format an effect operation for display.
265pub fn format_effect(effect: &EffectOp) -> String {
266    match effect.opcode {
267        EffectOpcode::Node => "Node".to_string(),
268        EffectOpcode::Arr => "Arr".to_string(),
269        EffectOpcode::Push => "Push".to_string(),
270        EffectOpcode::EndArr => "EndArr".to_string(),
271        EffectOpcode::Obj => "Obj".to_string(),
272        EffectOpcode::EndObj => "EndObj".to_string(),
273        EffectOpcode::Set => format!("Set(M{})", effect.payload),
274        EffectOpcode::Enum => format!("Enum(M{})", effect.payload),
275        EffectOpcode::EndEnum => "EndEnum".to_string(),
276        EffectOpcode::Text => "Text".to_string(),
277        EffectOpcode::Clear => "Clear".to_string(),
278        EffectOpcode::Null => "Null".to_string(),
279        EffectOpcode::SuppressBegin => "SuppressBegin".to_string(),
280        EffectOpcode::SuppressEnd => "SuppressEnd".to_string(),
281    }
282}