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