1use std::collections::{BTreeMap, BTreeSet};
30
31use crate::ontology::rdf::{ont, Graph, Term, RDF_TYPE};
32
33pub const XSD_NS: &str = "http://www.w3.org/2001/XMLSchema#";
34pub const RDF_NS: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
35pub const PROV_NS: &str = "http://www.w3.org/ns/prov#";
36
37#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum ShapeError {
40 Unsupported { shape: String, component: String },
42 Malformed { shape: String, what: String },
44}
45
46impl std::fmt::Display for ShapeError {
47 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 match self {
49 Self::Unsupported { shape, component } => {
50 write!(f, "shape {shape} uses unsupported {component}")
51 }
52 Self::Malformed { shape, what } => write!(f, "shape {shape} is malformed: {what}"),
53 }
54 }
55}
56
57impl std::error::Error for ShapeError {}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum NodeKind {
61 Iri,
62 Literal,
63}
64
65#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
66pub enum Severity {
67 Warning,
68 Violation,
69}
70
71pub const XSD_STRING_IRI: &str = "http://www.w3.org/2001/XMLSchema#string";
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InEntry {
77 pub lexical: String,
78 pub datatype: String,
79}
80
81impl InEntry {
82 #[must_use]
84 pub fn matches_literal(&self, value: &str, datatype: &str) -> bool {
85 self.lexical == value && self.datatype == datatype
86 }
87 #[must_use]
89 pub fn matches_iri(&self, iri: &str) -> bool {
90 self.lexical == iri || expand(&self.lexical) == iri
91 }
92}
93
94impl std::fmt::Display for InEntry {
95 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96 if self.datatype == XSD_STRING_IRI {
97 write!(f, "{:?}", self.lexical)
98 } else {
99 write!(f, "{:?}^^{}", self.lexical, short(&self.datatype))
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
106pub struct PropertyShape {
107 pub path: String,
108 pub min_count: Option<usize>,
109 pub max_count: Option<usize>,
110 pub datatype: Option<String>,
111 pub class: Option<String>,
112 pub node_kind: Option<NodeKind>,
113 pub r#in: Option<Vec<InEntry>>,
114 pub pattern: Option<(String, regex::Regex)>,
115 pub min_length: Option<usize>,
116 pub max_length: Option<usize>,
117 pub node: Option<Box<NodeShape>>,
118 pub resolves: Option<String>,
119 pub severity: Severity,
120}
121
122#[derive(Debug, Clone)]
124pub struct NodeShape {
125 pub id: String,
127 pub target_class: String,
128 pub closed: bool,
129 pub ignored_properties: Vec<String>,
130 pub properties: Vec<PropertyShape>,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
135pub struct ValidationResult {
136 pub severity: Severity,
137 pub focus: String,
138 pub shape: String,
139 pub path: Option<String>,
140 pub component: &'static str,
141 pub message: String,
142}
143
144#[derive(Debug, Clone, Default)]
146pub struct Report {
147 pub results: Vec<ValidationResult>,
148 pub focus_nodes_n: usize,
149}
150
151impl Report {
152 #[must_use]
153 pub fn violations(&self) -> usize {
154 self.results
155 .iter()
156 .filter(|r| r.severity == Severity::Violation)
157 .count()
158 }
159 #[must_use]
160 pub fn warnings(&self) -> usize {
161 self.results
162 .iter()
163 .filter(|r| r.severity == Severity::Warning)
164 .count()
165 }
166 #[must_use]
167 pub fn conforms(&self) -> bool {
168 self.violations() == 0
169 }
170}
171
172#[must_use]
174pub fn expand(name: &str) -> String {
175 if name.starts_with("http://") || name.starts_with("https://") {
176 return name.to_string();
177 }
178 match name.split_once(':') {
179 Some(("ont", local)) => ont(local),
180 Some(("xsd", local)) => format!("{XSD_NS}{local}"),
181 Some(("rdf", local)) => format!("{RDF_NS}{local}"),
182 Some(("prov", local)) => format!("{PROV_NS}{local}"),
183 Some((prefix, local)) => format!("{}{prefix}/{local}", crate::ontology::rdf::ONT_BASE),
184 None => ont(name),
185 }
186}
187
188const NODE_KEYS: &[&str] = &["targetClass", "closed", "ignoredProperties", "properties"];
189const PROPERTY_KEYS: &[&str] = &[
190 "path",
191 "minCount",
192 "maxCount",
193 "datatype",
194 "class",
195 "nodeKind",
196 "in",
197 "pattern",
198 "minLength",
199 "maxLength",
200 "node",
201 "resolves",
202 "severity",
203];
204
205pub fn parse_shape(stem: &str, doc: &serde_yaml::Value) -> Result<Option<NodeShape>, ShapeError> {
208 let Some(block) = doc.get("shape") else {
209 return Ok(None);
210 };
211 let Some(map) = block.as_mapping() else {
212 return Err(ShapeError::Malformed {
213 shape: stem.to_string(),
214 what: "`shape:` is not a mapping".into(),
215 });
216 };
217 parse_node_shape(stem, map, default_target(doc), 0).map(Some)
218}
219
220pub fn parse_shapes(stem: &str, doc: &serde_yaml::Value) -> Result<Vec<NodeShape>, ShapeError> {
225 let mut out = Vec::new();
226 if let Some(s) = parse_shape(stem, doc)? {
227 out.push(s);
228 }
229 let Some(list) = doc.get("shapes") else {
230 return Ok(out);
231 };
232 let seq = list.as_sequence().ok_or_else(|| ShapeError::Malformed {
233 shape: stem.to_string(),
234 what: "`shapes:` is not a list".into(),
235 })?;
236 for (i, entry) in seq.iter().enumerate() {
237 let map = entry.as_mapping().ok_or_else(|| ShapeError::Malformed {
238 shape: stem.to_string(),
239 what: format!("shapes[{i}] is not a mapping"),
240 })?;
241 let id = map
242 .get("id")
243 .and_then(serde_yaml::Value::as_str)
244 .ok_or_else(|| ShapeError::Malformed {
245 shape: stem.to_string(),
246 what: format!("shapes[{i}] has no `id`"),
247 })?;
248 if out.iter().any(|s| s.id == id) {
249 return Err(ShapeError::Malformed {
250 shape: stem.to_string(),
251 what: format!("shape id `{id}` repeats"),
252 });
253 }
254 let mut body = map.clone();
255 body.remove(serde_yaml::Value::String("id".into()));
256 out.push(parse_node_shape(id, &body, default_target(doc), 0)?);
257 }
258 Ok(out)
259}
260
261fn default_target(doc: &serde_yaml::Value) -> Option<String> {
262 doc.get("entity")
263 .and_then(|e| e.get("type"))
264 .and_then(serde_yaml::Value::as_str)
265 .filter(|t| *t == "pv-contract")
266 .map(|_| ont("Contract"))
267}
268
269fn parse_node_shape(
270 id: &str,
271 map: &serde_yaml::Mapping,
272 default_target: Option<String>,
273 depth: usize,
274) -> Result<NodeShape, ShapeError> {
275 for key in map.keys() {
276 let k = key.as_str().unwrap_or("?");
277 if !NODE_KEYS.contains(&k) {
278 return Err(ShapeError::Unsupported {
279 shape: id.to_string(),
280 component: k.to_string(),
281 });
282 }
283 }
284 let target_class = match map.get("targetClass").and_then(serde_yaml::Value::as_str) {
285 Some(t) => expand(t),
286 None => match (depth, default_target) {
287 (0, Some(t)) => t,
288 (0, None) => {
289 return Err(ShapeError::Malformed {
290 shape: id.to_string(),
291 what: "no `targetClass`, and the contract's entity is not pv-contract".into(),
292 })
293 }
294 (_, _) => String::new(),
296 },
297 };
298 let closed = match map.get("closed") {
299 None => false,
300 Some(v) => v.as_bool().ok_or_else(|| ShapeError::Malformed {
301 shape: id.to_string(),
302 what: "`closed` is not a bool".into(),
303 })?,
304 };
305 let ignored_properties = match map.get("ignoredProperties") {
306 None => Vec::new(),
307 Some(v) => v
308 .as_sequence()
309 .ok_or_else(|| ShapeError::Malformed {
310 shape: id.to_string(),
311 what: "`ignoredProperties` is not a list".into(),
312 })?
313 .iter()
314 .filter_map(serde_yaml::Value::as_str)
315 .map(expand)
316 .collect(),
317 };
318 let mut properties = Vec::new();
319 if let Some(list) = map.get("properties") {
320 let seq = list.as_sequence().ok_or_else(|| ShapeError::Malformed {
321 shape: id.to_string(),
322 what: "`properties` is not a list".into(),
323 })?;
324 for (i, p) in seq.iter().enumerate() {
325 let pm = p.as_mapping().ok_or_else(|| ShapeError::Malformed {
326 shape: id.to_string(),
327 what: format!("properties[{i}] is not a mapping"),
328 })?;
329 properties.push(parse_property(id, pm, depth)?);
330 }
331 }
332 Ok(NodeShape {
333 id: id.to_string(),
334 target_class,
335 closed,
336 ignored_properties,
337 properties,
338 })
339}
340
341fn parse_property(
342 shape: &str,
343 pm: &serde_yaml::Mapping,
344 depth: usize,
345) -> Result<PropertyShape, ShapeError> {
346 let malformed = |what: String| ShapeError::Malformed {
347 shape: shape.to_string(),
348 what,
349 };
350 for key in pm.keys() {
351 let k = key.as_str().unwrap_or("?");
352 if !PROPERTY_KEYS.contains(&k) {
353 return Err(ShapeError::Unsupported {
354 shape: shape.to_string(),
355 component: k.to_string(),
356 });
357 }
358 }
359 let path = pm
360 .get("path")
361 .and_then(serde_yaml::Value::as_str)
362 .ok_or_else(|| malformed("a property has no `path`".into()))?;
363 if path.contains(['/', '|', '^', '*', '+']) && !path.starts_with("http") {
364 return Err(ShapeError::Unsupported {
365 shape: shape.to_string(),
366 component: format!("path `{path}` (only a single predicate is a path here)"),
367 });
368 }
369 let count = |k: &str| -> Result<Option<usize>, ShapeError> {
370 match pm.get(k) {
371 None => Ok(None),
372 Some(v) => v
373 .as_u64()
374 .and_then(|n| usize::try_from(n).ok())
375 .map(Some)
376 .ok_or_else(|| malformed(format!("`{k}` is not a non-negative integer"))),
377 }
378 };
379 let iri_opt = |k: &str| pm.get(k).and_then(serde_yaml::Value::as_str).map(expand);
380 let node_kind = parse_node_kind(
381 shape,
382 pm.get("nodeKind").and_then(serde_yaml::Value::as_str),
383 )?;
384 let r#in = match pm.get("in") {
391 None => None,
392 Some(v) => Some(
393 v.as_sequence()
394 .ok_or_else(|| malformed("`in` is not a list".into()))?
395 .iter()
396 .map(|x| match x {
397 serde_yaml::Value::String(s) => InEntry {
398 lexical: s.clone(),
399 datatype: XSD_STRING_IRI.to_string(),
400 },
401 serde_yaml::Value::Number(n) => InEntry {
402 lexical: n.to_string(),
403 datatype: if n.is_f64() {
404 format!("{XSD_NS}double")
405 } else {
406 format!("{XSD_NS}integer")
407 },
408 },
409 serde_yaml::Value::Bool(b) => InEntry {
410 lexical: b.to_string(),
411 datatype: format!("{XSD_NS}boolean"),
412 },
413 _ => InEntry {
414 lexical: String::new(),
415 datatype: XSD_STRING_IRI.to_string(),
416 },
417 })
418 .collect(),
419 ),
420 };
421 let pattern = match pm.get("pattern").and_then(serde_yaml::Value::as_str) {
422 None => None,
423 Some(p) => Some((
424 p.to_string(),
425 regex::Regex::new(p)
426 .map_err(|e| malformed(format!("`pattern` does not compile: {e}")))?,
427 )),
428 };
429 let node = match pm.get("node") {
430 None => None,
431 Some(_) if depth >= 1 => {
432 return Err(ShapeError::Unsupported {
433 shape: shape.to_string(),
434 component: "node (nested more than one level)".into(),
435 })
436 }
437 Some(v) => {
438 let nm = v
439 .as_mapping()
440 .ok_or_else(|| malformed("`node` is not a mapping".into()))?;
441 Some(Box::new(parse_node_shape(
442 &format!("{shape}/node"),
443 nm,
444 None,
445 depth + 1,
446 )?))
447 }
448 };
449 let severity = parse_severity(
450 shape,
451 pm.get("severity").and_then(serde_yaml::Value::as_str),
452 )?;
453 Ok(PropertyShape {
454 path: expand(path),
455 min_count: count("minCount")?,
456 max_count: count("maxCount")?,
457 datatype: iri_opt("datatype"),
458 class: iri_opt("class"),
459 node_kind,
460 r#in,
461 pattern,
462 min_length: count("minLength")?,
463 max_length: count("maxLength")?,
464 node,
465 resolves: pm
466 .get("resolves")
467 .and_then(serde_yaml::Value::as_str)
468 .map(String::from),
469 severity,
470 })
471}
472
473fn parse_node_kind(shape: &str, v: Option<&str>) -> Result<Option<NodeKind>, ShapeError> {
475 match v {
476 None => Ok(None),
477 Some("IRI" | "sh:IRI") => Ok(Some(NodeKind::Iri)),
478 Some("Literal" | "sh:Literal") => Ok(Some(NodeKind::Literal)),
479 Some(other) => Err(ShapeError::Unsupported {
480 shape: shape.to_string(),
481 component: format!("nodeKind {other}"),
482 }),
483 }
484}
485
486fn parse_severity(shape: &str, v: Option<&str>) -> Result<Severity, ShapeError> {
488 match v {
489 None | Some("violation" | "Violation") => Ok(Severity::Violation),
490 Some("warning" | "Warning") => Ok(Severity::Warning),
491 Some(other) => Err(ShapeError::Unsupported {
492 shape: shape.to_string(),
493 component: format!("severity {other}"),
494 }),
495 }
496}
497
498pub const RDFS_SUBCLASS_OF: &str = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
500
501#[must_use]
505pub fn is_instance(graph: &Graph, node: &str, class: &str) -> bool {
506 graph
507 .objects(node, RDF_TYPE)
508 .iter()
509 .filter_map(|t| t.as_iri())
510 .any(|t| t == class || is_subclass_of(graph, t, class))
511}
512
513fn is_subclass_of(graph: &Graph, sub: &str, class: &str) -> bool {
515 let mut seen: BTreeSet<String> = BTreeSet::new();
516 let mut stack = vec![sub.to_string()];
517 while let Some(c) = stack.pop() {
518 if !seen.insert(c.clone()) {
519 continue;
520 }
521 for sup in graph.objects(&c, RDFS_SUBCLASS_OF) {
522 if let Some(s) = sup.as_iri() {
523 if s == class {
524 return true;
525 }
526 stack.push(s.to_string());
527 }
528 }
529 }
530 false
531}
532
533#[must_use]
537pub fn instances_closed(graph: &Graph, class: &str) -> Vec<String> {
538 let mut subs: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
539 for t in graph.iter() {
540 if t.predicate == RDFS_SUBCLASS_OF {
541 if let Some(sup) = t.object.as_iri() {
542 subs.entry(sup).or_default().push(t.subject.as_str());
543 }
544 }
545 }
546 let mut at_or_below: BTreeSet<&str> = BTreeSet::new();
547 let mut stack = vec![class];
548 while let Some(c) = stack.pop() {
549 if !at_or_below.insert(c) {
550 continue;
551 }
552 if let Some(children) = subs.get(c) {
553 stack.extend(children.iter().copied());
554 }
555 }
556 let out: BTreeSet<String> = graph
557 .iter()
558 .filter(|t| {
559 t.predicate == RDF_TYPE && t.object.as_iri().is_some_and(|o| at_or_below.contains(o))
560 })
561 .map(|t| t.subject.clone())
562 .collect();
563 out.into_iter().collect()
564}
565
566#[must_use]
568pub fn validate(graph: &Graph, shapes: &[NodeShape]) -> Report {
569 let mut report = Report::default();
570 let mut focus_seen: BTreeSet<String> = BTreeSet::new();
571 for shape in shapes {
572 for focus in instances_closed(graph, &shape.target_class) {
573 let focus = focus.as_str();
574 focus_seen.insert(focus.to_string());
575 validate_focus(graph, shape, focus, &mut report.results);
576 }
577 }
578 report.focus_nodes_n = focus_seen.len();
579 report.results.sort();
580 report.results.dedup();
581 report
582}
583
584fn validate_focus(graph: &Graph, shape: &NodeShape, focus: &str, out: &mut Vec<ValidationResult>) {
585 let mut push =
586 |severity: Severity, path: Option<&str>, component: &'static str, message: String| {
587 out.push(ValidationResult {
588 severity,
589 focus: focus.to_string(),
590 shape: shape.id.clone(),
591 path: path.map(String::from),
592 component,
593 message,
594 });
595 };
596 for p in &shape.properties {
597 let values = graph.objects(focus, &p.path);
598 if let Some(min) = p.min_count {
599 if values.len() < min {
600 push(
601 p.severity,
602 Some(&p.path),
603 "minCount",
604 format!(
605 "has {} value(s) of {}, minCount is {min}",
606 values.len(),
607 short(&p.path)
608 ),
609 );
610 }
611 }
612 if let Some(max) = p.max_count {
613 if values.len() > max {
614 let named: Vec<String> = values
617 .iter()
618 .take(5)
619 .map(|v| match v {
620 Term::Iri(i) => short(i),
621 Term::Literal { value, .. } => value.clone(),
622 })
623 .collect();
624 push(
625 p.severity,
626 Some(&p.path),
627 "maxCount",
628 format!(
629 "has {} value(s) of {}, maxCount is {max}: {}",
630 values.len(),
631 short(&p.path),
632 named.join(", ")
633 ),
634 );
635 }
636 }
637 for v in values {
638 check_value(graph, p, v, &mut push);
639 }
640 }
641 if shape.closed {
642 let allowed: BTreeSet<&str> = shape
643 .properties
644 .iter()
645 .map(|p| p.path.as_str())
646 .chain(shape.ignored_properties.iter().map(String::as_str))
647 .chain(std::iter::once(RDF_TYPE))
648 .collect();
649 for pred in graph.predicates_of(focus) {
650 if !allowed.contains(pred) {
651 push(
652 Severity::Violation,
653 Some(pred),
654 "closed",
655 format!(
656 "carries {}, which the closed shape does not declare",
657 short(pred)
658 ),
659 );
660 }
661 }
662 }
663}
664
665fn check_value(
666 graph: &Graph,
667 p: &PropertyShape,
668 v: &Term,
669 push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
670) {
671 check_kind_and_type(graph, p, v, push);
672 check_lexical(p, v, push);
673 if let Some(inner) = &p.node {
674 match v.as_iri() {
675 Some(i) => {
676 let mut nested = Vec::new();
679 validate_focus(graph, inner, i, &mut nested);
680 let violations: Vec<String> = nested
681 .iter()
682 .filter(|r| r.severity == Severity::Violation)
683 .map(|r| r.message.clone())
684 .collect();
685 if !violations.is_empty() {
686 push(
687 p.severity,
688 Some(&p.path),
689 "node",
690 format!(
691 "value {i} fails the nested shape: {}",
692 violations.join("; ")
693 ),
694 );
695 }
696 }
697 None => push(
698 p.severity,
699 Some(&p.path),
700 "node",
701 format!("{v} is a literal; `node` needs an IRI"),
702 ),
703 }
704 }
705}
706
707#[must_use]
712pub fn well_formed(value: &str, datatype: &str) -> bool {
713 let Some(local) = datatype.strip_prefix(XSD_NS) else {
714 return true;
715 };
716 let int_in = |lo: i128, hi: i128| value.parse::<i128>().is_ok_and(|n| n >= lo && n <= hi);
717 match local {
718 "string" | "anyURI" => true,
719 "boolean" => matches!(value, "true" | "false" | "1" | "0"),
720 "integer" => value.parse::<i128>().is_ok(),
721 "long" => int_in(i128::from(i64::MIN), i128::from(i64::MAX)),
722 "int" => int_in(i128::from(i32::MIN), i128::from(i32::MAX)),
723 "short" => int_in(i128::from(i16::MIN), i128::from(i16::MAX)),
724 "byte" => int_in(i128::from(i8::MIN), i128::from(i8::MAX)),
725 "nonNegativeInteger" => int_in(0, i128::MAX),
726 "positiveInteger" => int_in(1, i128::MAX),
727 "nonPositiveInteger" => int_in(i128::MIN, 0),
728 "negativeInteger" => int_in(i128::MIN, -1),
729 "unsignedLong" => int_in(0, i128::from(u64::MAX)),
730 "unsignedInt" => int_in(0, i128::from(u32::MAX)),
731 "unsignedShort" => int_in(0, i128::from(u16::MAX)),
732 "unsignedByte" => int_in(0, i128::from(u8::MAX)),
733 "decimal" => {
734 !value.is_empty()
735 && value
736 .strip_prefix(['+', '-'])
737 .unwrap_or(value)
738 .chars()
739 .all(|c| c.is_ascii_digit() || c == '.')
740 && value.chars().filter(|c| *c == '.').count() <= 1
741 && value.chars().any(|c| c.is_ascii_digit())
742 }
743 "double" | "float" => {
744 matches!(value, "INF" | "-INF" | "NaN") || value.parse::<f64>().is_ok()
745 }
746 "date" => is_date(value),
747 "dateTime" => value
748 .split_once('T')
749 .is_some_and(|(d, t)| is_date(d) && t.len() >= 8 && t.as_bytes()[2] == b':'),
750 _ => true,
751 }
752}
753
754fn is_date(s: &str) -> bool {
756 let core = s.split(['Z', '+']).next().unwrap_or(s);
757 let core = if core.len() > 10 { &core[..10] } else { core };
758 let b = core.as_bytes();
759 b.len() == 10
760 && b[4] == b'-'
761 && b[7] == b'-'
762 && [0, 1, 2, 3, 5, 6, 8, 9]
763 .iter()
764 .all(|&i| b[i].is_ascii_digit())
765 && (1..=12).contains(&core[5..7].parse::<u8>().unwrap_or(0))
766 && (1..=31).contains(&core[8..10].parse::<u8>().unwrap_or(0))
767}
768
769fn check_kind_and_type(
771 graph: &Graph,
772 p: &PropertyShape,
773 v: &Term,
774 push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
775) {
776 let path = Some(p.path.as_str());
777 if let Some(kind) = p.node_kind {
778 let ok = match kind {
779 NodeKind::Iri => v.as_iri().is_some(),
780 NodeKind::Literal => v.as_literal().is_some(),
781 };
782 if !ok {
783 push(
784 p.severity,
785 path,
786 "nodeKind",
787 format!("{v} is not of nodeKind {kind:?}"),
788 );
789 }
790 }
791 if let Some(dt) = &p.datatype {
792 match v.as_literal() {
793 Some((value, actual)) if actual == dt && well_formed(value, dt) => {}
794 Some((value, actual)) if actual == dt => push(
795 p.severity,
796 path,
797 "datatype",
798 format!("\"{value}\" is not a well-formed {}", short(dt)),
799 ),
800 _ => push(
801 p.severity,
802 path,
803 "datatype",
804 format!("{v} is not a {}", short(dt)),
805 ),
806 }
807 }
808 if let Some(class) = &p.class {
809 let ok = v.as_iri().is_some_and(|i| is_instance(graph, i, class));
810 if !ok {
811 push(
812 p.severity,
813 path,
814 "class",
815 format!("{v} is not an instance of {}", short(class)),
816 );
817 }
818 }
819}
820
821fn check_lexical(
823 p: &PropertyShape,
824 v: &Term,
825 push: &mut impl FnMut(Severity, Option<&str>, &'static str, String),
826) {
827 let path = Some(p.path.as_str());
828 let lexical: &str = match v {
829 Term::Iri(i) => i.as_str(),
830 Term::Literal { value, .. } => value.as_str(),
831 };
832 if let Some(allowed) = &p.r#in {
833 let ok = match v {
834 Term::Iri(i) => allowed.iter().any(|a| a.matches_iri(i)),
835 Term::Literal { value, datatype } => {
836 allowed.iter().any(|a| a.matches_literal(value, datatype))
837 }
838 };
839 if !ok {
840 let listed: Vec<String> = allowed.iter().map(ToString::to_string).collect();
846 push(
847 p.severity,
848 path,
849 "in",
850 format!(
851 "{}: {} is not one of [{}]",
852 short(&p.path),
853 term_short(v),
854 listed.join(", ")
855 ),
856 );
857 }
858 }
859 if let Some((src, re)) = &p.pattern {
860 if !re.is_match(lexical) {
861 push(
862 p.severity,
863 path,
864 "pattern",
865 format!("{}: {lexical:?} does not match /{src}/", short(&p.path)),
866 );
867 }
868 }
869 let len = lexical.chars().count();
870 if p.min_length.is_some_and(|m| len < m) {
871 push(
872 p.severity,
873 path,
874 "minLength",
875 format!("{}: length {len} is below minLength", short(&p.path)),
876 );
877 }
878 if p.max_length.is_some_and(|m| len > m) {
879 push(
880 p.severity,
881 path,
882 "maxLength",
883 format!("{}: length {len} is above maxLength", short(&p.path)),
884 );
885 }
886}
887
888#[must_use]
892pub fn term_short(v: &Term) -> String {
893 match v {
894 Term::Iri(i) => short(i),
895 Term::Literal { value, datatype } if datatype == XSD_STRING_IRI => format!("{value:?}"),
896 Term::Literal { value, datatype } => format!("{value:?}^^{}", short(datatype)),
897 }
898}
899
900#[must_use]
902pub fn short(iri: &str) -> String {
903 for (ns, prefix) in [
904 (crate::ontology::rdf::ONT_BASE, "ont"),
905 (XSD_NS, "xsd"),
906 (RDF_NS, "rdf"),
907 (PROV_NS, "prov"),
908 ] {
909 if let Some(rest) = iri.strip_prefix(ns) {
910 return format!("{prefix}:{rest}");
911 }
912 }
913 iri.to_string()
914}
915
916#[must_use]
918pub fn to_turtle(shapes: &[NodeShape]) -> String {
919 let mut out = String::from(
920 "@prefix sh: <http://www.w3.org/ns/shacl#> .\n@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .\n@prefix ont: <https://ont.paiml.dev/v1alpha1/> .\n\n",
921 );
922 for s in shapes {
923 out.push_str(&turtle_node(
924 s,
925 &format!("<{}shape/{}>", crate::ontology::rdf::ONT_BASE, s.id),
926 ));
927 }
928 out
929}
930
931fn turtle_node(s: &NodeShape, subject: &str) -> String {
932 let mut o = format!("{subject} a sh:NodeShape ;\n");
933 if !s.target_class.is_empty() {
934 o.push_str(&format!(" sh:targetClass <{}> ;\n", s.target_class));
935 }
936 if s.closed {
937 o.push_str(" sh:closed true ;\n");
938 if !s.ignored_properties.is_empty() {
939 let list: Vec<String> = s
940 .ignored_properties
941 .iter()
942 .map(|p| format!("<{p}>"))
943 .collect();
944 o.push_str(&format!(
945 " sh:ignoredProperties ( {} ) ;\n",
946 list.join(" ")
947 ));
948 }
949 }
950 for p in &s.properties {
951 o.push_str(&turtle_property(p));
952 }
953 o.push_str(".\n\n");
954 for inner in s.properties.iter().filter_map(|p| p.node.as_deref()) {
955 o.push_str(&turtle_node(
956 inner,
957 &format!("<{}shape/{}>", crate::ontology::rdf::ONT_BASE, inner.id),
958 ));
959 }
960 o
961}
962
963fn turtle_property(p: &PropertyShape) -> String {
965 let mut o = String::from(" sh:property [\n");
966 let mut line = |s: String| o.push_str(&format!(" {s} ;\n"));
967 line(format!("sh:path <{}>", p.path));
968 if let Some(n) = p.min_count {
969 line(format!("sh:minCount {n}"));
970 }
971 if let Some(n) = p.max_count {
972 line(format!("sh:maxCount {n}"));
973 }
974 if let Some(d) = &p.datatype {
975 line(format!("sh:datatype <{d}>"));
976 }
977 if let Some(c) = &p.class {
978 line(format!("sh:class <{c}>"));
979 }
980 if let Some(k) = p.node_kind {
981 line(format!(
982 "sh:nodeKind sh:{}",
983 match k {
984 NodeKind::Iri => "IRI",
985 NodeKind::Literal => "Literal",
986 }
987 ));
988 }
989 if let Some(list) = &p.r#in {
990 let items: Vec<String> = list
993 .iter()
994 .map(|v| {
995 if v.datatype == XSD_STRING_IRI {
996 format!("\"{}\"", v.lexical)
997 } else {
998 format!("\"{}\"^^<{}>", v.lexical, v.datatype)
999 }
1000 })
1001 .collect();
1002 line(format!("sh:in ( {} )", items.join(" ")));
1003 }
1004 if let Some((src, _)) = &p.pattern {
1005 line(format!(
1006 "sh:pattern \"{}\"",
1007 src.replace('\\', "\\\\").replace('"', "\\\"")
1008 ));
1009 }
1010 if let Some(n) = p.min_length {
1011 line(format!("sh:minLength {n}"));
1012 }
1013 if let Some(n) = p.max_length {
1014 line(format!("sh:maxLength {n}"));
1015 }
1016 if p.severity == Severity::Warning {
1017 line("sh:severity sh:Warning".to_string());
1018 }
1019 if let Some(inner) = &p.node {
1020 line(format!(
1021 "sh:node <{}shape/{}>",
1022 crate::ontology::rdf::ONT_BASE,
1023 inner.id
1024 ));
1025 }
1026 o.push_str(" ] ;\n");
1027 o
1028}
1029
1030#[cfg(test)]
1031mod tests {
1032 use super::*;
1033 use crate::ontology::rdf::{iri, Term};
1034
1035 fn shape(yaml: &str) -> NodeShape {
1036 let doc: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1037 parse_shape("t", &doc).unwrap().unwrap()
1038 }
1039
1040 fn graph_with(id: &str, kind: Option<&str>) -> Graph {
1041 let mut g = Graph::new();
1042 let s = iri("contract", id);
1043 g.insert(s.clone(), RDF_TYPE, Term::iri(ont("Contract")));
1044 g.insert(s.clone(), ont("id"), Term::string(id));
1045 if let Some(k) = kind {
1046 g.insert(s, ont("kind"), Term::string(k));
1047 }
1048 g
1049 }
1050
1051 const BASE: &str = "entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:id, minCount: 1, maxCount: 1, pattern: '^[a-z0-9-]+$'}\n - {path: ont:kind, maxCount: 1, in: [kernel, pattern]}\n";
1052
1053 #[test]
1054 fn a_conforming_focus_node_yields_no_result() {
1055 let r = validate(&graph_with("a-1", Some("kernel")), &[shape(BASE)]);
1056 assert!(r.conforms(), "{:?}", r.results);
1057 assert_eq!(r.focus_nodes_n, 1);
1058 }
1059
1060 #[test]
1061 fn each_implemented_component_fires_and_names_focus_and_shape() {
1062 let r = validate(&graph_with("Bad Id", Some("novel")), &[shape(BASE)]);
1063 let mut comps: Vec<&str> = r.results.iter().map(|x| x.component).collect();
1064 comps.sort_unstable();
1065 assert_eq!(comps, vec!["in", "pattern"], "{:?}", r.results);
1066 assert!(r.results[0].focus.ends_with("/contract/Bad%20Id"));
1067 assert_eq!(r.results[0].shape, "t");
1068 let mut g = Graph::new();
1069 g.insert(iri("contract", "x"), RDF_TYPE, Term::iri(ont("Contract"))); let r = validate(&g, &[shape(BASE)]);
1071 assert_eq!(r.results[0].component, "minCount");
1072 }
1073
1074 #[test]
1075 fn closed_rejects_an_undeclared_predicate_and_ignores_rdf_type() {
1076 let mut g = graph_with("a", None);
1077 g.insert(iri("contract", "a"), ont("extra"), Term::string("x"));
1078 let s = shape("entity: {type: pv-contract}\nshape:\n closed: true\n properties:\n - {path: ont:id}\n");
1079 let r = validate(&g, &[s]);
1080 assert_eq!(r.violations(), 1);
1081 assert_eq!(r.results[0].component, "closed");
1082 }
1083
1084 #[test]
1085 fn datatype_class_nodekind_and_lengths_fire() {
1086 let mut g = graph_with("a", None);
1087 let a = iri("contract", "a");
1088 g.insert(a.clone(), ont("n"), Term::string("7"));
1089 g.insert(a.clone(), ont("dep"), Term::string("not-an-iri"));
1090 let s = shape("entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:n, datatype: xsd:integer, maxLength: 0}\n - {path: ont:dep, nodeKind: IRI, class: ont:Contract}\n");
1091 let r = validate(&g, &[s]);
1092 let mut comps: Vec<&str> = r.results.iter().map(|x| x.component).collect();
1093 comps.sort_unstable();
1094 assert_eq!(
1095 comps,
1096 vec!["class", "datatype", "maxLength", "nodeKind"],
1097 "{:?}",
1098 r.results
1099 );
1100 }
1101
1102 #[test]
1103 fn a_warning_only_report_conforms_but_counts_the_warning() {
1104 let g = graph_with("a", Some("novel"));
1105 let s = shape("entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:kind, in: [kernel], severity: warning}\n");
1106 let r = validate(&g, &[s]);
1107 assert!(r.conforms());
1108 assert_eq!(r.warnings(), 1);
1109 }
1110
1111 #[test]
1112 fn node_one_level_validates_the_value_and_two_levels_are_refused() {
1113 let mut g = graph_with("a", None);
1114 let a = iri("contract", "a");
1115 let b = iri("contract", "b");
1116 g.insert(a.clone(), ont("depends_on"), Term::iri(b.clone()));
1117 g.insert(b.clone(), RDF_TYPE, Term::iri(ont("Contract")));
1118 let s = shape("entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:depends_on, node: {properties: [{path: ont:id, minCount: 1}]}}\n");
1119 let r = validate(&g, &[s]);
1120 assert_eq!(r.results.len(), 1, "{:?}", r.results);
1121 assert_eq!(r.results[0].component, "node");
1122 let doc: serde_yaml::Value = serde_yaml::from_str("entity: {type: pv-contract}\nshape:\n properties:\n - {path: ont:x, node: {properties: [{path: ont:y, node: {properties: []}}]}}\n").unwrap();
1123 assert!(matches!(
1124 parse_shape("t", &doc),
1125 Err(ShapeError::Unsupported { .. })
1126 ));
1127 }
1128
1129 #[test]
1130 fn unsupported_components_are_refused_at_parse_by_name() {
1131 for (yaml, want) in [
1132 ("shape:\n targetNode: x\n", "targetNode"),
1133 ("entity: {type: pv-contract}\nshape:\n properties: [{path: ont:x, qualifiedValueShape: {}}]\n", "qualifiedValueShape"),
1134 ("entity: {type: pv-contract}\nshape:\n properties: [{path: ont:x, languageIn: [en]}]\n", "languageIn"),
1135 ("entity: {type: pv-contract}\nshape:\n properties: [{path: 'ont:a/ont:b'}]\n", "path `ont:a/ont:b`"),
1136 ("entity: {type: pv-contract}\nshape:\n or: []\n", "or"),
1137 ] {
1138 let doc: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap();
1139 match parse_shape("t", &doc) {
1140 Err(ShapeError::Unsupported { component, .. }) => assert!(component.starts_with(want), "{component} vs {want}"),
1141 other => panic!("{yaml}: expected Unsupported, got {other:?}"),
1142 }
1143 }
1144 }
1145
1146 #[test]
1147 fn a_shape_with_no_target_and_no_pv_contract_entity_is_malformed() {
1148 let doc: serde_yaml::Value = serde_yaml::from_str("shape:\n properties: []\n").unwrap();
1149 assert!(matches!(
1150 parse_shape("t", &doc),
1151 Err(ShapeError::Malformed { .. })
1152 ));
1153 let doc: serde_yaml::Value =
1154 serde_yaml::from_str("shape:\n targetClass: ont:Contract\n properties: []\n")
1155 .unwrap();
1156 assert_eq!(
1157 parse_shape("t", &doc).unwrap().unwrap().target_class,
1158 ont("Contract")
1159 );
1160 }
1161
1162 #[test]
1163 fn turtle_export_is_deterministic_and_names_every_component() {
1164 let s = shape(BASE);
1165 let t1 = to_turtle(std::slice::from_ref(&s));
1166 let t2 = to_turtle(std::slice::from_ref(&s));
1167 assert_eq!(t1, t2);
1168 for want in [
1169 "sh:NodeShape",
1170 "sh:targetClass",
1171 "sh:minCount 1",
1172 "sh:maxCount 1",
1173 "sh:pattern",
1174 "sh:in ( \"kernel\" \"pattern\" )",
1175 ] {
1176 assert!(t1.contains(want), "{want}\n{t1}");
1177 }
1178 }
1179
1180 #[test]
1181 fn prefixes_expand_by_the_stated_rule() {
1182 assert_eq!(expand("ont:id"), "https://ont.paiml.dev/v1alpha1/id");
1183 assert_eq!(
1184 expand("xsd:integer"),
1185 "http://www.w3.org/2001/XMLSchema#integer"
1186 );
1187 assert_eq!(
1188 expand("readme:kind"),
1189 "https://ont.paiml.dev/v1alpha1/readme/kind"
1190 );
1191 assert_eq!(expand("https://x/y"), "https://x/y");
1192 assert_eq!(short("https://ont.paiml.dev/v1alpha1/id"), "ont:id");
1193 }
1194}