Skip to main content

sup_xml_core/xsd/
identity.rs

1//! Identity constraints — `<xs:key>` / `<xs:keyref>` / `<xs:unique>`.
2//!
3//! XSD identity constraints scope a uniqueness or foreign-key check to
4//! a subtree.  Each constraint has:
5//!
6//! * a **selector** XPath that picks the elements the constraint applies
7//!   to (relative to the declaring element);
8//! * one or more **field** XPaths that extract the key components from
9//!   each selected element;
10//! * for `xs:keyref`, a `refer` attribute naming a key/unique constraint
11//!   whose value-set this one's values must be members of.
12//!
13//! XSD restricts the XPath syntax for selectors and fields (XSD §3.11.6) —
14//! a small subset that we parse with a hand-written micro-parser here
15//! rather than by going through the general [`crate::xpath`] engine.
16//! That keeps the schema-compile path lean and makes the structural
17//! checks unambiguous.
18//!
19//! ## Scope semantics
20//!
21//! A constraint declared on `<element foo>` scopes to the subtree
22//! rooted at each instance of `foo`.  Within that subtree:
23//!
24//! * `xs:key` requires every selector-matched element to produce a
25//!   unique field-tuple.  A missing field is an error.
26//! * `xs:unique` is the same, but missing fields are tolerated.
27//! * `xs:keyref` checks that each matched field-tuple appears in the
28//!   value-set of the referenced key/unique within the *same* scope or
29//!   an enclosing one.  Forward references within a scope are fine
30//!   (we resolve at scope-close time).
31
32use std::sync::Arc;
33
34use super::schema::QName;
35
36// ── data structures ─────────────────────────────────────────────────────────
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum ConstraintKind {
40    /// `xs:key` — unique, all fields required.
41    Key,
42    /// `xs:unique` — unique, missing fields allowed.
43    Unique,
44    /// `xs:keyref` — each tuple must match a key/unique tuple in scope.
45    KeyRef,
46}
47
48#[derive(Debug, Clone)]
49pub struct IdentityConstraint {
50    pub name:     QName,
51    pub kind:     ConstraintKind,
52    pub selector: SelectorPath,
53    pub fields:   Vec<FieldPath>,
54    /// For `xs:keyref`: the referenced key/unique constraint name.
55    pub refer:    Option<QName>,
56}
57
58/// Selector path — picks elements relative to the constraint-declaring
59/// element.  Per XSD §3.11.6 the syntax is:
60///
61/// ```text
62/// Selector = Path ('|' Path)*
63/// Path     = ('.//')? Step ('/' Step)*
64/// Step     = '.' | NameTest | ('child::' NameTest)
65/// NameTest = QName | '*' | NCName ':*'
66/// ```
67///
68/// We model the union (`|`) as a `Vec<PathExpr>` and each path as a
69/// list of [`PathStep`]s with an optional descendant-or-self prefix.
70#[derive(Debug, Clone)]
71pub struct SelectorPath {
72    pub paths: Vec<PathExpr>,
73}
74
75#[derive(Debug, Clone)]
76pub struct FieldPath {
77    pub paths: Vec<PathExpr>,
78    /// True iff *every* alternative path ends in an attribute step.
79    /// Cached on parse for the field evaluator.
80    pub all_attribute: bool,
81}
82
83#[derive(Debug, Clone)]
84pub struct PathExpr {
85    /// True iff the path starts with `.//` (descendant axis).  Without
86    /// it, the path is anchored at the constraint-declaring element.
87    pub descendant: bool,
88    pub steps:      Vec<PathStep>,
89}
90
91#[derive(Debug, Clone)]
92pub enum PathStep {
93    /// `child::NameTest` (or unprefixed shorthand).
94    Child(NameTest),
95    /// `attribute::NameTest` or `@NameTest` — only legal as the last
96    /// step of a *field* path.
97    Attribute(NameTest),
98}
99
100#[derive(Debug, Clone)]
101pub enum NameTest {
102    /// `*` — any element / any attribute.
103    Any,
104    /// Specific name (namespace resolved at compile time using the
105    /// schema's prefix bindings).
106    Name(QName),
107    /// `prefix:*` — any name in the given namespace.
108    AnyInNs(Arc<str>),
109}
110
111// ── micro-parser for the XSD XPath subset ────────────────────────────────────
112
113/// Parse a `<xs:selector xpath="…">` value.
114pub fn parse_selector(
115    xpath: &str,
116    resolve_prefix: &dyn Fn(&str) -> Option<String>,
117) -> Result<SelectorPath, String> {
118    let mut paths = Vec::new();
119    for part in xpath.split('|') {
120        paths.push(parse_path(part.trim(), /*allow_attribute_tail=*/ false, resolve_prefix)?);
121    }
122    if paths.is_empty() {
123        return Err("empty selector".to_string());
124    }
125    Ok(SelectorPath { paths })
126}
127
128/// Parse a `<xs:field xpath="…">` value.  Allows a trailing `@name`
129/// step that selectors don't.
130pub fn parse_field(
131    xpath: &str,
132    resolve_prefix: &dyn Fn(&str) -> Option<String>,
133) -> Result<FieldPath, String> {
134    let mut paths = Vec::new();
135    for part in xpath.split('|') {
136        paths.push(parse_path(part.trim(), /*allow_attribute_tail=*/ true, resolve_prefix)?);
137    }
138    if paths.is_empty() {
139        return Err("empty field".to_string());
140    }
141    let all_attribute = paths.iter().all(|p|
142        matches!(p.steps.last(), Some(PathStep::Attribute(_)))
143    );
144    Ok(FieldPath { paths, all_attribute })
145}
146
147fn parse_path(
148    raw: &str,
149    allow_attribute_tail: bool,
150    resolve_prefix: &dyn Fn(&str) -> Option<String>,
151) -> Result<PathExpr, String> {
152    // XPath 1.0 §3.7 — whitespace is insignificant between tokens.
153    // Tighten the path to a canonical form so `. //.` and `.//.` parse
154    // identically.  We squeeze spaces around the structural separators
155    // (`/`, `//`, `::`, `@`) and the `.` self-step.
156    let trimmed = raw.trim();
157    let normalized = strip_xpath_whitespace(trimmed);
158    let raw = normalized.as_str();
159    let (descendant, body) = if let Some(rest) = raw.strip_prefix(".//") {
160        (true, rest)
161    } else if let Some(rest) = raw.strip_prefix("./") {
162        (false, rest)
163    } else if raw == "." {
164        return Ok(PathExpr { descendant: false, steps: Vec::new() });
165    } else {
166        (false, raw)
167    };
168
169    let mut steps = Vec::new();
170    let parts: Vec<&str> = body.split('/').collect();
171    for (i, part) in parts.iter().enumerate() {
172        // XPath 1.0 §3.1 — the axis separator `::` and the `@`
173        // abbreviation may be surrounded by whitespace. Strip
174        // it before applying our prefix-stripping logic so that
175        // `child :: imp:iid` parses the same as `child::imp:iid`.
176        let normalized = part.trim()
177            .replace(" :: ", "::")
178            .replace(":: ", "::")
179            .replace(" ::", "::")
180            .replace("@ ", "@");
181        let p = normalized.trim();
182        if p.is_empty() {
183            return Err(format!("empty step in path {raw:?}"));
184        }
185        // `.` is a no-op step (current node); the XSD identity-
186        // constraint subset (§3.11.6) allows it anywhere in a path —
187        // including as the final step of a field path, even though
188        // that selects the current element rather than a downstream
189        // value.  XSTS accepts these so we mirror the verdict.
190        if p == "." { continue; }
191        let is_last = i == parts.len() - 1;
192
193        let step = if let Some(rest) = p.strip_prefix("attribute::") {
194            if !allow_attribute_tail || !is_last {
195                return Err(format!(
196                    "attribute step not allowed here: {raw:?}"
197                ));
198            }
199            PathStep::Attribute(parse_name_test(rest, resolve_prefix)?)
200        } else if let Some(rest) = p.strip_prefix('@') {
201            if !allow_attribute_tail || !is_last {
202                return Err(format!(
203                    "attribute step not allowed here: {raw:?}"
204                ));
205            }
206            PathStep::Attribute(parse_name_test(rest, resolve_prefix)?)
207        } else {
208            let bare = p.strip_prefix("child::").unwrap_or(p);
209            PathStep::Child(parse_name_test(bare, resolve_prefix)?)
210        };
211        steps.push(step);
212    }
213    if steps.is_empty() && !descendant {
214        return Err(format!("path has no steps: {raw:?}"));
215    }
216    Ok(PathExpr { descendant, steps })
217}
218
219fn parse_name_test(
220    s: &str,
221    resolve_prefix: &dyn Fn(&str) -> Option<String>,
222) -> Result<NameTest, String> {
223    if s == "*" {
224        return Ok(NameTest::Any);
225    }
226    if s.is_empty() {
227        return Err("empty name in identity-constraint XPath".to_string());
228    }
229    if let Some((prefix, local)) = s.split_once(':') {
230        ensure_ncname(prefix)?;
231        let ns = resolve_prefix(prefix).ok_or_else(||
232            format!("undeclared namespace prefix in identity-constraint XPath: {prefix:?}"))?;
233        if local == "*" {
234            return Ok(NameTest::AnyInNs(Arc::from(ns)));
235        }
236        ensure_ncname(local)?;
237        Ok(NameTest::Name(QName::new(Some(&ns), local)))
238    } else {
239        // Unprefixed — XSD says no default namespace lookup applies in
240        // identity-constraint XPaths (per the spec, unprefixed names
241        // are in no namespace).  Some implementations (libxml2) instead
242        // use the schema's targetNamespace as the default.  We follow
243        // the stricter "no default namespace" reading and document it.
244        ensure_ncname(s)?;
245        Ok(NameTest::Name(QName::new(None, s)))
246    }
247}
248
249/// Reject anything that isn't a syntactically valid `NCName` per XML
250/// Names §3.  Predicates (`foo[1]`), function calls (`document('')`),
251/// quoted strings, and bare `@` (with no local name to follow) all
252/// fall out of this check because their characters aren't legal in an
253/// XML name — the XSD identity-constraint XPath subset (§3.11.6)
254/// rejects them by construction.
255fn ensure_ncname(s: &str) -> Result<(), String> {
256    let mut chars = s.chars();
257    let Some(first) = chars.next() else {
258        return Err("empty NCName in identity-constraint XPath".to_string());
259    };
260    if !is_name_start(first) || first == ':' {
261        return Err(format!("invalid NCName start char {first:?} in identity-constraint XPath"));
262    }
263    for c in chars {
264        if !is_name_char(c) || c == ':' {
265            return Err(format!("invalid NCName char {c:?} in identity-constraint XPath"));
266        }
267    }
268    Ok(())
269}
270
271fn is_name_start(c: char) -> bool {
272    matches!(c,
273        'A'..='Z' | '_' | 'a'..='z'
274        | '\u{C0}'..='\u{D6}' | '\u{D8}'..='\u{F6}' | '\u{F8}'..='\u{2FF}'
275        | '\u{370}'..='\u{37D}' | '\u{37F}'..='\u{1FFF}'
276        | '\u{200C}'..='\u{200D}' | '\u{2070}'..='\u{218F}'
277        | '\u{2C00}'..='\u{2FEF}' | '\u{3001}'..='\u{D7FF}'
278        | '\u{F900}'..='\u{FDCF}' | '\u{FDF0}'..='\u{FFFD}'
279        | '\u{10000}'..='\u{EFFFF}'
280    )
281}
282
283fn is_name_char(c: char) -> bool {
284    is_name_start(c)
285        || matches!(c,
286            '-' | '.' | '0'..='9' | '\u{B7}'
287            | '\u{0300}'..='\u{036F}' | '\u{203F}'..='\u{2040}'
288        )
289}
290
291/// Squeeze whitespace around the structural punctuation an XSD
292/// identity-constraint XPath actually uses (`/`, `::`, `@`).  XPath
293/// 1.0 §3.7 lets whitespace appear between tokens; we normalise into
294/// the no-space form so the downstream splitter doesn't have to.
295fn strip_xpath_whitespace(s: &str) -> String {
296    let mut out = String::with_capacity(s.len());
297    let mut prev_was_struct = false;
298    for tok in s.split_whitespace() {
299        if !out.is_empty() && !prev_was_struct && !tok.starts_with('/') {
300            out.push(' ');
301        }
302        out.push_str(tok);
303        prev_was_struct = tok.ends_with('/');
304    }
305    // Second pass: squeeze whitespace adjacent to the punctuation
306    // tokens (`/`, `//`, `::`, `@`).  Whitespace adjacent to a
307    // *single* `:` (prefix-separator within a QName) is preserved —
308    // a QName is a single token in XPath, so `xpns :*` is malformed
309    // and must stay malformed for the name-test parser to reject.
310    let mut compacted = String::with_capacity(out.len());
311    let bytes = out.as_bytes();
312    let mut i = 0;
313    while i < bytes.len() {
314        let c = bytes[i] as char;
315        if c == ' ' {
316            let prev = compacted.chars().last();
317            let next = bytes.get(i + 1).map(|b| *b as char);
318            let prev2: Option<char> = {
319                let s = compacted.as_str();
320                let mut chars = s.chars().rev();
321                chars.next(); chars.next()
322            };
323            let next2 = bytes.get(i + 2).map(|b| *b as char);
324            // `::` axis specifier — strip whitespace around either ':'.
325            let prev_is_axis = matches!((prev2, prev), (Some(':'), Some(':')));
326            let next_is_axis = matches!((next, next2), (Some(':'), Some(':')));
327            let touches_struct = matches!(prev, Some('/') | Some('@'))
328                || matches!(next, Some('/') | Some('@'))
329                || prev_is_axis
330                || next_is_axis;
331            if touches_struct { i += 1; continue; }
332        }
333        compacted.push(c);
334        i += 1;
335    }
336    compacted
337}
338
339// ── tests ───────────────────────────────────────────────────────────────────
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344
345    fn no_prefixes(_: &str) -> Option<String> { None }
346
347    #[test]
348    fn parses_simple_child_path() {
349        let p = parse_selector("child", &no_prefixes).unwrap();
350        assert_eq!(p.paths.len(), 1);
351        assert_eq!(p.paths[0].steps.len(), 1);
352        assert!(matches!(&p.paths[0].steps[0], PathStep::Child(NameTest::Name(_))));
353        assert!(!p.paths[0].descendant);
354    }
355
356    #[test]
357    fn parses_descendant_prefix() {
358        let p = parse_selector(".//foo/bar", &no_prefixes).unwrap();
359        assert!(p.paths[0].descendant);
360        assert_eq!(p.paths[0].steps.len(), 2);
361    }
362
363    #[test]
364    fn parses_dot_self() {
365        let p = parse_selector(".", &no_prefixes).unwrap();
366        assert_eq!(p.paths[0].steps.len(), 0);
367    }
368
369    #[test]
370    fn parses_wildcard() {
371        let p = parse_selector("*", &no_prefixes).unwrap();
372        assert!(matches!(&p.paths[0].steps[0], PathStep::Child(NameTest::Any)));
373    }
374
375    #[test]
376    fn parses_field_with_attribute() {
377        let f = parse_field("@id", &no_prefixes).unwrap();
378        assert_eq!(f.paths[0].steps.len(), 1);
379        assert!(matches!(&f.paths[0].steps[0], PathStep::Attribute(_)));
380        assert!(f.all_attribute);
381    }
382
383    #[test]
384    fn parses_field_with_child_then_attribute() {
385        let f = parse_field("foo/@id", &no_prefixes).unwrap();
386        assert_eq!(f.paths[0].steps.len(), 2);
387        assert!(matches!(&f.paths[0].steps[0], PathStep::Child(_)));
388        assert!(matches!(&f.paths[0].steps[1], PathStep::Attribute(_)));
389        assert!(f.all_attribute);
390    }
391
392    #[test]
393    fn rejects_attribute_in_selector() {
394        assert!(parse_selector("@id", &no_prefixes).is_err());
395        assert!(parse_selector("foo/@id", &no_prefixes).is_err());
396    }
397
398    #[test]
399    fn rejects_attribute_mid_path() {
400        assert!(parse_field("@id/foo", &no_prefixes).is_err());
401    }
402
403    #[test]
404    fn parses_union() {
405        let p = parse_selector("foo | bar | baz", &no_prefixes).unwrap();
406        assert_eq!(p.paths.len(), 3);
407    }
408
409    #[test]
410    fn resolves_prefixed_names() {
411        let resolver = |p: &str| if p == "ns" { Some("urn:x".into()) } else { None };
412        let p = parse_selector("ns:foo", &resolver).unwrap();
413        match &p.paths[0].steps[0] {
414            PathStep::Child(NameTest::Name(qn)) => {
415                assert_eq!(qn.namespace.as_deref(), Some("urn:x"));
416                assert_eq!(qn.local.as_ref(), "foo");
417            }
418            _ => panic!(),
419        }
420    }
421
422    #[test]
423    fn rejects_undeclared_prefix() {
424        assert!(parse_selector("unknown:foo", &no_prefixes).is_err());
425    }
426
427    #[test]
428    fn parses_namespace_wildcard() {
429        let resolver = |p: &str| if p == "ns" { Some("urn:x".into()) } else { None };
430        let p = parse_selector("ns:*", &resolver).unwrap();
431        assert!(matches!(&p.paths[0].steps[0], PathStep::Child(NameTest::AnyInNs(_))));
432    }
433}