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_slice(data, base)
71    }
72
73    pub fn from_ntriples(data: &[u8]) -> Result<Self, ParseError> {
74        Self::from_ntriples_slice(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_slice(data, base),
84            RdfFormat::NTriples => Self::from_ntriples_slice(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    /// Parse Turtle from bytes already resident in memory.
156    ///
157    /// oxttl's slice parser borrows directly from the buffer, while the reader
158    /// parser re-buffers and copies as it refills. Callers that start from a
159    /// `Vec<u8>` (every local file and HTTP body here) should use this: it is
160    /// roughly 40% faster to parse, worth ~120 ms on Brick's 18 MB closure.
161    fn from_turtle_slice(data: &[u8], base: Option<&str>) -> Result<Self, ParseError> {
162        let mut parser = TurtleParser::new();
163        if let Some(b) = base {
164            parser = parser
165                .with_base_iri(b)
166                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
167        }
168        let mut reader = parser.for_slice(data);
169        let mut graph = Graph::new();
170        for triple in reader.by_ref() {
171            let triple = triple.map_err(|e| ParseError(format!("turtle syntax error: {e}")))?;
172            graph.insert(&triple);
173        }
174        let prefixes = reader
175            .prefixes()
176            .map(|(p, iri)| (p.to_string(), iri.to_string()))
177            .collect();
178        let base = reader.base_iri().map(|s| s.to_string());
179        Ok(Self {
180            graph,
181            prefixes,
182            base,
183        })
184    }
185
186    /// N-Triples counterpart to [`Self::from_turtle_slice`].
187    fn from_ntriples_slice(data: &[u8]) -> Result<Self, ParseError> {
188        let mut graph = Graph::new();
189        for triple in NTriplesParser::new().for_slice(data) {
190            let triple = triple.map_err(|e| ParseError(format!("N-Triples syntax error: {e}")))?;
191            graph.insert(&triple);
192        }
193        Ok(Self {
194            graph,
195            prefixes: Vec::new(),
196            base: None,
197        })
198    }
199
200    fn from_turtle_reader(reader: impl Read, base: Option<&str>) -> Result<Self, ParseError> {
201        let mut parser = TurtleParser::new();
202        if let Some(b) = base {
203            parser = parser
204                .with_base_iri(b)
205                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
206        }
207        let mut reader = parser.for_reader(reader);
208        let mut graph = Graph::new();
209        for triple in reader.by_ref() {
210            let triple = triple.map_err(|e| ParseError(format!("turtle syntax error: {e}")))?;
211            graph.insert(&triple);
212        }
213        let prefixes = reader
214            .prefixes()
215            .map(|(p, iri)| (p.to_string(), iri.to_string()))
216            .collect();
217        let base = reader.base_iri().map(|s| s.to_string());
218        Ok(Self {
219            graph,
220            prefixes,
221            base,
222        })
223    }
224
225    fn from_ntriples_reader(reader: impl Read) -> Result<Self, ParseError> {
226        let mut graph = Graph::new();
227        for triple in NTriplesParser::new().for_reader(reader) {
228            let triple = triple.map_err(|e| ParseError(format!("N-Triples syntax error: {e}")))?;
229            graph.insert(&triple);
230        }
231        Ok(Self {
232            graph,
233            prefixes: Vec::new(),
234            base: None,
235        })
236    }
237
238    fn from_oxrdfio(
239        data: &[u8],
240        format: RdfFormat,
241        base: Option<&str>,
242    ) -> Result<Self, ParseError> {
243        let mut parser = RdfParser::from_format(format.to_oxrdfio()).without_named_graphs();
244        if let Some(base) = base {
245            parser = parser
246                .with_base_iri(base)
247                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
248        }
249        let mut graph = Graph::new();
250        for quad in parser.for_slice(data) {
251            let quad = quad.map_err(|e| ParseError(format!("{format:?} syntax error: {e}")))?;
252            graph.insert(&Triple {
253                subject: quad.subject,
254                predicate: quad.predicate,
255                object: quad.object,
256            });
257        }
258        Ok(Self {
259            graph,
260            prefixes: Vec::new(),
261            base: base.map(ToOwned::to_owned),
262        })
263    }
264
265    /// All objects of `(subject, predicate)`.
266    pub fn objects(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Vec<Term> {
267        self.graph
268            .objects_for_subject_predicate(subject, predicate)
269            .map(|t| t.into_owned())
270            .collect()
271    }
272
273    /// The first object of `(subject, predicate)`, if any.
274    pub fn object(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Option<Term> {
275        self.graph
276            .object_for_subject_predicate(subject, predicate)
277            .map(|t| t.into_owned())
278    }
279
280    /// Does the subject have `(subject, rdf:type, ty)`?
281    pub fn has_type(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
282        self.objects(subject, vocab::RDF_TYPE)
283            .iter()
284            .any(|t| matches!(t, Term::NamedNode(n) if n.as_ref() == ty))
285    }
286
287    /// Does the subject have `ty` through `rdf:type/rdfs:subClassOf*`?
288    pub fn is_instance_of(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
289        let mut pending: Vec<NamedNode> = self
290            .objects(subject, vocab::RDF_TYPE)
291            .into_iter()
292            .filter_map(|term| match term {
293                Term::NamedNode(node) => Some(node),
294                _ => None,
295            })
296            .collect();
297        let mut seen = HashSet::new();
298        while let Some(class) = pending.pop() {
299            if class.as_ref() == ty {
300                return true;
301            }
302            if !seen.insert(class.clone()) {
303                continue;
304            }
305            pending.extend(
306                self.objects(&NamedOrBlankNode::NamedNode(class), vocab::RDFS_SUBCLASSOF)
307                    .into_iter()
308                    .filter_map(|term| match term {
309                        Term::NamedNode(node) => Some(node),
310                        _ => None,
311                    }),
312            );
313        }
314        false
315    }
316
317    /// Merge all triples from `other` into this graph.
318    pub fn merge_from(&mut self, other: &Loaded) {
319        for triple in other.graph.iter() {
320            self.graph.insert(triple);
321        }
322    }
323
324    /// Read an `rdf:List` starting at `head` into its member terms.
325    pub fn read_list(&self, head: &Term) -> Vec<Term> {
326        let mut out = Vec::new();
327        let mut cursor = head.clone();
328        while let Some(node) = term_to_node(&cursor) {
329            if is_nil(&cursor) {
330                break;
331            }
332            if let Some(first) = self.object(&node, vocab::RDF_FIRST) {
333                out.push(first);
334            }
335            match self.object(&node, vocab::RDF_REST) {
336                Some(rest) => cursor = rest,
337                None => break,
338            }
339        }
340        out
341    }
342}
343
344fn format_from_source(source: &str) -> Option<RdfFormat> {
345    let path = source.split(['?', '#']).next().unwrap_or(source);
346    let extension = path.rsplit_once('.')?.1;
347    RdfFormat::from_extension(extension)
348}
349
350fn sniff_format(data: &[u8]) -> Option<RdfFormat> {
351    // Only examine a small prefix — format signatures are always near the top.
352    let prefix = &data[..data.len().min(4096)];
353    let text = std::str::from_utf8(prefix).ok()?;
354
355    // Walk past blank lines and # comment lines to find the first real token.
356    let first_token = text
357        .lines()
358        .map(|l| l.trim_start())
359        .find(|l| !l.is_empty() && !l.starts_with('#'))?;
360
361    if first_token.starts_with("<?xml") || first_token.starts_with("<rdf:RDF") {
362        return Some(RdfFormat::RdfXml);
363    }
364    if first_token.starts_with("@prefix")
365        || first_token.starts_with("@base")
366        || first_token.starts_with("PREFIX")
367        || first_token.starts_with("BASE")
368    {
369        return Some(RdfFormat::Turtle);
370    }
371    // Check only the first real line, not the whole buffer.
372    if first_token.starts_with('<') && first_token.contains("> <") {
373        return Some(RdfFormat::NTriples);
374    }
375    None
376}
377
378/// Convert an object term into a subject position node, if it is not a literal.
379pub fn term_to_node(term: &Term) -> Option<NamedOrBlankNode> {
380    match term {
381        Term::NamedNode(n) => Some(NamedOrBlankNode::NamedNode(n.clone())),
382        Term::BlankNode(b) => Some(NamedOrBlankNode::BlankNode(b.clone())),
383        Term::Literal(_) => None,
384    }
385}
386
387/// Is this term `rdf:nil`?
388pub fn is_nil(term: &Term) -> bool {
389    matches!(term, Term::NamedNode(n) if n.as_ref() == vocab::RDF_NIL)
390}
391
392/// The IRI of a named node, or `None` for blanks/literals.
393pub fn as_named(term: &Term) -> Option<NamedNode> {
394    match term {
395        Term::NamedNode(n) => Some(n.clone()),
396        _ => None,
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    // sniff_format tests
405
406    #[test]
407    fn sniff_rdfxml_xml_declaration() {
408        assert_eq!(
409            sniff_format(b"<?xml version=\"1.0\"?>\n<rdf:RDF>"),
410            Some(RdfFormat::RdfXml)
411        );
412    }
413
414    #[test]
415    fn sniff_rdfxml_bare_tag() {
416        assert_eq!(
417            sniff_format(b"<rdf:RDF xmlns:rdf=\"...\">"),
418            Some(RdfFormat::RdfXml)
419        );
420    }
421
422    #[test]
423    fn sniff_turtle_at_prefix() {
424        assert_eq!(
425            sniff_format(b"@prefix sh: <http://www.w3.org/ns/shacl#> ."),
426            Some(RdfFormat::Turtle)
427        );
428    }
429
430    #[test]
431    fn sniff_turtle_at_base() {
432        assert_eq!(
433            sniff_format(b"@base <http://example.org/> ."),
434            Some(RdfFormat::Turtle)
435        );
436    }
437
438    #[test]
439    fn sniff_turtle_sparql_prefix() {
440        assert_eq!(
441            sniff_format(b"PREFIX sh: <http://www.w3.org/ns/shacl#>"),
442            Some(RdfFormat::Turtle)
443        );
444    }
445
446    #[test]
447    fn sniff_turtle_sparql_base() {
448        assert_eq!(
449            sniff_format(b"BASE <http://example.org/>"),
450            Some(RdfFormat::Turtle)
451        );
452    }
453
454    #[test]
455    fn sniff_turtle_after_comment_lines() {
456        let data = b"# Copyright 2024\n# Licensed under ...\n@prefix ex: <http://ex/> .";
457        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
458    }
459
460    #[test]
461    fn sniff_turtle_after_blank_and_comment_lines() {
462        let data = b"\n\n# A comment\n\nPREFIX ex: <http://ex/>";
463        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
464    }
465
466    #[test]
467    fn sniff_ntriples_first_line() {
468        let data = b"<http://ex/s> <http://ex/p> <http://ex/o> .\n";
469        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
470    }
471
472    #[test]
473    fn sniff_ntriples_after_comment() {
474        let data = b"# generated by riot\n<http://ex/s> <http://ex/p> \"value\" .\n";
475        // NTriples line has `> <` pattern
476        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
477    }
478
479    #[test]
480    fn sniff_ntriples_contains_check_uses_first_line_only() {
481        // The "> <" pattern only appears in the second line; first real line has no prefix,
482        // so sniff should return None rather than scanning deep into the buffer.
483        let data =
484            b"# comment\n_:b0 <http://ex/p> _:b1 .\n<http://ex/s> <http://ex/p> <http://ex/o> .\n";
485        assert_eq!(sniff_format(data), None);
486    }
487
488    #[test]
489    fn sniff_returns_none_for_unknown() {
490        assert_eq!(sniff_format(b"SELECT ?s WHERE { ?s a ex:Thing }"), None);
491    }
492
493    #[test]
494    fn sniff_only_reads_prefix() {
495        // Build a buffer >4096 bytes whose format marker is at the very start.
496        let mut data = b"@prefix ex: <http://ex/> .\n".to_vec();
497        data.extend(std::iter::repeat_n(b'x', 8000));
498        assert_eq!(sniff_format(&data), Some(RdfFormat::Turtle));
499    }
500
501    #[test]
502    fn sniff_ignores_format_marker_beyond_prefix_window() {
503        // Format marker buried past the 4096-byte sniff window — should not be detected.
504        let mut data = vec![b' '; 5000];
505        data.extend_from_slice(b"@prefix ex: <http://ex/> .");
506        assert_eq!(sniff_format(&data), None);
507    }
508}