Skip to main content

shifty_parse/
graph.rs

1//! Loading an RDF graph from Turtle and convenience accessors over it.
2
3use crate::diagnostics::ParseError;
4use crate::vocab;
5use oxrdf::{Graph, NamedNode, NamedNodeRef, NamedOrBlankNode, Term, Triple};
6use oxrdfio::RdfParser;
7use oxttl::{NTriplesParser, TurtleParser};
8use std::collections::HashSet;
9use std::fs::File;
10use std::io::{BufReader, Read};
11use std::path::Path;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum RdfFormat {
15    Turtle,
16    NTriples,
17    RdfXml,
18    NQuads,
19    TriG,
20    N3,
21}
22
23impl RdfFormat {
24    pub fn from_media_type(media_type: &str) -> Option<Self> {
25        match oxrdfio::RdfFormat::from_media_type(media_type)? {
26            oxrdfio::RdfFormat::Turtle => Some(Self::Turtle),
27            oxrdfio::RdfFormat::NTriples => Some(Self::NTriples),
28            oxrdfio::RdfFormat::RdfXml => Some(Self::RdfXml),
29            oxrdfio::RdfFormat::NQuads => Some(Self::NQuads),
30            oxrdfio::RdfFormat::TriG => Some(Self::TriG),
31            oxrdfio::RdfFormat::N3 => Some(Self::N3),
32            _ => None,
33        }
34    }
35
36    pub fn from_extension(extension: &str) -> Option<Self> {
37        match oxrdfio::RdfFormat::from_extension(extension)? {
38            oxrdfio::RdfFormat::Turtle => Some(Self::Turtle),
39            oxrdfio::RdfFormat::NTriples => Some(Self::NTriples),
40            oxrdfio::RdfFormat::RdfXml => Some(Self::RdfXml),
41            oxrdfio::RdfFormat::NQuads => Some(Self::NQuads),
42            oxrdfio::RdfFormat::TriG => Some(Self::TriG),
43            oxrdfio::RdfFormat::N3 => Some(Self::N3),
44            _ => None,
45        }
46    }
47
48    fn to_oxrdfio(self) -> oxrdfio::RdfFormat {
49        match self {
50            Self::Turtle => oxrdfio::RdfFormat::Turtle,
51            Self::NTriples => oxrdfio::RdfFormat::NTriples,
52            Self::RdfXml => oxrdfio::RdfFormat::RdfXml,
53            Self::NQuads => oxrdfio::RdfFormat::NQuads,
54            Self::TriG => oxrdfio::RdfFormat::TriG,
55            Self::N3 => oxrdfio::RdfFormat::N3,
56        }
57    }
58}
59
60/// A loaded shapes graph plus the prefixes declared in the document.
61pub struct Loaded {
62    pub graph: Graph,
63    pub prefixes: Vec<(String, String)>,
64    pub base: Option<String>,
65}
66
67impl Loaded {
68    /// Parse a Turtle document into an in-memory graph.
69    pub fn from_turtle(data: &[u8], base: Option<&str>) -> Result<Self, ParseError> {
70        Self::from_turtle_reader(data, base)
71    }
72
73    pub fn from_ntriples(data: &[u8]) -> Result<Self, ParseError> {
74        Self::from_ntriples_reader(data)
75    }
76
77    pub fn from_rdf(
78        data: &[u8],
79        format: RdfFormat,
80        base: Option<&str>,
81    ) -> Result<Self, ParseError> {
82        match format {
83            RdfFormat::Turtle => Self::from_turtle_reader(data, base),
84            RdfFormat::NTriples => Self::from_ntriples_reader(data),
85            _ => Self::from_oxrdfio(data, format, base),
86        }
87    }
88
89    pub fn from_rdf_auto(
90        data: &[u8],
91        content_type: Option<&str>,
92        source: Option<&str>,
93        base: Option<&str>,
94    ) -> Result<Self, ParseError> {
95        let sniffed = sniff_format(data);
96        let hinted = content_type
97            .and_then(RdfFormat::from_media_type)
98            .or_else(|| source.and_then(format_from_source))
99            .or(sniffed);
100
101        if let Some(format) = hinted {
102            match Self::from_rdf(data, format, base) {
103                Ok(loaded) => return Ok(loaded),
104                Err(first_error)
105                    if content_type.is_some() || source.is_some() || sniffed.is_some() =>
106                {
107                    return Err(first_error);
108                }
109                Err(_) => {}
110            }
111        }
112
113        let mut errors = Vec::new();
114        for format in [
115            RdfFormat::Turtle,
116            RdfFormat::RdfXml,
117            RdfFormat::NTriples,
118            RdfFormat::NQuads,
119            RdfFormat::TriG,
120            RdfFormat::N3,
121        ] {
122            match Self::from_rdf(data, format, base) {
123                Ok(loaded) => return Ok(loaded),
124                Err(e) => errors.push(format!("{format:?}: {e}")),
125            }
126        }
127        Err(ParseError(format!(
128            "failed to parse RDF using common formats: {}",
129            errors.join("; ")
130        )))
131    }
132
133    pub fn from_path(
134        path: &Path,
135        format: RdfFormat,
136        base: Option<&str>,
137    ) -> Result<Self, ParseError> {
138        let file = File::open(path)
139            .map_err(|e| ParseError(format!("failed to open {}: {e}", path.display())))?;
140        let reader = BufReader::new(file);
141        match format {
142            RdfFormat::Turtle => Self::from_turtle_reader(reader, base),
143            RdfFormat::NTriples => Self::from_ntriples_reader(reader),
144            _ => {
145                let mut bytes = Vec::new();
146                let mut reader = reader;
147                reader
148                    .read_to_end(&mut bytes)
149                    .map_err(|e| ParseError(format!("failed to read RDF input: {e}")))?;
150                Self::from_oxrdfio(&bytes, format, base)
151            }
152        }
153    }
154
155    fn from_turtle_reader(reader: impl Read, base: Option<&str>) -> Result<Self, ParseError> {
156        let mut parser = TurtleParser::new();
157        if let Some(b) = base {
158            parser = parser
159                .with_base_iri(b)
160                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
161        }
162        let mut reader = parser.for_reader(reader);
163        let mut graph = Graph::new();
164        for triple in reader.by_ref() {
165            let triple = triple.map_err(|e| ParseError(format!("turtle syntax error: {e}")))?;
166            graph.insert(&triple);
167        }
168        let prefixes = reader
169            .prefixes()
170            .map(|(p, iri)| (p.to_string(), iri.to_string()))
171            .collect();
172        let base = reader.base_iri().map(|s| s.to_string());
173        Ok(Self {
174            graph,
175            prefixes,
176            base,
177        })
178    }
179
180    fn from_ntriples_reader(reader: impl Read) -> Result<Self, ParseError> {
181        let mut graph = Graph::new();
182        for triple in NTriplesParser::new().for_reader(reader) {
183            let triple = triple.map_err(|e| ParseError(format!("N-Triples syntax error: {e}")))?;
184            graph.insert(&triple);
185        }
186        Ok(Self {
187            graph,
188            prefixes: Vec::new(),
189            base: None,
190        })
191    }
192
193    fn from_oxrdfio(
194        data: &[u8],
195        format: RdfFormat,
196        base: Option<&str>,
197    ) -> Result<Self, ParseError> {
198        let mut parser = RdfParser::from_format(format.to_oxrdfio()).without_named_graphs();
199        if let Some(base) = base {
200            parser = parser
201                .with_base_iri(base)
202                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
203        }
204        let mut graph = Graph::new();
205        for quad in parser.for_slice(data) {
206            let quad = quad.map_err(|e| ParseError(format!("{format:?} syntax error: {e}")))?;
207            graph.insert(&Triple {
208                subject: quad.subject,
209                predicate: quad.predicate,
210                object: quad.object,
211            });
212        }
213        Ok(Self {
214            graph,
215            prefixes: Vec::new(),
216            base: base.map(ToOwned::to_owned),
217        })
218    }
219
220    /// All objects of `(subject, predicate)`.
221    pub fn objects(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Vec<Term> {
222        self.graph
223            .objects_for_subject_predicate(subject, predicate)
224            .map(|t| t.into_owned())
225            .collect()
226    }
227
228    /// The first object of `(subject, predicate)`, if any.
229    pub fn object(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Option<Term> {
230        self.graph
231            .object_for_subject_predicate(subject, predicate)
232            .map(|t| t.into_owned())
233    }
234
235    /// Does the subject have `(subject, rdf:type, ty)`?
236    pub fn has_type(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
237        self.objects(subject, vocab::RDF_TYPE)
238            .iter()
239            .any(|t| matches!(t, Term::NamedNode(n) if n.as_ref() == ty))
240    }
241
242    /// Does the subject have `ty` through `rdf:type/rdfs:subClassOf*`?
243    pub fn is_instance_of(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
244        let mut pending: Vec<NamedNode> = self
245            .objects(subject, vocab::RDF_TYPE)
246            .into_iter()
247            .filter_map(|term| match term {
248                Term::NamedNode(node) => Some(node),
249                _ => None,
250            })
251            .collect();
252        let mut seen = HashSet::new();
253        while let Some(class) = pending.pop() {
254            if class.as_ref() == ty {
255                return true;
256            }
257            if !seen.insert(class.clone()) {
258                continue;
259            }
260            pending.extend(
261                self.objects(&NamedOrBlankNode::NamedNode(class), vocab::RDFS_SUBCLASSOF)
262                    .into_iter()
263                    .filter_map(|term| match term {
264                        Term::NamedNode(node) => Some(node),
265                        _ => None,
266                    }),
267            );
268        }
269        false
270    }
271
272    /// Merge all triples from `other` into this graph.
273    pub fn merge_from(&mut self, other: &Loaded) {
274        for triple in other.graph.iter() {
275            self.graph.insert(triple);
276        }
277    }
278
279    /// Read an `rdf:List` starting at `head` into its member terms.
280    pub fn read_list(&self, head: &Term) -> Vec<Term> {
281        let mut out = Vec::new();
282        let mut cursor = head.clone();
283        while let Some(node) = term_to_node(&cursor) {
284            if is_nil(&cursor) {
285                break;
286            }
287            if let Some(first) = self.object(&node, vocab::RDF_FIRST) {
288                out.push(first);
289            }
290            match self.object(&node, vocab::RDF_REST) {
291                Some(rest) => cursor = rest,
292                None => break,
293            }
294        }
295        out
296    }
297}
298
299fn format_from_source(source: &str) -> Option<RdfFormat> {
300    let path = source.split(['?', '#']).next().unwrap_or(source);
301    let extension = path.rsplit_once('.')?.1;
302    RdfFormat::from_extension(extension)
303}
304
305fn sniff_format(data: &[u8]) -> Option<RdfFormat> {
306    // Only examine a small prefix — format signatures are always near the top.
307    let prefix = &data[..data.len().min(4096)];
308    let text = std::str::from_utf8(prefix).ok()?;
309
310    // Walk past blank lines and # comment lines to find the first real token.
311    let first_token = text
312        .lines()
313        .map(|l| l.trim_start())
314        .find(|l| !l.is_empty() && !l.starts_with('#'))?;
315
316    if first_token.starts_with("<?xml") || first_token.starts_with("<rdf:RDF") {
317        return Some(RdfFormat::RdfXml);
318    }
319    if first_token.starts_with("@prefix")
320        || first_token.starts_with("@base")
321        || first_token.starts_with("PREFIX")
322        || first_token.starts_with("BASE")
323    {
324        return Some(RdfFormat::Turtle);
325    }
326    // Check only the first real line, not the whole buffer.
327    if first_token.starts_with('<') && first_token.contains("> <") {
328        return Some(RdfFormat::NTriples);
329    }
330    None
331}
332
333/// Convert an object term into a subject position node, if it is not a literal.
334pub fn term_to_node(term: &Term) -> Option<NamedOrBlankNode> {
335    match term {
336        Term::NamedNode(n) => Some(NamedOrBlankNode::NamedNode(n.clone())),
337        Term::BlankNode(b) => Some(NamedOrBlankNode::BlankNode(b.clone())),
338        Term::Literal(_) => None,
339    }
340}
341
342/// Is this term `rdf:nil`?
343pub fn is_nil(term: &Term) -> bool {
344    matches!(term, Term::NamedNode(n) if n.as_ref() == vocab::RDF_NIL)
345}
346
347/// The IRI of a named node, or `None` for blanks/literals.
348pub fn as_named(term: &Term) -> Option<NamedNode> {
349    match term {
350        Term::NamedNode(n) => Some(n.clone()),
351        _ => None,
352    }
353}
354
355#[cfg(test)]
356mod tests {
357    use super::*;
358
359    // sniff_format tests
360
361    #[test]
362    fn sniff_rdfxml_xml_declaration() {
363        assert_eq!(
364            sniff_format(b"<?xml version=\"1.0\"?>\n<rdf:RDF>"),
365            Some(RdfFormat::RdfXml)
366        );
367    }
368
369    #[test]
370    fn sniff_rdfxml_bare_tag() {
371        assert_eq!(
372            sniff_format(b"<rdf:RDF xmlns:rdf=\"...\">"),
373            Some(RdfFormat::RdfXml)
374        );
375    }
376
377    #[test]
378    fn sniff_turtle_at_prefix() {
379        assert_eq!(
380            sniff_format(b"@prefix sh: <http://www.w3.org/ns/shacl#> ."),
381            Some(RdfFormat::Turtle)
382        );
383    }
384
385    #[test]
386    fn sniff_turtle_at_base() {
387        assert_eq!(
388            sniff_format(b"@base <http://example.org/> ."),
389            Some(RdfFormat::Turtle)
390        );
391    }
392
393    #[test]
394    fn sniff_turtle_sparql_prefix() {
395        assert_eq!(
396            sniff_format(b"PREFIX sh: <http://www.w3.org/ns/shacl#>"),
397            Some(RdfFormat::Turtle)
398        );
399    }
400
401    #[test]
402    fn sniff_turtle_sparql_base() {
403        assert_eq!(
404            sniff_format(b"BASE <http://example.org/>"),
405            Some(RdfFormat::Turtle)
406        );
407    }
408
409    #[test]
410    fn sniff_turtle_after_comment_lines() {
411        let data = b"# Copyright 2024\n# Licensed under ...\n@prefix ex: <http://ex/> .";
412        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
413    }
414
415    #[test]
416    fn sniff_turtle_after_blank_and_comment_lines() {
417        let data = b"\n\n# A comment\n\nPREFIX ex: <http://ex/>";
418        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
419    }
420
421    #[test]
422    fn sniff_ntriples_first_line() {
423        let data = b"<http://ex/s> <http://ex/p> <http://ex/o> .\n";
424        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
425    }
426
427    #[test]
428    fn sniff_ntriples_after_comment() {
429        let data = b"# generated by riot\n<http://ex/s> <http://ex/p> \"value\" .\n";
430        // NTriples line has `> <` pattern
431        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
432    }
433
434    #[test]
435    fn sniff_ntriples_contains_check_uses_first_line_only() {
436        // The "> <" pattern only appears in the second line; first real line has no prefix,
437        // so sniff should return None rather than scanning deep into the buffer.
438        let data =
439            b"# comment\n_:b0 <http://ex/p> _:b1 .\n<http://ex/s> <http://ex/p> <http://ex/o> .\n";
440        assert_eq!(sniff_format(data), None);
441    }
442
443    #[test]
444    fn sniff_returns_none_for_unknown() {
445        assert_eq!(sniff_format(b"SELECT ?s WHERE { ?s a ex:Thing }"), None);
446    }
447
448    #[test]
449    fn sniff_only_reads_prefix() {
450        // Build a buffer >4096 bytes whose format marker is at the very start.
451        let mut data = b"@prefix ex: <http://ex/> .\n".to_vec();
452        data.extend(std::iter::repeat_n(b'x', 8000));
453        assert_eq!(sniff_format(&data), Some(RdfFormat::Turtle));
454    }
455
456    #[test]
457    fn sniff_ignores_format_marker_beyond_prefix_window() {
458        // Format marker buried past the 4096-byte sniff window — should not be detected.
459        let mut data = vec![b' '; 5000];
460        data.extend_from_slice(b"@prefix ex: <http://ex/> .");
461        assert_eq!(sniff_format(&data), None);
462    }
463}