Skip to main content

shape_ast/
content_style.rs

1//! Content styling spec types — shared module per supervisor 2026-05-24
2//! disposition (a-modified) REVIVE-WITH-SHARED-MODULE.
3//!
4//! This module hosts the f-string / builder-API content styling spec types
5//! revived from pre-W18.3 (`git show 19de5ef2^:crates/shape-ast/src/
6//! interpolation.rs`). Both W18.4 (f-string styling parser/lowering) and
7//! W18.5 (`Content.table` / `Content.code` / `Content.kv` builder API)
8//! consume these types from this single shared module — no parallel
9//! implementations per CLAUDE.md §Parallel-implementation.
10//!
11//! Module placement rationale: the supervisor's preferred location was
12//! `crates/shape-value/src/content_style.rs` (next to `ContentNode`). The
13//! dependency graph forbids that placement: `shape-ast` cannot import from
14//! `shape-value` (shape-value depends on shape-ast, not the reverse), and
15//! the parsers MUST live in shape-ast because the f-string interpolation
16//! parser produces typed `InterpolationFormatSpec::ContentStyle(spec)`
17//! parts during AST parsing. Placing the types in shape-ast satisfies the
18//! "one source of truth" constraint while preserving the workspace
19//! dependency invariant. shape-vm's compiler lowering and
20//! shape-runtime's W18.5 builders both import from `shape_ast::
21//! content_style::*`.
22//!
23//! The spec types here are SYNTACTIC descriptors — they describe what the
24//! user wrote in `f"{x:bold,red}"` or `Content.table(...).border(rounded)`.
25//! They are NOT runtime `ContentNode`/`Style`/`Color` types from
26//! `shape_value::content`. Conversion happens at the lowering boundary.
27
28use crate::{Result, ShapeError};
29
30// =============================================================================
31// SPEC TYPES (revived from 19de5ef2^ — W18.3 deletion source)
32// =============================================================================
33
34/// Content-string format specification for rich terminal/HTML output.
35///
36/// Parsed from `:bold,red` / `:fg(green),bg(blue)` / `:border:rounded` style
37/// syntax in f-string interpolations, and constructed by builder methods
38/// (`Content.table(...).border(rounded)`).
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ContentFormatSpec {
41    pub fg: Option<ColorSpec>,
42    pub bg: Option<ColorSpec>,
43    pub bold: bool,
44    pub italic: bool,
45    pub underline: bool,
46    pub dim: bool,
47    pub fixed_precision: Option<u8>,
48    pub border: Option<BorderStyleSpec>,
49    pub max_rows: Option<usize>,
50    pub align: Option<AlignSpec>,
51    /// Chart type hint: render the value as a chart instead of text.
52    pub chart_type: Option<ChartTypeSpec>,
53    /// Column name to use as x-axis data.
54    pub x_column: Option<String>,
55    /// Column names to use as y-axis series.
56    pub y_columns: Vec<String>,
57}
58
59impl Default for ContentFormatSpec {
60    fn default() -> Self {
61        Self {
62            fg: None,
63            bg: None,
64            bold: false,
65            italic: false,
66            underline: false,
67            dim: false,
68            fixed_precision: None,
69            border: None,
70            max_rows: None,
71            align: None,
72            chart_type: None,
73            x_column: None,
74            y_columns: vec![],
75        }
76    }
77}
78
79/// Color specification for content strings.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum ColorSpec {
82    Named(NamedContentColor),
83    Rgb(u8, u8, u8),
84}
85
86/// Named colors for content strings.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub enum NamedContentColor {
89    Red,
90    Green,
91    Blue,
92    Yellow,
93    Magenta,
94    Cyan,
95    White,
96    Default,
97}
98
99/// Border style for content-string table rendering.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum BorderStyleSpec {
102    Rounded,
103    Sharp,
104    Heavy,
105    Double,
106    Minimal,
107    None,
108}
109
110/// Alignment for content-string rendering.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112pub enum AlignSpec {
113    Left,
114    Center,
115    Right,
116}
117
118/// Chart type hint for content format spec.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum ChartTypeSpec {
121    Line,
122    Bar,
123    Scatter,
124    Area,
125    Histogram,
126}
127
128// =============================================================================
129// PARSERS (revived from 19de5ef2^)
130// =============================================================================
131
132/// Parse a content-string format spec like `"fg(red), bold, fixed(2)"`.
133///
134/// Used by both the f-string interpolation parser (when a `:spec` part is
135/// recognised as content-styling syntax) and any builder-API path that
136/// accepts a stringly-typed style descriptor.
137pub fn parse_content_format_spec(raw_spec: &str) -> Result<ContentFormatSpec> {
138    let mut spec = ContentFormatSpec::default();
139    let trimmed = raw_spec.trim();
140    if trimmed.is_empty() {
141        return Ok(spec);
142    }
143
144    for entry in split_top_level_commas(trimmed)? {
145        let entry = entry.trim();
146        if entry.is_empty() {
147            continue;
148        }
149
150        // Boolean flags (no parens)
151        match entry {
152            "bold" => {
153                spec.bold = true;
154                continue;
155            }
156            "italic" => {
157                spec.italic = true;
158                continue;
159            }
160            "underline" => {
161                spec.underline = true;
162                continue;
163            }
164            "dim" => {
165                spec.dim = true;
166                continue;
167            }
168            _ => {}
169        }
170
171        // Named-color shorthand: `red` / `green` / ... at top level → fg(named)
172        if let Ok(color) = parse_color_spec(entry) {
173            spec.fg = Some(color);
174            continue;
175        }
176
177        // Call-like specs: fg(...), bg(...), fixed(...), border(...), max_rows(...), align(...)
178        if let Some(idx) = entry.find('(') {
179            if !entry.ends_with(')') {
180                return Err(ShapeError::RuntimeError {
181                    message: format!("Unclosed parenthesis in content format spec '{}'", entry),
182                    location: None,
183                });
184            }
185            let key = entry[..idx].trim();
186            let inner = entry[idx + 1..entry.len() - 1].trim();
187            match key {
188                "fg" => {
189                    spec.fg = Some(parse_color_spec(inner)?);
190                }
191                "bg" => {
192                    spec.bg = Some(parse_color_spec(inner)?);
193                }
194                "fixed" => {
195                    spec.fixed_precision = Some(parse_u8_value(inner, "fixed precision")?);
196                }
197                "border" => {
198                    spec.border = Some(parse_border_style_spec(inner)?);
199                }
200                "max_rows" => {
201                    spec.max_rows = Some(parse_usize_value(inner, "max_rows")?);
202                }
203                "align" => {
204                    spec.align = Some(parse_align_spec(inner)?);
205                }
206                "chart" => {
207                    spec.chart_type = Some(parse_chart_type_spec(inner)?);
208                }
209                "x" => {
210                    spec.x_column = Some(inner.to_string());
211                }
212                "y" => {
213                    // y(col) or y(col1, col2, ...)
214                    spec.y_columns = inner
215                        .split(',')
216                        .map(|s| s.trim().to_string())
217                        .filter(|s| !s.is_empty())
218                        .collect();
219                }
220                other => {
221                    return Err(ShapeError::RuntimeError {
222                        message: format!(
223                            "Unknown content format key '{}'. Supported: fg, bg, bold, italic, underline, dim, fixed, border, max_rows, align, chart, x, y.",
224                            other
225                        ),
226                        location: None,
227                    });
228                }
229            }
230            continue;
231        }
232
233        return Err(ShapeError::RuntimeError {
234            message: format!(
235                "Unknown content format entry '{}'. Expected a flag (bold, italic, ...) or key(value).",
236                entry
237            ),
238            location: None,
239        });
240    }
241
242    Ok(spec)
243}
244
245pub fn parse_color_spec(s: &str) -> Result<ColorSpec> {
246    let s = s.trim();
247    // Try RGB: rgb(r, g, b)
248    if s.starts_with("rgb(") && s.ends_with(')') {
249        let inner = &s[4..s.len() - 1];
250        let parts: Vec<&str> = inner.split(',').map(|p| p.trim()).collect();
251        if parts.len() != 3 {
252            return Err(ShapeError::RuntimeError {
253                message: format!("rgb() expects 3 values, got {}", parts.len()),
254                location: None,
255            });
256        }
257        let r = parse_u8_value(parts[0], "red")?;
258        let g = parse_u8_value(parts[1], "green")?;
259        let b = parse_u8_value(parts[2], "blue")?;
260        return Ok(ColorSpec::Rgb(r, g, b));
261    }
262    // Named color
263    match s {
264        "red" => Ok(ColorSpec::Named(NamedContentColor::Red)),
265        "green" => Ok(ColorSpec::Named(NamedContentColor::Green)),
266        "blue" => Ok(ColorSpec::Named(NamedContentColor::Blue)),
267        "yellow" => Ok(ColorSpec::Named(NamedContentColor::Yellow)),
268        "magenta" => Ok(ColorSpec::Named(NamedContentColor::Magenta)),
269        "cyan" => Ok(ColorSpec::Named(NamedContentColor::Cyan)),
270        "white" => Ok(ColorSpec::Named(NamedContentColor::White)),
271        "default" => Ok(ColorSpec::Named(NamedContentColor::Default)),
272        _ => Err(ShapeError::RuntimeError {
273            message: format!(
274                "Unknown color '{}'. Expected: red, green, blue, yellow, magenta, cyan, white, default, or rgb(r,g,b).",
275                s
276            ),
277            location: None,
278        }),
279    }
280}
281
282pub fn parse_border_style_spec(s: &str) -> Result<BorderStyleSpec> {
283    match s.trim() {
284        "rounded" => Ok(BorderStyleSpec::Rounded),
285        "sharp" => Ok(BorderStyleSpec::Sharp),
286        "heavy" => Ok(BorderStyleSpec::Heavy),
287        "double" => Ok(BorderStyleSpec::Double),
288        "minimal" => Ok(BorderStyleSpec::Minimal),
289        "none" => Ok(BorderStyleSpec::None),
290        _ => Err(ShapeError::RuntimeError {
291            message: format!(
292                "Unknown border style '{}'. Expected: rounded, sharp, heavy, double, minimal, none.",
293                s
294            ),
295            location: None,
296        }),
297    }
298}
299
300pub fn parse_align_spec(s: &str) -> Result<AlignSpec> {
301    match s.trim() {
302        "left" => Ok(AlignSpec::Left),
303        "center" => Ok(AlignSpec::Center),
304        "right" => Ok(AlignSpec::Right),
305        _ => Err(ShapeError::RuntimeError {
306            message: format!(
307                "Unknown align value '{}'. Expected: left, center, right.",
308                s
309            ),
310            location: None,
311        }),
312    }
313}
314
315pub fn parse_chart_type_spec(s: &str) -> Result<ChartTypeSpec> {
316    match s.trim().to_lowercase().as_str() {
317        "line" => Ok(ChartTypeSpec::Line),
318        "bar" => Ok(ChartTypeSpec::Bar),
319        "scatter" => Ok(ChartTypeSpec::Scatter),
320        "area" => Ok(ChartTypeSpec::Area),
321        "histogram" => Ok(ChartTypeSpec::Histogram),
322        _ => Err(ShapeError::RuntimeError {
323            message: format!(
324                "Unknown chart type '{}'. Expected: line, bar, scatter, area, histogram.",
325                s
326            ),
327            location: None,
328        }),
329    }
330}
331
332// =============================================================================
333// SHARED PARSE HELPERS (used by parse_content_format_spec)
334// =============================================================================
335
336fn split_top_level_commas(s: &str) -> Result<Vec<&str>> {
337    let mut parts = Vec::new();
338    let mut start = 0usize;
339    let mut paren_depth = 0usize;
340    let mut brace_depth = 0usize;
341    let mut bracket_depth = 0usize;
342    let mut in_string: Option<char> = None;
343    let mut escaped = false;
344
345    for (idx, ch) in s.char_indices() {
346        if let Some(quote) = in_string {
347            if escaped {
348                escaped = false;
349                continue;
350            }
351            if ch == '\\' {
352                escaped = true;
353                continue;
354            }
355            if ch == quote {
356                in_string = None;
357            }
358            continue;
359        }
360
361        match ch {
362            '"' | '\'' => in_string = Some(ch),
363            '(' => paren_depth += 1,
364            ')' => paren_depth = paren_depth.saturating_sub(1),
365            '{' => brace_depth += 1,
366            '}' => brace_depth = brace_depth.saturating_sub(1),
367            '[' => bracket_depth += 1,
368            ']' => bracket_depth = bracket_depth.saturating_sub(1),
369            ',' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => {
370                parts.push(&s[start..idx]);
371                start = idx + 1;
372            }
373            _ => {}
374        }
375    }
376
377    if in_string.is_some() || paren_depth != 0 || brace_depth != 0 || bracket_depth != 0 {
378        return Err(ShapeError::RuntimeError {
379            message: "Unclosed delimiter in content format spec".to_string(),
380            location: None,
381        });
382    }
383
384    parts.push(&s[start..]);
385    Ok(parts)
386}
387
388fn parse_u8_value(value: &str, label: &str) -> Result<u8> {
389    value.parse::<u8>().map_err(|_| ShapeError::RuntimeError {
390        message: format!(
391            "Invalid {} '{}'. Expected an integer in range 0..=255.",
392            label, value
393        ),
394        location: None,
395    })
396}
397
398fn parse_usize_value(value: &str, label: &str) -> Result<usize> {
399    value
400        .parse::<usize>()
401        .map_err(|_| ShapeError::RuntimeError {
402            message: format!(
403                "Invalid {} '{}'. Expected a non-negative integer.",
404                label, value
405            ),
406            location: None,
407        })
408}
409
410// =============================================================================
411// TESTS
412// =============================================================================
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417
418    #[test]
419    fn parse_content_format_spec_bold() {
420        let spec = parse_content_format_spec("bold").unwrap();
421        assert!(spec.bold);
422        assert!(!spec.italic);
423    }
424
425    #[test]
426    fn parse_content_format_spec_multiple_flags() {
427        let spec = parse_content_format_spec("bold, italic, underline").unwrap();
428        assert!(spec.bold);
429        assert!(spec.italic);
430        assert!(spec.underline);
431        assert!(!spec.dim);
432    }
433
434    #[test]
435    fn parse_content_format_spec_fg_named() {
436        let spec = parse_content_format_spec("fg(red)").unwrap();
437        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
438    }
439
440    #[test]
441    fn parse_content_format_spec_fg_rgb() {
442        let spec = parse_content_format_spec("fg(rgb(255, 128, 0))").unwrap();
443        assert_eq!(spec.fg, Some(ColorSpec::Rgb(255, 128, 0)));
444    }
445
446    #[test]
447    fn parse_content_format_spec_full() {
448        let spec = parse_content_format_spec(
449            "fg(green), bg(blue), bold, fixed(2), border(rounded), align(center)",
450        )
451        .unwrap();
452        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Green)));
453        assert_eq!(spec.bg, Some(ColorSpec::Named(NamedContentColor::Blue)));
454        assert!(spec.bold);
455        assert_eq!(spec.fixed_precision, Some(2));
456        assert_eq!(spec.border, Some(BorderStyleSpec::Rounded));
457        assert_eq!(spec.align, Some(AlignSpec::Center));
458    }
459
460    #[test]
461    fn parse_content_format_spec_unknown_key_errors() {
462        let err = parse_content_format_spec("foo(bar)").unwrap_err();
463        assert!(err.to_string().contains("Unknown content format key"));
464    }
465
466    #[test]
467    fn parse_content_format_spec_chart_type() {
468        let spec = parse_content_format_spec("chart(bar)").unwrap();
469        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Bar));
470    }
471
472    #[test]
473    fn parse_content_format_spec_chart_with_axes() {
474        let spec = parse_content_format_spec("chart(line), x(month), y(revenue, profit)").unwrap();
475        assert_eq!(spec.chart_type, Some(ChartTypeSpec::Line));
476        assert_eq!(spec.x_column, Some("month".to_string()));
477        assert_eq!(spec.y_columns, vec!["revenue", "profit"]);
478    }
479
480    #[test]
481    fn parse_content_format_spec_chart_invalid_type() {
482        let err = parse_content_format_spec("chart(pie)").unwrap_err();
483        assert!(err.to_string().contains("Unknown chart type"));
484    }
485
486    #[test]
487    fn parse_color_spec_named() {
488        assert_eq!(
489            parse_color_spec("red").unwrap(),
490            ColorSpec::Named(NamedContentColor::Red)
491        );
492        assert_eq!(
493            parse_color_spec("default").unwrap(),
494            ColorSpec::Named(NamedContentColor::Default)
495        );
496    }
497
498    #[test]
499    fn parse_color_spec_rgb() {
500        assert_eq!(
501            parse_color_spec("rgb(10, 20, 30)").unwrap(),
502            ColorSpec::Rgb(10, 20, 30)
503        );
504    }
505
506    #[test]
507    fn parse_color_spec_invalid() {
508        assert!(parse_color_spec("octarine").is_err());
509        assert!(parse_color_spec("rgb(1, 2)").is_err());
510        assert!(parse_color_spec("rgb(300, 0, 0)").is_err());
511    }
512
513    #[test]
514    fn parse_border_style_all() {
515        assert_eq!(parse_border_style_spec("rounded").unwrap(), BorderStyleSpec::Rounded);
516        assert_eq!(parse_border_style_spec("sharp").unwrap(), BorderStyleSpec::Sharp);
517        assert_eq!(parse_border_style_spec("heavy").unwrap(), BorderStyleSpec::Heavy);
518        assert_eq!(parse_border_style_spec("double").unwrap(), BorderStyleSpec::Double);
519        assert_eq!(parse_border_style_spec("minimal").unwrap(), BorderStyleSpec::Minimal);
520        assert_eq!(parse_border_style_spec("none").unwrap(), BorderStyleSpec::None);
521        assert!(parse_border_style_spec("triple").is_err());
522    }
523
524    #[test]
525    fn parse_align_all() {
526        assert_eq!(parse_align_spec("left").unwrap(), AlignSpec::Left);
527        assert_eq!(parse_align_spec("center").unwrap(), AlignSpec::Center);
528        assert_eq!(parse_align_spec("right").unwrap(), AlignSpec::Right);
529        assert!(parse_align_spec("justify").is_err());
530    }
531
532    #[test]
533    fn parse_chart_type_all() {
534        assert_eq!(parse_chart_type_spec("line").unwrap(), ChartTypeSpec::Line);
535        assert_eq!(parse_chart_type_spec("bar").unwrap(), ChartTypeSpec::Bar);
536        assert_eq!(parse_chart_type_spec("scatter").unwrap(), ChartTypeSpec::Scatter);
537        assert_eq!(parse_chart_type_spec("area").unwrap(), ChartTypeSpec::Area);
538        assert_eq!(parse_chart_type_spec("histogram").unwrap(), ChartTypeSpec::Histogram);
539        assert!(parse_chart_type_spec("pie").is_err());
540    }
541
542    #[test]
543    fn parse_content_format_spec_top_level_color_shorthand() {
544        // `{x:red}` → ColorSpec::Named(Red) as fg
545        let spec = parse_content_format_spec("red").unwrap();
546        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
547    }
548
549    #[test]
550    fn parse_content_format_spec_bold_red_shorthand() {
551        // `{x:bold,red}` → bold + fg(red) — the canonical W18.4 example
552        let spec = parse_content_format_spec("bold, red").unwrap();
553        assert!(spec.bold);
554        assert_eq!(spec.fg, Some(ColorSpec::Named(NamedContentColor::Red)));
555    }
556}