Skip to main content

shifty_parse/
lower.rs

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