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    /// Return the stable, data-independent [`ErrorKind`] of this error.
91    ///
92    /// Unlike matching on the enum variants (which also carry payloads), this
93    /// gives a lightweight, `Copy` discriminant that language bindings can map
94    /// onto their own typed-error hierarchies without parsing the display
95    /// message.
96    #[must_use]
97    pub fn kind(&self) -> ErrorKind {
98        match self {
99            #[cfg(feature = "std")]
100            Self::Io(_) => ErrorKind::Io,
101            Self::UndefinedVariable(_) => ErrorKind::UndefinedVariable,
102            Self::Syntax(_) => ErrorKind::Syntax,
103            Self::MissingParams(_) => ErrorKind::MissingParams,
104            Self::TypeMismatch { .. } => ErrorKind::TypeMismatch,
105            Self::UnknownFilter(_) => ErrorKind::UnknownFilter,
106            Self::IncludeNotFound(_) => ErrorKind::IncludeNotFound,
107            Self::DeclarationsMutated { .. } => ErrorKind::DeclarationsMutated,
108            Self::ExtraParams(_) => ErrorKind::ExtraParams,
109            Self::Panic(_) => ErrorKind::Panic,
110        }
111    }
112}
113
114/// A stable, `Copy` categorisation of a [`TemplateError`].
115///
116/// This mirrors the [`TemplateError`] variants but drops their payloads, giving
117/// a discriminant that is cheap to pass around and stable across releases. It is
118/// primarily intended for FFI and language bindings that expose a typed-error
119/// hierarchy of their own.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub enum ErrorKind {
123    /// I/O error while loading a template file. See [`TemplateError::Io`].
124    Io,
125    /// A referenced variable was not found. See [`TemplateError::UndefinedVariable`].
126    UndefinedVariable,
127    /// Syntax error in the template. See [`TemplateError::Syntax`].
128    Syntax,
129    /// Missing required parameters. See [`TemplateError::MissingParams`].
130    MissingParams,
131    /// Type mismatch. See [`TemplateError::TypeMismatch`].
132    TypeMismatch,
133    /// Unknown filter name. See [`TemplateError::UnknownFilter`].
134    UnknownFilter,
135    /// Include file not found. See [`TemplateError::IncludeNotFound`].
136    IncludeNotFound,
137    /// Declarations mutated at runtime. See [`TemplateError::DeclarationsMutated`].
138    DeclarationsMutated,
139    /// Extra undeclared parameters. See [`TemplateError::ExtraParams`].
140    ExtraParams,
141    /// Explicit `{% panic(...) %}`. See [`TemplateError::Panic`].
142    Panic,
143}
144
145impl ErrorKind {
146    /// Return the stable, machine-readable identifier for this kind.
147    ///
148    /// These identifiers are part of the public contract (they cross the FFI
149    /// boundary), so they must not change between releases.
150    #[must_use]
151    pub const fn as_str(self) -> &'static str {
152        match self {
153            Self::Io => "io",
154            Self::UndefinedVariable => "undefined_variable",
155            Self::Syntax => "syntax",
156            Self::MissingParams => "missing_params",
157            Self::TypeMismatch => "type_mismatch",
158            Self::UnknownFilter => "unknown_filter",
159            Self::IncludeNotFound => "include_not_found",
160            Self::DeclarationsMutated => "declarations_mutated",
161            Self::ExtraParams => "extra_params",
162            Self::Panic => "panic",
163        }
164    }
165}
166
167impl fmt::Display for ErrorKind {
168    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
169        f.write_str(self.as_str())
170    }
171}
172
173/// A structured syntax error with optional line number and source context.
174///
175/// Callers that match on [`TemplateError::Syntax`] can inspect `line` and
176/// `snippet` programmatically instead of parsing the error message string.
177#[derive(Debug, Clone)]
178pub struct SyntaxError {
179    /// The error message (without location prefix).
180    pub message: String,
181    /// 1-based line number where the error occurred (if known).
182    pub line: Option<usize>,
183    /// Snippet of the offending source line (if available).
184    pub snippet: Option<String>,
185}
186
187impl SyntaxError {
188    /// Create a syntax error with just a message.
189    #[must_use]
190    pub fn new(message: impl Into<String>) -> Self {
191        Self {
192            message: message.into(),
193            line: None,
194            snippet: None,
195        }
196    }
197
198    /// Attach a line number and source snippet.
199    #[must_use]
200    pub fn at_line(mut self, line: usize, snippet: impl Into<String>) -> Self {
201        self.line = Some(line);
202        self.snippet = Some(snippet.into());
203        self
204    }
205}
206
207impl fmt::Display for SyntaxError {
208    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
209        match (self.line, self.snippet.as_deref()) {
210            (Some(line), Some(snippet)) => {
211                write!(f, "line {line}: {}\n  --> {snippet}", self.message)
212            }
213            (Some(line), None) => write!(f, "line {line}: {}", self.message),
214            _ => f.write_str(&self.message),
215        }
216    }
217}
218
219/// Allow `TemplateError::Syntax("message".to_string().into())` etc.
220impl From<String> for SyntaxError {
221    fn from(message: String) -> Self {
222        Self::new(message)
223    }
224}
225
226/// Compute the Levenshtein edit distance between two strings.
227///
228/// Returns the minimum number of single-character edits (insertions,
229/// deletions, or substitutions) required to transform `a` into `b`.
230pub(crate) fn levenshtein_distance(a: &str, b: &str) -> usize {
231    let a_len = a.len();
232    let b_len = b.len();
233    if a_len == 0 {
234        return b_len;
235    }
236    if b_len == 0 {
237        return a_len;
238    }
239    let mut prev: Vec<usize> = (0..=b_len).collect();
240    let mut curr = vec![0; b_len + 1];
241    for (i, ca) in a.chars().enumerate() {
242        curr[0] = i + 1;
243        for (j, cb) in b.chars().enumerate() {
244            let cost = usize::from(ca != cb);
245            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
246        }
247        core::mem::swap(&mut prev, &mut curr);
248    }
249    prev[b_len]
250}
251
252#[cfg(test)]
253mod tests {
254    use alloc::string::ToString;
255
256    use super::*;
257
258    // ── SyntaxError::new ────────────────────────────────────────────
259
260    #[test]
261    fn syntax_error_new_sets_message() {
262        let err = SyntaxError::new("unexpected token");
263        assert_eq!(err.message, "unexpected token");
264    }
265
266    #[test]
267    fn syntax_error_new_defaults_line_to_none() {
268        let err = SyntaxError::new("oops");
269        assert!(err.line.is_none());
270    }
271
272    #[test]
273    fn syntax_error_new_defaults_snippet_to_none() {
274        let err = SyntaxError::new("oops");
275        assert!(err.snippet.is_none());
276    }
277
278    // ── SyntaxError::at_line ────────────────────────────────────────
279
280    #[test]
281    fn syntax_error_at_line_sets_line_and_snippet() {
282        let err = SyntaxError::new("bad token").at_line(42, "{{ bad }}");
283        assert_eq!(err.line, Some(42));
284        assert_eq!(err.snippet.as_deref(), Some("{{ bad }}"));
285        assert_eq!(err.message, "bad token");
286    }
287
288    #[test]
289    fn syntax_error_at_line_line_one() {
290        let err = SyntaxError::new("err").at_line(1, "line1");
291        assert_eq!(err.line, Some(1));
292    }
293
294    // ── SyntaxError Display ─────────────────────────────────────────
295
296    #[test]
297    fn syntax_error_display_with_line_and_snippet() {
298        let err = SyntaxError::new("unexpected end").at_line(7, "{{ if");
299        let formatted = err.to_string();
300        assert_eq!(formatted, "line 7: unexpected end\n  --> {{ if");
301    }
302
303    #[test]
304    fn syntax_error_display_with_line_only() {
305        let mut err = SyntaxError::new("missing bracket");
306        err.line = Some(3);
307        // snippet is None
308        let formatted = err.to_string();
309        assert_eq!(formatted, "line 3: missing bracket");
310    }
311
312    #[test]
313    fn syntax_error_display_message_only() {
314        let err = SyntaxError::new("generic problem");
315        assert_eq!(err.to_string(), "generic problem");
316    }
317
318    #[test]
319    fn syntax_error_display_message_only_when_snippet_without_line() {
320        // Edge case: snippet set but line is None → falls into the catch-all
321        let mut err = SyntaxError::new("edge case");
322        err.snippet = Some("some snippet".into());
323        // line is None, so the `_` branch fires
324        assert_eq!(err.to_string(), "edge case");
325    }
326
327    // ── From<String> for SyntaxError ────────────────────────────────
328
329    #[test]
330    fn syntax_error_from_string() {
331        let s = String::from("converted message");
332        let err: SyntaxError = s.into();
333        assert_eq!(err.message, "converted message");
334        assert!(err.line.is_none());
335        assert!(err.snippet.is_none());
336    }
337
338    // ── TemplateError::syntax() convenience ─────────────────────────
339
340    #[test]
341    fn template_error_syntax_constructor() {
342        let err = TemplateError::syntax("bad template");
343        match &err {
344            TemplateError::Syntax(inner) => {
345                assert_eq!(inner.message, "bad template");
346                assert!(inner.line.is_none());
347            }
348            other => panic!("expected Syntax variant, got: {other}"),
349        }
350    }
351
352    #[test]
353    fn template_error_syntax_accepts_string() {
354        let err = TemplateError::syntax(String::from("owned msg"));
355        assert!(matches!(err, TemplateError::Syntax(_)));
356    }
357
358    // ── TemplateError Display for every non-Io variant ──────────────
359
360    #[test]
361    fn template_error_display_undefined_variable() {
362        let err = TemplateError::UndefinedVariable("user_name".into());
363        assert_eq!(err.to_string(), "undefined variable: user_name");
364    }
365
366    #[test]
367    fn template_error_display_syntax() {
368        let err = TemplateError::Syntax(SyntaxError::new("unexpected end of input"));
369        assert_eq!(
370            err.to_string(),
371            "template syntax error: unexpected end of input"
372        );
373    }
374
375    #[test]
376    fn template_error_display_missing_params() {
377        let err = TemplateError::MissingParams(vec!["alpha".into(), "beta".into()]);
378        assert_eq!(err.to_string(), "missing required parameters: alpha, beta");
379    }
380
381    #[test]
382    fn template_error_display_missing_params_single() {
383        let err = TemplateError::MissingParams(vec!["only".into()]);
384        assert_eq!(err.to_string(), "missing required parameters: only");
385    }
386
387    #[test]
388    fn template_error_display_type_mismatch() {
389        let err = TemplateError::TypeMismatch {
390            name: "count".into(),
391            expected: "int".into(),
392            actual: "string".into(),
393            actual_value: "\"hello\"".into(),
394        };
395        assert_eq!(
396            err.to_string(),
397            "type mismatch for 'count': expected int, got string (\"hello\")"
398        );
399    }
400
401    #[test]
402    fn template_error_display_unknown_filter() {
403        let err = TemplateError::UnknownFilter("capitalize".into());
404        assert_eq!(err.to_string(), "unknown filter: capitalize");
405    }
406
407    #[test]
408    fn template_error_display_include_not_found() {
409        let err = TemplateError::IncludeNotFound("header.tmpl".into());
410        assert_eq!(err.to_string(), "include not found: header.tmpl");
411    }
412
413    #[test]
414    fn template_error_display_declarations_mutated() {
415        let err = TemplateError::DeclarationsMutated {
416            details: "added parameter 'foo'".into(),
417        };
418        let msg = err.to_string();
419        assert!(msg.contains("declarations were modified at runtime"));
420        assert!(msg.contains("added parameter 'foo'"));
421    }
422
423    #[test]
424    fn template_error_display_extra_params() {
425        let err = TemplateError::ExtraParams(vec!["x".into(), "y".into()]);
426        assert_eq!(err.to_string(), "extra undeclared parameters: x, y");
427    }
428
429    #[test]
430    fn template_error_display_panic() {
431        let err = TemplateError::panic("custom panic message");
432        assert_eq!(err.to_string(), "template panic: custom panic message");
433    }
434
435    // ── ErrorKind ───────────────────────────────────────────────────
436
437    #[test]
438    fn error_kind_maps_every_variant() {
439        assert_eq!(
440            TemplateError::UndefinedVariable("x".into()).kind(),
441            ErrorKind::UndefinedVariable
442        );
443        assert_eq!(TemplateError::syntax("bad").kind(), ErrorKind::Syntax);
444        assert_eq!(
445            TemplateError::MissingParams(vec!["a".into()]).kind(),
446            ErrorKind::MissingParams
447        );
448        assert_eq!(
449            TemplateError::TypeMismatch {
450                name: "n".into(),
451                expected: "int".into(),
452                actual: "str".into(),
453                actual_value: "\"x\"".into(),
454            }
455            .kind(),
456            ErrorKind::TypeMismatch
457        );
458        assert_eq!(
459            TemplateError::UnknownFilter("f".into()).kind(),
460            ErrorKind::UnknownFilter
461        );
462        assert_eq!(
463            TemplateError::IncludeNotFound("i".into()).kind(),
464            ErrorKind::IncludeNotFound
465        );
466        assert_eq!(
467            TemplateError::DeclarationsMutated {
468                details: "d".into()
469            }
470            .kind(),
471            ErrorKind::DeclarationsMutated
472        );
473        assert_eq!(
474            TemplateError::ExtraParams(vec!["e".into()]).kind(),
475            ErrorKind::ExtraParams
476        );
477        assert_eq!(TemplateError::panic("p").kind(), ErrorKind::Panic);
478    }
479
480    #[test]
481    fn error_kind_as_str_is_stable() {
482        // These identifiers cross the FFI boundary and are part of the public
483        // contract — pin them so an accidental rename is caught.
484        assert_eq!(ErrorKind::Io.as_str(), "io");
485        assert_eq!(ErrorKind::UndefinedVariable.as_str(), "undefined_variable");
486        assert_eq!(ErrorKind::Syntax.as_str(), "syntax");
487        assert_eq!(ErrorKind::MissingParams.as_str(), "missing_params");
488        assert_eq!(ErrorKind::TypeMismatch.as_str(), "type_mismatch");
489        assert_eq!(ErrorKind::UnknownFilter.as_str(), "unknown_filter");
490        assert_eq!(ErrorKind::IncludeNotFound.as_str(), "include_not_found");
491        assert_eq!(
492            ErrorKind::DeclarationsMutated.as_str(),
493            "declarations_mutated"
494        );
495        assert_eq!(ErrorKind::ExtraParams.as_str(), "extra_params");
496        assert_eq!(ErrorKind::Panic.as_str(), "panic");
497    }
498
499    #[test]
500    fn error_kind_display_matches_as_str() {
501        assert_eq!(ErrorKind::MissingParams.to_string(), "missing_params");
502    }
503
504    // ── levenshtein_distance ────────────────────────────────────────
505
506    #[test]
507    fn levenshtein_identical_strings() {
508        assert_eq!(levenshtein_distance("hello", "hello"), 0);
509    }
510
511    #[test]
512    fn levenshtein_empty_strings() {
513        assert_eq!(levenshtein_distance("", ""), 0);
514    }
515
516    #[test]
517    fn levenshtein_one_empty() {
518        assert_eq!(levenshtein_distance("abc", ""), 3);
519        assert_eq!(levenshtein_distance("", "xyz"), 3);
520    }
521
522    #[test]
523    fn levenshtein_single_char_diff() {
524        assert_eq!(levenshtein_distance("cat", "bat"), 1);
525    }
526
527    #[test]
528    fn levenshtein_kitten_sitting() {
529        assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
530    }
531
532    #[test]
533    fn levenshtein_completely_different() {
534        assert_eq!(levenshtein_distance("abc", "xyz"), 3);
535    }
536
537    #[test]
538    fn levenshtein_prefix() {
539        assert_eq!(levenshtein_distance("abc", "abcdef"), 3);
540    }
541
542    #[test]
543    fn levenshtein_single_insertion() {
544        assert_eq!(levenshtein_distance("ac", "abc"), 1);
545    }
546
547    #[test]
548    fn levenshtein_single_deletion() {
549        assert_eq!(levenshtein_distance("abc", "ac"), 1);
550    }
551
552    #[test]
553    fn levenshtein_symmetric() {
554        assert_eq!(
555            levenshtein_distance("foo", "bar"),
556            levenshtein_distance("bar", "foo")
557        );
558    }
559}