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