Skip to main content

openbim_dt/
conformance.rs

1//! XML Schema validation against the ISO 23387 structural grammar.
2//!
3//! This validates instance documents against the schema's element/attribute
4//! grammar: global roots, child sequences and choices, cardinalities,
5//! required and unknown attributes, enumerations, patterns, and the XML
6//! Schema datatypes the vocabulary uses.
7//!
8//! It is not a general-purpose XSD processor: there is no support for
9//! `xsi:type` substitution, identity constraints, or importing arbitrary
10//! schema documents. The normative schema is never redistributed; only the
11//! derived structural tables in [`crate::schema`] are compiled in.
12
13use std::fmt;
14
15use crate::{
16    schema::{AttributeRule, Definition, DEFINITIONS, GLOBAL_ROOTS},
17    Document, Element, Node, NAMESPACE,
18};
19
20/// A stable category for a schema-validation failure.
21///
22/// Codes are part of the public contract: callers match on them rather than
23/// parsing messages.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25#[non_exhaustive]
26pub enum SchemaViolationCode {
27    /// The root element is not a global root declared by the schema.
28    UnknownRoot,
29    /// An element appeared where the schema does not declare it.
30    UnexpectedElement,
31    /// A required child occurred fewer times than `minOccurs`.
32    MissingRequiredChild,
33    /// A child occurred more times than `maxOccurs`.
34    TooManyOccurrences,
35    /// Children appeared in an order the declared sequence does not permit.
36    OutOfOrderChild,
37    /// A required choice branch was absent.
38    MissingChoiceBranch,
39    /// A required attribute was absent.
40    MissingRequiredAttribute,
41    /// An attribute is not declared on this element.
42    UnknownAttribute,
43    /// A value is outside its datatype's lexical space.
44    InvalidLexicalValue,
45    /// A value is not a member of the declared enumeration.
46    InvalidEnumerationValue,
47    /// A value does not satisfy the declared pattern facet.
48    PatternMismatch,
49    /// Character data appeared in element-only content.
50    UnexpectedText,
51    /// An element is in a namespace the schema does not define.
52    ForeignNamespace,
53}
54
55/// One schema-validation failure with its location.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct SchemaViolation {
58    code: SchemaViolationCode,
59    /// Slash-separated element path, for example `/Library/Property`.
60    path: String,
61    message: String,
62}
63
64impl SchemaViolation {
65    #[must_use]
66    pub const fn code(&self) -> SchemaViolationCode {
67        self.code
68    }
69
70    #[must_use]
71    pub fn path(&self) -> &str {
72        &self.path
73    }
74
75    #[must_use]
76    pub fn message(&self) -> &str {
77        &self.message
78    }
79}
80
81impl fmt::Display for SchemaViolation {
82    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
83        write!(
84            formatter,
85            "{:?} at {}: {}",
86            self.code, self.path, self.message
87        )
88    }
89}
90
91/// The outcome of validating a document against the ISO 23387 grammar.
92#[derive(Debug, Clone, PartialEq, Eq, Default)]
93pub struct SchemaReport {
94    violations: Vec<SchemaViolation>,
95}
96
97impl SchemaReport {
98    /// Whether the document satisfied every checked schema rule.
99    #[must_use]
100    pub fn is_conforming(&self) -> bool {
101        self.violations.is_empty()
102    }
103
104    #[must_use]
105    pub fn violations(&self) -> &[SchemaViolation] {
106        &self.violations
107    }
108
109    fn push(&mut self, code: SchemaViolationCode, path: &str, message: impl Into<String>) {
110        self.violations.push(SchemaViolation {
111            code,
112            path: path.to_owned(),
113            message: message.into(),
114        });
115    }
116}
117
118impl Document {
119    /// Validates this document against the ISO 23387 element grammar.
120    ///
121    /// This is a stricter, structural check than [`Document::validate`], which
122    /// reports advisory diagnostics. A conforming report means every checked
123    /// rule held: declared root, child sequence and cardinality, attribute
124    /// presence, and datatype facets.
125    #[must_use]
126    pub fn validate_schema(&self) -> SchemaReport {
127        let mut report = SchemaReport::default();
128        let root = self.root();
129
130        if root.namespace_uri() != Some(NAMESPACE) {
131            report.push(
132                SchemaViolationCode::ForeignNamespace,
133                &format!("/{}", root.local_name()),
134                format!(
135                    "root element is in namespace {:?}, not the ISO 23387 namespace",
136                    root.namespace_uri().unwrap_or("(none)")
137                ),
138            );
139            return report;
140        }
141
142        let Some(definition) = global_root_definition(root.local_name()) else {
143            report.push(
144                SchemaViolationCode::UnknownRoot,
145                &format!("/{}", root.local_name()),
146                format!(
147                    "`{}` is not a global root element declared by the schema",
148                    root.local_name()
149                ),
150            );
151            return report;
152        };
153
154        let path = format!("/{}", root.local_name());
155        validate_element(root, definition, &path, &mut report);
156        report
157    }
158}
159
160fn global_root_definition(local_name: &str) -> Option<&'static Definition> {
161    GLOBAL_ROOTS
162        .iter()
163        .find(|(name, _)| *name == local_name)
164        .map(|(_, index)| &DEFINITIONS[*index])
165}
166
167fn validate_element(
168    element: &Element,
169    definition: &'static Definition,
170    path: &str,
171    report: &mut SchemaReport,
172) {
173    validate_attributes(element, definition, path, report);
174    validate_content(element, definition, path, report);
175}
176
177fn validate_attributes(
178    element: &Element,
179    definition: &'static Definition,
180    path: &str,
181    report: &mut SchemaReport,
182) {
183    for attribute in element.attributes() {
184        // Namespace declarations are not schema attributes.
185        if attribute.qname() == "xmlns" || attribute.prefix() == Some("xmlns") {
186            continue;
187        }
188        // `xml:*` and `xsi:*` are governed by their own specifications.
189        if matches!(
190            attribute.namespace_uri(),
191            Some("http://www.w3.org/XML/1998/namespace")
192                | Some("http://www.w3.org/2001/XMLSchema-instance")
193        ) {
194            continue;
195        }
196
197        // Schema attributes are unqualified or ISO 23387 qualified.
198        let applies =
199            attribute.namespace_uri().is_none() || attribute.namespace_uri() == Some(NAMESPACE);
200        if !applies {
201            continue;
202        }
203
204        match find_attribute(definition, attribute.local_name()) {
205            Some(rule) => validate_simple_value(
206                attribute.value(),
207                rule.data_type,
208                rule.pattern,
209                rule.enum_values,
210                &format!("{path}/@{}", attribute.local_name()),
211                report,
212            ),
213            None => report.push(
214                SchemaViolationCode::UnknownAttribute,
215                path,
216                format!(
217                    "attribute `{}` is not declared on `{}`",
218                    attribute.local_name(),
219                    definition.handle
220                ),
221            ),
222        }
223    }
224
225    for rule in definition.attributes.iter().filter(|rule| rule.required) {
226        let present = element.attributes().iter().any(|attribute| {
227            attribute.local_name() == rule.name
228                && (attribute.namespace_uri().is_none()
229                    || attribute.namespace_uri() == Some(NAMESPACE))
230        });
231        if !present {
232            report.push(
233                SchemaViolationCode::MissingRequiredAttribute,
234                path,
235                format!("required attribute `{}` is missing", rule.name),
236            );
237        }
238    }
239}
240
241fn find_attribute(definition: &'static Definition, name: &str) -> Option<&'static AttributeRule> {
242    definition.attributes.iter().find(|rule| rule.name == name)
243}
244
245fn validate_content(
246    element: &Element,
247    definition: &'static Definition,
248    path: &str,
249    report: &mut SchemaReport,
250) {
251    let element_only = !definition.children.is_empty();
252
253    if element_only {
254        for node in element.nodes() {
255            let has_text = match node {
256                Node::Text(text) => !text.chars().all(is_xml_whitespace),
257                Node::CData(text) => !text.is_empty(),
258                _ => false,
259            };
260            if has_text {
261                report.push(
262                    SchemaViolationCode::UnexpectedText,
263                    path,
264                    format!(
265                        "`{}` has element-only content but carries character data",
266                        definition.handle
267                    ),
268                );
269                break;
270            }
271        }
272    } else if let Some(data_type) = definition.data_type {
273        validate_simple_value(
274            &element.direct_text(),
275            data_type,
276            definition.pattern,
277            definition.enum_values,
278            path,
279            report,
280        );
281    }
282
283    // Walk children against the declared sequence. `cursor` only moves
284    // forward, so a child matching an already-passed rule is out of order
285    // rather than silently accepted.
286    let mut counts = vec![0_u32; definition.children.len()];
287    let mut cursor = 0_usize;
288
289    for child in element.children() {
290        if child.namespace_uri() != Some(NAMESPACE) {
291            // Foreign-namespace children are retained by the parser but are
292            // not part of this schema's content model.
293            continue;
294        }
295
296        let child_path = format!("{path}/{}", child.local_name());
297        let Some(position) = definition
298            .children
299            .iter()
300            .position(|rule| rule.name == child.local_name())
301        else {
302            report.push(
303                SchemaViolationCode::UnexpectedElement,
304                &child_path,
305                format!(
306                    "`{}` is not declared as a child of `{}`",
307                    child.local_name(),
308                    definition.handle
309                ),
310            );
311            continue;
312        };
313
314        let rule = &definition.children[position];
315
316        if position < cursor && !shares_choice_group(definition, position, cursor) {
317            report.push(
318                SchemaViolationCode::OutOfOrderChild,
319                &child_path,
320                format!(
321                    "`{}` appears after content the schema declares later in the sequence",
322                    child.local_name()
323                ),
324            );
325        } else {
326            cursor = position;
327        }
328
329        counts[position] += 1;
330        if let Some(max) = rule.max_occurs {
331            if counts[position] == max + 1 {
332                report.push(
333                    SchemaViolationCode::TooManyOccurrences,
334                    &child_path,
335                    format!(
336                        "`{}` occurs more than the permitted {max} time(s)",
337                        child.local_name()
338                    ),
339                );
340            }
341        }
342
343        validate_element(child, &DEFINITIONS[rule.definition], &child_path, report);
344    }
345
346    for (position, rule) in definition.children.iter().enumerate() {
347        // Choice branches carry their own minimum; the group is checked below.
348        if rule.choice_group.is_some() {
349            continue;
350        }
351        if counts[position] < rule.min_occurs {
352            report.push(
353                SchemaViolationCode::MissingRequiredChild,
354                path,
355                format!(
356                    "`{}` requires at least {} `{}` child element(s) but found {}",
357                    definition.handle, rule.min_occurs, rule.name, counts[position]
358                ),
359            );
360        }
361    }
362
363    for (group_index, group) in definition.choice_groups.iter().enumerate() {
364        let total: u32 = definition
365            .children
366            .iter()
367            .enumerate()
368            .filter(|(_, rule)| rule.choice_group == Some(group_index))
369            .map(|(position, _)| counts[position])
370            .sum();
371
372        if total < group.min_occurs {
373            report.push(
374                SchemaViolationCode::MissingChoiceBranch,
375                path,
376                format!(
377                    "`{}` requires at least {} of ({}) but found {total}",
378                    definition.handle,
379                    group.min_occurs,
380                    group.members.join(" | ")
381                ),
382            );
383        }
384        if let Some(max) = group.max_occurs {
385            if total > max {
386                report.push(
387                    SchemaViolationCode::TooManyOccurrences,
388                    path,
389                    format!(
390                        "`{}` permits at most {max} of ({}) but found {total}",
391                        definition.handle,
392                        group.members.join(" | ")
393                    ),
394                );
395            }
396        }
397    }
398}
399
400/// Whether two child positions belong to the same repeating choice, in which
401/// case interleaving is permitted and order carries no meaning.
402fn shares_choice_group(definition: &'static Definition, left: usize, right: usize) -> bool {
403    let left_group = definition.children.get(left).and_then(|r| r.choice_group);
404    let right_group = definition.children.get(right).and_then(|r| r.choice_group);
405    left_group.is_some() && left_group == right_group
406}
407
408/// The four characters XML Schema treats as whitespace. Unicode separators
409/// such as NBSP are *not* whitespace here and must not be collapsed away.
410const fn is_xml_whitespace(character: char) -> bool {
411    matches!(character, '\u{0009}' | '\u{000A}' | '\u{000D}' | '\u{0020}')
412}
413
414fn collapse(value: &str) -> String {
415    value
416        .split(is_xml_whitespace)
417        .filter(|part| !part.is_empty())
418        .collect::<Vec<_>>()
419        .join(" ")
420}
421
422fn validate_simple_value(
423    raw: &str,
424    data_type: &str,
425    pattern: Option<&str>,
426    enum_values: &[&str],
427    path: &str,
428    report: &mut SchemaReport,
429) {
430    // Every datatype the ISO 23387 vocabulary uses has `collapse` whitespace
431    // processing; `xs:string` itself is `preserve`.
432    let value = if data_type == "xs:string" && pattern.is_none() && enum_values.is_empty() {
433        raw.to_owned()
434    } else {
435        collapse(raw)
436    };
437
438    if !enum_values.is_empty() && !enum_values.contains(&value.as_str()) {
439        report.push(
440            SchemaViolationCode::InvalidEnumerationValue,
441            path,
442            format!(
443                "`{value}` is not one of the permitted values ({})",
444                enum_values.join(", ")
445            ),
446        );
447        return;
448    }
449
450    if let Some(pattern) = pattern {
451        if !matches_pattern(pattern, &value) {
452            report.push(
453                SchemaViolationCode::PatternMismatch,
454                path,
455                format!("`{value}` does not match the declared pattern"),
456            );
457            return;
458        }
459    }
460
461    if !lexical_space_admits(data_type, &value) {
462        report.push(
463            SchemaViolationCode::InvalidLexicalValue,
464            path,
465            format!("`{value}` is not a valid `{data_type}` value"),
466        );
467    }
468}
469
470/// Whether `value` is in the lexical space of the named XML Schema datatype.
471///
472/// Only the datatypes the ISO 23387 vocabulary actually references are
473/// modelled. Anything else is accepted rather than guessed at, so an unmapped
474/// type can never produce a false rejection.
475fn lexical_space_admits(data_type: &str, value: &str) -> bool {
476    match data_type {
477        "xs:string" | "xs:anySimpleType" | "xs:anyURI" => true,
478        "xs:boolean" => matches!(value, "true" | "false" | "1" | "0"),
479        "xs:int" => value.parse::<i32>().is_ok(),
480        "xs:integer" => is_integer(value),
481        "xs:positiveInteger" => is_integer(value) && !is_non_positive(value),
482        "xs:decimal" => is_decimal(value),
483        "xs:double" | "xs:float" => {
484            matches!(value, "INF" | "-INF" | "NaN") || value.parse::<f64>().is_ok()
485        }
486        "xs:dateTime" => is_date_time(value),
487        "xs:language" => is_language(value),
488        _ => true,
489    }
490}
491
492fn is_integer(value: &str) -> bool {
493    let digits = value.strip_prefix(['+', '-']).unwrap_or(value);
494    !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())
495}
496
497fn is_non_positive(value: &str) -> bool {
498    let (negative, digits) = match value.strip_prefix('-') {
499        Some(rest) => (true, rest),
500        None => (false, value.strip_prefix('+').unwrap_or(value)),
501    };
502    negative || digits.bytes().all(|byte| byte == b'0')
503}
504
505fn is_decimal(value: &str) -> bool {
506    let body = value.strip_prefix(['+', '-']).unwrap_or(value);
507    let (whole, fraction) = match body.split_once('.') {
508        Some((whole, fraction)) => (whole, Some(fraction)),
509        None => (body, None),
510    };
511    if !whole.bytes().all(|byte| byte.is_ascii_digit()) {
512        return false;
513    }
514    match fraction {
515        // At least one digit must appear on some side of the point.
516        Some(fraction) => {
517            fraction.bytes().all(|byte| byte.is_ascii_digit())
518                && !(whole.is_empty() && fraction.is_empty())
519        }
520        None => !whole.is_empty(),
521    }
522}
523
524/// `xs:language` is an RFC 3066 tag: `[a-zA-Z]{1,8}(-[a-zA-Z0-9]{1,8})*`.
525fn is_language(value: &str) -> bool {
526    let mut parts = value.split('-');
527    let Some(primary) = parts.next() else {
528        return false;
529    };
530    if primary.is_empty() || primary.len() > 8 || !primary.bytes().all(|b| b.is_ascii_alphabetic())
531    {
532        return false;
533    }
534    parts.all(|part| {
535        !part.is_empty() && part.len() <= 8 && part.bytes().all(|b| b.is_ascii_alphanumeric())
536    })
537}
538
539/// `xs:dateTime`: `[-]CCYY-MM-DDThh:mm:ss[.sss][Z|(+|-)hh:mm]`.
540fn is_date_time(value: &str) -> bool {
541    let body = value.strip_prefix('-').unwrap_or(value);
542    let Some((date, rest)) = body.split_once('T') else {
543        return false;
544    };
545
546    let date_parts: Vec<&str> = date.split('-').collect();
547    if date_parts.len() != 3 {
548        return false;
549    }
550    let (year, month, day) = (date_parts[0], date_parts[1], date_parts[2]);
551    if year.len() < 4 || !year.bytes().all(|b| b.is_ascii_digit()) {
552        return false;
553    }
554    // A four-digit year may not be zero-padded beyond four digits.
555    if year.len() > 4 && year.starts_with('0') {
556        return false;
557    }
558    if !is_fixed_digits(month, 2) || !is_fixed_digits(day, 2) {
559        return false;
560    }
561    let month_value: u32 = month.parse().unwrap_or(0);
562    let day_value: u32 = day.parse().unwrap_or(0);
563    if !(1..=12).contains(&month_value) || day_value < 1 {
564        return false;
565    }
566    let year_value: i64 = year.parse().unwrap_or(0);
567    if day_value > days_in_month(year_value, month_value) {
568        return false;
569    }
570
571    // Split the timezone designator off the time.
572    let (time, zone) = if let Some(stripped) = rest.strip_suffix('Z') {
573        (stripped, None)
574    } else if let Some(position) = rest.rfind(['+', '-']) {
575        (&rest[..position], Some(&rest[position + 1..]))
576    } else {
577        (rest, None)
578    };
579
580    if let Some(zone) = zone {
581        let Some((hours, minutes)) = zone.split_once(':') else {
582            return false;
583        };
584        if !is_fixed_digits(hours, 2) || !is_fixed_digits(minutes, 2) {
585            return false;
586        }
587        let hour_value: u32 = hours.parse().unwrap_or(99);
588        let minute_value: u32 = minutes.parse().unwrap_or(99);
589        if hour_value > 14 || minute_value > 59 || (hour_value == 14 && minute_value != 0) {
590            return false;
591        }
592    }
593
594    let time_parts: Vec<&str> = time.split(':').collect();
595    if time_parts.len() != 3 {
596        return false;
597    }
598    if !is_fixed_digits(time_parts[0], 2) || !is_fixed_digits(time_parts[1], 2) {
599        return false;
600    }
601    let (seconds, fraction) = match time_parts[2].split_once('.') {
602        Some((seconds, fraction)) => (seconds, Some(fraction)),
603        None => (time_parts[2], None),
604    };
605    if !is_fixed_digits(seconds, 2) {
606        return false;
607    }
608    if let Some(fraction) = fraction {
609        if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) {
610            return false;
611        }
612    }
613
614    let hour_value: u32 = time_parts[0].parse().unwrap_or(99);
615    let minute_value: u32 = time_parts[1].parse().unwrap_or(99);
616    let second_value: u32 = seconds.parse().unwrap_or(99);
617    if minute_value > 59 || second_value > 59 {
618        return false;
619    }
620    // 24:00:00 denotes end of day and is the only permitted hour-24 form.
621    if hour_value == 24 {
622        return minute_value == 0 && second_value == 0;
623    }
624    hour_value <= 23
625}
626
627fn is_fixed_digits(value: &str, count: usize) -> bool {
628    value.len() == count && value.bytes().all(|byte| byte.is_ascii_digit())
629}
630
631const fn is_leap_year(year: i64) -> bool {
632    (year % 4 == 0 && year % 100 != 0) || year % 400 == 0
633}
634
635const fn days_in_month(year: i64, month: u32) -> u32 {
636    match month {
637        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
638        4 | 6 | 9 | 11 => 30,
639        2 if is_leap_year(year) => 29,
640        2 => 28,
641        _ => 0,
642    }
643}
644
645/// Matches `value` against the XML Schema regular-expression subset used by
646/// the ISO 23387 schema.
647///
648/// XSD patterns are implicitly anchored to the whole value. Rather than
649/// depending on a full regex engine for two patterns, this supports exactly
650/// the constructs the vocabulary uses: character classes with ranges and
651/// negation, `{n}` and `{n,m}` quantifiers, `?`, `*`, `+`, groups, and
652/// alternation. An unsupported construct returns `false` — a pattern that
653/// cannot be evaluated must never silently pass.
654fn matches_pattern(pattern: &str, value: &str) -> bool {
655    let tokens: Vec<char> = pattern.chars().collect();
656    let input: Vec<char> = value.chars().collect();
657    match parse_alternation(&tokens, 0) {
658        Some((node, next)) if next == tokens.len() => node
659            .match_at(&input, 0)
660            .into_iter()
661            .any(|end| end == input.len()),
662        _ => false,
663    }
664}
665
666#[derive(Debug, Clone)]
667enum Pattern {
668    /// One position: a literal, a class, or a parenthesised sub-pattern.
669    Repeat {
670        inner: Box<Pattern>,
671        min: usize,
672        max: Option<usize>,
673    },
674    Sequence(Vec<Pattern>),
675    Alternation(Vec<Pattern>),
676    Class {
677        negated: bool,
678        items: Vec<ClassItem>,
679    },
680    Literal(char),
681}
682
683#[derive(Debug, Clone, Copy)]
684enum ClassItem {
685    Single(char),
686    Range(char, char),
687}
688
689impl Pattern {
690    /// All input positions this pattern can reach starting at `start`.
691    ///
692    /// Returning every reachable end rather than the first keeps alternation
693    /// and variable repetition correct under backtracking.
694    fn match_at(&self, input: &[char], start: usize) -> Vec<usize> {
695        match self {
696            Self::Literal(expected) => match input.get(start) {
697                Some(actual) if actual == expected => vec![start + 1],
698                _ => Vec::new(),
699            },
700            Self::Class { negated, items } => match input.get(start) {
701                Some(actual) => {
702                    let hit = items.iter().any(|item| match *item {
703                        ClassItem::Single(character) => character == *actual,
704                        ClassItem::Range(low, high) => (low..=high).contains(actual),
705                    });
706                    if hit != *negated {
707                        vec![start + 1]
708                    } else {
709                        Vec::new()
710                    }
711                }
712                None => Vec::new(),
713            },
714            Self::Sequence(parts) => {
715                let mut positions = vec![start];
716                for part in parts {
717                    let mut next = Vec::new();
718                    for position in positions {
719                        for end in part.match_at(input, position) {
720                            if !next.contains(&end) {
721                                next.push(end);
722                            }
723                        }
724                    }
725                    if next.is_empty() {
726                        return Vec::new();
727                    }
728                    positions = next;
729                }
730                positions
731            }
732            Self::Alternation(branches) => {
733                let mut ends = Vec::new();
734                for branch in branches {
735                    for end in branch.match_at(input, start) {
736                        if !ends.contains(&end) {
737                            ends.push(end);
738                        }
739                    }
740                }
741                ends
742            }
743            Self::Repeat { inner, min, max } => {
744                let mut ends = Vec::new();
745                let mut frontier = vec![start];
746                let mut count = 0usize;
747                if *min == 0 {
748                    ends.push(start);
749                }
750                while !frontier.is_empty() {
751                    if let Some(max) = max {
752                        if count >= *max {
753                            break;
754                        }
755                    }
756                    let mut next = Vec::new();
757                    for position in &frontier {
758                        for end in inner.match_at(input, *position) {
759                            // A zero-width match would loop forever.
760                            if end != *position && !next.contains(&end) {
761                                next.push(end);
762                            }
763                        }
764                    }
765                    count += 1;
766                    if next.is_empty() {
767                        break;
768                    }
769                    if count >= *min {
770                        for end in &next {
771                            if !ends.contains(end) {
772                                ends.push(*end);
773                            }
774                        }
775                    }
776                    frontier = next;
777                }
778                ends
779            }
780        }
781    }
782}
783
784type Parsed = Option<(Pattern, usize)>;
785
786fn parse_alternation(tokens: &[char], mut position: usize) -> Parsed {
787    let mut branches = Vec::new();
788    loop {
789        let (branch, next) = parse_sequence(tokens, position)?;
790        branches.push(branch);
791        position = next;
792        if tokens.get(position) == Some(&'|') {
793            position += 1;
794        } else {
795            break;
796        }
797    }
798    let node = if branches.len() == 1 {
799        branches.remove(0)
800    } else {
801        Pattern::Alternation(branches)
802    };
803    Some((node, position))
804}
805
806fn parse_sequence(tokens: &[char], mut position: usize) -> Parsed {
807    let mut parts = Vec::new();
808    while position < tokens.len() && !matches!(tokens[position], '|' | ')') {
809        let (atom, next) = parse_atom(tokens, position)?;
810        let (atom, next) = parse_quantifier(atom, tokens, next)?;
811        parts.push(atom);
812        position = next;
813    }
814    Some((Pattern::Sequence(parts), position))
815}
816
817fn parse_atom(tokens: &[char], position: usize) -> Parsed {
818    match tokens.get(position)? {
819        '(' => {
820            let (inner, next) = parse_alternation(tokens, position + 1)?;
821            if tokens.get(next) != Some(&')') {
822                return None;
823            }
824            Some((inner, next + 1))
825        }
826        '[' => parse_class(tokens, position + 1),
827        '\\' => {
828            // Escapes for regex metacharacters; `\d`-style shorthands are not
829            // used by this schema and are rejected rather than approximated.
830            let escaped = *tokens.get(position + 1)?;
831            if escaped.is_ascii_alphanumeric() {
832                return None;
833            }
834            Some((Pattern::Literal(escaped), position + 2))
835        }
836        // `.` and other unsupported metacharacters fail closed.
837        '.' | '*' | '+' | '?' | '{' | '}' | ']' => None,
838        literal => Some((Pattern::Literal(*literal), position + 1)),
839    }
840}
841
842fn parse_class(tokens: &[char], mut position: usize) -> Parsed {
843    let negated = tokens.get(position) == Some(&'^');
844    if negated {
845        position += 1;
846    }
847    let mut items = Vec::new();
848    while let Some(token) = tokens.get(position) {
849        if *token == ']' {
850            if items.is_empty() {
851                return None;
852            }
853            return Some((Pattern::Class { negated, items }, position + 1));
854        }
855        let low = if *token == '\\' {
856            position += 1;
857            *tokens.get(position)?
858        } else {
859            *token
860        };
861        position += 1;
862
863        // A `-` before the closing bracket is a literal, not a range.
864        if tokens.get(position) == Some(&'-') && tokens.get(position + 1) != Some(&']') {
865            let high = *tokens.get(position + 1)?;
866            items.push(ClassItem::Range(low, high));
867            position += 2;
868        } else {
869            items.push(ClassItem::Single(low));
870        }
871    }
872    None
873}
874
875fn parse_quantifier(atom: Pattern, tokens: &[char], position: usize) -> Parsed {
876    let (min, max, next) = match tokens.get(position) {
877        Some('?') => (0, Some(1), position + 1),
878        Some('*') => (0, None, position + 1),
879        Some('+') => (1, None, position + 1),
880        Some('{') => {
881            let close = tokens[position..].iter().position(|token| *token == '}')? + position;
882            let body: String = tokens[position + 1..close].iter().collect();
883            let (min, max) = match body.split_once(',') {
884                None => {
885                    let exact = body.parse().ok()?;
886                    (exact, Some(exact))
887                }
888                Some((low, "")) => (low.parse().ok()?, None),
889                Some((low, high)) => (low.parse().ok()?, Some(high.parse().ok()?)),
890            };
891            (min, max, close + 1)
892        }
893        _ => return Some((atom, position)),
894    };
895    Some((
896        Pattern::Repeat {
897            inner: Box::new(atom),
898            min,
899            max,
900        },
901        next,
902    ))
903}