1use std::collections::HashMap;
26
27use rdf_parsers::jsonld::convert::JsonLdVal;
28
29use crate::error::{ComponentsJsError, Result};
30
31#[derive(Debug, Clone, Default)]
33pub struct ContextResolver {
34 pub vocab: Option<String>,
36 pub prefixes: HashMap<String, String>,
38 pub terms: HashMap<String, TermDef>,
40}
41
42#[derive(Debug, Clone)]
43pub struct TermDef {
44 pub iri: String,
45 pub type_coercion: Option<String>,
46 pub container: Option<String>,
47}
48
49impl ContextResolver {
50 pub fn new() -> Self {
51 Self::default()
52 }
53
54 pub fn from_context_value(
57 context_value: &JsonLdVal,
58 known_contexts: &HashMap<String, JsonLdVal>,
59 ) -> Result<Self> {
60 let mut resolver = Self::new();
61 resolver.load_context_value(context_value, known_contexts)?;
62 Ok(resolver)
63 }
64
65 fn load_context_value(
66 &mut self,
67 value: &JsonLdVal,
68 known_contexts: &HashMap<String, JsonLdVal>,
69 ) -> Result<()> {
70 match value {
71 JsonLdVal::Array(arr) => {
72 for (item, _) in arr {
73 self.load_context_value(item, known_contexts)?;
74 }
75 }
76 JsonLdVal::Str(url) => {
77 if let Some(ctx_doc) = known_contexts.get(url.as_str()) {
78 if let Some(inner) = ctx_doc.get("@context") {
79 self.load_context_value(inner, known_contexts)?;
80 } else {
81 self.load_context_object(ctx_doc, known_contexts)?;
82 }
83 } else {
84 tracing::warn!("Unknown context URL: {url} — skipping");
85 }
86 }
87 JsonLdVal::Object(_, _) => {
88 self.load_context_object(value, known_contexts)?;
89 }
90 _ => {}
91 }
92 Ok(())
93 }
94
95 fn load_context_object(&mut self, obj: &JsonLdVal, known_contexts: &HashMap<String, JsonLdVal>) -> Result<()> {
96 let members = obj
97 .as_object()
98 .ok_or_else(|| ComponentsJsError::ContextResolution("Expected object".into()))?;
99
100 for (key, _, _, val) in members {
101 match key.as_str() {
102 "@vocab" => {
103 if let Some(s) = val.as_str() {
104 self.vocab = Some(s.to_string());
105 }
106 }
107 k if k.starts_with('@') => {}
108 _ => match val {
109 JsonLdVal::Str(iri) => {
110 if iri.ends_with('/') || iri.ends_with('#') {
111 self.prefixes.insert(key.clone(), iri.clone());
112 } else {
113 self.terms.insert(
114 key.clone(),
115 TermDef {
116 iri: iri.clone(),
117 type_coercion: None,
118 container: None,
119 },
120 );
121 }
122 }
123 JsonLdVal::Object(_, _) => {
124 if let Some(id) = val.get("@id").and_then(|v| v.as_str()) {
125 let type_coercion =
126 val.get("@type").and_then(|v| v.as_str()).map(String::from);
127 let container =
128 val.get("@container").and_then(|v| v.as_str()).map(String::from);
129 self.terms.insert(
130 key.clone(),
131 TermDef {
132 iri: id.to_string(),
133 type_coercion,
134 container,
135 },
136 );
137 }
138 if let Some(inner_ctx) = val.get("@context") {
141 self.load_context_value(inner_ctx, known_contexts)?;
142 }
143 }
144 _ => {}
145 },
146 }
147 }
148 Ok(())
149 }
150
151 pub fn expand_term(&self, term: &str) -> String {
153 self.expand_term_depth(term, 0)
154 }
155
156 fn expand_term_depth(&self, term: &str, depth: usize) -> String {
157 if depth > 10 {
158 return term.to_string();
159 }
160
161 if let Some(def) = self.terms.get(term) {
162 return self.expand_term_depth(&def.iri, depth + 1);
163 }
164
165 if let Some((prefix, suffix)) = term.split_once(':') {
166 if !suffix.starts_with("//") {
167 if let Some(base) = self.prefixes.get(prefix) {
168 let expanded_base = self.expand_term_depth(base, depth + 1);
169 return format!("{expanded_base}{suffix}");
170 }
171 }
172 }
173
174 if term.contains("://") {
175 return term.to_string();
176 }
177
178 if let Some(vocab) = &self.vocab {
179 return format!("{vocab}{term}");
180 }
181
182 term.to_string()
183 }
184
185 pub fn compact_iri(&self, iri: &str) -> String {
187 for (term, def) in &self.terms {
188 let expanded = self.expand_term(&def.iri);
189 if expanded == iri {
190 return term.clone();
191 }
192 }
193
194 let mut best: Option<(String, usize)> = None;
195 for (prefix, base_iri) in &self.prefixes {
196 let expanded_base = self.expand_term(base_iri);
197 if let Some(suffix) = iri.strip_prefix(expanded_base.as_str()) {
198 let base_len = expanded_base.len();
199 if best.as_ref().is_none_or(|(_, bl)| base_len > *bl) {
200 best = Some((format!("{prefix}:{suffix}"), base_len));
201 }
202 }
203 }
204 if let Some((compact, _)) = best {
205 return compact;
206 }
207
208 if let Some(vocab) = &self.vocab {
209 if let Some(suffix) = iri.strip_prefix(vocab.as_str()) {
210 if !suffix.contains('/') && !suffix.contains('#') {
211 return suffix.to_string();
212 }
213 }
214 }
215
216 iri.to_string()
217 }
218}
219
220#[derive(Debug, Clone, Default)]
226pub struct IriCompactor {
227 prefixes: Vec<(String, String)>,
228 terms: Vec<(String, String)>,
229 vocab: Option<String>,
230}
231
232impl IriCompactor {
233 pub fn from_contexts(known_contexts: &HashMap<String, JsonLdVal>) -> Result<Self> {
235 let mut resolver = ContextResolver::new();
236 for ctx_doc in known_contexts.values() {
237 if let Some(inner) = ctx_doc.get("@context") {
238 resolver.load_context_value(inner, known_contexts)?;
239 } else {
240 resolver.load_context_object(ctx_doc, known_contexts)?;
241 }
242 }
243
244 let mut prefixes: Vec<(String, String)> = resolver
245 .prefixes
246 .iter()
247 .map(|(name, base)| {
248 let expanded = resolver.expand_term(base);
249 (name.clone(), expanded)
250 })
251 .collect();
252 prefixes.sort_by(|a, b| b.1.len().cmp(&a.1.len()));
253
254 let terms: Vec<(String, String)> = resolver
255 .terms
256 .iter()
257 .map(|(name, def)| {
258 let expanded = resolver.expand_term(&def.iri);
259 (name.clone(), expanded)
260 })
261 .collect();
262
263 Ok(Self {
264 prefixes,
265 terms,
266 vocab: resolver.vocab,
267 })
268 }
269
270 pub fn compact(&self, iri: &str) -> String {
272 for (term, expanded) in &self.terms {
273 if expanded == iri {
274 return term.clone();
275 }
276 }
277 for (prefix, base) in &self.prefixes {
278 if let Some(suffix) = iri.strip_prefix(base.as_str()) {
279 return format!("{prefix}:{suffix}");
280 }
281 }
282 if let Some(vocab) = &self.vocab {
283 if let Some(suffix) = iri.strip_prefix(vocab.as_str()) {
284 if !suffix.contains('/') && !suffix.contains('#') {
285 return suffix.to_string();
286 }
287 }
288 }
289 iri.to_string()
290 }
291
292 pub fn expand(&self, term: &str) -> String {
294 for (name, expanded) in &self.terms {
295 if name == term {
296 return expanded.clone();
297 }
298 }
299 if let Some((prefix, suffix)) = term.split_once(':') {
300 if !suffix.starts_with("//") {
301 for (name, base) in &self.prefixes {
302 if name == prefix {
303 return format!("{base}{suffix}");
304 }
305 }
306 }
307 }
308 if term.contains("://") {
309 return term.to_string();
310 }
311 if let Some(vocab) = &self.vocab {
312 return format!("{vocab}{term}");
313 }
314 term.to_string()
315 }
316}
317
318#[derive(Debug, Clone)]
325pub struct ExpandedNode {
326 pub id: Option<String>,
328 pub types: Vec<String>,
330 pub properties: HashMap<String, Vec<JsonLdVal>>,
332}
333
334pub fn extract_graph_nodes(
336 doc: &JsonLdVal,
337 known_contexts: &HashMap<String, JsonLdVal>,
338) -> Result<Vec<ExpandedNode>> {
339 let resolver = if let Some(ctx) = doc.get("@context") {
340 ContextResolver::from_context_value(ctx, known_contexts)?
341 } else {
342 ContextResolver::new()
343 };
344
345 let entries: Vec<&JsonLdVal> = if let Some(graph) = doc.get("@graph") {
346 match graph.as_array() {
347 Some(arr) => arr.iter().map(|(v, _)| v).collect(),
348 None => vec![graph],
349 }
350 } else if doc.get("@id").is_some() || doc.get("@type").is_some() {
351 vec![doc]
352 } else {
353 vec![]
354 };
355
356 let mut nodes = Vec::new();
357 for entry in entries {
358 if let Some(node) = expand_node(entry, &resolver) {
359 nodes.push(node);
360 }
361 }
362 Ok(nodes)
363}
364
365fn expand_node(value: &JsonLdVal, resolver: &ContextResolver) -> Option<ExpandedNode> {
366 let members = value.as_object()?;
367
368 let id = members
369 .iter()
370 .find(|(k, _, _, _)| k == "@id")
371 .and_then(|(_, _, _, v)| v.as_str())
372 .map(|s| resolver.expand_term(s));
373
374 let types: Vec<String> = match value.get("@type") {
375 Some(JsonLdVal::Str(t)) => vec![resolver.expand_term(t)],
376 Some(v) => v
377 .as_array()
378 .map(|arr| {
379 arr.iter()
380 .filter_map(|(item, _)| item.as_str())
381 .map(|s| resolver.expand_term(s))
382 .collect()
383 })
384 .unwrap_or_default(),
385 None => vec![],
386 };
387
388 let mut properties = HashMap::new();
389 for (key, _, _, val) in members {
390 if key.starts_with('@') {
391 continue;
392 }
393 let expanded_key = resolver.expand_term(key);
394 let values = match val {
395 JsonLdVal::Array(arr) => arr.iter().map(|(v, _)| v.clone()).collect(),
396 other => vec![other.clone()],
397 };
398 properties.insert(expanded_key, values);
399 }
400
401 Some(ExpandedNode {
402 id,
403 types,
404 properties,
405 })
406}
407
408#[cfg(test)]
409mod tests {
410 use super::*;
411 use rdf_parsers::jsonld::convert::parse_json;
412
413 fn make_cjs_context() -> HashMap<String, JsonLdVal> {
414 let ctx_json = parse_json(r#"{
415 "@context": {
416 "oo": "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#",
417 "Module": { "@id": "oo:Module" },
418 "Class": { "@id": "oo:Class" },
419 "AbstractClass": { "@id": "oo:AbstractClass" },
420 "components": { "@id": "oo:component" },
421 "parameters": { "@id": "oo:parameter" },
422 "extends": { "@id": "rdfs:subClassOf", "@type": "@id" },
423 "rdfs": "http://www.w3.org/2000/01/rdf-schema#",
424 "doap": "http://usefulinc.com/ns/doap#",
425 "requireName": { "@id": "doap:name" },
426 "requireElement": { "@id": "oo:componentPath" },
427 "import": { "@id": "rdfs:seeAlso", "@type": "@id" }
428 }
429 }"#)
430 .unwrap();
431 let mut known = HashMap::new();
432 known.insert(
433 "https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld".to_string(),
434 ctx_json,
435 );
436 known
437 }
438
439 #[test]
440 fn test_expand_term_direct_mapping() {
441 let known = make_cjs_context();
442 let ctx_ref = parse_json(r#"["https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"]"#).unwrap();
443 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
444
445 assert_eq!(
446 resolver.expand_term("Class"),
447 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
448 );
449 assert_eq!(
450 resolver.expand_term("Module"),
451 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"
452 );
453 }
454
455 #[test]
456 fn test_expand_term_prefix() {
457 let known = make_cjs_context();
458 let ctx_ref = parse_json(r#"["https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"]"#).unwrap();
459 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
460
461 assert_eq!(
462 resolver.expand_term("oo:Class"),
463 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
464 );
465 }
466
467 #[test]
468 fn test_expand_term_with_local_context() {
469 let known = make_cjs_context();
470 let ctx_ref = parse_json(r#"[
471 "https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
472 { "ex": "http://example.org/", "hello": "http://example.org/hello/" }
473 ]"#).unwrap();
474 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
475
476 assert_eq!(resolver.expand_term("ex:MyModule"), "http://example.org/MyModule");
477 assert_eq!(resolver.expand_term("hello:say"), "http://example.org/hello/say");
478 }
479
480 #[test]
481 fn test_extract_graph_nodes() {
482 let known = make_cjs_context();
483 let doc = parse_json(r#"{
484 "@context": [
485 "https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
486 { "ex": "http://example.org/", "hello": "http://example.org/hello/" }
487 ],
488 "@graph": [
489 {
490 "@id": "ex:HelloWorldModule",
491 "@type": "Module",
492 "requireName": "helloworld",
493 "components": [
494 {
495 "@id": "ex:HelloWorldModule#SayHelloComponent",
496 "@type": "Class",
497 "requireElement": "Hello",
498 "parameters": [
499 { "@id": "hello:say" },
500 { "@id": "hello:hello" }
501 ]
502 }
503 ]
504 }
505 ]
506 }"#).unwrap();
507
508 let nodes = extract_graph_nodes(&doc, &known).unwrap();
509 assert_eq!(nodes.len(), 1);
510 let module = &nodes[0];
511 assert_eq!(module.id.as_deref(), Some("http://example.org/HelloWorldModule"));
512 assert_eq!(
513 module.types,
514 vec!["https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"]
515 );
516 }
517
518 #[test]
519 fn test_vocab_expansion() {
520 let known = HashMap::new();
521 let ctx = parse_json(r#"{
522 "@vocab": "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#",
523 "ex": "http://example.org/"
524 }"#).unwrap();
525 let resolver = ContextResolver::from_context_value(&ctx, &known).unwrap();
526
527 assert_eq!(
528 resolver.expand_term("SomeUnknownTerm"),
529 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#SomeUnknownTerm"
530 );
531 }
532
533 #[test]
534 fn test_compact_iri_term() {
535 let known = make_cjs_context();
536 let ctx_ref = parse_json(r#"["https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"]"#).unwrap();
537 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
538
539 assert_eq!(
540 resolver.compact_iri(
541 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class"
542 ),
543 "Class"
544 );
545 assert_eq!(
546 resolver.compact_iri("http://www.w3.org/2000/01/rdf-schema#label"),
547 "rdfs:label"
548 );
549 }
550
551 #[test]
552 fn test_compact_iri_prefix() {
553 let known = make_cjs_context();
554 let ctx_ref = parse_json(r#"[
555 "https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld",
556 { "ex": "http://example.org/" }
557 ]"#).unwrap();
558 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
559
560 assert_eq!(resolver.compact_iri("http://example.org/Foo"), "ex:Foo");
561 }
562
563 #[test]
564 fn test_compact_iri_unknown() {
565 let known = make_cjs_context();
566 let ctx_ref = parse_json(r#"["https://linkedsoftwaredependencies.org/bundles/npm/componentsjs/^4.0.0/components/context.jsonld"]"#).unwrap();
567 let resolver = ContextResolver::from_context_value(&ctx_ref, &known).unwrap();
568
569 assert_eq!(
570 resolver.compact_iri("https://unknown.example.org/Something"),
571 "https://unknown.example.org/Something"
572 );
573 }
574
575 #[test]
576 fn test_iri_compactor_roundtrip() {
577 let known = make_cjs_context();
578 let compactor = IriCompactor::from_contexts(&known).unwrap();
579
580 let full = "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Class";
581 let compact = compactor.compact(full);
582 assert_eq!(compact, "Class");
583 assert_eq!(compactor.expand(&compact), full);
584
585 let full2 = "http://www.w3.org/2000/01/rdf-schema#subClassOf";
586 let compact2 = compactor.compact(full2);
587 assert_eq!(compact2, "extends");
588 assert_eq!(compactor.expand(&compact2), full2);
589 }
590
591 #[test]
592 fn test_iri_compactor_expand() {
593 let known = make_cjs_context();
594 let compactor = IriCompactor::from_contexts(&known).unwrap();
595
596 assert_eq!(
597 compactor.expand("oo:Module"),
598 "https://linkedsoftwaredependencies.org/vocabularies/object-oriented#Module"
599 );
600 }
601}