jsonld_context_processing/algorithm/iri.rs
1use std::{hash::Hash, sync::Arc};
2
3use super::{DefinedTerms, Environment, Merged};
4use crate::{Error, Options, ProcessingStack, Warning, WarningHandler};
5use contextual::WithContext;
6use iri_rs::{Iri, IriRef};
7use jsonld_core::{Context, Id, Loader, NoLoader, ProcessingMode, Term, warning};
8use jsonld_syntax::{self as syntax, ExpandableRef, Nullable, context::definition::Key, native_stack::Recursion};
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}
470
471/// Expands `term` as the Expansion algorithm expands a member key of a
472/// node object under `active_context`: `vocab` on, not document-relative
473/// (JSON-LD 1.1 API, Expansion Algorithm step 13.2).
474///
475/// Returns the expanded term (an IRI, a blank node identifier, a keyword,
476/// or an invalid identifier carrying the text as written), or `None` when
477/// `policy` is [`Action::Drop`] and only `@vocab` would expand it. Warnings
478/// are discarded; no loader is involved, since the active context is already
479/// processed.
480///
481/// # Errors
482///
483/// [`RejectVocab`] when `policy` is [`Action::Reject`] and only `@vocab` would
484/// expand it.
485pub fn expand_property_key<N: VocabularyMut>(
486 vocabulary: &mut N,
487 active_context: &Context<N::Iri, N::BlankId>,
488 term: &str,
489 policy: Action,
490) -> IriExpansionResult<N>
491where
492 N::Iri: Clone,
493 N::BlankId: Clone,
494{
495 expand_iri_simple::<MalformedIri, N, NoLoader, ()>(
496 &mut Environment {
497 vocabulary,
498 loader: &NoLoader,
499 warnings: &mut (),
500 },
501 active_context,
502 Nullable::Some(term.into()),
503 false,
504 Some(policy),
505 )
506}
507
508/// Expands `value` as the Expansion algorithm expands an `@type` value of a
509/// node object under `active_context`: `vocab` on, document-relative against
510/// the context's base IRI (Expansion Algorithm step 13.4.4.4).
511///
512/// Same results and errors as [`expand_property_key`].
513///
514/// # Errors
515///
516/// [`RejectVocab`] when `policy` is [`Action::Reject`] and only `@vocab` would
517/// expand it.
518pub fn expand_type_value<N: VocabularyMut>(
519 vocabulary: &mut N,
520 active_context: &Context<N::Iri, N::BlankId>,
521 value: &str,
522 policy: Action,
523) -> IriExpansionResult<N>
524where
525 N::Iri: Clone,
526 N::BlankId: Clone,
527{
528 expand_iri_simple::<MalformedIri, N, NoLoader, ()>(
529 &mut Environment {
530 vocabulary,
531 loader: &NoLoader,
532 warnings: &mut (),
533 },
534 active_context,
535 Nullable::Some(value.into()),
536 true,
537 Some(policy),
538 )
539}
540
541#[cfg(test)]
542mod tests {
543 #![allow(clippy::unwrap_used)]
544 use super::*;
545 use crate::{Options, algorithm::sync::process_context_sync};
546 use iri_rs::IriBuf;
547 use jsonld_core::NoLoader;
548 use jsonld_syntax::{Keyword, Parse, TryFromJson};
549 use rdfx::{BlankIdBuf, vocabulary::no_vocabulary_mut};
550
551 const CONTEXT: &str = r#"{
552 "@vocab": "https://vocab.example/",
553 "ex": "https://example.com/",
554 "name": "https://example.com/name",
555 "type": "@type"
556 }"#;
557
558 /// Processes [`CONTEXT`] as the `@context` of a document.
559 fn processed_context() -> Context<IriBuf, BlankIdBuf> {
560 let (json, _) = jsonld_syntax::Value::parse_str(CONTEXT).unwrap();
561 let local = syntax::context::Context::try_from_json(&json).unwrap();
562 let active = Context::new(None);
563 let mut warnings = ();
564
565 process_context_sync(
566 Environment {
567 vocabulary: no_vocabulary_mut(),
568 loader: &NoLoader,
569 warnings: &mut warnings,
570 },
571 &active,
572 &local,
573 ProcessingStack::default(),
574 None,
575 Options::default(),
576 0,
577 )
578 .unwrap()
579 .into_processed()
580 }
581
582 /// Expands `value` against `context` the way keys and `@type` values
583 /// (`vocab`) or `@id` values (not `vocab`) are expanded, without resolving
584 /// against a base IRI.
585 fn expand(context: &Context<IriBuf, BlankIdBuf>, value: &str, vocab: bool) -> Term<IriBuf, BlankIdBuf> {
586 let mut warnings = ();
587 let mut env = Environment {
588 vocabulary: no_vocabulary_mut(),
589 loader: &NoLoader,
590 warnings: &mut warnings,
591 };
592
593 let expanded = expand_iri_simple::<Warning, _, _, _>(
594 &mut env,
595 context,
596 Nullable::Some(value.into()),
597 false,
598 vocab.then_some(Action::Keep),
599 )
600 .unwrap()
601 .unwrap();
602 Arc::unwrap_or_clone(expanded)
603 }
604
605 fn iri(source: &str) -> Term<IriBuf, BlankIdBuf> {
606 Term::Id(Id::iri(IriBuf::new(source.to_owned()).unwrap()))
607 }
608
609 /// A processed context expands terms, compact IRIs, absolute IRIs and
610 /// `@vocab`-relative terms without a loader; term definitions and `@vocab`
611 /// only apply where `vocab` does.
612 #[test]
613 fn processed_context_expands_terms_compact_iris_and_vocab_relative_terms() {
614 let context = processed_context();
615
616 let cases = [
617 ("name", true, iri("https://example.com/name")),
618 ("name", false, Term::Id(Id::Invalid("name".to_owned()))),
619 ("ex:thing", true, iri("https://example.com/thing")),
620 ("ex:thing", false, iri("https://example.com/thing")),
621 ("http://other.example/x", true, iri("http://other.example/x")),
622 ("http://other.example/x", false, iri("http://other.example/x")),
623 ("temperature", true, iri("https://vocab.example/temperature")),
624 ("temperature", false, Term::Id(Id::Invalid("temperature".to_owned()))),
625 ("type", true, Term::Keyword(Keyword::Type)),
626 ("_:b0", false, Term::Id(Id::blank(BlankIdBuf::new("_:b0".to_owned()).unwrap()))),
627 ];
628
629 for (value, vocab, expected) in cases {
630 assert_eq!(expand(&context, value, vocab), expected, "{value} (vocab: {vocab})");
631 }
632 }
633}