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