use alloc::{
string::{String, ToString},
vec::Vec,
};
use crate::{
consts::{
BLOCKQUOTE_COMPACT_OPEN, BLOCKQUOTE_PREFIX, BLOCKQUOTE_PREFIX_SPACED,
BLOCKQUOTE_SPACED_OPEN, ERR_BARE_STMT_TAG, STMT_END, STMT_START,
},
error::TemplateError,
};
const SNIPPET_MAX_DISPLAY_LEN: usize = 60;
const SNIPPET_TRUNCATION_BOUNDARY: usize = SNIPPET_MAX_DISPLAY_LEN - 3;
fn is_valid_tag_neighbor(line: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with("---") {
return true;
}
if trimmed.starts_with('>') {
let stripped = strip_blockquote_line(line);
let was_stripped = !core::ptr::eq(stripped, line);
return was_stripped && stripped.trim_start().starts_with(STMT_START);
}
false
}
pub(super) fn validate_blockquote_prefix(input: &str) -> Result<(), TemplateError> {
let mut in_raw = false;
let lines: Vec<&str> = input.lines().collect();
for (i, &line) in lines.iter().enumerate() {
let trimmed = line.trim_start();
if in_raw {
if trimmed.contains("{%") && (trimmed.contains("/raw") || trimmed.contains("- /raw")) {
in_raw = false;
let stripped = strip_blockquote_line(line);
let was_stripped = !core::ptr::eq(stripped, line);
if was_stripped && is_standalone_tag(stripped) {
validate_tag_neighbors(&lines, i, line)?;
}
}
continue;
}
if trimmed.starts_with('>')
&& trimmed.contains("{%")
&& (trimmed.contains(" raw ") || trimmed.contains(" raw=") || trimmed.contains(" raw%"))
{
in_raw = true;
continue;
}
if trimmed.starts_with(crate::consts::COMMENT_START) {
return Err(TemplateError::syntax(
"Comments starting at the beginning of a line must have a blockquote prefix (> {# ... #}) to ensure proper Markdown rendering",
));
}
if trimmed.starts_with(STMT_START) {
let snippet = if trimmed.len() > SNIPPET_MAX_DISPLAY_LEN {
let end = trimmed
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= SNIPPET_TRUNCATION_BOUNDARY)
.last()
.unwrap_or(0);
format!("{}…", &trimmed[..end])
} else {
trimmed.to_string()
};
return Err(TemplateError::syntax(format!(
"{ERR_BARE_STMT_TAG}: write '> {snippet}' instead of '{snippet}'"
)));
}
let stripped = strip_blockquote_line(line);
let was_stripped = !core::ptr::eq(stripped, line);
if was_stripped && is_standalone_tag(stripped) {
validate_tag_neighbors(&lines, i, line)?;
}
}
Ok(())
}
fn validate_tag_neighbors(lines: &[&str], i: usize, line: &str) -> Result<(), TemplateError> {
if i > 0 {
if let Some(&prev_line) = lines.get(i - 1) {
if !is_valid_tag_neighbor(prev_line) {
return Err(TemplateError::syntax(format!(
"Standalone statement tag '{}' must be preceded by a blank line or another blockquote tag line (> {{%...%}})",
line.trim()
)));
}
}
}
if i + 1 < lines.len() {
if let Some(&next_line) = lines.get(i + 1) {
if !is_valid_tag_neighbor(next_line) {
return Err(TemplateError::syntax(format!(
"Standalone statement tag '{}' must be followed by a blank line or another blockquote tag line (> {{%...%}})",
line.trim()
)));
}
}
}
Ok(())
}
pub(super) fn strip_blockquote_tags(input: &str) -> alloc::borrow::Cow<'_, str> {
if !input.contains(BLOCKQUOTE_COMPACT_OPEN)
&& !input.contains(BLOCKQUOTE_SPACED_OPEN)
&& !input.contains(">{#")
&& !input.contains("> {#")
{
return alloc::borrow::Cow::Borrowed(input);
}
let lines: Vec<&str> = input.split('\n').collect();
let mut result = String::with_capacity(input.len());
let mut after_standalone = false;
let mut pending_blanks: usize = 0;
for (i, &line) in lines.iter().enumerate() {
let stripped = strip_blockquote_line(line);
let was_stripped = !core::ptr::eq(stripped, line);
let is_blank = stripped.trim().is_empty();
let is_standalone = was_stripped && is_standalone_tag(stripped);
if after_standalone && is_blank {
pending_blanks += 1;
continue;
}
if after_standalone && is_standalone {
pending_blanks = 0;
} else if after_standalone {
pending_blanks = pending_blanks.saturating_sub(1); for _ in 0..pending_blanks {
result.push('\n');
}
pending_blanks = 0;
}
if i > 0 && !after_standalone {
result.push('\n');
}
after_standalone = false;
if is_standalone {
if result.ends_with("\n\n") || result == "\n" {
result.pop();
}
after_standalone = true;
}
result.push_str(stripped);
}
alloc::borrow::Cow::Owned(result)
}
pub(super) fn is_standalone_tag(line: &str) -> bool {
let trimmed = line.trim();
if trimmed.starts_with(crate::consts::COMMENT_START)
&& trimmed.ends_with(crate::consts::COMMENT_END)
{
return true;
}
if !trimmed.starts_with(STMT_START) || !trimmed.ends_with(STMT_END) {
return false;
}
let after_open = &trimmed[STMT_START.len()..]; let Some(close_pos) = after_open.find(STMT_END) else {
return false;
};
close_pos + STMT_END.len() == after_open.len()
}
fn strip_blockquote_line(line: &str) -> &str {
let trimmed = line.trim_start();
if let Some(rest) = trimmed.strip_prefix(BLOCKQUOTE_PREFIX_SPACED)
&& (rest.trim_start().starts_with(STMT_START)
|| rest.trim_start().starts_with(crate::consts::COMMENT_START))
{
return rest;
}
if let Some(rest) = trimmed.strip_prefix(BLOCKQUOTE_PREFIX)
&& (rest.trim_start().starts_with(STMT_START)
|| rest.trim_start().starts_with(crate::consts::COMMENT_START))
{
return rest;
}
line
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_indented_blockquote_tags() {
let input = r" > {% for task in tasks %}
- **{{ task.title }}**
> {% /for %}";
let expected = r"{% for task in tasks %}- **{{ task.title }}**
{% /for %}";
assert_eq!(strip_blockquote_tags(input).as_ref(), expected);
}
#[test]
fn strip_preserve_literal_blockquote_between_tags() {
let input = r"> {% if condition %}
> This is a literal blockquote inside the block.
> {% /if %}";
let expected = r"{% if condition %}> This is a literal blockquote inside the block.
{% /if %}";
assert_eq!(strip_blockquote_tags(input).as_ref(), expected);
}
#[test]
fn validate_rejects_indented_bare_tag() {
let input = r"Some prose
{% for task in tasks %}
- {{ task.title }}
{% /for %}";
let err = validate_blockquote_prefix(input).unwrap_err();
assert!(err.to_string().contains(ERR_BARE_STMT_TAG));
}
#[test]
fn validate_rejects_content_with_blockquote_prefix() {
let input = r"> {% if empty %}
> _No items._
> {% /if %}";
let err = validate_blockquote_prefix(input).unwrap_err();
assert!(
err.to_string().contains("must be followed by a blank line"),
"got: {err}"
);
}
#[test]
fn validate_accepts_blank_lines_around_tags() {
let input = r"
> {% if show %}
Content here.
> {% /if %}
";
assert!(validate_blockquote_prefix(input).is_ok());
}
#[test]
fn validate_accepts_consecutive_tags() {
let input = r"
> {% if x %}
> {% for item in items %}
{{ item }}
> {% /for %}
> {% /if %}
";
assert!(validate_blockquote_prefix(input).is_ok());
}
#[test]
fn validate_rejects_content_directly_after_tag() {
let input = r"
> {% if show %}
Content without blank line.
> {% /if %}
";
let err = validate_blockquote_prefix(input).unwrap_err();
assert!(
err.to_string().contains("must be followed by a blank line"),
"got: {err}"
);
}
#[test]
fn validate_rejects_content_directly_before_tag() {
let input = r"
> {% if show %}
Content without blank line.
> {% /if %}
";
let err = validate_blockquote_prefix(input).unwrap_err();
assert!(
err.to_string().contains("must be preceded by a blank line"),
"got: {err}"
);
}
#[test]
fn is_valid_tag_neighbor_empty() {
assert!(is_valid_tag_neighbor(""));
assert!(is_valid_tag_neighbor(" "));
}
#[test]
fn is_valid_tag_neighbor_frontmatter() {
assert!(is_valid_tag_neighbor("---"));
}
#[test]
fn is_valid_tag_neighbor_blockquote_tag() {
assert!(is_valid_tag_neighbor("> {% if x %}"));
assert!(is_valid_tag_neighbor("> {% /for %}"));
}
#[test]
fn is_valid_tag_neighbor_rejects_blockquote_content() {
assert!(!is_valid_tag_neighbor("> some content"));
assert!(!is_valid_tag_neighbor("> _No items._"));
}
#[test]
fn is_valid_tag_neighbor_rejects_plain_content() {
assert!(!is_valid_tag_neighbor("some content"));
assert!(!is_valid_tag_neighbor("- list item"));
}
#[test]
fn strip_inline_if_inside_match() {
let input = r"
> {% match status %}
> {% case Active %}
> {% if detail %}DETAIL{% else %}BRIEF{% /if %}
> {% case Inactive %}
OFF
> {% /match %}";
let result = strip_blockquote_tags(input);
assert!(
result.contains("{% if detail %}DETAIL{% else %}BRIEF{% /if %}"),
"inline if should be preserved: {result}"
);
}
#[test]
fn expression_blockquote_not_stripped() {
let input = "> {{ title }}";
let result = strip_blockquote_tags(input);
assert_eq!(
result.as_ref(),
"> {{ title }}",
"> before {{ }} is literal"
);
}
#[test]
fn expression_blockquote_mixed_with_tags() {
let input = r"
> {% if show %}
> {{ name }} is visible
> {% /if %}";
let result = strip_blockquote_tags(input);
assert!(
result.contains("> {{ name }} is visible"),
"`> ` before expression should be preserved: {result}"
);
assert!(
result.contains("{% if show %}"),
"`> ` before tag should be stripped: {result}"
);
}
#[test]
fn expression_blockquote_does_not_require_blank_lines() {
let input = r"
> {% if show %}
> {{ title }}
> {% /if %}";
let result = validate_blockquote_prefix(input);
assert!(
result.is_ok(),
"> {{ }} content line should not require blank lines beyond what tags need"
);
}
#[test]
fn expression_blockquote_adjacent_to_tag_accepted() {
let input = r"
> {% if show %}
> {{ title }}
> {% /if %}
";
let result = validate_blockquote_prefix(input);
assert!(
result.is_ok(),
"> {{{{ }}}} adjacent to > {{%...%}} via blank line should be accepted"
);
}
#[test]
fn strip_blockquote_line_only_strips_tags() {
let expr_line = "> {{ value }}";
assert!(
core::ptr::eq(strip_blockquote_line(expr_line), expr_line),
"expression line should not be stripped"
);
let tag_line = "> {% if x %}";
assert!(
!core::ptr::eq(strip_blockquote_line(tag_line), tag_line),
"tag line should be stripped"
);
assert_eq!(strip_blockquote_line(tag_line), "{% if x %}");
}
}