use std::{collections::BTreeSet, path::Path};
#[cfg(test)]
use std::path::PathBuf;
use serde_json::Value;
use crate::{code::parser::workspace::join_relative_path, domain::CodeWorkspaceMember};
use super::WorkspaceSource;
const PNPM_RECURSIVE_WORKSPACE_DIR_LIMIT: usize = 1024;
const PNPM_RECURSIVE_WORKSPACE_ENTRY_LIMIT: usize = 8192;
pub(super) fn detect_pnpm_workspace(
source: &dyn WorkspaceSource,
) -> Option<Vec<CodeWorkspaceMember>> {
let content = source.read_to_string("pnpm-workspace.yaml")?;
let members = parse_pnpm_workspace_content(source, &content);
if members.is_empty() {
None
} else {
Some(members)
}
}
fn parse_pnpm_workspace_content(
source: &dyn WorkspaceSource,
content: &str,
) -> Vec<CodeWorkspaceMember> {
let package_patterns = parse_yaml_packages_list(content);
let excluded = expanded_pnpm_exclusions(source, &package_patterns);
let mut members = Vec::new();
let mut seen_paths = BTreeSet::new();
if let Some(package_name) = read_package_json_name(source, "") {
seen_paths.insert(".".to_owned());
members.push(CodeWorkspaceMember {
package_name,
relative_path: ".".to_owned(),
});
}
for pattern in package_patterns {
if pattern.starts_with('!') {
continue;
}
for relative_path in expand_package_pattern(source, &pattern) {
if relative_path.is_empty()
|| relative_path == "."
|| relative_path == ".."
|| excluded.contains(&relative_path)
|| !seen_paths.insert(relative_path.clone())
{
continue;
}
let package_name = read_package_json_name(source, &relative_path)
.unwrap_or_else(|| dir_name(&relative_path));
members.push(CodeWorkspaceMember {
package_name,
relative_path,
});
}
}
members
}
fn parse_yaml_packages_list(content: &str) -> Vec<String> {
let mut patterns = Vec::new();
let mut in_packages = false;
for line in content.lines() {
let trimmed = strip_yaml_comment(line).trim().to_string();
if trimmed.is_empty() {
continue;
}
if let Some(rest) = trimmed.strip_prefix("packages:") {
in_packages = true;
let rest = rest.trim();
if rest.starts_with('[') && rest.ends_with(']') {
patterns.extend(parse_inline_yaml_array(&rest[1..rest.len() - 1]));
break;
}
continue;
}
if !in_packages {
continue;
}
if let Some(value) = trimmed.strip_prefix("- ") {
let value = yaml_unquote(value.trim());
if value.is_empty() {
continue;
}
patterns.push(value.to_string());
}
else if !trimmed.starts_with('-') {
break;
}
}
patterns
}
fn yaml_unquote(value: &str) -> &str {
if value.len() >= 2
&& ((value.starts_with('\'') && value.ends_with('\''))
|| (value.starts_with('"') && value.ends_with('"')))
{
return &value[1..value.len() - 1];
}
value
}
fn parse_inline_yaml_array(content: &str) -> Vec<String> {
content
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| yaml_unquote(s).to_string())
.collect()
}
fn strip_yaml_comment(line: &str) -> &str {
let mut in_single = false;
let mut in_double = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' if !in_double => in_single = !in_single,
'"' if !in_single => in_double = !in_double,
'#' if !in_single && !in_double => return &line[..i],
_ => {}
}
}
line
}
fn normalize_workspace_path(pattern: &str) -> String {
let mut p = pattern.trim().to_string();
if let Some(rest) = p.strip_prefix("./") {
p = rest.to_string();
}
p.trim_matches('/').to_owned()
}
fn expand_package_pattern(source: &dyn WorkspaceSource, pattern: &str) -> Vec<String> {
let pattern = normalize_workspace_path(pattern);
if pattern.ends_with("/*") {
let parent = pattern.trim_end_matches("/*");
return source
.child_dirs(parent)
.into_iter()
.filter(|dir| read_package_json_name(source, dir).is_some())
.collect();
}
if pattern.ends_with("/**") {
let parent = pattern.trim_end_matches("/**");
return package_dirs_under(source, parent);
}
vec![pattern]
}
fn package_dirs_under(source: &dyn WorkspaceSource, parent: &str) -> Vec<String> {
source
.descendant_dirs_containing_file(
parent,
"package.json",
PNPM_RECURSIVE_WORKSPACE_DIR_LIMIT,
PNPM_RECURSIVE_WORKSPACE_ENTRY_LIMIT,
)
.into_iter()
.filter(|dir| read_package_json_name(source, dir).is_some())
.collect()
}
fn expanded_pnpm_exclusions(source: &dyn WorkspaceSource, patterns: &[String]) -> BTreeSet<String> {
patterns
.iter()
.filter_map(|pattern| pattern.strip_prefix('!'))
.flat_map(|pattern| expand_package_pattern(source, pattern))
.collect()
}
fn read_package_json_name(source: &dyn WorkspaceSource, dir: &str) -> Option<String> {
let content = source.read_to_string(&join_relative_path(dir, "package.json"))?;
let value = serde_json::from_str::<Value>(&content).ok()?;
value
.get("name")
.and_then(Value::as_str)
.map(str::trim)
.filter(|name| !name.is_empty())
.map(str::to_owned)
}
fn dir_name(path: &str) -> String {
Path::new(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
#[cfg(test)]
#[path = "pnpm_workspace_tests.rs"]
mod tests;