Skip to main content

jsonld_context_processing/algorithm/
mod.rs

1//! # Two implementations, one algorithm: keep `sync.rs` in step
2//!
3//! This module, together with `define.rs` and `iri.rs`, holds the `async`
4//! implementation of the [context processing algorithm][1] — the one that can
5//! call the [`Loader`] to fetch remote contexts and `@import`ed documents.
6//! `sync.rs`, in this same directory, holds a second, hand-maintained
7//! implementation of the *same* algorithm with `async`, `.await` and `Box::pin`
8//! stripped out and recursion made direct. It is used as a fast path whenever
9//! `requires_loader` proves the input contains no remote `@context` IRI and no
10//! `@import`, which is the common case in real payloads.
11//!
12//! Which of the two runs is invisible to callers: for any input both must
13//! produce the same active context and the same error. **A change to the
14//! algorithm here therefore has to be mirrored in `sync.rs`, and a change there
15//! mirrored back here.** Nothing enforces this — the two are separate function
16//! bodies with no shared code, so a fix applied to only one silently makes the
17//! result depend on whether the input happened to reference a remote context.
18//! The mirrored pairs are:
19//!
20//! | this module | `sync.rs` |
21//! |---|---|
22//! | `process_context` (`mod.rs`) | `process_context_sync` |
23//! | `define` (`define.rs`) | `define_sync` |
24//! | `expand_iri_with` (`iri.rs`) | `expand_iri_with_sync` |
25//!
26//! `sync.rs` does guard the one class of divergence it can detect — reaching a
27//! code path that would need the loader — by returning
28//! [`Error::LoadingDocumentFailed`]. That protects against `requires_loader`
29//! drifting out of step with the algorithm body; it says nothing about the two
30//! implementations otherwise agreeing.
31//!
32//! [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
33
34use std::{hash::Hash, sync::Arc};
35
36use crate::{Error, Options, Process, Processed, ProcessingCache, ProcessingResult, ProcessingStack, WarningHandler, cache::cache_key};
37use iri_rs::{Iri, IriBuf};
38use jsonld_core::{Context, Environment, ExtractContext, Loader, ProcessingMode, Term};
39use jsonld_syntax::{self as syntax, Nullable};
40use rdfx::vocabulary::VocabularyMut;
41
42mod define;
43mod iri;
44mod merged;
45mod sync;
46
47pub use define::*;
48pub use iri::*;
49pub use merged::*;
50use sync::{process_context_sync, requires_loader};
51use syntax::context::definition::KeyOrKeywordRef;
52
53/// Checks an `@vocab` value against the JSON-LD 1.0 rule.
54///
55/// 1.0 required the value to be an absolute IRI or a blank node identifier;
56/// document-relative resolution only arrived in 1.1. A bare `_:` prefix counts,
57/// as it is what makes `@vocab` map properties to blank nodes.
58pub(crate) fn is_valid_vocab_json_ld_1_0(value: &syntax::context::definition::Vocab) -> bool {
59    value.as_str().starts_with("_:") || Iri::new(value.as_str()).is_ok()
60}
61
62impl Process for syntax::context::Context {
63    async fn process_full<N, L, W>(
64        &self,
65        vocabulary: &mut N,
66        active_context: &Context<N::Iri, N::BlankId>,
67        loader: &L,
68        base_url: Option<N::Iri>,
69        options: Options,
70        mut warnings: W,
71    ) -> Result<Processed<'_, N::Iri, N::BlankId>, Error<L::Error>>
72    where
73        N: VocabularyMut,
74        N::Iri: Clone + Eq + Hash,
75        N::BlankId: Clone + PartialEq,
76        L: Loader,
77        W: WarningHandler<N>,
78    {
79        // Fast path: no remote `@context` IRIs and no `@import` anywhere in
80        // the tree means the entire algorithm runs synchronously — no
81        // `Box::pin` per recursion, no future state machine.
82        if !requires_loader(self) {
83            return process_context_sync(
84                Environment {
85                    vocabulary,
86                    loader,
87                    warnings: &mut warnings,
88                },
89                active_context,
90                self,
91                ProcessingStack::default(),
92                base_url,
93                options,
94                0,
95            );
96        }
97
98        process_context(
99            Environment {
100                vocabulary,
101                loader,
102                warnings: &mut warnings,
103            },
104            active_context,
105            self,
106            ProcessingStack::default(),
107            base_url,
108            options,
109        )
110        .await
111    }
112
113    async fn process_full_with_cache<N, L, W>(
114        &self,
115        vocabulary: &mut N,
116        active_context: &Context<N::Iri, N::BlankId>,
117        loader: &L,
118        base_url: Option<N::Iri>,
119        options: Options,
120        mut warnings: W,
121        cache: &ProcessingCache<N::Iri, N::BlankId>,
122    ) -> Result<Processed<'_, N::Iri, N::BlankId>, Error<L::Error>>
123    where
124        N: VocabularyMut,
125        N::Iri: Clone + Eq + Hash,
126        N::BlankId: Clone + Eq + Hash,
127        L: Loader,
128        W: WarningHandler<N>,
129    {
130        let key = cache_key(active_context, self, base_url.as_ref(), options);
131
132        if let Some(cached) = cache.get(key, active_context, self, base_url.as_ref(), options) {
133            return Ok(Processed::new(self, (*cached).clone()));
134        }
135
136        // Retained for the cache entry: `base_url` is moved into processing.
137        let entry_active = active_context.clone();
138        let entry_base_url = base_url.clone();
139
140        let processed = if requires_loader(self) {
141            process_context(
142                Environment {
143                    vocabulary,
144                    loader,
145                    warnings: &mut warnings,
146                },
147                active_context,
148                self,
149                ProcessingStack::default(),
150                base_url,
151                options,
152            )
153            .await?
154        } else {
155            process_context_sync(
156                Environment {
157                    vocabulary,
158                    loader,
159                    warnings: &mut warnings,
160                },
161                active_context,
162                self,
163                ProcessingStack::default(),
164                base_url,
165                options,
166                0,
167            )?
168        };
169
170        cache.insert(key, entry_active, self.clone(), entry_base_url, options, Arc::new(processed.processed.clone()));
171        Ok(processed)
172    }
173}
174
175/// Resolves `iri_ref` against `base_iri` and interns the result in `vocabulary`.
176///
177/// With no base IRI, `iri_ref` must already be an absolute IRI. Returns `None`
178/// when the reference cannot be turned into one either way.
179fn resolve_iri<V, I>(vocabulary: &mut V, iri_ref: iri_rs::IriRef<&str>, base_iri: Option<&I>) -> Option<I>
180where
181    V: rdfx::vocabulary::IriVocabularyMut<Iri = I>,
182{
183    if let Some(base_iri) = base_iri {
184        let base = vocabulary.iri(base_iri)?;
185        let resolved = iri_ref.resolved(&base).ok()?;
186        let iri_buf = IriBuf::try_from(resolved).ok()?;
187        Some(vocabulary.insert_owned(iri_buf))
188    } else {
189        let iri = Iri::try_from(iri_ref).ok()?;
190        Some(vocabulary.insert(iri))
191    }
192}
193
194/// Runs the [context processing algorithm][1], layering `local_context` on top
195/// of `active_context`.
196///
197/// Recurses for each remote `@context` IRI, fetching it through the loader, and
198/// for each term definition's scoped `@context` via `define`. `remote_contexts`
199/// carries the chain of remote context URLs entered so far, which bounds that
200/// recursion; a top-level call starts from an empty [`ProcessingStack`].
201///
202/// Every mutation is applied to a clone of `active_context`, so the caller's
203/// context is left untouched.
204///
205/// This is the `async` half of a mirrored pair; see the module documentation
206/// before changing it.
207///
208/// [1]: https://www.w3.org/TR/json-ld11-api/#context-processing-algorithm
209async fn process_context<'l: 'a, 'a, N, L, W>(
210    mut env: Environment<'a, N, L, W>,
211    active_context: &'a Context<N::Iri, N::BlankId>,
212    local_context: &'l syntax::context::Context,
213    mut remote_contexts: ProcessingStack<N::Iri>,
214    base_url: Option<N::Iri>,
215    mut options: Options,
216) -> ProcessingResult<'l, N::Iri, N::BlankId, L::Error>
217where
218    N: VocabularyMut,
219    N::Iri: Clone + Eq + Hash,
220    N::BlankId: Clone + PartialEq,
221    L: Loader,
222    W: WarningHandler<N>,
223{
224    // 1) Initialize result to the result of cloning active context.
225    let mut result = active_context.clone();
226    result.set_processing_mode(options.processing_mode);
227
228    // 2) If `local_context` is an object containing the member @propagate,
229    // its value MUST be boolean true or false, set `propagate` to that value.
230    if let syntax::context::Context::One(syntax::ContextEntry::Definition(def)) = local_context
231        && let Some(propagate) = def.propagate
232    {
233        if options.processing_mode == ProcessingMode::JsonLd1_0 {
234            return Err(Error::InvalidContextEntry);
235        }
236
237        options.propagate = propagate;
238    }
239
240    // 3) If propagate is false, and result does not have a previous context,
241    // set previous context in result to active context.
242    if !options.propagate && result.previous_context().is_none() {
243        result.set_previous_context(active_context.clone());
244    }
245
246    // 4) If local context is not an array, set it to an array containing only local context.
247    // 5) For each item context in local context:
248    for context in local_context {
249        match context {
250            // 5.1) If context is null:
251            syntax::ContextEntry::Null => {
252                // If `override_protected` is false and `active_context` contains any protected term
253                // definitions, an invalid context nullification has been detected and processing
254                // is aborted.
255                if !options.override_protected && result.has_protected_items() {
256                    return Err(Error::InvalidContextNullification);
257                }
258                // Otherwise, initialize result as a newly-initialized active context, setting
259                // previous_context in result to the previous value of result if propagate is
260                // false. Continue with the next context.
261                let previous_result = result;
262
263                // Initialize `result` as a newly-initialized active context, setting both
264                // `base_iri` and `original_base_url` to the value of `original_base_url` in
265                // active context, ...
266                result = Context::new(active_context.original_base_url().cloned());
267                result.set_processing_mode(options.processing_mode);
268
269                // ... and, if `propagate` is `false`, `previous_context` in `result` to the
270                // previous value of `result`.
271                if !options.propagate {
272                    result.set_previous_context(previous_result);
273                }
274            }
275
276            // 5.2) If context is a string,
277            syntax::ContextEntry::IriRef(iri_ref) => {
278                // Initialize `context` to the result of resolving context against base URL.
279                // If base URL is not a valid IRI, then context MUST be a valid IRI, otherwise
280                // a loading document failed error has been detected and processing is aborted.
281                let context_iri = resolve_iri(env.vocabulary, iri_ref.as_ref(), base_url.as_ref()).ok_or(Error::LoadingDocumentFailed)?;
282
283                // If the number of entries in the `remote_contexts` array exceeds a processor
284                // defined limit, a context overflow error has been detected and processing is
285                // aborted; otherwise, add context to remote contexts.
286                //
287                // If context was previously dereferenced, then the processor MUST NOT do a further
288                // dereference, and context is set to the previously established internal
289                // representation: set `context_document` to the previously dereferenced document,
290                // and set loaded context to the value of the @context entry from the document in
291                // context document.
292                //
293                // Otherwise, set `context document` to the RemoteDocument obtained by dereferencing
294                // context using the LoadDocumentCallback, passing context for url, and
295                // http://www.w3.org/ns/json-ld#context for profile and for requestProfile.
296                //
297                // If context cannot be dereferenced, or the document from context document cannot
298                // be transformed into the internal representation , a loading remote context
299                // failed error has been detected and processing is aborted.
300                // If the document has no top-level map with an @context entry, an invalid remote
301                // context has been detected and processing is aborted.
302                // Set loaded context to the value of that entry.
303                // In JSON-LD 1.1 a context already in `remote_contexts` is simply
304                // not processed again — scoped contexts legitimately reload
305                // contexts, and only the entry limit (context overflow) applies.
306                // JSON-LD 1.0 had no such allowance: a repeat is a recursive
307                // context inclusion error.
308                if remote_contexts.len() >= crate::MAX_REMOTE_CONTEXTS {
309                    return Err(Error::ContextOverflow);
310                }
311
312                let fresh = remote_contexts.push(context_iri.clone());
313                if !fresh && options.processing_mode == ProcessingMode::JsonLd1_0 {
314                    return Err(Error::RecursiveContextInclusion);
315                }
316
317                if fresh {
318                    let loaded_context = env
319                        .loader
320                        .load_with(env.vocabulary, context_iri.clone())
321                        .await?
322                        .into_document()
323                        .into_ld_context()
324                        .map_err(Error::ContextExtractionFailed)?;
325
326                    // Set result to the result of recursively calling this algorithm, passing result
327                    // for active context, loaded context for local context, the documentUrl of context
328                    // document for base URL, and a copy of remote contexts.
329                    let new_options = Options {
330                        processing_mode: options.processing_mode,
331                        override_protected: false,
332                        propagate: true,
333                        vocab: options.vocab,
334                    };
335
336                    let r = Box::pin(process_context(
337                        Environment {
338                            vocabulary: env.vocabulary,
339                            loader: env.loader,
340                            warnings: env.warnings,
341                        },
342                        &result,
343                        &loaded_context,
344                        remote_contexts.clone(),
345                        Some(context_iri),
346                        new_options,
347                    ))
348                    .await?;
349
350                    result = r.into_processed();
351                }
352            }
353
354            // 5.4) Context definition.
355            syntax::ContextEntry::Definition(context) => {
356                // 5.5) If context has a @version entry:
357                if context.version.is_some() {
358                    // 5.5.2) If processing mode is set to json-ld-1.0, a processing mode conflict
359                    // error has been detected.
360                    if options.processing_mode == ProcessingMode::JsonLd1_0 {
361                        return Err(Error::ProcessingModeConflict);
362                    }
363                }
364
365                // 5.6) If context has an @import entry:
366                let import_context = match &context.import {
367                    Some(import_value) => {
368                        // 5.6.1) If processing mode is json-ld-1.0, an invalid context entry error
369                        // has been detected.
370                        if options.processing_mode == ProcessingMode::JsonLd1_0 {
371                            return Err(Error::InvalidContextEntry);
372                        }
373
374                        // 5.6.3) Initialize import to the result of resolving the value of
375                        // @import.
376                        let import = resolve_iri(env.vocabulary, import_value.as_ref(), base_url.as_ref()).ok_or(Error::InvalidImportValue)?;
377
378                        // 5.6.4) Dereference import.
379                        let import_context = env
380                            .loader
381                            .load_with(env.vocabulary, import.clone())
382                            .await?
383                            .into_document()
384                            .into_ld_context()
385                            .map_err(Error::ContextExtractionFailed)?;
386
387                        // If the dereferenced document has no top-level map with an @context
388                        // entry, or if the value of @context is not a context definition
389                        // (i.e., it is not an map), an invalid remote context has been
390                        // detected and processing is aborted; otherwise, set import context
391                        // to the value of that entry.
392                        match &import_context {
393                            syntax::context::Context::One(syntax::ContextEntry::Definition(import_context_def)) => {
394                                // If `import_context` has a @import entry, an invalid context entry
395                                // error has been detected and processing is aborted.
396                                if import_context_def.import.is_some() {
397                                    return Err(Error::InvalidContextEntry);
398                                }
399                            }
400                            _ => {
401                                return Err(Error::InvalidRemoteContext);
402                            }
403                        }
404
405                        // Set `context` to the result of merging context into
406                        // `import_context`, replacing common entries with those from
407                        // `context`.
408                        Some(import_context)
409                    }
410                    None => None,
411                };
412
413                let context = Merged::new(context, import_context);
414
415                // 5.7) If context has a @base entry and remote contexts is empty, i.e.,
416                // the currently being processed context is not a remote context:
417                if remote_contexts.is_empty() {
418                    // Initialize value to the value associated with the @base entry.
419                    if let Some(value) = context.base() {
420                        match value {
421                            syntax::Nullable::Null => {
422                                // If value is null, remove the base IRI of result.
423                                result.set_base_iri(None);
424                            }
425                            syntax::Nullable::Some(iri_ref) => {
426                                if let Ok(iri) = Iri::try_from(iri_ref.as_ref()) {
427                                    result.set_base_iri(Some(env.vocabulary.insert(iri)));
428                                } else {
429                                    let resolved = resolve_iri(env.vocabulary, iri_ref.as_ref(), result.base_iri()).ok_or(Error::InvalidBaseIri)?;
430                                    result.set_base_iri(Some(resolved));
431                                }
432                            }
433                        }
434                    }
435                }
436
437                // 5.8) If context has a @vocab entry:
438                // Initialize value to the value associated with the @vocab entry.
439                if let Some(value) = context.vocab() {
440                    match value {
441                        syntax::Nullable::Null => {
442                            // If value is null, remove any vocabulary mapping from result.
443                            result.set_vocabulary(None);
444                        }
445                        syntax::Nullable::Some(value) => {
446                            // Otherwise, if value is an IRI or blank node identifier, the
447                            // vocabulary mapping of result is set to the result of IRI
448                            // expanding value using true for document relative. If it is not
449                            // an IRI, or a blank node identifier, an invalid vocab mapping
450                            // error has been detected and processing is aborted.
451                            // NOTE: The use of blank node identifiers to value for @vocab is
452                            // obsolete, and may be removed in a future version of JSON-LD.
453                            //
454                            // Document-relative expansion of `@vocab` is a JSON-LD 1.1 addition:
455                            // 1.0 required an absolute IRI or blank node identifier, and rejects
456                            // `""` and `/relative` (`expand#t0115`, `expand#t0116`). A bare `_:`
457                            // prefix stays valid in 1.0 (`expand#t0075`).
458                            let json_ld_1_0 = options.processing_mode == ProcessingMode::JsonLd1_0;
459                            if json_ld_1_0 && !is_valid_vocab_json_ld_1_0(value) {
460                                return Err(Error::InvalidVocabMapping);
461                            }
462
463                            match expand_iri_simple(&mut env, &result, Nullable::Some(value.into()), !json_ld_1_0, Some(options.vocab))? {
464                                Some(arc) if matches!(arc.as_ref(), Term::Id(_)) => {
465                                    let term = std::sync::Arc::try_unwrap(arc).unwrap_or_else(|a| (*a).clone());
466                                    result.set_vocabulary(Some(term));
467                                }
468                                _ => return Err(Error::InvalidVocabMapping),
469                            }
470                        }
471                    }
472                }
473
474                // 5.9) If context has a @language entry:
475                if let Some(value) = context.language() {
476                    match value {
477                        Nullable::Null => {
478                            // 5.9.2) If value is null, remove any default language from result.
479                            result.set_default_language(None);
480                        }
481                        Nullable::Some(tag) => {
482                            result.set_default_language(Some(tag.to_owned()));
483                        }
484                    }
485                }
486
487                // 5.10) If context has a @direction entry:
488                if let Some(value) = context.direction() {
489                    // 5.10.1) If processing mode is json-ld-1.0, an invalid context entry error
490                    // has been detected and processing is aborted.
491                    if options.processing_mode == ProcessingMode::JsonLd1_0 {
492                        return Err(Error::InvalidContextEntry);
493                    }
494
495                    match value {
496                        Nullable::Null => {
497                            // 5.10.3) If value is null, remove any base direction from result.
498                            result.set_default_base_direction(None);
499                        }
500                        Nullable::Some(dir) => {
501                            result.set_default_base_direction(Some(dir));
502                        }
503                    }
504                }
505
506                // 5.12) Create a map `defined` to keep track of whether or not a term
507                // has already been defined or is currently being defined during recursion.
508                let mut defined = DefinedTerms::new();
509                let protected = context.protected().unwrap_or(false);
510
511                // 5.13) For each key-value pair in context where key is not
512                // @base, @direction, @import, @language, @propagate, @protected, @version,
513                // or @vocab,
514                // invoke the Create Term Definition algorithm passing result for
515                // active context, context for local context, key, defined, base URL,
516                // and the value of the @protected entry from context, if any, for protected.
517                // (and the value of override protected)
518                if context.type_().is_some() {
519                    define(
520                        Environment {
521                            vocabulary: env.vocabulary,
522                            loader: env.loader,
523                            warnings: env.warnings,
524                        },
525                        &mut result,
526                        &context,
527                        KeyOrKeywordRef::Keyword(syntax::Keyword::Type),
528                        &mut defined,
529                        remote_contexts.clone(),
530                        base_url.clone(),
531                        protected,
532                        options,
533                    )
534                    .await?;
535                }
536
537                for (key, _binding) in context.bindings() {
538                    define(
539                        Environment {
540                            vocabulary: env.vocabulary,
541                            loader: env.loader,
542                            warnings: env.warnings,
543                        },
544                        &mut result,
545                        &context,
546                        key.into(),
547                        &mut defined,
548                        remote_contexts.clone(),
549                        base_url.clone(),
550                        protected,
551                        options,
552                    )
553                    .await?;
554                }
555            }
556        }
557    }
558
559    Ok(Processed::new(local_context, result))
560}