Skip to main content

oxirs_core/jsonld/compaction/
mod.rs

1//! JSON-LD 1.1 Compaction Algorithm
2//!
3//! This module implements the W3C JSON-LD 1.1 Compaction specification:
4//! <https://www.w3.org/TR/json-ld11-api/#compaction-algorithm>
5//!
6//! Compaction is the inverse of Expansion: given an expanded JSON-LD document
7//! and a context, produce a compact document that uses the prefixes, terms, and
8//! language/type maps defined in the context.
9
10pub mod algorithm;
11pub mod context;
12#[cfg(test)]
13mod tests;
14
15pub use algorithm::{compact_array, compact_node, compact_value};
16pub use context::{compact_iri, create_compact_context, find_term};
17
18use indexmap::IndexMap;
19use std::collections::HashMap;
20use thiserror::Error;
21
22// ============================================================================
23// JsonLdValue — the core JSON-LD value type
24// ============================================================================
25
26/// A JSON-LD value that can appear in an expanded or compact JSON-LD document.
27///
28/// Uses [`IndexMap`] for objects to maintain insertion order (required by JSON-LD spec).
29#[derive(Debug, Clone, PartialEq)]
30pub enum JsonLdValue {
31    /// JSON null.
32    Null,
33    /// JSON boolean.
34    Bool(bool),
35    /// JSON number.
36    Number(f64),
37    /// JSON string.
38    Str(String),
39    /// JSON array.
40    Array(Vec<JsonLdValue>),
41    /// JSON object with insertion-ordered keys.
42    Object(IndexMap<String, JsonLdValue>),
43}
44
45impl JsonLdValue {
46    /// Returns `true` if this value is `Null`.
47    pub fn is_null(&self) -> bool {
48        matches!(self, Self::Null)
49    }
50
51    /// Returns the string value if this is a `Str`, otherwise `None`.
52    pub fn as_str(&self) -> Option<&str> {
53        match self {
54            Self::Str(s) => Some(s),
55            _ => None,
56        }
57    }
58
59    /// Returns the object map if this is an `Object`, otherwise `None`.
60    pub fn as_object(&self) -> Option<&IndexMap<String, JsonLdValue>> {
61        match self {
62            Self::Object(m) => Some(m),
63            _ => None,
64        }
65    }
66
67    /// Returns the array if this is an `Array`, otherwise `None`.
68    pub fn as_array(&self) -> Option<&[JsonLdValue]> {
69        match self {
70            Self::Array(a) => Some(a),
71            _ => None,
72        }
73    }
74
75    /// Wraps a value in a single-element array.
76    pub fn into_array_if_not(self) -> Vec<JsonLdValue> {
77        match self {
78            Self::Array(a) => a,
79            other => vec![other],
80        }
81    }
82}
83
84// ============================================================================
85// Context types
86// ============================================================================
87
88/// Container type for a JSON-LD term definition.
89///
90/// Corresponds to the `@container` keyword values defined in JSON-LD 1.1 §4.3.
91#[derive(Debug, Clone, PartialEq, Eq, Hash)]
92pub enum ContainerType {
93    /// `@list` — the value is an ordered list.
94    List,
95    /// `@set` — the value is an unordered set (coerce to array).
96    Set,
97    /// `@language` — value is a language-keyed map.
98    Language,
99    /// `@index` — value is an index-keyed map.
100    Index,
101    /// `@id` — value is an id-keyed map.
102    Id,
103    /// `@type` — value is a type-keyed map.
104    Type,
105    /// `@graph` — value is a named graph.
106    Graph,
107}
108
109/// Definition of a single term in the active context.
110///
111/// Corresponds to a JSON-LD term definition as specified in §4.1.
112#[derive(Debug, Clone)]
113pub struct TermDefinition {
114    /// The expanded IRI mapping for this term.
115    pub iri_mapping: Option<String>,
116    /// Whether this term can be used as a prefix.
117    pub prefix_flag: bool,
118    /// Whether this term definition is protected.
119    pub protected: bool,
120    /// Whether this is a reverse property.
121    pub reverse_property: bool,
122    /// Container types for the term's values.
123    pub container: Vec<ContainerType>,
124    /// Default language for the term's values.
125    pub language: Option<String>,
126    /// Default text direction for the term's values.
127    pub direction: Option<String>,
128    /// Nest value for the term.
129    pub nest: Option<String>,
130    /// Type mapping for the term.
131    pub type_mapping: Option<String>,
132}
133
134impl TermDefinition {
135    /// Creates a minimal term definition with just an IRI mapping.
136    pub fn simple(iri: impl Into<String>) -> Self {
137        Self {
138            iri_mapping: Some(iri.into()),
139            prefix_flag: false,
140            protected: false,
141            reverse_property: false,
142            container: Vec::new(),
143            language: None,
144            direction: None,
145            nest: None,
146            type_mapping: None,
147        }
148    }
149
150    /// Creates a prefix term definition.
151    pub fn prefix(iri: impl Into<String>) -> Self {
152        Self {
153            iri_mapping: Some(iri.into()),
154            prefix_flag: true,
155            protected: false,
156            reverse_property: false,
157            container: Vec::new(),
158            language: None,
159            direction: None,
160            nest: None,
161            type_mapping: None,
162        }
163    }
164}
165
166/// The active JSON-LD context used during compaction.
167///
168/// Corresponds to the W3C JSON-LD 1.1 active context data structure.
169#[derive(Debug, Clone, Default)]
170pub struct JsonLdContext {
171    /// Term definitions keyed by compact term name.
172    pub terms: HashMap<String, TermDefinition>,
173    /// Optional vocabulary mapping (`@vocab`).
174    pub vocab: Option<String>,
175    /// Optional base IRI (`@base`).
176    pub base: Option<String>,
177    /// Optional default language (`@language`).
178    pub language: Option<String>,
179    /// Optional default text direction (`@direction`).
180    pub direction: Option<String>,
181}
182
183impl JsonLdContext {
184    /// Creates an empty context.
185    pub fn new() -> Self {
186        Self::default()
187    }
188
189    /// Adds a simple prefix mapping (term → IRI prefix).
190    pub fn add_prefix(&mut self, prefix: impl Into<String>, iri: impl Into<String>) {
191        self.terms
192            .insert(prefix.into(), TermDefinition::prefix(iri.into()));
193    }
194
195    /// Adds a term definition.
196    pub fn add_term(&mut self, term: impl Into<String>, def: TermDefinition) {
197        self.terms.insert(term.into(), def);
198    }
199}
200
201// ============================================================================
202// Options
203// ============================================================================
204
205/// Processing mode for JSON-LD algorithms.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
207pub enum ProcessingMode {
208    /// JSON-LD 1.0 processing.
209    JsonLd10,
210    /// JSON-LD 1.1 processing (default).
211    #[default]
212    JsonLd11,
213}
214
215/// Options controlling the compaction algorithm.
216#[derive(Debug, Clone)]
217pub struct CompactionOptions {
218    /// If `true` (default), compact single-element arrays to a scalar value
219    /// when the container does not require an array.
220    pub compact_arrays: bool,
221    /// Processing mode (JSON-LD 1.0 or 1.1).
222    pub processing_mode: ProcessingMode,
223    /// If `true`, sort keys in output objects for deterministic output.
224    pub ordered: bool,
225    /// Optional override for the base IRI.
226    pub base: Option<String>,
227}
228
229impl Default for CompactionOptions {
230    fn default() -> Self {
231        Self {
232            compact_arrays: true,
233            processing_mode: ProcessingMode::JsonLd11,
234            ordered: false,
235            base: None,
236        }
237    }
238}
239
240// ============================================================================
241// Error type
242// ============================================================================
243
244/// Errors that can occur during JSON-LD compaction.
245#[derive(Debug, Error)]
246pub enum CompactionError {
247    /// The context was invalid.
248    #[error("Invalid context: {0}")]
249    InvalidContext(String),
250
251    /// An IRI was invalid.
252    #[error("Invalid IRI: {0}")]
253    InvalidIri(String),
254
255    /// A general processing error.
256    #[error("Processing error: {0}")]
257    ProcessingError(String),
258
259    /// Two terms conflict for the same compacted representation.
260    #[error("Term collision: term '{term}' is already defined with a different mapping")]
261    CollisionError {
262        /// The conflicting term name.
263        term: String,
264    },
265
266    /// An attempt was made to redefine a protected term.
267    #[error("Protected term redefinition: cannot redefine protected term '{0}'")]
268    ProtectedTermRedefinition(String),
269}
270
271// ============================================================================
272// Main compaction entry point
273// ============================================================================
274
275/// Compact an expanded JSON-LD document using the given context.
276///
277/// This is the main entry point for the W3C JSON-LD 1.1 Compaction Algorithm.
278/// See <https://www.w3.org/TR/json-ld11-api/#compaction-algorithm>.
279///
280/// # Arguments
281///
282/// * `input` — the expanded JSON-LD document (typically an array of node objects)
283/// * `context` — the active context defining prefix mappings, vocabulary, etc.
284/// * `options` — options controlling compaction behaviour
285///
286/// # Returns
287///
288/// A compact JSON-LD document as a [`JsonLdValue`].
289pub fn compact(
290    input: &JsonLdValue,
291    context: &JsonLdContext,
292    options: &CompactionOptions,
293) -> Result<JsonLdValue, CompactionError> {
294    // Step 1: If input is an array, compact each element.
295    let compacted_input = match input {
296        JsonLdValue::Array(items) => {
297            let mut out = Vec::with_capacity(items.len());
298            for item in items {
299                let c = compact_element(item, context, None, options)?;
300                if !c.is_null() {
301                    out.push(c);
302                }
303            }
304            if out.len() == 1 && options.compact_arrays {
305                out.into_iter().next().unwrap_or(JsonLdValue::Null)
306            } else {
307                JsonLdValue::Array(out)
308            }
309        }
310        other => compact_element(other, context, None, options)?,
311    };
312
313    // Step 2: Build the output document with the @context entry.
314    let ctx_value = create_compact_context(context);
315    let mut result: IndexMap<String, JsonLdValue> = IndexMap::new();
316
317    if !ctx_value.is_null() {
318        result.insert("@context".to_string(), ctx_value);
319    }
320
321    // Merge compacted input into the result object.
322    match compacted_input {
323        JsonLdValue::Object(map) => {
324            for (k, v) in map {
325                result.insert(k, v);
326            }
327        }
328        JsonLdValue::Null => {}
329        other => {
330            // scalar — wrap in @graph
331            result.insert("@graph".to_string(), other);
332        }
333    }
334
335    Ok(JsonLdValue::Object(result))
336}
337
338/// Compact a single element (node, value, list, or scalar).
339fn compact_element(
340    value: &JsonLdValue,
341    ctx: &JsonLdContext,
342    active_property: Option<&str>,
343    options: &CompactionOptions,
344) -> Result<JsonLdValue, CompactionError> {
345    match value {
346        JsonLdValue::Object(map) => {
347            // Check if this is a value object, list object, or node object.
348            if map.contains_key("@value") {
349                compact_value(ctx, active_property, value)
350            } else if map.contains_key("@list") {
351                let list_items = match map.get("@list") {
352                    Some(JsonLdValue::Array(a)) => a.as_slice(),
353                    _ => &[],
354                };
355                compact_array(ctx, active_property.unwrap_or("@list"), list_items, options)
356            } else {
357                compact_node(ctx, ctx, active_property, map, options)
358            }
359        }
360        JsonLdValue::Array(items) => {
361            compact_array(ctx, active_property.unwrap_or("@graph"), items, options)
362        }
363        other => Ok(other.clone()),
364    }
365}