Skip to main content

rdf_parsers/jsonld/
convert.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::ops::Range;
4use std::pin::Pin;
5
6use rowan::SyntaxNode;
7
8use crate::Spanned;
9use crate::model::*;
10
11use super::parser::{Lang, SyntaxKind};
12
13type Node = SyntaxNode<Lang>;
14
15// RDF/XSD constants
16const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
17const RDF_FIRST: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#first";
18const RDF_REST: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest";
19const RDF_NIL: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil";
20const RDF_JSON: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#JSON";
21const XSD_STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
22const XSD_INTEGER: &str = "http://www.w3.org/2001/XMLSchema#integer";
23const XSD_DOUBLE: &str = "http://www.w3.org/2001/XMLSchema#double";
24const XSD_BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
25
26// ── Intermediate JSON-LD value representation ─────────────────────────────────
27
28#[derive(Debug, Clone)]
29pub enum JsonLdVal {
30    /// Object carries its span so we can pass it as the triple span for nodes.
31    /// Each member is `(key, key_span, val_span, value)` where `key_span` is
32    /// the byte range of the key string token and `val_span` is the byte range
33    /// of the value token/node in the source, enabling accurate predicate and
34    /// object source-mapping.
35    Object(
36        Vec<(String, Range<usize>, Range<usize>, JsonLdVal)>,
37        Range<usize>,
38    ),
39    Array(Vec<(JsonLdVal, Range<usize>)>),
40    Str(String),
41    Number(String),
42    Bool(bool),
43    Null,
44    Invalid,
45}
46
47impl JsonLdVal {
48    /// Look up a key inside an `Object`, returning the value or `None`.
49    pub fn get(&self, key: &str) -> Option<&JsonLdVal> {
50        if let JsonLdVal::Object(members, _) = self {
51            members
52                .iter()
53                .find(|(k, _, _, _)| k == key)
54                .map(|(_, _, _, v)| v)
55        } else {
56            None
57        }
58    }
59
60    /// Return the contained string if this is a `Str` variant.
61    pub fn as_str(&self) -> Option<&str> {
62        if let JsonLdVal::Str(s) = self {
63            Some(s.as_str())
64        } else {
65            None
66        }
67    }
68
69    /// Return the contained bool if this is a `Bool` variant.
70    pub fn as_bool(&self) -> Option<bool> {
71        if let JsonLdVal::Bool(b) = self {
72            Some(*b)
73        } else {
74            None
75        }
76    }
77
78    /// Return the member slice if this is an `Object`.
79    /// Each element is `(key, key_span, val_span, value)`.
80    pub fn as_object(&self) -> Option<&[(String, Range<usize>, Range<usize>, JsonLdVal)]> {
81        if let JsonLdVal::Object(members, _) = self {
82            Some(members.as_slice())
83        } else {
84            None
85        }
86    }
87
88    /// Return the item slice if this is an `Array`.
89    /// Each element is `(value, span)`.
90    pub fn as_array(&self) -> Option<&[(JsonLdVal, Range<usize>)]> {
91        if let JsonLdVal::Array(items) = self {
92            Some(items.as_slice())
93        } else {
94            None
95        }
96    }
97}
98
99// ── Context types ─────────────────────────────────────────────────────────────
100
101#[derive(Debug, Clone, Default)]
102pub struct TermDefinition {
103    pub iri: Option<String>,
104    pub type_mapping: Option<String>,
105    pub language: Option<String>,
106    pub container: Option<String>,
107    pub reverse: bool,
108}
109
110/// The active context built up from all `@context` entries encountered during
111/// JSON-LD processing.  Returned alongside the [`Turtle`] triples by
112/// [`convert`] and [`convert_with_loader`].
113#[derive(Debug, Clone, Default)]
114pub struct ActiveContext {
115    pub base: Option<String>,
116    pub vocab: Option<String>,
117    pub language: Option<String>,
118    pub terms: HashMap<String, TermDefinition>,
119}
120
121// ── Context loader hook ───────────────────────────────────────────────────────
122
123/// Hook for fetching remote JSON-LD context documents asynchronously.
124///
125/// Implement this trait to support `@context` values that are URLs.  The
126/// `load` method receives the URL and should return the raw JSON-LD document
127/// content as a string, or `None` if the document cannot be fetched.
128///
129/// # Example (sync wrapper)
130/// ```rust,ignore
131/// struct MyLoader;
132/// impl ContextLoader for MyLoader {
133///     fn load<'a>(&'a self, url: &'a str) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>> {
134///         Box::pin(async move { Some(fetch_remote(url).await) })
135///     }
136/// }
137/// ```
138pub trait ContextLoader {
139    fn load<'a>(&'a mut self, url: &'a str) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>>;
140
141    /// Fetch and parse a remote JSON-LD context document, returning its parsed
142    /// value.  The default implementation calls [`load`](Self::load) and parses
143    /// the result; override this to add caching of parsed values.
144    fn load_val<'a>(
145        &'a mut self,
146        url: &'a str,
147    ) -> Pin<Box<dyn Future<Output = Option<JsonLdVal>> + 'a>> {
148        Box::pin(async move {
149            let content = self.load(url).await?;
150            parse_jsonld_for_context(&content)
151        })
152    }
153}
154
155/// A no-op `ContextLoader` that never resolves remote contexts.
156pub struct NoopContextLoader;
157
158impl ContextLoader for NoopContextLoader {
159    fn load<'a>(&'a mut self, _url: &'a str) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>> {
160        Box::pin(std::future::ready(None))
161    }
162}
163
164// ── Convert state ─────────────────────────────────────────────────────────────
165
166struct ConvertState {
167    blank_counter: usize,
168    triples: Vec<Spanned<Triple>>,
169    prefixes: Vec<Spanned<TurtlePrefix>>,
170    /// Accumulates every term/base/vocab/language defined anywhere in the
171    /// document's `@context` entries (later definitions override earlier ones).
172    accumulated_context: ActiveContext,
173    /// The base IRI passed by the caller (document URL).  Stored so that
174    /// `into_turtle` can populate `Turtle::set_base` without re-deriving it.
175    initial_base: Option<String>,
176}
177
178impl ConvertState {
179    fn new(initial_base: Option<String>) -> Self {
180        Self {
181            blank_counter: 0,
182            triples: Vec::new(),
183            prefixes: Vec::new(),
184            accumulated_context: ActiveContext::default(),
185            initial_base,
186        }
187    }
188
189    fn register_prefix(
190        &mut self,
191        term: &str,
192        iri: &str,
193        term_span: Range<usize>,
194        val_span: Range<usize>,
195    ) {
196        // Overwrite any existing definition for this prefix (last definition wins,
197        // matching JSON-LD context-array merging semantics).
198        self.prefixes.retain(|p| p.value().prefix.value() != term);
199        let full_span = term_span.start..val_span.end;
200        self.prefixes.push(Spanned(
201            TurtlePrefix {
202                span: full_span.clone(),
203                prefix: Spanned(term.to_string(), term_span),
204                value: Spanned(NamedNode::Full(iri.to_string(), val_span.start), val_span),
205            },
206            full_span,
207        ));
208    }
209
210    fn fresh_blank(&mut self, offset: usize) -> Term {
211        let id = self.blank_counter;
212        self.blank_counter += 1;
213        Term::BlankNode(BlankNode::Named(format!("b{}", id), offset))
214    }
215
216    fn add_triple(
217        &mut self,
218        subject: (Term, Range<usize>),
219        predicate: (Term, Range<usize>),
220        object: (Term, Range<usize>),
221        graph: Option<(Term, Range<usize>)>,
222        span: Range<usize>,
223    ) {
224        let s = span.clone();
225        self.triples.push(Spanned(
226            Triple {
227                subject: Spanned(subject.0, subject.1),
228                po: vec![Spanned(
229                    PO {
230                        predicate: Spanned(predicate.0, predicate.1),
231                        object: vec![Spanned(object.0, object.1)],
232                    },
233                    s.clone(),
234                )],
235                graph: graph.map(|g| Spanned(g.0, g.1)),
236            },
237            s,
238        ));
239    }
240
241    fn named(iri: &str) -> Term {
242        Term::NamedNode(NamedNode::Full(iri.to_string(), 0))
243    }
244
245    fn into_turtle(self) -> (Turtle, ActiveContext) {
246        // If the document declared an explicit @base, represent it as the
247        // syntactic Turtle base directive.  `accumulated_context.base` is only
248        // written when `@base` is encountered in a `@context` object, so it
249        // never reflects the caller-supplied `initial_base`.
250        let turtle_base = self.accumulated_context.base.as_deref().map(|iri| {
251            let nn = NamedNode::Full(iri.to_string(), 0);
252            Spanned(Base(0..0, Spanned(nn, 0..0)), 0..0)
253        });
254        let mut turtle = Turtle::new(turtle_base, self.prefixes, self.triples);
255        turtle.set_base = self.initial_base;
256        (turtle, self.accumulated_context)
257    }
258}
259
260// ── CST helpers ───────────────────────────────────────────────────────────────
261
262fn text_range(node: &Node) -> Range<usize> {
263    let r = node.text_range();
264    r.start().into()..r.end().into()
265}
266
267fn child(node: &Node, kind: SyntaxKind) -> Option<Node> {
268    node.children().find(|c| c.kind() == kind)
269}
270
271fn children(node: &Node, kind: SyntaxKind) -> impl Iterator<Item = Node> {
272    node.children().filter(move |c| c.kind() == kind)
273}
274
275/// Extract the unquoted, unescaped string content from a `JsonString` node.
276///
277/// The A* parser wraps every matched terminal in a terminal wrapper node of the
278/// same `SyntaxKind`.  So a `JsonString` producing-rule node contains one child
279/// node (e.g., `AtId` or `StringToken`), which in turn contains the raw token.
280/// This function peeks through that extra level to retrieve the token text.
281fn extract_json_string(node: &Node) -> Option<String> {
282    // `JsonString` has one child node: the terminal wrapper (AtId, StringToken, …).
283    let terminal_wrapper = node.children().next()?;
284    let tok = terminal_wrapper
285        .children_with_tokens()
286        .filter_map(|e| e.into_token())
287        .find(|t| !matches!(t.kind(), SyntaxKind::WhiteSpace | SyntaxKind::Comment))?;
288    let text = tok.text();
289    if text.starts_with('"') && text.ends_with('"') && text.len() >= 2 {
290        Some(unescape_json_string(&text[1..text.len() - 1]))
291    } else {
292        None
293    }
294}
295
296/// Extract the text of a token from inside a terminal wrapper node.
297fn terminal_text(wrapper: &Node) -> Option<String> {
298    wrapper
299        .children_with_tokens()
300        .filter_map(|e| e.into_token())
301        .find(|t| !matches!(t.kind(), SyntaxKind::WhiteSpace | SyntaxKind::Comment))
302        .map(|t| t.text().to_string())
303}
304
305fn unescape_json_string(s: &str) -> String {
306    let mut result = String::with_capacity(s.len());
307    let mut chars = s.chars();
308    while let Some(c) = chars.next() {
309        if c == '\\' {
310            match chars.next() {
311                Some('"') => result.push('"'),
312                Some('\\') => result.push('\\'),
313                Some('/') => result.push('/'),
314                Some('b') => result.push('\x08'),
315                Some('f') => result.push('\x0C'),
316                Some('n') => result.push('\n'),
317                Some('r') => result.push('\r'),
318                Some('t') => result.push('\t'),
319                Some('u') => {
320                    let hex: String = chars.by_ref().take(4).collect();
321                    if let Ok(n) = u32::from_str_radix(&hex, 16) {
322                        if let Some(c) = char::from_u32(n) {
323                            result.push(c);
324                        }
325                    }
326                }
327                Some(other) => {
328                    result.push('\\');
329                    result.push(other);
330                }
331                None => result.push('\\'),
332            }
333        } else {
334            result.push(c);
335        }
336    }
337    result
338}
339
340// ── CST → JsonLdVal ───────────────────────────────────────────────────────────
341
342fn cst_to_value(node: &Node) -> Option<JsonLdVal> {
343    let span = text_range(node);
344    match node.kind() {
345        SyntaxKind::JsonValue => {
346            // Non-terminal alternatives: JsonObject, JsonArray, JsonString.
347            if let Some(obj) = child(node, SyntaxKind::JsonObject) {
348                return cst_to_value(&obj);
349            }
350            if let Some(arr) = child(node, SyntaxKind::JsonArray) {
351                return cst_to_value(&arr);
352            }
353            if let Some(js) = child(node, SyntaxKind::JsonString) {
354                return cst_to_value(&js);
355            }
356            // Terminal alternatives: JSON_NUMBER, 'true', 'false', 'null'.
357            // The A* wraps each matched terminal in a terminal wrapper node of the
358            // same SyntaxKind, so they appear as child *nodes* of JsonValue.
359            if let Some(num_node) = child(node, SyntaxKind::JsonNumber) {
360                if let Some(text) = terminal_text(&num_node) {
361                    return Some(JsonLdVal::Number(text));
362                }
363            }
364            if child(node, SyntaxKind::TrueLit).is_some() {
365                return Some(JsonLdVal::Bool(true));
366            }
367            if child(node, SyntaxKind::FalseLit).is_some() {
368                return Some(JsonLdVal::Bool(false));
369            }
370            if child(node, SyntaxKind::NullLit).is_some() {
371                return Some(JsonLdVal::Null);
372            }
373            None
374        }
375        SyntaxKind::JsonObject => {
376            let mut members = Vec::new();
377            // JsonObject ::= '{' memberList? '}'
378            // Members live inside a MemberList child, not directly in JsonObject.
379            let member_nodes: Vec<Node> = if let Some(ml) = child(node, SyntaxKind::MemberList) {
380                children(&ml, SyntaxKind::Member).collect()
381            } else {
382                Vec::new()
383            };
384            for member in member_nodes {
385                let Some(key_node) = child(&member, SyntaxKind::JsonString) else {
386                    continue;
387                };
388                let Some(key) = extract_json_string(&key_node) else {
389                    continue;
390                };
391                let key_span = text_range(&key_node);
392                let Some(val_node) = child(&member, SyntaxKind::JsonValue) else {
393                    continue;
394                };
395                let val_span = text_range(&val_node);
396                let val = cst_to_value(&val_node).unwrap_or(JsonLdVal::Invalid);
397                members.push((key, key_span, val_span, val));
398            }
399            Some(JsonLdVal::Object(members, span))
400        }
401        SyntaxKind::JsonArray => {
402            let items = if let Some(vl) = child(node, SyntaxKind::ValueList) {
403                children(&vl, SyntaxKind::JsonValue)
404                    .filter_map(|v| {
405                        let span = text_range(&v);
406                        cst_to_value(&v).map(|val| (val, span))
407                    })
408                    .collect()
409            } else {
410                Vec::new()
411            };
412            Some(JsonLdVal::Array(items))
413        }
414        SyntaxKind::JsonString => {
415            let s = extract_json_string(node)?;
416            Some(JsonLdVal::Str(s))
417        }
418        _ => None,
419    }
420}
421
422// ── Context processing ────────────────────────────────────────────────────────
423
424/// Process a JSON-LD `@context` value and return the updated active context.
425///
426/// This is a boxed future because it is recursively called for arrays of
427/// contexts and for remote contexts loaded via the `loader`.
428fn process_context<'a>(
429    mut active: ActiveContext,
430    ctx_val: JsonLdVal,
431    loader: &'a mut dyn ContextLoader,
432    state: &'a mut ConvertState,
433) -> Pin<Box<dyn Future<Output = ActiveContext> + 'a>> {
434    Box::pin(async move {
435        match ctx_val {
436            JsonLdVal::Null => {
437                active = ActiveContext::default();
438            }
439            JsonLdVal::Str(ref url) => {
440                // Resolve the context URL against the active base (from an
441                // earlier @base entry) or, if none, against the caller-supplied
442                // initial base IRI.  Absolute URLs pass through unchanged.
443                let base = active.base.clone().or_else(|| state.initial_base.clone());
444                let resolved_url = resolve_iri(&base, url);
445                if let Some(remote_ctx) = loader.load_val(&resolved_url).await {
446                    active = process_context(active, remote_ctx, loader, state).await;
447                }
448            }
449            JsonLdVal::Array(items) => {
450                for (item, _) in items {
451                    active = process_context(active, item, loader, state).await;
452                }
453            }
454            JsonLdVal::Object(members, _) => {
455                for (key, key_span, val_span, val) in members {
456                    match key.as_str() {
457                        "@base" => match &val {
458                            JsonLdVal::Str(s) if s.is_empty() => active.base = None,
459                            JsonLdVal::Str(s) => {
460                                let resolved = resolve_iri(&active.base, s);
461                                active.base = Some(resolved.clone());
462                                state.accumulated_context.base = Some(resolved);
463                            }
464                            JsonLdVal::Null => active.base = None,
465                            _ => {}
466                        },
467                        "@vocab" => match val {
468                            JsonLdVal::Str(s) => {
469                                state.accumulated_context.vocab = Some(s.clone());
470                                active.vocab = Some(s);
471                            }
472                            JsonLdVal::Null => active.vocab = None,
473                            _ => {}
474                        },
475                        "@language" => match val {
476                            JsonLdVal::Str(s) => {
477                                state.accumulated_context.language = Some(s.clone());
478                                active.language = Some(s);
479                            }
480                            JsonLdVal::Null => active.language = None,
481                            _ => {}
482                        },
483                        // TODO: @version, @import, @propagate, @protected, @direction
484                        k if !k.starts_with('@') => {
485                            // Extract inner type-scoped @context before borrowing val.
486                            let scoped_ctx = if let JsonLdVal::Object(ref members, _) = val {
487                                members
488                                    .iter()
489                                    .find(|(k, _, _, _)| k == "@context")
490                                    .map(|(_, _, _, v)| v.clone())
491                            } else {
492                                None
493                            };
494
495                            if let Some(def) = process_term_definition(&active, k, &val) {
496                                if let (Some(iri), false) = (&def.iri, def.reverse) {
497                                    state.register_prefix(
498                                        k,
499                                        iri,
500                                        key_span.clone(),
501                                        val_span.clone(),
502                                    );
503                                }
504                                state
505                                    .accumulated_context
506                                    .terms
507                                    .insert(k.to_string(), def.clone());
508                                active.terms.insert(k.to_string(), def);
509                            } else {
510                                active.terms.remove(k);
511                            }
512
513                            // Flatten type-scoped context into the active context so
514                            // that parameter names like `originalUrlExtractor` resolve
515                            // to their full IRIs for completion and hover.
516                            if let Some(scoped) = scoped_ctx {
517                                active = process_context(active, scoped, loader, state).await;
518                            }
519                        }
520                        _ => {}
521                    }
522                }
523            }
524            _ => {}
525        }
526        active
527    })
528}
529
530fn process_term_definition(
531    active: &ActiveContext,
532    term: &str,
533    val: &JsonLdVal,
534) -> Option<TermDefinition> {
535    match val {
536        JsonLdVal::Null => None,
537        JsonLdVal::Str(s) => {
538            let iri = expand_iri(active, s, true, false);
539            Some(TermDefinition {
540                iri,
541                ..Default::default()
542            })
543        }
544        JsonLdVal::Object(members, _) => {
545            let mut def = TermDefinition::default();
546            let map: HashMap<&str, &JsonLdVal> =
547                members.iter().map(|(k, _, _, v)| (k.as_str(), v)).collect();
548
549            // @reverse takes priority over @id for the IRI
550            if let Some(rev_val) = map.get("@reverse") {
551                if let JsonLdVal::Str(s) = rev_val {
552                    def.iri = expand_iri(active, s, true, false);
553                    def.reverse = true;
554                }
555            } else if let Some(id_val) = map.get("@id") {
556                match id_val {
557                    JsonLdVal::Str(s) => def.iri = expand_iri(active, s, true, false),
558                    JsonLdVal::Null => def.iri = None,
559                    _ => {}
560                }
561            } else {
562                def.iri = expand_iri(active, term, true, false);
563            }
564
565            if let Some(JsonLdVal::Str(s)) = map.get("@type") {
566                def.type_mapping = Some(s.clone());
567            }
568            if let Some(lang_val) = map.get("@language") {
569                match lang_val {
570                    JsonLdVal::Str(s) => def.language = Some(s.clone()),
571                    JsonLdVal::Null => def.language = None,
572                    _ => {}
573                }
574            }
575            if let Some(JsonLdVal::Str(s)) = map.get("@container") {
576                def.container = Some(s.clone());
577            }
578
579            Some(def)
580        }
581        _ => Some(TermDefinition {
582            iri: None,
583            ..Default::default()
584        }),
585    }
586}
587
588/// Parse a JSON string and return the full `JsonLdVal` tree with source spans.
589///
590/// Unlike [`parse_jsonld_for_context`], this returns the entire document — including
591/// the `@context` entry — making it suitable for span extraction over component and
592/// config files where caller code needs the precise byte ranges of `@id` values.
593pub fn parse_json(content: &str) -> Option<JsonLdVal> {
594    use super::parser::{Rule, SyntaxKind as SK};
595    let rule = Rule::new(SK::JsonldDoc);
596    let (parse, _) = crate::parse(rule, content);
597    let root = parse.syntax::<Lang>();
598    let json_val_node = root.children().find(|c| c.kind() == SK::JsonValue)?;
599    cst_to_value(&json_val_node)
600}
601
602/// Parse a JSON-LD string (fetched from a remote URL) and return the value
603/// that should be used as a context.  If the document has a top-level
604/// `@context`, return that; otherwise return the whole document.
605pub fn parse_jsonld_for_context(content: &str) -> Option<JsonLdVal> {
606    use super::parser::{Rule, SyntaxKind as SK};
607    let rule = Rule::new(SK::JsonldDoc);
608    let (parse, _) = crate::parse(rule, content);
609    let root = parse.syntax::<Lang>();
610    // jsonldDoc ::= jsonValue; the JsonValue is a direct child of ROOT.
611    let json_val_node = root.children().find(|c| c.kind() == SK::JsonValue)?;
612    let val = cst_to_value(&json_val_node)?;
613    // If the document contains @context at the top level, return that.
614    if let JsonLdVal::Object(members, _) = &val {
615        if let Some((_, _, _, ctx)) = members.iter().find(|(k, _, _, _)| k == "@context") {
616            return Some(ctx.clone());
617        }
618    }
619    Some(val)
620}
621
622// ── IRI expansion ─────────────────────────────────────────────────────────────
623
624fn expand_iri(
625    active: &ActiveContext,
626    value: &str,
627    vocab: bool,
628    document_relative: bool,
629) -> Option<String> {
630    if value.is_empty() {
631        if document_relative {
632            return active.base.clone();
633        }
634        return None;
635    }
636
637    // Keywords pass through as-is
638    if value.starts_with('@') {
639        return Some(value.to_string());
640    }
641
642    // Term definition lookup (vocab=true: term names expand via active context)
643    if vocab {
644        if let Some(def) = active.terms.get(value) {
645            if let Some(iri) = &def.iri {
646                // Recursively expand the stored IRI in case it was stored unexpanded
647                // (e.g., the prefix term was defined later in the same context object).
648                // Use vocab=false to avoid re-entering term lookup and causing cycles.
649                if iri == value {
650                    return Some(iri.clone());
651                }
652                return expand_iri(active, iri, false, false);
653            }
654        }
655    }
656
657    // Compact IRI: contains ':' → split into prefix:suffix
658    if let Some(pos) = value.find(':') {
659        if pos > 0 {
660            let prefix = &value[..pos];
661            let suffix = &value[pos + 1..];
662
663            // Already absolute (has scheme like http://) or blank node
664            if suffix.starts_with("//") || prefix == "_" {
665                return Some(value.to_string());
666            }
667
668            // Look up prefix in the active context
669            if let Some(def) = active.terms.get(prefix) {
670                if let Some(iri) = &def.iri {
671                    return expand_iri(
672                        active,
673                        &format!("{}{}", iri, suffix),
674                        vocab,
675                        document_relative,
676                    );
677                }
678            }
679
680            // Unknown prefix — treat as an absolute IRI
681            return Some(value.to_string());
682        }
683    }
684
685    // Vocab-relative expansion
686    if vocab {
687        if let Some(vocab_iri) = &active.vocab {
688            return Some(format!("{}{}", vocab_iri, value));
689        }
690    }
691
692    // Document-relative resolution
693    if document_relative {
694        if let Some(base) = &active.base {
695            return Some(resolve_iri(&Some(base.clone()), value));
696        }
697    }
698
699    Some(value.to_string())
700}
701
702fn resolve_iri(base: &Option<String>, reference: &str) -> String {
703    // Fast path: reference is already absolute.
704    if let Some(base_str) = base {
705        if let Ok(base_iri) = oxiri::Iri::parse(base_str.as_str()) {
706            if let Ok(resolved) = base_iri.resolve(reference) {
707                return resolved.to_string();
708            }
709        }
710    }
711    // No base or resolution failed — return the reference unchanged.
712    reference.to_string()
713}
714
715// ── Document processing ───────────────────────────────────────────────────────
716
717/// Top-level: process one JSON-LD value (document or item in a top-level array).
718///
719/// Boxed because it is directly self-recursive for top-level arrays.
720fn process_document<'a>(
721    state: &'a mut ConvertState,
722    active: &'a ActiveContext,
723    loader: &'a mut dyn ContextLoader,
724    val: &'a JsonLdVal,
725) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
726    Box::pin(async move {
727        match val {
728            JsonLdVal::Object(members, span) => {
729                process_node(state, active, loader, members, span, None).await;
730            }
731            JsonLdVal::Array(items) => {
732                for (item, _) in items {
733                    process_document(state, active, loader, item).await;
734                }
735            }
736            _ => {}
737        }
738    })
739}
740
741/// Process a JSON-LD value in the context of a named graph.
742///
743/// Boxed because it is directly self-recursive for arrays and mutually
744/// recursive with [`process_node`].
745fn process_document_with_graph<'a>(
746    state: &'a mut ConvertState,
747    active: &'a ActiveContext,
748    loader: &'a mut dyn ContextLoader,
749    val: &'a JsonLdVal,
750    graph: Option<(&'a Term, &'a Range<usize>)>,
751) -> Pin<Box<dyn Future<Output = ()> + 'a>> {
752    Box::pin(async move {
753        match val {
754            JsonLdVal::Object(members, span) => {
755                process_node(state, active, loader, members, span, graph).await;
756            }
757            JsonLdVal::Array(items) => {
758                for (item, _) in items {
759                    process_document_with_graph(state, active, loader, item, graph).await;
760                }
761            }
762            _ => {}
763        }
764    })
765}
766
767type MemberMap<'a> = HashMap<&'a str, (&'a Range<usize>, &'a JsonLdVal, &'a Range<usize>)>;
768/// Process a JSON-LD node object.  Returns the subject term if the node can
769/// serve as an object in another triple (e.g., a nested node).
770///
771/// Boxed because it is mutually recursive with `process_document_with_graph`
772/// and transitively recursive through `collect_objects` → `value_to_rdf`.
773fn process_node<'a>(
774    state: &'a mut ConvertState,
775    active: &'a ActiveContext,
776    loader: &'a mut dyn ContextLoader,
777    members: &'a [(String, Range<usize>, Range<usize>, JsonLdVal)],
778    span: &'a Range<usize>,
779    graph: Option<(&'a Term, &'a Range<usize>)>,
780) -> Pin<Box<dyn Future<Output = Option<Term>> + 'a>> {
781    Box::pin(async move {
782        let map: MemberMap<'_> = members
783            .iter()
784            .map(|(k, k_span, v_span, v)| (k.as_str(), (k_span, v, v_span)))
785            .collect();
786
787        // Value object — return as a literal, generate no subject-based triples
788        if map.contains_key("@value") {
789            return process_value_object(active, &map, span);
790        }
791
792        // List object
793        if let Some(list_val) = map.get("@list") {
794            let owned;
795            let items: &[(JsonLdVal, Range<usize>)] = match list_val.1 {
796                JsonLdVal::Array(items) => items.as_slice(),
797                other => {
798                    owned = [((*other).clone(), span.clone())];
799                    &owned
800                }
801            };
802            return Some(process_list(state, active, loader, items, span, graph).await);
803        }
804
805        // Set object — process contents but return nothing as a subject
806        if let Some(set_val) = map.get("@set") {
807            if let JsonLdVal::Array(items) = set_val.1 {
808                for (item, _) in items {
809                    process_document_with_graph(state, active, loader, item, graph).await;
810                }
811            }
812            return None;
813        }
814
815        // Update active context if @context present in this node
816        let updated;
817        let active: &ActiveContext = if let Some(ctx_val) = map.get("@context") {
818            updated = process_context(active.clone(), ctx_val.1.clone(), loader, state).await;
819            &updated
820        } else {
821            active
822        };
823
824        // Determine the subject
825        let id_val_span: Range<usize> = members
826            .iter()
827            .find(|(k, _, _, _)| k == "@id")
828            .map(|(_, _, vs, _)| vs.clone())
829            .unwrap_or_else(|| span.start..span.start);
830        let subject: Term = if let Some(id_val) = map.get("@id") {
831            match id_val.1 {
832                JsonLdVal::Str(s) => match expand_iri(active, s, false, true) {
833                    Some(iri) if iri.starts_with("_:") => {
834                        Term::BlankNode(BlankNode::Named(iri[2..].to_string(), id_val_span.start))
835                    }
836                    Some(iri) => {
837                        Term::NamedNode(named_from_source(active, s, iri, id_val_span.start, false))
838                    }
839                    None => state.fresh_blank(id_val_span.start),
840                },
841                _ => state.fresh_blank(id_val_span.start),
842            }
843        } else {
844            state.fresh_blank(span.start)
845        };
846
847        // @type → rdf:type triples
848        if let Some(type_val) = map.get("@type") {
849            for (type_str, type_span) in collect_strings_spanned(type_val.1, type_val.2) {
850                if let Some(iri) = expand_iri(active, &type_str, true, true) {
851                    let obj =
852                        term_or_blank_from_source(active, &type_str, iri, type_span.start, true);
853                    state.add_triple(
854                        (subject.clone(), id_val_span.clone()),
855                        (ConvertState::named(RDF_TYPE), type_val.0.clone()), // TODO, why doesn't the map have the
856                        // spans?
857                        (obj, type_span),
858                        graph.map(|(a, b)| (a.clone(), b.clone())),
859                        span.clone(),
860                    );
861                }
862            }
863        }
864
865        // @graph → named graph containing nested nodes
866        if let Some(graph_val) = map.get("@graph") {
867            let named_graph = subject.clone();
868            match graph_val.1 {
869                JsonLdVal::Array(items) => {
870                    for (item, _) in items {
871                        process_document_with_graph(
872                            state,
873                            active,
874                            loader,
875                            item,
876                            Some((&named_graph, graph_val.2)),
877                        )
878                        .await;
879                    }
880                }
881                JsonLdVal::Object(m, s) => {
882                    process_node(
883                        state,
884                        active,
885                        loader,
886                        m,
887                        s,
888                        Some((&named_graph, graph_val.2)),
889                    )
890                    .await;
891                }
892                _ => {}
893            }
894        }
895
896        // @reverse → reverse-property triples (object becomes subject)
897        if let Some(rev_val) = map.get("@reverse") {
898            if let JsonLdVal::Object(rev_members, _) = rev_val.1 {
899                for (prop, prop_span, val_span, val) in rev_members {
900                    if prop.starts_with('@') {
901                        continue;
902                    }
903                    if let Some(pred_iri) = expand_iri(active, prop, true, false) {
904                        let pred = Term::NamedNode(named_from_source(
905                            active,
906                            prop,
907                            pred_iri,
908                            prop_span.start,
909                            true,
910                        ));
911                        let objects =
912                            collect_objects(state, active, loader, val, val_span, graph, None)
913                                .await;
914                        for (obj, obj_span) in objects {
915                            // Subject and object are swapped for reverse properties
916                            state.add_triple(
917                                (obj, obj_span.clone()),
918                                (pred.clone(), prop_span.clone()),
919                                (subject.clone(), id_val_span.clone()),
920                                graph.map(|(a, b)| (a.clone(), b.clone())),
921                                span.clone(),
922                            );
923                        }
924                    }
925                }
926            }
927        }
928
929        // @included → additional node objects (processed in default graph)
930        if let Some(included_val) = map.get("@included") {
931            match included_val.1 {
932                JsonLdVal::Array(items) => {
933                    for (item, _) in items {
934                        process_document(state, active, loader, item).await;
935                    }
936                }
937                JsonLdVal::Object(m, s) => {
938                    process_node(state, active, loader, m, s, None).await;
939                }
940                _ => {}
941            }
942        }
943
944        // Regular properties
945        for (key, key_span, val_span, val) in members {
946            match key.as_str() {
947                "@context" | "@id" | "@type" | "@graph" | "@reverse" | "@included" | "@nest" => {
948                    continue;
949                }
950                k if k.starts_with('@') => continue,
951                _ => {}
952            }
953
954            // Handle @nest: the value must be an object whose properties are promoted
955            // TODO: full @nest support — for now we skip nested containers
956
957            let pred_iri = match expand_iri(active, key, true, false) {
958                Some(iri) if !iri.starts_with('@') => iri,
959                _ => continue,
960            };
961
962            let term_def = active.terms.get(key.as_str());
963            let objects =
964                collect_objects(state, active, loader, val, val_span, graph, term_def).await;
965            if objects.is_empty() {
966                continue;
967            }
968            let s = span.clone();
969            state.triples.push(Spanned(
970                Triple {
971                    subject: Spanned(subject.clone(), id_val_span.clone()),
972                    po: vec![Spanned(
973                        PO {
974                            predicate: Spanned(
975                                Term::NamedNode(named_from_source(
976                                    active,
977                                    key,
978                                    pred_iri,
979                                    key_span.start,
980                                    true,
981                                )),
982                                key_span.clone(),
983                            ),
984                            object: objects
985                                .into_iter()
986                                .map(|(o, o_span)| Spanned(o, o_span))
987                                .collect(),
988                        },
989                        key_span.clone(),
990                    )],
991                    graph: graph.map(|g| Spanned(g.0.clone(), g.1.clone())),
992                },
993                s,
994            ));
995        }
996
997        Some(subject)
998    })
999}
1000
1001// ── Value helpers ─────────────────────────────────────────────────────────────
1002
1003fn collect_strings(val: &JsonLdVal) -> Vec<String> {
1004    match val {
1005        JsonLdVal::Str(s) => vec![s.clone()],
1006        JsonLdVal::Array(items) => items
1007            .iter()
1008            .filter_map(|(i, _)| {
1009                if let JsonLdVal::Str(s) = i {
1010                    Some(s.clone())
1011                } else {
1012                    None
1013                }
1014            })
1015            .collect(),
1016        _ => vec![],
1017    }
1018}
1019
1020/// Like [`collect_strings`], but keeps each string's own source span.
1021///
1022/// For a single scalar the `fallback` span (the whole value) is used; for an
1023/// array each element carries its own span.  This matters for highlighting:
1024/// using the whole-array span for every `@type` entry makes the entire `[...]`
1025/// render as one token, wiping the per-element coloring.
1026fn collect_strings_spanned(
1027    val: &JsonLdVal,
1028    fallback: &Range<usize>,
1029) -> Vec<(String, Range<usize>)> {
1030    match val {
1031        JsonLdVal::Str(s) => vec![(s.clone(), fallback.clone())],
1032        JsonLdVal::Array(items) => items
1033            .iter()
1034            .filter_map(|(i, span)| {
1035                if let JsonLdVal::Str(s) = i {
1036                    Some((s.clone(), span.clone()))
1037                } else {
1038                    None
1039                }
1040            })
1041            .collect(),
1042        _ => vec![],
1043    }
1044}
1045
1046/// Collect zero or more RDF terms from a JSON-LD value.
1047///
1048/// Boxed because it is directly self-recursive for arrays.
1049fn collect_objects<'a>(
1050    state: &'a mut ConvertState,
1051    active: &'a ActiveContext,
1052    loader: &'a mut dyn ContextLoader,
1053    val: &'a JsonLdVal,
1054    span: &'a Range<usize>,
1055    graph: Option<(&'a Term, &'a Range<usize>)>,
1056    term_def: Option<&'a TermDefinition>,
1057) -> Pin<Box<dyn Future<Output = Vec<(Term, Range<usize>)>> + 'a>> {
1058    Box::pin(async move {
1059        match val {
1060            JsonLdVal::Array(items) => {
1061                let mut result = Vec::new();
1062                for (item, item_span) in items {
1063                    let mut sub =
1064                        collect_objects(state, active, loader, item, item_span, graph, term_def)
1065                            .await;
1066                    result.append(&mut sub);
1067                }
1068                result
1069            }
1070            _ => value_to_rdf(state, active, loader, val, span, graph, term_def)
1071                .await
1072                .into_iter()
1073                .map(|t| (t, span.clone()))
1074                .collect(),
1075        }
1076    })
1077}
1078
1079/// Map a single JSON-LD value to zero or one RDF term.
1080///
1081/// This is a plain `async fn` (not boxed) because it is not directly
1082/// self-recursive — the only cycle is through the boxed `process_node` and
1083/// `process_list` calls.
1084async fn value_to_rdf<'a>(
1085    state: &'a mut ConvertState,
1086    active: &'a ActiveContext,
1087    loader: &'a mut dyn ContextLoader,
1088    val: &'a JsonLdVal,
1089    span: &'a Range<usize>,
1090    graph: Option<(&'a Term, &'a Range<usize>)>,
1091    term_def: Option<&'a TermDefinition>,
1092) -> Option<Term> {
1093    match val {
1094        JsonLdVal::Object(members, obj_span) => {
1095            let map: MemberMap<'_> = members
1096                .iter()
1097                .map(|(k, k_span, v_span, v)| (k.as_str(), (k_span, v, v_span)))
1098                .collect();
1099            if map.contains_key("@value") {
1100                return process_value_object(active, &map, obj_span);
1101            }
1102            if let Some(list_val) = map.get("@list") {
1103                let owned;
1104                let items: &[(JsonLdVal, Range<usize>)] = match list_val.1 {
1105                    JsonLdVal::Array(items) => items.as_slice(),
1106                    other => {
1107                        owned = [((*other).clone(), obj_span.clone())];
1108                        &owned
1109                    }
1110                };
1111                return Some(process_list(state, active, loader, items, obj_span, graph).await);
1112            }
1113            process_node(state, active, loader, members, obj_span, graph).await
1114        }
1115
1116        JsonLdVal::Str(s) => {
1117            if let Some(def) = term_def {
1118                match def.type_mapping.as_deref() {
1119                    Some("@id") => {
1120                        return expand_iri(active, s, false, true).map(|iri| {
1121                            term_or_blank_from_source(active, s, iri, span.start, false)
1122                        });
1123                    }
1124                    Some("@vocab") => {
1125                        return expand_iri(active, s, true, false).map(|iri| {
1126                            Term::NamedNode(named_from_source(active, s, iri, span.start, true))
1127                        });
1128                    }
1129                    Some(ty) if !ty.starts_with('@') => {
1130                        let ty_owned = ty.to_string();
1131                        if let Some(ty_iri) = expand_iri(active, &ty_owned, true, false) {
1132                            return Some(Term::Literal(Literal::RDF(RDFLiteral {
1133                                value: s.clone(),
1134                                quote_style: StringStyle::Double,
1135                                lang: None,
1136                                ty: Some(Spanned(
1137                                    named_from_source(active, ty, ty_iri, 0, true),
1138                                    0..0,
1139                                )),
1140                                idx: span.start,
1141                                len: span.len(),
1142                            })));
1143                        }
1144                    }
1145                    _ => {}
1146                }
1147                if let Some(lang) = &def.language {
1148                    return Some(Term::Literal(Literal::RDF(RDFLiteral {
1149                        value: s.clone(),
1150                        quote_style: StringStyle::Double,
1151                        lang: Some(Spanned(lang.clone(), 0..0)),
1152                        ty: None,
1153                        idx: span.start,
1154                        len: span.len(),
1155                    })));
1156                }
1157            }
1158            // Default: plain string, inheriting the context default language if set
1159            let lang = active.language.clone().map(|l| Spanned(l, 0..0));
1160            let ty = if lang.is_none() {
1161                Some(Spanned(NamedNode::Full(XSD_STRING.to_string(), 0), 0..0))
1162            } else {
1163                None
1164            };
1165            Some(Term::Literal(Literal::RDF(RDFLiteral {
1166                value: s.clone(),
1167                quote_style: StringStyle::Double,
1168                lang,
1169                ty,
1170                idx: span.start,
1171                len: span.len(),
1172            })))
1173        }
1174
1175        JsonLdVal::Number(n) => {
1176            let type_iri = if n.contains('.') || n.to_lowercase().contains('e') {
1177                XSD_DOUBLE
1178            } else {
1179                XSD_INTEGER
1180            };
1181            Some(Term::Literal(Literal::RDF(RDFLiteral {
1182                value: n.clone(),
1183                quote_style: StringStyle::Double,
1184                lang: None,
1185                ty: Some(Spanned(NamedNode::Full(type_iri.to_string(), 0), 0..0)),
1186                idx: span.start,
1187                len: span.len(),
1188            })))
1189        }
1190
1191        JsonLdVal::Bool(b) => Some(Term::Literal(Literal::RDF(RDFLiteral {
1192            value: if *b { "true" } else { "false" }.to_string(),
1193            quote_style: StringStyle::Double,
1194            lang: None,
1195            ty: Some(Spanned(NamedNode::Full(XSD_BOOLEAN.to_string(), 0), 0..0)),
1196            idx: span.start,
1197            len: span.len(),
1198        }))),
1199
1200        JsonLdVal::Invalid => Some(Term::Invalid),
1201        JsonLdVal::Null => None,
1202
1203        // Arrays must be handled by collect_objects, not here
1204        JsonLdVal::Array(_) => None,
1205    }
1206}
1207
1208fn process_value_object(
1209    active: &ActiveContext,
1210    map: &MemberMap<'_>,
1211    span: &Range<usize>,
1212) -> Option<Term> {
1213    let value = map.get("@value")?;
1214    let lang_val = map.get("@language");
1215    let type_val = map.get("@type");
1216
1217    let (value_str, is_complex) = match value.1 {
1218        JsonLdVal::Str(s) => (s.clone(), false),
1219        JsonLdVal::Bool(b) => (if *b { "true" } else { "false" }.to_string(), false),
1220        JsonLdVal::Number(n) => (n.clone(), false),
1221        other => (json_serialize(other), true),
1222    };
1223
1224    let type_str = type_val.and_then(|t| {
1225        if let JsonLdVal::Str(s) = t.1 {
1226            Some(s.as_str())
1227        } else {
1228            None
1229        }
1230    });
1231
1232    // @type: @json or complex value → rdf:JSON literal
1233    if type_str == Some("@json") || is_complex {
1234        return Some(Term::Literal(Literal::RDF(RDFLiteral {
1235            value: value_str,
1236            quote_style: StringStyle::Double,
1237            lang: None,
1238            ty: Some(Spanned(NamedNode::Full(RDF_JSON.to_string(), 0), 0..0)),
1239            idx: span.start,
1240            len: span.len(),
1241        })));
1242    }
1243
1244    // Language-tagged string
1245    let lang_tag = lang_val.and_then(|l| {
1246        if let JsonLdVal::Str(s) = l.1 {
1247            Some((s.as_str(), l.2.clone()))
1248        } else {
1249            None
1250        }
1251    });
1252    if let Some((lt, lt_span)) = lang_tag {
1253        return Some(Term::Literal(Literal::RDF(RDFLiteral {
1254            value: value_str,
1255            quote_style: StringStyle::Double,
1256            lang: Some(Spanned(lt.to_string(), lt_span)),
1257            ty: None,
1258            idx: span.start,
1259            len: span.len(),
1260        })));
1261    }
1262
1263    // Explicit @type
1264    if let Some(ty) = type_str {
1265        let ty_owned = ty.to_string();
1266        let ty_span = type_val.map(|t| t.2.clone()).unwrap_or(0..0);
1267        let ty_node = expand_iri(active, &ty_owned, true, false).map(|iri| {
1268            let start = ty_span.start;
1269            Spanned(named_from_source(active, ty, iri, start, true), ty_span)
1270        });
1271        return Some(Term::Literal(Literal::RDF(RDFLiteral {
1272            value: value_str,
1273            quote_style: StringStyle::Double,
1274            lang: None,
1275            ty: ty_node,
1276            idx: span.start,
1277            len: span.len(),
1278        })));
1279    }
1280
1281    // Infer type from the JSON value kind
1282    let (inferred_ty, inferred_lang) = match value.1 {
1283        JsonLdVal::Bool(_) => (Some(XSD_BOOLEAN), None),
1284        JsonLdVal::Number(n) => {
1285            if n.contains('.') || n.to_lowercase().contains('e') {
1286                (Some(XSD_DOUBLE), None)
1287            } else {
1288                (Some(XSD_INTEGER), None)
1289            }
1290        }
1291        _ => {
1292            if let Some(lang) = &active.language {
1293                (None, Some(lang.clone()))
1294            } else {
1295                (Some(XSD_STRING), None)
1296            }
1297        }
1298    };
1299
1300    Some(Term::Literal(Literal::RDF(RDFLiteral {
1301        value: value_str,
1302        quote_style: StringStyle::Double,
1303        lang: inferred_lang.map(|l| Spanned(l, 0..0)),
1304        ty: inferred_ty.map(|s| Spanned(NamedNode::Full(s.to_string(), 0), 0..0)),
1305        idx: span.start,
1306        len: span.len(),
1307    })))
1308}
1309
1310/// Build an `rdf:first`/`rdf:rest` list and return the head node.
1311///
1312/// Boxed because it is mutually recursive with `value_to_rdf` (which may
1313/// call `process_list` again for nested `@list` values).
1314fn process_list<'a>(
1315    state: &'a mut ConvertState,
1316    active: &'a ActiveContext,
1317    loader: &'a mut dyn ContextLoader,
1318    items: &'a [(JsonLdVal, Range<usize>)],
1319    span: &'a Range<usize>,
1320    graph: Option<(&'a Term, &'a Range<usize>)>,
1321) -> Pin<Box<dyn Future<Output = Term> + 'a>> {
1322    Box::pin(async move {
1323        if items.is_empty() {
1324            return ConvertState::named(RDF_NIL);
1325        }
1326        // Build the linked list in reverse
1327        let mut rest = ConvertState::named(RDF_NIL);
1328        for (item, item_span) in items.iter().rev() {
1329            let first_term = value_to_rdf(state, active, loader, item, item_span, graph, None)
1330                .await
1331                .unwrap_or(Term::Invalid);
1332            let node = state.fresh_blank(item_span.start);
1333            state.add_triple(
1334                (node.clone(), item_span.start..item_span.start),
1335                (ConvertState::named(RDF_FIRST), 0..0),
1336                (first_term, item_span.clone()),
1337                clone_graph(graph),
1338                item_span.clone(),
1339            );
1340            state.add_triple(
1341                (node.clone(), item_span.start..item_span.start),
1342                (ConvertState::named(RDF_REST), 0..0),
1343                (rest, item_span.start..item_span.start),
1344                clone_graph(graph),
1345                span.clone(),
1346            );
1347            rest = node;
1348        }
1349        rest
1350    })
1351}
1352
1353fn clone_graph(g: Option<(&'_ Term, &'_ Range<usize>)>) -> Option<(Term, Range<usize>)> {
1354    g.map(|g| (g.0.clone(), g.1.clone()))
1355}
1356
1357/// Build a `NamedNode` for an IRI that appears in the source as `source` and
1358/// expands (via the active context) to `expanded`.
1359///
1360/// Compact IRIs (`prefix:local`) and terms that look like compact IRIs are kept
1361/// in `Prefixed` form so JSON-LD output stays faithful to the source and
1362/// consistent with the syntactic parsers (Turtle/TriG/SPARQL/N3). The
1363/// context-computed absolute IRI is carried in `computed` so downstream
1364/// consumers use it directly rather than re-expanding `prefix`/`value` — JSON-LD
1365/// expansion (whole-term precedence, scoped contexts, `@vocab`) is not
1366/// equivalent to plain prefix substitution. Absolute IRIs, blank-node-style
1367/// values, unknown prefixes, and bare/`@vocab` terms have no prefix form and
1368/// stay `Full`.
1369///
1370/// `vocab` mirrors the flag used for the corresponding `expand_iri` call so the
1371/// prefix/value split matches how the value was actually resolved.
1372fn named_from_source(
1373    active: &ActiveContext,
1374    source: &str,
1375    expanded: String,
1376    offset: usize,
1377    vocab: bool,
1378) -> NamedNode {
1379    // A registered term that itself looks like a compact IRI (`foaf:name`) takes
1380    // whole-term precedence under `@vocab` resolution: keep the whole source as
1381    // the lookup key so it re-resolves the same way JSON-LD did.
1382    if vocab && source.contains(':') && active.terms.contains_key(source) {
1383        return NamedNode::Prefixed {
1384            prefix: source.to_string(),
1385            value: String::new(),
1386            idx: offset,
1387            computed: Some(expanded),
1388        };
1389    }
1390
1391    // Genuine compact IRI: `prefix:local` where `prefix` maps to a namespace.
1392    if let Some(pos) = source.find(':') {
1393        if pos > 0 {
1394            let prefix = &source[..pos];
1395            let suffix = &source[pos + 1..];
1396            let known_prefix = active
1397                .terms
1398                .get(prefix)
1399                .and_then(|d| d.iri.as_ref())
1400                .is_some();
1401            if !suffix.starts_with("//") && prefix != "_" && known_prefix {
1402                return NamedNode::Prefixed {
1403                    prefix: prefix.to_string(),
1404                    value: suffix.to_string(),
1405                    idx: offset,
1406                    computed: Some(expanded),
1407                };
1408            }
1409        }
1410    }
1411
1412    NamedNode::Full(expanded, offset)
1413}
1414
1415/// Resolve an expanded IRI to a blank node (`_:…`) or a named node, preserving
1416/// compact/prefixed source form for the latter via [`named_from_source`].
1417/// `source` is the pre-expansion text, `expanded` its context expansion.
1418fn term_or_blank_from_source(
1419    active: &ActiveContext,
1420    source: &str,
1421    expanded: String,
1422    offset: usize,
1423    vocab: bool,
1424) -> Term {
1425    if expanded.starts_with("_:") {
1426        Term::BlankNode(BlankNode::Named(expanded[2..].to_string(), offset))
1427    } else {
1428        Term::NamedNode(named_from_source(active, source, expanded, offset, vocab))
1429    }
1430}
1431
1432fn json_serialize(val: &JsonLdVal) -> String {
1433    match val {
1434        JsonLdVal::Object(members, _) => {
1435            let pairs: Vec<String> = members
1436                .iter()
1437                .map(|(k, _, _, v)| format!("\"{}\":{}", json_escape(k), json_serialize(v)))
1438                .collect();
1439            format!("{{{}}}", pairs.join(","))
1440        }
1441        JsonLdVal::Array(items) => {
1442            let elems: Vec<String> = items.iter().map(|(item, _)| json_serialize(item)).collect();
1443            format!("[{}]", elems.join(","))
1444        }
1445        JsonLdVal::Str(s) => format!("\"{}\"", json_escape(s)),
1446        JsonLdVal::Number(n) => n.clone(),
1447        JsonLdVal::Bool(b) => {
1448            if *b {
1449                "true".to_string()
1450            } else {
1451                "false".to_string()
1452            }
1453        }
1454        JsonLdVal::Invalid => "invalid".to_string(),
1455        JsonLdVal::Null => "null".to_string(),
1456    }
1457}
1458
1459fn json_escape(s: &str) -> String {
1460    s.replace('\\', "\\\\").replace('"', "\\\"")
1461}
1462
1463// ── Public API ────────────────────────────────────────────────────────────────
1464
1465/// Convert a parsed JSON-LD CST to RDF triples.
1466///
1467/// `base_iri` sets the initial base IRI for resolving relative IRIs.  When the
1468/// document was fetched from a URL, pass that URL here.  An explicit `@base` in
1469/// the document's `@context` will override this value.
1470///
1471/// Returns `(turtle, context)` where `context` is the accumulated
1472/// [`ActiveContext`] built from all `@context` entries in the document.
1473///
1474/// Remote `@context` references are not resolved.  Use
1475/// [`convert_with_loader`] to supply an async context-loading hook.
1476pub fn convert(root: &Node, base_iri: Option<String>) -> (Turtle, ActiveContext) {
1477    // NoopContextLoader always resolves immediately (std::future::ready),
1478    // so polling once is sufficient and safe without a real async runtime.
1479    use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
1480
1481    fn noop_clone(p: *const ()) -> RawWaker {
1482        RawWaker::new(p, &NOOP_VTABLE)
1483    }
1484    fn noop(_: *const ()) {}
1485    static NOOP_VTABLE: RawWakerVTable = RawWakerVTable::new(noop_clone, noop, noop, noop);
1486
1487    // SAFETY: The waker never actually wakes anything; it is only used to drive
1488    // the future to completion in a single synchronous poll.  This is safe
1489    // because NoopContextLoader::load returns std::future::ready(None), which
1490    // is always Poll::Ready on the first poll.
1491    let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &NOOP_VTABLE)) };
1492    let mut cx = Context::from_waker(&waker);
1493    let mut loader = NoopContextLoader;
1494    let mut fut = std::pin::pin!(convert_with_loader(root, &mut loader, base_iri));
1495    match fut.as_mut().poll(&mut cx) {
1496        Poll::Ready(v) => v,
1497        Poll::Pending => unreachable!("NoopContextLoader always resolves immediately"),
1498    }
1499}
1500
1501/// Convert a parsed JSON-LD CST to RDF triples, using `loader` to fetch
1502/// remote `@context` documents asynchronously.
1503///
1504/// `base_iri` sets the initial base IRI for resolving relative IRIs.  When the
1505/// document was fetched from a URL, pass that URL here.  An explicit `@base` in
1506/// the document's `@context` will override this value.
1507///
1508/// Returns `(turtle, context)` where `context` is the accumulated
1509/// [`ActiveContext`] built from all `@context` entries in the document.
1510pub async fn convert_with_loader(
1511    root: &Node,
1512    loader: &mut dyn ContextLoader,
1513    base_iri: Option<String>,
1514) -> (Turtle, ActiveContext) {
1515    let mut state = ConvertState::new(base_iri.clone());
1516    let active = ActiveContext {
1517        base: base_iri,
1518        ..Default::default()
1519    };
1520
1521    // jsonldDoc ::= jsonValue — the JsonValue is a direct child of ROOT.
1522    if let Some(json_val_node) = root.children().find(|c| c.kind() == SyntaxKind::JsonValue) {
1523        if let Some(val) = cst_to_value(&json_val_node) {
1524            process_document(&mut state, &active, loader, &val).await;
1525        }
1526    }
1527
1528    state.into_turtle()
1529}
1530
1531// ── Tests ─────────────────────────────────────────────────────────────────────
1532
1533#[cfg(test)]
1534mod tests {
1535    use super::super::parser::{Rule, SyntaxKind as SK};
1536    use super::*;
1537
1538    fn parse_jsonld(input: &str) -> Turtle {
1539        use crate::parse as crate_parse;
1540        let rule = Rule::new(SK::JsonldDoc);
1541        let (result, _) = crate_parse(rule, input);
1542        let root = result.syntax::<Lang>();
1543        convert(&root, None).0
1544    }
1545
1546    fn parse_jsonld_with_context(input: &str) -> (Turtle, ActiveContext) {
1547        use crate::parse as crate_parse;
1548        let rule = Rule::new(SK::JsonldDoc);
1549        let (result, _) = crate_parse(rule, input);
1550        let root = result.syntax::<Lang>();
1551        convert(&root, None)
1552    }
1553
1554    fn parse_jsonld_with_base(input: &str, base_iri: &str) -> (Turtle, ActiveContext) {
1555        use crate::parse as crate_parse;
1556        let rule = Rule::new(SK::JsonldDoc);
1557        let (result, _) = crate_parse(rule, input);
1558        let root = result.syntax::<Lang>();
1559        convert(&root, Some(base_iri.to_string()))
1560    }
1561
1562    fn triples_of(t: &Turtle) -> &[Spanned<Triple>] {
1563        &t.triples
1564    }
1565
1566    /// Resolved absolute IRI of a named node: `Full`'s string, or a `Prefixed`
1567    /// node's context-`computed` IRI (compact JSON-LD IRIs are stored prefixed).
1568    fn nn_iri(nn: &NamedNode) -> Option<&str> {
1569        match nn {
1570            NamedNode::Full(s, _) => Some(s.as_str()),
1571            NamedNode::Prefixed { computed, .. } => computed.as_deref(),
1572            _ => None,
1573        }
1574    }
1575
1576    fn term_iri(term: &Term) -> Option<&str> {
1577        match term {
1578            Term::NamedNode(nn) => nn_iri(nn),
1579            _ => None,
1580        }
1581    }
1582
1583    fn subject_iri(triple: &Triple) -> Option<&str> {
1584        term_iri(&triple.subject.0)
1585    }
1586
1587    fn predicate_iri(triple: &Triple) -> Option<&str> {
1588        triple.po.first().and_then(|po| term_iri(&po.predicate.0))
1589    }
1590
1591    fn object_iri(triple: &Triple) -> Option<&str> {
1592        triple
1593            .po
1594            .first()
1595            .and_then(|po| po.object.first())
1596            .and_then(|o| term_iri(&o.0))
1597    }
1598
1599    fn object_literal(triple: &Triple) -> Option<(&str, Option<&str>, Option<&str>)> {
1600        triple
1601            .po
1602            .first()
1603            .and_then(|po| po.object.first())
1604            .and_then(|o| {
1605                if let Term::Literal(Literal::RDF(lit)) = &o.0 {
1606                    let lang = lit.lang.as_ref().map(|l| l.as_str());
1607                    let ty = lit.ty.as_ref().and_then(|sp| nn_iri(sp.value()));
1608                    Some((lit.value.as_str(), lang, ty))
1609                } else {
1610                    None
1611                }
1612            })
1613    }
1614
1615    // ── 1. Basic node with @id and one property ───────────────────────────────
1616
1617    #[test]
1618    fn test_basic_triple() {
1619        let t = parse_jsonld(
1620            r#"
1621            {
1622              "@id": "http://example.org/s",
1623              "http://example.org/p": "hello"
1624            }
1625        "#,
1626        );
1627        let triples = triples_of(&t);
1628        assert_eq!(triples.len(), 1);
1629        assert_eq!(subject_iri(&triples[0]), Some("http://example.org/s"));
1630        assert_eq!(predicate_iri(&triples[0]), Some("http://example.org/p"));
1631        assert_eq!(
1632            object_literal(&triples[0]),
1633            Some(("hello", None, Some(XSD_STRING)))
1634        );
1635    }
1636
1637    // ── 2. Multiple properties ────────────────────────────────────────────────
1638
1639    #[test]
1640    fn test_multiple_properties() {
1641        let t = parse_jsonld(
1642            r#"
1643            {
1644              "@id": "http://example.org/s",
1645              "http://example.org/p1": "a",
1646              "http://example.org/p2": "b"
1647            }
1648        "#,
1649        );
1650        assert_eq!(triples_of(&t).len(), 2);
1651    }
1652
1653    // ── 3. Anonymous nested object → blank node ───────────────────────────────
1654
1655    #[test]
1656    fn test_nested_blank_node() {
1657        let t = parse_jsonld(
1658            r#"
1659            {
1660              "@id": "http://example.org/s",
1661              "http://example.org/p": {
1662                "http://example.org/q": "inner"
1663              }
1664            }
1665        "#,
1666        );
1667        // 2 triples: outer s→blank, inner blank→inner
1668        assert_eq!(triples_of(&t).len(), 2);
1669        let outer = &triples_of(&t)[1]; // outer triple
1670        assert_eq!(subject_iri(outer), Some("http://example.org/s"));
1671        // Object should be a blank node
1672        if let Some(po) = outer.po.first() {
1673            if let Some(obj) = po.object.first() {
1674                assert!(matches!(obj.0, Term::BlankNode(_)));
1675            } else {
1676                panic!("no object");
1677            }
1678        }
1679    }
1680
1681    // ── 4. Inline @context with term definitions ──────────────────────────────
1682
1683    #[test]
1684    fn test_inline_context_term() {
1685        let t = parse_jsonld(
1686            r#"
1687            {
1688              "@context": { "name": "http://schema.org/name" },
1689              "@id": "http://example.org/alice",
1690              "name": "Alice"
1691            }
1692        "#,
1693        );
1694        let triples = triples_of(&t);
1695        assert_eq!(triples.len(), 1);
1696        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
1697        assert_eq!(
1698            object_literal(&triples[0]),
1699            Some(("Alice", None, Some(XSD_STRING)))
1700        );
1701    }
1702
1703    // ── 5. @vocab expansion ───────────────────────────────────────────────────
1704
1705    #[test]
1706    fn test_vocab_expansion() {
1707        let t = parse_jsonld(
1708            r#"
1709            {
1710              "@context": { "@vocab": "http://schema.org/" },
1711              "@id": "http://example.org/alice",
1712              "name": "Alice"
1713            }
1714        "#,
1715        );
1716        let triples = triples_of(&t);
1717        assert_eq!(triples.len(), 1);
1718        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
1719    }
1720
1721    // ── 6. @type generates rdf:type triple ───────────────────────────────────
1722
1723    #[test]
1724    fn test_type_triple() {
1725        let t = parse_jsonld(
1726            r#"
1727            {
1728              "@id": "http://example.org/alice",
1729              "@type": "http://schema.org/Person"
1730            }
1731        "#,
1732        );
1733        let triples = triples_of(&t);
1734        assert_eq!(triples.len(), 1);
1735        assert_eq!(predicate_iri(&triples[0]), Some(RDF_TYPE));
1736        assert_eq!(object_iri(&triples[0]), Some("http://schema.org/Person"));
1737    }
1738
1739    // ── 7. @type array ────────────────────────────────────────────────────────
1740
1741    #[test]
1742    fn test_type_array() {
1743        let t = parse_jsonld(
1744            r#"
1745            {
1746              "@id": "http://example.org/x",
1747              "@type": ["http://example.org/A", "http://example.org/B"]
1748            }
1749        "#,
1750        );
1751        assert_eq!(triples_of(&t).len(), 2);
1752    }
1753
1754    // ── 8. Value object with @language ────────────────────────────────────────
1755
1756    #[test]
1757    fn test_value_object_language() {
1758        let t = parse_jsonld(
1759            r#"
1760            {
1761              "@id": "http://example.org/s",
1762              "http://example.org/p": { "@value": "hello", "@language": "en" }
1763            }
1764        "#,
1765        );
1766        let triples = triples_of(&t);
1767        assert_eq!(triples.len(), 1);
1768        assert_eq!(
1769            object_literal(&triples[0]),
1770            Some(("hello", Some("en"), None))
1771        );
1772    }
1773
1774    #[test]
1775    fn test_type_array_per_element_spans() {
1776        let input = r#"{
1777  "@id": "http://example.org/s",
1778  "@type": ["http://example.org/A", "http://example.org/B"]
1779}"#;
1780        let t = parse_jsonld(input);
1781        let mut type_spans: Vec<&str> = Vec::new();
1782        for tr in triples_of(&t) {
1783            for po in &tr.po {
1784                let is_type = matches!(
1785                    &po.predicate.0,
1786                    Term::NamedNode(NamedNode::Full(s, _)) if s == RDF_TYPE
1787                );
1788                if !is_type {
1789                    continue;
1790                }
1791                for o in &po.object {
1792                    type_spans.push(&input[o.1.clone()]);
1793                }
1794            }
1795        }
1796        // Each @type entry must cover only its own string token, not the whole array.
1797        assert_eq!(
1798            type_spans,
1799            vec!["\"http://example.org/A\"", "\"http://example.org/B\"",]
1800        );
1801    }
1802
1803    // ── 9. Value object with explicit @type ───────────────────────────────────
1804
1805    #[test]
1806    fn test_value_object_typed() {
1807        let t = parse_jsonld(
1808            r#"
1809            {
1810              "@id": "http://example.org/s",
1811              "http://example.org/p": { "@value": "42", "@type": "http://www.w3.org/2001/XMLSchema#integer" }
1812            }
1813        "#,
1814        );
1815        let triples = triples_of(&t);
1816        assert_eq!(triples.len(), 1);
1817        assert_eq!(
1818            object_literal(&triples[0]),
1819            Some(("42", None, Some(XSD_INTEGER)))
1820        );
1821    }
1822
1823    // ── 10. Native number → xsd:integer / xsd:double ─────────────────────────
1824
1825    #[test]
1826    fn test_native_integer() {
1827        let t = parse_jsonld(
1828            r#"
1829            { "@id": "http://example.org/s", "http://example.org/p": 42 }
1830        "#,
1831        );
1832        assert_eq!(
1833            object_literal(&triples_of(&t)[0]),
1834            Some(("42", None, Some(XSD_INTEGER)))
1835        );
1836    }
1837
1838    #[test]
1839    fn test_native_double() {
1840        let t = parse_jsonld(
1841            r#"
1842            { "@id": "http://example.org/s", "http://example.org/p": 3.14 }
1843        "#,
1844        );
1845        assert_eq!(
1846            object_literal(&triples_of(&t)[0]),
1847            Some(("3.14", None, Some(XSD_DOUBLE)))
1848        );
1849    }
1850
1851    // ── 11. Boolean → xsd:boolean ────────────────────────────────────────────
1852
1853    #[test]
1854    fn test_boolean() {
1855        let t = parse_jsonld(
1856            r#"
1857            { "@id": "http://example.org/s", "http://example.org/p": true }
1858        "#,
1859        );
1860        assert_eq!(
1861            object_literal(&triples_of(&t)[0]),
1862            Some(("true", None, Some(XSD_BOOLEAN)))
1863        );
1864    }
1865
1866    // ── 12. Null value is skipped ─────────────────────────────────────────────
1867
1868    #[test]
1869    fn test_null_skipped() {
1870        let t = parse_jsonld(
1871            r#"
1872            { "@id": "http://example.org/s", "http://example.org/p": null }
1873        "#,
1874        );
1875        assert_eq!(triples_of(&t).len(), 0);
1876    }
1877
1878    // ── 13. @list → rdf:first/rdf:rest chain ─────────────────────────────────
1879
1880    #[test]
1881    fn test_list() {
1882        let t = parse_jsonld(
1883            r#"
1884            {
1885              "@id": "http://example.org/s",
1886              "http://example.org/p": { "@list": ["a", "b"] }
1887            }
1888        "#,
1889        );
1890        // 1 triple for the property + 2*2 triples for the two list nodes
1891        let triples = triples_of(&t);
1892        assert!(triples.len() >= 3);
1893        // The property triple's object should be a blank node (list head)
1894        let prop_triple = triples.last().unwrap();
1895        if let Some(po) = prop_triple.po.first() {
1896            if let Some(obj) = po.object.first() {
1897                assert!(matches!(obj.0, Term::BlankNode(_)));
1898            }
1899        }
1900        // rdf:first and rdf:rest triples should be present
1901        let has_first = triples.iter().any(|t| predicate_iri(t) == Some(RDF_FIRST));
1902        let has_rest = triples.iter().any(|t| predicate_iri(t) == Some(RDF_REST));
1903        assert!(has_first);
1904        assert!(has_rest);
1905    }
1906
1907    // ── 14. Empty @list → rdf:nil ─────────────────────────────────────────────
1908
1909    #[test]
1910    fn test_empty_list() {
1911        let t = parse_jsonld(
1912            r#"
1913            {
1914              "@id": "http://example.org/s",
1915              "http://example.org/p": { "@list": [] }
1916            }
1917        "#,
1918        );
1919        let triples = triples_of(&t);
1920        assert_eq!(triples.len(), 1);
1921        assert_eq!(object_iri(&triples[0]), Some(RDF_NIL));
1922    }
1923
1924    // ── 15. @graph → named graph triples ─────────────────────────────────────
1925
1926    #[test]
1927    fn test_named_graph() {
1928        let t = parse_jsonld(
1929            r#"
1930            {
1931              "@id": "http://example.org/g",
1932              "@graph": [
1933                { "@id": "http://example.org/s", "http://example.org/p": "v" }
1934              ]
1935            }
1936        "#,
1937        );
1938        let triples = triples_of(&t);
1939        // One triple inside the named graph
1940        assert_eq!(triples.len(), 1);
1941        let triple = &triples[0];
1942        if let Some(g) = &triple.graph {
1943            if let Term::NamedNode(NamedNode::Full(g_iri, _)) = &g.0 {
1944                assert_eq!(g_iri, "http://example.org/g");
1945            } else {
1946                panic!("graph should be a named node");
1947            }
1948        } else {
1949            panic!("triple should be in a named graph");
1950        }
1951    }
1952
1953    // ── 16. @reverse property ────────────────────────────────────────────────
1954
1955    #[test]
1956    fn test_reverse() {
1957        let t = parse_jsonld(
1958            r#"
1959            {
1960              "@id": "http://example.org/child",
1961              "@reverse": {
1962                "http://example.org/parent": { "@id": "http://example.org/dad" }
1963              }
1964            }
1965        "#,
1966        );
1967        // Triple: dad → parent → child
1968        let triples = triples_of(&t);
1969        assert_eq!(triples.len(), 1);
1970        assert_eq!(subject_iri(&triples[0]), Some("http://example.org/dad"));
1971        assert_eq!(object_iri(&triples[0]), Some("http://example.org/child"));
1972    }
1973
1974    // ── 17. Top-level array ───────────────────────────────────────────────────
1975
1976    #[test]
1977    fn test_top_level_array() {
1978        let t = parse_jsonld(
1979            r#"
1980            [
1981              { "@id": "http://example.org/a", "http://example.org/p": "x" },
1982              { "@id": "http://example.org/b", "http://example.org/p": "y" }
1983            ]
1984        "#,
1985        );
1986        assert_eq!(triples_of(&t).len(), 2);
1987    }
1988
1989    // ── 18. Compact IRI in context ────────────────────────────────────────────
1990
1991    #[test]
1992    fn test_compact_iri() {
1993        let t = parse_jsonld(
1994            r#"
1995            {
1996              "@context": {
1997                "schema": "http://schema.org/",
1998                "name": "schema:name"
1999              },
2000              "@id": "http://example.org/alice",
2001              "name": "Alice"
2002            }
2003        "#,
2004        );
2005        let triples = triples_of(&t);
2006        assert_eq!(triples.len(), 1);
2007        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2008    }
2009
2010    #[test]
2011    fn test_compact_predicate_kept_prefixed_with_computed() {
2012        // A `prefix:local` predicate keeps its source-faithful prefixed form
2013        // (consistent with the Turtle parser), while the context-computed
2014        // absolute IRI is carried in `computed` for downstream consumers.
2015        let input = r#"{"@context":{"foaf":"http://xmlns.com/foaf/0.1/"},"@id":"http://example.org/alice","foaf:name":"Alice"}"#;
2016        let t = parse_jsonld(input);
2017        let triples = triples_of(&t);
2018        assert_eq!(triples.len(), 1);
2019        match &triples[0].po[0].predicate.0 {
2020            Term::NamedNode(NamedNode::Prefixed {
2021                prefix,
2022                value,
2023                computed,
2024                ..
2025            }) => {
2026                assert_eq!(prefix, "foaf");
2027                assert_eq!(value, "name");
2028                assert_eq!(
2029                    computed.as_deref(),
2030                    Some("http://xmlns.com/foaf/0.1/name"),
2031                    "computed should hold the context-resolved absolute IRI"
2032                );
2033            }
2034            other => panic!("expected a prefixed predicate, got {:?}", other),
2035        }
2036        // Downstream helpers resolve to the full IRI via `computed`.
2037        assert_eq!(
2038            predicate_iri(&triples[0]),
2039            Some("http://xmlns.com/foaf/0.1/name")
2040        );
2041    }
2042
2043    #[test]
2044    fn test_whole_term_compact_iri_takes_precedence() {
2045        // When a term literally named `foaf:name` is defined, JSON-LD whole-term
2046        // precedence resolves it to that term's IRI — not `foaf:` + `name`. The
2047        // node keeps the whole compact source as the lookup key, and `computed`
2048        // reflects the term's IRI so a Turtle-style re-expansion can't diverge.
2049        let input = r#"{"@context":{"foaf":"http://xmlns.com/foaf/0.1/","foaf:name":"http://example.org/fullName"},"@id":"http://example.org/alice","foaf:name":"Alice"}"#;
2050        let t = parse_jsonld(input);
2051        let triples = triples_of(&t);
2052        assert_eq!(triples.len(), 1);
2053        match &triples[0].po[0].predicate.0 {
2054            Term::NamedNode(NamedNode::Prefixed {
2055                prefix,
2056                value,
2057                computed,
2058                ..
2059            }) => {
2060                assert_eq!(prefix, "foaf:name");
2061                assert_eq!(value, "");
2062                assert_eq!(computed.as_deref(), Some("http://example.org/fullName"));
2063            }
2064            other => panic!("expected a prefixed predicate, got {:?}", other),
2065        }
2066        assert_eq!(
2067            predicate_iri(&triples[0]),
2068            Some("http://example.org/fullName")
2069        );
2070        // Display renders the whole-term compact IRI without a spurious colon.
2071        assert_eq!(format!("{}", triples[0].po[0].predicate.0), "foaf:name");
2072    }
2073
2074    // ── 19. @type coercion to @id ─────────────────────────────────────────────
2075
2076    #[test]
2077    fn test_type_coercion_id() {
2078        let t = parse_jsonld(
2079            r#"
2080            {
2081              "@context": {
2082                "knows": { "@id": "http://schema.org/knows", "@type": "@id" }
2083              },
2084              "@id": "http://example.org/alice",
2085              "knows": "http://example.org/bob"
2086            }
2087        "#,
2088        );
2089        let triples = triples_of(&t);
2090        assert_eq!(triples.len(), 1);
2091        assert_eq!(object_iri(&triples[0]), Some("http://example.org/bob"));
2092    }
2093
2094    // ── 20. Default language from context ─────────────────────────────────────
2095
2096    #[test]
2097    fn test_default_language() {
2098        let t = parse_jsonld(
2099            r#"
2100            {
2101              "@context": { "@language": "fr" },
2102              "@id": "http://example.org/s",
2103              "http://example.org/p": "Bonjour"
2104            }
2105        "#,
2106        );
2107        let triples = triples_of(&t);
2108        assert_eq!(triples.len(), 1);
2109        assert_eq!(
2110            object_literal(&triples[0]),
2111            Some(("Bonjour", Some("fr"), None))
2112        );
2113    }
2114
2115    // ── 21. Error recovery — malformed JSON-LD still produces some triples ────
2116
2117    #[test]
2118    fn test_error_recovery() {
2119        // Missing closing brace — parser should still extract the one valid property
2120        let t = parse_jsonld(r#"{ "@id": "http://example.org/s", "http://example.org/p": "v" "#);
2121        // We may or may not get the triple depending on how error recovery works,
2122        // but the converter should not panic.
2123        let _ = triples_of(&t);
2124    }
2125
2126    // ── Span tests ────────────────────────────────────────────────────────────
2127    //
2128    // Each test pinpoints where a token lives in the source string and asserts
2129    // that the corresponding Spanned range starts at that position.
2130
2131    /// Byte offset of the first occurrence of `needle` in `haystack`.
2132    fn offset_of(haystack: &str, needle: &str) -> usize {
2133        haystack
2134            .find(needle)
2135            .unwrap_or_else(|| panic!("needle {:?} not found in {:?}", needle, haystack))
2136    }
2137
2138    #[test]
2139    fn test_subject_span_points_to_id_value() {
2140        // Compact single-line so byte offsets are easy to calculate.
2141        let input = r#"{"@id":"http://example.org/s","http://example.org/p":"v"}"#;
2142        let t = parse_jsonld(input);
2143        let triples = triples_of(&t);
2144        assert_eq!(triples.len(), 1);
2145
2146        let expected_start = offset_of(input, "\"http://example.org/s\"");
2147        let subject_span = &triples[0].subject.1;
2148        assert_eq!(
2149            subject_span.start, expected_start,
2150            "subject span should start at the @id value, not the whole object"
2151        );
2152    }
2153
2154    #[test]
2155    fn test_predicate_span_points_to_key_field() {
2156        let input = r#"{"@id":"http://example.org/s","http://example.org/p":"v"}"#;
2157        let t = parse_jsonld(input);
2158        let triples = triples_of(&t);
2159        assert_eq!(triples.len(), 1);
2160
2161        let expected_start = offset_of(input, "\"http://example.org/p\"");
2162        let pred_span = &triples[0].po[0].predicate.1;
2163        assert_eq!(
2164            pred_span.start, expected_start,
2165            "predicate span should start at the key field"
2166        );
2167    }
2168
2169    #[test]
2170    fn test_predicate_span_points_to_compact_iri_key_not_expanded_iri() {
2171        // The predicate "foaf:name" expands to the full FOAF URI via context, but
2172        // the span must point to the compact source key "foaf:name", not to the
2173        // expanded IRI (which doesn't exist in the source at all).
2174        let input = r#"{"@context":{"foaf":"http://xmlns.com/foaf/0.1/"},"@id":"http://example.org/alice","foaf:name":"Alice"}"#;
2175        let t = parse_jsonld(input);
2176        let triples = triples_of(&t);
2177        assert_eq!(triples.len(), 1);
2178        assert_eq!(
2179            predicate_iri(&triples[0]),
2180            Some("http://xmlns.com/foaf/0.1/name")
2181        );
2182
2183        let expected_start = offset_of(input, "\"foaf:name\"");
2184        let pred_span = &triples[0].po[0].predicate.1;
2185        assert_eq!(
2186            pred_span.start, expected_start,
2187            "predicate span should point to the compact key in source, not the expanded IRI"
2188        );
2189        // The span should cover exactly "foaf:name" (with quotes)
2190        assert_eq!(
2191            pred_span.end,
2192            expected_start + "\"foaf:name\"".len(),
2193            "predicate span end should be just past the compact key"
2194        );
2195    }
2196
2197    #[test]
2198    fn test_object_span_points_to_value() {
2199        let input = r#"{"@id":"http://example.org/s","http://example.org/p":"hello"}"#;
2200        let t = parse_jsonld(input);
2201        let triples = triples_of(&t);
2202        assert_eq!(triples.len(), 1);
2203
2204        let expected_start = offset_of(input, "\"hello\"");
2205        let obj_span = &triples[0].po[0].object[0].1;
2206        assert_eq!(
2207            obj_span.start, expected_start,
2208            "object span should start at the value, not the whole object"
2209        );
2210    }
2211
2212    #[test]
2213    fn test_object_span_for_iri_value() {
2214        // Object is an IRI (via @type coercion)
2215        let input = r#"{"@context":{"p":{"@id":"http://example.org/p","@type":"@id"}},"@id":"http://example.org/s","p":"http://example.org/o"}"#;
2216        let t = parse_jsonld(input);
2217        let triples = triples_of(&t);
2218        assert_eq!(triples.len(), 1);
2219
2220        // The object "http://example.org/o" appears once at the end
2221        let expected_start = offset_of(input, "\"http://example.org/o\"");
2222        let obj_span = &triples[0].po[0].object[0].1;
2223        assert_eq!(
2224            obj_span.start, expected_start,
2225            "object IRI span should point to the value string"
2226        );
2227    }
2228
2229    #[test]
2230    fn test_object_span_for_number() {
2231        let input = r#"{"@id":"http://example.org/s","http://example.org/p":42}"#;
2232        let t = parse_jsonld(input);
2233        let triples = triples_of(&t);
2234        assert_eq!(triples.len(), 1);
2235
2236        let expected_start = offset_of(input, "42");
2237        let obj_span = &triples[0].po[0].object[0].1;
2238        assert_eq!(
2239            obj_span.start, expected_start,
2240            "numeric object span should start at the number token"
2241        );
2242    }
2243
2244    #[test]
2245    fn test_spans_are_independent_per_property() {
2246        // Two properties — each should get spans from their own key/value tokens.
2247        let input = r#"{"@id":"http://example.org/s","http://example.org/p1":"v1","http://example.org/p2":"v2"}"#;
2248        let t = parse_jsonld(input);
2249        let triples = triples_of(&t);
2250        assert_eq!(triples.len(), 2);
2251
2252        // Subject span is the same @id value for both triples
2253        let id_start = offset_of(input, "\"http://example.org/s\"");
2254        for triple in triples {
2255            assert_eq!(triple.subject.1.start, id_start);
2256        }
2257
2258        // Predicate and object spans differ between the two triples
2259        let p1_start = offset_of(input, "\"http://example.org/p1\"");
2260        let p2_start = offset_of(input, "\"http://example.org/p2\"");
2261        let pred_starts: Vec<usize> = triples.iter().map(|t| t.po[0].predicate.1.start).collect();
2262        assert!(pred_starts.contains(&p1_start), "p1 span missing");
2263        assert!(pred_starts.contains(&p2_start), "p2 span missing");
2264
2265        let v1_start = offset_of(input, "\"v1\"");
2266        let v2_start = offset_of(input, "\"v2\"");
2267        let obj_starts: Vec<usize> = triples.iter().map(|t| t.po[0].object[0].1.start).collect();
2268        assert!(obj_starts.contains(&v1_start), "v1 span missing");
2269        assert!(obj_starts.contains(&v2_start), "v2 span missing");
2270    }
2271
2272    // ── 22. Remote @context via a custom ContextLoader ────────────────────────
2273
2274    /// Drive an async future to completion using a no-op waker.
2275    ///
2276    /// Safe as long as the future only awaits immediately-ready sub-futures
2277    /// (i.e. no real I/O suspension).
2278    fn block_on_ready<T>(fut: impl std::future::Future<Output = T>) -> T {
2279        use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
2280        fn clone(p: *const ()) -> RawWaker {
2281            RawWaker::new(p, &VTABLE)
2282        }
2283        fn noop(_: *const ()) {}
2284        static VTABLE: RawWakerVTable = RawWakerVTable::new(clone, noop, noop, noop);
2285        let waker = unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABLE)) };
2286        let mut cx = Context::from_waker(&waker);
2287        let mut pinned = std::pin::pin!(fut);
2288        match pinned.as_mut().poll(&mut cx) {
2289            Poll::Ready(v) => v,
2290            Poll::Pending => panic!("future did not complete synchronously"),
2291        }
2292    }
2293
2294    #[test]
2295    fn test_remote_context_loader() {
2296        // A mock loader that returns a hardcoded context document for one URL.
2297        struct MockLoader;
2298        impl ContextLoader for MockLoader {
2299            fn load<'a>(
2300                &'a mut self,
2301                url: &'a str,
2302            ) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>> {
2303                let result = if url == "https://example.org/context.jsonld" {
2304                    Some(
2305                        r#"{
2306                          "@context": {
2307                            "name":   "http://schema.org/name",
2308                            "email":  "http://schema.org/email",
2309                            "Person": "http://schema.org/Person"
2310                          }
2311                        }"#
2312                        .to_string(),
2313                    )
2314                } else {
2315                    None
2316                };
2317                Box::pin(std::future::ready(result))
2318            }
2319        }
2320
2321        let input = r#"
2322        {
2323          "@context": "https://example.org/context.jsonld",
2324          "@id":   "http://example.org/alice",
2325          "@type": "Person",
2326          "name":  "Alice",
2327          "email": "alice@example.org"
2328        }"#;
2329
2330        let rule = Rule::new(SK::JsonldDoc);
2331        let (result, _) = crate::parse(rule, input);
2332        let root = result.syntax::<Lang>();
2333
2334        let (turtle, _ctx) = block_on_ready(convert_with_loader(&root, &mut MockLoader, None));
2335        let triples = triples_of(&turtle);
2336
2337        // @type → rdf:type + 2 data properties = 3 triples
2338        assert_eq!(triples.len(), 3);
2339
2340        // rdf:type triple
2341        let type_triple = triples
2342            .iter()
2343            .find(|t| predicate_iri(t) == Some(RDF_TYPE))
2344            .expect("missing rdf:type triple");
2345        assert_eq!(subject_iri(type_triple), Some("http://example.org/alice"));
2346        assert_eq!(object_iri(type_triple), Some("http://schema.org/Person"));
2347
2348        // name triple — expanded via the remote context
2349        let name_triple = triples
2350            .iter()
2351            .find(|t| predicate_iri(t) == Some("http://schema.org/name"))
2352            .expect("missing schema:name triple");
2353        assert_eq!(
2354            object_literal(name_triple),
2355            Some(("Alice", None, Some(XSD_STRING)))
2356        );
2357
2358        // email triple
2359        assert!(
2360            triples
2361                .iter()
2362                .any(|t| predicate_iri(t) == Some("http://schema.org/email")),
2363            "missing schema:email triple"
2364        );
2365    }
2366
2367    #[test]
2368    fn test_prefix_map_populated_from_context() {
2369        let t = parse_jsonld(
2370            r#"{
2371            "@context": {
2372                "foaf": "http://xmlns.com/foaf/0.1/",
2373                "schema": "http://schema.org/"
2374            },
2375            "@id": "http://example.org/bob",
2376            "foaf:name": "Bob"
2377        }"#,
2378        );
2379
2380        let prefix_map: std::collections::HashMap<&str, &str> = t
2381            .prefixes
2382            .iter()
2383            .map(|p| {
2384                (
2385                    p.value().prefix.value().as_str(),
2386                    match p.value().value.value() {
2387                        NamedNode::Full(s, _) => s.as_str(),
2388                        _ => "",
2389                    },
2390                )
2391            })
2392            .collect();
2393
2394        assert_eq!(
2395            prefix_map.get("foaf"),
2396            Some(&"http://xmlns.com/foaf/0.1/"),
2397            "foaf prefix missing"
2398        );
2399        assert_eq!(
2400            prefix_map.get("schema"),
2401            Some(&"http://schema.org/"),
2402            "schema prefix missing"
2403        );
2404    }
2405
2406    #[test]
2407    fn test_prefix_map_object_term_definition() {
2408        // Terms defined with @id objects should also populate the prefix map
2409        let t = parse_jsonld(
2410            r#"{
2411            "@context": {
2412                "name": { "@id": "http://xmlns.com/foaf/0.1/name" }
2413            },
2414            "@id": "http://example.org/bob",
2415            "name": "Bob"
2416        }"#,
2417        );
2418
2419        let prefix_map: std::collections::HashMap<&str, &str> = t
2420            .prefixes
2421            .iter()
2422            .map(|p| {
2423                (
2424                    p.value().prefix.value().as_str(),
2425                    match p.value().value.value() {
2426                        NamedNode::Full(s, _) => s.as_str(),
2427                        _ => "",
2428                    },
2429                )
2430            })
2431            .collect();
2432
2433        assert_eq!(
2434            prefix_map.get("name"),
2435            Some(&"http://xmlns.com/foaf/0.1/name"),
2436            "name term missing"
2437        );
2438    }
2439
2440    #[test]
2441    fn test_active_context_returned() {
2442        let (_turtle, ctx) = parse_jsonld_with_context(
2443            r#"{
2444            "@context": {
2445                "@vocab": "http://schema.org/",
2446                "@base": "http://example.org/",
2447                "@language": "en",
2448                "foaf": "http://xmlns.com/foaf/0.1/",
2449                "name": { "@id": "http://xmlns.com/foaf/0.1/name" }
2450            },
2451            "@id": "bob",
2452            "name": "Bob"
2453        }"#,
2454        );
2455
2456        assert_eq!(ctx.vocab.as_deref(), Some("http://schema.org/"));
2457        assert_eq!(ctx.base.as_deref(), Some("http://example.org/"));
2458        assert_eq!(ctx.language.as_deref(), Some("en"));
2459        assert_eq!(
2460            ctx.terms["foaf"].iri.as_deref(),
2461            Some("http://xmlns.com/foaf/0.1/")
2462        );
2463        assert_eq!(
2464            ctx.terms["name"].iri.as_deref(),
2465            Some("http://xmlns.com/foaf/0.1/name")
2466        );
2467    }
2468
2469    // ── 23. base_iri parameter resolves relative IRIs ─────────────────────────
2470
2471    fn turtle_base_iri(t: &Turtle) -> Option<&str> {
2472        t.base.as_ref().and_then(|b| {
2473            if let NamedNode::Full(s, _) = &b.value().1.value() {
2474                Some(s.as_str())
2475            } else {
2476                None
2477            }
2478        })
2479    }
2480
2481    #[test]
2482    fn test_base_iri_resolves_relative_subject() {
2483        // Without a @base in the document, the base_iri parameter should be used.
2484        let (t, _) = parse_jsonld_with_base(
2485            r#"{ "@id": "alice", "http://schema.org/name": "Alice" }"#,
2486            "http://example.org/",
2487        );
2488        let triples = triples_of(&t);
2489        assert_eq!(triples.len(), 1);
2490        assert_eq!(subject_iri(&triples[0]), Some("http://example.org/alice"));
2491        // No @base in the document → Turtle.base is None, set_base carries the URL
2492        assert_eq!(turtle_base_iri(&t), None);
2493        assert_eq!(t.set_base.as_deref(), Some("http://example.org/"));
2494    }
2495
2496    #[test]
2497    fn test_base_iri_resolves_relative_object_with_type_id() {
2498        let (t, _) = parse_jsonld_with_base(
2499            r#"{
2500              "@context": { "knows": { "@id": "http://schema.org/knows", "@type": "@id" } },
2501              "@id": "http://example.org/alice",
2502              "knows": "bob"
2503            }"#,
2504            "http://example.org/",
2505        );
2506        let triples = triples_of(&t);
2507        assert_eq!(triples.len(), 1);
2508        assert_eq!(object_iri(&triples[0]), Some("http://example.org/bob"));
2509    }
2510
2511    #[test]
2512    fn test_document_base_overrides_base_iri_parameter() {
2513        // An explicit @base in the document must win over the base_iri parameter.
2514        let (t, ctx) = parse_jsonld_with_base(
2515            r#"{
2516              "@context": { "@base": "http://override.org/" },
2517              "@id": "carol",
2518              "http://schema.org/name": "Carol"
2519            }"#,
2520            "http://example.org/",
2521        );
2522        let triples = triples_of(&t);
2523        assert_eq!(triples.len(), 1);
2524        assert_eq!(subject_iri(&triples[0]), Some("http://override.org/carol"));
2525        assert_eq!(ctx.base.as_deref(), Some("http://override.org/"));
2526        // @base from the document is reflected in Turtle.base
2527        assert_eq!(turtle_base_iri(&t), Some("http://override.org/"));
2528        // The caller-supplied URL is still in set_base
2529        assert_eq!(t.set_base.as_deref(), Some("http://example.org/"));
2530    }
2531
2532    #[test]
2533    fn test_base_iri_no_effect_on_absolute_iris() {
2534        // Absolute IRIs must not be altered by the base_iri parameter.
2535        let (t, _) = parse_jsonld_with_base(
2536            r#"{ "@id": "http://absolute.example/s", "http://schema.org/name": "S" }"#,
2537            "http://example.org/",
2538        );
2539        let triples = triples_of(&t);
2540        assert_eq!(triples.len(), 1);
2541        assert_eq!(subject_iri(&triples[0]), Some("http://absolute.example/s"));
2542    }
2543
2544    #[test]
2545    fn test_document_at_base_without_base_iri_parameter() {
2546        // @base in the document populates Turtle.base even when no base_iri is passed.
2547        let (t, ctx) = parse_jsonld_with_context(
2548            r#"{
2549            "@context": { "@base": "http://doc.example/" },
2550            "@id": "thing",
2551            "http://schema.org/name": "Thing"
2552        }"#,
2553        );
2554        let triples = triples_of(&t);
2555        assert_eq!(triples.len(), 1);
2556        assert_eq!(subject_iri(&triples[0]), Some("http://doc.example/thing"));
2557        assert_eq!(ctx.base.as_deref(), Some("http://doc.example/"));
2558        assert_eq!(turtle_base_iri(&t), Some("http://doc.example/"));
2559        assert_eq!(t.set_base, None);
2560    }
2561
2562    // ── 24. Local context imports via ContextLoader ───────────────────────────
2563
2564    /// A loader that serves one or more hardcoded (URL → JSON-LD context) pairs,
2565    /// simulating local file serving or a simple in-memory registry.
2566    struct LocalLoader {
2567        entries: Vec<(&'static str, &'static str)>,
2568    }
2569
2570    impl LocalLoader {
2571        fn new(entries: Vec<(&'static str, &'static str)>) -> Self {
2572            Self { entries }
2573        }
2574    }
2575
2576    impl ContextLoader for LocalLoader {
2577        fn load<'a>(
2578            &'a mut self,
2579            url: &'a str,
2580        ) -> Pin<Box<dyn Future<Output = Option<String>> + 'a>> {
2581            let result = self
2582                .entries
2583                .iter()
2584                .find(|(u, _)| *u == url)
2585                .map(|(_, body)| body.to_string());
2586            Box::pin(std::future::ready(result))
2587        }
2588    }
2589
2590    fn parse_with_local_loader(
2591        input: &str,
2592        loader: &mut dyn ContextLoader,
2593    ) -> (Turtle, ActiveContext) {
2594        let rule = Rule::new(SK::JsonldDoc);
2595        let (result, _) = crate::parse(rule, input);
2596        let root = result.syntax::<Lang>();
2597        block_on_ready(convert_with_loader(&root, loader, None))
2598    }
2599
2600    #[test]
2601    fn test_local_context_terms_expanded() {
2602        // The document references a local context URL; the loader provides it.
2603        let mut loader = LocalLoader::new(vec![(
2604            "file:///contexts/person.jsonld",
2605            r#"{ "@context": { "name": "http://schema.org/name", "Person": "http://schema.org/Person" } }"#,
2606        )]);
2607
2608        let (turtle, _) = parse_with_local_loader(
2609            r#"{
2610              "@context": "file:///contexts/person.jsonld",
2611              "@id": "http://example.org/alice",
2612              "@type": "Person",
2613              "name": "Alice"
2614            }"#,
2615            &mut loader,
2616        );
2617        let triples = triples_of(&turtle);
2618        assert_eq!(triples.len(), 2);
2619        assert!(
2620            triples.iter().any(|t| predicate_iri(t) == Some(RDF_TYPE)
2621                && object_iri(t) == Some("http://schema.org/Person")),
2622            "missing rdf:type triple"
2623        );
2624        assert!(
2625            triples
2626                .iter()
2627                .any(|t| predicate_iri(t) == Some("http://schema.org/name")),
2628            "missing schema:name triple"
2629        );
2630    }
2631
2632    #[test]
2633    fn test_local_context_array_merges_multiple_imports() {
2634        // A context array that mixes a local import with an inline object.
2635        let mut loader = LocalLoader::new(vec![(
2636            "file:///contexts/base.jsonld",
2637            r#"{ "@context": { "schema": "http://schema.org/" } }"#,
2638        )]);
2639
2640        let (turtle, ctx) = parse_with_local_loader(
2641            r#"{
2642              "@context": [
2643                "file:///contexts/base.jsonld",
2644                { "name": "schema:name" }
2645              ],
2646              "@id": "http://example.org/bob",
2647              "name": "Bob"
2648            }"#,
2649            &mut loader,
2650        );
2651        let triples = triples_of(&turtle);
2652        assert_eq!(triples.len(), 1);
2653        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2654        // Both the imported prefix and the inline term should be in the context.
2655        assert_eq!(
2656            ctx.terms["schema"].iri.as_deref(),
2657            Some("http://schema.org/")
2658        );
2659        assert_eq!(
2660            ctx.terms["name"].iri.as_deref(),
2661            Some("http://schema.org/name")
2662        );
2663    }
2664
2665    #[test]
2666    fn test_local_context_unknown_url_is_skipped() {
2667        // If the loader returns None for a URL, processing continues without that
2668        // context — no panic, no partial expansion.
2669        let mut loader = LocalLoader::new(vec![]); // serves nothing
2670
2671        let (turtle, _) = parse_with_local_loader(
2672            r#"{
2673              "@context": "file:///contexts/missing.jsonld",
2674              "@id": "http://example.org/s",
2675              "http://example.org/p": "v"
2676            }"#,
2677            &mut loader,
2678        );
2679        // The absolute-IRI property must still be emitted.
2680        let triples = triples_of(&turtle);
2681        assert_eq!(triples.len(), 1);
2682        assert_eq!(predicate_iri(&triples[0]), Some("http://example.org/p"));
2683    }
2684
2685    #[test]
2686    fn test_local_context_chained_imports() {
2687        // context_a imports context_b; the document imports context_a.
2688        // This verifies that the loader is called recursively for nested imports.
2689        let mut loader = LocalLoader::new(vec![
2690            (
2691                "file:///ctx_a.jsonld",
2692                r#"{ "@context": ["file:///ctx_b.jsonld", { "name": "http://schema.org/name" }] }"#,
2693            ),
2694            (
2695                "file:///ctx_b.jsonld",
2696                r#"{ "@context": { "schema": "http://schema.org/" } }"#,
2697            ),
2698        ]);
2699
2700        let (turtle, ctx) = parse_with_local_loader(
2701            r#"{
2702              "@context": "file:///ctx_a.jsonld",
2703              "@id": "http://example.org/carol",
2704              "name": "Carol"
2705            }"#,
2706            &mut loader,
2707        );
2708        let triples = triples_of(&turtle);
2709        assert_eq!(triples.len(), 1);
2710        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2711        assert_eq!(
2712            ctx.terms["schema"].iri.as_deref(),
2713            Some("http://schema.org/")
2714        );
2715    }
2716
2717    #[test]
2718    fn test_relative_context_url_resolved_against_document_base() {
2719        // The @context value is a relative URL.  With a base_iri parameter set,
2720        // the loader should receive the fully-resolved absolute URL.
2721        let rule = Rule::new(SK::JsonldDoc);
2722        let input = r#"{
2723          "@context": "context.jsonld",
2724          "@id": "http://example.org/s",
2725          "name": "Alice"
2726        }"#;
2727        let (result, _) = crate::parse(rule, input);
2728        let root = result.syntax::<Lang>();
2729
2730        let mut loader = LocalLoader::new(vec![(
2731            "http://example.org/context.jsonld",
2732            r#"{ "@context": { "name": "http://schema.org/name" } }"#,
2733        )]);
2734
2735        let (turtle, _) = block_on_ready(convert_with_loader(
2736            &root,
2737            &mut loader,
2738            Some("http://example.org/".to_string()),
2739        ));
2740        let triples = triples_of(&turtle);
2741        assert_eq!(triples.len(), 1);
2742        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2743    }
2744
2745    #[test]
2746    fn test_relative_context_url_resolved_against_at_base() {
2747        // When the document sets @base before the relative context URL appears
2748        // (as part of an array context), that @base is used for resolution.
2749        let rule = Rule::new(SK::JsonldDoc);
2750        let input = r#"{
2751          "@context": [
2752            { "@base": "http://other.example/" },
2753            "ctx.jsonld"
2754          ],
2755          "@id": "http://example.org/s",
2756          "label": "Hello"
2757        }"#;
2758        let (result, _) = crate::parse(rule, input);
2759        let root = result.syntax::<Lang>();
2760
2761        let mut loader = LocalLoader::new(vec![(
2762            "http://other.example/ctx.jsonld",
2763            r#"{ "@context": { "label": "http://schema.org/name" } }"#,
2764        )]);
2765
2766        let (turtle, _) = block_on_ready(convert_with_loader(&root, &mut loader, None));
2767        let triples = triples_of(&turtle);
2768        assert_eq!(triples.len(), 1);
2769        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2770    }
2771
2772    #[test]
2773    fn test_local_context_with_base_iri_parameter() {
2774        // Combines a local context import with the base_iri parameter so that
2775        // relative subject IRIs are resolved correctly.
2776        let rule = Rule::new(SK::JsonldDoc);
2777        let input = r#"{
2778          "@context": "file:///contexts/vocab.jsonld",
2779          "@id": "dave",
2780          "name": "Dave"
2781        }"#;
2782        let (result, _) = crate::parse(rule, input);
2783        let root = result.syntax::<Lang>();
2784
2785        let mut loader = LocalLoader::new(vec![(
2786            "file:///contexts/vocab.jsonld",
2787            r#"{ "@context": { "name": "http://schema.org/name" } }"#,
2788        )]);
2789
2790        let (turtle, _) = block_on_ready(convert_with_loader(
2791            &root,
2792            &mut loader,
2793            Some("http://example.org/people/".to_string()),
2794        ));
2795
2796        let triples = triples_of(&turtle);
2797        assert_eq!(triples.len(), 1);
2798        assert_eq!(
2799            subject_iri(&triples[0]),
2800            Some("http://example.org/people/dave")
2801        );
2802        assert_eq!(predicate_iri(&triples[0]), Some("http://schema.org/name"));
2803    }
2804
2805    // ── Formatter tests ──────────────────────────────────────────────────────
2806
2807    fn format_jsonld(input: &str, width: usize) -> String {
2808        use super::super::parser::format;
2809        use crate::parse as crate_parse;
2810        let rule = Rule::new(SK::JsonldDoc);
2811        let (result, _) = crate_parse(rule, input);
2812        let root = result.syntax::<Lang>();
2813        format::format(&root, width)
2814    }
2815
2816    #[test]
2817    fn format_small_object_fits_on_one_line() {
2818        // A small object that fits in 80 columns stays flat.
2819        // Prettier-style: spaces inside braces in flat mode (Line → " ").
2820        let input = r#"{"a": 1, "b": 2}"#;
2821        let out = format_jsonld(input, 80);
2822        assert_eq!(out, "{ \"a\": 1, \"b\": 2 }\n");
2823    }
2824
2825    #[test]
2826    fn format_large_object_breaks() {
2827        // An object too wide for the column limit expands to one member per line.
2828        let input = r#"{"key": "value"}"#;
2829        let out = format_jsonld(input, 10);
2830        assert_eq!(out, "{\n  \"key\": \"value\"\n}\n");
2831    }
2832
2833    #[test]
2834    fn format_multiple_members_break() {
2835        let input = r#"{"a":1,"b":2,"c":3}"#;
2836        let out = format_jsonld(input, 10);
2837        assert_eq!(out, "{\n  \"a\": 1,\n  \"b\": 2,\n  \"c\": 3\n}\n");
2838    }
2839
2840    #[test]
2841    fn format_nested_objects() {
2842        // Both inner and outer fit at wide width — both stay flat.
2843        let input = r#"{"outer":{"inner":1}}"#;
2844        let out = format_jsonld(input, 80);
2845        assert_eq!(out, "{ \"outer\": { \"inner\": 1 } }\n");
2846    }
2847
2848    #[test]
2849    fn format_is_idempotent() {
2850        // Formatting already-formatted output produces the same result.
2851        let input = r#"{"key": "value", "num": 42}"#;
2852        let first = format_jsonld(input, 80);
2853        let second = format_jsonld(&first, 80);
2854        assert_eq!(first, second);
2855    }
2856}