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}