Skip to main content

retex_engine/
diag.rs

1//! Structured diagnostics for retex's default output mode.
2//!
3//! In compatibility mode the terminal shows exactly what LuaTeX prints. In
4//! the default mode the terminal is quiet and the engine records errors and
5//! warnings here instead, with their source location; the command-line
6//! driver presents them. The log file is the same in both modes.
7
8/// How serious a diagnostic is.
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum Severity {
11    /// A TeX error (the run continues after recovery, or stops if fatal).
12    Error,
13    /// A warning: overfull/underfull boxes, LaTeX and package warnings,
14    /// missing characters.
15    Warning,
16    /// A message written to the terminal (`\message`, `\typeout`) or
17    /// shown (`\show`, `\showthe`, …).
18    Note,
19}
20
21/// One error or warning.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Diagnostic {
24    /// Error or warning.
25    pub severity: Severity,
26    /// Who reports it, e.g. `LaTeX`, `Package hyperref`; `None` for the
27    /// engine itself.
28    pub origin: Option<String>,
29    /// The message, on one line.
30    pub message: String,
31    /// The input file, as TeX opened it.
32    pub file: Option<String>,
33    /// The line number in `file` (1-based).
34    pub line: Option<u32>,
35    /// The source line, if known.
36    pub source: Option<String>,
37    /// The 0-based character offset in `source` where reading stopped.
38    pub column: Option<usize>,
39    /// Help text.
40    pub help: Vec<String>,
41    /// Explanations of the cause, shown before the help.
42    pub notes: Vec<String>,
43    /// What the engine knew when the problem arose, for the explanations
44    /// of the default output mode: `similar` (defined commands close to an
45    /// undefined one), `token` (the token that caused a "Missing $"),
46    /// `math_line` (where the current formula began), …
47    pub facts: Vec<(String, String)>,
48    /// Changes to the source line that would fix the problem, as
49    /// alternatives (the first is the likeliest).
50    pub fixes: Vec<Fix>,
51}
52
53/// A change to the source line of a diagnostic: some of its characters
54/// replaced by others.
55#[derive(Clone, Debug, PartialEq, Eq)]
56pub struct Fix {
57    /// The 0-based character offset of the first character replaced.
58    pub start: usize,
59    /// The offset after the last one (equal to `start` to insert).
60    pub end: usize,
61    /// The new text.
62    pub replacement: String,
63}
64
65impl Diagnostic {
66    /// A diagnostic without location or help.
67    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
68        Diagnostic {
69            severity,
70            origin: None,
71            message: message.into(),
72            file: None,
73            line: None,
74            source: None,
75            column: None,
76            help: Vec::new(),
77            notes: Vec::new(),
78            facts: Vec::new(),
79            fixes: Vec::new(),
80        }
81    }
82
83    /// The fact named `key`.
84    pub fn fact(&self, key: &str) -> Option<&str> {
85        self.facts
86            .iter()
87            .find(|(k, _)| k == key)
88            .map(|(_, v)| v.as_str())
89    }
90}
91
92/// The Levenshtein distance between `a` and `b`, or `None` if it is more
93/// than `max` (cheap to reject).
94pub fn edit_distance_within(a: &[u8], b: &[u8], max: usize) -> Option<usize> {
95    if a.len().abs_diff(b.len()) > max {
96        return None;
97    }
98    let mut prev: Vec<usize> = (0..=b.len()).collect();
99    for i in 1..=a.len() {
100        let mut cur = vec![i; b.len() + 1];
101        let mut row_min = cur[0];
102        for j in 1..=b.len() {
103            let cost = usize::from(a[i - 1] != b[j - 1]);
104            cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
105            row_min = row_min.min(cur[j]);
106        }
107        if row_min > max {
108            return None;
109        }
110        prev = cur;
111    }
112    (prev[b.len()] <= max).then_some(prev[b.len()])
113}
114
115/// Parses a LaTeX-style warning written to the terminal, such as
116/// `LaTeX Warning: Reference `x' on page 1 undefined on input line 12.` or
117/// `Package hyperref Warning: Token not allowed ... (hyperref) removing ...`.
118/// Returns the origin, the message (continuation prefixes removed, lines
119/// joined) and the input line it names, if any.
120pub fn parse_latex_warning(text: &str) -> Option<(String, String, Option<u32>)> {
121    let text = text.trim_start_matches('\n').trim_end();
122    let (head, rest) = text.split_once(" Warning: ")?;
123    let first_word = head.split(' ').next()?;
124    if !matches!(
125        first_word,
126        "LaTeX" | "Package" | "Class" | "pdfTeX" | "luatex"
127    ) {
128        return None;
129    }
130    if head.contains('\n') {
131        return None;
132    }
133    // Continuation lines start with "(name)" and spaces.
134    let name = head.rsplit(' ').next().unwrap_or(head);
135    let prefix = format!("({name})");
136    let mut parts: Vec<&str> = Vec::new();
137    for (i, l) in rest.lines().enumerate() {
138        let l = if i == 0 {
139            l
140        } else {
141            l.strip_prefix(prefix.as_str()).unwrap_or(l)
142        };
143        let l = l.trim();
144        if !l.is_empty() {
145            parts.push(l);
146        }
147    }
148    let mut message = parts.join(" ");
149    let mut line = None;
150    if let Some(i) = message.rfind(" on input line ") {
151        let num = message[i + 15..].trim_end_matches('.');
152        if let Ok(n) = num.parse() {
153            line = Some(n);
154            message.truncate(i);
155            message.push('.');
156        }
157    }
158    Some((head.to_owned(), message, line))
159}
160
161/// Parses an error that LaTeX writes to the terminal instead of raising
162/// it with `\errmessage`: `! LaTeX Error: File `x.sty' not found.` and
163/// the like (`! Package P Error: …`, `! Class C Error: …`). Returns the
164/// origin (`LaTeX`, `Package P`, …) and the first line of the message.
165pub fn parse_latex_typed_error(text: &str) -> Option<(String, String)> {
166    let line = text.trim_start_matches('\n').lines().next()?.trim();
167    let rest = line.strip_prefix("! ")?;
168    let (head, message) = rest.split_once(" Error: ")?;
169    let first_word = head.split(' ').next()?;
170    if !matches!(first_word, "LaTeX" | "Package" | "Class") {
171        return None;
172    }
173    Some((head.to_owned(), message.trim().to_owned()))
174}
175
176#[cfg(test)]
177mod tests {
178    use super::*;
179
180    #[test]
181    fn latex_warnings() {
182        assert_eq!(
183            parse_latex_warning(
184                "\nLaTeX Warning: Reference `fig:x' on page 1 undefined on input line 12.\n"
185            ),
186            Some((
187                "LaTeX".to_owned(),
188                "Reference `fig:x' on page 1 undefined.".to_owned(),
189                Some(12)
190            ))
191        );
192        assert_eq!(
193            parse_latex_warning(
194                "\nPackage hyperref Warning: Token not allowed in a PDF string\n\
195                 (hyperref)                removing `math shift' on input line 5.\n"
196            ),
197            Some((
198                "Package hyperref".to_owned(),
199                "Token not allowed in a PDF string removing `math shift'.".to_owned(),
200                Some(5)
201            ))
202        );
203        assert_eq!(parse_latex_warning("Some other message"), None);
204    }
205
206    #[test]
207    fn latex_typed_errors() {
208        assert_eq!(
209            parse_latex_typed_error(
210                "\n! LaTeX Error: File `beamerthemex.sty' not found.\n\nType X to quit"
211            ),
212            Some((
213                "LaTeX".to_owned(),
214                "File `beamerthemex.sty' not found.".to_owned()
215            ))
216        );
217        assert_eq!(parse_latex_typed_error("LaTeX Warning: x"), None);
218    }
219}