Skip to main content

shifty_engine/
report.rs

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