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::content_style::{ContentFormatSpec, parse_content_format_spec};
10use crate::{Result, ShapeError};
11
12/// Horizontal alignment for formatted output.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum FormatAlignment {
15    Left,
16    Center,
17    Right,
18}
19
20impl FormatAlignment {
21    fn parse(s: &str) -> Option<Self> {
22        match s {
23            "left" => Some(Self::Left),
24            "center" => Some(Self::Center),
25            "right" => Some(Self::Right),
26            _ => None,
27        }
28    }
29}
30
31/// Color hint for formatted output.
32///
33/// Renderers may map these hints to ANSI, HTML, or plain output.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum FormatColor {
36    Default,
37    Red,
38    Green,
39    Yellow,
40    Blue,
41    Magenta,
42    Cyan,
43    White,
44}
45
46impl FormatColor {
47    fn parse(s: &str) -> Option<Self> {
48        match s {
49            "default" => Some(Self::Default),
50            "red" => Some(Self::Red),
51            "green" => Some(Self::Green),
52            "yellow" => Some(Self::Yellow),
53            "blue" => Some(Self::Blue),
54            "magenta" => Some(Self::Magenta),
55            "cyan" => Some(Self::Cyan),
56            "white" => Some(Self::White),
57            _ => None,
58        }
59    }
60}
61
62/// Typed table rendering configuration for interpolation.
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct TableFormatSpec {
65    pub max_rows: Option<usize>,
66    pub align: Option<FormatAlignment>,
67    pub precision: Option<u8>,
68    pub color: Option<FormatColor>,
69    pub border: bool,
70}
71
72impl Default for TableFormatSpec {
73    fn default() -> Self {
74        Self {
75            max_rows: None,
76            align: None,
77            precision: None,
78            color: None,
79            border: true,
80        }
81    }
82}
83
84/// Typed format specification for interpolation expressions.
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum InterpolationFormatSpec {
87    /// Fixed-point numeric precision (`fixed(2)`).
88    Fixed { precision: u8 },
89    /// Tabular formatting for `DataTable`-like values (`table(...)`).
90    Table(TableFormatSpec),
91    /// Content-styling specification (R8 W4 W18.4 — supervisor 2026-05-24
92    /// D1 + (a-modified) REVIVE-WITH-SHARED-MODULE). Parsed from f-string
93    /// styling syntax like `{x:bold,red}` / `{x:fg(blue),italic}`.
94    /// The presence of this variant in any interpolation part flips the
95    /// f-string's return type from `string` to `content` per D1's
96    /// syntax-determined rule.
97    ContentStyle(ContentFormatSpec),
98}
99
100/// A parsed segment of an interpolated string.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum InterpolationPart {
103    /// Literal text.
104    Literal(String),
105    /// Expression segment with optional format specifier.
106    Expression {
107        /// Raw Shape expression between `{` and `}`.
108        expr: String,
109        /// Optional typed format spec after top-level `:`.
110        format_spec: Option<InterpolationFormatSpec>,
111    },
112}
113
114/// Parse a formatted string payload into interpolation parts.
115pub fn parse_interpolation(s: &str) -> Result<Vec<InterpolationPart>> {
116    parse_interpolation_with_mode(s, InterpolationMode::Braces)
117}
118
119/// Parse a formatted string payload into interpolation parts using the given mode.
120pub fn parse_interpolation_with_mode(
121    s: &str,
122    mode: InterpolationMode,
123) -> Result<Vec<InterpolationPart>> {
124    let mut parts = Vec::new();
125    let mut current_text = String::new();
126    let mut chars = s.chars().peekable();
127
128    while let Some(ch) = chars.next() {
129        // Backslash-escaped delimiters: `\{` → `{`, `\}` → `}`, `\$` → `$`, `\#` → `#`
130        if ch == '\\'
131            && matches!(
132                chars.peek(),
133                Some(&'{') | Some(&'}') | Some(&'$') | Some(&'#')
134            )
135        {
136            current_text.push(chars.next().unwrap());
137            continue;
138        }
139
140        match mode {
141            InterpolationMode::Braces => match ch {
142                '{' => {
143                    if chars.peek() == Some(&'{') {
144                        chars.next();
145                        current_text.push('{');
146                        continue;
147                    }
148
149                    if !current_text.is_empty() {
150                        parts.push(InterpolationPart::Literal(current_text.clone()));
151                        current_text.clear();
152                    }
153
154                    let raw_expr = parse_expression_content(&mut chars)?;
155                    let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
156                    parts.push(InterpolationPart::Expression { expr, format_spec });
157                }
158                '}' => {
159                    if chars.peek() == Some(&'}') {
160                        chars.next();
161                        current_text.push('}');
162                    } else {
163                        return Err(ShapeError::RuntimeError {
164                            message:
165                                "Unmatched '}' in interpolation string. Use '}}' for a literal '}'"
166                                    .to_string(),
167                            location: None,
168                        });
169                    }
170                }
171                _ => current_text.push(ch),
172            },
173            InterpolationMode::Dollar | InterpolationMode::Hash => {
174                let sigil = mode.sigil().expect("sigil mode must provide sigil");
175                if ch == sigil {
176                    if chars.peek() == Some(&sigil) {
177                        chars.next();
178                        if chars.peek() == Some(&'{') {
179                            chars.next();
180                            current_text.push(sigil);
181                            current_text.push('{');
182                        } else {
183                            current_text.push(sigil);
184                        }
185                        continue;
186                    }
187
188                    if chars.peek() == Some(&'{') {
189                        chars.next();
190                        if !current_text.is_empty() {
191                            parts.push(InterpolationPart::Literal(current_text.clone()));
192                            current_text.clear();
193                        }
194                        let raw_expr = parse_expression_content(&mut chars)?;
195                        let (expr, format_spec) = split_expression_and_format_spec(&raw_expr)?;
196                        parts.push(InterpolationPart::Expression { expr, format_spec });
197                        continue;
198                    }
199                }
200
201                current_text.push(ch);
202            }
203        }
204    }
205
206    if !current_text.is_empty() {
207        parts.push(InterpolationPart::Literal(current_text));
208    }
209
210    Ok(parts)
211}
212
213/// Check whether a string contains at least one interpolation segment.
214pub fn has_interpolation(s: &str) -> bool {
215    has_interpolation_with_mode(s, InterpolationMode::Braces)
216}
217
218/// Check whether a string contains at least one interpolation segment for the mode.
219pub fn has_interpolation_with_mode(s: &str, mode: InterpolationMode) -> bool {
220    let mut chars = s.chars().peekable();
221    while let Some(ch) = chars.next() {
222        // Skip backslash-escaped braces
223        if ch == '\\' && matches!(chars.peek(), Some(&'{') | Some(&'}')) {
224            chars.next();
225            continue;
226        }
227        match mode {
228            InterpolationMode::Braces => {
229                if ch == '{' {
230                    if chars.peek() != Some(&'{') {
231                        return true;
232                    }
233                    chars.next();
234                }
235            }
236            InterpolationMode::Dollar | InterpolationMode::Hash => {
237                let sigil = mode.sigil().expect("sigil mode must provide sigil");
238                if ch == sigil && chars.peek() == Some(&'{') {
239                    return true;
240                }
241            }
242        }
243    }
244    false
245}
246
247/// Split interpolation content `expr[:spec]` at the top-level format separator.
248///
249/// This preserves `::` (enum/type separators) and ignores separators inside
250/// nested delimiters/strings.
251pub fn split_expression_and_format_spec(
252    raw: &str,
253) -> Result<(String, Option<InterpolationFormatSpec>)> {
254    let trimmed = raw.trim();
255    if trimmed.is_empty() {
256        return Err(ShapeError::RuntimeError {
257            message: "Empty expression in interpolation".to_string(),
258            location: None,
259        });
260    }
261
262    let split_at = find_top_level_format_colon(trimmed);
263
264    if let Some(idx) = split_at {
265        let expr = trimmed[..idx].trim();
266        let spec = trimmed[idx + 1..].trim();
267        if expr.is_empty() {
268            return Err(ShapeError::RuntimeError {
269                message: "Missing expression before format spec in interpolation".to_string(),
270                location: None,
271            });
272        }
273        if spec.is_empty() {
274            return Err(ShapeError::RuntimeError {
275                message: "Missing format spec after ':' in interpolation".to_string(),
276                location: None,
277            });
278        }
279        Ok((expr.to_string(), Some(parse_format_spec(spec)?)))
280    } else {
281        Ok((trimmed.to_string(), None))
282    }
283}
284
285/// Find the top-level format-separator `:` in an interpolation expression.
286///
287/// Returns the byte index of the separator if present.
288pub fn find_top_level_format_colon(raw: &str) -> Option<usize> {
289    let bytes = raw.as_bytes();
290    let mut paren_depth = 0usize;
291    let mut brace_depth = 0usize;
292    let mut bracket_depth = 0usize;
293    let mut in_string: Option<char> = None;
294    let mut escaped = false;
295
296    for (idx, ch) in raw.char_indices() {
297        if let Some(quote) = in_string {
298            if escaped {
299                escaped = false;
300                continue;
301            }
302            if ch == '\\' {
303                escaped = true;
304                continue;
305            }
306            if ch == quote {
307                in_string = None;
308            }
309            continue;
310        }
311
312        match ch {
313            '"' | '\'' => in_string = Some(ch),
314            '(' => paren_depth += 1,
315            ')' => paren_depth = paren_depth.saturating_sub(1),
316            '{' => brace_depth += 1,
317            '}' => brace_depth = brace_depth.saturating_sub(1),
318            '[' => bracket_depth += 1,
319            ']' => bracket_depth = bracket_depth.saturating_sub(1),
320            ':' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
321                let prev_is_colon = idx > 0 && bytes[idx - 1] == b':';
322                let next_is_colon = idx + 1 < bytes.len() && bytes[idx + 1] == b':';
323                if !prev_is_colon && !next_is_colon {
324                    return Some(idx);
325                }
326            }
327            _ => {}
328        }
329    }
330
331    None
332}
333
334fn parse_format_spec(raw_spec: &str) -> Result<InterpolationFormatSpec> {
335    let spec = raw_spec.trim();
336
337    // Legacy shorthand kept as a parser alias, but normalized into typed format.
338    if let Some(precision) = parse_legacy_fixed_precision(spec)? {
339        return Ok(InterpolationFormatSpec::Fixed { precision });
340    }
341
342    if let Some(inner) = parse_call_like_spec(spec, "fixed")? {
343        let precision = parse_u8_value(inner.trim(), "fixed precision")?;
344        return Ok(InterpolationFormatSpec::Fixed { precision });
345    }
346
347    if let Some(inner) = parse_call_like_spec(spec, "table")? {
348        return Ok(InterpolationFormatSpec::Table(parse_table_format_spec(
349            inner,
350        )?));
351    }
352
353    // R8 W4 W18.4 (supervisor 2026-05-24 D1): try content-styling syntax
354    // (`bold` / `red` / `fg(blue)` / `bold,red` / etc.). The content-style
355    // parser is permissive about composition (multiple comma-separated
356    // entries) and surfaces a descriptive error if no entry matches. Any
357    // successful parse here triggers the syntax-determined `string` →
358    // `content` flip at the f-string return-type inference site.
359    let content_spec = parse_content_format_spec(spec).map_err(|err| ShapeError::RuntimeError {
360        message: format!(
361            "Unsupported interpolation format spec '{}'. Supported: fixed(N), table(...), content-styling (bold, italic, underline, dim, fg(color), bg(color), border(style), align(side), chart(type)). Inner error: {}",
362            spec, err
363        ),
364        location: None,
365    })?;
366    Ok(InterpolationFormatSpec::ContentStyle(content_spec))
367}
368
369fn parse_legacy_fixed_precision(spec: &str) -> Result<Option<u8>> {
370    if let Some(rest) = spec.strip_prefix('.') {
371        let digits = rest.strip_suffix('f').unwrap_or(rest);
372        if digits.is_empty() {
373            return Err(ShapeError::RuntimeError {
374                message: "Legacy fixed format requires digits after '.'".to_string(),
375                location: None,
376            });
377        }
378        if digits.chars().all(|c| c.is_ascii_digit()) {
379            return Ok(Some(parse_u8_value(digits, "fixed precision")?));
380        }
381    }
382    Ok(None)
383}
384
385fn parse_call_like_spec<'a>(spec: &'a str, name: &str) -> Result<Option<&'a str>> {
386    if !spec.starts_with(name) {
387        return Ok(None);
388    }
389
390    let rest = &spec[name.len()..];
391    if !rest.starts_with('(') || !rest.ends_with(')') {
392        return Err(ShapeError::RuntimeError {
393            message: format!("Format spec '{}' must use call syntax: {}(...)", spec, name),
394            location: None,
395        });
396    }
397
398    Ok(Some(&rest[1..rest.len() - 1]))
399}
400
401fn parse_table_format_spec(inner: &str) -> Result<TableFormatSpec> {
402    let mut spec = TableFormatSpec::default();
403    let trimmed = inner.trim();
404
405    if trimmed.is_empty() {
406        return Ok(spec);
407    }
408
409    for entry in split_top_level_commas(trimmed)? {
410        let entry = entry.trim();
411        if entry.is_empty() {
412            continue;
413        }
414
415        let (key, value) = entry
416            .split_once('=')
417            .ok_or_else(|| ShapeError::RuntimeError {
418                message: format!(
419                    "Invalid table format argument '{}'. Expected key=value pairs.",
420                    entry
421                ),
422                location: None,
423            })?;
424        let key = key.trim();
425        let value = value.trim();
426
427        match key {
428            "max_rows" => {
429                spec.max_rows = Some(parse_usize_value(value, "max_rows")?);
430            }
431            "align" => {
432                spec.align = Some(FormatAlignment::parse(value).ok_or_else(|| {
433                    ShapeError::RuntimeError {
434                        message: format!(
435                            "Invalid align value '{}'. Expected: left, center, right.",
436                            value
437                        ),
438                        location: None,
439                    }
440                })?);
441            }
442            "precision" => {
443                spec.precision = Some(parse_u8_value(value, "precision")?);
444            }
445            "color" => {
446                spec.color = Some(FormatColor::parse(value).ok_or_else(|| {
447                    ShapeError::RuntimeError {
448                        message: format!(
449                            "Invalid color value '{}'. Expected: default, red, green, yellow, blue, magenta, cyan, white.",
450                            value
451                        ),
452                        location: None,
453                    }
454                })?);
455            }
456            "border" => {
457                spec.border = parse_on_off(value)?;
458            }
459            other => {
460                return Err(ShapeError::RuntimeError {
461                    message: format!(
462                        "Unknown table format key '{}'. Supported: max_rows, align, precision, color, border.",
463                        other
464                    ),
465                    location: None,
466                });
467            }
468        }
469    }
470
471    Ok(spec)
472}
473
474fn split_top_level_commas(s: &str) -> Result<Vec<&str>> {
475    let mut parts = Vec::new();
476    let mut start = 0usize;
477    let mut paren_depth = 0usize;
478    let mut brace_depth = 0usize;
479    let mut bracket_depth = 0usize;
480    let mut in_string: Option<char> = None;
481    let mut escaped = false;
482
483    for (idx, ch) in s.char_indices() {
484        if let Some(quote) = in_string {
485            if escaped {
486                escaped = false;
487                continue;
488            }
489            if ch == '\\' {
490                escaped = true;
491                continue;
492            }
493            if ch == quote {
494                in_string = None;
495            }
496            continue;
497        }
498
499        match ch {
500            '"' | '\'' => in_string = Some(ch),
501            '(' => paren_depth += 1,
502            ')' => paren_depth = paren_depth.saturating_sub(1),
503            '{' => brace_depth += 1,
504            '}' => brace_depth = brace_depth.saturating_sub(1),
505            '[' => bracket_depth += 1,
506            ']' => bracket_depth = bracket_depth.saturating_sub(1),
507            ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
508                parts.push(&s[start..idx]);
509                start = idx + 1;
510            }
511            _ => {}
512        }
513    }
514
515    if in_string.is_some() || paren_depth != 0 || brace_depth != 0 || bracket_depth != 0 {
516        return Err(ShapeError::RuntimeError {
517            message: "Unclosed delimiter in table format spec".to_string(),
518            location: None,
519        });
520    }
521
522    parts.push(&s[start..]);
523    Ok(parts)
524}
525
526fn parse_u8_value(value: &str, label: &str) -> Result<u8> {
527    value.parse::<u8>().map_err(|_| ShapeError::RuntimeError {
528        message: format!(
529            "Invalid {} '{}'. Expected an integer in range 0..=255.",
530            label, value
531        ),
532        location: None,
533    })
534}
535
536fn parse_usize_value(value: &str, label: &str) -> Result<usize> {
537    value
538        .parse::<usize>()
539        .map_err(|_| ShapeError::RuntimeError {
540            message: format!(
541                "Invalid {} '{}'. Expected a non-negative integer.",
542                label, value
543            ),
544            location: None,
545        })
546}
547
548fn parse_on_off(value: &str) -> Result<bool> {
549    match value {
550        "on" => Ok(true),
551        "off" => Ok(false),
552        _ => Err(ShapeError::RuntimeError {
553            message: format!("Invalid border value '{}'. Expected on or off.", value),
554            location: None,
555        }),
556    }
557}
558
559fn parse_expression_content(chars: &mut std::iter::Peekable<std::str::Chars>) -> Result<String> {
560    let mut expr = String::new();
561    let mut brace_depth = 1usize;
562
563    while let Some(ch) = chars.next() {
564        match ch {
565            '{' => {
566                brace_depth += 1;
567                expr.push(ch);
568            }
569            '}' => {
570                brace_depth = brace_depth.saturating_sub(1);
571                if brace_depth == 0 {
572                    return if expr.trim().is_empty() {
573                        Err(ShapeError::RuntimeError {
574                            message: "Empty expression in interpolation".to_string(),
575                            location: None,
576                        })
577                    } else {
578                        Ok(expr)
579                    };
580                }
581                expr.push(ch);
582            }
583            '"' => {
584                expr.push(ch);
585                while let Some(c) = chars.next() {
586                    expr.push(c);
587                    if c == '"' {
588                        break;
589                    }
590                    if c == '\\' {
591                        if let Some(escaped) = chars.next() {
592                            expr.push(escaped);
593                        }
594                    }
595                }
596            }
597            '\'' => {
598                expr.push(ch);
599                while let Some(c) = chars.next() {
600                    expr.push(c);
601                    if c == '\'' {
602                        break;
603                    }
604                    if c == '\\' {
605                        if let Some(escaped) = chars.next() {
606                            expr.push(escaped);
607                        }
608                    }
609                }
610            }
611            _ => expr.push(ch),
612        }
613    }
614
615    Err(ShapeError::RuntimeError {
616        message: "Unclosed interpolation (missing })".to_string(),
617        location: None,
618    })
619}
620
621#[cfg(test)]
622mod tests {
623    use super::*;
624    use crate::ast::InterpolationMode;
625
626    #[test]
627    fn parse_basic_interpolation() {
628        let parts = parse_interpolation("value: {x}").unwrap();
629        assert_eq!(parts.len(), 2);
630        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "value: "));
631        assert!(matches!(
632            &parts[1],
633            InterpolationPart::Expression {
634                expr,
635                format_spec: None
636            } if expr == "x"
637        ));
638    }
639
640    #[test]
641    fn parse_format_spec() {
642        let parts = parse_interpolation("px={price:fixed(2)}").unwrap();
643        assert!(matches!(
644            &parts[1],
645            InterpolationPart::Expression {
646                expr,
647                format_spec: Some(spec)
648            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
649        ));
650    }
651
652    #[test]
653    fn parse_legacy_fixed_precision_alias() {
654        let parts = parse_interpolation("px={price:.2f}").unwrap();
655        assert!(matches!(
656            &parts[1],
657            InterpolationPart::Expression {
658                expr,
659                format_spec: Some(spec)
660            } if expr == "price" && *spec == InterpolationFormatSpec::Fixed { precision: 2 }
661        ));
662    }
663
664    #[test]
665    fn parse_table_format_spec() {
666        let parts = parse_interpolation(
667            "rows={dt:table(max_rows=5, align=right, precision=2, color=green, border=off)}",
668        )
669        .unwrap();
670
671        assert!(matches!(
672            &parts[1],
673            InterpolationPart::Expression {
674                expr,
675                format_spec: Some(InterpolationFormatSpec::Table(TableFormatSpec {
676                    max_rows: Some(5),
677                    align: Some(FormatAlignment::Right),
678                    precision: Some(2),
679                    color: Some(FormatColor::Green),
680                    border: false
681                }))
682            } if expr == "dt"
683        ));
684    }
685
686    #[test]
687    fn parse_table_format_unknown_key_errors() {
688        let err = parse_interpolation("rows={dt:table(foo=1)}").unwrap_err();
689        let msg = err.to_string();
690        assert!(
691            msg.contains("Unknown table format key"),
692            "unexpected error: {}",
693            msg
694        );
695    }
696
697    #[test]
698    fn parse_double_colon_is_not_format_spec() {
699        let parts = parse_interpolation("{Type::Variant}").unwrap();
700        assert!(matches!(
701            &parts[0],
702            InterpolationPart::Expression {
703                expr,
704                format_spec: None
705            } if expr == "Type::Variant"
706        ));
707    }
708
709    #[test]
710    fn escaped_braces_do_not_count_as_interpolation() {
711        assert!(!has_interpolation("Use {{x}} for literal"));
712        assert!(has_interpolation("Use {x} for value"));
713    }
714
715    #[test]
716    fn parse_dollar_interpolation() {
717        let parts = parse_interpolation_with_mode(
718            "json={\"name\": ${user.name}}",
719            InterpolationMode::Dollar,
720        )
721        .unwrap();
722        assert_eq!(parts.len(), 3);
723        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "json={\"name\": "));
724        assert!(matches!(
725            &parts[1],
726            InterpolationPart::Expression {
727                expr,
728                format_spec: None
729            } if expr == "user.name"
730        ));
731        assert!(matches!(&parts[2], InterpolationPart::Literal(s) if s == "}"));
732    }
733
734    #[test]
735    fn parse_hash_interpolation() {
736        let parts = parse_interpolation_with_mode("echo #{cmd}", InterpolationMode::Hash).unwrap();
737        assert_eq!(parts.len(), 2);
738        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "echo "));
739        assert!(matches!(
740            &parts[1],
741            InterpolationPart::Expression {
742                expr,
743                format_spec: None
744            } if expr == "cmd"
745        ));
746    }
747
748    #[test]
749    fn escaped_sigil_opener_is_literal_in_sigil_modes() {
750        let parts =
751            parse_interpolation_with_mode("literal $${x}", InterpolationMode::Dollar).unwrap();
752        assert_eq!(parts.len(), 1);
753        assert!(matches!(
754            &parts[0],
755            InterpolationPart::Literal(s) if s == "literal ${x}"
756        ));
757    }
758
759    #[test]
760    fn braces_are_plain_text_in_sigil_mode() {
761        assert!(!has_interpolation_with_mode(
762            "{\"a\": 1}",
763            InterpolationMode::Dollar
764        ));
765        assert!(has_interpolation_with_mode(
766            "${x}",
767            InterpolationMode::Dollar
768        ));
769    }
770
771    // --- LOW-2: backslash-escaped braces in interpolation ---
772
773    #[test]
774    fn backslash_escaped_braces_produce_literal_text() {
775        // `\{` and `\}` should produce literal `{` and `}`, not interpolation.
776        let parts = parse_interpolation("hello \\{world\\}").unwrap();
777        assert_eq!(parts.len(), 1);
778        assert!(matches!(
779            &parts[0],
780            InterpolationPart::Literal(s) if s == "hello {world}"
781        ));
782    }
783
784    #[test]
785    fn backslash_escaped_braces_not_counted_as_interpolation() {
786        assert!(!has_interpolation("hello \\{world\\}"));
787        assert!(has_interpolation("hello {world}"));
788    }
789
790    #[test]
791    fn backslash_escaped_braces_mixed_with_real_interpolation() {
792        // `\{literal\} and {expr}` → Literal("{literal} and "), Expression("expr")
793        let parts = parse_interpolation("\\{literal\\} and {expr}").unwrap();
794        assert_eq!(parts.len(), 2);
795        assert!(matches!(
796            &parts[0],
797            InterpolationPart::Literal(s) if s == "{literal} and "
798        ));
799        assert!(matches!(
800            &parts[1],
801            InterpolationPart::Expression { expr, .. } if expr == "expr"
802        ));
803    }
804
805    // --- R8 W4 W18.4: content-styling f-string syntax ---
806
807    #[test]
808    fn parse_content_style_bold() {
809        use crate::content_style::ContentFormatSpec;
810        let parts = parse_interpolation("{x:bold}").unwrap();
811        assert_eq!(parts.len(), 1);
812        match &parts[0] {
813            InterpolationPart::Expression {
814                expr,
815                format_spec: Some(InterpolationFormatSpec::ContentStyle(spec)),
816            } => {
817                assert_eq!(expr, "x");
818                let expected = ContentFormatSpec {
819                    bold: true,
820                    ..Default::default()
821                };
822                assert_eq!(spec, &expected);
823            }
824            other => panic!("expected ContentStyle bold, got {:?}", other),
825        }
826    }
827
828    #[test]
829    fn parse_content_style_bold_red_canonical() {
830        use crate::content_style::{ColorSpec, NamedContentColor};
831        // Canonical W18.4 example from supervisor: `{x:bold,red}` parses to
832        // ContentStyle with bold=true and fg=Named(Red).
833        let parts = parse_interpolation("hello {name:bold,red}").unwrap();
834        assert_eq!(parts.len(), 2);
835        assert!(matches!(&parts[0], InterpolationPart::Literal(s) if s == "hello "));
836        match &parts[1] {
837            InterpolationPart::Expression {
838                expr,
839                format_spec: Some(InterpolationFormatSpec::ContentStyle(spec)),
840            } => {
841                assert_eq!(expr, "name");
842                assert!(spec.bold);
843                assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
844            }
845            other => panic!("expected ContentStyle, got {:?}", other),
846        }
847    }
848
849    #[test]
850    fn parse_content_style_fg_call() {
851        use crate::content_style::{ColorSpec, NamedContentColor};
852        let parts = parse_interpolation("{x:fg(green)}").unwrap();
853        match &parts[0] {
854            InterpolationPart::Expression {
855                format_spec: Some(InterpolationFormatSpec::ContentStyle(spec)),
856                ..
857            } => {
858                assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Green)));
859            }
860            other => panic!("expected ContentStyle, got {:?}", other),
861        }
862    }
863
864    #[test]
865    fn fixed_and_table_still_parse_separately_from_content_style() {
866        // Regression: fixed(N) must not be reinterpreted as content-style.
867        let parts = parse_interpolation("{x:fixed(2)}").unwrap();
868        match &parts[0] {
869            InterpolationPart::Expression {
870                format_spec: Some(InterpolationFormatSpec::Fixed { precision }),
871                ..
872            } => {
873                assert_eq!(*precision, 2);
874            }
875            other => panic!("expected Fixed, got {:?}", other),
876        }
877
878        let parts = parse_interpolation("{x:table()}").unwrap();
879        assert!(matches!(
880            &parts[0],
881            InterpolationPart::Expression {
882                format_spec: Some(InterpolationFormatSpec::Table(_)),
883                ..
884            }
885        ));
886    }
887}