#[cfg(not(feature = "std"))]
use alloc::format;
#[cfg(not(feature = "std"))]
use alloc::string::{String, ToString};
#[cfg(not(feature = "std"))]
use alloc::vec::Vec;
use crate::error::ProsaicError;
use prosaic_common::{PipeSpec, ValueType, pipe_spec, types_compatible};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PipeArg {
String(String),
Number(usize),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pipe {
pub name: String,
pub arg: Option<PipeArg>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Segment {
Literal(String),
Slot { key: String, pipes: Vec<Pipe> },
Conditional {
condition_key: String,
inner: Vec<Segment>,
},
Partial { name: String },
}
#[derive(Debug, Clone)]
pub struct Template {
pub source: String,
pub segments: Vec<Segment>,
}
impl Template {
pub fn parse(source: &str) -> Result<Self, ProsaicError> {
let segments = parse_segments(source, 0, source.len())?;
Ok(Template {
source: source.to_string(),
segments,
})
}
pub fn literal_tokens(&self) -> Vec<&str> {
let mut out = Vec::new();
collect_literals(&self.segments, &mut out);
out
}
pub fn slot_keys(&self) -> Vec<String> {
let mut out = Vec::new();
collect_slot_keys(&self.segments, &mut out);
out
}
pub fn pipe_names(&self) -> Vec<String> {
let mut out = Vec::new();
collect_pipe_names(&self.segments, &mut out);
out
}
pub fn partial_names(&self) -> Vec<String> {
let mut out = Vec::new();
collect_partial_names(&self.segments, &mut out);
out
}
pub fn infer_types(&self) -> Result<Vec<(String, ValueType)>, String> {
let mut by_slot: Vec<(String, ValueType)> = Vec::new();
infer_segments(&self.segments, &mut by_slot)?;
Ok(by_slot)
}
pub fn as_bare_slots(&self) -> Option<Vec<BareSegment<'_>>> {
let mut out = Vec::new();
for seg in &self.segments {
match seg {
Segment::Literal(s) => out.push(BareSegment::Text(s.as_str())),
Segment::Slot { pipes, key } if pipes.is_empty() => {
out.push(BareSegment::Slot(key.as_str()));
}
_ => return None,
}
}
Some(out)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BareSegment<'a> {
Text(&'a str),
Slot(&'a str),
}
fn collect_literals<'a>(segments: &'a [Segment], out: &mut Vec<&'a str>) {
for seg in segments {
match seg {
Segment::Literal(s) => out.push(s.as_str()),
Segment::Slot { .. } => {}
Segment::Conditional { inner, .. } => collect_literals(inner, out),
Segment::Partial { .. } => {}
}
}
}
fn collect_slot_keys(segments: &[Segment], out: &mut Vec<String>) {
for seg in segments {
match seg {
Segment::Slot { key, .. } => out.push(key.clone()),
Segment::Conditional {
condition_key,
inner,
} => {
out.push(condition_key.clone());
collect_slot_keys(inner, out);
}
Segment::Literal(_) | Segment::Partial { .. } => {}
}
}
}
fn collect_partial_names(segments: &[Segment], out: &mut Vec<String>) {
for seg in segments {
match seg {
Segment::Partial { name } => out.push(name.clone()),
Segment::Conditional { inner, .. } => collect_partial_names(inner, out),
Segment::Literal(_) | Segment::Slot { .. } => {}
}
}
}
fn collect_pipe_names(segments: &[Segment], out: &mut Vec<String>) {
for seg in segments {
match seg {
Segment::Slot { pipes, .. } => {
for pipe in pipes {
out.push(pipe.name.clone());
}
}
Segment::Conditional { inner, .. } => collect_pipe_names(inner, out),
Segment::Literal(_) | Segment::Partial { .. } => {}
}
}
}
fn parse_segments(source: &str, start: usize, end: usize) -> Result<Vec<Segment>, ProsaicError> {
let mut segments = Vec::new();
let slice = &source[start..end];
let bytes = slice.as_bytes();
let mut i: usize = 0;
let mut literal_start: usize = 0;
while i < bytes.len() {
if bytes[i] != b'{' {
i += 1;
continue;
}
if i > literal_start {
segments.push(Segment::Literal(slice[literal_start..i].to_string()));
}
let content_start = i + 1;
let is_conditional = content_start < bytes.len() && bytes[content_start] == b'?';
let is_partial = content_start < bytes.len() && bytes[content_start] == b'>';
let is_closing = content_start + 1 < bytes.len()
&& bytes[content_start] == b'/'
&& bytes[content_start + 1] == b'?';
if is_closing {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "unexpected closing `{/?}` without opening".to_string(),
});
}
if is_partial {
let name_start = content_start + 1; let name_end = slice[name_start..]
.find('}')
.map(|rel| name_start + rel)
.ok_or_else(|| ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "unclosed `{>`".to_string(),
})?;
let name = slice[name_start..name_end].trim().to_string();
if name.is_empty() {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "empty partial name".to_string(),
});
}
segments.push(Segment::Partial { name });
i = name_end + 1;
literal_start = i;
continue;
}
if is_conditional {
let key_start = content_start + 1; let key_end = slice[key_start..]
.find('}')
.map(|rel| key_start + rel)
.ok_or_else(|| ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "unclosed `{?`".to_string(),
})?;
let condition_key = slice[key_start..key_end].trim().to_string();
if condition_key.is_empty() {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "empty condition key".to_string(),
});
}
let inner_start = key_end + 1;
let inner_end = find_matching_close(slice, inner_start).ok_or_else(|| {
ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: format!("unclosed conditional `{{?{condition_key}}}`"),
}
})?;
let inner_segments = parse_segments(source, start + inner_start, start + inner_end)?;
segments.push(Segment::Conditional {
condition_key,
inner: inner_segments,
});
i = inner_end + 4;
literal_start = i;
} else {
let mut slot_end: Option<usize> = None;
let mut depth: i32 = 1;
let mut j = content_start;
while j < bytes.len() {
match bytes[j] {
b'{' => depth += 1,
b'}' => {
depth -= 1;
if depth == 0 {
slot_end = Some(j);
break;
}
}
_ => {}
}
j += 1;
}
let slot_end = slot_end.ok_or_else(|| ProsaicError::TemplateParseError {
template: source.to_string(),
position: start + i,
reason: "unclosed `{`".to_string(),
})?;
let slot_content = &slice[content_start..slot_end];
let segment = parse_slot(slot_content, source, start + i)?;
segments.push(segment);
i = slot_end + 1;
literal_start = i;
}
}
if literal_start < slice.len() {
segments.push(Segment::Literal(slice[literal_start..].to_string()));
}
Ok(segments)
}
fn find_matching_close(slice: &str, start: usize) -> Option<usize> {
let mut depth: i32 = 1;
let bytes = slice.as_bytes();
let mut i = start;
while i < bytes.len() {
if i + 1 < bytes.len() && bytes[i] == b'{' {
if bytes[i + 1] == b'?' {
depth += 1;
i += 2;
continue;
}
if i + 2 < bytes.len() && bytes[i + 1] == b'/' && bytes[i + 2] == b'?' {
depth -= 1;
if depth == 0 {
return Some(i);
}
i += 3;
continue;
}
}
i += 1;
}
None
}
fn parse_slot(content: &str, source: &str, position: usize) -> Result<Segment, ProsaicError> {
let parts: Vec<&str> = content.split('|').collect();
let key = parts[0].trim();
if key.is_empty() {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position,
reason: "empty slot key".to_string(),
});
}
let mut pipes = Vec::new();
for part in &parts[1..] {
let pipe = parse_pipe(part.trim(), source, position)?;
pipes.push(pipe);
}
Ok(Segment::Slot {
key: key.to_string(),
pipes,
})
}
fn parse_pipe(content: &str, source: &str, position: usize) -> Result<Pipe, ProsaicError> {
if content.is_empty() {
return Err(ProsaicError::TemplateParseError {
template: source.to_string(),
position,
reason: "empty pipe name".to_string(),
});
}
if let Some((name, arg_str)) = content.split_once(':') {
let name = name.trim();
let arg_str = arg_str.trim();
let arg = if let Ok(n) = arg_str.parse::<usize>() {
PipeArg::Number(n)
} else {
PipeArg::String(arg_str.to_string())
};
Ok(Pipe {
name: name.to_string(),
arg: Some(arg),
})
} else {
Ok(Pipe {
name: content.to_string(),
arg: None,
})
}
}
fn infer_segments(segments: &[Segment], out: &mut Vec<(String, ValueType)>) -> Result<(), String> {
for seg in segments {
match seg {
Segment::Literal(_) | Segment::Partial { .. } => {}
Segment::Slot { key, pipes } => {
let slot_ty = slot_type_from_pipes(key, pipes)?;
unify(out, key, slot_ty)?;
}
Segment::Conditional {
condition_key,
inner,
} => {
unify(out, condition_key, ValueType::Any)?;
infer_segments(inner, out)?;
}
}
}
Ok(())
}
fn slot_type_from_pipes(key: &str, pipes: &[Pipe]) -> Result<ValueType, String> {
let Some(first) = pipes.first() else {
return Ok(ValueType::Any);
};
let first_spec = lookup_spec(&first.name)?;
let slot_ty = first_spec.input;
let mut current_output = first_spec.output;
let mut prev_name: &str = &first.name;
for next in &pipes[1..] {
let next_spec = lookup_spec(&next.name)?;
if !types_compatible(current_output, next_spec.input) {
return Err(format!(
"pipe chain mismatch on slot `{key}`: \
pipe `{prev_name}` outputs {current_output:?} but pipe `{cur}` expects {expected:?}",
cur = next.name,
expected = next_spec.input,
));
}
current_output = next_spec.output;
prev_name = &next.name;
}
Ok(slot_ty)
}
fn lookup_spec(name: &str) -> Result<&'static PipeSpec, String> {
pipe_spec(name).ok_or_else(|| format!("unknown pipe `{name}`"))
}
fn unify(out: &mut Vec<(String, ValueType)>, key: &str, ty: ValueType) -> Result<(), String> {
if let Some(entry) = out.iter_mut().find(|(k, _)| k == key) {
entry.1 = match (entry.1, ty) {
(ValueType::Any, t) | (t, ValueType::Any) => t,
(a, b) if a == b => a,
(a, b) => {
return Err(format!(
"slot `{key}` has conflicting types: used as both {a:?} and {b:?}"
));
}
};
} else {
out.push((key.to_string(), ty));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_literal_only() {
let t = Template::parse("hello world").unwrap();
assert_eq!(
t.segments,
vec![Segment::Literal("hello world".to_string())]
);
}
#[test]
fn parse_single_slot() {
let t = Template::parse("{name}").unwrap();
assert_eq!(
t.segments,
vec![Segment::Slot {
key: "name".to_string(),
pipes: vec![],
}]
);
}
#[test]
fn parse_slot_with_surrounding_text() {
let t = Template::parse("Hello {name}!").unwrap();
assert_eq!(
t.segments,
vec![
Segment::Literal("Hello ".to_string()),
Segment::Slot {
key: "name".to_string(),
pipes: vec![],
},
Segment::Literal("!".to_string()),
]
);
}
#[test]
fn parse_slot_with_pipe() {
let t = Template::parse("{name|capitalize}").unwrap();
assert_eq!(
t.segments,
vec![Segment::Slot {
key: "name".to_string(),
pipes: vec![Pipe {
name: "capitalize".to_string(),
arg: None,
}],
}]
);
}
#[test]
fn parse_slot_with_pipe_and_string_arg() {
let t = Template::parse("{count|pluralize:item}").unwrap();
assert_eq!(
t.segments,
vec![Segment::Slot {
key: "count".to_string(),
pipes: vec![Pipe {
name: "pluralize".to_string(),
arg: Some(PipeArg::String("item".to_string())),
}],
}]
);
}
#[test]
fn parse_slot_with_pipe_and_number_arg() {
let t = Template::parse("{items|truncate:3}").unwrap();
assert_eq!(
t.segments,
vec![Segment::Slot {
key: "items".to_string(),
pipes: vec![Pipe {
name: "truncate".to_string(),
arg: Some(PipeArg::Number(3)),
}],
}]
);
}
#[test]
fn parse_chained_pipes() {
let t = Template::parse("{items|truncate:3|join}").unwrap();
assert_eq!(
t.segments,
vec![Segment::Slot {
key: "items".to_string(),
pipes: vec![
Pipe {
name: "truncate".to_string(),
arg: Some(PipeArg::Number(3)),
},
Pipe {
name: "join".to_string(),
arg: None,
},
],
}]
);
}
#[test]
fn parse_multiple_slots() {
let t = Template::parse("{a} and {b}").unwrap();
assert_eq!(
t.segments,
vec![
Segment::Slot {
key: "a".to_string(),
pipes: vec![],
},
Segment::Literal(" and ".to_string()),
Segment::Slot {
key: "b".to_string(),
pipes: vec![],
},
]
);
}
#[test]
fn parse_unclosed_brace_is_error() {
let result = Template::parse("hello {name");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_empty_slot_is_error() {
let result = Template::parse("hello {}");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_empty_pipe_name_is_error() {
let result = Template::parse("{name|}");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_complex_template() {
let t = Template::parse(
"The {entity_type} {old_name} was renamed to {new_name} \
which impacts {count} direct {count|pluralize:consumer} \
[{consumers|truncate:3|join}]",
)
.unwrap();
assert_eq!(t.segments.len(), 13);
}
#[test]
fn parse_conditional_section() {
let t = Template::parse("foo{?count} bar{/?} baz").unwrap();
assert_eq!(t.segments.len(), 3);
assert_eq!(t.segments[0], Segment::Literal("foo".into()));
assert!(matches!(t.segments[1], Segment::Conditional { .. }));
assert_eq!(t.segments[2], Segment::Literal(" baz".into()));
}
#[test]
fn parse_conditional_with_inner_slot() {
let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
assert_eq!(t.segments.len(), 2);
if let Segment::Conditional {
condition_key,
inner,
} = &t.segments[1]
{
assert_eq!(condition_key, "count");
assert_eq!(inner.len(), 3); } else {
panic!("Expected Conditional segment");
}
}
#[test]
fn parse_unclosed_conditional_is_error() {
let result = Template::parse("{?count} never closed");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_empty_conditional_key_is_error() {
let result = Template::parse("{?}content{/?}");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_partial_reference() {
let t = Template::parse("start {>tail} end").unwrap();
assert_eq!(t.segments.len(), 3);
assert!(matches!(&t.segments[1], Segment::Partial { name } if name == "tail"));
}
#[test]
fn parse_empty_partial_name_is_error() {
let result = Template::parse("{>}");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn parse_unclosed_partial_is_error() {
let result = Template::parse("{>tail");
assert!(matches!(
result,
Err(ProsaicError::TemplateParseError { .. })
));
}
#[test]
fn literal_tokens_simple() {
let t = Template::parse("The {type} {name} was modified").unwrap();
let lits = t.literal_tokens();
assert_eq!(lits, vec!["The ", " ", " was modified"]);
}
#[test]
fn literal_tokens_from_conditional_sections() {
let t = Template::parse("{name}{?count}, impacting {count} consumers{/?}").unwrap();
let lits = t.literal_tokens();
assert!(lits.iter().any(|l| l.contains("impacting")));
assert!(lits.iter().any(|l| l.contains("consumers")));
}
#[test]
fn literal_tokens_empty_for_all_slots() {
let t = Template::parse("{a}{b}{c}").unwrap();
assert!(t.literal_tokens().is_empty());
}
#[test]
fn literal_tokens_skips_partial_nodes() {
let t = Template::parse("prefix {>partial_name} suffix").unwrap();
let lits = t.literal_tokens();
assert_eq!(lits, vec!["prefix ", " suffix"]);
}
#[test]
fn literal_tokens_nested_conditional_recursion() {
let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
let lits = t.literal_tokens();
assert!(lits.iter().any(|l| l.contains("outer")));
assert!(lits.iter().any(|l| l.contains("inner")));
}
#[test]
fn slot_keys_simple() {
let t = Template::parse("{a} and {b}").unwrap();
let mut keys = t.slot_keys();
keys.sort();
assert_eq!(keys, vec!["a", "b"]);
}
#[test]
fn slot_keys_includes_condition_key() {
let t = Template::parse("{name}{?count}, {count} items{/?}").unwrap();
let keys = t.slot_keys();
assert!(keys.contains(&"name".to_string()));
assert!(keys.iter().filter(|k| k.as_str() == "count").count() >= 2);
}
#[test]
fn slot_keys_skips_partials() {
let t = Template::parse("start {>partial_name} {slot} end").unwrap();
let keys = t.slot_keys();
assert_eq!(keys, vec!["slot"]);
assert!(!keys.contains(&"partial_name".to_string()));
}
#[test]
fn slot_keys_empty_for_literal_only() {
let t = Template::parse("just a string").unwrap();
assert!(t.slot_keys().is_empty());
}
#[test]
fn slot_keys_nested_conditional() {
let t = Template::parse("{?a}outer{?b} inner{/?}{/?}").unwrap();
let mut keys = t.slot_keys();
keys.sort();
keys.dedup();
assert_eq!(keys, vec!["a", "b"]);
}
#[test]
fn pipe_names_simple() {
let t = Template::parse("{count|pluralize:item}").unwrap();
assert_eq!(t.pipe_names(), vec!["pluralize"]);
}
#[test]
fn pipe_names_chained() {
let t = Template::parse("{items|truncate:3|join}").unwrap();
assert_eq!(t.pipe_names(), vec!["truncate", "join"]);
}
#[test]
fn pipe_names_empty_when_no_pipes() {
let t = Template::parse("{name} and {other}").unwrap();
assert!(t.pipe_names().is_empty());
}
#[test]
fn pipe_names_inside_conditional() {
let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
assert_eq!(t.pipe_names(), vec!["pluralize"]);
}
#[test]
fn pipe_names_arg_not_included_in_name() {
let t = Template::parse("{items|truncate:3}").unwrap();
let names = t.pipe_names();
assert_eq!(names, vec!["truncate"]);
assert!(!names.iter().any(|n| n.contains(':')));
}
use prosaic_common::ValueType;
fn types(t: &Template) -> Vec<(String, ValueType)> {
let mut v = t.infer_types().expect("expected successful inference");
v.sort_by(|a, b| a.0.cmp(&b.0));
v
}
#[test]
fn infer_bare_slot_is_any() {
let t = Template::parse("{x}").unwrap();
assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
}
#[test]
fn infer_slot_with_number_pipe_is_number() {
let t = Template::parse("{count|pluralize:item}").unwrap();
assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
}
#[test]
fn infer_slot_input_is_first_pipe_input_for_list_chain() {
let t = Template::parse("{items|truncate:3|join}").unwrap();
assert_eq!(types(&t), vec![("items".into(), ValueType::List)]);
}
#[test]
fn infer_chain_mismatch_is_error() {
let t = Template::parse("{x|capitalize|pluralize}").unwrap();
let err = t.infer_types().unwrap_err();
assert!(err.contains("capitalize"), "error was: {err}");
assert!(err.contains("pluralize"), "error was: {err}");
assert!(
err.contains("String"),
"error should name the output type; got: {err}"
);
assert!(
err.contains("Number"),
"error should name the expected input; got: {err}"
);
}
#[test]
fn infer_multi_mention_any_and_number_unifies_to_number() {
let t = Template::parse("{x|pluralize:item} {x}").unwrap();
assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
}
#[test]
fn infer_multi_mention_conflict_is_error() {
let t = Template::parse("{x|pluralize:item} {x|join}").unwrap();
let err = t.infer_types().unwrap_err();
assert!(err.contains("`x`"), "error was: {err}");
assert!(err.contains("Number"), "error was: {err}");
assert!(err.contains("List"), "error was: {err}");
}
#[test]
fn infer_same_bare_slot_twice_stays_any() {
let t = Template::parse("{x} and {x}").unwrap();
assert_eq!(types(&t), vec![("x".into(), ValueType::Any)]);
}
#[test]
fn infer_unknown_pipe_is_error() {
let t = Template::parse("{x|nonexistent_pipe}").unwrap();
let err = t.infer_types().unwrap_err();
assert!(err.contains("nonexistent_pipe"), "error was: {err}");
}
#[test]
fn infer_conditional_guard_slot_is_any() {
let t = Template::parse("{?count}hello{/?}").unwrap();
let ts = types(&t);
assert_eq!(ts, vec![("count".into(), ValueType::Any)]);
}
#[test]
fn infer_pipes_inside_conditional_are_checked() {
let t = Template::parse("{?count}{count|pluralize:item}{/?}").unwrap();
assert_eq!(types(&t), vec![("count".into(), ValueType::Number)]);
}
#[test]
fn infer_literal_only_is_empty() {
let t = Template::parse("no slots").unwrap();
assert_eq!(types(&t), vec![]);
}
#[test]
fn infer_skips_partial_nodes() {
let t = Template::parse("{x|pluralize:item} {>some_partial}").unwrap();
assert_eq!(types(&t), vec![("x".into(), ValueType::Number)]);
}
#[test]
fn as_bare_slots_accepts_bare_template() {
let t = Template::parse("Hello {name} world").unwrap();
let segs = t.as_bare_slots().unwrap();
assert_eq!(segs.len(), 3);
assert_eq!(segs[0], BareSegment::Text("Hello "));
assert_eq!(segs[1], BareSegment::Slot("name"));
assert_eq!(segs[2], BareSegment::Text(" world"));
}
#[test]
fn as_bare_slots_accepts_literal_only_template() {
let t = Template::parse("no slots here").unwrap();
let segs = t.as_bare_slots().unwrap();
assert_eq!(segs.len(), 1);
assert_eq!(segs[0], BareSegment::Text("no slots here"));
}
#[test]
fn as_bare_slots_accepts_multiple_bare_slots() {
let t = Template::parse("{greeting}, {name}!").unwrap();
let segs = t.as_bare_slots().unwrap();
assert_eq!(segs.len(), 4);
assert_eq!(segs[0], BareSegment::Slot("greeting"));
assert_eq!(segs[1], BareSegment::Text(", "));
assert_eq!(segs[2], BareSegment::Slot("name"));
assert_eq!(segs[3], BareSegment::Text("!"));
}
#[test]
fn as_bare_slots_rejects_piped_template() {
let t = Template::parse("Hello {name|capitalize}").unwrap();
assert!(t.as_bare_slots().is_none());
}
#[test]
fn as_bare_slots_rejects_conditional_template() {
let t = Template::parse("Hello{?greet} friend{/?}").unwrap();
assert!(t.as_bare_slots().is_none());
}
#[test]
fn as_bare_slots_rejects_partial_template() {
let t = Template::parse("prefix {>partial_name} suffix").unwrap();
assert!(t.as_bare_slots().is_none());
}
#[test]
fn as_bare_slots_rejects_chained_pipes() {
let t = Template::parse("{items|truncate:3|join}").unwrap();
assert!(t.as_bare_slots().is_none());
}
}