1#[cfg(not(feature = "std"))]
2use alloc::format;
3#[cfg(not(feature = "std"))]
4use alloc::string::{String, ToString};
5#[cfg(not(feature = "std"))]
6use alloc::vec::Vec;
7
8use crate::error::ProsaicError;
9use prosaic_common::{PipeSpec, ValueType, pipe_spec, types_compatible};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum PipeArg {
14 String(String),
15 Number(usize),
16}
17
18#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct Pipe {
21 pub name: String,
22 pub arg: Option<PipeArg>,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub enum Segment {
28 Literal(String),
30 Slot { key: String, pipes: Vec<Pipe> },
32 Conditional {
35 condition_key: String,
36 inner: Vec<Segment>,
37 },
38 Partial { name: String },
43}
44
45#[derive(Debug, Clone)]
47pub struct Template {
48 pub source: String,
49 pub segments: Vec<Segment>,
50}
51
52impl Template {
53 pub fn parse(source: &str) -> Result<Self, ProsaicError> {
62 let segments = parse_segments(source, 0, source.len())?;
63 Ok(Template {
64 source: source.to_string(),
65 segments,
66 })
67 }
68
69 pub fn literal_tokens(&self) -> Vec<&str> {
82 let mut out = Vec::new();
83 collect_literals(&self.segments, &mut out);
84 out
85 }
86
87 pub fn slot_keys(&self) -> Vec<String> {
96 let mut out = Vec::new();
97 collect_slot_keys(&self.segments, &mut out);
98 out
99 }
100
101 pub fn pipe_names(&self) -> Vec<String> {
108 let mut out = Vec::new();
109 collect_pipe_names(&self.segments, &mut out);
110 out
111 }
112
113 pub fn partial_names(&self) -> Vec<String> {
119 let mut out = Vec::new();
120 collect_partial_names(&self.segments, &mut out);
121 out
122 }
123
124 pub fn infer_types(&self) -> Result<Vec<(String, ValueType)>, String> {
142 let mut by_slot: Vec<(String, ValueType)> = Vec::new();
143 infer_segments(&self.segments, &mut by_slot)?;
144 Ok(by_slot)
145 }
146
147 pub fn as_bare_slots(&self) -> Option<Vec<BareSegment<'_>>> {
154 let mut out = Vec::new();
155 for seg in &self.segments {
156 match seg {
157 Segment::Literal(s) => out.push(BareSegment::Text(s.as_str())),
158 Segment::Slot { pipes, key } if pipes.is_empty() => {
159 out.push(BareSegment::Slot(key.as_str()));
160 }
161 _ => return None,
163 }
164 }
165 Some(out)
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq)]
175pub enum BareSegment<'a> {
176 Text(&'a str),
178 Slot(&'a str),
180}
181
182fn collect_literals<'a>(segments: &'a [Segment], out: &mut Vec<&'a str>) {
185 for seg in segments {
186 match seg {
187 Segment::Literal(s) => out.push(s.as_str()),
188 Segment::Slot { .. } => {}
189 Segment::Conditional { inner, .. } => collect_literals(inner, out),
190 Segment::Partial { .. } => {}
191 }
192 }
193}
194
195fn collect_slot_keys(segments: &[Segment], out: &mut Vec<String>) {
198 for seg in segments {
199 match seg {
200 Segment::Slot { key, .. } => out.push(key.clone()),
201 Segment::Conditional {
202 condition_key,
203 inner,
204 } => {
205 out.push(condition_key.clone());
206 collect_slot_keys(inner, out);
207 }
208 Segment::Literal(_) | Segment::Partial { .. } => {}
209 }
210 }
211}
212
213fn collect_partial_names(segments: &[Segment], out: &mut Vec<String>) {
216 for seg in segments {
217 match seg {
218 Segment::Partial { name } => out.push(name.clone()),
219 Segment::Conditional { inner, .. } => collect_partial_names(inner, out),
220 Segment::Literal(_) | Segment::Slot { .. } => {}
221 }
222 }
223}
224
225fn collect_pipe_names(segments: &[Segment], out: &mut Vec<String>) {
228 for seg in segments {
229 match seg {
230 Segment::Slot { pipes, .. } => {
231 for pipe in pipes {
232 out.push(pipe.name.clone());
233 }
234 }
235 Segment::Conditional { inner, .. } => collect_pipe_names(inner, out),
236 Segment::Literal(_) | Segment::Partial { .. } => {}
237 }
238 }
239}
240
241fn parse_segments(source: &str, start: usize, end: usize) -> Result<Vec<Segment>, ProsaicError> {
243 let mut segments = Vec::new();
244 let slice = &source[start..end];
245 let bytes = slice.as_bytes();
246 let mut i: usize = 0;
247 let mut literal_start: usize = 0;
248
249 while i < bytes.len() {
250 if bytes[i] != b'{' {
251 i += 1;
252 continue;
253 }
254
255 if i > literal_start {
257 segments.push(Segment::Literal(slice[literal_start..i].to_string()));
258 }
259
260 let content_start = i + 1;
261 let is_conditional = content_start < bytes.len() && bytes[content_start] == b'?';
262 let is_partial = content_start < bytes.len() && bytes[content_start] == b'>';
263 let is_closing = content_start + 1 < bytes.len()
264 && bytes[content_start] == b'/'
265 && bytes[content_start + 1] == b'?';
266
267 if is_closing {
268 return Err(ProsaicError::TemplateParseError {
269 template: source.to_string(),
270 position: start + i,
271 reason: "unexpected closing `{/?}` without opening".to_string(),
272 });
273 }
274
275 if is_partial {
276 let name_start = content_start + 1; let name_end = slice[name_start..]
278 .find('}')
279 .map(|rel| name_start + rel)
280 .ok_or_else(|| ProsaicError::TemplateParseError {
281 template: source.to_string(),
282 position: start + i,
283 reason: "unclosed `{>`".to_string(),
284 })?;
285
286 let name = slice[name_start..name_end].trim().to_string();
287 if name.is_empty() {
288 return Err(ProsaicError::TemplateParseError {
289 template: source.to_string(),
290 position: start + i,
291 reason: "empty partial name".to_string(),
292 });
293 }
294
295 segments.push(Segment::Partial { name });
296 i = name_end + 1;
297 literal_start = i;
298 continue;
299 }
300
301 if is_conditional {
302 let key_start = content_start + 1; let key_end = slice[key_start..]
305 .find('}')
306 .map(|rel| key_start + rel)
307 .ok_or_else(|| ProsaicError::TemplateParseError {
308 template: source.to_string(),
309 position: start + i,
310 reason: "unclosed `{?`".to_string(),
311 })?;
312
313 let condition_key = slice[key_start..key_end].trim().to_string();
314 if condition_key.is_empty() {
315 return Err(ProsaicError::TemplateParseError {
316 template: source.to_string(),
317 position: start + i,
318 reason: "empty condition key".to_string(),
319 });
320 }
321
322 let inner_start = key_end + 1;
323 let inner_end = find_matching_close(slice, inner_start).ok_or_else(|| {
324 ProsaicError::TemplateParseError {
325 template: source.to_string(),
326 position: start + i,
327 reason: format!("unclosed conditional `{{?{condition_key}}}`"),
328 }
329 })?;
330
331 let inner_segments = parse_segments(source, start + inner_start, start + inner_end)?;
332
333 segments.push(Segment::Conditional {
334 condition_key,
335 inner: inner_segments,
336 });
337
338 i = inner_end + 4;
340 literal_start = i;
341 } else {
342 let mut slot_end: Option<usize> = None;
344 let mut depth: i32 = 1;
345 let mut j = content_start;
346 while j < bytes.len() {
347 match bytes[j] {
348 b'{' => depth += 1,
349 b'}' => {
350 depth -= 1;
351 if depth == 0 {
352 slot_end = Some(j);
353 break;
354 }
355 }
356 _ => {}
357 }
358 j += 1;
359 }
360 let slot_end = slot_end.ok_or_else(|| ProsaicError::TemplateParseError {
361 template: source.to_string(),
362 position: start + i,
363 reason: "unclosed `{`".to_string(),
364 })?;
365
366 let slot_content = &slice[content_start..slot_end];
367 let segment = parse_slot(slot_content, source, start + i)?;
368 segments.push(segment);
369
370 i = slot_end + 1;
371 literal_start = i;
372 }
373 }
374
375 if literal_start < slice.len() {
377 segments.push(Segment::Literal(slice[literal_start..].to_string()));
378 }
379
380 Ok(segments)
381}
382
383fn find_matching_close(slice: &str, start: usize) -> Option<usize> {
386 let mut depth: i32 = 1;
387 let bytes = slice.as_bytes();
388 let mut i = start;
389
390 while i < bytes.len() {
391 if i + 1 < bytes.len() && bytes[i] == b'{' {
392 if bytes[i + 1] == b'?' {
393 depth += 1;
394 i += 2;
395 continue;
396 }
397 if i + 2 < bytes.len() && bytes[i + 1] == b'/' && bytes[i + 2] == b'?' {
398 depth -= 1;
399 if depth == 0 {
400 return Some(i);
401 }
402 i += 3;
403 continue;
404 }
405 }
406 i += 1;
407 }
408
409 None
410}
411
412fn parse_slot(content: &str, source: &str, position: usize) -> Result<Segment, ProsaicError> {
413 let parts: Vec<&str> = content.split('|').collect();
414
415 let key = parts[0].trim();
416 if key.is_empty() {
417 return Err(ProsaicError::TemplateParseError {
418 template: source.to_string(),
419 position,
420 reason: "empty slot key".to_string(),
421 });
422 }
423
424 let mut pipes = Vec::new();
425 for part in &parts[1..] {
426 let pipe = parse_pipe(part.trim(), source, position)?;
427 pipes.push(pipe);
428 }
429
430 Ok(Segment::Slot {
431 key: key.to_string(),
432 pipes,
433 })
434}
435
436fn parse_pipe(content: &str, source: &str, position: usize) -> Result<Pipe, ProsaicError> {
437 if content.is_empty() {
438 return Err(ProsaicError::TemplateParseError {
439 template: source.to_string(),
440 position,
441 reason: "empty pipe name".to_string(),
442 });
443 }
444
445 if let Some((name, arg_str)) = content.split_once(':') {
446 let name = name.trim();
447 let arg_str = arg_str.trim();
448
449 let arg = if let Ok(n) = arg_str.parse::<usize>() {
450 PipeArg::Number(n)
451 } else {
452 PipeArg::String(arg_str.to_string())
453 };
454
455 Ok(Pipe {
456 name: name.to_string(),
457 arg: Some(arg),
458 })
459 } else {
460 Ok(Pipe {
461 name: content.to_string(),
462 arg: None,
463 })
464 }
465}
466
467fn infer_segments(segments: &[Segment], out: &mut Vec<(String, ValueType)>) -> Result<(), String> {
468 for seg in segments {
469 match seg {
470 Segment::Literal(_) | Segment::Partial { .. } => {}
471 Segment::Slot { key, pipes } => {
472 let slot_ty = slot_type_from_pipes(key, pipes)?;
473 unify(out, key, slot_ty)?;
474 }
475 Segment::Conditional {
476 condition_key,
477 inner,
478 } => {
479 unify(out, condition_key, ValueType::Any)?;
480 infer_segments(inner, out)?;
481 }
482 }
483 }
484 Ok(())
485}
486
487fn slot_type_from_pipes(key: &str, pipes: &[Pipe]) -> Result<ValueType, String> {
488 let Some(first) = pipes.first() else {
490 return Ok(ValueType::Any);
491 };
492
493 let first_spec = lookup_spec(&first.name)?;
494 let slot_ty = first_spec.input;
495 let mut current_output = first_spec.output;
496 let mut prev_name: &str = &first.name;
497
498 for next in &pipes[1..] {
499 let next_spec = lookup_spec(&next.name)?;
500 if !types_compatible(current_output, next_spec.input) {
501 return Err(format!(
502 "pipe chain mismatch on slot `{key}`: \
503 pipe `{prev_name}` outputs {current_output:?} but pipe `{cur}` expects {expected:?}",
504 cur = next.name,
505 expected = next_spec.input,
506 ));
507 }
508 current_output = next_spec.output;
509 prev_name = &next.name;
510 }
511
512 Ok(slot_ty)
513}
514
515fn lookup_spec(name: &str) -> Result<&'static PipeSpec, String> {
516 pipe_spec(name).ok_or_else(|| format!("unknown pipe `{name}`"))
517}
518
519fn unify(out: &mut Vec<(String, ValueType)>, key: &str, ty: ValueType) -> Result<(), String> {
520 if let Some(entry) = out.iter_mut().find(|(k, _)| k == key) {
521 entry.1 = match (entry.1, ty) {
522 (ValueType::Any, t) | (t, ValueType::Any) => t,
523 (a, b) if a == b => a,
524 (a, b) => {
525 return Err(format!(
526 "slot `{key}` has conflicting types: used as both {a:?} and {b:?}"
527 ));
528 }
529 };
530 } else {
531 out.push((key.to_string(), ty));
532 }
533 Ok(())
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539
540 #[test]
541 fn parse_literal_only() {
542 let t = Template::parse("hello world").unwrap();
543 assert_eq!(
544 t.segments,
545 vec![Segment::Literal("hello world".to_string())]
546 );
547 }
548
549 #[test]
550 fn parse_single_slot() {
551 let t = Template::parse("{name}").unwrap();
552 assert_eq!(
553 t.segments,
554 vec![Segment::Slot {
555 key: "name".to_string(),
556 pipes: vec![],
557 }]
558 );
559 }
560
561 #[test]
562 fn parse_slot_with_surrounding_text() {
563 let t = Template::parse("Hello {name}!").unwrap();
564 assert_eq!(
565 t.segments,
566 vec![
567 Segment::Literal("Hello ".to_string()),
568 Segment::Slot {
569 key: "name".to_string(),
570 pipes: vec![],
571 },
572 Segment::Literal("!".to_string()),
573 ]
574 );
575 }
576
577 #[test]
578 fn parse_slot_with_pipe() {
579 let t = Template::parse("{name|capitalize}").unwrap();
580 assert_eq!(
581 t.segments,
582 vec![Segment::Slot {
583 key: "name".to_string(),
584 pipes: vec![Pipe {
585 name: "capitalize".to_string(),
586 arg: None,
587 }],
588 }]
589 );
590 }
591
592 #[test]
593 fn parse_slot_with_pipe_and_string_arg() {
594 let t = Template::parse("{count|pluralize:item}").unwrap();
595 assert_eq!(
596 t.segments,
597 vec![Segment::Slot {
598 key: "count".to_string(),
599 pipes: vec![Pipe {
600 name: "pluralize".to_string(),
601 arg: Some(PipeArg::String("item".to_string())),
602 }],
603 }]
604 );
605 }
606
607 #[test]
608 fn parse_slot_with_pipe_and_number_arg() {
609 let t = Template::parse("{items|truncate:3}").unwrap();
610 assert_eq!(
611 t.segments,
612 vec![Segment::Slot {
613 key: "items".to_string(),
614 pipes: vec![Pipe {
615 name: "truncate".to_string(),
616 arg: Some(PipeArg::Number(3)),
617 }],
618 }]
619 );
620 }
621
622 #[test]
623 fn parse_chained_pipes() {
624 let t = Template::parse("{items|truncate:3|join}").unwrap();
625 assert_eq!(
626 t.segments,
627 vec![Segment::Slot {
628 key: "items".to_string(),
629 pipes: vec![
630 Pipe {
631 name: "truncate".to_string(),
632 arg: Some(PipeArg::Number(3)),
633 },
634 Pipe {
635 name: "join".to_string(),
636 arg: None,
637 },
638 ],
639 }]
640 );
641 }
642
643 #[test]
644 fn parse_multiple_slots() {
645 let t = Template::parse("{a} and {b}").unwrap();
646 assert_eq!(
647 t.segments,
648 vec![
649 Segment::Slot {
650 key: "a".to_string(),
651 pipes: vec![],
652 },
653 Segment::Literal(" and ".to_string()),
654 Segment::Slot {
655 key: "b".to_string(),
656 pipes: vec![],
657 },
658 ]
659 );
660 }
661
662 #[test]
663 fn parse_unclosed_brace_is_error() {
664 let result = Template::parse("hello {name");
665 assert!(matches!(
666 result,
667 Err(ProsaicError::TemplateParseError { .. })
668 ));
669 }
670
671 #[test]
672 fn parse_empty_slot_is_error() {
673 let result = Template::parse("hello {}");
674 assert!(matches!(
675 result,
676 Err(ProsaicError::TemplateParseError { .. })
677 ));
678 }
679
680 #[test]
681 fn parse_empty_pipe_name_is_error() {
682 let result = Template::parse("{name|}");
683 assert!(matches!(
684 result,
685 Err(ProsaicError::TemplateParseError { .. })
686 ));
687 }
688
689 #[test]
690 fn parse_complex_template() {
691 let t = Template::parse(
692 "The {entity_type} {old_name} was renamed to {new_name} \
693 which impacts {count} direct {count|pluralize:consumer} \
694 [{consumers|truncate:3|join}]",
695 )
696 .unwrap();
697
698 assert_eq!(t.segments.len(), 13);
702 }
703
704 #[test]
707 fn parse_conditional_section() {
708 let t = Template::parse("foo{?count} bar{/?} baz").unwrap();
709 assert_eq!(t.segments.len(), 3);
710 assert_eq!(t.segments[0], Segment::Literal("foo".into()));
711 assert!(matches!(t.segments[1], Segment::Conditional { .. }));
712 assert_eq!(t.segments[2], Segment::Literal(" baz".into()));
713 }
714
715 #[test]
716 fn parse_conditional_with_inner_slot() {
717 let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
718 assert_eq!(t.segments.len(), 2);
719 if let Segment::Conditional {
720 condition_key,
721 inner,
722 } = &t.segments[1]
723 {
724 assert_eq!(condition_key, "count");
725 assert_eq!(inner.len(), 3); } else {
727 panic!("Expected Conditional segment");
728 }
729 }
730
731 #[test]
732 fn parse_unclosed_conditional_is_error() {
733 let result = Template::parse("{?count} never closed");
734 assert!(matches!(
735 result,
736 Err(ProsaicError::TemplateParseError { .. })
737 ));
738 }
739
740 #[test]
741 fn parse_empty_conditional_key_is_error() {
742 let result = Template::parse("{?}content{/?}");
743 assert!(matches!(
744 result,
745 Err(ProsaicError::TemplateParseError { .. })
746 ));
747 }
748
749 #[test]
752 fn parse_partial_reference() {
753 let t = Template::parse("start {>tail} end").unwrap();
754 assert_eq!(t.segments.len(), 3);
755 assert!(matches!(&t.segments[1], Segment::Partial { name } if name == "tail"));
756 }
757
758 #[test]
759 fn parse_empty_partial_name_is_error() {
760 let result = Template::parse("{>}");
761 assert!(matches!(
762 result,
763 Err(ProsaicError::TemplateParseError { .. })
764 ));
765 }
766
767 #[test]
768 fn parse_unclosed_partial_is_error() {
769 let result = Template::parse("{>tail");
770 assert!(matches!(
771 result,
772 Err(ProsaicError::TemplateParseError { .. })
773 ));
774 }
775
776 #[test]
779 fn literal_tokens_simple() {
780 let t = Template::parse("The {type} {name} was modified").unwrap();
781 let lits = t.literal_tokens();
782 assert_eq!(lits, vec!["The ", " ", " was modified"]);
783 }
784
785 #[test]
786 fn literal_tokens_from_conditional_sections() {
787 let t = Template::parse("{name}{?count}, impacting {count} consumers{/?}").unwrap();
788 let lits = t.literal_tokens();
789 assert!(lits.iter().any(|l| l.contains("impacting")));
790 assert!(lits.iter().any(|l| l.contains("consumers")));
791 }
792
793 #[test]
794 fn literal_tokens_empty_for_all_slots() {
795 let t = Template::parse("{a}{b}{c}").unwrap();
796 assert!(t.literal_tokens().is_empty());
797 }
798
799 #[test]
800 fn literal_tokens_skips_partial_nodes() {
801 let t = Template::parse("prefix {>partial_name} suffix").unwrap();
804 let lits = t.literal_tokens();
805 assert_eq!(lits, vec!["prefix ", " suffix"]);
806 }
807
808 #[test]
809 fn literal_tokens_nested_conditional_recursion() {
810 let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
812 let lits = t.literal_tokens();
813 assert!(lits.iter().any(|l| l.contains("outer")));
814 assert!(lits.iter().any(|l| l.contains("inner")));
815 }
816
817 #[test]
820 fn slot_keys_simple() {
821 let t = Template::parse("{a} and {b}").unwrap();
822 let mut keys = t.slot_keys();
823 keys.sort();
824 assert_eq!(keys, vec!["a", "b"]);
825 }
826
827 #[test]
828 fn slot_keys_includes_condition_key() {
829 let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
830 let keys = t.slot_keys();
831 assert!(keys.contains(&"name".to_string()));
832 assert!(keys.iter().filter(|k| k.as_str() == "count").count() >= 2);
834 }
835
836 #[test]
837 fn slot_keys_skips_partials() {
838 let t = Template::parse("start {>partial_name} {slot} end").unwrap();
839 let keys = t.slot_keys();
840 assert_eq!(keys, vec!["slot"]);
841 assert!(!keys.contains(&"partial_name".to_string()));
842 }
843
844 #[test]
845 fn slot_keys_empty_for_literal_only() {
846 let t = Template::parse("just a string").unwrap();
847 assert!(t.slot_keys().is_empty());
848 }
849
850 #[test]
851 fn slot_keys_nested_conditional() {
852 let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
853 let mut keys = t.slot_keys();
854 keys.sort();
855 keys.dedup();
856 assert_eq!(keys, vec!["a", "b"]);
857 }
858
859 #[test]
862 fn pipe_names_simple() {
863 let t = Template::parse("{count|pluralize:item}").unwrap();
864 assert_eq!(t.pipe_names(), vec!["pluralize"]);
865 }
866
867 #[test]
868 fn pipe_names_chained() {
869 let t = Template::parse("{items|truncate:3|join}").unwrap();
870 assert_eq!(t.pipe_names(), vec!["truncate", "join"]);
871 }
872
873 #[test]
874 fn pipe_names_empty_when_no_pipes() {
875 let t = Template::parse("{name} and {other}").unwrap();
876 assert!(t.pipe_names().is_empty());
877 }
878
879 #[test]
880 fn pipe_names_inside_conditional() {
881 let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
882 assert_eq!(t.pipe_names(), vec!["pluralize"]);
883 }
884
885 #[test]
886 fn pipe_names_arg_not_included_in_name() {
887 let t = Template::parse("{items|truncate:3}").unwrap();
889 let names = t.pipe_names();
890 assert_eq!(names, vec!["truncate"]);
891 assert!(!names.iter().any(|n| n.contains(':')));
892 }
893
894 use prosaic_common::ValueType;
897
898 fn types(t: &Template) -> Vec<(String, ValueType)> {
899 let mut v = t.infer_types().expect("expected successful inference");
900 v.sort_by(|a, b| a.0.cmp(&b.0));
901 v
902 }
903
904 #[test]
905 fn infer_bare_slot_is_any() {
906 let t = Template::parse("{x}").unwrap();
907 assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
908 }
909
910 #[test]
911 fn infer_slot_with_number_pipe_is_number() {
912 let t = Template::parse("{count|pluralize:item}").unwrap();
913 assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
914 }
915
916 #[test]
917 fn infer_slot_input_is_first_pipe_input_for_list_chain() {
918 let t = Template::parse("{items|truncate:3|join}").unwrap();
922 assert_eq!(types(&t), vec![("items".into(), ValueType::List)]);
923 }
924
925 #[test]
926 fn infer_chain_mismatch_is_error() {
927 let t = Template::parse("{x|capitalize|pluralize}").unwrap();
928 let err = t.infer_types().unwrap_err();
929 assert!(err.contains("capitalize"), "error was: {err}");
932 assert!(err.contains("pluralize"), "error was: {err}");
933 assert!(
934 err.contains("String"),
935 "error should name the output type; got: {err}"
936 );
937 assert!(
938 err.contains("Number"),
939 "error should name the expected input; got: {err}"
940 );
941 }
942
943 #[test]
944 fn infer_multi_mention_any_and_number_unifies_to_number() {
945 let t = Template::parse("{x|pluralize:item} {x}").unwrap();
946 assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
947 }
948
949 #[test]
950 fn infer_multi_mention_conflict_is_error() {
951 let t = Template::parse("{x|pluralize:item} {x|join}").unwrap();
952 let err = t.infer_types().unwrap_err();
953 assert!(err.contains("`x`"), "error was: {err}");
956 assert!(err.contains("Number"), "error was: {err}");
957 assert!(err.contains("List"), "error was: {err}");
958 }
959
960 #[test]
961 fn infer_same_bare_slot_twice_stays_any() {
962 let t = Template::parse("{x} and {x}").unwrap();
965 assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
966 }
967
968 #[test]
969 fn infer_unknown_pipe_is_error() {
970 let t = Template::parse("{x|nonexistent_pipe}").unwrap();
971 let err = t.infer_types().unwrap_err();
972 assert!(err.contains("nonexistent_pipe"), "error was: {err}");
973 }
974
975 #[test]
976 fn infer_conditional_guard_slot_is_any() {
977 let t = Template::parse("{?count}hello{/?}").unwrap();
978 let ts = types(&t);
979 assert_eq!(ts, vec![("count".into(), ValueType::Any)]);
980 }
981
982 #[test]
983 fn infer_pipes_inside_conditional_are_checked() {
984 let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
985 assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
986 }
987
988 #[test]
989 fn infer_literal_only_is_empty() {
990 let t = Template::parse("no slots").unwrap();
991 assert_eq!(types(&t), vec![]);
992 }
993
994 #[test]
995 fn infer_skips_partial_nodes() {
996 let t = Template::parse("{x|pluralize:item} {>some_partial}").unwrap();
999 assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
1000 }
1001
1002 #[test]
1005 fn as_bare_slots_accepts_bare_template() {
1006 let t = Template::parse("Hello {name} world").unwrap();
1007 let segs = t.as_bare_slots().unwrap();
1008 assert_eq!(segs.len(), 3);
1010 assert_eq!(segs[0], BareSegment::Text("Hello "));
1011 assert_eq!(segs[1], BareSegment::Slot("name"));
1012 assert_eq!(segs[2], BareSegment::Text(" world"));
1013 }
1014
1015 #[test]
1016 fn as_bare_slots_accepts_literal_only_template() {
1017 let t = Template::parse("no slots here").unwrap();
1018 let segs = t.as_bare_slots().unwrap();
1019 assert_eq!(segs.len(), 1);
1020 assert_eq!(segs[0], BareSegment::Text("no slots here"));
1021 }
1022
1023 #[test]
1024 fn as_bare_slots_accepts_multiple_bare_slots() {
1025 let t = Template::parse("{greeting}, {name}!").unwrap();
1026 let segs = t.as_bare_slots().unwrap();
1027 assert_eq!(segs.len(), 4);
1029 assert_eq!(segs[0], BareSegment::Slot("greeting"));
1030 assert_eq!(segs[1], BareSegment::Text(", "));
1031 assert_eq!(segs[2], BareSegment::Slot("name"));
1032 assert_eq!(segs[3], BareSegment::Text("!"));
1033 }
1034
1035 #[test]
1036 fn as_bare_slots_rejects_piped_template() {
1037 let t = Template::parse("Hello {name|capitalize}").unwrap();
1038 assert!(t.as_bare_slots().is_none());
1039 }
1040
1041 #[test]
1042 fn as_bare_slots_rejects_conditional_template() {
1043 let t = Template::parse("Hello{?greet} friend{/?}").unwrap();
1044 assert!(t.as_bare_slots().is_none());
1045 }
1046
1047 #[test]
1048 fn as_bare_slots_rejects_partial_template() {
1049 let t = Template::parse("prefix {>partial_name} suffix").unwrap();
1050 assert!(t.as_bare_slots().is_none());
1051 }
1052
1053 #[test]
1054 fn as_bare_slots_rejects_chained_pipes() {
1055 let t = Template::parse("{items|truncate:3|join}").unwrap();
1056 assert!(t.as_bare_slots().is_none());
1057 }
1058}