Skip to main content

triblespace_core/import/
ntriples.rs

1//! N-Triples → TribleSpace importer.
2//!
3//! Each N-Triples line maps directly to a triblespace trible. Subjects and
4//! object URIs are derived deterministically into entity ids via
5//! [`crate::import::rdf_uri`] — the same URI always maps to the same
6//! triblespace `Id` across processes, so repeated imports converge.
7//!
8//! Predicate URIs become attribute ids by wrapping the IRI handle in
9//! [`entity!`] under [`metadata::iri`] and [`metadata::value_encoding`],
10//! then taking the resulting fragment's root via
11//! [`Attribute::<S>::from`]. The inline encoding is chosen from the
12//! object's XSD datatype:
13//!
14//! - `xsd:integer` / `xsd:long` / `xsd:int` / `xsd:short` / `xsd:byte`
15//!   / `xsd:negativeInteger` / `xsd:nonPositiveInteger` → `I256BE`
16//! - `xsd:nonNegativeInteger` / `xsd:positiveInteger` / `xsd:unsignedInt`
17//!   / `xsd:unsignedLong` / `xsd:unsignedShort` / `xsd:unsignedByte` → `U256BE`
18//! - `xsd:decimal` → `R256BE` (exact rational)
19//! - `xsd:float` / `xsd:double` → `F64`
20//! - `xsd:boolean` → `Boolean`
21//! - `xsd:string`, untyped → `Handle<LongString>`
22//! - URI objects (and `xsd:anyURI` literals) → `GenId`
23//! - `xsd:dateTime` → `NsTAIInterval` as `[t, t]` (degenerate instant)
24//! - `xsd:date` → `NsTAIInterval` (whole day, inclusive bounds)
25//! - `xsd:gYear` / `xsd:gYearMonth` → `NsTAIInterval` (year / month)
26//! - `xsd:duration` / `xsd:dayTimeDuration` → `NsDuration`
27//!   (year/month-only durations fall through to text since their
28//!   ns count depends on context)
29//! - `xsd:hexBinary` / `xsd:base64Binary` → `Handle<RawBytes>`
30//!
31//! Language-tagged literals (`"text"@lang`) are reified into a small
32//! entity carrying [`rdf_lang`](crate::import::rdf_lang) and
33//! [`rdf_text`](crate::import::rdf_text). The owning predicate then
34//! holds a `GenId` pointing at that entity, so language handling falls
35//! out of normal joins instead of needing a `lang()` builtin.
36//!
37//! Blank nodes are resolved via the same content-address path the
38//! `entity!` macro uses: a bnode's id is the Blake3 of its sorted
39//! `(attribute, value)` pairs. Two bnodes with the same outgoing facts
40//! collapse to a single entity automatically — the bnode IS the entity
41//! that has these facts. Orphan bnodes (referenced but never appear as
42//! subject) get a per-import salt so they're distinct existentials
43//! across separate ingest calls. Cyclic blank-node graphs return
44//! [`IngestError::BnodeCycle`] — there's no fixed-point id assignment
45//! without symmetry-breaking, and we'd rather refuse than guess.
46//!
47//! ## API
48//!
49//! [`import_bytes`] is the core entry point — it parses a `Bytes`
50//! buffer (e.g. a memory-mapped file, an over-the-wire payload, or a
51//! `String::into_bytes`'d test fixture) without copying string slices.
52//! [`import_blob`] is a convenience over `Blob<LongString>`, mirroring
53//! [`crate::import::json::JsonObjectImporter::import_blob`].
54//! [`ingest_ntriples`] adapts a `BufRead` by slurping it into a
55//! `Bytes`. [`ingest_ntriples_file`] opens a path and forwards.
56//!
57//! Inside, the parser is winnow-driven over `anybytes::Bytes`. URI and
58//! bnode label slices come back as `View<str>` — Arc-shared into the
59//! input buffer, so storing them in the bnode-resolution buffer costs
60//! nothing. Literal lexical forms come back as `Bytes`: zero-copy on
61//! the no-escape fast path, freshly-allocated only when ECHAR / UCHAR
62//! escapes forced decoding. ECHAR `\b`, `\f`, `\'`, `\n`, `\r`, `\t`,
63//! `\"`, `\\` and UCHAR `\uXXXX` / `\UXXXXXXXX` are all supported in
64//! both string literals and IRIs.
65
66use std::collections::{HashMap, HashSet, VecDeque};
67use std::fmt;
68use std::io::{BufRead, Read};
69use std::path::Path;
70
71use anybytes::{Bytes, View};
72use base64::engine::general_purpose::STANDARD as BASE64;
73use base64::Engine as _;
74use blake3::Hasher;
75use hifitime::prelude::*;
76use num_rational::Ratio;
77use winnow::error::InputError;
78use winnow::stream::Stream;
79use winnow::token::{take, take_while};
80use winnow::Parser;
81
82use crate::attribute::Attribute;
83use crate::blob::encodings::longstring::LongString;
84use crate::blob::encodings::rawbytes::RawBytes;
85use crate::blob::{Blob, IntoBlob};
86use crate::id::{ExclusiveId, Id, ID_LEN};
87use crate::macros::entity;
88use crate::prelude::inlineencodings;
89use crate::repo::{BlobStore, Workspace};
90use crate::trible::{Trible, TribleSet};
91use crate::inline::encodings::genid::GenId;
92use crate::inline::encodings::hash::Handle;
93use crate::inline::encodings::shortstring::ShortString;
94use crate::inline::encodings::time::{i128_to_ordered_be, NsDuration, NsTAIInterval};
95use crate::inline::encodings::UnknownInline;
96use crate::inline::encodings::boolean::Boolean;
97use crate::inline::encodings::f64::F64;
98use crate::inline::{RawInline, IntoInline, TryToInline, Inline};
99
100const XSD: &str = "http://www.w3.org/2001/XMLSchema#";
101
102// ── Errors ──────────────────────────────────────────────────────────
103
104/// Error returned by [`ingest_ntriples`] when the input cannot be
105/// completed without compromising semantics.
106#[derive(Debug, Clone)]
107pub enum IngestError {
108    /// A blank-node cycle was detected. Each label's intrinsic id depends
109    /// on its neighbors' ids, so a cycle has no fixed-point assignment
110    /// without a more elaborate symmetry-breaking scheme (see RDF-Canon's
111    /// gossip-path algorithm). We refuse rather than emit something
112    /// arbitrary.
113    BnodeCycle {
114        /// The bnode labels that participate in the unresolved cycle.
115        labels: Vec<String>,
116    },
117    /// The underlying reader returned an I/O error.
118    Io(String),
119}
120
121impl fmt::Display for IngestError {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::BnodeCycle { labels } => {
125                write!(f, "blank-node cycle in input: {}", labels.join(", "))
126            }
127            Self::Io(msg) => write!(f, "i/o error reading n-triples: {msg}"),
128        }
129    }
130}
131
132impl std::error::Error for IngestError {}
133
134// ── Blank-node buffering ────────────────────────────────────────────
135//
136// Bnode identity in RDF is existential ("some thing with these
137// properties"). We materialise that by deriving each bnode's
138// triblespace `Id` from the set of its outgoing facts via the same
139// content-hash the `entity!` macro uses — the bnode IS the entity that
140// has these facts. Two bnodes with identical outgoing facts collapse
141// to the same id automatically, which is what RDF semantics promise.
142//
143// For bnodes with no outgoing facts (only referenced from elsewhere),
144// we skolemise with a per-import salt so they're treated as distinct
145// existentials across separate ingest calls. Same call, same label →
146// same id; different calls, same label → different ids.
147//
148// Cycles in the bnode reference graph produce `IngestError::BnodeCycle`
149// — there is no fixed point to assign without arbitrarily breaking
150// symmetry, and we'd rather refuse than emit something wrong.
151
152/// One outgoing edge from a bnode subject.
153enum OutgoingFact {
154    /// Fully-resolved value (URI handle, literal, lang-entity reference).
155    Resolved { attr_id: Id, value_raw: RawInline },
156    /// Bnode-to-bnode edge; both subject and target are deferred. The
157    /// `attr_id` is already the GenId-typed predicate-attribute id.
158    BnodeRef {
159        attr_id: Id,
160        target_label: View<str>,
161    },
162}
163
164/// One triple of the form `<resolved_subject> <predicate> _:target`.
165/// Emitted after `target` is resolved.
166struct IncomingFact {
167    subject_id: Id,
168    attr_id: Id,
169    target_label: View<str>,
170}
171
172/// Per-import buffer for blank-node triples. Keys are `View<str>`
173/// slices into the underlying input `Bytes`, so storing a label costs
174/// nothing — the slice Arc-shares the input buffer.
175struct BnodeBuffer {
176    /// Outgoing facts, keyed by bnode subject label.
177    outgoing: HashMap<View<str>, Vec<OutgoingFact>>,
178    /// Triples whose object is a bnode (subject is already resolved).
179    incoming: Vec<IncomingFact>,
180    /// Per-import salt for orphan-bnode skolemisation.
181    salt: [u8; 16],
182}
183
184impl BnodeBuffer {
185    fn new() -> Self {
186        // Random salt → orphans differ across ingest calls, matching
187        // their "fresh existential" RDF semantics. Within a call, the
188        // salt is constant so the same orphan label always produces
189        // the same id.
190        let mut salt = [0u8; 16];
191        rand::Rng::fill(&mut rand::thread_rng(), &mut salt[..]);
192        Self {
193            outgoing: HashMap::new(),
194            incoming: Vec::new(),
195            salt,
196        }
197    }
198
199    fn is_empty(&self) -> bool {
200        self.outgoing.is_empty() && self.incoming.is_empty()
201    }
202
203    fn push_outgoing(&mut self, label: View<str>, fact: OutgoingFact) {
204        self.outgoing.entry(label).or_default().push(fact);
205    }
206
207    fn push_incoming(&mut self, fact: IncomingFact) {
208        self.incoming.push(fact);
209    }
210
211    /// Resolve every buffered bnode and emit its tribles into `facts`.
212    fn flush(self, facts: &mut TribleSet) -> Result<(), IngestError> {
213        if self.is_empty() {
214            return Ok(());
215        }
216
217        // 1. Build dependency graph: label → labels its outgoing facts reference.
218        //    Also collect every label that appears anywhere (subject or target).
219        let mut deps: HashMap<View<str>, HashSet<View<str>>> = HashMap::new();
220        let mut all_labels: HashSet<View<str>> = HashSet::new();
221        for (label, edges) in &self.outgoing {
222            all_labels.insert(label.clone());
223            let entry = deps.entry(label.clone()).or_default();
224            for edge in edges {
225                if let OutgoingFact::BnodeRef { target_label, .. } = edge {
226                    entry.insert(target_label.clone());
227                    all_labels.insert(target_label.clone());
228                }
229            }
230        }
231        for inc in &self.incoming {
232            all_labels.insert(inc.target_label.clone());
233        }
234
235        // 2. Topo-sort. Cycle → error.
236        let order = topo_sort(&all_labels, &deps).map_err(|labels| {
237            let mut sorted: Vec<String> = labels.iter().map(|v| v.as_ref().to_owned()).collect();
238            sorted.sort();
239            IngestError::BnodeCycle { labels: sorted }
240        })?;
241
242        // 3. Resolve each bnode's id in dependency order. By the time
243        //    we visit a label, all labels its outgoing facts reference
244        //    are already in `resolved`.
245        let mut resolved: HashMap<View<str>, Id> = HashMap::new();
246        for label in order {
247            let id = resolve_bnode_id(&label, &self.outgoing, &resolved, &self.salt);
248            resolved.insert(label, id);
249        }
250
251        // 4. Emit outgoing tribles (bnode-as-subject).
252        for (label, edges) in self.outgoing {
253            let subject_id = resolved[&label];
254            let e = ExclusiveId::force_ref(&subject_id);
255            for edge in edges {
256                let (attr_id, value_raw) = match edge {
257                    OutgoingFact::Resolved { attr_id, value_raw } => (attr_id, value_raw),
258                    OutgoingFact::BnodeRef {
259                        attr_id,
260                        target_label,
261                    } => {
262                        let target_id = resolved[&target_label];
263                        let v: Inline<GenId> = target_id.to_inline();
264                        (attr_id, v.raw)
265                    }
266                };
267                let v: Inline<UnknownInline> = Inline::new(value_raw);
268                facts.insert(&Trible::new(e, &attr_id, &v));
269            }
270        }
271
272        // 5. Emit incoming tribles (bnode-as-object, subject already known).
273        for inc in self.incoming {
274            let target_id = resolved[&inc.target_label];
275            let e = ExclusiveId::force_ref(&inc.subject_id);
276            let g: Inline<GenId> = target_id.to_inline();
277            let v: Inline<UnknownInline> = Inline::new(g.raw);
278            facts.insert(&Trible::new(e, &inc.attr_id, &v));
279        }
280
281        Ok(())
282    }
283}
284
285/// Compute the intrinsic id for a single bnode given its outgoing facts.
286/// Mirrors the `entity!` macro's derivation: sort `(attr_id, value_raw)`
287/// pairs, dedupe consecutive duplicates, hash with Blake3, and take the
288/// last 16 bytes as the id.
289///
290/// For orphans (no outgoing facts), falls back to skolemisation:
291/// `Blake3(salt || label)[16..]`.
292fn resolve_bnode_id(
293    label: &View<str>,
294    outgoing: &HashMap<View<str>, Vec<OutgoingFact>>,
295    resolved: &HashMap<View<str>, Id>,
296    salt: &[u8; 16],
297) -> Id {
298    let pairs: Vec<(Id, RawInline)> = outgoing
299        .get(label)
300        .map(|edges| {
301            edges
302                .iter()
303                .map(|edge| match edge {
304                    OutgoingFact::Resolved { attr_id, value_raw } => (*attr_id, *value_raw),
305                    OutgoingFact::BnodeRef {
306                        attr_id,
307                        target_label,
308                    } => {
309                        let target_id = resolved
310                            .get(target_label)
311                            .expect("topo order resolved this target first");
312                        let v: Inline<GenId> = target_id.to_inline();
313                        (*attr_id, v.raw)
314                    }
315                })
316                .collect()
317        })
318        .unwrap_or_default();
319
320    if pairs.is_empty() {
321        // Orphan: skolemise via salt.
322        let mut hasher = Hasher::new();
323        hasher.update(salt);
324        hasher.update(label.as_ref().as_bytes());
325        let digest = hasher.finalize();
326        let mut raw = [0u8; ID_LEN];
327        raw.copy_from_slice(&digest.as_bytes()[digest.as_bytes().len() - ID_LEN..]);
328        return Id::new(raw).expect("non-nil from random salt");
329    }
330
331    let mut pairs = pairs;
332    pairs.sort_unstable();
333    let mut hasher = Hasher::new();
334    let mut last: Option<(Id, RawInline)> = None;
335    for (a, v) in &pairs {
336        if let Some((la, lv)) = last {
337            if *a == la && *v == lv {
338                continue;
339            }
340        }
341        hasher.update(&a[..]);
342        hasher.update(&v[..]);
343        last = Some((*a, *v));
344    }
345    let digest = hasher.finalize();
346    let mut raw = [0u8; ID_LEN];
347    raw.copy_from_slice(&digest.as_bytes()[digest.as_bytes().len() - ID_LEN..]);
348    Id::new(raw).expect("intrinsic id from non-empty pairs")
349}
350
351/// Kahn's topological sort. Returns an ordering where every node comes
352/// after the labels it depends on (its outgoing-fact targets). Returns
353/// the unresolved cycle labels as `Err` if no full ordering exists.
354fn topo_sort(
355    nodes: &HashSet<View<str>>,
356    edges: &HashMap<View<str>, HashSet<View<str>>>,
357) -> Result<Vec<View<str>>, Vec<View<str>>> {
358    let mut in_degree: HashMap<View<str>, usize> =
359        nodes.iter().map(|n| (n.clone(), 0)).collect();
360    for dsts in edges.values() {
361        for dst in dsts {
362            *in_degree.entry(dst.clone()).or_insert(0) += 1;
363        }
364    }
365    let mut queue: VecDeque<View<str>> = in_degree
366        .iter()
367        .filter(|(_, d)| **d == 0)
368        .map(|(n, _)| n.clone())
369        .collect();
370    let mut order: Vec<View<str>> = Vec::with_capacity(nodes.len());
371    while let Some(n) = queue.pop_front() {
372        if let Some(dsts) = edges.get(&n) {
373            for dst in dsts {
374                let d = in_degree.get_mut(dst).expect("dst recorded above");
375                *d -= 1;
376                if *d == 0 {
377                    queue.push_back(dst.clone());
378                }
379            }
380        }
381        order.push(n);
382    }
383    if order.len() < nodes.len() {
384        let cycle: Vec<View<str>> = nodes
385            .iter()
386            .filter(|n| in_degree.get(*n).copied().unwrap_or(0) > 0)
387            .cloned()
388            .collect();
389        Err(cycle)
390    } else {
391        Ok(order)
392    }
393}
394
395// ── Parsing — Bytes/winnow ──────────────────────────────────────────
396//
397// Each parser step takes `&mut Bytes` and returns a `View<str>` (or a
398// `Bytes` for literal lexical forms whose fast/slow path differ). View
399// slices Arc-share the input buffer, so storing them in `BnodeBuffer`
400// across line-equivalent boundaries costs nothing — no copy or alloc.
401// Mirrors `json::parse_string_common`'s shape.
402
403/// What follows a closing `"` on an N-Triples literal:
404/// `^^<datatype>`, `@language`, or nothing.
405enum LiteralSuffix {
406    None,
407    Datatype(View<str>),
408    Language(View<str>),
409}
410
411fn skip_ws_and_comments(bytes: &mut Bytes) {
412    loop {
413        // Eat whitespace bytes. N-Triples grammar permits HT/LF/CR/SP.
414        while matches!(bytes.peek_token(), Some(b) if matches!(b, b' ' | b'\t' | b'\n' | b'\r')) {
415            bytes.pop_front();
416        }
417        // Eat `# ... \n` comments.
418        if bytes.peek_token() == Some(b'#') {
419            while let Some(b) = bytes.pop_front() {
420                if b == b'\n' {
421                    break;
422                }
423            }
424            continue;
425        }
426        break;
427    }
428}
429
430fn skip_inline_ws(bytes: &mut Bytes) {
431    while matches!(bytes.peek_token(), Some(b' ') | Some(b'\t')) {
432        bytes.pop_front();
433    }
434}
435
436/// Take an `<iri>` and return its content as a `View<str>`.
437///
438/// Fast path: scan for `>` with no `\` along the way → return the
439/// slice directly, zero copy. Slow path (any `\u`/`\U` UCHAR escape):
440/// decode into a fresh `Bytes`-backed buffer.
441///
442/// Per the N-Triples grammar, an IRIREF is `'<' (([^#x00-#x20<>"{}|^`\]
443/// | UCHAR))* '>'` — bytes in `0x00..=0x20`, `<`, `>`, `"`, `{`, `}`,
444/// `|`, `^`, `\`` and lone `\` are all rejected literal-form.
445fn take_iri(bytes: &mut Bytes) -> Option<View<str>> {
446    if bytes.peek_token() != Some(b'<') {
447        return None;
448    }
449    bytes.pop_front();
450
451    // Fast path: scan unescaped chars, rejecting any forbidden byte.
452    {
453        let mut tentative = bytes.clone();
454        let mut take = take_while::<_, _, InputError<Bytes>>(0.., |b: u8| {
455            // Accept: anything that isn't terminator (`>`), escape
456            // (`\\`), or one of the spec-forbidden literal chars.
457            b > 0x20 && !matches!(b, b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' | b'\\')
458        });
459        if let Ok(prefix) = take.parse_next(&mut tentative) {
460            if tentative.peek_token() == Some(b'>') {
461                tentative.pop_front();
462                *bytes = tentative;
463                return prefix.view::<str>().ok();
464            }
465        }
466    }
467
468    // Slow path: handle `\uXXXX` / `\UXXXXXXXX` escapes. We arrive
469    // here only if the fast path bailed — either an escape was seen
470    // or a forbidden byte was hit.
471    let mut out: Vec<u8> = Vec::new();
472    while let Some(b) = bytes.peek_token() {
473        match b {
474            b'>' => {
475                bytes.pop_front();
476                return Bytes::from_source(out).view::<str>().ok();
477            }
478            b'\\' => {
479                bytes.pop_front();
480                let kind = bytes.pop_front()?;
481                let decoded = match kind {
482                    b'u' => parse_uchar(bytes, 4)?,
483                    b'U' => parse_uchar(bytes, 8)?,
484                    _ => return None, // IRIs allow only UCHAR escapes
485                };
486                out.extend_from_slice(&decoded);
487            }
488            // Forbidden literal bytes — reject.
489            0..=0x20 | b'<' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => return None,
490            _ => {
491                out.push(b);
492                bytes.pop_front();
493            }
494        }
495    }
496    None
497}
498
499/// Take a `_:label` blank-node label as a `View<str>`. Per BLANK_NODE_LABEL,
500/// labels can contain dots in the middle — we terminate purely on
501/// whitespace, since the triple-ending `.` is always preceded by it.
502fn take_bnode(bytes: &mut Bytes) -> Option<View<str>> {
503    if bytes.peek_token() != Some(b'_') {
504        return None;
505    }
506    let mut tentative = bytes.clone();
507    let mut prefix = take::<_, _, InputError<Bytes>>(2usize);
508    let head = prefix.parse_next(&mut tentative).ok()?;
509    if head.as_ref() != b"_:" {
510        return None;
511    }
512    let mut take_label = take_while::<_, _, InputError<Bytes>>(1.., |b: u8| {
513        !matches!(b, b' ' | b'\t' | b'\n' | b'\r')
514    });
515    let label = take_label.parse_next(&mut tentative).ok()?;
516    *bytes = tentative;
517
518    // Re-include the `_:` prefix so callers see the literal label form.
519    // We rebuild from `out` rather than try to reconstruct a contiguous
520    // slice — `View<str>` allocation cost is one Vec, comparable to a
521    // `String::from`. (Future optimisation: have anybytes expose a
522    // contiguous-slice constructor that re-merges adjacent Bytes.)
523    let mut combined = Vec::with_capacity(2 + label.len());
524    combined.extend_from_slice(b"_:");
525    combined.extend_from_slice(label.as_ref());
526    Bytes::from_source(combined).view::<str>().ok()
527}
528
529/// Take a `"..."` literal plus optional `^^<datatype>` / `@language`
530/// suffix. Returns the lexical form as `Bytes` (use `.view::<str>()`)
531/// — zero-copy on the no-escape fast path, freshly-allocated only when
532/// escapes forced decoding.
533fn take_literal(bytes: &mut Bytes) -> Option<(Bytes, LiteralSuffix)> {
534    if bytes.peek_token() != Some(b'"') {
535        return None;
536    }
537    bytes.pop_front();
538
539    // Fast path: scan for the closing quote without any `\`.
540    {
541        let mut tentative = bytes.clone();
542        let mut take = take_while::<_, _, InputError<Bytes>>(0.., |b: u8| {
543            b != b'"' && b != b'\\' && b != b'\n' && b != b'\r'
544        });
545        if let Ok(prefix) = take.parse_next(&mut tentative) {
546            if tentative.peek_token() == Some(b'"') {
547                tentative.pop_front();
548                *bytes = tentative;
549                let suffix = parse_literal_suffix(bytes)?;
550                return Some((prefix, suffix));
551            }
552        }
553    }
554
555    // Slow path: full ECHAR + UCHAR decoding.
556    let mut out: Vec<u8> = Vec::new();
557    loop {
558        let b = bytes.peek_token()?;
559        match b {
560            b'"' => {
561                bytes.pop_front();
562                let suffix = parse_literal_suffix(bytes)?;
563                return Some((Bytes::from_source(out), suffix));
564            }
565            b'\\' => {
566                bytes.pop_front();
567                let kind = bytes.pop_front()?;
568                match kind {
569                    b'n' => out.push(b'\n'),
570                    b't' => out.push(b'\t'),
571                    b'r' => out.push(b'\r'),
572                    b'b' => out.push(0x08),
573                    b'f' => out.push(0x0c),
574                    b'"' => out.push(b'"'),
575                    b'\'' => out.push(b'\''),
576                    b'\\' => out.push(b'\\'),
577                    b'u' => {
578                        let decoded = parse_uchar(bytes, 4)?;
579                        out.extend_from_slice(&decoded);
580                    }
581                    b'U' => {
582                        let decoded = parse_uchar(bytes, 8)?;
583                        out.extend_from_slice(&decoded);
584                    }
585                    _ => return None,
586                }
587            }
588            b'\n' | b'\r' => return None,
589            _ => {
590                out.push(b);
591                bytes.pop_front();
592            }
593        }
594    }
595}
596
597/// Decode `\uXXXX` (4 hex digits) or `\UXXXXXXXX` (8) into UTF-8 bytes.
598/// Caller has already consumed the leading `\u` / `\U`.
599fn parse_uchar(bytes: &mut Bytes, hex_digits: usize) -> Option<Vec<u8>> {
600    let mut grab = take::<_, _, InputError<Bytes>>(hex_digits);
601    let hex = grab.parse_next(bytes).ok()?;
602    let mut code: u32 = 0;
603    for h in hex.as_ref() {
604        code = (code << 4)
605            | match h {
606                b'0'..=b'9' => (h - b'0') as u32,
607                b'a'..=b'f' => (h - b'a' + 10) as u32,
608                b'A'..=b'F' => (h - b'A' + 10) as u32,
609                _ => return None,
610            };
611    }
612    let ch = char::from_u32(code)?;
613    let mut buf = [0u8; 4];
614    Some(ch.encode_utf8(&mut buf).as_bytes().to_vec())
615}
616
617/// Match the optional `^^<datatype>` / `@language` suffix.
618fn parse_literal_suffix(bytes: &mut Bytes) -> Option<LiteralSuffix> {
619    match bytes.peek_token() {
620        Some(b'^') => {
621            // expect `^^`
622            bytes.pop_front();
623            if bytes.pop_front() != Some(b'^') {
624                return None;
625            }
626            let dt = take_iri(bytes)?;
627            Some(LiteralSuffix::Datatype(dt))
628        }
629        Some(b'@') => {
630            bytes.pop_front();
631            let mut take = take_while::<_, _, InputError<Bytes>>(1.., |b: u8| {
632                b.is_ascii_alphanumeric() || b == b'-'
633            });
634            let tag = take.parse_next(bytes).ok()?;
635            tag.view::<str>().ok().map(LiteralSuffix::Language)
636        }
637        _ => Some(LiteralSuffix::None),
638    }
639}
640
641/// Parse a decimal string into a `Ratio<i128>`.
642/// Handles `"3.14"` → `314/100`, `"42"` → `42/1`, `"-0.5"` → `-1/2`.
643fn parse_decimal(s: &str) -> Option<Ratio<i128>> {
644    if let Some(dot_pos) = s.find('.') {
645        let decimals = s.len() - dot_pos - 1;
646        let without_dot: String = s.chars().filter(|c| *c != '.').collect();
647        let numerator: i128 = without_dot.parse().ok()?;
648        let denominator: i128 = 10i128.checked_pow(decimals as u32)?;
649        Some(Ratio::new(numerator, denominator))
650    } else {
651        let n: i128 = s.parse().ok()?;
652        Some(Ratio::from_integer(n))
653    }
654}
655
656// ── XSD temporal parsers ────────────────────────────────────────────
657//
658// xsd:dateTime / xsd:date / xsd:gYear* lexical forms are deliberately
659// strict subsets of ISO 8601. We parse the components ourselves and
660// hand them to hifitime's `from_gregorian_utc`, so leap-second handling
661// and pre-Gregorian dates fall out of hifitime's correctness — we just
662// have to be permissive about timezone notation (`Z`, `+HH:MM`,
663// missing — RDF data uses all three).
664
665/// Eat `[-]YYYY` from the front of `s`, returning the signed year and
666/// the remainder. (Year-range overflow handled by hifitime's checked
667/// constructor in `epoch_from_gregorian_with_offset`.)
668fn parse_year(mut s: &str) -> Option<(i32, &str)> {
669    let neg = if let Some(rest) = s.strip_prefix('-') {
670        s = rest;
671        true
672    } else {
673        false
674    };
675    let digits_end = s
676        .as_bytes()
677        .iter()
678        .position(|b| !b.is_ascii_digit())
679        .unwrap_or(s.len());
680    if digits_end < 4 {
681        return None;
682    }
683    let year_abs: i64 = s[..digits_end].parse().ok()?;
684    let year: i32 = if neg {
685        i32::try_from(-year_abs).ok()?
686    } else {
687        i32::try_from(year_abs).ok()?
688    };
689    Some((year, &s[digits_end..]))
690}
691
692/// Strip an `xsd` timezone suffix (`Z` or `±HH:MM`) and return the
693/// offset in seconds. Missing timezone → 0 (UTC convention for RDF).
694fn parse_timezone_offset(s: &str) -> Option<i64> {
695    if s.is_empty() {
696        return Some(0);
697    }
698    if s == "Z" {
699        return Some(0);
700    }
701    let bytes = s.as_bytes();
702    let sign = match bytes.first()? {
703        b'+' => 1i64,
704        b'-' => -1i64,
705        _ => return None,
706    };
707    if bytes.len() != 6 || bytes[3] != b':' {
708        return None;
709    }
710    let hh: i64 = std::str::from_utf8(&bytes[1..3]).ok()?.parse().ok()?;
711    let mm: i64 = std::str::from_utf8(&bytes[4..6]).ok()?.parse().ok()?;
712    Some(sign * (hh * 3600 + mm * 60))
713}
714
715/// Build an [`Epoch`] (UTC) from Gregorian fields and a timezone offset
716/// in seconds. The offset is *subtracted* — `12:00 +05:00` is `07:00 UTC`.
717fn epoch_from_gregorian_with_offset(
718    year: i32,
719    month: u8,
720    day: u8,
721    hh: u8,
722    mm: u8,
723    ss: u8,
724    ns: u32,
725    offset_secs: i64,
726) -> Option<Epoch> {
727    // hifitime panics on overflow when out of its representable range
728    // (Wikidata has dateTime values like year 1e9 that hifitime can't
729    // hold). Use the checked variant so we fall through to text
730    // storage instead of crashing the importer.
731    let local = Epoch::maybe_from_gregorian_utc(year, month, day, hh, mm, ss, ns).ok()?;
732    Some(local - Duration::from_seconds(offset_secs as f64))
733}
734
735/// xsd:dateTime — `[-]YYYY-MM-DDThh:mm:ss[.f][Z|±HH:MM]`.
736fn parse_xsd_datetime(s: &str) -> Option<i128> {
737    let (year, rest) = parse_year(s)?;
738    let mut chars = rest.as_bytes();
739    if chars.first() != Some(&b'-') {
740        return None;
741    }
742    let month: u8 = std::str::from_utf8(chars.get(1..3)?).ok()?.parse().ok()?;
743    if chars.get(3) != Some(&b'-') {
744        return None;
745    }
746    let day: u8 = std::str::from_utf8(chars.get(4..6)?).ok()?.parse().ok()?;
747    if chars.get(6) != Some(&b'T') {
748        return None;
749    }
750    let hh: u8 = std::str::from_utf8(chars.get(7..9)?).ok()?.parse().ok()?;
751    if chars.get(9) != Some(&b':') {
752        return None;
753    }
754    let mm: u8 = std::str::from_utf8(chars.get(10..12)?).ok()?.parse().ok()?;
755    if chars.get(12) != Some(&b':') {
756        return None;
757    }
758    let ss: u8 = std::str::from_utf8(chars.get(13..15)?).ok()?.parse().ok()?;
759    chars = &chars[15..];
760
761    let mut ns: u32 = 0;
762    if chars.first() == Some(&b'.') {
763        chars = &chars[1..];
764        let frac_end = chars
765            .iter()
766            .position(|b| !b.is_ascii_digit())
767            .unwrap_or(chars.len());
768        // Pad / truncate to 9 digits (nanosecond resolution).
769        let frac_str = std::str::from_utf8(&chars[..frac_end]).ok()?;
770        let mut padded = String::with_capacity(9);
771        padded.push_str(frac_str);
772        while padded.len() < 9 {
773            padded.push('0');
774        }
775        ns = padded[..9].parse().ok()?;
776        chars = &chars[frac_end..];
777    }
778
779    let tz = std::str::from_utf8(chars).ok()?;
780    let offset = parse_timezone_offset(tz)?;
781    let epoch = epoch_from_gregorian_with_offset(year, month, day, hh, mm, ss, ns, offset)?;
782    Some(epoch.to_tai_duration().total_nanoseconds())
783}
784
785/// xsd:date — `[-]YYYY-MM-DD[Z|±HH:MM]`. Returned as inclusive bounds
786/// `[day_start, day_end]`.
787fn parse_xsd_date(s: &str) -> Option<(i128, i128)> {
788    let (year, rest) = parse_year(s)?;
789    let bytes = rest.as_bytes();
790    if bytes.first() != Some(&b'-') {
791        return None;
792    }
793    let month: u8 = std::str::from_utf8(bytes.get(1..3)?).ok()?.parse().ok()?;
794    if bytes.get(3) != Some(&b'-') {
795        return None;
796    }
797    let day: u8 = std::str::from_utf8(bytes.get(4..6)?).ok()?.parse().ok()?;
798    let tz = std::str::from_utf8(&bytes[6..]).ok()?;
799    let offset = parse_timezone_offset(tz)?;
800    let lower = epoch_from_gregorian_with_offset(year, month, day, 0, 0, 0, 0, offset)?
801        .to_tai_duration()
802        .total_nanoseconds();
803    // Day end: lower + 86_400 s - 1 ns. (Inclusive upper.)
804    let upper = lower.checked_add(86_400_000_000_000i128 - 1)?;
805    Some((lower, upper))
806}
807
808/// xsd:gYear — `[-]YYYY[Z|±HH:MM]`. Returned as the whole year as an
809/// inclusive interval.
810fn parse_xsd_gyear(s: &str) -> Option<(i128, i128)> {
811    let (year, rest) = parse_year(s)?;
812    let offset = parse_timezone_offset(rest)?;
813    let lower = epoch_from_gregorian_with_offset(year, 1, 1, 0, 0, 0, 0, offset)?
814        .to_tai_duration()
815        .total_nanoseconds();
816    let next_year = year.checked_add(1)?;
817    let upper_excl = epoch_from_gregorian_with_offset(next_year, 1, 1, 0, 0, 0, 0, offset)?
818        .to_tai_duration()
819        .total_nanoseconds();
820    Some((lower, upper_excl.checked_sub(1)?))
821}
822
823/// xsd:gYearMonth — `[-]YYYY-MM[Z|±HH:MM]`. Whole month, inclusive.
824fn parse_xsd_gyearmonth(s: &str) -> Option<(i128, i128)> {
825    let (year, rest) = parse_year(s)?;
826    let bytes = rest.as_bytes();
827    if bytes.first() != Some(&b'-') {
828        return None;
829    }
830    let month: u8 = std::str::from_utf8(bytes.get(1..3)?).ok()?.parse().ok()?;
831    if !(1..=12).contains(&month) {
832        return None;
833    }
834    let tz = std::str::from_utf8(&bytes[3..]).ok()?;
835    let offset = parse_timezone_offset(tz)?;
836    let lower = epoch_from_gregorian_with_offset(year, month, 1, 0, 0, 0, 0, offset)?
837        .to_tai_duration()
838        .total_nanoseconds();
839    let (next_year, next_month) = if month == 12 {
840        (year.checked_add(1)?, 1u8)
841    } else {
842        (year, month + 1)
843    };
844    let upper_excl = epoch_from_gregorian_with_offset(next_year, next_month, 1, 0, 0, 0, 0, offset)?
845        .to_tai_duration()
846        .total_nanoseconds();
847    Some((lower, upper_excl.checked_sub(1)?))
848}
849
850/// xsd:duration — `[-]P[nY][nM][nD][T[nH][nM][nS]]`. We reject mixed
851/// year/month durations (their second-count depends on context); pure
852/// dayTime durations (`PnDTnHnMnS`) convert to a single ns count.
853fn parse_xsd_duration(s: &str) -> Option<i128> {
854    let mut s = s;
855    let neg = if let Some(rest) = s.strip_prefix('-') {
856        s = rest;
857        true
858    } else {
859        false
860    };
861    let mut s = s.strip_prefix('P')?;
862    let mut total_ns: i128 = 0;
863
864    let mut in_time = false;
865    while !s.is_empty() {
866        if let Some(rest) = s.strip_prefix('T') {
867            in_time = true;
868            s = rest;
869            continue;
870        }
871        let num_end = s
872            .as_bytes()
873            .iter()
874            .position(|b| !(b.is_ascii_digit() || *b == b'.'))?;
875        let num_str = &s[..num_end];
876        let unit = s.as_bytes().get(num_end).copied()?;
877        s = &s[num_end + 1..];
878        let value: f64 = num_str.parse().ok()?;
879        match (in_time, unit) {
880            (false, b'Y') | (false, b'M') => {
881                // Years and months can't be expressed in fixed ns —
882                // their second count depends on which year/month.
883                return None;
884            }
885            (false, b'D') => total_ns = total_ns.checked_add((value * 86_400e9) as i128)?,
886            (true, b'H') => total_ns = total_ns.checked_add((value * 3_600e9) as i128)?,
887            (true, b'M') => total_ns = total_ns.checked_add((value * 60e9) as i128)?,
888            (true, b'S') => total_ns = total_ns.checked_add((value * 1e9) as i128)?,
889            _ => return None,
890        }
891    }
892    Some(if neg { -total_ns } else { total_ns })
893}
894
895// ── URI → Id ────────────────────────────────────────────────────────
896
897/// Map an RDF URI to a triblespace [`Id`] deterministically by routing it
898/// through an `rdf_uri` fragment. The same URI always produces the same
899/// `Id` — across processes, machines, and repeated imports — so callers
900/// outside this module can use this to derive ids for query constants
901/// that match what [`ingest_ntriples`] inserts.
902pub fn uri_to_id<Blobs>(ws: &mut Workspace<Blobs>, uri: &str) -> Id
903where
904    Blobs: BlobStore,
905{
906    let handle: Inline<Handle<LongString>> = ws.put(uri.to_owned());
907    let fragment = entity! { crate::import::rdf_uri: handle };
908    fragment.root().expect("intrinsic URI entity")
909}
910
911/// Same id as [`uri_to_id`] but without the workspace side effect.
912///
913/// `uri_to_id` does two things: derive the entity id, and record the
914/// URI string as a blob in the workspace so the inverse mapping is
915/// recoverable. Callers writing query constants only need the former
916/// — they're matching against ids that some prior `ingest_ntriples`
917/// already emitted. This pure variant is for them.
918pub fn uri_to_id_pure(uri: &str) -> Id {
919    let handle: Inline<Handle<LongString>> =
920        uri.to_owned().to_blob().get_handle();
921    let fragment = entity! { crate::import::rdf_uri: handle };
922    fragment.root().expect("intrinsic URI entity")
923}
924
925// ── Ingestion ───────────────────────────────────────────────────────
926
927/// Import an N-Triples document already loaded as `Bytes`. This is the
928/// core entry point — every other adapter funnels here. Mirrors
929/// [`crate::import::json::JsonObjectImporter::import_blob`]'s shape.
930pub fn import_bytes<Blobs>(
931    ws: &mut Workspace<Blobs>,
932    mut bytes: Bytes,
933) -> Result<(TribleSet, usize), IngestError>
934where
935    Blobs: BlobStore,
936{
937    let mut facts = TribleSet::new();
938    let mut bnodes = BnodeBuffer::new();
939    let mut count = 0;
940    let mut attr_cache = NTriplesAttrCache::default();
941
942    loop {
943        skip_ws_and_comments(&mut bytes);
944        if bytes.peek_token().is_none() {
945            break;
946        }
947        if parse_triple(ws, &mut facts, &mut bnodes, &mut bytes, &mut attr_cache) {
948            count += 1;
949        } else {
950            // Malformed triple — skip to next newline so a single bad
951            // line doesn't abort the import. Mirrors the line-skip
952            // tolerance the BufRead version had.
953            while let Some(b) = bytes.pop_front() {
954                if b == b'\n' {
955                    break;
956                }
957            }
958        }
959    }
960
961    bnodes.flush(&mut facts)?;
962    Ok((facts, count))
963}
964
965/// Convenience wrapper around [`import_bytes`] for a `Blob<LongString>`
966/// — the on-disk / on-wire representation N-Triples shows up as.
967pub fn import_blob<Blobs>(
968    ws: &mut Workspace<Blobs>,
969    blob: Blob<LongString>,
970) -> Result<(TribleSet, usize), IngestError>
971where
972    Blobs: BlobStore,
973{
974    import_bytes(ws, blob.bytes)
975}
976
977/// `BufRead` adapter — slurps the reader into a `Bytes` and forwards
978/// to [`import_bytes`].
979pub fn ingest_ntriples<Blobs>(
980    ws: &mut Workspace<Blobs>,
981    mut reader: impl BufRead,
982) -> Result<(TribleSet, usize), IngestError>
983where
984    Blobs: BlobStore,
985{
986    let mut buf = Vec::new();
987    reader
988        .read_to_end(&mut buf)
989        .map_err(|e| IngestError::Io(e.to_string()))?;
990    import_bytes(ws, Bytes::from_source(buf))
991}
992
993/// Parse one triple from the front of `bytes` and emit its facts.
994/// Plain triples emit directly into `facts`; triples touching a blank
995/// node go into `bnodes` for deferred resolution. Returns `true` on
996/// success, `false` on malformed input (caller skips to next line).
997/// Per-import cache of predicate-IRI → attribute-id, one slot per value
998/// schema the parser dispatches to. Each cache method computes
999/// `Attribute::<S>::from(entity!{ metadata::iri:, metadata::value_encoding: }).id()`,
1000/// which runs `<S as MetaDescribe>::id()` and an `entity!{}.root()` per
1001/// call — both nontrivial — so caching by (S, IRI) avoids redoing that
1002/// work for every trible sharing a predicate.
1003#[derive(Default)]
1004struct NTriplesAttrCache {
1005    genid: HashMap<String, Id>,
1006    longstring: HashMap<String, Id>,
1007    rawbytes: HashMap<String, Id>,
1008    i256be: HashMap<String, Id>,
1009    u256be: HashMap<String, Id>,
1010    r256be: HashMap<String, Id>,
1011    f64: HashMap<String, Id>,
1012    boolean: HashMap<String, Id>,
1013    nsduration: HashMap<String, Id>,
1014    nstai: HashMap<String, Id>,
1015}
1016
1017impl NTriplesAttrCache {
1018    fn genid(&mut self, iri: &str) -> Id {
1019        *self.genid.entry(iri.to_string()).or_insert_with(|| {
1020            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1021                String::from(iri).to_blob().get_handle();
1022            Attribute::<inlineencodings::GenId>::from(entity! {
1023                crate::metadata::iri:          h,
1024                crate::metadata::value_encoding: <inlineencodings::GenId as crate::metadata::MetaDescribe>::id(),
1025            })
1026            .id()
1027        })
1028    }
1029    fn longstring(&mut self, iri: &str) -> Id {
1030        *self.longstring.entry(iri.to_string()).or_insert_with(|| {
1031            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1032                String::from(iri).to_blob().get_handle();
1033            Attribute::<Handle<LongString>>::from(entity! {
1034                crate::metadata::iri:          h,
1035                crate::metadata::value_encoding: <Handle<LongString> as crate::metadata::MetaDescribe>::id(),
1036            })
1037            .id()
1038        })
1039    }
1040    fn rawbytes(&mut self, iri: &str) -> Id {
1041        *self.rawbytes.entry(iri.to_string()).or_insert_with(|| {
1042            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1043                String::from(iri).to_blob().get_handle();
1044            Attribute::<Handle<RawBytes>>::from(entity! {
1045                crate::metadata::iri:          h,
1046                crate::metadata::value_encoding: <Handle<RawBytes> as crate::metadata::MetaDescribe>::id(),
1047            })
1048            .id()
1049        })
1050    }
1051    fn i256be(&mut self, iri: &str) -> Id {
1052        *self.i256be.entry(iri.to_string()).or_insert_with(|| {
1053            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1054                String::from(iri).to_blob().get_handle();
1055            Attribute::<inlineencodings::I256BE>::from(entity! {
1056                crate::metadata::iri:          h,
1057                crate::metadata::value_encoding: <inlineencodings::I256BE as crate::metadata::MetaDescribe>::id(),
1058            })
1059            .id()
1060        })
1061    }
1062    fn u256be(&mut self, iri: &str) -> Id {
1063        *self.u256be.entry(iri.to_string()).or_insert_with(|| {
1064            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1065                String::from(iri).to_blob().get_handle();
1066            Attribute::<inlineencodings::U256BE>::from(entity! {
1067                crate::metadata::iri:          h,
1068                crate::metadata::value_encoding: <inlineencodings::U256BE as crate::metadata::MetaDescribe>::id(),
1069            })
1070            .id()
1071        })
1072    }
1073    fn r256be(&mut self, iri: &str) -> Id {
1074        *self.r256be.entry(iri.to_string()).or_insert_with(|| {
1075            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1076                String::from(iri).to_blob().get_handle();
1077            Attribute::<inlineencodings::R256BE>::from(entity! {
1078                crate::metadata::iri:          h,
1079                crate::metadata::value_encoding: <inlineencodings::R256BE as crate::metadata::MetaDescribe>::id(),
1080            })
1081            .id()
1082        })
1083    }
1084    fn f64(&mut self, iri: &str) -> Id {
1085        *self.f64.entry(iri.to_string()).or_insert_with(|| {
1086            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1087                String::from(iri).to_blob().get_handle();
1088            Attribute::<inlineencodings::F64>::from(entity! {
1089                crate::metadata::iri:          h,
1090                crate::metadata::value_encoding: <inlineencodings::F64 as crate::metadata::MetaDescribe>::id(),
1091            })
1092            .id()
1093        })
1094    }
1095    fn boolean(&mut self, iri: &str) -> Id {
1096        *self.boolean.entry(iri.to_string()).or_insert_with(|| {
1097            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1098                String::from(iri).to_blob().get_handle();
1099            Attribute::<inlineencodings::Boolean>::from(entity! {
1100                crate::metadata::iri:          h,
1101                crate::metadata::value_encoding: <inlineencodings::Boolean as crate::metadata::MetaDescribe>::id(),
1102            })
1103            .id()
1104        })
1105    }
1106    fn nsduration(&mut self, iri: &str) -> Id {
1107        *self.nsduration.entry(iri.to_string()).or_insert_with(|| {
1108            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1109                String::from(iri).to_blob().get_handle();
1110            Attribute::<NsDuration>::from(entity! {
1111                crate::metadata::iri:          h,
1112                crate::metadata::value_encoding: <NsDuration as crate::metadata::MetaDescribe>::id(),
1113            })
1114            .id()
1115        })
1116    }
1117    fn nstai(&mut self, iri: &str) -> Id {
1118        *self.nstai.entry(iri.to_string()).or_insert_with(|| {
1119            let h: Inline<Handle<crate::blob::encodings::longstring::LongString>> =
1120                String::from(iri).to_blob().get_handle();
1121            Attribute::<NsTAIInterval>::from(entity! {
1122                crate::metadata::iri:          h,
1123                crate::metadata::value_encoding: <NsTAIInterval as crate::metadata::MetaDescribe>::id(),
1124            })
1125            .id()
1126        })
1127    }
1128}
1129
1130fn parse_triple<Blobs>(
1131    ws: &mut Workspace<Blobs>,
1132    facts: &mut TribleSet,
1133    bnodes: &mut BnodeBuffer,
1134    bytes: &mut Bytes,
1135    attr_cache: &mut NTriplesAttrCache,
1136) -> bool
1137where
1138    Blobs: BlobStore,
1139{
1140    // Subject — IRI or bnode label.
1141    let (subject_iri, subject_label): (Option<View<str>>, Option<View<str>>) =
1142        match bytes.peek_token() {
1143            Some(b'<') => match take_iri(bytes) {
1144                Some(uri) => (Some(uri), None),
1145                None => return false,
1146            },
1147            Some(b'_') => match take_bnode(bytes) {
1148                Some(label) => (None, Some(label)),
1149                None => return false,
1150            },
1151            _ => return false,
1152        };
1153    skip_inline_ws(bytes);
1154
1155    let Some(predicate) = take_iri(bytes) else {
1156        return false;
1157    };
1158    skip_inline_ws(bytes);
1159
1160    // Anchor the IRI subject up front so its rdf_uri annotation lands
1161    // before any emission. Bnode subjects resolve in `flush`.
1162    let iri_subject_anchor: Option<Id> = subject_iri.as_ref().map(|uri| {
1163        let s = uri.as_ref();
1164        let id = uri_to_id(ws, s);
1165        let sub_h: Inline<Handle<LongString>> = ws.put(uri.clone());
1166        *facts += entity! { crate::import::rdf_uri: sub_h };
1167        id
1168    });
1169
1170    // Object — IRI, bnode, or literal.
1171    let outcome = match bytes.peek_token() {
1172        Some(b'<') => {
1173            let Some(obj_uri) = take_iri(bytes) else {
1174                return false;
1175            };
1176            emit_object_iri(
1177                ws,
1178                facts,
1179                bnodes,
1180                iri_subject_anchor,
1181                subject_label,
1182                predicate.as_ref(),
1183                obj_uri,
1184                attr_cache,
1185            );
1186            true
1187        }
1188        Some(b'_') => {
1189            let Some(target_label) = take_bnode(bytes) else {
1190                return false;
1191            };
1192            let attr_id = attr_cache.genid(predicate.as_ref());
1193            match (iri_subject_anchor, subject_label) {
1194                (Some(s_id), None) => {
1195                    bnodes.push_incoming(IncomingFact {
1196                        subject_id: s_id,
1197                        attr_id,
1198                        target_label,
1199                    });
1200                }
1201                (None, Some(s_label)) => {
1202                    bnodes.push_outgoing(
1203                        s_label,
1204                        OutgoingFact::BnodeRef {
1205                            attr_id,
1206                            target_label,
1207                        },
1208                    );
1209                }
1210                _ => return false,
1211            }
1212            true
1213        }
1214        Some(b'"') => {
1215            let Some((text_bytes, suffix)) = take_literal(bytes) else {
1216                return false;
1217            };
1218            let Ok(text) = text_bytes.view::<str>() else {
1219                return false;
1220            };
1221            match (iri_subject_anchor, subject_label) {
1222                (Some(s_id), None) => {
1223                    let e = ExclusiveId::force_ref(&s_id);
1224                    match suffix {
1225                        LiteralSuffix::None => {
1226                            emit_text_literal(ws, facts, e, predicate.as_ref(), text, attr_cache)
1227                        }
1228                        LiteralSuffix::Datatype(dt) => emit_typed_literal(
1229                            ws,
1230                            facts,
1231                            e,
1232                            predicate.as_ref(),
1233                            text,
1234                            dt.as_ref(),
1235                            attr_cache,
1236                        ),
1237                        LiteralSuffix::Language(lang) => emit_lang_literal(
1238                            ws,
1239                            facts,
1240                            e,
1241                            predicate.as_ref(),
1242                            lang.as_ref(),
1243                            text,
1244                            attr_cache,
1245                        ),
1246                    }
1247                }
1248                (None, Some(s_label)) => {
1249                    if let Some(fact) = build_resolved_outgoing(
1250                        ws,
1251                        facts,
1252                        predicate.as_ref(),
1253                        text,
1254                        suffix,
1255                        attr_cache,
1256                    ) {
1257                        bnodes.push_outgoing(s_label, fact);
1258                    }
1259                }
1260                _ => return false,
1261            }
1262            true
1263        }
1264        _ => false,
1265    };
1266
1267    if outcome {
1268        skip_inline_ws(bytes);
1269        // The trailing `.` terminator. Tolerant: missing-dot gets the
1270        // line skipped by the outer loop.
1271        if bytes.peek_token() != Some(b'.') {
1272            return false;
1273        }
1274        bytes.pop_front();
1275    }
1276    outcome
1277}
1278
1279fn emit_object_iri<Blobs>(
1280    ws: &mut Workspace<Blobs>,
1281    facts: &mut TribleSet,
1282    bnodes: &mut BnodeBuffer,
1283    iri_subject_anchor: Option<Id>,
1284    subject_label: Option<View<str>>,
1285    predicate: &str,
1286    obj_uri: View<str>,
1287    attr_cache: &mut NTriplesAttrCache,
1288) where
1289    Blobs: BlobStore,
1290{
1291    match (iri_subject_anchor, subject_label) {
1292        (Some(s_id), None) => {
1293            emit_uri_object(
1294                ws,
1295                facts,
1296                &ExclusiveId::force_ref(&s_id),
1297                predicate,
1298                obj_uri.as_ref(),
1299                attr_cache,
1300            );
1301        }
1302        (None, Some(s_label)) => {
1303            let attr_id = attr_cache.genid(predicate);
1304            let obj_id = uri_to_id(ws, obj_uri.as_ref());
1305            let obj_h: Inline<Handle<LongString>> = ws.put(obj_uri);
1306            *facts += entity! { crate::import::rdf_uri: obj_h };
1307            let g: Inline<GenId> = obj_id.to_inline();
1308            bnodes.push_outgoing(
1309                s_label,
1310                OutgoingFact::Resolved {
1311                    attr_id,
1312                    value_raw: g.raw,
1313                },
1314            );
1315        }
1316        _ => {}
1317    }
1318}
1319
1320/// Materialise a literal-valued bnode-outgoing fact: blob writes /
1321/// reified-language entities happen now, and we hand back the
1322/// (attr_id, value_raw) pair to be inserted once the bnode subject id
1323/// is resolved.
1324fn build_resolved_outgoing<Blobs>(
1325    ws: &mut Workspace<Blobs>,
1326    facts: &mut TribleSet,
1327    predicate: &str,
1328    text: View<str>,
1329    suffix: LiteralSuffix,
1330    attr_cache: &mut NTriplesAttrCache,
1331) -> Option<OutgoingFact>
1332where
1333    Blobs: BlobStore,
1334{
1335    match suffix {
1336        LiteralSuffix::None => {
1337            let attr_id = attr_cache.longstring(predicate);
1338            let handle: Inline<Handle<LongString>> = ws.put(text);
1339            Some(OutgoingFact::Resolved {
1340                attr_id,
1341                value_raw: handle.raw,
1342            })
1343        }
1344        LiteralSuffix::Datatype(dt) => {
1345            // Build a temporary scratch trible to reuse the existing
1346            // emit_typed_literal logic, then steal the (attr, value)
1347            // back out of it. Cheaper than re-implementing per-type.
1348            let mut scratch = TribleSet::new();
1349            let scratch_id = Id::new([0xFF; ID_LEN]).expect("non-nil scratch id");
1350            let scratch_e = ExclusiveId::force_ref(&scratch_id);
1351            emit_typed_literal(
1352                ws,
1353                &mut scratch,
1354                scratch_e,
1355                predicate,
1356                text,
1357                dt.as_ref(),
1358                attr_cache,
1359            );
1360            let pair = scratch
1361                .iter()
1362                .next()
1363                .map(|t| (*t.a(), t.v::<UnknownInline>().raw));
1364            pair.map(|(attr_id, value_raw)| OutgoingFact::Resolved { attr_id, value_raw })
1365        }
1366        LiteralSuffix::Language(lang) => {
1367            // Reify into the @lang entity now; the parent bnode's
1368            // outgoing fact carries a GenId reference to it. Side
1369            // effects (the lang-entity tribles) land in `facts`
1370            // immediately since they don't depend on the parent id.
1371            let Ok(lang_value): Result<Inline<ShortString>, _> = lang.as_ref().try_to_inline() else {
1372                return None;
1373            };
1374            let text_handle: Inline<Handle<LongString>> = ws.put(text);
1375            let label_fragment = entity! {
1376                crate::import::rdf_lang: lang_value,
1377                crate::import::rdf_text: text_handle,
1378            };
1379            let label_id = label_fragment
1380                .root()
1381                .expect("intrinsic id from rdf_lang+rdf_text");
1382            *facts += label_fragment;
1383            let attr_id = attr_cache.genid(predicate);
1384            let g: Inline<GenId> = label_id.to_inline();
1385            Some(OutgoingFact::Resolved {
1386                attr_id,
1387                value_raw: g.raw,
1388            })
1389        }
1390    }
1391}
1392
1393fn emit_uri_object<Blobs>(
1394    ws: &mut Workspace<Blobs>,
1395    facts: &mut TribleSet,
1396    e: &ExclusiveId,
1397    predicate: &str,
1398    obj_uri: &str,
1399    attr_cache: &mut NTriplesAttrCache,
1400) where
1401    Blobs: BlobStore,
1402{
1403    let attr_id = attr_cache.genid(predicate);
1404    let obj_id = uri_to_id(ws, obj_uri);
1405    let obj_h: Inline<Handle<LongString>> = ws.put(obj_uri.to_owned());
1406    *facts += entity! { crate::import::rdf_uri: obj_h };
1407    let g: Inline<GenId> = obj_id.to_inline();
1408    facts.insert(&Trible::new(e, &attr_id, &g));
1409}
1410
1411fn emit_text_literal<Blobs>(
1412    ws: &mut Workspace<Blobs>,
1413    facts: &mut TribleSet,
1414    e: &ExclusiveId,
1415    predicate: &str,
1416    text: View<str>,
1417    attr_cache: &mut NTriplesAttrCache,
1418) where
1419    Blobs: BlobStore,
1420{
1421    let attr_id = attr_cache.longstring(predicate);
1422    let handle: Inline<Handle<LongString>> = ws.put(text);
1423    facts.insert(&Trible::new(e, &attr_id, &handle));
1424}
1425
1426fn emit_typed_literal<Blobs>(
1427    ws: &mut Workspace<Blobs>,
1428    facts: &mut TribleSet,
1429    e: &ExclusiveId,
1430    predicate: &str,
1431    text: View<str>,
1432    datatype: &str,
1433    attr_cache: &mut NTriplesAttrCache,
1434) where
1435    Blobs: BlobStore,
1436{
1437    if let Some(local) = datatype.strip_prefix(XSD) {
1438        match local {
1439            "integer" | "int" | "long" | "short" | "byte" | "negativeInteger"
1440            | "nonPositiveInteger" => {
1441                if let Ok(val) = text.parse::<i128>() {
1442                    let attr_id = attr_cache.i256be(predicate);
1443                    let v: Inline<inlineencodings::I256BE> = val.to_inline();
1444                    facts.insert(&Trible::new(e, &attr_id, &v));
1445                    return;
1446                }
1447            }
1448            "nonNegativeInteger" | "positiveInteger" | "unsignedInt" | "unsignedLong"
1449            | "unsignedShort" | "unsignedByte" => {
1450                if let Ok(val) = text.parse::<u128>() {
1451                    let attr_id = attr_cache.u256be(predicate);
1452                    let v: Inline<inlineencodings::U256BE> = val.to_inline();
1453                    facts.insert(&Trible::new(e, &attr_id, &v));
1454                    return;
1455                }
1456            }
1457            "decimal" => {
1458                if let Some(val) = parse_decimal(text.as_ref()) {
1459                    let attr_id = attr_cache.r256be(predicate);
1460                    let v: Inline<inlineencodings::R256BE> = val.to_inline();
1461                    facts.insert(&Trible::new(e, &attr_id, &v));
1462                    return;
1463                }
1464            }
1465            "float" | "double" => {
1466                if let Ok(val) = text.parse::<f64>() {
1467                    let attr_id = attr_cache.f64(predicate);
1468                    let v: Inline<F64> = val.to_inline();
1469                    facts.insert(&Trible::new(e, &attr_id, &v));
1470                    return;
1471                }
1472            }
1473            "boolean" => match text.as_ref() {
1474                "true" | "1" => {
1475                    let attr_id = attr_cache.boolean(predicate);
1476                    let v: Inline<Boolean> = true.to_inline();
1477                    facts.insert(&Trible::new(e, &attr_id, &v));
1478                    return;
1479                }
1480                "false" | "0" => {
1481                    let attr_id = attr_cache.boolean(predicate);
1482                    let v: Inline<Boolean> = false.to_inline();
1483                    facts.insert(&Trible::new(e, &attr_id, &v));
1484                    return;
1485                }
1486                _ => {}
1487            },
1488            "dateTime" => {
1489                if let Some(ns) = parse_xsd_datetime(text.as_ref()) {
1490                    emit_interval(facts, e, predicate, ns, ns, attr_cache);
1491                    return;
1492                }
1493            }
1494            "date" => {
1495                if let Some((lo, hi)) = parse_xsd_date(text.as_ref()) {
1496                    emit_interval(facts, e, predicate, lo, hi, attr_cache);
1497                    return;
1498                }
1499            }
1500            "gYear" => {
1501                if let Some((lo, hi)) = parse_xsd_gyear(text.as_ref()) {
1502                    emit_interval(facts, e, predicate, lo, hi, attr_cache);
1503                    return;
1504                }
1505            }
1506            "gYearMonth" => {
1507                if let Some((lo, hi)) = parse_xsd_gyearmonth(text.as_ref()) {
1508                    emit_interval(facts, e, predicate, lo, hi, attr_cache);
1509                    return;
1510                }
1511            }
1512            "duration" | "dayTimeDuration" => {
1513                if let Some(ns) = parse_xsd_duration(text.as_ref()) {
1514                    let attr_id = attr_cache.nsduration(predicate);
1515                    let v: Inline<NsDuration> = ns.to_inline();
1516                    facts.insert(&Trible::new(e, &attr_id, &v));
1517                    return;
1518                }
1519            }
1520            "hexBinary" => {
1521                if let Ok(bytes) = hex::decode(text.as_ref()) {
1522                    let attr_id = attr_cache.rawbytes(predicate);
1523                    let handle: Inline<Handle<RawBytes>> = ws.put(bytes);
1524                    facts.insert(&Trible::new(e, &attr_id, &handle));
1525                    return;
1526                }
1527            }
1528            "base64Binary" => {
1529                if let Ok(bytes) = BASE64.decode(text.as_ref()) {
1530                    let attr_id = attr_cache.rawbytes(predicate);
1531                    let handle: Inline<Handle<RawBytes>> = ws.put(bytes);
1532                    facts.insert(&Trible::new(e, &attr_id, &handle));
1533                    return;
1534                }
1535            }
1536            "anyURI" => {
1537                // Treat the literal as an IRI reference — same path as
1538                // bracketed `<...>` objects, so `"http://x"^^xsd:anyURI`
1539                // and `<http://x>` collapse to the same entity id.
1540                emit_uri_object(ws, facts, e, predicate, text.as_ref(), attr_cache);
1541                return;
1542            }
1543            _ => {}
1544        }
1545    }
1546    // Unknown / unparseable typed literal: fall back to text storage.
1547    emit_text_literal(ws, facts, e, predicate, text, attr_cache);
1548}
1549
1550/// Helper to emit an `[lo, hi]` interval trible.
1551fn emit_interval(
1552    facts: &mut TribleSet,
1553    e: &ExclusiveId,
1554    predicate: &str,
1555    lo: i128,
1556    hi: i128,
1557    attr_cache: &mut NTriplesAttrCache,
1558) {
1559    let attr_id = attr_cache.nstai(predicate);
1560    let mut raw = [0u8; 32];
1561    raw[0..16].copy_from_slice(&i128_to_ordered_be(lo));
1562    raw[16..32].copy_from_slice(&i128_to_ordered_be(hi));
1563    let v: Inline<NsTAIInterval> = Inline::new(raw);
1564    facts.insert(&Trible::new(e, &attr_id, &v));
1565}
1566
1567fn emit_lang_literal<Blobs>(
1568    ws: &mut Workspace<Blobs>,
1569    facts: &mut TribleSet,
1570    e: &ExclusiveId,
1571    predicate: &str,
1572    lang: &str,
1573    text: View<str>,
1574    attr_cache: &mut NTriplesAttrCache,
1575) where
1576    Blobs: BlobStore,
1577{
1578    // Reify `"text"@lang` into a small entity carrying `rdf_lang` and
1579    // `rdf_text`. The intrinsic id derived from those facts dedupes
1580    // `(lang, text)` pairs across the whole import.
1581    let Ok(lang_value): Result<Inline<ShortString>, _> = lang.try_to_inline() else {
1582        return; // tag too long; BCP-47 caps subtags at 8 chars
1583    };
1584    let text_handle: Inline<Handle<LongString>> = ws.put(text);
1585    let label_fragment = entity! {
1586        crate::import::rdf_lang: lang_value,
1587        crate::import::rdf_text: text_handle,
1588    };
1589    let label_id = label_fragment
1590        .root()
1591        .expect("intrinsic id from rdf_lang+rdf_text");
1592    *facts += label_fragment;
1593    let attr_id = attr_cache.genid(predicate);
1594    let v: Inline<GenId> = label_id.to_inline();
1595    facts.insert(&Trible::new(e, &attr_id, &v));
1596}
1597
1598/// Convenience wrapper around [`import_bytes`] that opens a file at
1599/// `path`, slurps it into a `Bytes`, and ingests.
1600pub fn ingest_ntriples_file<Blobs>(
1601    ws: &mut Workspace<Blobs>,
1602    path: &Path,
1603) -> Result<(TribleSet, usize), IngestError>
1604where
1605    Blobs: BlobStore,
1606{
1607    let file = std::fs::File::open(path).map_err(|e| IngestError::Io(e.to_string()))?;
1608    let mut reader = std::io::BufReader::new(file);
1609    let mut buf = Vec::new();
1610    reader
1611        .read_to_end(&mut buf)
1612        .map_err(|e| IngestError::Io(e.to_string()))?;
1613    import_bytes(ws, Bytes::from_source(buf))
1614}
1615
1616// ── Tests ───────────────────────────────────────────────────────────
1617
1618#[cfg(test)]
1619mod tests {
1620    use super::*;
1621
1622    fn bytes_of(s: &str) -> Bytes {
1623        Bytes::from_source(s.as_bytes().to_vec())
1624    }
1625
1626    #[test]
1627    fn take_iri_consumes_brackets() {
1628        let mut input = bytes_of("<http://example.org/s> rest");
1629        let iri = take_iri(&mut input).unwrap();
1630        assert_eq!(iri.as_ref(), "http://example.org/s");
1631        // Remaining bytes should start with " rest".
1632        let remaining: Vec<u8> = (0..)
1633            .scan(input.clone(), |b, _| b.pop_front())
1634            .collect();
1635        assert_eq!(&remaining[..5], b" rest");
1636    }
1637
1638    #[test]
1639    fn take_bnode_includes_prefix() {
1640        let mut input = bytes_of("_:bf55954f96378f65ddb1da9836e2eb87 .");
1641        let label = take_bnode(&mut input).unwrap();
1642        assert_eq!(label.as_ref(), "_:bf55954f96378f65ddb1da9836e2eb87");
1643    }
1644
1645    #[test]
1646    fn take_bnode_allows_internal_dot() {
1647        // BLANK_NODE_LABEL grammar permits dots in the middle of labels.
1648        // The trailing triple-`.` is always preceded by whitespace, so
1649        // whitespace-only termination handles both.
1650        let mut input = bytes_of("_:foo.bar .");
1651        let label = take_bnode(&mut input).unwrap();
1652        assert_eq!(label.as_ref(), "_:foo.bar");
1653    }
1654
1655    #[test]
1656    fn take_literal_unescaped() {
1657        let mut input = bytes_of(r#""hello" ."#);
1658        let (text, suffix) = take_literal(&mut input).unwrap();
1659        assert_eq!(text.view::<str>().unwrap().as_ref(), "hello");
1660        assert!(matches!(suffix, LiteralSuffix::None));
1661    }
1662
1663    #[test]
1664    fn take_literal_with_datatype_suffix() {
1665        let mut input = bytes_of(r#""42"^^<http://www.w3.org/2001/XMLSchema#integer> ."#);
1666        let (text, suffix) = take_literal(&mut input).unwrap();
1667        assert_eq!(text.view::<str>().unwrap().as_ref(), "42");
1668        assert!(matches!(
1669            suffix,
1670            LiteralSuffix::Datatype(ref dt)
1671                if dt.as_ref() == "http://www.w3.org/2001/XMLSchema#integer"
1672        ));
1673    }
1674
1675    #[test]
1676    fn take_literal_with_lang_tag() {
1677        let mut input = bytes_of(r#""hello"@en ."#);
1678        let (text, suffix) = take_literal(&mut input).unwrap();
1679        assert_eq!(text.view::<str>().unwrap().as_ref(), "hello");
1680        assert!(matches!(
1681            suffix,
1682            LiteralSuffix::Language(ref tag) if tag.as_ref() == "en"
1683        ));
1684    }
1685
1686    #[test]
1687    fn take_literal_with_lang_region() {
1688        let mut input = bytes_of(r#""labor"@en-US ."#);
1689        let (text, suffix) = take_literal(&mut input).unwrap();
1690        assert_eq!(text.view::<str>().unwrap().as_ref(), "labor");
1691        assert!(matches!(
1692            suffix,
1693            LiteralSuffix::Language(ref tag) if tag.as_ref() == "en-US"
1694        ));
1695    }
1696
1697    #[test]
1698    fn take_literal_with_basic_escapes() {
1699        let mut input = bytes_of(r#""line\nbreak" ."#);
1700        let (text, _) = take_literal(&mut input).unwrap();
1701        assert_eq!(text.view::<str>().unwrap().as_ref(), "line\nbreak");
1702    }
1703
1704    #[test]
1705    fn take_literal_with_extended_echar() {
1706        // \b, \f, \' are valid N-Triples ECHAR but were previously unsupported.
1707        let mut input = bytes_of(r#""a\bb\fc\'d" ."#);
1708        let (text, _) = take_literal(&mut input).unwrap();
1709        assert_eq!(
1710            text.view::<str>().unwrap().as_ref(),
1711            "a\u{0008}b\u{000c}c'd"
1712        );
1713    }
1714
1715    #[test]
1716    fn take_literal_with_unicode_escape_4() {
1717        let mut input = bytes_of(r#""smile ☺ here" ."#);
1718        let (text, _) = take_literal(&mut input).unwrap();
1719        assert_eq!(text.view::<str>().unwrap().as_ref(), "smile ☺ here");
1720    }
1721
1722    #[test]
1723    fn take_literal_with_unicode_escape_8() {
1724        // \U with 8 hex digits — N-Triples-only (JSON has no \U).
1725        let mut input = bytes_of(r#""grin \U0001F600 here" ."#);
1726        let (text, _) = take_literal(&mut input).unwrap();
1727        assert_eq!(text.view::<str>().unwrap().as_ref(), "grin 😀 here");
1728    }
1729
1730    #[test]
1731    fn take_iri_with_unicode_escape() {
1732        // IRIs may carry \u escapes for non-ASCII path components.
1733        let mut input = bytes_of(r#"<http://ex/é> rest"#);
1734        let iri = take_iri(&mut input).unwrap();
1735        assert_eq!(iri.as_ref(), "http://ex/é");
1736    }
1737
1738    #[test]
1739    fn decimal_parse_helper() {
1740        let r = parse_decimal("3.14").unwrap();
1741        assert_eq!(*r.numer(), 157);
1742        assert_eq!(*r.denom(), 50);
1743
1744        let r = parse_decimal("42").unwrap();
1745        assert_eq!(*r.numer(), 42);
1746        assert_eq!(*r.denom(), 1);
1747
1748        let r = parse_decimal("-0.5").unwrap();
1749        assert_eq!(*r.numer(), -1);
1750        assert_eq!(*r.denom(), 2);
1751    }
1752
1753    #[test]
1754    fn xsd_datetime_z_and_offset() {
1755        // The two strings should parse to the same instant.
1756        let utc = parse_xsd_datetime("2020-01-01T12:00:00Z").unwrap();
1757        let plus5 = parse_xsd_datetime("2020-01-01T17:00:00+05:00").unwrap();
1758        assert_eq!(utc, plus5);
1759    }
1760
1761    #[test]
1762    fn xsd_datetime_with_fractional_seconds() {
1763        let no_frac = parse_xsd_datetime("2020-01-01T00:00:00Z").unwrap();
1764        let with_frac = parse_xsd_datetime("2020-01-01T00:00:00.5Z").unwrap();
1765        assert_eq!(with_frac - no_frac, 500_000_000);
1766    }
1767
1768    #[test]
1769    fn xsd_datetime_bce_year() {
1770        // Negative year → year before 1 CE in proleptic Gregorian.
1771        // Just check it parses (round-trip semantics is hifitime's problem).
1772        assert!(parse_xsd_datetime("-0500-01-01T00:00:00Z").is_some());
1773    }
1774
1775    #[test]
1776    fn xsd_date_spans_one_day() {
1777        let (lo, hi) = parse_xsd_date("2020-01-01").unwrap();
1778        // 86400 seconds in nanoseconds, minus 1 for inclusive upper.
1779        assert_eq!(hi - lo, 86_400_000_000_000 - 1);
1780    }
1781
1782    #[test]
1783    fn xsd_gyear_spans_full_year() {
1784        let (lo_2020, hi_2020) = parse_xsd_gyear("2020").unwrap();
1785        let (lo_2021, _) = parse_xsd_gyear("2021").unwrap();
1786        // 2020 was a leap year — 366 days.
1787        assert_eq!(hi_2020 - lo_2020, 366 * 86_400_000_000_000 - 1);
1788        // 2020 immediately precedes 2021.
1789        assert_eq!(hi_2020 + 1, lo_2021);
1790    }
1791
1792    #[test]
1793    fn xsd_gyearmonth_spans_one_month() {
1794        let (lo_jan, hi_jan) = parse_xsd_gyearmonth("2020-01").unwrap();
1795        // January has 31 days.
1796        assert_eq!(hi_jan - lo_jan, 31 * 86_400_000_000_000 - 1);
1797
1798        let (_, hi_feb) = parse_xsd_gyearmonth("2020-02").unwrap();
1799        let (lo_mar, _) = parse_xsd_gyearmonth("2020-03").unwrap();
1800        assert_eq!(hi_feb + 1, lo_mar);
1801    }
1802
1803    #[test]
1804    fn xsd_duration_daytime_only() {
1805        // P1DT2H3M4.5S = 1 day + 2h + 3m + 4.5s
1806        let ns = parse_xsd_duration("P1DT2H3M4.5S").unwrap();
1807        let expected = 86_400_000_000_000i128
1808            + 2 * 3_600_000_000_000
1809            + 3 * 60_000_000_000
1810            + 4_500_000_000;
1811        assert_eq!(ns, expected);
1812    }
1813
1814    #[test]
1815    fn xsd_duration_negative() {
1816        let ns = parse_xsd_duration("-PT5S").unwrap();
1817        assert_eq!(ns, -5_000_000_000);
1818    }
1819
1820    #[test]
1821    fn xsd_duration_rejects_year_month() {
1822        // Year/month durations don't have a fixed ns count.
1823        assert!(parse_xsd_duration("P1Y").is_none());
1824        assert!(parse_xsd_duration("P1M").is_none());
1825        assert!(parse_xsd_duration("P1Y2M").is_none());
1826    }
1827}