Skip to main content

components_rs/context/
expand.rs

1//! JSON-LD term expansion, context resolution, and IRI compaction.
2//!
3//! # [`ContextResolver`]
4//!
5//! A per-file resolver built from a single document's `@context` value (string URL, inline
6//! object, or array of both).  Call [`ContextResolver::from_context_value`] with the parsed
7//! `@context` and the project's preloaded context map, then use [`ContextResolver::expand_term`]
8//! to turn short terms into full IRIs during component or config extraction.
9//!
10//! # [`IriCompactor`]
11//!
12//! A project-wide compactor built by merging *all* context documents from [`ModuleState`].
13//! Used by the LSP at display time — e.g., to show `oo:Class` in a hover card instead of the
14//! full `https://linkedsoftwaredependencies.org/…#Class` IRI.
15//!
16//! # [`extract_graph_nodes`]
17//!
18//! Extracts the `@graph` entries (or the document root if there is no `@graph`) from a parsed
19//! `JsonLdVal` document, expanding all term keys to full IRIs.  The returned [`ExpandedNode`]
20//! values form the input to the two-phase component collection in
21//! [`crate::components::registry`].
22//!
23//! [`ModuleState`]: crate::module_state::ModuleState
24
25use std::collections::HashMap;
26
27use rdf_parsers::jsonld::convert::JsonLdVal;
28
29use crate::error::{ComponentsJsError, Result};
30
31/// A resolved JSON-LD context that maps short terms to full IRIs.
32#[derive(Debug, Clone, Default)]
33pub struct ContextResolver {
34    /// @vocab — default IRI prefix for unmapped terms
35    pub vocab: Option<String>,
36    /// prefix:suffix mappings (e.g., "oo" -> "https://...#")
37    pub prefixes: HashMap<String, String>,
38    /// Direct term mappings (e.g., "Class" -> TermDef { iri, type_coercion })
39    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    /// Parse a JSON-LD `@context` value and build the resolver.
55    /// `known_contexts` maps context IRIs to their parsed `JsonLdVal` content.
56    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                        // Flatten type-scoped @context so parameter names like
139                        // `originalUrlExtractor` are available for IRI compaction.
140                        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    /// Expand a compacted term to a full IRI.
152    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    /// Compact a full IRI back to a prefixed form.
186    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/// Project-wide bidirectional IRI translator.
221///
222/// Built by merging all known contexts across the project. Used by the LSP to
223/// convert full IRIs shown in hover cards to compact forms for display, and to
224/// expand compact IRIs typed by the user during completion.
225#[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    /// Build a project-wide compactor from all known context documents.
234    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    /// Compact a full IRI to its shortest prefixed form.
271    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    /// Expand a compact term to a full IRI.
293    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/// An expanded JSON-LD node with full IRIs as keys.
319///
320/// Produced by [`extract_graph_nodes`] during the collection phase. Both `id`
321/// and all keys in `properties` are fully expanded IRIs; values in `properties`
322/// are the raw `JsonLdVal`s as they appear in the source (terms within values
323/// are NOT expanded, because components may define their own inline nodes).
324#[derive(Debug, Clone)]
325pub struct ExpandedNode {
326    /// Fully expanded `@id` of this node, or `None` if absent.
327    pub id: Option<String>,
328    /// Fully expanded `@type` IRIs.
329    pub types: Vec<String>,
330    /// Property values keyed by fully expanded predicate IRI.
331    pub properties: HashMap<String, Vec<JsonLdVal>>,
332}
333
334/// Extract the `@graph` entries from a JSON-LD document, expanding all terms.
335pub 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}