1use std::path::{Path, PathBuf};
21use std::sync::{Mutex, OnceLock};
22
23use rustc_hash::{FxHashMap, FxHashSet};
24use schemars::JsonSchema;
25use serde::{Deserialize, Serialize};
26
27#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
33#[serde(tag = "kind", rename_all = "kebab-case")]
34pub enum WorkspaceDiagnosticKind {
35 UndeclaredWorkspace,
40 MalformedPackageJson {
43 error: String,
45 },
46 GlobMatchedNoPackageJson {
50 pattern: String,
52 },
53 MalformedTsconfig {
56 error: String,
58 },
59 TsconfigReferenceDirMissing,
62}
63
64impl WorkspaceDiagnosticKind {
65 #[must_use]
67 pub const fn id(&self) -> &'static str {
68 match self {
69 Self::UndeclaredWorkspace => "undeclared-workspace",
70 Self::MalformedPackageJson { .. } => "malformed-package-json",
71 Self::GlobMatchedNoPackageJson { .. } => "glob-matched-no-package-json",
72 Self::MalformedTsconfig { .. } => "malformed-tsconfig",
73 Self::TsconfigReferenceDirMissing => "tsconfig-reference-dir-missing",
74 }
75 }
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
85pub struct WorkspaceDiagnostic {
86 pub path: PathBuf,
88 #[serde(flatten)]
90 pub kind: WorkspaceDiagnosticKind,
91 pub message: String,
94}
95
96impl WorkspaceDiagnostic {
97 #[must_use]
111 pub fn new(root: &Path, path: PathBuf, kind: WorkspaceDiagnosticKind) -> Self {
112 let kind = normalise_payload_paths(root, kind);
113 let message = render_message(root, &path, &kind);
114 Self {
115 path,
116 kind,
117 message,
118 }
119 }
120}
121
122fn normalise_payload_paths(root: &Path, kind: WorkspaceDiagnosticKind) -> WorkspaceDiagnosticKind {
127 let root_str = root.display().to_string();
128 let root_alt = root_str.replace('\\', "/");
129 let normalise = |text: String| -> String {
130 let stripped = text
131 .replace(&format!("{root_str}/"), "")
132 .replace(&format!("{root_alt}/"), "");
133 stripped
137 .replace(&format!("{root_str}\\"), "")
138 .replace(&format!("{root_alt}\\"), "")
139 };
140 match kind {
141 WorkspaceDiagnosticKind::MalformedPackageJson { error } => {
142 WorkspaceDiagnosticKind::MalformedPackageJson {
143 error: normalise(error),
144 }
145 }
146 WorkspaceDiagnosticKind::MalformedTsconfig { error } => {
147 WorkspaceDiagnosticKind::MalformedTsconfig {
148 error: normalise(error),
149 }
150 }
151 other => other,
152 }
153}
154
155fn display_relative(root: &Path, path: &Path) -> String {
160 path.strip_prefix(root)
161 .unwrap_or(path)
162 .display()
163 .to_string()
164 .replace('\\', "/")
165}
166
167fn render_message(root: &Path, path: &Path, kind: &WorkspaceDiagnosticKind) -> String {
168 let display = display_relative(root, path);
169 match kind {
170 WorkspaceDiagnosticKind::UndeclaredWorkspace => format!(
171 "Directory '{display}' contains package.json but is not declared as a workspace. \
172 Add it to package.json workspaces or pnpm-workspace.yaml, or add it to ignorePatterns."
173 ),
174 WorkspaceDiagnosticKind::MalformedPackageJson { error } => format!(
175 "Dropped workspace '{display}': package.json is not valid JSON ({error}). \
176 Fix the JSON syntax or remove '{display}' from the workspaces pattern."
177 ),
178 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => format!(
179 "Glob '{pattern}' matched '{display}' but no package.json is present. \
180 Add a package.json, narrow the pattern, or add '{display}' to ignorePatterns."
181 ),
182 WorkspaceDiagnosticKind::MalformedTsconfig { error } => format!(
183 "tsconfig.json at '{display}' failed to parse ({error}); \
184 project references will be ignored. Fix the JSON syntax."
185 ),
186 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => format!(
187 "tsconfig.json references '{display}' but the directory does not exist. \
188 Update or remove the reference, or restore the missing directory."
189 ),
190 }
191}
192
193#[derive(Debug, Clone)]
200pub enum WorkspaceLoadError {
201 MalformedRootPackageJson { path: PathBuf, error: String },
203}
204
205impl std::fmt::Display for WorkspaceLoadError {
206 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
207 match self {
208 Self::MalformedRootPackageJson { path, error } => write!(
209 f,
210 "root package.json at '{}' is not valid JSON ({error}). \
211 Fix the syntax before re-running fallow.",
212 path.display()
213 ),
214 }
215 }
216}
217
218impl std::error::Error for WorkspaceLoadError {}
219
220const GLOB_EXAMPLE_CAP: usize = 3;
224
225fn warned_keys() -> &'static Mutex<FxHashSet<String>> {
232 static WARNED: OnceLock<Mutex<FxHashSet<String>>> = OnceLock::new();
233 WARNED.get_or_init(|| Mutex::new(FxHashSet::default()))
234}
235
236fn should_emit(key: String) -> bool {
241 warned_keys().lock().map_or(true, |mut set| set.insert(key))
242}
243
244#[derive(Debug, PartialEq, Eq)]
249struct PlannedWarning {
250 dedupe_key: String,
251 message: String,
252}
253
254fn plan_warnings(root: &Path, diagnostics: &[WorkspaceDiagnostic]) -> Vec<PlannedWarning> {
269 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
270 let per_instance = |diag: &WorkspaceDiagnostic| PlannedWarning {
271 dedupe_key: format!(
272 "{}::{}::{}",
273 canonical.display(),
274 diag.kind.id(),
275 diag.path.display()
276 ),
277 message: diag.message.clone(),
278 };
279
280 let mut plans: Vec<PlannedWarning> = Vec::new();
281 let mut glob_groups: Vec<(&str, Vec<&WorkspaceDiagnostic>)> = Vec::new();
282 let mut tsconfig_ref_misses: Vec<&WorkspaceDiagnostic> = Vec::new();
287 for diag in diagnostics {
288 match &diag.kind {
289 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson { pattern } => {
290 match glob_groups.iter_mut().find(|(p, _)| *p == pattern.as_str()) {
291 Some((_, group)) => group.push(diag),
292 None => glob_groups.push((pattern.as_str(), vec![diag])),
293 }
294 }
295 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing => tsconfig_ref_misses.push(diag),
296 _ => plans.push(per_instance(diag)),
297 }
298 }
299
300 for (pattern, group) in glob_groups {
301 if let [only] = group.as_slice() {
302 plans.push(per_instance(only));
303 continue;
304 }
305 let paths: Vec<&Path> = group.iter().map(|d| d.path.as_path()).collect();
306 plans.push(PlannedWarning {
307 dedupe_key: format!(
308 "{}::glob-matched-no-package-json-agg::{pattern}",
309 canonical.display()
310 ),
311 message: build_glob_group_message(root, pattern, &paths),
312 });
313 }
314
315 if let [only] = tsconfig_ref_misses.as_slice() {
318 plans.push(per_instance(only));
319 } else if !tsconfig_ref_misses.is_empty() {
320 let paths: Vec<&Path> = tsconfig_ref_misses
321 .iter()
322 .map(|d| d.path.as_path())
323 .collect();
324 plans.push(PlannedWarning {
325 dedupe_key: format!(
326 "{}::tsconfig-reference-dir-missing-agg",
327 canonical.display()
328 ),
329 message: build_tsconfig_refs_message(root, &paths),
330 });
331 }
332
333 plans
334}
335
336pub(super) fn emit_diagnostics(root: &Path, diagnostics: &[WorkspaceDiagnostic]) {
345 #[cfg(test)]
349 for diag in diagnostics {
350 capture_diag(diag);
351 }
352
353 for plan in plan_warnings(root, diagnostics) {
354 if should_emit(plan.dedupe_key) {
357 tracing::warn!("fallow: {}", plan.message);
358 }
359 }
360}
361
362fn summarize_examples(root: &Path, paths: &[&Path]) -> (String, usize) {
367 let mut examples: Vec<String> = paths.iter().map(|p| display_relative(root, p)).collect();
368 examples.sort();
369 let count = examples.len();
370 let shown = examples
371 .iter()
372 .take(GLOB_EXAMPLE_CAP)
373 .cloned()
374 .collect::<Vec<_>>()
375 .join(", ");
376 let remaining = count.saturating_sub(GLOB_EXAMPLE_CAP);
377 let listed = if remaining > 0 {
378 format!("{shown}, and {remaining} more")
379 } else {
380 shown
381 };
382 (listed, count)
383}
384
385fn build_glob_group_message(root: &Path, pattern: &str, paths: &[&Path]) -> String {
388 let (listed, count) = summarize_examples(root, paths);
389 format!(
390 "Glob '{pattern}' matched {count} directories with no package.json \
391 (e.g. {listed}). Add a package.json, narrow the pattern, or add \
392 them to ignorePatterns."
393 )
394}
395
396fn build_tsconfig_refs_message(root: &Path, paths: &[&Path]) -> String {
400 let (listed, count) = summarize_examples(root, paths);
401 format!(
402 "tsconfig.json references {count} directories that do not exist \
403 (e.g. {listed}). Update or remove the references, or restore the \
404 missing directories."
405 )
406}
407
408thread_local! {
409 #[cfg(test)]
416 static WORKSPACE_DIAGNOSTIC_CAPTURE: std::cell::RefCell<Option<Vec<WorkspaceDiagnostic>>> =
417 const { std::cell::RefCell::new(None) };
418}
419
420#[cfg(test)]
426fn capture_diag(diag: &WorkspaceDiagnostic) {
427 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
428 if let Some(buf) = cell.borrow_mut().as_mut() {
429 buf.push(diag.clone());
430 }
431 });
432}
433
434#[cfg(test)]
442#[must_use]
443pub fn capture_workspace_warnings<F: FnOnce() -> R, R>(body: F) -> (R, Vec<WorkspaceDiagnostic>) {
444 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| {
445 *cell.borrow_mut() = Some(Vec::new());
446 });
447 let result = body();
448 let findings =
449 WORKSPACE_DIAGNOSTIC_CAPTURE.with(|cell| cell.borrow_mut().take().unwrap_or_default());
450 (result, findings)
451}
452
453static WORKSPACE_DIAGNOSTICS: OnceLock<Mutex<FxHashMap<PathBuf, Vec<WorkspaceDiagnostic>>>> =
464 OnceLock::new();
465
466pub fn stash_workspace_diagnostics(root: &Path, diagnostics: Vec<WorkspaceDiagnostic>) {
472 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
473 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
474 if let Ok(mut map) = registry.lock() {
475 map.insert(canonical, diagnostics);
476 }
477}
478
479pub fn append_workspace_diagnostics(root: &Path, additions: Vec<WorkspaceDiagnostic>) {
488 if additions.is_empty() {
489 return;
490 }
491 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
492 let registry = WORKSPACE_DIAGNOSTICS.get_or_init(|| Mutex::new(FxHashMap::default()));
493 if let Ok(mut map) = registry.lock() {
494 let existing = map.entry(canonical).or_default();
495 let mut seen: FxHashSet<(String, String)> = existing
496 .iter()
497 .map(|d| {
498 (
499 d.kind.id().to_owned(),
500 dunce::canonicalize(&d.path)
501 .unwrap_or_else(|_| d.path.clone())
502 .display()
503 .to_string(),
504 )
505 })
506 .collect();
507 for addition in additions {
508 let key = (
509 addition.kind.id().to_owned(),
510 dunce::canonicalize(&addition.path)
511 .unwrap_or_else(|_| addition.path.clone())
512 .display()
513 .to_string(),
514 );
515 if seen.insert(key) {
516 existing.push(addition);
517 }
518 }
519 }
520}
521
522#[must_use]
528pub fn workspace_diagnostics_for(root: &Path) -> Vec<WorkspaceDiagnostic> {
529 let canonical = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
530 let Some(registry) = WORKSPACE_DIAGNOSTICS.get() else {
531 return Vec::new();
532 };
533 registry
534 .lock()
535 .ok()
536 .and_then(|map| map.get(&canonical).cloned())
537 .unwrap_or_default()
538}
539
540#[must_use]
546pub(super) fn is_skip_listed_dir(name: &str) -> bool {
547 name.starts_with('.') || matches!(name, "node_modules" | "build" | "dist" | "coverage")
552}
553
554#[must_use]
559pub(super) fn is_ignored_workspace_dir(
560 relative_dir: &Path,
561 ignore_patterns: &globset::GlobSet,
562) -> bool {
563 if ignore_patterns.is_empty() {
564 return false;
565 }
566 let relative_str = relative_dir.to_string_lossy().replace('\\', "/");
567 ignore_patterns.is_match(relative_str.as_str())
568 || ignore_patterns.is_match(format!("{relative_str}/package.json").as_str())
569}
570
571#[cfg(test)]
572mod tests {
573 use super::*;
574
575 fn glob_diag(root: &Path, pattern: &str, rel_path: &str) -> WorkspaceDiagnostic {
576 WorkspaceDiagnostic::new(
577 root,
578 root.join(rel_path),
579 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
580 pattern: pattern.to_owned(),
581 },
582 )
583 }
584
585 #[test]
586 fn build_glob_group_message_caps_examples_and_summarises_tail() {
587 let root = Path::new("/project");
588 let paths = [
589 root.join("playground/cli"),
590 root.join("playground/lib-types"),
591 root.join("playground/minify"),
592 root.join("playground/ssr"),
593 root.join("playground/worker"),
594 ];
595 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
596 let message = build_glob_group_message(root, "playground/**", &refs);
597
598 assert!(
599 message.starts_with("Glob 'playground/**' matched 5 directories with no package.json"),
600 "count and pattern lead the message: {message}"
601 );
602 assert!(
604 message.contains(
605 "(e.g. playground/cli, playground/lib-types, playground/minify, and 2 more)"
606 ),
607 "three sorted examples + tail count: {message}"
608 );
609 assert!(
610 message.ends_with(
611 "Add a package.json, narrow the pattern, or add them to ignorePatterns."
612 ),
613 "next-step hint preserved: {message}"
614 );
615 assert!(
617 !message.contains("playground/ssr"),
618 "tail example not named: {message}"
619 );
620 }
621
622 #[test]
623 fn build_glob_group_message_no_tail_when_at_or_below_cap() {
624 let root = Path::new("/project");
625 let paths = [root.join("packages/a"), root.join("packages/b")];
626 let refs: Vec<&Path> = paths.iter().map(PathBuf::as_path).collect();
627 let message = build_glob_group_message(root, "packages/*", &refs);
628
629 assert!(message.contains("matched 2 directories"), "{message}");
630 assert!(
631 message.contains("(e.g. packages/a, packages/b)"),
632 "both examples named, no `and N more`: {message}"
633 );
634 assert!(!message.contains("more)"), "no tail clause: {message}");
635 }
636
637 #[test]
638 fn plan_warnings_aggregates_repeated_glob_diagnostics_to_one_line() {
639 let root = Path::new("/project");
640 let diagnostics: Vec<WorkspaceDiagnostic> = (0..50)
641 .map(|i| glob_diag(root, "playground/**", &format!("playground/p{i}")))
642 .collect();
643
644 let plans = plan_warnings(root, &diagnostics);
645
646 assert_eq!(
647 plans.len(),
648 1,
649 "50 same-pattern diagnostics collapse to one plan"
650 );
651 assert!(
652 plans[0]
653 .dedupe_key
654 .ends_with("::glob-matched-no-package-json-agg::playground/**")
655 );
656 assert!(plans[0].message.contains("matched 50 directories"));
657 }
658
659 #[test]
660 fn plan_warnings_keeps_distinct_patterns_separate() {
661 let root = Path::new("/project");
662 let diagnostics = vec![
663 glob_diag(root, "apps/*", "apps/a"),
664 glob_diag(root, "apps/*", "apps/b"),
665 glob_diag(root, "packages/*", "packages/x"),
666 glob_diag(root, "packages/*", "packages/y"),
667 ];
668
669 let plans = plan_warnings(root, &diagnostics);
670
671 assert_eq!(plans.len(), 2, "one aggregated plan per distinct pattern");
672 let messages: Vec<&str> = plans.iter().map(|p| p.message.as_str()).collect();
673 assert!(
674 messages
675 .iter()
676 .any(|m| m.contains("Glob 'apps/*' matched 2")),
677 "{messages:?}"
678 );
679 assert!(
680 messages
681 .iter()
682 .any(|m| m.contains("Glob 'packages/*' matched 2")),
683 "{messages:?}"
684 );
685 }
686
687 #[test]
688 fn plan_warnings_single_match_keeps_per_instance_message_and_key() {
689 let root = Path::new("/project");
690 let diag = glob_diag(root, "packages/*", "packages/scratch");
691
692 let plans = plan_warnings(root, std::slice::from_ref(&diag));
693
694 assert_eq!(plans.len(), 1);
695 assert_eq!(plans[0].message, diag.message);
697 assert!(
698 plans[0]
699 .dedupe_key
700 .contains("::glob-matched-no-package-json::")
701 && plans[0].dedupe_key.ends_with("packages/scratch"),
702 "per-instance key is `root::kind::path`, not the `-agg::pattern` form: {}",
703 plans[0].dedupe_key
704 );
705 assert!(
706 !plans[0].message.contains("directories"),
707 "single match is not aggregated"
708 );
709 }
710
711 #[test]
712 fn plan_warnings_non_glob_kinds_stay_per_instance() {
713 let root = Path::new("/project");
714 let diagnostics = vec![
715 WorkspaceDiagnostic::new(
716 root,
717 root.join("packages/a"),
718 WorkspaceDiagnosticKind::UndeclaredWorkspace,
719 ),
720 WorkspaceDiagnostic::new(
721 root,
722 root.join("packages/b"),
723 WorkspaceDiagnosticKind::MalformedPackageJson {
724 error: "trailing comma".to_owned(),
725 },
726 ),
727 ];
728
729 let plans = plan_warnings(root, &diagnostics);
730
731 assert_eq!(
732 plans.len(),
733 2,
734 "each non-glob diagnostic plans its own warning"
735 );
736 assert!(
737 plans
738 .iter()
739 .all(|p| !p.message.contains("directories with no package.json"))
740 );
741 }
742
743 fn tsconfig_ref_diag(root: &Path, rel_path: &str) -> WorkspaceDiagnostic {
744 WorkspaceDiagnostic::new(
745 root,
746 root.join(rel_path),
747 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
748 )
749 }
750
751 #[test]
752 fn plan_warnings_aggregates_repeated_tsconfig_ref_misses_to_one_line() {
753 let root = Path::new("/project");
754 let diagnostics: Vec<WorkspaceDiagnostic> = (0..30)
755 .map(|i| tsconfig_ref_diag(root, &format!("packages/p{i:02}/tsconfig.json")))
756 .collect();
757
758 let plans = plan_warnings(root, &diagnostics);
759
760 assert_eq!(plans.len(), 1, "30 missing references collapse to one plan");
761 assert!(
762 plans[0]
763 .dedupe_key
764 .ends_with("::tsconfig-reference-dir-missing-agg")
765 );
766 assert!(
767 plans[0]
768 .message
769 .starts_with("tsconfig.json references 30 directories that do not exist"),
770 "{}",
771 plans[0].message
772 );
773 assert!(
774 plans[0].message.contains(
775 "(e.g. packages/p00/tsconfig.json, packages/p01/tsconfig.json, \
776 packages/p02/tsconfig.json, and 27 more)"
777 ),
778 "three sorted examples + tail: {}",
779 plans[0].message
780 );
781 assert!(
782 plans[0]
783 .message
784 .ends_with("Update or remove the references, or restore the missing directories."),
785 "{}",
786 plans[0].message
787 );
788 }
789
790 #[test]
791 fn plan_warnings_single_tsconfig_ref_miss_keeps_per_instance_message() {
792 let root = Path::new("/project");
793 let diag = tsconfig_ref_diag(root, "packages/only/tsconfig.json");
794
795 let plans = plan_warnings(root, std::slice::from_ref(&diag));
796
797 assert_eq!(plans.len(), 1);
798 assert_eq!(
799 plans[0].message, diag.message,
800 "single miss is not aggregated"
801 );
802 assert!(!plans[0].message.contains("directories that do not exist"));
803 }
804
805 #[test]
806 fn plan_warnings_mixed_aggregatable_kinds_each_collapse_independently() {
807 let root = Path::new("/project");
808 let mut diagnostics: Vec<WorkspaceDiagnostic> = (0..5)
809 .map(|i| glob_diag(root, "packages/*", &format!("packages/g{i}")))
810 .collect();
811 diagnostics.extend(
812 (0..4).map(|i| tsconfig_ref_diag(root, &format!("packages/t{i}/tsconfig.json"))),
813 );
814
815 let plans = plan_warnings(root, &diagnostics);
816
817 assert_eq!(plans.len(), 2, "one glob summary + one tsconfig summary");
818 assert!(
819 plans
820 .iter()
821 .any(|p| p.message.contains("matched 5 directories"))
822 );
823 assert!(
824 plans
825 .iter()
826 .any(|p| p.message.contains("references 4 directories"))
827 );
828 }
829}