Skip to main content

oxirs_core/model/
star.rs

1//! RDF-star (RDF*) support for statement annotations
2//!
3//! This module implements RDF-star extensions for SPARQL 1.2 compliance,
4//! allowing triples to be used as subjects or objects in other triples.
5
6use crate::model::{
7    NamedNode, Object, ObjectTerm, Predicate, RdfTerm, Subject, SubjectTerm, Triple,
8};
9use crate::query::algebra::{AlgebraTriplePattern, TermPattern};
10use crate::OxirsError;
11use std::fmt;
12use std::sync::Arc;
13
14/// A quoted triple that can be used as a subject or object in RDF-star
15#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
16pub struct QuotedTriple {
17    /// The inner triple being quoted
18    inner: Arc<Triple>,
19}
20
21impl QuotedTriple {
22    /// Create a new quoted triple
23    pub fn new(triple: Triple) -> Self {
24        QuotedTriple {
25            inner: Arc::new(triple),
26        }
27    }
28
29    /// Create from an existing `Arc<Triple>`
30    pub fn from_arc(triple: Arc<Triple>) -> Self {
31        QuotedTriple { inner: triple }
32    }
33
34    /// Get the inner triple
35    pub fn inner(&self) -> &Triple {
36        &self.inner
37    }
38
39    /// Get the subject of the quoted triple
40    pub fn subject(&self) -> &Subject {
41        self.inner.subject()
42    }
43
44    /// Get the predicate of the quoted triple
45    pub fn predicate(&self) -> &Predicate {
46        self.inner.predicate()
47    }
48
49    /// Get the object of the quoted triple
50    pub fn object(&self) -> &Object {
51        self.inner.object()
52    }
53
54    /// Convert to a triple reference
55    pub fn as_ref(&self) -> QuotedTripleRef<'_> {
56        QuotedTripleRef { inner: &self.inner }
57    }
58}
59
60impl fmt::Display for QuotedTriple {
61    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
62        // `Triple`'s own `Display` appends a trailing " ." (it formats a
63        // top-level Turtle statement), which is not valid inside `<< ... >>`
64        // -- `<< s p o . >>` fails to re-parse ("expected exactly 3 terms").
65        // Format the s/p/o components directly instead, matching
66        // `serialize_quoted_triple` below.
67        write!(
68            f,
69            "<< {} {} {} >>",
70            self.inner.subject(),
71            self.inner.predicate(),
72            self.inner.object()
73        )
74    }
75}
76
77impl RdfTerm for QuotedTriple {
78    fn as_str(&self) -> &str {
79        // For quoted triples, we return a synthetic string representation
80        "<<quoted-triple>>"
81    }
82
83    fn is_quoted_triple(&self) -> bool {
84        true
85    }
86}
87
88impl SubjectTerm for QuotedTriple {}
89impl ObjectTerm for QuotedTriple {}
90
91// Custom serialization for QuotedTriple to handle Arc<Triple>
92#[cfg(feature = "serde")]
93impl serde::Serialize for QuotedTriple {
94    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
95    where
96        S: serde::Serializer,
97    {
98        // Serialize the inner triple directly
99        self.inner.as_ref().serialize(serializer)
100    }
101}
102
103#[cfg(feature = "serde")]
104impl<'de> serde::Deserialize<'de> for QuotedTriple {
105    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
106    where
107        D: serde::Deserializer<'de>,
108    {
109        let triple = Triple::deserialize(deserializer)?;
110        Ok(QuotedTriple::new(triple))
111    }
112}
113
114/// A borrowed quoted triple reference
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
116pub struct QuotedTripleRef<'a> {
117    inner: &'a Triple,
118}
119
120impl<'a> QuotedTripleRef<'a> {
121    /// Create a new quoted triple reference
122    pub fn new(triple: &'a Triple) -> Self {
123        QuotedTripleRef { inner: triple }
124    }
125
126    /// Get the inner triple
127    pub fn inner(&self) -> &'a Triple {
128        self.inner
129    }
130
131    /// Convert to owned quoted triple
132    pub fn to_owned(&self) -> QuotedTriple {
133        QuotedTriple::new(self.inner.clone())
134    }
135}
136
137impl<'a> fmt::Display for QuotedTripleRef<'a> {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        // See `QuotedTriple`'s `Display` impl: avoid `Triple`'s trailing
140        // " ." statement terminator, which is invalid inside `<< ... >>`.
141        write!(
142            f,
143            "<< {} {} {} >>",
144            self.inner.subject(),
145            self.inner.predicate(),
146            self.inner.object()
147        )
148    }
149}
150
151impl<'a> RdfTerm for QuotedTripleRef<'a> {
152    fn as_str(&self) -> &str {
153        "<<quoted-triple>>"
154    }
155
156    fn is_quoted_triple(&self) -> bool {
157        true
158    }
159}
160
161/// RDF-star annotation syntax support
162pub struct Annotation {
163    /// The statement being annotated (as a quoted triple)
164    pub statement: QuotedTriple,
165    /// The annotation property
166    pub property: NamedNode,
167    /// The annotation value
168    pub value: Object,
169}
170
171impl Annotation {
172    /// Create a new annotation
173    pub fn new(statement: Triple, property: NamedNode, value: Object) -> Self {
174        Annotation {
175            statement: QuotedTriple::new(statement),
176            property,
177            value,
178        }
179    }
180
181    /// Convert annotation to a regular triple with quoted triple as subject
182    pub fn to_triple(&self) -> Triple {
183        Triple::new(
184            Subject::QuotedTriple(Box::new(self.statement.clone())),
185            self.property.clone(),
186            self.value.clone(),
187        )
188    }
189}
190
191/// RDF-star pattern for SPARQL queries
192#[derive(Debug, Clone, PartialEq, Eq, Hash)]
193pub enum StarPattern {
194    /// A regular triple pattern
195    Triple(AlgebraTriplePattern),
196    /// A quoted triple pattern
197    QuotedTriple {
198        /// Subject pattern (can be nested quoted triple)
199        subject: Box<StarPattern>,
200        /// Predicate pattern
201        predicate: TermPattern,
202        /// Object pattern (can be nested quoted triple)
203        object: Box<StarPattern>,
204    },
205    /// An annotation pattern
206    Annotation {
207        /// The annotated statement pattern
208        statement: Box<StarPattern>,
209        /// Annotation property pattern
210        property: TermPattern,
211        /// Annotation value pattern
212        value: TermPattern,
213    },
214}
215
216impl StarPattern {
217    /// Check if pattern contains variables
218    pub fn has_variables(&self) -> bool {
219        match self {
220            StarPattern::Triple(pattern) => {
221                matches!(pattern.subject, TermPattern::Variable(_))
222                    || matches!(pattern.predicate, TermPattern::Variable(_))
223                    || matches!(pattern.object, TermPattern::Variable(_))
224            }
225            StarPattern::QuotedTriple {
226                subject,
227                predicate: _,
228                object,
229            } => subject.has_variables() || object.has_variables(),
230            StarPattern::Annotation {
231                statement,
232                property: _,
233                value: _,
234            } => statement.has_variables(),
235        }
236    }
237
238    /// Get all variables in the pattern
239    pub fn variables(&self) -> Vec<crate::model::Variable> {
240        let mut vars = Vec::new();
241        self.collect_variables(&mut vars);
242        vars
243    }
244
245    fn collect_variables(&self, _vars: &mut Vec<crate::model::Variable>) {
246        match self {
247            StarPattern::Triple(pattern) => {
248                if let TermPattern::Variable(ref v) = pattern.subject {
249                    _vars.push(v.clone());
250                }
251                if let TermPattern::Variable(ref v) = pattern.predicate {
252                    _vars.push(v.clone());
253                }
254                if let TermPattern::Variable(ref v) = pattern.object {
255                    _vars.push(v.clone());
256                }
257            }
258            StarPattern::QuotedTriple {
259                subject,
260                predicate: _,
261                object,
262            } => {
263                subject.collect_variables(_vars);
264                object.collect_variables(_vars);
265            }
266            StarPattern::Annotation {
267                statement,
268                property: _,
269                value: _,
270            } => {
271                statement.collect_variables(_vars);
272            }
273        }
274    }
275}
276
277/// RDF-star serialization format extensions
278pub mod serialization {
279    use super::*;
280
281    /// Turtle-star syntax extensions
282    pub mod turtle_star {
283        use super::*;
284
285        /// Serialize a quoted triple in Turtle-star syntax
286        pub fn serialize_quoted_triple(qt: &QuotedTriple) -> String {
287            format!("<< {} {} {} >>", qt.subject(), qt.predicate(), qt.object())
288        }
289
290        /// Parse a quoted triple from Turtle-star syntax.
291        ///
292        /// Supports the RDF-star subject/object term grammar: absolute IRIs
293        /// (`<...>`), blank nodes (`_:id`), plain/typed/language-tagged
294        /// literals (`"..."`, `"..."^^<...>`, `"..."@lang`), and nested
295        /// quoted triples (`<< ... >>`). This is a self-contained
296        /// tokenizer scoped to the `<< s p o >>` grammar rather than a
297        /// full Turtle document parser; prefixed names (`ex:foo`) are not
298        /// supported since there is no prefix map in scope here.
299        pub fn parse_quoted_triple(input: &str) -> Result<QuotedTriple, OxirsError> {
300            let trimmed = input.trim();
301            if !trimmed.starts_with("<<") || !trimmed.ends_with(">>") || trimmed.len() < 4 {
302                return Err(OxirsError::Parse(
303                    "Invalid quoted triple syntax: expected '<< subject predicate object >>'"
304                        .to_string(),
305                ));
306            }
307
308            let inner = trimmed[2..trimmed.len() - 2].trim();
309            let terms = split_star_terms(inner)?;
310            if terms.len() != 3 {
311                return Err(OxirsError::Parse(format!(
312                    "Invalid quoted triple: expected exactly 3 terms (subject, predicate, object), found {}",
313                    terms.len()
314                )));
315            }
316
317            let subject = parse_star_subject(terms[0])?;
318            let predicate = parse_star_predicate(terms[1])?;
319            let object = parse_star_object(terms[2])?;
320
321            Ok(QuotedTriple::new(Triple::new(subject, predicate, object)))
322        }
323
324        /// Split the space-separated `subject predicate object` term list
325        /// of a quoted triple, respecting nesting of `<< ... >>`, `<...>`,
326        /// and `"..."` (including escaped quotes) so that whitespace
327        /// inside those constructs does not cause a spurious split.
328        fn split_star_terms(input: &str) -> Result<Vec<&str>, OxirsError> {
329            let bytes = input.as_bytes();
330            let mut terms = Vec::new();
331            let mut i = 0usize;
332            let mut depth = 0i32;
333            let mut in_string = false;
334            let mut escape = false;
335            let mut term_start: Option<usize> = None;
336
337            while i < bytes.len() {
338                let c = bytes[i] as char;
339                if in_string {
340                    if escape {
341                        escape = false;
342                    } else if c == '\\' {
343                        escape = true;
344                    } else if c == '"' {
345                        in_string = false;
346                    }
347                    i += 1;
348                    continue;
349                }
350
351                match c {
352                    '"' => {
353                        in_string = true;
354                        if term_start.is_none() {
355                            term_start = Some(i);
356                        }
357                    }
358                    '<' if input[i..].starts_with("<<") => {
359                        depth += 1;
360                        if term_start.is_none() {
361                            term_start = Some(i);
362                        }
363                        i += 1; // consume the extra '<' of "<<"
364                    }
365                    '>' if input[i..].starts_with(">>") => {
366                        depth -= 1;
367                        if depth < 0 {
368                            return Err(OxirsError::Parse(
369                                "Unbalanced '>>' in quoted triple term".to_string(),
370                            ));
371                        }
372                        i += 1; // consume the extra '>' of ">>"
373                    }
374                    '<' => {
375                        if term_start.is_none() {
376                            term_start = Some(i);
377                        }
378                    }
379                    c if c.is_whitespace() && depth == 0 => {
380                        if let Some(start) = term_start.take() {
381                            terms.push(input[start..i].trim());
382                        }
383                    }
384                    _ => {
385                        if term_start.is_none() {
386                            term_start = Some(i);
387                        }
388                    }
389                }
390                i += 1;
391            }
392            if in_string {
393                return Err(OxirsError::Parse(
394                    "Unterminated string literal in quoted triple".to_string(),
395                ));
396            }
397            if depth != 0 {
398                return Err(OxirsError::Parse(
399                    "Unbalanced '<<'/'>>' nesting in quoted triple".to_string(),
400                ));
401            }
402            if let Some(start) = term_start {
403                terms.push(input[start..].trim());
404            }
405            Ok(terms.into_iter().filter(|t| !t.is_empty()).collect())
406        }
407
408        fn parse_star_iri(term: &str) -> Result<NamedNode, OxirsError> {
409            let inner = term
410                .strip_prefix('<')
411                .and_then(|s| s.strip_suffix('>'))
412                .ok_or_else(|| OxirsError::Parse(format!("Invalid IRI term: {term}")))?;
413            NamedNode::new(inner)
414        }
415
416        fn parse_star_literal(term: &str) -> Result<crate::model::Literal, OxirsError> {
417            use crate::model::Literal;
418
419            // Locate the closing quote of the (possibly escaped) string body.
420            let bytes = term.as_bytes();
421            if bytes.first() != Some(&b'"') {
422                return Err(OxirsError::Parse(format!("Invalid literal term: {term}")));
423            }
424            let mut end = None;
425            let mut escape = false;
426            for (idx, b) in bytes.iter().enumerate().skip(1) {
427                if escape {
428                    escape = false;
429                } else if *b == b'\\' {
430                    escape = true;
431                } else if *b == b'"' {
432                    end = Some(idx);
433                    break;
434                }
435            }
436            let end =
437                end.ok_or_else(|| OxirsError::Parse(format!("Unterminated literal: {term}")))?;
438            let raw_value = &term[1..end];
439            let value = unescape_star_string(raw_value)?;
440            let suffix = &term[end + 1..];
441
442            if let Some(lang) = suffix.strip_prefix('@') {
443                Literal::new_language_tagged_literal(value, lang)
444                    .map_err(|e| OxirsError::Parse(format!("Invalid language tag: {e}")))
445            } else if let Some(datatype) = suffix.strip_prefix("^^") {
446                let datatype = parse_star_iri(datatype)?;
447                Ok(Literal::new_typed(value, datatype))
448            } else if suffix.is_empty() {
449                Ok(Literal::new(value))
450            } else {
451                Err(OxirsError::Parse(format!(
452                    "Invalid literal suffix in term: {term}"
453                )))
454            }
455        }
456
457        /// Consume exactly `digits` hex characters from `chars` and return the
458        /// decoded `u32` codepoint. Used for `\uXXXX` (4 hex digits) and
459        /// `\UXXXXXXXX` (8 hex digits) escapes.
460        fn read_hex_escape(
461            chars: &mut std::str::Chars<'_>,
462            digits: usize,
463            kind: char,
464        ) -> Result<u32, OxirsError> {
465            let mut value: u32 = 0;
466            for _ in 0..digits {
467                let c = chars.next().ok_or_else(|| {
468                    OxirsError::Parse(format!(
469                        "Truncated \\{kind} escape in quoted triple literal: expected {digits} hex digits"
470                    ))
471                })?;
472                let digit = c.to_digit(16).ok_or_else(|| {
473                    OxirsError::Parse(format!(
474                        "Invalid hex digit '{c}' in \\{kind} escape in quoted triple literal"
475                    ))
476                })?;
477                value = (value << 4) | digit;
478            }
479            Ok(value)
480        }
481
482        /// Unescape a quoted-triple literal's lexical body.
483        ///
484        /// Mirrors the N-Triples/Turtle `ECHAR`/`UCHAR` grammar used by this
485        /// module's own serializer ([`crate::model::literal::print_quoted_str`]):
486        /// `\t \b \n \r \f \" \' \\` plus `\uXXXX` (4 hex digits) and
487        /// `\UXXXXXXXX` (8 hex digits) Unicode escapes. Without `\b`/`\f`/
488        /// `\u`/`\U` support, a quoted-triple literal containing a control
489        /// character or non-ASCII-escaped codepoint would serialize
490        /// correctly but fail to re-parse.
491        fn unescape_star_string(raw: &str) -> Result<String, OxirsError> {
492            let mut result = String::with_capacity(raw.len());
493            let mut chars = raw.chars();
494            while let Some(c) = chars.next() {
495                if c != '\\' {
496                    result.push(c);
497                    continue;
498                }
499                match chars.next() {
500                    Some('n') => result.push('\n'),
501                    Some('t') => result.push('\t'),
502                    Some('r') => result.push('\r'),
503                    Some('b') => result.push('\u{08}'),
504                    Some('f') => result.push('\u{0C}'),
505                    Some('"') => result.push('"'),
506                    Some('\'') => result.push('\''),
507                    Some('\\') => result.push('\\'),
508                    Some('u') => {
509                        let codepoint = read_hex_escape(&mut chars, 4, 'u')?;
510                        let c = char::from_u32(codepoint).ok_or_else(|| {
511                            OxirsError::Parse(format!(
512                                "Invalid Unicode codepoint U+{codepoint:04X} in \\u escape in quoted triple literal"
513                            ))
514                        })?;
515                        result.push(c);
516                    }
517                    Some('U') => {
518                        let codepoint = read_hex_escape(&mut chars, 8, 'U')?;
519                        let c = char::from_u32(codepoint).ok_or_else(|| {
520                            OxirsError::Parse(format!(
521                                "Invalid Unicode codepoint U+{codepoint:08X} in \\U escape in quoted triple literal"
522                            ))
523                        })?;
524                        result.push(c);
525                    }
526                    Some(other) => {
527                        return Err(OxirsError::Parse(format!(
528                            "Unsupported escape sequence '\\{other}' in quoted triple literal"
529                        )))
530                    }
531                    None => {
532                        return Err(OxirsError::Parse(
533                            "Trailing backslash in quoted triple literal".to_string(),
534                        ))
535                    }
536                }
537            }
538            Ok(result)
539        }
540
541        fn parse_star_subject(term: &str) -> Result<Subject, OxirsError> {
542            if term.starts_with("<<") {
543                Ok(Subject::QuotedTriple(Box::new(parse_quoted_triple(term)?)))
544            } else if let Some(id) = term.strip_prefix("_:") {
545                Ok(Subject::BlankNode(crate::model::BlankNode::new(id)?))
546            } else if term.starts_with('<') {
547                Ok(Subject::NamedNode(parse_star_iri(term)?))
548            } else {
549                Err(OxirsError::Parse(format!(
550                    "Unsupported subject term in quoted triple: {term}"
551                )))
552            }
553        }
554
555        fn parse_star_predicate(term: &str) -> Result<Predicate, OxirsError> {
556            if term.starts_with('<') {
557                Ok(Predicate::NamedNode(parse_star_iri(term)?))
558            } else {
559                Err(OxirsError::Parse(format!(
560                    "Unsupported predicate term in quoted triple: {term}"
561                )))
562            }
563        }
564
565        fn parse_star_object(term: &str) -> Result<Object, OxirsError> {
566            if term.starts_with("<<") {
567                Ok(Object::QuotedTriple(Box::new(parse_quoted_triple(term)?)))
568            } else if let Some(id) = term.strip_prefix("_:") {
569                Ok(Object::BlankNode(crate::model::BlankNode::new(id)?))
570            } else if term.starts_with('<') {
571                Ok(Object::NamedNode(parse_star_iri(term)?))
572            } else if term.starts_with('"') {
573                Ok(Object::Literal(parse_star_literal(term)?))
574            } else {
575                Err(OxirsError::Parse(format!(
576                    "Unsupported object term in quoted triple: {term}"
577                )))
578            }
579        }
580    }
581
582    /// SPARQL-star syntax extensions
583    pub mod sparql_star {
584        use super::*;
585
586        /// Format a star pattern for SPARQL
587        pub fn format_star_pattern(pattern: &StarPattern) -> String {
588            match pattern {
589                StarPattern::Triple(pattern) => pattern.to_string(),
590                StarPattern::QuotedTriple {
591                    subject,
592                    predicate: _,
593                    object,
594                } => {
595                    format!(
596                        "<< {} {} {} >>",
597                        format_star_pattern(subject),
598                        "PREDICATE",
599                        format_star_pattern(object)
600                    )
601                }
602                StarPattern::Annotation {
603                    statement,
604                    property: _,
605                    value: _,
606                } => {
607                    format!(
608                        "{} {} {}",
609                        format_star_pattern(statement),
610                        "PROPERTY",
611                        "VALUE"
612                    )
613                }
614            }
615        }
616    }
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622    use crate::model::{Literal, NamedNode};
623
624    #[test]
625    fn test_quoted_triple() {
626        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
627        let predicate = NamedNode::new("http://example.org/says").expect("valid IRI");
628        let object = Object::Literal(Literal::new("Hello"));
629
630        let triple = Triple::new(subject, predicate, object);
631        let quoted = QuotedTriple::new(triple.clone());
632
633        assert_eq!(quoted.inner(), &triple);
634        // No trailing " ." before the closing ">>": that form is not valid
635        // RDF-star/Turtle-star syntax and doesn't round-trip through
636        // `parse_quoted_triple` (see regression test below).
637        assert_eq!(
638            format!("{quoted}"),
639            "<< <http://example.org/alice> <http://example.org/says> \"Hello\" >>"
640        );
641    }
642
643    /// Regression test: `Display` output for `QuotedTriple`/`QuotedTripleRef`
644    /// must be valid RDF-star syntax that round-trips through
645    /// `parse_quoted_triple`, matching the hand-rolled `serialize_quoted_triple`
646    /// exactly (both formatters must agree).
647    #[test]
648    fn regression_quoted_triple_display_round_trips() {
649        use serialization::turtle_star::{parse_quoted_triple, serialize_quoted_triple};
650
651        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
652        let predicate = NamedNode::new("http://example.org/says").expect("valid IRI");
653        let object = Object::Literal(Literal::new("Hello"));
654        let triple = Triple::new(subject, predicate, object);
655        let quoted = QuotedTriple::new(triple);
656
657        let displayed = format!("{quoted}");
658        assert!(
659            !displayed.contains(". >>"),
660            "Display output must not contain a trailing statement terminator: {displayed}"
661        );
662
663        // `Display` and the hand-rolled serializer must agree.
664        assert_eq!(displayed, serialize_quoted_triple(&quoted));
665
666        // And the result must actually re-parse back to the same triple.
667        let reparsed = parse_quoted_triple(&displayed).expect("Display output must re-parse");
668        assert_eq!(reparsed, quoted);
669
670        // `QuotedTripleRef`'s `Display` must match too.
671        let triple_ref = quoted.inner().clone();
672        let quoted_ref = QuotedTripleRef::new(&triple_ref);
673        assert_eq!(format!("{quoted_ref}"), displayed);
674    }
675
676    #[test]
677    fn test_annotation() {
678        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
679        let predicate = NamedNode::new("http://example.org/age").expect("valid IRI");
680        let object = Object::Literal(Literal::new_typed(
681            "30",
682            NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").expect("valid IRI"),
683        ));
684
685        let statement = Triple::new(subject, predicate, object);
686        let ann_property = NamedNode::new("http://example.org/confidence").expect("valid IRI");
687        let ann_value = Object::Literal(Literal::new_typed(
688            "0.9",
689            NamedNode::new("http://www.w3.org/2001/XMLSchema#double").expect("valid IRI"),
690        ));
691
692        let annotation = Annotation::new(statement, ann_property, ann_value);
693        let ann_triple = annotation.to_triple();
694
695        assert!(matches!(ann_triple.subject(), Subject::QuotedTriple(_)));
696    }
697
698    #[test]
699    fn test_parse_quoted_triple_basic() {
700        use serialization::turtle_star::parse_quoted_triple;
701
702        let parsed = parse_quoted_triple(
703            "<< <http://example.org/alice> <http://example.org/says> \"Hello\" >>",
704        )
705        .expect("valid quoted triple");
706
707        assert_eq!(
708            parsed.subject(),
709            &Subject::NamedNode(NamedNode::new("http://example.org/alice").expect("valid IRI"))
710        );
711        assert_eq!(
712            parsed.predicate(),
713            &Predicate::NamedNode(NamedNode::new("http://example.org/says").expect("valid IRI"))
714        );
715        assert_eq!(parsed.object(), &Object::Literal(Literal::new("Hello")));
716    }
717
718    #[test]
719    fn test_parse_quoted_triple_roundtrip_with_serialize() {
720        use serialization::turtle_star::{parse_quoted_triple, serialize_quoted_triple};
721
722        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
723        let predicate = NamedNode::new("http://example.org/age").expect("valid IRI");
724        let object = Object::Literal(Literal::new_typed(
725            "30",
726            NamedNode::new("http://www.w3.org/2001/XMLSchema#integer").expect("valid IRI"),
727        ));
728        let original = QuotedTriple::new(Triple::new(subject, predicate, object));
729
730        let text = serialize_quoted_triple(&original);
731        let parsed = parse_quoted_triple(&text).expect("round-trip parse");
732        assert_eq!(parsed, original);
733    }
734
735    #[test]
736    fn test_parse_quoted_triple_nested() {
737        use serialization::turtle_star::parse_quoted_triple;
738
739        let input = "<< << <http://example.org/a> <http://example.org/p> <http://example.org/b> >> <http://example.org/certainty> \"0.9\"^^<http://www.w3.org/2001/XMLSchema#double> >>";
740        let parsed = parse_quoted_triple(input).expect("valid nested quoted triple");
741        assert!(matches!(parsed.subject(), Subject::QuotedTriple(_)));
742    }
743
744    #[test]
745    fn test_parse_quoted_triple_rejects_bad_syntax() {
746        use serialization::turtle_star::parse_quoted_triple;
747
748        assert!(parse_quoted_triple("not a quoted triple").is_err());
749        assert!(
750            parse_quoted_triple("<< <http://example.org/a> <http://example.org/b> >>").is_err()
751        );
752    }
753
754    /// Regression test: a quoted-triple literal containing control
755    /// characters that `print_quoted_str` escapes as `\b`, `\f`, or
756    /// `\uXXXX` must round-trip through serialize -> parse, not fail with
757    /// "Unsupported escape sequence".
758    #[test]
759    fn regression_unescape_star_string_supports_full_echar_uchar_grammar() {
760        use serialization::turtle_star::{parse_quoted_triple, serialize_quoted_triple};
761
762        // 0x08 (backspace, -> \b), 0x0C (form feed, -> \f), and 0x07 (bell,
763        // control char without a short escape, -> ) per
764        // `print_quoted_str`.
765        let value = "back\u{08}space form\u{0C}feed bell\u{07}end";
766        let subject = NamedNode::new("http://example.org/alice").expect("valid IRI");
767        let predicate = NamedNode::new("http://example.org/note").expect("valid IRI");
768        let original = QuotedTriple::new(Triple::new(
769            subject,
770            predicate,
771            Object::Literal(Literal::new(value)),
772        ));
773
774        let text = serialize_quoted_triple(&original);
775        assert!(text.contains("\\b"), "expected a \\b escape in: {text}");
776        assert!(text.contains("\\f"), "expected a \\f escape in: {text}");
777        assert!(
778            text.contains("\\u0007"),
779            "expected a \\u0007 escape in: {text}"
780        );
781
782        let parsed = parse_quoted_triple(&text).expect("must re-parse its own serialized form");
783        assert_eq!(parsed, original);
784        assert_eq!(parsed.object(), &Object::Literal(Literal::new(value)));
785    }
786
787    /// Regression test: `\uXXXX` (4 hex digits) and `\UXXXXXXXX` (8 hex
788    /// digits) Unicode escapes are both accepted, including a codepoint
789    /// outside the Basic Multilingual Plane that only `\U` can express.
790    #[test]
791    fn regression_unescape_star_string_handles_u_and_big_u_escapes() {
792        use serialization::turtle_star::parse_quoted_triple;
793
794        // A == 'A'; \U0001F600 == the grinning-face emoji.
795        let input = "<< <http://example.org/s> <http://example.org/p> \"\\u0041-\\U0001F600\" >>";
796        let parsed = parse_quoted_triple(input).expect("valid \\u/\\U escapes must parse");
797        assert_eq!(
798            parsed.object(),
799            &Object::Literal(Literal::new("A-\u{1F600}"))
800        );
801    }
802
803    /// Regression test: a malformed `\u`/`\U` escape (too few hex digits, or
804    /// a non-hex character) must be rejected with an explicit parse error,
805    /// never silently accepted or truncated.
806    #[test]
807    fn regression_unescape_star_string_rejects_truncated_unicode_escape() {
808        use serialization::turtle_star::parse_quoted_triple;
809
810        let truncated = "<< <http://example.org/s> <http://example.org/p> \"\\u12\" >>";
811        assert!(parse_quoted_triple(truncated).is_err());
812
813        let bad_digit = "<< <http://example.org/s> <http://example.org/p> \"\\u12ZZ\" >>";
814        assert!(parse_quoted_triple(bad_digit).is_err());
815    }
816}