Skip to main content

tabnas/
error.rs

1// Copyright (c) 2013-2026 Richard Rodger, MIT License
2
3use serde::ser::{Serialize, SerializeStruct, Serializer};
4use std::any::Any;
5use std::collections::{BTreeMap, HashMap};
6use std::fmt;
7use std::fmt::Write;
8use std::panic::{catch_unwind, AssertUnwindSafe};
9
10use crate::options::{ColorOptions, ErrorSuffix, ErrorSuffixContext, Options};
11use crate::token::Token;
12use crate::value::Value;
13
14#[derive(Debug, Clone, PartialEq)]
15pub struct ErrorToken {
16    pub name: String,
17    pub src: String,
18}
19
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct RecoveredAt {
22    pub skipped: usize,
23    pub sync: Option<crate::token::Tin>,
24    pub bad: bool,
25}
26
27#[derive(Debug, Clone, PartialEq)]
28pub struct TabnasError {
29    pub code: String,
30    pub detail: String,
31    pub pos: usize,
32    pub row: usize,
33    pub col: usize,
34    pub src: String,
35    pub hint: String,
36    pub tag: String,
37    pub full_source: String,
38    pub len: usize,
39    pub rule: String,
40    pub rule_stack: Vec<String>,
41    pub token: ErrorToken,
42    pub expected: Vec<String>,
43    pub plugins: Vec<String>,
44    /// Recovery metadata is intentionally not part of the stable serialized
45    /// diagnostic schema, matching the canonical implementations.
46    pub recovered: Option<RecoveredAt>,
47    details: HashMap<String, Value>,
48    rendered_detail: String,
49    rendered_hint: String,
50    instance_tag: String,
51    rule_state: String,
52    why: String,
53    suffix: ErrorSuffix,
54    link: String,
55    color: ColorOptions,
56}
57
58impl TabnasError {
59    pub fn new(
60        code: impl Into<String>,
61        src: impl Into<String>,
62        full_source: impl Into<String>,
63        pos: usize,
64        row: usize,
65        col: usize,
66    ) -> Self {
67        let code_str = code.into();
68        let src_str = src.into();
69        let len = src_str.chars().count();
70        let full_src = full_source.into();
71        let detail = format_error_message(&code_str, &src_str);
72        let hint = format_error_hint(&code_str, &src_str);
73
74        TabnasError {
75            code: code_str,
76            detail: detail.clone(),
77            pos,
78            row,
79            col,
80            src: src_str.clone(),
81            hint: hint.clone(),
82            tag: "tabnas".to_string(),
83            full_source: full_src,
84            len,
85            rule: String::new(),
86            rule_stack: Vec::new(),
87            token: ErrorToken {
88                name: "#BD".to_string(),
89                src: src_str,
90            },
91            expected: Vec::new(),
92            plugins: Vec::new(),
93            recovered: None,
94            details: HashMap::new(),
95            rendered_detail: detail,
96            rendered_hint: hint,
97            instance_tag: "-".into(),
98            rule_state: String::new(),
99            why: String::new(),
100            suffix: ErrorSuffix::Standard,
101            link: String::new(),
102            color: ColorOptions::default(),
103        }
104    }
105
106    pub(crate) fn apply_options(&mut self, options: &Options) {
107        let render = ErrorRender {
108            code: &self.code,
109            src: &self.src,
110            pos: self.pos,
111            row: self.row,
112            col: self.col,
113            rule: &self.rule,
114            details: &self.details,
115        };
116        let next_detail = format_from_catalog(&options.error, &render);
117        if self.detail == self.rendered_detail {
118            self.detail = next_detail.clone();
119        }
120        self.rendered_detail = next_detail;
121
122        let next_hint = format_hint_from_catalog(&options.hint, &render);
123        if self.hint == self.rendered_hint {
124            self.hint = next_hint.clone();
125        }
126        self.rendered_hint = next_hint;
127
128        self.tag.clone_from(&options.errmsg.name);
129        self.instance_tag.clone_from(&options.tag);
130        self.suffix.clone_from(&options.errmsg.suffix);
131        self.link.clone_from(&options.errmsg.link);
132        self.color.clone_from(&options.color);
133    }
134
135    pub(crate) fn from_panic(
136        payload: Box<dyn Any + Send>,
137        api: &str,
138        full_source: &str,
139        pos: usize,
140        row: usize,
141        col: usize,
142        options: &Options,
143    ) -> Self {
144        let mut error = if let Some(error) = payload.downcast_ref::<Self>() {
145            error.clone()
146        } else if let Some(error) = payload.downcast_ref::<Box<Self>>() {
147            (**error).clone()
148        } else {
149            let detail = payload
150                .downcast_ref::<&str>()
151                .map(|message| (*message).to_string())
152                .or_else(|| payload.downcast_ref::<String>().cloned())
153                .unwrap_or_else(|| "unknown panic".into());
154            Self::new(
155                "internal",
156                format!("{api}: {detail}"),
157                full_source,
158                pos,
159                row,
160                col,
161            )
162        };
163        if error.full_source.is_empty() {
164            error.full_source = full_source.into();
165        }
166        if error.row == 0 {
167            error.row = row;
168        }
169        if error.col == 0 {
170            error.col = col;
171        }
172        error.apply_options(options);
173        error
174    }
175
176    pub fn attach_context(
177        &mut self,
178        rule: &str,
179        rule_state: &str,
180        rule_stack: Vec<String>,
181        token: Option<&Token>,
182        mut expected: Vec<String>,
183    ) {
184        self.rule = rule.to_string();
185        self.rule_state = rule_state.to_string();
186        self.rule_stack = rule_stack;
187        expected.sort();
188        expected.dedup();
189        self.expected = expected;
190        if let Some(token) = token {
191            self.pos = token.site.pos;
192            self.row = token.site.ri;
193            self.col = token.site.ci;
194            self.len = token.src.chars().count();
195            self.token = ErrorToken {
196                name: token.name.to_string(),
197                src: token.src.to_string(),
198            };
199            self.details.clone_from(token.use_data());
200            self.why = if token.why.is_empty() {
201                token.err.to_string()
202            } else {
203                token.why.to_string()
204            };
205        }
206    }
207
208    fn source_line(&self) -> String {
209        self.full_source
210            .lines()
211            .nth(self.row.saturating_sub(1))
212            .unwrap_or("")
213            .to_string()
214    }
215}
216
217impl Serialize for TabnasError {
218    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
219    where
220        S: Serializer,
221    {
222        let mut state = serializer.serialize_struct("TabnasError", 15)?;
223        state.serialize_field("status", "failure")?;
224        state.serialize_field("code", &self.code)?;
225        state.serialize_field("message", &self.detail)?;
226        state.serialize_field("hint", &self.hint)?;
227        state.serialize_field("row", &self.row)?;
228        state.serialize_field("col", &self.col)?;
229        state.serialize_field("pos", &self.pos)?;
230        state.serialize_field("len", &self.len)?;
231        state.serialize_field("rule", &self.rule)?;
232        state.serialize_field("ruleStack", &self.rule_stack)?;
233        state.serialize_field(
234            "token",
235            &serde_json::json!({"name": self.token.name, "src": self.token.src}),
236        )?;
237        state.serialize_field("expected", &self.expected)?;
238        state.serialize_field("src", &self.source_line())?;
239        state.serialize_field("plugins", &self.plugins)?;
240        state.serialize_field("version", crate::VERSION)?;
241        state.end()
242    }
243}
244
245impl fmt::Display for TabnasError {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        let (hi, lo, line, reset) = self.color.codes();
248        write!(
249            f,
250            "{hi}[{}/{}]:{reset} {}\n  {line}-->{reset} <no-file>:{}:{}",
251            self.tag, self.code, self.detail, self.row, self.col
252        )?;
253
254        if !self.full_source.is_empty() {
255            let site = source_site(
256                &self.full_source,
257                &self.src,
258                &self.detail,
259                self.row,
260                self.col,
261                &self.color,
262            );
263            if !site.is_empty() {
264                write!(f, "\n{site}")?;
265            }
266        }
267
268        if !self.hint.is_empty() {
269            for (index, hint_line) in self.hint.trim().lines().enumerate() {
270                if index == 0 {
271                    write!(f, "\n\n  {hint_line}")?;
272                } else {
273                    write!(f, "\n  {hint_line}")?;
274                }
275            }
276        }
277
278        match &self.suffix {
279            ErrorSuffix::Disabled => {}
280            ErrorSuffix::Text(text) => write!(f, "\n{text}")?,
281            ErrorSuffix::Callback(render) => {
282                let context = ErrorSuffixContext {
283                    code: self.code.clone(),
284                    source: self.src.clone(),
285                    message: self.detail.clone(),
286                    hint: self.hint.clone(),
287                    pos: self.pos,
288                    row: self.row,
289                    col: self.col,
290                    name: self.tag.clone(),
291                    tag: self.instance_tag.clone(),
292                    rule: self.rule.clone(),
293                    rule_state: self.rule_state.clone(),
294                    token: self.token.name.clone(),
295                    why: self.why.clone(),
296                    plugins: self.plugins.clone(),
297                    color: self.color.clone(),
298                };
299                let suffix = catch_unwind(AssertUnwindSafe(|| render(&context)))
300                    .unwrap_or_else(|_| "--internal: errmsg.suffix callback panicked--".into());
301                write!(f, "\n{suffix}")?;
302            }
303            ErrorSuffix::Standard => {
304                if !self.link.is_empty() {
305                    write!(f, "\n\n  {lo}{}{reset}", self.link)?;
306                }
307                write!(
308                    f,
309                    "\n\n  {lo}--internal: tag={}; rule={}~{}; token={}",
310                    self.instance_tag, self.rule, self.rule_state, self.token.name
311                )?;
312                if !self.why.is_empty() {
313                    write!(f, "~{}", self.why)?;
314                }
315                write!(f, "; plugins={}--{reset}", self.plugins.join(","))?;
316            }
317        }
318
319        Ok(())
320    }
321}
322
323impl std::error::Error for TabnasError {}
324
325pub(crate) fn default_error_messages() -> HashMap<String, String> {
326    [
327        ("unknown", "unknown error: {code}"),
328        ("unexpected", "unexpected character(s): {src}"),
329        ("invalid_unicode", "invalid unicode escape: {src}"),
330        ("invalid_ascii", "invalid ascii escape: {src}"),
331        ("unprintable", "unprintable character: {src}"),
332        ("unterminated_string", "unterminated string: {src}"),
333        ("unterminated_comment", "unterminated comment: {src}"),
334        ("unknown_rule", "unknown rule: {rulename}"),
335        ("end_of_source", "unexpected end of source"),
336        ("cancel", "parse cancelled"),
337        ("internal", "internal error: {src}"),
338    ]
339    .into_iter()
340    .map(|(code, template)| (code.into(), template.into()))
341    .collect()
342}
343
344pub(crate) fn default_error_hints() -> HashMap<String, String> {
345    [
346        (
347            "unknown",
348            "Unknown error code: {code}\nDetails:\n{details}",
349        ),
350        (
351            "unexpected",
352            "The character(s) {src} do not match any rule alternative active at\nthis position.",
353        ),
354        (
355            "invalid_unicode",
356            "The escape sequence {src} does not encode a valid unicode code point.",
357        ),
358        (
359            "invalid_ascii",
360            "The escape sequence {src} does not encode a valid ASCII character.",
361        ),
362        (
363            "unprintable",
364            "The character {src} (code point below 32) is not allowed inside a\nstring literal.",
365        ),
366        ("unterminated_string", "This string has no end quote."),
367        ("unterminated_comment", "This comment is never closed."),
368        ("unknown_rule", "No rule named {rulename} is defined."),
369        ("end_of_source", "Unexpected end of source."),
370        (
371            "cancel",
372            "The parse was cancelled by the caller's parse.budget.onCheck callback\n(or exceeded its configured budget) before completing.",
373        ),
374        (
375            "internal",
376            "The parser failed unexpectedly; this is a bug in tabnas\nor a plugin, not in your input.",
377        ),
378    ]
379    .into_iter()
380    .map(|(code, template)| (code.into(), template.into()))
381    .collect()
382}
383
384pub fn format_error_message(code: &str, src: &str) -> String {
385    format_from_catalog(
386        &default_error_messages(),
387        &ErrorRender {
388            code,
389            src,
390            pos: 0,
391            row: 1,
392            col: 1,
393            rule: if code == "unknown_rule" { src } else { "" },
394            details: &HashMap::new(),
395        },
396    )
397}
398
399pub fn format_error_hint(code: &str, src: &str) -> String {
400    format_hint_from_catalog(
401        &default_error_hints(),
402        &ErrorRender {
403            code,
404            src,
405            pos: 0,
406            row: 1,
407            col: 1,
408            rule: if code == "unknown_rule" { src } else { "" },
409            details: &HashMap::new(),
410        },
411    )
412}
413
414struct ErrorRender<'a> {
415    code: &'a str,
416    src: &'a str,
417    pos: usize,
418    row: usize,
419    col: usize,
420    rule: &'a str,
421    details: &'a HashMap<String, Value>,
422}
423
424fn format_from_catalog(catalog: &HashMap<String, String>, render: &ErrorRender<'_>) -> String {
425    let defaults = default_error_messages();
426    let template = catalog
427        .get(render.code)
428        .filter(|template| !template.is_empty())
429        .or_else(|| {
430            catalog
431                .get("unknown")
432                .filter(|template| !template.is_empty())
433        })
434        .or_else(|| defaults.get("unknown"))
435        .map(String::as_str)
436        .unwrap_or("unknown error: {code}");
437    interpolate(template, &interpolation_vars(render))
438}
439
440fn format_hint_from_catalog(catalog: &HashMap<String, String>, render: &ErrorRender<'_>) -> String {
441    let defaults = default_error_hints();
442    let Some(template) = catalog
443        .get(render.code)
444        .filter(|template| !template.is_empty())
445        .or_else(|| {
446            catalog
447                .get("unknown")
448                .filter(|template| !template.is_empty())
449        })
450        .or_else(|| defaults.get("unknown"))
451    else {
452        return String::new();
453    };
454    interpolate(template, &interpolation_vars(render))
455        .trim()
456        .to_string()
457}
458
459fn interpolation_vars(render: &ErrorRender<'_>) -> HashMap<String, String> {
460    let mut vars = HashMap::new();
461    vars.insert("code".into(), render.code.into());
462    vars.insert("src".into(), render.src.into());
463    vars.insert("pos".into(), render.pos.to_string());
464    vars.insert("row".into(), render.row.to_string());
465    vars.insert("col".into(), render.col.to_string());
466    let unknown_rule_name = render
467        .details
468        .get("rulename")
469        .map(injection_value)
470        .unwrap_or_else(|| {
471            if render.code == "unknown_rule" && render.rule.is_empty() {
472                render.src.into()
473            } else {
474                render.rule.into()
475            }
476        });
477    vars.insert("rulename".into(), unknown_rule_name);
478    for (key, value) in render.details {
479        vars.insert(key.clone(), injection_value(value));
480    }
481    vars.insert("details".into(), format_details(render.details));
482    vars
483}
484
485fn injection_value(value: &Value) -> String {
486    match value {
487        Value::Undefined => String::new(),
488        Value::Null => "null".into(),
489        Value::Bool(value) => value.to_string(),
490        Value::Number(value) => value.to_string(),
491        Value::String(value) => value.clone(),
492        other => serde_json::to_string(other).unwrap_or_else(|_| other.to_string()),
493    }
494}
495
496fn format_details(details: &HashMap<String, Value>) -> String {
497    let ordered: BTreeMap<_, _> = details.iter().collect();
498    let json = serde_json::to_string(&ordered).unwrap_or_else(|_| "{}".into());
499    json.replace('"', "")
500}
501
502fn interpolate(template: &str, vars: &HashMap<String, String>) -> String {
503    let mut out = String::with_capacity(template.len());
504    let mut chars = template.chars().peekable();
505    while let Some(c) = chars.next() {
506        if c == '{' {
507            let mut key = String::new();
508            while let Some(&k) = chars.peek() {
509                chars.next();
510                if k == '}' {
511                    break;
512                }
513                key.push(k);
514            }
515            if let Some(val) = vars.get(key.as_str()) {
516                out.push_str(val);
517            } else {
518                out.push('{');
519                out.push_str(&key);
520                out.push('}');
521            }
522        } else {
523            out.push(c);
524        }
525    }
526    out
527}
528
529fn source_site(
530    source: &str,
531    span: &str,
532    message: &str,
533    row: usize,
534    col: usize,
535    color: &ColorOptions,
536) -> String {
537    let row = row.max(1);
538    let col = col.max(1);
539    let lines: Vec<_> = source.split('\n').collect();
540    if lines.is_empty() {
541        return String::new();
542    }
543    let line_index = row.saturating_sub(1).min(lines.len() - 1);
544    let pad = (row + 2).to_string().len() + 2;
545    let (_, _, line_color, reset) = color.codes();
546    let mut output = Vec::new();
547    let render_line = |number: usize, text: &str| {
548        format!("{line_color}{number:>width$} | {reset}{text}", width = pad)
549    };
550
551    if line_index >= 2 {
552        output.push(render_line(row - 2, lines[line_index - 2]));
553    }
554    if line_index >= 1 {
555        output.push(render_line(row - 1, lines[line_index - 1]));
556    }
557    output.push(render_line(row, lines[line_index]));
558
559    let caret_count = span.chars().count().max(1);
560    let mut caret = String::new();
561    write!(
562        caret,
563        "{}   {}{line_color}{} {message}{reset}",
564        " ".repeat(pad),
565        " ".repeat(col - 1),
566        "^".repeat(caret_count),
567    )
568    .expect("writing to a String cannot fail");
569    output.push(caret);
570
571    if line_index + 1 < lines.len() {
572        output.push(render_line(row + 1, lines[line_index + 1]));
573    }
574    if line_index + 2 < lines.len() {
575        output.push(render_line(row + 2, lines[line_index + 2]));
576    }
577    output.join("\n")
578}