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(¤t.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}