Skip to main content

md_tmpl_core/
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    /// Template rendering halted by an explicit `{% panic(...) %}` statement.
72    #[error("template panic: {0}")]
73    Panic(String),
74}
75
76impl TemplateError {
77    /// Create a [`Panic`](Self::Panic) error from any string-like value.
78    pub(crate) fn panic(msg: impl Into<String>) -> Self {
79        Self::Panic(msg.into())
80    }
81
82    /// Create a [`Syntax`](Self::Syntax) error from any string-like value.
83    ///
84    /// This is the preferred constructor — use it instead of
85    /// `TemplateError::Syntax(SyntaxError::new(...))` for brevity.
86    pub(crate) fn syntax(msg: impl Into<String>) -> Self {
87        Self::Syntax(SyntaxError::new(msg))
88    }
89}
90
91/// A structured syntax error with optional line number and source context.
92///
93/// Callers that match on [`TemplateError::Syntax`] can inspect `line` and
94/// `snippet` programmatically instead of parsing the error message string.
95#[derive(Debug, Clone)]
96pub struct SyntaxError {
97    /// The error message (without location prefix).
98    pub message: String,
99    /// 1-based line number where the error occurred (if known).
100    pub line: Option<usize>,
101    /// Snippet of the offending source line (if available).
102    pub snippet: Option<String>,
103}
104
105impl SyntaxError {
106    /// Create a syntax error with just a message.
107    #[must_use]
108    pub fn new(message: impl Into<String>) -> Self {
109        Self {
110            message: message.into(),
111            line: None,
112            snippet: None,
113        }
114    }
115
116    /// Attach a line number and source snippet.
117    #[must_use]
118    pub fn at_line(mut self, line: usize, snippet: impl Into<String>) -> Self {
119        self.line = Some(line);
120        self.snippet = Some(snippet.into());
121        self
122    }
123}
124
125impl fmt::Display for SyntaxError {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        match (self.line, self.snippet.as_deref()) {
128            (Some(line), Some(snippet)) => {
129                write!(f, "line {line}: {}\n  --> {snippet}", self.message)
130            }
131            (Some(line), None) => write!(f, "line {line}: {}", self.message),
132            _ => f.write_str(&self.message),
133        }
134    }
135}
136
137/// Allow `TemplateError::Syntax("message".to_string().into())` etc.
138impl From<String> for SyntaxError {
139    fn from(message: String) -> Self {
140        Self::new(message)
141    }
142}
143
144/// Compute the Levenshtein edit distance between two strings.
145///
146/// Returns the minimum number of single-character edits (insertions,
147/// deletions, or substitutions) required to transform `a` into `b`.
148pub(crate) fn levenshtein_distance(a: &str, b: &str) -> usize {
149    let a_len = a.len();
150    let b_len = b.len();
151    if a_len == 0 {
152        return b_len;
153    }
154    if b_len == 0 {
155        return a_len;
156    }
157    let mut prev: Vec<usize> = (0..=b_len).collect();
158    let mut curr = vec![0; b_len + 1];
159    for (i, ca) in a.chars().enumerate() {
160        curr[0] = i + 1;
161        for (j, cb) in b.chars().enumerate() {
162            let cost = usize::from(ca != cb);
163            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
164        }
165        core::mem::swap(&mut prev, &mut curr);
166    }
167    prev[b_len]
168}
169
170#[cfg(test)]
171mod tests {
172    use alloc::string::ToString;
173
174    use super::*;
175
176    // ── SyntaxError::new ────────────────────────────────────────────
177
178    #[test]
179    fn syntax_error_new_sets_message() {
180        let err = SyntaxError::new("unexpected token");
181        assert_eq!(err.message, "unexpected token");
182    }
183
184    #[test]
185    fn syntax_error_new_defaults_line_to_none() {
186        let err = SyntaxError::new("oops");
187        assert!(err.line.is_none());
188    }
189
190    #[test]
191    fn syntax_error_new_defaults_snippet_to_none() {
192        let err = SyntaxError::new("oops");
193        assert!(err.snippet.is_none());
194    }
195
196    // ── SyntaxError::at_line ────────────────────────────────────────
197
198    #[test]
199    fn syntax_error_at_line_sets_line_and_snippet() {
200        let err = SyntaxError::new("bad token").at_line(42, "{{ bad }}");
201        assert_eq!(err.line, Some(42));
202        assert_eq!(err.snippet.as_deref(), Some("{{ bad }}"));
203        assert_eq!(err.message, "bad token");
204    }
205
206    #[test]
207    fn syntax_error_at_line_line_one() {
208        let err = SyntaxError::new("err").at_line(1, "line1");
209        assert_eq!(err.line, Some(1));
210    }
211
212    // ── SyntaxError Display ─────────────────────────────────────────
213
214    #[test]
215    fn syntax_error_display_with_line_and_snippet() {
216        let err = SyntaxError::new("unexpected end").at_line(7, "{{ if");
217        let formatted = err.to_string();
218        assert_eq!(formatted, "line 7: unexpected end\n  --> {{ if");
219    }
220
221    #[test]
222    fn syntax_error_display_with_line_only() {
223        let mut err = SyntaxError::new("missing bracket");
224        err.line = Some(3);
225        // snippet is None
226        let formatted = err.to_string();
227        assert_eq!(formatted, "line 3: missing bracket");
228    }
229
230    #[test]
231    fn syntax_error_display_message_only() {
232        let err = SyntaxError::new("generic problem");
233        assert_eq!(err.to_string(), "generic problem");
234    }
235
236    #[test]
237    fn syntax_error_display_message_only_when_snippet_without_line() {
238        // Edge case: snippet set but line is None → falls into the catch-all
239        let mut err = SyntaxError::new("edge case");
240        err.snippet = Some("some snippet".into());
241        // line is None, so the `_` branch fires
242        assert_eq!(err.to_string(), "edge case");
243    }
244
245    // ── From<String> for SyntaxError ────────────────────────────────
246
247    #[test]
248    fn syntax_error_from_string() {
249        let s = String::from("converted message");
250        let err: SyntaxError = s.into();
251        assert_eq!(err.message, "converted message");
252        assert!(err.line.is_none());
253        assert!(err.snippet.is_none());
254    }
255
256    // ── TemplateError::syntax() convenience ─────────────────────────
257
258    #[test]
259    fn template_error_syntax_constructor() {
260        let err = TemplateError::syntax("bad template");
261        match &err {
262            TemplateError::Syntax(inner) => {
263                assert_eq!(inner.message, "bad template");
264                assert!(inner.line.is_none());
265            }
266            other => panic!("expected Syntax variant, got: {other}"),
267        }
268    }
269
270    #[test]
271    fn template_error_syntax_accepts_string() {
272        let err = TemplateError::syntax(String::from("owned msg"));
273        assert!(matches!(err, TemplateError::Syntax(_)));
274    }
275
276    // ── TemplateError Display for every non-Io variant ──────────────
277
278    #[test]
279    fn template_error_display_undefined_variable() {
280        let err = TemplateError::UndefinedVariable("user_name".into());
281        assert_eq!(err.to_string(), "undefined variable: user_name");
282    }
283
284    #[test]
285    fn template_error_display_syntax() {
286        let err = TemplateError::Syntax(SyntaxError::new("unexpected end of input"));
287        assert_eq!(
288            err.to_string(),
289            "template syntax error: unexpected end of input"
290        );
291    }
292
293    #[test]
294    fn template_error_display_missing_params() {
295        let err = TemplateError::MissingParams(vec!["alpha".into(), "beta".into()]);
296        assert_eq!(err.to_string(), "missing required parameters: alpha, beta");
297    }
298
299    #[test]
300    fn template_error_display_missing_params_single() {
301        let err = TemplateError::MissingParams(vec!["only".into()]);
302        assert_eq!(err.to_string(), "missing required parameters: only");
303    }
304
305    #[test]
306    fn template_error_display_type_mismatch() {
307        let err = TemplateError::TypeMismatch {
308            name: "count".into(),
309            expected: "int".into(),
310            actual: "string".into(),
311            actual_value: "\"hello\"".into(),
312        };
313        assert_eq!(
314            err.to_string(),
315            "type mismatch for 'count': expected int, got string (\"hello\")"
316        );
317    }
318
319    #[test]
320    fn template_error_display_unknown_filter() {
321        let err = TemplateError::UnknownFilter("capitalize".into());
322        assert_eq!(err.to_string(), "unknown filter: capitalize");
323    }
324
325    #[test]
326    fn template_error_display_include_not_found() {
327        let err = TemplateError::IncludeNotFound("header.tmpl".into());
328        assert_eq!(err.to_string(), "include not found: header.tmpl");
329    }
330
331    #[test]
332    fn template_error_display_declarations_mutated() {
333        let err = TemplateError::DeclarationsMutated {
334            details: "added parameter 'foo'".into(),
335        };
336        let msg = err.to_string();
337        assert!(msg.contains("declarations were modified at runtime"));
338        assert!(msg.contains("added parameter 'foo'"));
339    }
340
341    #[test]
342    fn template_error_display_extra_params() {
343        let err = TemplateError::ExtraParams(vec!["x".into(), "y".into()]);
344        assert_eq!(err.to_string(), "extra undeclared parameters: x, y");
345    }
346
347    #[test]
348    fn template_error_display_panic() {
349        let err = TemplateError::panic("custom panic message");
350        assert_eq!(err.to_string(), "template panic: custom panic message");
351    }
352
353    // ── levenshtein_distance ────────────────────────────────────────
354
355    #[test]
356    fn levenshtein_identical_strings() {
357        assert_eq!(levenshtein_distance("hello", "hello"), 0);
358    }
359
360    #[test]
361    fn levenshtein_empty_strings() {
362        assert_eq!(levenshtein_distance("", ""), 0);
363    }
364
365    #[test]
366    fn levenshtein_one_empty() {
367        assert_eq!(levenshtein_distance("abc", ""), 3);
368        assert_eq!(levenshtein_distance("", "xyz"), 3);
369    }
370
371    #[test]
372    fn levenshtein_single_char_diff() {
373        assert_eq!(levenshtein_distance("cat", "bat"), 1);
374    }
375
376    #[test]
377    fn levenshtein_kitten_sitting() {
378        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
379    }
380
381    #[test]
382    fn levenshtein_completely_different() {
383        assert_eq!(levenshtein_distance("abc", "xyz"), 3);
384    }
385
386    #[test]
387    fn levenshtein_prefix() {
388        assert_eq!(levenshtein_distance("abc", "abcdef"), 3);
389    }
390
391    #[test]
392    fn levenshtein_single_insertion() {
393        assert_eq!(levenshtein_distance("ac", "abc"), 1);
394    }
395
396    #[test]
397    fn levenshtein_single_deletion() {
398        assert_eq!(levenshtein_distance("abc", "ac"), 1);
399    }
400
401    #[test]
402    fn levenshtein_symmetric() {
403        assert_eq!(
404            levenshtein_distance("foo", "bar"),
405            levenshtein_distance("bar", "foo")
406        );
407    }
408}