Skip to main content

jsonld_context_processing/algorithm/
iri.rs

1use std::{hash::Hash, sync::Arc};
2
3use super::{DefinedTerms, Environment, Merged, native_stack::Recursion};
4use crate::{Error, Options, ProcessingStack, Warning, WarningHandler};
5use contextual::WithContext;
6use iri_rs::{Iri, IriRef};
7use jsonld_core::{Context, Id, Loader, ProcessingMode, Term, warning};
8use jsonld_syntax::{self as syntax, ExpandableRef, Nullable, context::definition::Key};
9use rdfx::{
10    BlankId,
11    vocabulary::{BlankIdVocabulary, IriVocabulary, Vocabulary, VocabularyMut},
12};
13use syntax::{CompactIri, context::definition::KeyOrKeywordRef, is_keyword_like};
14
15/// Warning reporting that a value expanded to something that is not a valid IRI.
16///
17/// Carries the value as it was written. A dedicated type rather than a
18/// [`Warning`] variant so that the algorithm can raise it against any warning
19/// vocabulary that knows how to build itself `From<MalformedIri>` —
20/// `jsonld-expansion` has its own warning enum and reuses this.
21pub struct MalformedIri(pub String);
22
23impl From<MalformedIri> for Warning {
24    fn from(MalformedIri(s): MalformedIri) -> Self {
25        Self::MalformedIri(s)
26    }
27}
28
29/// Result of [`expand_iri_with`].
30///
31/// `Ok(None)` means the value was dropped under [`Action::Drop`], as opposed to
32/// expanding to [`Term::Null`].
33pub type ExpandIriResult<T, B, E> = Result<Option<Arc<Term<T, B>>>, Error<E>>;
34
35/// Runs the [IRI expansion algorithm][1] on `value`, defining terms of
36/// `local_context` on demand.
37///
38/// Used while a context is still being processed, which is why it can recurse
39/// into [`define`][super::define]: expanding a term may require first defining
40/// that term, or the prefix of a compact IRI, from the context currently being
41/// built. `defined` guards that recursion against cycles.
42///
43/// `document_relative` allows a value that is otherwise unresolvable to be
44/// resolved against the active context's base IRI. `vocab` enables expansion
45/// through the vocabulary mapping and selects what to do when that is the only
46/// thing that would make the value expand; `None` disables it entirely.
47///
48/// A value that resolves to nothing usable is returned as an invalid identifier
49/// and reported through the warning handler, rather than failing.
50///
51/// [1]: https://www.w3.org/TR/json-ld11-api/#iri-expansion
52///
53/// # Errors
54///
55/// Returns an error when a term definition needed for the expansion is invalid, or when loading a remote context fails.
56pub async fn expand_iri_with<'a, N, L, W>(
57    mut env: Environment<'a, N, L, W>,
58    active_context: &'a mut Context<N::Iri, N::BlankId>,
59    value: Nullable<ExpandableRef<'a>>,
60    document_relative: bool,
61    vocab: Option<Action>,
62    local_context: &'a Merged<'a>,
63    defined: &'a mut DefinedTerms,
64    remote_contexts: ProcessingStack<N::Iri>,
65    options: Options,
66) -> ExpandIriResult<N::Iri, N::BlankId, L::Error>
67where
68    N: VocabularyMut,
69    N::Iri: Clone + Eq + Hash,
70    N::BlankId: Clone + PartialEq,
71    L: Loader,
72    W: WarningHandler<N>,
73{
74    match value {
75        Nullable::Null => Ok(Some(Arc::new(Term::Null))),
76        Nullable::Some(ExpandableRef::Keyword(k)) => Ok(Some(Arc::new(Term::Keyword(k)))),
77        Nullable::Some(ExpandableRef::String(value)) => {
78            if is_keyword_like(value) {
79                return Ok(Some(Arc::new(Term::Null)));
80            }
81
82            // If `local_context` is not null, it contains an entry with a key that equals value, and the
83            // value of the entry for value in defined is not true, invoke the Create Term Definition
84            // algorithm, passing active context, local context, value as term, and defined. This will
85            // ensure that a term definition is created for value in active context during Context
86            // Processing.
87            Recursion::new(super::define(
88                Environment {
89                    vocabulary: env.vocabulary,
90                    loader: env.loader,
91                    warnings: env.warnings,
92                },
93                active_context,
94                local_context,
95                value.into(),
96                defined,
97                remote_contexts.clone(),
98                None,
99                false,
100                options.with_no_override(),
101            ))
102            .await?;
103
104            if let Some(term_definition) = active_context.get(value) {
105                // If active context has a term definition for value, and the associated IRI mapping
106                // is a keyword, return that keyword.
107                if let Some(arc) = term_definition.value_arc()
108                    && arc.is_keyword()
109                {
110                    return Ok(Some(Arc::clone(arc)));
111                }
112
113                // If vocab is true and the active context has a term definition for value, return the
114                // associated IRI mapping.
115                if vocab.is_some() {
116                    return match term_definition.value_arc() {
117                        Some(arc) => Ok(Some(Arc::clone(arc))),
118                        None => Ok(Some(Arc::new(Term::Null))),
119                    };
120                }
121            }
122
123            if value.find(':').is_some_and(|i| i > 0) {
124                if let Ok(blank_id) = BlankId::new(value) {
125                    return Ok(Some(Arc::new(Term::Id(Id::blank(env.vocabulary.insert_blank_id(blank_id))))));
126                }
127
128                if value == "_:" {
129                    return Ok(Some(Arc::new(Term::Id(Id::Invalid("_:".to_string())))));
130                }
131
132                if let Ok(compact_iri) = CompactIri::new(value) {
133                    // If local context is not null, it contains a `prefix` entry, and the value of the
134                    // prefix entry in defined is not true, invoke the Create Term Definition
135                    // algorithm, passing active context, local context, prefix as term, and defined.
136                    // This will ensure that a term definition is created for prefix in active context
137                    // during Context Processing.
138                    Recursion::new(super::define(
139                        Environment {
140                            vocabulary: env.vocabulary,
141                            loader: env.loader,
142                            warnings: env.warnings,
143                        },
144                        active_context,
145                        local_context,
146                        KeyOrKeywordRef::Key(compact_iri.prefix().into()),
147                        defined,
148                        remote_contexts,
149                        None,
150                        false,
151                        options.with_no_override(),
152                    ))
153                    .await?;
154
155                    // If active context contains a term definition for prefix having a non-null IRI
156                    // mapping and the prefix flag of the term definition is true, return the result
157                    // of concatenating the IRI mapping associated with prefix and suffix.
158                    // The `prefix` flag is a JSON-LD 1.1 addition: in 1.0 any term
159                    // definition with an IRI mapping expands a compact IRI
160                    // (`flatten#t0014`). Compaction keeps the 1.1 rule, which is why
161                    // this is relaxed here and not on the flag itself (`compact#tp001`).
162                    let prefix_key = Key::from(compact_iri.prefix());
163                    if let Some(term_definition) = active_context.get_normal(&prefix_key)
164                        && (term_definition.prefix || active_context.processing_mode() == ProcessingMode::JsonLd1_0)
165                        && let Some(mapping) = term_definition.value()
166                    {
167                        let mut result = mapping.with(&*env.vocabulary).as_str().to_string();
168                        result.push_str(compact_iri.suffix());
169
170                        return Ok(Some(Arc::new(Term::Id(Id::from_string_in(env.vocabulary, result)))));
171                    }
172                }
173
174                if let Ok(iri) = Iri::parse(value) {
175                    return Ok(Some(Arc::new(Term::Id(Id::iri(env.vocabulary.insert(iri))))));
176                }
177            }
178
179            // If vocab is true, and active context has a vocabulary mapping, return the result of
180            // concatenating the vocabulary mapping with value.
181            if let Some(action) = vocab {
182                match active_context.vocabulary() {
183                    Some(Term::Id(mapping)) => {
184                        return match action {
185                            Action::Keep => {
186                                let mut result = mapping.with(&*env.vocabulary).as_str().to_string();
187                                result.push_str(value);
188
189                                Ok(Some(Arc::new(Term::Id(Id::from_string_in(env.vocabulary, result)))))
190                            }
191                            Action::Drop => Ok(None),
192                            Action::Reject => Err(Error::ForbiddenVocab),
193                        };
194                    }
195                    Some(_) => return Ok(Some(Arc::new(invalid_iri(&mut env, value.to_string())))),
196                    None => (),
197                }
198            }
199
200            // Otherwise, if document relative is true set value to the result of resolving value
201            // against the base IRI from active context. Only the basic algorithm in section 5.2 of
202            // [RFC3986] is used; neither Syntax-Based Normalization nor Scheme-Based Normalization
203            // are performed. Characters additionally allowed in IRI references are treated in the
204            // same way that unreserved characters are treated in URI references, per section 6.5 of
205            // [RFC3987].
206            if document_relative
207                && let Ok(iri_ref) = IriRef::parse(value)
208                && let Some(iri) = super::resolve_iri(env.vocabulary, iri_ref, active_context.base_iri())
209            {
210                return Ok(Some(Arc::new(Term::from(iri))));
211            }
212
213            // Return value as is.
214            Ok(Some(Arc::new(invalid_iri(&mut env, value.to_string()))))
215        }
216    }
217}
218
219/// Reports `value` as a malformed IRI and returns it as an invalid identifier.
220///
221/// Expansion never fails on an unresolvable value: it keeps it verbatim so the
222/// entry survives, and leaves it to the caller's policy to reject it later.
223fn invalid_iri<N, L, W: jsonld_core::warning::Handler<N, Warning>>(env: &mut Environment<N, L, W>, value: String) -> Term<N::Iri, N::BlankId>
224where
225    N: Vocabulary,
226{
227    env.warnings.handle(env.vocabulary, MalformedIri(value.clone()).into());
228    Term::Id(Id::Invalid(value))
229}
230
231#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
232/// How strictly to treat a term whose expansion is questionable.
233///
234/// Used by this crate for terms that only expand because the active context
235/// defines a vocabulary mapping (see [`Options::vocab`][crate::Options::vocab]),
236/// and by `jsonld-expansion` for those as well as for terms that expand to a
237/// malformed IRI.
238pub enum Action {
239    #[default]
240    /// Accept the expansion. This is the behaviour the specification requires.
241    Keep,
242    /// Treat the term as unexpandable, so that whatever entry carries it is
243    /// dropped.
244    Drop,
245    /// Abort processing with an error.
246    Reject,
247}
248
249impl Action {
250    /// Checks whether this action aborts processing rather than accepting or
251    /// dropping the term.
252    #[must_use]
253    pub fn is_reject(&self) -> bool {
254        matches!(self, Self::Reject)
255    }
256}
257
258#[derive(Debug)]
259/// A term could only be expanded through the vocabulary mapping, while
260/// [`Options::vocab`][crate::Options::vocab] was set to [`Action::Reject`].
261///
262/// Reported by the loader-free expansion path, which cannot construct an
263/// [`Error`]; callers convert it into [`Error::ForbiddenVocab`].
264pub struct RejectVocab;
265
266/// Result of expanding a term into an IRI without a loader.
267///
268/// `Ok(None)` means the term was dropped under [`Action::Drop`], as opposed to
269/// expanding to [`Term::Null`].
270pub type IriExpansionResult<N> = Result<Option<Arc<Term<<N as IriVocabulary>::Iri, <N as BlankIdVocabulary>::BlankId>>>, RejectVocab>;
271// `needless_pass_by_value`: `ProcessingStack` is a single `Arc` handle and
272// `Nullable<ExpandableRef>` wraps a borrow, so by-value is the cheaper calling
273// convention here, and callers `.clone()` the stack deliberately to fork it per
274// branch. Taking references would add an indirection and push the clones inside.
275/// Expands `value` into an IRI using only the term definitions `active_context`
276/// already has.
277///
278/// The loader-free counterpart of [`expand_iri_with`]: it never creates a term
279/// definition on demand, so it can only be used once the enclosing context has
280/// been processed, or for values that cannot introduce a dependency (`@vocab`,
281/// `@index`, a relative-IRI term).
282///
283/// `document_relative` allows a value that is otherwise unresolvable to be
284/// resolved against the active context's base IRI. `vocab` enables expansion
285/// through the vocabulary mapping and selects what to do when that is the only
286/// thing that would make the value expand; `None` disables it entirely.
287///
288/// Results are memoized per active context for the dominant call shape — a
289/// string value with `vocab: Some(Action::Keep)` and `document_relative` off —
290/// which is what makes repeated keys across the nodes of a document cheap.
291///
292/// # Errors
293///
294/// Returns an error when the value cannot be expanded into an IRI with the definitions already in the active context.
295#[allow(clippy::needless_pass_by_value)]
296pub fn expand_iri_simple<W, N, L, H>(
297    env: &mut Environment<N, L, H>,
298    active_context: &Context<N::Iri, N::BlankId>,
299    value: Nullable<ExpandableRef>,
300    document_relative: bool,
301    vocab: Option<Action>,
302) -> IriExpansionResult<N>
303where
304    N: VocabularyMut,
305    N::Iri: Clone,
306    N::BlankId: Clone,
307    W: From<MalformedIri>,
308    H: warning::Handler<N, W>,
309{
310    // Per-context memoization of the dominant key-expansion call shape:
311    // `vocab=Some(Keep)`, `document_relative=false`, value is a string. Hits
312    // skip term-definition lookup, blank/CompactIRI/Iri::parse validators and
313    // the vocab-fallback string concat + IRI re-parse + Arc::new for repeated
314    // keys (the common NGSI-LD / schema.org pattern).
315    let cache_value: Option<&str> = if !document_relative && matches!(vocab, Some(Action::Keep)) {
316        if let Nullable::Some(ExpandableRef::String(s)) = &value {
317            Some(*s)
318        } else {
319            None
320        }
321    } else {
322        None
323    };
324
325    if let Some(s) = cache_value
326        && let Some(arc) = active_context.term_resolution_cache().get(s)
327    {
328        return Ok(Some(arc));
329    }
330
331    let result = expand_iri_simple_inner::<W, N, L, H>(env, active_context, value, document_relative, vocab)?;
332
333    if let (Some(s), Some(arc)) = (cache_value, &result) {
334        active_context.term_resolution_cache().insert(Box::from(s), Arc::clone(arc));
335    }
336
337    Ok(result)
338}
339
340/// The body of [`expand_iri_simple`], without the memoization wrapper.
341#[allow(clippy::needless_pass_by_value)]
342fn expand_iri_simple_inner<W, N, L, H>(
343    env: &mut Environment<N, L, H>,
344    active_context: &Context<N::Iri, N::BlankId>,
345    value: Nullable<ExpandableRef>,
346    document_relative: bool,
347    vocab: Option<Action>,
348) -> IriExpansionResult<N>
349where
350    N: VocabularyMut,
351    N::Iri: Clone,
352    N::BlankId: Clone,
353    W: From<MalformedIri>,
354    H: warning::Handler<N, W>,
355{
356    match value {
357        Nullable::Null => Ok(Some(Arc::new(Term::Null))),
358        Nullable::Some(ExpandableRef::Keyword(k)) => Ok(Some(Arc::new(Term::Keyword(k)))),
359        Nullable::Some(ExpandableRef::String(value)) => {
360            if is_keyword_like(value) {
361                return Ok(Some(Arc::new(Term::Null)));
362            }
363
364            if let Some(term_definition) = active_context.get(value) {
365                // If active context has a term definition for value, and the associated IRI mapping
366                // is a keyword, return that keyword.
367                if let Some(arc) = term_definition.value_arc()
368                    && arc.is_keyword()
369                {
370                    return Ok(Some(Arc::clone(arc)));
371                }
372
373                // If vocab is true and the active context has a term definition for value, return the
374                // associated IRI mapping.
375                if vocab.is_some() {
376                    return match term_definition.value_arc() {
377                        Some(arc) => Ok(Some(Arc::clone(arc))),
378                        None => Ok(Some(Arc::new(Term::Null))),
379                    };
380                }
381            }
382
383            if value.find(':').is_some_and(|i| i > 0) {
384                if let Ok(blank_id) = BlankId::new(value) {
385                    return Ok(Some(Arc::new(Term::Id(Id::blank(env.vocabulary.insert_blank_id(blank_id))))));
386                }
387
388                if value == "_:" {
389                    return Ok(Some(Arc::new(Term::Id(Id::Invalid("_:".to_string())))));
390                }
391
392                if let Ok(compact_iri) = CompactIri::new(value) {
393                    // If active context contains a term definition for prefix having a non-null IRI
394                    // mapping and the prefix flag of the term definition is true, return the result
395                    // of concatenating the IRI mapping associated with prefix and suffix.
396                    // The `prefix` flag is a JSON-LD 1.1 addition: in 1.0 any term
397                    // definition with an IRI mapping expands a compact IRI
398                    // (`flatten#t0014`). Compaction keeps the 1.1 rule, which is why
399                    // this is relaxed here and not on the flag itself (`compact#tp001`).
400                    let prefix_key = Key::from(compact_iri.prefix());
401                    if let Some(term_definition) = active_context.get_normal(&prefix_key)
402                        && (term_definition.prefix || active_context.processing_mode() == ProcessingMode::JsonLd1_0)
403                        && let Some(mapping) = term_definition.value()
404                    {
405                        let mut result = mapping.with(&*env.vocabulary).as_str().to_string();
406                        result.push_str(compact_iri.suffix());
407
408                        return Ok(Some(Arc::new(Term::Id(Id::from_string_in(env.vocabulary, result)))));
409                    }
410                }
411
412                if let Ok(iri) = Iri::parse(value) {
413                    return Ok(Some(Arc::new(Term::Id(Id::iri(env.vocabulary.insert(iri))))));
414                }
415            }
416
417            // If vocab is true, and active context has a vocabulary mapping, return the result of
418            // concatenating the vocabulary mapping with value.
419            if let Some(action) = vocab {
420                match active_context.vocabulary() {
421                    Some(Term::Id(mapping)) => {
422                        return match action {
423                            Action::Keep => {
424                                let mut result = mapping.with(&*env.vocabulary).as_str().to_string();
425                                result.push_str(value);
426
427                                Ok(Some(Arc::new(Term::Id(Id::from_string_in(env.vocabulary, result)))))
428                            }
429                            Action::Drop => Ok(None),
430                            Action::Reject => Err(RejectVocab),
431                        };
432                    }
433                    Some(_) => return Ok(Some(Arc::new(invalid_iri_simple(env, value.to_string())))),
434                    None => (),
435                }
436            }
437
438            // Otherwise, if document relative is true set value to the result of resolving value
439            // against the base IRI from active context. Only the basic algorithm in section 5.2 of
440            // [RFC3986] is used; neither Syntax-Based Normalization nor Scheme-Based Normalization
441            // are performed. Characters additionally allowed in IRI references are treated in the
442            // same way that unreserved characters are treated in URI references, per section 6.5 of
443            // [RFC3987].
444            if document_relative
445                && let Ok(iri_ref) = IriRef::parse(value)
446                && let Some(iri) = super::resolve_iri(env.vocabulary, iri_ref, active_context.base_iri())
447            {
448                return Ok(Some(Arc::new(Term::from(iri))));
449            }
450
451            // Return value as is.
452            Ok(Some(Arc::new(invalid_iri_simple(env, value.to_string()))))
453        }
454    }
455}
456
457/// Reports `value` as a malformed IRI and returns it as an invalid identifier.
458///
459/// Same as [`invalid_iri`], for the loader-free path's more general warning
460/// handler.
461fn invalid_iri_simple<W, N, L, H>(env: &mut Environment<N, L, H>, value: String) -> Term<N::Iri, N::BlankId>
462where
463    N: Vocabulary,
464    W: From<MalformedIri>,
465    H: warning::Handler<N, W>,
466{
467    env.warnings.handle(env.vocabulary, MalformedIri(value.clone()).into());
468    Term::Id(Id::Invalid(value))
469}