Skip to main content

oxirs_core/jsonld/compaction/
algorithm.rs

1//! Core compaction algorithm steps for JSON-LD 1.1.
2//!
3//! This module implements:
4//! - Value object compaction (`compact_value`)
5//! - Node object compaction (`compact_node`)
6//! - Array compaction (`compact_array`)
7//!
8//! All functions follow the W3C JSON-LD 1.1 Compaction Algorithm specification:
9//! <https://www.w3.org/TR/json-ld11-api/#compaction-algorithm>
10
11use super::context::{compact_iri, find_term, is_keyword};
12use super::{
13    CompactionError, CompactionOptions, ContainerType, JsonLdContext, JsonLdValue, TermDefinition,
14};
15use indexmap::IndexMap;
16
17// ============================================================================
18// compact_value — §4.1 Value Object Compaction
19// ============================================================================
20
21/// Compact a JSON-LD value object.
22///
23/// Applies the W3C JSON-LD 1.1 Value Compaction algorithm:
24/// <https://www.w3.org/TR/json-ld11-api/#value-compaction>
25///
26/// # Compaction rules
27///
28/// 1. If the value is `{"@value": v, "@type": "@json"}` — keep as-is.
29/// 2. If the value has `@type` and the active property's term has a matching
30///    type mapping — drop the `@type` and `@value` wrapper.
31/// 3. If the value has `@language` and the active property's term has a
32///    matching language mapping — drop the wrapper.
33/// 4. Plain string values without type/language — drop `@value` wrapper.
34/// 5. Numeric and boolean values — return native JSON representation.
35///
36/// # Arguments
37///
38/// * `active_ctx` — the active context
39/// * `active_property` — the property whose value is being compacted
40/// * `value` — the expanded value object
41pub fn compact_value(
42    active_ctx: &JsonLdContext,
43    active_property: Option<&str>,
44    value: &JsonLdValue,
45) -> Result<JsonLdValue, CompactionError> {
46    let obj = match value {
47        JsonLdValue::Object(m) => m,
48        other => return Ok(other.clone()),
49    };
50
51    // Get the active term definition (if any).
52    let term_def = active_property.and_then(|p| active_ctx.terms.get(p));
53
54    let val = obj.get("@value");
55    let typ = obj.get("@type");
56    let lang = obj.get("@language");
57
58    // 1. @json type — keep as-is.
59    if matches!(typ, Some(JsonLdValue::Str(t)) if t == "@json") {
60        return Ok(value.clone());
61    }
62
63    // 2. Type mapping match.
64    if let Some(JsonLdValue::Str(type_iri)) = typ {
65        if let Some(def) = term_def {
66            if def.type_mapping.as_deref() == Some(type_iri.as_str()) {
67                // Compaction: unwrap @value.
68                return Ok(val.cloned().unwrap_or(JsonLdValue::Null));
69            }
70            if def.type_mapping.as_deref() == Some("@id") {
71                // Value is an IRI; compact it.
72                if let Some(JsonLdValue::Str(v)) = val {
73                    let compacted = compact_iri(active_ctx, v, None, false, false);
74                    return Ok(JsonLdValue::Str(compacted));
75                }
76            }
77        }
78        // Compact the type IRI.
79        let compact_type = compact_iri(active_ctx, type_iri, None, true, false);
80        let mut out: IndexMap<String, JsonLdValue> = IndexMap::new();
81        out.insert(
82            "@value".to_string(),
83            val.cloned().unwrap_or(JsonLdValue::Null),
84        );
85        out.insert("@type".to_string(), JsonLdValue::Str(compact_type));
86        return Ok(JsonLdValue::Object(out));
87    }
88
89    // 3. Language mapping match.
90    if let Some(JsonLdValue::Str(lang_tag)) = lang {
91        if let Some(def) = term_def {
92            if def.language.as_deref() == Some(lang_tag.as_str()) {
93                // Compaction: unwrap @value.
94                return Ok(val.cloned().unwrap_or(JsonLdValue::Null));
95            }
96        }
97        // Keep @language but compact if possible.
98        let mut out: IndexMap<String, JsonLdValue> = IndexMap::new();
99        if let Some(v) = val {
100            out.insert("@value".to_string(), v.clone());
101        }
102        out.insert("@language".to_string(), JsonLdValue::Str(lang_tag.clone()));
103        return Ok(JsonLdValue::Object(out));
104    }
105
106    // 4. Plain string — can drop @value wrapper.
107    if let Some(JsonLdValue::Str(s)) = val {
108        if typ.is_none() && lang.is_none() {
109            return Ok(JsonLdValue::Str(s.clone()));
110        }
111    }
112
113    // 5. Numeric value.
114    if let Some(JsonLdValue::Number(n)) = val {
115        if typ.is_none() && lang.is_none() {
116            return Ok(JsonLdValue::Number(*n));
117        }
118    }
119
120    // 6. Boolean value.
121    if let Some(JsonLdValue::Bool(b)) = val {
122        if typ.is_none() && lang.is_none() {
123            return Ok(JsonLdValue::Bool(*b));
124        }
125    }
126
127    // Fall back to returning the value object as-is (with compacted keys).
128    let mut out: IndexMap<String, JsonLdValue> = IndexMap::new();
129    if let Some(v) = val {
130        out.insert("@value".to_string(), v.clone());
131    }
132    if let Some(t) = typ {
133        out.insert("@type".to_string(), t.clone());
134    }
135    if let Some(l) = lang {
136        out.insert("@language".to_string(), l.clone());
137    }
138    Ok(JsonLdValue::Object(out))
139}
140
141// ============================================================================
142// compact_node — §4.2 Node Object Compaction
143// ============================================================================
144
145/// Compact a node object.
146///
147/// This implements the node object compaction portion of the W3C JSON-LD 1.1
148/// Compaction Algorithm.
149///
150/// # Steps (abbreviated)
151///
152/// 1. Compact `@id`, `@type` using context term definitions.
153/// 2. Handle `@reverse` properties.
154/// 3. Handle container types (`@index`, `@graph`, `@set`, `@list`, `@language`).
155/// 4. Recursively compact all property values.
156///
157/// # Arguments
158///
159/// * `active_ctx` — the current active context
160/// * `type_scoped_ctx` — type-scoped context (may differ from `active_ctx` when types change it)
161/// * `active_property` — the property under which this node appears
162/// * `node` — the expanded node object map
163/// * `options` — compaction options
164pub fn compact_node(
165    active_ctx: &JsonLdContext,
166    _type_scoped_ctx: &JsonLdContext,
167    _active_property: Option<&str>,
168    node: &IndexMap<String, JsonLdValue>,
169    options: &CompactionOptions,
170) -> Result<JsonLdValue, CompactionError> {
171    let mut result: IndexMap<String, JsonLdValue> = IndexMap::new();
172
173    // Step 1: Handle @id.
174    if let Some(id_val) = node.get("@id") {
175        if let Some(id_str) = id_val.as_str() {
176            let compact = compact_iri(active_ctx, id_str, None, false, false);
177            result.insert("@id".to_string(), JsonLdValue::Str(compact));
178        }
179    }
180
181    // Step 2: Handle @type.
182    if let Some(type_val) = node.get("@type") {
183        let types: Vec<&JsonLdValue> = match type_val {
184            JsonLdValue::Array(a) => a.iter().collect(),
185            single => vec![single],
186        };
187        let mut compact_types: Vec<JsonLdValue> = Vec::new();
188        for t in types {
189            if let Some(type_iri) = t.as_str() {
190                let compacted = compact_iri(active_ctx, type_iri, None, true, false);
191                compact_types.push(JsonLdValue::Str(compacted));
192            }
193        }
194        let mut type_iter = compact_types.into_iter();
195        match (options.compact_arrays, type_iter.next()) {
196            (true, Some(single)) if type_iter.len() == 0 => {
197                result.insert("@type".to_string(), single);
198            }
199            (_, first) => {
200                let values: Vec<JsonLdValue> = first.into_iter().chain(type_iter).collect();
201                result.insert("@type".to_string(), JsonLdValue::Array(values));
202            }
203        }
204    }
205
206    // Step 3: Handle @graph.
207    if let Some(graph_val) = node.get("@graph") {
208        let compact_key = compact_iri(active_ctx, "@graph", None, true, false);
209        let compacted = compact_element_dispatch(graph_val, active_ctx, Some("@graph"), options)?;
210        result.insert(compact_key, compacted);
211    }
212
213    // Step 4: Handle @reverse properties.
214    if let Some(rev_val) = node.get("@reverse") {
215        if let Some(rev_obj) = rev_val.as_object() {
216            let mut rev_result: IndexMap<String, JsonLdValue> = IndexMap::new();
217            for (prop, val) in rev_obj {
218                let compact_prop = compact_iri(active_ctx, prop, Some(val), true, true);
219                // Check if there is a reverse term for this property.
220                let rev_term = find_term(active_ctx, prop, Some(val), &[], "@type", "");
221                let compact_val = compact_element_dispatch(val, active_ctx, Some(prop), options)?;
222                if let Some(_term) = rev_term {
223                    // Map directly into the result object as a reverse-mapped term.
224                    result.insert(compact_prop.clone(), compact_val);
225                } else {
226                    rev_result.insert(compact_prop, compact_val);
227                }
228            }
229            if !rev_result.is_empty() {
230                result.insert("@reverse".to_string(), JsonLdValue::Object(rev_result));
231            }
232        }
233    }
234
235    // Step 5: Handle all other properties.
236    for (expanded_prop, value) in node {
237        // Skip already-handled keywords.
238        if matches!(
239            expanded_prop.as_str(),
240            "@id" | "@type" | "@graph" | "@reverse" | "@context"
241        ) {
242            continue;
243        }
244
245        if is_keyword(expanded_prop) {
246            // Map JSON-LD keyword to compact form.
247            let compact_key = compact_iri(active_ctx, expanded_prop, None, true, false);
248            let compact_val =
249                compact_element_dispatch(value, active_ctx, Some(expanded_prop), options)?;
250            result.insert(compact_key, compact_val);
251            continue;
252        }
253
254        // Find the compact property IRI.
255        let compact_prop = compact_iri(active_ctx, expanded_prop, Some(value), true, false);
256
257        // Look up the term definition for container typing.
258        let term_def = active_ctx.terms.get(&compact_prop);
259
260        // Compact the value(s).
261        let values: &[JsonLdValue] = match value {
262            JsonLdValue::Array(a) => a.as_slice(),
263            single => std::slice::from_ref(single),
264        };
265
266        if let Some(def) = term_def {
267            // Handle @language container.
268            if def.container.contains(&ContainerType::Language) {
269                let lang_map = build_language_map(values, active_ctx, expanded_prop, options)?;
270                result.insert(compact_prop, lang_map);
271                continue;
272            }
273
274            // Handle @type container.
275            if def.container.contains(&ContainerType::Type) {
276                let type_map = build_type_map(values, active_ctx, expanded_prop, options)?;
277                result.insert(compact_prop, type_map);
278                continue;
279            }
280
281            // Handle @index container.
282            if def.container.contains(&ContainerType::Index) {
283                let idx_map = build_index_map(values, active_ctx, expanded_prop, options)?;
284                result.insert(compact_prop, idx_map);
285                continue;
286            }
287
288            // Handle @list container.
289            if def.container.contains(&ContainerType::List) {
290                // Unwrap the @list array.
291                let list_items = collect_list_items(values);
292                let mut compact_items: Vec<JsonLdValue> = Vec::new();
293                for item in &list_items {
294                    let c =
295                        compact_element_dispatch(item, active_ctx, Some(expanded_prop), options)?;
296                    compact_items.push(c);
297                }
298                result.insert(compact_prop, JsonLdValue::Array(compact_items));
299                continue;
300            }
301
302            // Handle @set container — always keep as array.
303            if def.container.contains(&ContainerType::Set) {
304                let compact_arr = compact_array(active_ctx, expanded_prop, values, options)?;
305                // Force array even for single element.
306                let arr = match compact_arr {
307                    JsonLdValue::Array(a) => JsonLdValue::Array(a),
308                    single => JsonLdValue::Array(vec![single]),
309                };
310                result.insert(compact_prop, arr);
311                continue;
312            }
313        }
314
315        // Default: compact value(s).
316        let compact_val = if values.len() == 1 && options.compact_arrays {
317            // Try to compact single value.
318            let v = compact_element_dispatch(&values[0], active_ctx, Some(expanded_prop), options)?;
319            // But if the term requires an array container, keep it.
320            let needs_array = term_def
321                .map(|d| {
322                    d.container.contains(&ContainerType::Set)
323                        || d.container.contains(&ContainerType::List)
324                })
325                .unwrap_or(false);
326            if needs_array {
327                JsonLdValue::Array(vec![v])
328            } else {
329                v
330            }
331        } else {
332            compact_array(active_ctx, expanded_prop, values, options)?
333        };
334
335        // Merge into result (handle duplicate keys by collecting into array).
336        insert_or_merge(&mut result, compact_prop, compact_val);
337    }
338
339    // Optionally sort keys for deterministic output.
340    if options.ordered {
341        result.sort_keys();
342    }
343
344    Ok(JsonLdValue::Object(result))
345}
346
347// ============================================================================
348// compact_array — §4.3 Array Compaction
349// ============================================================================
350
351/// Compact an array of JSON-LD values.
352///
353/// If `compact_arrays=true` and the array has exactly one element, and the
354/// active property's container does not require an array, the element is
355/// returned unwrapped (as a scalar).
356///
357/// # Arguments
358///
359/// * `active_ctx` — the active context
360/// * `active_property` — the property under which this array appears
361/// * `array` — the array elements to compact
362/// * `options` — compaction options
363pub fn compact_array(
364    active_ctx: &JsonLdContext,
365    active_property: &str,
366    array: &[JsonLdValue],
367    options: &CompactionOptions,
368) -> Result<JsonLdValue, CompactionError> {
369    let mut out: Vec<JsonLdValue> = Vec::with_capacity(array.len());
370
371    for item in array {
372        let compacted = compact_element_dispatch(item, active_ctx, Some(active_property), options)?;
373        out.push(compacted);
374    }
375
376    // Determine if we can collapse to a single value.
377    let term_def = active_ctx.terms.get(active_property);
378    let requires_array = term_def
379        .map(|d| {
380            d.container.contains(&ContainerType::Set) || d.container.contains(&ContainerType::List)
381        })
382        .unwrap_or(false);
383
384    if options.compact_arrays && out.len() == 1 && !requires_array {
385        Ok(out.into_iter().next().unwrap_or(JsonLdValue::Null))
386    } else {
387        Ok(JsonLdValue::Array(out))
388    }
389}
390
391// ============================================================================
392// Helpers
393// ============================================================================
394
395/// Dispatch compaction for any value type (node, value, array, scalar).
396fn compact_element_dispatch(
397    value: &JsonLdValue,
398    ctx: &JsonLdContext,
399    active_property: Option<&str>,
400    options: &CompactionOptions,
401) -> Result<JsonLdValue, CompactionError> {
402    match value {
403        JsonLdValue::Object(map) => {
404            if map.contains_key("@value") {
405                compact_value(ctx, active_property, value)
406            } else if map.contains_key("@list") {
407                let items = match map.get("@list") {
408                    Some(JsonLdValue::Array(a)) => a.as_slice(),
409                    _ => &[],
410                };
411                compact_array(ctx, active_property.unwrap_or("@list"), items, options)
412            } else {
413                compact_node(ctx, ctx, active_property, map, options)
414            }
415        }
416        JsonLdValue::Array(items) => {
417            compact_array(ctx, active_property.unwrap_or("@graph"), items, options)
418        }
419        other => Ok(other.clone()),
420    }
421}
422
423/// Build a language-keyed map from an array of value objects.
424///
425/// Example: `[{"@value": "hello", "@language": "en"}, {"@value": "bonjour", "@language": "fr"}]`
426/// becomes `{"en": "hello", "fr": "bonjour"}`.
427fn build_language_map(
428    values: &[JsonLdValue],
429    _ctx: &JsonLdContext,
430    _active_property: &str,
431    options: &CompactionOptions,
432) -> Result<JsonLdValue, CompactionError> {
433    let mut lang_map: IndexMap<String, JsonLdValue> = IndexMap::new();
434    for val in values {
435        // For a language container, the value object `{"@value": v, "@language": lang}`
436        // compacts to the key=lang, value=v form.
437        let (lang_key, compacted) = match val {
438            JsonLdValue::Object(obj) => {
439                let lang = obj
440                    .get("@language")
441                    .and_then(|l| l.as_str())
442                    .unwrap_or("@none")
443                    .to_string();
444                // The compacted value is the plain @value string.
445                let v = obj.get("@value").cloned().unwrap_or(JsonLdValue::Null);
446                (lang, v)
447            }
448            other => ("@none".to_string(), other.clone()),
449        };
450        insert_or_merge(&mut lang_map, lang_key, compacted);
451    }
452    // Optionally sort.
453    if options.ordered {
454        lang_map.sort_keys();
455    }
456    Ok(JsonLdValue::Object(lang_map))
457}
458
459/// Build a type-keyed map from an array of value objects.
460fn build_type_map(
461    values: &[JsonLdValue],
462    ctx: &JsonLdContext,
463    active_property: &str,
464    options: &CompactionOptions,
465) -> Result<JsonLdValue, CompactionError> {
466    let mut type_map: IndexMap<String, JsonLdValue> = IndexMap::new();
467    for val in values {
468        let compacted = compact_value(ctx, Some(active_property), val)?;
469        let type_key = match val {
470            JsonLdValue::Object(obj) => obj
471                .get("@type")
472                .and_then(|t| t.as_str())
473                .map(|t| compact_iri(ctx, t, None, true, false))
474                .unwrap_or_else(|| "@none".to_string()),
475            _ => "@none".to_string(),
476        };
477        insert_or_merge(&mut type_map, type_key, compacted);
478    }
479    if options.ordered {
480        type_map.sort_keys();
481    }
482    Ok(JsonLdValue::Object(type_map))
483}
484
485/// Build an index-keyed map from an array of node/value objects with `@index`.
486fn build_index_map(
487    values: &[JsonLdValue],
488    ctx: &JsonLdContext,
489    active_property: &str,
490    options: &CompactionOptions,
491) -> Result<JsonLdValue, CompactionError> {
492    let mut idx_map: IndexMap<String, JsonLdValue> = IndexMap::new();
493    for val in values {
494        let idx_key = match val {
495            JsonLdValue::Object(obj) => obj
496                .get("@index")
497                .and_then(|i| i.as_str())
498                .unwrap_or("@none")
499                .to_string(),
500            _ => "@none".to_string(),
501        };
502        let compacted = compact_element_dispatch(val, ctx, Some(active_property), options)?;
503        insert_or_merge(&mut idx_map, idx_key, compacted);
504    }
505    if options.ordered {
506        idx_map.sort_keys();
507    }
508    Ok(JsonLdValue::Object(idx_map))
509}
510
511/// Collect items from `@list` wrappers.
512fn collect_list_items(values: &[JsonLdValue]) -> Vec<&JsonLdValue> {
513    let mut items: Vec<&JsonLdValue> = Vec::new();
514    for val in values {
515        match val {
516            JsonLdValue::Object(obj) => {
517                if let Some(JsonLdValue::Array(list)) = obj.get("@list") {
518                    items.extend(list.iter());
519                } else {
520                    items.push(val);
521                }
522            }
523            _ => items.push(val),
524        }
525    }
526    items
527}
528
529/// Insert a value into a map, merging with existing values via an array.
530fn insert_or_merge(map: &mut IndexMap<String, JsonLdValue>, key: String, value: JsonLdValue) {
531    if let Some(existing) = map.get_mut(&key) {
532        // First check if it's already an array so we can push without reconstruction.
533        if let JsonLdValue::Array(arr) = existing {
534            arr.push(value);
535            return;
536        }
537        // Not an array — replace with a two-element array.
538        let prev = std::mem::replace(existing, JsonLdValue::Null);
539        *existing = JsonLdValue::Array(vec![prev, value]);
540    } else {
541        map.insert(key, value);
542    }
543}
544
545/// Add a simple term definition (IRI prefix) to the context for compaction use.
546pub fn add_prefix_term(ctx: &mut JsonLdContext, prefix: impl Into<String>, iri: impl Into<String>) {
547    ctx.terms
548        .insert(prefix.into(), TermDefinition::prefix(iri.into()));
549}