use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use glob::glob;
use serde::Deserialize;
use tokio::fs;
use tokio::process::Command;
use tokio::sync::RwLock;
use tower_lsp::lsp_types::{Location, Range, Url};
use crate::document::Document;
use crate::parser::{
WorkspaceData, WorkspacePositions, parse_json_workspace_data, parse_json_workspace_positions,
parse_yaml_workspace_data, parse_yaml_workspace_positions,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PackageManager {
Pnpm,
Yarn,
Bun,
}
impl PackageManager {
pub fn as_str(self) -> &'static str {
match self {
Self::Pnpm => "pnpm",
Self::Yarn => "yarn",
Self::Bun => "bun",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkspaceInfo {
pub path: PathBuf,
pub manager: PackageManager,
}
#[derive(Clone, Debug)]
pub struct CatalogResult {
pub version: String,
pub definition: Option<Location>,
pub manager: PackageManager,
}
#[derive(Clone, Debug)]
pub struct WorkspacePackageResult {
pub definition: Location,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct PackageOutdatedInfo {
#[serde(default)]
pub current: String,
#[serde(default)]
pub latest: String,
#[serde(default)]
pub wanted: String,
#[serde(default)]
pub is_deprecated: bool,
#[serde(default)]
pub dependency_type: String,
}
#[derive(Default)]
pub struct WorkspaceManager {
documents: Arc<RwLock<HashMap<Url, Document>>>,
workspace_folders: RwLock<Vec<PathBuf>>,
workspace_cache: RwLock<HashMap<PathBuf, Option<WorkspaceInfo>>>,
data_cache: RwLock<HashMap<PathBuf, WorkspaceData>>,
position_cache: RwLock<HashMap<PathBuf, WorkspacePositions>>,
outdated_cache: RwLock<HashMap<PathBuf, HashMap<String, PackageOutdatedInfo>>>,
}
impl WorkspaceManager {
pub fn new(documents: Arc<RwLock<HashMap<Url, Document>>>) -> Self {
Self {
documents,
..Self::default()
}
}
pub async fn set_workspace_folders(&self, folders: Vec<Url>) {
let folders = folders
.into_iter()
.filter_map(|uri| uri.to_file_path().ok())
.collect();
*self.workspace_folders.write().await = folders;
self.workspace_cache.write().await.clear();
}
pub async fn clear_document_caches(&self, uri: &Url) {
if let Ok(path) = uri.to_file_path() {
self.data_cache.write().await.remove(&path);
self.position_cache.write().await.remove(&path);
self.outdated_cache.write().await.remove(&path);
self.workspace_cache.write().await.clear();
}
}
pub async fn resolve_catalog(
&self,
doc_uri: &Url,
package_name: &str,
catalog: &str,
) -> Option<CatalogResult> {
let doc_path = doc_uri.to_file_path().ok()?;
let workspace = self.find_workspace(&doc_path).await?;
let document = self.workspace_document(&workspace.path).await?;
let data = self.workspace_data(&workspace, &document).await;
let map = if catalog == "default" {
if data.catalog.is_empty() {
data.catalogs.get("default")
} else {
Some(&data.catalog)
}
} else {
data.catalogs.get(catalog)
}?;
let version = map.get(package_name)?.clone();
let positions = self.workspace_positions(&workspace.path, &document).await;
let position_map = if catalog == "default" {
if positions.catalog.is_empty() {
positions.catalogs.get("default")
} else {
Some(&positions.catalog)
}
} else {
positions.catalogs.get(catalog)
};
let definition = position_map
.and_then(|map| map.get(package_name).copied())
.and_then(|range| {
Some(Location {
uri: Url::from_file_path(&workspace.path).ok()?,
range,
})
});
Some(CatalogResult {
version,
definition,
manager: workspace.manager,
})
}
pub async fn resolve_workspace_package(
&self,
doc_uri: &Url,
package_name: &str,
) -> Option<WorkspacePackageResult> {
let doc_path = doc_uri.to_file_path().ok()?;
let workspace = self.find_workspace(&doc_path).await?;
let document = self.workspace_document(&workspace.path).await?;
let data = self.workspace_data(&workspace, &document).await;
let root = workspace.path.parent()?;
for pattern in data.packages {
let pattern = root.join(pattern).join("package.json");
let pattern = pattern.to_string_lossy().to_string();
let Ok(paths) = glob(&pattern) else {
continue;
};
for path in paths.flatten() {
let Some(document) = self.workspace_document(&path).await else {
continue;
};
let data = parse_json_workspace_data(document.text());
if package_json_name(document.text()).as_deref() == Some(package_name) {
let uri = Url::from_file_path(path).ok()?;
let _ = data;
return Some(WorkspacePackageResult {
definition: Location {
uri,
range: Range::default(),
},
});
}
}
}
None
}
pub async fn resolve_version(
&self,
doc_uri: &Url,
package_name: &str,
) -> Option<PackageOutdatedInfo> {
let doc_path = doc_uri.to_file_path().ok()?;
let workspace = self.find_workspace(&doc_path).await?;
if workspace.manager != PackageManager::Pnpm {
return None;
}
self.get_outdated(&doc_path, package_name).await
}
pub async fn find_workspace(&self, path: &Path) -> Option<WorkspaceInfo> {
if let Some(cached) = self.workspace_cache.read().await.get(path).cloned() {
return cached;
}
let start = if path.is_dir() {
path.to_path_buf()
} else {
path.parent()?.to_path_buf()
};
let stop_at = self.stop_at_for(&start).await;
let mut dir = Some(start.as_path());
while let Some(current) = dir {
if let Some(info) = workspace_in_dir(current).await {
self.workspace_cache
.write()
.await
.insert(path.to_path_buf(), Some(info.clone()));
return Some(info);
}
if stop_at.as_deref() == Some(current) {
break;
}
dir = current.parent();
}
self.workspace_cache
.write()
.await
.insert(path.to_path_buf(), None);
None
}
async fn stop_at_for(&self, start: &Path) -> Option<PathBuf> {
self.workspace_folders
.read()
.await
.iter()
.find(|folder| start.starts_with(folder))
.cloned()
}
async fn workspace_document(&self, path: &Path) -> Option<Document> {
let uri = Url::from_file_path(path).ok()?;
if let Some(document) = self.documents.read().await.get(&uri).cloned() {
return Some(document);
}
let text = fs::read_to_string(path).await.ok()?;
Some(Document::new(uri, 1, text))
}
async fn workspace_data(
&self,
workspace: &WorkspaceInfo,
document: &Document,
) -> WorkspaceData {
if let Some(data) = self.data_cache.read().await.get(&workspace.path).cloned() {
return data;
}
let data = match workspace.manager {
PackageManager::Pnpm | PackageManager::Yarn => {
parse_yaml_workspace_data(document.text())
}
PackageManager::Bun => parse_json_workspace_data(document.text()),
};
self.data_cache
.write()
.await
.insert(workspace.path.clone(), data.clone());
data
}
async fn workspace_positions(&self, path: &Path, document: &Document) -> WorkspacePositions {
if let Some(positions) = self.position_cache.read().await.get(path).cloned() {
return positions;
}
let positions = if path.extension().and_then(|ext| ext.to_str()) == Some("json") {
parse_json_workspace_positions(document)
} else {
parse_yaml_workspace_positions(document)
};
self.position_cache
.write()
.await
.insert(path.to_path_buf(), positions.clone());
positions
}
async fn get_outdated(
&self,
package_json_path: &Path,
package_name: &str,
) -> Option<PackageOutdatedInfo> {
if let Some(cached) = self.outdated_cache.read().await.get(package_json_path) {
return cached.get(package_name).cloned();
}
let dir = package_json_path.parent()?;
let output = Command::new("pnpm")
.arg("outdated")
.arg("--json")
.current_dir(dir)
.output()
.await
.ok()?;
let code = output.status.code().unwrap_or_default();
if code != 0 && code != 1 {
eprintln!(
"pnpm outdated failed with code {code}: {}",
String::from_utf8_lossy(&output.stderr)
);
return None;
}
let outdated =
serde_json::from_slice::<HashMap<String, PackageOutdatedInfo>>(&output.stdout)
.unwrap_or_default();
self.outdated_cache
.write()
.await
.insert(package_json_path.to_path_buf(), outdated.clone());
outdated.get(package_name).cloned()
}
}
async fn workspace_in_dir(dir: &Path) -> Option<WorkspaceInfo> {
let pnpm = dir.join("pnpm-workspace.yaml");
if fs::metadata(&pnpm).await.is_ok() {
return Some(WorkspaceInfo {
path: pnpm,
manager: PackageManager::Pnpm,
});
}
let yarn = dir.join(".yarnrc.yml");
if fs::metadata(&yarn).await.is_ok() {
return Some(WorkspaceInfo {
path: yarn,
manager: PackageManager::Yarn,
});
}
let bun_lock = dir.join("bun.lock");
let bun_lockb = dir.join("bun.lockb");
if fs::metadata(&bun_lock).await.is_ok() || fs::metadata(&bun_lockb).await.is_ok() {
return Some(WorkspaceInfo {
path: dir.join("package.json"),
manager: PackageManager::Bun,
});
}
None
}
fn package_json_name(text: &str) -> Option<String> {
jsonc_parser::parse_to_serde_value::<serde_json::Value>(
text,
&jsonc_parser::ParseOptions::default(),
)
.ok()?
.get("name")?
.as_str()
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use tokio::fs;
use tower_lsp::lsp_types::Url;
use super::*;
#[tokio::test]
async fn resolves_workspace_package_definition() {
let tmp = tempfile::tempdir().unwrap();
fs::write(
tmp.path().join("pnpm-workspace.yaml"),
"packages:\n - packages/*\n",
)
.await
.unwrap();
fs::create_dir_all(tmp.path().join("packages/app"))
.await
.unwrap();
fs::write(
tmp.path().join("packages/app/package.json"),
r#"{ "name": "@scope/app", "version": "1.0.0" }"#,
)
.await
.unwrap();
let root_package = tmp.path().join("package.json");
fs::write(
&root_package,
r#"{ "dependencies": { "@scope/app": "workspace:*" } }"#,
)
.await
.unwrap();
let manager = WorkspaceManager::new(Arc::new(RwLock::new(HashMap::new())));
let result = manager
.resolve_workspace_package(&Url::from_file_path(root_package).unwrap(), "@scope/app")
.await
.unwrap();
assert!(
result
.definition
.uri
.as_str()
.ends_with("/packages/app/package.json")
);
}
}