Skip to main content

shape_ast/
interpolation.rs

1//! Shared formatted-string interpolation parsing.
2//!
3//! This module is intentionally syntax-only. It extracts literal and
4//! expression segments from `f"..."` strings so all consumers (compiler,
5//! type checker, LSP) can run their normal expression pipelines on the
6//! extracted `{...}` expressions.
7
8use crate::ast::InterpolationMode;
9use crate::{Result, ShapeError};
10
11/// Horizontal alignment for formatted output.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum FormatAlignment {
14    Left,
15    Center,
16    Right,
17}
18
19impl FormatAlignment {
20    fn parse(s: &str) -> Option<Self> {
21        match s {
22            "left" => Some(Self::Left),
23            "center" => Some(Self::Center),
24            "right" => Some(Self::Right),
25            _ => None,
26        }
27    }
28}
29
30/// Color hint for formatted output.
31///
32/// Renderers may map these hints to ANSI, HTML, or plain output.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum FormatColor {
35    Default,
36    Red,
37    Green,
38    Yellow,
39    Blue,
40    Magenta,
41    Cyan,
42    White,
43}
44
45impl FormatColor {
46    fn parse(s: &str) -> Option<Self> {
47        match s {
48            "default" => Some(Self::Default),
49            "red" => Some(Self::Red),
50            "green" => Some(Self::Green),
51            "yellow" => Some(Self::Yellow),
52            "blue" => Some(Self::Blue),
53            "magenta" => Some(Self::Magenta),
54            "cyan" => Some(Self::Cyan),
55            "white" => Some(Self::White),
56            _ => None,
57        }
58    }
59}
60
61/// Typed table rendering configuration for interpolation.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct TableFormatSpec {
64    pub max_rows: Option<usize>,
65    pub align: Option<FormatAlignment>,
66    pub precision: Option<u8>,
67    pub color: Option<FormatColor>,
68    pub border: bool,
69}
70
71impl Default for TableFormatSpec {
72    fn default() -> Self {
73        Self {
74            max_rows: None,
75            align: None,
76            precision: None,
77            color: None,
78            border: true,
79        }
80    }
81}
82
83/// Typed format specification for interpolation expressions.
84#[derive(Debug, Clone, PartialEq, Eq)]
85pub enum InterpolationFormatSpec {
86    /// Fixed-point numeric precision (`fixed(2)`).
87    Fixed { precision: u8 },
88    /// Tabular formatting for `DataTable`-like values (`table(...)`).
89    Table(TableFormatSpec),
90    /// Content-string styling specification.
91    ContentStyle(ContentFormatSpec),
92}
93
94/// Chart type hint for content format spec.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub enum ChartTypeSpec {
97    Line,
98    Bar,
99    Scatter,
100    Area,
101    Histogram,
102}
103
104/// Content-string format specification for rich terminal/HTML output.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct ContentFormatSpec {
107    pub fg: Option<ColorSpec>,
108    pub bg: Option<ColorSpec>,
109    pub bold: bool,
110    pub italic: bool,
111    pub underline: bool,
112    pub dim: bool,
113    pub fixed_precision: Option<u8>,
114    pub border: Option<BorderStyleSpec>,
115    pub max_rows: Option<usize>,
116    pub align: Option<AlignSpec>,
117    /// Chart type hint: render the value as a chart instead of text.
118    pub chart_type: Option<ChartTypeSpec>,
119    /// Column name to use as x-axis data.
120    pub x_column: Option<String>,
121    /// Column names to use as y-axis series.
122    pub y_columns: Vec<String>,
123}
124
125impl Default for ContentFormatSpec {
126    fn default() -> Self {
127        Self {
128            fg: None,
129            bg: None,
130            bold: false,
131            italic: false,
132            underline: false,
133            dim: false,
134            fixed_precision: None,
135            border: None,
136            max_rows: None,
137            align: None,
138            chart_type: None,
139            x_column: None,
140            y_columns: vec![],
141        }
142    }
143}
144
145/// Color specification for content strings.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ColorSpec {
148    Named(NamedContentColor),
149    Rgb(u8, u8, u8),
150}
151
152/// Named colors for content strings.
153#[derive(Debug, Clone, PartialEq, Eq)]
154pub enum NamedContentColor {
155    Red,
156    Green,
157    Blue,
158    Yellow,
159    Magenta,
160    Cyan,
161    White,
162    Default,
163}
164
165/// Border style for content-string table rendering.
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub enum BorderStyleSpec {
168    Rounded,
169    Sharp,
170    Heavy,
171    Double,
172    Minimal,
173    None,
174}
175
176/// Alignment for content-string rendering.
177#[derive(Debug, Clone, Copy, PartialEq, Eq)]
178pub enum AlignSpec {
179    Left,
180    Center,
181    Right,
182}
183
184/// A parsed segment of an interpolated string.
185#[derive(Debug, Clone, PartialEq, Eq)]
186pub enum InterpolationPart {
187    /// Literal text.
188    Literal(String),
189    /// Expression segment with optional format specifier.
190    Expression {
191        /// Raw Shape expression between `{` and `}`.
192        expr: String,
193        /// Optional typed format spec after top-level `:`.
194        format_spec: Option<InterpolationFormatSpec>,
195    },
196}
197
198/// Parse a formatted string payload into interpolation parts.
199pub fn parse_interpolation(s: &str) -> Result<Vec<InterpolationPart>> {
200    parse_interpolation_with_mode(s, InterpolationMode::Braces)
201}
202
203/// Parse a formatted string payload into interpolation parts using the given mode.
204pub fn parse_interpolation_with_mode(
205    s: &str,
206    mode: InterpolationMode,
207) -> Result<Vec<InterpolationPart>> {
208    let mut parts = Vec::new();
209    let mut current_text = String::new();
210    let mut chars = s.chars().peekable();
211
212    while let Some(ch) = chars.next() {
213        // Backslash-escaped delimiters: `\{` → `{`, `\}` → `}`, `\$` → `$`, `\#` → `#`
214        if ch == '\\'
215            && matches!(
216                chars.peek(),
217                Some(&'{') | Some(&'}') | Some(&'$') | Some(&'#')
218            )
219        {
220            current_text.push(chars.next().unwrap());
221            continue;
222        }
223
224        match mode {
225            InterpolationMode::Braces => match ch {
226                '{' => {
227                    if chars.peek() == Some(&'{') {
228                        chars.next();
229                        current_text.push('{');
230                        continue;
231                    }
232
233                    if !current_text.is_empty() {
234                        parts.push(InterpolationPart::Literal(current_text.clone()));
235                        current_text.clear();
236                    }
237
238                    let raw_expr = parse_expression_content(&mut chars)?;
239                    let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
240                    parts.push(InterpolationPart::Expression { expr, format_spec });
241                }
242                '}' => {
243                    if chars.peek() == Some(&'}') {
244                        chars.next();
245                        current_text.push('}');
246                    } else {
247                        return Err(ShapeError::RuntimeError {
248                            message:
249                                "Unmatched '}' in interpolation string. Use '}}' for a literal '}'"
250                                    .to_string(),
251                            location: None,
252                        });
253                    }
254                }
255                _ => current_text.push(ch),
256            },
257            InterpolationMode::Dollar | InterpolationMode::Hash => {
258                let sigil = mode.sigil().expect("sigil mode must provide sigil");
259                if ch == sigil {
260                    if chars.peek() == Some(&sigil) {
261                        chars.next();
262                        if chars.peek() == Some(&'{') {
263                            chars.next();
264                            current_text.push(sigil);
265                            current_text.push('{');
266                        } else {
267                            current_text.push(sigil);
268                        }
269                        continue;
270                    }
271
272                    if chars.peek() == Some(&'{') {
273                        chars.next();
274                        if !current_text.is_empty() {
275                            parts.push(InterpolationPart::Literal(current_text.clone()));
276                            current_text.clear();
277                        }
278                        let raw_expr = parse_expression_content(&mut chars)?;
279                        let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
280                        parts.push(InterpolationPart::Expression { expr, format_spec });
281                        continue;
282                    }
283                }
284
285                current_text.push(ch);
286            }
287        }
288    }
289
290    if !current_text.is_empty() {
291        parts.push(InterpolationPart::Literal(current_text));
292    }
293
294    Ok(parts)
295}
296
297/// Parse a content string payload into interpolation parts.
298///
299/// Unlike `parse_interpolation_with_mode`, this uses `split_expression_and_content_format_spec`
300/// to parse content-specific format specs (e.g., `fg(red), bold`) instead of the regular
301/// fixed/table format specs.
302pub fn parse_content_interpolation_with_mode(
303    s: &str,
304    mode: InterpolationMode,
305) -> Result<Vec<InterpolationPart>> {
306    let mut parts = Vec::new();
307    let mut current_text = String::new();
308    let mut chars = s.chars().peekable();
309
310    while let Some(ch) = chars.next() {
311        // Backslash-escaped delimiters: `\{` → `{`, `\}` → `}`, `\$` → `$`, `\#` → `#`
312        if ch == '\\'
313            && matches!(
314                chars.peek(),
315                Some(&'{') | Some(&'}') | Some(&'$') | Some(&'#')
316            )
317        {
318            current_text.push(chars.next().unwrap());
319            continue;
320        }
321
322        match mode {
323            InterpolationMode::Braces => match ch {
324                '{' => {
325                    if chars.peek() == Some(&'{') {
326                        chars.next();
327                        current_text.push('{');
328                        continue;
329                    }
330
331                    if !current_text.is_empty() {
332                        parts.push(InterpolationPart::Literal(current_text.clone()));
333                        current_text.clear();
334                    }
335
336                    let raw_expr = parse_expression_content(&mut chars)?;
337                    let (expr, format_spec) = split_expression_and_content_format_spec(&raw_expr)?;
338                    parts.push(InterpolationPart::Expression { expr, format_spec });
339                }
340                '}' => {
341                    if chars.peek() == Some(&'}') {
342                        chars.next();
343                        current_text.push('}');
344                    } else {
345                        return Err(ShapeError::RuntimeError {
346                            message:
347                                "Unmatched '}' in interpolation string. Use '}}' for a literal '}'"
348                                    .to_string(),
349                            location: None,
350                        });
351                    }
352                }
353                _ => current_text.push(ch),
354            },
355            InterpolationMode::Dollar | InterpolationMode::Hash => {
356                let sigil = mode.sigil().expect("sigil mode must provide sigil");
357                if ch == sigil {
358                    if chars.peek() == Some(&sigil) {
359                        chars.next();
360                        if chars.peek() == Some(&'{') {
361                            chars.next();
362                            current_text.push(sigil);
363                            current_text.push('{');
364                        } else {
365                            current_text.push(sigil);
366                        }
367                        continue;
368                    }
369
370                    if chars.peek() == Some(&'{') {
371                        chars.next();
372                        if !current_text.is_empty() {
373                            parts.push(InterpolationPart::Literal(current_text.clone()));
374                            current_text.clear();
375                        }
376                        let raw_expr = parse_expression_content(&mut chars)?;
377                        let (expr, format_spec) =
378                            split_expression_and_content_format_spec(&raw_expr)?;
379                        parts.push(InterpolationPart::Expression { expr, format_spec });
380                        continue;
381                    }
382                }
383
384                current_text.push(ch);
385            }
386        }
387    }
388
389    if !current_text.is_empty() {
390        parts.push(InterpolationPart::Literal(current_text));
391    }
392
393    Ok(parts)
394}
395
396/// Check whether a string contains at least one interpolation segment.
397pub fn has_interpolation(s: &str) -> bool {
398    has_interpolation_with_mode(s, InterpolationMode::Braces)
399}
400
401/// Check whether a string contains at least one interpolation segment for the mode.
402pub fn has_interpolation_with_mode(s: &str, mode: InterpolationMode) -> bool {
403    let mut chars = s.chars().peekable();
404    while let Some(ch) = chars.next() {
405        // Skip backslash-escaped braces
406        if ch == '\\' && matches!(chars.peek(), Some(&'{') | Some(&'}')) {
407            chars.next();
408            continue;
409        }
410        match mode {
411            InterpolationMode::Braces => {
412                if ch == '{' {
413                    if chars.peek() != Some(&'{') {
414                        return true;
415                    }
416                    chars.next();
417                }
418            }
419            InterpolationMode::Dollar | InterpolationMode::Hash => {
420                let sigil = mode.sigil().expect("sigil mode must provide sigil");
421                if ch == sigil && chars.peek() == Some(&'{') {
422                    return true;
423                }
424            }
425        }
426    }
427    false
428}
429
430/// Split interpolation content `expr[:spec]` at the top-level format separator.
431///
432/// This preserves `::` (enum/type separators) and ignores separators inside
433/// nested delimiters/strings.
434pub fn split_expression_and_format_spec(
435    raw: &str,
436) -> Result<(String, Option<InterpolationFormatSpec>)> {
437    let trimmed = raw.trim();
438    if trimmed.is_empty() {
439        return Err(ShapeError::RuntimeError {
440            message: "Empty expression in interpolation".to_string(),
441            location: None,
442        });
443    }
444
445    let split_at = find_top_level_format_colon(trimmed);
446
447    if let Some(idx) = split_at {
448        let expr = trimmed[..idx].trim();
449        let spec = trimmed[idx + 1..].trim();
450        if expr.is_empty() {
451            return Err(ShapeError::RuntimeError {
452                message: "Missing expression before format spec in interpolation".to_string(),
453                location: None,
454            });
455        }
456        if spec.is_empty() {
457            return Err(ShapeError::RuntimeError {
458                message: "Missing format spec after ':' in interpolation".to_string(),
459                location: None,
460            });
461        }
462        Ok((expr.to_string(), Some(parse_format_spec(spec)?)))
463    } else {
464        Ok((trimmed.to_string(), None))
465    }
466}
467
468/// Find the top-level format-separator `:` in an interpolation expression.
469///
470/// Returns the byte index of the separator if present.
471pub fn find_top_level_format_colon(raw: &str) -> Option<usize> {
472    let bytes = raw.as_bytes();
473    let mut paren_depth = 0usize;
474    let mut brace_depth = 0usize;
475    let mut bracket_depth = 0usize;
476    let mut in_string: Option<char> = None;
477    let mut escaped = false;
478
479    for (idx, ch) in raw.char_indices() {
480        if let Some(quote) = in_string {
481            if escaped {
482                escaped = false;
483                continue;
484            }
485            if ch == '\\' {
486                escaped = true;
487                continue;
488            }
489            if ch == quote {
490                in_string = None;
491            }
492            continue;
493        }
494
495        match ch {
496            '"' | '\'' => in_string = Some(ch),
497            '(' => paren_depth += 1,
498            ')' => paren_depth = paren_depth.saturating_sub(1),
499            '{' => brace_depth += 1,
500            '}' => brace_depth = brace_depth.saturating_sub(1),
501            '[' => bracket_depth += 1,
502            ']' => bracket_depth = bracket_depth.saturating_sub(1),
503            ':' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
504                let prev_is_colon = idx > 0 && bytes[idx - 1] == b':';
505                let next_is_colon = idx + 1 < bytes.len() && bytes[idx + 1] == b':';
506                if !prev_is_colon && !next_is_colon {
507                    return Some(idx);
508                }
509            }
510            _ => {}
511        }
512    }
513
514    None
515}
516
517fn parse_format_spec(raw_spec: &str) -> Result<InterpolationFormatSpec> {
518    let spec = raw_spec.trim();
519
520    // Legacy shorthand kept as a parser alias, but normalized into typed format.
521    if let Some(precision) = parse_legacy_fixed_precision(spec)? {
522        return Ok(InterpolationFormatSpec::Fixed { precision });
523    }
524
525    if let Some(inner) = parse_call_like_spec(spec, "fixed")? {
526        let precision = parse_u8_value(inner.trim(), "fixed precision")?;
527        return Ok(InterpolationFormatSpec::Fixed { precision });
528    }
529
530    if let Some(inner) = parse_call_like_spec(spec, "table")? {
531        return Ok(InterpolationFormatSpec::Table(parse_table_format_spec(
532            inner,
533        )?));
534    }
535
536    Err(ShapeError::RuntimeError {
537        message: format!(
538            "Unsupported interpolation format spec '{}'. Supported: fixed(N), table(...).",
539            spec
540        ),
541        location: None,
542    })
543}
544
545/// Parse a content-string format spec like `"fg(red), bold, fixed(2)"`.
546pub fn parse_content_format_spec(raw_spec: &str) -> Result<ContentFormatSpec> {
547    let mut spec = ContentFormatSpec::default();
548    let trimmed = raw_spec.trim();
549    if trimmed.is_empty() {
550        return Ok(spec);
551    }
552
553    for entry in split_top_level_commas(trimmed)? {
554        let entry = entry.trim();
555        if entry.is_empty() {
556            continue;
557        }
558
559        // Boolean flags (no parens)
560        match entry {
561            "bold" => {
562                spec.bold = true;
563                continue;
564            }
565            "italic" => {
566                spec.italic = true;
567                continue;
568            }
569            "underline" => {
570                spec.underline = true;
571                continue;
572            }
573            "dim" => {
574                spec.dim = true;
575                continue;
576            }
577            _ => {}
578        }
579
580        // Call-like specs: fg(...), bg(...), fixed(...), border(...), max_rows(...), align(...)
581        if let Some(idx) = entry.find('(') {
582            if !entry.ends_with(')') {
583                return Err(ShapeError::RuntimeError {
584                    message: format!("Unclosed parenthesis in content format spec '{}'", entry),
585                    location: None,
586                });
587            }
588            let key = entry[..idx].trim();
589            let inner = entry[idx + 1..entry.len() - 1].trim();
590            match key {
591                "fg" => {
592                    spec.fg = Some(parse_color_spec(inner)?);
593                }
594                "bg" => {
595                    spec.bg = Some(parse_color_spec(inner)?);
596                }
597                "fixed" => {
598                    spec.fixed_precision = Some(parse_u8_value(inner, "fixed precision")?);
599                }
600                "border" => {
601                    spec.border = Some(parse_border_style_spec(inner)?);
602                }
603                "max_rows" => {
604                    spec.max_rows = Some(parse_usize_value(inner, "max_rows")?);
605                }
606                "align" => {
607                    spec.align = Some(parse_align_spec(inner)?);
608                }
609                "chart" => {
610                    spec.chart_type = Some(parse_chart_type_spec(inner)?);
611                }
612                "x" => {
613                    spec.x_column = Some(inner.to_string());
614                }
615                "y" => {
616                    // y(col) or y(col1, col2, ...)
617                    spec.y_columns = inner
618                        .split(',')
619                        .map(|s| s.trim().to_string())
620                        .filter(|s| !s.is_empty())
621                        .collect();
622                }
623                other => {
624                    return Err(ShapeError::RuntimeError {
625                        message: format!(
626                            "Unknown content format key '{}'. Supported: fg, bg, bold, italic, underline, dim, fixed, border, max_rows, align, chart, x, y.",
627                            other
628                        ),
629                        location: None,
630                    });
631                }
632            }
633            continue;
634        }
635
636        return Err(ShapeError::RuntimeError {
637            message: format!(
638                "Unknown content format entry '{}'. Expected a flag (bold, italic, ...) or key(value).",
639                entry
640            ),
641            location: None,
642        });
643    }
644
645    Ok(spec)
646}
647
648fn parse_color_spec(s: &str) -> Result<ColorSpec> {
649    let s = s.trim();
650    // Try RGB: rgb(r, g, b)
651    if s.starts_with("rgb(") && s.ends_with(')') {
652        let inner = &s[4..s.len() - 1];
653        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
654        if parts.len() != 3 {
655            return Err(ShapeError::RuntimeError {
656                message: format!("rgb() expects 3 values, got {}", parts.len()),
657                location: None,
658            });
659        }
660        let r = parse_u8_value(parts[0], "red")?;
661        let g = parse_u8_value(parts[1], "green")?;
662        let b = parse_u8_value(parts[2], "blue")?;
663        return Ok(ColorSpec::Rgb(r, g, b));
664    }
665    // Named color
666    match s {
667        "red" => Ok(ColorSpec::Named(NamedContentColor::Red)),
668        "green" => Ok(ColorSpec::Named(NamedContentColor::Green)),
669        "blue" => Ok(ColorSpec::Named(NamedContentColor::Blue)),
670        "yellow" => Ok(ColorSpec::Named(NamedContentColor::Yellow)),
671        "magenta" => Ok(ColorSpec::Named(NamedContentColor::Magenta)),
672        "cyan" => Ok(ColorSpec::Named(NamedContentColor::Cyan)),
673        "white" => Ok(ColorSpec::Named(NamedContentColor::White)),
674        "default" => Ok(ColorSpec::Named(NamedContentColor::Default)),
675        _ => Err(ShapeError::RuntimeError {
676            message: format!(
677                "Unknown color '{}'. Expected: red, green, blue, yellow, magenta, cyan, white, default, or rgb(r,g,b).",
678                s
679            ),
680            location: None,
681        }),
682    }
683}
684
685fn parse_border_style_spec(s: &str) -> Result<BorderStyleSpec> {
686    match s.trim() {
687        "rounded" => Ok(BorderStyleSpec::Rounded),
688        "sharp" => Ok(BorderStyleSpec::Sharp),
689        "heavy" => Ok(BorderStyleSpec::Heavy),
690        "double" => Ok(BorderStyleSpec::Double),
691        "minimal" => Ok(BorderStyleSpec::Minimal),
692        "none" => Ok(BorderStyleSpec::None),
693        _ => Err(ShapeError::RuntimeError {
694            message: format!(
695                "Unknown border style '{}'. Expected: rounded, sharp, heavy, double, minimal, none.",
696                s
697            ),
698            location: None,
699        }),
700    }
701}
702
703fn parse_chart_type_spec(s: &str) -> Result<ChartTypeSpec> {
704    match s.trim().to_lowercase().as_str() {
705        "line" => Ok(ChartTypeSpec::Line),
706        "bar" => Ok(ChartTypeSpec::Bar),
707        "scatter" => Ok(ChartTypeSpec::Scatter),
708        "area" => Ok(ChartTypeSpec::Area),
709        "histogram" => Ok(ChartTypeSpec::Histogram),
710        _ => Err(ShapeError::RuntimeError {
711            message: format!(
712                "Unknown chart type '{}'. Expected: line, bar, scatter, area, histogram.",
713                s
714            ),
715            location: None,
716        }),
717    }
718}
719
720fn parse_align_spec(s: &str) -> Result<AlignSpec> {
721    match s.trim() {
722        "left" => Ok(AlignSpec::Left),
723        "center" => Ok(AlignSpec::Center),
724        "right" => Ok(AlignSpec::Right),
725        _ => Err(ShapeError::RuntimeError {
726            message: format!(
727                "Unknown align value '{}'. Expected: left, center, right.",
728                s
729            ),
730            location: None,
731        }),
732    }
733}
734
735/// Split interpolation content for content strings.
736///
737/// Content-string format specs use `parse_content_format_spec` instead of
738/// the regular `parse_format_spec`.
739pub fn split_expression_and_content_format_spec(
740    raw: &str,
741) -> Result<(String, Option<InterpolationFormatSpec>)> {
742    let trimmed = raw.trim();
743    if trimmed.is_empty() {
744        return Err(ShapeError::RuntimeError {
745            message: "Empty expression in interpolation".to_string(),
746            location: None,
747        });
748    }
749
750    let split_at = find_top_level_format_colon(trimmed);
751
752    if let Some(idx) = split_at {
753        let expr = trimmed[..idx].trim();
754        let spec = trimmed[idx + 1..].trim();
755        if expr.is_empty() {
756            return Err(ShapeError::RuntimeError {
757                message: "Missing expression before format spec in interpolation".to_string(),
758                location: None,
759            });
760        }
761        if spec.is_empty() {
762            return Err(ShapeError::RuntimeError {
763                message: "Missing format spec after ':' in interpolation".to_string(),
764                location: None,
765            });
766        }
767        Ok((
768            expr.to_string(),
769            Some(InterpolationFormatSpec::ContentStyle(
770                parse_content_format_spec(spec)?,
771            )),
772        ))
773    } else {
774        Ok((trimmed.to_string(), None))
775    }
776}
777
778fn parse_legacy_fixed_precision(spec: &str) -> Result<Option<u8>> {
779    if let Some(rest) = spec.strip_prefix('.') {
780        let digits = rest.strip_suffix('f').unwrap_or(rest);
781        if digits.is_empty() {
782            return Err(ShapeError::RuntimeError {
783                message: "Legacy fixed format requires digits after '.'".to_string(),
784                location: None,
785            });
786        }
787        if digits.chars().all(|c| c.is_ascii_digit()) {
788            return Ok(Some(parse_u8_value(digits, "fixed precision")?));
789        }
790    }
791    Ok(None)
792}
793
794fn parse_call_like_spec<'a>(spec: &'a str, name: &str) -> Result<Option<&'a str>> {
795    if !spec.starts_with(name) {
796        return Ok(None);
797    }
798
799    let rest = &spec[name.len()..];
800    if !rest.starts_with('(') || !rest.ends_with(')') {
801        return Err(ShapeError::RuntimeError {
802            message: format!("Format spec '{}' must use call syntax: {}(...)", spec, name),
803            location: None,
804        });
805    }
806
807    Ok(Some(&rest[1..rest.len() - 1]))
808}
809
810fn parse_table_format_spec(inner: &str) -> Result<TableFormatSpec> {
811    let mut spec = TableFormatSpec::default();
812    let trimmed = inner.trim();
813
814    if trimmed.is_empty() {
815        return Ok(spec);
816    }
817
818    for entry in split_top_level_commas(trimmed)? {
819        let entry = entry.trim();
820        if entry.is_empty() {
821            continue;
822        }
823
824        let (key, value) = entry
825            .split_once('=')
826            .ok_or_else(|| ShapeError::RuntimeError {
827                message: format!(
828                    "Invalid table format argument '{}'. Expected key=value pairs.",
829                    entry
830                ),
831                location: None,
832            })?;
833        let key = key.trim();
834        let value = value.trim();
835
836        match key {
837            "max_rows" => {
838                spec.max_rows = Some(parse_usize_value(value, "max_rows")?);
839            }
840            "align" => {
841                spec.align = Some(FormatAlignment::parse(value).ok_or_else(|| {
842                    ShapeError::RuntimeError {
843                        message: format!(
844                            "Invalid align value '{}'. Expected: left, center, right.",
845                            value
846                        ),
847                        location: None,
848                    }
849                })?);
850            }
851            "precision" => {
852                spec.precision = Some(parse_u8_value(value, "precision")?);
853            }
854            "color" => {
855                spec.color = Some(FormatColor::parse(value).ok_or_else(|| {
856                    ShapeError::RuntimeError {
857                        message: format!(
858                            "Invalid color value '{}'. Expected: default, red, green, yellow, blue, magenta, cyan, white.",
859                            value
860                        ),
861                        location: None,
862                    }
863                })?);
864            }
865            "border" => {
866                spec.border = parse_on_off(value)?;
867            }
868            other => {
869                return Err(ShapeError::RuntimeError {
870                    message: format!(
871                        "Unknown table format key '{}'. Supported: max_rows, align, precision, color, border.",
872                        other
873                    ),
874                    location: None,
875                });
876            }
877        }
878    }
879
880    Ok(spec)
881}
882
883fn split_top_level_commas(s: &str) -> Result<Vec<&str>> {
884    let mut parts = Vec::new();
885    let mut start = 0usize;
886    let mut paren_depth = 0usize;
887    let mut brace_depth = 0usize;
888    let mut bracket_depth = 0usize;
889    let mut in_string: Option<char> = None;
890    let mut escaped = false;
891
892    for (idx, ch) in s.char_indices() {
893        if let Some(quote) = in_string {
894            if escaped {
895                escaped = false;
896                continue;
897            }
898            if ch == '\\' {
899                escaped = true;
900                continue;
901            }
902            if ch == quote {
903                in_string = None;
904            }
905            continue;
906        }
907
908        match ch {
909            '"' | '\'' => in_string = Some(ch),
910            '(' => paren_depth += 1,
911            ')' => paren_depth = paren_depth.saturating_sub(1),
912            '{' => brace_depth += 1,
913            '}' => brace_depth = brace_depth.saturating_sub(1),
914            '[' => bracket_depth += 1,
915            ']' => bracket_depth = bracket_depth.saturating_sub(1),
916            ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
917                parts.push(&s[start..idx]);
918                start = idx + 1;
919            }
920            _ => {}
921        }
922    }
923
924    if in_string.is_some() || paren_depth != 0 || brace_depth != 0 || bracket_depth != 0 {
925        return Err(ShapeError::RuntimeError {
926            message: "Unclosed delimiter in table format spec".to_string(),
927            location: None,
928        });
929    }
930
931    parts.push(&s[start..]);
932    Ok(parts)
933}
934
935fn parse_u8_value(value: &str, label: &str) -> Result<u8> {
936    value.parse::<u8>().map_err(|_| ShapeError::RuntimeError {
937        message: format!(
938            "Invalid {} '{}'. Expected an integer in range 0..=255.",
939            label, value
940        ),
941        location: None,
942    })
943}
944
945fn parse_usize_value(value: &str, label: &str) -> Result<usize> {
946    value
947        .parse::<usize>()
948        .map_err(|_| ShapeError::RuntimeError {
949            message: format!(
950                "Invalid {} '{}'. Expected a non-negative integer.",
951                label, value
952            ),
953            location: None,
954        })
955}
956
957fn parse_on_off(value: &str) -> Result<bool> {
958    match value {
959        "on" => Ok(true),
960        "off" => Ok(false),
961        _ => Err(ShapeError::RuntimeError {
962            message: format!("Invalid border value '{}'. Expected on or off.", value),
963            location: None,
964        }),
965    }
966}
967
968fn parse_expression_content(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String> {
969    let mut expr = String::new();
970    let mut brace_depth = 1usize;
971
972    while let Some(ch) = chars.next() {
973        match ch {
974            '{' => {
975                brace_depth += 1;
976                expr.push(ch);
977            }
978            '}' => {
979                brace_depth = brace_depth.saturating_sub(1);
980                if brace_depth == 0 {
981                    return if expr.trim().is_empty() {
982                        Err(ShapeError::RuntimeError {
983                            message: "Empty expression in interpolation".to_string(),
984                            location: None,
985                        })
986                    } else {
987                        Ok(expr)
988                    };
989                }
990                expr.push(ch);
991            }
992            '"' => {
993                expr.push(ch);
994                while let Some(c) = chars.next() {
995                    expr.push(c);
996                    if c == '"' {
997                        break;
998                    }
999                    if c == '\\' {
1000                        if let Some(escaped) = chars.next() {
1001                            expr.push(escaped);
1002                        }
1003                    }
1004                }
1005            }
1006            '\'' => {
1007                expr.push(ch);
1008                while let Some(c) = chars.next() {
1009                    expr.push(c);
1010                    if c == '\'' {
1011                        break;
1012                    }
1013                    if c == '\\' {
1014                        if let Some(escaped) = chars.next() {
1015                            expr.push(escaped);
1016                        }
1017                    }
1018                }
1019            }
1020            _ => expr.push(ch),
1021        }
1022    }
1023
1024    Err(ShapeError::RuntimeError {
1025        message: "Unclosed interpolation (missing })".to_string(),
1026        location: None,
1027    })
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032    use super::*;
1033    use crate::ast::InterpolationMode;
1034
1035    #[test]
1036    fn parse_basic_interpolation() {
1037        let parts = parse_interpolation("value: {x}").unwrap();
1038        assert_eq!(parts.len(), 2);
1039        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "value: "));
1040        assert!(matches!(
1041            &parts[1],
1042            InterpolationPart::Expression {
1043                expr,
1044                format_spec: None
1045            } if expr == "x"
1046        ));
1047    }
1048
1049    #[test]
1050    fn parse_format_spec() {
1051        let parts = parse_interpolation("px={price:fixed(2)}").unwrap();
1052        assert!(matches!(
1053            &parts[1],
1054            InterpolationPart::Expression {
1055                expr,
1056                format_spec: Some(spec)
1057            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
1058        ));
1059    }
1060
1061    #[test]
1062    fn parse_legacy_fixed_precision_alias() {
1063        let parts = parse_interpolation("px={price:.2f}").unwrap();
1064        assert!(matches!(
1065            &parts[1],
1066            InterpolationPart::Expression {
1067                expr,
1068                format_spec: Some(spec)
1069            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
1070        ));
1071    }
1072
1073    #[test]
1074    fn parse_table_format_spec() {
1075        let parts = parse_interpolation(
1076            "rows={dt:table(max_rows=5, align=right, precision=2, color=green, border=off)}",
1077        )
1078        .unwrap();
1079
1080        assert!(matches!(
1081            &parts[1],
1082            InterpolationPart::Expression {
1083                expr,
1084                format_spec: Some(InterpolationFormatSpec::Table(TableFormatSpec {
1085                    max_rows: Some(5),
1086                    align: Some(FormatAlignment::Right),
1087                    precision: Some(2),
1088                    color: Some(FormatColor::Green),
1089                    border: false
1090                }))
1091            } if expr == "dt"
1092        ));
1093    }
1094
1095    #[test]
1096    fn parse_table_format_unknown_key_errors() {
1097        let err = parse_interpolation("rows={dt:table(foo=1)}").unwrap_err();
1098        let msg = err.to_string();
1099        assert!(
1100            msg.contains("Unknown table format key"),
1101            "unexpected error: {}",
1102            msg
1103        );
1104    }
1105
1106    #[test]
1107    fn parse_double_colon_is_not_format_spec() {
1108        let parts = parse_interpolation("{Type::Variant}").unwrap();
1109        assert!(matches!(
1110            &parts[0],
1111            InterpolationPart::Expression {
1112                expr,
1113                format_spec: None
1114            } if expr == "Type::Variant"
1115        ));
1116    }
1117
1118    #[test]
1119    fn escaped_braces_do_not_count_as_interpolation() {
1120        assert!(!has_interpolation("Use {{x}} for literal"));
1121        assert!(has_interpolation("Use {x} for value"));
1122    }
1123
1124    #[test]
1125    fn parse_dollar_interpolation() {
1126        let parts = parse_interpolation_with_mode(
1127            "json={\"name\": ${user.name}}",
1128            InterpolationMode::Dollar,
1129        )
1130        .unwrap();
1131        assert_eq!(parts.len(), 3);
1132        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "json={\"name\": "));
1133        assert!(matches!(
1134            &parts[1],
1135            InterpolationPart::Expression {
1136                expr,
1137                format_spec: None
1138            } if expr == "user.name"
1139        ));
1140        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == "}"));
1141    }
1142
1143    #[test]
1144    fn parse_hash_interpolation() {
1145        let parts = parse_interpolation_with_mode("echo #{cmd}", InterpolationMode::Hash).unwrap();
1146        assert_eq!(parts.len(), 2);
1147        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "echo "));
1148        assert!(matches!(
1149            &parts[1],
1150            InterpolationPart::Expression {
1151                expr,
1152                format_spec: None
1153            } if expr == "cmd"
1154        ));
1155    }
1156
1157    #[test]
1158    fn escaped_sigil_opener_is_literal_in_sigil_modes() {
1159        let parts =
1160            parse_interpolation_with_mode("literal $${x}", InterpolationMode::Dollar).unwrap();
1161        assert_eq!(parts.len(), 1);
1162        assert!(matches!(
1163            &parts[0],
1164            InterpolationPart::Literal(s) if s == "literal ${x}"
1165        ));
1166    }
1167
1168    #[test]
1169    fn braces_are_plain_text_in_sigil_mode() {
1170        assert!(!has_interpolation_with_mode(
1171            "{\"a\": 1}",
1172            InterpolationMode::Dollar
1173        ));
1174        assert!(has_interpolation_with_mode(
1175            "${x}",
1176            InterpolationMode::Dollar
1177        ));
1178    }
1179
1180    // ====== Content format spec tests ======
1181
1182    #[test]
1183    fn parse_content_format_spec_bold() {
1184        let spec = parse_content_format_spec("bold").unwrap();
1185        assert!(spec.bold);
1186        assert!(!spec.italic);
1187    }
1188
1189    #[test]
1190    fn parse_content_format_spec_multiple_flags() {
1191        let spec = parse_content_format_spec("bold, italic, underline").unwrap();
1192        assert!(spec.bold);
1193        assert!(spec.italic);
1194        assert!(spec.underline);
1195        assert!(!spec.dim);
1196    }
1197
1198    #[test]
1199    fn parse_content_format_spec_fg_named() {
1200        let spec = parse_content_format_spec("fg(red)").unwrap();
1201        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
1202    }
1203
1204    #[test]
1205    fn parse_content_format_spec_fg_rgb() {
1206        let spec = parse_content_format_spec("fg(rgb(255, 128, 0))").unwrap();
1207        assert_eq!(spec.fg, Some(ColorSpec::Rgb(255, 128, 0)));
1208    }
1209
1210    #[test]
1211    fn parse_content_format_spec_full() {
1212        let spec = parse_content_format_spec(
1213            "fg(green), bg(blue), bold, fixed(2), border(rounded), align(center)",
1214        )
1215        .unwrap();
1216        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Green)));
1217        assert_eq!(spec.bg, Some(ColorSpec::Named(NamedContentColor::Blue)));
1218        assert!(spec.bold);
1219        assert_eq!(spec.fixed_precision, Some(2));
1220        assert_eq!(spec.border, Some(BorderStyleSpec::Rounded));
1221        assert_eq!(spec.align, Some(AlignSpec::Center));
1222    }
1223
1224    #[test]
1225    fn parse_content_format_spec_unknown_key_errors() {
1226        let err = parse_content_format_spec("foo(bar)").unwrap_err();
1227        assert!(err.to_string().contains("Unknown content format key"));
1228    }
1229
1230    #[test]
1231    fn split_content_format_spec_basic() {
1232        let (expr, spec) = split_expression_and_content_format_spec("price:fg(red), bold").unwrap();
1233        assert_eq!(expr, "price");
1234        assert!(matches!(
1235            spec,
1236            Some(InterpolationFormatSpec::ContentStyle(_))
1237        ));
1238        if let Some(InterpolationFormatSpec::ContentStyle(cs)) = spec {
1239            assert_eq!(cs.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
1240            assert!(cs.bold);
1241        }
1242    }
1243
1244    #[test]
1245    fn parse_content_format_spec_chart_type() {
1246        let spec = parse_content_format_spec("chart(bar)").unwrap();
1247        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Bar));
1248    }
1249
1250    #[test]
1251    fn parse_content_format_spec_chart_with_axes() {
1252        let spec = parse_content_format_spec("chart(line), x(month), y(revenue, profit)").unwrap();
1253        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Line));
1254        assert_eq!(spec.x_column, Some("month".to_string()));
1255        assert_eq!(spec.y_columns, vec!["revenue", "profit"]);
1256    }
1257
1258    #[test]
1259    fn parse_content_format_spec_chart_single_y() {
1260        let spec = parse_content_format_spec("chart(scatter), x(date), y(price)").unwrap();
1261        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Scatter));
1262        assert_eq!(spec.x_column, Some("date".to_string()));
1263        assert_eq!(spec.y_columns, vec!["price"]);
1264    }
1265
1266    #[test]
1267    fn parse_content_format_spec_chart_invalid_type() {
1268        let err = parse_content_format_spec("chart(pie)").unwrap_err();
1269        assert!(err.to_string().contains("Unknown chart type"));
1270    }
1271
1272    #[test]
1273    fn split_content_chart_format_spec() {
1274        let (expr, spec) =
1275            split_expression_and_content_format_spec("data:chart(bar), x(month), y(sales)")
1276                .unwrap();
1277        assert_eq!(expr, "data");
1278        if let Some(InterpolationFormatSpec::ContentStyle(cs)) = spec {
1279            assert_eq!(cs.chart_type, Some(ChartTypeSpec::Bar));
1280            assert_eq!(cs.x_column, Some("month".to_string()));
1281            assert_eq!(cs.y_columns, vec!["sales"]);
1282        } else {
1283            panic!("expected ContentStyle");
1284        }
1285    }
1286
1287    // --- LOW-2: backslash-escaped braces in interpolation ---
1288
1289    #[test]
1290    fn backslash_escaped_braces_produce_literal_text() {
1291        // `\{` and `\}` should produce literal `{` and `}`, not interpolation.
1292        let parts = parse_interpolation("hello \\{world\\}").unwrap();
1293        assert_eq!(parts.len(), 1);
1294        assert!(matches!(
1295            &parts[0],
1296            InterpolationPart::Literal(s) if s == "hello {world}"
1297        ));
1298    }
1299
1300    #[test]
1301    fn backslash_escaped_braces_not_counted_as_interpolation() {
1302        assert!(!has_interpolation("hello \\{world\\}"));
1303        assert!(has_interpolation("hello {world}"));
1304    }
1305
1306    #[test]
1307    fn backslash_escaped_braces_mixed_with_real_interpolation() {
1308        // `\{literal\} and {expr}` → Literal("{literal} and "), Expression("expr")
1309        let parts = parse_interpolation("\\{literal\\} and {expr}").unwrap();
1310        assert_eq!(parts.len(), 2);
1311        assert!(matches!(
1312            &parts[0],
1313            InterpolationPart::Literal(s) if s == "{literal} and "
1314        ));
1315        assert!(matches!(
1316            &parts[1],
1317            InterpolationPart::Expression { expr, .. } if expr == "expr"
1318        ));
1319    }
1320
1321    #[test]
1322    fn content_interpolation_backslash_escaped_braces() {
1323        let parts =
1324            parse_content_interpolation_with_mode("\\{not interp\\}", InterpolationMode::Braces)
1325                .unwrap();
1326        assert_eq!(parts.len(), 1);
1327        assert!(matches!(
1328            &parts[0],
1329            InterpolationPart::Literal(s) if s == "{not interp}"
1330        ));
1331    }
1332}