Skip to main content

md_tmpl/
error.rs

1//! Template engine error types.
2
3use alloc::{string::String, vec::Vec};
4use core::fmt;
5
6/// Errors produced by the template engine.
7#[derive(Debug, thiserror::Error)]
8pub enum TemplateError {
9    /// I/O error while loading a template file.
10    #[cfg(feature = "std")]
11    #[error("failed to load template: {0}")]
12    Io(#[from] std::io::Error),
13
14    /// A referenced variable was not found in the context.
15    #[error("undefined variable: {0}")]
16    UndefinedVariable(String),
17
18    /// Syntax error in the template.
19    #[error("template syntax error: {0}")]
20    Syntax(SyntaxError),
21
22    /// Missing required parameters from frontmatter.
23    #[error("missing required parameters: {}", .0.join(", "))]
24    MissingParams(Vec<String>),
25
26    /// Type mismatch between declaration and value.
27    #[error("type mismatch for '{name}': expected {expected}, got {actual} ({actual_value})")]
28    TypeMismatch {
29        /// Variable name.
30        name: String,
31        /// The type declared in frontmatter.
32        expected: String,
33        /// The type found in the context.
34        actual: String,
35        /// Preview of the actual value for debugging.
36        actual_value: String,
37    },
38
39    /// Unknown filter name.
40    #[error("unknown filter: {0}")]
41    UnknownFilter(String),
42
43    /// Include file not found.
44    #[error("include not found: {0}")]
45    IncludeNotFound(String),
46
47    /// Parameter declarations were mutated at runtime.
48    ///
49    /// Template frontmatter `params:` declarations are fixed at compile
50    /// time. If a runtime-reloaded template has different declarations, this
51    /// error is returned — the template body may be changed freely, but the
52    /// parameter contract must remain stable.
53    #[error(
54        "template parameter declarations were modified at runtime: {details}. \
55         The frontmatter `params:` block is part of the compile-time \
56         contract and must not be changed"
57    )]
58    DeclarationsMutated {
59        /// Human-readable description of what changed.
60        details: String,
61    },
62
63    /// Extra (undeclared) parameters were passed in the context.
64    ///
65    /// The template engine is strict by default: only parameters declared
66    /// in frontmatter may be passed. Use `allow_extra_params` on the
67    /// render call to suppress this check.
68    #[error("extra undeclared parameters: {}", .0.join(", "))]
69    ExtraParams(Vec<String>),
70}
71
72impl TemplateError {
73    /// Create a [`Syntax`](Self::Syntax) error from any string-like value.
74    ///
75    /// This is the preferred constructor — use it instead of
76    /// `TemplateError::Syntax(SyntaxError::new(...))` for brevity.
77    pub(crate) fn syntax(msg: impl Into<String>) -> Self {
78        Self::Syntax(SyntaxError::new(msg))
79    }
80}
81
82/// A structured syntax error with optional line number and source context.
83///
84/// Callers that match on [`TemplateError::Syntax`] can inspect `line` and
85/// `snippet` programmatically instead of parsing the error message string.
86#[derive(Debug, Clone)]
87pub struct SyntaxError {
88    /// The error message (without location prefix).
89    pub message: String,
90    /// 1-based line number where the error occurred (if known).
91    pub line: Option<usize>,
92    /// Snippet of the offending source line (if available).
93    pub snippet: Option<String>,
94}
95
96impl SyntaxError {
97    /// Create a syntax error with just a message.
98    #[must_use]
99    pub fn new(message: impl Into<String>) -> Self {
100        Self {
101            message: message.into(),
102            line: None,
103            snippet: None,
104        }
105    }
106
107    /// Attach a line number and source snippet.
108    #[must_use]
109    pub fn at_line(mut self, line: usize, snippet: impl Into<String>) -> Self {
110        self.line = Some(line);
111        self.snippet = Some(snippet.into());
112        self
113    }
114}
115
116impl fmt::Display for SyntaxError {
117    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
118        match (self.line, self.snippet.as_deref()) {
119            (Some(line), Some(snippet)) => {
120                write!(f, "line {line}: {}\n  --> {snippet}", self.message)
121            }
122            (Some(line), None) => write!(f, "line {line}: {}", self.message),
123            _ => f.write_str(&self.message),
124        }
125    }
126}
127
128/// Allow `TemplateError::Syntax("message".to_string().into())` etc.
129impl From<String> for SyntaxError {
130    fn from(message: String) -> Self {
131        Self::new(message)
132    }
133}
134
135/// Compute the Levenshtein edit distance between two strings.
136///
137/// Returns the minimum number of single-character edits (insertions,
138/// deletions, or substitutions) required to transform `a` into `b`.
139pub(crate) fn levenshtein_distance(a: &str, b: &str) -> usize {
140    let a_len = a.len();
141    let b_len = b.len();
142    if a_len == 0 {
143        return b_len;
144    }
145    if b_len == 0 {
146        return a_len;
147    }
148    let mut prev: Vec<usize> = (0..=b_len).collect();
149    let mut curr = vec![0; b_len + 1];
150    for (i, ca) in a.chars().enumerate() {
151        curr[0] = i + 1;
152        for (j, cb) in b.chars().enumerate() {
153            let cost = usize::from(ca != cb);
154            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
155        }
156        core::mem::swap(&mut prev, &mut curr);
157    }
158    prev[b_len]
159}
160
161#[cfg(test)]
162mod tests {
163    use alloc::string::ToString;
164
165    use super::*;
166
167    // ── SyntaxError::new ────────────────────────────────────────────
168
169    #[test]
170    fn syntax_error_new_sets_message() {
171        let err = SyntaxError::new("unexpected token");
172        assert_eq!(err.message, "unexpected token");
173    }
174
175    #[test]
176    fn syntax_error_new_defaults_line_to_none() {
177        let err = SyntaxError::new("oops");
178        assert!(err.line.is_none());
179    }
180
181    #[test]
182    fn syntax_error_new_defaults_snippet_to_none() {
183        let err = SyntaxError::new("oops");
184        assert!(err.snippet.is_none());
185    }
186
187    // ── SyntaxError::at_line ────────────────────────────────────────
188
189    #[test]
190    fn syntax_error_at_line_sets_line_and_snippet() {
191        let err = SyntaxError::new("bad token").at_line(42, "{{ bad }}");
192        assert_eq!(err.line, Some(42));
193        assert_eq!(err.snippet.as_deref(), Some("{{ bad }}"));
194        assert_eq!(err.message, "bad token");
195    }
196
197    #[test]
198    fn syntax_error_at_line_line_one() {
199        let err = SyntaxError::new("err").at_line(1, "line1");
200        assert_eq!(err.line, Some(1));
201    }
202
203    // ── SyntaxError Display ─────────────────────────────────────────
204
205    #[test]
206    fn syntax_error_display_with_line_and_snippet() {
207        let err = SyntaxError::new("unexpected end").at_line(7, "{{ if");
208        let formatted = err.to_string();
209        assert_eq!(formatted, "line 7: unexpected end\n  --> {{ if");
210    }
211
212    #[test]
213    fn syntax_error_display_with_line_only() {
214        let mut err = SyntaxError::new("missing bracket");
215        err.line = Some(3);
216        // snippet is None
217        let formatted = err.to_string();
218        assert_eq!(formatted, "line 3: missing bracket");
219    }
220
221    #[test]
222    fn syntax_error_display_message_only() {
223        let err = SyntaxError::new("generic problem");
224        assert_eq!(err.to_string(), "generic problem");
225    }
226
227    #[test]
228    fn syntax_error_display_message_only_when_snippet_without_line() {
229        // Edge case: snippet set but line is None → falls into the catch-all
230        let mut err = SyntaxError::new("edge case");
231        err.snippet = Some("some snippet".into());
232        // line is None, so the `_` branch fires
233        assert_eq!(err.to_string(), "edge case");
234    }
235
236    // ── From<String> for SyntaxError ────────────────────────────────
237
238    #[test]
239    fn syntax_error_from_string() {
240        let s = String::from("converted message");
241        let err: SyntaxError = s.into();
242        assert_eq!(err.message, "converted message");
243        assert!(err.line.is_none());
244        assert!(err.snippet.is_none());
245    }
246
247    // ── TemplateError::syntax() convenience ─────────────────────────
248
249    #[test]
250    fn template_error_syntax_constructor() {
251        let err = TemplateError::syntax("bad template");
252        match &err {
253            TemplateError::Syntax(inner) => {
254                assert_eq!(inner.message, "bad template");
255                assert!(inner.line.is_none());
256            }
257            other => panic!("expected Syntax variant, got: {other}"),
258        }
259    }
260
261    #[test]
262    fn template_error_syntax_accepts_string() {
263        let err = TemplateError::syntax(String::from("owned msg"));
264        assert!(matches!(err, TemplateError::Syntax(_)));
265    }
266
267    // ── TemplateError Display for every non-Io variant ──────────────
268
269    #[test]
270    fn template_error_display_undefined_variable() {
271        let err = TemplateError::UndefinedVariable("user_name".into());
272        assert_eq!(err.to_string(), "undefined variable: user_name");
273    }
274
275    #[test]
276    fn template_error_display_syntax() {
277        let err = TemplateError::Syntax(SyntaxError::new("unexpected end of input"));
278        assert_eq!(
279            err.to_string(),
280            "template syntax error: unexpected end of input"
281        );
282    }
283
284    #[test]
285    fn template_error_display_missing_params() {
286        let err = TemplateError::MissingParams(vec!["alpha".into(), "beta".into()]);
287        assert_eq!(err.to_string(), "missing required parameters: alpha, beta");
288    }
289
290    #[test]
291    fn template_error_display_missing_params_single() {
292        let err = TemplateError::MissingParams(vec!["only".into()]);
293        assert_eq!(err.to_string(), "missing required parameters: only");
294    }
295
296    #[test]
297    fn template_error_display_type_mismatch() {
298        let err = TemplateError::TypeMismatch {
299            name: "count".into(),
300            expected: "int".into(),
301            actual: "string".into(),
302            actual_value: "\"hello\"".into(),
303        };
304        assert_eq!(
305            err.to_string(),
306            "type mismatch for 'count': expected int, got string (\"hello\")"
307        );
308    }
309
310    #[test]
311    fn template_error_display_unknown_filter() {
312        let err = TemplateError::UnknownFilter("capitalize".into());
313        assert_eq!(err.to_string(), "unknown filter: capitalize");
314    }
315
316    #[test]
317    fn template_error_display_include_not_found() {
318        let err = TemplateError::IncludeNotFound("header.tmpl".into());
319        assert_eq!(err.to_string(), "include not found: header.tmpl");
320    }
321
322    #[test]
323    fn template_error_display_declarations_mutated() {
324        let err = TemplateError::DeclarationsMutated {
325            details: "added parameter 'foo'".into(),
326        };
327        let msg = err.to_string();
328        assert!(msg.contains("declarations were modified at runtime"));
329        assert!(msg.contains("added parameter 'foo'"));
330    }
331
332    #[test]
333    fn template_error_display_extra_params() {
334        let err = TemplateError::ExtraParams(vec!["x".into(), "y".into()]);
335        assert_eq!(err.to_string(), "extra undeclared parameters: x, y");
336    }
337
338    // ── levenshtein_distance ────────────────────────────────────────
339
340    #[test]
341    fn levenshtein_identical_strings() {
342        assert_eq!(levenshtein_distance("hello", "hello"), 0);
343    }
344
345    #[test]
346    fn levenshtein_empty_strings() {
347        assert_eq!(levenshtein_distance("", ""), 0);
348    }
349
350    #[test]
351    fn levenshtein_one_empty() {
352        assert_eq!(levenshtein_distance("abc", ""), 3);
353        assert_eq!(levenshtein_distance("", "xyz"), 3);
354    }
355
356    #[test]
357    fn levenshtein_single_char_diff() {
358        assert_eq!(levenshtein_distance("cat", "bat"), 1);
359    }
360
361    #[test]
362    fn levenshtein_kitten_sitting() {
363        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
364    }
365
366    #[test]
367    fn levenshtein_completely_different() {
368        assert_eq!(levenshtein_distance("abc", "xyz"), 3);
369    }
370
371    #[test]
372    fn levenshtein_prefix() {
373        assert_eq!(levenshtein_distance("abc", "abcdef"), 3);
374    }
375
376    #[test]
377    fn levenshtein_single_insertion() {
378        assert_eq!(levenshtein_distance("ac", "abc"), 1);
379    }
380
381    #[test]
382    fn levenshtein_single_deletion() {
383        assert_eq!(levenshtein_distance("abc", "ac"), 1);
384    }
385
386    #[test]
387    fn levenshtein_symmetric() {
388        assert_eq!(
389            levenshtein_distance("foo", "bar"),
390            levenshtein_distance("bar", "foo")
391        );
392    }
393}