use std::path::{Path, PathBuf};
use super::{
ManifestCache, WorkspaceDiagnostic, WorkspaceDiagnosticKind, WorkspaceInfo,
collect_declared_patterns, register_matched_workspace,
undeclared::{is_ignored_scan_dir, is_undeclared_scan_skip_dir},
};
pub(super) fn is_nested_project_dir(dir: &Path) -> bool {
dir.join("package.json").is_file() && dir.join(PNPM_WORKSPACE_FILE).is_file()
}
const PNPM_WORKSPACE_FILE: &str = "pnpm-workspace.yaml";
struct ManifestPatternMatcher {
positive: globset::GlobSet,
negative: globset::GlobSet,
}
impl ManifestPatternMatcher {
fn load(root: &Path) -> Self {
let mut positive = globset::GlobSetBuilder::new();
let mut negative = globset::GlobSetBuilder::new();
for declared in collect_declared_patterns(root, &[]).unwrap_or_default() {
let (builder, pattern) = match declared.pattern.strip_prefix('!') {
Some(rest) => (&mut negative, rest),
None => (&mut positive, declared.pattern.as_str()),
};
let pattern = pattern.strip_prefix("./").unwrap_or(pattern);
let pattern = if pattern.ends_with('/') {
format!("{pattern}*")
} else {
pattern.to_owned()
};
if let Ok(glob) = globset::GlobBuilder::new(&pattern)
.literal_separator(true)
.build()
{
builder.add(glob);
}
}
Self {
positive: positive
.build()
.unwrap_or_else(|_| globset::GlobSet::empty()),
negative: negative
.build()
.unwrap_or_else(|_| globset::GlobSet::empty()),
}
}
fn declares(&self, root: &Path, dir: &Path) -> bool {
let relative = dir.strip_prefix(root).unwrap_or(dir);
let relative = relative.to_string_lossy().replace('\\', "/");
self.positive.is_match(relative.as_str()) && !self.negative.is_match(relative.as_str())
}
}
#[must_use]
pub fn is_independent_pnpm_project(root: &Path, ws_root: &Path) -> bool {
if !ws_root.join(PNPM_WORKSPACE_FILE).is_file() || ws_root.strip_prefix(root).is_err() {
return false;
}
let canonical = |path: &Path| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
if canonical(ws_root) == canonical(root) {
return false;
}
!ManifestPatternMatcher::load(root).declares(root, ws_root)
}
pub(super) struct NestedProjectScan<'a> {
pub(super) root: &'a Path,
pub(super) canonical_root: &'a Path,
pub(super) ignore_patterns: &'a crate::IgnorePatternSet,
}
fn nested_project_roots(scan: &NestedProjectScan<'_>) -> Vec<PathBuf> {
let matcher = ManifestPatternMatcher::load(scan.root);
let is_candidate = |dir: &Path| {
is_nested_project_dir(dir)
&& !matcher.declares(scan.root, dir)
&& !is_ignored_scan_dir(scan.root, dir, scan.ignore_patterns)
};
let mut roots = Vec::new();
for top in scan_dirs(scan.root) {
if is_nested_project_dir(&top) {
if is_candidate(&top) {
roots.push(top);
}
continue;
}
roots.extend(
scan_dirs(&top)
.into_iter()
.filter(|child| is_candidate(child)),
);
}
roots.sort();
roots
}
fn scan_dirs(parent: &Path) -> Vec<PathBuf> {
let Ok(entries) = std::fs::read_dir(parent) else {
return Vec::new();
};
entries
.filter_map(Result::ok)
.filter(|entry| !is_undeclared_scan_skip_dir(&entry.file_name().to_string_lossy()))
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.collect()
}
pub(super) fn collect_nested_project_workspaces(
scan: &NestedProjectScan<'_>,
workspaces: &mut Vec<(WorkspaceInfo, Vec<String>)>,
diagnostics: &mut Vec<WorkspaceDiagnostic>,
manifest_cache: &mut ManifestCache,
) {
let canonical = |path: &Path| dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
let known: rustc_hash::FxHashSet<PathBuf> = workspaces
.iter()
.map(|(ws, _)| canonical(&ws.root))
.collect();
for dir in nested_project_roots(scan) {
let canonical_dir = canonical(&dir);
if known.contains(&canonical_dir) || !canonical_dir.starts_with(scan.canonical_root) {
continue;
}
let registered_before = workspaces.len();
register_matched_workspace(
scan.root,
dir.clone(),
workspaces,
diagnostics,
manifest_cache,
);
if workspaces.len() > registered_before {
diagnostics.push(WorkspaceDiagnostic::new(
scan.root,
dir,
WorkspaceDiagnosticKind::NestedProjectRoot,
));
}
}
}