Skip to main content

components_rs/
module_state.rs

1//! Project-wide state: the shared lookup tables built from all discovered packages.
2//!
3//! [`ModuleState`] is the first thing an LSP session should create.  Call
4//! [`ModuleState::build`] with the workspace root and it will:
5//!
6//! 1. Walk all ancestor `node_modules/` directories (via [`crate::discovery`]).
7//! 2. Parse every `package.json` that has `lsd:` fields.
8//! 3. Build three lookup tables consumed by the registries:
9//!    - `component_modules` — maps module IRI → URL to `components.jsonld`
10//!    - `contexts` — maps context IRI → parsed [`rdf_parsers::jsonld::convert::JsonLdVal`]
11//!    - `import_paths` — maps IRI prefix → local directory URL (for resolving `rdfs:seeAlso` imports)
12//!
13//! The state is read-only after construction; pass `&state` into
14//! [`crate::components::registry::ComponentRegistry`] and
15//! [`crate::config::registry::ConfigRegistry`].
16
17use std::collections::HashMap;
18use std::sync::Arc;
19
20use rdf_parsers::jsonld::convert::{parse_json, JsonLdVal};
21use url::Url;
22
23use crate::discovery::node_modules;
24use crate::discovery::package_json::{self, get_module_iri, PackageJson};
25use crate::error::Result;
26use crate::fs::Fs;
27
28/// Represents the fully-resolved state of all discoverable CJS modules.
29/// Mirrors the TypeScript `IModuleState`.
30#[derive(Debug, Clone)]
31pub struct ModuleState {
32    /// URL of the root project directory.
33    pub main_module_path: Url,
34    /// All ancestor URLs used for node_modules searching.
35    pub node_module_import_paths: Vec<Url>,
36    /// All discovered node module directory URLs.
37    pub node_module_paths: Vec<Url>,
38    /// Parsed package.json by module directory URL.
39    pub package_jsons: HashMap<Url, PackageJson>,
40    /// Component modules: module IRI → (major version → absolute components.jsonld URL).
41    pub component_modules: HashMap<String, HashMap<u64, Url>>,
42    /// Contexts: context IRI → parsed content of context file.
43    /// Wrapped in Arc so it can be shared cheaply across async tasks.
44    pub contexts: Arc<HashMap<String, JsonLdVal>>,
45    /// Import paths: IRI prefix → absolute local directory URL.
46    pub import_paths: HashMap<String, Url>,
47    /// Context URLs: full context IRI → absolute local file URL (from lsd:contexts).
48    pub context_urls: HashMap<String, Url>,
49}
50
51impl ModuleState {
52    pub fn empty() -> Self {
53        Self {
54            main_module_path: Url::parse("file:///tmp/").unwrap(),
55            node_module_import_paths: Vec::new(),
56            node_module_paths: Vec::new(),
57            package_jsons: HashMap::new(),
58            component_modules: HashMap::new(),
59            contexts: Arc::new(HashMap::new()),
60            import_paths: HashMap::new(),
61            context_urls: HashMap::new(),
62        }
63    }
64    /// Build the full module state from a project root URL.
65    pub async fn build(fs: &dyn Fs, main_module_path: &Url) -> Result<Self> {
66        tracing::info!(
67            "[CJS] Building module state from: {}",
68            main_module_path.as_str()
69        );
70
71        let node_module_import_paths = vec![main_module_path.clone()];
72        let node_module_paths =
73            node_modules::build_node_module_paths(fs, &node_module_import_paths, false).await?;
74
75        tracing::info!("Discovered {} node module paths", node_module_paths.len());
76
77        let mut package_jsons = package_json::read_package_jsons(fs, &node_module_paths).await?;
78        package_json::preprocess_all(fs, &mut package_jsons).await;
79
80        let component_modules = build_component_modules(&package_jsons)?;
81        let contexts = Arc::new(build_component_contexts(fs, &package_jsons).await?);
82        let import_paths = build_component_import_paths(&package_jsons)?;
83        let context_urls = build_component_context_urls(&package_jsons)?;
84
85        tracing::info!(
86            "[CJS] Found {} component modules, {} contexts, {} import paths",
87            component_modules.len(),
88            contexts.len(),
89            import_paths.len()
90        );
91
92        Ok(ModuleState {
93            main_module_path: main_module_path.clone(),
94            node_module_import_paths,
95            node_module_paths,
96            package_jsons,
97            component_modules,
98            contexts,
99            import_paths,
100            context_urls,
101        })
102    }
103}
104
105/// Build the component modules map: module IRI → (major version → absolute URL to components.jsonld).
106fn build_component_modules(
107    package_jsons: &HashMap<Url, PackageJson>,
108) -> Result<HashMap<String, HashMap<u64, Url>>> {
109    let mut modules: HashMap<String, HashMap<u64, Url>> = HashMap::new();
110    let mut versions: HashMap<String, HashMap<u64, semver::Version>> = HashMap::new();
111
112    for (module_path, pkg) in package_jsons {
113        let Some(module_iri) = get_module_iri(pkg) else {
114            continue;
115        };
116        let Some(components_rel) = &pkg.lsd_components else {
117            continue;
118        };
119        let Ok(version) = semver::Version::parse(&pkg.version) else {
120            continue;
121        };
122
123        let absolute_path = match module_path.join(components_rel) {
124            Ok(u) => u,
125            Err(_) => continue,
126        };
127
128        let major = version.major;
129        let entry = modules.entry(module_iri.clone()).or_default();
130        let ver_entry = versions.entry(module_iri.clone()).or_default();
131
132        if let Some(existing_ver) = ver_entry.get(&major) {
133            if &version > existing_ver {
134                entry.insert(major, absolute_path);
135                ver_entry.insert(major, version);
136            }
137        } else {
138            entry.insert(major, absolute_path);
139            ver_entry.insert(major, version);
140        }
141    }
142
143    Ok(modules)
144}
145
146/// Build the contexts map: context IRI → parsed content.
147async fn build_component_contexts(
148    fs: &dyn Fs,
149    package_jsons: &HashMap<Url, PackageJson>,
150) -> Result<HashMap<String, JsonLdVal>> {
151    let mut contexts: HashMap<String, JsonLdVal> = HashMap::new();
152    let mut ctx_versions: HashMap<String, semver::Version> = HashMap::new();
153
154    for (module_path, pkg) in package_jsons {
155        let Some(ctx_map) = &pkg.lsd_contexts else {
156            continue;
157        };
158        let Ok(version) = semver::Version::parse(&pkg.version) else {
159            continue;
160        };
161
162        for (ctx_iri, rel_path) in ctx_map {
163            let file_url = match module_path.join(rel_path) {
164                Ok(u) => u,
165                Err(_) => continue,
166            };
167
168            if let Some(existing_ver) = ctx_versions.get(ctx_iri) {
169                if &version <= existing_ver {
170                    continue;
171                }
172            }
173
174            match fs.read_to_string(&file_url).await {
175                Ok(contents) => match parse_json(&contents) {
176                    Some(parsed) => {
177                        contexts.insert(ctx_iri.clone(), parsed);
178                        ctx_versions.insert(ctx_iri.clone(), version.clone());
179                    }
180                    None => {
181                        tracing::warn!("Failed to parse context file {}", file_url.as_str());
182                    }
183                },
184                Err(e) => {
185                    tracing::warn!("Failed to read context file {}: {}", file_url.as_str(), e);
186                }
187            }
188        }
189    }
190
191    Ok(contexts)
192}
193
194/// Build the import paths map: IRI prefix → absolute directory URL.
195fn build_component_import_paths(
196    package_jsons: &HashMap<Url, PackageJson>,
197) -> Result<HashMap<String, Url>> {
198    let mut import_paths: HashMap<String, Url> = HashMap::new();
199    let mut path_versions: HashMap<String, semver::Version> = HashMap::new();
200
201    for (module_path, pkg) in package_jsons {
202        let Some(ip_map) = &pkg.lsd_import_paths else {
203            continue;
204        };
205        let Ok(version) = semver::Version::parse(&pkg.version) else {
206            continue;
207        };
208
209        for (iri_prefix, rel_path) in ip_map {
210            // Ensure the relative path is treated as a directory by adding trailing slash.
211            let rel_dir = if rel_path.ends_with('/') {
212                rel_path.clone()
213            } else {
214                format!("{}/", rel_path)
215            };
216            let abs_url = match module_path.join(&rel_dir) {
217                Ok(u) => u,
218                Err(e) => {
219                    tracing::warn!("Failed to join import path: {}", e);
220                    continue;
221                }
222            };
223
224            if let Some(existing_ver) = path_versions.get(iri_prefix) {
225                if &version <= existing_ver {
226                    continue;
227                }
228            }
229
230            import_paths.insert(iri_prefix.clone(), abs_url);
231            path_versions.insert(iri_prefix.clone(), version.clone());
232        }
233    }
234
235    Ok(import_paths)
236}
237
238/// Build the context URLs map: full context IRI → absolute local file URL (from lsd:contexts).
239fn build_component_context_urls(
240    package_jsons: &HashMap<Url, PackageJson>,
241) -> Result<HashMap<String, Url>> {
242    let mut context_urls: HashMap<String, Url> = HashMap::new();
243    let mut ctx_versions: HashMap<String, semver::Version> = HashMap::new();
244
245    for (module_path, pkg) in package_jsons {
246        let Some(ctx_map) = &pkg.lsd_contexts else {
247            continue;
248        };
249        let Ok(version) = semver::Version::parse(&pkg.version) else {
250            continue;
251        };
252
253        for (ctx_iri, rel_path) in ctx_map {
254            let file_url = match module_path.join(rel_path) {
255                Ok(u) => u,
256                Err(_) => continue,
257            };
258
259            if let Some(existing_ver) = ctx_versions.get(ctx_iri) {
260                if &version <= existing_ver {
261                    continue;
262                }
263            }
264
265            context_urls.insert(ctx_iri.clone(), file_url);
266            ctx_versions.insert(ctx_iri.clone(), version.clone());
267        }
268    }
269
270    Ok(context_urls)
271}