md-tmpl-core 0.6.2

Core template engine for md-tmpl — parsing, compilation, and rendering
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Blockquote prefix stripping for template statement tags.
//!
//! Markdown blockquote `>` prefixes on `{% ... %}` lines are
//! transparently stripped before compilation so the template engine
//! sees plain tags.

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,
};

/// Maximum display length for error-message snippets of blockquote lines.
const SNIPPET_MAX_DISPLAY_LEN: usize = 60;
/// Safe truncation boundary: leaves room for the trailing `…` (3 UTF-8 bytes).
const SNIPPET_TRUNCATION_BOUNDARY: usize = SNIPPET_MAX_DISPLAY_LEN - 3;

/// Returns `true` when a line is a valid neighbor for a standalone tag line.
///
/// Valid neighbors are:
/// - Empty / blank lines
/// - `---` (frontmatter delimiter)
/// - Other blockquote tag lines (`> {% ... %}`)
///
/// Content lines starting with `>` that do NOT contain `{% %}` are NOT valid.
fn is_valid_tag_neighbor(line: &str) -> bool {
    let trimmed = line.trim_start();
    if trimmed.is_empty() || trimmed.starts_with("---") {
        return true;
    }
    // A `>` line is only valid if it's itself a blockquote tag line.
    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
}

/// Validate that every line starting with `{% ` has a blockquote `>` prefix,
/// and that standalone tag lines are surrounded by blank lines or other tags.
///
/// This check runs on the raw body **before** blockquote stripping. Lines
/// inside `{% raw %}` blocks are exempted since their content is literal.
///
/// Only lines whose first non-whitespace characters are `{%` are checked —
/// `{{ }}` expressions and mid-line `{% %}` tags are always allowed without
/// a `>` prefix.
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 {
            // Inside raw block — look for close tag (with or without `>`)
            // to resume checking. Raw blocks don't nest.
            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;
        }

        // Detect raw block open: `> {% raw %}` or `> {% raw=X %}`
        if trimmed.starts_with('>')
            && trimmed.contains("{%")
            && (trimmed.contains(" raw ") || trimmed.contains(" raw=") || trimmed.contains(" raw%"))
        {
            in_raw = true;
            continue;
        }

        // Check for comments starting at line beginning without blockquote prefix
        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",
            ));
        }

        // Main check: line starts with `{%` (or `{%-`) without `>` prefix.
        if trimmed.starts_with(STMT_START) {
            // Truncate for a clean error message.
            let snippet = if trimmed.len() > SNIPPET_MAX_DISPLAY_LEN {
                // Find a safe truncation point at a char boundary.
                let end = trimmed
                    .char_indices()
                    .map(|(i, _)| i)
                    .take_while(|&i| i <= SNIPPET_TRUNCATION_BOUNDARY)
                    .last()
                    // NOLINT: empty iterator means string has no chars — 0 is the correct truncation point
                    .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(())
}

/// Check that a standalone tag at index `i` is surrounded by valid neighbors.
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(())
}

/// Strip markdown blockquote `>` prefix from lines containing `{%` tags.
///
/// Allows authors to write `>{% if x %}` which renders as a visually-distinct
/// blockquote in markdown preview. The `>` prefix is transparently removed
/// before compilation so the template engine sees plain `{% if x %}`.
///
/// Supports both `>{%` (compact) and `> {%` (spaced). Lines without `{%`
/// are left untouched, preserving actual markdown blockquotes.
///
/// This function is idempotent — calling it on already-processed text is safe.
pub(super) fn strip_blockquote_tags(input: &str) -> alloc::borrow::Cow<'_, str> {
    // Fast path: no blockquote tags present.
    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);

        // Accumulate blank lines after a standalone tag.
        if after_standalone && is_blank {
            pending_blanks += 1;
            continue;
        }

        if after_standalone && is_standalone {
            // Tag-to-tag: consume all pending blanks (structural whitespace).
            pending_blanks = 0;
        } else if after_standalone {
            // Tag-to-content: consume 1 mandatory blank, preserve the rest.
            // Each extra blank becomes a \n in the output.
            pending_blanks = pending_blanks.saturating_sub(1); // consume mandatory blank
            for _ in 0..pending_blanks {
                result.push('\n');
            }
            pending_blanks = 0;
        }

        // Normal line separator — suppress only after standalone tags (they handle
        // their own whitespace via the pending_blanks mechanism above).
        if i > 0 && !after_standalone {
            result.push('\n');
        }
        after_standalone = false;

        // Standalone tag: pop preceding blank + set up post-tag state.
        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)
}

/// Returns `true` when the line is a standalone template tag — the entire
/// line (after trimming) is a single `{% ... %}` or `{# ... #}` with no other content.
///
/// Lines like `{% if x %}yes{% /if %}` are NOT standalone because they
/// contain content between/around the tags.
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;
    }
    // Must start with `{%` and end with `%}`.
    if !trimmed.starts_with(STMT_START) || !trimmed.ends_with(STMT_END) {
        return false;
    }
    // Find the FIRST `%}` — if it's the last one (at the end), the line
    // is a single tag. If there's content after the first `%}`, it's not.
    let after_open = &trimmed[STMT_START.len()..]; // skip `{%`
    let Some(close_pos) = after_open.find(STMT_END) else {
        return false;
    };
    // The close should be at the end of the trimmed line.
    close_pos + STMT_END.len() == after_open.len()
}

/// Strip a leading `>` or `> ` from a single line if the remainder starts
/// with `{%` or `{#` (optionally after whitespace).
fn strip_blockquote_line(line: &str) -> &str {
    let trimmed = line.trim_start();
    // Try `> {% ...` or `> {# ...` (with space after >).
    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;
    }
    // Try `>{% ...` or `>{# ...` (no space).
    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() {
        // Content line starting with `>` but no `{% %}` — NOT a valid tag neighbor
        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);
        // The inline if line should be preserved intact
        assert!(
            result.contains("{% if detail %}DETAIL{% else %}BRIEF{% /if %}"),
            "inline if should be preserved: {result}"
        );
    }
}