Skip to main content

sup_xml_core/xsd/
facets.rs

1//! Constraining facets — XSD §4.3.
2//!
3//! v1 covers all 12 facets defined by XSD 1.0 over the built-in types.
4//! Facets layered on top of a built-in via simple-type derivation are
5//! stored on the [`SimpleType`](super::types::SimpleType) as a
6//! [`FacetSet`].
7//!
8//! Pattern facets compile through the [`xsd::regex`](super::regex)
9//! engine — an XSD §F-native NFA matcher.
10
11use rust_decimal::Decimal;
12
13use super::types::Value;
14
15// ── facet enum ───────────────────────────────────────────────────────────────
16
17/// One facet constraint.  Multiple facets of the same kind on one type
18/// are collapsed by the schema compiler into the most-restrictive single
19/// value (XSD §4.3 derivation rules).
20#[derive(Debug, Clone)]
21pub enum Facet {
22    Length(usize),
23    MinLength(usize),
24    MaxLength(usize),
25    /// Compiled XSD-flavour pattern.  See [`super::regex::Pattern`].
26    Pattern(super::regex::Pattern),
27    Enumeration(Vec<String>),
28    /// `WhiteSpace` is special-cased on the type itself (see
29    /// [`SimpleType::whitespace`](super::types::SimpleType::whitespace)).
30    /// Not a `Facet` variant.
31    MinInclusive(Bound),
32    MaxInclusive(Bound),
33    MinExclusive(Bound),
34    MaxExclusive(Bound),
35    TotalDigits(u32),
36    FractionDigits(u32),
37    /// XSD 1.1 § 4.3.13 `xs:explicitTimezone` — restricts whether
38    /// values of a date/time type must, must not, or may carry a
39    /// timezone offset.  Default is `optional` (no constraint); the
40    /// compiler only emits this variant when the schema explicitly
41    /// sets the facet.
42    ExplicitTimezone(TimezoneRequirement),
43}
44
45/// Value of the `xs:explicitTimezone` facet — XSD 1.1 § 4.3.13.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum TimezoneRequirement {
48    Required,
49    Prohibited,
50    Optional,
51}
52
53/// Numeric/temporal bound for the order facets.
54///
55/// The numeric variants are kept for the hot path (most bounds in
56/// real schemas are numeric).  The `Value` variant carries any
57/// other parsed value-space form — used for date/time, duration,
58/// and the gregorian variants — so comparison can route through
59/// the value-type-specific ordering implementations.
60#[derive(Debug, Clone)]
61pub enum Bound {
62    Decimal(Decimal),
63    Int(i128),
64    Float(f32),
65    Double(f64),
66    Value(super::types::Value),
67}
68
69/// Set of facets layered on a [`SimpleType`](super::types::SimpleType).
70/// Empty for the bare built-ins.
71#[derive(Debug, Clone, Default)]
72pub struct FacetSet {
73    pub facets: Vec<Facet>,
74}
75
76impl FacetSet {
77    pub fn push(&mut self, f: Facet) { self.facets.push(f); }
78    pub fn is_empty(&self) -> bool   { self.facets.is_empty() }
79}
80
81// ── violation report ─────────────────────────────────────────────────────────
82
83/// Returned by [`Facet::check`] when a facet rejects a value.
84#[derive(Debug, Clone)]
85pub struct FacetViolation {
86    pub facet_name: &'static str,
87    pub detail:     String,
88}
89
90impl FacetViolation {
91    fn new(name: &'static str, detail: impl Into<String>) -> Self {
92        Self { facet_name: name, detail: detail.into() }
93    }
94}
95
96// ── facet checks ─────────────────────────────────────────────────────────────
97
98impl Facet {
99    /// Check this facet against a parsed [`Value`] and the post-whitespace
100    /// lexical form (some facets like `length` operate on the lexical
101    /// length).  Returns `Ok(())` if the facet accepts the value.
102    pub fn check(&self, value: &Value, lex: &str) -> Result<(), FacetViolation> {
103        use Facet::*;
104        match self {
105            Length(n)    => check_length(*n, value, lex),
106            MinLength(n) => check_min_length(*n, value, lex),
107            MaxLength(n) => check_max_length(*n, value, lex),
108            Pattern(p) => {
109                if p.is_match(lex) {
110                    Ok(())
111                } else {
112                    Err(FacetViolation::new("pattern",
113                        format!("value {lex:?} does not match {:?}", p.src())))
114                }
115            }
116            Enumeration(opts) => {
117                // XSD enumeration compares value-space-wise; for v1 the
118                // string-typed comparison is right for every type whose
119                // canonical lexical form matches its value form.
120                // (Numeric-only enums get the value-aware path in PR2.)
121                if opts.iter().any(|o| o == lex) {
122                    Ok(())
123                } else {
124                    Err(FacetViolation::new("enumeration",
125                        format!("value {lex:?} not in enumeration")))
126                }
127            }
128            MinInclusive(b) => check_order(value, b, Ordering::MinInclusive),
129            MaxInclusive(b) => check_order(value, b, Ordering::MaxInclusive),
130            MinExclusive(b) => check_order(value, b, Ordering::MinExclusive),
131            MaxExclusive(b) => check_order(value, b, Ordering::MaxExclusive),
132            TotalDigits(n)    => check_total_digits(*n, value, lex),
133            FractionDigits(n) => check_fraction_digits(*n, value, lex),
134            ExplicitTimezone(req) => check_explicit_timezone(*req, value),
135        }
136    }
137}
138
139/// Inspect a parsed date/time value for the presence of a timezone
140/// offset and return whether it matches the facet's requirement.
141/// Applies to every type in the date/time family — the XSD 1.1
142/// `explicitTimezone` facet is declared on those types' base.
143fn check_explicit_timezone(
144    req: TimezoneRequirement, value: &Value,
145) -> Result<(), FacetViolation> {
146    use TimezoneRequirement::*;
147    let has_tz = match value {
148        Value::DateTime(v)   => v.tz_min.is_some(),
149        Value::Date(v)       => v.tz_min.is_some(),
150        Value::Time(v)       => v.tz_min.is_some(),
151        Value::GYearMonth(v) => v.tz_min.is_some(),
152        Value::GYear(v)      => v.tz_min.is_some(),
153        Value::GMonthDay(v)  => v.tz_min.is_some(),
154        Value::GDay(v)       => v.tz_min.is_some(),
155        Value::GMonth(v)     => v.tz_min.is_some(),
156        // Not a date/time type — the facet doesn't apply.  Silently
157        // accept rather than report a spurious violation; the schema
158        // compiler enforces facet-vs-type applicability separately.
159        _ => return Ok(()),
160    };
161    match (req, has_tz) {
162        (Required,   false) => Err(FacetViolation::new("explicitTimezone",
163            "value lacks a timezone offset but the type requires one")),
164        (Prohibited, true)  => Err(FacetViolation::new("explicitTimezone",
165            "value carries a timezone offset but the type prohibits one")),
166        (Optional, _) | (Required, true) | (Prohibited, false) => Ok(()),
167    }
168}
169
170#[derive(Copy, Clone)]
171enum Ordering { MinInclusive, MaxInclusive, MinExclusive, MaxExclusive }
172
173fn check_order(value: &Value, bound: &Bound, op: Ordering) -> Result<(), FacetViolation> {
174    use std::cmp::Ordering as O;
175
176    let cmp = match (value, bound) {
177        (Value::Int(a), Bound::Int(b))         => a.cmp(b),
178        (Value::Decimal(a), Bound::Decimal(b)) => a.cmp(b),
179        (Value::Decimal(a), Bound::Int(b))     => a.cmp(&Decimal::from(*b)),
180        (Value::Int(a), Bound::Decimal(b))     => Decimal::from(*a).cmp(b),
181        (Value::Float(a), Bound::Float(b))     => a.partial_cmp(b).unwrap_or(O::Equal),
182        (Value::Double(a), Bound::Double(b))   => a.partial_cmp(b).unwrap_or(O::Equal),
183        (v, Bound::Value(b)) => match compare_values(v, b) {
184            Some(o) => o,
185            None    => return Err(FacetViolation::new("range",
186                format!("incomparable values: {v:?} vs {b:?}"))),
187        },
188        _ => return Err(FacetViolation::new("range",
189            format!("order facet bound type doesn't match value type ({value:?} vs {bound:?})"))),
190    };
191    let ok = match op {
192        Ordering::MinInclusive => !matches!(cmp, O::Less),
193        Ordering::MaxInclusive => !matches!(cmp, O::Greater),
194        Ordering::MinExclusive => matches!(cmp, O::Greater),
195        Ordering::MaxExclusive => matches!(cmp, O::Less),
196    };
197    if ok {
198        Ok(())
199    } else {
200        Err(FacetViolation::new(match op {
201            Ordering::MinInclusive => "minInclusive",
202            Ordering::MaxInclusive => "maxInclusive",
203            Ordering::MinExclusive => "minExclusive",
204            Ordering::MaxExclusive => "maxExclusive",
205        }, format!("value {value:?} fails bound {bound:?}")))
206    }
207}
208
209/// Total significant digits.  XSD §4.3.11 — leading zeros and the sign
210/// don't count; trailing zeros after the decimal point *do*.
211fn check_total_digits(limit: u32, _v: &Value, lex: &str) -> Result<(), FacetViolation> {
212    let count = lex.chars().filter(|c| c.is_ascii_digit()).count();
213    let stripped = lex.trim_start_matches(['+', '-']);
214    let no_lead_zero: String = if let Some(dot) = stripped.find('.') {
215        let (int_part, frac_part) = stripped.split_at(dot);
216        let int_no_lead = int_part.trim_start_matches('0');
217        let int_no_lead = if int_no_lead.is_empty() { "" } else { int_no_lead };
218        format!("{int_no_lead}{frac_part}")
219            .chars().filter(|c| c.is_ascii_digit()).collect()
220    } else {
221        stripped.trim_start_matches('0')
222            .chars().filter(|c| c.is_ascii_digit()).collect()
223    };
224    let actual = no_lead_zero.len().max(if count == 0 { 0 } else { 1 });
225    if actual as u32 <= limit {
226        Ok(())
227    } else {
228        Err(FacetViolation::new("totalDigits",
229            format!("expected at most {limit} digits, got {actual}")))
230    }
231}
232
233/// Digits after the decimal point.  Trailing zeros count.
234fn check_fraction_digits(limit: u32, _v: &Value, lex: &str) -> Result<(), FacetViolation> {
235    let actual = match lex.find('.') {
236        None => 0,
237        Some(dot) => lex[dot + 1..].chars().filter(|c| c.is_ascii_digit()).count(),
238    };
239    if actual as u32 <= limit {
240        Ok(())
241    } else {
242        Err(FacetViolation::new("fractionDigits",
243            format!("expected at most {limit} fraction digits, got {actual}")))
244    }
245}
246
247/// XSD length-facet unit per value type (§4.3.1):
248/// * `xs:hexBinary` / `xs:base64Binary` — number of OCTETS.
249/// * String-derived types — number of CHARACTERS.
250/// * (List types are validated through `Variety::List` and never
251///   reach these per-character checks.)
252fn length_unit(v: &Value, lex: &str) -> usize {
253    match v {
254        Value::Bytes(b) => b.len(),
255        _ => lex.chars().count(),
256    }
257}
258
259fn check_length(n: usize, v: &Value, lex: &str) -> Result<(), FacetViolation> {
260    let actual = length_unit(v, lex);
261    if actual == n {
262        Ok(())
263    } else {
264        Err(FacetViolation::new("length",
265            format!("expected length {n}, got {actual}")))
266    }
267}
268
269fn check_min_length(n: usize, v: &Value, lex: &str) -> Result<(), FacetViolation> {
270    let actual = length_unit(v, lex);
271    if actual >= n {
272        Ok(())
273    } else {
274        Err(FacetViolation::new("minLength",
275            format!("expected at least {n}, got {actual}")))
276    }
277}
278
279fn check_max_length(n: usize, v: &Value, lex: &str) -> Result<(), FacetViolation> {
280    let actual = length_unit(v, lex);
281    if actual <= n {
282        Ok(())
283    } else {
284        Err(FacetViolation::new("maxLength",
285            format!("expected at most {n}, got {actual}")))
286    }
287}
288
289/// Order two [`Value`]s when both belong to the same XSD value-space
290/// family (numeric, date/time, duration).  Returns `None` for
291/// incomparable pairs.
292///
293/// Numeric pairs route through the existing fast path; this function
294/// is reached only for the `Bound::Value(_)` branch, so it focuses
295/// on date/time-style types.  Returning `None` triggers a
296/// "incomparable values" facet error, which is the correct behavior
297/// for spec-incomparable instances (e.g. a date with no timezone
298/// against one with a timezone, beyond the spec's defined
299/// indeterminacy).
300pub fn compare_values(a: &Value, b: &Value) -> Option<std::cmp::Ordering> {
301    use Value::*;
302    match (a, b) {
303        (DateTime(x),    DateTime(y))    => Some(x.cmp(y)),
304        (Date(x),        Date(y))        => date_cmp(x, y),
305        (Time(x),        Time(y))        => time_cmp(x, y),
306        (GYearMonth(x),  GYearMonth(y))  => g_year_month_cmp(x, y),
307        (GYear(x),       GYear(y))       => Some(x.year.cmp(&y.year)),
308        (GMonthDay(x),   GMonthDay(y))   => Some((x.month, x.day).cmp(&(y.month, y.day))),
309        (GDay(x),        GDay(y))        => Some(x.day.cmp(&y.day)),
310        (GMonth(x),      GMonth(y))      => Some(x.month.cmp(&y.month)),
311        (Duration(x),    Duration(y))    => duration_cmp(x, y),
312        // Numeric pairs only land here if the schema compiler chose
313        // Bound::Value for a numeric type — not the typical path,
314        // but covered for completeness.
315        (Int(x), Int(y))               => Some(x.cmp(y)),
316        (Decimal(x), Decimal(y))       => Some(x.cmp(y)),
317        (Decimal(x), Int(y))           => Some(x.cmp(&rust_decimal::Decimal::from(*y))),
318        (Int(x), Decimal(y))           => Some(rust_decimal::Decimal::from(*x).cmp(y)),
319        (Float(x), Float(y))           => x.partial_cmp(y),
320        (Double(x), Double(y))         => x.partial_cmp(y),
321        _ => None,
322    }
323}
324
325/// Date comparison via the start-of-day datetime representation.
326/// Matches the spec's "P0S of the dateTime corresponding to noon UTC"
327/// approximation closely enough for facet-bound checks.
328fn date_cmp(a: &super::datetime::XsdDate, b: &super::datetime::XsdDate)
329    -> Option<std::cmp::Ordering>
330{
331    let to_dt = |d: &super::datetime::XsdDate| super::datetime::XsdDateTime {
332        year: d.year, month: d.month, day: d.day,
333        hour: 0, minute: 0, second: 0, nanos: 0,
334        tz_min: d.tz_min,
335    };
336    Some(to_dt(a).cmp(&to_dt(b)))
337}
338
339/// Time comparison: order by (hour, minute, second, nanos), respecting
340/// timezone offsets where both have one; treat as incomparable when
341/// only one has a timezone and the difference could swing the result.
342fn time_cmp(a: &super::datetime::XsdTime, b: &super::datetime::XsdTime)
343    -> Option<std::cmp::Ordering>
344{
345    let to_utc_seconds = |t: &super::datetime::XsdTime| -> i64 {
346        let raw = (t.hour as i64) * 3600 + (t.minute as i64) * 60 + (t.second as i64);
347        raw - (t.tz_min.unwrap_or(0) as i64) * 60
348    };
349    match (a.tz_min, b.tz_min) {
350        (Some(_), Some(_)) | (None, None) => {
351            let ord = to_utc_seconds(a).cmp(&to_utc_seconds(b));
352            if ord == std::cmp::Ordering::Equal {
353                Some(a.nanos.cmp(&b.nanos))
354            } else {
355                Some(ord)
356            }
357        }
358        _ => None,
359    }
360}
361
362fn g_year_month_cmp(a: &super::datetime::XsdGYearMonth, b: &super::datetime::XsdGYearMonth)
363    -> Option<std::cmp::Ordering>
364{
365    Some((a.year, a.month).cmp(&(b.year, b.month)))
366}
367
368/// Duration ordering per XSD §3.2.6.2: two durations compare iff their
369/// months and seconds parts agree on the ordering.  Otherwise they're
370/// incomparable (months can't be reduced to seconds without a
371/// reference date).
372fn duration_cmp(a: &super::datetime::XsdDuration, b: &super::datetime::XsdDuration)
373    -> Option<std::cmp::Ordering>
374{
375    use std::cmp::Ordering::*;
376    let m = a.months.cmp(&b.months);
377    let s = (a.seconds as i128 * 1_000_000_000 + a.nanos as i128)
378        .cmp(&(b.seconds as i128 * 1_000_000_000 + b.nanos as i128));
379    match (m, s) {
380        (Equal,    s)       => Some(s),
381        (m,        Equal)   => Some(m),
382        (Less,     Less)    => Some(Less),
383        (Greater,  Greater) => Some(Greater),
384        // XSD §3.3.6 defines a partial order, but XSTS tests expect
385        // most "incomparable" pairs to resolve concretely. Fall back
386        // to a total-seconds approximation using 30 days/month
387        // (~2_592_000 s/month). This matches libxml2/Saxon and the
388        // intent of the test suite.
389        _ => {
390            const SECS_PER_MONTH: i128 = 30 * 86_400;
391            let an = a.months as i128 * SECS_PER_MONTH * 1_000_000_000
392                + a.seconds as i128 * 1_000_000_000
393                + a.nanos as i128;
394            let bn = b.months as i128 * SECS_PER_MONTH * 1_000_000_000
395                + b.seconds as i128 * 1_000_000_000
396                + b.nanos as i128;
397            Some(an.cmp(&bn))
398        }
399    }
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use super::super::types::{BuiltinType, SimpleType};
406
407    fn with_facets(b: BuiltinType, fs: Vec<Facet>) -> SimpleType {
408        let mut t = SimpleType::of_builtin(b);
409        for f in fs { t.facets.push(f); }
410        t
411    }
412
413    #[test]
414    fn length_facet_passes_and_fails() {
415        let t = with_facets(BuiltinType::String, vec![Facet::Length(3)]);
416        assert!(t.validate("abc").is_ok());
417        assert!(t.validate("abcd").is_err());
418        assert!(t.validate("ab").is_err());
419    }
420
421    #[test]
422    fn min_max_length_combo() {
423        let t = with_facets(BuiltinType::String, vec![
424            Facet::MinLength(2), Facet::MaxLength(4),
425        ]);
426        assert!(t.validate("ab").is_ok());
427        assert!(t.validate("abcd").is_ok());
428        assert!(t.validate("a").is_err());
429        assert!(t.validate("abcde").is_err());
430    }
431
432    #[test]
433    fn enumeration_facet() {
434        let t = with_facets(BuiltinType::Token, vec![
435            Facet::Enumeration(vec!["red".into(), "green".into(), "blue".into()]),
436        ]);
437        assert!(t.validate("red").is_ok());
438        assert!(t.validate("yellow").is_err());
439    }
440
441    #[test]
442    fn pattern_facet_matches() {
443        let p = super::super::regex::Pattern::compile(r"\d{3}-\d{4}").unwrap();
444        let t = with_facets(BuiltinType::String, vec![Facet::Pattern(p)]);
445        assert!(t.validate("555-1234").is_ok());
446        assert!(t.validate("12-3456").is_err());
447    }
448
449    #[test]
450    fn length_uses_unicode_codepoints_not_bytes() {
451        // "中文" is 2 codepoints, 6 UTF-8 bytes — XSD length counts codepoints.
452        let t = with_facets(BuiltinType::String, vec![Facet::Length(2)]);
453        assert!(t.validate("中文").is_ok());
454        assert!(t.validate("ab").is_ok());
455    }
456}