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        match mode {
214            InterpolationMode::Braces => match ch {
215                '{' => {
216                    if chars.peek() == Some(&'{') {
217                        chars.next();
218                        current_text.push('{');
219                        continue;
220                    }
221
222                    if !current_text.is_empty() {
223                        parts.push(InterpolationPart::Literal(current_text.clone()));
224                        current_text.clear();
225                    }
226
227                    let raw_expr = parse_expression_content(&mut chars)?;
228                    let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
229                    parts.push(InterpolationPart::Expression { expr, format_spec });
230                }
231                '}' => {
232                    if chars.peek() == Some(&'}') {
233                        chars.next();
234                        current_text.push('}');
235                    } else {
236                        return Err(ShapeError::RuntimeError {
237                            message:
238                                "Unmatched '}' in interpolation string. Use '}}' for a literal '}'"
239                                    .to_string(),
240                            location: None,
241                        });
242                    }
243                }
244                _ => current_text.push(ch),
245            },
246            InterpolationMode::Dollar | InterpolationMode::Hash => {
247                let sigil = mode.sigil().expect("sigil mode must provide sigil");
248                if ch == sigil {
249                    if chars.peek() == Some(&sigil) {
250                        chars.next();
251                        if chars.peek() == Some(&'{') {
252                            chars.next();
253                            current_text.push(sigil);
254                            current_text.push('{');
255                        } else {
256                            current_text.push(sigil);
257                        }
258                        continue;
259                    }
260
261                    if chars.peek() == Some(&'{') {
262                        chars.next();
263                        if !current_text.is_empty() {
264                            parts.push(InterpolationPart::Literal(current_text.clone()));
265                            current_text.clear();
266                        }
267                        let raw_expr = parse_expression_content(&mut chars)?;
268                        let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
269                        parts.push(InterpolationPart::Expression { expr, format_spec });
270                        continue;
271                    }
272                }
273
274                current_text.push(ch);
275            }
276        }
277    }
278
279    if !current_text.is_empty() {
280        parts.push(InterpolationPart::Literal(current_text));
281    }
282
283    Ok(parts)
284}
285
286/// Parse a content string payload into interpolation parts.
287///
288/// Unlike `parse_interpolation_with_mode`, this uses `split_expression_and_content_format_spec`
289/// to parse content-specific format specs (e.g., `fg(red), bold`) instead of the regular
290/// fixed/table format specs.
291pub fn parse_content_interpolation_with_mode(
292    s: &str,
293    mode: InterpolationMode,
294) -> Result<Vec<InterpolationPart>> {
295    let mut parts = Vec::new();
296    let mut current_text = String::new();
297    let mut chars = s.chars().peekable();
298
299    while let Some(ch) = chars.next() {
300        match mode {
301            InterpolationMode::Braces => match ch {
302                '{' => {
303                    if chars.peek() == Some(&'{') {
304                        chars.next();
305                        current_text.push('{');
306                        continue;
307                    }
308
309                    if !current_text.is_empty() {
310                        parts.push(InterpolationPart::Literal(current_text.clone()));
311                        current_text.clear();
312                    }
313
314                    let raw_expr = parse_expression_content(&mut chars)?;
315                    let (expr, format_spec) = split_expression_and_content_format_spec(&raw_expr)?;
316                    parts.push(InterpolationPart::Expression { expr, format_spec });
317                }
318                '}' => {
319                    if chars.peek() == Some(&'}') {
320                        chars.next();
321                        current_text.push('}');
322                    } else {
323                        return Err(ShapeError::RuntimeError {
324                            message:
325                                "Unmatched '}' in interpolation string. Use '}}' for a literal '}'"
326                                    .to_string(),
327                            location: None,
328                        });
329                    }
330                }
331                _ => current_text.push(ch),
332            },
333            InterpolationMode::Dollar | InterpolationMode::Hash => {
334                let sigil = mode.sigil().expect("sigil mode must provide sigil");
335                if ch == sigil {
336                    if chars.peek() == Some(&sigil) {
337                        chars.next();
338                        if chars.peek() == Some(&'{') {
339                            chars.next();
340                            current_text.push(sigil);
341                            current_text.push('{');
342                        } else {
343                            current_text.push(sigil);
344                        }
345                        continue;
346                    }
347
348                    if chars.peek() == Some(&'{') {
349                        chars.next();
350                        if !current_text.is_empty() {
351                            parts.push(InterpolationPart::Literal(current_text.clone()));
352                            current_text.clear();
353                        }
354                        let raw_expr = parse_expression_content(&mut chars)?;
355                        let (expr, format_spec) =
356                            split_expression_and_content_format_spec(&raw_expr)?;
357                        parts.push(InterpolationPart::Expression { expr, format_spec });
358                        continue;
359                    }
360                }
361
362                current_text.push(ch);
363            }
364        }
365    }
366
367    if !current_text.is_empty() {
368        parts.push(InterpolationPart::Literal(current_text));
369    }
370
371    Ok(parts)
372}
373
374/// Check whether a string contains at least one interpolation segment.
375pub fn has_interpolation(s: &str) -> bool {
376    has_interpolation_with_mode(s, InterpolationMode::Braces)
377}
378
379/// Check whether a string contains at least one interpolation segment for the mode.
380pub fn has_interpolation_with_mode(s: &str, mode: InterpolationMode) -> bool {
381    let mut chars = s.chars().peekable();
382    while let Some(ch) = chars.next() {
383        match mode {
384            InterpolationMode::Braces => {
385                if ch == '{' {
386                    if chars.peek() != Some(&'{') {
387                        return true;
388                    }
389                    chars.next();
390                }
391            }
392            InterpolationMode::Dollar | InterpolationMode::Hash => {
393                let sigil = mode.sigil().expect("sigil mode must provide sigil");
394                if ch == sigil && chars.peek() == Some(&'{') {
395                    return true;
396                }
397            }
398        }
399    }
400    false
401}
402
403/// Split interpolation content `expr[:spec]` at the top-level format separator.
404///
405/// This preserves `::` (enum/type separators) and ignores separators inside
406/// nested delimiters/strings.
407pub fn split_expression_and_format_spec(
408    raw: &str,
409) -> Result<(String, Option<InterpolationFormatSpec>)> {
410    let trimmed = raw.trim();
411    if trimmed.is_empty() {
412        return Err(ShapeError::RuntimeError {
413            message: "Empty expression in interpolation".to_string(),
414            location: None,
415        });
416    }
417
418    let split_at = find_top_level_format_colon(trimmed);
419
420    if let Some(idx) = split_at {
421        let expr = trimmed[..idx].trim();
422        let spec = trimmed[idx + 1..].trim();
423        if expr.is_empty() {
424            return Err(ShapeError::RuntimeError {
425                message: "Missing expression before format spec in interpolation".to_string(),
426                location: None,
427            });
428        }
429        if spec.is_empty() {
430            return Err(ShapeError::RuntimeError {
431                message: "Missing format spec after ':' in interpolation".to_string(),
432                location: None,
433            });
434        }
435        Ok((expr.to_string(), Some(parse_format_spec(spec)?)))
436    } else {
437        Ok((trimmed.to_string(), None))
438    }
439}
440
441/// Find the top-level format-separator `:` in an interpolation expression.
442///
443/// Returns the byte index of the separator if present.
444pub fn find_top_level_format_colon(raw: &str) -> Option<usize> {
445    let bytes = raw.as_bytes();
446    let mut paren_depth = 0usize;
447    let mut brace_depth = 0usize;
448    let mut bracket_depth = 0usize;
449    let mut in_string: Option<char> = None;
450    let mut escaped = false;
451
452    for (idx, ch) in raw.char_indices() {
453        if let Some(quote) = in_string {
454            if escaped {
455                escaped = false;
456                continue;
457            }
458            if ch == '\\' {
459                escaped = true;
460                continue;
461            }
462            if ch == quote {
463                in_string = None;
464            }
465            continue;
466        }
467
468        match ch {
469            '"' | '\'' => in_string = Some(ch),
470            '(' => paren_depth += 1,
471            ')' => paren_depth = paren_depth.saturating_sub(1),
472            '{' => brace_depth += 1,
473            '}' => brace_depth = brace_depth.saturating_sub(1),
474            '[' => bracket_depth += 1,
475            ']' => bracket_depth = bracket_depth.saturating_sub(1),
476            ':' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
477                let prev_is_colon = idx > 0 && bytes[idx - 1] == b':';
478                let next_is_colon = idx + 1 < bytes.len() && bytes[idx + 1] == b':';
479                if !prev_is_colon && !next_is_colon {
480                    return Some(idx);
481                }
482            }
483            _ => {}
484        }
485    }
486
487    None
488}
489
490fn parse_format_spec(raw_spec: &str) -> Result<InterpolationFormatSpec> {
491    let spec = raw_spec.trim();
492
493    // Legacy shorthand kept as a parser alias, but normalized into typed format.
494    if let Some(precision) = parse_legacy_fixed_precision(spec)? {
495        return Ok(InterpolationFormatSpec::Fixed { precision });
496    }
497
498    if let Some(inner) = parse_call_like_spec(spec, "fixed")? {
499        let precision = parse_u8_value(inner.trim(), "fixed precision")?;
500        return Ok(InterpolationFormatSpec::Fixed { precision });
501    }
502
503    if let Some(inner) = parse_call_like_spec(spec, "table")? {
504        return Ok(InterpolationFormatSpec::Table(parse_table_format_spec(
505            inner,
506        )?));
507    }
508
509    Err(ShapeError::RuntimeError {
510        message: format!(
511            "Unsupported interpolation format spec '{}'. Supported: fixed(N), table(...).",
512            spec
513        ),
514        location: None,
515    })
516}
517
518/// Parse a content-string format spec like `"fg(red), bold, fixed(2)"`.
519pub fn parse_content_format_spec(raw_spec: &str) -> Result<ContentFormatSpec> {
520    let mut spec = ContentFormatSpec::default();
521    let trimmed = raw_spec.trim();
522    if trimmed.is_empty() {
523        return Ok(spec);
524    }
525
526    for entry in split_top_level_commas(trimmed)? {
527        let entry = entry.trim();
528        if entry.is_empty() {
529            continue;
530        }
531
532        // Boolean flags (no parens)
533        match entry {
534            "bold" => {
535                spec.bold = true;
536                continue;
537            }
538            "italic" => {
539                spec.italic = true;
540                continue;
541            }
542            "underline" => {
543                spec.underline = true;
544                continue;
545            }
546            "dim" => {
547                spec.dim = true;
548                continue;
549            }
550            _ => {}
551        }
552
553        // Call-like specs: fg(...), bg(...), fixed(...), border(...), max_rows(...), align(...)
554        if let Some(idx) = entry.find('(') {
555            if !entry.ends_with(')') {
556                return Err(ShapeError::RuntimeError {
557                    message: format!("Unclosed parenthesis in content format spec '{}'", entry),
558                    location: None,
559                });
560            }
561            let key = entry[..idx].trim();
562            let inner = entry[idx + 1..entry.len() - 1].trim();
563            match key {
564                "fg" => {
565                    spec.fg = Some(parse_color_spec(inner)?);
566                }
567                "bg" => {
568                    spec.bg = Some(parse_color_spec(inner)?);
569                }
570                "fixed" => {
571                    spec.fixed_precision = Some(parse_u8_value(inner, "fixed precision")?);
572                }
573                "border" => {
574                    spec.border = Some(parse_border_style_spec(inner)?);
575                }
576                "max_rows" => {
577                    spec.max_rows = Some(parse_usize_value(inner, "max_rows")?);
578                }
579                "align" => {
580                    spec.align = Some(parse_align_spec(inner)?);
581                }
582                "chart" => {
583                    spec.chart_type = Some(parse_chart_type_spec(inner)?);
584                }
585                "x" => {
586                    spec.x_column = Some(inner.to_string());
587                }
588                "y" => {
589                    // y(col) or y(col1, col2, ...)
590                    spec.y_columns = inner
591                        .split(',')
592                        .map(|s| s.trim().to_string())
593                        .filter(|s| !s.is_empty())
594                        .collect();
595                }
596                other => {
597                    return Err(ShapeError::RuntimeError {
598                        message: format!(
599                            "Unknown content format key '{}'. Supported: fg, bg, bold, italic, underline, dim, fixed, border, max_rows, align, chart, x, y.",
600                            other
601                        ),
602                        location: None,
603                    });
604                }
605            }
606            continue;
607        }
608
609        return Err(ShapeError::RuntimeError {
610            message: format!(
611                "Unknown content format entry '{}'. Expected a flag (bold, italic, ...) or key(value).",
612                entry
613            ),
614            location: None,
615        });
616    }
617
618    Ok(spec)
619}
620
621fn parse_color_spec(s: &str) -> Result<ColorSpec> {
622    let s = s.trim();
623    // Try RGB: rgb(r, g, b)
624    if s.starts_with("rgb(") && s.ends_with(')') {
625        let inner = &s[4..s.len() - 1];
626        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
627        if parts.len() != 3 {
628            return Err(ShapeError::RuntimeError {
629                message: format!("rgb() expects 3 values, got {}", parts.len()),
630                location: None,
631            });
632        }
633        let r = parse_u8_value(parts[0], "red")?;
634        let g = parse_u8_value(parts[1], "green")?;
635        let b = parse_u8_value(parts[2], "blue")?;
636        return Ok(ColorSpec::Rgb(r, g, b));
637    }
638    // Named color
639    match s {
640        "red" => Ok(ColorSpec::Named(NamedContentColor::Red)),
641        "green" => Ok(ColorSpec::Named(NamedContentColor::Green)),
642        "blue" => Ok(ColorSpec::Named(NamedContentColor::Blue)),
643        "yellow" => Ok(ColorSpec::Named(NamedContentColor::Yellow)),
644        "magenta" => Ok(ColorSpec::Named(NamedContentColor::Magenta)),
645        "cyan" => Ok(ColorSpec::Named(NamedContentColor::Cyan)),
646        "white" => Ok(ColorSpec::Named(NamedContentColor::White)),
647        "default" => Ok(ColorSpec::Named(NamedContentColor::Default)),
648        _ => Err(ShapeError::RuntimeError {
649            message: format!(
650                "Unknown color '{}'. Expected: red, green, blue, yellow, magenta, cyan, white, default, or rgb(r,g,b).",
651                s
652            ),
653            location: None,
654        }),
655    }
656}
657
658fn parse_border_style_spec(s: &str) -> Result<BorderStyleSpec> {
659    match s.trim() {
660        "rounded" => Ok(BorderStyleSpec::Rounded),
661        "sharp" => Ok(BorderStyleSpec::Sharp),
662        "heavy" => Ok(BorderStyleSpec::Heavy),
663        "double" => Ok(BorderStyleSpec::Double),
664        "minimal" => Ok(BorderStyleSpec::Minimal),
665        "none" => Ok(BorderStyleSpec::None),
666        _ => Err(ShapeError::RuntimeError {
667            message: format!(
668                "Unknown border style '{}'. Expected: rounded, sharp, heavy, double, minimal, none.",
669                s
670            ),
671            location: None,
672        }),
673    }
674}
675
676fn parse_chart_type_spec(s: &str) -> Result<ChartTypeSpec> {
677    match s.trim().to_lowercase().as_str() {
678        "line" => Ok(ChartTypeSpec::Line),
679        "bar" => Ok(ChartTypeSpec::Bar),
680        "scatter" => Ok(ChartTypeSpec::Scatter),
681        "area" => Ok(ChartTypeSpec::Area),
682        "histogram" => Ok(ChartTypeSpec::Histogram),
683        _ => Err(ShapeError::RuntimeError {
684            message: format!(
685                "Unknown chart type '{}'. Expected: line, bar, scatter, area, histogram.",
686                s
687            ),
688            location: None,
689        }),
690    }
691}
692
693fn parse_align_spec(s: &str) -> Result<AlignSpec> {
694    match s.trim() {
695        "left" => Ok(AlignSpec::Left),
696        "center" => Ok(AlignSpec::Center),
697        "right" => Ok(AlignSpec::Right),
698        _ => Err(ShapeError::RuntimeError {
699            message: format!(
700                "Unknown align value '{}'. Expected: left, center, right.",
701                s
702            ),
703            location: None,
704        }),
705    }
706}
707
708/// Split interpolation content for content strings.
709///
710/// Content-string format specs use `parse_content_format_spec` instead of
711/// the regular `parse_format_spec`.
712pub fn split_expression_and_content_format_spec(
713    raw: &str,
714) -> Result<(String, Option<InterpolationFormatSpec>)> {
715    let trimmed = raw.trim();
716    if trimmed.is_empty() {
717        return Err(ShapeError::RuntimeError {
718            message: "Empty expression in interpolation".to_string(),
719            location: None,
720        });
721    }
722
723    let split_at = find_top_level_format_colon(trimmed);
724
725    if let Some(idx) = split_at {
726        let expr = trimmed[..idx].trim();
727        let spec = trimmed[idx + 1..].trim();
728        if expr.is_empty() {
729            return Err(ShapeError::RuntimeError {
730                message: "Missing expression before format spec in interpolation".to_string(),
731                location: None,
732            });
733        }
734        if spec.is_empty() {
735            return Err(ShapeError::RuntimeError {
736                message: "Missing format spec after ':' in interpolation".to_string(),
737                location: None,
738            });
739        }
740        Ok((
741            expr.to_string(),
742            Some(InterpolationFormatSpec::ContentStyle(
743                parse_content_format_spec(spec)?,
744            )),
745        ))
746    } else {
747        Ok((trimmed.to_string(), None))
748    }
749}
750
751fn parse_legacy_fixed_precision(spec: &str) -> Result<Option<u8>> {
752    if let Some(rest) = spec.strip_prefix('.') {
753        let digits = rest.strip_suffix('f').unwrap_or(rest);
754        if digits.is_empty() {
755            return Err(ShapeError::RuntimeError {
756                message: "Legacy fixed format requires digits after '.'".to_string(),
757                location: None,
758            });
759        }
760        if digits.chars().all(|c| c.is_ascii_digit()) {
761            return Ok(Some(parse_u8_value(digits, "fixed precision")?));
762        }
763    }
764    Ok(None)
765}
766
767fn parse_call_like_spec<'a>(spec: &'a str, name: &str) -> Result<Option<&'a str>> {
768    if !spec.starts_with(name) {
769        return Ok(None);
770    }
771
772    let rest = &spec[name.len()..];
773    if !rest.starts_with('(') || !rest.ends_with(')') {
774        return Err(ShapeError::RuntimeError {
775            message: format!("Format spec '{}' must use call syntax: {}(...)", spec, name),
776            location: None,
777        });
778    }
779
780    Ok(Some(&rest[1..rest.len() - 1]))
781}
782
783fn parse_table_format_spec(inner: &str) -> Result<TableFormatSpec> {
784    let mut spec = TableFormatSpec::default();
785    let trimmed = inner.trim();
786
787    if trimmed.is_empty() {
788        return Ok(spec);
789    }
790
791    for entry in split_top_level_commas(trimmed)? {
792        let entry = entry.trim();
793        if entry.is_empty() {
794            continue;
795        }
796
797        let (key, value) = entry
798            .split_once('=')
799            .ok_or_else(|| ShapeError::RuntimeError {
800                message: format!(
801                    "Invalid table format argument '{}'. Expected key=value pairs.",
802                    entry
803                ),
804                location: None,
805            })?;
806        let key = key.trim();
807        let value = value.trim();
808
809        match key {
810            "max_rows" => {
811                spec.max_rows = Some(parse_usize_value(value, "max_rows")?);
812            }
813            "align" => {
814                spec.align = Some(FormatAlignment::parse(value).ok_or_else(|| {
815                    ShapeError::RuntimeError {
816                        message: format!(
817                            "Invalid align value '{}'. Expected: left, center, right.",
818                            value
819                        ),
820                        location: None,
821                    }
822                })?);
823            }
824            "precision" => {
825                spec.precision = Some(parse_u8_value(value, "precision")?);
826            }
827            "color" => {
828                spec.color = Some(FormatColor::parse(value).ok_or_else(|| {
829                    ShapeError::RuntimeError {
830                        message: format!(
831                            "Invalid color value '{}'. Expected: default, red, green, yellow, blue, magenta, cyan, white.",
832                            value
833                        ),
834                        location: None,
835                    }
836                })?);
837            }
838            "border" => {
839                spec.border = parse_on_off(value)?;
840            }
841            other => {
842                return Err(ShapeError::RuntimeError {
843                    message: format!(
844                        "Unknown table format key '{}'. Supported: max_rows, align, precision, color, border.",
845                        other
846                    ),
847                    location: None,
848                });
849            }
850        }
851    }
852
853    Ok(spec)
854}
855
856fn split_top_level_commas(s: &str) -> Result<Vec<&str>> {
857    let mut parts = Vec::new();
858    let mut start = 0usize;
859    let mut paren_depth = 0usize;
860    let mut brace_depth = 0usize;
861    let mut bracket_depth = 0usize;
862    let mut in_string: Option<char> = None;
863    let mut escaped = false;
864
865    for (idx, ch) in s.char_indices() {
866        if let Some(quote) = in_string {
867            if escaped {
868                escaped = false;
869                continue;
870            }
871            if ch == '\\' {
872                escaped = true;
873                continue;
874            }
875            if ch == quote {
876                in_string = None;
877            }
878            continue;
879        }
880
881        match ch {
882            '"' | '\'' => in_string = Some(ch),
883            '(' => paren_depth += 1,
884            ')' => paren_depth = paren_depth.saturating_sub(1),
885            '{' => brace_depth += 1,
886            '}' => brace_depth = brace_depth.saturating_sub(1),
887            '[' => bracket_depth += 1,
888            ']' => bracket_depth = bracket_depth.saturating_sub(1),
889            ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
890                parts.push(&s[start..idx]);
891                start = idx + 1;
892            }
893            _ => {}
894        }
895    }
896
897    if in_string.is_some() || paren_depth != 0 || brace_depth != 0 || bracket_depth != 0 {
898        return Err(ShapeError::RuntimeError {
899            message: "Unclosed delimiter in table format spec".to_string(),
900            location: None,
901        });
902    }
903
904    parts.push(&s[start..]);
905    Ok(parts)
906}
907
908fn parse_u8_value(value: &str, label: &str) -> Result<u8> {
909    value.parse::<u8>().map_err(|_| ShapeError::RuntimeError {
910        message: format!(
911            "Invalid {} '{}'. Expected an integer in range 0..=255.",
912            label, value
913        ),
914        location: None,
915    })
916}
917
918fn parse_usize_value(value: &str, label: &str) -> Result<usize> {
919    value
920        .parse::<usize>()
921        .map_err(|_| ShapeError::RuntimeError {
922            message: format!(
923                "Invalid {} '{}'. Expected a non-negative integer.",
924                label, value
925            ),
926            location: None,
927        })
928}
929
930fn parse_on_off(value: &str) -> Result<bool> {
931    match value {
932        "on" => Ok(true),
933        "off" => Ok(false),
934        _ => Err(ShapeError::RuntimeError {
935            message: format!("Invalid border value '{}'. Expected on or off.", value),
936            location: None,
937        }),
938    }
939}
940
941fn parse_expression_content(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String> {
942    let mut expr = String::new();
943    let mut brace_depth = 1usize;
944
945    while let Some(ch) = chars.next() {
946        match ch {
947            '{' => {
948                brace_depth += 1;
949                expr.push(ch);
950            }
951            '}' => {
952                brace_depth = brace_depth.saturating_sub(1);
953                if brace_depth == 0 {
954                    return if expr.trim().is_empty() {
955                        Err(ShapeError::RuntimeError {
956                            message: "Empty expression in interpolation".to_string(),
957                            location: None,
958                        })
959                    } else {
960                        Ok(expr)
961                    };
962                }
963                expr.push(ch);
964            }
965            '"' => {
966                expr.push(ch);
967                while let Some(c) = chars.next() {
968                    expr.push(c);
969                    if c == '"' {
970                        break;
971                    }
972                    if c == '\\' {
973                        if let Some(escaped) = chars.next() {
974                            expr.push(escaped);
975                        }
976                    }
977                }
978            }
979            '\'' => {
980                expr.push(ch);
981                while let Some(c) = chars.next() {
982                    expr.push(c);
983                    if c == '\'' {
984                        break;
985                    }
986                    if c == '\\' {
987                        if let Some(escaped) = chars.next() {
988                            expr.push(escaped);
989                        }
990                    }
991                }
992            }
993            _ => expr.push(ch),
994        }
995    }
996
997    Err(ShapeError::RuntimeError {
998        message: "Unclosed interpolation (missing })".to_string(),
999        location: None,
1000    })
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005    use super::*;
1006    use crate::ast::InterpolationMode;
1007
1008    #[test]
1009    fn parse_basic_interpolation() {
1010        let parts = parse_interpolation("value: {x}").unwrap();
1011        assert_eq!(parts.len(), 2);
1012        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "value: "));
1013        assert!(matches!(
1014            &parts[1],
1015            InterpolationPart::Expression {
1016                expr,
1017                format_spec: None
1018            } if expr == "x"
1019        ));
1020    }
1021
1022    #[test]
1023    fn parse_format_spec() {
1024        let parts = parse_interpolation("px={price:fixed(2)}").unwrap();
1025        assert!(matches!(
1026            &parts[1],
1027            InterpolationPart::Expression {
1028                expr,
1029                format_spec: Some(spec)
1030            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
1031        ));
1032    }
1033
1034    #[test]
1035    fn parse_legacy_fixed_precision_alias() {
1036        let parts = parse_interpolation("px={price:.2f}").unwrap();
1037        assert!(matches!(
1038            &parts[1],
1039            InterpolationPart::Expression {
1040                expr,
1041                format_spec: Some(spec)
1042            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
1043        ));
1044    }
1045
1046    #[test]
1047    fn parse_table_format_spec() {
1048        let parts = parse_interpolation(
1049            "rows={dt:table(max_rows=5, align=right, precision=2, color=green, border=off)}",
1050        )
1051        .unwrap();
1052
1053        assert!(matches!(
1054            &parts[1],
1055            InterpolationPart::Expression {
1056                expr,
1057                format_spec: Some(InterpolationFormatSpec::Table(TableFormatSpec {
1058                    max_rows: Some(5),
1059                    align: Some(FormatAlignment::Right),
1060                    precision: Some(2),
1061                    color: Some(FormatColor::Green),
1062                    border: false
1063                }))
1064            } if expr == "dt"
1065        ));
1066    }
1067
1068    #[test]
1069    fn parse_table_format_unknown_key_errors() {
1070        let err = parse_interpolation("rows={dt:table(foo=1)}").unwrap_err();
1071        let msg = err.to_string();
1072        assert!(
1073            msg.contains("Unknown table format key"),
1074            "unexpected error: {}",
1075            msg
1076        );
1077    }
1078
1079    #[test]
1080    fn parse_double_colon_is_not_format_spec() {
1081        let parts = parse_interpolation("{Type::Variant}").unwrap();
1082        assert!(matches!(
1083            &parts[0],
1084            InterpolationPart::Expression {
1085                expr,
1086                format_spec: None
1087            } if expr == "Type::Variant"
1088        ));
1089    }
1090
1091    #[test]
1092    fn escaped_braces_do_not_count_as_interpolation() {
1093        assert!(!has_interpolation("Use {{x}} for literal"));
1094        assert!(has_interpolation("Use {x} for value"));
1095    }
1096
1097    #[test]
1098    fn parse_dollar_interpolation() {
1099        let parts = parse_interpolation_with_mode(
1100            "json={\"name\": ${user.name}}",
1101            InterpolationMode::Dollar,
1102        )
1103        .unwrap();
1104        assert_eq!(parts.len(), 3);
1105        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "json={\"name\": "));
1106        assert!(matches!(
1107            &parts[1],
1108            InterpolationPart::Expression {
1109                expr,
1110                format_spec: None
1111            } if expr == "user.name"
1112        ));
1113        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == "}"));
1114    }
1115
1116    #[test]
1117    fn parse_hash_interpolation() {
1118        let parts = parse_interpolation_with_mode("echo #{cmd}", InterpolationMode::Hash).unwrap();
1119        assert_eq!(parts.len(), 2);
1120        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "echo "));
1121        assert!(matches!(
1122            &parts[1],
1123            InterpolationPart::Expression {
1124                expr,
1125                format_spec: None
1126            } if expr == "cmd"
1127        ));
1128    }
1129
1130    #[test]
1131    fn escaped_sigil_opener_is_literal_in_sigil_modes() {
1132        let parts =
1133            parse_interpolation_with_mode("literal $${x}", InterpolationMode::Dollar).unwrap();
1134        assert_eq!(parts.len(), 1);
1135        assert!(matches!(
1136            &parts[0],
1137            InterpolationPart::Literal(s) if s == "literal ${x}"
1138        ));
1139    }
1140
1141    #[test]
1142    fn braces_are_plain_text_in_sigil_mode() {
1143        assert!(!has_interpolation_with_mode(
1144            "{\"a\": 1}",
1145            InterpolationMode::Dollar
1146        ));
1147        assert!(has_interpolation_with_mode(
1148            "${x}",
1149            InterpolationMode::Dollar
1150        ));
1151    }
1152
1153    // ====== Content format spec tests ======
1154
1155    #[test]
1156    fn parse_content_format_spec_bold() {
1157        let spec = parse_content_format_spec("bold").unwrap();
1158        assert!(spec.bold);
1159        assert!(!spec.italic);
1160    }
1161
1162    #[test]
1163    fn parse_content_format_spec_multiple_flags() {
1164        let spec = parse_content_format_spec("bold, italic, underline").unwrap();
1165        assert!(spec.bold);
1166        assert!(spec.italic);
1167        assert!(spec.underline);
1168        assert!(!spec.dim);
1169    }
1170
1171    #[test]
1172    fn parse_content_format_spec_fg_named() {
1173        let spec = parse_content_format_spec("fg(red)").unwrap();
1174        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
1175    }
1176
1177    #[test]
1178    fn parse_content_format_spec_fg_rgb() {
1179        let spec = parse_content_format_spec("fg(rgb(255, 128, 0))").unwrap();
1180        assert_eq!(spec.fg, Some(ColorSpec::Rgb(255, 128, 0)));
1181    }
1182
1183    #[test]
1184    fn parse_content_format_spec_full() {
1185        let spec = parse_content_format_spec(
1186            "fg(green), bg(blue), bold, fixed(2), border(rounded), align(center)",
1187        )
1188        .unwrap();
1189        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Green)));
1190        assert_eq!(spec.bg, Some(ColorSpec::Named(NamedContentColor::Blue)));
1191        assert!(spec.bold);
1192        assert_eq!(spec.fixed_precision, Some(2));
1193        assert_eq!(spec.border, Some(BorderStyleSpec::Rounded));
1194        assert_eq!(spec.align, Some(AlignSpec::Center));
1195    }
1196
1197    #[test]
1198    fn parse_content_format_spec_unknown_key_errors() {
1199        let err = parse_content_format_spec("foo(bar)").unwrap_err();
1200        assert!(err.to_string().contains("Unknown content format key"));
1201    }
1202
1203    #[test]
1204    fn split_content_format_spec_basic() {
1205        let (expr, spec) = split_expression_and_content_format_spec("price:fg(red), bold").unwrap();
1206        assert_eq!(expr, "price");
1207        assert!(matches!(
1208            spec,
1209            Some(InterpolationFormatSpec::ContentStyle(_))
1210        ));
1211        if let Some(InterpolationFormatSpec::ContentStyle(cs)) = spec {
1212            assert_eq!(cs.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
1213            assert!(cs.bold);
1214        }
1215    }
1216
1217    #[test]
1218    fn parse_content_format_spec_chart_type() {
1219        let spec = parse_content_format_spec("chart(bar)").unwrap();
1220        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Bar));
1221    }
1222
1223    #[test]
1224    fn parse_content_format_spec_chart_with_axes() {
1225        let spec = parse_content_format_spec("chart(line), x(month), y(revenue, profit)").unwrap();
1226        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Line));
1227        assert_eq!(spec.x_column, Some("month".to_string()));
1228        assert_eq!(spec.y_columns, vec!["revenue", "profit"]);
1229    }
1230
1231    #[test]
1232    fn parse_content_format_spec_chart_single_y() {
1233        let spec = parse_content_format_spec("chart(scatter), x(date), y(price)").unwrap();
1234        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Scatter));
1235        assert_eq!(spec.x_column, Some("date".to_string()));
1236        assert_eq!(spec.y_columns, vec!["price"]);
1237    }
1238
1239    #[test]
1240    fn parse_content_format_spec_chart_invalid_type() {
1241        let err = parse_content_format_spec("chart(pie)").unwrap_err();
1242        assert!(err.to_string().contains("Unknown chart type"));
1243    }
1244
1245    #[test]
1246    fn split_content_chart_format_spec() {
1247        let (expr, spec) =
1248            split_expression_and_content_format_spec("data:chart(bar), x(month), y(sales)")
1249                .unwrap();
1250        assert_eq!(expr, "data");
1251        if let Some(InterpolationFormatSpec::ContentStyle(cs)) = spec {
1252            assert_eq!(cs.chart_type, Some(ChartTypeSpec::Bar));
1253            assert_eq!(cs.x_column, Some("month".to_string()));
1254            assert_eq!(cs.y_columns, vec!["sales"]);
1255        } else {
1256            panic!("expected ContentStyle");
1257        }
1258    }
1259}