rexact-syntax 0.2.0

Regex parsers for ECMAScript, Python, Go, Rust, PCRE2 and Java dialects, with an error catalogue
Documentation
//! The error catalogue (docs/07). Single source of truth (RULE-17): the enum,
//! code strings, exit codes, meanings and remedies are all generated from the
//! one table below. A test cross-checks it against the documentation.

use std::fmt;

macro_rules! catalogue {
    ($( $variant:ident, $code:literal, $exit:literal, $meaning:literal, $remedy:literal; )+) => {
        /// One variant per catalogue entry, one-to-one with docs/07.
        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
        pub enum ErrorCode { $( #[doc = $meaning] $variant, )+ }

        impl ErrorCode {
            /// Every code, in catalogue order.
            pub const ALL: &'static [ErrorCode] = &[ $( ErrorCode::$variant, )+ ];

            /// The stable code string, e.g. `RX-P001`.
            pub const fn code(self) -> &'static str {
                match self { $( ErrorCode::$variant => $code, )+ }
            }
            /// Process exit code for this error (docs/06 exit-code table).
            pub const fn exit_code(self) -> u8 {
                match self { $( ErrorCode::$variant => $exit, )+ }
            }
            /// Human-readable meaning.
            pub const fn meaning(self) -> &'static str {
                match self { $( ErrorCode::$variant => $meaning, )+ }
            }
            /// Remedy shown to the user.
            pub const fn remedy(self) -> &'static str {
                match self { $( ErrorCode::$variant => $remedy, )+ }
            }
        }
    };
}

catalogue! {
    P001, "RX-P001", 3, "Syntax error in the pattern for the chosen dialect", "Position and caret; the expected construct";
    P002, "RX-P002", 3, "Unknown dialect name", "List of valid dialects";
    P003, "RX-P003", 3, "Flag not valid for the dialect, or conflicting flags", "Valid flags for the dialect";
    P004, "RX-P004", 3, "Invalid escape sequence for the dialect", "The valid escapes of the dialect";
    P005, "RX-P005", 3, "Invalid quantifier (nothing to repeat, reversed bounds, bound out of range)", "Corrected form";
    P006, "RX-P006", 3, "Invalid character class (empty or reversed range, unknown class name)", "Corrected form";
    P007, "RX-P007", 3, "Unbalanced group or class", "Position of the opener";
    P008, "RX-P008", 3, "Unknown Unicode property or script for the pinned Unicode version", "Nearest valid names";
    P009, "RX-P009", 3, "Invalid group name or duplicate name where the dialect forbids it", "Rename";
    P010, "RX-P010", 3, "Invalid /body/flags literal", "Expected literal shape";
    U001, "RX-U001", 4, "Backreference (language is not regular)", "Rewrite without the backreference, or analyse the parts separately";
    U002, "RX-U002", 4, "Recursion or subroutine call", "Not supported";
    U003, "RX-U003", 4, "Conditional group", "Rewrite as alternation where equivalent";
    U004, "RX-U004", 4, "Atomic group or possessive quantifier (changes matching semantics, not only the language)", "Remove, or use the analysis on the underlying pattern with a stated caveat";
    U005, "RX-U005", 4, "Lookaround form outside the restrictions", "Simplify or raise the nesting limit";
    U006, "RX-U006", 4, "Callout or embedded code", "Remove";
    U007, "RX-U007", 4, "Dialect feature recognised but not yet supported", "Listed in the support matrix";
    L001, "RX-L001", 2, "State limit reached", "Raise --limit-states or simplify";
    L002, "RX-L002", 2, "Time limit reached", "Raise --limit-time";
    L003, "RX-L003", 2, "Memory limit reached", "Raise --limit-memory";
    L004, "RX-L004", 2, "Repetition expansion limit reached", "Raise --limit-repeat or reduce the counts";
    L005, "RX-L005", 3, "Pattern too long", "Raise --limit-length";
    L006, "RX-L006", 3, "Nesting too deep", "Raise --limit-depth";
    L007, "RX-L007", 2, "Enumeration limit reached (witness or difference synthesis)", "Raise --limit-enumerate";
    L008, "RX-L008", 2, "Synthesised pattern size cap reached", "Raise the cap; examples are still shown";
    I001, "RX-I001", 6, "File not found or unreadable", "Path";
    I002, "RX-I002", 3, "Input is not valid UTF-8 where text is required", "Use --alphabet bytes or fix encoding";
    I003, "RX-I003", 6, "Not a git repository or unknown revision", "Valid revisions";
    I004, "RX-I004", 6, "Reference engine required but unavailable", "Install, or drop the requirement";
    I005, "RX-I005", 3, "Unknown source language for extraction", "Supported languages";
    C001, "RX-C001", 3, "Malformed configuration file", "Position";
    C002, "RX-C002", 3, "Ignore entry without a reason", "Add reason";
    C003, "RX-C003", 3, "Unknown option or value in configuration", "Valid names";
    E001, "RX-E001", 5, "Certificate rejected by the independent checker", "Report the case; includes the failing pair";
    E002, "RX-E002", 5, "Counterexample rejected by the independent matcher", "Report the case";
    E003, "RX-E003", 5, "Reference engine disagrees with the verdict", "Shows the engine, version and input";
    E004, "RX-E004", 5, "Certificate malformed or of an unknown version", "Regenerate";
    X001, "RX-X001", 70, "Internal invariant violated", "Report with version, command and a minimal pattern";
}

impl fmt::Display for ErrorCode {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.code())
    }
}

/// A located catalogue error. `position` is (line, column), 1-based, counted
/// in Unicode scalar values (docs/07 rule 2).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RexactError {
    /// Which catalogue entry this is.
    pub kind: ErrorCode,
    /// Optional detail appended to the catalogue meaning.
    pub detail: Option<String>,
    /// Position in the pattern text, when the error relates to one.
    pub position: Option<(usize, usize)>,
}

impl RexactError {
    /// Error with no detail or position.
    pub fn new(kind: ErrorCode) -> Self {
        Self {
            kind,
            detail: None,
            position: None,
        }
    }
    /// Stable code string (`RX-...`).
    pub fn code(&self) -> &'static str {
        self.kind.code()
    }
}

impl fmt::Display for RexactError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}: {}", self.kind.code(), self.kind.meaning())?;
        if let Some(d) = &self.detail {
            write!(f, " ({d})")?;
        }
        if let Some((l, c)) = self.position {
            write!(f, " at line {l}, column {c}")?;
        }
        write!(f, ". Remedy: {}", self.kind.remedy())
    }
}

impl std::error::Error for RexactError {}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use std::collections::HashSet;

    #[test]
    fn codes_are_unique_and_well_formed() {
        let mut seen = HashSet::new();
        for e in ErrorCode::ALL {
            assert!(seen.insert(e.code()), "duplicate {}", e.code());
            assert!(e.code().starts_with("RX-") && e.code().len() == 7);
            assert!(!e.meaning().is_empty() && !e.remedy().is_empty());
        }
    }

    #[test]
    fn exit_codes_are_in_the_documented_set() {
        for e in ErrorCode::ALL {
            assert!([2, 3, 4, 5, 6, 70].contains(&e.exit_code()), "{}", e.code());
        }
    }

    /// Catalogue consistency check (docs/11 section 4): every row of docs/07
    /// is in the data with the same exit code, in the same order.
    #[test]
    fn matches_documentation_table() {
        let path = concat!(
            env!("CARGO_MANIFEST_DIR"),
            "/../../docs/07-error-catalogue.md"
        );
        let doc = std::fs::read_to_string(path).expect("docs/07 readable");
        let mut documented = Vec::new();
        for line in doc.lines() {
            let cells: Vec<&str> = line.split('|').map(str::trim).collect();
            if cells.len() > 3 && cells[1].starts_with("RX-") {
                documented.push((cells[1].to_string(), cells[2].parse::<u8>().expect("exit")));
            }
        }
        let data: Vec<(String, u8)> = ErrorCode::ALL
            .iter()
            .map(|e| (e.code().to_string(), e.exit_code()))
            .collect();
        assert_eq!(documented, data);
    }

    #[test]
    fn display_includes_code_position_and_remedy() {
        let mut e = RexactError::new(ErrorCode::P007);
        e.position = Some((1, 1));
        let s = e.to_string();
        assert!(s.contains("RX-P007") && s.contains("line 1, column 1") && s.contains("Remedy"));
    }
}