retex-engine 1.0.0-rc.2

The TeX typesetting engine for retex
Documentation
//! Structured diagnostics for retex's default output mode.
//!
//! In compatibility mode the terminal shows exactly what LuaTeX prints. In
//! the default mode the terminal is quiet and the engine records errors and
//! warnings here instead, with their source location; the command-line
//! driver presents them. The log file is the same in both modes.

/// How serious a diagnostic is.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Severity {
    /// A TeX error (the run continues after recovery, or stops if fatal).
    Error,
    /// A warning: overfull/underfull boxes, LaTeX and package warnings,
    /// missing characters.
    Warning,
}

/// One error or warning.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Diagnostic {
    /// Error or warning.
    pub severity: Severity,
    /// Who reports it, e.g. `LaTeX`, `Package hyperref`; `None` for the
    /// engine itself.
    pub origin: Option<String>,
    /// The message, on one line.
    pub message: String,
    /// The input file, as TeX opened it.
    pub file: Option<String>,
    /// The line number in `file` (1-based).
    pub line: Option<u32>,
    /// The source line, if known.
    pub source: Option<String>,
    /// The 0-based character offset in `source` where reading stopped.
    pub column: Option<usize>,
    /// Help text.
    pub help: Vec<String>,
    /// Explanations of the cause, shown before the help.
    pub notes: Vec<String>,
    /// What the engine knew when the problem arose, for the explanations
    /// of the default output mode: `similar` (defined commands close to an
    /// undefined one), `token` (the token that caused a "Missing $"),
    /// `math_line` (where the current formula began), …
    pub facts: Vec<(String, String)>,
}

impl Diagnostic {
    /// A diagnostic without location or help.
    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
        Diagnostic {
            severity,
            origin: None,
            message: message.into(),
            file: None,
            line: None,
            source: None,
            column: None,
            help: Vec::new(),
            notes: Vec::new(),
            facts: Vec::new(),
        }
    }

    /// The fact named `key`.
    pub fn fact(&self, key: &str) -> Option<&str> {
        self.facts
            .iter()
            .find(|(k, _)| k == key)
            .map(|(_, v)| v.as_str())
    }
}

/// The Levenshtein distance between `a` and `b`, or `None` if it is more
/// than `max` (cheap to reject).
pub fn edit_distance_within(a: &[u8], b: &[u8], max: usize) -> Option<usize> {
    if a.len().abs_diff(b.len()) > max {
        return None;
    }
    let mut prev: Vec<usize> = (0..=b.len()).collect();
    for i in 1..=a.len() {
        let mut cur = vec![i; b.len() + 1];
        let mut row_min = cur[0];
        for j in 1..=b.len() {
            let cost = usize::from(a[i - 1] != b[j - 1]);
            cur[j] = (prev[j] + 1).min(cur[j - 1] + 1).min(prev[j - 1] + cost);
            row_min = row_min.min(cur[j]);
        }
        if row_min > max {
            return None;
        }
        prev = cur;
    }
    (prev[b.len()] <= max).then_some(prev[b.len()])
}

/// Parses a LaTeX-style warning written to the terminal, such as
/// `LaTeX Warning: Reference `x' on page 1 undefined on input line 12.` or
/// `Package hyperref Warning: Token not allowed ... (hyperref) removing ...`.
/// Returns the origin, the message (continuation prefixes removed, lines
/// joined) and the input line it names, if any.
pub fn parse_latex_warning(text: &str) -> Option<(String, String, Option<u32>)> {
    let text = text.trim_start_matches('\n').trim_end();
    let (head, rest) = text.split_once(" Warning: ")?;
    let first_word = head.split(' ').next()?;
    if !matches!(
        first_word,
        "LaTeX" | "Package" | "Class" | "pdfTeX" | "luatex"
    ) {
        return None;
    }
    if head.contains('\n') {
        return None;
    }
    // Continuation lines start with "(name)" and spaces.
    let name = head.rsplit(' ').next().unwrap_or(head);
    let prefix = format!("({name})");
    let mut parts: Vec<&str> = Vec::new();
    for (i, l) in rest.lines().enumerate() {
        let l = if i == 0 {
            l
        } else {
            l.strip_prefix(prefix.as_str()).unwrap_or(l)
        };
        let l = l.trim();
        if !l.is_empty() {
            parts.push(l);
        }
    }
    let mut message = parts.join(" ");
    let mut line = None;
    if let Some(i) = message.rfind(" on input line ") {
        let num = message[i + 15..].trim_end_matches('.');
        if let Ok(n) = num.parse() {
            line = Some(n);
            message.truncate(i);
            message.push('.');
        }
    }
    Some((head.to_owned(), message, line))
}

/// Parses an error that LaTeX writes to the terminal instead of raising
/// it with `\errmessage`: `! LaTeX Error: File `x.sty' not found.` and
/// the like (`! Package P Error: …`, `! Class C Error: …`). Returns the
/// origin (`LaTeX`, `Package P`, …) and the first line of the message.
pub fn parse_latex_typed_error(text: &str) -> Option<(String, String)> {
    let line = text.trim_start_matches('\n').lines().next()?.trim();
    let rest = line.strip_prefix("! ")?;
    let (head, message) = rest.split_once(" Error: ")?;
    let first_word = head.split(' ').next()?;
    if !matches!(first_word, "LaTeX" | "Package" | "Class") {
        return None;
    }
    Some((head.to_owned(), message.trim().to_owned()))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn latex_warnings() {
        assert_eq!(
            parse_latex_warning(
                "\nLaTeX Warning: Reference `fig:x' on page 1 undefined on input line 12.\n"
            ),
            Some((
                "LaTeX".to_owned(),
                "Reference `fig:x' on page 1 undefined.".to_owned(),
                Some(12)
            ))
        );
        assert_eq!(
            parse_latex_warning(
                "\nPackage hyperref Warning: Token not allowed in a PDF string\n\
                 (hyperref)                removing `math shift' on input line 5.\n"
            ),
            Some((
                "Package hyperref".to_owned(),
                "Token not allowed in a PDF string removing `math shift'.".to_owned(),
                Some(5)
            ))
        );
        assert_eq!(parse_latex_warning("Some other message"), None);
    }

    #[test]
    fn latex_typed_errors() {
        assert_eq!(
            parse_latex_typed_error(
                "\n! LaTeX Error: File `beamerthemex.sty' not found.\n\nType X to quit"
            ),
            Some((
                "LaTeX".to_owned(),
                "File `beamerthemex.sty' not found.".to_owned()
            ))
        );
        assert_eq!(parse_latex_typed_error("LaTeX Warning: x"), None);
    }
}