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
28impl ParseOutput {
29    /// Reject a shapes graph whose lowering reported an error.
30    ///
31    /// Unsupported features remain a caller policy choice, but an invalid
32    /// constraint or rule must not be normalized away and treated as absent.
33    pub fn require_valid(&self) -> Result<(), ParseError> {
34        let errors: Vec<_> = self
35            .diagnostics
36            .iter()
37            .filter(|diagnostic| diagnostic.level == DiagLevel::Error)
38            .collect();
39        if errors.is_empty() {
40            return Ok(());
41        }
42        Err(ParseError(format!(
43            "invalid shapes graph: {}",
44            errors
45                .iter()
46                .map(ToString::to_string)
47                .collect::<Vec<_>>()
48                .join("; ")
49        )))
50    }
51}
52
53/// Load a Turtle shapes graph (for inspecting the raw RDF stage).
54pub fn load_turtle(data: &[u8], base: Option<&str>) -> Result<Loaded, ParseError> {
55    Loaded::from_turtle(data, base)
56}
57
58pub fn load_ntriples(data: &[u8]) -> Result<Loaded, ParseError> {
59    Loaded::from_ntriples(data)
60}
61
62/// Load an RDF graph using content type, source extension, lightweight content
63/// sniffing, and finally a cascading try across common RDF formats.
64pub fn load_rdf_auto(
65    data: &[u8],
66    content_type: Option<&str>,
67    source: Option<&str>,
68    base: Option<&str>,
69) -> Result<Loaded, ParseError> {
70    Loaded::from_rdf_auto(data, content_type, source, base)
71}
72
73/// Parse and lower a Turtle shapes graph into the algebra IR.
74pub fn parse_turtle(data: &[u8], base: Option<&str>) -> Result<ParseOutput, ParseError> {
75    let loaded = Loaded::from_turtle(data, base)?;
76    let lowered = lower::lower(&loaded);
77    Ok(ParseOutput {
78        schema: lowered.schema,
79        diagnostics: lowered.diagnostics,
80    })
81}
82
83/// Lower an already-loaded graph into the algebra IR.
84pub fn parse_loaded(loaded: &Loaded) -> ParseOutput {
85    let lowered = lower::lower(loaded);
86    ParseOutput {
87        schema: lowered.schema,
88        diagnostics: lowered.diagnostics,
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use shifty_algebra::render::schema_to_text;
96
97    const SHAPES: &str = r#"
98        @prefix sh: <http://www.w3.org/ns/shacl#> .
99        @prefix ex: <http://ex/> .
100        @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
101
102        ex:PersonShape a sh:NodeShape ;
103            sh:targetClass ex:Person ;
104            sh:property [
105                sh:path ex:name ;
106                sh:minCount 1 ;
107                sh:maxCount 1 ;
108                sh:datatype xsd:string ;
109            ] ;
110            sh:property [
111                sh:path [ sh:inversePath ex:child ] ;
112                sh:nodeKind sh:IRI ;
113            ] .
114    "#;
115
116    #[test]
117    fn lowers_person_shape() {
118        let out = parse_turtle(SHAPES.as_bytes(), None).unwrap();
119        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
120
121        let text = schema_to_text(&out.schema);
122        // a class-target statement was produced
123        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
124        // cardinality on ex:name lowered to an interval count
125        assert!(text.contains("[1..1] <http://ex/name>"), "text:\n{text}");
126        // inverse path rendered
127        assert!(text.contains("^<http://ex/child>"), "text:\n{text}");
128        // datatype facet present
129        assert!(text.contains("datatype(xsd:string)"), "text:\n{text}");
130    }
131
132    #[test]
133    fn invalid_lowering_diagnostics_are_fatal() {
134        let shapes = br#"
135            @prefix sh: <http://www.w3.org/ns/shacl#> .
136            @prefix ex: <http://ex/> .
137            ex:S a sh:NodeShape ;
138                sh:sparql [
139                    sh:select "SELECT $this WHERE { $this missing:p ?value }"
140                ] .
141        "#;
142        let out = parse_turtle(shapes, None).unwrap();
143        let error = out.require_valid().unwrap_err();
144        assert!(error.to_string().contains("invalid SPARQL query"));
145    }
146
147    #[test]
148    fn sources_provenance_named_and_blank_shapes() {
149        let out = parse_turtle(SHAPES.as_bytes(), None).unwrap();
150        let named_id = out
151            .schema
152            .names
153            .iter()
154            .find(|(_, names)| names.iter().any(|name| name == "http://ex/PersonShape"))
155            .map(|(id, _)| *id)
156            .expect("PersonShape should be named");
157        assert_eq!(
158            out.schema.sources.get(&named_id),
159            Some(&shifty_algebra::Term::NamedNode(
160                shifty_algebra::NamedNode::new("http://ex/PersonShape").unwrap()
161            ))
162        );
163
164        // The two blank `sh:property` shapes also get source entries, not just
165        // the named node shape.
166        let blank_sources = out
167            .schema
168            .sources
169            .values()
170            .filter(|t| matches!(t, shifty_algebra::Term::BlankNode(_)))
171            .count();
172        assert_eq!(blank_sources, 2, "sources: {:?}", out.schema.sources);
173
174        // Deterministic across two parses of the same document.
175        let out2 = parse_turtle(SHAPES.as_bytes(), None).unwrap();
176        assert_eq!(out.schema.sources.len(), out2.schema.sources.len());
177    }
178
179    #[test]
180    fn auto_loads_rdf_xml_shapes() {
181        let rdfxml = br#"<?xml version="1.0"?>
182            <rdf:RDF
183                xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
184                xmlns:sh="http://www.w3.org/ns/shacl#"
185                xmlns:ex="http://ex/">
186              <sh:NodeShape rdf:about="http://ex/S">
187                <sh:targetClass rdf:resource="http://ex/Thing"/>
188              </sh:NodeShape>
189            </rdf:RDF>"#;
190        let loaded = load_rdf_auto(
191            rdfxml,
192            Some("application/rdf+xml"),
193            Some("https://example.test/shapes.rdf"),
194            Some("https://example.test/shapes.rdf"),
195        )
196        .unwrap();
197        let out = parse_loaded(&loaded);
198        let text = schema_to_text(&out.schema);
199        assert!(text.contains("rdf:type/rdfs:subClassOf*"), "text:\n{text}");
200    }
201
202    #[test]
203    fn lowers_triple_rule() {
204        let ttl = r#"
205            @prefix sh: <http://www.w3.org/ns/shacl#> .
206            @prefix ex: <http://ex/> .
207            ex:S a sh:NodeShape ;
208                sh:targetClass ex:Rectangle ;
209                sh:rule [
210                    a sh:TripleRule ;
211                    sh:subject sh:this ;
212                    sh:predicate ex:area ;
213                    sh:object [ sh:path ex:width ] ;
214                    sh:condition ex:S ;
215                    sh:order 1 ;
216                ] .
217        "#;
218        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
219        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
220        assert_eq!(out.schema.rules.len(), 1);
221        let r = &out.schema.rules[0];
222        assert_eq!(r.order, Some(1));
223        assert_eq!(r.conditions.len(), 1);
224        use shifty_algebra::{NodeExpr, RuleHead};
225        match &r.head {
226            RuleHead::Triple {
227                subject,
228                predicate,
229                object,
230            } => {
231                assert!(matches!(subject, NodeExpr::This));
232                assert!(matches!(predicate, NodeExpr::Constant(_)));
233                assert!(matches!(object, NodeExpr::Path(_)));
234            }
235            other => panic!("expected TripleRule, got {other:?}"),
236        }
237    }
238
239    #[test]
240    fn lowers_sparql_rule_opaque() {
241        let ttl = r#"
242            @prefix sh: <http://www.w3.org/ns/shacl#> .
243            @prefix ex: <http://ex/> .
244            ex:S a sh:NodeShape ;
245                sh:targetNode ex:x ;
246                sh:rule [ a sh:SPARQLRule ; sh:construct "CONSTRUCT { ?this ex:p ?this } WHERE {}" ] .
247        "#;
248        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
249        assert_eq!(out.schema.rules.len(), 1);
250        assert!(matches!(
251            out.schema.rules[0].head,
252            shifty_algebra::RuleHead::Sparql(_)
253        ));
254    }
255
256    #[test]
257    fn lowers_sparql_constraint() {
258        let ttl = r#"
259            @prefix sh: <http://www.w3.org/ns/shacl#> .
260            @prefix ex: <http://ex/> .
261            ex:S a sh:NodeShape ;
262                sh:targetNode ex:x ;
263                sh:sparql [ sh:select "SELECT $this WHERE {}" ] .
264        "#;
265        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
266        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
267        let root = out.schema.statements[0].shape;
268        let shifty_algebra::Shape::Annotated {
269            severity, shape, ..
270        } = out.schema.arena.get(root)
271        else {
272            panic!("expected severity annotation");
273        };
274        assert_eq!(severity, &shifty_algebra::Severity::Violation);
275        assert!(matches!(
276            out.schema.arena.get(*shape),
277            shifty_algebra::Shape::Sparql(_)
278        ));
279    }
280
281    #[test]
282    fn lowers_sparql_target() {
283        let ttl = r#"
284            @prefix sh: <http://www.w3.org/ns/shacl#> .
285            @prefix ex: <http://ex/> .
286            ex:S a sh:NodeShape ;
287                sh:target [ sh:select "SELECT ?this WHERE { ?this a ex:Person }" ] .
288        "#;
289        let out = parse_turtle(ttl.as_bytes(), None).unwrap();
290        assert!(out.diagnostics.is_empty(), "diags: {:?}", out.diagnostics);
291        assert!(matches!(
292            out.schema.statements[0].selector,
293            shifty_algebra::Selector::Sparql(_)
294        ));
295    }
296}