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, Rule, RuleHead, Schema, Selector, Severity, Shape,
35    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    };
101    for (node, id) in &l.cache {
102        if let NamedOrBlankNode::NamedNode(n) = node {
103            schema.add_name(*id, n.as_str().to_string());
104        }
105    }
106    schema.arena.debug_assert_finalized();
107    Lowered {
108        schema,
109        diagnostics: l.diags,
110    }
111}
112
113struct Lowerer<'a> {
114    g: &'a Loaded,
115    arena: ShapeArena,
116    /// Source RDF node → *outer* arena slot. Inserting a reserved slot before
117    /// reading the body is the recursion knot: a recursive reference observes
118    /// the same id immediately, and the caller fills it exactly once at the
119    /// end. This keeps cyclic SHACL shapes a graph rather than forcing a
120    /// special recursive AST throughout the rest of the program.
121    cache: HashMap<NamedOrBlankNode, ShapeId>,
122    statements: Vec<Statement>,
123    rules: Vec<Rule>,
124    diags: Vec<Diagnostic>,
125}
126
127struct ComponentDef {
128    iri: NamedNode,
129    params: Vec<ParamDef>,
130    node_validator: Option<ValidatorDef>,
131    property_validator: Option<ValidatorDef>,
132    generic_validator: Option<ValidatorDef>,
133}
134
135struct ParamDef {
136    path: NamedNode,
137    var: String,
138    optional: bool,
139}
140
141struct ValidatorDef {
142    kind: SparqlQueryKind,
143    query: String,
144    messages: Vec<Term>,
145}
146
147/// The local name of an IRI (after the last `#` or `/`) — the SHACL rule for a
148/// parameter's pre-bound variable name (SHACL §6.2.1).
149fn local_name(iri: &str) -> &str {
150    iri.rsplit(['#', '/']).next().unwrap_or(iri)
151}
152
153impl Lowerer<'_> {
154    fn diag(&mut self, level: DiagLevel, msg: impl Into<String>, subj: &NamedOrBlankNode) {
155        self.diags
156            .push(Diagnostic::new(level, msg, Some(subj.to_string())));
157    }
158
159    /// Subjects that are declared shapes: typed NodeShape/PropertyShape, or
160    /// carrying `sh:path` or a target predicate. Referenced-only shapes are
161    /// pulled in on demand during lowering. Sorted for deterministic output.
162    fn discover_shapes(&self) -> Vec<NamedOrBlankNode> {
163        let mut found: HashSet<NamedOrBlankNode> = HashSet::new();
164        for triple in self.g.graph.iter() {
165            let p = triple.predicate;
166            let is_target = p == vocab::SH_TARGET_NODE
167                || p == vocab::SH_TARGET_CLASS
168                || p == vocab::SH_TARGET_SUBJECTS_OF
169                || p == vocab::SH_TARGET_OBJECTS_OF
170                || p == vocab::SH_TARGET;
171            if p == vocab::SH_PATH || p == vocab::SH_SPARQL || p == vocab::SH_RULE || is_target {
172                found.insert(triple.subject.into_owned());
173            }
174            if p == vocab::RDF_TYPE
175                && let Term::NamedNode(ty) = triple.object.into_owned()
176                && (ty.as_ref() == vocab::SH_NODE_SHAPE || ty.as_ref() == vocab::SH_PROPERTY_SHAPE)
177            {
178                found.insert(triple.subject.into_owned());
179            }
180        }
181        let mut shapes: Vec<NamedOrBlankNode> = found.into_iter().collect();
182        shapes.sort_by_key(|n| n.to_string());
183        shapes
184    }
185
186    /// Lower one source shape to its outer `Annotated` slot.
187    ///
188    /// The method has two layers on purpose: the outer wrapper preserves source
189    /// severity/messages and stable identity, while `body` holds the logical
190    /// predicate. Optimizers may reason about the body without having to know
191    /// SHACL reporting metadata, yet the metadata survives all the way to a
192    /// violation. Reserving `id` before descending also gives the recursive
193    /// case the same interface as the non-recursive case.
194    fn lower_shape(&mut self, s: &NamedOrBlankNode) -> ShapeId {
195        if let Some(id) = self.cache.get(s) {
196            return *id;
197        }
198        let id = self.arena.reserve();
199        self.cache.insert(s.clone(), id);
200
201        if self.bool_prop(s, vocab::SH_DEACTIVATED) {
202            self.arena.set(id, Shape::Top);
203            return id;
204        }
205
206        let path = self.parse_shape_path(s);
207        let mut conjuncts: Vec<ShapeId> = Vec::new();
208
209        // Value-scoped constraints: each applies to every value node along the
210        // path (or to the focus node directly when there is no path). The
211        // universal is represented using Count rather than introducing a second
212        // evaluator operator. That is the key abstraction: one qualified-count
213        // implementation covers cardinality, nested property shapes, and every
214        // per-value SHACL constraint.
215        let value = self.collect_value_constraints(s);
216        if !value.is_empty() {
217            let value_phi = self.arena.and(value);
218            match &path {
219                Some(p) => {
220                    // ∀π.φ  ≡  ∃≤0 π.¬φ
221                    let neg = self.arena.not(value_phi);
222                    let c = self.arena.count(p.clone(), None, Some(0), neg);
223                    conjuncts.push(c);
224                }
225                None => conjuncts.push(value_phi),
226            }
227        }
228
229        self.collect_path_constraints(s, path.as_ref(), &mut conjuncts);
230
231        if self.bool_prop(s, vocab::SH_CLOSED) {
232            let q = self.closed_allowed(s);
233            let c = self.arena.insert(Shape::Closed(q));
234            conjuncts.push(c);
235        }
236
237        let mut sparql_prebound = vec!["this", "shapesGraph", "currentShape"];
238        if path.is_some() {
239            sparql_prebound.push("PATH");
240        }
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                    path: path.clone(),
757                    shape: shape_term.clone(),
758                    messages: validator.messages.clone(),
759                    extra_bindings,
760                    // For node-shape activations (no sh:path), $value equals the
761                    // focus node, so rename ?value → ?this before binding per focus.
762                    bind_value_to_this: !is_property_shape
763                        && validator.kind == SparqlQueryKind::Ask,
764                };
765                let raw_id = self.arena.insert(Shape::Sparql(constraint));
766                // sh:SPARQLAskValidator (SHACL §6.2.3): ASK=true means the
767                // constraint is *satisfied*. The algebra evaluator treats
768                // ASK=true as a violation (matching sh:sparql semantics), so
769                // we wrap in Not to restore the correct polarity. SELECT
770                // validators are violation-per-row, same as sh:sparql — no inversion.
771                let sparql_id = if validator.kind == SparqlQueryKind::Ask {
772                    self.arena.not(raw_id)
773                } else {
774                    raw_id
775                };
776
777                // Conjoin with the shape's existing body.
778                let (severity, messages, inner) = match self.arena.get(shape_id) {
779                    Shape::Annotated {
780                        severity,
781                        messages,
782                        shape: inner,
783                    } => (severity.clone(), messages.clone(), *inner),
784                    _ => continue, // defensive; lower_shape always produces Annotated
785                };
786                let combined = self.arena.and(vec![inner, sparql_id]);
787                self.arena.set(
788                    shape_id,
789                    Shape::Annotated {
790                        severity,
791                        messages,
792                        shape: combined,
793                    },
794                );
795            }
796        }
797    }
798
799    /// Collect all custom constraint component definitions from the shapes graph:
800    /// named subjects that carry `sh:parameter` and at least one validator
801    /// predicate (`sh:validator`, `sh:nodeValidator`, `sh:propertyValidator`).
802    fn collect_component_defs(&mut self) -> Vec<ComponentDef> {
803        let mut out: Vec<ComponentDef> = Vec::new();
804        let mut seen = HashSet::new();
805        for triple in self.g.graph.triples_for_predicate(vocab::SH_PARAMETER) {
806            let subject = triple.subject.into_owned();
807            if !seen.insert(subject.clone()) {
808                continue;
809            }
810            let NamedOrBlankNode::NamedNode(iri) = &subject else {
811                continue;
812            };
813            if vocab::NATIVE_CONSTRAINT_COMPONENTS.contains(&iri.as_ref()) {
814                continue; // SHACL Core component; already implemented natively
815            }
816            let mut params: Vec<ParamDef> = Vec::new();
817            for p in self.g.objects(&subject, vocab::SH_PARAMETER) {
818                let Some(pn) = term_to_node(&p) else { continue };
819                let Some(Term::NamedNode(path)) = self.g.object(&pn, vocab::SH_PATH) else {
820                    continue;
821                };
822                let var = local_name(path.as_str()).to_string();
823                let optional = matches!(
824                    self.g.object(&pn, vocab::SH_OPTIONAL),
825                    Some(Term::Literal(ref l)) if l.value() == "true"
826                );
827                params.push(ParamDef {
828                    path,
829                    var,
830                    optional,
831                });
832            }
833            if params.is_empty() {
834                continue;
835            }
836            let mut prebound = vec![
837                "this".to_string(),
838                "value".to_string(),
839                "shapesGraph".to_string(),
840                "currentShape".to_string(),
841                "PATH".to_string(),
842            ];
843            prebound.extend(params.iter().map(|p| p.var.clone()));
844            let node_validator = self
845                .g
846                .object(&subject, vocab::SH_NODE_VALIDATOR)
847                .and_then(|v| self.parse_validator_def(&v, &prebound));
848            let property_validator = self
849                .g
850                .object(&subject, vocab::SH_PROPERTY_VALIDATOR)
851                .and_then(|v| self.parse_validator_def(&v, &prebound));
852            let generic_validator = self
853                .g
854                .object(&subject, vocab::SH_VALIDATOR)
855                .and_then(|v| self.parse_validator_def(&v, &prebound));
856            if node_validator.is_none()
857                && property_validator.is_none()
858                && generic_validator.is_none()
859            {
860                continue; // not a constraint component (e.g. a sh:SPARQLFunction)
861            }
862            out.push(ComponentDef {
863                iri: iri.clone(),
864                params,
865                node_validator,
866                property_validator,
867                generic_validator,
868            });
869        }
870        out.sort_by(|a, b| a.iri.as_str().cmp(b.iri.as_str()));
871        out
872    }
873
874    /// Parse a validator node into a `ValidatorDef`, canonicalizing its SPARQL
875    /// query. Returns `None` (and emits an error diagnostic) if the query is
876    /// invalid SPARQL.
877    fn parse_validator_def(&mut self, node: &Term, prebound: &[String]) -> Option<ValidatorDef> {
878        let node = term_to_node(node)?;
879        let (kind, raw) = if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_ASK) {
880            (SparqlQueryKind::Ask, q.value().to_string())
881        } else if let Some(Term::Literal(q)) = self.g.object(&node, vocab::SH_SELECT) {
882            (SparqlQueryKind::Select, q.value().to_string())
883        } else {
884            return None; // no query body — not a SPARQL validator
885        };
886        let canonical = match canonical_sparql_query(self.g, &node, &raw).and_then(|(query, c)| {
887            validate_shacl_prebinding(&query, prebound.iter().map(String::as_str))?;
888            Ok(c)
889        }) {
890            Ok(c) => c,
891            Err(msg) => {
892                self.diag(
893                    DiagLevel::Error,
894                    format!("invalid SPARQL in validator: {msg}"),
895                    &node,
896                );
897                return None;
898            }
899        };
900        let messages = self.g.objects(&node, vocab::SH_MESSAGE);
901        Some(ValidatorDef {
902            kind,
903            query: canonical,
904            messages,
905        })
906    }
907
908    /// Lower the `sh:rule`s of a shape (SHACL-AF). A rule fires on the shape's
909    /// targets, so we emit one [`Rule`] per selector.
910    fn parse_rules(&mut self, s: &NamedOrBlankNode, selectors: &[Selector]) {
911        for rule_term in self.g.objects(s, vocab::SH_RULE) {
912            let Some(rn) = term_to_node(&rule_term) else {
913                continue;
914            };
915            let Some(head) = self.parse_rule_head(&rn) else {
916                continue;
917            };
918
919            let conditions: Vec<ShapeId> = self
920                .g
921                .objects(&rn, vocab::SH_CONDITION)
922                .iter()
923                .filter_map(term_to_node)
924                .map(|c| self.lower_shape(&c))
925                .collect();
926            let order = self.order(&rn);
927            let deactivated = self.bool_prop(&rn, vocab::SH_DEACTIVATED);
928
929            for sel in selectors {
930                self.rules.push(Rule {
931                    selector: sel.clone(),
932                    conditions: conditions.clone(),
933                    head: head.clone(),
934                    order,
935                    deactivated,
936                });
937            }
938        }
939    }
940
941    /// Qualified value shapes attached through the same parent `sh:property`
942    /// declaration, excluding the current qualified shape itself.
943    fn sibling_qualified_shapes(
944        &self,
945        shape: &NamedOrBlankNode,
946        qualifier: &NamedOrBlankNode,
947    ) -> Vec<NamedOrBlankNode> {
948        let mut siblings = HashSet::new();
949        for triple in self.g.graph.triples_for_predicate(vocab::SH_PROPERTY) {
950            if term_to_node(&triple.object.into_owned()).as_ref() != Some(shape) {
951                continue;
952            }
953            let parent = triple.subject.into_owned();
954            for property in self.g.objects(&parent, vocab::SH_PROPERTY) {
955                let Some(property) = term_to_node(&property) else {
956                    continue;
957                };
958                for sibling in self.g.objects(&property, vocab::SH_QUALIFIED_VALUE_SHAPE) {
959                    if let Some(sibling) = term_to_node(&sibling) {
960                        siblings.insert(sibling);
961                    }
962                }
963            }
964        }
965        siblings.remove(qualifier);
966        let mut siblings: Vec<_> = siblings.into_iter().collect();
967        siblings.sort_by_key(|node| node.to_string());
968        siblings
969    }
970
971    fn parse_rule_head(&mut self, rn: &NamedOrBlankNode) -> Option<RuleHead> {
972        // sh:SPARQLRule — parse and canonicalize the CONSTRUCT while retaining
973        // an opaque algebra leaf for later query rewriting.
974        if let Some(Term::Literal(q)) = self.g.object(rn, vocab::SH_CONSTRUCT) {
975            let query = self.canonical_sparql(rn, q.value(), ExpectedQuery::Construct)?;
976            return Some(RuleHead::Sparql(SparqlConstruct { query }));
977        }
978        // sh:TripleRule — subject/predicate/object node expressions
979        let (subj, pred, obj) = (
980            self.g.object(rn, vocab::SH_SUBJECT),
981            self.g.object(rn, vocab::SH_PREDICATE),
982            self.g.object(rn, vocab::SH_OBJECT),
983        );
984        if subj.is_none() && pred.is_none() && obj.is_none() {
985            self.diag(DiagLevel::Unsupported, "unrecognized sh:rule head", rn);
986            return None;
987        }
988        let (Some(subj), Some(pred), Some(obj)) = (subj, pred, obj) else {
989            self.diag(
990                DiagLevel::Error,
991                "sh:TripleRule missing subject/predicate/object",
992                rn,
993            );
994            return None;
995        };
996        Some(RuleHead::Triple {
997            subject: self.parse_node_expr(subj, rn)?,
998            predicate: self.parse_node_expr(pred, rn)?,
999            object: self.parse_node_expr(obj, rn)?,
1000        })
1001    }
1002
1003    /// Parse a node expression (SHACL-AF §6). Handles `sh:this`, constants,
1004    /// path expressions, filter / intersection / union expressions, and SPARQL
1005    /// function calls `[ ex:fn (arg …) ]`.
1006    fn parse_node_expr(&mut self, term: Term, owner: &NamedOrBlankNode) -> Option<NodeExpr> {
1007        match &term {
1008            Term::NamedNode(n) if n.as_ref() == vocab::SH_THIS => Some(NodeExpr::This),
1009            Term::NamedNode(_) | Term::Literal(_) => Some(NodeExpr::Constant(term)),
1010            Term::BlankNode(_) => {
1011                let node = term_to_node(&term).expect("blank node");
1012                if let Some(path_term) = self.g.object(&node, vocab::SH_PATH) {
1013                    match parse_path(self.g, &path_term) {
1014                        Ok(path) => Some(NodeExpr::Path(path)),
1015                        Err(e) => {
1016                            self.diag(
1017                                DiagLevel::Error,
1018                                format!("invalid node-expression path: {e}"),
1019                                owner,
1020                            );
1021                            None
1022                        }
1023                    }
1024                } else if self.g.object(&node, vocab::SH_FILTER_SHAPE).is_some() {
1025                    self.parse_filter_expr(&node, owner)
1026                } else if let Some(list) = self.g.object(&node, vocab::SH_INTERSECTION) {
1027                    self.parse_set_expr(&list, owner)
1028                        .map(NodeExpr::Intersection)
1029                } else if let Some(list) = self.g.object(&node, vocab::SH_UNION) {
1030                    self.parse_set_expr(&list, owner).map(NodeExpr::Union)
1031                } else if let Some(expr) = self.try_function_call(&node, owner) {
1032                    Some(expr)
1033                } else {
1034                    self.diag(
1035                        DiagLevel::Unsupported,
1036                        "complex node expression not yet lowered",
1037                        owner,
1038                    );
1039                    None
1040                }
1041            }
1042        }
1043    }
1044
1045    /// `sh:filterShape` + `sh:nodes` — a filter node expression (SHACL-AF §6.4):
1046    /// the value nodes of `sh:nodes` that conform to `sh:filterShape`.
1047    fn parse_filter_expr(
1048        &mut self,
1049        node: &NamedOrBlankNode,
1050        owner: &NamedOrBlankNode,
1051    ) -> Option<NodeExpr> {
1052        let Some(shape_term) = self.g.object(node, vocab::SH_FILTER_SHAPE) else {
1053            self.diag(DiagLevel::Error, "sh:filterShape missing a value", owner);
1054            return None;
1055        };
1056        let Some(shape_node) = term_to_node(&shape_term) else {
1057            self.diag(
1058                DiagLevel::Error,
1059                "sh:filterShape must reference a shape",
1060                owner,
1061            );
1062            return None;
1063        };
1064        let Some(nodes_term) = self.g.object(node, vocab::SH_NODES) else {
1065            self.diag(
1066                DiagLevel::Error,
1067                "filter expression missing sh:nodes",
1068                owner,
1069            );
1070            return None;
1071        };
1072        let input = self.parse_node_expr(nodes_term, owner)?;
1073        let shape = self.lower_shape(&shape_node);
1074        Some(NodeExpr::Filter {
1075            input: Box::new(input),
1076            shape,
1077        })
1078    }
1079
1080    /// Parse the RDF list of an `sh:intersection` / `sh:union` node expression
1081    /// (SHACL-AF §6.5–6.6) into its member node expressions. Returns `None` if
1082    /// the list is empty or any member fails to parse (already diagnosed).
1083    fn parse_set_expr(
1084        &mut self,
1085        list_head: &Term,
1086        owner: &NamedOrBlankNode,
1087    ) -> Option<Vec<NodeExpr>> {
1088        let members = self.g.read_list(list_head);
1089        if members.is_empty() {
1090            self.diag(
1091                DiagLevel::Error,
1092                "sh:intersection/sh:union expects a non-empty list",
1093                owner,
1094            );
1095            return None;
1096        }
1097        let n = members.len();
1098        let exprs: Vec<NodeExpr> = members
1099            .into_iter()
1100            .filter_map(|t| self.parse_node_expr(t, owner))
1101            .collect();
1102        if exprs.len() != n {
1103            return None;
1104        }
1105        Some(exprs)
1106    }
1107
1108    /// Detect a function-call node expression `[ ex:fn ( arg1 arg2 … ) ]`.
1109    ///
1110    /// The blank node must have exactly one non-SHACL/RDF/RDFS/OWL predicate;
1111    /// its object must be an RDF list of argument node expressions.
1112    fn try_function_call(
1113        &mut self,
1114        node: &NamedOrBlankNode,
1115        owner: &NamedOrBlankNode,
1116    ) -> Option<NodeExpr> {
1117        let func_preds: Vec<(NamedNode, Term)> = self
1118            .g
1119            .graph
1120            .triples_for_subject(node)
1121            .map(|t| (t.predicate.into_owned(), t.object.into_owned()))
1122            .filter(|(p, _)| {
1123                let s = p.as_str();
1124                !s.starts_with(vocab::SH)
1125                    && !s.starts_with(vocab::RDF)
1126                    && !s.starts_with(vocab::RDFS)
1127                    && !s.starts_with(vocab::OWL)
1128            })
1129            .collect();
1130
1131        if func_preds.len() != 1 {
1132            return None;
1133        }
1134        let (func_iri, list_head) = func_preds.into_iter().next().unwrap();
1135        let arg_terms = self.g.read_list(&list_head);
1136        let n = arg_terms.len();
1137        let args: Vec<NodeExpr> = arg_terms
1138            .into_iter()
1139            .filter_map(|t| self.parse_node_expr(t, owner))
1140            .collect();
1141        if args.len() != n {
1142            return None;
1143        }
1144        Some(NodeExpr::Function {
1145            iri: func_iri,
1146            args,
1147        })
1148    }
1149
1150    fn order(&self, s: &NamedOrBlankNode) -> Option<i64> {
1151        match self.g.object(s, vocab::SH_ORDER) {
1152            Some(Term::Literal(l)) => l.value().parse().ok(),
1153            _ => None,
1154        }
1155    }
1156
1157    /// `∃≥1 (rdf:type/rdfs:subClassOf*) . test(class)` as a selector.
1158    fn class_selector(&mut self, class: Term) -> Selector {
1159        let tn = self.arena.insert(Shape::TestConst(class));
1160        Selector::HasPath(class_path(), tn)
1161    }
1162
1163    fn lower_shape_list(&mut self, list_head: &Term) -> Vec<ShapeId> {
1164        self.g
1165            .read_list(list_head)
1166            .into_iter()
1167            .filter_map(|m| term_to_node(&m))
1168            .map(|n| self.lower_shape(&n))
1169            .collect()
1170    }
1171
1172    fn parse_shape_path(&mut self, s: &NamedOrBlankNode) -> Option<Path> {
1173        let term = self.g.object(s, vocab::SH_PATH)?;
1174        match parse_path(self.g, &term) {
1175            Ok(p) => Some(p),
1176            Err(e) => {
1177                self.diag(DiagLevel::Error, format!("invalid sh:path: {e}"), s);
1178                None
1179            }
1180        }
1181    }
1182
1183    fn closed_allowed(&self, s: &NamedOrBlankNode) -> BTreeSet<oxrdf::NamedNode> {
1184        let mut q = BTreeSet::new();
1185        for prop in self.g.objects(s, vocab::SH_PROPERTY) {
1186            if let Some(pn) = term_to_node(&prop)
1187                && let Some(Term::NamedNode(n)) = self.g.object(&pn, vocab::SH_PATH)
1188            {
1189                q.insert(n);
1190            }
1191        }
1192        for ip in self.g.objects(s, vocab::SH_IGNORED_PROPERTIES) {
1193            for m in self.g.read_list(&ip) {
1194                if let Term::NamedNode(n) = m {
1195                    q.insert(n);
1196                }
1197            }
1198        }
1199        q
1200    }
1201
1202    fn bool_prop(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> bool {
1203        matches!(self.g.object(s, pred), Some(Term::Literal(l)) if l.value() == "true")
1204    }
1205
1206    fn int(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<u64> {
1207        match self.g.object(s, pred) {
1208            Some(Term::Literal(l)) => l.value().parse().ok(),
1209            _ => None,
1210        }
1211    }
1212
1213    fn lit(&self, s: &NamedOrBlankNode, pred: oxrdf::NamedNodeRef) -> Option<Literal> {
1214        match self.g.object(s, pred) {
1215            Some(Term::Literal(l)) => Some(l),
1216            _ => None,
1217        }
1218    }
1219
1220    /// Parse a SHACL SPARQL query once, resolving both document prefixes and
1221    /// `sh:prefixes` declarations. `Query::to_string` expands prefix names, so
1222    /// the IR remains self-contained and can be reparsed or rewritten later.
1223    fn canonical_sparql(
1224        &mut self,
1225        owner: &NamedOrBlankNode,
1226        raw: &str,
1227        expected: ExpectedQuery,
1228    ) -> Option<String> {
1229        let (query, canonical) = match canonical_sparql_query(self.g, owner, raw) {
1230            Ok(result) => result,
1231            Err(message) => {
1232                self.diag(DiagLevel::Error, message, owner);
1233                return None;
1234            }
1235        };
1236        let actual = match &query {
1237            Query::Select { .. } => ExpectedQuery::Select,
1238            Query::Ask { .. } => ExpectedQuery::Ask,
1239            Query::Construct { .. } => ExpectedQuery::Construct,
1240            Query::Describe { .. } => ExpectedQuery::Describe,
1241        };
1242        if actual != expected {
1243            self.diag(
1244                DiagLevel::Error,
1245                format!("expected SPARQL {expected}, found {actual}"),
1246                owner,
1247            );
1248            return None;
1249        }
1250        Some(canonical)
1251    }
1252
1253    /// Parse and validate a query that is evaluated with SHACL pre-bound
1254    /// variables. The SPARQL grammar accepts several constructs whose
1255    /// interaction with pre-binding is explicitly forbidden by SHACL 1.0.
1256    fn canonical_shacl_sparql(
1257        &mut self,
1258        owner: &NamedOrBlankNode,
1259        raw: &str,
1260        expected: ExpectedQuery,
1261        prebound: &[&str],
1262    ) -> Option<String> {
1263        let (query, canonical) = match canonical_sparql_query(self.g, owner, raw) {
1264            Ok(result) => result,
1265            Err(message) => {
1266                self.diag(DiagLevel::Error, message, owner);
1267                return None;
1268            }
1269        };
1270        let actual = match &query {
1271            Query::Select { .. } => ExpectedQuery::Select,
1272            Query::Ask { .. } => ExpectedQuery::Ask,
1273            Query::Construct { .. } => ExpectedQuery::Construct,
1274            Query::Describe { .. } => ExpectedQuery::Describe,
1275        };
1276        if actual != expected {
1277            self.diag(
1278                DiagLevel::Error,
1279                format!("expected SPARQL {expected}, found {actual}"),
1280                owner,
1281            );
1282            return None;
1283        }
1284        if let Err(message) = validate_shacl_prebinding(&query, prebound.iter().copied()) {
1285            self.diag(DiagLevel::Error, message, owner);
1286            return None;
1287        }
1288        Some(canonical)
1289    }
1290}
1291
1292/// Enforce the SHACL 1.0 restrictions needed to make pre-binding well-defined.
1293fn validate_shacl_prebinding<'a>(
1294    query: &Query,
1295    prebound: impl IntoIterator<Item = &'a str>,
1296) -> Result<(), String> {
1297    let prebound: HashSet<&str> = prebound.into_iter().collect();
1298    let pattern = match query {
1299        Query::Select { pattern, .. }
1300        | Query::Ask { pattern, .. }
1301        | Query::Construct { pattern, .. }
1302        | Query::Describe { pattern, .. } => pattern,
1303    };
1304    let mut in_scope = HashSet::new();
1305    pattern.on_in_scope_variable(|variable| {
1306        in_scope.insert(variable.as_str());
1307    });
1308    let projection_prebound: HashSet<&str> = prebound
1309        .iter()
1310        .copied()
1311        .filter(|name| matches!(*name, "this" | "value" | "PATH") && in_scope.contains(name))
1312        .collect();
1313
1314    fn visit(
1315        pattern: &GraphPattern,
1316        prebound: &HashSet<&str>,
1317        projection_prebound: &HashSet<&str>,
1318        seen_top_projection: bool,
1319    ) -> Result<(), String> {
1320        match pattern {
1321            GraphPattern::Minus { .. } => {
1322                return Err("SHACL-SPARQL queries must not contain MINUS".into());
1323            }
1324            GraphPattern::Service { .. } => {
1325                return Err("SHACL-SPARQL queries must not contain SERVICE".into());
1326            }
1327            GraphPattern::Values { .. } => {
1328                return Err("SHACL-SPARQL queries must not contain VALUES".into());
1329            }
1330            GraphPattern::Extend {
1331                inner, variable, ..
1332            } => {
1333                if prebound.contains(variable.as_str()) {
1334                    return Err(format!(
1335                        "SHACL-SPARQL must not assign potentially pre-bound variable ?{} with AS",
1336                        variable.as_str()
1337                    ));
1338                }
1339                visit(inner, prebound, projection_prebound, seen_top_projection)?;
1340            }
1341            GraphPattern::Project { inner, variables } => {
1342                if seen_top_projection {
1343                    let projected: HashSet<&str> = variables.iter().map(|v| v.as_str()).collect();
1344                    let mut missing: Vec<&str> = projection_prebound
1345                        .iter()
1346                        .copied()
1347                        .filter(|name| !projected.contains(name))
1348                        .collect();
1349                    missing.sort_unstable();
1350                    if !missing.is_empty() {
1351                        return Err(format!(
1352                            "SHACL-SPARQL subquery does not project potentially pre-bound variable(s): {}",
1353                            missing
1354                                .into_iter()
1355                                .map(|name| format!("?{name}"))
1356                                .collect::<Vec<_>>()
1357                                .join(", ")
1358                        ));
1359                    }
1360                }
1361                visit(inner, prebound, projection_prebound, true)?;
1362            }
1363            GraphPattern::Join { left, right }
1364            | GraphPattern::LeftJoin { left, right, .. }
1365            | GraphPattern::Union { left, right }
1366            | GraphPattern::Lateral { left, right } => {
1367                visit(left, prebound, projection_prebound, seen_top_projection)?;
1368                visit(right, prebound, projection_prebound, seen_top_projection)?;
1369            }
1370            GraphPattern::Graph { inner, .. }
1371            | GraphPattern::Filter { inner, .. }
1372            | GraphPattern::OrderBy { inner, .. }
1373            | GraphPattern::Distinct { inner }
1374            | GraphPattern::Reduced { inner }
1375            | GraphPattern::Slice { inner, .. }
1376            | GraphPattern::Group { inner, .. } => {
1377                visit(inner, prebound, projection_prebound, seen_top_projection)?;
1378            }
1379            GraphPattern::Bgp { .. } | GraphPattern::Path { .. } => {}
1380        }
1381        Ok(())
1382    }
1383
1384    // We intentionally apply the subquery-projection rule only to SHACL's
1385    // standard pre-bound variables that occur in the query's visible scope.
1386    // This preserves the existing aggregate-subquery extension for custom
1387    // component parameters. AS reassignment remains forbidden for every
1388    // declared parameter through the full `prebound` set.
1389    visit(pattern, &prebound, &projection_prebound, false)
1390}
1391
1392/// Build the canonical, prefix-expanded form of a SHACL SPARQL query string.
1393///
1394/// Resolves the document base IRI, document-level prefixes, and the
1395/// `sh:prefixes` / `sh:declare` chains (following `owl:imports`) declared on
1396/// `owner`, parses `raw`, and returns the parsed query together with its
1397/// canonical string form. `Query::to_string` expands prefix names, so the
1398/// result is self-contained and can be reparsed without external declarations.
1399///
1400/// Errors are returned as messages so callers can decide how to surface them:
1401/// the lowerer routes them to diagnostics; the report validator drops the
1402/// offending constraint, matching the lowering path.
1403pub fn canonical_sparql_query(
1404    g: &Loaded,
1405    owner: &NamedOrBlankNode,
1406    raw: &str,
1407) -> Result<(Query, String), String> {
1408    let mut parser = SparqlParser::new();
1409    if let Some(base) = &g.base {
1410        parser = parser
1411            .with_base_iri(base)
1412            .map_err(|e| format!("invalid SPARQL base IRI: {e}"))?;
1413    }
1414    for (prefix, namespace) in &g.prefixes {
1415        parser = parser
1416            .with_prefix(prefix, namespace)
1417            .map_err(|e| format!("invalid SPARQL prefix declaration {prefix}: {e}"))?;
1418    }
1419    let mut prefix_sources: Vec<NamedOrBlankNode> = g
1420        .objects(owner, vocab::SH_PREFIXES)
1421        .iter()
1422        .filter_map(term_to_node)
1423        .collect();
1424    let mut seen_sources = HashSet::new();
1425    while let Some(source) = prefix_sources.pop() {
1426        if !seen_sources.insert(source.clone()) {
1427            continue;
1428        }
1429        prefix_sources.extend(
1430            g.objects(&source, vocab::OWL_IMPORTS)
1431                .iter()
1432                .filter_map(term_to_node),
1433        );
1434        for declaration_term in g.objects(&source, vocab::SH_DECLARE) {
1435            let Some(declaration) = term_to_node(&declaration_term) else {
1436                continue;
1437            };
1438            let (Some(Term::Literal(prefix)), Some(Term::Literal(namespace))) = (
1439                g.object(&declaration, vocab::SH_PREFIX),
1440                g.object(&declaration, vocab::SH_NAMESPACE),
1441            ) else {
1442                continue;
1443            };
1444            parser = parser
1445                .with_prefix(prefix.value(), namespace.value())
1446                .map_err(|e| format!("invalid SHACL SPARQL prefix declaration: {e}"))?;
1447        }
1448    }
1449    let query = parser
1450        .parse_query(raw)
1451        .map_err(|e| format!("invalid SPARQL query: {e}"))?;
1452    let canonical = query.to_string();
1453    Ok((query, canonical))
1454}
1455
1456#[derive(Clone, Copy, PartialEq, Eq)]
1457enum ExpectedQuery {
1458    Select,
1459    Ask,
1460    Construct,
1461    Describe,
1462}
1463
1464impl std::fmt::Display for ExpectedQuery {
1465    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1466        f.write_str(match self {
1467            Self::Select => "SELECT",
1468            Self::Ask => "ASK",
1469            Self::Construct => "CONSTRUCT",
1470            Self::Describe => "DESCRIBE",
1471        })
1472    }
1473}
1474
1475fn class_path() -> Path {
1476    Path::seq(vec![
1477        Path::Pred(vocab::rdf_type()),
1478        Path::star(Path::Pred(vocab::rdfs_subclassof())),
1479    ])
1480}
1481
1482/// Whether a node expression contains a SPARQL function application anywhere in
1483/// its tree. Such expressions cannot yet be evaluated in the validation paths
1484/// (they need shapes-graph function lookups), so expression constraints over
1485/// them are diagnosed rather than lowered.
1486fn node_expr_has_function(e: &NodeExpr) -> bool {
1487    match e {
1488        NodeExpr::Function { .. } => true,
1489        NodeExpr::Filter { input, .. } => node_expr_has_function(input),
1490        NodeExpr::Intersection(es) | NodeExpr::Union(es) => es.iter().any(node_expr_has_function),
1491        NodeExpr::This | NodeExpr::Constant(_) | NodeExpr::Path(_) => false,
1492    }
1493}
1494
1495fn map_node_kind(term: &Term) -> Option<NodeKindSet> {
1496    let Term::NamedNode(n) = term else {
1497        return None;
1498    };
1499    let r = n.as_ref();
1500    Some(if r == vocab::SH_IRI {
1501        NodeKindSet::IRI
1502    } else if r == vocab::SH_BLANK_NODE {
1503        NodeKindSet::BLANK_NODE
1504    } else if r == vocab::SH_LITERAL {
1505        NodeKindSet::LITERAL
1506    } else if r == vocab::SH_BLANK_NODE_OR_IRI {
1507        NodeKindSet::BLANK_NODE_OR_IRI
1508    } else if r == vocab::SH_BLANK_NODE_OR_LITERAL {
1509        NodeKindSet::BLANK_NODE_OR_LITERAL
1510    } else if r == vocab::SH_IRI_OR_LITERAL {
1511        NodeKindSet::IRI_OR_LITERAL
1512    } else {
1513        return None;
1514    })
1515}