Skip to main content

package_json_lsp/
workspace.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use glob::glob;
6use serde::Deserialize;
7use tokio::fs;
8use tokio::process::Command;
9use tokio::sync::RwLock;
10use tower_lsp::lsp_types::{Location, Range, Url};
11
12use crate::document::Document;
13use crate::parser::{
14    WorkspaceData, WorkspacePositions, parse_json_workspace_data, parse_json_workspace_positions,
15    parse_yaml_workspace_data, parse_yaml_workspace_positions,
16};
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
19pub enum PackageManager {
20    Pnpm,
21    Yarn,
22    Bun,
23}
24
25impl PackageManager {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::Pnpm => "pnpm",
29            Self::Yarn => "yarn",
30            Self::Bun => "bun",
31        }
32    }
33}
34
35#[derive(Clone, Debug, PartialEq, Eq)]
36pub struct WorkspaceInfo {
37    pub path: PathBuf,
38    pub manager: PackageManager,
39}
40
41#[derive(Clone, Debug)]
42pub struct CatalogResult {
43    pub version: String,
44    pub definition: Option<Location>,
45    pub manager: PackageManager,
46}
47
48#[derive(Clone, Debug)]
49pub struct WorkspacePackageResult {
50    pub definition: Location,
51}
52
53#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct PackageOutdatedInfo {
56    #[serde(default)]
57    pub current: String,
58    #[serde(default)]
59    pub latest: String,
60    #[serde(default)]
61    pub wanted: String,
62    #[serde(default)]
63    pub is_deprecated: bool,
64    #[serde(default)]
65    pub dependency_type: String,
66}
67
68#[derive(Default)]
69pub struct WorkspaceManager {
70    documents: Arc<RwLock<HashMap<Url, Document>>>,
71    workspace_folders: RwLock<Vec<PathBuf>>,
72    workspace_cache: RwLock<HashMap<PathBuf, Option<WorkspaceInfo>>>,
73    data_cache: RwLock<HashMap<PathBuf, WorkspaceData>>,
74    position_cache: RwLock<HashMap<PathBuf, WorkspacePositions>>,
75    outdated_cache: RwLock<HashMap<PathBuf, HashMap<String, PackageOutdatedInfo>>>,
76}
77
78impl WorkspaceManager {
79    pub fn new(documents: Arc<RwLock<HashMap<Url, Document>>>) -> Self {
80        Self {
81            documents,
82            ..Self::default()
83        }
84    }
85
86    pub async fn set_workspace_folders(&self, folders: Vec<Url>) {
87        let folders = folders
88            .into_iter()
89            .filter_map(|uri| uri.to_file_path().ok())
90            .collect();
91        *self.workspace_folders.write().await = folders;
92        self.workspace_cache.write().await.clear();
93    }
94
95    pub async fn clear_document_caches(&self, uri: &Url) {
96        if let Ok(path) = uri.to_file_path() {
97            self.data_cache.write().await.remove(&path);
98            self.position_cache.write().await.remove(&path);
99            self.outdated_cache.write().await.remove(&path);
100            self.workspace_cache.write().await.clear();
101        }
102    }
103
104    pub async fn resolve_catalog(
105        &self,
106        doc_uri: &Url,
107        package_name: &str,
108        catalog: &str,
109    ) -> Option<CatalogResult> {
110        let doc_path = doc_uri.to_file_path().ok()?;
111        let workspace = self.find_workspace(&doc_path).await?;
112        let document = self.workspace_document(&workspace.path).await?;
113        let data = self.workspace_data(&workspace, &document).await;
114
115        let map = if catalog == "default" {
116            if data.catalog.is_empty() {
117                data.catalogs.get("default")
118            } else {
119                Some(&data.catalog)
120            }
121        } else {
122            data.catalogs.get(catalog)
123        }?;
124
125        let version = map.get(package_name)?.clone();
126        let positions = self.workspace_positions(&workspace.path, &document).await;
127        let position_map = if catalog == "default" {
128            if positions.catalog.is_empty() {
129                positions.catalogs.get("default")
130            } else {
131                Some(&positions.catalog)
132            }
133        } else {
134            positions.catalogs.get(catalog)
135        };
136
137        let definition = position_map
138            .and_then(|map| map.get(package_name).copied())
139            .and_then(|range| {
140                Some(Location {
141                    uri: Url::from_file_path(&workspace.path).ok()?,
142                    range,
143                })
144            });
145
146        Some(CatalogResult {
147            version,
148            definition,
149            manager: workspace.manager,
150        })
151    }
152
153    pub async fn resolve_workspace_package(
154        &self,
155        doc_uri: &Url,
156        package_name: &str,
157    ) -> Option<WorkspacePackageResult> {
158        let doc_path = doc_uri.to_file_path().ok()?;
159        let workspace = self.find_workspace(&doc_path).await?;
160        let document = self.workspace_document(&workspace.path).await?;
161        let data = self.workspace_data(&workspace, &document).await;
162        let root = workspace.path.parent()?;
163
164        for pattern in data.packages {
165            let pattern = root.join(pattern).join("package.json");
166            let pattern = pattern.to_string_lossy().to_string();
167            let Ok(paths) = glob(&pattern) else {
168                continue;
169            };
170            for path in paths.flatten() {
171                let Some(document) = self.workspace_document(&path).await else {
172                    continue;
173                };
174                let data = parse_json_workspace_data(document.text());
175                if package_json_name(document.text()).as_deref() == Some(package_name) {
176                    let uri = Url::from_file_path(path).ok()?;
177                    let _ = data;
178                    return Some(WorkspacePackageResult {
179                        definition: Location {
180                            uri,
181                            range: Range::default(),
182                        },
183                    });
184                }
185            }
186        }
187
188        None
189    }
190
191    pub async fn resolve_version(
192        &self,
193        doc_uri: &Url,
194        package_name: &str,
195    ) -> Option<PackageOutdatedInfo> {
196        let doc_path = doc_uri.to_file_path().ok()?;
197        let workspace = self.find_workspace(&doc_path).await?;
198        if workspace.manager != PackageManager::Pnpm {
199            return None;
200        }
201        self.get_outdated(&doc_path, package_name).await
202    }
203
204    pub async fn find_workspace(&self, path: &Path) -> Option<WorkspaceInfo> {
205        if let Some(cached) = self.workspace_cache.read().await.get(path).cloned() {
206            return cached;
207        }
208
209        let start = if path.is_dir() {
210            path.to_path_buf()
211        } else {
212            path.parent()?.to_path_buf()
213        };
214        let stop_at = self.stop_at_for(&start).await;
215
216        let mut dir = Some(start.as_path());
217        while let Some(current) = dir {
218            if let Some(info) = workspace_in_dir(current).await {
219                self.workspace_cache
220                    .write()
221                    .await
222                    .insert(path.to_path_buf(), Some(info.clone()));
223                return Some(info);
224            }
225
226            if stop_at.as_deref() == Some(current) {
227                break;
228            }
229            dir = current.parent();
230        }
231
232        self.workspace_cache
233            .write()
234            .await
235            .insert(path.to_path_buf(), None);
236        None
237    }
238
239    async fn stop_at_for(&self, start: &Path) -> Option<PathBuf> {
240        self.workspace_folders
241            .read()
242            .await
243            .iter()
244            .find(|folder| start.starts_with(folder))
245            .cloned()
246    }
247
248    async fn workspace_document(&self, path: &Path) -> Option<Document> {
249        let uri = Url::from_file_path(path).ok()?;
250        if let Some(document) = self.documents.read().await.get(&uri).cloned() {
251            return Some(document);
252        }
253
254        let text = fs::read_to_string(path).await.ok()?;
255        Some(Document::new(uri, 1, text))
256    }
257
258    async fn workspace_data(
259        &self,
260        workspace: &WorkspaceInfo,
261        document: &Document,
262    ) -> WorkspaceData {
263        if let Some(data) = self.data_cache.read().await.get(&workspace.path).cloned() {
264            return data;
265        }
266
267        let data = match workspace.manager {
268            PackageManager::Pnpm | PackageManager::Yarn => {
269                parse_yaml_workspace_data(document.text())
270            }
271            PackageManager::Bun => parse_json_workspace_data(document.text()),
272        };
273
274        self.data_cache
275            .write()
276            .await
277            .insert(workspace.path.clone(), data.clone());
278        data
279    }
280
281    async fn workspace_positions(&self, path: &Path, document: &Document) -> WorkspacePositions {
282        if let Some(positions) = self.position_cache.read().await.get(path).cloned() {
283            return positions;
284        }
285
286        let positions = if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
287            parse_json_workspace_positions(document)
288        } else {
289            parse_yaml_workspace_positions(document)
290        };
291
292        self.position_cache
293            .write()
294            .await
295            .insert(path.to_path_buf(), positions.clone());
296        positions
297    }
298
299    async fn get_outdated(
300        &self,
301        package_json_path: &Path,
302        package_name: &str,
303    ) -> Option<PackageOutdatedInfo> {
304        if let Some(cached) = self.outdated_cache.read().await.get(package_json_path) {
305            return cached.get(package_name).cloned();
306        }
307
308        let dir = package_json_path.parent()?;
309        let output = Command::new("pnpm")
310            .arg("outdated")
311            .arg("--json")
312            .current_dir(dir)
313            .output()
314            .await
315            .ok()?;
316
317        let code = output.status.code().unwrap_or_default();
318        if code != 0 && code != 1 {
319            eprintln!(
320                "pnpm outdated failed with code {code}: {}",
321                String::from_utf8_lossy(&output.stderr)
322            );
323            return None;
324        }
325
326        let outdated =
327            serde_json::from_slice::<HashMap<String, PackageOutdatedInfo>>(&output.stdout)
328                .unwrap_or_default();
329        self.outdated_cache
330            .write()
331            .await
332            .insert(package_json_path.to_path_buf(), outdated.clone());
333        outdated.get(package_name).cloned()
334    }
335}
336
337async fn workspace_in_dir(dir: &Path) -> Option<WorkspaceInfo> {
338    let pnpm = dir.join("pnpm-workspace.yaml");
339    if fs::metadata(&pnpm).await.is_ok() {
340        return Some(WorkspaceInfo {
341            path: pnpm,
342            manager: PackageManager::Pnpm,
343        });
344    }
345
346    let yarn = dir.join(".yarnrc.yml");
347    if fs::metadata(&yarn).await.is_ok() {
348        return Some(WorkspaceInfo {
349            path: yarn,
350            manager: PackageManager::Yarn,
351        });
352    }
353
354    let bun_lock = dir.join("bun.lock");
355    let bun_lockb = dir.join("bun.lockb");
356    if fs::metadata(&bun_lock).await.is_ok() || fs::metadata(&bun_lockb).await.is_ok() {
357        return Some(WorkspaceInfo {
358            path: dir.join("package.json"),
359            manager: PackageManager::Bun,
360        });
361    }
362
363    None
364}
365
366fn package_json_name(text: &str) -> Option<String> {
367    jsonc_parser::parse_to_serde_value::<serde_json::Value>(
368        text,
369        &jsonc_parser::ParseOptions::default(),
370    )
371    .ok()?
372    .get("name")?
373    .as_str()
374    .map(ToOwned::to_owned)
375}
376
377#[cfg(test)]
378mod tests {
379    use tokio::fs;
380    use tower_lsp::lsp_types::Url;
381
382    use super::*;
383
384    #[tokio::test]
385    async fn resolves_workspace_package_definition() {
386        let tmp = tempfile::tempdir().unwrap();
387        fs::write(
388            tmp.path().join("pnpm-workspace.yaml"),
389            "packages:\n  - packages/*\n",
390        )
391        .await
392        .unwrap();
393        fs::create_dir_all(tmp.path().join("packages/app"))
394            .await
395            .unwrap();
396        fs::write(
397            tmp.path().join("packages/app/package.json"),
398            r#"{ "name": "@scope/app", "version": "1.0.0" }"#,
399        )
400        .await
401        .unwrap();
402        let root_package = tmp.path().join("package.json");
403        fs::write(
404            &root_package,
405            r#"{ "dependencies": { "@scope/app": "workspace:*" } }"#,
406        )
407        .await
408        .unwrap();
409
410        let manager = WorkspaceManager::new(Arc::new(RwLock::new(HashMap::new())));
411        let result = manager
412            .resolve_workspace_package(&Url::from_file_path(root_package).unwrap(), "@scope/app")
413            .await
414            .unwrap();
415
416        assert!(
417            result
418                .definition
419                .uri
420                .as_str()
421                .ends_with("/packages/app/package.json")
422        );
423    }
424}