1use std::str::Chars;
31
32use super::binder::{BakedBody, LayoutMode, RenderStep};
33use super::color::ColorSpec;
34use super::readout::{Lod, OptionValue, ReadoutOptions};
35use super::registry::Registry;
36
37pub fn bake(body: &str) -> Result<(BakedBody, Vec<String>), String> {
43 let mut lex = Lexer::new(body);
44 let mut steps: Vec<RenderStep> = Vec::new();
45 let warnings: Vec<String> = Vec::new();
46 let mut first_item = true;
47
48 while let Some(token) = lex.next_token()? {
49 if let Token::ColorDirective(spec) = token {
53 steps.push(RenderStep::ColorDirective(spec));
54 continue;
55 }
56 if !first_item {
60 steps.push(RenderStep::Literal(" ".to_string()));
61 }
62 first_item = false;
63
64 match token {
65 Token::ColorDirective(_) => unreachable!(),
66 Token::Quoted(s) => {
67 steps.push(RenderStep::Literal(s));
68 }
69 Token::Punct(c) => {
70 steps.push(RenderStep::Literal(c.to_string()));
71 }
72 Token::Ident(name) => {
73 let mut options = ReadoutOptions::new();
77 let mut lod = Lod::default();
78 let mut layout = LayoutMode::Auto;
79 let mut color: Option<ColorSpec> = None;
80 let mut primary_arg: Option<String> = None;
81
82 if let Some(arg) = lex.consume_attached_colon_arg() {
87 primary_arg = Some(arg);
88 }
89
90 while lex.peek_option().is_some() {
91 let (key, value) = lex.consume_option_pair()?;
92 apply_option(
93 &key,
94 value,
95 &mut lod,
96 &mut layout,
97 &mut color,
98 &mut options,
99 &mut primary_arg,
100 )?;
101 }
102
103 let readout = Registry::lookup(&name).ok_or_else(|| {
104 let known = Registry::all_names().join(", ");
105 format!("readouts: unknown readout name '{name}'. Known: {known}")
106 })?;
107 if let Some(arg) = primary_arg {
115 options.set("pattern", OptionValue::Str(arg));
116 }
117 steps.push(RenderStep::Render {
118 readout,
119 lod,
120 layout,
121 options,
122 color,
123 });
124 }
125 }
126 }
127
128 Ok((BakedBody::from_steps(steps), warnings))
129}
130
131fn apply_option(
132 key: &str,
133 value: OptionValue,
134 lod: &mut Lod,
135 layout: &mut LayoutMode,
136 color: &mut Option<ColorSpec>,
137 options: &mut ReadoutOptions,
138 _primary_arg: &mut Option<String>,
139) -> Result<(), String> {
140 match key {
141 "lod" => {
142 *lod = parse_lod(&value).map_err(|e| format!("readouts: lod=…: {e}"))?;
143 }
144 "layout" => {
145 *layout = parse_layout(&value).map_err(|e| format!("readouts: layout=…: {e}"))?;
146 }
147 "color" | "style" => {
148 let token = match value {
156 OptionValue::Str(s) => s,
157 other => {
158 return Err(format!(
159 "readouts: {key}= must be a string token (RED, BRIGHT_GREEN, \
160 #aabbcc, ERROR, …); got {other:?}"
161 ));
162 }
163 };
164 *color = Some(ColorSpec::parse(&token).ok_or_else(|| {
165 format!(
166 "readouts: {key}=…: unknown colour / style '{token}' \
167 (RED|BRIGHT_RED|#rrggbb|ERROR|INFO|…)"
168 )
169 })?);
170 }
171 other => {
179 options.set(other, value);
180 }
181 }
182 Ok(())
183}
184
185fn parse_lod(value: &OptionValue) -> Result<Lod, String> {
186 match value {
187 OptionValue::Str(s) => match s.as_str() {
188 "compact" | "1" => Ok(Lod::Compact),
189 "labeled" | "2" => Ok(Lod::Labeled),
190 "expanded" | "3" => Ok(Lod::Expanded),
191 other => Err(format!("unknown LOD '{other}' (compact/labeled/expanded)")),
192 },
193 OptionValue::Int(1) => Ok(Lod::Compact),
194 OptionValue::Int(2) => Ok(Lod::Labeled),
195 OptionValue::Int(3) => Ok(Lod::Expanded),
196 other => Err(format!("LOD must be name or 1..=3, got {other:?}")),
197 }
198}
199
200fn parse_layout(value: &OptionValue) -> Result<LayoutMode, String> {
201 match value {
202 OptionValue::Str(s) => match s.as_str() {
203 "auto" => Ok(LayoutMode::Auto),
204 "inline" => Ok(LayoutMode::Inline),
205 "block" => Ok(LayoutMode::Block),
206 other => Err(format!("unknown layout '{other}' (auto/inline/block)")),
207 },
208 other => Err(format!("layout must be a string, got {other:?}")),
209 }
210}
211
212#[derive(Debug, PartialEq)]
215enum Token {
216 Ident(String),
217 Quoted(String),
218 Punct(char),
219 ColorDirective(ColorSpec),
223}
224
225struct Lexer<'a> {
226 src: &'a str,
227 pos: usize,
228 pending_colon_arg: Option<String>,
232}
233
234impl<'a> Lexer<'a> {
235 fn new(src: &'a str) -> Self {
236 Self {
237 src,
238 pos: 0,
239 pending_colon_arg: None,
240 }
241 }
242
243 fn rest(&self) -> &str {
244 &self.src[self.pos..]
245 }
246
247 fn skip_ws(&mut self) {
248 while let Some(c) = self.rest().chars().next() {
249 if c.is_whitespace() {
250 self.pos += c.len_utf8();
251 } else {
252 break;
253 }
254 }
255 }
256
257 fn next_token(&mut self) -> Result<Option<Token>, String> {
258 self.skip_ws();
259 let mut chars = self.rest().chars();
260 let Some(c) = chars.next() else {
261 return Ok(None);
262 };
263 if c == '"' || c == '\'' {
264 return self.read_quoted(c).map(Some);
265 }
266 if c == '@' {
267 return self.read_at_color().map(Some);
268 }
269 if c == '[' && self.rest().starts_with("[#") {
270 return self.read_bracketed_hex().map(Some);
271 }
272 if is_ident_start(c) {
273 return self.read_ident().map(Some);
274 }
275 self.pos += c.len_utf8();
279 Ok(Some(Token::Punct(c)))
280 }
281
282 fn read_at_color(&mut self) -> Result<Token, String> {
286 self.pos += 1; let start = self.pos;
288 while let Some(c) = self.rest().chars().next() {
289 if c.is_ascii_alphanumeric() || c == '_' || c == '#' {
290 self.pos += c.len_utf8();
291 } else {
292 break;
293 }
294 }
295 let raw = &self.src[start..self.pos];
296 if raw.is_empty() {
297 return Err("readouts: stray `@` with no colour token after it".to_string());
298 }
299 let spec = ColorSpec::parse(raw).ok_or_else(|| {
300 format!(
301 "readouts: unknown colour / style '@{raw}' (RED|BRIGHT_RED|#rrggbb|ERROR|INFO|…)"
302 )
303 })?;
304 Ok(Token::ColorDirective(spec))
305 }
306
307 fn read_bracketed_hex(&mut self) -> Result<Token, String> {
309 self.pos += 1; let start = self.pos;
311 while let Some(c) = self.rest().chars().next() {
312 if c == ']' {
313 break;
314 }
315 self.pos += c.len_utf8();
316 }
317 let raw = &self.src[start..self.pos];
318 if !self.rest().starts_with(']') {
319 return Err(format!(
320 "readouts: unterminated `[…]` color directive at byte {}",
321 start - 1
322 ));
323 }
324 self.pos += 1; let spec = ColorSpec::parse(raw).ok_or_else(|| {
326 format!(
327 "readouts: invalid bracketed colour '[{raw}]' (expected `[#rrggbb]` or `[#rgb]`)"
328 )
329 })?;
330 Ok(Token::ColorDirective(spec))
331 }
332
333 fn read_quoted(&mut self, quote: char) -> Result<Token, String> {
334 let mut chars = self.rest().chars();
335 chars.next(); let inner_start = self.pos + quote.len_utf8();
337 let mut inner_end = inner_start;
338 for c in chars {
339 if c == quote {
340 break;
341 }
342 inner_end += c.len_utf8();
343 }
344 if inner_end >= self.src.len() {
345 return Err(format!("unterminated quoted literal at byte {}", self.pos));
346 }
347 let s = self.src[inner_start..inner_end].to_string();
348 self.pos = inner_end + quote.len_utf8();
349 Ok(Token::Quoted(s))
350 }
351
352 fn read_ident(&mut self) -> Result<Token, String> {
353 let start = self.pos;
354 while let Some(c) = self.rest().chars().next() {
355 if is_ident_cont(c) {
356 self.pos += c.len_utf8();
357 } else {
358 break;
359 }
360 }
361 let name = self.src[start..self.pos].to_string();
362
363 if self.rest().starts_with(':') && !self.rest().starts_with(":=")
367 {
369 let after_colon = &self.src[self.pos + 1..];
373 if let Some(c) = after_colon.chars().next()
374 && (is_ident_start(c)
375 || c == '"'
376 || c == '\''
377 || c == '*'
378 || c == '?'
379 || c.is_ascii_digit())
380 {
381 self.pos += 1; let arg = self.read_unquoted_value()?;
383 self.pending_colon_arg = Some(arg);
384 }
385 }
386 Ok(Token::Ident(name))
387 }
388
389 fn consume_attached_colon_arg(&mut self) -> Option<String> {
390 self.pending_colon_arg.take()
391 }
392
393 fn peek_option(&mut self) -> Option<()> {
402 self.skip_ws();
403 let rest = self.rest();
404 let mut chars = rest.chars();
405 let c = chars.next()?;
406 if !is_ident_start(c) {
407 return None;
408 }
409 let mut idx = c.len_utf8();
411 for c in chars {
412 if is_ident_cont(c) {
413 idx += c.len_utf8();
414 } else {
415 break;
416 }
417 }
418 let after = &rest[idx..];
419 let key = &rest[..idx];
420 if after.starts_with('=') {
421 Some(())
422 } else if after.starts_with(':') && is_structural_key(key) {
423 Some(())
429 } else {
430 None
431 }
432 }
433
434 fn consume_option_pair(&mut self) -> Result<(String, OptionValue), String> {
435 self.skip_ws();
436 let start = self.pos;
438 while let Some(c) = self.rest().chars().next() {
439 if is_ident_cont(c) {
440 self.pos += c.len_utf8();
441 } else {
442 break;
443 }
444 }
445 let key = self.src[start..self.pos].to_string();
446 match self.rest().chars().next() {
449 Some('=') => {
450 self.pos += 1;
451 }
452 Some(':') if is_structural_key(&key) => {
453 self.pos += 1;
454 }
455 _ => {
456 return Err(format!(
457 "expected `=` (or `:` for lod/layout/color/style) after option key '{key}'"
458 ));
459 }
460 }
461
462 let value = match self.rest().chars().next() {
464 Some('"') => OptionValue::Str(self.read_quoted_value('"')?),
465 Some('\'') => OptionValue::Str(self.read_quoted_value('\'')?),
466 Some(_) => {
467 let raw = self.read_unquoted_value()?;
468 if let Ok(i) = raw.parse::<i64>() {
470 OptionValue::Int(i)
471 } else if let Ok(f) = raw.parse::<f64>() {
472 OptionValue::Float(f)
473 } else if raw == "true" {
474 OptionValue::Bool(true)
475 } else if raw == "false" {
476 OptionValue::Bool(false)
477 } else {
478 OptionValue::Str(raw)
479 }
480 }
481 None => return Err(format!("missing value for option '{key}'")),
482 };
483 Ok((key, value))
484 }
485}
486
487fn is_structural_key(key: &str) -> bool {
494 matches!(key, "lod" | "layout" | "color" | "style")
495}
496
497impl<'a> Lexer<'a> {
498 fn read_quoted_value(&mut self, quote: char) -> Result<String, String> {
499 let inner_start = self.pos + quote.len_utf8();
501 let mut chars = self.src[self.pos..].chars();
502 chars.next(); let mut inner_end = inner_start;
504 for c in chars {
505 if c == quote {
506 break;
507 }
508 inner_end += c.len_utf8();
509 }
510 if inner_end >= self.src.len() {
511 return Err(format!("unterminated quoted value at byte {}", self.pos));
512 }
513 let s = self.src[inner_start..inner_end].to_string();
514 self.pos = inner_end + quote.len_utf8();
515 Ok(s)
516 }
517
518 fn read_unquoted_value(&mut self) -> Result<String, String> {
519 let start = self.pos;
520 while let Some(c) = self.rest().chars().next() {
521 if c.is_whitespace() || c == '=' {
522 break;
523 }
524 self.pos += c.len_utf8();
525 }
526 Ok(self.src[start..self.pos].to_string())
527 }
528}
529
530fn is_ident_start(c: char) -> bool {
531 c.is_ascii_alphabetic() || c == '_'
532}
533
534fn is_ident_cont(c: char) -> bool {
535 c.is_ascii_alphanumeric() || c == '_'
536}
537
538#[allow(dead_code)]
541fn _unused_chars_marker(_: Chars<'_>) {}
542
543#[cfg(test)]
544mod tests {
545 use super::*;
546
547 #[test]
548 fn parses_single_readout_name() {
549 let (baked, _) = bake("phase_outcome").expect("parse");
550 assert_eq!(baked.steps.len(), 1);
552 assert!(matches!(baked.steps[0], RenderStep::Render { .. }));
553 }
554
555 #[test]
556 fn parses_multiple_readouts_with_joining_space() {
557 let (baked, _) = bake("phase_outcome phase_status").expect("parse");
559 assert_eq!(baked.steps.len(), 3);
560 match &baked.steps[1] {
561 RenderStep::Literal(s) => assert_eq!(s, " "),
562 _ => panic!("expected joining literal at index 1"),
563 }
564 }
565
566 #[test]
567 fn parses_quoted_literal() {
568 let (baked, _) = bake(r#"phase_outcome "ok:" phase_status"#).expect("parse");
569 assert_eq!(baked.steps.len(), 5);
571 match &baked.steps[2] {
572 RenderStep::Literal(s) => assert_eq!(s, "ok:"),
573 other => panic!("expected literal, got {:?}", other.discriminant()),
574 }
575 }
576
577 #[test]
578 fn unknown_readout_name_is_error() {
579 let err = bake("not_a_real_readout").unwrap_err();
580 assert!(
581 err.contains("unknown readout name 'not_a_real_readout'"),
582 "wrong message: {err}"
583 );
584 }
585
586 #[test]
587 fn parses_lod_option() {
588 let (baked, _) = bake("phase_status lod=compact").expect("parse");
589 match &baked.steps[0] {
590 RenderStep::Render { lod, .. } => {
591 assert_eq!(*lod, Lod::Compact);
592 }
593 _ => panic!("expected Render"),
594 }
595 }
596
597 #[test]
598 fn parses_lod_numeric_alias() {
599 let (baked, _) = bake("phase_status lod=2").expect("parse");
600 match &baked.steps[0] {
601 RenderStep::Render { lod, .. } => assert_eq!(*lod, Lod::Labeled),
602 _ => panic!("expected Render"),
603 }
604 }
605
606 #[test]
607 fn parses_layout_option() {
608 let (baked, _) = bake("phase_status layout=block").expect("parse");
609 match &baked.steps[0] {
610 RenderStep::Render { layout, .. } => {
611 assert_eq!(*layout, LayoutMode::Block);
612 }
613 _ => panic!("expected Render"),
614 }
615 }
616
617 #[test]
618 fn rejects_unknown_lod_value() {
619 let err = bake("phase_status lod=enormous").unwrap_err();
620 assert!(err.contains("unknown LOD"), "wrong message: {err}");
621 }
622
623 #[test]
624 fn rejects_unknown_layout_value() {
625 let err = bake("phase_status layout=sideways").unwrap_err();
626 assert!(err.contains("unknown layout"), "wrong message: {err}");
627 }
628
629 #[test]
630 fn parses_colon_shorthand_arg() {
631 let (baked, _) = bake("metric:recall*").expect("parse");
637 assert!(matches!(baked.steps[0], RenderStep::Render { .. }));
638 }
639
640 #[test]
641 fn parses_lod_colon_shorthand() {
642 let (baked, _) = bake("phase_status lod:compact").expect("parse");
646 match &baked.steps[0] {
647 RenderStep::Render { lod, .. } => assert_eq!(*lod, Lod::Compact),
648 _ => panic!("expected Render"),
649 }
650 }
651
652 #[test]
653 fn parses_layout_colon_shorthand() {
654 let (baked, _) = bake("phase_status layout:block").expect("parse");
655 match &baked.steps[0] {
656 RenderStep::Render { layout, .. } => {
657 assert_eq!(*layout, LayoutMode::Block);
658 }
659 _ => panic!("expected Render"),
660 }
661 }
662
663 #[test]
664 fn parses_color_colon_shorthand() {
665 let (baked, _) = bake("phase_outcome color:RED").expect("parse");
666 match &baked.steps[0] {
667 RenderStep::Render { color: Some(c), .. } => {
668 assert_eq!(*c, ColorSpec::Direct("RED"));
669 }
670 _ => panic!("expected Render with colour"),
671 }
672 }
673
674 #[test]
675 fn parses_mixed_colon_and_equals_options() {
676 let (baked, _) = bake("phase_status lod:compact layout=block").expect("parse");
678 match &baked.steps[0] {
679 RenderStep::Render { lod, layout, .. } => {
680 assert_eq!(*lod, Lod::Compact);
681 assert_eq!(*layout, LayoutMode::Block);
682 }
683 _ => panic!("expected Render"),
684 }
685 }
686
687 #[test]
688 fn non_structural_colon_is_primary_arg_not_option() {
689 let (baked, _) = bake("metric:recall*").expect("parse");
695 match &baked.steps[0] {
696 RenderStep::Render { options, .. } => {
697 assert_eq!(options.get_str("pattern"), Some("recall*"));
698 }
699 _ => panic!("expected Render"),
700 }
701 }
702
703 #[test]
704 fn parses_at_color_directive() {
705 let (baked, _) = bake("@RED phase_outcome").expect("parse");
706 assert_eq!(baked.steps.len(), 2);
709 match &baked.steps[0] {
710 RenderStep::ColorDirective(c) => {
711 assert_eq!(*c, ColorSpec::Direct("RED"));
712 }
713 other => panic!("expected ColorDirective, got {other:?}"),
714 }
715 assert!(matches!(baked.steps[1], RenderStep::Render { .. }));
716 }
717
718 #[test]
719 fn parses_at_hex_color() {
720 let (baked, _) = bake("@#7AC166 phase_outcome").expect("parse");
721 match &baked.steps[0] {
722 RenderStep::ColorDirective(c) => {
723 assert_eq!(*c, ColorSpec::Rgb(0x7A, 0xC1, 0x66));
724 }
725 other => panic!("expected ColorDirective, got {other:?}"),
726 }
727 }
728
729 #[test]
730 fn parses_bracketed_hex_color() {
731 let (baked, _) = bake("[#FFF] phase_outcome").expect("parse");
732 match &baked.steps[0] {
733 RenderStep::ColorDirective(c) => {
734 assert_eq!(*c, ColorSpec::Rgb(0xFF, 0xFF, 0xFF));
735 }
736 other => panic!("expected ColorDirective, got {other:?}"),
737 }
738 }
739
740 #[test]
741 fn parses_at_style_name() {
742 let (baked, _) = bake("@ERROR phase_outcome").expect("parse");
743 match &baked.steps[0] {
744 RenderStep::ColorDirective(c) => {
745 assert_eq!(*c, ColorSpec::Style(super::super::color::StyleName::Error));
746 }
747 other => panic!("expected ColorDirective, got {other:?}"),
748 }
749 }
750
751 #[test]
752 fn parses_color_option() {
753 let (baked, _) = bake("phase_outcome color=BLUE").expect("parse");
754 match &baked.steps[0] {
755 RenderStep::Render { color, .. } => {
756 assert_eq!(*color, Some(ColorSpec::Direct("BLUE")));
757 }
758 _ => panic!("expected Render"),
759 }
760 }
761
762 #[test]
763 fn parses_style_option() {
764 let (baked, _) = bake("phase_outcome style=ERROR").expect("parse");
765 match &baked.steps[0] {
766 RenderStep::Render { color, .. } => {
767 assert_eq!(
768 *color,
769 Some(ColorSpec::Style(super::super::color::StyleName::Error))
770 );
771 }
772 _ => panic!("expected Render"),
773 }
774 }
775
776 #[test]
777 fn unknown_color_token_is_error() {
778 let err = bake("@notacolor phase_outcome").unwrap_err();
779 assert!(
780 err.contains("unknown colour / style '@notacolor'"),
781 "wrong message: {err}"
782 );
783 }
784
785 #[test]
786 fn unknown_style_option_is_error() {
787 let err = bake("phase_outcome style=notastyle").unwrap_err();
788 assert!(
789 err.contains("unknown colour / style"),
790 "wrong message: {err}"
791 );
792 }
793
794 trait DebugDiscriminant {
799 fn discriminant(&self) -> &'static str;
800 }
801 impl DebugDiscriminant for RenderStep {
802 fn discriminant(&self) -> &'static str {
803 match self {
804 RenderStep::Literal(_) => "Literal",
805 RenderStep::Render { .. } => "Render",
806 RenderStep::ColorDirective(_) => "ColorDirective",
807 }
808 }
809 }
810}