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