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    /// The document-format name to show a reader (media-type spelling).
49    pub fn name(self) -> &'static str {
50        match self {
51            Self::Turtle => "turtle",
52            Self::NTriples => "n-triples",
53            Self::RdfXml => "rdf/xml",
54            Self::NQuads => "n-quads",
55            Self::TriG => "trig",
56            Self::N3 => "n3",
57        }
58    }
59
60    fn to_oxrdfio(self) -> oxrdfio::RdfFormat {
61        match self {
62            Self::Turtle => oxrdfio::RdfFormat::Turtle,
63            Self::NTriples => oxrdfio::RdfFormat::NTriples,
64            Self::RdfXml => oxrdfio::RdfFormat::RdfXml,
65            Self::NQuads => oxrdfio::RdfFormat::NQuads,
66            Self::TriG => oxrdfio::RdfFormat::TriG,
67            Self::N3 => oxrdfio::RdfFormat::N3,
68        }
69    }
70}
71
72impl std::fmt::Display for RdfFormat {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        f.write_str(self.name())
75    }
76}
77
78/// A loaded shapes graph plus the prefixes declared in the document.
79pub struct Loaded {
80    pub graph: Graph,
81    pub prefixes: Vec<(String, String)>,
82    pub base: Option<String>,
83}
84
85impl Loaded {
86    /// Parse a Turtle document into an in-memory graph.
87    pub fn from_turtle(data: &[u8], base: Option<&str>) -> Result<Self, ParseError> {
88        Self::from_turtle_slice(data, base)
89    }
90
91    pub fn from_ntriples(data: &[u8]) -> Result<Self, ParseError> {
92        Self::from_ntriples_slice(data)
93    }
94
95    pub fn from_rdf(
96        data: &[u8],
97        format: RdfFormat,
98        base: Option<&str>,
99    ) -> Result<Self, ParseError> {
100        match format {
101            RdfFormat::Turtle => Self::from_turtle_slice(data, base),
102            RdfFormat::NTriples => Self::from_ntriples_slice(data),
103            _ => Self::from_oxrdfio(data, format, base),
104        }
105    }
106
107    pub fn from_rdf_auto(
108        data: &[u8],
109        content_type: Option<&str>,
110        source: Option<&str>,
111        base: Option<&str>,
112    ) -> Result<Self, ParseError> {
113        Self::from_rdf_auto_reporting(data, content_type, source, base).map(|(loaded, _)| loaded)
114    }
115
116    /// [`Self::from_rdf_auto`], also reporting the format that actually parsed
117    /// the document.
118    ///
119    /// The format is the one that succeeded, not the one that was guessed: a
120    /// `.ttl.md` literate-Turtle document hints at nothing by extension, is
121    /// sniffed as Turtle, and reports Turtle because Turtle is what read it.
122    pub fn from_rdf_auto_reporting(
123        data: &[u8],
124        content_type: Option<&str>,
125        source: Option<&str>,
126        base: Option<&str>,
127    ) -> Result<(Self, RdfFormat), ParseError> {
128        let sniffed = sniff_format(data);
129        let hinted = content_type
130            .and_then(RdfFormat::from_media_type)
131            .or_else(|| source.and_then(format_from_source))
132            .or(sniffed);
133
134        if let Some(format) = hinted {
135            match Self::from_rdf(data, format, base) {
136                Ok(loaded) => return Ok((loaded, format)),
137                Err(first_error)
138                    if content_type.is_some() || source.is_some() || sniffed.is_some() =>
139                {
140                    return Err(first_error);
141                }
142                Err(_) => {}
143            }
144        }
145
146        let mut errors = Vec::new();
147        for format in [
148            RdfFormat::Turtle,
149            RdfFormat::RdfXml,
150            RdfFormat::NTriples,
151            RdfFormat::NQuads,
152            RdfFormat::TriG,
153            RdfFormat::N3,
154        ] {
155            match Self::from_rdf(data, format, base) {
156                Ok(loaded) => return Ok((loaded, format)),
157                Err(e) => errors.push(format!("{format:?}: {e}")),
158            }
159        }
160        Err(ParseError(format!(
161            "failed to parse RDF using common formats: {}",
162            errors.join("; ")
163        )))
164    }
165
166    pub fn from_path(
167        path: &Path,
168        format: RdfFormat,
169        base: Option<&str>,
170    ) -> Result<Self, ParseError> {
171        let file = File::open(path)
172            .map_err(|e| ParseError(format!("failed to open {}: {e}", path.display())))?;
173        let reader = BufReader::new(file);
174        match format {
175            RdfFormat::Turtle => Self::from_turtle_reader(reader, base),
176            RdfFormat::NTriples => Self::from_ntriples_reader(reader),
177            _ => {
178                let mut bytes = Vec::new();
179                let mut reader = reader;
180                reader
181                    .read_to_end(&mut bytes)
182                    .map_err(|e| ParseError(format!("failed to read RDF input: {e}")))?;
183                Self::from_oxrdfio(&bytes, format, base)
184            }
185        }
186    }
187
188    /// Parse Turtle from bytes already resident in memory.
189    ///
190    /// oxttl's slice parser borrows directly from the buffer, while the reader
191    /// parser re-buffers and copies as it refills. Callers that start from a
192    /// `Vec<u8>` (every local file and HTTP body here) should use this: it is
193    /// roughly 40% faster to parse, worth ~120 ms on Brick's 18 MB closure.
194    fn from_turtle_slice(data: &[u8], base: Option<&str>) -> Result<Self, ParseError> {
195        let mut parser = TurtleParser::new();
196        if let Some(b) = base {
197            parser = parser
198                .with_base_iri(b)
199                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
200        }
201        let mut reader = parser.for_slice(data);
202        let mut graph = Graph::new();
203        for triple in reader.by_ref() {
204            let triple = triple.map_err(|e| ParseError(format!("turtle syntax error: {e}")))?;
205            graph.insert(&triple);
206        }
207        let prefixes = reader
208            .prefixes()
209            .map(|(p, iri)| (p.to_string(), iri.to_string()))
210            .collect();
211        let base = reader.base_iri().map(|s| s.to_string());
212        Ok(Self {
213            graph,
214            prefixes,
215            base,
216        })
217    }
218
219    /// N-Triples counterpart to [`Self::from_turtle_slice`].
220    fn from_ntriples_slice(data: &[u8]) -> Result<Self, ParseError> {
221        let mut graph = Graph::new();
222        for triple in NTriplesParser::new().for_slice(data) {
223            let triple = triple.map_err(|e| ParseError(format!("N-Triples syntax error: {e}")))?;
224            graph.insert(&triple);
225        }
226        Ok(Self {
227            graph,
228            prefixes: Vec::new(),
229            base: None,
230        })
231    }
232
233    fn from_turtle_reader(reader: impl Read, base: Option<&str>) -> Result<Self, ParseError> {
234        let mut parser = TurtleParser::new();
235        if let Some(b) = base {
236            parser = parser
237                .with_base_iri(b)
238                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
239        }
240        let mut reader = parser.for_reader(reader);
241        let mut graph = Graph::new();
242        for triple in reader.by_ref() {
243            let triple = triple.map_err(|e| ParseError(format!("turtle syntax error: {e}")))?;
244            graph.insert(&triple);
245        }
246        let prefixes = reader
247            .prefixes()
248            .map(|(p, iri)| (p.to_string(), iri.to_string()))
249            .collect();
250        let base = reader.base_iri().map(|s| s.to_string());
251        Ok(Self {
252            graph,
253            prefixes,
254            base,
255        })
256    }
257
258    fn from_ntriples_reader(reader: impl Read) -> Result<Self, ParseError> {
259        let mut graph = Graph::new();
260        for triple in NTriplesParser::new().for_reader(reader) {
261            let triple = triple.map_err(|e| ParseError(format!("N-Triples syntax error: {e}")))?;
262            graph.insert(&triple);
263        }
264        Ok(Self {
265            graph,
266            prefixes: Vec::new(),
267            base: None,
268        })
269    }
270
271    fn from_oxrdfio(
272        data: &[u8],
273        format: RdfFormat,
274        base: Option<&str>,
275    ) -> Result<Self, ParseError> {
276        let mut parser = RdfParser::from_format(format.to_oxrdfio()).without_named_graphs();
277        if let Some(base) = base {
278            parser = parser
279                .with_base_iri(base)
280                .map_err(|e| ParseError(format!("invalid base IRI: {e}")))?;
281        }
282        let mut graph = Graph::new();
283        for quad in parser.for_slice(data) {
284            let quad = quad.map_err(|e| ParseError(format!("{format:?} syntax error: {e}")))?;
285            graph.insert(&Triple {
286                subject: quad.subject,
287                predicate: quad.predicate,
288                object: quad.object,
289            });
290        }
291        Ok(Self {
292            graph,
293            prefixes: Vec::new(),
294            base: base.map(ToOwned::to_owned),
295        })
296    }
297
298    /// All objects of `(subject, predicate)`.
299    pub fn objects(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Vec<Term> {
300        self.graph
301            .objects_for_subject_predicate(subject, predicate)
302            .map(|t| t.into_owned())
303            .collect()
304    }
305
306    /// The first object of `(subject, predicate)`, if any.
307    pub fn object(&self, subject: &NamedOrBlankNode, predicate: NamedNodeRef) -> Option<Term> {
308        self.graph
309            .object_for_subject_predicate(subject, predicate)
310            .map(|t| t.into_owned())
311    }
312
313    /// Does the subject have `(subject, rdf:type, ty)`?
314    pub fn has_type(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
315        self.objects(subject, vocab::RDF_TYPE)
316            .iter()
317            .any(|t| matches!(t, Term::NamedNode(n) if n.as_ref() == ty))
318    }
319
320    /// Does the subject have `ty` through `rdf:type/rdfs:subClassOf*`?
321    pub fn is_instance_of(&self, subject: &NamedOrBlankNode, ty: NamedNodeRef) -> bool {
322        let mut pending: Vec<NamedNode> = self
323            .objects(subject, vocab::RDF_TYPE)
324            .into_iter()
325            .filter_map(|term| match term {
326                Term::NamedNode(node) => Some(node),
327                _ => None,
328            })
329            .collect();
330        let mut seen = HashSet::new();
331        while let Some(class) = pending.pop() {
332            if class.as_ref() == ty {
333                return true;
334            }
335            if !seen.insert(class.clone()) {
336                continue;
337            }
338            pending.extend(
339                self.objects(&NamedOrBlankNode::NamedNode(class), vocab::RDFS_SUBCLASSOF)
340                    .into_iter()
341                    .filter_map(|term| match term {
342                        Term::NamedNode(node) => Some(node),
343                        _ => None,
344                    }),
345            );
346        }
347        false
348    }
349
350    /// Merge all triples from `other` into this graph.
351    pub fn merge_from(&mut self, other: &Loaded) {
352        for triple in other.graph.iter() {
353            self.graph.insert(triple);
354        }
355    }
356
357    /// Read an `rdf:List` starting at `head` into its member terms.
358    pub fn read_list(&self, head: &Term) -> Vec<Term> {
359        let mut out = Vec::new();
360        let mut cursor = head.clone();
361        while let Some(node) = term_to_node(&cursor) {
362            if is_nil(&cursor) {
363                break;
364            }
365            if let Some(first) = self.object(&node, vocab::RDF_FIRST) {
366                out.push(first);
367            }
368            match self.object(&node, vocab::RDF_REST) {
369                Some(rest) => cursor = rest,
370                None => break,
371            }
372        }
373        out
374    }
375}
376
377fn format_from_source(source: &str) -> Option<RdfFormat> {
378    let path = source.split(['?', '#']).next().unwrap_or(source);
379    let extension = path.rsplit_once('.')?.1;
380    RdfFormat::from_extension(extension)
381}
382
383fn sniff_format(data: &[u8]) -> Option<RdfFormat> {
384    // Only examine a small prefix — format signatures are always near the top.
385    let prefix = &data[..data.len().min(4096)];
386    let text = std::str::from_utf8(prefix).ok()?;
387
388    // Walk past blank lines and # comment lines to find the first real token.
389    let first_token = text
390        .lines()
391        .map(|l| l.trim_start())
392        .find(|l| !l.is_empty() && !l.starts_with('#'))?;
393
394    if first_token.starts_with("<?xml") || first_token.starts_with("<rdf:RDF") {
395        return Some(RdfFormat::RdfXml);
396    }
397    if first_token.starts_with("@prefix")
398        || first_token.starts_with("@base")
399        || first_token.starts_with("PREFIX")
400        || first_token.starts_with("BASE")
401    {
402        return Some(RdfFormat::Turtle);
403    }
404    // Check only the first real line, not the whole buffer.
405    if first_token.starts_with('<') && first_token.contains("> <") {
406        return Some(RdfFormat::NTriples);
407    }
408    None
409}
410
411/// Convert an object term into a subject position node, if it is not a literal.
412pub fn term_to_node(term: &Term) -> Option<NamedOrBlankNode> {
413    match term {
414        Term::NamedNode(n) => Some(NamedOrBlankNode::NamedNode(n.clone())),
415        Term::BlankNode(b) => Some(NamedOrBlankNode::BlankNode(b.clone())),
416        Term::Literal(_) => None,
417    }
418}
419
420/// Is this term `rdf:nil`?
421pub fn is_nil(term: &Term) -> bool {
422    matches!(term, Term::NamedNode(n) if n.as_ref() == vocab::RDF_NIL)
423}
424
425/// The IRI of a named node, or `None` for blanks/literals.
426pub fn as_named(term: &Term) -> Option<NamedNode> {
427    match term {
428        Term::NamedNode(n) => Some(n.clone()),
429        _ => None,
430    }
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436
437    // sniff_format tests
438
439    #[test]
440    fn sniff_rdfxml_xml_declaration() {
441        assert_eq!(
442            sniff_format(b"<?xml version=\"1.0\"?>\n<rdf:RDF>"),
443            Some(RdfFormat::RdfXml)
444        );
445    }
446
447    #[test]
448    fn sniff_rdfxml_bare_tag() {
449        assert_eq!(
450            sniff_format(b"<rdf:RDF xmlns:rdf=\"...\">"),
451            Some(RdfFormat::RdfXml)
452        );
453    }
454
455    #[test]
456    fn sniff_turtle_at_prefix() {
457        assert_eq!(
458            sniff_format(b"@prefix sh: <http://www.w3.org/ns/shacl#> ."),
459            Some(RdfFormat::Turtle)
460        );
461    }
462
463    #[test]
464    fn sniff_turtle_at_base() {
465        assert_eq!(
466            sniff_format(b"@base <http://example.org/> ."),
467            Some(RdfFormat::Turtle)
468        );
469    }
470
471    #[test]
472    fn sniff_turtle_sparql_prefix() {
473        assert_eq!(
474            sniff_format(b"PREFIX sh: <http://www.w3.org/ns/shacl#>"),
475            Some(RdfFormat::Turtle)
476        );
477    }
478
479    #[test]
480    fn sniff_turtle_sparql_base() {
481        assert_eq!(
482            sniff_format(b"BASE <http://example.org/>"),
483            Some(RdfFormat::Turtle)
484        );
485    }
486
487    #[test]
488    fn sniff_turtle_after_comment_lines() {
489        let data = b"# Copyright 2024\n# Licensed under ...\n@prefix ex: <http://ex/> .";
490        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
491    }
492
493    #[test]
494    fn sniff_turtle_after_blank_and_comment_lines() {
495        let data = b"\n\n# A comment\n\nPREFIX ex: <http://ex/>";
496        assert_eq!(sniff_format(data), Some(RdfFormat::Turtle));
497    }
498
499    #[test]
500    fn sniff_ntriples_first_line() {
501        let data = b"<http://ex/s> <http://ex/p> <http://ex/o> .\n";
502        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
503    }
504
505    #[test]
506    fn sniff_ntriples_after_comment() {
507        let data = b"# generated by riot\n<http://ex/s> <http://ex/p> \"value\" .\n";
508        // NTriples line has `> <` pattern
509        assert_eq!(sniff_format(data), Some(RdfFormat::NTriples));
510    }
511
512    #[test]
513    fn sniff_ntriples_contains_check_uses_first_line_only() {
514        // The "> <" pattern only appears in the second line; first real line has no prefix,
515        // so sniff should return None rather than scanning deep into the buffer.
516        let data =
517            b"# comment\n_:b0 <http://ex/p> _:b1 .\n<http://ex/s> <http://ex/p> <http://ex/o> .\n";
518        assert_eq!(sniff_format(data), None);
519    }
520
521    #[test]
522    fn sniff_returns_none_for_unknown() {
523        assert_eq!(sniff_format(b"SELECT ?s WHERE { ?s a ex:Thing }"), None);
524    }
525
526    #[test]
527    fn sniff_only_reads_prefix() {
528        // Build a buffer >4096 bytes whose format marker is at the very start.
529        let mut data = b"@prefix ex: <http://ex/> .\n".to_vec();
530        data.extend(std::iter::repeat_n(b'x', 8000));
531        assert_eq!(sniff_format(&data), Some(RdfFormat::Turtle));
532    }
533
534    #[test]
535    fn sniff_ignores_format_marker_beyond_prefix_window() {
536        // Format marker buried past the 4096-byte sniff window — should not be detected.
537        let mut data = vec![b' '; 5000];
538        data.extend_from_slice(b"@prefix ex: <http://ex/> .");
539        assert_eq!(sniff_format(&data), None);
540    }
541}