Skip to main content

shifty_parse/
lower.rs

1//! Lower a loaded shapes graph into the formalism [`Schema`].
2//!
3//! This is the semantic boundary of the implementation. Above it, SHACL is an
4//! RDF graph: the meaning of one shape may be spread across many triples and
5//! RDF lists, and references may form cycles. Below it, every later phase sees
6//! a small graph of focus-node predicates. Keeping that translation here makes
7//! the evaluator, normalizer, planner, and evidence system independent of the
8//! accidental layout of the source graph.
9//!
10//! Every SHACL Core construct collapses into the small IR, applying the sugar
11//! rules from the gap analysis (`class → path`, `minCount/maxCount → Count`,
12//! per-value constraints wrapped in `∀π = ∃≤0 π.¬φ`, `xone → ∧∨¬`, …). Each
13//! shape lowers to a **focus-node predicate** `φ`, so `sh:property`/`sh:node`
14//! compose by conjunction. Unsupported AF constructs emit diagnostics rather
15//! than becoming an implicit, weaker constraint.
16//!
17//! The lowering pass first discovers deterministic roots and then lowers helper
18//! shapes on demand. It reserves a slot before reading a shape body, which is
19//! sufficient to represent recursive references without a special recursive
20//! AST. The body is assembled as a conjunction, source metadata is attached,
21//! and targets/rules/custom components are derived from the same source map.
22//!
23//! The result keeps two identities: `ShapeId` is the compact execution identity,
24//! while `Schema::sources` points back to the authored RDF resource. Provenance
25//! stays outside the evaluator so the execution IR can remain small and
26//! canonical.
27
28use crate::diagnostics::{DiagLevel, Diagnostic};
29use crate::graph::{Loaded, term_to_node};
30use crate::path::parse_path;
31use crate::vocab;
32use oxrdf::{Literal, NamedNode, NamedOrBlankNode, Term};
33use shifty_algebra::{
34    Bound, NodeExpr, NodeKindSet, Path, Prefixes, Rule, RuleHead, Schema, Selector, Severity,
35    Shape, ShapeArena, ShapeId, SparqlConstraint, SparqlConstruct, SparqlQueryKind, SparqlTarget,
36    Statement, ValueType,
37};
38use spargebra::{Query, SparqlParser, algebra::GraphPattern};
39use std::collections::{BTreeSet, HashMap, HashSet};
40
41pub struct Lowered {
42    /// The self-contained algebraic schema. It contains only finalized arena
43    /// slots: callers never need to understand the temporary recursion markers
44    /// used while lowering.
45    pub schema: Schema,
46    /// Non-fatal information about the source graph. Keeping diagnostics beside
47    /// the usable schema lets command-line users choose a best-effort workflow
48    /// while bindings can enforce a stricter policy at their API boundary.
49    pub diagnostics: Vec<Diagnostic>,
50}
51
52/// Lower a loaded graph into a schema plus diagnostics.
53pub fn lower(g: &Loaded) -> Lowered {
54    let mut l = Lowerer {
55        g,
56        arena: ShapeArena::new(),
57        cache: HashMap::new(),
58        statements: Vec::new(),
59        rules: Vec::new(),
60        diags: Vec::new(),
61    };
62    // Discover roots first, then lower references on demand. This avoids a
63    // whole-graph "is this shape reachable?" pre-pass while still ensuring that
64    // a shape mentioned only by `sh:node` or `sh:property` receives a slot.
65    let shapes = l.discover_shapes();
66    for s in &shapes {
67        l.lower_shape(s);
68    }
69    l.lower_custom_components(&shapes);
70    for s in &shapes {
71        // selectors are shared by the shape's statements and its rules
72        let selectors = l.target_selectors(s);
73        if let Some(shape) = l.cache.get(s).copied() {
74            for sel in &selectors {
75                l.statements.push(Statement {
76                    selector: sel.clone(),
77                    shape,
78                });
79            }
80        }
81        l.parse_rules(s, &selectors);
82    }
83    // Every shape lowered from an RDF node (named or blank) is keyed by that
84    // node in `cache`; `id` is the node's outermost slot (the `Annotated`
85    // wrapper `lower_shape` sets last), which is what progress children and
86    // catalog entries reference. Source identity deliberately lives alongside
87    // the optimized algebra rather than in `Shape`: normalization is free to
88    // merge equal bodies without losing the authored node a UI needs to name.
89    let sources = l
90        .cache
91        .iter()
92        .map(|(node, id)| (*id, Term::from(node.clone())))
93        .collect();
94    let mut schema = Schema {
95        arena: l.arena,
96        statements: l.statements,
97        rules: l.rules,
98        names: Default::default(),
99        sources,
100        // The document's own vocabulary, carried along so reports can compact
101        // IRIs the way the author wrote them.
102        prefixes: Prefixes::new(g.prefixes.iter().cloned()),
103    };
104    for (node, id) in &l.cache {
105        if let NamedOrBlankNode::NamedNode(n) = node {
106            schema.add_name(*id, n.as_str().to_string());
107        }
108    }
109    schema.arena.debug_assert_finalized();
110    Lowered {
111        schema,
112        diagnostics: l.diags,
113    }
114}
115
116struct Lowerer<'a> {
117    g: &'a Loaded,
118    arena: ShapeArena,
119    /// Source RDF node → *outer* arena slot. Inserting a reserved slot before
120    /// reading the body is the recursion knot: a recursive reference observes
121    /// the same id immediately, and the caller fills it exactly once at the
122    /// end. This keeps cyclic SHACL shapes a graph rather than forcing a
123    /// special recursive AST throughout the rest of the program.
124    cache: HashMap<NamedOrBlankNode, ShapeId>,
125    statements: Vec<Statement>,
126    rules: Vec<Rule>,
127    diags: Vec<Diagnostic>,
128}
129
130struct ComponentDef {
131    iri: NamedNode,
132    params: Vec<ParamDef>,
133    node_validator: Option<ValidatorDef>,
134    property_validator: Option<ValidatorDef>,
135    generic_validator: Option<ValidatorDef>,
136}
137
138struct ParamDef {
139    path: NamedNode,
140    var: String,
141    optional: bool,
142}
143
144struct ValidatorDef {
145    kind: SparqlQueryKind,
146    query: String,
147    messages: Vec<Term>,
148}
149
150/// The local name of an IRI (after the last `#` or `/`) — the SHACL rule for a
151/// parameter's pre-bound variable name (SHACL §6.2.1).
152fn local_name(iri: &str) -> &str {
153    iri.rsplit(['#', '/']).next().unwrap_or(iri)
154}
155
156impl Lowerer<'_> {
157    fn diag(&mut self, level: DiagLevel, msg: impl Into<String>, subj: &NamedOrBlankNode) {
158        self.diags
159            .push(Diagnostic::new(level, msg, Some(subj.to_string())));
160    }
161
162    /// Subjects that are declared shapes: typed NodeShape/PropertyShape, or
163    /// carrying `sh:path` or a target predicate. Referenced-only shapes are
164    /// pulled in on demand during lowering. Sorted for deterministic output.
165    fn discover_shapes(&self) -> Vec<NamedOrBlankNode> {
166        let mut found: HashSet<NamedOrBlankNode> = HashSet::new();
167        for triple in self.g.graph.iter() {
168            let p = triple.predicate;
169            let is_target = p == vocab::SH_TARGET_NODE
170                || p == vocab::SH_TARGET_CLASS
171                || p == vocab::SH_TARGET_SUBJECTS_OF
172                || p == vocab::SH_TARGET_OBJECTS_OF
173                || p == vocab::SH_TARGET;
174            if p == vocab::SH_PATH || p == vocab::SH_SPARQL || p == vocab::SH_RULE || is_target {
175                found.insert(triple.subject.into_owned());
176            }
177            if p == vocab::RDF_TYPE
178                && let Term::NamedNode(ty) = triple.object.into_owned()
179                && (ty.as_ref() == vocab::SH_NODE_SHAPE || ty.as_ref() == vocab::SH_PROPERTY_SHAPE)
180            {
181                found.insert(triple.subject.into_owned());
182            }
183        }
184        let mut shapes: Vec<NamedOrBlankNode> = found.into_iter().collect();
185        shapes.sort_by_key(|n| n.to_string());
186        shapes
187    }
188
189    /// Lower one source shape to its outer `Annotated` slot.
190    ///
191    /// The method has two layers on purpose: the outer wrapper preserves source
192    /// severity/messages and stable identity, while `body` holds the logical
193    /// predicate. Optimizers may reason about the body without having to know
194    /// SHACL reporting metadata, yet the metadata survives all the way to a
195    /// violation. Reserving `id` before descending also gives the recursive
196    /// case the same interface as the non-recursive case.
197    fn lower_shape(&mut self, s: &NamedOrBlankNode) -> ShapeId {
198        if let Some(id) = self.cache.get(s) {
199            return *id;
200        }
201        let id = self.arena.reserve();
202        self.cache.insert(s.clone(), id);
203
204        if self.bool_prop(s, vocab::SH_DEACTIVATED) {
205            self.arena.set(id, Shape::Top);
206            return id;
207        }
208
209        let path = self.parse_shape_path(s);
210        let mut conjuncts: Vec<ShapeId> = Vec::new();
211
212        // Value-scoped constraints: each applies to every value node along the
213        // path (or to the focus node directly when there is no path). The
214        // universal is represented using Count rather than introducing a second
215        // evaluator operator. That is the key abstraction: one qualified-count
216        // implementation covers cardinality, nested property shapes, and every
217        // per-value SHACL constraint.
218        let value = self.collect_value_constraints(s);
219        if !value.is_empty() {
220            let value_phi = self.arena.and(value);
221            match &path {
222                Some(p) => {
223                    // ∀π.φ  ≡  ∃≤0 π.¬φ
224                    let neg = self.arena.not(value_phi);
225                    let c = self.arena.count(p.clone(), None, Some(0), neg);
226                    conjuncts.push(c);
227                }
228                None => conjuncts.push(value_phi),
229            }
230        }
231
232        self.collect_path_constraints(s, path.as_ref(), &mut conjuncts);
233
234        if self.bool_prop(s, vocab::SH_CLOSED) {
235            let q = self.closed_allowed(s);
236            let c = self.arena.insert(Shape::Closed(q));
237            conjuncts.push(c);
238        }
239
240        let sparql_prebound = vec!["this", "shapesGraph", "currentShape"];
241        for constraint_term in self.g.objects(s, vocab::SH_SPARQL) {
242            let Some(constraint_node) = term_to_node(&constraint_term) else {
243                self.diag(DiagLevel::Error, "sh:sparql must reference a resource", s);
244                continue;
245            };
246            let parsed = if let Some(Term::Literal(query)) =
247                self.g.object(&constraint_node, vocab::SH_SELECT)
248            {
249                self.canonical_shacl_sparql(
250                    &constraint_node,
251                    query.value(),
252                    ExpectedQuery::Select,
253                    &sparql_prebound,
254                )
255                .map(|query| (SparqlQueryKind::Select, query))
256            } else if let Some(Term::Literal(query)) =
257                self.g.object(&constraint_node, vocab::SH_ASK)
258            {
259                self.canonical_shacl_sparql(
260                    &constraint_node,
261                    query.value(),
262                    ExpectedQuery::Ask,
263                    &sparql_prebound,
264                )
265                .map(|query| (SparqlQueryKind::Ask, query))
266            } else {
267                self.diag(
268                    DiagLevel::Error,
269                    "sh:sparql constraint requires sh:select or sh:ask",
270                    &constraint_node,
271                );
272                None
273            };
274            if let Some((kind, query)) = parsed {
275                let shape = Some(match s {
276                    NamedOrBlankNode::NamedNode(n) => Term::NamedNode(n.clone()),
277                    NamedOrBlankNode::BlankNode(b) => Term::BlankNode(b.clone()),
278                });
279                // `sh:message` on the SPARQL constraint takes precedence; absent
280                // that, fall back to the owning shape's `sh:message` (SHACL §5.2.1).
281                let mut messages: Vec<Term> = self.g.objects(&constraint_node, vocab::SH_MESSAGE);
282                if messages.is_empty() {
283                    messages = self.g.objects(s, vocab::SH_MESSAGE);
284                }
285                let constraint = SparqlConstraint {
286                    kind,
287                    query,
288                    path: path.clone(),
289                    shape,
290                    messages,
291                    extra_bindings: Vec::new(),
292                    bind_value_to_this: false,
293                };
294                conjuncts.push(self.arena.insert(Shape::Sparql(constraint)));
295            }
296        }
297
298        // sh:expression (SHACL-AF §5): the node expression must evaluate to
299        // `true` with the focus node as `?this`. Focus-scoped, so it joins the
300        // conjuncts directly rather than under a `∀π` wrapper.
301        for expr_term in self.g.objects(s, vocab::SH_EXPRESSION) {
302            if let Some(expr) = self.parse_node_expr(expr_term, s) {
303                if node_expr_has_function(&expr) {
304                    // SPARQL functions inside expressions need shapes-graph
305                    // lookups the validation evaluators don't perform; refuse
306                    // rather than silently under-constrain.
307                    self.diag(
308                        DiagLevel::Unsupported,
309                        "sh:expression with a function call is not yet evaluated",
310                        s,
311                    );
312                } else {
313                    conjuncts.push(self.arena.insert(Shape::Expression(expr)));
314                }
315            }
316        }
317
318        // Do not leave a self-reference exposed as a one-child body. A shape
319        // that contains only a recursive reference is a valid cycle; a shape
320        // that accidentally reaches its own reserved id while otherwise empty
321        // is equivalent to Top at this layer. `Pending` must not escape, since
322        // later passes intentionally treat a finalized arena as immutable.
323        let body = if conjuncts.is_empty() {
324            self.arena.top()
325        } else if conjuncts.len() == 1 {
326            if conjuncts[0] == id {
327                self.arena.top()
328            } else {
329                conjuncts[0]
330            }
331        } else {
332            self.arena.insert(Shape::And(conjuncts))
333        };
334        self.arena.set(
335            id,
336            Shape::Annotated {
337                severity: self.severity(s),
338                messages: self.messages(s),
339                shape: body,
340            },
341        );
342        id
343    }
344
345    fn severity(&self, shape: &NamedOrBlankNode) -> Severity {
346        match self.g.object(shape, vocab::SH_SEVERITY) {
347            Some(Term::NamedNode(value)) => Severity::from_named_node(value),
348            _ => Severity::Violation,
349        }
350    }
351
352    /// Author-supplied `sh:message`(s) on the source shape, kept for reporting.
353    fn messages(&self, shape: &NamedOrBlankNode) -> std::sync::Arc<[Term]> {
354        self.g.objects(shape, vocab::SH_MESSAGE).into()
355    }
356
357    /// Collect constraints whose subject is the current value node.
358    ///
359    /// A property shape uses this exact function too. Its caller supplies the
360    /// `∀path` wrapper, so the constraint definitions below do not branch on
361    /// "node shape versus property shape". That separation is important: it
362    /// makes a referenced property shape behave identically whether it appears
363    /// as a top-level statement or underneath another property shape.
364    fn collect_value_constraints(&mut self, s: &NamedOrBlankNode) -> Vec<ShapeId> {
365        let mut value: Vec<ShapeId> = Vec::new();
366
367        // sh:class C  ≡  ∃≥1 (rdf:type/rdfs:subClassOf*) . test(C)
368        for c in self.g.objects(s, vocab::SH_CLASS) {
369            let tn = self.arena.insert(Shape::TestConst(c));
370            let cc = self.arena.count(class_path(), Some(1), None, tn);
371            value.push(cc);
372        }
373
374        // sh:datatype
375        for d in self.g.objects(s, vocab::SH_DATATYPE) {
376            if let Term::NamedNode(n) = d {
377                let id = self.arena.insert(Shape::TestType(ValueType::Datatype(n)));
378                value.push(id);
379            }
380        }
381
382        // sh:nodeKind
383        for k in self.g.objects(s, vocab::SH_NODE_KIND) {
384            if let Some(set) = map_node_kind(&k) {
385                let id = self.arena.insert(Shape::TestKind(set));
386                value.push(id);
387            } else {
388                self.diag(DiagLevel::Warning, "unrecognized sh:nodeKind value", s);
389            }
390        }
391
392        // Numeric bounds are combined into one leaf. Besides avoiding four
393        // repeated literal comparisons at evaluation time, this gives
394        // normalization a single place to detect an empty interval.
395        let lo = self
396            .lit(s, vocab::SH_MIN_INCLUSIVE)
397            .map(|value| Bound {
398                value,
399                inclusive: true,
400            })
401            .or_else(|| {
402                self.lit(s, vocab::SH_MIN_EXCLUSIVE).map(|value| Bound {
403                    value,
404                    inclusive: false,
405                })
406            });
407        let hi = self
408            .lit(s, vocab::SH_MAX_INCLUSIVE)
409            .map(|value| Bound {
410                value,
411                inclusive: true,
412            })
413            .or_else(|| {
414                self.lit(s, vocab::SH_MAX_EXCLUSIVE).map(|value| Bound {
415                    value,
416                    inclusive: false,
417                })
418            });
419        if lo.is_some() || hi.is_some() {
420            let id = self
421                .arena
422                .insert(Shape::TestType(ValueType::NumericRange { lo, hi }));
423            value.push(id);
424        }
425
426        // length
427        let min_len = self.int(s, vocab::SH_MIN_LENGTH);
428        let max_len = self.int(s, vocab::SH_MAX_LENGTH);
429        if min_len.is_some() || max_len.is_some() {
430            let id = self.arena.insert(Shape::TestType(ValueType::Length {
431                min: min_len,
432                max: max_len,
433            }));
434            value.push(id);
435        }
436
437        // pattern (+ flags)
438        let flags = self
439            .lit(s, vocab::SH_FLAGS)
440            .map(|l| l.value().to_string())
441            .unwrap_or_default();
442        for pat in self.g.objects(s, vocab::SH_PATTERN) {
443            if let Term::Literal(l) = pat {
444                let id = self.arena.insert(Shape::TestType(ValueType::Pattern {
445                    regex: l.value().to_string(),
446                    flags: flags.clone(),
447                }));
448                value.push(id);
449            }
450        }
451
452        // sh:languageIn
453        for li in self.g.objects(s, vocab::SH_LANGUAGE_IN) {
454            let langs: Vec<String> = self
455                .g
456                .read_list(&li)
457                .into_iter()
458                .filter_map(|m| match m {
459                    Term::Literal(l) => Some(l.value().to_string()),
460                    _ => None,
461                })
462                .collect();
463            let id = self.arena.insert(Shape::TestType(ValueType::LangIn(langs)));
464            value.push(id);
465        }
466
467        // `sh:in` is a finite disjunction. Keeping each member a normal
468        // TestConst leaf means it composes with `sh:not` and CSE without a
469        // bespoke membership operator in the evaluator.
470        for inl in self.g.objects(s, vocab::SH_IN) {
471            let alts: Vec<ShapeId> = self
472                .g
473                .read_list(&inl)
474                .into_iter()
475                .map(|m| self.arena.insert(Shape::TestConst(m)))
476                .collect();
477            let or = self.arena.or(alts);
478            value.push(or);
479        }
480
481        // `sh:node` — each value node must conform to the referenced shape.
482        // `lower_shape` returns a slot immediately even for a cycle, so no
483        // special recursion case leaks into the lowering rules.
484        for n in self.g.objects(s, vocab::SH_NODE) {
485            if let Some(nn) = term_to_node(&n) {
486                let id = self.lower_shape(&nn);
487                value.push(id);
488            }
489        }
490
491        // sh:property — like sh:node, each *value node* must conform to the
492        // referenced property shape (so on a property shape it is scoped under
493        // ∀path, not applied to the focus node directly).
494        for prop in self.g.objects(s, vocab::SH_PROPERTY) {
495            if let Some(pn) = term_to_node(&prop) {
496                let id = self.lower_shape(&pn);
497                value.push(id);
498            }
499        }
500
501        // sh:not
502        for n in self.g.objects(s, vocab::SH_NOT) {
503            if let Some(nn) = term_to_node(&n) {
504                let id = self.lower_shape(&nn);
505                let neg = self.arena.not(id);
506                value.push(neg);
507            }
508        }
509
510        // sh:and / sh:or / sh:xone (each object is an rdf:list of shapes)
511        for l in self.g.objects(s, vocab::SH_AND) {
512            let ids = self.lower_shape_list(&l);
513            let a = self.arena.and(ids);
514            value.push(a);
515        }
516        for l in self.g.objects(s, vocab::SH_OR) {
517            let ids = self.lower_shape_list(&l);
518            let o = self.arena.or(ids);
519            value.push(o);
520        }
521        for l in self.g.objects(s, vocab::SH_XONE) {
522            let ids = self.lower_shape_list(&l);
523            let x = self.arena.xone(ids);
524            value.push(x);
525        }
526
527        value
528    }
529
530    /// Path-level constraints (cardinality, qualified counts, property pairs,
531    /// hasValue, uniqueLang). Most require a path; without one they are ignored
532    /// with a diagnostic, except `sh:hasValue` which applies to the focus node.
533    fn collect_path_constraints(
534        &mut self,
535        s: &NamedOrBlankNode,
536        path: Option<&Path>,
537        conjuncts: &mut Vec<ShapeId>,
538    ) {
539        let need_path = |me: &mut Self, what: &str| {
540            me.diag(DiagLevel::Warning, format!("{what} ignored: no sh:path"), s);
541        };
542
543        let min_count = self.int(s, vocab::SH_MIN_COUNT);
544        let max_count = self.int(s, vocab::SH_MAX_COUNT);
545        if min_count.is_some() || max_count.is_some() {
546            match path {
547                Some(p) => {
548                    let top = self.arena.top();
549                    let c = self.arena.count(p.clone(), min_count, max_count, top);
550                    conjuncts.push(c);
551                }
552                None => need_path(self, "sh:minCount/sh:maxCount"),
553            }
554        }
555
556        // sh:hasValue
557        for v in self.g.objects(s, vocab::SH_HAS_VALUE) {
558            match path {
559                Some(p) => {
560                    let tc = self.arena.insert(Shape::TestConst(v));
561                    let c = self.arena.count(p.clone(), Some(1), None, tc);
562                    conjuncts.push(c);
563                }
564                None => {
565                    let tc = self.arena.insert(Shape::TestConst(v));
566                    conjuncts.push(tc);
567                }
568            }
569        }
570
571        // sh:qualifiedValueShape + qualifiedMin/MaxCount
572        for q in self.g.objects(s, vocab::SH_QUALIFIED_VALUE_SHAPE) {
573            if let Some(qn) = term_to_node(&q) {
574                let qmin = self.int(s, vocab::SH_QUALIFIED_MIN_COUNT);
575                let qmax = self.int(s, vocab::SH_QUALIFIED_MAX_COUNT);
576                match path {
577                    Some(p) => {
578                        let mut qualifiers = vec![self.lower_shape(&qn)];
579                        if self.bool_prop(s, vocab::SH_QUALIFIED_VALUE_SHAPES_DISJOINT) {
580                            for sibling in self.sibling_qualified_shapes(s, &qn) {
581                                let sibling = self.lower_shape(&sibling);
582                                qualifiers.push(self.arena.not(sibling));
583                            }
584                        }
585                        let qualifier = self.arena.and(qualifiers);
586                        let c = self.arena.count(p.clone(), qmin, qmax, qualifier);
587                        conjuncts.push(c);
588                    }
589                    None => need_path(self, "sh:qualifiedValueShape"),
590                }
591            }
592        }
593
594        // property-pair constraints
595        let pairs = [
596            (vocab::SH_EQUALS, "equals"),
597            (vocab::SH_DISJOINT, "disjoint"),
598            (vocab::SH_LESS_THAN, "lessThan"),
599            (vocab::SH_LESS_THAN_OR_EQUALS, "lessThanOrEquals"),
600        ];
601        for (pred, name) in pairs {
602            for other in self.g.objects(s, pred) {
603                let Term::NamedNode(op) = other else { continue };
604                match path {
605                    Some(p) => {
606                        let shape = match name {
607                            "equals" => Shape::Eq(p.clone(), op),
608                            "disjoint" => Shape::Disj(p.clone(), op),
609                            "lessThan" => Shape::Lt(p.clone(), op),
610                            _ => Shape::Le(p.clone(), op),
611                        };
612                        let c = self.arena.insert(shape);
613                        conjuncts.push(c);
614                    }
615                    None if matches!(name, "equals" | "disjoint") => {
616                        let shape = if name == "equals" {
617                            Shape::Eq(Path::Id, op)
618                        } else {
619                            Shape::Disj(Path::Id, op)
620                        };
621                        let c = self.arena.insert(shape);
622                        conjuncts.push(c);
623                    }
624                    None => need_path(self, &format!("sh:{name}")),
625                }
626            }
627        }
628
629        // sh:uniqueLang
630        if self.bool_prop(s, vocab::SH_UNIQUE_LANG) {
631            match path {
632                Some(p) => {
633                    let c = self.arena.insert(Shape::UniqueLang(p.clone()));
634                    conjuncts.push(c);
635                }
636                None => need_path(self, "sh:uniqueLang"),
637            }
638        }
639    }
640
641    /// The target selectors of a shape (used by both its statements and rules).
642    fn target_selectors(&mut self, s: &NamedOrBlankNode) -> Vec<Selector> {
643        let mut sels = Vec::new();
644
645        for c in self.g.objects(s, vocab::SH_TARGET_NODE) {
646            sels.push(Selector::IsConst(c));
647        }
648        for c in self.g.objects(s, vocab::SH_TARGET_CLASS) {
649            sels.push(self.class_selector(c));
650        }
651        for p in self.g.objects(s, vocab::SH_TARGET_SUBJECTS_OF) {
652            if let Term::NamedNode(n) = p {
653                sels.push(Selector::HasOut(n));
654            }
655        }
656        for p in self.g.objects(s, vocab::SH_TARGET_OBJECTS_OF) {
657            if let Term::NamedNode(n) = p {
658                sels.push(Selector::HasIn(n));
659            }
660        }
661
662        // implicit class target: a shape that is also an rdfs:Class / owl:Class
663        if (self.g.is_instance_of(s, vocab::RDFS_CLASS)
664            || self.g.is_instance_of(s, vocab::OWL_CLASS))
665            && let NamedOrBlankNode::NamedNode(n) = s
666        {
667            sels.push(self.class_selector(Term::NamedNode(n.clone())));
668        }
669
670        for target_term in self.g.objects(s, vocab::SH_TARGET) {
671            let Some(target_node) = term_to_node(&target_term) else {
672                self.diag(DiagLevel::Error, "sh:target must reference a resource", s);
673                continue;
674            };
675            match self.g.object(&target_node, vocab::SH_SELECT) {
676                Some(Term::Literal(query)) => {
677                    if let Some(query) =
678                        self.canonical_sparql(&target_node, query.value(), ExpectedQuery::Select)
679                    {
680                        sels.push(Selector::Sparql(SparqlTarget { query }));
681                    }
682                }
683                _ => self.diag(
684                    DiagLevel::Unsupported,
685                    "custom sh:target without sh:select is not yet lowered",
686                    &target_node,
687                ),
688            }
689        }
690
691        sels
692    }
693
694    /// Lower SPARQL-based custom constraint components (SHACL §6.3) into the
695    /// algebra. For each shape that *activates* a component (supplies a value for
696    /// every mandatory parameter), a `Shape::Sparql` node is appended to that
697    /// shape's conjunction. The parameter values from the activating shape are
698    /// stored as `extra_bindings` so `compile_constraint` can pre-substitute them
699    /// (§6.2.3) before binding `$this` per focus node.
700    ///
701    /// Components with an invalid SPARQL validator query emit `DiagLevel::Error`
702    /// and are skipped (the constraint is not enforced for that activation).
703    fn lower_custom_components(&mut self, shapes: &[NamedOrBlankNode]) {
704        let components = self.collect_component_defs();
705        if components.is_empty() {
706            return;
707        }
708        for s in shapes {
709            let Some(&shape_id) = self.cache.get(s) else {
710                continue;
711            };
712            let is_property_shape = self.g.object(s, vocab::SH_PATH).is_some();
713            let path = if is_property_shape {
714                self.parse_shape_path(s)
715            } else {
716                None
717            };
718            let shape_term = match s {
719                NamedOrBlankNode::NamedNode(n) => Some(Term::NamedNode(n.clone())),
720                NamedOrBlankNode::BlankNode(b) => Some(Term::BlankNode(b.clone())),
721            };
722
723            for component in &components {
724                // Activation: every mandatory parameter must be present on `s`.
725                let mut extra_bindings: Vec<(String, Term)> = Vec::new();
726                let mut activated = true;
727                for p in &component.params {
728                    if let Some(value) = self.g.object(s, p.path.as_ref()) {
729                        extra_bindings.push((p.var.clone(), value));
730                    } else if !p.optional {
731                        activated = false;
732                        break;
733                    }
734                }
735                if !activated {
736                    continue;
737                }
738
739                // Pick the right validator for the shape kind.
740                let validator = if is_property_shape {
741                    component
742                        .property_validator
743                        .as_ref()
744                        .or(component.generic_validator.as_ref())
745                } else {
746                    component
747                        .node_validator
748                        .as_ref()
749                        .or(component.generic_validator.as_ref())
750                };
751                let Some(validator) = validator else { continue };
752
753                let constraint = SparqlConstraint {
754                    kind: validator.kind,
755                    query: validator.query.clone(),
756                    // SHACL §6.3 substitutes `$PATH` only for SELECT
757                    // validators applied to property shapes. ASK validators
758                    // receive their value node through the `$value` binding.
759                    path: if validator.kind == SparqlQueryKind::Select {
760                        path.clone()
761                    } else {
762                        None
763                    },
764                    shape: shape_term.clone(),
765                    messages: validator.messages.clone(),
766                    extra_bindings,
767                    // For node-shape activations (no sh:path), $value equals the
768                    // focus node, so rename ?value → ?this before binding per focus.
769                    bind_value_to_this: !is_property_shape
770                        && validator.kind == SparqlQueryKind::Ask,
771                };
772                let raw_id = self.arena.insert(Shape::Sparql(constraint));
773                // sh:SPARQLAskValidator (SHACL §6.2.3): ASK=true means the
774                // constraint is *satisfied*. The algebra evaluator treats
775                // ASK=true as a violation (matching sh:sparql semantics), so
776                // we wrap in Not to restore the correct polarity. SELECT
777                // validators are violation-per-row, same as sh:sparql — no inversion.
778                let sparql_id = if validator.kind == SparqlQueryKind::Ask {
779                    self.arena.not(raw_id)
780                } else {
781                    raw_id
782                };
783
784                // Conjoin with the shape's existing body.
785                let (severity, messages, inner) = match self.arena.get(shape_id) {
786                    Shape::Annotated {
787                        severity,
788                        messages,
789                        shape: inner,
790                    } => (severity.clone(), messages.clone(), *inner),
791                    _ => continue, // defensive; lower_shape always produces Annotated
792                };
793                let combined = self.arena.and(vec![inner, sparql_id]);
794                self.arena.set(
795                    shape_id,
796                    Shape::Annotated {
797                        severity,
798                        messages,
799                        shape: combined,
800                    },
801                );
802            }
803        }
804    }
805
806    /// Collect all custom constraint component definitions from the shapes graph:
807    /// named subjects that carry `sh:parameter` and at least one validator
808    /// predicate (`sh:validator`, `sh:nodeValidator`, `sh:propertyValidator`).
809    fn collect_component_defs(&mut self) -> Vec<ComponentDef> {
810        let mut out: Vec<ComponentDef> = Vec::new();
811        let mut seen = HashSet::new();
812        for triple in self.g.graph.triples_for_predicate(vocab::SH_PARAMETER) {
813            let subject = triple.subject.into_owned();
814            if !seen.insert(subject.clone()) {
815                continue;
816            }
817            let NamedOrBlankNode::NamedNode(iri) = &subject else {
818                continue;
819            };
820            if vocab::NATIVE_CONSTRAINT_COMPONENTS.contains(&iri.as_ref()) {
821                continue; // SHACL Core component; already implemented natively
822            }
823            let mut params: Vec<ParamDef> = Vec::new();
824            for p in self.g.objects(&subject, vocab::SH_PARAMETER) {
825                let Some(pn) = term_to_node(&p) else { continue };
826                let Some(Term::NamedNode(path)) = self.g.object(&pn, vocab::SH_PATH) else {
827                    continue;
828                };
829                let var = local_name(path.as_str()).to_string();
830                let optional = matches!(
831                    self.g.object(&pn, vocab::SH_OPTIONAL),
832                    Some(Term::Literal(ref l)) if l.value() == "true"
833                );
834                params.push(ParamDef {
835                    path,
836                    var,
837                    optional,
838                });
839            }
840            if params.is_empty() {
841                continue;
842            }
843            let mut prebound = vec![
844                "this".to_string(),
845                "shapesGraph".to_string(),
846                "currentShape".to_string(),
847            ];
848            prebound.extend(params.iter().map(|p| p.var.clone()));
849            let node_validator = self
850                .g
851                .object(&subject, vocab::SH_NODE_VALIDATOR)
852                .and_then(|v| self.parse_validator_def(&v, &prebound));
853            let property_validator = self
854                .g
855                .object(&subject, vocab::SH_PROPERTY_VALIDATOR)
856                .and_then(|v| self.parse_validator_def(&v, &prebound));
857            let generic_validator = self
858                .g
859                .object(&subject, vocab::SH_VALIDATOR)
860                .and_then(|v| self.parse_validator_def(&v, &prebound));
861            if node_validator.is_none()
862                && property_validator.is_none()
863                && generic_validator.is_none()
864            {
865                continue; // not a constraint component (e.g. a sh:SPARQLFunction)
866            }
867            out.push(ComponentDef {
868                iri: iri.clone(),
869                params,
870                node_validator,
871                property_validator,
872                generic_validator,
873            });
874        }
875        out.sort_by(|a, b| a.iri.as_str().cmp(b.iri.as_str()));
876        out
877    }
878
879    /// Parse a validator node into a `ValidatorDef`, canonicalizing its SPARQL
880    /// query. Returns `None` (and emits an error diagnostic) if the query is
881    /// invalid SPARQL.
882    fn parse_validator_def(&mut self, node: &Term, prebound: &[String]) -> Option<ValidatorDef> {
883        let node = term_to_node(node)?;
884        let (kind, raw) = if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_ASK) {
885            (SparqlQueryKind::Ask, q.value().to_string())
886        } else if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_SELECT) {
887            (SparqlQueryKind::Select, q.value().to_string())
888        } else {
889            return None; // no query body — not a SPARQL validator
890        };
891        let canonical = match canonical_sparql_query(self.g, &node, &raw).and_then(|(query, c)| {
892            // SHACL §6.3 pre-binds `$value` only for ASK validators.  SELECT
893            // validators may legitimately bind/project `?value` as a result
894            // variable (for example, `SELECT $this ($this AS ?value)`).
895            let mut validator_prebound = prebound.to_vec();
896            if kind == SparqlQueryKind::Ask {
897                validator_prebound.push("value".to_string());
898            }
899            validate_shacl_prebinding(&query, validator_prebound.iter().map(String::as_str))?;
900            Ok(c)
901        }) {
902            Ok(c) => c,
903            Err(msg) => {
904                self.diag(
905                    DiagLevel::Error,
906                    format!("invalid SPARQL in validator: {msg}"),
907                    &node,
908                );
909                return None;
910            }
911        };
912        let messages = self.g.objects(&node, vocab::SH_MESSAGE);
913        Some(ValidatorDef {
914            kind,
915            query: canonical,
916            messages,
917        })
918    }
919
920    /// Lower the `sh:rule`s of a shape (SHACL-AF). A rule fires on the shape's
921    /// targets, so we emit one [`Rule`] per selector.
922    fn parse_rules(&mut self, s: &NamedOrBlankNode, selectors: &[Selector]) {
923        for rule_term in self.g.objects(s, vocab::SH_RULE) {
924            let Some(rn) = term_to_node(&rule_term) else {
925                continue;
926            };
927            let Some(head) = self.parse_rule_head(&rn) else {
928                continue;
929            };
930
931            let conditions: Vec<ShapeId> = self
932                .g
933                .objects(&rn, vocab::SH_CONDITION)
934                .iter()
935                .filter_map(term_to_node)
936                .map(|c| self.lower_shape(&c))
937                .collect();
938            let order = self.order(&rn);
939            let deactivated = self.bool_prop(&rn, vocab::SH_DEACTIVATED);
940
941            for sel in selectors {
942                self.rules.push(Rule {
943                    selector: sel.clone(),
944                    conditions: conditions.clone(),
945                    head: head.clone(),
946                    order,
947                    deactivated,
948                });
949            }
950        }
951    }
952
953    /// Qualified value shapes attached through the same parent `sh:property`
954    /// declaration, excluding the current qualified shape itself.
955    fn sibling_qualified_shapes(
956        &self,
957        shape: &NamedOrBlankNode,
958        qualifier: &NamedOrBlankNode,
959    ) -> Vec<NamedOrBlankNode> {
960        let mut siblings = HashSet::new();
961        for triple in self.g.graph.triples_for_predicate(vocab::SH_PROPERTY) {
962            if term_to_node(&triple.object.into_owned()).as_ref() != Some(shape) {
963                continue;
964            }
965            let parent = triple.subject.into_owned();
966            for property in self.g.objects(&parent, vocab::SH_PROPERTY) {
967                let Some(property) = term_to_node(&property) else {
968                    continue;
969                };
970                for sibling in self.g.objects(&property, vocab::SH_QUALIFIED_VALUE_SHAPE) {
971                    if let Some(sibling) = term_to_node(&sibling) {
972                        siblings.insert(sibling);
973                    }
974                }
975            }
976        }
977        siblings.remove(qualifier);
978        let mut siblings: Vec<_> = siblings.into_iter().collect();
979        siblings.sort_by_key(|node| node.to_string());
980        siblings
981    }
982
983    fn parse_rule_head(&mut self, rn: &NamedOrBlankNode) -> Option<RuleHead> {
984        // sh:SPARQLRule — parse and canonicalize the CONSTRUCT while retaining
985        // an opaque algebra leaf for later query rewriting.
986        if let Some(Term::Literal(q)) = self.g.object(rn, vocab::SH_CONSTRUCT) {
987            let query = self.canonical_sparql(rn, q.value(), ExpectedQuery::Construct)?;
988            return Some(RuleHead::Sparql(SparqlConstruct { query }));
989        }
990        // sh:TripleRule — subject/predicate/object node expressions
991        let (subj, pred, obj) = (
992            self.g.object(rn, vocab::SH_SUBJECT),
993            self.g.object(rn, vocab::SH_PREDICATE),
994            self.g.object(rn, vocab::SH_OBJECT),
995        );
996        if subj.is_none() && pred.is_none() && obj.is_none() {
997            self.diag(DiagLevel::Unsupported, "unrecognized sh:rule head", rn);
998            return None;
999        }
1000        let (Some(subj), Some(pred), Some(obj)) = (subj, pred, obj) else {
1001            self.diag(
1002                DiagLevel::Error,
1003                "sh:TripleRule missing subject/predicate/object",
1004                rn,
1005            );
1006            return None;
1007        };
1008        Some(RuleHead::Triple {
1009            subject: self.parse_node_expr(subj, rn)?,
1010            predicate: self.parse_node_expr(pred, rn)?,
1011            object: self.parse_node_expr(obj, rn)?,
1012        })
1013    }
1014
1015    /// Parse a node expression (SHACL-AF §6). Handles `sh:this`, constants,
1016    /// path expressions, filter / intersection / union expressions, and SPARQL
1017    /// function calls `[ ex:fn (arg …) ]`.
1018    fn parse_node_expr(&mut self, term: Term, owner: &NamedOrBlankNode) -> Option<NodeExpr> {
1019        match &term {
1020            Term::NamedNode(n) if n.as_ref() == vocab::SH_THIS => Some(NodeExpr::This),
1021            Term::NamedNode(_) | Term::Literal(_) => Some(NodeExpr::Constant(term)),
1022            Term::BlankNode(_) => {
1023                let node = term_to_node(&term).expect("blank node");
1024                if let Some(path_term) = self.g.object(&node, vocab::SH_PATH) {
1025                    match parse_path(self.g, &path_term) {
1026                        Ok(path) => Some(NodeExpr::Path(path)),
1027                        Err(e) => {
1028                            self.diag(
1029                                DiagLevel::Error,
1030                                format!("invalid node-expression path: {e}"),
1031                                owner,
1032                            );
1033                            None
1034                        }
1035                    }
1036                } else if self.g.object(&node, vocab::SH_FILTER_SHAPE).is_some() {
1037                    self.parse_filter_expr(&node, owner)
1038                } else if let Some(list) = self.g.object(&node, vocab::SH_INTERSECTION) {
1039                    self.parse_set_expr(&list, owner)
1040                        .map(NodeExpr::Intersection)
1041                } else if let Some(list) = self.g.object(&node, vocab::SH_UNION) {
1042                    self.parse_set_expr(&list, owner).map(NodeExpr::Union)
1043                } else if let Some(expr) = self.try_function_call(&node, owner) {
1044                    Some(expr)
1045                } else {
1046                    self.diag(
1047                        DiagLevel::Unsupported,
1048                        "complex node expression not yet lowered",
1049                        owner,
1050                    );
1051                    None
1052                }
1053            }
1054        }
1055    }
1056
1057    /// `sh:filterShape` + `sh:nodes` — a filter node expression (SHACL-AF §6.4):
1058    /// the value nodes of `sh:nodes` that conform to `sh:filterShape`.
1059    fn parse_filter_expr(
1060        &mut self,
1061        node: &NamedOrBlankNode,
1062        owner: &NamedOrBlankNode,
1063    ) -> Option<NodeExpr> {
1064        let Some(shape_term) = self.g.object(node, vocab::SH_FILTER_SHAPE) else {
1065            self.diag(DiagLevel::Error, "sh:filterShape missing a value", owner);
1066            return None;
1067        };
1068        let Some(shape_node) = term_to_node(&shape_term) else {
1069            self.diag(
1070                DiagLevel::Error,
1071                "sh:filterShape must reference a shape",
1072                owner,
1073            );
1074            return None;
1075        };
1076        let Some(nodes_term) = self.g.object(node, vocab::SH_NODES) else {
1077            self.diag(
1078                DiagLevel::Error,
1079                "filter expression missing sh:nodes",
1080                owner,
1081            );
1082            return None;
1083        };
1084        let input = self.parse_node_expr(nodes_term, owner)?;
1085        let shape = self.lower_shape(&shape_node);
1086        Some(NodeExpr::Filter {
1087            input: Box::new(input),
1088            shape,
1089        })
1090    }
1091
1092    /// Parse the RDF list of an `sh:intersection` / `sh:union` node expression
1093    /// (SHACL-AF §6.5–6.6) into its member node expressions. Returns `None` if
1094    /// the list is empty or any member fails to parse (already diagnosed).
1095    fn parse_set_expr(
1096        &mut self,
1097        list_head: &Term,
1098        owner: &NamedOrBlankNode,
1099    ) -> Option<Vec<NodeExpr>> {
1100        let members = self.g.read_list(list_head);
1101        if members.is_empty() {
1102            self.diag(
1103                DiagLevel::Error,
1104                "sh:intersection/sh:union expects a non-empty list",
1105                owner,
1106            );
1107            return None;
1108        }
1109        let n = members.len();
1110        let exprs: Vec<NodeExpr> = members
1111            .into_iter()
1112            .filter_map(|t| self.parse_node_expr(t, owner))
1113            .collect();
1114        if exprs.len() != n {
1115            return None;
1116        }
1117        Some(exprs)
1118    }
1119
1120    /// Detect a function-call node expression `[ ex:fn ( arg1 arg2 … ) ]`.
1121    ///
1122    /// The blank node must have exactly one non-SHACL/RDF/RDFS/OWL predicate;
1123    /// its object must be an RDF list of argument node expressions.
1124    fn try_function_call(
1125        &mut self,
1126        node: &NamedOrBlankNode,
1127        owner: &NamedOrBlankNode,
1128    ) -> Option<NodeExpr> {
1129        let func_preds: Vec<(NamedNode, Term)> = self
1130            .g
1131            .graph
1132            .triples_for_subject(node)
1133            .map(|t| (t.predicate.into_owned(), t.object.into_owned()))
1134            .filter(|(p, _)| {
1135                let s = p.as_str();
1136                !s.starts_with(vocab::SH)
1137                    && !s.starts_with(vocab::RDF)
1138                    && !s.starts_with(vocab::RDFS)
1139                    && !s.starts_with(vocab::OWL)
1140            })
1141            .collect();
1142
1143        if func_preds.len() != 1 {
1144            return None;
1145        }
1146        let (func_iri, list_head) = func_preds.into_iter().next().unwrap();
1147        let arg_terms = self.g.read_list(&list_head);
1148        let n = arg_terms.len();
1149        let args: Vec<NodeExpr> = arg_terms
1150            .into_iter()
1151            .filter_map(|t| self.parse_node_expr(t, owner))
1152            .collect();
1153        if args.len() != n {
1154            return None;
1155        }
1156        Some(NodeExpr::Function {
1157            iri: func_iri,
1158            args,
1159        })
1160    }
1161
1162    fn order(&self, s: &NamedOrBlankNode) -> Option<i64> {
1163        match self.g.object(s, vocab::SH_ORDER) {
1164            Some(Term::Literal(l)) => l.value().parse().ok(),
1165            _ => None,
1166        }
1167    }
1168
1169    /// `∃≥1 (rdf:type/rdfs:subClassOf*) . test(class)` as a selector.
1170    fn class_selector(&mut self, class: Term) -> Selector {
1171        let tn = self.arena.insert(Shape::TestConst(class));
1172        Selector::HasPath(class_path(), tn)
1173    }
1174
1175    fn lower_shape_list(&mut self, list_head: &Term) -> Vec<ShapeId> {
1176        self.g
1177            .read_list(list_head)
1178            .into_iter()
1179            .filter_map(|m| term_to_node(&m))
1180            .map(|n| self.lower_shape(&n))
1181            .collect()
1182    }
1183
1184    fn parse_shape_path(&mut self, s: &NamedOrBlankNode) -> Option<Path> {
1185        let term = self.g.object(s, vocab::SH_PATH)?;
1186        match parse_path(self.g, &term) {
1187            Ok(p) => Some(p),
1188            Err(e) => {
1189                self.diag(DiagLevel::Error, format!("invalid sh:path: {e}"), s);
1190                None
1191            }
1192        }
1193    }
1194
1195    fn closed_allowed(&self, s: &NamedOrBlankNode) -> BTreeSet<oxrdf::NamedNode> {
1196        let mut q = BTreeSet::new();
1197        for prop in self.g.objects(s, vocab::SH_PROPERTY) {
1198            if let Some(pn) = term_to_node(&prop)
1199                && let Some(Term::NamedNode(n)) = self.g.object(&pn, vocab::SH_PATH)
1200            {
1201                q.insert(n);
1202            }
1203        }
1204        for ip in self.g.objects(s, vocab::SH_IGNORED_PROPERTIES) {
1205            for m in self.g.read_list(&ip) {
1206                if let Term::NamedNode(n) = m {
1207                    q.insert(n);
1208                }
1209            }
1210        }
1211        q
1212    }
1213
1214    fn bool_prop(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> bool {
1215        matches!(self.g.object(s, pred), Some(Term::Literal(l)) if l.value() == "true")
1216    }
1217
1218    fn int(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<u64> {
1219        match self.g.object(s, pred) {
1220            Some(Term::Literal(l)) => l.value().parse().ok(),
1221            _ => None,
1222        }
1223    }
1224
1225    fn lit(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<Literal> {
1226        match self.g.object(s, pred) {
1227            Some(Term::Literal(l)) => Some(l),
1228            _ => None,
1229        }
1230    }
1231
1232    /// Parse a SHACL SPARQL query once, resolving both document prefixes and
1233    /// `sh:prefixes` declarations. `Query::to_string` expands prefix names, so
1234    /// the IR remains self-contained and can be reparsed or rewritten later.
1235    fn canonical_sparql(
1236        &mut self,
1237        owner: &NamedOrBlankNode,
1238        raw: &str,
1239        expected: ExpectedQuery,
1240    ) -> Option<String> {
1241        let (query, canonical) = match canonical_sparql_query(self.g, owner, raw) {
1242            Ok(result) => result,
1243            Err(message) => {
1244                self.diag(DiagLevel::Error, message, owner);
1245                return None;
1246            }
1247        };
1248        let actual = match &query {
1249            Query::Select { .. } => ExpectedQuery::Select,
1250            Query::Ask { .. } => ExpectedQuery::Ask,
1251            Query::Construct { .. } => ExpectedQuery::Construct,
1252            Query::Describe { .. } => ExpectedQuery::Describe,
1253        };
1254        if actual != expected {
1255            self.diag(
1256                DiagLevel::Error,
1257                format!("expected SPARQL {expected}, found {actual}"),
1258                owner,
1259            );
1260            return None;
1261        }
1262        Some(canonical)
1263    }
1264
1265    /// Parse and validate a query that is evaluated with SHACL pre-bound
1266    /// variables. The SPARQL grammar accepts several constructs whose
1267    /// interaction with pre-binding is explicitly forbidden by SHACL 1.0.
1268    fn canonical_shacl_sparql(
1269        &mut self,
1270        owner: &NamedOrBlankNode,
1271        raw: &str,
1272        expected: ExpectedQuery,
1273        prebound: &[&str],
1274    ) -> Option<String> {
1275        let (query, canonical) = match canonical_sparql_query(self.g, owner, raw) {
1276            Ok(result) => result,
1277            Err(message) => {
1278                self.diag(DiagLevel::Error, message, owner);
1279                return None;
1280            }
1281        };
1282        let actual = match &query {
1283            Query::Select { .. } => ExpectedQuery::Select,
1284            Query::Ask { .. } => ExpectedQuery::Ask,
1285            Query::Construct { .. } => ExpectedQuery::Construct,
1286            Query::Describe { .. } => ExpectedQuery::Describe,
1287        };
1288        if actual != expected {
1289            self.diag(
1290                DiagLevel::Error,
1291                format!("expected SPARQL {expected}, found {actual}"),
1292                owner,
1293            );
1294            return None;
1295        }
1296        if let Err(message) = validate_shacl_prebinding(&query, prebound.iter().copied()) {
1297            self.diag(DiagLevel::Error, message, owner);
1298            return None;
1299        }
1300        Some(canonical)
1301    }
1302}
1303
1304/// Enforce the SHACL 1.0 restrictions needed to make pre-binding well-defined.
1305fn validate_shacl_prebinding<'a>(
1306    query: &Query,
1307    prebound: impl IntoIterator<Item = &'a str>,
1308) -> Result<(), String> {
1309    let prebound: HashSet<&str> = prebound.into_iter().collect();
1310    let pattern = match query {
1311        Query::Select { pattern, .. }
1312        | Query::Ask { pattern, .. }
1313        | Query::Construct { pattern, .. }
1314        | Query::Describe { pattern, .. } => pattern,
1315    };
1316    let mut in_scope = HashSet::new();
1317    pattern.on_in_scope_variable(|variable| {
1318        in_scope.insert(variable.as_str());
1319    });
1320    let projection_prebound: HashSet<&str> = prebound
1321        .iter()
1322        .copied()
1323        .filter(|name| matches!(*name, "this" | "value" | "PATH") && in_scope.contains(name))
1324        .collect();
1325
1326    fn visit(
1327        pattern: &GraphPattern,
1328        prebound: &HashSet<&str>,
1329        projection_prebound: &HashSet<&str>,
1330        seen_top_projection: bool,
1331    ) -> Result<(), String> {
1332        match pattern {
1333            GraphPattern::Minus { .. } => {
1334                return Err("SHACL-SPARQL queries must not contain MINUS".into());
1335            }
1336            GraphPattern::Service { .. } => {
1337                return Err("SHACL-SPARQL queries must not contain SERVICE".into());
1338            }
1339            GraphPattern::Values { .. } => {
1340                return Err("SHACL-SPARQL queries must not contain VALUES".into());
1341            }
1342            GraphPattern::Extend {
1343                inner, variable, ..
1344            } => {
1345                if prebound.contains(variable.as_str()) {
1346                    return Err(format!(
1347                        "SHACL-SPARQL must not assign potentially pre-bound variable ?{} with AS",
1348                        variable.as_str()
1349                    ));
1350                }
1351                visit(inner, prebound, projection_prebound, seen_top_projection)?;
1352            }
1353            GraphPattern::Project { inner, variables } => {
1354                if seen_top_projection {
1355                    let projected: HashSet<&str> = variables.iter().map(|v| v.as_str()).collect();
1356                    let mut missing: Vec<&str> = projection_prebound
1357                        .iter()
1358                        .copied()
1359                        .filter(|name| !projected.contains(name))
1360                        .collect();
1361                    missing.sort_unstable();
1362                    if !missing.is_empty() {
1363                        return Err(format!(
1364                            "SHACL-SPARQL subquery does not project potentially pre-bound variable(s): {}",
1365                            missing
1366                                .into_iter()
1367                                .map(|name| format!("?{name}"))
1368                                .collect::<Vec<_>>()
1369                                .join(", ")
1370                        ));
1371                    }
1372                }
1373                visit(inner, prebound, projection_prebound, true)?;
1374            }
1375            GraphPattern::Join { left, right }
1376            | GraphPattern::LeftJoin { left, right, .. }
1377            | GraphPattern::Union { left, right }
1378            | GraphPattern::Lateral { left, right } => {
1379                visit(left, prebound, projection_prebound, seen_top_projection)?;
1380                visit(right, prebound, projection_prebound, seen_top_projection)?;
1381            }
1382            GraphPattern::Graph { inner, .. }
1383            | GraphPattern::Filter { inner, .. }
1384            | GraphPattern::OrderBy { inner, .. }
1385            | GraphPattern::Distinct { inner }
1386            | GraphPattern::Reduced { inner }
1387            | GraphPattern::Slice { inner, .. }
1388            | GraphPattern::Group { inner, .. } => {
1389                visit(inner, prebound, projection_prebound, seen_top_projection)?;
1390            }
1391            GraphPattern::Bgp { .. } | GraphPattern::Path { .. } => {}
1392        }
1393        Ok(())
1394    }
1395
1396    // We intentionally apply the subquery-projection rule only to SHACL's
1397    // standard pre-bound variables that occur in the query's visible scope.
1398    // This preserves the existing aggregate-subquery extension for custom
1399    // component parameters. AS reassignment remains forbidden for every
1400    // declared parameter through the full `prebound` set.
1401    visit(pattern, &prebound, &projection_prebound, false)
1402}
1403
1404/// Build the canonical, prefix-expanded form of a SHACL SPARQL query string.
1405///
1406/// Resolves the document base IRI, document-level prefixes, and the
1407/// `sh:prefixes` / `sh:declare` chains (following `owl:imports`) declared on
1408/// `owner`, parses `raw`, and returns the parsed query together with its
1409/// canonical string form. `Query::to_string` expands prefix names, so the
1410/// result is self-contained and can be reparsed without external declarations.
1411///
1412/// Errors are returned as messages so callers can decide how to surface them:
1413/// the lowerer routes them to diagnostics; the report validator drops the
1414/// offending constraint, matching the lowering path.
1415pub fn canonical_sparql_query(
1416    g: &Loaded,
1417    owner: &NamedOrBlankNode,
1418    raw: &str,
1419) -> Result<(Query, String), String> {
1420    let mut parser = SparqlParser::new();
1421    if let Some(base) = &g.base {
1422        parser = parser
1423            .with_base_iri(base)
1424            .map_err(|e| format!("invalid SPARQL base IRI: {e}"))?;
1425    }
1426    for (prefix, namespace) in &g.prefixes {
1427        parser = parser
1428            .with_prefix(prefix, namespace)
1429            .map_err(|e| format!("invalid SPARQL prefix declaration {prefix}: {e}"))?;
1430    }
1431    let mut prefix_sources: Vec<NamedOrBlankNode> = g
1432        .objects(owner, vocab::SH_PREFIXES)
1433        .iter()
1434        .filter_map(term_to_node)
1435        .collect();
1436    let mut seen_sources = HashSet::new();
1437    while let Some(source) = prefix_sources.pop() {
1438        if !seen_sources.insert(source.clone()) {
1439            continue;
1440        }
1441        prefix_sources.extend(
1442            g.objects(&source, vocab::OWL_IMPORTS)
1443                .iter()
1444                .filter_map(term_to_node),
1445        );
1446        for declaration_term in g.objects(&source, vocab::SH_DECLARE) {
1447            let Some(declaration) = term_to_node(&declaration_term) else {
1448                continue;
1449            };
1450            let (Some(Term::Literal(prefix)), Some(Term::Literal(namespace))) = (
1451                g.object(&declaration, vocab::SH_PREFIX),
1452                g.object(&declaration, vocab::SH_NAMESPACE),
1453            ) else {
1454                continue;
1455            };
1456            parser = parser
1457                .with_prefix(prefix.value(), namespace.value())
1458                .map_err(|e| format!("invalid SHACL SPARQL prefix declaration: {e}"))?;
1459        }
1460    }
1461    let query = parser
1462        .parse_query(raw)
1463        .map_err(|e| format!("invalid SPARQL query: {e}"))?;
1464    let canonical = query.to_string();
1465    Ok((query, canonical))
1466}
1467
1468#[derive(Clone, Copy, PartialEq, Eq)]
1469enum ExpectedQuery {
1470    Select,
1471    Ask,
1472    Construct,
1473    Describe,
1474}
1475
1476impl std::fmt::Display for ExpectedQuery {
1477    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1478        f.write_str(match self {
1479            Self::Select => "SELECT",
1480            Self::Ask => "ASK",
1481            Self::Construct => "CONSTRUCT",
1482            Self::Describe => "DESCRIBE",
1483        })
1484    }
1485}
1486
1487fn class_path() -> Path {
1488    Path::seq(vec![
1489        Path::Pred(vocab::rdf_type()),
1490        Path::star(Path::Pred(vocab::rdfs_subclassof())),
1491    ])
1492}
1493
1494/// Whether a node expression contains a SPARQL function application anywhere in
1495/// its tree. Such expressions cannot yet be evaluated in the validation paths
1496/// (they need shapes-graph function lookups), so expression constraints over
1497/// them are diagnosed rather than lowered.
1498fn node_expr_has_function(e: &NodeExpr) -> bool {
1499    match e {
1500        NodeExpr::Function { .. } => true,
1501        NodeExpr::Filter { input, .. } => node_expr_has_function(input),
1502        NodeExpr::Intersection(es) | NodeExpr::Union(es) => es.iter().any(node_expr_has_function),
1503        NodeExpr::This | NodeExpr::Constant(_) | NodeExpr::Path(_) => false,
1504    }
1505}
1506
1507fn map_node_kind(term: &Term) -> Option<NodeKindSet> {
1508    let Term::NamedNode(n) = term else {
1509        return None;
1510    };
1511    let r = n.as_ref();
1512    Some(if r == vocab::SH_IRI {
1513        NodeKindSet::IRI
1514    } else if r == vocab::SH_BLANK_NODE {
1515        NodeKindSet::BLANK_NODE
1516    } else if r == vocab::SH_LITERAL {
1517        NodeKindSet::LITERAL
1518    } else if r == vocab::SH_BLANK_NODE_OR_IRI {
1519        NodeKindSet::BLANK_NODE_OR_IRI
1520    } else if r == vocab::SH_BLANK_NODE_OR_LITERAL {
1521        NodeKindSet::BLANK_NODE_OR_LITERAL
1522    } else if r == vocab::SH_IRI_OR_LITERAL {
1523        NodeKindSet::IRI_OR_LITERAL
1524    } else {
1525        return None;
1526    })
1527}