Skip to main content

rdf_parsers/turtle/
convert.rs

1use rowan::SyntaxNode;
2
3use crate::Spanned;
4
5use super::parser::{Lang, SyntaxKind};
6use crate::model::*;
7
8type Node = SyntaxNode<Lang>;
9
10fn text_range(node: &Node) -> std::ops::Range<usize> {
11    let r = node.text_range();
12    r.start().into()..r.end().into()
13}
14
15fn child(node: &Node, kind: SyntaxKind) -> Option<Node> {
16    node.children().find(|c| c.kind() == kind)
17}
18
19fn children(node: &Node, kind: SyntaxKind) -> impl Iterator<Item = Node> {
20    node.children().filter(move |c| c.kind() == kind)
21}
22
23/// Get text from a terminal node (a node that wraps a single token of the same kind).
24fn terminal_text(node: &Node) -> String {
25    node.text().to_string()
26}
27
28pub fn convert(root: &Node) -> Turtle {
29    // TurtleDoc is the initial rule — its contents are direct children of ROOT.
30    let mut base = None;
31    let mut prefixes = Vec::new();
32    let mut triples = Vec::new();
33
34    for stmt in children(root, SyntaxKind::Statement) {
35        if let Some(dir) = child(&stmt, SyntaxKind::Directive) {
36            if let Some(b) =
37                child(&dir, SyntaxKind::Base).or_else(|| child(&dir, SyntaxKind::SparqlBase))
38            {
39                base = Some(Spanned(convert_base(&b), text_range(&b)));
40            } else if let Some(p) =
41                child(&dir, SyntaxKind::PrefixId).or_else(|| child(&dir, SyntaxKind::SparqlPrefix))
42            {
43                prefixes.push(Spanned(convert_prefix(&p), text_range(&p)));
44            }
45        } else if let Some(t) = child(&stmt, SyntaxKind::Triples) {
46            let range = text_range(&stmt);
47            triples.push(Spanned(convert_triples(&t), range));
48        }
49    }
50
51    Turtle::new(base, prefixes, triples)
52}
53
54fn convert_base(node: &Node) -> Base {
55    let range = text_range(node);
56    // base ::= '@base' IRIREF '.' — IRIREF is a direct terminal child
57    let nn = child(node, SyntaxKind::Iriref)
58        .map(|n| iri_from_iriref_node(&n))
59        .or_else(|| child(node, SyntaxKind::Iri).map(|n| convert_iri(&n)))
60        .unwrap_or(NamedNode::Invalid);
61
62    let nn_range = child(node, SyntaxKind::Iriref)
63        .or_else(|| child(node, SyntaxKind::Iri))
64        .map(|n| text_range(&n))
65        .unwrap_or(range.clone());
66
67    Base(range, Spanned(nn, nn_range))
68}
69
70fn convert_prefix(node: &Node) -> TurtlePrefix {
71    let range = text_range(node);
72
73    // prefixID ::= '@prefix' PNAME_NS IRIREF '.'
74    // SparqlPrefix ::= "PREFIX" PNAME_NS IRIREF
75    let prefix_text = child(node, SyntaxKind::PnameNs)
76        .map(|n| {
77            let text = terminal_text(&n);
78            let tr = text_range(&n);
79            Spanned(text.trim_end_matches(':').to_string(), tr)
80        })
81        .unwrap_or_else(|| Spanned(String::new(), range.clone()));
82
83    // IRIREF is a direct terminal child of prefixID/sparqlPrefix
84    let value = child(node, SyntaxKind::Iriref)
85        .map(|n| {
86            let tr = text_range(&n);
87            Spanned(iri_from_iriref_node(&n), tr)
88        })
89        .or_else(|| {
90            child(node, SyntaxKind::Iri).map(|n| {
91                let r = text_range(&n);
92                Spanned(convert_iri(&n), r)
93            })
94        })
95        .unwrap_or_else(|| Spanned(NamedNode::Invalid, range.clone()));
96
97    TurtlePrefix {
98        span: range,
99        prefix: prefix_text,
100        value,
101    }
102}
103
104fn convert_triples(node: &Node) -> Triple {
105    // triples ::= blankNodePropertyList predicateObjectList? | subject predicateObjectList
106    let (subject, po_node) = if let Some(bpl) = child(node, SyntaxKind::BlankNodePropertyList) {
107        let range = text_range(&bpl);
108        let subject = convert_blank_node_property_list(&bpl);
109        (
110            Spanned(subject, range),
111            child(node, SyntaxKind::PredicateObjectList),
112        )
113    } else if let Some(subj) = child(node, SyntaxKind::Subject) {
114        let range = text_range(&subj);
115        (
116            Spanned(convert_subject(&subj), range),
117            child(node, SyntaxKind::PredicateObjectList),
118        )
119    } else {
120        (Spanned(Term::Invalid, text_range(node)), None)
121    };
122
123    let po = po_node
124        .map(|n| convert_predicate_object_list(&n))
125        .unwrap_or_default();
126
127    Triple {
128        subject,
129        po,
130        graph: None,
131    }
132}
133
134fn convert_predicate_object_list(node: &Node) -> Vec<Spanned<PO>> {
135    // predicateObjectList ::= verb objectList (';' (verb objectList)?)*
136    let verbs: Vec<_> = children(node, SyntaxKind::Verb).collect();
137    let object_lists: Vec<_> = children(node, SyntaxKind::ObjectList).collect();
138
139    verbs
140        .into_iter()
141        .zip(object_lists.into_iter())
142        .map(|(v, ol)| {
143            let range = text_range(&v).start..text_range(&ol).end;
144            let pred_range = text_range(&v);
145            let predicate = convert_verb(&v);
146            let objects = convert_object_list(&ol);
147            Spanned(
148                PO {
149                    predicate: Spanned(predicate, pred_range),
150                    object: objects,
151                },
152                range,
153            )
154        })
155        .collect()
156}
157
158fn convert_verb(node: &Node) -> Term {
159    // verb ::= predicate | 'a'
160    if let Some(pred) = child(node, SyntaxKind::Predicate) {
161        if let Some(iri) = child(&pred, SyntaxKind::Iri) {
162            Term::NamedNode(convert_iri(&iri))
163        } else {
164            Term::Invalid
165        }
166    } else if let Some(alit) = child(node, SyntaxKind::Alit) {
167        Term::NamedNode(NamedNode::A(alit.text_range().start().into()))
168    } else {
169        Term::Invalid
170    }
171}
172
173fn convert_object_list(node: &Node) -> Vec<Spanned<Term>> {
174    children(node, SyntaxKind::Object)
175        .map(|o| {
176            let range = text_range(&o);
177            Spanned(convert_object(&o), range)
178        })
179        .collect()
180}
181
182fn convert_subject(node: &Node) -> Term {
183    // subject ::= iri | BlankNode | collection
184    if let Some(iri) = child(node, SyntaxKind::Iri) {
185        Term::NamedNode(convert_iri(&iri))
186    } else if let Some(bn) = child(node, SyntaxKind::BlankNode) {
187        Term::BlankNode(convert_blank_node(&bn))
188    } else if let Some(coll) = child(node, SyntaxKind::Collection) {
189        Term::Collection(convert_collection(&coll))
190    } else {
191        Term::Invalid
192    }
193}
194
195fn convert_object(node: &Node) -> Term {
196    // object ::= iri | BlankNode | collection | blankNodePropertyList | literal
197    if let Some(iri) = child(node, SyntaxKind::Iri) {
198        Term::NamedNode(convert_iri(&iri))
199    } else if let Some(label) = child(node, SyntaxKind::BlankNodeLabel) {
200        let text = terminal_text(&label);
201        let offset: usize = label.text_range().start().into();
202        let name = text.strip_prefix("_:").unwrap_or(&text);
203        Term::BlankNode(BlankNode::Named(name.to_string(), offset))
204    } else if let Some(bn) = child(node, SyntaxKind::BlankNode) {
205        Term::BlankNode(convert_blank_node(&bn))
206    } else if let Some(coll) = child(node, SyntaxKind::Collection) {
207        Term::Collection(convert_collection(&coll))
208    } else if let Some(bpl) = child(node, SyntaxKind::BlankNodePropertyList2) {
209        convert_blank_node_property_list(&bpl)
210    } else if let Some(lit) = child(node, SyntaxKind::Literal) {
211        Term::Literal(convert_literal(&lit))
212    } else {
213        Term::Invalid
214    }
215}
216
217fn convert_iri(node: &Node) -> NamedNode {
218    // iri ::= IRIREF | PrefixedName
219    // Both appear as terminal nodes (Iriref, PrefixedName) wrapping their token.
220    if let Some(iriref_node) = child(node, SyntaxKind::Iriref) {
221        iri_from_iriref_node(&iriref_node)
222    } else if let Some(pn) = child(node, SyntaxKind::PrefixedName) {
223        convert_prefixed_name(&pn)
224    } else {
225        NamedNode::Invalid
226    }
227}
228
229fn iri_from_iriref_node(node: &Node) -> NamedNode {
230    let text = terminal_text(node);
231    let offset: usize = node.text_range().start().into();
232    let inner = text.trim_start_matches('<').trim_end_matches('>');
233    NamedNode::Full(inner.to_string(), offset)
234}
235
236fn convert_prefixed_name(node: &Node) -> NamedNode {
237    // PrefixedName ::= PNAME_LN | PNAME_NS
238    if let Some(pname_ln) = child(node, SyntaxKind::PnameLn) {
239        let text = terminal_text(&pname_ln);
240        let offset: usize = pname_ln.text_range().start().into();
241        let (prefix, value) = text.split_once(':').unwrap_or((&text, ""));
242        NamedNode::Prefixed {
243            prefix: prefix.to_string(),
244            value: value.to_string(),
245            idx: offset,
246            computed: None,
247        }
248    } else if let Some(pname_ns) = child(node, SyntaxKind::PnameNs) {
249        let text = terminal_text(&pname_ns);
250        let offset: usize = pname_ns.text_range().start().into();
251        let prefix = text.trim_end_matches(':');
252        NamedNode::Prefixed {
253            prefix: prefix.to_string(),
254            value: String::new(),
255            idx: offset,
256            computed: None,
257        }
258    } else {
259        NamedNode::Invalid
260    }
261}
262
263fn convert_blank_node(node: &Node) -> BlankNode {
264    // BlankNode ::= BLANK_NODE_LABEL | ANON | '[' ']'
265    if let Some(label) = child(node, SyntaxKind::BlankNodeLabel) {
266        let text = terminal_text(&label);
267        let offset: usize = label.text_range().start().into();
268        let name = text.strip_prefix("_:").unwrap_or(&text);
269        BlankNode::Named(name.to_string(), offset)
270    } else {
271        // ANON or '[ ]'
272        let offset: usize = node.text_range().start().into();
273        BlankNode::Unnamed(Vec::new(), offset, offset)
274    }
275}
276
277fn convert_blank_node_property_list(node: &Node) -> Term {
278    // blankNodePropertyList ::= '[' predicateObjectList ']'
279    let offset: usize = node.text_range().start().into();
280    let end: usize = node.text_range().end().into();
281    let po = child(node, SyntaxKind::PredicateObjectList)
282        .map(|n| convert_predicate_object_list(&n))
283        .unwrap_or_default();
284    Term::BlankNode(BlankNode::Unnamed(po, offset, end))
285}
286
287fn convert_collection(node: &Node) -> Vec<Spanned<Term>> {
288    children(node, SyntaxKind::Object)
289        .map(|o| {
290            let range = text_range(&o);
291            Spanned(convert_object(&o), range)
292        })
293        .collect()
294}
295
296fn convert_literal(node: &Node) -> Literal {
297    // literal ::= RDFLiteral | NumericLiteral | BooleanLiteral
298    if let Some(rdf) = child(node, SyntaxKind::Rdfliteral) {
299        Literal::RDF(convert_rdf_literal(&rdf))
300    } else if let Some(num) = child(node, SyntaxKind::NumericLiteral) {
301        Literal::Numeric(num.text().to_string().trim().to_string())
302    } else if let Some(b) = child(node, SyntaxKind::BooleanLiteral) {
303        Literal::Boolean(b.text().to_string().trim() == "true")
304    } else {
305        Literal::Numeric(String::new())
306    }
307}
308
309fn convert_rdf_literal(node: &Node) -> RDFLiteral {
310    // RDFLiteral ::= String (LANGTAG | '^^' iri)?
311    let offset: usize = node.text_range().start().into();
312
313    let (value, quote_style, len) = if let Some(str_node) = child(node, SyntaxKind::MyString) {
314        // MyString contains one of the four string terminal nodes
315        let string_node = str_node.children().next();
316        if let Some(sn) = string_node {
317            let text = terminal_text(&sn);
318            let (inner, style) = strip_string_delimiters(&text);
319            (inner.to_string(), style, 1)
320        } else {
321            (String::new(), StringStyle::Double, 0)
322        }
323    } else {
324        (String::new(), StringStyle::Double, 0)
325    };
326
327    let lang = child(node, SyntaxKind::Langtag).map(|n| {
328        let span = text_range(&n);
329        let text = terminal_text(&n);
330        let value = text.strip_prefix('@').unwrap_or(&text).to_string();
331        Spanned(value, span)
332    });
333
334    let ty = child(node, SyntaxKind::Iri).map(|n| Spanned(convert_iri(&n), text_range(&n)));
335
336    RDFLiteral {
337        value,
338        quote_style,
339        lang,
340        ty,
341        idx: offset,
342        len,
343    }
344}
345
346fn strip_string_delimiters(text: &str) -> (&str, StringStyle) {
347    if let Some(inner) = text
348        .strip_prefix("\"\"\"")
349        .and_then(|s| s.strip_suffix("\"\"\""))
350    {
351        (inner, StringStyle::DoubleLong)
352    } else if let Some(inner) = text.strip_prefix("'''").and_then(|s| s.strip_suffix("'''")) {
353        (inner, StringStyle::SingleLong)
354    } else if let Some(inner) = text.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
355        (inner, StringStyle::Double)
356    } else if let Some(inner) = text.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
357        (inner, StringStyle::Single)
358    } else {
359        (text, StringStyle::Double)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use rowan::TextSize;
366
367    use super::*;
368    use crate::{parse as crate_parse, turtle::parser as lang};
369
370    fn parse(input: &str) -> Turtle {
371        let (result, _) = crate_parse(lang::Rule::new(lang::SyntaxKind::TurtleDoc), input);
372        let root = result.syntax::<lang::Lang>();
373        convert(&root)
374    }
375
376    // ── helpers ──────────────────────────────────────────────────────────────
377
378    fn prefixed(prefix: &str, value: &str) -> NamedNode {
379        NamedNode::Prefixed {
380            prefix: prefix.to_string(),
381            value: value.to_string(),
382            idx: 0, // ignored in PartialEq? No — but we only care about the string parts.
383            computed: None,
384        }
385    }
386
387    /// Compare two NamedNodes ignoring offset fields.
388    fn nn_eq(a: &NamedNode, b: &NamedNode) -> bool {
389        match (a, b) {
390            (NamedNode::Full(s1, _), NamedNode::Full(s2, _)) => s1 == s2,
391            (
392                NamedNode::Prefixed {
393                    prefix: p1,
394                    value: v1,
395                    ..
396                },
397                NamedNode::Prefixed {
398                    prefix: p2,
399                    value: v2,
400                    ..
401                },
402            ) => p1 == p2 && v1 == v2,
403            (NamedNode::A(_), NamedNode::A(_)) => true,
404            (NamedNode::Invalid, NamedNode::Invalid) => true,
405            _ => false,
406        }
407    }
408
409    fn term_nn(t: &Term) -> &NamedNode {
410        match t {
411            Term::NamedNode(nn) => nn,
412            other => panic!("expected NamedNode, got {:?}", other),
413        }
414    }
415
416    fn term_lit(t: &Term) -> &Literal {
417        match t {
418            Term::Literal(l) => l,
419            other => panic!("expected Literal, got {:?}", other),
420        }
421    }
422
423    fn term_bn(t: &Term) -> &BlankNode {
424        match t {
425            Term::BlankNode(bn) => bn,
426            other => panic!("expected BlankNode, got {:?}", other),
427        }
428    }
429
430    // ── prefix directive ─────────────────────────────────────────────────────
431
432    #[test]
433    fn test_prefix_directive() {
434        let doc = parse("@prefix ex: <http://example.org/> .");
435        assert_eq!(doc.prefixes.len(), 1);
436        let p = doc.prefixes[0].value();
437        assert_eq!(p.prefix.value(), "ex");
438        assert!(nn_eq(
439            p.value.value(),
440            &NamedNode::Full("http://example.org/".to_string(), 0)
441        ));
442    }
443
444    #[test]
445    fn test_sparql_prefix_directive() {
446        let doc = parse("PREFIX ex: <http://example.org/>");
447        assert_eq!(doc.prefixes.len(), 1);
448        assert_eq!(doc.prefixes[0].prefix.value(), "ex");
449    }
450
451    // ── base directive ────────────────────────────────────────────────────────
452
453    #[test]
454    fn test_base_directive() {
455        let doc = parse("@base <http://example.org/> .");
456        let base = doc.base.as_ref().expect("base should be present");
457        assert!(nn_eq(
458            base.value().1.value(),
459            &NamedNode::Full("http://example.org/".to_string(), 0)
460        ));
461    }
462
463    // ── simple triple with full IRI subject ───────────────────────────────────
464
465    #[test]
466    fn test_full_iri_subject_and_predicate() {
467        let doc = parse(
468            "<http://example.org/alice> <http://www.w3.org/1999/02/22-rdf-syntax-ns#type> <http://xmlns.com/foaf/0.1/Person> .",
469        );
470        assert_eq!(doc.triples.len(), 1);
471        let t = doc.triples[0].value();
472        assert!(nn_eq(
473            term_nn(t.subject.value()),
474            &NamedNode::Full("http://example.org/alice".to_string(), 0)
475        ));
476        assert_eq!(t.po.len(), 1);
477        assert!(nn_eq(
478            term_nn(t.po[0].predicate.value()),
479            &NamedNode::Full(
480                "http://www.w3.org/1999/02/22-rdf-syntax-ns#type".to_string(),
481                0
482            )
483        ));
484        assert!(nn_eq(
485            term_nn(t.po[0].object[0].value()),
486            &NamedNode::Full("http://xmlns.com/foaf/0.1/Person".to_string(), 0)
487        ));
488    }
489
490    // ── `a` shorthand ─────────────────────────────────────────────────────────
491
492    #[test]
493    fn test_a_shorthand() {
494        let doc = parse(
495            "@prefix foaf: <http://xmlns.com/foaf/0.1/> . <http://example.org/alice> a foaf:Person .",
496        );
497        let t = doc.triples[0].value();
498        assert!(matches!(
499            t.po[0].predicate.value(),
500            Term::NamedNode(NamedNode::A(_))
501        ));
502        assert!(nn_eq(
503            term_nn(t.po[0].object[0].value()),
504            &prefixed("foaf", "Person")
505        ));
506    }
507
508    // ── prefixed name ─────────────────────────────────────────────────────────
509
510    #[test]
511    fn test_prefixed_name_subject() {
512        let doc = parse("@prefix ex: <http://example.org/> . ex:alice ex:knows ex:bob .");
513        let t = doc.triples[0].value();
514        assert!(nn_eq(term_nn(t.subject.value()), &prefixed("ex", "alice")));
515        assert!(nn_eq(
516            term_nn(t.po[0].object[0].value()),
517            &prefixed("ex", "bob")
518        ));
519    }
520
521    // ── multiple predicate-object pairs (`;`) ────────────────────────────────
522
523    #[test]
524    fn test_multiple_po_pairs() {
525        let doc =
526            parse("@prefix ex: <http://example.org/> . ex:alice ex:name \"Alice\" ; ex:age 30 .");
527        let t = doc.triples[0].value();
528        assert_eq!(t.po.len(), 2);
529        assert!(nn_eq(
530            term_nn(t.po[0].predicate.value()),
531            &prefixed("ex", "name")
532        ));
533        assert!(nn_eq(
534            term_nn(t.po[1].predicate.value()),
535            &prefixed("ex", "age")
536        ));
537    }
538
539    // ── multiple objects (`,`) ────────────────────────────────────────────────
540
541    #[test]
542    fn test_multiple_objects() {
543        let doc =
544            parse("@prefix ex: <http://example.org/> . ex:alice ex:knows ex:bob , ex:carol .");
545        let t = doc.triples[0].value();
546        assert_eq!(t.po[0].object.len(), 2);
547        assert!(nn_eq(
548            term_nn(t.po[0].object[0].value()),
549            &prefixed("ex", "bob")
550        ));
551        assert!(nn_eq(
552            term_nn(t.po[0].object[1].value()),
553            &prefixed("ex", "carol")
554        ));
555    }
556
557    // ── string literal ────────────────────────────────────────────────────────
558
559    #[test]
560    fn test_string_literal() {
561        let doc = parse("@prefix ex: <http://example.org/> . ex:alice ex:name \"Alice\" .");
562        let t = doc.triples[0].value();
563        let lit = term_lit(t.po[0].object[0].value());
564        assert_eq!(lit.plain_string(), "Alice");
565    }
566
567    #[test]
568    fn test_string_literal_with_lang() {
569        let src = "@prefix ex: <http://example.org/> . ex:alice ex:name \"Alice\"@en .";
570        let doc = parse(src);
571        let t = doc.triples[0].value();
572        match term_lit(t.po[0].object[0].value()) {
573            Literal::RDF(r) => {
574                assert_eq!(r.value, "Alice");
575                let lang = r.lang.as_ref().expect("lang tag should be present");
576                assert_eq!(lang.as_str(), "en");
577                assert_eq!(&src[lang.span().clone()], "@en");
578            }
579            other => panic!("expected RDF literal, got {:?}", other),
580        }
581    }
582
583    #[test]
584    fn test_string_literal_with_datatype() {
585        let src = "@prefix xsd: <http://www.w3.org/2001/XMLSchema#> . @prefix ex: <http://example.org/> . ex:alice ex:age \"30\"^^xsd:integer .";
586        let doc = parse(src);
587        let t = doc.triples[0].value();
588        match term_lit(t.po[0].object[0].value()) {
589            Literal::RDF(r) => {
590                assert_eq!(r.value, "30");
591                let ty = r.ty.as_ref().expect("datatype should be present");
592                assert!(nn_eq(ty, &prefixed("xsd", "integer")));
593                assert_eq!(&src[ty.span().clone()], "xsd:integer");
594            }
595            other => panic!("expected RDF literal, got {:?}", other),
596        }
597    }
598
599    // ── numeric literal ───────────────────────────────────────────────────────
600
601    #[test]
602    fn test_numeric_literal() {
603        let doc = parse("@prefix ex: <http://example.org/> . ex:alice ex:age 30 .");
604        let t = doc.triples[0].value();
605        match term_lit(t.po[0].object[0].value()) {
606            Literal::Numeric(n) => assert_eq!(n, "30"),
607            other => panic!("expected Numeric literal, got {:?}", other),
608        }
609    }
610
611    // ── boolean literal ───────────────────────────────────────────────────────
612
613    #[test]
614    fn test_boolean_literal() {
615        let doc = parse("@prefix ex: <http://example.org/> . ex:alice ex:active true .");
616        let t = doc.triples[0].value();
617        assert_eq!(*term_lit(t.po[0].object[0].value()), Literal::Boolean(true));
618    }
619
620    // ── named blank node ──────────────────────────────────────────────────────
621
622    #[test]
623    fn test_named_blank_node() {
624        let doc = parse("@prefix ex: <http://example.org/> . _:b0 ex:knows ex:alice .");
625        let t = doc.triples[0].value();
626        match term_bn(t.subject.value()) {
627            BlankNode::Named(name, _) => assert_eq!(name, "b0"),
628            other => panic!("expected Named blank node, got {:?}", other),
629        }
630    }
631
632    // ── anonymous blank node property list ───────────────────────────────────
633
634    #[test]
635    fn test_anon_blank_node_property_list() {
636        let doc =
637            parse("@prefix ex: <http://example.org/> . ex:alice ex:knows [ ex:name \"Bob\" ] .");
638        let t = doc.triples[0].value();
639        match term_bn(t.po[0].object[0].value()) {
640            BlankNode::Unnamed(pos, _, _) => {
641                assert_eq!(pos.len(), 1);
642                assert!(nn_eq(
643                    term_nn(pos[0].predicate.value()),
644                    &prefixed("ex", "name")
645                ));
646            }
647            other => panic!("expected Unnamed blank node, got {:?}", other),
648        }
649    }
650
651    // ── blank node as subject with property list ──────────────────────────────
652
653    #[test]
654    fn test_blank_node_subject_property_list() {
655        let doc = parse("@prefix ex: <http://example.org/> . [ ex:name \"Alice\" ] ex:age 30 .");
656        let t = doc.triples[0].value();
657        assert!(matches!(
658            t.subject.value(),
659            Term::BlankNode(BlankNode::Unnamed(_, _, _))
660        ));
661        assert_eq!(t.po.len(), 1);
662    }
663
664    // ── collection ────────────────────────────────────────────────────────────
665
666    #[test]
667    fn test_collection() {
668        let doc =
669            parse("@prefix ex: <http://example.org/> . ex:alice ex:list ( ex:a ex:b ex:c ) .");
670        let t = doc.triples[0].value();
671        match t.po[0].object[0].value() {
672            Term::Collection(items) => {
673                assert_eq!(items.len(), 3);
674                assert!(nn_eq(term_nn(items[0].value()), &prefixed("ex", "a")));
675                assert!(nn_eq(term_nn(items[1].value()), &prefixed("ex", "b")));
676                assert!(nn_eq(term_nn(items[2].value()), &prefixed("ex", "c")));
677            }
678            other => panic!("expected Collection, got {:?}", other),
679        }
680    }
681
682    // ── fault-tolerant parsing ────────────────────────────────────────────────
683
684    fn parse_raw(input: &str) -> crate::Parse {
685        let (result, _) = crate_parse(lang::Rule::new(lang::SyntaxKind::TurtleDoc), input);
686        result
687    }
688
689    #[test]
690    fn test_valid_input_has_no_errors() {
691        let p = parse_raw("@prefix ex: <http://example.org/> . ex:alice ex:age 30 .");
692        assert_eq!(p.errors.len(), 0, "valid input should produce no errors");
693    }
694
695    #[test]
696    fn test_verb_is_verb() {
697        let p = parse("<a> <b>.");
698        let predicate = p.triples[0].po[0].predicate.value();
699        assert!(nn_eq(
700            term_nn(predicate),
701            &NamedNode::Full("b".to_string(), 0)
702        ))
703    }
704
705    #[test]
706    fn test_missing_trailing_dot_reports_error() {
707        let p = parse_raw("@prefix ex: <http://example.org/> . ex:alice ex:age 30");
708        assert!(
709            p.errors.len() > 0,
710            "missing trailing dot should produce an error"
711        );
712        // The error should name the missing Stop (`.`) terminal
713        assert!(
714            p.errors.iter().any(|e| e.contains("Stop")),
715            "expected an error mentioning Stop, got: {:?}",
716            p.errors.iter().collect::<Vec<_>>()
717        );
718    }
719
720    #[test]
721    fn test_missing_prefix_iri_reports_error() {
722        // `@prefix ex:` with no IRI — missing the Iriref token
723        let p = parse_raw("@prefix ex: .");
724        assert!(
725            p.errors.len() > 0,
726            "missing prefix IRI should produce an error"
727        );
728    }
729
730    #[test]
731    fn test_recovery_missing_trailing_dot_still_extracts_triple() {
732        // Even when the trailing `.` is absent the A* still builds a Statement
733        // node with an Error child, and the converter should extract the triple.
734        let doc = parse("@prefix ex: <http://example.org/> . ex:alice ex:age 30");
735        assert_eq!(doc.triples.len(), 1);
736        assert!(nn_eq(
737            term_nn(doc.triples[0].subject.value()),
738            &prefixed("ex", "alice")
739        ));
740        match term_lit(doc.triples[0].po[0].object[0].value()) {
741            Literal::Numeric(n) => assert_eq!(n, "30"),
742            other => panic!("expected Numeric, got {:?}", other),
743        }
744    }
745
746    #[test]
747    fn test_recovery_second_triple_after_missing_dot() {
748        // The first triple is missing its `.`.  The A* should still produce two
749        // Statement nodes — one with an error, one clean — giving us two triples.
750        let input = "@prefix ex: <http://example.org/> .\n\
751                     ex:alice ex:age 30\n\
752                     ex:bob ex:age 25 .";
753        let p = parse_raw(input);
754        let root = p.syntax::<lang::Lang>();
755        println!("Parse tree:\n{:#?}", root);
756        assert!(p.errors.len() > 0, "should report at least one error");
757
758        let doc = parse(input);
759        assert_eq!(doc.triples.len(), 2, "both triples should be recovered");
760        assert!(nn_eq(
761            term_nn(doc.triples[0].subject.value()),
762            &prefixed("ex", "alice")
763        ));
764        assert!(nn_eq(
765            term_nn(doc.triples[1].subject.value()),
766            &prefixed("ex", "bob")
767        ));
768    }
769
770    // ── multiple triples ──────────────────────────────────────────────────────
771
772    #[test]
773    fn test_multiple_triples() {
774        let doc =
775            parse("@prefix ex: <http://example.org/> . ex:alice ex:age 30 . ex:bob ex:age 25 .");
776        assert_eq!(doc.triples.len(), 2);
777        assert!(nn_eq(
778            term_nn(doc.triples[0].subject.value()),
779            &prefixed("ex", "alice")
780        ));
781        assert!(nn_eq(
782            term_nn(doc.triples[1].subject.value()),
783            &prefixed("ex", "bob")
784        ));
785    }
786
787    // ── incremental parsing ───────────────────────────────────────────────────
788
789    use crate::{IncrementalBias, PrevParseInfo, TokenTrait, parse_incremental};
790
791    fn prev_info(text: &str) -> PrevParseInfo {
792        let (_, tokens) = crate_parse(lang::Rule::new(lang::SyntaxKind::TurtleDoc), text);
793        let mut depth: i32 = 0;
794        PrevParseInfo {
795            tokens: tokens
796                .iter()
797                .map(|t| {
798                    let d = depth.clamp(0, 255) as u8;
799                    depth += t.kind.bracket_delta() as i32;
800                    t.to_prev_token(d)
801                })
802                .collect(),
803            had_errors: false,
804        }
805    }
806
807    /// Going from `<b> <c> <d> .` to `<a> <b> <c> <d> .` (inserting `<a>`
808    /// at the front), the incremental parser should produce two triples:
809    ///
810    ///   1. `<a>` as subject with error-recovery predicate/object (empty IRIs)
811    ///   2. `<b> <c> <d>` — the original triple, roles preserved
812    ///
813    /// This works because `expect_as` enqueues a fallback element (with
814    /// lower cost) whenever a token matches but conflicts with its old role,
815    /// letting the A* explore paths where the conflicting token begins a new
816    /// parse context (e.g. as Subject of the next statement).
817    #[test]
818    fn test_incremental_two_triples_from_inserted_token() {
819        let prev = prev_info("<b> <c> <d> .");
820        let bias = IncrementalBias::default();
821        let (parse, _) = parse_incremental(
822            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
823            "<a> <b> <c> <d> .",
824            Some(&prev),
825            bias,
826        );
827        let root = parse.syntax::<lang::Lang>();
828        let doc = convert(&root);
829
830        // With token deletion the parser may delete <a> (new token, cheapest
831        // recovery) producing one clean triple, or keep <a> as subject of an
832        // error-recovered first triple producing two triples.  Both are valid
833        // error recovery strategies; deletion is often cheaper.
834        match doc.triples.len() {
835            1 => {
836                // Single triple: <b> <c> <d> — <a> was deleted as unexpected
837                let t = doc.triples[0].value();
838                match t.subject.value() {
839                    Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "b"),
840                    other => panic!("expected <b> as subject, got {:?}", other),
841                }
842            }
843            2 => {
844                // Two triples: <a> (error recovery) + <b> <c> <d>
845                match doc.triples[0].value().subject.value() {
846                    Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "a"),
847                    other => panic!("expected <a> as first subject, got {:?}", other),
848                }
849                let t2 = doc.triples[1].value();
850                match t2.subject.value() {
851                    Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "b"),
852                    other => panic!("expected <b> as subject of second triple, got {:?}", other),
853                }
854            }
855            n => panic!("expected 1 or 2 triples, got {}", n),
856        }
857
858        // Regardless of recovery strategy, <b> <c> <d> should be preserved
859        let last = doc.triples.last().unwrap().value();
860        assert_eq!(last.po.len(), 1);
861        match last.po[0].value().predicate.value() {
862            Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "c"),
863            other => panic!("expected <c> as predicate, got {:?}", other),
864        }
865        assert_eq!(last.po[0].value().object.len(), 1);
866        match last.po[0].value().object[0].value() {
867            Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "d"),
868            other => panic!("expected <d> as object, got {:?}", other),
869        }
870    }
871
872    #[test]
873    fn test_incremental_remove_object() {
874        let prev = prev_info("<a> <b> <c> .");
875        let bias = IncrementalBias::default();
876        let (parse, _) = parse_incremental(
877            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
878            "<a> <b> .",
879            Some(&prev),
880            bias,
881        );
882        let root = parse.syntax::<lang::Lang>();
883        let doc = convert(&root);
884
885        assert_eq!(doc.triples.len(), 1, "should produce two triples");
886
887        // Triple 1: <a> as subject (error recovery — predicate/object are
888        // error nodes with empty IRIs, representing the undefined slots)
889        match doc.triples[0].value().subject.value() {
890            Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "a"),
891            other => panic!("expected <a> as first subject, got {:?}", other),
892        }
893    }
894
895    #[test]
896    fn test_incremental_remove_prefix() {
897        let prev =
898            prev_info("@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n <a> foaf:knows ex:Bob .");
899        let bias = IncrementalBias::default();
900        let (parse, _) = parse_incremental(
901            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
902            "<a> foaf:knows ex:Bob .",
903            Some(&prev),
904            bias,
905        );
906        let root = parse.syntax::<lang::Lang>();
907        let doc = convert(&root);
908
909        assert_eq!(doc.triples.len(), 1, "should produce one triples");
910
911        // Triple 1: <a> as subject (error recovery — predicate/object are
912        // error nodes with empty IRIs, representing the undefined slots)
913        match doc.triples[0].value().subject.value() {
914            Term::NamedNode(NamedNode::Full(iri, _)) => assert_eq!(iri, "a"),
915            other => panic!("expected <a> as first subject, got {:?}", other),
916        }
917    }
918
919    #[test]
920    fn test_suggest_correct_sq_close_location() {
921        let before = r#"ex:Bob foaf: [
922  foaf:knows [
923    foaf:name "Alice"
924  ];
925  foaf:name "Bob";
926 ]."#;
927
928        let after = r#"ex:Bob foaf: [
929  foaf:knows [
930    foaf:name "Alice"
931  ;
932  foaf:name "Bob";
933 ]."#;
934        let prev = prev_info(before);
935        let bias = IncrementalBias::default();
936        let (parse, _) = parse_incremental(
937            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
938            after,
939            Some(&prev),
940            bias,
941        );
942        let root = parse.syntax::<lang::Lang>();
943
944        let first_location = before.find(']').unwrap_or_default();
945        let error_span = root
946            .descendants_with_tokens()
947            .filter(|t| t.kind() == lang::SyntaxKind::Error)
948            .map(|t| t.text_range())
949            .next()
950            .expect("has an error");
951
952        println!("error span {:?} location {}", error_span, first_location);
953        let loc = TextSize::new(first_location as u32);
954        assert!(
955            error_span.contains(loc) || error_span.start() == loc,
956            "The error span should start at or include the location of the removed bracket"
957        );
958    }
959
960    #[test]
961    fn test_suggest_completing_triple() {
962        let before = r#"ex:Alice a foaf:Person ;
963    foaf:knows ex:Bob . "#;
964
965        let after = r#"ex:Alice a foaf:Person ; <a>
966    foaf:knows ex:Bob . "#;
967        let prev = prev_info(before);
968        let bias = IncrementalBias::default();
969        let (parse, _) = parse_incremental(
970            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
971            after,
972            Some(&prev),
973            bias,
974        );
975
976        let root = parse.syntax::<lang::Lang>();
977        let doc = convert(&root);
978        assert_eq!(doc.triples.len(), 1, "should produce one triples");
979        assert_eq!(
980            doc.triples[0].0.po.len(),
981            3,
982            "should produce three predicate objects"
983        );
984
985        let errors: Vec<_> = parse.errors.iter().collect();
986
987        assert_eq!(errors.len(), 2, "expect a term and a semi colon");
988    }
989
990    #[test]
991    fn test_suggest_completing_triple_2() {
992        let before = r#"ex:Alice a foaf:Person ;
993    foaf:knows ex:Bob . "#;
994
995        let after_1 = r#"ex:Alice a foaf:Person ; <a>
996    foaf:knows ex:Bob . "#;
997
998        let after_2 = r#"ex:Alice a foaf:Person ; <a> <b>
999    foaf:knows ex:Bob . "#;
1000        let prev = prev_info(before);
1001        let bias = IncrementalBias::default();
1002        let (_, prev) = parse_incremental(
1003            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1004            after_1,
1005            Some(&prev),
1006            bias,
1007        );
1008        let (parse, _) = parse_incremental(
1009            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1010            after_2,
1011            Some(&prev),
1012            bias,
1013        );
1014
1015        let root = parse.syntax::<lang::Lang>();
1016        let doc = convert(&root);
1017        assert_eq!(doc.triples.len(), 1, "should produce one triples");
1018        assert_eq!(
1019            doc.triples[0].0.po.len(),
1020            3,
1021            "should produce three predicate objects"
1022        );
1023
1024        let errors: Vec<_> = parse.errors.iter().collect();
1025
1026        assert_eq!(errors.len(), 1, "expect a semi colon");
1027    }
1028
1029    #[test]
1030    fn test_suggest_inside_blank_node() {
1031        let before = r#"<a> a <b> ;
1032    <knows> [  ] ."#;
1033
1034        let after_1 = r#"<a> a <b> ;
1035    <knows> [  ] ."#;
1036        //     let after_1 = r#"<a> a <b> ;
1037        // <knows> [ <nam ] ."#;
1038
1039        let after_2 = r#"<a> a <b> ;
1040    <knows> [ <name> ] ."#;
1041
1042        let prev = prev_info(before);
1043        let bias = IncrementalBias::default();
1044
1045        let (_, prev) = parse_incremental(
1046            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1047            after_1,
1048            Some(&prev),
1049            bias,
1050        );
1051        let (parse, _) = parse_incremental(
1052            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1053            after_2,
1054            Some(&prev),
1055            bias,
1056        );
1057
1058        let root = parse.syntax::<lang::Lang>();
1059        let doc = convert(&root);
1060        assert_eq!(doc.triples.len(), 1, "should produce one triples");
1061        assert_eq!(
1062            doc.triples[0].0.po.len(),
1063            2,
1064            "should produce two predicate objects"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_changing_whitespace_does_not_influence_errors() {
1070        let before = r#"<a> a <b> . "#;
1071
1072        let after_1 = r#"<a>  <b> . "#;
1073        let after_2 = r#"<a> <b> . "#;
1074
1075        let prev = prev_info(before);
1076        let bias = IncrementalBias::default();
1077
1078        let (parse, prev) = parse_incremental(
1079            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1080            after_1,
1081            Some(&prev),
1082            bias,
1083        );
1084        assert!(
1085            parse
1086                .errors
1087                .iter()
1088                .any(|x| x.to_lowercase().contains("verb")),
1089            "expected a verb error; got: {:?}",
1090            parse.errors,
1091        );
1092        let (parse, _) = parse_incremental(
1093            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1094            after_2,
1095            Some(&prev),
1096            bias,
1097        );
1098        assert!(
1099            parse
1100                .errors
1101                .iter()
1102                .any(|x| x.to_lowercase().contains("verb")),
1103            "expected a verb error; got: {:?}",
1104            parse.errors,
1105        );
1106    }
1107
1108    /// When `a` (the rdf:type shorthand verb) is removed from `<s> a <o> .`,
1109    /// the parser must report a missing verb.  The error should name the `Verb`
1110    /// grammar rule, not the low-level `Alit` terminal.
1111    #[test]
1112    fn test_remove_rdf_type_verb_error_names_verb_rule() {
1113        let prev = prev_info("<s> a <o> .");
1114        let bias = IncrementalBias::default();
1115        let (parse, _) = parse_incremental(
1116            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1117            "<s> <o> .",
1118            Some(&prev),
1119            bias,
1120        );
1121
1122        let errors: Vec<_> = parse.errors.iter().collect();
1123        assert!(
1124            !errors.is_empty(),
1125            "removing the verb should produce an error, got none"
1126        );
1127        assert!(
1128            errors.iter().any(|e| e.to_lowercase().contains("verb")),
1129            "error should name the Verb grammar rule, got: {:?}",
1130            errors
1131        );
1132        assert!(
1133            !errors.iter().any(|e| e.to_lowercase().contains("alit")),
1134            "error should not expose the low-level Alit terminal, got: {:?}",
1135            errors
1136        );
1137    }
1138
1139    /// Removing the `a` verb from `<b> a <c> .` incrementally should produce
1140    /// an error that names the Verb grammar rule.  The raw CST error node is
1141    /// zero-width, but `effective_error_span` widens it to cover the whitespace
1142    /// gap between `<b>` and `<c>` — a span of 1.
1143    #[test]
1144    fn test_remove_verb_from_iri_triple_reports_verb_error() {
1145        let prev = prev_info("<b> a <c> .");
1146        let bias = IncrementalBias::default();
1147        let (parse, _) = parse_incremental(
1148            lang::Rule::new(lang::SyntaxKind::TurtleDoc),
1149            "<b> <c> .",
1150            Some(&prev),
1151            bias,
1152        );
1153
1154        let errors: Vec<_> = parse.errors.iter().collect();
1155        assert!(
1156            errors.iter().any(|e| e.to_lowercase().contains("verb")),
1157            "error should name the Verb grammar rule, got: {:?}",
1158            errors
1159        );
1160
1161        let root = parse.syntax::<lang::Lang>();
1162        let error_node = root
1163            .descendants()
1164            .find(|n| n.kind() == lang::SyntaxKind::Error)
1165            .expect("should have an error node");
1166        let effective = crate::effective_error_span::<lang::Lang>(&error_node);
1167        assert_eq!(
1168            effective.end - effective.start,
1169            1,
1170            "effective_error_span should cover the whitespace gap (1 byte), got {:?}",
1171            effective
1172        );
1173    }
1174
1175    // ── fast (non-fault-tolerant) parsing ─────────────────────────────────────
1176
1177    use crate::parse_fast;
1178
1179    fn parse_fast_doc(input: &str) -> Option<Turtle> {
1180        let (result, _) = parse_fast(lang::Rule::new(lang::SyntaxKind::TurtleDoc), input)?;
1181        let root = result.syntax::<lang::Lang>();
1182        Some(convert(&root))
1183    }
1184
1185    fn parse_fast_raw(input: &str) -> Option<crate::Parse> {
1186        let (result, _) = parse_fast(lang::Rule::new(lang::SyntaxKind::TurtleDoc), input)?;
1187        Some(result)
1188    }
1189
1190    /// A well-formed document should succeed and produce no errors.
1191    #[test]
1192    fn test_fast_valid_simple_triple() {
1193        let doc = parse_fast_doc("<http://a> <http://b> <http://c> .")
1194            .expect("valid input should return Some");
1195        assert_eq!(doc.triples.len(), 1);
1196    }
1197
1198    /// parse_fast must return None (not panic) when the document has errors.
1199    #[test]
1200    fn test_fast_returns_none_on_error() {
1201        // Missing trailing dot
1202        assert!(
1203            parse_fast_doc("<http://a> <http://b> <http://c>").is_none(),
1204            "document with missing dot should return None"
1205        );
1206    }
1207
1208    /// Syntax errors mid-document (extra subject with no predicate) must yield None.
1209    #[test]
1210    fn test_fast_returns_none_on_mid_document_error() {
1211        // A valid triple followed by a subject with no predicate or object —
1212        // a genuine grammar-level error that can't be skipped at the lexer level.
1213        assert!(
1214            parse_fast_doc("<http://a> <http://b> <http://c> . <http://dangling>").is_none(),
1215            "incomplete trailing statement should return None"
1216        );
1217    }
1218
1219    /// For a correct document, fast mode should produce no CST errors.
1220    #[test]
1221    fn test_fast_produces_no_errors() {
1222        let p = parse_fast_raw("@prefix ex: <http://example.org/> . ex:alice ex:age 30 .")
1223            .expect("valid input should return Some");
1224        assert_eq!(
1225            p.errors.len(),
1226            0,
1227            "fast parse of valid input should have no errors"
1228        );
1229    }
1230
1231    /// Fast mode should correctly parse prefix declarations.
1232    #[test]
1233    fn test_fast_prefix_directive() {
1234        let doc = parse_fast_doc("@prefix ex: <http://example.org/> . ex:alice ex:knows ex:bob .")
1235            .expect("valid input should return Some");
1236        assert_eq!(doc.prefixes.len(), 1);
1237        assert_eq!(doc.triples.len(), 1);
1238        assert!(nn_eq(
1239            term_nn(doc.triples[0].subject.value()),
1240            &prefixed("ex", "alice")
1241        ));
1242    }
1243
1244    /// Fast mode should correctly parse multiple triples.
1245    #[test]
1246    fn test_fast_multiple_triples() {
1247        let doc = parse_fast_doc(
1248            "@prefix ex: <http://example.org/> . ex:alice ex:age 30 . ex:bob ex:age 25 .",
1249        )
1250        .expect("valid input should return Some");
1251        assert_eq!(doc.triples.len(), 2);
1252        assert!(nn_eq(
1253            term_nn(doc.triples[0].subject.value()),
1254            &prefixed("ex", "alice")
1255        ));
1256        assert!(nn_eq(
1257            term_nn(doc.triples[1].subject.value()),
1258            &prefixed("ex", "bob")
1259        ));
1260    }
1261
1262    /// Fast mode result should match regular parse for a correct document.
1263    #[test]
1264    fn test_fast_matches_regular_parse() {
1265        let input = "@prefix ex: <http://example.org/> . ex:alice ex:name \"Alice\" ; ex:age 30 .";
1266        let fast_doc = parse_fast_doc(input).expect("valid input should return Some");
1267        let regular_doc = parse(input);
1268        assert_eq!(fast_doc.triples.len(), regular_doc.triples.len());
1269        assert_eq!(fast_doc.prefixes.len(), regular_doc.prefixes.len());
1270    }
1271
1272    /// Fast mode should handle blank node property lists.
1273    #[test]
1274    fn test_fast_blank_node_property_list() {
1275        let doc = parse_fast_doc(
1276            "@prefix ex: <http://example.org/> . ex:alice ex:knows [ ex:name \"Bob\" ] .",
1277        )
1278        .expect("valid input should return Some");
1279        assert_eq!(doc.triples.len(), 1);
1280    }
1281
1282    /// Fast mode should handle collections.
1283    #[test]
1284    fn test_fast_collection() {
1285        let doc = parse_fast_doc(
1286            "@prefix ex: <http://example.org/> . ex:alice ex:list ( ex:a ex:b ex:c ) .",
1287        )
1288        .expect("valid input should return Some");
1289        assert_eq!(doc.triples.len(), 1);
1290    }
1291
1292    // ── error recovery stress tests ───────────────────────────────────────────
1293
1294    /// A badly broken document (missing dots, garbage tokens, missing semicolons)
1295    /// should still recover and extract some triples without hitting the max
1296    /// iteration limit.
1297    #[test]
1298    fn test_recovery_badly_broken_document() {
1299        let broken = "\
1300@prefix foaf: <http://xmlns.com/foaf/0.1/> .
1301@prefix ex: <http://example.org/> .
1302
1303ex:alice a foaf:Person ;
1304    foaf:name \"Alice\" ;
1305    foaf:mbox <mailto:alice@example.org>
1306    foaf:homepage <http://example.org/alice> .
1307
1308ex:bob a foaf:Person ;
1309    foaf:name \"Bob\" GARBAGE_TOKEN more_garbage ;
1310    foaf:mbox <mailto:bob@example.org> .
1311
1312ex:carol a foaf:Person ;
1313    foaf:name \"Carol\" ;
1314    foaf:mbox <mailto:carol@example.org> .
1315";
1316        let doc = parse(broken);
1317        // Should recover at least some triples (not zero).
1318        assert!(
1319            !doc.triples.is_empty(),
1320            "error recovery should extract some triples from broken document"
1321        );
1322    }
1323
1324    /// Performance regression guard: parsing a broken document should not take
1325    /// an unreasonable number of iterations.
1326    #[test]
1327    fn test_recovery_performance_bounded() {
1328        use std::time::Instant;
1329        let broken = "\
1330@prefix ex: <http://example.org/> .
1331ex:s1 ex:p1 INVALID GARBAGE LOTS OF BAD TOKENS .
1332ex:s2 ex:p2 ex:o2 .
1333ex:s3 ex:p3 ANOTHER MESS OF GARBAGE .
1334ex:s4 ex:p4 ex:o4 .
1335";
1336        let start = Instant::now();
1337        for _ in 0..10 {
1338            let _ = parse(broken);
1339        }
1340        let elapsed = start.elapsed();
1341        // 10 parses of this small broken document should complete in under 1 second.
1342        assert!(
1343            elapsed.as_millis() < 1000,
1344            "10 error recovery parses took {:?} — performance regression?",
1345            elapsed
1346        );
1347    }
1348}