Skip to main content

shifty_parse/
lower.rs

1//! Lower a loaded shapes graph into the formalism [`Schema`].
2//!
3//! Every SHACL Core construct collapses into the small IR, applying the sugar
4//! rules from the gap analysis (`class → path`, `minCount/maxCount → Count`,
5//! per-value constraints wrapped in `∀π = ∃≤0 π.¬φ`, `xone → ∧∨¬`, …). Each
6//! shape lowers to a **focus-node predicate** `φ`, so `sh:property`/`sh:node`
7//! compose by conjunction. Unsupported AF constructs emit diagnostics.
8
9use crate::diagnostics::{DiagLevel, Diagnostic};
10use crate::graph::{Loaded, term_to_node};
11use crate::path::parse_path;
12use crate::vocab;
13use oxrdf::{Literal, NamedNode, NamedOrBlankNode, Term};
14use shifty_algebra::{
15    Bound, NodeExpr, NodeKindSet, Path, Rule, RuleHead, Schema, Selector, Severity, Shape,
16    ShapeArena, ShapeId, SparqlConstraint, SparqlConstruct, SparqlQueryKind, SparqlTarget,
17    Statement, ValueType,
18};
19use spargebra::{Query, SparqlParser};
20use std::collections::{BTreeSet, HashMap, HashSet};
21
22pub struct Lowered {
23    pub schema: Schema,
24    pub diagnostics: Vec<Diagnostic>,
25}
26
27/// Lower a loaded graph into a schema plus diagnostics.
28pub fn lower(g: &Loaded) -> Lowered {
29    let mut l = Lowerer {
30        g,
31        arena: ShapeArena::new(),
32        cache: HashMap::new(),
33        statements: Vec::new(),
34        rules: Vec::new(),
35        diags: Vec::new(),
36    };
37    let shapes = l.discover_shapes();
38    for s in &shapes {
39        l.lower_shape(s);
40    }
41    l.lower_custom_components(&shapes);
42    for s in &shapes {
43        // selectors are shared by the shape's statements and its rules
44        let selectors = l.target_selectors(s);
45        if let Some(shape) = l.cache.get(s).copied() {
46            for sel in &selectors {
47                l.statements.push(Statement {
48                    selector: sel.clone(),
49                    shape,
50                });
51            }
52        }
53        l.parse_rules(s, &selectors);
54    }
55    let names = l
56        .cache
57        .iter()
58        .filter_map(|(node, id)| match node {
59            NamedOrBlankNode::NamedNode(n) => Some((*id, n.as_str().to_string())),
60            NamedOrBlankNode::BlankNode(_) => None,
61        })
62        .collect();
63    let schema = Schema {
64        arena: l.arena,
65        statements: l.statements,
66        rules: l.rules,
67        names,
68    };
69    schema.arena.debug_assert_finalized();
70    Lowered {
71        schema,
72        diagnostics: l.diags,
73    }
74}
75
76struct Lowerer<'a> {
77    g: &'a Loaded,
78    arena: ShapeArena,
79    cache: HashMap<NamedOrBlankNode, ShapeId>,
80    statements: Vec<Statement>,
81    rules: Vec<Rule>,
82    diags: Vec<Diagnostic>,
83}
84
85struct ComponentDef {
86    iri: NamedNode,
87    params: Vec<ParamDef>,
88    node_validator: Option<ValidatorDef>,
89    property_validator: Option<ValidatorDef>,
90    generic_validator: Option<ValidatorDef>,
91}
92
93struct ParamDef {
94    path: NamedNode,
95    var: String,
96    optional: bool,
97}
98
99struct ValidatorDef {
100    kind: SparqlQueryKind,
101    query: String,
102    messages: Vec<Term>,
103}
104
105/// The local name of an IRI (after the last `#` or `/`) — the SHACL rule for a
106/// parameter's pre-bound variable name (SHACL §6.2.1).
107fn local_name(iri: &str) -> &str {
108    iri.rsplit(['#', '/']).next().unwrap_or(iri)
109}
110
111impl Lowerer<'_> {
112    fn diag(&mut self, level: DiagLevel, msg: impl Into<String>, subj: &NamedOrBlankNode) {
113        self.diags
114            .push(Diagnostic::new(level, msg, Some(subj.to_string())));
115    }
116
117    /// Subjects that are declared shapes: typed NodeShape/PropertyShape, or
118    /// carrying `sh:path` or a target predicate. Referenced-only shapes are
119    /// pulled in on demand during lowering. Sorted for deterministic output.
120    fn discover_shapes(&self) -> Vec<NamedOrBlankNode> {
121        let mut found: HashSet<NamedOrBlankNode> = HashSet::new();
122        for triple in self.g.graph.iter() {
123            let p = triple.predicate;
124            let is_target = p == vocab::SH_TARGET_NODE
125                || p == vocab::SH_TARGET_CLASS
126                || p == vocab::SH_TARGET_SUBJECTS_OF
127                || p == vocab::SH_TARGET_OBJECTS_OF
128                || p == vocab::SH_TARGET;
129            if p == vocab::SH_PATH || p == vocab::SH_SPARQL || p == vocab::SH_RULE || is_target {
130                found.insert(triple.subject.into_owned());
131            }
132            if p == vocab::RDF_TYPE
133                && let Term::NamedNode(ty) = triple.object.into_owned()
134                && (ty.as_ref() == vocab::SH_NODE_SHAPE || ty.as_ref() == vocab::SH_PROPERTY_SHAPE)
135            {
136                found.insert(triple.subject.into_owned());
137            }
138        }
139        let mut shapes: Vec<NamedOrBlankNode> = found.into_iter().collect();
140        shapes.sort_by_key(|n| n.to_string());
141        shapes
142    }
143
144    fn lower_shape(&mut self, s: &NamedOrBlankNode) -> ShapeId {
145        if let Some(id) = self.cache.get(s) {
146            return *id;
147        }
148        let id = self.arena.reserve();
149        self.cache.insert(s.clone(), id);
150
151        if self.bool_prop(s, vocab::SH_DEACTIVATED) {
152            self.arena.set(id, Shape::Top);
153            return id;
154        }
155
156        let path = self.parse_shape_path(s);
157        let mut conjuncts: Vec<ShapeId> = Vec::new();
158
159        // Value-scoped constraints: each applies to every value node along the
160        // path (or to the focus node directly when there is no path).
161        let value = self.collect_value_constraints(s);
162        if !value.is_empty() {
163            let value_phi = self.arena.and(value);
164            match &path {
165                Some(p) => {
166                    // ∀π.φ  ≡  ∃≤0 π.¬φ
167                    let neg = self.arena.not(value_phi);
168                    let c = self.arena.count(p.clone(), None, Some(0), neg);
169                    conjuncts.push(c);
170                }
171                None => conjuncts.push(value_phi),
172            }
173        }
174
175        self.collect_path_constraints(s, path.as_ref(), &mut conjuncts);
176
177        if self.bool_prop(s, vocab::SH_CLOSED) {
178            let q = self.closed_allowed(s);
179            let c = self.arena.insert(Shape::Closed(q));
180            conjuncts.push(c);
181        }
182
183        for constraint_term in self.g.objects(s, vocab::SH_SPARQL) {
184            let Some(constraint_node) = term_to_node(&constraint_term) else {
185                self.diag(DiagLevel::Error, "sh:sparql must reference a resource", s);
186                continue;
187            };
188            let parsed = if let Some(Term::Literal(query)) =
189                self.g.object(&constraint_node, vocab::SH_SELECT)
190            {
191                self.canonical_sparql(&constraint_node, query.value(), ExpectedQuery::Select)
192                    .map(|query| (SparqlQueryKind::Select, query))
193            } else if let Some(Term::Literal(query)) =
194                self.g.object(&constraint_node, vocab::SH_ASK)
195            {
196                self.canonical_sparql(&constraint_node, query.value(), ExpectedQuery::Ask)
197                    .map(|query| (SparqlQueryKind::Ask, query))
198            } else {
199                self.diag(
200                    DiagLevel::Error,
201                    "sh:sparql constraint requires sh:select or sh:ask",
202                    &constraint_node,
203                );
204                None
205            };
206            if let Some((kind, query)) = parsed {
207                let shape = Some(match s {
208                    NamedOrBlankNode::NamedNode(n) => Term::NamedNode(n.clone()),
209                    NamedOrBlankNode::BlankNode(b) => Term::BlankNode(b.clone()),
210                });
211                // `sh:message` on the SPARQL constraint takes precedence; absent
212                // that, fall back to the owning shape's `sh:message` (SHACL §5.2.1).
213                let mut messages: Vec<Term> = self.g.objects(&constraint_node, vocab::SH_MESSAGE);
214                if messages.is_empty() {
215                    messages = self.g.objects(s, vocab::SH_MESSAGE);
216                }
217                let constraint = SparqlConstraint {
218                    kind,
219                    query,
220                    path: path.clone(),
221                    shape,
222                    messages,
223                    extra_bindings: Vec::new(),
224                    bind_value_to_this: false,
225                };
226                conjuncts.push(self.arena.insert(Shape::Sparql(constraint)));
227            }
228        }
229
230        // sh:expression (SHACL-AF §5): the node expression must evaluate to
231        // `true` with the focus node as `?this`. Focus-scoped, so it joins the
232        // conjuncts directly rather than under a `∀π` wrapper.
233        for expr_term in self.g.objects(s, vocab::SH_EXPRESSION) {
234            if let Some(expr) = self.parse_node_expr(expr_term, s) {
235                if node_expr_has_function(&expr) {
236                    // SPARQL functions inside expressions need shapes-graph
237                    // lookups the validation evaluators don't perform; refuse
238                    // rather than silently under-constrain.
239                    self.diag(
240                        DiagLevel::Unsupported,
241                        "sh:expression with a function call is not yet evaluated",
242                        s,
243                    );
244                } else {
245                    conjuncts.push(self.arena.insert(Shape::Expression(expr)));
246                }
247            }
248        }
249
250        let body = if conjuncts.is_empty() {
251            self.arena.top()
252        } else if conjuncts.len() == 1 {
253            if conjuncts[0] == id {
254                self.arena.top()
255            } else {
256                conjuncts[0]
257            }
258        } else {
259            self.arena.insert(Shape::And(conjuncts))
260        };
261        self.arena.set(
262            id,
263            Shape::Annotated {
264                severity: self.severity(s),
265                messages: self.messages(s),
266                shape: body,
267            },
268        );
269        id
270    }
271
272    fn severity(&self, shape: &NamedOrBlankNode) -> Severity {
273        match self.g.object(shape, vocab::SH_SEVERITY) {
274            Some(Term::NamedNode(value)) => Severity::from_named_node(value),
275            _ => Severity::Violation,
276        }
277    }
278
279    /// Author-supplied `sh:message`(s) on the source shape, kept for reporting.
280    fn messages(&self, shape: &NamedOrBlankNode) -> std::sync::Arc<[Term]> {
281        self.g.objects(shape, vocab::SH_MESSAGE).into()
282    }
283
284    fn collect_value_constraints(&mut self, s: &NamedOrBlankNode) -> Vec<ShapeId> {
285        let mut value: Vec<ShapeId> = Vec::new();
286
287        // sh:class C  ≡  ∃≥1 (rdf:type/rdfs:subClassOf*) . test(C)
288        for c in self.g.objects(s, vocab::SH_CLASS) {
289            let tn = self.arena.insert(Shape::TestConst(c));
290            let cc = self.arena.count(class_path(), Some(1), None, tn);
291            value.push(cc);
292        }
293
294        // sh:datatype
295        for d in self.g.objects(s, vocab::SH_DATATYPE) {
296            if let Term::NamedNode(n) = d {
297                let id = self.arena.insert(Shape::TestType(ValueType::Datatype(n)));
298                value.push(id);
299            }
300        }
301
302        // sh:nodeKind
303        for k in self.g.objects(s, vocab::SH_NODE_KIND) {
304            if let Some(set) = map_node_kind(&k) {
305                let id = self.arena.insert(Shape::TestKind(set));
306                value.push(id);
307            } else {
308                self.diag(DiagLevel::Warning, "unrecognized sh:nodeKind value", s);
309            }
310        }
311
312        // numeric range (combine the four bounds into one facet)
313        let lo = self
314            .lit(s, vocab::SH_MIN_INCLUSIVE)
315            .map(|value| Bound {
316                value,
317                inclusive: true,
318            })
319            .or_else(|| {
320                self.lit(s, vocab::SH_MIN_EXCLUSIVE).map(|value| Bound {
321                    value,
322                    inclusive: false,
323                })
324            });
325        let hi = self
326            .lit(s, vocab::SH_MAX_INCLUSIVE)
327            .map(|value| Bound {
328                value,
329                inclusive: true,
330            })
331            .or_else(|| {
332                self.lit(s, vocab::SH_MAX_EXCLUSIVE).map(|value| Bound {
333                    value,
334                    inclusive: false,
335                })
336            });
337        if lo.is_some() || hi.is_some() {
338            let id = self
339                .arena
340                .insert(Shape::TestType(ValueType::NumericRange { lo, hi }));
341            value.push(id);
342        }
343
344        // length
345        let min_len = self.int(s, vocab::SH_MIN_LENGTH);
346        let max_len = self.int(s, vocab::SH_MAX_LENGTH);
347        if min_len.is_some() || max_len.is_some() {
348            let id = self.arena.insert(Shape::TestType(ValueType::Length {
349                min: min_len,
350                max: max_len,
351            }));
352            value.push(id);
353        }
354
355        // pattern (+ flags)
356        let flags = self
357            .lit(s, vocab::SH_FLAGS)
358            .map(|l| l.value().to_string())
359            .unwrap_or_default();
360        for pat in self.g.objects(s, vocab::SH_PATTERN) {
361            if let Term::Literal(l) = pat {
362                let id = self.arena.insert(Shape::TestType(ValueType::Pattern {
363                    regex: l.value().to_string(),
364                    flags: flags.clone(),
365                }));
366                value.push(id);
367            }
368        }
369
370        // sh:languageIn
371        for li in self.g.objects(s, vocab::SH_LANGUAGE_IN) {
372            let langs: Vec<String> = self
373                .g
374                .read_list(&li)
375                .into_iter()
376                .filter_map(|m| match m {
377                    Term::Literal(l) => Some(l.value().to_string()),
378                    _ => None,
379                })
380                .collect();
381            let id = self.arena.insert(Shape::TestType(ValueType::LangIn(langs)));
382            value.push(id);
383        }
384
385        // sh:in  ≡  ⋁ test(member)
386        for inl in self.g.objects(s, vocab::SH_IN) {
387            let alts: Vec<ShapeId> = self
388                .g
389                .read_list(&inl)
390                .into_iter()
391                .map(|m| self.arena.insert(Shape::TestConst(m)))
392                .collect();
393            let or = self.arena.or(alts);
394            value.push(or);
395        }
396
397        // sh:node — each value node must conform to the referenced shape
398        for n in self.g.objects(s, vocab::SH_NODE) {
399            if let Some(nn) = term_to_node(&n) {
400                let id = self.lower_shape(&nn);
401                value.push(id);
402            }
403        }
404
405        // sh:property — like sh:node, each *value node* must conform to the
406        // referenced property shape (so on a property shape it is scoped under
407        // ∀path, not applied to the focus node directly).
408        for prop in self.g.objects(s, vocab::SH_PROPERTY) {
409            if let Some(pn) = term_to_node(&prop) {
410                let id = self.lower_shape(&pn);
411                value.push(id);
412            }
413        }
414
415        // sh:not
416        for n in self.g.objects(s, vocab::SH_NOT) {
417            if let Some(nn) = term_to_node(&n) {
418                let id = self.lower_shape(&nn);
419                let neg = self.arena.not(id);
420                value.push(neg);
421            }
422        }
423
424        // sh:and / sh:or / sh:xone (each object is an rdf:list of shapes)
425        for l in self.g.objects(s, vocab::SH_AND) {
426            let ids = self.lower_shape_list(&l);
427            let a = self.arena.and(ids);
428            value.push(a);
429        }
430        for l in self.g.objects(s, vocab::SH_OR) {
431            let ids = self.lower_shape_list(&l);
432            let o = self.arena.or(ids);
433            value.push(o);
434        }
435        for l in self.g.objects(s, vocab::SH_XONE) {
436            let ids = self.lower_shape_list(&l);
437            let x = self.arena.xone(ids);
438            value.push(x);
439        }
440
441        value
442    }
443
444    /// Path-level constraints (cardinality, qualified counts, property pairs,
445    /// hasValue, uniqueLang). Most require a path; without one they are ignored
446    /// with a diagnostic, except `sh:hasValue` which applies to the focus node.
447    fn collect_path_constraints(
448        &mut self,
449        s: &NamedOrBlankNode,
450        path: Option<&Path>,
451        conjuncts: &mut Vec<ShapeId>,
452    ) {
453        let need_path = |me: &mut Self, what: &str| {
454            me.diag(DiagLevel::Warning, format!("{what} ignored: no sh:path"), s);
455        };
456
457        let min_count = self.int(s, vocab::SH_MIN_COUNT);
458        let max_count = self.int(s, vocab::SH_MAX_COUNT);
459        if min_count.is_some() || max_count.is_some() {
460            match path {
461                Some(p) => {
462                    let top = self.arena.top();
463                    let c = self.arena.count(p.clone(), min_count, max_count, top);
464                    conjuncts.push(c);
465                }
466                None => need_path(self, "sh:minCount/sh:maxCount"),
467            }
468        }
469
470        // sh:hasValue
471        for v in self.g.objects(s, vocab::SH_HAS_VALUE) {
472            match path {
473                Some(p) => {
474                    let tc = self.arena.insert(Shape::TestConst(v));
475                    let c = self.arena.count(p.clone(), Some(1), None, tc);
476                    conjuncts.push(c);
477                }
478                None => {
479                    let tc = self.arena.insert(Shape::TestConst(v));
480                    conjuncts.push(tc);
481                }
482            }
483        }
484
485        // sh:qualifiedValueShape + qualifiedMin/MaxCount
486        for q in self.g.objects(s, vocab::SH_QUALIFIED_VALUE_SHAPE) {
487            if let Some(qn) = term_to_node(&q) {
488                let qmin = self.int(s, vocab::SH_QUALIFIED_MIN_COUNT);
489                let qmax = self.int(s, vocab::SH_QUALIFIED_MAX_COUNT);
490                match path {
491                    Some(p) => {
492                        let mut qualifiers = vec![self.lower_shape(&qn)];
493                        if self.bool_prop(s, vocab::SH_QUALIFIED_VALUE_SHAPES_DISJOINT) {
494                            for sibling in self.sibling_qualified_shapes(s, &qn) {
495                                let sibling = self.lower_shape(&sibling);
496                                qualifiers.push(self.arena.not(sibling));
497                            }
498                        }
499                        let qualifier = self.arena.and(qualifiers);
500                        let c = self.arena.count(p.clone(), qmin, qmax, qualifier);
501                        conjuncts.push(c);
502                    }
503                    None => need_path(self, "sh:qualifiedValueShape"),
504                }
505            }
506        }
507
508        // property-pair constraints
509        let pairs = [
510            (vocab::SH_EQUALS, "equals"),
511            (vocab::SH_DISJOINT, "disjoint"),
512            (vocab::SH_LESS_THAN, "lessThan"),
513            (vocab::SH_LESS_THAN_OR_EQUALS, "lessThanOrEquals"),
514        ];
515        for (pred, name) in pairs {
516            for other in self.g.objects(s, pred) {
517                let Term::NamedNode(op) = other else { continue };
518                match path {
519                    Some(p) => {
520                        let shape = match name {
521                            "equals" => Shape::Eq(p.clone(), op),
522                            "disjoint" => Shape::Disj(p.clone(), op),
523                            "lessThan" => Shape::Lt(p.clone(), op),
524                            _ => Shape::Le(p.clone(), op),
525                        };
526                        let c = self.arena.insert(shape);
527                        conjuncts.push(c);
528                    }
529                    None if matches!(name, "equals" | "disjoint") => {
530                        let shape = if name == "equals" {
531                            Shape::Eq(Path::Id, op)
532                        } else {
533                            Shape::Disj(Path::Id, op)
534                        };
535                        let c = self.arena.insert(shape);
536                        conjuncts.push(c);
537                    }
538                    None => need_path(self, &format!("sh:{name}")),
539                }
540            }
541        }
542
543        // sh:uniqueLang
544        if self.bool_prop(s, vocab::SH_UNIQUE_LANG) {
545            match path {
546                Some(p) => {
547                    let c = self.arena.insert(Shape::UniqueLang(p.clone()));
548                    conjuncts.push(c);
549                }
550                None => need_path(self, "sh:uniqueLang"),
551            }
552        }
553    }
554
555    /// The target selectors of a shape (used by both its statements and rules).
556    fn target_selectors(&mut self, s: &NamedOrBlankNode) -> Vec<Selector> {
557        let mut sels = Vec::new();
558
559        for c in self.g.objects(s, vocab::SH_TARGET_NODE) {
560            sels.push(Selector::IsConst(c));
561        }
562        for c in self.g.objects(s, vocab::SH_TARGET_CLASS) {
563            sels.push(self.class_selector(c));
564        }
565        for p in self.g.objects(s, vocab::SH_TARGET_SUBJECTS_OF) {
566            if let Term::NamedNode(n) = p {
567                sels.push(Selector::HasOut(n));
568            }
569        }
570        for p in self.g.objects(s, vocab::SH_TARGET_OBJECTS_OF) {
571            if let Term::NamedNode(n) = p {
572                sels.push(Selector::HasIn(n));
573            }
574        }
575
576        // implicit class target: a shape that is also an rdfs:Class / owl:Class
577        if (self.g.is_instance_of(s, vocab::RDFS_CLASS)
578            || self.g.is_instance_of(s, vocab::OWL_CLASS))
579            && let NamedOrBlankNode::NamedNode(n) = s
580        {
581            sels.push(self.class_selector(Term::NamedNode(n.clone())));
582        }
583
584        for target_term in self.g.objects(s, vocab::SH_TARGET) {
585            let Some(target_node) = term_to_node(&target_term) else {
586                self.diag(DiagLevel::Error, "sh:target must reference a resource", s);
587                continue;
588            };
589            match self.g.object(&target_node, vocab::SH_SELECT) {
590                Some(Term::Literal(query)) => {
591                    if let Some(query) =
592                        self.canonical_sparql(&target_node, query.value(), ExpectedQuery::Select)
593                    {
594                        sels.push(Selector::Sparql(SparqlTarget { query }));
595                    }
596                }
597                _ => self.diag(
598                    DiagLevel::Unsupported,
599                    "custom sh:target without sh:select is not yet lowered",
600                    &target_node,
601                ),
602            }
603        }
604
605        sels
606    }
607
608    /// Lower SPARQL-based custom constraint components (SHACL §6.3) into the
609    /// algebra. For each shape that *activates* a component (supplies a value for
610    /// every mandatory parameter), a `Shape::Sparql` node is appended to that
611    /// shape's conjunction. The parameter values from the activating shape are
612    /// stored as `extra_bindings` so `compile_constraint` can pre-substitute them
613    /// (§6.2.3) before binding `$this` per focus node.
614    ///
615    /// Components with an invalid SPARQL validator query emit `DiagLevel::Error`
616    /// and are skipped (the constraint is not enforced for that activation).
617    fn lower_custom_components(&mut self, shapes: &[NamedOrBlankNode]) {
618        let components = self.collect_component_defs();
619        if components.is_empty() {
620            return;
621        }
622        for s in shapes {
623            let Some(&shape_id) = self.cache.get(s) else {
624                continue;
625            };
626            let is_property_shape = self.g.object(s, vocab::SH_PATH).is_some();
627            let path = if is_property_shape {
628                self.parse_shape_path(s)
629            } else {
630                None
631            };
632            let shape_term = match s {
633                NamedOrBlankNode::NamedNode(n) => Some(Term::NamedNode(n.clone())),
634                NamedOrBlankNode::BlankNode(b) => Some(Term::BlankNode(b.clone())),
635            };
636
637            for component in &components {
638                // Activation: every mandatory parameter must be present on `s`.
639                let mut extra_bindings: Vec<(String, Term)> = Vec::new();
640                let mut activated = true;
641                for p in &component.params {
642                    if let Some(value) = self.g.object(s, p.path.as_ref()) {
643                        extra_bindings.push((p.var.clone(), value));
644                    } else if !p.optional {
645                        activated = false;
646                        break;
647                    }
648                }
649                if !activated {
650                    continue;
651                }
652
653                // Pick the right validator for the shape kind.
654                let validator = if is_property_shape {
655                    component
656                        .property_validator
657                        .as_ref()
658                        .or(component.generic_validator.as_ref())
659                } else {
660                    component
661                        .node_validator
662                        .as_ref()
663                        .or(component.generic_validator.as_ref())
664                };
665                let Some(validator) = validator else { continue };
666
667                let constraint = SparqlConstraint {
668                    kind: validator.kind,
669                    query: validator.query.clone(),
670                    path: path.clone(),
671                    shape: shape_term.clone(),
672                    messages: validator.messages.clone(),
673                    extra_bindings,
674                    // For node-shape activations (no sh:path), $value equals the
675                    // focus node, so rename ?value → ?this before binding per focus.
676                    bind_value_to_this: !is_property_shape
677                        && validator.kind == SparqlQueryKind::Ask,
678                };
679                let raw_id = self.arena.insert(Shape::Sparql(constraint));
680                // sh:SPARQLAskValidator (SHACL §6.2.3): ASK=true means the
681                // constraint is *satisfied*. The algebra evaluator treats
682                // ASK=true as a violation (matching sh:sparql semantics), so
683                // we wrap in Not to restore the correct polarity. SELECT
684                // validators are violation-per-row, same as sh:sparql — no inversion.
685                let sparql_id = if validator.kind == SparqlQueryKind::Ask {
686                    self.arena.not(raw_id)
687                } else {
688                    raw_id
689                };
690
691                // Conjoin with the shape's existing body.
692                let (severity, messages, inner) = match self.arena.get(shape_id) {
693                    Shape::Annotated {
694                        severity,
695                        messages,
696                        shape: inner,
697                    } => (severity.clone(), messages.clone(), *inner),
698                    _ => continue, // defensive; lower_shape always produces Annotated
699                };
700                let combined = self.arena.and(vec![inner, sparql_id]);
701                self.arena.set(
702                    shape_id,
703                    Shape::Annotated {
704                        severity,
705                        messages,
706                        shape: combined,
707                    },
708                );
709            }
710        }
711    }
712
713    /// Collect all custom constraint component definitions from the shapes graph:
714    /// named subjects that carry `sh:parameter` and at least one validator
715    /// predicate (`sh:validator`, `sh:nodeValidator`, `sh:propertyValidator`).
716    fn collect_component_defs(&mut self) -> Vec<ComponentDef> {
717        let mut out: Vec<ComponentDef> = Vec::new();
718        let mut seen = HashSet::new();
719        for triple in self.g.graph.triples_for_predicate(vocab::SH_PARAMETER) {
720            let subject = triple.subject.into_owned();
721            if !seen.insert(subject.clone()) {
722                continue;
723            }
724            let NamedOrBlankNode::NamedNode(iri) = &subject else {
725                continue;
726            };
727            if vocab::NATIVE_CONSTRAINT_COMPONENTS.contains(&iri.as_ref()) {
728                continue; // SHACL Core component; already implemented natively
729            }
730            let node_validator = self
731                .g
732                .object(&subject, vocab::SH_NODE_VALIDATOR)
733                .and_then(|v| self.parse_validator_def(&v));
734            let property_validator = self
735                .g
736                .object(&subject, vocab::SH_PROPERTY_VALIDATOR)
737                .and_then(|v| self.parse_validator_def(&v));
738            let generic_validator = self
739                .g
740                .object(&subject, vocab::SH_VALIDATOR)
741                .and_then(|v| self.parse_validator_def(&v));
742            if node_validator.is_none()
743                && property_validator.is_none()
744                && generic_validator.is_none()
745            {
746                continue; // not a constraint component (e.g. a sh:SPARQLFunction)
747            }
748            let mut params: Vec<ParamDef> = Vec::new();
749            for p in self.g.objects(&subject, vocab::SH_PARAMETER) {
750                let Some(pn) = term_to_node(&p) else { continue };
751                let Some(Term::NamedNode(path)) = self.g.object(&pn, vocab::SH_PATH) else {
752                    continue;
753                };
754                let var = local_name(path.as_str()).to_string();
755                let optional = matches!(
756                    self.g.object(&pn, vocab::SH_OPTIONAL),
757                    Some(Term::Literal(ref l)) if l.value() == "true"
758                );
759                params.push(ParamDef {
760                    path,
761                    var,
762                    optional,
763                });
764            }
765            if params.is_empty() {
766                continue;
767            }
768            out.push(ComponentDef {
769                iri: iri.clone(),
770                params,
771                node_validator,
772                property_validator,
773                generic_validator,
774            });
775        }
776        out.sort_by(|a, b| a.iri.as_str().cmp(b.iri.as_str()));
777        out
778    }
779
780    /// Parse a validator node into a `ValidatorDef`, canonicalizing its SPARQL
781    /// query. Returns `None` (and emits an error diagnostic) if the query is
782    /// invalid SPARQL.
783    fn parse_validator_def(&mut self, node: &Term) -> Option<ValidatorDef> {
784        let node = term_to_node(node)?;
785        let (kind, raw) = if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_ASK) {
786            (SparqlQueryKind::Ask, q.value().to_string())
787        } else if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_SELECT) {
788            (SparqlQueryKind::Select, q.value().to_string())
789        } else {
790            return None; // no query body — not a SPARQL validator
791        };
792        let canonical = match canonical_sparql_query(self.g, &node, &raw) {
793            Ok((_, c)) => c,
794            Err(msg) => {
795                self.diag(
796                    DiagLevel::Error,
797                    format!("invalid SPARQL in validator: {msg}"),
798                    &node,
799                );
800                return None;
801            }
802        };
803        let messages = self.g.objects(&node, vocab::SH_MESSAGE);
804        Some(ValidatorDef {
805            kind,
806            query: canonical,
807            messages,
808        })
809    }
810
811    /// Lower the `sh:rule`s of a shape (SHACL-AF). A rule fires on the shape's
812    /// targets, so we emit one [`Rule`] per selector.
813    fn parse_rules(&mut self, s: &NamedOrBlankNode, selectors: &[Selector]) {
814        for rule_term in self.g.objects(s, vocab::SH_RULE) {
815            let Some(rn) = term_to_node(&rule_term) else {
816                continue;
817            };
818            let Some(head) = self.parse_rule_head(&rn) else {
819                continue;
820            };
821
822            let conditions: Vec<ShapeId> = self
823                .g
824                .objects(&rn, vocab::SH_CONDITION)
825                .iter()
826                .filter_map(term_to_node)
827                .map(|c| self.lower_shape(&c))
828                .collect();
829            let order = self.order(&rn);
830            let deactivated = self.bool_prop(&rn, vocab::SH_DEACTIVATED);
831
832            for sel in selectors {
833                self.rules.push(Rule {
834                    selector: sel.clone(),
835                    conditions: conditions.clone(),
836                    head: head.clone(),
837                    order,
838                    deactivated,
839                });
840            }
841        }
842    }
843
844    /// Qualified value shapes attached through the same parent `sh:property`
845    /// declaration, excluding the current qualified shape itself.
846    fn sibling_qualified_shapes(
847        &self,
848        shape: &NamedOrBlankNode,
849        qualifier: &NamedOrBlankNode,
850    ) -> Vec<NamedOrBlankNode> {
851        let mut siblings = HashSet::new();
852        for triple in self.g.graph.triples_for_predicate(vocab::SH_PROPERTY) {
853            if term_to_node(&triple.object.into_owned()).as_ref() != Some(shape) {
854                continue;
855            }
856            let parent = triple.subject.into_owned();
857            for property in self.g.objects(&parent, vocab::SH_PROPERTY) {
858                let Some(property) = term_to_node(&property) else {
859                    continue;
860                };
861                for sibling in self.g.objects(&property, vocab::SH_QUALIFIED_VALUE_SHAPE) {
862                    if let Some(sibling) = term_to_node(&sibling) {
863                        siblings.insert(sibling);
864                    }
865                }
866            }
867        }
868        siblings.remove(qualifier);
869        let mut siblings: Vec<_> = siblings.into_iter().collect();
870        siblings.sort_by_key(|node| node.to_string());
871        siblings
872    }
873
874    fn parse_rule_head(&mut self, rn: &NamedOrBlankNode) -> Option<RuleHead> {
875        // sh:SPARQLRule — parse and canonicalize the CONSTRUCT while retaining
876        // an opaque algebra leaf for later query rewriting.
877        if let Some(Term::Literal(q)) = self.g.object(rn, vocab::SH_CONSTRUCT) {
878            let query = self.canonical_sparql(rn, q.value(), ExpectedQuery::Construct)?;
879            return Some(RuleHead::Sparql(SparqlConstruct { query }));
880        }
881        // sh:TripleRule — subject/predicate/object node expressions
882        let (subj, pred, obj) = (
883            self.g.object(rn, vocab::SH_SUBJECT),
884            self.g.object(rn, vocab::SH_PREDICATE),
885            self.g.object(rn, vocab::SH_OBJECT),
886        );
887        if subj.is_none() && pred.is_none() && obj.is_none() {
888            self.diag(DiagLevel::Unsupported, "unrecognized sh:rule head", rn);
889            return None;
890        }
891        let (Some(subj), Some(pred), Some(obj)) = (subj, pred, obj) else {
892            self.diag(
893                DiagLevel::Error,
894                "sh:TripleRule missing subject/predicate/object",
895                rn,
896            );
897            return None;
898        };
899        Some(RuleHead::Triple {
900            subject: self.parse_node_expr(subj, rn)?,
901            predicate: self.parse_node_expr(pred, rn)?,
902            object: self.parse_node_expr(obj, rn)?,
903        })
904    }
905
906    /// Parse a node expression (SHACL-AF §6). Handles `sh:this`, constants,
907    /// path expressions, filter / intersection / union expressions, and SPARQL
908    /// function calls `[ ex:fn (arg …) ]`.
909    fn parse_node_expr(&mut self, term: Term, owner: &NamedOrBlankNode) -> Option<NodeExpr> {
910        match &term {
911            Term::NamedNode(n) if n.as_ref() == vocab::SH_THIS => Some(NodeExpr::This),
912            Term::NamedNode(_) | Term::Literal(_) => Some(NodeExpr::Constant(term)),
913            Term::BlankNode(_) => {
914                let node = term_to_node(&term).expect("blank node");
915                if let Some(path_term) = self.g.object(&node, vocab::SH_PATH) {
916                    match parse_path(self.g, &path_term) {
917                        Ok(path) => Some(NodeExpr::Path(path)),
918                        Err(e) => {
919                            self.diag(
920                                DiagLevel::Error,
921                                format!("invalid node-expression path: {e}"),
922                                owner,
923                            );
924                            None
925                        }
926                    }
927                } else if self.g.object(&node, vocab::SH_FILTER_SHAPE).is_some() {
928                    self.parse_filter_expr(&node, owner)
929                } else if let Some(list) = self.g.object(&node, vocab::SH_INTERSECTION) {
930                    self.parse_set_expr(&list, owner)
931                        .map(NodeExpr::Intersection)
932                } else if let Some(list) = self.g.object(&node, vocab::SH_UNION) {
933                    self.parse_set_expr(&list, owner).map(NodeExpr::Union)
934                } else if let Some(expr) = self.try_function_call(&node, owner) {
935                    Some(expr)
936                } else {
937                    self.diag(
938                        DiagLevel::Unsupported,
939                        "complex node expression not yet lowered",
940                        owner,
941                    );
942                    None
943                }
944            }
945        }
946    }
947
948    /// `sh:filterShape` + `sh:nodes` — a filter node expression (SHACL-AF §6.4):
949    /// the value nodes of `sh:nodes` that conform to `sh:filterShape`.
950    fn parse_filter_expr(
951        &mut self,
952        node: &NamedOrBlankNode,
953        owner: &NamedOrBlankNode,
954    ) -> Option<NodeExpr> {
955        let Some(shape_term) = self.g.object(node, vocab::SH_FILTER_SHAPE) else {
956            self.diag(DiagLevel::Error, "sh:filterShape missing a value", owner);
957            return None;
958        };
959        let Some(shape_node) = term_to_node(&shape_term) else {
960            self.diag(
961                DiagLevel::Error,
962                "sh:filterShape must reference a shape",
963                owner,
964            );
965            return None;
966        };
967        let Some(nodes_term) = self.g.object(node, vocab::SH_NODES) else {
968            self.diag(
969                DiagLevel::Error,
970                "filter expression missing sh:nodes",
971                owner,
972            );
973            return None;
974        };
975        let input = self.parse_node_expr(nodes_term, owner)?;
976        let shape = self.lower_shape(&shape_node);
977        Some(NodeExpr::Filter {
978            input: Box::new(input),
979            shape,
980        })
981    }
982
983    /// Parse the RDF list of an `sh:intersection` / `sh:union` node expression
984    /// (SHACL-AF §6.5–6.6) into its member node expressions. Returns `None` if
985    /// the list is empty or any member fails to parse (already diagnosed).
986    fn parse_set_expr(
987        &mut self,
988        list_head: &Term,
989        owner: &NamedOrBlankNode,
990    ) -> Option<Vec<NodeExpr>> {
991        let members = self.g.read_list(list_head);
992        if members.is_empty() {
993            self.diag(
994                DiagLevel::Error,
995                "sh:intersection/sh:union expects a non-empty list",
996                owner,
997            );
998            return None;
999        }
1000        let n = members.len();
1001        let exprs: Vec<NodeExpr> = members
1002            .into_iter()
1003            .filter_map(|t| self.parse_node_expr(t, owner))
1004            .collect();
1005        if exprs.len() != n {
1006            return None;
1007        }
1008        Some(exprs)
1009    }
1010
1011    /// Detect a function-call node expression `[ ex:fn ( arg1 arg2 … ) ]`.
1012    ///
1013    /// The blank node must have exactly one non-SHACL/RDF/RDFS/OWL predicate;
1014    /// its object must be an RDF list of argument node expressions.
1015    fn try_function_call(
1016        &mut self,
1017        node: &NamedOrBlankNode,
1018        owner: &NamedOrBlankNode,
1019    ) -> Option<NodeExpr> {
1020        let func_preds: Vec<(NamedNode, Term)> = self
1021            .g
1022            .graph
1023            .triples_for_subject(node)
1024            .map(|t| (t.predicate.into_owned(), t.object.into_owned()))
1025            .filter(|(p, _)| {
1026                let s = p.as_str();
1027                !s.starts_with(vocab::SH)
1028                    && !s.starts_with(vocab::RDF)
1029                    && !s.starts_with(vocab::RDFS)
1030                    && !s.starts_with(vocab::OWL)
1031            })
1032            .collect();
1033
1034        if func_preds.len() != 1 {
1035            return None;
1036        }
1037        let (func_iri, list_head) = func_preds.into_iter().next().unwrap();
1038        let arg_terms = self.g.read_list(&list_head);
1039        let n = arg_terms.len();
1040        let args: Vec<NodeExpr> = arg_terms
1041            .into_iter()
1042            .filter_map(|t| self.parse_node_expr(t, owner))
1043            .collect();
1044        if args.len() != n {
1045            return None;
1046        }
1047        Some(NodeExpr::Function {
1048            iri: func_iri,
1049            args,
1050        })
1051    }
1052
1053    fn order(&self, s: &NamedOrBlankNode) -> Option<i64> {
1054        match self.g.object(s, vocab::SH_ORDER) {
1055            Some(Term::Literal(l)) => l.value().parse().ok(),
1056            _ => None,
1057        }
1058    }
1059
1060    /// `∃≥1 (rdf:type/rdfs:subClassOf*) . test(class)` as a selector.
1061    fn class_selector(&mut self, class: Term) -> Selector {
1062        let tn = self.arena.insert(Shape::TestConst(class));
1063        Selector::HasPath(class_path(), tn)
1064    }
1065
1066    fn lower_shape_list(&mut self, list_head: &Term) -> Vec<ShapeId> {
1067        self.g
1068            .read_list(list_head)
1069            .into_iter()
1070            .filter_map(|m| term_to_node(&m))
1071            .map(|n| self.lower_shape(&n))
1072            .collect()
1073    }
1074
1075    fn parse_shape_path(&mut self, s: &NamedOrBlankNode) -> Option<Path> {
1076        let term = self.g.object(s, vocab::SH_PATH)?;
1077        match parse_path(self.g, &term) {
1078            Ok(p) => Some(p),
1079            Err(e) => {
1080                self.diag(DiagLevel::Error, format!("invalid sh:path: {e}"), s);
1081                None
1082            }
1083        }
1084    }
1085
1086    fn closed_allowed(&self, s: &NamedOrBlankNode) -> BTreeSet<oxrdf::NamedNode> {
1087        let mut q = BTreeSet::new();
1088        for prop in self.g.objects(s, vocab::SH_PROPERTY) {
1089            if let Some(pn) = term_to_node(&prop)
1090                && let Some(Term::NamedNode(n)) = self.g.object(&pn, vocab::SH_PATH)
1091            {
1092                q.insert(n);
1093            }
1094        }
1095        for ip in self.g.objects(s, vocab::SH_IGNORED_PROPERTIES) {
1096            for m in self.g.read_list(&ip) {
1097                if let Term::NamedNode(n) = m {
1098                    q.insert(n);
1099                }
1100            }
1101        }
1102        q
1103    }
1104
1105    fn bool_prop(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> bool {
1106        matches!(self.g.object(s, pred), Some(Term::Literal(l)) if l.value() == "true")
1107    }
1108
1109    fn int(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<u64> {
1110        match self.g.object(s, pred) {
1111            Some(Term::Literal(l)) => l.value().parse().ok(),
1112            _ => None,
1113        }
1114    }
1115
1116    fn lit(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<Literal> {
1117        match self.g.object(s, pred) {
1118            Some(Term::Literal(l)) => Some(l),
1119            _ => None,
1120        }
1121    }
1122
1123    /// Parse a SHACL SPARQL query once, resolving both document prefixes and
1124    /// `sh:prefixes` declarations. `Query::to_string` expands prefix names, so
1125    /// the IR remains self-contained and can be reparsed or rewritten later.
1126    fn canonical_sparql(
1127        &mut self,
1128        owner: &NamedOrBlankNode,
1129        raw: &str,
1130        expected: ExpectedQuery,
1131    ) -> Option<String> {
1132        let (query, canonical) = match canonical_sparql_query(self.g, owner, raw) {
1133            Ok(result) => result,
1134            Err(message) => {
1135                self.diag(DiagLevel::Error, message, owner);
1136                return None;
1137            }
1138        };
1139        let actual = match &query {
1140            Query::Select { .. } => ExpectedQuery::Select,
1141            Query::Ask { .. } => ExpectedQuery::Ask,
1142            Query::Construct { .. } => ExpectedQuery::Construct,
1143            Query::Describe { .. } => ExpectedQuery::Describe,
1144        };
1145        if actual != expected {
1146            self.diag(
1147                DiagLevel::Error,
1148                format!("expected SPARQL {expected}, found {actual}"),
1149                owner,
1150            );
1151            return None;
1152        }
1153        Some(canonical)
1154    }
1155}
1156
1157/// Build the canonical, prefix-expanded form of a SHACL SPARQL query string.
1158///
1159/// Resolves the document base IRI, document-level prefixes, and the
1160/// `sh:prefixes` / `sh:declare` chains (following `owl:imports`) declared on
1161/// `owner`, parses `raw`, and returns the parsed query together with its
1162/// canonical string form. `Query::to_string` expands prefix names, so the
1163/// result is self-contained and can be reparsed without external declarations.
1164///
1165/// Errors are returned as messages so callers can decide how to surface them:
1166/// the lowerer routes them to diagnostics; the report validator drops the
1167/// offending constraint, matching the lowering path.
1168pub fn canonical_sparql_query(
1169    g: &Loaded,
1170    owner: &NamedOrBlankNode,
1171    raw: &str,
1172) -> Result<(Query, String), String> {
1173    let mut parser = SparqlParser::new();
1174    if let Some(base) = &g.base {
1175        parser = parser
1176            .with_base_iri(base)
1177            .map_err(|e| format!("invalid SPARQL base IRI: {e}"))?;
1178    }
1179    for (prefix, namespace) in &g.prefixes {
1180        parser = parser
1181            .with_prefix(prefix, namespace)
1182            .map_err(|e| format!("invalid SPARQL prefix declaration {prefix}: {e}"))?;
1183    }
1184    let mut prefix_sources: Vec<NamedOrBlankNode> = g
1185        .objects(owner, vocab::SH_PREFIXES)
1186        .iter()
1187        .filter_map(term_to_node)
1188        .collect();
1189    let mut seen_sources = HashSet::new();
1190    while let Some(source) = prefix_sources.pop() {
1191        if !seen_sources.insert(source.clone()) {
1192            continue;
1193        }
1194        prefix_sources.extend(
1195            g.objects(&source, vocab::OWL_IMPORTS)
1196                .iter()
1197                .filter_map(term_to_node),
1198        );
1199        for declaration_term in g.objects(&source, vocab::SH_DECLARE) {
1200            let Some(declaration) = term_to_node(&declaration_term) else {
1201                continue;
1202            };
1203            let (Some(Term::Literal(prefix)), Some(Term::Literal(namespace))) = (
1204                g.object(&declaration, vocab::SH_PREFIX),
1205                g.object(&declaration, vocab::SH_NAMESPACE),
1206            ) else {
1207                continue;
1208            };
1209            parser = parser
1210                .with_prefix(prefix.value(), namespace.value())
1211                .map_err(|e| format!("invalid SHACL SPARQL prefix declaration: {e}"))?;
1212        }
1213    }
1214    let query = parser
1215        .parse_query(raw)
1216        .map_err(|e| format!("invalid SPARQL query: {e}"))?;
1217    let canonical = query.to_string();
1218    Ok((query, canonical))
1219}
1220
1221#[derive(Clone, Copy, PartialEq, Eq)]
1222enum ExpectedQuery {
1223    Select,
1224    Ask,
1225    Construct,
1226    Describe,
1227}
1228
1229impl std::fmt::Display for ExpectedQuery {
1230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1231        f.write_str(match self {
1232            Self::Select => "SELECT",
1233            Self::Ask => "ASK",
1234            Self::Construct => "CONSTRUCT",
1235            Self::Describe => "DESCRIBE",
1236        })
1237    }
1238}
1239
1240fn class_path() -> Path {
1241    Path::seq(vec![
1242        Path::Pred(vocab::rdf_type()),
1243        Path::star(Path::Pred(vocab::rdfs_subclassof())),
1244    ])
1245}
1246
1247/// Whether a node expression contains a SPARQL function application anywhere in
1248/// its tree. Such expressions cannot yet be evaluated in the validation paths
1249/// (they need shapes-graph function lookups), so expression constraints over
1250/// them are diagnosed rather than lowered.
1251fn node_expr_has_function(e: &NodeExpr) -> bool {
1252    match e {
1253        NodeExpr::Function { .. } => true,
1254        NodeExpr::Filter { input, .. } => node_expr_has_function(input),
1255        NodeExpr::Intersection(es) | NodeExpr::Union(es) => es.iter().any(node_expr_has_function),
1256        NodeExpr::This | NodeExpr::Constant(_) | NodeExpr::Path(_) => false,
1257    }
1258}
1259
1260fn map_node_kind(term: &Term) -> Option<NodeKindSet> {
1261    let Term::NamedNode(n) = term else {
1262        return None;
1263    };
1264    let r = n.as_ref();
1265    Some(if r == vocab::SH_IRI {
1266        NodeKindSet::IRI
1267    } else if r == vocab::SH_BLANK_NODE {
1268        NodeKindSet::BLANK_NODE
1269    } else if r == vocab::SH_LITERAL {
1270        NodeKindSet::LITERAL
1271    } else if r == vocab::SH_BLANK_NODE_OR_IRI {
1272        NodeKindSet::BLANK_NODE_OR_IRI
1273    } else if r == vocab::SH_BLANK_NODE_OR_LITERAL {
1274        NodeKindSet::BLANK_NODE_OR_LITERAL
1275    } else if r == vocab::SH_IRI_OR_LITERAL {
1276        NodeKindSet::IRI_OR_LITERAL
1277    } else {
1278        return None;
1279    })
1280}