Skip to main content

shifty_engine/
report.rs

1//! W3C `sh:ValidationReport` generation (component-granular, RDF-driven).
2//!
3//! Producing a spec-faithful report needs provenance the optimized algebra
4//! discards: each result carries `sh:sourceConstraintComponent`,
5//! `sh:sourceShape`, and `sh:resultPath`, and the granularity is one result per
6//! (focus, value node, component) — `sh:and`/`sh:or`/`sh:not`/`sh:node` report
7//! as a *unit* (they do not drill into sub-failures), while `sh:property`
8//! delegates to the nested shape. So this validator walks the shapes graph
9//! directly, reusing only the leaf evaluation primitives (`succ`,
10//! `value_type_holds`). It is separate from the algebra path used for fast
11//! conformance.
12//!
13//! Coverage is a growing subset of SHACL Core (see `docs/BACKLOG.md`).
14
15use crate::frozen::FrozenIndexedDataset;
16use crate::path::succ;
17use crate::sparql::{FunctionDef, SparqlExecutor};
18use crate::validate::{
19    UnsupportedPolicy, ValidationGraphMode, ValidationOptions, apply_message_template, graph_union,
20    is_boolean_true,
21};
22use crate::value::{compare_terms, value_type_holds};
23use oxrdf::{BlankNode, Graph, Literal, NamedNode, NamedNodeRef, NamedOrBlankNode, Term, Triple};
24use shifty_algebra::value_type::{Bound, ValueType};
25use shifty_algebra::{NodeKindSet, Path, Severity, SparqlConstraint, SparqlQueryKind};
26use shifty_parse::graph::{Loaded, term_to_node};
27use shifty_parse::lower::canonical_sparql_query;
28use shifty_parse::path::parse_path;
29use shifty_parse::vocab;
30use std::cell::RefCell;
31use std::cmp::Ordering;
32use std::collections::{HashMap, HashSet};
33
34/// One `sh:ValidationResult`.
35#[derive(Debug, Clone, PartialEq, Eq, Hash)]
36pub struct ValidationResult {
37    pub focus: Term,
38    /// `sh:resultPath` as the original RDF node (predicate IRI for simple paths).
39    pub path: Option<Term>,
40    pub value: Option<Term>,
41    pub component: NamedNode,
42    pub source_shape: Term,
43    /// `sh:resultSeverity` — the `sh:severity` declared on the source shape,
44    /// defaulting to `sh:Violation`.
45    pub severity: NamedNode,
46    /// `sh:resultMessage` — copied from `sh:message` on the source shape.
47    pub messages: Vec<Term>,
48}
49
50#[derive(Debug, Clone)]
51pub struct ValidationReport {
52    pub conforms: bool,
53    pub results: Vec<ValidationResult>,
54}
55
56/// Validate `data` against the shapes in `shapes`, producing a W3C report.
57pub fn validate_report(shapes: &Loaded, data: &Graph) -> ValidationReport {
58    validate_report_with_options(shapes, data, &ValidationOptions::default())
59}
60
61/// Evaluate a SPARQL expression (a `dash:expression` function-call string such
62/// as `ex:fn("A", "B")`) against the `sh:SPARQLFunction`s declared in `shapes`,
63/// returning the result term. Drives `dash:FunctionTestCase`s and exposes SHACL
64/// functions as a standalone capability. The document's prefixes and base form
65/// the query prologue so prefixed function names resolve.
66pub fn evaluate_function_expression(shapes: &Loaded, expr: &str) -> Result<Option<Term>, String> {
67    let frozen = FrozenIndexedDataset::from_graph(&shapes.graph);
68    let mut sparql = SparqlExecutor::from_frozen(frozen, false);
69    // Expression evaluation is the function's own dataset-free path; register
70    // every function (Ignore) so pure dash:expressions resolve.
71    sparql.set_functions(collect_functions(shapes), UnsupportedPolicy::Ignore);
72    let mut prologue = String::new();
73    if let Some(base) = &shapes.base {
74        prologue.push_str(&format!("BASE <{base}>\n"));
75    }
76    for (prefix, namespace) in &shapes.prefixes {
77        prologue.push_str(&format!("PREFIX {prefix}: <{namespace}>\n"));
78    }
79    sparql.evaluate_expression(&prologue, expr)
80}
81
82/// Validate and build a W3C report using an explicit severity policy.
83pub fn validate_report_with_options(
84    shapes: &Loaded,
85    data: &Graph,
86    options: &ValidationOptions,
87) -> ValidationReport {
88    let has_shapes_graph = shapes_reference_shapes_graph(shapes);
89    let frozen = if has_shapes_graph {
90        FrozenIndexedDataset::from_graphs(data, &shapes.graph)
91    } else {
92        FrozenIndexedDataset::from_graph(data)
93    };
94    validate_report_context(shapes, data, frozen, has_shapes_graph, options)
95}
96
97/// Validate split data and shapes graphs using the selected graph mode.
98pub fn validate_report_graphs(shapes: &Loaded, data: &Graph) -> ValidationReport {
99    validate_report_graphs_with_mode_and_options(
100        shapes,
101        data,
102        ValidationGraphMode::default(),
103        &ValidationOptions::default(),
104    )
105}
106
107/// Validate split data and shapes graphs using an explicit graph mode.
108pub fn validate_report_graphs_with_mode(
109    shapes: &Loaded,
110    data: &Graph,
111    mode: ValidationGraphMode,
112) -> ValidationReport {
113    validate_report_graphs_with_mode_and_options(shapes, data, mode, &ValidationOptions::default())
114}
115
116/// Validate split graphs with an explicit graph mode and severity policy.
117pub fn validate_report_graphs_with_mode_and_options(
118    shapes: &Loaded,
119    data: &Graph,
120    mode: ValidationGraphMode,
121    options: &ValidationOptions,
122) -> ValidationReport {
123    let has_shapes_graph = shapes_reference_shapes_graph(shapes);
124    match mode {
125        ValidationGraphMode::Data => {
126            let frozen = if has_shapes_graph {
127                FrozenIndexedDataset::from_graphs(data, &shapes.graph)
128            } else {
129                FrozenIndexedDataset::from_graph(data)
130            };
131            validate_report_context(shapes, data, frozen, has_shapes_graph, options)
132        }
133        ValidationGraphMode::Union => {
134            let frozen = if has_shapes_graph {
135                FrozenIndexedDataset::from_graph_union_with_shapes(data, &shapes.graph)
136            } else {
137                FrozenIndexedDataset::from_graph_union(data, &shapes.graph)
138            };
139            validate_report_context(shapes, data, frozen, has_shapes_graph, options)
140        }
141        ValidationGraphMode::UnionAll => {
142            let union = graph_union(data, &shapes.graph);
143            let frozen = if has_shapes_graph {
144                FrozenIndexedDataset::from_graphs(&union, &shapes.graph)
145            } else {
146                FrozenIndexedDataset::from_graph(&union)
147            };
148            validate_report_context(shapes, &union, frozen, has_shapes_graph, options)
149        }
150    }
151}
152
153fn validate_report_context(
154    shapes: &Loaded,
155    focus_data: &Graph,
156    frozen: FrozenIndexedDataset,
157    has_shapes_graph: bool,
158    options: &ValidationOptions,
159) -> ValidationReport {
160    // Only execute SPARQL target/constraint work when the shapes graph contains
161    // those features. Query execution shares the frozen validation dataset.
162    let needs_sparql = shapes
163        .graph
164        .triples_for_predicate(vocab::SH_SPARQL)
165        .next()
166        .is_some()
167        || shapes
168            .graph
169            .triples_for_predicate(vocab::SH_TARGET)
170            .next()
171            .is_some();
172    let mut sparql = SparqlExecutor::from_frozen(frozen, needs_sparql && has_shapes_graph);
173    sparql.set_functions(collect_functions(shapes), options.engine.unsupported);
174    // Index class membership once (instead of a forward scan over every node per
175    // class-target shape): this is the report path's analogue of the plan's
176    // backward `PathToConst` focus source, amortized across all shapes.
177    let has_explicit_class_target = shapes
178        .graph
179        .triples_for_predicate(vocab::SH_TARGET_CLASS)
180        .next()
181        .is_some();
182    let has_implicit_class_target = shapes.graph.iter().any(|triple| {
183        let subject = triple.subject.into_owned();
184        is_shape_node(shapes, &subject)
185            && (shapes.is_instance_of(&subject, vocab::RDFS_CLASS)
186                || shapes.is_instance_of(&subject, vocab::OWL_CLASS))
187    });
188    let needs_class_index = has_explicit_class_target || has_implicit_class_target;
189    let class_index = if needs_class_index {
190        build_class_index(
191            focus_data,
192            sparql
193                .frozen()
194                .expect("report validation always has a frozen dataset"),
195        )
196    } else {
197        HashMap::new()
198    };
199    let r = Reporter {
200        shapes,
201        focus_data,
202        sparql,
203        needs_sparql,
204        class_index,
205        path_cache: RefCell::new(HashMap::new()),
206        components: build_components(shapes, options.engine.unsupported),
207    };
208    let mut results = Vec::new();
209    for shape in r.target_shapes() {
210        let foci = r.focus_nodes(&shape);
211        r.prefetch_sparql(&shape, &foci);
212        for focus in &foci {
213            let mut visited = HashSet::new();
214            r.collect(&shape, focus, &mut results, &mut visited, &[]);
215        }
216    }
217    if options.sort_results {
218        results.sort_by(|left, right| {
219            Severity::from_named_node(right.severity.clone())
220                .rank()
221                .cmp(&Severity::from_named_node(left.severity.clone()).rank())
222                .then_with(|| left.focus.to_string().cmp(&right.focus.to_string()))
223                .then_with(|| {
224                    left.source_shape
225                        .to_string()
226                        .cmp(&right.source_shape.to_string())
227                })
228                .then_with(|| left.component.as_str().cmp(right.component.as_str()))
229        });
230    }
231    ValidationReport {
232        conforms: !results.iter().any(|result| {
233            Severity::from_named_node(result.severity.clone()).meets(&options.minimum_severity)
234        }),
235        results,
236    }
237}
238
239/// Serialize a report as an RDF `sh:ValidationReport` graph (W3C shape).
240pub fn report_to_graph(report: &ValidationReport) -> Graph {
241    let mut g = Graph::new();
242    let root = BlankNode::default();
243    let t = |s: NamedOrBlankNode, p: NamedNodeRef, o: Term| Triple::new(s, p.into_owned(), o);
244
245    g.insert(&t(
246        root.clone().into(),
247        vocab::RDF_TYPE,
248        vocab::SH_VALIDATION_REPORT.into_owned().into(),
249    ));
250    g.insert(&t(
251        root.clone().into(),
252        vocab::SH_CONFORMS,
253        Literal::from(report.conforms).into(),
254    ));
255
256    for r in &report.results {
257        let rn = BlankNode::default();
258        g.insert(&t(root.clone().into(), vocab::SH_RESULT, rn.clone().into()));
259        g.insert(&t(
260            rn.clone().into(),
261            vocab::RDF_TYPE,
262            vocab::SH_VALIDATION_RESULT.into_owned().into(),
263        ));
264        g.insert(&t(rn.clone().into(), vocab::SH_FOCUS_NODE, r.focus.clone()));
265        if let Some(path) = &r.path {
266            g.insert(&t(rn.clone().into(), vocab::SH_RESULT_PATH, path.clone()));
267        }
268        if let Some(value) = &r.value {
269            g.insert(&t(rn.clone().into(), vocab::SH_VALUE, value.clone()));
270        }
271        g.insert(&t(
272            rn.clone().into(),
273            vocab::SH_RESULT_SEVERITY,
274            r.severity.clone().into(),
275        ));
276        g.insert(&t(
277            rn.clone().into(),
278            vocab::SH_SOURCE_CONSTRAINT_COMPONENT,
279            r.component.clone().into(),
280        ));
281        for msg in &r.messages {
282            g.insert(&t(rn.clone().into(), vocab::SH_RESULT_MESSAGE, msg.clone()));
283        }
284        g.insert(&t(
285            rn.into(),
286            vocab::SH_SOURCE_SHAPE,
287            r.source_shape.clone(),
288        ));
289    }
290    g
291}
292
293/// Substitute `{$varName}` / `{?varName}` placeholders in `sh:message` literals.
294///
295/// `$this` is resolved from `focus`; all other names are looked up in
296/// `bindings` (keyed without the `$`/`?` sigil). Unresolved placeholders are
297/// left as-is. Only `sh:Literal` messages are processed; IRI/blank-node
298/// message terms pass through unchanged.
299fn substitute_messages(
300    messages: &[Term],
301    focus: &Term,
302    bindings: &HashMap<String, Term>,
303) -> Vec<Term> {
304    messages
305        .iter()
306        .map(|msg| {
307            let Term::Literal(lit) = msg else {
308                return msg.clone();
309            };
310            let text = lit.value();
311            let substituted = apply_message_template(text, focus, bindings);
312            if substituted == text {
313                msg.clone()
314            } else {
315                Term::Literal(Literal::new_simple_literal(&substituted))
316            }
317        })
318        .collect()
319}
320
321/// A SPARQL-based custom constraint component (SHACL §6.2–6.3): a named
322/// component IRI, its parameters, and the validators that apply to node shapes,
323/// property shapes, or both.
324struct CustomComponent {
325    /// The component IRI, reported as `sh:sourceConstraintComponent`.
326    iri: NamedNode,
327    params: Vec<ComponentParam>,
328    /// `sh:nodeValidator` — used when the component is applied to a node shape.
329    node_validator: Option<ComponentValidator>,
330    /// `sh:propertyValidator` — used when applied to a property shape.
331    property_validator: Option<ComponentValidator>,
332    /// `sh:validator` — an ASK validator usable for either shape kind.
333    generic_validator: Option<ComponentValidator>,
334}
335
336struct ComponentParam {
337    /// The parameter's `sh:path` predicate; the shape supplies its value here.
338    path: NamedNode,
339    /// The pre-bound SPARQL variable name (the local name of `path`).
340    var: String,
341    optional: bool,
342}
343
344struct ComponentValidator {
345    kind: SparqlQueryKind,
346    /// Prefix-expanded query text (`sh:ask` / `sh:select`).
347    query: String,
348    messages: Vec<Term>,
349}
350
351fn resolve_validator(
352    shapes: &Loaded,
353    node: Term,
354    component_iri: &NamedNode,
355    policy: UnsupportedPolicy,
356) -> Option<ComponentValidator> {
357    match parse_validator(shapes, &node) {
358        Ok(v) => Some(v),
359        Err(e) => {
360            assert!(
361                policy != UnsupportedPolicy::Error,
362                "invalid SPARQL in custom constraint component <{component_iri}>: {e}"
363            );
364            None
365        }
366    }
367}
368
369/// Discover every SPARQL-based custom constraint component in the shapes graph:
370/// a named subject carrying `sh:parameter`(s) and at least one validator. (A
371/// `sh:SPARQLFunction` also has `sh:parameter` but no validator, so it is
372/// excluded.)
373///
374/// Under [`UnsupportedPolicy::Error`], a component whose validator query is
375/// invalid SPARQL causes a panic with a diagnostic message so the problem
376/// surfaces immediately rather than producing a silent wrong answer.
377/// Under [`UnsupportedPolicy::Ignore`], such components are silently skipped
378/// (the constraint is not enforced, which is the historical default behaviour).
379fn build_components(shapes: &Loaded, policy: UnsupportedPolicy) -> Vec<CustomComponent> {
380    let mut out = Vec::new();
381    let mut seen = HashSet::new();
382    for triple in shapes.graph.triples_for_predicate(vocab::SH_PARAMETER) {
383        let subject = triple.subject.into_owned();
384        if !seen.insert(subject.clone()) {
385            continue;
386        }
387        let NamedOrBlankNode::NamedNode(iri) = &subject else {
388            continue; // a component must be named to be a sourceConstraintComponent
389        };
390        if vocab::NATIVE_CONSTRAINT_COMPONENTS.contains(&iri.as_ref()) {
391            continue; // SHACL Core component; already implemented natively
392        }
393
394        let node_validator = shapes
395            .object(&subject, vocab::SH_NODE_VALIDATOR)
396            .and_then(|v| resolve_validator(shapes, v, iri, policy));
397        let property_validator = shapes
398            .object(&subject, vocab::SH_PROPERTY_VALIDATOR)
399            .and_then(|v| resolve_validator(shapes, v, iri, policy));
400        let generic_validator = shapes
401            .object(&subject, vocab::SH_VALIDATOR)
402            .and_then(|v| resolve_validator(shapes, v, iri, policy));
403        if node_validator.is_none() && property_validator.is_none() && generic_validator.is_none() {
404            continue; // not a constraint component (e.g. a sh:SPARQLFunction)
405        }
406        let mut params = Vec::new();
407        for p in shapes.objects(&subject, vocab::SH_PARAMETER) {
408            let Some(pn) = term_to_node(&p) else { continue };
409            let Some(Term::NamedNode(path)) = shapes.object(&pn, vocab::SH_PATH) else {
410                continue;
411            };
412            let var = local_name(path.as_str()).to_string();
413            let optional = matches!(
414                shapes.object(&pn, vocab::SH_OPTIONAL),
415                Some(Term::Literal(ref l)) if l.value() == "true"
416            );
417            params.push(ComponentParam {
418                path,
419                var,
420                optional,
421            });
422        }
423        if params.is_empty() {
424            continue;
425        }
426        out.push(CustomComponent {
427            iri: iri.clone(),
428            params,
429            node_validator,
430            property_validator,
431            generic_validator,
432        });
433    }
434    out.sort_by(|a, b| a.iri.as_str().cmp(b.iri.as_str()));
435    out
436}
437
438/// Parse a validator node (`sh:SPARQLAskValidator` / `sh:SPARQLSelectValidator`
439/// or a subclass), resolving its `sh:prefixes` into a canonical query string.
440/// Returns `Err` (with the parse error message) when the query is invalid SPARQL.
441fn parse_validator(shapes: &Loaded, node: &Term) -> Result<ComponentValidator, String> {
442    let node = term_to_node(node)
443        .ok_or_else(|| "validator node is not an IRI or blank node".to_string())?;
444    let (kind, raw) = if let Some(Term::Literal(q)) = shapes.object(&node, vocab::SH_ASK) {
445        (SparqlQueryKind::Ask, q.value().to_string())
446    } else if let Some(Term::Literal(q)) = shapes.object(&node, vocab::SH_SELECT) {
447        (SparqlQueryKind::Select, q.value().to_string())
448    } else {
449        return Err("validator has neither sh:ask nor sh:select".to_string());
450    };
451    let (_, query) = canonical_sparql_query(shapes, &node, &raw)
452        .map_err(|e| format!("invalid SPARQL in {node}: {e}"))?;
453    let messages = shapes.objects(&node, vocab::SH_MESSAGE);
454    Ok(ComponentValidator {
455        kind,
456        query,
457        messages,
458    })
459}
460
461/// The local name of an IRI (after the last `#` or `/`) — the SHACL rule for a
462/// parameter's pre-bound variable name (SHACL §6.2.1).
463fn local_name(iri: &str) -> &str {
464    iri.rsplit(['#', '/']).next().unwrap_or(iri)
465}
466
467/// Discover `sh:SPARQLFunction`s in the shapes graph (SHACL-AF §5) and build
468/// their registrable [`FunctionDef`]s: the function IRI, its parameter variable
469/// names in positional order (`sh:order`, then local name), and its prefix-
470/// expanded `sh:select`/`sh:ask` body.
471pub(crate) fn collect_functions(shapes: &Loaded) -> Vec<FunctionDef> {
472    let mut out = Vec::new();
473    for func in shapes
474        .graph
475        .subjects_for_predicate_object(vocab::RDF_TYPE, vocab::SH_SPARQL_FUNCTION)
476        .map(|s| s.into_owned())
477        .collect::<Vec<_>>()
478    {
479        let NamedOrBlankNode::NamedNode(iri) = &func else {
480            continue;
481        };
482        let raw = match shapes
483            .object(&func, vocab::SH_SELECT)
484            .or_else(|| shapes.object(&func, vocab::SH_ASK))
485        {
486            Some(Term::Literal(q)) => q.value().to_string(),
487            _ => continue,
488        };
489        let Ok((_, query)) = canonical_sparql_query(shapes, &func, &raw) else {
490            continue;
491        };
492        out.push(FunctionDef {
493            iri: iri.clone(),
494            params: function_param_names(shapes, &func),
495            reads_graph: crate::sparql::query_reads_graph(&query),
496            query,
497        });
498    }
499    out
500}
501
502/// Parameter variable names of a function, ordered by `sh:order` then by the
503/// local name of `sh:path` (matching the node-expression evaluator).
504fn function_param_names(shapes: &Loaded, func: &NamedOrBlankNode) -> Vec<String> {
505    let mut params: Vec<(i64, String)> = shapes
506        .objects(func, vocab::SH_PARAMETER)
507        .iter()
508        .filter_map(|p| {
509            let pn = term_to_node(p)?;
510            let order = match shapes.object(&pn, vocab::SH_ORDER) {
511                Some(Term::Literal(l)) => l.value().parse::<i64>().unwrap_or(0),
512                _ => 0,
513            };
514            let name = match shapes.object(&pn, vocab::SH_NAME) {
515                Some(Term::Literal(l)) => l.value().to_string(),
516                _ => match shapes.object(&pn, vocab::SH_PATH) {
517                    Some(Term::NamedNode(n)) => local_name(n.as_str()).to_string(),
518                    _ => return None,
519                },
520            };
521            Some((order, name))
522        })
523        .collect();
524    params.sort_by(|a, b| a.0.cmp(&b.0).then(a.1.cmp(&b.1)));
525    params.into_iter().map(|(_, name)| name).collect()
526}
527
528struct Reporter<'a> {
529    shapes: &'a Loaded,
530    focus_data: &'a Graph,
531    sparql: SparqlExecutor,
532    needs_sparql: bool,
533    /// `class → focus-data instances` under `rdf:type / rdfs:subClassOf*`, built
534    /// once and shared by every `sh:targetClass` / implicit-class lookup.
535    class_index: HashMap<Term, Vec<Term>>,
536    /// Parsed `sh:path` per shape node, so `collect` does not re-parse the path
537    /// RDF on every (shape, focus) visit. `None` = shape has no/invalid path.
538    path_cache: RefCell<HashMap<NamedOrBlankNode, PathCacheEntry>>,
539    /// SPARQL-based custom constraint components declared in the shapes graph
540    /// (empty for the common case of no custom components).
541    components: Vec<CustomComponent>,
542}
543
544type Visited = HashSet<(NamedOrBlankNode, Term)>;
545
546/// Cached parsed path and its term representation for sh:path expressions
547type PathCacheEntry = (Option<Term>, Option<Path>);
548
549impl Reporter<'_> {
550    fn frozen(&self) -> &FrozenIndexedDataset {
551        self.sparql
552            .frozen()
553            .expect("report validation always has a frozen dataset")
554    }
555
556    fn target_shapes(&self) -> Vec<NamedOrBlankNode> {
557        let mut found: HashSet<NamedOrBlankNode> = HashSet::new();
558        for t in self.shapes.graph.iter() {
559            let p = t.predicate;
560            if p == vocab::SH_TARGET_NODE
561                || p == vocab::SH_TARGET_CLASS
562                || p == vocab::SH_TARGET_SUBJECTS_OF
563                || p == vocab::SH_TARGET_OBJECTS_OF
564            {
565                found.insert(t.subject.into_owned());
566            }
567            // SPARQL-based target: sh:target [ sh:select "…" ]
568            if p == vocab::SH_TARGET
569                && let Some(target) = term_to_node(&t.object.into_owned())
570                && self.shapes.object(&target, vocab::SH_SELECT).is_some()
571            {
572                found.insert(t.subject.into_owned());
573            }
574            // implicit class target: a shape that is also an rdfs:Class / owl:Class
575            if p == vocab::RDF_TYPE {
576                let s = t.subject.into_owned();
577                if self.is_class(&s) && self.is_shape(&s) {
578                    found.insert(s);
579                }
580            }
581        }
582        let mut v: Vec<_> = found.into_iter().collect();
583        v.sort_by_key(|n| n.to_string());
584        v
585    }
586
587    /// Does this node look like a SHACL shape (so its class-ness implies a target)?
588    fn is_shape(&self, n: &NamedOrBlankNode) -> bool {
589        is_shape_node(self.shapes, n)
590    }
591
592    fn is_class(&self, n: &NamedOrBlankNode) -> bool {
593        self.shapes.is_instance_of(n, vocab::RDFS_CLASS)
594            || self.shapes.is_instance_of(n, vocab::OWL_CLASS)
595    }
596
597    fn deactivated(&self, n: &NamedOrBlankNode) -> bool {
598        matches!(self.shapes.object(n, vocab::SH_DEACTIVATED),
599            Some(Term::Literal(ref l)) if l.value() == "true")
600    }
601
602    fn focus_nodes(&self, shape: &NamedOrBlankNode) -> Vec<Term> {
603        let mut nodes = Vec::new();
604        nodes.extend(self.shapes.objects(shape, vocab::SH_TARGET_NODE));
605        for c in self.shapes.objects(shape, vocab::SH_TARGET_CLASS) {
606            if let Some(instances) = self.class_index.get(&c) {
607                nodes.extend(instances.iter().cloned());
608            }
609        }
610        for p in self.shapes.objects(shape, vocab::SH_TARGET_SUBJECTS_OF) {
611            if let Term::NamedNode(n) = p {
612                nodes.extend(
613                    self.focus_data
614                        .triples_for_predicate(n.as_ref())
615                        .map(|t| node_term(t.subject)),
616                );
617            }
618        }
619        for p in self.shapes.objects(shape, vocab::SH_TARGET_OBJECTS_OF) {
620            if let Term::NamedNode(n) = p {
621                nodes.extend(
622                    self.focus_data
623                        .triples_for_predicate(n.as_ref())
624                        .map(|t| t.object.into_owned()),
625                );
626            }
627        }
628        // SPARQL-based targets: sh:target [ sh:select "…" ]. The query selects
629        // `?this` focus nodes from the context store.
630        if self.needs_sparql {
631            let exec = &self.sparql;
632            for target in self.shapes.objects(shape, vocab::SH_TARGET) {
633                let Some(target_node) = term_to_node(&target) else {
634                    continue;
635                };
636                let Some(Term::Literal(query)) = self.shapes.object(&target_node, vocab::SH_SELECT)
637                else {
638                    continue;
639                };
640                // Drop targets that fail to canonicalize, matching the lowering path.
641                let Ok((_, canonical)) =
642                    canonical_sparql_query(self.shapes, &target_node, query.value())
643                else {
644                    continue;
645                };
646                if let Ok(found) = exec.target_nodes(&canonical) {
647                    nodes.extend(found);
648                }
649            }
650        }
651        // implicit class target: instances of the shape (which is also a class)
652        if let NamedOrBlankNode::NamedNode(n) = shape
653            && self.is_class(shape)
654        {
655            let class = Term::NamedNode(n.clone());
656            if let Some(instances) = self.class_index.get(&class) {
657                nodes.extend(instances.iter().cloned());
658            }
659        }
660        let mut seen = HashSet::new();
661        nodes.retain(|t| seen.insert(t.clone()));
662        nodes
663    }
664
665    /// The shape's `sh:path` as both its raw RDF node (for `sh:resultPath`) and
666    /// the parsed path algebra, memoized so repeated visits don't re-parse it.
667    fn shape_path(&self, shape: &NamedOrBlankNode) -> (Option<Term>, Option<Path>) {
668        if let Some(cached) = self.path_cache.borrow().get(shape) {
669            return cached.clone();
670        }
671        let path_term = self.shapes.object(shape, vocab::SH_PATH);
672        let parsed = path_term
673            .as_ref()
674            .and_then(|t| parse_path(self.shapes, t).ok());
675        let entry = (path_term, parsed);
676        self.path_cache
677            .borrow_mut()
678            .insert(shape.clone(), entry.clone());
679        entry
680    }
681
682    /// `shape`'s own `sh:message`, falling back to `inherited` — the nearest
683    /// enclosing shape's `sh:message` — when `shape` declares none. Mirrors the
684    /// algebra path's "nearest-enclosing shape" resolution (see `explain` in
685    /// `lib.rs`) so a message authored on an outer node shape still surfaces on
686    /// violations from an unlabeled nested property shape.
687    fn messages_or_inherited(&self, shape: &NamedOrBlankNode, inherited: &[Term]) -> Vec<Term> {
688        let own = self.messages(shape);
689        if own.is_empty() {
690            inherited.to_vec()
691        } else {
692            own
693        }
694    }
695
696    /// Collect the results of validating `focus` against `shape`.
697    ///
698    /// `inherited` is the nearest enclosing shape's `sh:message` (empty at the
699    /// top-level target shapes), used when `shape` itself has none.
700    fn collect(
701        &self,
702        shape: &NamedOrBlankNode,
703        focus: &Term,
704        out: &mut Vec<ValidationResult>,
705        visited: &mut Visited,
706        inherited: &[Term],
707    ) {
708        if self.deactivated(shape) {
709            return; // deactivated shapes produce no results
710        }
711        let key = (shape.clone(), focus.clone());
712        if !visited.insert(key.clone()) {
713            return; // recursion: conform on the back-edge (gfp)
714        }
715
716        let (path_term, parsed) = self.shape_path(shape);
717        let value_nodes: Vec<Term> = match &parsed {
718            Some(p) => succ(self.frozen(), focus, p).into_iter().collect(),
719            None => vec![focus.clone()],
720        };
721        let severity = self.severity(shape);
722        let messages = self.messages_or_inherited(shape, inherited);
723        let push = |out: &mut Vec<ValidationResult>, value, component| {
724            out.push(ValidationResult {
725                focus: focus.clone(),
726                path: path_term.clone(),
727                value,
728                component,
729                source_shape: node_term_ref(shape),
730                severity: severity.clone(),
731                messages: messages.clone(),
732            });
733        };
734
735        // cardinality (only meaningful with a path)
736        if parsed.is_some() {
737            if let Some(min) = self.int(shape, vocab::SH_MIN_COUNT)
738                && (value_nodes.len() as u64) < min
739            {
740                push(out, None, vocab::SH_CC_MIN_COUNT.into_owned());
741            }
742            if let Some(max) = self.int(shape, vocab::SH_MAX_COUNT)
743                && (value_nodes.len() as u64) > max
744            {
745                push(out, None, vocab::SH_CC_MAX_COUNT.into_owned());
746            }
747        }
748
749        // sh:hasValue — one of the value nodes must equal the constant
750        for hv in self.shapes.objects(shape, vocab::SH_HAS_VALUE) {
751            if !value_nodes.contains(&hv) {
752                push(out, None, vocab::SH_CC_HAS_VALUE.into_owned());
753            }
754        }
755
756        self.collect_closed(shape, focus, &value_nodes, out, &messages);
757        self.collect_property_pairs(shape, focus, &path_term, &value_nodes, out, &messages);
758        self.collect_unique_lang(shape, focus, &path_term, &value_nodes, out, &messages);
759        self.collect_qualified_counts(
760            shape,
761            focus,
762            &path_term,
763            &value_nodes,
764            out,
765            visited,
766            &messages,
767        );
768
769        // value-scoped components
770        for u in &value_nodes {
771            for (component, ok) in self.value_checks(shape, u, visited) {
772                if !ok {
773                    push(out, Some(u.clone()), component);
774                }
775            }
776        }
777
778        // nested property shapes: delegate (each value node is a focus for P),
779        // passing this shape's resolved message down as the inherited fallback.
780        for prop in self.shapes.objects(shape, vocab::SH_PROPERTY) {
781            if let Some(pn) = term_to_node(&prop) {
782                for u in &value_nodes {
783                    self.collect(&pn, u, out, visited, &messages);
784                }
785            }
786        }
787
788        self.collect_sparql(shape, focus, &path_term, &parsed, out, &messages);
789        self.collect_expression(shape, focus, out, visited, &messages);
790        self.collect_components(
791            shape,
792            focus,
793            &path_term,
794            &parsed,
795            &value_nodes,
796            out,
797            &messages,
798        );
799
800        visited.remove(&key);
801    }
802
803    /// SPARQL-based custom constraint components (SHACL §6.3). A component is
804    /// *activated* for `shape` iff the shape supplies a value for each of its
805    /// mandatory parameters; those values (plus `$this`, `$value`, `$PATH`,
806    /// `$currentShape`) are pre-bound into the validator query. ASK validators
807    /// run per value node (violation iff they return `false`); SELECT validators
808    /// run once per focus (each solution row is a violation).
809    #[allow(clippy::too_many_arguments)]
810    fn collect_components(
811        &self,
812        shape: &NamedOrBlankNode,
813        focus: &Term,
814        path_term: &Option<Term>,
815        parsed_path: &Option<Path>,
816        value_nodes: &[Term],
817        out: &mut Vec<ValidationResult>,
818        inherited: &[Term],
819    ) {
820        if self.components.is_empty() {
821            return;
822        }
823        let is_property_shape = parsed_path.is_some();
824        for component in &self.components {
825            // Activation: every mandatory parameter must have a value on `shape`.
826            let mut params: Vec<(String, Term)> = Vec::new();
827            let mut activated = true;
828            for p in &component.params {
829                if let Some(value) = self.shapes.object(shape, p.path.as_ref()) {
830                    params.push((p.var.clone(), value));
831                } else if !p.optional {
832                    activated = false;
833                    break;
834                }
835            }
836            if !activated {
837                continue;
838            }
839
840            let validator = if is_property_shape {
841                component
842                    .property_validator
843                    .as_ref()
844                    .or(component.generic_validator.as_ref())
845            } else {
846                component
847                    .node_validator
848                    .as_ref()
849                    .or(component.generic_validator.as_ref())
850            };
851            let Some(validator) = validator else { continue };
852
853            // Bindings shared across value nodes: parameters and $currentShape.
854            // For property shapes, `$PATH` is pre-bound to the shape's path
855            // (simple predicate or complex property path) inside the executor.
856            let mut base = params;
857            base.push(("currentShape".to_string(), node_term_ref(shape)));
858            let path = parsed_path.as_ref();
859
860            match validator.kind {
861                SparqlQueryKind::Ask => {
862                    for value in value_nodes {
863                        let mut bindings = base.clone();
864                        bindings.push(("this".to_string(), focus.clone()));
865                        bindings.push(("value".to_string(), value.clone()));
866                        // Conform iff ASK is true; a runtime error fails closed.
867                        let violates = match self.sparql.eval_ask(&validator.query, path, &bindings)
868                        {
869                            Ok(conforms) => !conforms,
870                            Err(_) => true,
871                        };
872                        if violates {
873                            self.push_component_result(
874                                out,
875                                component,
876                                shape,
877                                focus,
878                                path_term.clone(),
879                                Some(value.clone()),
880                                &bindings,
881                                &validator.messages,
882                                inherited,
883                            );
884                        }
885                    }
886                }
887                SparqlQueryKind::Select => {
888                    let mut bindings = base.clone();
889                    bindings.push(("this".to_string(), focus.clone()));
890                    match self.sparql.eval_select(&validator.query, path, &bindings) {
891                        Ok(rows) => {
892                            for row in rows {
893                                // ?value projected; for node validators it is the
894                                // focus node itself when not projected.
895                                let value = row
896                                    .get("value")
897                                    .cloned()
898                                    .or_else(|| (!is_property_shape).then(|| focus.clone()));
899                                let path = row.get("path").cloned().or_else(|| path_term.clone());
900                                let mut binds = bindings.clone();
901                                binds.extend(row);
902                                self.push_component_result(
903                                    out,
904                                    component,
905                                    shape,
906                                    focus,
907                                    path,
908                                    value,
909                                    &binds,
910                                    &validator.messages,
911                                    inherited,
912                                );
913                            }
914                        }
915                        Err(_) => self.push_component_result(
916                            out,
917                            component,
918                            shape,
919                            focus,
920                            path_term.clone(),
921                            None,
922                            &bindings,
923                            &validator.messages,
924                            inherited,
925                        ),
926                    }
927                }
928            }
929        }
930    }
931
932    #[allow(clippy::too_many_arguments)]
933    fn push_component_result(
934        &self,
935        out: &mut Vec<ValidationResult>,
936        component: &CustomComponent,
937        shape: &NamedOrBlankNode,
938        focus: &Term,
939        path: Option<Term>,
940        value: Option<Term>,
941        bindings: &[(String, Term)],
942        validator_messages: &[Term],
943        inherited: &[Term],
944    ) {
945        let raw = if validator_messages.is_empty() {
946            self.messages_or_inherited(shape, inherited)
947        } else {
948            validator_messages.to_vec()
949        };
950        let bind_map: HashMap<String, Term> = bindings.iter().cloned().collect();
951        let messages = substitute_messages(&raw, focus, &bind_map);
952        out.push(ValidationResult {
953            focus: focus.clone(),
954            path,
955            value,
956            component: component.iri.clone(),
957            source_shape: node_term_ref(shape),
958            severity: self.severity(shape),
959            messages,
960        });
961    }
962
963    /// `sh:expression` constraints (SHACL-AF §5). The node expression is
964    /// evaluated with the focus node as `?this`; every produced value that is
965    /// not the boolean `true` yields one `sh:ExpressionConstraintComponent`
966    /// result whose `sh:value` is that value. Expression forms the report path
967    /// cannot evaluate (function applications) are skipped, matching the
968    /// lowering path which diagnoses them rather than under-constraining.
969    fn collect_expression(
970        &self,
971        shape: &NamedOrBlankNode,
972        focus: &Term,
973        out: &mut Vec<ValidationResult>,
974        visited: &mut Visited,
975        inherited: &[Term],
976    ) {
977        for expr_term in self.shapes.objects(shape, vocab::SH_EXPRESSION) {
978            let Some(results) = self.eval_node_expr(&expr_term, focus, visited) else {
979                continue;
980            };
981            for value in results {
982                if is_boolean_true(&value) {
983                    continue;
984                }
985                out.push(ValidationResult {
986                    focus: focus.clone(),
987                    path: None,
988                    value: Some(value),
989                    component: vocab::SH_CC_EXPRESSION.into_owned(),
990                    source_shape: node_term_ref(shape),
991                    severity: self.severity(shape),
992                    messages: self.messages_or_inherited(shape, inherited),
993                });
994            }
995        }
996    }
997
998    /// Evaluate a SHACL-AF node expression term (read straight from the shapes
999    /// graph) with `focus` as `?this`. `None` means the expression uses a form
1000    /// the report path cannot evaluate (e.g. a function application), so the
1001    /// caller skips the owning constraint.
1002    fn eval_node_expr(
1003        &self,
1004        term: &Term,
1005        focus: &Term,
1006        visited: &mut Visited,
1007    ) -> Option<Vec<Term>> {
1008        match term {
1009            Term::NamedNode(n) if n.as_ref() == vocab::SH_THIS => Some(vec![focus.clone()]),
1010            Term::NamedNode(_) | Term::Literal(_) => Some(vec![term.clone()]),
1011            Term::BlankNode(_) => {
1012                let node = term_to_node(term)?;
1013                if let Some(path_term) = self.shapes.object(&node, vocab::SH_PATH) {
1014                    let path = parse_path(self.shapes, &path_term).ok()?;
1015                    Some(succ(self.frozen(), focus, &path).into_iter().collect())
1016                } else if let Some(filter_shape) = self.shapes.object(&node, vocab::SH_FILTER_SHAPE)
1017                {
1018                    let filter_shape = term_to_node(&filter_shape)?;
1019                    let nodes_term = self.shapes.object(&node, vocab::SH_NODES)?;
1020                    let inputs = self.eval_node_expr(&nodes_term, focus, visited)?;
1021                    Some(
1022                        inputs
1023                            .into_iter()
1024                            .filter(|x| self.conforms(&filter_shape, x, visited))
1025                            .collect(),
1026                    )
1027                } else if let Some(list) = self.shapes.object(&node, vocab::SH_INTERSECTION) {
1028                    self.eval_node_expr_set(&list, focus, visited, true)
1029                } else if let Some(list) = self.shapes.object(&node, vocab::SH_UNION) {
1030                    self.eval_node_expr_set(&list, focus, visited, false)
1031                } else {
1032                    // Function application or unrecognized form: unsupported here.
1033                    None
1034                }
1035            }
1036        }
1037    }
1038
1039    /// Evaluate the members of an `sh:intersection` / `sh:union` list and combine
1040    /// them (`intersect = true` for intersection, set union otherwise),
1041    /// preserving each member's order while deduplicating.
1042    fn eval_node_expr_set(
1043        &self,
1044        list_head: &Term,
1045        focus: &Term,
1046        visited: &mut Visited,
1047        intersect: bool,
1048    ) -> Option<Vec<Term>> {
1049        let members = self.shapes.read_list(list_head);
1050        if members.is_empty() {
1051            return None;
1052        }
1053        let mut iter = members.iter();
1054        let mut acc = self.eval_node_expr(iter.next().unwrap(), focus, visited)?;
1055        for member in iter {
1056            let next = self.eval_node_expr(member, focus, visited)?;
1057            if intersect {
1058                acc.retain(|x| next.contains(x));
1059            } else {
1060                for t in next {
1061                    if !acc.contains(&t) {
1062                        acc.push(t);
1063                    }
1064                }
1065            }
1066        }
1067        Some(acc)
1068    }
1069
1070    /// `sh:sparql` constraints (SHACL-SPARQL). Each `SELECT`/`ASK` query runs for
1071    /// the focus node against the context store; every solution (or a `true`
1072    /// `ASK`) is one `sh:SPARQLConstraintComponent` result. A `value`/`path`
1073    /// projected by the query overrides the value node / `sh:resultPath`.
1074    /// Build the [`SparqlConstraint`] for a `sh:sparql` constraint node, applying
1075    /// the same canonicalization the lowering path uses. `None` when the node has
1076    /// neither `sh:select` nor `sh:ask`, or when canonicalization fails (matching
1077    /// the lowering path, which omits such constraints with a diagnostic).
1078    fn build_sparql_constraint(
1079        &self,
1080        shape: &NamedOrBlankNode,
1081        constraint_node: &NamedOrBlankNode,
1082        parsed_path: &Option<Path>,
1083    ) -> Option<SparqlConstraint> {
1084        let (kind, raw) = if let Some(Term::Literal(query)) =
1085            self.shapes.object(constraint_node, vocab::SH_SELECT)
1086        {
1087            (SparqlQueryKind::Select, query.value().to_string())
1088        } else if let Some(Term::Literal(query)) =
1089            self.shapes.object(constraint_node, vocab::SH_ASK)
1090        {
1091            (SparqlQueryKind::Ask, query.value().to_string())
1092        } else {
1093            return None;
1094        };
1095        let (_, query) = canonical_sparql_query(self.shapes, constraint_node, &raw).ok()?;
1096        Some(SparqlConstraint {
1097            kind,
1098            query,
1099            path: parsed_path.clone(),
1100            shape: Some(node_term_ref(shape)),
1101            // The report path resolves messages itself, so the constraint's own
1102            // message slot is left empty here.
1103            messages: Vec::new(),
1104            extra_bindings: Vec::new(),
1105            bind_value_to_this: false,
1106        })
1107    }
1108
1109    /// Batch-evaluate a shape's direct `sh:sparql` constraints over its whole
1110    /// focus set before the per-focus walk, so fallback queries run once over a
1111    /// `VALUES` table (doc §189) rather than once per focus.
1112    fn prefetch_sparql(&self, shape: &NamedOrBlankNode, foci: &[Term]) {
1113        if !self.needs_sparql || foci.len() < 2 {
1114            return;
1115        }
1116        let (_, parsed_path) = self.shape_path(shape);
1117        for constraint_term in self.shapes.objects(shape, vocab::SH_SPARQL) {
1118            let Some(constraint_node) = term_to_node(&constraint_term) else {
1119                continue;
1120            };
1121            if let Some(constraint) =
1122                self.build_sparql_constraint(shape, &constraint_node, &parsed_path)
1123            {
1124                let _ = self.sparql.prefetch_constraint(&constraint, foci);
1125            }
1126        }
1127    }
1128
1129    fn collect_sparql(
1130        &self,
1131        shape: &NamedOrBlankNode,
1132        focus: &Term,
1133        path_term: &Option<Term>,
1134        parsed_path: &Option<Path>,
1135        out: &mut Vec<ValidationResult>,
1136        inherited: &[Term],
1137    ) {
1138        if !self.needs_sparql {
1139            return;
1140        }
1141        let sparql = &self.sparql;
1142        let severity = self.severity(shape);
1143        for constraint_term in self.shapes.objects(shape, vocab::SH_SPARQL) {
1144            let Some(constraint_node) = term_to_node(&constraint_term) else {
1145                continue;
1146            };
1147            let Some(constraint) =
1148                self.build_sparql_constraint(shape, &constraint_node, parsed_path)
1149            else {
1150                continue;
1151            };
1152            // Mirror lower.rs §179-184: constraint-node sh:message takes
1153            // precedence; absent that, fall back to the owning shape's
1154            // sh:message, then to the nearest enclosing shape's.
1155            let raw_messages = {
1156                let on_constraint = self.shapes.objects(&constraint_node, vocab::SH_MESSAGE);
1157                if on_constraint.is_empty() {
1158                    self.messages_or_inherited(shape, inherited)
1159                } else {
1160                    on_constraint
1161                }
1162            };
1163            match sparql.constraint_violations(&constraint, focus) {
1164                Ok(violations) => {
1165                    for violation in violations {
1166                        let messages =
1167                            substitute_messages(&raw_messages, focus, &violation.bindings);
1168                        // SHACL-AF §8.4.1: for SELECT constraints, when ?value is
1169                        // not projected, the focus node itself is used as sh:value.
1170                        let value = violation.value.or_else(|| match constraint.kind {
1171                            SparqlQueryKind::Select => Some(focus.clone()),
1172                            SparqlQueryKind::Ask => None,
1173                        });
1174                        out.push(ValidationResult {
1175                            focus: focus.clone(),
1176                            path: violation.path.or_else(|| path_term.clone()),
1177                            value,
1178                            component: vocab::SH_CC_SPARQL.into_owned(),
1179                            source_shape: node_term_ref(shape),
1180                            severity: severity.clone(),
1181                            messages,
1182                        });
1183                    }
1184                }
1185                // Runtime failure (e.g. an unsupported graph-reading function
1186                // under UnsupportedPolicy::Error, or complex-path prebinding):
1187                // fail closed, surfacing the error so it is not a silent miss.
1188                Err(error) => {
1189                    let mut messages = raw_messages;
1190                    messages.push(Term::Literal(Literal::new_simple_literal(format!(
1191                        "SPARQL constraint evaluation failed: {error}"
1192                    ))));
1193                    out.push(ValidationResult {
1194                        focus: focus.clone(),
1195                        path: path_term.clone(),
1196                        value: None,
1197                        component: vocab::SH_CC_SPARQL.into_owned(),
1198                        source_shape: node_term_ref(shape),
1199                        severity: severity.clone(),
1200                        messages,
1201                    });
1202                }
1203            }
1204        }
1205    }
1206
1207    fn collect_closed(
1208        &self,
1209        shape: &NamedOrBlankNode,
1210        focus: &Term,
1211        value_nodes: &[Term],
1212        out: &mut Vec<ValidationResult>,
1213        inherited: &[Term],
1214    ) {
1215        if !self.bool(shape, vocab::SH_CLOSED) {
1216            return;
1217        }
1218        let mut allowed = HashSet::new();
1219        for prop in self.shapes.objects(shape, vocab::SH_PROPERTY) {
1220            let Some(prop) = term_to_node(&prop) else {
1221                continue;
1222            };
1223            if let Some(Term::NamedNode(path)) = self.shapes.object(&prop, vocab::SH_PATH) {
1224                allowed.insert(path);
1225            }
1226        }
1227        for list in self.shapes.objects(shape, vocab::SH_IGNORED_PROPERTIES) {
1228            for term in self.shapes.read_list(&list) {
1229                if let Term::NamedNode(predicate) = term {
1230                    allowed.insert(predicate);
1231                }
1232            }
1233        }
1234        for value_node in value_nodes {
1235            for (predicate, object) in self.frozen().outgoing(value_node) {
1236                if allowed.contains(&predicate) {
1237                    continue;
1238                }
1239                out.push(ValidationResult {
1240                    focus: focus.clone(),
1241                    path: Some(Term::NamedNode(predicate)),
1242                    value: Some(object),
1243                    component: vocab::SH_CC_CLOSED.into_owned(),
1244                    source_shape: node_term_ref(shape),
1245                    severity: self.severity(shape),
1246                    messages: self.messages_or_inherited(shape, inherited),
1247                });
1248            }
1249        }
1250    }
1251
1252    #[allow(clippy::too_many_arguments)]
1253    fn collect_property_pairs(
1254        &self,
1255        shape: &NamedOrBlankNode,
1256        focus: &Term,
1257        path: &Option<Term>,
1258        value_nodes: &[Term],
1259        out: &mut Vec<ValidationResult>,
1260        inherited: &[Term],
1261    ) {
1262        for predicate in self.shapes.objects(shape, vocab::SH_EQUALS) {
1263            let Term::NamedNode(predicate) = predicate else {
1264                continue;
1265            };
1266            let other = succ(self.frozen(), focus, &Path::Pred(predicate));
1267            for value in value_nodes.iter().filter(|value| !other.contains(*value)) {
1268                self.push(
1269                    out,
1270                    shape,
1271                    focus,
1272                    path.clone(),
1273                    Some((*value).clone()),
1274                    vocab::SH_CC_EQUALS,
1275                    inherited,
1276                );
1277            }
1278            for value in other.iter().filter(|value| !value_nodes.contains(*value)) {
1279                self.push(
1280                    out,
1281                    shape,
1282                    focus,
1283                    path.clone(),
1284                    Some(value.clone()),
1285                    vocab::SH_CC_EQUALS,
1286                    inherited,
1287                );
1288            }
1289        }
1290        for predicate in self.shapes.objects(shape, vocab::SH_DISJOINT) {
1291            let Term::NamedNode(predicate) = predicate else {
1292                continue;
1293            };
1294            let other = succ(self.frozen(), focus, &Path::Pred(predicate));
1295            for value in value_nodes.iter().filter(|value| other.contains(*value)) {
1296                self.push(
1297                    out,
1298                    shape,
1299                    focus,
1300                    path.clone(),
1301                    Some((*value).clone()),
1302                    vocab::SH_CC_DISJOINT,
1303                    inherited,
1304                );
1305            }
1306        }
1307        for (constraint, component, inclusive) in [
1308            (vocab::SH_LESS_THAN, vocab::SH_CC_LESS_THAN, false),
1309            (
1310                vocab::SH_LESS_THAN_OR_EQUALS,
1311                vocab::SH_CC_LESS_THAN_OR_EQUALS,
1312                true,
1313            ),
1314        ] {
1315            for predicate in self.shapes.objects(shape, constraint) {
1316                let Term::NamedNode(predicate) = predicate else {
1317                    continue;
1318                };
1319                for left in value_nodes {
1320                    for right in succ(self.frozen(), focus, &Path::Pred(predicate.clone())) {
1321                        let ordering = compare_terms(left, &right);
1322                        let passes = ordering == Some(Ordering::Less)
1323                            || inclusive && ordering == Some(Ordering::Equal);
1324                        if !passes {
1325                            self.push(
1326                                out,
1327                                shape,
1328                                focus,
1329                                path.clone(),
1330                                Some(left.clone()),
1331                                component,
1332                                inherited,
1333                            );
1334                        }
1335                    }
1336                }
1337            }
1338        }
1339    }
1340
1341    #[allow(clippy::too_many_arguments)]
1342    fn collect_unique_lang(
1343        &self,
1344        shape: &NamedOrBlankNode,
1345        focus: &Term,
1346        path: &Option<Term>,
1347        value_nodes: &[Term],
1348        out: &mut Vec<ValidationResult>,
1349        inherited: &[Term],
1350    ) {
1351        if !self.bool(shape, vocab::SH_UNIQUE_LANG) {
1352            return;
1353        }
1354        let mut counts = HashMap::new();
1355        for value in value_nodes {
1356            if let Term::Literal(literal) = value
1357                && let Some(language) = literal.language()
1358            {
1359                *counts
1360                    .entry(language.to_ascii_lowercase())
1361                    .or_insert(0usize) += 1;
1362            }
1363        }
1364        for _ in counts.values().filter(|count| **count > 1) {
1365            self.push(
1366                out,
1367                shape,
1368                focus,
1369                path.clone(),
1370                None,
1371                vocab::SH_CC_UNIQUE_LANG,
1372                inherited,
1373            );
1374        }
1375    }
1376
1377    #[allow(clippy::too_many_arguments)]
1378    fn collect_qualified_counts(
1379        &self,
1380        shape: &NamedOrBlankNode,
1381        focus: &Term,
1382        path: &Option<Term>,
1383        value_nodes: &[Term],
1384        out: &mut Vec<ValidationResult>,
1385        visited: &mut Visited,
1386        inherited: &[Term],
1387    ) {
1388        for qualifier in self.shapes.objects(shape, vocab::SH_QUALIFIED_VALUE_SHAPE) {
1389            let Some(qualifier) = term_to_node(&qualifier) else {
1390                continue;
1391            };
1392            let siblings = if self.bool(shape, vocab::SH_QUALIFIED_VALUE_SHAPES_DISJOINT) {
1393                self.sibling_qualified_shapes(shape, &qualifier)
1394            } else {
1395                Vec::new()
1396            };
1397            let count = value_nodes
1398                .iter()
1399                .filter(|value| {
1400                    self.conforms(&qualifier, value, visited)
1401                        && siblings
1402                            .iter()
1403                            .all(|sibling| !self.conforms(sibling, value, visited))
1404                })
1405                .count() as u64;
1406            if let Some(min) = self.int(shape, vocab::SH_QUALIFIED_MIN_COUNT)
1407                && count < min
1408            {
1409                self.push(
1410                    out,
1411                    shape,
1412                    focus,
1413                    path.clone(),
1414                    None,
1415                    vocab::SH_CC_QUALIFIED_MIN_COUNT,
1416                    inherited,
1417                );
1418            }
1419            if let Some(max) = self.int(shape, vocab::SH_QUALIFIED_MAX_COUNT)
1420                && count > max
1421            {
1422                self.push(
1423                    out,
1424                    shape,
1425                    focus,
1426                    path.clone(),
1427                    None,
1428                    vocab::SH_CC_QUALIFIED_MAX_COUNT,
1429                    inherited,
1430                );
1431            }
1432        }
1433    }
1434
1435    fn sibling_qualified_shapes(
1436        &self,
1437        shape: &NamedOrBlankNode,
1438        qualifier: &NamedOrBlankNode,
1439    ) -> Vec<NamedOrBlankNode> {
1440        let shape_term = node_term_ref(shape);
1441        let mut siblings = HashSet::new();
1442        for triple in self.shapes.graph.triples_for_predicate(vocab::SH_PROPERTY) {
1443            if triple.object != shape_term.as_ref() {
1444                continue;
1445            }
1446            let parent = triple.subject.into_owned();
1447            for property in self.shapes.objects(&parent, vocab::SH_PROPERTY) {
1448                let Some(property) = term_to_node(&property) else {
1449                    continue;
1450                };
1451                for qualifier in self
1452                    .shapes
1453                    .objects(&property, vocab::SH_QUALIFIED_VALUE_SHAPE)
1454                {
1455                    if let Some(qualifier) = term_to_node(&qualifier) {
1456                        siblings.insert(qualifier);
1457                    }
1458                }
1459            }
1460        }
1461        siblings.remove(qualifier);
1462        siblings.into_iter().collect()
1463    }
1464
1465    #[allow(clippy::too_many_arguments)]
1466    fn push(
1467        &self,
1468        out: &mut Vec<ValidationResult>,
1469        shape: &NamedOrBlankNode,
1470        focus: &Term,
1471        path: Option<Term>,
1472        value: Option<Term>,
1473        component: NamedNodeRef<'static>,
1474        inherited: &[Term],
1475    ) {
1476        let mut bindings = HashMap::new();
1477        if let Some(v) = &value {
1478            bindings.insert("value".to_string(), v.clone());
1479        }
1480        if let Some(p) = &path {
1481            bindings.insert("path".to_string(), p.clone());
1482        }
1483        let raw = self.messages_or_inherited(shape, inherited);
1484        let messages = substitute_messages(&raw, focus, &bindings);
1485        out.push(ValidationResult {
1486            focus: focus.clone(),
1487            path,
1488            value,
1489            component: component.into_owned(),
1490            source_shape: node_term_ref(shape),
1491            severity: self.severity(shape),
1492            messages,
1493        });
1494    }
1495
1496    /// Read `sh:message` values from `shape` to propagate as `sh:resultMessage`.
1497    fn messages(&self, shape: &NamedOrBlankNode) -> Vec<Term> {
1498        self.shapes.objects(shape, vocab::SH_MESSAGE)
1499    }
1500
1501    fn conforms(&self, shape: &NamedOrBlankNode, focus: &Term, visited: &mut Visited) -> bool {
1502        let mut scratch = Vec::new();
1503        self.collect(shape, focus, &mut scratch, visited, &[]);
1504        scratch.is_empty()
1505    }
1506
1507    /// Each value-scoped constraint component on `shape` and whether it holds at
1508    /// value node `u`. `sh:and`/`or`/`not`/`node` report as a unit.
1509    fn value_checks(
1510        &self,
1511        shape: &NamedOrBlankNode,
1512        u: &Term,
1513        visited: &mut Visited,
1514    ) -> Vec<(NamedNode, bool)> {
1515        let mut checks = Vec::new();
1516
1517        for c in self.shapes.objects(shape, vocab::SH_CLASS) {
1518            checks.push((vocab::SH_CC_CLASS.into_owned(), self.is_instance(u, &c)));
1519        }
1520        for d in self.shapes.objects(shape, vocab::SH_DATATYPE) {
1521            if let Term::NamedNode(dt) = d {
1522                let ok = value_type_holds(&ValueType::Datatype(dt), u);
1523                checks.push((vocab::SH_CC_DATATYPE.into_owned(), ok));
1524            }
1525        }
1526        for k in self.shapes.objects(shape, vocab::SH_NODE_KIND) {
1527            if let Some(set) = map_node_kind(&k) {
1528                checks.push((vocab::SH_CC_NODE_KIND.into_owned(), set.matches(u)));
1529            }
1530        }
1531        // numeric ranges (each bound is its own component)
1532        for (pred_iri, comp, inclusive) in [
1533            (vocab::SH_MIN_INCLUSIVE, vocab::SH_CC_MIN_INCLUSIVE, true),
1534            (vocab::SH_MIN_EXCLUSIVE, vocab::SH_CC_MIN_EXCLUSIVE, false),
1535        ] {
1536            if let Some(Term::Literal(b)) = self.shapes.object(shape, pred_iri) {
1537                let vt = ValueType::NumericRange {
1538                    lo: Some(Bound {
1539                        value: b,
1540                        inclusive,
1541                    }),
1542                    hi: None,
1543                };
1544                checks.push((comp.into_owned(), value_type_holds(&vt, u)));
1545            }
1546        }
1547        for (pred_iri, comp, inclusive) in [
1548            (vocab::SH_MAX_INCLUSIVE, vocab::SH_CC_MAX_INCLUSIVE, true),
1549            (vocab::SH_MAX_EXCLUSIVE, vocab::SH_CC_MAX_EXCLUSIVE, false),
1550        ] {
1551            if let Some(Term::Literal(b)) = self.shapes.object(shape, pred_iri) {
1552                let vt = ValueType::NumericRange {
1553                    lo: None,
1554                    hi: Some(Bound {
1555                        value: b,
1556                        inclusive,
1557                    }),
1558                };
1559                checks.push((comp.into_owned(), value_type_holds(&vt, u)));
1560            }
1561        }
1562        // length / pattern
1563        let min_len = self.int(shape, vocab::SH_MIN_LENGTH);
1564        let max_len = self.int(shape, vocab::SH_MAX_LENGTH);
1565        if let Some(m) = min_len {
1566            let vt = ValueType::Length {
1567                min: Some(m),
1568                max: None,
1569            };
1570            checks.push((
1571                vocab::SH_CC_MIN_LENGTH.into_owned(),
1572                value_type_holds(&vt, u),
1573            ));
1574        }
1575        if let Some(m) = max_len {
1576            let vt = ValueType::Length {
1577                min: None,
1578                max: Some(m),
1579            };
1580            checks.push((
1581                vocab::SH_CC_MAX_LENGTH.into_owned(),
1582                value_type_holds(&vt, u),
1583            ));
1584        }
1585        if let Some(Term::Literal(re)) = self.shapes.object(shape, vocab::SH_PATTERN) {
1586            let flags = match self.shapes.object(shape, vocab::SH_FLAGS) {
1587                Some(Term::Literal(f)) => f.value().to_string(),
1588                _ => String::new(),
1589            };
1590            let vt = ValueType::Pattern {
1591                regex: re.value().to_string(),
1592                flags,
1593            };
1594            checks.push((vocab::SH_CC_PATTERN.into_owned(), value_type_holds(&vt, u)));
1595        }
1596        // sh:in
1597        for list in self.shapes.objects(shape, vocab::SH_IN) {
1598            let members = self.shapes.read_list(&list);
1599            checks.push((vocab::SH_CC_IN.into_owned(), members.contains(u)));
1600        }
1601        for list in self.shapes.objects(shape, vocab::SH_LANGUAGE_IN) {
1602            let languages = self
1603                .shapes
1604                .read_list(&list)
1605                .into_iter()
1606                .filter_map(|term| match term {
1607                    Term::Literal(literal) => Some(literal.value().to_string()),
1608                    _ => None,
1609                })
1610                .collect();
1611            checks.push((
1612                vocab::SH_CC_LANGUAGE_IN.into_owned(),
1613                value_type_holds(&ValueType::LangIn(languages), u),
1614            ));
1615        }
1616
1617        // logical (unit results)
1618        for list in self.shapes.objects(shape, vocab::SH_AND) {
1619            let ok = self
1620                .shapes
1621                .read_list(&list)
1622                .iter()
1623                .filter_map(term_to_node)
1624                .all(|m| self.conforms(&m, u, visited));
1625            checks.push((vocab::SH_CC_AND.into_owned(), ok));
1626        }
1627        for list in self.shapes.objects(shape, vocab::SH_OR) {
1628            let ok = self
1629                .shapes
1630                .read_list(&list)
1631                .iter()
1632                .filter_map(term_to_node)
1633                .any(|m| self.conforms(&m, u, visited));
1634            checks.push((vocab::SH_CC_OR.into_owned(), ok));
1635        }
1636        for list in self.shapes.objects(shape, vocab::SH_XONE) {
1637            let count = self
1638                .shapes
1639                .read_list(&list)
1640                .iter()
1641                .filter_map(term_to_node)
1642                .filter(|m| self.conforms(m, u, visited))
1643                .count();
1644            checks.push((vocab::SH_CC_XONE.into_owned(), count == 1));
1645        }
1646        for n in self.shapes.objects(shape, vocab::SH_NOT) {
1647            if let Some(nn) = term_to_node(&n) {
1648                checks.push((
1649                    vocab::SH_CC_NOT.into_owned(),
1650                    !self.conforms(&nn, u, visited),
1651                ));
1652            }
1653        }
1654        for n in self.shapes.objects(shape, vocab::SH_NODE) {
1655            if let Some(nn) = term_to_node(&n) {
1656                checks.push((
1657                    vocab::SH_CC_NODE.into_owned(),
1658                    self.conforms(&nn, u, visited),
1659                ));
1660            }
1661        }
1662
1663        checks
1664    }
1665
1666    fn is_instance(&self, u: &Term, class: &Term) -> bool {
1667        succ(self.frozen(), u, &class_path()).contains(class)
1668    }
1669
1670    fn int(&self, s: &NamedOrBlankNode, p: NamedNodeRef) -> Option<u64> {
1671        match self.shapes.object(s, p) {
1672            Some(Term::Literal(l)) => l.value().parse().ok(),
1673            _ => None,
1674        }
1675    }
1676
1677    fn bool(&self, s: &NamedOrBlankNode, p: NamedNodeRef) -> bool {
1678        matches!(
1679            self.shapes.object(s, p),
1680            Some(Term::Literal(ref literal)) if matches!(literal.value(), "true" | "1")
1681        )
1682    }
1683
1684    /// `sh:resultSeverity` for results from `shape`: its declared `sh:severity`
1685    /// (an IRI such as `sh:Warning`/`sh:Info`), defaulting to `sh:Violation`.
1686    fn severity(&self, shape: &NamedOrBlankNode) -> NamedNode {
1687        match self.shapes.object(shape, vocab::SH_SEVERITY) {
1688            Some(Term::NamedNode(n)) => n,
1689            _ => vocab::SH_VIOLATION.into_owned(),
1690        }
1691    }
1692}
1693
1694fn is_shape_node(shapes: &Loaded, node: &NamedOrBlankNode) -> bool {
1695    shapes.has_type(node, vocab::SH_NODE_SHAPE)
1696        || shapes.has_type(node, vocab::SH_PROPERTY_SHAPE)
1697        || [
1698            vocab::SH_PROPERTY,
1699            vocab::SH_NODE,
1700            vocab::SH_AND,
1701            vocab::SH_OR,
1702            vocab::SH_NOT,
1703            vocab::SH_XONE,
1704            vocab::SH_DATATYPE,
1705            vocab::SH_CLASS,
1706            vocab::SH_NODE_KIND,
1707            vocab::SH_IN,
1708            vocab::SH_HAS_VALUE,
1709            vocab::SH_PROPERTY,
1710        ]
1711        .iter()
1712        .any(|predicate| shapes.object(node, *predicate).is_some())
1713}
1714
1715/// Whether any `sh:select` / `sh:ask` query references `$shapesGraph`, so the
1716/// shapes graph must be mirrored into a named graph for evaluation.
1717fn shapes_reference_shapes_graph(shapes: &Loaded) -> bool {
1718    [vocab::SH_SELECT, vocab::SH_ASK].iter().any(|predicate| {
1719        shapes.graph.triples_for_predicate(*predicate).any(
1720            |t| matches!(t.object, oxrdf::TermRef::Literal(l) if l.value().contains("shapesGraph")),
1721        )
1722    })
1723}
1724
1725fn class_path() -> Path {
1726    Path::seq(vec![
1727        Path::Pred(vocab::rdf_type()),
1728        Path::star(Path::Pred(vocab::rdfs_subclassof())),
1729    ])
1730}
1731
1732/// Index `class → focus-data instances` under `rdf:type / rdfs:subClassOf*`.
1733///
1734/// One pass over the `rdf:type` triples replaces the per-shape forward scan
1735/// (`graph_nodes(data).filter(node is instance of c)`), which was
1736/// `O(shapes × nodes × type-closure)`. Each instance is attributed to every
1737/// superclass of its declared type, and the reflexive `subClassOf*` closure of
1738/// each distinct type is computed at most once. Only nodes present in the focus
1739/// (data) graph are indexed, matching the original target-selection semantics.
1740fn build_class_index(
1741    focus_data: &Graph,
1742    frozen: &FrozenIndexedDataset,
1743) -> HashMap<Term, Vec<Term>> {
1744    let focus_nodes = graph_nodes(focus_data);
1745    let subclass_star = Path::star(Path::Pred(vocab::rdfs_subclassof()));
1746    let mut supers: HashMap<Term, Vec<Term>> = HashMap::new();
1747    let mut index: HashMap<Term, Vec<Term>> = HashMap::new();
1748    let mut seen: HashSet<(Term, Term)> = HashSet::new();
1749    for (node, ty) in frozen.triples_for_predicate(&vocab::rdf_type()) {
1750        if !focus_nodes.contains(&node) {
1751            continue;
1752        }
1753        let classes = supers
1754            .entry(ty.clone())
1755            .or_insert_with(|| succ(frozen, &ty, &subclass_star).into_iter().collect());
1756        for class in classes.iter() {
1757            if seen.insert((class.clone(), node.clone())) {
1758                index.entry(class.clone()).or_default().push(node.clone());
1759            }
1760        }
1761    }
1762    index
1763}
1764
1765fn graph_nodes(graph: &Graph) -> HashSet<Term> {
1766    let mut nodes = HashSet::new();
1767    for triple in graph.iter() {
1768        nodes.insert(node_term(triple.subject));
1769        nodes.insert(triple.object.into_owned());
1770    }
1771    nodes
1772}
1773
1774fn node_term(s: oxrdf::NamedOrBlankNodeRef) -> Term {
1775    crate::path::term_of(s.into_owned())
1776}
1777
1778fn node_term_ref(s: &NamedOrBlankNode) -> Term {
1779    match s {
1780        NamedOrBlankNode::NamedNode(n) => Term::NamedNode(n.clone()),
1781        NamedOrBlankNode::BlankNode(b) => Term::BlankNode(b.clone()),
1782    }
1783}
1784
1785fn map_node_kind(term: &Term) -> Option<NodeKindSet> {
1786    let Term::NamedNode(n) = term else {
1787        return None;
1788    };
1789    let r = n.as_ref();
1790    Some(if r == vocab::SH_IRI {
1791        NodeKindSet::IRI
1792    } else if r == vocab::SH_BLANK_NODE {
1793        NodeKindSet::BLANK_NODE
1794    } else if r == vocab::SH_LITERAL {
1795        NodeKindSet::LITERAL
1796    } else if r == vocab::SH_BLANK_NODE_OR_IRI {
1797        NodeKindSet::BLANK_NODE_OR_IRI
1798    } else if r == vocab::SH_BLANK_NODE_OR_LITERAL {
1799        NodeKindSet::BLANK_NODE_OR_LITERAL
1800    } else if r == vocab::SH_IRI_OR_LITERAL {
1801        NodeKindSet::IRI_OR_LITERAL
1802    } else {
1803        return None;
1804    })
1805}