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 vocab;
14
15pub use diagnostics::{DiagLevel, Diagnostic, ParseError};
16pub use graph::{Loaded, RdfFormat};
17
18use shifty_algebra::Schema;
19
20/// The result of lowering a shapes graph.
21pub struct ParseOutput {
22    pub schema: Schema,
23    pub diagnostics: Vec<Diagnostic>,
24}
25
26/// Load a Turtle shapes graph (for inspecting the raw RDF stage).
27pub fn load_turtle(data: &[u8], base: Option<&str>) -> Result<Loaded, ParseError> {
28    Loaded::from_turtle(data, base)
29}
30
31pub fn load_ntriples(data: &[u8]) -> Result<Loaded, ParseError> {
32    Loaded::from_ntriples(data)
33}
34
35/// Load an RDF graph using content type, source extension, lightweight content
36/// sniffing, and finally a cascading try across common RDF formats.
37pub fn load_rdf_auto(
38    data: &[u8],
39    content_type: Option<&str>,
40    source: Option<&str>,
41    base: Option<&str>,
42) -> Result<Loaded, ParseError> {
43    Loaded::from_rdf_auto(data, content_type, source, base)
44}
45
46/// Parse and lower a Turtle shapes graph into the algebra IR.
47pub fn parse_turtle(data: &[u8], base: Option<&str>) -> Result<ParseOutput, ParseError> {
48    let loaded = Loaded::from_turtle(data, base)?;
49    let lowered = lower::lower(&loaded);
50    Ok(ParseOutput {
51        schema: lowered.schema,
52        diagnostics: lowered.diagnostics,
53    })
54}
55
56/// Lower an already-loaded graph into the algebra IR.
57pub fn parse_loaded(loaded: &Loaded) -> ParseOutput {
58    let lowered = lower::lower(loaded);
59    ParseOutput {
60        schema: lowered.schema,
61        diagnostics: lowered.diagnostics,
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use shifty_algebra::render::schema_to_text;
69
70    const SHAPES: &str = r#"
71        @prefix sh: <http://www.w3.org/ns/shacl#> .
72        @prefix ex: <http://ex/> .
73        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
74
75        ex:PersonShape a sh:NodeShape ;
76            sh:targetClass ex:Person ;
77            sh:property [
78                sh:path ex:name ;
79                sh:minCount 1 ;
80                sh:maxCount 1 ;
81                sh:datatype xsd:string ;
82            ] ;
83            sh:property [
84                sh:path [ sh:inversePath ex:child ] ;
85                sh:nodeKind sh:IRI ;
86            ] .
87    "#;
88
89    #[test]
90    fn lowers_person_shape() {
91        let out = parse_turtle(SHAPES.as_bytes(), None).unwrap();
92        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
93
94        let text = schema_to_text(&out.schema);
95        // a class-target statement was produced
96        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
97        // cardinality on ex:name lowered to an interval count
98        assert!(text.contains("[1..1] <http://ex/name>"), "text:\n{text}");
99        // inverse path rendered
100        assert!(text.contains("^<http://ex/child>"), "text:\n{text}");
101        // datatype facet present
102        assert!(text.contains("datatype(xsd:string)"), "text:\n{text}");
103    }
104
105    #[test]
106    fn auto_loads_rdf_xml_shapes() {
107        let rdfxml = br#"<?xml version="1.0"?>
108            <rdf:RDF
109                xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
110                xmlns:sh="http://www.w3.org/ns/shacl#"
111                xmlns:ex="http://ex/">
112              <sh:NodeShape rdf:about="http://ex/S">
113                <sh:targetClass rdf:resource="http://ex/Thing"/>
114              </sh:NodeShape>
115            </rdf:RDF>"#;
116        let loaded = load_rdf_auto(
117            rdfxml,
118            Some("application/rdf+xml"),
119            Some("https://example.test/shapes.rdf"),
120            Some("https://example.test/shapes.rdf"),
121        )
122        .unwrap();
123        let out = parse_loaded(&loaded);
124        let text = schema_to_text(&out.schema);
125        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
126    }
127
128    #[test]
129    fn lowers_triple_rule() {
130        let ttl = r#"
131            @prefix sh: <http://www.w3.org/ns/shacl#> .
132            @prefix ex: <http://ex/> .
133            ex:S a sh:NodeShape ;
134                sh:targetClass ex:Rectangle ;
135                sh:rule [
136                    a sh:TripleRule ;
137                    sh:subject sh:this ;
138                    sh:predicate ex:area ;
139                    sh:object [ sh:path ex:width ] ;
140                    sh:condition ex:S ;
141                    sh:order 1 ;
142                ] .
143        "#;
144        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
145        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
146        assert_eq!(out.schema.rules.len(), 1);
147        let r = &out.schema.rules[0];
148        assert_eq!(r.order, Some(1));
149        assert_eq!(r.conditions.len(), 1);
150        use shifty_algebra::{NodeExpr, RuleHead};
151        match &r.head {
152            RuleHead::Triple {
153                subject,
154                predicate,
155                object,
156            } => {
157                assert!(matches!(subject, NodeExpr::This));
158                assert!(matches!(predicate, NodeExpr::Constant(_)));
159                assert!(matches!(object, NodeExpr::Path(_)));
160            }
161            other => panic!("expected TripleRule, got {other:?}"),
162        }
163    }
164
165    #[test]
166    fn lowers_sparql_rule_opaque() {
167        let ttl = r#"
168            @prefix sh: <http://www.w3.org/ns/shacl#> .
169            @prefix ex: <http://ex/> .
170            ex:S a sh:NodeShape ;
171                sh:targetNode ex:x ;
172                sh:rule [ a sh:SPARQLRule ; sh:construct "CONSTRUCT { ?this ex:p ?this } WHERE {}" ] .
173        "#;
174        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
175        assert_eq!(out.schema.rules.len(), 1);
176        assert!(matches!(
177            out.schema.rules[0].head,
178            shifty_algebra::RuleHead::Sparql(_)
179        ));
180    }
181
182    #[test]
183    fn lowers_sparql_constraint() {
184        let ttl = r#"
185            @prefix sh: <http://www.w3.org/ns/shacl#> .
186            @prefix ex: <http://ex/> .
187            ex:S a sh:NodeShape ;
188                sh:targetNode ex:x ;
189                sh:sparql [ sh:select "SELECT $this WHERE {}" ] .
190        "#;
191        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
192        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
193        let root = out.schema.statements[0].shape;
194        let shifty_algebra::Shape::Annotated {
195            severity, shape, ..
196        } = out.schema.arena.get(root)
197        else {
198            panic!("expected severity annotation");
199        };
200        assert_eq!(severity, &shifty_algebra::Severity::Violation);
201        assert!(matches!(
202            out.schema.arena.get(*shape),
203            shifty_algebra::Shape::Sparql(_)
204        ));
205    }
206
207    #[test]
208    fn lowers_sparql_target() {
209        let ttl = r#"
210            @prefix sh: <http://www.w3.org/ns/shacl#> .
211            @prefix ex: <http://ex/> .
212            ex:S a sh:NodeShape ;
213                sh:target [ sh:select "SELECT ?this WHERE { ?this a ex:Person }" ] .
214        "#;
215        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
216        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
217        assert!(matches!(
218            out.schema.statements[0].selector,
219            shifty_algebra::Selector::Sparql(_)
220        ));
221    }
222}