Skip to main content

shifty_parse/
lower.rs

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