Skip to main content

rete_core/
terms.rs

1//! Term identifiers and N-Triples term-token helpers.
2//!
3//! Two distinct things live in one place here because they describe the same
4//! domain — "what is a term, and how is it identified":
5//!
6//! 1. **ID aliases.** Every term in a `.rete` file is interned into the
7//!    dictionary and addressed by a `u32`. Code passes these `u32`s through
8//!    many signatures where the bare type says nothing about *which* id space a
9//!    value lives in. The aliases below ([`NodeId`], [`SubjectId`],
10//!    [`PredicateId`], [`ObjectId`]) are documentation: they are all `u32`
11//!    today (so they cost nothing and mix freely), but they let a signature
12//!    state its intent — `subject_node(sid: SubjectId) -> NodeId` reads as the
13//!    role-id → unified-node mapping it is. A later pass can promote them to
14//!    true newtypes (`struct NodeId(u32)`) without touching call sites that
15//!    already name the alias.
16//!
17//! 2. **Term-token helpers.** A [`TermToken`] is the textual form of a term as
18//!    it appears in N-Triples and in the dictionary: an IRI `<http://…>`, a
19//!    blank node `_:b0`, or a literal `"text"`, `"text"@en`, `"text"^^<dt>`.
20//!    Several modules (SPARQL evaluation, SHACL validation, doc rendering)
21//!    independently grew the same little parsers for "is this an IRI", "what's
22//!    the lexical value", "what's the datatype". They are consolidated here so
23//!    there is one definition of the term grammar to reason about.
24
25use std::borrow::Cow;
26
27/// A dictionary id in the **unified node space** — the single id space that
28/// covers every term that ever appears as a subject or an object. This is the
29/// id reachability, the community pyramid, and the graph index work in.
30pub type NodeId = u32;
31
32/// A dictionary id in the **subject** id space (terms seen in subject
33/// position). Map to a [`NodeId`] with [`Dictionary::subject_node`].
34///
35/// [`Dictionary::subject_node`]: crate::dictionary::Dictionary::subject_node
36pub type SubjectId = u32;
37
38/// A dictionary id in the **predicate** id space. Predicates have their own
39/// dense id space and are never part of the unified node space.
40pub type PredicateId = u32;
41
42/// A dictionary id in the **object** id space (terms seen in object position).
43/// Map to a [`NodeId`] with [`Dictionary::object_node`].
44///
45/// [`Dictionary::object_node`]: crate::dictionary::Dictionary::object_node
46pub type ObjectId = u32;
47
48/// The textual form of an RDF term as stored in the dictionary and emitted in
49/// N-Triples: an IRI (`<…>`), a blank node (`_:…`), or a literal (`"…"`,
50/// optionally with an `@lang` or `^^<datatype>` suffix). An alias for `str`;
51/// it names intent at API boundaries that take a term rather than arbitrary
52/// text.
53pub type TermToken = str;
54
55const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
56const RDF_LANG_STRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString";
57/// RDF 1.2 base-direction language string: `"…"@lang--dir` (dir = `ltr`/`rtl`).
58const RDF_DIR_LANG_STRING: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString";
59
60/// Is `t` an IRI term (`<…>`)?
61#[inline]
62pub fn is_iri(t: &TermToken) -> bool {
63    // A quoted triple (`<< … >>`, RDF-star) also starts with `<` and ends with
64    // `>`, so exclude it explicitly — it is its own term kind, not an IRI.
65    t.starts_with('<') && !t.starts_with("<<") && t.ends_with('>')
66}
67
68/// Is `t` a **quoted triple** term (`<< s p o >>`, RDF-star)? These appear only
69/// in subject/object position and are stored in the dictionary as their
70/// canonical N-Triples-star surface, exactly like any other term.
71#[inline]
72pub fn is_quoted_triple(t: &TermToken) -> bool {
73    t.starts_with("<<") && t.ends_with(">>")
74}
75
76/// The content of an IRI term without its angle brackets (`<http://x>` →
77/// `http://x`), or `None` if `t` is not an IRI term.
78#[inline]
79pub fn iri_content(t: &TermToken) -> Option<&str> {
80    if is_quoted_triple(t) {
81        return None;
82    }
83    t.strip_prefix('<').and_then(|s| s.strip_suffix('>'))
84}
85
86/// Is `t` a blank-node term (`_:…`)?
87#[inline]
88pub fn is_blank(t: &TermToken) -> bool {
89    t.starts_with("_:")
90}
91
92/// Is `t` a literal term (`"…"`)?
93#[inline]
94pub fn is_literal(t: &TermToken) -> bool {
95    t.starts_with('"')
96}
97
98/// Index of the closing quote of a literal term, scanning from the opening
99/// quote and honoring `\"` escapes. `t` must start with `"`.
100fn closing_quote(t: &TermToken) -> usize {
101    let bytes = t.as_bytes();
102    let mut i = 1;
103    while i < bytes.len() {
104        match bytes[i] {
105            b'\\' => i += 2,
106            b'"' => break,
107            _ => i += 1,
108        }
109    }
110    i.min(t.len())
111}
112
113/// The **lexical value** of a literal term — the text between the quotes with
114/// N-Triples escapes resolved — or `None` for IRIs and blank nodes. The
115/// datatype and language suffix are dropped (`"42"^^<…int>` → `42`,
116/// `"hi"@en` → `hi`).
117pub fn literal_lexical(token: &TermToken) -> Option<String> {
118    if !is_literal(token) {
119        return None;
120    }
121    Some(unescape_literal(&token[1..closing_quote(token)]))
122}
123
124/// The **lexical value** of any term: a literal's unescaped body, an IRI's
125/// content, or a blank-node token unchanged. Always succeeds. Useful where a
126/// plain comparable string is wanted regardless of term kind.
127pub fn lexical(token: &TermToken) -> Cow<'_, str> {
128    if is_literal(token) {
129        Cow::Owned(unescape_literal(&token[1..closing_quote(token)]))
130    } else if let Some(iri) = iri_content(token) {
131        Cow::Borrowed(iri)
132    } else {
133        Cow::Borrowed(token)
134    }
135}
136
137/// A literal term split into its **still-escaped** body and its suffix
138/// (`"a\"b"^^<dt>` → `(a\"b, ^^<dt>)`), or `None` if `token` is not a literal.
139/// Borrowing and allocation-free: callers that need the value unescape the body
140/// only when it contains a backslash.
141pub(crate) fn literal_parts(token: &TermToken) -> Option<(&str, &str)> {
142    if !is_literal(token) {
143        return None;
144    }
145    let close = closing_quote(token);
146    Some((
147        token.get(1..close).unwrap_or(""),
148        token.get(close + 1..).unwrap_or(""),
149    ))
150}
151
152/// The part of a literal term after its closing quote (`"x"^^<dt>` → `^^<dt>`,
153/// `"x"@en` → `@en`, `"x"` → ``), or `None` if `token` is not a literal.
154fn literal_suffix(token: &TermToken) -> Option<&str> {
155    if !is_literal(token) {
156        return None;
157    }
158    token.get(closing_quote(token) + 1..)
159}
160
161/// The datatype IRI **content** of a literal term (no angle brackets): the
162/// explicit `^^<dt>`, else `rdf:langString` for a language-tagged literal,
163/// else `xsd:string` for a plain one. `None` for a non-literal or a malformed
164/// suffix.
165pub fn literal_datatype(token: &TermToken) -> Option<String> {
166    let suffix = literal_suffix(token)?;
167    if let Some(dt) = suffix.strip_prefix("^^<").and_then(|s| s.strip_suffix('>')) {
168        Some(dt.to_string())
169    } else if suffix.starts_with('@') {
170        // RDF 1.2: a language string WITH a base direction (`@lang--dir`) is an
171        // `rdf:dirLangString`; a plain `@lang` is `rdf:langString`. `--` never
172        // occurs in a well-formed BCP-47 tag, so this is unambiguous.
173        if suffix.contains("--") {
174            Some(RDF_DIR_LANG_STRING.to_string())
175        } else {
176            Some(RDF_LANG_STRING.to_string())
177        }
178    } else if suffix.is_empty() {
179        Some(XSD_STRING.to_string())
180    } else {
181        None
182    }
183}
184
185/// The language tag of a literal term (`"hi"@en` → `en`), `""` when the literal
186/// is untagged, or `None` for a non-literal. For an RDF 1.2 directional string
187/// (`"x"@ar--rtl`) this is the LANGUAGE only (`ar`) — the base direction is
188/// separate (see [`lang_dir`]), matching SPARQL 1.2 `LANG`.
189pub fn lang_tag(token: &TermToken) -> Option<String> {
190    literal_suffix(token).map(|s| {
191        s.strip_prefix('@')
192            .unwrap_or("")
193            .split("--")
194            .next()
195            .unwrap_or("")
196            .to_string()
197    })
198}
199
200/// The base direction of an RDF 1.2 directional language string (`"x"@ar--rtl` →
201/// `rtl`), or `None` if the literal has no direction (or is not a literal).
202pub fn lang_dir(token: &TermToken) -> Option<String> {
203    let tag = literal_suffix(token)?.strip_prefix('@')?;
204    tag.split("--").nth(1).map(str::to_string)
205}
206
207/// Numeric value of a term: the lexical part of a literal parsed as `f64`
208/// (`"30"^^<…int>` → `30.0`) or a bare numeric token, else `None`.
209///
210/// **Lenient, and not what SPARQL compares with.** Any literal whose text
211/// parses counts — a plain string `"30"`, or `"1e3"^^xsd:integer` — which is
212/// right for a quick numeric read of a value but wrong for the SPARQL
213/// operators: there a string is never a number (`"01" = "1"` is false), only
214/// a literal of a numeric datatype with a valid lexical form is. The engine's
215/// `=`, `<`, `IN`, ORDER BY, MIN/MAX, SUM/AVG and arithmetic use the
216/// datatype-aware value space in `sparql::value` instead.
217///
218/// The literal's lexical value is taken through the escape-aware
219/// [`literal_lexical`] — closing quote located correctly, body unescaped, any
220/// `^^<datatype>` suffix dropped — rather than scanning to the *first* `"`. A
221/// value containing an embedded escaped quote (`"1\"2"`) is therefore read
222/// whole and simply fails to parse, instead of being truncated at the escaped
223/// quote. A language-tagged literal (`"5"@en`) is never a numeric literal in
224/// SPARQL/RDF, so it yields `None`.
225pub fn as_number(token: &TermToken) -> Option<f64> {
226    if is_literal(token) {
227        if lang_tag(token).is_some_and(|tag| !tag.is_empty()) {
228            return None;
229        }
230        literal_lexical(token)?.parse::<f64>().ok()
231    } else {
232        token.parse::<f64>().ok()
233    }
234}
235
236/// Escape a string for use as the body of an N-Triples literal (`"…"`): the
237/// inverse of [`unescape_literal`] for the characters that must be escaped
238/// (`\`, `"`, newline, carriage return, tab). The common case (no special
239/// characters) returns the input untouched.
240pub fn escape_literal(s: &str) -> String {
241    if !s.contains(['\\', '"', '\n', '\r', '\t']) {
242        return s.to_string();
243    }
244    let mut out = String::with_capacity(s.len() + 2);
245    for c in s.chars() {
246        match c {
247            '\\' => out.push_str("\\\\"),
248            '"' => out.push_str("\\\""),
249            '\n' => out.push_str("\\n"),
250            '\r' => out.push_str("\\r"),
251            '\t' => out.push_str("\\t"),
252            _ => out.push(c),
253        }
254    }
255    out
256}
257
258/// The datatype suffix of an `xsd:string`-typed literal token, closing quote
259/// included.
260const XSD_STRING_SUFFIX: &str = "\"^^<http://www.w3.org/2001/XMLSchema#string>";
261
262/// The **canonical** token of a literal: in RDF 1.1, `"x"^^xsd:string` and
263/// `"x"` are one term, and rete spells it `"x"` — at ingest, in query
264/// constants, in computed values and in results. Any other token comes back
265/// unchanged (borrowed).
266pub fn canonical_literal(token: &TermToken) -> Cow<'_, str> {
267    if is_literal(token) && token.ends_with(XSD_STRING_SUFFIX) {
268        Cow::Owned(token[..token.len() - XSD_STRING_SUFFIX.len() + 1].to_string())
269    } else {
270        Cow::Borrowed(token)
271    }
272}
273
274/// [`canonical_literal`] for an owned token, in place (no allocation).
275pub fn into_canonical_literal(mut token: String) -> String {
276    if is_literal(&token) && token.ends_with(XSD_STRING_SUFFIX) {
277        token.truncate(token.len() - XSD_STRING_SUFFIX.len() + 1);
278    }
279    token
280}
281
282/// The other spelling of a string literal: `"x"` ↔ `"x"^^xsd:string`. `None`
283/// for any other term. A file built before rete canonicalized the typed form
284/// at ingest can hold either spelling; a lookup that misses one tries the
285/// other, so a plain constant still finds a typed string stored by an older
286/// build.
287pub(crate) fn string_literal_alias(token: &TermToken) -> Option<String> {
288    if !is_literal(token) {
289        return None;
290    }
291    if token.ends_with(XSD_STRING_SUFFIX) {
292        return Some(token[..token.len() - XSD_STRING_SUFFIX.len() + 1].to_string());
293    }
294    // A plain literal: the closing quote is the last byte.
295    if token.len() >= 2 && closing_quote(token) == token.len() - 1 {
296        let mut typed = String::with_capacity(token.len() + XSD_STRING_SUFFIX.len());
297        typed.push_str(&token[..token.len() - 1]);
298        typed.push_str(XSD_STRING_SUFFIX);
299        return Some(typed);
300    }
301    None
302}
303/// Build a literal term token from a (raw, unescaped) lexical value, attaching
304/// an optional non-empty language tag (`@lang`) or datatype IRI content
305/// (`^^<dt>`). `lang` wins over `datatype` if both are given (a tagged literal
306/// is implicitly `rdf:langString`). An `xsd:string` datatype gives the plain
307/// literal, its canonical spelling (see [`canonical_literal`]).
308pub fn make_literal(lexical: &str, lang: Option<&str>, datatype: Option<&str>) -> String {
309    let body = escape_literal(lexical);
310    match (lang.filter(|l| !l.is_empty()), datatype) {
311        (Some(l), _) => format!("\"{body}\"@{l}"),
312        (None, Some(XSD_STRING)) => format!("\"{body}\""),
313        (None, Some(dt)) => format!("\"{body}\"^^<{dt}>"),
314        (None, None) => format!("\"{body}\""),
315    }
316}
317
318/// Resolve the N-Triples escape sequences in a literal's body to actual chars
319/// (`\n`, `\t`, `\"`, `\\`, `\uXXXX`, `\UXXXXXXXX`, …). Strings without a
320/// backslash — the overwhelming majority — are returned untouched.
321pub fn unescape_literal(s: &str) -> String {
322    if !s.contains('\\') {
323        return s.to_string();
324    }
325    let mut out = String::with_capacity(s.len());
326    let mut chars = s.chars();
327    while let Some(c) = chars.next() {
328        if c != '\\' {
329            out.push(c);
330            continue;
331        }
332        let unicode = |chars: &mut std::str::Chars, n: usize, out: &mut String| {
333            let hex: String = chars.take(n).collect();
334            match u32::from_str_radix(&hex, 16).ok().and_then(char::from_u32) {
335                Some(ch) => out.push(ch),
336                None => out.push('\u{FFFD}'),
337            }
338        };
339        match chars.next() {
340            Some('t') => out.push('\t'),
341            Some('b') => out.push('\u{08}'),
342            Some('n') => out.push('\n'),
343            Some('r') => out.push('\r'),
344            Some('f') => out.push('\u{0C}'),
345            Some('"') => out.push('"'),
346            Some('\'') => out.push('\''),
347            Some('\\') => out.push('\\'),
348            Some('u') => unicode(&mut chars, 4, &mut out),
349            Some('U') => unicode(&mut chars, 8, &mut out),
350            Some(other) => {
351                out.push('\\');
352                out.push(other);
353            }
354            None => out.push('\\'),
355        }
356    }
357    out
358}
359
360/// Rewrite a term token into the **RDF 1.2 triple-term surface** for a text
361/// serializer.
362///
363/// rete stores a quoted triple in one canonical token, the RDF-star surface
364/// `<<s p o>>` — see `ingest::take_term`, which accepts both surfaces
365/// on ingest and canonicalises them to that one. Current RDF 1.2 parsers
366/// (oxttl 0.2 and anything built on it, including the `oxigraph` CLI) do not
367/// read that surface: in N-Triples/N-Quads they **reject** it outright, and in
368/// Turtle/TriG they read `<< s p o >>` as a *reifier* — one statement silently
369/// becomes two, with a blank node where the triple term was. So a dump in the
370/// stored surface is not interoperable, quietly in one format and loudly in the
371/// other. This is the translation that makes it so, at write time only: nothing
372/// about the file changes.
373///
374/// Returns:
375///
376/// * `Some(Borrowed(token))` when `token` is not a quoted triple. This is the
377///   overwhelming majority of terms and the only cost is a two-byte prefix
378///   check, so a dump with no quoted triples in it is byte-for-byte unchanged.
379/// * `Some(Owned(…))` with the token rewritten to `<<( s p o )>>`, recursively:
380///   a triple term nested in the object slot is rewritten too.
381/// * `None` when the token has **no RDF 1.2 spelling at all**. RDF 1.2 puts a
382///   triple term in *object position only* — the grammar is
383///   `tripleTerm ::= '<<(' ttSubject predicate ttObject ')>>'` with
384///   `ttSubject ::= iri | BlankNode` — so a quoted triple standing in the
385///   subject slot of another quoted triple cannot be written. (The caller is
386///   responsible for the same rule at statement level: a quoted triple in the
387///   *statement's* subject or predicate slot is equally unwritable, and the
388///   caller is the one that knows which slot a term came from.)
389///
390/// A token that is not well-formed — `<<` … `>>` that does not parse as three
391/// terms — also yields `None` rather than a mangled rewrite.
392pub fn rdf12_triple_term(token: &TermToken) -> Option<Cow<'_, TermToken>> {
393    if !is_quoted_triple(token) {
394        return Some(Cow::Borrowed(token));
395    }
396    rewrite_rdf12(token).map(Cow::Owned)
397}
398
399/// The owned half of [`rdf12_triple_term`], split out so the recursion does not
400/// re-run the `is_quoted_triple` fast path on a token it already classified.
401fn rewrite_rdf12(token: &TermToken) -> Option<String> {
402    let (s, p, o) = crate::ingest::quoted_triple_parts(token)?;
403    // `ttSubject ::= iri | BlankNode` and `predicate ::= iri`: neither slot
404    // admits a triple term, at any depth.
405    if is_quoted_triple(&s) || is_quoted_triple(&p) {
406        return None;
407    }
408    // `ttObject` does admit one, which is where nesting lives.
409    let o = if is_quoted_triple(&o) {
410        rewrite_rdf12(&o)?
411    } else {
412        o
413    };
414    Some(format!("<<( {s} {p} {o} )>>"))
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420
421    // --- the RDF 1.2 writer surface ----------------------------------------
422
423    #[test]
424    fn a_plain_term_is_borrowed_unchanged() {
425        // The hot path. Every term that is not a quoted triple comes back
426        // borrowed, which is what makes a dump of a quoted-triple-free file
427        // byte-for-byte what it was.
428        for t in [
429            "<http://example.org/x>",
430            "_:b0",
431            "\"lit\"@en",
432            "\"5\"^^<http://www.w3.org/2001/XMLSchema#integer>",
433            "\"a > b\"",
434        ] {
435            match rdf12_triple_term(t) {
436                Some(Cow::Borrowed(got)) => assert_eq!(got, t),
437                other => panic!("{t} should borrow unchanged, got {other:?}"),
438            }
439        }
440    }
441
442    #[test]
443    fn an_object_triple_term_gets_the_rdf12_surface() {
444        assert_eq!(
445            rdf12_triple_term("<<<http://ex/s> <http://ex/p> <http://ex/o>>>").unwrap(),
446            "<<( <http://ex/s> <http://ex/p> <http://ex/o> )>>"
447        );
448    }
449
450    #[test]
451    fn nesting_in_the_object_slot_recurses() {
452        // `ttObject` admits another triple term, so depth works — and the
453        // recursion has to rewrite the inner one too, not just the outer.
454        assert_eq!(
455            rdf12_triple_term(
456                "<<<http://ex/a> <http://ex/b> <<<http://ex/x> <http://ex/y> <http://ex/z>>>>>"
457            )
458            .unwrap(),
459            "<<( <http://ex/a> <http://ex/b> <<( <http://ex/x> <http://ex/y> <http://ex/z> )>> )>>"
460        );
461    }
462
463    #[test]
464    fn a_literal_object_survives_verbatim() {
465        // Term boundaries come from `take_term`, not from splitting on spaces,
466        // so a literal carrying spaces, a `>` and a `<<` does not derail it.
467        assert_eq!(
468            rdf12_triple_term("<<_:b1 <http://ex/p> \"a > b << c\"@en>>").unwrap(),
469            "<<( _:b1 <http://ex/p> \"a > b << c\"@en )>>"
470        );
471    }
472
473    #[test]
474    fn a_triple_term_in_a_subject_slot_has_no_rdf12_spelling() {
475        // RDF 1.2: `ttSubject ::= iri | BlankNode`. A quoted triple nested in
476        // another one's subject cannot be written, at any depth, and the honest
477        // answer is `None` rather than a token no parser accepts.
478        assert!(rdf12_triple_term(
479            "<<<<<http://ex/x> <http://ex/y> <http://ex/z>>> <http://ex/p> <http://ex/o>>>"
480        )
481        .is_none());
482        // …including one level down.
483        assert!(rdf12_triple_term(
484            "<<<http://ex/a> <http://ex/b> <<<<<http://ex/x> <http://ex/y> <http://ex/z>>> <http://ex/p> <http://ex/o>>>>>"
485        )
486        .is_none());
487    }
488
489    #[test]
490    fn a_malformed_quoted_triple_is_refused_not_mangled() {
491        assert!(rdf12_triple_term("<<<http://ex/s> <http://ex/p>>>").is_none());
492        assert!(rdf12_triple_term("<<>>").is_none());
493    }
494
495    #[test]
496    fn the_rewrite_is_what_ingest_accepts_back() {
497        // The round-trip property, at the term level: what the writer emits is
498        // what `take_term` canonicalises back to the stored token. This is why
499        // rete -> nq -> rete is safe in either surface.
500        let stored =
501            "<<<http://ex/a> <http://ex/b> <<<http://ex/x> <http://ex/y> <http://ex/z>>>>>";
502        let written = rdf12_triple_term(stored).unwrap().into_owned();
503        let (back, rest) = crate::ingest::take_term(&written).unwrap();
504        assert_eq!(back, stored);
505        assert!(rest.trim().is_empty());
506    }
507
508    #[test]
509    fn term_kinds() {
510        assert!(is_iri("<http://example.org/x>"));
511        assert!(!is_iri("\"x\""));
512        assert!(!is_iri("_:b0"));
513        assert!(is_blank("_:b0"));
514        assert!(is_literal("\"x\"@en"));
515        assert_eq!(iri_content("<http://x>"), Some("http://x"));
516        assert_eq!(iri_content("\"x\""), None);
517    }
518
519    #[test]
520    fn lexical_values() {
521        assert_eq!(literal_lexical("\"hello\""), Some("hello".to_string()));
522        assert_eq!(literal_lexical("\"42\"^^<int>"), Some("42".to_string()));
523        assert_eq!(literal_lexical("\"hi\"@en"), Some("hi".to_string()));
524        assert_eq!(literal_lexical("<http://x>"), None);
525        // any-term lexical
526        assert_eq!(lexical("\"hi\"@en"), "hi");
527        assert_eq!(lexical("<http://x>"), "http://x");
528        assert_eq!(lexical("_:b0"), "_:b0");
529    }
530
531    #[test]
532    fn datatype_and_lang() {
533        assert_eq!(literal_datatype("\"42\"^^<int>").as_deref(), Some("int"));
534        assert_eq!(
535            literal_datatype("\"hi\"@en").as_deref(),
536            Some(RDF_LANG_STRING)
537        );
538        assert_eq!(literal_datatype("\"plain\"").as_deref(), Some(XSD_STRING));
539        assert_eq!(literal_datatype("<http://x>"), None);
540        assert_eq!(lang_tag("\"hi\"@en").as_deref(), Some("en"));
541        assert_eq!(lang_tag("\"plain\"").as_deref(), Some(""));
542        assert_eq!(lang_tag("<http://x>"), None);
543    }
544
545    #[test]
546    fn numbers() {
547        assert_eq!(as_number("\"30\"^^<int>"), Some(30.0));
548        assert_eq!(as_number("3.5"), Some(3.5));
549        assert_eq!(as_number("\"nope\""), None);
550        assert_eq!(as_number("<http://x>"), None);
551    }
552
553    #[test]
554    fn as_number_escape_aware() {
555        // Plain and typed numeric literals parse as before.
556        assert_eq!(
557            as_number("\"42\"^^<http://www.w3.org/2001/XMLSchema#integer>"),
558            Some(42.0)
559        );
560        assert_eq!(
561            as_number("\"12.5\"^^<http://www.w3.org/2001/XMLSchema#decimal>"),
562            Some(12.5)
563        );
564        assert_eq!(
565            as_number("\"6.022e23\"^^<http://www.w3.org/2001/XMLSchema#double>"),
566            Some(6.022e23)
567        );
568        // Plain literal, negative, and leading-`+`.
569        assert_eq!(as_number("\"5\""), Some(5.0));
570        assert_eq!(as_number("\"-5\"^^<int>"), Some(-5.0));
571        assert_eq!(as_number("\"+7\""), Some(7.0));
572        // Non-numeric literal → None.
573        assert_eq!(as_number("\"not a number\""), None);
574        // A value with an EMBEDDED escaped quote must be read whole (`1"2`),
575        // fail to parse, and never be truncated to `1` (or panic). This is the
576        // escape-aware path: the old first-`"` scan would have stopped early.
577        assert_eq!(as_number("\"1\\\"2\""), None);
578        // IRI and blank node → None.
579        assert_eq!(as_number("<http://example.org/n>"), None);
580        assert_eq!(as_number("_:b0"), None);
581        // A language-tagged literal is never numeric (SPARQL): `"5"@en` → None.
582        assert_eq!(as_number("\"5\"@en"), None);
583    }
584
585    #[test]
586    fn escapes() {
587        assert_eq!(unescape_literal("plain"), "plain");
588        assert_eq!(unescape_literal("a\\nb"), "a\nb");
589        assert_eq!(unescape_literal("a\\\"b"), "a\"b");
590        assert_eq!(unescape_literal("\\u0041"), "A");
591        // an escaped quote inside the body is honored by the closing-quote scan
592        assert_eq!(literal_lexical("\"a\\\"b\"@en"), Some("a\"b".to_string()));
593    }
594}