Skip to main content

jsonld_compaction/
iri.rs

1use crate::{Options, TypeLangValue};
2use contextual::WithContext;
3use jsonld_core::{
4    Container,
5    Context,
6    Indexed,
7    Nullable,
8    Object,
9    ProcessingMode,
10    Term,
11    Type,
12    Value,
13    context::{
14        CACHED_KEYWORDS,
15        CompactIriKeyRef,
16        KeywordAliases,
17        inverse::{LangSelection, Selection, TypeSelection},
18    },
19    object,
20};
21use jsonld_syntax::{Keyword, is_keyword, is_keyword_like};
22use rdfx::vocabulary::Vocabulary;
23use smallvec::SmallVec;
24use std::{hash::Hash, sync::Arc};
25
26/// An IRI cannot be written out because it would be read back as a compact IRI.
27///
28/// Raised when neither a term nor a compact IRI could be found for an IRI whose
29/// scheme is itself a term of the active context: emitting the IRI verbatim
30/// would make a reader expand `scheme:rest` through that term instead.
31///
32/// The [specification][1] narrows this to terms whose `prefix` flag is set and
33/// to IRIs with no authority component (no `//`); the check here does not
34/// distinguish those cases and so can fire slightly more often.
35///
36/// [1]: https://www.w3.org/TR/json-ld-api/#iri-compaction
37#[derive(Debug, Clone, Copy, thiserror::Error)]
38#[error("IRI confused with prefix")]
39pub struct IriConfusedWithPrefix;
40
41/// Checks whether the compact IRI `key:suffix` is a better candidate than the
42/// current best `current`.
43///
44/// The rule is *shortest first, then lexicographically least*: a shorter
45/// candidate always wins, and among candidates of equal length the byte-wise
46/// smallest wins. This is the ordering the JSON-LD API specification prescribes
47/// for [IRI compaction][1] ("shorter or the same length but lexicographically
48/// less than compact IRI"), and it matches what `jsonld.js` produces.
49///
50/// Note that this is *not* the same as `candidate.len() <= current.len() &&
51/// candidate < current`: that stricter test rejects a shorter candidate whose
52/// first differing byte is larger, for example `zz:a` against `aaaa:a`.
53///
54/// `candidate_len` is `key.len() + 1 + suffix.len()`, passed in so the length
55/// comparison — which settles almost every call — costs nothing. The
56/// concatenation is only walked, never materialized, so no allocation happens
57/// on this path.
58///
59/// [1]: https://www.w3.org/TR/json-ld-api/#iri-compaction
60fn candidate_beats(key: &str, suffix: &str, candidate_len: usize, current: &str) -> bool {
61    use std::cmp::Ordering;
62    match candidate_len.cmp(&current.len()) {
63        Ordering::Less => true,
64        Ordering::Greater => false,
65        Ordering::Equal => {
66            let cand = key.bytes().chain(std::iter::once(b':')).chain(suffix.bytes());
67            cand.cmp(current.bytes()) == Ordering::Less
68        }
69    }
70}
71
72/// One-entry memo over a run of values compacted against the same term.
73///
74/// The container list and the type/language selection are the only things
75/// [`compact_iri_full`] derives from its `value` argument; everything after
76/// them — the inverse-context search and the compact-IRI fallback — reads the
77/// value only through whether it is present at all. So two values producing the
78/// same pair produce the same term, and consecutive values of one property
79/// almost always do: an array of value objects, an array of node references.
80///
81/// Both halves of the key are handed over by move once the algorithm is done
82/// with them, so a miss costs a comparison and no allocation.
83///
84/// The key covers the value **only**: a memo is valid while `vocabulary`,
85/// `active_context`, `var`, `vocab`, `reverse` and `options` are held fixed.
86/// Build one immediately before such a loop and drop it after.
87pub(crate) struct CompactIriMemo<'a, T> {
88    key: Option<MemoKey<'a, T>>,
89    result: Option<Arc<str>>,
90}
91
92struct MemoKey<'a, T> {
93    containers: SmallVec<[Container; 8]>,
94    selection: Selection<'a, T>,
95}
96
97impl<T> CompactIriMemo<'_, T> {
98    pub fn new() -> Self {
99        Self { key: None, result: None }
100    }
101}
102
103impl<'a, T: PartialEq> CompactIriMemo<'a, T> {
104    // The two layers mean different things: the outer `None` is a memo miss,
105    // the inner `None` is a remembered "no compact IRI applies". Collapsing them
106    // would recompute the negative case on every lookup.
107    #[allow(clippy::option_option)]
108    fn get(&self, containers: &[Container], selection: &Selection<'a, T>) -> Option<Option<Arc<str>>> {
109        let key = self.key.as_ref()?;
110        (key.containers.as_slice() == containers && key.selection == *selection).then(|| self.result.clone())
111    }
112
113    fn store(&mut self, containers: SmallVec<[Container; 8]>, selection: Selection<'a, T>, result: Option<Arc<str>>) {
114        self.key = Some(MemoKey { containers, selection });
115        self.result = result;
116    }
117}
118
119/// Compacts `var` into a term, compact IRI or relative IRI reference, without
120/// considering any value.
121///
122/// This is [`compact_iri_full`] with no `value`, which is how keys, `@type`
123/// values and `@id` values are compacted. Memoized per active context: a
124/// repeated `(var, vocab, reverse, processing mode)` lookup returns the cached
125/// result instead of rerunning the algorithm. The processing mode belongs in
126/// the key because term selection depends on it — an `@index` container term,
127/// for instance, is only eligible for an index-less value under JSON-LD 1.1.
128pub(crate) fn compact_iri<N>(
129    vocabulary: &N,
130    active_context: &Context<N::Iri, N::BlankId>,
131    var: &Term<N::Iri, N::BlankId>,
132    vocab: bool,
133    reverse: bool,
134    options: Options,
135) -> Result<Option<Arc<str>>, IriConfusedWithPrefix>
136where
137    N: Vocabulary,
138    N::Iri: Clone + Hash + Eq,
139    N::BlankId: Clone + Hash + Eq,
140{
141    let cache = active_context.compact_iri_cache();
142    if let Some(hit) = cache.get(&CompactIriKeyRef(var, vocab, reverse, options.processing_mode)) {
143        return Ok(hit);
144    }
145
146    let result = compact_iri_full::<N, Object<N::Iri, N::BlankId>>(vocabulary, active_context, var, None, vocab, reverse, options, None)?;
147
148    cache.insert((var.clone(), vocab, reverse, options.processing_mode), result.clone());
149    Ok(result)
150}
151
152/// Returns the key a keyword compacts to under the active context, which is
153/// either an alias the context defines for it or the keyword itself.
154///
155/// Restricted to the keywords compaction emits (see [`CACHED_KEYWORDS`]). All
156/// of their aliases are resolved on first use through [`compact_iri`] and
157/// cached on the active context, once per processing mode, so that the many
158/// keyword call sites throughout compaction cost a slice index instead of a
159/// lock, a hash lookup and a walk through [`compact_iri_full`].
160///
161/// A keyword with no alias falls back to its own spelling rather than to
162/// `None`, so callers always get a usable key.
163pub(crate) fn keyword_alias<'a, N>(vocabulary: &N, active_context: &'a Context<N::Iri, N::BlankId>, options: Options, k: Keyword) -> &'a str
164where
165    N: Vocabulary,
166    N::Iri: Clone + Hash + Eq,
167    N::BlankId: Clone + Hash + Eq,
168{
169    let aliases = active_context.keyword_aliases_or_init(options.processing_mode, || {
170        let arr: [Box<str>; 13] = std::array::from_fn(|i| {
171            let kw = CACHED_KEYWORDS[i];
172            match compact_iri(vocabulary, active_context, &Term::Keyword(kw), true, false, options).ok().flatten() {
173                Some(arc) => Box::<str>::from(&*arc),
174                None => Box::<str>::from(kw.into_str()),
175            }
176        });
177        KeywordAliases::new(arr)
178    });
179    aliases.get(k)
180}
181
182/// Compacts `var` into the term best suited to holding `value`.
183///
184/// This is [`compact_iri_full`] with a value, so the inverse-context search may
185/// prefer a term whose container, type or language mapping matches `value`.
186pub(crate) fn compact_iri_with<N, O>(
187    vocabulary: &N,
188    active_context: &Context<N::Iri, N::BlankId>,
189    var: &Term<N::Iri, N::BlankId>,
190    value: &Indexed<O>,
191    vocab: bool,
192    reverse: bool,
193    options: Options,
194) -> Result<Option<Arc<str>>, IriConfusedWithPrefix>
195where
196    N: Vocabulary,
197    N::Iri: Clone + Hash + Eq,
198    N::BlankId: Clone + Hash + Eq,
199    O: object::Any<N::Iri, N::BlankId>,
200{
201    compact_iri_full(vocabulary, active_context, var, Some(value), vocab, reverse, options, None)
202}
203
204/// Compacts `var` into the term best suited to holding `value`, reusing `memo`
205/// across values that select the same term.
206///
207/// See [`CompactIriMemo`] for the conditions under which one memo may be shared
208/// by successive calls.
209pub(crate) fn compact_iri_with_memo<'a, N, O>(
210    vocabulary: &N,
211    active_context: &'a Context<N::Iri, N::BlankId>,
212    var: &Term<N::Iri, N::BlankId>,
213    value: &'a Indexed<O>,
214    vocab: bool,
215    reverse: bool,
216    options: Options,
217    memo: &mut CompactIriMemo<'a, N::Iri>,
218) -> Result<Option<Arc<str>>, IriConfusedWithPrefix>
219where
220    N: Vocabulary,
221    N::Iri: Clone + Hash + Eq,
222    N::BlankId: Clone + Hash + Eq,
223    O: object::Any<N::Iri, N::BlankId>,
224{
225    compact_iri_full(vocabulary, active_context, var, Some(value), vocab, reverse, options, Some(memo))
226}
227
228/// Runs the [IRI compaction algorithm][1] on `var`.
229///
230/// Returns the shortest key that expands back to `var` under `active_context`:
231/// a term from the inverse context if one is compatible, otherwise a compact
232/// IRI, a `@vocab`-relative suffix, a base-relative IRI reference, or `var`
233/// itself. Returns `None` when `var` is null.
234///
235/// `vocab` says the result will be used where `@vocab` applies — as a key, an
236/// `@type` value, or the value of a `@vocab`-typed term — which is what makes
237/// the inverse-context search and `@vocab` suffixing eligible at all. `reverse`
238/// says `var` is being compacted as a reverse property, restricting selection to
239/// terms declared with `@reverse`. `value`, when given, lets the search prefer a
240/// term whose container, type or language mapping fits it. `memo` short-circuits
241/// the search for a run of values that all select the same term; see
242/// [`CompactIriMemo`].
243///
244/// [1]: https://www.w3.org/TR/json-ld-api/#iri-compaction
245pub(crate) fn compact_iri_full<'a, N, O>(
246    vocabulary: &N,
247    active_context: &'a Context<N::Iri, N::BlankId>,
248    var: &Term<N::Iri, N::BlankId>,
249    value: Option<&'a Indexed<O>>,
250    vocab: bool,
251    reverse: bool,
252    options: Options,
253    mut memo: Option<&mut CompactIriMemo<'a, N::Iri>>,
254) -> Result<Option<Arc<str>>, IriConfusedWithPrefix>
255where
256    N: Vocabulary,
257    N::Iri: Clone + Hash + Eq,
258    N::BlankId: Clone + Hash + Eq,
259    O: object::Any<N::Iri, N::BlankId>,
260{
261    if var.is_null() {
262        return Ok(None);
263    }
264
265    if vocab && let Some(entry) = active_context.inverse().get(var) {
266        // Initialize containers to an empty array.
267        // This array will be used to keep track of an ordered list of preferred container
268        // mapping for a term, based on what is compatible with value.
269        let mut containers: SmallVec<[Container; 8]> = SmallVec::new();
270        let mut type_lang_value = None;
271
272        if let Some(value) = value
273            && value.index().is_some()
274            && !value.is_graph()
275        {
276            containers.push(Container::Index);
277            containers.push(Container::IndexSet);
278        }
279
280        let mut has_index = false;
281        let mut is_simple_value = false; // value object with no type, no index, no language and no direction.
282
283        if reverse {
284            type_lang_value = Some(TypeLangValue::Type(TypeSelection::Reverse));
285            containers.push(Container::Set);
286        } else {
287            let value_ref = value.map(|v| {
288                has_index = v.index().is_some();
289                v.inner().as_ref()
290            });
291
292            match value_ref {
293                Some(object::Ref::List(list)) => {
294                    if !has_index {
295                        containers.push(Container::List);
296                    }
297
298                    let mut common_type = None;
299                    let mut common_lang_dir = None;
300
301                    if list.is_empty() {
302                        common_lang_dir = Some(Nullable::Some((active_context.default_language(), active_context.default_base_direction())));
303                    } else {
304                        for item in list {
305                            let mut item_type = None;
306                            let mut item_lang_dir = None;
307                            let mut is_value = false;
308
309                            match item.inner() {
310                                Object::Value(value) => {
311                                    is_value = true;
312                                    match value {
313                                        Value::LangString(lang_str) => item_lang_dir = Some(Nullable::Some((lang_str.language(), lang_str.direction()))),
314                                        Value::Literal(_, Some(ty)) => item_type = Some(Type::Iri(ty.clone())),
315                                        Value::Literal(_, None) => item_lang_dir = Some(Nullable::Null),
316                                        Value::Json(_) => item_type = Some(Type::Json),
317                                    }
318                                }
319                                _ => item_type = Some(Type::Id),
320                            }
321
322                            if common_lang_dir.is_none() {
323                                common_lang_dir = item_lang_dir;
324                            } else if is_value && common_lang_dir != item_lang_dir {
325                                common_lang_dir = Some(Nullable::Some((None, None)));
326                            }
327
328                            if common_type.is_none() {
329                                common_type = Some(item_type);
330                            } else if common_type.as_ref().is_some_and(|t| *t != item_type) {
331                                common_type = Some(None);
332                            }
333
334                            if common_lang_dir == Some(Nullable::Some((None, None))) && common_type == Some(None) {
335                                break;
336                            }
337                        }
338                    }
339
340                    let common_lang_dir = common_lang_dir.unwrap_or(Nullable::Some((None, None)));
341                    let common_type = common_type.unwrap_or(None);
342
343                    if let Some(common_type) = common_type {
344                        type_lang_value = Some(TypeLangValue::Type(TypeSelection::Type(common_type)));
345                    } else {
346                        type_lang_value = Some(TypeLangValue::Lang(LangSelection::Lang(common_lang_dir)));
347                    }
348                }
349                Some(object::Ref::Node(node)) if node.is_graph() => {
350                    // Otherwise, if value is a graph object, prefer a mapping most
351                    // appropriate for the particular value.
352                    if has_index {
353                        // If value contains an @index entry, append the values
354                        // @graph@index and @graph@index@set to containers.
355                        containers.push(Container::GraphIndex);
356                        containers.push(Container::GraphIndexSet);
357                    }
358
359                    if node.id.is_some() {
360                        // If value contains an @id entry, append the values @graph@id and
361                        // @graph@id@set to containers.
362                        containers.push(Container::GraphId);
363                        containers.push(Container::GraphIdSet);
364                    }
365
366                    // Append the values @graph, @graph@set, and @set to containers.
367                    containers.push(Container::Graph);
368                    containers.push(Container::GraphSet);
369                    containers.push(Container::Set);
370
371                    if !has_index {
372                        // If value does not contain an @index entry, append the values
373                        // @graph@index and @graph@index@set to containers.
374                        containers.push(Container::GraphIndex);
375                        containers.push(Container::GraphIndexSet);
376                    }
377
378                    if node.id.is_none() {
379                        // If the value does not contain an @id entry, append the values
380                        // @graph@id and @graph@id@set to containers.
381                        containers.push(Container::GraphId);
382                        containers.push(Container::GraphIdSet);
383                    }
384
385                    // Append the values @index and @index@set to containers.
386                    containers.push(Container::Index);
387                    containers.push(Container::IndexSet);
388
389                    type_lang_value = Some(TypeLangValue::Type(TypeSelection::Type(Type::Id)));
390                }
391                Some(object::Ref::Value(v)) => {
392                    // If value is a value object:
393                    if (v.direction().is_some() || v.language().is_some()) && !has_index {
394                        type_lang_value = Some(TypeLangValue::Lang(LangSelection::Lang(Nullable::Some((v.language(), v.direction())))));
395                        containers.push(Container::Language);
396                        containers.push(Container::LanguageSet);
397                    } else if let Some(ty) = v.typ() {
398                        type_lang_value = Some(TypeLangValue::Type(TypeSelection::Type(ty.as_syntax_type().cloned())));
399                    } else {
400                        is_simple_value = v.direction().is_none() && v.language().is_none() && !has_index;
401                    }
402
403                    containers.push(Container::Set);
404                }
405                _ => {
406                    // Otherwise, set type/language to @type and set type/language value
407                    // to @id, and append @id, @id@set, @type, and @set@type, to containers.
408                    type_lang_value = Some(TypeLangValue::Type(TypeSelection::Type(Type::Id)));
409                    containers.push(Container::Id);
410                    containers.push(Container::IdSet);
411                    containers.push(Container::Type);
412                    containers.push(Container::SetType);
413
414                    containers.push(Container::Set);
415                }
416            }
417        }
418
419        containers.push(Container::None);
420
421        if options.processing_mode != ProcessingMode::JsonLd1_0 && !has_index {
422            containers.push(Container::Index);
423            containers.push(Container::IndexSet);
424        }
425
426        if options.processing_mode != ProcessingMode::JsonLd1_0 && is_simple_value {
427            containers.push(Container::Language);
428            containers.push(Container::LanguageSet);
429        }
430
431        let mut is_empty_list = false;
432        if let Some(value) = value
433            && let object::Ref::List(list) = value.inner().as_ref()
434            && list.is_empty()
435        {
436            is_empty_list = true;
437        }
438
439        // If type/language value is @reverse, append @reverse to preferred values.
440        let selection = if is_empty_list {
441            Selection::Any
442        } else {
443            match type_lang_value {
444                Some(TypeLangValue::Type(type_value)) => {
445                    let mut selection: Vec<TypeSelection<N::Iri>> = Vec::new();
446
447                    if type_value == TypeSelection::Reverse {
448                        selection.push(TypeSelection::Reverse);
449                    }
450
451                    let mut has_id_type = false;
452                    if let Some(value) = value
453                        && let Some(id) = value.id()
454                        && (type_value == TypeSelection::Type(Type::Id) || type_value == TypeSelection::Reverse)
455                    {
456                        has_id_type = true;
457                        let mut vocab = false;
458                        if let Some(compacted_iri) = compact_iri(vocabulary, active_context, &id.clone().into_term(), true, false, options)?
459                            && let Some(def) = active_context.get(&*compacted_iri)
460                            && let Some(iri_mapping) = def.value()
461                        {
462                            vocab = iri_mapping == id;
463                        }
464
465                        if vocab {
466                            selection.push(TypeSelection::Type(Type::Vocab));
467                            selection.push(TypeSelection::Type(Type::Id));
468                        } else {
469                            selection.push(TypeSelection::Type(Type::Id));
470                            selection.push(TypeSelection::Type(Type::Vocab));
471                        }
472
473                        selection.push(TypeSelection::Type(Type::None));
474                    }
475
476                    if !has_id_type {
477                        selection.push(type_value);
478                        selection.push(TypeSelection::Type(Type::None));
479                    }
480
481                    selection.push(TypeSelection::Any);
482
483                    Selection::Type(selection)
484                }
485                Some(TypeLangValue::Lang(lang_value)) => {
486                    let mut selection = vec![lang_value, LangSelection::Lang(Nullable::Some((None, None))), LangSelection::Any];
487
488                    if let LangSelection::Lang(Nullable::Some((Some(_), Some(dir)))) = lang_value {
489                        selection.push(LangSelection::Lang(Nullable::Some((None, Some(dir)))));
490                    }
491
492                    Selection::Lang(selection)
493                }
494                None => Selection::Lang(vec![
495                    LangSelection::Lang(Nullable::Null),
496                    LangSelection::Lang(Nullable::Some((None, None))),
497                    LangSelection::Any,
498                ]),
499            }
500        };
501
502        if let Some(memo) = memo.as_deref_mut()
503            && let Some(hit) = memo.get(&containers, &selection)
504        {
505            return Ok(hit);
506        }
507
508        let result = match entry.select(&containers, &selection) {
509            Some(term) => Some(Arc::from(term.as_str())),
510            // No term was selected. What follows does not read `value`
511            // beyond whether it is present, which the memo holds fixed.
512            None => compact_iri_fallback(vocabulary, active_context, var, value.is_none(), vocab)?,
513        };
514
515        if let Some(memo) = memo {
516            memo.store(containers, selection, result.clone());
517        }
518
519        return Ok(result);
520    }
521
522    compact_iri_fallback(vocabulary, active_context, var, value.is_none(), vocab)
523}
524
525/// Tail of [`compact_iri_full`], reached when no term could be selected from the
526/// inverse context.
527///
528/// Tries, in order: a suffix relative to the active context's `@vocab`, the
529/// shortest compact IRI built from a prefix term, then either an IRI reference
530/// relative to the base IRI (when `vocab` is false) or `var` written out in
531/// full.
532///
533/// `no_value` is `true` when the caller passed no value; it only affects whether
534/// a compact IRI may collide with an existing term definition. Reading the value
535/// through this one flag is what lets [`compact_iri_full`] memoize across this
536/// function.
537fn compact_iri_fallback<N>(
538    vocabulary: &N,
539    active_context: &Context<N::Iri, N::BlankId>,
540    var: &Term<N::Iri, N::BlankId>,
541    no_value: bool,
542    vocab: bool,
543) -> Result<Option<Arc<str>>, IriConfusedWithPrefix>
544where
545    N: Vocabulary,
546    N::Iri: Clone + Hash + Eq,
547    N::BlankId: Clone + Hash + Eq,
548{
549    if vocab {
550        // At this point, there is no simple term that var can be compacted to.
551        // If vocab is true and active context has a vocabulary mapping:
552        if let Some(vocab_mapping) = active_context.vocabulary() {
553            // If var begins with the vocabulary mapping's value but is longer, then initialize
554            // suffix to the substring of var that does not match. If suffix does not have a term
555            // definition in active context, then return suffix.
556            if let Some(suffix) = var.with(vocabulary).as_str().strip_prefix(vocab_mapping.with(vocabulary).as_str())
557                && !suffix.is_empty()
558                && active_context.get(suffix).is_none()
559            {
560                return Ok(Some(Arc::from(suffix)));
561            }
562        }
563    }
564
565    // The var could not be compacted using the active context's vocabulary mapping.
566    // Try to create a compact IRI, starting by initializing compact IRI to null.
567    // This variable will be used to store the created compact IRI, if any.
568    let mut compact_iri: Option<String> = None;
569    let var_str = var.with(vocabulary).as_str();
570
571    // Stack-inline scratch buffer reused across loop iterations. Allocates
572    // only when the candidate exceeds the inline capacity. The accepted
573    // winner is the only allocation paid per accept.
574    let mut buf: SmallVec<[u8; 64]> = SmallVec::new();
575
576    // Iterate only term definitions whose `prefix` flag is true, with their IRI
577    // mappings (cached on the active context).
578    for (key, iri_mapping) in active_context.prefix_terms() {
579        let Some(suffix) = var_str.strip_prefix((**iri_mapping).with(vocabulary).as_str()) else {
580            continue;
581        };
582        if suffix.is_empty() {
583            continue;
584        }
585
586        let key_str = key.as_str();
587        let candidate_len = key_str.len() + 1 + suffix.len();
588
589        // Cheap precondition: candidate must beat current best by (length, lex)
590        // before we pay for materialization or the term-definition lookup.
591        if let Some(current) = compact_iri.as_deref()
592            && !candidate_beats(key_str, suffix, candidate_len, current)
593        {
594            continue;
595        }
596
597        // Build the candidate into the scratch buffer (no heap alloc when
598        // the total fits in the inline capacity).
599        buf.clear();
600        buf.reserve(candidate_len);
601        buf.extend_from_slice(key_str.as_bytes());
602        buf.push(b':');
603        buf.extend_from_slice(suffix.as_bytes());
604        // SAFETY: `key_str` and `suffix` are `&str`, and ':' is ASCII, so
605        // the concatenated bytes are valid UTF-8.
606        let candidate_str: &str = unsafe { std::str::from_utf8_unchecked(&buf) };
607
608        // If candidate has a term definition in active context, accept it
609        // only when that definition's IRI mapping equals `var` and the
610        // caller did not pass a `value`.
611        let candidate_def = active_context.get(candidate_str);
612        let definition_ok = match candidate_def {
613            None => true,
614            Some(def) => def.value() == Some(var) && no_value,
615        };
616        if definition_ok {
617            compact_iri = Some(candidate_str.to_owned());
618        }
619    }
620
621    // If compact IRI is not null, return compact IRI.
622    if let Some(compact_iri) = compact_iri {
623        return Ok(Some(Arc::from(compact_iri)));
624    }
625
626    // To ensure that the IRI var is not confused with a compact IRI,
627    // if the IRI scheme of var matches any term in active context with prefix flag set to true,
628    // and var has no IRI authority (preceded by double-forward-slash (//),
629    // an IRI confused with prefix error has been detected, and processing is aborted.
630    //
631    // The test below is broader than that: it looks only at whether the scheme
632    // is a term of the active context, ignoring the term's `prefix` flag and
633    // the absence of an authority component. The W3C compaction suite passes
634    // either way, so the extra strictness has never been observed to matter.
635    if let Some(iri) = var.as_iri()
636        && let Some(iri) = vocabulary.iri(iri)
637        && active_context.contains_term(iri.scheme())
638    {
639        return Err(IriConfusedWithPrefix);
640    }
641
642    // If vocab is false, transform var to a relative IRI reference using the
643    // base IRI from active context, if it exists.
644    if !vocab
645        && let Some(base_iri) = active_context.base_iri()
646        && let Some(iri) = var.as_iri()
647    {
648        let Some(iri) = vocabulary.iri(iri) else {
649            return Ok(None);
650        };
651        let Some(base) = vocabulary.iri(base_iri) else {
652            return Ok(None);
653        };
654        let rel = iri.relative_to(&base);
655        let s = rel.as_str();
656        // RFC 3986 relativization yields "" when the target equals the base;
657        // the W3C compaction test `compact#t0076` expects the last path segment
658        // of the base instead. When the base itself ends in `/` that segment is
659        // empty too, so fall back to "./" — which also resolves back to the
660        // base — rather than emitting `"@id": ""`.
661        let out = if s.is_empty() {
662            match base.as_str().rsplit('/').next() {
663                Some(segment) if !segment.is_empty() => segment.to_string(),
664                _ => "./".to_string(),
665            }
666        } else {
667            s.to_string()
668        };
669        return Ok(Some(Arc::from(disambiguate_keyword(out))));
670    }
671
672    // Finally, return var as is.
673    Ok(Some(Arc::from(var.with(vocabulary).to_string())))
674}
675
676/// Prefixes `./` to a relative IRI reference that would otherwise be read back
677/// as a keyword.
678///
679/// A relative reference such as `@foo` is keyword-like but is not a keyword, so
680/// re-expanding it would silently drop the entry. `./@foo` resolves to the same
681/// IRI while staying a plain reference. Real keywords are left alone, since
682/// nothing else can relativize to one.
683fn disambiguate_keyword(s: String) -> String {
684    if is_keyword_like(&s) && !is_keyword(&s) { "./".to_string() + &s } else { s }
685}
686
687#[cfg(test)]
688mod tests {
689    #![allow(clippy::unwrap_used)]
690    use super::*;
691    use jsonld_core::{Id, ValidId, context::NormalTermDefinition};
692    use rdfx::{BlankIdBuf, IriBuf, vocabulary::no_vocabulary};
693
694    /// One processed context compacted under 1.1 and then 1.0: the
695    /// per-context compact-IRI cache must not serve the 1.1 result to the
696    /// 1.0 run (selection is mode-dependent — an `@index` container term is
697    /// eligible for an index-less value in 1.1 only).
698    #[test]
699    fn compact_iri_cache_distinguishes_processing_modes() {
700        let iri = IriBuf::new("http://example.com/t".to_string()).unwrap();
701        let term = Term::<IriBuf, BlankIdBuf>::Id(Id::Valid(ValidId::Iri(iri)));
702
703        let mut context: Context<IriBuf, BlankIdBuf> = Context::new(None);
704        context.set_normal(
705            "t".into(),
706            Some(NormalTermDefinition {
707                value: Some(Arc::new(term.clone())),
708                container: Container::Index,
709                ..Default::default()
710            }),
711        );
712
713        let options_1_1 = Options {
714            processing_mode: ProcessingMode::JsonLd1_1,
715            ..Default::default()
716        };
717        let options_1_0 = Options {
718            processing_mode: ProcessingMode::JsonLd1_0,
719            ..Default::default()
720        };
721
722        let compacted_1_1 = compact_iri(no_vocabulary(), &context, &term, true, false, options_1_1).unwrap();
723        let compacted_1_0 = compact_iri(no_vocabulary(), &context, &term, true, false, options_1_0).unwrap();
724
725        assert_eq!(compacted_1_1.as_deref(), Some("t"));
726        assert_eq!(compacted_1_0.as_deref(), Some("http://example.com/t"));
727    }
728
729    /// Relativizing an IRI equal to a base ending in `/` must not produce
730    /// the empty string.
731    #[test]
732    fn relativization_of_base_with_trailing_slash_is_not_empty() {
733        let base = IriBuf::new("http://example.com/dir/".to_string()).unwrap();
734        let iri = IriBuf::new("http://example.com/dir/".to_string()).unwrap();
735        let term = Term::<IriBuf, BlankIdBuf>::Id(Id::Valid(ValidId::Iri(iri)));
736
737        let mut context: Context<IriBuf, BlankIdBuf> = Context::new(None);
738        context.set_base_iri(Some(base));
739
740        let compacted = compact_iri(no_vocabulary(), &context, &term, false, false, Options::default()).unwrap();
741        assert_eq!(compacted.as_deref(), Some("./"));
742    }
743}