use std::ops::Range;
use crate::parse::scan::{CLOSE, OPEN, find_tag_end};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct TagSpan {
pub outer: Range<usize>,
pub inner: Range<usize>,
pub lines: [usize; 2],
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum Block {
Markdown(Range<usize>),
Tag(TagSpan),
}
pub(crate) struct Segmentation {
pub masked: String,
pub blocks: Vec<Block>,
pub inline_tags: Vec<TagSpan>,
}
pub(crate) fn segment(source: &str) -> Segmentation {
let lines = line_ranges(source);
let mut state = Scan {
source,
masked: source.as_bytes().to_vec(),
blocks: Vec::new(),
inline_tags: Vec::new(),
markdown_start: 0,
fence: None,
};
let mut index = 0;
while index < lines.len() {
index = state.line(&lines, index);
}
state.flush_markdown(source.len());
Segmentation {
masked: String::from_utf8(state.masked).unwrap_or_else(|_| source.to_string()),
blocks: state.blocks,
inline_tags: state.inline_tags,
}
}
struct Fence {
marker: u8,
length: usize,
}
struct Scan<'s> {
source: &'s str,
masked: Vec<u8>,
blocks: Vec<Block>,
inline_tags: Vec<TagSpan>,
markdown_start: usize,
fence: Option<Fence>,
}
impl Scan<'_> {
fn line(&mut self, lines: &[Range<usize>], index: usize) -> usize {
let Some(line) = lines.get(index) else {
return index + 1;
};
let text = self.source.get(line.clone()).unwrap_or("");
if let Some(fence) = &self.fence {
if closes_fence(text, fence) {
self.fence = None;
}
return index + 1;
}
if let Some(fence) = opens_fence(text) {
self.fence = Some(fence);
return index + 1;
}
let content_start = line.start + indent_of(text);
if let Some(span) = self.block_tag(lines, index, content_start) {
let next = span.lines[1];
self.flush_markdown(line.start);
self.markdown_start = lines.get(next).map_or(self.source.len(), |line| line.start);
self.blocks.push(Block::Tag(span));
return next.max(index + 1);
}
self.mask_inline_tags(lines, index)
}
fn block_tag(
&self,
lines: &[Range<usize>],
index: usize,
content_start: usize,
) -> Option<TagSpan> {
if !self.source.get(content_start..)?.starts_with(OPEN) {
return None;
}
let tag_end = find_tag_end(self.source, content_start)?;
let inner = content_start + OPEN.len()..tag_end;
if self
.source
.get(inner.clone())?
.trim_start()
.starts_with('$')
{
return None;
}
let outer_end = tag_end + CLOSE.len();
let last = line_of(lines, index, outer_end);
let rest = self.source.get(outer_end..lines.get(last)?.end)?;
if !rest.trim().is_empty() {
return None;
}
Some(TagSpan {
outer: content_start..outer_end,
inner,
lines: [index, last + 1],
})
}
fn mask_inline_tags(&mut self, lines: &[Range<usize>], index: usize) -> usize {
let Some(line) = lines.get(index) else {
return index + 1;
};
let mut pos = line.start;
let mut last = index;
while pos < line.end {
if self
.source
.get(pos..)
.is_none_or(|rest| !rest.starts_with(OPEN))
{
pos += 1;
continue;
}
let Some(tag_end) = find_tag_end(self.source, pos) else {
pos += OPEN.len();
continue;
};
let inner = pos + OPEN.len()..tag_end;
let outer_end = tag_end + CLOSE.len();
last = line_of(lines, index, outer_end);
self.mask(inner.clone());
self.inline_tags.push(TagSpan {
outer: pos..outer_end,
inner,
lines: [index, last + 1],
});
pos = outer_end;
}
last + 1
}
fn mask(&mut self, range: Range<usize>) {
for index in range {
if let Some(byte) = self.masked.get_mut(index)
&& *byte != b'\n'
{
*byte = b'x';
}
}
}
fn flush_markdown(&mut self, end: usize) {
if end > self.markdown_start {
self.blocks.push(Block::Markdown(
self.markdown_start..end.min(self.source.len()),
));
}
}
}
fn line_of(lines: &[Range<usize>], from: usize, offset: usize) -> usize {
for (index, line) in lines.iter().enumerate().skip(from) {
if offset <= line.end {
return index;
}
}
lines.len().saturating_sub(1).max(from)
}
fn line_ranges(source: &str) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut start = 0;
for (index, byte) in source.bytes().enumerate() {
if byte == b'\n' {
out.push(start..index);
start = index + 1;
}
}
if start <= source.len() {
out.push(start..source.len());
}
out
}
fn indent_of(line: &str) -> usize {
line.len() - line.trim_start_matches([' ', '\t']).len()
}
fn opens_fence(line: &str) -> Option<Fence> {
let trimmed = line.trim_start_matches([' ', '\t']);
let marker = trimmed.bytes().next()?;
if marker != b'`' && marker != b'~' {
return None;
}
let length = trimmed.bytes().take_while(|&byte| byte == marker).count();
if length < 3 {
return None;
}
if marker == b'`' && trimmed.get(length..).is_some_and(|info| info.contains('`')) {
return None;
}
Some(Fence { marker, length })
}
fn closes_fence(line: &str, fence: &Fence) -> bool {
let trimmed = line.trim_start_matches([' ', '\t']);
let length = trimmed
.bytes()
.take_while(|&byte| byte == fence.marker)
.count();
length >= fence.length
&& trimmed
.get(length..)
.is_some_and(|rest| rest.trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
fn blocks(source: &str) -> Vec<String> {
segment(source)
.blocks
.iter()
.map(|block| match block {
Block::Markdown(range) => format!("markdown {:?}", &source[range.clone()]),
Block::Tag(span) => format!("tag {:?}", &source[span.inner.clone()]),
})
.collect()
}
#[test]
fn a_tag_on_its_own_line_splits_the_document() {
assert_eq!(
blocks("{% foo %}\nThis is a test\n{% /foo %}\n"),
[
"tag \" foo \"",
"markdown \"This is a test\\n\"",
"tag \" /foo \"",
]
);
}
#[test]
fn a_tag_with_text_after_it_is_not_a_block() {
assert_eq!(
blocks("{% foo %} trailing\n"),
["markdown \"{% foo %} trailing\\n\""]
);
assert_eq!(segment("{% foo %} trailing\n").inline_tags.len(), 1);
}
#[test]
fn a_bare_variable_is_not_a_block_tag() {
assert_eq!(blocks("{% $test %}\n"), ["markdown \"{% $test %}\\n\""]);
}
#[test]
fn a_block_tag_may_span_lines() {
let source = "{%\nfoo\n#bar\n%}\nThis is a test\n";
assert_eq!(
blocks(source),
["tag \"\\nfoo\\n#bar\\n\"", "markdown \"This is a test\\n\""]
);
let Block::Tag(span) = &segment(source).blocks[0] else {
panic!("expected a tag");
};
assert_eq!(span.lines, [0, 4]);
}
#[test]
fn a_block_tag_may_close_on_a_line_with_its_own_content() {
assert_eq!(
blocks("{% foo\n#bar %}\nThis is a test\n"),
["tag \" foo\\n#bar \"", "markdown \"This is a test\\n\""]
);
}
#[test]
fn an_indented_block_tag_is_still_a_block_tag() {
assert_eq!(blocks(" {% foo %}\n"), ["tag \" foo \""]);
}
#[test]
fn tags_inside_a_fence_are_left_alone() {
let source = "```\n{% foo %}\n```\n";
assert_eq!(blocks(source), [format!("markdown {source:?}")]);
assert!(segment(source).inline_tags.is_empty());
}
#[test]
fn a_tilde_fence_closes_only_on_tildes() {
let source = "~~~\n{% foo %}\n```\n{% bar %}\n~~~\n";
assert!(segment(source).inline_tags.is_empty());
}
#[test]
fn an_inline_code_span_does_not_open_a_fence() {
let source = "a ```b``` c\n{% foo %}\n";
assert!(
segment(source)
.blocks
.iter()
.any(|block| matches!(block, Block::Tag(_)))
);
}
#[test]
fn masking_hides_markdown_inside_a_tag_and_keeps_every_offset() {
let source = "Example {% foo bar=\"a*b*c\" %} baz";
let segmentation = segment(source);
assert_eq!(segmentation.masked.len(), source.len());
assert_eq!(segmentation.masked, "Example {%xxxxxxxxxxxxxxxxx%} baz");
assert_eq!(segmentation.inline_tags.len(), 1);
assert_eq!(
&source[segmentation.inline_tags[0].inner.clone()],
" foo bar=\"a*b*c\" "
);
}
#[test]
fn masking_preserves_newlines_so_block_structure_survives() {
let source = "Example {% foo\n#bar %} baz";
assert_eq!(segment(source).masked, "Example {%xxxx\nxxxxx%} baz");
}
#[test]
fn two_tags_in_succession_are_both_recorded() {
let source = "a {% foo %}b{% /foo %} c";
let segmentation = segment(source);
assert_eq!(segmentation.inline_tags.len(), 2);
assert_eq!(segmentation.masked, "a {%xxxxx%}b{%xxxxxx%} c");
}
#[test]
fn an_unclosed_tag_does_not_stall_the_scan() {
let source = "hello {%\nworld\n";
let segmentation = segment(source);
assert!(segmentation.inline_tags.is_empty());
assert_eq!(segmentation.masked, source);
}
#[test]
fn an_empty_document_produces_nothing() {
assert!(segment("").blocks.is_empty());
}
}