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