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