Skip to main content

somni_template/
syntax.rs

1//! Configuration of the template surface syntax.
2//!
3//! A [`Syntax`] describes three independent axes:
4//!
5//! - **Expression interpolation**: always a delimiter pair, defaulting to `{{` … `}}`.
6//! - **Block directives**: either a delimiter pair ([`BlockStyle::Paired`], e.g. `{%` … `%}`)
7//!   or a line prefix ([`BlockStyle::Line`], e.g. `#`), where a directive occupies a whole
8//!   physical line.
9//! - **Text line prefix** ([`Syntax::text_prefix`]): an optional line prefix that is stripped;
10//!   the remainder of the line is emitted as ordinary template text. Useful when a template is
11//!   also a valid program in another language — conditional output can sit behind a comment
12//!   marker so it is inert if the file is compiled/run directly.
13//!
14//! Exactly one [`BlockStyle`] is used per compiled template.
15//!
16//! Templates may also begin with a `---`-fenced **frontmatter** block that overrides these
17//! settings (see [`split_frontmatter`] and [`Syntax::with_frontmatter`]). When present,
18//! frontmatter keys take precedence over the [`Syntax`] passed to [`crate::Template::compile`].
19
20use somni_parser::Location;
21
22use crate::error::TemplateError;
23
24/// How block directives (`if`/`else`/`for`/…) are delimited.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum BlockStyle {
27    /// Directives are wrapped in a delimiter pair, e.g. `{% if x %}`.
28    Paired {
29        /// Opening delimiter, e.g. `{%`.
30        open: String,
31        /// Closing delimiter, e.g. `%}`.
32        close: String,
33    },
34    /// Directives occupy a whole line, introduced by a prefix, e.g. `#if x`.
35    ///
36    /// The entire physical line (leading whitespace through the trailing newline) is
37    /// consumed and produces no output.
38    Line {
39        /// The line prefix that introduces a directive, e.g. `#`.
40        prefix: String,
41    },
42}
43
44/// The configurable surface syntax of a template.
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub struct Syntax {
47    /// The interpolation delimiter pair, e.g. (`{{`, `}}`).
48    pub expr: (String, String),
49    /// How block directives are delimited.
50    pub block: BlockStyle,
51    /// Optional line prefix that is stripped; content after it is emitted as text.
52    ///
53    /// A physical line whose first non-whitespace characters equal this prefix has the leading
54    /// whitespace and the prefix removed. The rest of the line (including a trailing newline)
55    /// is ordinary template text and may contain interpolations.
56    ///
57    /// When both this and a [`BlockStyle::Line`] prefix could match the same line, the longer
58    /// prefix wins. They must not be identical (see [`Syntax::validate`]).
59    pub text_prefix: Option<String>,
60}
61
62impl Default for Syntax {
63    fn default() -> Self {
64        Self::brackets()
65    }
66}
67
68/// Frontmatter keys that may appear at most once.
69const FRONTMATTER_KEYS: &[&str] = &["expr", "block", "text_prefix"];
70
71impl Syntax {
72    /// Bracket style: `{{ expr }}` interpolation and `{% ... %}` block directives.
73    pub fn brackets() -> Self {
74        Self {
75            expr: ("{{".into(), "}}".into()),
76            block: BlockStyle::Paired {
77                open: "{%".into(),
78                close: "%}".into(),
79            },
80            text_prefix: None,
81        }
82    }
83
84    /// Line style: `{{ expr }}` interpolation and `#kw ...` line directives.
85    pub fn lines() -> Self {
86        Self {
87            expr: ("{{".into(), "}}".into()),
88            block: BlockStyle::Line { prefix: "#".into() },
89            text_prefix: None,
90        }
91    }
92
93    /// Checks that this syntax configuration is internally consistent.
94    ///
95    /// Currently rejects an empty `text_prefix` and a `text_prefix` identical to the block
96    /// line prefix.
97    pub fn validate(&self) -> Result<(), TemplateError> {
98        if let Some(tp) = &self.text_prefix {
99            if tp.is_empty() {
100                return Err(TemplateError::new(
101                    "`text_prefix` must not be empty",
102                    Location { start: 0, end: 0 },
103                ));
104            }
105            if let BlockStyle::Line { prefix } = &self.block {
106                if prefix == tp {
107                    return Err(TemplateError::new(
108                        "`text_prefix` must differ from the block line prefix",
109                        Location { start: 0, end: 0 },
110                    ));
111                }
112            }
113        }
114        Ok(())
115    }
116
117    /// Applies frontmatter key/value overrides onto a copy of this syntax.
118    ///
119    /// Recognized keys (all optional; each may appear at most once):
120    ///
121    /// - `expr: <open> <close>` — interpolation delimiters.
122    /// - `block: paired <open> <close>` — paired block directives.
123    /// - `block: line <prefix>` — line directives.
124    /// - `text_prefix: <prefix>` — strip this line prefix; remainder is verbatim text.
125    ///
126    /// Blank lines and lines whose first non-whitespace character is `#` are ignored
127    /// (comments). Keys present in `front` replace the corresponding fields of `self`;
128    /// omitted keys are left unchanged. Locations in returned errors are relative to `front`.
129    pub fn with_frontmatter(&self, front: &str) -> Result<Self, TemplateError> {
130        let mut syntax = self.clone();
131        let mut offset = 0usize;
132        let mut seen = [false; FRONTMATTER_KEYS.len()];
133
134        for line in front.split_inclusive('\n') {
135            let line_start = offset;
136            offset += line.len();
137
138            let trimmed = line.trim();
139            // `trim` also strips a trailing `\r` left by CRLF after splitting on `\n`.
140            if trimmed.is_empty() || trimmed.starts_with('#') {
141                continue;
142            }
143
144            let (key, value) = trimmed.split_once(':').ok_or_else(|| {
145                TemplateError::new(
146                    format!("frontmatter line is not `key: value`: {trimmed:?}"),
147                    line_span(front, line_start, line.len()),
148                )
149            })?;
150
151            let key = key.trim();
152            let Some(idx) = FRONTMATTER_KEYS.iter().position(|&k| k == key) else {
153                return Err(TemplateError::new(
154                    format!("unknown frontmatter key: {key:?}"),
155                    line_span(front, line_start, line.len()),
156                ));
157            };
158            if seen[idx] {
159                return Err(TemplateError::new(
160                    format!("`{key}` specified more than once"),
161                    line_span(front, line_start, line.len()),
162                ));
163            }
164            seen[idx] = true;
165
166            let mut parts = value.split_whitespace();
167            match key {
168                "expr" => {
169                    let open = parts.next().ok_or_else(|| {
170                        TemplateError::new(
171                            "`expr` needs an opening delimiter",
172                            line_span(front, line_start, line.len()),
173                        )
174                    })?;
175                    let close = parts.next().ok_or_else(|| {
176                        TemplateError::new(
177                            "`expr` needs a closing delimiter",
178                            line_span(front, line_start, line.len()),
179                        )
180                    })?;
181                    syntax.expr = (open.to_string(), close.to_string());
182                }
183                "block" => match parts.next() {
184                    Some("line") => {
185                        let prefix = parts.next().ok_or_else(|| {
186                            TemplateError::new(
187                                "`block: line` needs a prefix",
188                                line_span(front, line_start, line.len()),
189                            )
190                        })?;
191                        if syntax.text_prefix.as_deref() == Some(prefix) {
192                            return Err(TemplateError::new(
193                                "`text_prefix` must differ from the block line prefix",
194                                line_span(front, line_start, line.len()),
195                            ));
196                        }
197                        syntax.block = BlockStyle::Line {
198                            prefix: prefix.to_string(),
199                        };
200                    }
201                    Some("paired") => {
202                        let open = parts.next().ok_or_else(|| {
203                            TemplateError::new(
204                                "`block: paired` needs an opening delimiter",
205                                line_span(front, line_start, line.len()),
206                            )
207                        })?;
208                        let close = parts.next().ok_or_else(|| {
209                            TemplateError::new(
210                                "`block: paired` needs a closing delimiter",
211                                line_span(front, line_start, line.len()),
212                            )
213                        })?;
214                        syntax.block = BlockStyle::Paired {
215                            open: open.to_string(),
216                            close: close.to_string(),
217                        };
218                    }
219                    other => {
220                        return Err(TemplateError::new(
221                            format!("unknown block style: {other:?}"),
222                            line_span(front, line_start, line.len()),
223                        ));
224                    }
225                },
226                "text_prefix" => {
227                    let prefix = parts.next().ok_or_else(|| {
228                        TemplateError::new(
229                            "`text_prefix` needs a prefix",
230                            line_span(front, line_start, line.len()),
231                        )
232                    })?;
233                    if let BlockStyle::Line {
234                        prefix: block_prefix,
235                    } = &syntax.block
236                    {
237                        if block_prefix == prefix {
238                            return Err(TemplateError::new(
239                                "`text_prefix` must differ from the block line prefix",
240                                line_span(front, line_start, line.len()),
241                            ));
242                        }
243                    }
244                    syntax.text_prefix = Some(prefix.to_string());
245                }
246                _ => unreachable!("key matched FRONTMATTER_KEYS"),
247            }
248        }
249
250        syntax.validate()?;
251        Ok(syntax)
252    }
253}
254
255/// If `source` begins with a `---`-fenced frontmatter block, returns `(front, body)`.
256///
257/// The opening fence must be at the start of `source` (`---` followed by a newline). The
258/// closing fence is a line that is exactly `---`. Without both fences, returns `None` and
259/// the whole string is treated as template body.
260///
261/// Both LF and CRLF newlines are accepted.
262pub fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
263    let rest = source
264        .strip_prefix("---\r\n")
265        .or_else(|| source.strip_prefix("---\n"))?;
266
267    // Prefer the LF form; `\r\n---\r\n` still contains `\n---\r\n`.
268    // Trim a trailing `\r` so CRLF front matter does not leave a dangling CR in `front`.
269    if let Some(end) = rest.find("\n---\n") {
270        let front = rest[..end].strip_suffix('\r').unwrap_or(&rest[..end]);
271        let body = &rest[end + "\n---\n".len()..];
272        return Some((front, body));
273    }
274    if let Some(end) = rest.find("\n---\r\n") {
275        let front = rest[..end].strip_suffix('\r').unwrap_or(&rest[..end]);
276        let body = &rest[end + "\n---\r\n".len()..];
277        return Some((front, body));
278    }
279    None
280}
281
282/// Resolves the effective [`Syntax`] for a template source.
283///
284/// When frontmatter is present, its settings overlay `base` (frontmatter wins). Returns the
285/// effective syntax, the template body with frontmatter stripped, and the byte offset of the
286/// body within the original `source`. Without frontmatter, returns a clone of `base`, the
287/// original `source`, and offset `0`.
288pub fn resolve_syntax<'a>(
289    source: &'a str,
290    base: &Syntax,
291) -> Result<(Syntax, &'a str, usize), TemplateError> {
292    let Some((front, body)) = split_frontmatter(source) else {
293        base.validate()?;
294        return Ok((base.clone(), source, 0));
295    };
296
297    // `front` begins immediately after the opening `---\n` / `---\r\n` fence.
298    let front_start = source
299        .strip_prefix("---\r\n")
300        .or_else(|| source.strip_prefix("---\n"))
301        .map(|rest| source.len() - rest.len())
302        .expect("split_frontmatter found a fence");
303
304    let syntax = base.with_frontmatter(front).map_err(|err| {
305        TemplateError::new(
306            err.message,
307            Location {
308                start: err.location.start + front_start,
309                end: err.location.end + front_start,
310            },
311        )
312    })?;
313    // `body` is always a suffix of `source`.
314    let body_offset = source.len() - body.len();
315    Ok((syntax, body, body_offset))
316}
317
318fn line_span(front: &str, line_start: usize, line_len: usize) -> Location {
319    let end = (line_start + line_len).min(front.len());
320    // Prefer pointing at the non-newline contents of the line.
321    let content_end = front.as_bytes()[line_start..end]
322        .iter()
323        .rposition(|&b| b != b'\n' && b != b'\r')
324        .map(|i| line_start + i + 1)
325        .unwrap_or(line_start);
326    Location {
327        start: line_start,
328        end: content_end.max(line_start),
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn split_no_frontmatter() {
338        assert_eq!(split_frontmatter("hello"), None);
339        assert_eq!(split_frontmatter("---\nno close"), None);
340    }
341
342    #[test]
343    fn split_lf_and_crlf() {
344        assert_eq!(
345            split_frontmatter("---\nblock: line #\n---\nbody"),
346            Some(("block: line #", "body"))
347        );
348        assert_eq!(
349            split_frontmatter("---\r\nblock: line #\r\n---\r\nbody"),
350            Some(("block: line #", "body"))
351        );
352    }
353
354    #[test]
355    fn frontmatter_overlays_base() {
356        let base = Syntax {
357            expr: ("[[".into(), "]]".into()),
358            block: BlockStyle::Paired {
359                open: "{%".into(),
360                close: "%}".into(),
361            },
362            text_prefix: None,
363        };
364        let merged = base
365            .with_frontmatter("block: line //\ntext_prefix: //>")
366            .unwrap();
367        assert_eq!(merged.expr, ("[[".into(), "]]".into()));
368        assert_eq!(
369            merged.block,
370            BlockStyle::Line {
371                prefix: "//".into()
372            }
373        );
374        assert_eq!(merged.text_prefix.as_deref(), Some("//>"));
375    }
376
377    #[test]
378    fn frontmatter_rejects_duplicate_keys() {
379        for (front, duplicate_line) in [
380            ("block: line #\nblock: paired {% %}", "block: paired {% %}"),
381            ("expr: {{ }}\nexpr: [[ ]]", "expr: [[ ]]"),
382        ] {
383            let err = Syntax::brackets().with_frontmatter(front).unwrap_err();
384            assert!(
385                err.message.contains("more than once"),
386                "unexpected message for {front:?}: {}",
387                err.message
388            );
389            assert_eq!(err.location.extract(front), duplicate_line);
390        }
391    }
392
393    #[test]
394    fn frontmatter_ignores_hash_comments() {
395        let merged = Syntax::brackets()
396            .with_frontmatter(
397                "# use C-style block comments\n\
398                 block: paired /* */\n\
399                 # keep default expr\n",
400            )
401            .unwrap();
402        assert_eq!(merged.expr, ("{{".into(), "}}".into()));
403        assert_eq!(
404            merged.block,
405            BlockStyle::Paired {
406                open: "/*".into(),
407                close: "*/".into(),
408            }
409        );
410    }
411
412    #[test]
413    fn resolve_prefers_frontmatter_over_base() {
414        let base = Syntax::lines();
415        let source = "---\nblock: paired /* */\n---\n/* if x */y/* endif */";
416        let (syntax, body, body_offset) = resolve_syntax(source, &base).unwrap();
417        assert_eq!(body, "/* if x */y/* endif */");
418        assert_eq!(&source[body_offset..], body);
419        assert_eq!(
420            syntax.block,
421            BlockStyle::Paired {
422                open: "/*".into(),
423                close: "*/".into(),
424            }
425        );
426        // Unmentioned keys keep the Rust-provided value.
427        assert_eq!(syntax.expr, base.expr);
428    }
429}