Skip to main content

jsonld_compaction/
iri.rs

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