Skip to main content

components_rs/config/
registry.rs

1//! Config registry: loads `config/*.jsonld` files and extracts concrete instances.
2//!
3//! [`ConfigRegistry::discover_configs`] scans every directory whose IRI prefix contains
4//! `/config/` in [`ModuleState::import_paths`](crate::module_state::ModuleState) and calls
5//! [`ConfigRegistry::load_config_file`] on each `.json`/`.jsonld` file found.
6//!
7//! Each file is parsed with [`rdf_parsers::jsonld::convert::parse_json`] so that `@id` spans
8//! are available.  `import` directives within a config file are followed recursively, mirroring
9//! what the Components.js runtime does at startup.
10
11use std::collections::HashMap;
12use std::ops::Range;
13
14use rdf_parsers::jsonld::convert::{parse_json, JsonLdVal};
15use url::Url;
16
17use crate::components::registry::{collect_id_spans, resolve_iri_to_url};
18use crate::components::types::*;
19use crate::context::expand::ContextResolver;
20use crate::error::Result;
21use crate::fs::{self as cfs, Fs};
22use crate::module_state::ModuleState;
23
24/// Registry of discovered configuration instances.
25///
26/// Config instances are the primary subjects of `config/*.jsonld` files — each
27/// one wires a concrete set of parameter values to a component class. The LSP
28/// uses this registry to:
29/// - **Validate** that all required parameters are present in an open config file
30/// - **Provide diagnostics** when a `@type` IRI does not match any known component
31/// - **Enable goto-definition** on instance IRIs via `source_file` + `iri_span`
32#[derive(Debug, Clone)]
33pub struct ConfigRegistry {
34    pub configs: Vec<ConfigInstance>,
35}
36
37impl ConfigRegistry {
38    pub fn new() -> Self {
39        Self {
40            configs: Vec::new(),
41        }
42    }
43
44    /// Discover and load all config files reachable from the import paths.
45    pub async fn discover_configs(&mut self, fs: &dyn Fs, state: &ModuleState) -> Result<()> {
46        for (iri_prefix, local_dir) in &state.import_paths {
47            if iri_prefix.contains("/config/") && fs.is_dir(local_dir).await {
48                self.load_config_directory(fs, local_dir, state).await?;
49            }
50        }
51        Ok(())
52    }
53
54    /// Load a single config file, following any `import` directives it contains.
55    pub fn load_config_file<'a>(
56        &'a mut self,
57        fs: &'a dyn Fs,
58        url: &'a Url,
59        state: &'a ModuleState,
60    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
61        Box::pin(async move {
62            tracing::debug!("Loading config file: {}", url.as_str());
63
64            let contents = fs.read_to_string(url).await?;
65            let Some(doc) = parse_json(&contents) else {
66                tracing::warn!("Failed to parse config file: {}", url.as_str());
67                return Ok(());
68            };
69
70            let resolver = if let Some(ctx) = doc.get("@context") {
71                ContextResolver::from_context_value(ctx, &state.contexts)?
72            } else {
73                ContextResolver::new()
74            };
75
76            let mut id_spans: HashMap<String, Range<usize>> = HashMap::new();
77            collect_id_spans(&doc, &resolver, &mut id_spans);
78
79            self.process_imports(fs, &doc, &resolver, state, url).await?;
80
81            let entries: Vec<&JsonLdVal> = if let Some(graph) = doc.get("@graph") {
82                match graph.as_array() {
83                    Some(arr) => arr.iter().map(|(v, _)| v).collect(),
84                    None => vec![graph],
85                }
86            } else if doc.get("@id").is_some() {
87                vec![&doc]
88            } else {
89                vec![]
90            };
91
92            for entry in entries {
93                if let Some(config) = self.parse_config_entry(entry, &resolver, url, &id_spans) {
94                    self.configs.push(config);
95                }
96            }
97
98            Ok(())
99        })
100    }
101
102    async fn process_imports(
103        &mut self,
104        fs: &dyn Fs,
105        doc: &JsonLdVal,
106        resolver: &ContextResolver,
107        state: &ModuleState,
108        source_url: &Url,
109    ) -> Result<()> {
110        if let Some(import_val) = doc.get("import") {
111            let iris: Vec<String> = match import_val {
112                JsonLdVal::Str(s) => vec![resolver.expand_term(s)],
113                _ => import_val
114                    .as_array()
115                    .map(|arr| {
116                        arr.iter()
117                            .filter_map(|(v, _)| v.as_str())
118                            .map(|s| resolver.expand_term(s))
119                            .collect()
120                    })
121                    .unwrap_or_default(),
122            };
123            for iri in iris {
124                if let Some(local_url) = resolve_iri_to_url(&iri, &state.import_paths) {
125                    if cfs::exists(fs, &local_url).await && &local_url != source_url {
126                        self.load_config_file(fs, &local_url, state).await?;
127                    }
128                }
129            }
130        }
131        Ok(())
132    }
133
134    fn parse_config_entry(
135        &self,
136        value: &JsonLdVal,
137        resolver: &ContextResolver,
138        source_url: &Url,
139        id_spans: &HashMap<String, Range<usize>>,
140    ) -> Option<ConfigInstance> {
141        let id_str = value.get("@id")?.as_str()?;
142        let iri = resolver.expand_term(id_str);
143        let iri_span = id_spans.get(&iri).cloned().unwrap_or(0..0);
144
145        let type_iri = match value.get("@type") {
146            Some(JsonLdVal::Str(t)) => resolver.expand_term(t),
147            Some(v) => v
148                .as_array()?
149                .iter()
150                .filter_map(|(item, _)| item.as_str())
151                .map(|s| resolver.expand_term(s))
152                .next()?,
153            None => return None,
154        };
155
156        if type_iri.contains("Override") {
157            return None;
158        }
159
160        let mut parameters = HashMap::new();
161        if let Some(members) = value.as_object() {
162            for (key, _, _, val) in members {
163                if key.starts_with('@') {
164                    continue;
165                }
166                let expanded_key = resolver.expand_term(key);
167                parameters.insert(expanded_key, val.clone());
168            }
169        }
170
171        Some(ConfigInstance {
172            iri,
173            component_type_iri: type_iri,
174            parameters,
175            source_file: source_url.to_string(),
176            iri_span,
177        })
178    }
179
180    async fn load_config_directory(
181        &mut self,
182        fs: &dyn Fs,
183        dir: &Url,
184        state: &ModuleState,
185    ) -> Result<()> {
186        if !fs.is_dir(dir).await {
187            return Ok(());
188        }
189
190        let files = cfs::walk_dir(fs, dir).await?;
191        for url in files {
192            let is_config = std::path::Path::new(url.path())
193                .extension()
194                .is_some_and(|ext| ext == "jsonld" || ext == "json");
195            if is_config {
196                self.load_config_file(fs, &url, state).await?;
197            }
198        }
199
200        Ok(())
201    }
202}