use std::collections::HashMap;
use std::path::Path;
use super::model::*;
fn literals(values: &[RdfVal]) -> Vec<String> {
values.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()
}
pub fn import_ttl_file(path: &Path) -> Result<FileResults, Box<dyn std::error::Error>> {
let src = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let file_name = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let (graph, prefixes) = parse_turtle(&src)?;
let shapes = extract_shapes(&graph);
Ok(FileResults { file_name, shapes, prefixes })
}
#[derive(Debug, Clone)]
pub enum RdfVal {
Iri(String), Str(String), Int(i64),
Float(f64),
#[allow(dead_code)]
Bool(bool),
List(Vec<RdfVal>), }
impl RdfVal {
fn as_iri(&self) -> Option<&str> {
if let RdfVal::Iri(s) = self { Some(s) } else { None }
}
fn as_str(&self) -> Option<&str> {
if let RdfVal::Str(s) = self { Some(s) } else { None }
}
fn as_int(&self) -> Option<i64> {
if let RdfVal::Int(n) = self { Some(*n) } else { None }
}
fn as_float(&self) -> Option<f64> {
match self {
RdfVal::Float(f) => Some(*f),
RdfVal::Int(n) => Some(*n as f64),
_ => None,
}
}
fn as_list(&self) -> Option<&Vec<RdfVal>> {
if let RdfVal::List(v) = self { Some(v) } else { None }
}
}
type Graph = HashMap<String, Vec<(String, RdfVal)>>;
fn get_one<'g>(g: &'g Graph, subj: &str, pred: &str) -> Option<&'g RdfVal> {
g.get(subj)?.iter().find(|(p, _)| p == pred).map(|(_, v)| v)
}
fn get_all<'g>(g: &'g Graph, subj: &str, pred: &str) -> Vec<&'g RdfVal> {
g.get(subj)
.map(|pairs| pairs.iter().filter(|(p, _)| p == pred).map(|(_, v)| v).collect())
.unwrap_or_default()
}
#[derive(Debug, Clone, PartialEq)]
enum Token {
IriRef(String), PrefixedName(String), BlankNodeLabel(String), StringLit(String), Integer(i64),
Float(f64),
Bool(bool),
At, Semicolon,
Comma,
Dot,
LParen,
RParen,
LBracket,
RBracket,
DoubleCaret, A, PrefixDecl, BaseDecl, }
struct Lexer<'a> {
src: &'a [u8],
pos: usize,
}
impl<'a> Lexer<'a> {
fn new(src: &'a str) -> Self {
Self { src: src.as_bytes(), pos: 0 }
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn peek2(&self) -> Option<u8> {
self.src.get(self.pos + 1).copied()
}
fn advance(&mut self) -> Option<u8> {
let c = self.peek()?;
self.pos += 1;
Some(c)
}
fn skip_whitespace_and_comments(&mut self) {
loop {
while self.peek().is_some_and(|c| c.is_ascii_whitespace()) {
self.advance();
}
if self.peek() == Some(b'#') {
while self.peek().is_some_and(|c| c != b'\n') {
self.advance();
}
} else {
break;
}
}
}
fn read_iri_ref(&mut self) -> String {
self.advance(); let start = self.pos;
while self.peek().is_some_and(|c| c != b'>') {
self.advance();
}
let s = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or("").to_string();
self.advance(); s
}
fn read_string(&mut self) -> String {
let q = self.advance().unwrap(); let triple = self.peek() == Some(q) && self.peek2() == Some(q);
if triple {
self.advance(); self.advance(); }
let mut s = String::new();
loop {
if triple {
if self.peek() == Some(q) && self.peek2() == Some(q)
&& self.src.get(self.pos + 2).copied() == Some(q)
{
self.advance(); self.advance(); self.advance();
break;
}
} else if self.peek() == Some(q) {
self.advance();
break;
}
if self.peek().is_none() { break; }
let c = self.advance().unwrap();
if c == b'\\' {
match self.advance() {
Some(b'n') => s.push('\n'),
Some(b't') => s.push('\t'),
Some(b'r') => s.push('\r'),
Some(b'\\') => s.push('\\'),
Some(b'"') => s.push('"'),
Some(b'\'') => s.push('\''),
Some(esc) => { s.push('\\'); s.push(esc as char); }
None => break,
}
} else {
s.push(c as char);
}
}
s
}
fn read_blank_node_label(&mut self) -> String {
self.advance(); self.advance(); let start = self.pos;
while self.peek().is_some_and(|c| !c.is_ascii_whitespace() && c != b',' && c != b';' && c != b'.' && c != b')') {
self.advance();
}
std::str::from_utf8(&self.src[start..self.pos]).unwrap_or("").to_string()
}
fn read_prefixed_name_or_keyword(&mut self) -> Token {
let start = self.pos;
loop {
match self.peek() {
None => break,
Some(c) if c.is_ascii_whitespace() || c == b',' || c == b';'
|| c == b'(' || c == b')' || c == b'[' || c == b']' || c == b'^' => break,
Some(b'.') => {
let next = self.src.get(self.pos + 1).copied();
let stop = match next {
None => true,
Some(n) => n.is_ascii_whitespace() || n == b'#' || n == b';'
|| n == b',' || n == b')' || n == b']',
};
if stop { break; }
self.advance();
}
_ => { self.advance(); }
}
}
let s = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or("").to_string();
match s.as_str() {
"a" => Token::A,
"true" => Token::Bool(true),
"false" => Token::Bool(false),
"PREFIX" => Token::PrefixDecl,
"BASE" => Token::BaseDecl,
_ => Token::PrefixedName(s),
}
}
fn read_number(&mut self, first: u8) -> Token {
let neg = first == b'-';
let mut s = String::new();
if neg { s.push('-'); } else { s.push(first as char); }
let mut is_float = false;
while let Some(c) = self.peek() {
if c.is_ascii_digit() { s.push(c as char); self.advance(); }
else if c == b'.' && self.peek2().is_some_and(|n| n.is_ascii_digit()) {
is_float = true; s.push('.'); self.advance();
}
else if c == b'e' || c == b'E' { is_float = true; s.push(c as char); self.advance(); }
else { break; }
}
if is_float {
Token::Float(s.parse().unwrap_or(0.0))
} else {
Token::Integer(s.parse().unwrap_or(0))
}
}
fn next_token(&mut self) -> Option<Token> {
self.skip_whitespace_and_comments();
let c = self.peek()?;
match c {
b'<' => {
Some(Token::IriRef(self.read_iri_ref()))
}
b'"' | b'\'' => {
Some(Token::StringLit(self.read_string()))
}
b'_' if self.peek2() == Some(b':') => {
Some(Token::BlankNodeLabel(self.read_blank_node_label()))
}
b'@' => {
self.advance(); let start = self.pos;
while self.peek().is_some_and(|c| c.is_ascii_alphabetic()) {
self.advance();
}
let kw = std::str::from_utf8(&self.src[start..self.pos]).unwrap_or("");
match kw {
"prefix" => Some(Token::PrefixDecl),
"base" => Some(Token::BaseDecl),
_ => Some(Token::At),
}
}
b';' => { self.advance(); Some(Token::Semicolon) }
b',' => { self.advance(); Some(Token::Comma) }
b'.' => { self.advance(); Some(Token::Dot) }
b'(' => { self.advance(); Some(Token::LParen) }
b')' => { self.advance(); Some(Token::RParen) }
b'[' => { self.advance(); Some(Token::LBracket) }
b']' => { self.advance(); Some(Token::RBracket) }
b'^' if self.peek2() == Some(b'^') => {
self.advance(); self.advance();
Some(Token::DoubleCaret)
}
b'^' => { self.advance(); Some(Token::At) } b'-' | b'+' => {
let sign = self.advance().unwrap();
if self.peek().is_some_and(|c| c.is_ascii_digit()) {
Some(self.read_number(sign))
} else {
Some(Token::PrefixedName(String::from(sign as char)))
}
}
c if c.is_ascii_digit() => {
let d = self.advance().unwrap();
Some(self.read_number(d))
}
_ => {
Some(self.read_prefixed_name_or_keyword())
}
}
}
fn tokenize(mut self) -> Vec<Token> {
let mut tokens = Vec::new();
while let Some(tok) = self.next_token() {
tokens.push(tok);
}
tokens
}
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
prefixes: HashMap<String, String>, base: String,
graph: Graph,
bnode_counter: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self {
tokens,
pos: 0,
prefixes: HashMap::new(),
base: String::new(),
graph: HashMap::new(),
bnode_counter: 0,
}
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) -> Option<&Token> {
let t = self.tokens.get(self.pos)?;
self.pos += 1;
Some(t)
}
fn skip_until_statement_end(&mut self) {
let mut depth = 0i32;
loop {
match self.peek() {
None => break,
Some(Token::LParen) | Some(Token::LBracket) => { depth += 1; self.advance(); }
Some(Token::RParen) | Some(Token::RBracket) => {
if depth > 0 { depth -= 1; }
self.advance();
}
Some(Token::Dot) if depth == 0 => { self.advance(); break; }
_ => { self.advance(); }
}
}
}
fn new_bnode(&mut self) -> String {
let id = format!("_:b{}", self.bnode_counter);
self.bnode_counter += 1;
id
}
fn simplify(&self, iri: &str) -> String {
if !iri.starts_with("http") && !iri.starts_with("urn") && !iri.starts_with("_:") {
return iri.to_string();
}
for (prefix, expanded) in &self.prefixes {
if iri.starts_with(expanded.as_str()) {
return format!("{}:{}", prefix, &iri[expanded.len()..]);
}
}
format!("<{iri}>")
}
fn token_to_iri(&self, tok: &Token) -> Option<String> {
match tok {
Token::IriRef(s) => Some(self.simplify(s)),
Token::PrefixedName(s) => Some(s.clone()),
Token::A => Some("rdf:type".to_string()),
_ => None,
}
}
fn parse_collection(&mut self) -> RdfVal {
let mut items = Vec::new();
loop {
match self.peek() {
None | Some(Token::RParen) => { self.advance(); break; }
Some(Token::LBracket) => {
self.advance();
let bnode = self.parse_blank_node_body();
items.push(RdfVal::Iri(bnode));
}
_ => {
if let Some(val) = self.parse_object() {
items.push(val);
} else {
self.advance(); }
}
}
}
RdfVal::List(items)
}
fn parse_blank_node_body(&mut self) -> String {
let id = self.new_bnode();
loop {
match self.peek() {
None | Some(Token::RBracket) => { self.advance(); break; }
Some(Token::Semicolon) => { self.advance(); continue; }
Some(Token::Dot) => { break; } _ => {
let pred = match self.advance().cloned() {
Some(t) => match self.token_to_iri(&t) {
Some(p) => p,
None => { self.skip_until_statement_end(); break; }
},
None => break,
};
loop {
if let Some(val) = self.parse_object() {
self.graph.entry(id.clone()).or_default().push((pred.clone(), val));
}
match self.peek() {
Some(Token::Comma) => { self.advance(); }
_ => break,
}
}
}
}
}
id
}
fn parse_object(&mut self) -> Option<RdfVal> {
let tok = self.advance()?.clone();
match &tok {
Token::IriRef(s) => Some(RdfVal::Iri(self.simplify(s))),
Token::PrefixedName(s) => Some(RdfVal::Iri(s.clone())),
Token::A => Some(RdfVal::Iri("rdf:type".to_string())),
Token::BlankNodeLabel(s) => Some(RdfVal::Iri(format!("_:{s}"))),
Token::StringLit(s) => {
let val = s.clone();
match self.peek() {
Some(Token::At) => { self.advance(); } Some(Token::DoubleCaret) => {
self.advance(); if let Some(dt_tok) = self.advance().cloned() {
let dt = self.token_to_iri(&dt_tok).unwrap_or_default();
match dt.as_str() {
"xsd:float" | "xsd:double" | "xsd:decimal"
| "<http://www.w3.org/2001/XMLSchema#float>"
| "<http://www.w3.org/2001/XMLSchema#double>"
| "<http://www.w3.org/2001/XMLSchema#decimal>" => {
if let Ok(f) = val.trim().parse::<f64>() {
return Some(RdfVal::Float(f));
}
}
"xsd:integer" | "xsd:int" | "xsd:long"
| "<http://www.w3.org/2001/XMLSchema#integer>"
| "<http://www.w3.org/2001/XMLSchema#int>"
| "<http://www.w3.org/2001/XMLSchema#long>" => {
if let Ok(n) = val.trim().parse::<i64>() {
return Some(RdfVal::Int(n));
}
}
_ => {}
}
}
}
_ => {}
}
Some(RdfVal::Str(val))
}
Token::Integer(n) => Some(RdfVal::Int(*n)),
Token::Float(f) => Some(RdfVal::Float(*f)),
Token::Bool(b) => Some(RdfVal::Bool(*b)),
Token::LParen => Some(self.parse_collection()),
Token::LBracket => {
let bnode = self.parse_blank_node_body();
Some(RdfVal::Iri(bnode))
}
Token::DoubleCaret => {
self.advance();
None
}
_ => None,
}
}
fn parse(&mut self) {
loop {
match self.peek().cloned() {
None => break,
Some(Token::PrefixDecl) => {
self.advance();
let prefix = match self.advance().cloned() {
Some(Token::PrefixedName(s)) => {
s.trim_end_matches(':').to_string()
}
_ => { self.skip_until_statement_end(); continue; }
};
let expanded = match self.advance().cloned() {
Some(Token::IriRef(s)) => s,
_ => { self.skip_until_statement_end(); continue; }
};
if self.peek() == Some(&Token::Dot) { self.advance(); }
self.prefixes.insert(prefix, expanded);
}
Some(Token::BaseDecl) => {
self.advance();
if let Some(Token::IriRef(s)) = self.advance().cloned() {
self.base = s;
}
if self.peek() == Some(&Token::Dot) { self.advance(); }
}
Some(Token::Dot) => { self.advance(); }
Some(Token::Semicolon) | Some(Token::Comma) => { self.advance(); }
_ => {
let subj = match self.advance().cloned() {
Some(Token::IriRef(s)) => self.simplify(&s),
Some(Token::PrefixedName(s)) => s,
Some(Token::BlankNodeLabel(s)) => format!("_:{s}"),
Some(Token::LBracket) => self.parse_blank_node_body(),
Some(Token::A) => "rdf:type".to_string(),
_ => { self.skip_until_statement_end(); continue; }
};
loop {
match self.peek() {
None | Some(Token::Dot) => { self.advance(); break; }
Some(Token::Semicolon) => { self.advance(); continue; }
_ => {}
}
let pred_tok = match self.advance().cloned() {
Some(t) => t,
None => break,
};
let pred = match self.token_to_iri(&pred_tok) {
Some(p) => p,
None => { self.skip_until_statement_end(); break; }
};
loop {
if let Some(val) = self.parse_object() {
self.graph.entry(subj.clone()).or_default().push((pred.clone(), val));
}
match self.peek() {
Some(Token::Comma) => { self.advance(); }
_ => break,
}
}
match self.peek() {
Some(Token::Semicolon) => { self.advance(); }
Some(Token::Dot) | None => {}
_ => {}
}
}
}
}
}
}
}
fn parse_turtle(
src: &str,
) -> Result<(Graph, HashMap<String, String>), Box<dyn std::error::Error>> {
let tokens = Lexer::new(src).tokenize();
let mut parser = Parser::new(tokens);
parser.parse();
Ok((parser.graph, parser.prefixes))
}
const SH_NODE_SHAPE: &str = "sh:NodeShape";
const SH_PROPERTY_SHAPE: &str = "sh:PropertyShape";
const RDF_TYPE: &str = "rdf:type";
fn extract_shapes(g: &Graph) -> Vec<ShapeInfo> {
let mut node_shapes: Vec<String> = g
.keys()
.filter(|s| {
get_all(g, s, RDF_TYPE)
.iter()
.any(|v| v.as_iri() == Some(SH_NODE_SHAPE))
})
.cloned()
.collect();
node_shapes.sort();
node_shapes
.into_iter()
.filter_map(|id| build_node_shape(g, &id))
.collect()
}
fn build_node_shape(g: &Graph, id: &str) -> Option<ShapeInfo> {
let mut seen_targets = std::collections::HashSet::new();
let mut targets: Vec<TargetInfo> = get_all(g, id, "sh:targetClass")
.into_iter()
.filter_map(|v| v.as_iri())
.filter(|iri| seen_targets.insert(iri.to_string()))
.map(|iri| TargetInfo {
kind: "targetClass".to_string(),
value: iri.to_string(),
})
.collect();
for v in get_all(g, id, "sh:targetNode") {
if let Some(iri) = v.as_iri()
&& seen_targets.insert(iri.to_string()) {
targets.push(TargetInfo {
kind: "targetNode".to_string(),
value: iri.to_string(),
});
}
}
for (pred, kind) in [
("sh:targetSubjectsOf", "targetSubjectsOf"),
("sh:targetObjectsOf", "targetObjectsOf"),
] {
for v in get_all(g, id, pred) {
if let Some(iri) = v.as_iri() {
let key = format!("{kind}:{iri}");
if seen_targets.insert(key) {
targets.push(TargetInfo {
kind: kind.to_string(),
value: iri.to_string(),
});
}
}
}
}
for v in get_all(g, id, "sh:target") {
if let Some(bnode_id) = v.as_iri() {
let is_sparql_target = get_all(g, bnode_id, RDF_TYPE)
.iter()
.any(|t| t.as_iri() == Some("sh:SPARQLTarget"));
if is_sparql_target && seen_targets.insert(bnode_id.to_string()) {
targets.push(TargetInfo {
kind: "sparqlTarget".to_string(),
value: bnode_id.to_string(),
});
}
}
}
if targets.is_empty() {
return None; }
let prop_refs: Vec<String> = collect_iri_list(g, id, "sh:property");
let mut properties: Vec<ShapeInfo> = prop_refs
.into_iter()
.filter_map(|prop_id| build_property_shape(g, &prop_id))
.collect();
for nested_id in nested_shape_ids(g, id) {
if let Some(ps) = build_property_shape(g, &nested_id) {
properties.push(ps);
}
}
let name = get_str(g, id, "sh:name").unwrap_or_default();
let description = get_str(g, id, "sh:description").unwrap_or_default();
let constraints = extract_node_compound_constraints(g, id, &name, &description);
Some(ShapeInfo {
id: id.to_string(),
targets,
path: Vec::new(),
name,
description,
constraints,
properties,
closed: closed_properties(g, id),
deactivated: is_deactivated(g, id),
})
}
fn closed_properties(g: &Graph, id: &str) -> Option<Vec<String>> {
let closed = match get_one(g, id, "sh:closed") {
Some(RdfVal::Bool(b)) => *b,
_ => false,
};
if !closed {
return None;
}
let mut allowed: Vec<String> = Vec::new();
for prop_id in collect_iri_list(g, id, "sh:property") {
allowed.extend(extract_path(g, &prop_id));
}
for val in get_all(g, id, "sh:ignoredProperties") {
if let Some(items) = val.as_list() {
allowed.extend(items.iter().filter_map(|i| i.as_iri()).map(str::to_string));
}
}
allowed.sort();
allowed.dedup();
Some(allowed)
}
fn is_deactivated(g: &Graph, id: &str) -> bool {
matches!(get_one(g, id, "sh:deactivated"), Some(RdfVal::Bool(true)))
}
fn extract_node_compound_constraints(
g: &Graph,
id: &str,
name: &str,
description: &str,
) -> Vec<ConstraintInfo> {
let severity = get_str(g, id, "sh:severity").unwrap_or_else(|| "sh:Violation".to_string());
let message = get_str(g, id, "sh:message").unwrap_or_default();
let mut constraints = Vec::new();
for (pred, component) in &[
("sh:xone", "sh:XoneConstraintComponent"),
("sh:or", "sh:OrConstraintComponent"),
("sh:and", "sh:AndConstraintComponent"),
] {
for val in get_all(g, id, pred) {
let items = match val.as_list() {
Some(v) => v,
None => continue,
};
let mut branches: Vec<Vec<ConstraintInfo>> = Vec::new();
let mut negated: Vec<String> = Vec::new();
let mut unsupported: Vec<String> = Vec::new();
for item in items {
let Some(node) = item.as_iri() else { continue };
let (target, negate) = match get_one(g, node, "sh:not").and_then(|v| v.as_iri()) {
Some(x) => {
unsupported.extend(
unsupported_predicates(g, node, &["sh:not"]),
);
(x, true)
}
None => (node, false),
};
let (branch, unsup) = shape_constraints(g, target);
unsupported.extend(unsup);
if branch.is_empty() {
continue;
}
if negate {
negated.push(branches.len().to_string());
}
branches.push(branch);
}
if branches.len() < 2 {
continue;
}
unsupported.sort();
unsupported.dedup();
let mut payload = HashMap::new();
payload.insert("branches".to_string(), ShaclValue::Shapes(branches));
payload.insert(NEGATED.to_string(), ShaclValue::List(negated));
payload.insert(UNSUPPORTED.to_string(), ShaclValue::List(unsupported));
constraints.push(ConstraintInfo {
path: Vec::new(),
severity: severity.clone(),
message: message.clone(),
name: name.to_string(),
description: description.to_string(),
component: component.to_string(),
payload,
rule_id: id.to_string(),
});
}
}
constraints
}
fn unsupported_predicates(g: &Graph, node: &str, also: &[&str]) -> Vec<String> {
const UNDERSTOOD: &[&str] = &[
"sh:path", "sh:minCount", "sh:maxCount", "sh:hasValue", "sh:in",
RDF_TYPE, "sh:name", "sh:description", "sh:message", "sh:severity", "sh:order", "sh:group",
];
g.get(node)
.into_iter()
.flatten()
.map(|(p, _)| p.as_str())
.filter(|p| !UNDERSTOOD.contains(p) && !also.contains(p))
.map(str::to_string)
.collect()
}
fn shape_constraints(g: &Graph, node: &str) -> (Vec<ConstraintInfo>, Vec<String>) {
let mut constraints = extract_branch_constraints(g, node);
let mut unsupported = unsupported_predicates(
g,
node,
&["sh:property", "sh:qualifiedValueShape", "sh:qualifiedMinCount"],
);
if let Some(qmin) = get_one(g, node, "sh:qualifiedMinCount").and_then(|v| v.as_int()) {
match get_one(g, node, "sh:qualifiedValueShape").and_then(|v| v.as_iri()) {
Some(inner) => {
unsupported.extend(unsupported_predicates(g, inner, &[]));
let mut payload = HashMap::new();
payload.insert("qualifiedMinCount".to_string(), ShaclValue::Int(qmin));
payload.insert(
"shape".to_string(),
ShaclValue::Shapes(vec![extract_branch_constraints(g, inner)]),
);
constraints.push(ConstraintInfo {
path: extract_path(g, node),
severity: String::new(),
message: String::new(),
name: String::new(),
description: String::new(),
component: "sh:QualifiedMinCountConstraintComponent".to_string(),
payload,
rule_id: String::new(),
});
}
None => unsupported.push("sh:qualifiedMinCount".to_string()),
}
}
for prop in collect_iri_list(g, node, "sh:property") {
let (nested, unsup) = shape_constraints(g, &prop);
constraints.extend(nested);
unsupported.extend(unsup);
}
(constraints, unsupported)
}
fn extract_branch_constraints(g: &Graph, bnode_id: &str) -> Vec<ConstraintInfo> {
let mut constraints = Vec::new();
let path = extract_path(g, bnode_id);
let mk = |component: &str, key: &str, val: ShaclValue, path: Vec<String>| -> ConstraintInfo {
let mut payload = HashMap::new();
payload.insert(key.to_string(), val);
ConstraintInfo {
path,
severity: String::new(),
message: String::new(),
name: String::new(),
description: String::new(),
component: component.to_string(),
payload,
rule_id: String::new(),
}
};
if let Some(n) = get_one(g, bnode_id, "sh:minCount").and_then(|v| v.as_int()) {
constraints.push(mk("sh:MinCountConstraintComponent", "minCount", ShaclValue::Int(n), path.clone()));
}
if let Some(n) = get_one(g, bnode_id, "sh:maxCount").and_then(|v| v.as_int()) {
constraints.push(mk("sh:MaxCountConstraintComponent", "maxCount", ShaclValue::Int(n), path.clone()));
}
if let Some(v) = get_one(g, bnode_id, "sh:hasValue") {
let sv = v.as_iri().or_else(|| v.as_str()).map(str::to_string);
if let Some(s) = sv {
let mut c = mk("sh:HasValueConstraintComponent", "hasValue", ShaclValue::Str(s), path.clone());
c.payload.insert(LITERALS.to_string(), ShaclValue::List(literals(std::slice::from_ref(v))));
constraints.push(c);
}
}
if let Some(in_list) = get_one(g, bnode_id, "sh:in").and_then(|v| v.as_list()) {
let values: Vec<String> = in_list
.iter()
.filter_map(|v| v.as_iri().or_else(|| v.as_str()).map(str::to_string))
.collect();
if !values.is_empty() {
let mut c = mk("sh:InConstraintComponent", "in", ShaclValue::List(values), path.clone());
c.payload.insert(LITERALS.to_string(), ShaclValue::List(literals(in_list)));
constraints.push(c);
}
}
constraints
}
fn build_property_shape(g: &Graph, id: &str) -> Option<ShapeInfo> {
let is_prop = get_all(g, id, RDF_TYPE)
.iter()
.any(|v| v.as_iri() == Some(SH_PROPERTY_SHAPE));
let has_path = g.get(id).is_some_and(|pairs| pairs.iter().any(|(p, _)| p == "sh:path"));
if !is_prop && !has_path && !id.starts_with("_:") {
return None;
}
let path = extract_path(g, id);
let name = get_str(g, id, "sh:name").unwrap_or_default();
let description = get_str(g, id, "sh:description").unwrap_or_default();
let message = get_str(g, id, "sh:message").unwrap_or_default();
let severity = get_str(g, id, "sh:severity")
.or_else(|| get_all(g, id, "sh:severity").into_iter().find_map(|v| v.as_iri().map(str::to_string)))
.unwrap_or_else(|| "sh:Violation".to_string());
let mut constraints = Vec::new();
if let Some(min) = get_one(g, id, "sh:minCount").and_then(|v| v.as_int()) {
let max = get_one(g, id, "sh:maxCount").and_then(|v| v.as_int());
if min == 1 && max == Some(1) {
let mut payload = HashMap::new();
payload.insert("minCount".to_string(), ShaclValue::Int(1));
payload.insert("maxCount".to_string(), ShaclValue::Int(1));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:RequiredConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
} else {
{
let mut payload = HashMap::new();
payload.insert("minCount".to_string(), ShaclValue::Int(min));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:MinCountConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(m) = max {
let mut payload = HashMap::new();
payload.insert("maxCount".to_string(), ShaclValue::Int(m));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:MaxCountConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
} else if let Some(max) = get_one(g, id, "sh:maxCount").and_then(|v| v.as_int()) {
let mut payload = HashMap::new();
payload.insert("maxCount".to_string(), ShaclValue::Int(max));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:MaxCountConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(dt) = get_one(g, id, "sh:datatype").and_then(|v| v.as_iri()) {
let mut payload = HashMap::new();
payload.insert("datatype".to_string(), ShaclValue::Str(dt.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:DatatypeConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(nk) = get_one(g, id, "sh:nodeKind").and_then(|v| v.as_iri()) {
let mut payload = HashMap::new();
payload.insert("nodeKind".to_string(), ShaclValue::Str(nk.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:NodeKindConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
for class_val in get_all(g, id, "sh:class") {
if let Some(class) = class_val.as_iri() {
let mut payload = HashMap::new();
payload.insert("class".to_string(), ShaclValue::Str(class.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:ClassConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
if let Some(in_list) = get_one(g, id, "sh:in").and_then(|v| v.as_list()) {
let values: Vec<String> = in_list
.iter()
.filter_map(|v| v.as_iri().map(str::to_string).or_else(|| v.as_str().map(str::to_string)))
.collect();
let mut payload = HashMap::new();
payload.insert("in".to_string(), ShaclValue::List(values));
payload.insert(LITERALS.to_string(), ShaclValue::List(literals(in_list)));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:InConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(v) = get_one(g, id, "sh:hasValue") {
let val = v.as_iri().map(str::to_string).or_else(|| v.as_str().map(str::to_string));
if let Some(hv) = val {
let mut payload = HashMap::new();
payload.insert("hasValue".to_string(), ShaclValue::Str(hv));
payload.insert(LITERALS.to_string(), ShaclValue::List(literals(std::slice::from_ref(v))));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:HasValueConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
if let Some(pat) = get_str(g, id, "sh:pattern") {
let mut payload = HashMap::new();
payload.insert("pattern".to_string(), ShaclValue::Str(pat));
if let Some(flags) = get_str(g, id, "sh:flags") {
payload.insert("flags".to_string(), ShaclValue::Str(flags));
}
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:PatternConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(n) = get_one(g, id, "sh:length").and_then(|v| v.as_int()) {
let mut payload = HashMap::new();
payload.insert("length".to_string(), ShaclValue::Int(n));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:LengthConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(n) = get_one(g, id, "sh:minLength").and_then(|v| v.as_int()) {
let mut payload = HashMap::new();
payload.insert("minLength".to_string(), ShaclValue::Int(n));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:MinLengthConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(n) = get_one(g, id, "sh:maxLength").and_then(|v| v.as_int()) {
let mut payload = HashMap::new();
payload.insert("maxLength".to_string(), ShaclValue::Int(n));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:MaxLengthConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
for (pred, comp) in &[
("sh:minExclusive", "sh:MinExclusiveConstraintComponent"),
("sh:maxExclusive", "sh:MaxExclusiveConstraintComponent"),
("sh:minInclusive", "sh:MinInclusiveConstraintComponent"),
("sh:maxInclusive", "sh:MaxInclusiveConstraintComponent"),
] {
if let Some(val) = get_one(g, id, pred).and_then(|v| v.as_float()) {
let mut payload = HashMap::new();
payload.insert(pred.trim_start_matches("sh:").to_string(), ShaclValue::Float(val));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: comp.to_string(),
payload,
rule_id: id.to_string(),
});
}
}
for or_val in get_all(g, id, "sh:or") {
if let Some(list) = or_val.as_list() {
let classes: Vec<String> = list
.iter()
.filter_map(|item| {
item.as_iri().and_then(|bnode_id| {
get_one(g, bnode_id, "sh:class")
.and_then(|v| v.as_iri())
.map(str::to_string)
})
})
.collect();
if !classes.is_empty() {
let mut payload = HashMap::new();
payload.insert("classes".to_string(), ShaclValue::List(classes));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:OrClassConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
let inv_items: Vec<_> = list
.iter()
.filter_map(|item| item.as_iri())
.filter(|bnode_id| {
g.get(*bnode_id).is_some_and(|pairs| {
pairs.iter().any(|(p, _)| p == "sh:path")
})
})
.collect();
if !inv_items.is_empty() {
let mut payload = HashMap::new();
payload.insert("complex".to_string(), ShaclValue::Str("sh:or+inversePath".to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:OrInversePathConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
}
if let Some(lt_val) = get_one(g, id, "sh:lessThan").and_then(|v| v.as_iri()) {
let mut payload = HashMap::new();
payload.insert("lessThan".to_string(), ShaclValue::Str(lt_val.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:LessThanConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
if let Some(lte_val) = get_one(g, id, "sh:lessThanOrEquals").and_then(|v| v.as_iri()) {
let mut payload = HashMap::new();
payload.insert("lessThanOrEquals".to_string(), ShaclValue::Str(lte_val.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:LessThanOrEqualsConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
for not_val in get_all(g, id, "sh:not") {
if let Some(bnode_id) = not_val.as_iri()
&& let Some(class_val) = get_one(g, bnode_id, "sh:class")
&& let Some(class) = class_val.as_iri() {
let mut payload = HashMap::new();
payload.insert("class".to_string(), ShaclValue::Str(class.to_string()));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:NotClassConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
if !get_all(g, id, "sh:sparql").is_empty() {
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:SPARQLConstraintComponent".to_string(),
payload: HashMap::new(),
rule_id: id.to_string(),
});
}
if let Some(qmin) = get_one(g, id, "sh:qualifiedMinCount").and_then(|v| v.as_int())
&& let Some(shape_id) = get_one(g, id, "sh:qualifiedValueShape").and_then(|v| v.as_iri()) {
let branch = extract_branch_constraints(g, shape_id);
if !branch.is_empty() {
let mut payload = HashMap::new();
payload.insert("qualifiedMinCount".to_string(), ShaclValue::Int(qmin));
payload.insert("shape".to_string(), ShaclValue::Shapes(vec![branch]));
constraints.push(ConstraintInfo {
path: path.clone(),
severity: severity.clone(),
message: message.clone(),
name: name.clone(),
description: description.clone(),
component: "sh:QualifiedMinCountConstraintComponent".to_string(),
payload,
rule_id: id.to_string(),
});
}
}
if constraints.is_empty() {
return None;
}
Some(ShapeInfo {
id: id.to_string(),
targets: Vec::new(),
path,
name,
description,
constraints,
properties: Vec::new(),
closed: None,
deactivated: is_deactivated(g, id),
})
}
fn extract_path(g: &Graph, id: &str) -> Vec<String> {
let val = match get_one(g, id, "sh:path") {
Some(v) => v,
None => return Vec::new(),
};
match val {
RdfVal::Iri(iri) if iri.starts_with("_:") => {
if let Some(RdfVal::Iri(inv)) = get_one(g, iri, "sh:inversePath") {
vec![format!("^{inv}")]
} else if let Some(alt) = alternative_path(g, iri) {
vec![alt]
} else {
Vec::new()
}
}
RdfVal::Iri(iri) => vec![iri.clone()],
RdfVal::List(items) => {
let mut segs = Vec::new();
for item in items.iter() {
if let Some(iri) = item.as_iri() {
if iri.starts_with("_:") {
if let Some(RdfVal::Iri(inv)) = get_one(g, iri, "sh:inversePath") {
segs.push(format!("^{inv}"));
} else if let Some(alt) = alternative_path(g, iri) {
segs.push(alt);
}
} else {
segs.push(iri.to_string());
}
}
}
segs
}
_ => Vec::new(),
}
}
fn alternative_path(g: &Graph, bnode_id: &str) -> Option<String> {
let items = get_one(g, bnode_id, "sh:alternativePath")?.as_list()?;
let mut branches: Vec<String> = Vec::with_capacity(items.len());
for item in items {
let iri = item.as_iri()?;
if iri.starts_with("_:") {
match get_one(g, iri, "sh:inversePath") {
Some(RdfVal::Iri(inv)) => branches.push(format!("^{inv}")),
_ => return None,
}
} else {
branches.push(iri.to_string());
}
}
if branches.is_empty() {
return None;
}
Some(format!("|{}", branches.join("|")))
}
fn collect_iri_list(g: &Graph, subj: &str, pred: &str) -> Vec<String> {
let mut result = Vec::new();
for val in get_all(g, subj, pred) {
match val {
RdfVal::Iri(s) => result.push(s.clone()),
RdfVal::List(items) => {
for item in items {
if let Some(iri) = item.as_iri() {
result.push(iri.to_string());
}
}
}
_ => {}
}
}
result
}
fn nested_shape_ids(g: &Graph, id: &str) -> Vec<String> {
let mut result = Vec::new();
for pred in &["sh:or", "sh:and", "sh:not", "sh:xone"] {
for val in get_all(g, id, pred) {
match val {
RdfVal::List(items) => {
for item in items {
if let Some(bnode) = item.as_iri() {
let is_prop = get_all(g, bnode, RDF_TYPE)
.iter()
.any(|v| v.as_iri() == Some(SH_PROPERTY_SHAPE));
if is_prop {
result.push(bnode.to_string());
}
}
}
}
RdfVal::Iri(bnode)
if bnode.starts_with("_:") => {
result.push(bnode.clone());
}
_ => {}
}
}
}
result
}
fn get_str(g: &Graph, subj: &str, pred: &str) -> Option<String> {
get_one(g, subj, pred).and_then(|v| v.as_str().map(str::to_string)
.or_else(|| v.as_iri().map(|s| s.to_string())))
}
#[derive(Debug)]
pub struct Manifest {
pub profile: String,
pub imports: Vec<String>,
}
pub fn import_manifest(path: &Path) -> Result<Manifest, Box<dyn std::error::Error>> {
let src = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read {}: {e}", path.display()))?;
let (graph, _) = parse_turtle(&src)?;
let mut profile = String::new();
let mut imports = Vec::new();
for pairs in graph.values() {
for (pred, val) in pairs {
match (pred.as_str(), val) {
("dcat:keyword", RdfVal::Str(s)) if profile.is_empty() && !s.is_empty() => {
profile = s.clone();
}
("owl:imports", RdfVal::Iri(iri)) => {
let trimmed = iri.trim_start_matches('<').trim_end_matches('>');
let file = trimmed.rsplit('/').next().unwrap_or(trimmed);
imports.push(file.trim_end_matches(".ttl").to_string());
}
_ => {}
}
}
}
if profile.is_empty() {
return Err(format!("no dcat:keyword in {}", path.display()).into());
}
imports.sort();
imports.dedup();
Ok(Manifest { profile, imports })
}