//! W3C XML 1.0 Fifth Edition grammar productions → Rust predicates.
//!
//! Reads the registered `xml_1_0_fifth_edition@2008` source — the
//! published `xmlspec.dtd`-format XML containing 86 `<prod>` blocks,
//! one per EBNF production. Extracts the character-class productions
//! (§2.2 \[2\] `Char`, §2.3 \[4\] `NameStartChar`, §2.3 \[4a\] `NameChar`)
//! and emits Rust source with const range tables + predicate
//! functions consumed by `parser::grammar`.
//!
//! Per `feedback_bottom_up_loaded_not_encoded`: the character-class
//! tables are derived at build time from the loaded spec bytes — not
//! hand-coded from prose. The runtime predicate is a binary-search
//! over a const-table, not a `matches!` against literal code points.
//!
//! ## RHS grammar
//!
//! Each `<rhs>` of a character-class production is a `|`-separated
//! list of items. The W3C "Notation" appendix (XML 1.0 Fifth Edition
//! Appendix \[B\]) defines the relevant alternatives:
//!
//! - `#xN`             — a single Unicode code point (hex).
//! - `[#xN-#xM]`       — an inclusive range of code points (hex).
//! - `"c"` or `'c'`    — a single ASCII character literal.
//! - `[a-z]`           — an inclusive ASCII range (un-prefixed).
//!
//! The praxis-way claim is that these range tuples come from the
//! published spec, not from this file. The RHS parser below is the
//! standard W3C Notation Appendix B grammar; the literal code points
//! it emits are whatever the spec declares.
//!
//! ## Citation
//!
//! - **Bray, T., Paoli, J., Sperberg-McQueen, C. M., Maler, E. &
//!   Yergeau, F.** (eds.) (2008) *Extensible Markup Language (XML)
//!   1.0 (Fifth Edition)*, W3C Recommendation 26 November 2008,
//!   §2.2 production \[2\] `Char`, §2.3 productions \[4\]/\[4a\]
//!   `NameStartChar` / `NameChar`, Appendix \[B\] *Notation*.

use std::path::Path;

/// Errors returned by [`generate_xml_grammar_source`].
#[derive(Debug)]
pub enum XmlGrammarCodegenError {
    /// The bundled spec file could not be read.
    ReadSource(String, std::io::Error),
    /// The expected `<prod>` block was not found by the text scan.
    /// `name` is the production left-hand side we were looking for
    /// (e.g. `Char`, `NameStartChar`).
    ProductionNotFound(&'static str),
    /// The RHS of a production contained a token the W3C-Notation
    /// parser doesn't recognise. The position lets the caller locate
    /// the offending byte in the spec.
    UnknownRhsToken {
        production: &'static str,
        token: String,
    },
}

impl core::fmt::Display for XmlGrammarCodegenError {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        match self {
            Self::ReadSource(p, e) => write!(f, "read source {p}: {e}"),
            Self::ProductionNotFound(name) => {
                write!(f, "production `{name}` not found in W3C XML 1.0 spec")
            }
            Self::UnknownRhsToken { production, token } => {
                write!(
                    f,
                    "production `{production}` RHS contained unrecognised token `{token}`"
                )
            }
        }
    }
}

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

/// One inclusive range of Unicode code points, as extracted from a
/// W3C XML 1.0 character-class production's RHS.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CodePointRange {
    /// Lower bound (inclusive).
    pub lo: u32,
    /// Upper bound (inclusive).
    pub hi: u32,
}

/// Top-level entry point — read the spec file, extract every
/// character-class production we ground the parser against, emit
/// Rust source for inclusion at `$OUT_DIR/xml_grammar_generated.rs`.
///
/// The runtime module that `include!`s the result gets:
///
/// - `CHAR_RANGES: &[(u32, u32)]` + `pub fn is_char(c: u32) -> bool`
/// - `NAME_START_CHAR_RANGES` + `pub fn is_name_start_char(c: u32) -> bool`
/// - `NAME_CHAR_RANGES` + `pub fn is_name_char(c: u32) -> bool`
///
/// Each table is sorted lo-ascending and uses linear scan (the
/// tables are short — 6 ranges for Char, 14 for NameStartChar).
pub fn generate_xml_grammar_source(spec_path: &Path) -> Result<String, XmlGrammarCodegenError> {
    let spec_bytes = std::fs::read_to_string(spec_path)
        .map_err(|e| XmlGrammarCodegenError::ReadSource(spec_path.display().to_string(), e))?;

    let char_ranges = extract_production(&spec_bytes, "Char")?;
    let name_start_char_ranges = extract_production(&spec_bytes, "NameStartChar")?;
    let name_char_ranges = extract_production(&spec_bytes, "NameChar")?;
    let predefined_entities = extract_predefined_entities(&spec_bytes)?;

    let mut out = String::new();
    out.push_str(
        "// Generated by pr4xis::codegen::xml_grammar from the loaded W3C XML 1.0\n\
         // Fifth Edition spec (Bray et al. 2008). Do not edit by hand — change the\n\
         // upstream spec source `xml_1_0_fifth_edition@2008` and re-run cargo build.\n\
         //\n\
         // Each table corresponds to one EBNF production from §2.2 or §2.3 of the\n\
         // spec, or to the §4.6 predefined-entities declarations. Inclusive\n\
         // ranges; lo ≤ hi; sorted lo-ascending in source order.\n\n",
    );

    emit_range_table(&mut out, "CHAR_RANGES", "§2.2 \\[2\\] Char", &char_ranges);
    emit_predicate(&mut out, "is_char", "CHAR_RANGES");

    emit_range_table(
        &mut out,
        "NAME_START_CHAR_RANGES",
        "§2.3 \\[4\\] NameStartChar",
        &name_start_char_ranges,
    );
    emit_predicate(&mut out, "is_name_start_char", "NAME_START_CHAR_RANGES");

    emit_range_table(
        &mut out,
        "NAME_CHAR_RANGES",
        "§2.3 \\[4a\\] NameChar",
        &name_char_ranges,
    );
    emit_predicate(&mut out, "is_name_char", "NAME_CHAR_RANGES");

    emit_predefined_entities(&mut out, &predefined_entities);

    Ok(out)
}

/// Find the `<prod>` block for the given left-hand-side name and
/// parse its RHS into a list of code-point ranges. Nonterminal
/// references (`<nt def="NT-X">X</nt>`) in the RHS are recursively
/// expanded by looking up the referenced production — that lets
/// e.g. NameChar's RHS (which starts `NameStartChar | "-" | ...`)
/// inherit NameStartChar's ranges.
fn extract_production(
    spec_bytes: &str,
    lhs_name: &'static str,
) -> Result<Vec<CodePointRange>, XmlGrammarCodegenError> {
    let rhs = locate_rhs(spec_bytes, lhs_name)
        .ok_or(XmlGrammarCodegenError::ProductionNotFound(lhs_name))?;
    parse_rhs_ranges(rhs, spec_bytes, lhs_name)
}

/// Locate the `<rhs ...>...</rhs>` content of the production whose
/// `<lhs ...>` text-content is `lhs_name`. Tolerates attributes on
/// `<lhs>` and `<rhs>` (e.g. `<lhs diff="chg">`).
fn locate_rhs<'a>(spec_bytes: &'a str, lhs_name: &str) -> Option<&'a str> {
    // Find <lhs...>{lhs_name}</lhs> with any attributes between `<lhs`
    // and `>`. Search incrementally so multiple occurrences are
    // considered (the spec's <prodgroup> may shadow earlier blocks
    // with different attribute sets).
    let mut cursor = 0;
    while cursor < spec_bytes.len() {
        let rest = &spec_bytes[cursor..];
        let lhs_open = rest.find("<lhs")?;
        let after_open = &rest[lhs_open + 4..]; // skip past "<lhs"
        let gt = after_open.find('>')?;
        let lhs_content_start = lhs_open + 4 + gt + 1;
        let after_content = &rest[lhs_content_start..];
        let lhs_close = after_content.find("</lhs>")?;
        let content = &after_content[..lhs_close];
        if content.trim() == lhs_name {
            // Found matching lhs — now scan forward for the <rhs>.
            let after_lhs = &after_content[lhs_close + "</lhs>".len()..];
            let rhs_open = after_lhs.find("<rhs")?;
            let after_rhs_open = &after_lhs[rhs_open + 4..];
            let gt2 = after_rhs_open.find('>')?;
            let rhs_content_start = rhs_open + 4 + gt2 + 1;
            let after_rhs_content = &after_lhs[rhs_content_start..];
            let rhs_close = after_rhs_content.find("</rhs>")?;
            return Some(&after_rhs_content[..rhs_close]);
        }
        // Advance past this <lhs>...</lhs> and continue.
        cursor += lhs_open + 4 + gt + 1 + lhs_close + "</lhs>".len();
    }
    None
}

/// Parse a W3C-Notation RHS (Appendix B) consisting of `|`-separated
/// character-class tokens into a list of inclusive code-point ranges.
///
/// Recognised tokens:
/// - `#xN` — single hex code point
/// - `[#xN-#xM]` — hex range
/// - `"c"` or `'c'` — single ASCII literal
/// - `[a-z]` — ASCII range (un-prefixed)
/// - `<nt def="NT-X">X</nt>` — nonterminal reference; the referenced
///   production's RHS is recursively expanded inline. (Required by
///   §2.3 NameChar, whose RHS begins with a NameStartChar reference.)
///
/// `spec_bytes` is the full spec — needed for nonterminal lookups.
fn parse_rhs_ranges(
    rhs: &str,
    spec_bytes: &str,
    production: &'static str,
) -> Result<Vec<CodePointRange>, XmlGrammarCodegenError> {
    let mut ranges = Vec::new();
    for raw in rhs.split('|') {
        let tok = raw.trim();
        if tok.is_empty() {
            continue;
        }
        // Nonterminal reference — recursively expand the referenced
        // production. The xmlspec.dtd represents these as
        // `<nt def="NT-Foo">Foo</nt>` markup elements.
        if let Some(nt_ref) = extract_nt_reference(tok) {
            let inner_rhs = locate_rhs(spec_bytes, nt_ref)
                .ok_or(XmlGrammarCodegenError::ProductionNotFound(production))?;
            // Recurse: parse the referenced production's RHS.
            let mut inner = parse_rhs_ranges_owned(inner_rhs, spec_bytes, production)?;
            ranges.append(&mut inner);
            continue;
        }
        let range = parse_token(tok).ok_or_else(|| XmlGrammarCodegenError::UnknownRhsToken {
            production,
            token: tok.to_string(),
        })?;
        ranges.push(range);
    }
    Ok(ranges)
}

/// Recursion helper — parses an RHS owned by [`parse_rhs_ranges`]'s
/// nonterminal-reference branch. The lifetime split keeps the
/// borrow-checker happy when we substitute one production's bytes
/// into another's parse.
fn parse_rhs_ranges_owned(
    rhs: &str,
    spec_bytes: &str,
    production: &'static str,
) -> Result<Vec<CodePointRange>, XmlGrammarCodegenError> {
    parse_rhs_ranges(rhs, spec_bytes, production)
}

/// If `tok` is a nonterminal reference `<nt def="NT-X">X</nt>`,
/// return the LHS name `X`. Returns `None` for any other token.
fn extract_nt_reference(tok: &str) -> Option<&str> {
    let after_open = tok.strip_prefix("<nt ")?;
    let gt = after_open.find('>')?;
    let after_content_start = &after_open[gt + 1..];
    let nt_close = after_content_start.find("</nt>")?;
    let content = &after_content_start[..nt_close];
    Some(content.trim())
}

/// Parse one Appendix B character-class token.
fn parse_token(tok: &str) -> Option<CodePointRange> {
    // [#xN-#xM] — hex range
    if let Some(inner) = tok.strip_prefix("[#x").and_then(|s| s.strip_suffix(']')) {
        let (lo_hex, hi_hex) = inner.split_once("-#x")?;
        let lo = u32::from_str_radix(lo_hex.trim(), 16).ok()?;
        let hi = u32::from_str_radix(hi_hex.trim(), 16).ok()?;
        return Some(CodePointRange { lo, hi });
    }
    // #xN — single hex code point
    if let Some(hex) = tok.strip_prefix("#x") {
        let cp = u32::from_str_radix(hex.trim(), 16).ok()?;
        return Some(CodePointRange { lo: cp, hi: cp });
    }
    // [a-z] — ASCII range (no #x prefix)
    if let Some(inner) = tok.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
        let chars: Vec<char> = inner.chars().collect();
        if chars.len() == 3 && chars[1] == '-' {
            return Some(CodePointRange {
                lo: chars[0] as u32,
                hi: chars[2] as u32,
            });
        }
    }
    // "c" or 'c' — single ASCII literal
    for delim in ['"', '\''] {
        if let Some(inner) = tok.strip_prefix(delim).and_then(|s| s.strip_suffix(delim)) {
            let chars: Vec<char> = inner.chars().collect();
            if chars.len() == 1 {
                let cp = chars[0] as u32;
                return Some(CodePointRange { lo: cp, hi: cp });
            }
        }
    }
    None
}

/// Emit `const NAME: &[(u32, u32)] = &[ ... ];` for a range table.
fn emit_range_table(out: &mut String, name: &str, citation: &str, ranges: &[CodePointRange]) {
    out.push_str(&format!(
        "/// W3C XML 1.0 Fifth Edition {citation}.\n\
         /// {n} inclusive code-point ranges, derived from the loaded spec.\n\
         #[allow(dead_code)]\n\
         pub const {name}: &[(u32, u32)] = &[\n",
        citation = citation,
        n = ranges.len(),
        name = name,
    ));
    for r in ranges {
        out.push_str(&format!("    (0x{:X}, 0x{:X}),\n", r.lo, r.hi));
    }
    out.push_str("];\n\n");
}

/// One §4.6 predefined-entity declaration projected from the loaded
/// W3C XML 1.0 Fifth Edition spec source. `name` is the entity
/// reference name (e.g. `lt`); `replacement` is the single Unicode
/// scalar the §4.6 declaration resolves to after the spec's required
/// double-escape (e.g. `<` for `lt`). The replacement-source string
/// is the canonical text the spec writes (e.g. `&#38;#60;`) — kept
/// alongside the resolved char so the emitted table preserves the
/// audit trail.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PredefinedEntityDecl {
    /// Entity reference name (the EntityName from `<!ENTITY name "...">`)
    pub name: String,
    /// Resolved replacement character per §4.6 — the single Unicode
    /// scalar produced when the spec's declaration is parsed.
    pub replacement: char,
    /// The literal replacement-text bytes the spec source carries
    /// inside the `<!ENTITY name "...">` declaration. Retained for
    /// the generated table's audit-trail comment so a reviewer can
    /// trace the resolution back to the exact spec bytes.
    pub replacement_literal: String,
}

/// Locate and parse the five §4.6 predefined-entity declarations
/// the W3C XML 1.0 Fifth Edition spec writes inside the
/// `<div2 id="sec-predefined-ent">` section's `<eg>` example block.
/// Per the spec's prose, those five declarations are the normative
/// list every conforming processor recognises — loading them from
/// the spec source rather than hand-coding the map satisfies
/// `feedback_bottom_up_loaded_not_encoded`.
///
/// The spec writes:
///
/// ```text
/// <div2 id="sec-predefined-ent">
///   …
///   <eg><![CDATA[<!ENTITY lt     "&#38;#60;">
/// <!ENTITY gt     "&#62;">
/// <!ENTITY amp    "&#38;#38;">
/// <!ENTITY apos   "&#39;">
/// <!ENTITY quot   "&#34;">]]></eg>
/// </div2>
/// ```
///
/// We extract the CDATA payload, parse the five `<!ENTITY …>` lines,
/// and resolve each replacement text per §4.5 (character references
/// inside an EntityValue are decoded at construction time — the
/// `&#38;` in `lt`'s declaration decodes to `&`, leaving the
/// replacement-text `&#60;`; that's then the value the runtime
/// returns when the entity is referenced).
fn extract_predefined_entities(
    spec_bytes: &str,
) -> Result<Vec<PredefinedEntityDecl>, XmlGrammarCodegenError> {
    // 1. Find the §4.6 section anchor.
    let section_anchor = spec_bytes.find(r#"id="sec-predefined-ent""#).ok_or(
        XmlGrammarCodegenError::ProductionNotFound("sec-predefined-ent"),
    )?;
    let after_anchor = &spec_bytes[section_anchor..];

    // 2. Find the first `<eg>...</eg>` block inside.
    let eg_open = after_anchor
        .find("<eg>")
        .ok_or(XmlGrammarCodegenError::ProductionNotFound(
            "sec-predefined-ent/eg",
        ))?;
    let after_eg_open = &after_anchor[eg_open + "<eg>".len()..];
    let eg_close =
        after_eg_open
            .find("</eg>")
            .ok_or(XmlGrammarCodegenError::ProductionNotFound(
                "sec-predefined-ent/eg-close",
            ))?;
    let eg_body = &after_eg_open[..eg_close];

    // 3. Strip the `<![CDATA[ … ]]>` wrapper.
    let cdata = eg_body
        .trim()
        .strip_prefix("<![CDATA[")
        .and_then(|s| s.strip_suffix("]]>"))
        .ok_or(XmlGrammarCodegenError::ProductionNotFound(
            "sec-predefined-ent/cdata",
        ))?;

    // 4. Parse each `<!ENTITY name "value">` line.
    let mut out = Vec::new();
    for raw_line in cdata.lines() {
        let line = raw_line.trim();
        if line.is_empty() {
            continue;
        }
        let decl = parse_predefined_entity_line(line).ok_or_else(|| {
            XmlGrammarCodegenError::UnknownRhsToken {
                production: "PredefinedEntity",
                token: line.to_string(),
            }
        })?;
        out.push(decl);
    }
    Ok(out)
}

/// Parse one `<!ENTITY name "value">` declaration as it appears in
/// the §4.6 spec example, applying §4.5 character-reference
/// expansion to the replacement text. Returns `None` on any
/// structural mismatch — the caller treats that as
/// `UnknownRhsToken` since the input came from the published spec
/// and any deviation is a regression at the source.
fn parse_predefined_entity_line(line: &str) -> Option<PredefinedEntityDecl> {
    let rest = line.strip_prefix("<!ENTITY")?.trim_start();
    let space = rest.find(char::is_whitespace)?;
    let name = rest[..space].trim().to_string();
    let after_name = rest[space..].trim_start();
    let quote_char = after_name.chars().next()?;
    if quote_char != '"' && quote_char != '\'' {
        return None;
    }
    let after_quote = &after_name[quote_char.len_utf8()..];
    let close_quote = after_quote.find(quote_char)?;
    let literal = &after_quote[..close_quote];
    let after_value = after_quote[close_quote + quote_char.len_utf8()..].trim_start();
    if !after_value.starts_with('>') {
        return None;
    }
    let replacement = resolve_predefined_entity_value(literal)?;
    Some(PredefinedEntityDecl {
        name,
        replacement,
        replacement_literal: literal.to_string(),
    })
}

/// Decode the literal replacement text inside a §4.6 entity
/// declaration. Per §4.5 "Construction of Internal Entity
/// Replacement Text", character references inside the EntityValue
/// resolve at declaration time. The §4.6 declarations use the
/// double-escape pattern `&#38;#NN;` for `amp`/`lt` so that
/// expansion at reference-time yields a working `&#NN;` character
/// reference; we resolve one layer of `&#NN;` here, which produces
/// either the final character (for `gt`/`apos`/`quot`) or a
/// character-reference text the runtime resolves a second time
/// (for `amp`/`lt`).
fn resolve_predefined_entity_value(literal: &str) -> Option<char> {
    // W3C XML 1.0 §4.5 + §4.6: the §4.6 declarations use one of two
    // forms inside the EntityValue:
    //
    //   (a) a single character reference `&#NN;` / `&#xHH;`, e.g.
    //       `gt`, `apos`, `quot` each declare `"&#NN;"`. §4.5 expands
    //       the character reference at decl time, so the runtime
    //       replacement text is the single character itself.
    //
    //   (b) a double-escaped pair `&#38;#NN;` (or `&#38;#xHH;`), used
    //       for `amp` and `lt`. §4.5 expands `&#38;` to `&` at decl
    //       time, leaving `&#NN;` as the literal replacement text;
    //       when the entity is later referenced in content, the
    //       parser re-resolves that `&#NN;` to the target character.
    //
    // Either way the *final* resolved character is the one keyed by
    // the inner `#NN;`. We pick that out directly: locate the last
    // `#NN;` (or `#xHH;`) substring and decode it. The intermediate
    // `&#38;` prefix, when present, is the spec's required
    // double-escape and doesn't affect the final replacement.
    let trimmed = literal.trim();
    // Find the LAST `#` that starts a numeric character reference.
    // For form (a), that's right after the leading `&`. For form
    // (b), it's the second `#` (the one inside the inner ref).
    let last_hash = trimmed.rfind('#')?;
    let after_hash = &trimmed[last_hash + 1..];
    let digits = after_hash.strip_suffix(';')?;
    let cp = if let Some(hex) = digits.strip_prefix('x') {
        u32::from_str_radix(hex, 16).ok()?
    } else {
        digits.parse::<u32>().ok()?
    };
    char::from_u32(cp)
}

/// Emit the §4.6 predefined-entities table — a const slice the
/// runtime parser consults instead of a hand-coded `match` over
/// `"amp" | "lt" | ...` literals. The table is sorted by name so
/// the generated bytes are stable across rebuilds.
fn emit_predefined_entities(out: &mut String, entities: &[PredefinedEntityDecl]) {
    let mut sorted: Vec<&PredefinedEntityDecl> = entities.iter().collect();
    sorted.sort_by(|a, b| a.name.cmp(&b.name));

    out.push_str(
        "/// W3C XML 1.0 Fifth Edition §4.6 predefined-entities table.\n\
         /// `(name, replacement_char)` projected from the §4.6 `<!ENTITY …>`\n\
         /// declarations in the loaded spec source. The parser uses this\n\
         /// table to resolve `&name;` references for the five spec-mandated\n\
         /// entities (per `feedback_bottom_up_loaded_not_encoded`).\n\
         #[allow(dead_code)]\n\
         pub const PREDEFINED_ENTITIES: &[(&str, char)] = &[\n",
    );
    for d in &sorted {
        out.push_str(&format!(
            "    // <!ENTITY {name:<4} {literal:?}> → U+{cp:04X}\n    (\"{name}\", '{esc}'),\n",
            name = d.name,
            literal = d.replacement_literal,
            cp = d.replacement as u32,
            esc = escape_char_literal(d.replacement),
        ));
    }
    out.push_str("];\n\n");

    out.push_str(
        "/// True iff `name` is one of the five §4.6 predefined-entity\n\
         /// names. Returns the resolved replacement character.\n\
         #[must_use]\n\
         #[allow(dead_code)]\n\
         pub fn resolve_predefined_entity(name: &str) -> Option<char> {\n\
         \x20   PREDEFINED_ENTITIES\n\
         \x20       .iter()\n\
         \x20       .find(|(n, _)| *n == name)\n\
         \x20       .map(|(_, c)| *c)\n\
         }\n\n",
    );
}

/// Escape a `char` for emission inside a Rust `char` literal.
fn escape_char_literal(ch: char) -> String {
    match ch {
        '\\' => "\\\\".into(),
        '\'' => "\\'".into(),
        '"' => "\\\"".into(),
        '\t' => "\\t".into(),
        '\n' => "\\n".into(),
        '\r' => "\\r".into(),
        c if c.is_ascii_graphic() || c == ' ' => c.to_string(),
        c => format!("\\u{{{:X}}}", c as u32),
    }
}

/// Emit `pub fn <fname>(c: u32) -> bool { TABLE.iter().any(|&(l,h)| ...) }`.
fn emit_predicate(out: &mut String, fname: &str, table: &str) {
    out.push_str(&format!(
        "/// True iff `c` is in the {table} table.\n\
         #[must_use]\n\
         #[allow(dead_code)]\n\
         pub fn {fname}(c: u32) -> bool {{\n\
         \x20   {table}.iter().any(|&(lo, hi)| c >= lo && c <= hi)\n\
         }}\n\n",
        fname = fname,
        table = table,
    ));
}

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

    #[test]
    fn parses_hex_range() {
        let r = parse_token("[#x20-#xD7FF]").unwrap();
        assert_eq!(r.lo, 0x20);
        assert_eq!(r.hi, 0xD7FF);
    }

    #[test]
    fn parses_single_hex() {
        let r = parse_token("#x9").unwrap();
        assert_eq!(r.lo, 9);
        assert_eq!(r.hi, 9);
    }

    #[test]
    fn parses_ascii_range() {
        let r = parse_token("[A-Z]").unwrap();
        assert_eq!(r.lo, 0x41);
        assert_eq!(r.hi, 0x5A);
    }

    #[test]
    fn parses_ascii_literal() {
        let r = parse_token("\":\"").unwrap();
        assert_eq!(r.lo, 0x3A);
        assert_eq!(r.hi, 0x3A);
        let r = parse_token("'_'").unwrap();
        assert_eq!(r.lo, 0x5F);
        assert_eq!(r.hi, 0x5F);
    }

    #[test]
    fn rejects_unknown_token() {
        assert!(parse_token("xyzzy").is_none());
        assert!(parse_token("[abc]").is_none()); // not a range
    }

    #[test]
    fn parses_full_char_rhs() {
        // The actual §2.2 Char production RHS, verbatim.
        let rhs = "#x9 | #xA | #xD | [#x20-#xD7FF] | [#xE000-#xFFFD] | [#x10000-#x10FFFF]";
        // No nonterminal references in this RHS, so the spec_bytes
        // argument is unused — pass an empty slice.
        let ranges = parse_rhs_ranges(rhs, "", "Char").unwrap();
        assert_eq!(ranges.len(), 6);
        assert_eq!(ranges[0], CodePointRange { lo: 9, hi: 9 });
        assert_eq!(
            ranges[3],
            CodePointRange {
                lo: 0x20,
                hi: 0xD7FF
            }
        );
        assert_eq!(
            ranges[5],
            CodePointRange {
                lo: 0x10000,
                hi: 0x10FFFF
            }
        );
    }

    #[test]
    fn extracts_nt_reference() {
        assert_eq!(
            extract_nt_reference("<nt def=\"NT-NameStartChar\">NameStartChar</nt>"),
            Some("NameStartChar")
        );
        assert_eq!(extract_nt_reference("[A-Z]"), None);
        assert_eq!(extract_nt_reference("#x20"), None);
    }

    #[test]
    fn locate_rhs_tolerates_attributes_on_lhs_and_rhs() {
        // Mimics the spec's `<lhs diff="chg">NameStartChar</lhs>`
        // pattern — the locator must tolerate attributes on both tags.
        let spec = "<lhs diff=\"chg\">NameStartChar</lhs>\
                    <rhs diff=\"chg\">\":\" | [A-Z]</rhs>";
        let rhs = locate_rhs(spec, "NameStartChar").unwrap();
        assert!(rhs.contains("[A-Z]"));
    }

    #[test]
    fn resolves_predefined_entity_double_escape() {
        // §4.6's `lt` and `amp` use the double-escape pattern
        // `&#38;#NN;` so reference-time re-resolution produces a
        // working `&#NN;` character reference. The codegen resolver
        // must pick out the *final* target character.
        assert_eq!(resolve_predefined_entity_value("&#38;#60;"), Some('<'));
        assert_eq!(resolve_predefined_entity_value("&#38;#38;"), Some('&'));
    }

    #[test]
    fn resolves_predefined_entity_single_escape() {
        // §4.6's `gt`, `apos`, `quot` use a single character ref.
        assert_eq!(resolve_predefined_entity_value("&#62;"), Some('>'));
        assert_eq!(resolve_predefined_entity_value("&#39;"), Some('\''));
        assert_eq!(resolve_predefined_entity_value("&#34;"), Some('"'));
    }

    #[test]
    fn parses_predefined_entity_line_with_extra_whitespace() {
        let decl = parse_predefined_entity_line(r#"<!ENTITY lt     "&#38;#60;">"#).unwrap();
        assert_eq!(decl.name, "lt");
        assert_eq!(decl.replacement, '<');
        assert_eq!(decl.replacement_literal, "&#38;#60;");
    }

    #[test]
    fn extracts_all_five_predefined_entities_from_spec() {
        // Mini spec slice mirroring the §4.6 markup. Verify all five
        // declarations are extracted and resolve to the right chars.
        let spec = r#"<div2 id="sec-predefined-ent">
<eg><![CDATA[<!ENTITY lt     "&#38;#60;">
<!ENTITY gt     "&#62;">
<!ENTITY amp    "&#38;#38;">
<!ENTITY apos   "&#39;">
<!ENTITY quot   "&#34;">]]></eg>
</div2>"#;
        let entities = extract_predefined_entities(spec).unwrap();
        assert_eq!(entities.len(), 5);
        let by_name: std::collections::HashMap<&str, char> = entities
            .iter()
            .map(|e| (e.name.as_str(), e.replacement))
            .collect();
        assert_eq!(by_name.get("lt"), Some(&'<'));
        assert_eq!(by_name.get("gt"), Some(&'>'));
        assert_eq!(by_name.get("amp"), Some(&'&'));
        assert_eq!(by_name.get("apos"), Some(&'\''));
        assert_eq!(by_name.get("quot"), Some(&'"'));
    }

    #[test]
    fn name_char_resolves_via_nt_expansion() {
        // Mini two-production spec; NameChar references NameStartChar
        // via <nt>. The expansion inlines NameStartChar's ranges into
        // NameChar's.
        let spec = "<lhs>NameStartChar</lhs>\
                    <rhs>\":\" | [A-Z]</rhs>\
                    <lhs>NameChar</lhs>\
                    <rhs><nt def=\"NT-NameStartChar\">NameStartChar</nt> | \"-\" | [0-9]</rhs>";
        let ranges = extract_production(spec, "NameChar").unwrap();
        // 2 ranges inlined from NameStartChar + 2 own = 4 total.
        assert_eq!(ranges.len(), 4);
        assert_eq!(ranges[0], CodePointRange { lo: 0x3A, hi: 0x3A }); // ":"
        assert_eq!(ranges[1], CodePointRange { lo: 0x41, hi: 0x5A }); // [A-Z]
        assert_eq!(ranges[2], CodePointRange { lo: 0x2D, hi: 0x2D }); // "-"
        assert_eq!(ranges[3], CodePointRange { lo: 0x30, hi: 0x39 }); // [0-9]
    }
}
