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}