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 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
286pub 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
374pub fn has_interpolation(s: &str) -> bool {
376 has_interpolation_with_mode(s, InterpolationMode::Braces)
377}
378
379pub 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
403pub 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
441pub 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 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
518pub 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 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 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 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 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 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
708pub 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 #[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}