Skip to main content

jsonld_compaction/
lib.rs

1//! Implementation of the [JSON-LD compaction algorithms][1].
2//!
3//! Compaction turns an expanded JSON-LD document back into the terse,
4//! human-friendly shape described by a `@context`: IRIs become terms or
5//! compact IRIs, single-element arrays collapse, and value objects reduce to
6//! plain JSON scalars wherever the context makes that lossless.
7//!
8//! [`Compact`] compacts a whole document (an
9//! [`ExpandedDocument`][jsonld_core::ExpandedDocument] or a
10//! [`FlattenedDocument`][jsonld_core::FlattenedDocument]) and embeds the
11//! `@context` used into the output. [`CompactFragment`] compacts a single
12//! object, node, list or collection against an already-processed active
13//! context, and is what the document-level algorithm recurses through.
14//!
15//! This crate is the compaction half of the `jsonld` family; the `jsonld`
16//! crate re-exports it behind a processor API that also handles expansion,
17//! flattening and RDF serialization.
18//!
19//! [1]: https://www.w3.org/TR/json-ld-api/#compaction-algorithms
20use jsonld_context_processing::{Options as ProcessingOptions, Process};
21use jsonld_core::{
22    Context,
23    ContextRef,
24    IndexSet,
25    Indexed,
26    Loader,
27    ProcessingMode,
28    Term,
29    Value,
30    context::inverse::{LangSelection, TypeSelection},
31    object::Any,
32};
33use jsonld_syntax::{ContainerKind, ErrorCode, Keyword};
34use rdfx::vocabulary::{self, VocabularyMut};
35use std::hash::Hash;
36
37mod document;
38mod iri;
39mod node;
40mod property;
41mod value;
42
43pub use document::*;
44pub use iri::IriConfusedWithPrefix;
45pub(crate) use iri::{compact_iri, compact_iri_with, compact_iri_with_memo};
46use node::compact_indexed_node_with;
47use property::compact_property;
48use value::compact_indexed_value_with;
49
50#[derive(Debug, thiserror::Error)]
51/// Error raised while compacting a document.
52pub enum Error<E = std::convert::Infallible> {
53    #[error("IRI confused with prefix")]
54    /// An IRI cannot be written out because it would be read back as a
55    /// compact IRI.
56    ///
57    /// See [`IriConfusedWithPrefix`] for the exact condition.
58    IriConfusedWithPrefix,
59
60    #[error("Invalid `@nest` value")]
61    /// A term definition's `@nest` value is neither `@nest` itself nor a term
62    /// of the active context that expands to `@nest`.
63    InvalidNestValue,
64
65    #[error("Colliding compacted entry")]
66    /// The compaction target of a map-based container or `@nest` entry
67    /// already holds a non-map value: two terms compacted to the same key
68    /// with incompatible shapes. The JSON-LD specification does not define
69    /// an output for this state.
70    CollidingEntry,
71
72    #[error("Context processing failed: {0}")]
73    /// A `@context` encountered during compaction could not be processed.
74    ///
75    /// Compaction processes scoped contexts (`@context` entries inside term
76    /// definitions) as it descends, so any context-processing error can
77    /// surface here.
78    ContextProcessing(jsonld_context_processing::Error<E>),
79}
80
81impl<E> Error<E> {
82    /// Returns the JSON-LD error code this error is reported under.
83    pub fn code(&self) -> ErrorCode {
84        match self {
85            Self::IriConfusedWithPrefix => ErrorCode::IriConfusedWithPrefix,
86            Self::InvalidNestValue => ErrorCode::InvalidNestValue,
87            Self::CollidingEntry => ErrorCode::ConflictingIndexes,
88            Self::ContextProcessing(e) => e.code(),
89        }
90    }
91}
92
93impl<E> From<jsonld_context_processing::Error<E>> for Error<E> {
94    fn from(e: jsonld_context_processing::Error<E>) -> Self {
95        Self::ContextProcessing(e)
96    }
97}
98
99impl<E> From<IriConfusedWithPrefix> for Error<E> {
100    fn from(_: IriConfusedWithPrefix) -> Self {
101        Self::IriConfusedWithPrefix
102    }
103}
104
105/// Result of compacting a document fragment.
106pub type CompactFragmentResult<E> = Result<jstrict::Value, Error<E>>;
107
108/// Compaction options.
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
110pub struct Options {
111    /// JSON-LD processing mode.
112    pub processing_mode: ProcessingMode,
113
114    /// Whether IRIs may be compacted into references relative to the
115    /// document's location.
116    ///
117    /// The compaction algorithm itself does not read this flag: it relativizes
118    /// IRIs whenever the active context has a base IRI. The flag is what
119    /// decides whether that base IRI gets set in the first place — the
120    /// `jsonld` processor seeds the active context with the input document's
121    /// URL only when this is `true` and no explicit base IRI was given.
122    pub compact_to_relative: bool,
123
124    /// Whether a single-element array is replaced by that element.
125    ///
126    /// When `false`, arrays stay arrays even with one element. Terms whose
127    /// container mapping includes `@set`, and the `@graph` and `@list` keys,
128    /// keep their arrays regardless.
129    pub compact_arrays: bool,
130
131    /// Whether the entries of each node object are processed in lexicographic
132    /// order of their expanded property IRI.
133    ///
134    /// Only affects the order of keys in the output, not which keys appear.
135    pub ordered: bool,
136}
137
138impl From<Options> for jsonld_context_processing::Options {
139    fn from(options: Options) -> jsonld_context_processing::Options {
140        jsonld_context_processing::Options {
141            processing_mode: options.processing_mode,
142            ..Default::default()
143        }
144    }
145}
146
147impl From<jsonld_expansion::Options> for Options {
148    fn from(options: jsonld_expansion::Options) -> Options {
149        Options {
150            processing_mode: options.processing_mode,
151            ordered: options.ordered,
152            ..Options::default()
153        }
154    }
155}
156
157impl Default for Options {
158    fn default() -> Options {
159        Options {
160            processing_mode: ProcessingMode::default(),
161            compact_to_relative: true,
162            compact_arrays: true,
163            ordered: false,
164        }
165    }
166}
167
168/// Document fragments that can be compacted against an active context.
169pub trait CompactFragment<I, B> {
170    /// Compacts this fragment, taking every parameter of the algorithm
171    /// explicitly.
172    ///
173    /// `active_context` is the context in force for this fragment.
174    /// `type_scoped_context` is the context as it stood *before* this
175    /// fragment's own type-scoped contexts were applied; term definitions for
176    /// `active_property` and for `@type` values are looked up there, as the
177    /// specification requires. `active_property` is the term this fragment was
178    /// reached through, and drives container, language and index handling.
179    /// `loader` resolves any remote context referenced by a scoped `@context`.
180    async fn compact_fragment_full<'a, N, L>(
181        &'a self,
182        vocabulary: &'a mut N,
183        active_context: &'a Context<I, B>,
184        type_scoped_context: &'a Context<I, B>,
185        active_property: Option<&'a str>,
186        loader: &'a L,
187        options: Options,
188    ) -> CompactFragmentResult<L::Error>
189    where
190        N: VocabularyMut<Iri = I, BlankId = B>,
191        I: Clone + Hash + Eq,
192        B: Clone + Hash + Eq,
193        L: Loader;
194
195    #[inline(always)]
196    /// Compacts this fragment at the top level of a document, with the default
197    /// [`Options`], using `vocabulary` to resolve IRI and blank node
198    /// identifiers.
199    async fn compact_fragment_with<'a, N, L>(
200        &'a self,
201        vocabulary: &'a mut N,
202        active_context: &'a Context<I, B>,
203        loader: &'a mut L,
204    ) -> CompactFragmentResult<L::Error>
205    where
206        N: VocabularyMut<Iri = I, BlankId = B>,
207        I: Clone + Hash + Eq,
208        B: Clone + Hash + Eq,
209        L: Loader,
210    {
211        self.compact_fragment_full(vocabulary, active_context, active_context, None, loader, Options::default())
212            .await
213    }
214
215    #[inline(always)]
216    /// Compacts this fragment at the top level of a document, with the default
217    /// [`Options`], for documents that store IRIs and blank node identifiers
218    /// inline instead of indexing them through a vocabulary.
219    async fn compact_fragment<'a, L>(&'a self, active_context: &'a Context<I, B>, loader: &'a mut L) -> CompactFragmentResult<L::Error>
220    where
221        (): VocabularyMut<Iri = I, BlankId = B>,
222        I: Clone + Hash + Eq,
223        B: Clone + Hash + Eq,
224        L: Loader,
225    {
226        self.compact_fragment_full(
227            vocabulary::no_vocabulary_mut(),
228            active_context,
229            active_context,
230            None,
231            loader,
232            Options::default(),
233        )
234        .await
235    }
236}
237
238#[derive(PartialEq)]
239enum TypeLangValue<'a, I> {
240    Type(TypeSelection<I>),
241    Lang(LangSelection<'a>),
242}
243
244/// Document fragments that carry an `@index` separately from their own
245/// contents.
246///
247/// [`Indexed<T>`] pairs a fragment with the index it was reached through;
248/// implementing this trait for `T` is what gives `Indexed<T>` its
249/// [`CompactFragment`] implementation. The index has to be passed down rather
250/// than read off the fragment because whether it survives into the output
251/// depends on the active property's container mapping.
252pub trait CompactIndexedFragment<I, B> {
253    /// Compacts this fragment together with the `@index` value it was reached
254    /// through.
255    ///
256    /// The index is emitted as an `@index` entry unless the active property's
257    /// container mapping includes `@index`, in which case it has already become
258    /// the enclosing map's key and is dropped.
259    async fn compact_indexed_fragment<'a, N, L>(
260        &'a self,
261        vocabulary: &'a mut N,
262        index: Option<&'a str>,
263        active_context: &'a Context<I, B>,
264        type_scoped_context: &'a Context<I, B>,
265        active_property: Option<&'a str>,
266        loader: &'a L,
267        options: Options,
268    ) -> CompactFragmentResult<L::Error>
269    where
270        N: VocabularyMut<Iri = I, BlankId = B>,
271        I: Clone + Hash + Eq,
272        B: Clone + Hash + Eq,
273        L: Loader;
274}
275
276impl<I, B, T: CompactIndexedFragment<I, B>> CompactFragment<I, B> for Indexed<T> {
277    async fn compact_fragment_full<'a, N, L>(
278        &'a self,
279        vocabulary: &'a mut N,
280        active_context: &'a Context<I, B>,
281        type_scoped_context: &'a Context<I, B>,
282        active_property: Option<&'a str>,
283        loader: &'a L,
284        options: Options,
285    ) -> CompactFragmentResult<L::Error>
286    where
287        N: VocabularyMut<Iri = I, BlankId = B>,
288        I: Clone + Hash + Eq,
289        B: Clone + Hash + Eq,
290        L: Loader,
291    {
292        self.inner()
293            .compact_indexed_fragment(vocabulary, self.index(), active_context, type_scoped_context, active_property, loader, options)
294            .await
295    }
296}
297
298impl<I, B, T: Any<I, B>> CompactIndexedFragment<I, B> for T {
299    async fn compact_indexed_fragment<'a, N, L>(
300        &'a self,
301        vocabulary: &'a mut N,
302        index: Option<&'a str>,
303        active_context: &'a Context<I, B>,
304        type_scoped_context: &'a Context<I, B>,
305        active_property: Option<&'a str>,
306        loader: &'a L,
307        options: Options,
308    ) -> CompactFragmentResult<L::Error>
309    where
310        N: VocabularyMut<Iri = I, BlankId = B>,
311        I: Clone + Hash + Eq,
312        B: Clone + Hash + Eq,
313        L: Loader,
314    {
315        use jsonld_core::object::Ref;
316        match self.as_ref() {
317            Ref::Value(value) => compact_indexed_value_with(vocabulary, value, index, active_context, active_property, loader, options).await,
318            Ref::Node(node) => compact_indexed_node_with(vocabulary, node, index, active_context, type_scoped_context, active_property, loader, options).await,
319            Ref::List(list) => {
320                let mut active_context = active_context;
321                // If active context has a previous context, the active context is not propagated.
322                // If element does not contain an @value entry, and element does not consist of
323                // a single @id entry, set active context to previous context from active context,
324                // as the scope of a term-scoped context does not apply when processing new node objects.
325                if let Some(previous_context) = active_context.previous_context() {
326                    active_context = previous_context;
327                }
328
329                // If the term definition for active property in active context has a local context:
330                //
331                // Known deviation from the specification text, which says to look the
332                // term definition up in the active context. This looks it up in
333                // `type_scoped_context` instead, which is what makes the W3C
334                // compaction test suite pass; the spec text is believed to be in error.
335                // See https://github.com/w3c/json-ld-api/issues/502.
336                let mut active_context = ContextRef::Borrowed(active_context);
337                let mut list_container = false;
338                if let Some(active_property) = active_property
339                    && let Some(active_property_definition) = type_scoped_context.get(active_property)
340                {
341                    if let Some(local_context) = active_property_definition.context() {
342                        active_context = ContextRef::owned(
343                            local_context
344                                .process_with(
345                                    vocabulary,
346                                    active_context.as_ref(),
347                                    loader,
348                                    active_property_definition.base_url().cloned(),
349                                    ProcessingOptions::from(options).with_override(),
350                                )
351                                .await?
352                                .into_processed(),
353                        );
354                    }
355
356                    list_container = active_property_definition.container().contains(ContainerKind::List);
357                }
358
359                if list_container {
360                    compact_collection_with(
361                        vocabulary,
362                        list.iter(),
363                        active_context.as_ref(),
364                        active_context.as_ref(),
365                        active_property,
366                        loader,
367                        options,
368                    )
369                    .await
370                } else {
371                    let mut result = jstrict::Object::default();
372                    compact_property(
373                        vocabulary,
374                        &mut result,
375                        Term::Keyword(Keyword::List),
376                        list,
377                        active_context.as_ref(),
378                        loader,
379                        false,
380                        options,
381                    )
382                    .await?;
383
384                    // If expanded property is @index and active property has a container mapping in
385                    // active context that includes @index,
386                    if let Some(index) = index {
387                        let mut index_container = false;
388                        if let Some(active_property) = active_property
389                            && let Some(active_property_definition) = active_context.get(active_property)
390                            && active_property_definition.container().contains(ContainerKind::Index)
391                        {
392                            // then the compacted result will be inside of an @index container,
393                            // drop the @index entry by continuing to the next expanded property.
394                            index_container = true;
395                        }
396
397                        if !index_container {
398                            // Initialize alias by IRI compacting expanded property.
399                            let alias = crate::iri::keyword_alias(vocabulary, active_context.as_ref(), options, Keyword::Index);
400
401                            // Add an entry alias to result whose value is set to expanded value and continue with the next expanded property.
402                            result.insert(alias.into(), jstrict::Value::String(index.into()));
403                        }
404                    }
405
406                    Ok(jstrict::Value::Object(result))
407                }
408            }
409        }
410    }
411}
412
413/// Adds `value` under `key` in `map`, following the JSON-LD API's [add value][1]
414/// algorithm.
415///
416/// A key that already holds something becomes an array of both values. When
417/// `as_array` is true the value is wrapped in an array even if `key` was absent,
418/// so that a term with an `@set` container always compacts to an array. An
419/// array `value` is added element by element rather than nested.
420///
421/// [1]: https://www.w3.org/TR/json-ld-api/#add-value
422fn add_value(map: &mut jstrict::Object, key: &str, value: jstrict::Value, as_array: bool) {
423    match value {
424        jstrict::Value::Array(values) => {
425            // Establish the entry's shape before adding the elements: wrap an
426            // existing scalar into a single-element array, or insert an empty
427            // array when `as_array` is set and the entry is absent, so that an
428            // empty `values` still leaves an array behind.
429            // Compaction-built objects never have duplicate keys, so the `Err`
430            // (duplicate key) case of `get_unique_mut` is treated as absent.
431            match map.get_unique_mut(key).ok().flatten() {
432                Some(existing) if !existing.is_array() => {
433                    let prev = std::mem::replace(existing, jstrict::Value::Array(Vec::new()));
434                    if let jstrict::Value::Array(arr) = existing {
435                        arr.push(prev);
436                    }
437                }
438                None if as_array => {
439                    map.insert(key.into(), jstrict::Value::Array(Vec::new()));
440                }
441                _ => {}
442            }
443            for v in values {
444                add_value(map, key, v, false);
445            }
446        }
447        scalar => match map.get_unique_mut(key).ok().flatten() {
448            Some(jstrict::Value::Array(arr)) => {
449                arr.push(scalar);
450            }
451            Some(existing) => {
452                // Existing scalar — wrap into [prev, new] array.
453                let prev = std::mem::replace(existing, jstrict::Value::Array(Vec::new()));
454                if let jstrict::Value::Array(arr) = existing {
455                    arr.push(prev);
456                    arr.push(scalar);
457                }
458            }
459            None if as_array => {
460                map.insert(key.into(), jstrict::Value::Array(vec![scalar]));
461            }
462            None => {
463                map.insert(key.into(), scalar);
464            }
465        },
466    }
467}
468
469/// Returns the JSON value carried by the `@value` entry of a value object,
470/// discarding its `@type`, `@language` and `@direction`.
471fn value_value<I>(value: &Value<I>) -> jstrict::Value {
472    use jsonld_core::object::Literal;
473    match value {
474        Value::Literal(lit, _ty) => match lit {
475            Literal::Null => jstrict::Value::Null,
476            Literal::Boolean(b) => jstrict::Value::Boolean(*b),
477            Literal::Number(n) => jstrict::Value::Number(n.clone()),
478            Literal::String(s) => jstrict::Value::String(s.as_str().into()),
479        },
480        Value::LangString(s) => jstrict::Value::String(s.as_str().into()),
481        Value::Json(json) => json.clone(),
482    }
483}
484
485/// Compacts every item of a collection, then unwraps the result to a single
486/// value when the `compactArrays` option allows it.
487///
488/// Items compacting to `null` are dropped. A single-element array is kept as an
489/// array when `options.compact_arrays` is `false`, when `active_property` is
490/// `@graph` or `@set`, or when its container mapping includes `@list` or
491/// `@set`.
492async fn compact_collection_with<'a, N, L, O, T>(
493    vocabulary: &'a mut N,
494    items: O,
495    active_context: &'a Context<N::Iri, N::BlankId>,
496    type_scoped_context: &'a Context<N::Iri, N::BlankId>,
497    active_property: Option<&'a str>,
498    loader: &'a L,
499    options: Options,
500) -> CompactFragmentResult<L::Error>
501where
502    N: VocabularyMut,
503    N::Iri: Clone + Hash + Eq,
504    N::BlankId: Clone + Hash + Eq,
505    T: 'a + CompactFragment<N::Iri, N::BlankId>,
506    O: 'a + Iterator<Item = &'a T>,
507    L: Loader,
508{
509    let mut result = Vec::new();
510
511    for item in items {
512        let compacted_item = Box::pin(item.compact_fragment_full(vocabulary, active_context, type_scoped_context, active_property, loader, options)).await?;
513
514        if !compacted_item.is_null() {
515            result.push(compacted_item);
516        }
517    }
518
519    let mut list_or_set = false;
520    if let Some(active_property) = active_property
521        && let Some(active_property_definition) = active_context.get(active_property)
522    {
523        list_or_set =
524            active_property_definition.container().contains(ContainerKind::List) || active_property_definition.container().contains(ContainerKind::Set);
525    }
526
527    if result.len() != 1 || !options.compact_arrays || active_property == Some("@graph") || active_property == Some("@set") || list_or_set {
528        return Ok(jstrict::Value::Array(result.into_iter().collect()));
529    }
530
531    // `result.len() == 1` after the early return above, so the fallback
532    // is unreachable.
533    Ok(result.into_iter().next().unwrap_or(jstrict::Value::Null))
534}
535
536impl<T: CompactFragment<I, B>, I, B> CompactFragment<I, B> for IndexSet<T> {
537    async fn compact_fragment_full<'a, N, L>(
538        &'a self,
539        vocabulary: &'a mut N,
540        active_context: &'a Context<I, B>,
541        type_scoped_context: &'a Context<I, B>,
542        active_property: Option<&'a str>,
543        loader: &'a L,
544        options: Options,
545    ) -> CompactFragmentResult<L::Error>
546    where
547        N: VocabularyMut<Iri = I, BlankId = B>,
548        I: Clone + Hash + Eq,
549        B: Clone + Hash + Eq,
550        L: Loader,
551    {
552        compact_collection_with(vocabulary, self.iter(), active_context, type_scoped_context, active_property, loader, options).await
553    }
554}
555
556impl<T: CompactFragment<I, B>, I, B> CompactFragment<I, B> for Vec<T> {
557    async fn compact_fragment_full<'a, N, L>(
558        &'a self,
559        vocabulary: &'a mut N,
560        active_context: &'a Context<I, B>,
561        type_scoped_context: &'a Context<I, B>,
562        active_property: Option<&'a str>,
563        loader: &'a L,
564        options: Options,
565    ) -> CompactFragmentResult<L::Error>
566    where
567        N: VocabularyMut<Iri = I, BlankId = B>,
568        I: Clone + Hash + Eq,
569        B: Clone + Hash + Eq,
570        L: Loader,
571    {
572        compact_collection_with(vocabulary, self.iter(), active_context, type_scoped_context, active_property, loader, options).await
573    }
574}
575
576impl<T: CompactFragment<I, B>, I, B> CompactFragment<I, B> for [T] {
577    async fn compact_fragment_full<'a, N, L>(
578        &'a self,
579        vocabulary: &'a mut N,
580        active_context: &'a Context<I, B>,
581        type_scoped_context: &'a Context<I, B>,
582        active_property: Option<&'a str>,
583        loader: &'a L,
584        options: Options,
585    ) -> CompactFragmentResult<L::Error>
586    where
587        N: VocabularyMut<Iri = I, BlankId = B>,
588        I: Clone + Hash + Eq,
589        B: Clone + Hash + Eq,
590        L: Loader,
591    {
592        compact_collection_with(vocabulary, self.iter(), active_context, type_scoped_context, active_property, loader, options).await
593    }
594}