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