1use crate::ast::InterpolationMode;
9use crate::content_style::{ContentFormatSpec, parse_content_format_spec};
10use crate::{Result, ShapeError};
11
12#[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#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
86pub enum InterpolationFormatSpec {
87 Fixed { precision: u8 },
89 Table(TableFormatSpec),
91 ContentStyle(ContentFormatSpec),
98}
99
100#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum InterpolationPart {
103 Literal(String),
105 Expression {
107 expr: String,
109 format_spec: Option<InterpolationFormatSpec>,
111 },
112}
113
114pub fn parse_interpolation(s: &str) -> Result<Vec<InterpolationPart>> {
116 parse_interpolation_with_mode(s, InterpolationMode::Braces)
117}
118
119pub 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 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
213pub fn has_interpolation(s: &str) -> bool {
215 has_interpolation_with_mode(s, InterpolationMode::Braces)
216}
217
218pub 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 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
247pub 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
285pub 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 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 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 #[test]
774 fn backslash_escaped_braces_produce_literal_text() {
775 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 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 #[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 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 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}