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