Skip to main content

shifty_engine/
report.rs

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