Skip to main content

shifty_parse/
lib.rs

1//! RDF shapes graph -> formalism IR lowering (Layer 2).
2//!
3//! [`parse_turtle`] reads a SHACL shapes graph and lowers all supported Core +
4//! AF vocabulary into the [`shifty_algebra::Schema`] IR, applying every sugar
5//! rule from `docs/01-gap-analysis.md`. Unsupported custom components, JS, and
6//! richer AF constructs produce [`Diagnostic`]s rather than silent wrong
7//! answers.
8
9pub mod diagnostics;
10pub mod graph;
11pub mod lower;
12pub mod path;
13pub mod sparql_path;
14pub mod vocab;
15
16pub use diagnostics::{DiagLevel, Diagnostic, ParseError};
17pub use graph::{Loaded, RdfFormat};
18pub use sparql_path::parse_property_path;
19
20use shifty_algebra::Schema;
21
22/// The result of lowering a shapes graph.
23pub struct ParseOutput {
24    pub schema: Schema,
25    pub diagnostics: Vec<Diagnostic>,
26}
27
28/// Load a Turtle shapes graph (for inspecting the raw RDF stage).
29pub fn load_turtle(data: &[u8], base: Option<&str>) -> Result<Loaded, ParseError> {
30    Loaded::from_turtle(data, base)
31}
32
33pub fn load_ntriples(data: &[u8]) -> Result<Loaded, ParseError> {
34    Loaded::from_ntriples(data)
35}
36
37/// Load an RDF graph using content type, source extension, lightweight content
38/// sniffing, and finally a cascading try across common RDF formats.
39pub fn load_rdf_auto(
40    data: &[u8],
41    content_type: Option<&str>,
42    source: Option<&str>,
43    base: Option<&str>,
44) -> Result<Loaded, ParseError> {
45    Loaded::from_rdf_auto(data, content_type, source, base)
46}
47
48/// Parse and lower a Turtle shapes graph into the algebra IR.
49pub fn parse_turtle(data: &[u8], base: Option<&str>) -> Result<ParseOutput, ParseError> {
50    let loaded = Loaded::from_turtle(data, base)?;
51    let lowered = lower::lower(&loaded);
52    Ok(ParseOutput {
53        schema: lowered.schema,
54        diagnostics: lowered.diagnostics,
55    })
56}
57
58/// Lower an already-loaded graph into the algebra IR.
59pub fn parse_loaded(loaded: &Loaded) -> ParseOutput {
60    let lowered = lower::lower(loaded);
61    ParseOutput {
62        schema: lowered.schema,
63        diagnostics: lowered.diagnostics,
64    }
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use shifty_algebra::render::schema_to_text;
71
72    const SHAPES: &str = r#"
73        @prefix sh: <http://www.w3.org/ns/shacl#> .
74        @prefix ex: <http://ex/> .
75        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
76
77        ex:PersonShape a sh:NodeShape ;
78            sh:targetClass ex:Person ;
79            sh:property [
80                sh:path ex:name ;
81                sh:minCount 1 ;
82                sh:maxCount 1 ;
83                sh:datatype xsd:string ;
84            ] ;
85            sh:property [
86                sh:path [ sh:inversePath ex:child ] ;
87                sh:nodeKind sh:IRI ;
88            ] .
89    "#;
90
91    #[test]
92    fn lowers_person_shape() {
93        let out = parse_turtle(SHAPES.as_bytes(), None).unwrap();
94        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
95
96        let text = schema_to_text(&out.schema);
97        // a class-target statement was produced
98        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
99        // cardinality on ex:name lowered to an interval count
100        assert!(text.contains("[1..1] <http://ex/name>"), "text:\n{text}");
101        // inverse path rendered
102        assert!(text.contains("^<http://ex/child>"), "text:\n{text}");
103        // datatype facet present
104        assert!(text.contains("datatype(xsd:string)"), "text:\n{text}");
105    }
106
107    #[test]
108    fn sources_provenance_named_and_blank_shapes() {
109        let out = parse_turtle(SHAPES.as_bytes(), None).unwrap();
110        let named_id = out
111            .schema
112            .names
113            .iter()
114            .find(|(_, names)| names.iter().any(|name| name == "http://ex/PersonShape"))
115            .map(|(id, _)| *id)
116            .expect("PersonShape should be named");
117        assert_eq!(
118            out.schema.sources.get(&named_id),
119            Some(&shifty_algebra::Term::NamedNode(
120                shifty_algebra::NamedNode::new("http://ex/PersonShape").unwrap()
121            ))
122        );
123
124        // The two blank `sh:property` shapes also get source entries, not just
125        // the named node shape.
126        let blank_sources = out
127            .schema
128            .sources
129            .values()
130            .filter(|t| matches!(t, shifty_algebra::Term::BlankNode(_)))
131            .count();
132        assert_eq!(blank_sources, 2, "sources: {:?}", out.schema.sources);
133
134        // Deterministic across two parses of the same document.
135        let out2 = parse_turtle(SHAPES.as_bytes(), None).unwrap();
136        assert_eq!(out.schema.sources.len(), out2.schema.sources.len());
137    }
138
139    #[test]
140    fn auto_loads_rdf_xml_shapes() {
141        let rdfxml = br#"<?xml version="1.0"?>
142            <rdf:RDF
143                xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
144                xmlns:sh="http://www.w3.org/ns/shacl#"
145                xmlns:ex="http://ex/">
146              <sh:NodeShape rdf:about="http://ex/S">
147                <sh:targetClass rdf:resource="http://ex/Thing"/>
148              </sh:NodeShape>
149            </rdf:RDF>"#;
150        let loaded = load_rdf_auto(
151            rdfxml,
152            Some("application/rdf+xml"),
153            Some("https://example.test/shapes.rdf"),
154            Some("https://example.test/shapes.rdf"),
155        )
156        .unwrap();
157        let out = parse_loaded(&loaded);
158        let text = schema_to_text(&out.schema);
159        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
160    }
161
162    #[test]
163    fn lowers_triple_rule() {
164        let ttl = r#"
165            @prefix sh: <http://www.w3.org/ns/shacl#> .
166            @prefix ex: <http://ex/> .
167            ex:S a sh:NodeShape ;
168                sh:targetClass ex:Rectangle ;
169                sh:rule [
170                    a sh:TripleRule ;
171                    sh:subject sh:this ;
172                    sh:predicate ex:area ;
173                    sh:object [ sh:path ex:width ] ;
174                    sh:condition ex:S ;
175                    sh:order 1 ;
176                ] .
177        "#;
178        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
179        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
180        assert_eq!(out.schema.rules.len(), 1);
181        let r = &out.schema.rules[0];
182        assert_eq!(r.order, Some(1));
183        assert_eq!(r.conditions.len(), 1);
184        use shifty_algebra::{NodeExpr, RuleHead};
185        match &r.head {
186            RuleHead::Triple {
187                subject,
188                predicate,
189                object,
190            } => {
191                assert!(matches!(subject, NodeExpr::This));
192                assert!(matches!(predicate, NodeExpr::Constant(_)));
193                assert!(matches!(object, NodeExpr::Path(_)));
194            }
195            other => panic!("expected TripleRule, got {other:?}"),
196        }
197    }
198
199    #[test]
200    fn lowers_sparql_rule_opaque() {
201        let ttl = r#"
202            @prefix sh: <http://www.w3.org/ns/shacl#> .
203            @prefix ex: <http://ex/> .
204            ex:S a sh:NodeShape ;
205                sh:targetNode ex:x ;
206                sh:rule [ a sh:SPARQLRule ; sh:construct "CONSTRUCT { ?this ex:p ?this } WHERE {}" ] .
207        "#;
208        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
209        assert_eq!(out.schema.rules.len(), 1);
210        assert!(matches!(
211            out.schema.rules[0].head,
212            shifty_algebra::RuleHead::Sparql(_)
213        ));
214    }
215
216    #[test]
217    fn lowers_sparql_constraint() {
218        let ttl = r#"
219            @prefix sh: <http://www.w3.org/ns/shacl#> .
220            @prefix ex: <http://ex/> .
221            ex:S a sh:NodeShape ;
222                sh:targetNode ex:x ;
223                sh:sparql [ sh:select "SELECT $this WHERE {}" ] .
224        "#;
225        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
226        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
227        let root = out.schema.statements[0].shape;
228        let shifty_algebra::Shape::Annotated {
229            severity, shape, ..
230        } = out.schema.arena.get(root)
231        else {
232            panic!("expected severity annotation");
233        };
234        assert_eq!(severity, &shifty_algebra::Severity::Violation);
235        assert!(matches!(
236            out.schema.arena.get(*shape),
237            shifty_algebra::Shape::Sparql(_)
238        ));
239    }
240
241    #[test]
242    fn lowers_sparql_target() {
243        let ttl = r#"
244            @prefix sh: <http://www.w3.org/ns/shacl#> .
245            @prefix ex: <http://ex/> .
246            ex:S a sh:NodeShape ;
247                sh:target [ sh:select "SELECT ?this WHERE { ?this a ex:Person }" ] .
248        "#;
249        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
250        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
251        assert!(matches!(
252            out.schema.statements[0].selector,
253            shifty_algebra::Selector::Sparql(_)
254        ));
255    }
256}