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 skip_next_newline = false;
for (i, &line) in lines.iter().enumerate() {
let stripped = strip_blockquote_line(line);
let was_stripped = !core::ptr::eq(stripped, line);
if skip_next_newline && stripped.trim().is_empty() {
continue;
}
if i > 0 && !skip_next_newline {
result.push('\n');
}
skip_next_newline = false;
if was_stripped && is_standalone_tag(stripped) {
if result.ends_with("\n\n") || result == "\n" {
result.pop();
}
skip_next_newline = 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 = "> {% if condition %}\n\n> This is a literal blockquote inside the block.\n\n> {% /if %}";
let expected =
"{% if condition %}> This is a literal blockquote inside the block.\n{% /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 = "> {% if empty %}\n> _No items._\n> {% /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 = "\n> {% if show %}\n\nContent here.\n\n> {% /if %}\n";
assert!(validate_blockquote_prefix(input).is_ok());
}
#[test]
fn validate_accepts_consecutive_tags() {
let input = "\n> {% if x %}\n> {% for item in items %}\n\n{{ item }}\n\n> {% /for %}\n> {% /if %}\n";
assert!(validate_blockquote_prefix(input).is_ok());
}
#[test]
fn validate_rejects_content_directly_after_tag() {
let input = "\n> {% if show %}\nContent without blank line.\n\n> {% /if %}\n";
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 = "\n> {% if show %}\n\nContent without blank line.\n> {% /if %}\n";
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}"
);
}
}