1use std::path::{Path, PathBuf};
2
3use super::PackageJson;
4use super::diagnostics::{
5 WorkspaceDiagnostic, WorkspaceDiagnosticKind, is_ignored_workspace_dir, is_skip_listed_dir,
6};
7
8#[cfg(test)]
24pub(super) fn parse_tsconfig_references(root: &Path) -> Vec<PathBuf> {
25 let mut diagnostics = Vec::new();
26 parse_tsconfig_references_with_diagnostics(root, &globset::GlobSet::empty(), &mut diagnostics)
27}
28
29pub(super) fn parse_tsconfig_references_with_diagnostics(
47 root: &Path,
48 ignore_patterns: &globset::GlobSet,
49 diagnostics: &mut Vec<WorkspaceDiagnostic>,
50) -> Vec<PathBuf> {
51 let tsconfig_path = root.join("tsconfig.json");
52 let Ok(content) = std::fs::read_to_string(&tsconfig_path) else {
53 return Vec::new();
54 };
55
56 let content = content.trim_start_matches('\u{FEFF}');
57
58 let value: serde_json::Value = match crate::jsonc::parse_to_value(content) {
59 Ok(v) => v,
60 Err(error) => {
61 let diag = WorkspaceDiagnostic::new(
62 root,
63 tsconfig_path,
64 WorkspaceDiagnosticKind::MalformedTsconfig {
65 error: error.to_string(),
66 },
67 );
68 diagnostics.push(diag);
69 return Vec::new();
70 }
71 };
72
73 let Some(refs) = value.get("references").and_then(|v| v.as_array()) else {
74 return Vec::new();
75 };
76
77 let mut results = Vec::new();
78 for r in refs {
79 let Some(raw_path) = r.get("path").and_then(|p| p.as_str()) else {
80 continue;
81 };
82 let cleaned = raw_path.strip_prefix("./").unwrap_or(raw_path);
83 let candidate = root.join(cleaned);
84 if candidate.is_dir() {
85 results.push(candidate);
86 continue;
87 }
88
89 if candidate.is_file() {
90 continue;
91 }
92
93 let relative = candidate
94 .strip_prefix(root)
95 .unwrap_or(candidate.as_path())
96 .to_path_buf();
97 if is_ignored_workspace_dir(&relative, ignore_patterns) {
98 continue;
99 }
100
101 let diag = WorkspaceDiagnostic::new(
102 root,
103 candidate,
104 WorkspaceDiagnosticKind::TsconfigReferenceDirMissing,
105 );
106 diagnostics.push(diag);
107 }
108 results
109}
110
111pub fn parse_tsconfig_root_dir(root: &Path) -> Option<String> {
115 let tsconfig_path = root.join("tsconfig.json");
116 let content = std::fs::read_to_string(&tsconfig_path).ok()?;
117 let content = content.trim_start_matches('\u{FEFF}');
118
119 let value: serde_json::Value = crate::jsonc::parse_to_value(content).ok()?;
120
121 value
122 .get("compilerOptions")
123 .and_then(|opts| opts.get("rootDir"))
124 .and_then(|v| v.as_str())
125 .map(|s| {
126 s.strip_prefix("./")
127 .unwrap_or(s)
128 .trim_end_matches('/')
129 .to_owned()
130 })
131}
132
133#[cfg(test)]
138pub(super) fn strip_trailing_commas(input: &str) -> String {
139 let bytes = input.as_bytes();
140 let len = bytes.len();
141 let mut result = Vec::with_capacity(len);
142 let mut in_string = false;
143 let mut i = 0;
144
145 while i < len {
146 let b = bytes[i];
147
148 if in_string {
149 result.push(b);
150 if b == b'\\' && i + 1 < len {
151 i += 1;
152 result.push(bytes[i]);
153 } else if b == b'"' {
154 in_string = false;
155 }
156 i += 1;
157 continue;
158 }
159
160 if b == b'"' {
161 in_string = true;
162 result.push(b);
163 i += 1;
164 continue;
165 }
166
167 if b == b',' {
168 let mut j = i + 1;
169 while j < len && bytes[j].is_ascii_whitespace() {
170 j += 1;
171 }
172 if j < len && (bytes[j] == b']' || bytes[j] == b'}') {
173 i += 1;
174 continue;
175 }
176 }
177
178 result.push(b);
179 i += 1;
180 }
181
182 String::from_utf8(result).unwrap_or_else(|_| input.to_string())
183}
184
185#[cfg(test)]
198pub(super) fn expand_workspace_glob(
199 root: &Path,
200 pattern: &str,
201 canonical_root: &Path,
202) -> Vec<(PathBuf, PathBuf)> {
203 let mut diagnostics = Vec::new();
204 expand_workspace_glob_with_diagnostics(
205 root,
206 pattern,
207 pattern,
208 canonical_root,
209 &globset::GlobSet::empty(),
210 &mut diagnostics,
211 )
212}
213
214pub(super) fn expand_workspace_glob_with_diagnostics(
230 root: &Path,
231 raw_pattern: &str,
232 expanded_pattern: &str,
233 canonical_root: &Path,
234 ignore_patterns: &globset::GlobSet,
235 diagnostics: &mut Vec<WorkspaceDiagnostic>,
236) -> Vec<(PathBuf, PathBuf)> {
237 if expanded_pattern.contains("**") {
238 return expand_recursive_workspace_pattern(
239 root,
240 raw_pattern,
241 expanded_pattern,
242 canonical_root,
243 ignore_patterns,
244 diagnostics,
245 );
246 }
247
248 let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
249 match glob::glob(&full_pattern) {
250 Ok(paths) => {
251 let mut results = Vec::new();
252 for path in paths.filter_map(Result::ok) {
253 collect_globbed_workspace_dir(
254 path,
255 &mut GlobbedWorkspaceContext {
256 root,
257 raw_pattern,
258 canonical_root,
259 ignore_patterns,
260 results: &mut results,
261 diagnostics,
262 },
263 );
264 }
265 results
266 }
267 Err(e) => {
268 tracing::warn!("invalid workspace glob pattern '{raw_pattern}': {e}");
269 Vec::new()
270 }
271 }
272}
273
274struct GlobbedWorkspaceContext<'a, 'b> {
275 root: &'a Path,
276 raw_pattern: &'a str,
277 canonical_root: &'a Path,
278 ignore_patterns: &'a globset::GlobSet,
279 results: &'b mut Vec<(PathBuf, PathBuf)>,
280 diagnostics: &'b mut Vec<WorkspaceDiagnostic>,
281}
282
283fn collect_globbed_workspace_dir(path: PathBuf, ctx: &mut GlobbedWorkspaceContext<'_, '_>) {
287 if !path.is_dir() {
288 return;
289 }
290 if path.components().any(|c| c.as_os_str() == "node_modules") {
291 return;
292 }
293 if path.join("package.json").exists() {
294 if let Some(cp) = dunce::canonicalize(&path)
295 .ok()
296 .filter(|cp| cp.starts_with(ctx.canonical_root))
297 {
298 ctx.results.push((path, cp));
299 }
300 return;
301 }
302 let recovered = recover_nested_packages(&path, ctx.canonical_root, ctx.ignore_patterns);
303 if recovered.is_empty() {
304 maybe_emit_glob_no_pkg_diag(
305 ctx.root,
306 ctx.raw_pattern,
307 &path,
308 ctx.ignore_patterns,
309 ctx.diagnostics,
310 );
311 } else {
312 let raw_pattern = ctx.raw_pattern;
313 tracing::debug!(
318 "workspace glob '{raw_pattern}' matched '{}' which has no package.json; \
319 recovered {} nested package(s) one level down. Consider '{raw_pattern}/*' \
320 so npm/pnpm/yarn resolve them as workspace members too.",
321 path.display(),
322 recovered.len()
323 );
324 ctx.results.extend(recovered);
325 }
326}
327
328fn recover_nested_packages(
350 path: &Path,
351 canonical_root: &Path,
352 ignore_patterns: &globset::GlobSet,
353) -> Vec<(PathBuf, PathBuf)> {
354 let Ok(entries) = std::fs::read_dir(path) else {
355 return Vec::new();
356 };
357 let mut recovered = Vec::new();
358 for entry in entries.filter_map(Result::ok) {
359 let child = entry.path();
360 if !child.is_dir() {
361 continue;
362 }
363 let leaf = entry.file_name();
364 let leaf = leaf.to_string_lossy();
365 if leaf == "node_modules" || is_skip_listed_dir(&leaf) {
366 continue;
367 }
368 let Some(cp) = dunce::canonicalize(&child)
369 .ok()
370 .filter(|cp| cp.starts_with(canonical_root))
371 else {
372 continue;
373 };
374 let relative = cp.strip_prefix(canonical_root).unwrap_or(cp.as_path());
378 if is_ignored_workspace_dir(relative, ignore_patterns) {
379 continue;
380 }
381 let pkg_path = child.join("package.json");
382 let Ok(pkg) = PackageJson::load(&pkg_path) else {
385 continue;
386 };
387 if pkg.name.is_none() {
388 continue;
389 }
390 recovered.push((child, cp));
391 }
392 recovered
393}
394
395fn maybe_emit_glob_no_pkg_diag(
406 root: &Path,
407 raw_pattern: &str,
408 path: &Path,
409 ignore_patterns: &globset::GlobSet,
410 diagnostics: &mut Vec<WorkspaceDiagnostic>,
411) {
412 let leaf = path
413 .file_name()
414 .map(|n| n.to_string_lossy().into_owned())
415 .unwrap_or_default();
416 if is_skip_listed_dir(&leaf) {
417 return;
418 }
419 let canonical_root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
420 let canonical_path = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
421 let relative = canonical_path
422 .strip_prefix(&canonical_root)
423 .unwrap_or(canonical_path.as_path())
424 .to_path_buf();
425 if is_ignored_workspace_dir(&relative, ignore_patterns) {
426 return;
427 }
428 let diag = WorkspaceDiagnostic::new(
429 root,
430 path.to_path_buf(),
431 WorkspaceDiagnosticKind::GlobMatchedNoPackageJson {
432 pattern: raw_pattern.to_string(),
433 },
434 );
435 diagnostics.push(diag);
436}
437
438fn expand_recursive_workspace_pattern(
444 root: &Path,
445 raw_pattern: &str,
446 expanded_pattern: &str,
447 canonical_root: &Path,
448 ignore_patterns: &globset::GlobSet,
449 diagnostics: &mut Vec<WorkspaceDiagnostic>,
450) -> Vec<(PathBuf, PathBuf)> {
451 let full_pattern = root.join(expanded_pattern).to_string_lossy().to_string();
452 let Ok(matcher) = glob::Pattern::new(&full_pattern) else {
453 tracing::warn!("invalid workspace glob pattern '{raw_pattern}'");
454 return Vec::new();
455 };
456
457 let base_dir = match expanded_pattern.find('*') {
458 Some(idx) => root.join(&expanded_pattern[..idx]),
459 None => root.join(expanded_pattern),
460 };
461
462 let mut results = Vec::new();
463 walk_workspace_dirs(
464 raw_pattern,
465 &base_dir,
466 &mut WorkspaceDirWalkInput {
467 root,
468 matcher: &matcher,
469 canonical_root,
470 ignore_patterns,
471 results: &mut results,
472 diagnostics,
473 },
474 );
475 results
476}
477
478struct WorkspaceDirWalkInput<'a> {
485 root: &'a Path,
486 matcher: &'a glob::Pattern,
487 canonical_root: &'a Path,
488 ignore_patterns: &'a globset::GlobSet,
489 results: &'a mut Vec<(PathBuf, PathBuf)>,
490 diagnostics: &'a mut Vec<WorkspaceDiagnostic>,
491}
492
493fn walk_workspace_dirs(raw_pattern: &str, dir: &Path, input: &mut WorkspaceDirWalkInput<'_>) {
494 let Ok(entries) = std::fs::read_dir(dir) else {
495 return;
496 };
497 for entry in entries.flatten() {
498 let path = entry.path();
499 if !path.is_dir() {
500 continue;
501 }
502 let name = entry.file_name();
503 if name == "node_modules" || name == ".git" {
504 continue;
505 }
506 if input.matcher.matches_path(&path) {
507 if path.join("package.json").exists() {
508 if let Ok(cp) = dunce::canonicalize(&path)
509 && cp.starts_with(input.canonical_root)
510 {
511 input.results.push((path.clone(), cp));
512 }
513 } else {
514 maybe_emit_glob_no_pkg_diag(
515 input.root,
516 raw_pattern,
517 &path,
518 input.ignore_patterns,
519 input.diagnostics,
520 );
521 }
522 }
523 walk_workspace_dirs(raw_pattern, &path, input);
524 }
525}
526
527pub(super) fn parse_pnpm_workspace_yaml(content: &str) -> Vec<String> {
529 let mut patterns = Vec::new();
530 let mut in_packages = false;
531
532 for line in content.lines() {
533 let trimmed = line.trim();
534 if trimmed == "packages:" {
535 in_packages = true;
536 continue;
537 }
538 if in_packages {
539 if trimmed.starts_with("- ") {
540 let value = trimmed
541 .strip_prefix("- ")
542 .unwrap_or(trimmed)
543 .trim_matches('\'')
544 .trim_matches('"');
545 patterns.push(value.to_string());
546 } else if !trimmed.is_empty() && !trimmed.starts_with('#') {
547 break; }
549 }
550 }
551
552 patterns
553}
554
555#[cfg(test)]
556mod tests {
557 use super::*;
558
559 #[test]
560 fn parse_pnpm_workspace_basic() {
561 let yaml = "packages:\n - 'packages/*'\n - 'apps/*'\n";
562 let patterns = parse_pnpm_workspace_yaml(yaml);
563 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
564 }
565
566 #[test]
567 fn parse_pnpm_workspace_double_quotes() {
568 let yaml = "packages:\n - \"packages/*\"\n - \"apps/*\"\n";
569 let patterns = parse_pnpm_workspace_yaml(yaml);
570 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
571 }
572
573 #[test]
574 fn parse_pnpm_workspace_no_quotes() {
575 let yaml = "packages:\n - packages/*\n - apps/*\n";
576 let patterns = parse_pnpm_workspace_yaml(yaml);
577 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
578 }
579
580 #[test]
581 fn parse_pnpm_workspace_empty() {
582 let yaml = "";
583 let patterns = parse_pnpm_workspace_yaml(yaml);
584 assert!(patterns.is_empty());
585 }
586
587 #[test]
588 fn parse_pnpm_workspace_no_packages_key() {
589 let yaml = "other:\n - something\n";
590 let patterns = parse_pnpm_workspace_yaml(yaml);
591 assert!(patterns.is_empty());
592 }
593
594 #[test]
595 fn parse_pnpm_workspace_with_comments() {
596 let yaml = "packages:\n # Comment\n - 'packages/*'\n";
597 let patterns = parse_pnpm_workspace_yaml(yaml);
598 assert_eq!(patterns, vec!["packages/*"]);
599 }
600
601 #[test]
602 fn parse_pnpm_workspace_stops_at_next_key() {
603 let yaml = "packages:\n - 'packages/*'\ncatalog:\n react: ^18\n";
604 let patterns = parse_pnpm_workspace_yaml(yaml);
605 assert_eq!(patterns, vec!["packages/*"]);
606 }
607
608 #[test]
609 fn strip_trailing_commas_basic() {
610 assert_eq!(
611 strip_trailing_commas(r#"{"a": 1, "b": 2,}"#),
612 r#"{"a": 1, "b": 2}"#
613 );
614 }
615
616 #[test]
617 fn strip_trailing_commas_array() {
618 assert_eq!(strip_trailing_commas(r"[1, 2, 3,]"), r"[1, 2, 3]");
619 }
620
621 #[test]
622 fn strip_trailing_commas_with_whitespace() {
623 assert_eq!(
624 strip_trailing_commas("{\n \"a\": 1,\n}"),
625 "{\n \"a\": 1\n}"
626 );
627 }
628
629 #[test]
630 fn strip_trailing_commas_preserves_strings() {
631 assert_eq!(
632 strip_trailing_commas(r#"{"a": "hello,}"}"#),
633 r#"{"a": "hello,}"}"#
634 );
635 }
636
637 #[test]
638 fn strip_trailing_commas_nested() {
639 let input = r#"{"refs": [{"path": "./a",}, {"path": "./b",},],}"#;
640 let expected = r#"{"refs": [{"path": "./a"}, {"path": "./b"}]}"#;
641 assert_eq!(strip_trailing_commas(input), expected);
642 }
643
644 #[test]
645 fn strip_trailing_commas_escaped_quotes() {
646 assert_eq!(
647 strip_trailing_commas(r#"{"a": "he\"llo,}",}"#),
648 r#"{"a": "he\"llo,}"}"#
649 );
650 }
651
652 #[test]
653 fn tsconfig_references_from_dir() {
654 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-refs");
655 let _ = std::fs::remove_dir_all(&temp_dir);
656 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
657 std::fs::create_dir_all(temp_dir.join("packages/ui")).unwrap();
658
659 std::fs::write(
660 temp_dir.join("tsconfig.json"),
661 r#"{
662 "references": [
663 {"path": "./packages/core"},
664 {"path": "./packages/ui"},
665 ],
666 }"#,
667 )
668 .unwrap();
669
670 let refs = parse_tsconfig_references(&temp_dir);
671 assert_eq!(refs.len(), 2);
672 assert!(refs.iter().any(|p| p.ends_with("packages/core")));
673 assert!(refs.iter().any(|p| p.ends_with("packages/ui")));
674
675 let _ = std::fs::remove_dir_all(&temp_dir);
676 }
677
678 #[test]
679 fn tsconfig_references_no_file() {
680 let refs = parse_tsconfig_references(std::path::Path::new("/nonexistent"));
681 assert!(refs.is_empty());
682 }
683
684 #[test]
685 fn tsconfig_references_no_references_field() {
686 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-no-refs");
687 let _ = std::fs::remove_dir_all(&temp_dir);
688 std::fs::create_dir_all(&temp_dir).unwrap();
689
690 std::fs::write(
691 temp_dir.join("tsconfig.json"),
692 r#"{"compilerOptions": {"strict": true}}"#,
693 )
694 .unwrap();
695
696 let refs = parse_tsconfig_references(&temp_dir);
697 assert!(refs.is_empty());
698
699 let _ = std::fs::remove_dir_all(&temp_dir);
700 }
701
702 #[test]
703 fn tsconfig_references_skips_nonexistent_dirs() {
704 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-missing-dir");
705 let _ = std::fs::remove_dir_all(&temp_dir);
706 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
707
708 std::fs::write(
709 temp_dir.join("tsconfig.json"),
710 r#"{"references": [{"path": "./packages/core"}, {"path": "./packages/missing"}]}"#,
711 )
712 .unwrap();
713
714 let refs = parse_tsconfig_references(&temp_dir);
715 assert_eq!(refs.len(), 1);
716 assert!(refs[0].ends_with("packages/core"));
717
718 let _ = std::fs::remove_dir_all(&temp_dir);
719 }
720
721 #[test]
722 fn tsconfig_references_skip_file_paths_silently() {
723 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-file-refs");
724 let _ = std::fs::remove_dir_all(&temp_dir);
725 std::fs::create_dir_all(temp_dir.join("build")).unwrap();
726 std::fs::create_dir_all(temp_dir.join("dist/types")).unwrap();
727 std::fs::create_dir_all(temp_dir.join("packages/foo")).unwrap();
728
729 std::fs::write(
730 temp_dir.join("build/tsconfig.app.json"),
731 r#"{"compilerOptions": {}}"#,
732 )
733 .unwrap();
734 std::fs::write(
735 temp_dir.join("dist/types/index.d.json"),
736 r#"{"compilerOptions": {}}"#,
737 )
738 .unwrap();
739 std::fs::write(
740 temp_dir.join("packages/foo/tsconfig.lib.json"),
741 r#"{"compilerOptions": {}}"#,
742 )
743 .unwrap();
744 std::fs::write(
745 temp_dir.join("tsconfig.base.json"),
746 r#"{"compilerOptions": {}}"#,
747 )
748 .unwrap();
749
750 std::fs::write(
751 temp_dir.join("tsconfig.json"),
752 r#"{
753 "references": [
754 {"path": "./build/tsconfig.app.json"},
755 {"path": "./dist/types/index.d.json"},
756 {"path": "./packages/foo/tsconfig.lib.json"},
757 {"path": "./tsconfig.base.json"}
758 ]
759 }"#,
760 )
761 .unwrap();
762
763 let mut diagnostics = Vec::new();
764 let refs = parse_tsconfig_references_with_diagnostics(
765 &temp_dir,
766 &globset::GlobSet::empty(),
767 &mut diagnostics,
768 );
769
770 assert!(
771 refs.is_empty(),
772 "file references at any path should not be workspace candidates; got: {refs:?}"
773 );
774 assert!(
775 diagnostics.is_empty(),
776 "file references must not trigger TsconfigReferenceDirMissing; got: {diagnostics:?}"
777 );
778
779 let _ = std::fs::remove_dir_all(&temp_dir);
780 }
781
782 #[test]
783 fn tsconfig_references_mixed_file_and_dir() {
784 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-mixed-refs");
785 let _ = std::fs::remove_dir_all(&temp_dir);
786 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
787 std::fs::create_dir_all(temp_dir.join("apps/web")).unwrap();
788 std::fs::write(
789 temp_dir.join("tsconfig.shared.json"),
790 r#"{"compilerOptions": {}}"#,
791 )
792 .unwrap();
793 std::fs::write(
794 temp_dir.join("apps/web/tsconfig.json"),
795 r#"{"compilerOptions": {}}"#,
796 )
797 .unwrap();
798
799 std::fs::write(
800 temp_dir.join("tsconfig.json"),
801 r#"{
802 "references": [
803 {"path": "./packages/core"},
804 {"path": "./tsconfig.shared.json"},
805 {"path": "./apps/web/tsconfig.json"}
806 ]
807 }"#,
808 )
809 .unwrap();
810
811 let mut diagnostics = Vec::new();
812 let refs = parse_tsconfig_references_with_diagnostics(
813 &temp_dir,
814 &globset::GlobSet::empty(),
815 &mut diagnostics,
816 );
817
818 assert_eq!(
819 refs.len(),
820 1,
821 "only the directory reference should be returned"
822 );
823 assert!(refs[0].ends_with("packages/core"));
824 assert!(
825 diagnostics.is_empty(),
826 "file references must not trigger diagnostics; got: {diagnostics:?}"
827 );
828
829 let _ = std::fs::remove_dir_all(&temp_dir);
830 }
831
832 #[test]
833 fn strip_trailing_commas_no_commas() {
834 let input = r#"{"a": 1, "b": [2, 3]}"#;
835 assert_eq!(strip_trailing_commas(input), input);
836 }
837
838 #[test]
839 fn strip_trailing_commas_empty_input() {
840 assert_eq!(strip_trailing_commas(""), "");
841 }
842
843 #[test]
844 fn strip_trailing_commas_nested_objects() {
845 let input = "{\n \"a\": {\n \"b\": 1,\n \"c\": 2,\n },\n \"d\": 3,\n}";
846 let expected = "{\n \"a\": {\n \"b\": 1,\n \"c\": 2\n },\n \"d\": 3\n}";
847 assert_eq!(strip_trailing_commas(input), expected);
848 }
849
850 #[test]
851 fn strip_trailing_commas_array_of_objects() {
852 let input = r#"[{"a": 1,}, {"b": 2,},]"#;
853 let expected = r#"[{"a": 1}, {"b": 2}]"#;
854 assert_eq!(strip_trailing_commas(input), expected);
855 }
856
857 #[test]
858 fn tsconfig_references_malformed_json() {
859 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-malformed");
860 let _ = std::fs::remove_dir_all(&temp_dir);
861 std::fs::create_dir_all(&temp_dir).unwrap();
862
863 std::fs::write(
864 temp_dir.join("tsconfig.json"),
865 r"{ this is not valid json at all",
866 )
867 .unwrap();
868
869 let refs = parse_tsconfig_references(&temp_dir);
870 assert!(refs.is_empty());
871
872 let _ = std::fs::remove_dir_all(&temp_dir);
873 }
874
875 #[test]
876 fn tsconfig_references_empty_array() {
877 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-empty-refs");
878 let _ = std::fs::remove_dir_all(&temp_dir);
879 std::fs::create_dir_all(&temp_dir).unwrap();
880
881 std::fs::write(temp_dir.join("tsconfig.json"), r#"{"references": []}"#).unwrap();
882
883 let refs = parse_tsconfig_references(&temp_dir);
884 assert!(refs.is_empty());
885
886 let _ = std::fs::remove_dir_all(&temp_dir);
887 }
888
889 #[test]
890 fn parse_pnpm_workspace_malformed() {
891 let patterns = parse_pnpm_workspace_yaml(":::not yaml at all:::");
892 assert!(patterns.is_empty());
893 }
894
895 #[test]
896 fn parse_pnpm_workspace_packages_key_empty_list() {
897 let yaml = "packages:\nother:\n - something\n";
898 let patterns = parse_pnpm_workspace_yaml(yaml);
899 assert!(patterns.is_empty());
900 }
901
902 #[test]
903 fn expand_workspace_glob_exact_path() {
904 let temp_dir = std::env::temp_dir().join("fallow-test-expand-exact");
905 let _ = std::fs::remove_dir_all(&temp_dir);
906 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
907 std::fs::write(
908 temp_dir.join("packages/core/package.json"),
909 r#"{"name": "core"}"#,
910 )
911 .unwrap();
912
913 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
914 let results = expand_workspace_glob(&temp_dir, "packages/core", &canonical_root);
915 assert_eq!(results.len(), 1);
916 assert!(results[0].0.ends_with("packages/core"));
917
918 let _ = std::fs::remove_dir_all(&temp_dir);
919 }
920
921 #[test]
922 fn expand_workspace_glob_star() {
923 let temp_dir = std::env::temp_dir().join("fallow-test-expand-star");
924 let _ = std::fs::remove_dir_all(&temp_dir);
925 std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
926 std::fs::create_dir_all(temp_dir.join("packages/b")).unwrap();
927 std::fs::create_dir_all(temp_dir.join("packages/c")).unwrap();
928 std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
929 std::fs::write(temp_dir.join("packages/b/package.json"), r#"{"name": "b"}"#).unwrap();
930
931 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
932 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
933 assert_eq!(results.len(), 2);
934
935 let _ = std::fs::remove_dir_all(&temp_dir);
936 }
937
938 #[test]
939 fn expand_workspace_glob_nested() {
940 let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested");
941 let _ = std::fs::remove_dir_all(&temp_dir);
942 std::fs::create_dir_all(temp_dir.join("packages/scope/a")).unwrap();
943 std::fs::create_dir_all(temp_dir.join("packages/scope/b")).unwrap();
944 std::fs::write(
945 temp_dir.join("packages/scope/a/package.json"),
946 r#"{"name": "@scope/a"}"#,
947 )
948 .unwrap();
949 std::fs::write(
950 temp_dir.join("packages/scope/b/package.json"),
951 r#"{"name": "@scope/b"}"#,
952 )
953 .unwrap();
954
955 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
956 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
957 assert_eq!(results.len(), 2);
958
959 let _ = std::fs::remove_dir_all(&temp_dir);
960 }
961
962 #[test]
963 fn tsconfig_root_dir_extracted() {
964 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir");
965 let _ = std::fs::remove_dir_all(&temp_dir);
966 std::fs::create_dir_all(&temp_dir).unwrap();
967
968 std::fs::write(
969 temp_dir.join("tsconfig.json"),
970 r#"{ "compilerOptions": { "rootDir": "./src" } }"#,
971 )
972 .unwrap();
973
974 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("src".to_string()));
975 let _ = std::fs::remove_dir_all(&temp_dir);
976 }
977
978 #[test]
979 fn tsconfig_root_dir_lib() {
980 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-lib");
981 let _ = std::fs::remove_dir_all(&temp_dir);
982 std::fs::create_dir_all(&temp_dir).unwrap();
983
984 std::fs::write(
985 temp_dir.join("tsconfig.json"),
986 r#"{ "compilerOptions": { "rootDir": "lib/" } }"#,
987 )
988 .unwrap();
989
990 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("lib".to_string()));
991 let _ = std::fs::remove_dir_all(&temp_dir);
992 }
993
994 #[test]
995 fn tsconfig_root_dir_missing_field() {
996 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-nofield");
997 let _ = std::fs::remove_dir_all(&temp_dir);
998 std::fs::create_dir_all(&temp_dir).unwrap();
999
1000 std::fs::write(
1001 temp_dir.join("tsconfig.json"),
1002 r#"{ "compilerOptions": { "strict": true } }"#,
1003 )
1004 .unwrap();
1005
1006 assert_eq!(parse_tsconfig_root_dir(&temp_dir), None);
1007 let _ = std::fs::remove_dir_all(&temp_dir);
1008 }
1009
1010 #[test]
1011 fn tsconfig_root_dir_no_file() {
1012 assert_eq!(parse_tsconfig_root_dir(Path::new("/nonexistent")), None);
1013 }
1014
1015 #[test]
1016 fn tsconfig_root_dir_with_comments() {
1017 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-comments");
1018 let _ = std::fs::remove_dir_all(&temp_dir);
1019 std::fs::create_dir_all(&temp_dir).unwrap();
1020
1021 std::fs::write(
1022 temp_dir.join("tsconfig.json"),
1023 "{\n // Root directory\n \"compilerOptions\": { \"rootDir\": \"app\" }\n}",
1024 )
1025 .unwrap();
1026
1027 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
1028 let _ = std::fs::remove_dir_all(&temp_dir);
1029 }
1030
1031 #[test]
1032 fn tsconfig_root_dir_dot_value() {
1033 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-dot");
1034 let _ = std::fs::remove_dir_all(&temp_dir);
1035 std::fs::create_dir_all(&temp_dir).unwrap();
1036
1037 std::fs::write(
1038 temp_dir.join("tsconfig.json"),
1039 r#"{ "compilerOptions": { "rootDir": "." } }"#,
1040 )
1041 .unwrap();
1042
1043 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some(".".to_string()));
1044 let _ = std::fs::remove_dir_all(&temp_dir);
1045 }
1046
1047 #[test]
1048 fn tsconfig_root_dir_parent_traversal() {
1049 let temp_dir = std::env::temp_dir().join("fallow-test-tsconfig-rootdir-parent");
1050 let _ = std::fs::remove_dir_all(&temp_dir);
1051 std::fs::create_dir_all(&temp_dir).unwrap();
1052
1053 std::fs::write(
1054 temp_dir.join("tsconfig.json"),
1055 r#"{ "compilerOptions": { "rootDir": "../other" } }"#,
1056 )
1057 .unwrap();
1058
1059 assert_eq!(
1060 parse_tsconfig_root_dir(&temp_dir),
1061 Some("../other".to_string())
1062 );
1063 let _ = std::fs::remove_dir_all(&temp_dir);
1064 }
1065
1066 #[test]
1067 fn expand_workspace_glob_no_matches() {
1068 let temp_dir = std::env::temp_dir().join("fallow-test-expand-nomatch");
1069 let _ = std::fs::remove_dir_all(&temp_dir);
1070 std::fs::create_dir_all(&temp_dir).unwrap();
1071
1072 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1073 let results = expand_workspace_glob(&temp_dir, "nonexistent/*", &canonical_root);
1074 assert!(results.is_empty());
1075
1076 let _ = std::fs::remove_dir_all(&temp_dir);
1077 }
1078
1079 #[test]
1080 fn parse_pnpm_workspace_with_empty_lines_between_entries() {
1081 let yaml = "packages:\n - 'packages/*'\n\n - 'apps/*'\n";
1082 let patterns = parse_pnpm_workspace_yaml(yaml);
1083 assert_eq!(patterns, vec!["packages/*", "apps/*"]);
1084 }
1085
1086 #[test]
1087 fn parse_pnpm_workspace_mixed_quotes() {
1088 let yaml = "packages:\n - 'single/*'\n - \"double/*\"\n - bare/*\n";
1089 let patterns = parse_pnpm_workspace_yaml(yaml);
1090 assert_eq!(patterns, vec!["single/*", "double/*", "bare/*"]);
1091 }
1092
1093 #[test]
1094 fn parse_pnpm_workspace_with_negation() {
1095 let yaml = "packages:\n - 'packages/*'\n - '!packages/test-*'\n";
1096 let patterns = parse_pnpm_workspace_yaml(yaml);
1097 assert_eq!(patterns, vec!["packages/*", "!packages/test-*"]);
1098 }
1099
1100 #[test]
1101 fn strip_trailing_commas_string_with_closing_brackets() {
1102 let input = r#"{"key": "value with ] and }",}"#;
1103 let expected = r#"{"key": "value with ] and }"}"#;
1104 assert_eq!(strip_trailing_commas(input), expected);
1105 }
1106
1107 #[test]
1108 fn strip_trailing_commas_multiple_levels() {
1109 let input = r#"{"a": {"b": [1, 2,], "c": 3,},}"#;
1110 let expected = r#"{"a": {"b": [1, 2], "c": 3}}"#;
1111 assert_eq!(strip_trailing_commas(input), expected);
1112 }
1113
1114 #[test]
1115 fn tsconfig_root_dir_with_trailing_commas() {
1116 let temp_dir = std::env::temp_dir().join("fallow-test-rootdir-trailing-comma");
1117 let _ = std::fs::remove_dir_all(&temp_dir);
1118 std::fs::create_dir_all(&temp_dir).unwrap();
1119
1120 std::fs::write(
1121 temp_dir.join("tsconfig.json"),
1122 "{\n \"compilerOptions\": {\n \"rootDir\": \"app\",\n },\n}",
1123 )
1124 .unwrap();
1125
1126 assert_eq!(parse_tsconfig_root_dir(&temp_dir), Some("app".to_string()));
1127 let _ = std::fs::remove_dir_all(&temp_dir);
1128 }
1129
1130 #[test]
1131 fn expand_workspace_glob_trailing_slash() {
1132 let temp_dir = std::env::temp_dir().join("fallow-test-expand-trailing");
1133 let _ = std::fs::remove_dir_all(&temp_dir);
1134 std::fs::create_dir_all(temp_dir.join("packages/a")).unwrap();
1135 std::fs::write(temp_dir.join("packages/a/package.json"), r#"{"name": "a"}"#).unwrap();
1136
1137 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1138 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1139 assert_eq!(results.len(), 1);
1140
1141 let _ = std::fs::remove_dir_all(&temp_dir);
1142 }
1143
1144 #[test]
1145 fn expand_workspace_glob_excludes_node_modules() {
1146 let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-nodemod");
1147 let _ = std::fs::remove_dir_all(&temp_dir);
1148
1149 let nm_pkg = temp_dir.join("packages/foo/node_modules/bar");
1150 std::fs::create_dir_all(&nm_pkg).unwrap();
1151 std::fs::write(nm_pkg.join("package.json"), r#"{"name":"bar"}"#).unwrap();
1152
1153 let ws_pkg = temp_dir.join("packages/foo");
1154 std::fs::write(ws_pkg.join("package.json"), r#"{"name":"foo"}"#).unwrap();
1155
1156 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1157 let results = expand_workspace_glob(&temp_dir, "packages/**", &canonical_root);
1158
1159 assert!(results.iter().any(|(_orig, canon)| {
1160 canon
1161 .to_string_lossy()
1162 .replace('\\', "/")
1163 .contains("packages/foo")
1164 && !canon.to_string_lossy().contains("node_modules")
1165 }));
1166 assert!(
1167 !results
1168 .iter()
1169 .any(|(_, cp)| cp.to_string_lossy().contains("node_modules"))
1170 );
1171
1172 let _ = std::fs::remove_dir_all(&temp_dir);
1173 }
1174
1175 #[test]
1176 fn expand_workspace_glob_skips_dirs_without_pkg() {
1177 let temp_dir = std::env::temp_dir().join("fallow-test-expand-no-pkg");
1178 let _ = std::fs::remove_dir_all(&temp_dir);
1179 std::fs::create_dir_all(temp_dir.join("packages/with-pkg")).unwrap();
1180 std::fs::create_dir_all(temp_dir.join("packages/without-pkg")).unwrap();
1181 std::fs::write(
1182 temp_dir.join("packages/with-pkg/package.json"),
1183 r#"{"name": "with"}"#,
1184 )
1185 .unwrap();
1186
1187 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1188 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1189 assert_eq!(results.len(), 1);
1190 assert!(
1191 results[0]
1192 .0
1193 .to_string_lossy()
1194 .replace('\\', "/")
1195 .ends_with("packages/with-pkg")
1196 );
1197
1198 let _ = std::fs::remove_dir_all(&temp_dir);
1199 }
1200
1201 #[test]
1202 fn expand_workspace_glob_recovers_nested_package_under_bare_intermediate() {
1203 let temp_dir = std::env::temp_dir().join("fallow-test-expand-nested-recover");
1208 let _ = std::fs::remove_dir_all(&temp_dir);
1209 std::fs::create_dir_all(temp_dir.join("packages/themes/my-theme")).unwrap();
1210 std::fs::create_dir_all(temp_dir.join("packages/themes/no-name")).unwrap();
1211 std::fs::create_dir_all(temp_dir.join("packages/themes/just-src")).unwrap();
1212 std::fs::write(
1213 temp_dir.join("packages/themes/my-theme/package.json"),
1214 r#"{"name": "my-theme", "dependencies": {"react": "^18"}}"#,
1215 )
1216 .unwrap();
1217 std::fs::write(
1219 temp_dir.join("packages/themes/no-name/package.json"),
1220 r#"{"private": true}"#,
1221 )
1222 .unwrap();
1223 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1226 let results = expand_workspace_glob(&temp_dir, "packages/*", &canonical_root);
1227
1228 let names: Vec<String> = results
1229 .iter()
1230 .map(|(p, _)| p.to_string_lossy().replace('\\', "/"))
1231 .collect();
1232 assert_eq!(
1233 results.len(),
1234 1,
1235 "only the named nested package should be recovered, got {names:?}"
1236 );
1237 assert!(
1238 names[0].ends_with("packages/themes/my-theme"),
1239 "recovered path should be the deep named package, got {names:?}"
1240 );
1241
1242 let _ = std::fs::remove_dir_all(&temp_dir);
1243 }
1244
1245 #[test]
1246 fn expand_workspace_glob_recovery_honors_ignore_patterns() {
1247 let temp_dir = std::env::temp_dir().join("fallow-test-recover-ignore");
1250 let _ = std::fs::remove_dir_all(&temp_dir);
1251 std::fs::create_dir_all(temp_dir.join("packages/themes/my-theme")).unwrap();
1252 std::fs::write(
1253 temp_dir.join("packages/themes/my-theme/package.json"),
1254 r#"{"name": "my-theme"}"#,
1255 )
1256 .unwrap();
1257
1258 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1259 let mut builder = globset::GlobSetBuilder::new();
1260 builder.add(globset::Glob::new("packages/themes/my-theme").unwrap());
1261 let ignore = builder.build().unwrap();
1262 let mut diagnostics = Vec::new();
1263 let results = expand_workspace_glob_with_diagnostics(
1264 &temp_dir,
1265 "packages/*",
1266 "packages/*",
1267 &canonical_root,
1268 &ignore,
1269 &mut diagnostics,
1270 );
1271 assert!(
1272 results.is_empty(),
1273 "an ignored nested package must not be recovered, got {results:?}"
1274 );
1275
1276 let _ = std::fs::remove_dir_all(&temp_dir);
1277 }
1278
1279 #[test]
1280 fn expand_recursive_glob_prunes_node_modules() {
1281 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-prune");
1282 let _ = std::fs::remove_dir_all(&temp_dir);
1283
1284 std::fs::create_dir_all(temp_dir.join("packages/app")).unwrap();
1285 std::fs::write(
1286 temp_dir.join("packages/app/package.json"),
1287 r#"{"name": "app"}"#,
1288 )
1289 .unwrap();
1290 std::fs::create_dir_all(temp_dir.join("packages/lib")).unwrap();
1291 std::fs::write(
1292 temp_dir.join("packages/lib/package.json"),
1293 r#"{"name": "lib"}"#,
1294 )
1295 .unwrap();
1296
1297 let nm_dep = temp_dir.join("packages/app/node_modules/dep");
1298 std::fs::create_dir_all(&nm_dep).unwrap();
1299 std::fs::write(nm_dep.join("package.json"), r#"{"name": "dep"}"#).unwrap();
1300
1301 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1302 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1303
1304 let found_names: Vec<String> = results
1305 .iter()
1306 .map(|(orig, _)| orig.file_name().unwrap().to_string_lossy().to_string())
1307 .collect();
1308 assert!(
1309 found_names.contains(&"app".to_string()),
1310 "should find packages/app"
1311 );
1312 assert!(
1313 found_names.contains(&"lib".to_string()),
1314 "should find packages/lib"
1315 );
1316 assert!(
1317 !results
1318 .iter()
1319 .any(|(_, cp)| cp.to_string_lossy().contains("node_modules")),
1320 "should NOT include packages inside node_modules"
1321 );
1322 assert_eq!(
1323 results.len(),
1324 2,
1325 "should find exactly 2 workspace packages (node_modules pruned)"
1326 );
1327
1328 let _ = std::fs::remove_dir_all(&temp_dir);
1329 }
1330
1331 #[test]
1332 fn expand_recursive_glob_preserves_nested_workspace_roots() {
1333 let temp_dir = std::env::temp_dir().join("fallow-test-expand-recursive-workspace-prune");
1334 let _ = std::fs::remove_dir_all(&temp_dir);
1335
1336 std::fs::create_dir_all(temp_dir.join("apps/app/packages/nested")).unwrap();
1337 std::fs::write(temp_dir.join("apps/app/package.json"), r#"{"name":"app"}"#).unwrap();
1338 std::fs::write(
1339 temp_dir.join("apps/app/packages/nested/package.json"),
1340 r#"{"name":"nested"}"#,
1341 )
1342 .unwrap();
1343
1344 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1345 let results = expand_workspace_glob(&temp_dir, "apps/**", &canonical_root);
1346 let mut paths: Vec<_> = results
1347 .iter()
1348 .map(|(path, _)| path.strip_prefix(&temp_dir).unwrap().to_path_buf())
1349 .collect();
1350 paths.sort();
1351
1352 assert_eq!(
1353 paths,
1354 vec![
1355 PathBuf::from("apps/app"),
1356 PathBuf::from("apps/app/packages/nested")
1357 ]
1358 );
1359
1360 let _ = std::fs::remove_dir_all(&temp_dir);
1361 }
1362
1363 #[test]
1364 fn expand_recursive_glob_prunes_deeply_nested_node_modules() {
1365 let temp_dir = std::env::temp_dir().join("fallow-test-expand-deep-prune");
1366 let _ = std::fs::remove_dir_all(&temp_dir);
1367
1368 std::fs::create_dir_all(temp_dir.join("packages/core")).unwrap();
1369 std::fs::write(
1370 temp_dir.join("packages/core/package.json"),
1371 r#"{"name": "core"}"#,
1372 )
1373 .unwrap();
1374
1375 let deep_nm = temp_dir.join("packages/core/node_modules/.pnpm/react@18/node_modules/react");
1376 std::fs::create_dir_all(&deep_nm).unwrap();
1377 std::fs::write(deep_nm.join("package.json"), r#"{"name": "react"}"#).unwrap();
1378
1379 let canonical_root = dunce::canonicalize(&temp_dir).unwrap();
1380 let results = expand_workspace_glob(&temp_dir, "packages/**/*", &canonical_root);
1381
1382 assert_eq!(
1383 results.len(),
1384 1,
1385 "should find exactly 1 workspace package, pruning deep node_modules"
1386 );
1387 assert!(
1388 results[0]
1389 .0
1390 .to_string_lossy()
1391 .replace('\\', "/")
1392 .ends_with("packages/core"),
1393 "the single result should be packages/core"
1394 );
1395
1396 let _ = std::fs::remove_dir_all(&temp_dir);
1397 }
1398}