Skip to main content

sup_xml_core/dtd/
validate.rs

1//! DTD validation walk.
2//!
3//! Given a [`Document`] and its captured [`Dtd`], check every element
4//! and attribute against the declarations.  Returns the first error
5//! encountered (libxml2 stops at first fatal error in
6//! `xmlValidateDocument` too).
7//!
8//! # Algorithm
9//!
10//! Two passes:
11//!
12//! 1. **Tree walk** — for each element node:
13//!    * Look up its `<!ELEMENT>` decl.  If absent in a non-empty DTD,
14//!      that's an error (libxml2's "No declaration for element foo").
15//!    * Match its child sequence against the content model.
16//!    * Check each attribute against the corresponding `<!ATTLIST>`:
17//!      required ones present, enumerated values valid, FIXED values
18//!      equal, IDs unique, IDREFs recorded for pass 2.
19//! 2. **Cross-ref pass** — every IDREF target collected in pass 1
20//!    must equal some ID seen anywhere in pass 1.
21//!
22//! The content-model matcher is a small recursive descent over the
23//! [`Group`] tree.  We don't bother with NFA→DFA conversion: real
24//! DTD content models are tiny (rarely more than a few dozen
25//! particles), and recursive matching with greedy/lazy fallback on
26//! `?`/`*`/`+` handles them in microseconds.
27
28use std::collections::{HashMap, HashSet};
29
30use sup_xml_tree::dom::{Document, Node, NodeKind};
31
32use super::{
33    model::{ContentModel, Group, GroupKind, Item, Occurrence, Particle},
34    AttDecl, AttDefault, AttType, Dtd,
35};
36
37/// One DTD validation failure.  Mirrors libxml2's per-error
38/// structure shape just enough for the compat layer to surface.
39#[derive(Debug, Clone)]
40pub struct DtdError {
41    pub element: String,
42    pub message: String,
43}
44
45impl std::fmt::Display for DtdError {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        write!(f, "DTD validation error on element <{}>: {}", self.element, self.message)
48    }
49}
50
51impl std::error::Error for DtdError {}
52
53/// Validate `doc` against `dtd`.  Returns `Ok(())` if every
54/// declaration was satisfied; otherwise an `Err` carrying *every*
55/// error found in document order.
56///
57/// libxml2's `xmlValidateDocument` historically returned only a
58/// single boolean, but its structured-error callback fires for
59/// each violation.  We surface the full list directly so consumers
60/// can dump them via lxml's `dtd.error_log` without depending on
61/// callback installation.
62///
63/// An empty DTD (`dtd.is_empty()`) trivially passes.
64pub fn validate(doc: &Document, dtd: &Dtd) -> Result<(), Vec<DtdError>> {
65    if dtd.is_empty() {
66        return Ok(());
67    }
68    let mut ids:    HashSet<String> = HashSet::new();
69    let mut idrefs: Vec<(String, String)> = Vec::new();
70    let mut errors: Vec<DtdError> = Vec::new();
71    let root = doc.root();
72    walk(root, dtd, &mut ids, &mut idrefs, &mut errors);
73    for (element, idref) in idrefs {
74        if !ids.contains(&idref) {
75            errors.push(DtdError {
76                element,
77                message: format!("IDREF '{}' does not match any declared ID", idref),
78            });
79        }
80    }
81    if errors.is_empty() { Ok(()) } else { Err(errors) }
82}
83
84/// Convenience for the common "did it validate?" yes/no question.
85/// Equivalent to `validate(doc, dtd).is_ok()`.
86pub fn is_valid(doc: &Document, dtd: &Dtd) -> bool {
87    validate(doc, dtd).is_ok()
88}
89
90fn walk(
91    node:   &Node<'_>,
92    dtd:    &Dtd,
93    ids:    &mut HashSet<String>,
94    idrefs: &mut Vec<(String, String)>,
95    errors: &mut Vec<DtdError>,
96) {
97    if !node.is_element() { return; }
98
99    let name = node.name();
100
101    // 1. Element decl lookup.
102    let Some(decl) = dtd.elements.get(name) else {
103        errors.push(DtdError {
104            element: name.to_string(),
105            message: "no declaration found".into(),
106        });
107        // Continue walking children — the doc may still have valid
108        // sub-trees, and we want to report all errors in one go.
109        for child in node.children() {
110            walk(child, dtd, ids, idrefs, errors);
111        }
112        return;
113    };
114
115    // 2. Content-model check.
116    if let Err(e) = check_content(node, &decl.content) {
117        errors.push(e);
118    }
119
120    // 3. Attribute check.
121    if let Some(attlist) = dtd.attlists.get(name) {
122        check_attrs(node, attlist, ids, idrefs, errors);
123    }
124
125    // 4. Recurse into children.
126    for child in node.children() {
127        walk(child, dtd, ids, idrefs, errors);
128    }
129}
130
131/// Verify `element`'s children satisfy `model`.  PCDATA = any
132/// run of Text/CData nodes; non-element non-text/non-cdata
133/// children (Comment, PI) are skipped — they're allowed everywhere
134/// per § 2.5.
135fn check_content(element: &Node<'_>, model: &ContentModel) -> Result<(), DtdError> {
136    match model {
137        ContentModel::Empty => {
138            for child in element.children() {
139                match child.kind {
140                    NodeKind::Comment | NodeKind::Pi => continue,
141                    NodeKind::Text | NodeKind::CData => {
142                        // Whitespace-only text is allowed in EMPTY per
143                        // libxml2's permissive mode; we follow suit.
144                        if !child.content().chars().all(char::is_whitespace) {
145                            return Err(DtdError {
146                                element: element.name().to_string(),
147                                message: "content not allowed in EMPTY element".into(),
148                            });
149                        }
150                    }
151                    _ => return Err(DtdError {
152                        element: element.name().to_string(),
153                        message: "content not allowed in EMPTY element".into(),
154                    }),
155                }
156            }
157            Ok(())
158        }
159        ContentModel::Any => Ok(()),
160        ContentModel::Mixed { choices } => {
161            for child in element.children() {
162                match child.kind {
163                    NodeKind::Comment | NodeKind::Pi
164                    | NodeKind::Text  | NodeKind::CData => continue,
165                    NodeKind::Element => {
166                        if !choices.iter().any(|n| n == child.name()) {
167                            return Err(DtdError {
168                                element: element.name().to_string(),
169                                message: format!(
170                                    "child <{}> not allowed in mixed content (allowed: {})",
171                                    child.name(),
172                                    if choices.is_empty() { "#PCDATA only".into() }
173                                    else { choices.join(", ") }
174                                ),
175                            });
176                        }
177                    }
178                    _ => return Err(DtdError {
179                        element: element.name().to_string(),
180                        message: "unexpected node kind in mixed content".into(),
181                    }),
182                }
183            }
184            Ok(())
185        }
186        ContentModel::Children(group) => {
187            // Collect element children only — text in a children
188            // model is forbidden, except for whitespace per § 3.2.1
189            // (which libxml2 silently strips).
190            let mut child_names: Vec<&str> = Vec::new();
191            for child in element.children() {
192                match child.kind {
193                    NodeKind::Element => child_names.push(child.name()),
194                    NodeKind::Comment | NodeKind::Pi => continue,
195                    NodeKind::Text | NodeKind::CData
196                        if !child.content().chars().all(char::is_whitespace) =>
197                    {
198                        return Err(DtdError {
199                            element: element.name().to_string(),
200                            message: "character data not allowed in element-only content".into(),
201                        });
202                    }
203                    NodeKind::Text | NodeKind::CData => {}
204                    _ => {}
205                }
206            }
207            let mut pos = 0usize;
208            if !match_group(group, &child_names, &mut pos) || pos != child_names.len() {
209                return Err(DtdError {
210                    element: element.name().to_string(),
211                    message: format!(
212                        "child sequence [{}] does not match content model",
213                        child_names.join(", ")
214                    ),
215                });
216            }
217            Ok(())
218        }
219    }
220}
221
222/// Recursive-descent matcher for children content models.  Tries
223/// the greedy interpretation first; backtracks on failure for
224/// optional/star/plus particles.
225///
226/// Returns `true` if at least one matching of `group` against
227/// `names[pos..]` exists; `pos` advances to the end of the
228/// successful match.
229fn match_group(group: &Group, names: &[&str], pos: &mut usize) -> bool {
230    let start = *pos;
231    let min = match group.occur {
232        Occurrence::One | Occurrence::OneOrMore => 1,
233        Occurrence::ZeroOrOne | Occurrence::ZeroOrMore => 0,
234    };
235    let max = match group.occur {
236        Occurrence::One | Occurrence::ZeroOrOne => 1,
237        Occurrence::OneOrMore | Occurrence::ZeroOrMore => usize::MAX,
238    };
239    let mut matches = 0;
240    loop {
241        let try_pos = *pos;
242        if matches >= max || !match_group_one(group, names, pos) {
243            *pos = try_pos;
244            break;
245        }
246        matches += 1;
247    }
248    if matches < min {
249        *pos = start;
250        return false;
251    }
252    true
253}
254
255/// Match exactly one iteration of `group` (without the outer
256/// occurrence indicator).
257fn match_group_one(group: &Group, names: &[&str], pos: &mut usize) -> bool {
258    match group.kind {
259        GroupKind::Sequence => {
260            let start = *pos;
261            for item in &group.items {
262                if !match_particle(item, names, pos) {
263                    *pos = start;
264                    return false;
265                }
266            }
267            true
268        }
269        GroupKind::Choice => {
270            let start = *pos;
271            for item in &group.items {
272                let mut local = start;
273                if match_particle(item, names, &mut local) {
274                    *pos = local;
275                    return true;
276                }
277            }
278            *pos = start;
279            false
280        }
281    }
282}
283
284fn match_particle(p: &Particle, names: &[&str], pos: &mut usize) -> bool {
285    let start = *pos;
286    let min = match p.occur {
287        Occurrence::One | Occurrence::OneOrMore => 1,
288        Occurrence::ZeroOrOne | Occurrence::ZeroOrMore => 0,
289    };
290    let max = match p.occur {
291        Occurrence::One | Occurrence::ZeroOrOne => 1,
292        Occurrence::OneOrMore | Occurrence::ZeroOrMore => usize::MAX,
293    };
294    let mut matches = 0;
295    loop {
296        let try_pos = *pos;
297        let advanced = match &p.item {
298            Item::Name(n) => {
299                if names.get(*pos) == Some(&n.as_str()) { *pos += 1; true } else { false }
300            }
301            Item::Group(g) => match_group(g, names, pos),
302        };
303        if !advanced || matches >= max {
304            *pos = try_pos;
305            break;
306        }
307        matches += 1;
308    }
309    if matches < min { *pos = start; return false; }
310    true
311}
312
313fn check_attrs(
314    element: &Node<'_>,
315    attlist: &[AttDecl],
316    ids:    &mut HashSet<String>,
317    idrefs: &mut Vec<(String, String)>,
318    errors: &mut Vec<DtdError>,
319) {
320    // Index present attributes by name for O(1) lookup.
321    let mut present: HashMap<&str, &str> = HashMap::new();
322    for a in element.attributes() {
323        let name = a.name();
324        // libxml2 ignores `xmlns` and `xmlns:*` in attribute
325        // validation — they're namespace declarations, not user
326        // attributes.
327        if name == "xmlns" || name.starts_with("xmlns:") { continue; }
328        present.insert(name, a.value());
329    }
330
331    for decl in attlist {
332        let value = present.get(decl.name.as_str()).copied();
333
334        // Default-decl checks.
335        match &decl.default {
336            AttDefault::Required => {
337                if value.is_none() {
338                    errors.push(DtdError {
339                        element: element.name().to_string(),
340                        message: format!("required attribute '{}' missing", decl.name),
341                    });
342                    continue;
343                }
344            }
345            AttDefault::Fixed(want) => {
346                if let Some(got) = value {
347                    if got != want {
348                        errors.push(DtdError {
349                            element: element.name().to_string(),
350                            message: format!(
351                                "attribute '{}' = '{}' violates #FIXED \"{}\"",
352                                decl.name, got, want
353                            ),
354                        });
355                        continue;
356                    }
357                }
358            }
359            AttDefault::Implied | AttDefault::Default(_) => {}
360        }
361
362        // Type checks — only meaningful when a value is present.
363        if let Some(v) = value {
364            if let Err(e) = check_att_type(element.name(), &decl.name, &decl.att_type, v, ids, idrefs) {
365                errors.push(e);
366            }
367        }
368    }
369}
370
371fn check_att_type(
372    element: &str,
373    attr:    &str,
374    ty:      &AttType,
375    value:   &str,
376    ids:     &mut HashSet<String>,
377    idrefs:  &mut Vec<(String, String)>,
378) -> Result<(), DtdError> {
379    match ty {
380        AttType::CData => Ok(()),
381        AttType::Id => {
382            if !is_valid_name(value) {
383                return Err(DtdError {
384                    element: element.into(),
385                    message: format!("attribute '{}' = '{}' is not a valid ID Name", attr, value),
386                });
387            }
388            if !ids.insert(value.to_string()) {
389                return Err(DtdError {
390                    element: element.into(),
391                    message: format!("duplicate ID '{}'", value),
392                });
393            }
394            Ok(())
395        }
396        AttType::IdRef => {
397            if !is_valid_name(value) {
398                return Err(DtdError {
399                    element: element.into(),
400                    message: format!("attribute '{}' = '{}' is not a valid IDREF Name", attr, value),
401                });
402            }
403            idrefs.push((element.to_string(), value.to_string()));
404            Ok(())
405        }
406        AttType::IdRefs => {
407            for tok in value.split_ascii_whitespace() {
408                if !is_valid_name(tok) {
409                    return Err(DtdError {
410                        element: element.into(),
411                        message: format!(
412                            "attribute '{}' IDREFS token '{}' is not a valid Name", attr, tok
413                        ),
414                    });
415                }
416                idrefs.push((element.to_string(), tok.to_string()));
417            }
418            Ok(())
419        }
420        AttType::Nmtoken => {
421            if !is_valid_nmtoken(value) {
422                return Err(DtdError {
423                    element: element.into(),
424                    message: format!("attribute '{}' = '{}' is not a valid NMTOKEN", attr, value),
425                });
426            }
427            Ok(())
428        }
429        AttType::Nmtokens => {
430            for tok in value.split_ascii_whitespace() {
431                if !is_valid_nmtoken(tok) {
432                    return Err(DtdError {
433                        element: element.into(),
434                        message: format!(
435                            "attribute '{}' NMTOKENS token '{}' invalid", attr, tok
436                        ),
437                    });
438                }
439            }
440            Ok(())
441        }
442        AttType::Entity | AttType::Entities => {
443            // We don't track entity declarations cross-validation here.
444            // Accept any Name(s).
445            for tok in value.split_ascii_whitespace() {
446                if !is_valid_name(tok) {
447                    return Err(DtdError {
448                        element: element.into(),
449                        message: format!(
450                            "attribute '{}' ENTITY token '{}' is not a valid Name", attr, tok
451                        ),
452                    });
453                }
454            }
455            Ok(())
456        }
457        AttType::Notation(allowed) | AttType::Enumeration(allowed) => {
458            if !allowed.iter().any(|v| v == value) {
459                return Err(DtdError {
460                    element: element.into(),
461                    message: format!(
462                        "attribute '{}' = '{}' not in enumeration ({})",
463                        attr, value, allowed.join(" | ")
464                    ),
465                });
466            }
467            Ok(())
468        }
469    }
470}
471
472/// True if `s` is a valid XML Name (§ 2.3 [4][5]).  Simplified —
473/// requires Letter/`_`/`:` start then NameChars; doesn't enforce
474/// the full Unicode class, but matches every real-world ID/IDREF.
475fn is_valid_name(s: &str) -> bool {
476    let mut chars = s.chars();
477    let first = match chars.next() { Some(c) => c, None => return false };
478    if !is_name_start_char(first) { return false; }
479    chars.all(is_name_char)
480}
481
482fn is_valid_nmtoken(s: &str) -> bool {
483    !s.is_empty() && s.chars().all(is_name_char)
484}
485
486fn is_name_start_char(c: char) -> bool {
487    matches!(c, 'A'..='Z' | 'a'..='z' | '_' | ':')
488        || (c as u32) >= 0xC0
489}
490
491fn is_name_char(c: char) -> bool {
492    is_name_start_char(c) || matches!(c, '0'..='9' | '-' | '.')
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use crate::options::ParseOptions;
499    use crate::parser::parse_bytes_with_dtd;
500
501    fn parse(src: &str) -> (sup_xml_tree::dom::Document, Dtd) {
502        let opts = ParseOptions { namespace_aware: false, ..ParseOptions::default() };
503        parse_bytes_with_dtd(src.as_bytes(), &opts).expect("parse failed")
504    }
505
506    fn assert_err_contains(errs: &[DtdError], needle: &str) {
507        assert!(
508            errs.iter().any(|e| e.message.contains(needle)),
509            "no error matching {needle:?}, got: {errs:#?}",
510        );
511    }
512
513    // ── infrastructure: Display, is_valid, empty DTD ─────────────────
514
515    #[test]
516    fn dtd_error_display_format() {
517        let e = DtdError {
518            element: "foo".into(),
519            message: "boom".into(),
520        };
521        assert_eq!(format!("{e}"), "DTD validation error on element <foo>: boom");
522    }
523
524    #[test]
525    fn empty_dtd_validates_anything() {
526        let opts = ParseOptions::default();
527        let doc = crate::parse_str("<r><a/><b/></r>", &opts).unwrap();
528        let dtd = Dtd::new();
529        assert!(dtd.is_empty());
530        assert!(validate(&doc, &dtd).is_ok());
531        assert!(is_valid(&doc, &dtd));
532    }
533
534    #[test]
535    fn is_valid_returns_false_on_failure() {
536        // Element 'r' is declared but 'unknown' is not.
537        let src = r#"<?xml version="1.0"?>
538<!DOCTYPE r [
539  <!ELEMENT r ANY>
540]>
541<r><unknown/></r>"#;
542        let (doc, dtd) = parse(src);
543        assert!(!is_valid(&doc, &dtd));
544    }
545
546    // ── walk: missing declaration, recurses into children ────────────
547
548    #[test]
549    fn missing_element_decl_reports_error_but_keeps_walking() {
550        let src = r#"<?xml version="1.0"?>
551<!DOCTYPE r [
552  <!ELEMENT r ANY>
553  <!ELEMENT b ANY>
554]>
555<r><undeclared><b/></undeclared></r>"#;
556        // 'undeclared' has no decl, but 'b' inside it does — the walker
557        // must continue past 'undeclared' so the doc doesn't silently
558        // pass for descendants.
559        let (doc, dtd) = parse(src);
560        let errs = validate(&doc, &dtd).unwrap_err();
561        assert_err_contains(&errs, "no declaration found");
562    }
563
564    // ── EMPTY content model ──────────────────────────────────────────
565
566    #[test]
567    fn empty_model_accepts_whitespace_text_and_comments() {
568        let src = r#"<?xml version="1.0"?>
569<!DOCTYPE r [
570  <!ELEMENT r EMPTY>
571]>
572<r>   <!-- comment --><?pi?></r>"#;
573        let (doc, dtd) = parse(src);
574        assert!(validate(&doc, &dtd).is_ok());
575    }
576
577    #[test]
578    fn empty_model_rejects_non_whitespace_text() {
579        let src = r#"<?xml version="1.0"?>
580<!DOCTYPE r [
581  <!ELEMENT r EMPTY>
582]>
583<r>text</r>"#;
584        let (doc, dtd) = parse(src);
585        let errs = validate(&doc, &dtd).unwrap_err();
586        assert_err_contains(&errs, "EMPTY");
587    }
588
589    #[test]
590    fn empty_model_rejects_child_elements() {
591        let src = r#"<?xml version="1.0"?>
592<!DOCTYPE r [
593  <!ELEMENT r EMPTY>
594  <!ELEMENT a EMPTY>
595]>
596<r><a/></r>"#;
597        let (doc, dtd) = parse(src);
598        let errs = validate(&doc, &dtd).unwrap_err();
599        assert_err_contains(&errs, "EMPTY");
600    }
601
602    // ── ANY content model ────────────────────────────────────────────
603
604    #[test]
605    fn any_model_accepts_everything() {
606        let src = r#"<?xml version="1.0"?>
607<!DOCTYPE r [
608  <!ELEMENT r ANY>
609  <!ELEMENT a ANY>
610  <!ELEMENT b ANY>
611]>
612<r>text<a/>more<b><a/></b></r>"#;
613        let (doc, dtd) = parse(src);
614        assert!(validate(&doc, &dtd).is_ok(), "got {:?}", validate(&doc, &dtd));
615    }
616
617    // ── Mixed content ────────────────────────────────────────────────
618
619    #[test]
620    fn mixed_accepts_listed_children() {
621        let src = r#"<?xml version="1.0"?>
622<!DOCTYPE r [
623  <!ELEMENT r (#PCDATA | em | b)*>
624  <!ELEMENT em (#PCDATA)>
625  <!ELEMENT b (#PCDATA)>
626]>
627<r>text <em>emp</em> tail <b>bold</b></r>"#;
628        let (doc, dtd) = parse(src);
629        assert!(validate(&doc, &dtd).is_ok());
630    }
631
632    #[test]
633    fn mixed_rejects_unlisted_child() {
634        let src = r#"<?xml version="1.0"?>
635<!DOCTYPE r [
636  <!ELEMENT r (#PCDATA | em)*>
637  <!ELEMENT em (#PCDATA)>
638  <!ELEMENT bad (#PCDATA)>
639]>
640<r>text <bad/></r>"#;
641        let (doc, dtd) = parse(src);
642        let errs = validate(&doc, &dtd).unwrap_err();
643        assert_err_contains(&errs, "mixed content");
644    }
645
646    #[test]
647    fn mixed_pcdata_only_lists_pcdata_only_in_error() {
648        let src = r#"<?xml version="1.0"?>
649<!DOCTYPE r [
650  <!ELEMENT r (#PCDATA)>
651  <!ELEMENT bad (#PCDATA)>
652]>
653<r>text <bad/></r>"#;
654        let (doc, dtd) = parse(src);
655        let errs = validate(&doc, &dtd).unwrap_err();
656        assert_err_contains(&errs, "#PCDATA only");
657    }
658
659    // ── Children content (sequences, choices, occurrences) ──────────
660
661    #[test]
662    fn children_sequence_matches_in_order() {
663        let src = r#"<?xml version="1.0"?>
664<!DOCTYPE r [
665  <!ELEMENT r (a, b)>
666  <!ELEMENT a EMPTY>
667  <!ELEMENT b EMPTY>
668]>
669<r><a/><b/></r>"#;
670        let (doc, dtd) = parse(src);
671        assert!(validate(&doc, &dtd).is_ok());
672    }
673
674    #[test]
675    fn children_sequence_wrong_order_fails() {
676        let src = r#"<?xml version="1.0"?>
677<!DOCTYPE r [
678  <!ELEMENT r (a, b)>
679  <!ELEMENT a EMPTY>
680  <!ELEMENT b EMPTY>
681]>
682<r><b/><a/></r>"#;
683        let (doc, dtd) = parse(src);
684        let errs = validate(&doc, &dtd).unwrap_err();
685        assert_err_contains(&errs, "does not match content model");
686    }
687
688    #[test]
689    fn children_choice_accepts_either() {
690        let src = r#"<?xml version="1.0"?>
691<!DOCTYPE r [
692  <!ELEMENT r (a | b)>
693  <!ELEMENT a EMPTY>
694  <!ELEMENT b EMPTY>
695]>
696<r><b/></r>"#;
697        let (doc, dtd) = parse(src);
698        assert!(validate(&doc, &dtd).is_ok());
699    }
700
701    #[test]
702    fn children_choice_rejects_neither() {
703        let src = r#"<?xml version="1.0"?>
704<!DOCTYPE r [
705  <!ELEMENT r (a | b)>
706  <!ELEMENT a EMPTY>
707  <!ELEMENT b EMPTY>
708  <!ELEMENT c EMPTY>
709]>
710<r><c/></r>"#;
711        let (doc, dtd) = parse(src);
712        assert!(validate(&doc, &dtd).is_err());
713    }
714
715    #[test]
716    fn children_optional_allows_zero_or_one() {
717        let src = r#"<?xml version="1.0"?>
718<!DOCTYPE r [
719  <!ELEMENT r (a?)>
720  <!ELEMENT a EMPTY>
721]>
722<r></r>"#;
723        let (doc, dtd) = parse(src);
724        assert!(validate(&doc, &dtd).is_ok());
725
726        let src2 = r#"<?xml version="1.0"?>
727<!DOCTYPE r [
728  <!ELEMENT r (a?)>
729  <!ELEMENT a EMPTY>
730]>
731<r><a/></r>"#;
732        let (doc2, dtd2) = parse(src2);
733        assert!(validate(&doc2, &dtd2).is_ok());
734    }
735
736    #[test]
737    fn children_zero_or_more_allows_repetition() {
738        let src = r#"<?xml version="1.0"?>
739<!DOCTYPE r [
740  <!ELEMENT r (a*)>
741  <!ELEMENT a EMPTY>
742]>
743<r><a/><a/><a/></r>"#;
744        let (doc, dtd) = parse(src);
745        assert!(validate(&doc, &dtd).is_ok());
746    }
747
748    #[test]
749    fn children_one_or_more_requires_at_least_one() {
750        let src = r#"<?xml version="1.0"?>
751<!DOCTYPE r [
752  <!ELEMENT r (a+)>
753  <!ELEMENT a EMPTY>
754]>
755<r></r>"#;
756        let (doc, dtd) = parse(src);
757        assert!(validate(&doc, &dtd).is_err());
758
759        let src2 = r#"<?xml version="1.0"?>
760<!DOCTYPE r [
761  <!ELEMENT r (a+)>
762  <!ELEMENT a EMPTY>
763]>
764<r><a/><a/></r>"#;
765        let (doc2, dtd2) = parse(src2);
766        assert!(validate(&doc2, &dtd2).is_ok());
767    }
768
769    #[test]
770    fn children_nested_group() {
771        let src = r#"<?xml version="1.0"?>
772<!DOCTYPE r [
773  <!ELEMENT r ((a, b)+ , c?)>
774  <!ELEMENT a EMPTY>
775  <!ELEMENT b EMPTY>
776  <!ELEMENT c EMPTY>
777]>
778<r><a/><b/><a/><b/><c/></r>"#;
779        let (doc, dtd) = parse(src);
780        assert!(validate(&doc, &dtd).is_ok());
781    }
782
783    #[test]
784    fn children_text_rejected_unless_whitespace() {
785        let src = r#"<?xml version="1.0"?>
786<!DOCTYPE r [
787  <!ELEMENT r (a)>
788  <!ELEMENT a EMPTY>
789]>
790<r>nope<a/></r>"#;
791        let (doc, dtd) = parse(src);
792        let errs = validate(&doc, &dtd).unwrap_err();
793        assert_err_contains(&errs, "character data");
794    }
795
796    #[test]
797    fn children_whitespace_text_is_ignored() {
798        let src = r#"<?xml version="1.0"?>
799<!DOCTYPE r [
800  <!ELEMENT r (a)>
801  <!ELEMENT a EMPTY>
802]>
803<r>
804  <a/>
805</r>"#;
806        let (doc, dtd) = parse(src);
807        assert!(validate(&doc, &dtd).is_ok());
808    }
809
810    // ── Attribute validation ─────────────────────────────────────────
811
812    #[test]
813    fn required_attribute_missing_errors() {
814        let src = r#"<?xml version="1.0"?>
815<!DOCTYPE r [
816  <!ELEMENT r EMPTY>
817  <!ATTLIST r id CDATA #REQUIRED>
818]>
819<r/>"#;
820        let (doc, dtd) = parse(src);
821        let errs = validate(&doc, &dtd).unwrap_err();
822        assert_err_contains(&errs, "required attribute 'id'");
823    }
824
825    #[test]
826    fn required_attribute_present_ok() {
827        let src = r#"<?xml version="1.0"?>
828<!DOCTYPE r [
829  <!ELEMENT r EMPTY>
830  <!ATTLIST r id CDATA #REQUIRED>
831]>
832<r id="x"/>"#;
833        let (doc, dtd) = parse(src);
834        assert!(validate(&doc, &dtd).is_ok());
835    }
836
837    #[test]
838    fn fixed_attribute_mismatch_errors() {
839        let src = r#"<?xml version="1.0"?>
840<!DOCTYPE r [
841  <!ELEMENT r EMPTY>
842  <!ATTLIST r kind CDATA #FIXED "v1">
843]>
844<r kind="v2"/>"#;
845        let (doc, dtd) = parse(src);
846        let errs = validate(&doc, &dtd).unwrap_err();
847        assert_err_contains(&errs, "#FIXED");
848    }
849
850    #[test]
851    fn fixed_attribute_match_ok() {
852        let src = r#"<?xml version="1.0"?>
853<!DOCTYPE r [
854  <!ELEMENT r EMPTY>
855  <!ATTLIST r kind CDATA #FIXED "v1">
856]>
857<r kind="v1"/>"#;
858        let (doc, dtd) = parse(src);
859        assert!(validate(&doc, &dtd).is_ok());
860    }
861
862    #[test]
863    fn enumeration_accepts_listed_value() {
864        let src = r#"<?xml version="1.0"?>
865<!DOCTYPE r [
866  <!ELEMENT r EMPTY>
867  <!ATTLIST r role (admin|user|guest) "user">
868]>
869<r role="admin"/>"#;
870        let (doc, dtd) = parse(src);
871        assert!(validate(&doc, &dtd).is_ok());
872    }
873
874    #[test]
875    fn enumeration_rejects_unlisted_value() {
876        let src = r#"<?xml version="1.0"?>
877<!DOCTYPE r [
878  <!ELEMENT r EMPTY>
879  <!ATTLIST r role (admin|user|guest) "user">
880]>
881<r role="root"/>"#;
882        let (doc, dtd) = parse(src);
883        let errs = validate(&doc, &dtd).unwrap_err();
884        assert_err_contains(&errs, "not in enumeration");
885    }
886
887    #[test]
888    fn id_must_be_valid_name() {
889        let src = r#"<?xml version="1.0"?>
890<!DOCTYPE r [
891  <!ELEMENT r EMPTY>
892  <!ATTLIST r id ID #IMPLIED>
893]>
894<r id="0bad"/>"#;
895        let (doc, dtd) = parse(src);
896        let errs = validate(&doc, &dtd).unwrap_err();
897        assert_err_contains(&errs, "valid ID Name");
898    }
899
900    #[test]
901    fn duplicate_id_errors() {
902        let src = r#"<?xml version="1.0"?>
903<!DOCTYPE r [
904  <!ELEMENT r (x, y)>
905  <!ELEMENT x EMPTY>
906  <!ELEMENT y EMPTY>
907  <!ATTLIST x id ID #REQUIRED>
908  <!ATTLIST y id ID #REQUIRED>
909]>
910<r><x id="a"/><y id="a"/></r>"#;
911        let (doc, dtd) = parse(src);
912        let errs = validate(&doc, &dtd).unwrap_err();
913        assert_err_contains(&errs, "duplicate ID");
914    }
915
916    #[test]
917    fn idref_must_match_declared_id() {
918        let src = r#"<?xml version="1.0"?>
919<!DOCTYPE r [
920  <!ELEMENT r (x, y)>
921  <!ELEMENT x EMPTY>
922  <!ELEMENT y EMPTY>
923  <!ATTLIST x id ID #REQUIRED>
924  <!ATTLIST y target IDREF #REQUIRED>
925]>
926<r><x id="a"/><y target="ghost"/></r>"#;
927        let (doc, dtd) = parse(src);
928        let errs = validate(&doc, &dtd).unwrap_err();
929        assert_err_contains(&errs, "IDREF 'ghost'");
930    }
931
932    #[test]
933    fn idref_resolves_to_existing_id() {
934        let src = r#"<?xml version="1.0"?>
935<!DOCTYPE r [
936  <!ELEMENT r (x, y)>
937  <!ELEMENT x EMPTY>
938  <!ELEMENT y EMPTY>
939  <!ATTLIST x id ID #REQUIRED>
940  <!ATTLIST y target IDREF #REQUIRED>
941]>
942<r><x id="a"/><y target="a"/></r>"#;
943        let (doc, dtd) = parse(src);
944        assert!(validate(&doc, &dtd).is_ok());
945    }
946
947    #[test]
948    fn idrefs_validates_each_token() {
949        let src = r#"<?xml version="1.0"?>
950<!DOCTYPE r [
951  <!ELEMENT r (x, x, y)>
952  <!ELEMENT x EMPTY>
953  <!ELEMENT y EMPTY>
954  <!ATTLIST x id ID #REQUIRED>
955  <!ATTLIST y refs IDREFS #REQUIRED>
956]>
957<r><x id="a"/><x id="b"/><y refs="a b"/></r>"#;
958        let (doc, dtd) = parse(src);
959        assert!(validate(&doc, &dtd).is_ok());
960    }
961
962    #[test]
963    fn idrefs_rejects_invalid_token() {
964        let src = r#"<?xml version="1.0"?>
965<!DOCTYPE r [
966  <!ELEMENT r (x, y)>
967  <!ELEMENT x EMPTY>
968  <!ELEMENT y EMPTY>
969  <!ATTLIST x id ID #REQUIRED>
970  <!ATTLIST y refs IDREFS #REQUIRED>
971]>
972<r><x id="a"/><y refs="a 0bad"/></r>"#;
973        let (doc, dtd) = parse(src);
974        let errs = validate(&doc, &dtd).unwrap_err();
975        assert_err_contains(&errs, "valid Name");
976    }
977
978    #[test]
979    fn nmtoken_validation() {
980        let src = r#"<?xml version="1.0"?>
981<!DOCTYPE r [
982  <!ELEMENT r EMPTY>
983  <!ATTLIST r tok NMTOKEN #IMPLIED>
984]>
985<r tok="bad token"/>"#;
986        // Space makes it not a valid single NMTOKEN.
987        let (doc, dtd) = parse(src);
988        let errs = validate(&doc, &dtd).unwrap_err();
989        assert_err_contains(&errs, "NMTOKEN");
990    }
991
992    #[test]
993    fn nmtoken_accepts_digit_start() {
994        let src = r#"<?xml version="1.0"?>
995<!DOCTYPE r [
996  <!ELEMENT r EMPTY>
997  <!ATTLIST r tok NMTOKEN #IMPLIED>
998]>
999<r tok="123abc"/>"#;
1000        let (doc, dtd) = parse(src);
1001        assert!(validate(&doc, &dtd).is_ok());
1002    }
1003
1004    #[test]
1005    fn nmtokens_validates_each_token() {
1006        let src = r#"<?xml version="1.0"?>
1007<!DOCTYPE r [
1008  <!ELEMENT r EMPTY>
1009  <!ATTLIST r toks NMTOKENS #IMPLIED>
1010]>
1011<r toks="a 1b c-d"/>"#;
1012        let (doc, dtd) = parse(src);
1013        assert!(validate(&doc, &dtd).is_ok());
1014    }
1015
1016    #[test]
1017    fn nmtokens_rejects_invalid_token() {
1018        // The whole attribute string contains an empty token if we have
1019        // a leading/internal space — but split_ascii_whitespace handles
1020        // that.  Use an explicitly invalid char like '!'.
1021        let src = r#"<?xml version="1.0"?>
1022<!DOCTYPE r [
1023  <!ELEMENT r EMPTY>
1024  <!ATTLIST r toks NMTOKENS #IMPLIED>
1025]>
1026<r toks="a b!c d"/>"#;
1027        let (doc, dtd) = parse(src);
1028        let errs = validate(&doc, &dtd).unwrap_err();
1029        assert_err_contains(&errs, "NMTOKENS");
1030    }
1031
1032    #[test]
1033    fn entity_attr_accepts_name() {
1034        let src = r#"<?xml version="1.0"?>
1035<!DOCTYPE r [
1036  <!ELEMENT r EMPTY>
1037  <!ATTLIST r ent ENTITY #IMPLIED>
1038]>
1039<r ent="picture"/>"#;
1040        let (doc, dtd) = parse(src);
1041        assert!(validate(&doc, &dtd).is_ok());
1042    }
1043
1044    #[test]
1045    fn entity_attr_rejects_invalid_name() {
1046        let src = r#"<?xml version="1.0"?>
1047<!DOCTYPE r [
1048  <!ELEMENT r EMPTY>
1049  <!ATTLIST r ent ENTITY #IMPLIED>
1050]>
1051<r ent="0bad"/>"#;
1052        let (doc, dtd) = parse(src);
1053        let errs = validate(&doc, &dtd).unwrap_err();
1054        assert_err_contains(&errs, "ENTITY token");
1055    }
1056
1057    #[test]
1058    fn xmlns_attributes_are_skipped_during_attribute_validation() {
1059        // xmlns:foo isn't declared in the ATTLIST but it must not
1060        // trigger an "unexpected attribute"-style failure.
1061        let src = r#"<?xml version="1.0"?>
1062<!DOCTYPE r [
1063  <!ELEMENT r EMPTY>
1064  <!ATTLIST r id CDATA #IMPLIED>
1065]>
1066<r xmlns:foo="urn:bar" id="x"/>"#;
1067        let (doc, dtd) = parse(src);
1068        assert!(validate(&doc, &dtd).is_ok());
1069    }
1070
1071    // ── is_valid_name / is_valid_nmtoken (direct) ────────────────────
1072
1073    #[test]
1074    fn name_predicates_basic() {
1075        assert!(is_valid_name("foo"));
1076        assert!(is_valid_name("_bar"));
1077        assert!(is_valid_name(":ns"));
1078        assert!(is_valid_name("a1-2.3"));
1079        assert!(!is_valid_name(""));
1080        assert!(!is_valid_name("0foo"));
1081        assert!(!is_valid_name("a b"));
1082    }
1083
1084    #[test]
1085    fn nmtoken_predicate() {
1086        assert!(is_valid_nmtoken("1abc"));
1087        assert!(is_valid_nmtoken("foo-bar"));
1088        assert!(!is_valid_nmtoken(""));
1089        assert!(!is_valid_nmtoken("a b"));
1090        assert!(!is_valid_nmtoken("!"));
1091    }
1092}