use somni_parser::Location;
use crate::{
error::TemplateError,
syntax::{BlockStyle, Syntax},
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Segment {
Text(Location),
Interp(Location),
Directive {
inner: Location,
full: Location,
},
}
fn trimmed(source: &str, start: usize, end: usize) -> Location {
let slice = &source[start..end];
let trimmed = slice.trim();
if trimmed.is_empty() {
return Location { start, end: start };
}
let offset = trimmed.as_ptr() as usize - slice.as_ptr() as usize;
Location {
start: start + offset,
end: start + offset + trimmed.len(),
}
}
fn find_from(source: &str, from: usize, needle: &str) -> Option<usize> {
source[from..].find(needle).map(|i| from + i)
}
pub fn scan(source: &str, syntax: &Syntax) -> Result<Vec<Segment>, TemplateError> {
match &syntax.block {
BlockStyle::Paired { open, close } => scan_paired(source, &syntax.expr, open, close),
BlockStyle::Line { prefix } => scan_lines(source, &syntax.expr, prefix),
}
}
fn push_text(out: &mut Vec<Segment>, start: usize, end: usize) {
if end > start {
out.push(Segment::Text(Location { start, end }));
}
}
fn scan_paired(
source: &str,
expr: &(String, String),
block_open: &str,
block_close: &str,
) -> Result<Vec<Segment>, TemplateError> {
let (expr_open, expr_close) = expr;
let mut out = Vec::new();
let mut pos = 0;
while pos < source.len() {
let next_expr = find_from(source, pos, expr_open);
let next_block = find_from(source, pos, block_open);
let (at, is_expr) = match (next_expr, next_block) {
(None, None) => {
push_text(&mut out, pos, source.len());
break;
}
(Some(e), None) => (e, true),
(None, Some(b)) => (b, false),
(Some(e), Some(b)) => {
if e <= b {
(e, true)
} else {
(b, false)
}
}
};
push_text(&mut out, pos, at);
if is_expr {
let inner_start = at + expr_open.len();
let Some(close_at) = find_from(source, inner_start, expr_close) else {
return Err(TemplateError::new(
format!("unterminated interpolation, expected `{expr_close}`"),
Location {
start: at,
end: source.len(),
},
));
};
out.push(Segment::Interp(trimmed(source, inner_start, close_at)));
pos = close_at + expr_close.len();
} else {
let inner_start = at + block_open.len();
let Some(close_at) = find_from(source, inner_start, block_close) else {
return Err(TemplateError::new(
format!("unterminated block directive, expected `{block_close}`"),
Location {
start: at,
end: source.len(),
},
));
};
out.push(Segment::Directive {
inner: trimmed(source, inner_start, close_at),
full: Location {
start: at,
end: close_at + block_close.len(),
},
});
pos = close_at + block_close.len();
}
}
Ok(out)
}
fn scan_lines(
source: &str,
expr: &(String, String),
prefix: &str,
) -> Result<Vec<Segment>, TemplateError> {
let (expr_open, expr_close) = expr;
let mut out = Vec::new();
let mut line_start = 0;
while line_start < source.len() {
let (line_end, next_line) = match find_from(source, line_start, "\n") {
Some(nl) => (nl, nl + 1),
None => (source.len(), source.len()),
};
let line = &source[line_start..line_end];
let leading_ws = line.len() - line.trim_start().len();
let content_start = line_start + leading_ws;
let is_directive = source[content_start..line_end].starts_with(prefix);
if is_directive {
let inner_start = content_start + prefix.len();
out.push(Segment::Directive {
inner: trimmed(source, inner_start, line_end),
full: Location {
start: line_start,
end: next_line,
},
});
line_start = next_line;
} else {
scan_text_run(
source, line_start, next_line, expr_open, expr_close, &mut out,
)?;
line_start = next_line;
}
}
Ok(out)
}
fn scan_text_run(
source: &str,
start: usize,
end: usize,
expr_open: &str,
expr_close: &str,
out: &mut Vec<Segment>,
) -> Result<(), TemplateError> {
let mut pos = start;
while pos < end {
match find_from(source, pos, expr_open) {
Some(at) if at < end => {
push_text(out, pos, at);
let inner_start = at + expr_open.len();
let Some(close_at) = find_from(source, inner_start, expr_close) else {
return Err(TemplateError::new(
format!("unterminated interpolation, expected `{expr_close}`"),
Location {
start: at,
end: source.len(),
},
));
};
out.push(Segment::Interp(trimmed(source, inner_start, close_at)));
pos = close_at + expr_close.len();
}
_ => {
push_text(out, pos, end);
break;
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn text<'s>(source: &'s str, seg: &Segment) -> &'s str {
match seg {
Segment::Text(l) | Segment::Interp(l) => l.extract(source),
Segment::Directive { inner, .. } => inner.extract(source),
}
}
#[test]
fn plain_text_is_single_text_segment() {
let src = "hello world";
let segs = scan(src, &Syntax::brackets()).unwrap();
assert_eq!(segs, vec![Segment::Text(Location { start: 0, end: 11 })]);
}
#[test]
fn brackets_interpolation() {
let src = "hi {{ name }}!";
let segs = scan(src, &Syntax::brackets()).unwrap();
assert_eq!(segs.len(), 3);
assert_eq!(text(src, &segs[0]), "hi ");
assert!(matches!(segs[1], Segment::Interp(_)));
assert_eq!(text(src, &segs[1]), "name");
assert_eq!(text(src, &segs[2]), "!");
}
#[test]
fn brackets_directive_and_expr_ordering() {
let src = "{% if x %}a{{ y }}{% endif %}";
let segs = scan(src, &Syntax::brackets()).unwrap();
assert!(matches!(segs[0], Segment::Directive { .. }));
assert_eq!(text(src, &segs[0]), "if x");
assert_eq!(text(src, &segs[1]), "a");
assert_eq!(text(src, &segs[2]), "y");
assert_eq!(text(src, &segs[3]), "endif");
}
#[test]
fn line_directive_swallows_whole_line() {
let src = "#if x\nhello\n#endif\n";
let segs = scan(src, &Syntax::lines()).unwrap();
assert!(matches!(segs[0], Segment::Directive { .. }));
assert_eq!(text(src, &segs[0]), "if x");
assert_eq!(text(src, &segs[1]), "hello\n");
assert!(matches!(segs[2], Segment::Directive { .. }));
assert_eq!(text(src, &segs[2]), "endif");
assert_eq!(segs.len(), 3);
}
#[test]
fn line_style_interpolation_inline() {
let src = "name: {{ user }}\n";
let segs = scan(src, &Syntax::lines()).unwrap();
assert_eq!(text(src, &segs[0]), "name: ");
assert_eq!(text(src, &segs[1]), "user");
assert_eq!(text(src, &segs[2]), "\n");
}
#[test]
fn unterminated_interpolation_errors() {
let src = "hi {{ name";
let err = scan(src, &Syntax::brackets()).unwrap_err();
assert!(err.message.contains("unterminated interpolation"));
}
}