use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use rustc_hash::{FxHashMap, FxHashSet};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum WorkspaceDiagnosticKind {
UndeclaredWorkspace,
MalformedPackageJson {
error: String,
},
GlobMatchedNoPackageJson {
pattern: String,
},
MalformedTsconfig {
error: String,
},
TsconfigReferenceDirMissing,
}
impl WorkspaceDiagnosticKind {
#[must_use]
pub const fn id(&self) -> &'static str {
match self {
Self::UndeclaredWorkspace => "undeclared-workspace",
Self::MalformedPackageJson { .. } => "malformed-package-json",
Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
Self::MalformedTsconfig { .. } => "malformed-tsconfig",
Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WorkspaceDiagnostic {
pub path: PathBuf,
#[serde(flatten)]
pub kind: WorkspaceDiagnosticKind,
pub message: String,
}
impl WorkspaceDiagnostic {
#[must_use]
pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
let kind = normalise_payload_paths(root, kind);
let message = render_message(root, &path, &kind);
Self {
path,
kind,
message,
}
}
}
fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
let root_str = root.display().to_string();
let root_alt = root_str.replace('\\', "/");
let normalise = |text: String| -> String {
let stripped = text
.replace(&format!("{root_str}/"), "")
.replace(&format!("{root_alt}/"), "");
stripped
.replace(&format!("{root_str}\\"), "")
.replace(&format!("{root_alt}\\"), "")
};
match kind {
WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
WorkspaceDiagnosticKind::MalformedPackageJson {
error: normalise(error),
}
}
WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
WorkspaceDiagnosticKind::MalformedTsconfig {
error: normalise(error),
}
}
other => other,
}
}
fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
let display = path
.strip_prefix(root)
.unwrap_or(path)
.display()
.to_string()
.replace('\\', "/");
match kind {
WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
"Directory '{display}' contains package.json but is not declared as a workspace. \
Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
"Dropped workspace '{display}': package.json is not valid JSON ({error}). \
Fix the JSON syntax or remove '{display}' from the workspaces pattern."
),
WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
"Glob '{pattern}' matched '{display}' but no package.json is present. \
Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
),
WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
"tsconfig.json at '{display}' failed to parse ({error}); \
project references will be ignored. Fix the JSON syntax."
),
WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
"tsconfig.json references '{display}' but the directory does not exist. \
Update or remove the reference, or restore the missing directory."
),
}
}
#[derive(Debug, Clone)]
pub enum WorkspaceLoadError {
MalformedRootPackageJson { path: PathBuf, error: String },
}
impl std::fmt::Display for WorkspaceLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MalformedRootPackageJson { path, error } => write!(
f,
"root package.json at '{}' is not valid JSON ({error}). \
Fix the syntax before re-running fallow.",
path.display()
),
}
}
}
impl std::error::Error for WorkspaceLoadError {}
pub(super) fn emit_warn(root: &Path, diag: &WorkspaceDiagnostic) {
static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
let warned = WARNED.get_or_init(|| Mutex::new(FxHashSet::default()));
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let dedupe_key = format!(
"{}::{}::{}",
canonical.display(),
diag.kind.id(),
diag.path.display()
);
#[cfg(test)]
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
if let Some(buf) = cell.borrow_mut().as_mut() {
buf.push(diag.clone());
}
});
if let Ok(mut set) = warned.lock()
&& !set.insert(dedupe_key)
{
return;
}
tracing::warn!("fallow: {}", diag.message);
}
thread_local! {
#[cfg(test)]
static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
#[must_use]
pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
*cell.borrow_mut() = Some(Vec::new());
});
let result = body();
let findings =
WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
(result, findings)
}
static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
OnceLock::new();
pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
if let Ok(mut map) = registry.lock() {
map.insert(canonical, diagnostics);
}
}
pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
if additions.is_empty() {
return;
}
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
if let Ok(mut map) = registry.lock() {
let existing = map.entry(canonical).or_default();
let mut seen: FxHashSet<(String, String)> = existing
.iter()
.map(|d| {
(
d.kind.id().to_owned(),
dunce::canonicalize(&d.path)
.unwrap_or_else(|_| d.path.clone())
.display()
.to_string(),
)
})
.collect();
for addition in additions {
let key = (
addition.kind.id().to_owned(),
dunce::canonicalize(&addition.path)
.unwrap_or_else(|_| addition.path.clone())
.display()
.to_string(),
);
if seen.insert(key) {
existing.push(addition);
}
}
}
}
#[must_use]
pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
return Vec::new();
};
registry
.lock()
.ok()
.and_then(|map| map.get(&canonical).cloned())
.unwrap_or_default()
}
#[must_use]
pub(super) fn is_skip_listed_dir(name: &str) -> bool {
name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
}
#[must_use]
pub(super) fn is_ignored_workspace_dir(
relative_dir: &Path,
ignore_patterns: &globset::GlobSet,
) -> bool {
if ignore_patterns.is_empty() {
return false;
}
let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
ignore_patterns.is_match(relative_str.as_str())
|| ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
}