1use std::ffi::OsStr;
4use std::path::{Path, PathBuf};
5use std::time::Instant;
6
7use fallow_config::{
8 PackageJson, ResolvedConfig, WorkspaceDiagnostic, WorkspaceInfo, discover_workspaces,
9 find_undeclared_workspaces_with_ignores,
10};
11pub use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource, FileId};
12use rustc_hash::FxHashSet;
13
14use crate::{EngineError, EngineResult, plugins::PluginRegistry};
15
16const UNDECLARED_WORKSPACE_WARNING_PREVIEW: usize = 5;
17
18pub const SOURCE_EXTENSIONS: &[&str] = &[
19 "ts", "tsx", "mts", "cts", "gts", "js", "jsx", "mjs", "cjs", "gjs", "vue", "svelte", "astro",
20 "mdx", "css", "scss", "sass", "less", "html", "graphql", "gql",
21];
22
23pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
25 "**/*.test.*",
26 "**/*.spec.*",
27 "**/*.e2e.*",
28 "**/*.e2e-spec.*",
29 "**/*.bench.*",
30 "**/*.fixture.*",
31 "**/*.stories.*",
32 "**/*.story.*",
33 "**/__tests__/**",
34 "**/__mocks__/**",
35 "**/__snapshots__/**",
36 "**/__fixtures__/**",
37 "**/test/**",
38 "**/tests/**",
39 "*.config.*",
40 "**/.*.js",
41 "**/.*.ts",
42 "**/.*.mjs",
43 "**/.*.cjs",
44];
45
46const ALLOWED_HIDDEN_DIRS: &[&str] = &[
47 ".storybook",
48 ".vitepress",
49 ".well-known",
50 ".changeset",
51 ".github",
52];
53
54const SCRIPT_SCOPE_DENYLIST: &[&str] = &[
55 ".git",
56 ".next",
57 ".nuxt",
58 ".output",
59 ".svelte-kit",
60 ".turbo",
61 ".nx",
62 ".cache",
63 ".parcel-cache",
64 ".vercel",
65 ".netlify",
66 ".yarn",
67 ".pnpm-store",
68 ".docusaurus",
69 ".vscode",
70 ".idea",
71 ".fallow",
72 ".husky",
73];
74
75const ENV_WRAPPERS: &[&str] = &["cross-env", "dotenv", "env"];
76const NODE_RUNNERS: &[&str] = &["node", "ts-node", "tsx", "babel-node", "bun"];
77const SCRIPT_MULTIPLEXERS: &[&str] = &[
78 "concurrently",
79 "npm-run-all",
80 "npm-run-all2",
81 "run-s",
82 "run-p",
83 "run-s2",
84 "run-p2",
85];
86const BUN_RUNTIME_FLAGS: &[&str] = &["--bun", "--watch", "--hot", "--smol", "--no-clear-screen"];
87
88#[must_use]
95pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
96 discover_workspaces(root)
97}
98
99pub fn discover_workspace_packages_with_diagnostics(
108 root: &Path,
109 ignore_patterns: &globset::GlobSet,
110) -> EngineResult<(Vec<WorkspaceInfo>, Vec<WorkspaceDiagnostic>)> {
111 fallow_config::discover_workspaces_with_diagnostics(root, ignore_patterns)
112 .map_err(|err| EngineError::new(err.to_string()))
113}
114
115#[derive(Debug, Clone, Default)]
117pub struct CategorizedEntryPoints {
118 pub all: Vec<EntryPoint>,
119 pub runtime: Vec<EntryPoint>,
120 pub test: Vec<EntryPoint>,
121}
122
123impl CategorizedEntryPoints {
124 pub fn push_runtime(&mut self, entry: EntryPoint) {
125 self.runtime.push(entry.clone());
126 self.all.push(entry);
127 }
128
129 pub fn push_test(&mut self, entry: EntryPoint) {
130 self.test.push(entry.clone());
131 self.all.push(entry);
132 }
133
134 pub fn push_support(&mut self, entry: EntryPoint) {
135 self.all.push(entry);
136 }
137
138 #[must_use]
139 pub fn dedup(mut self) -> Self {
140 dedup_entry_paths(&mut self.all);
141 dedup_entry_paths(&mut self.runtime);
142 dedup_entry_paths(&mut self.test);
143 self
144 }
145}
146
147fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
148 entries.sort_by(|a, b| a.path.cmp(&b.path));
149 entries.dedup_by(|a, b| a.path == b.path);
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
154pub struct HiddenDirScope {
155 root: PathBuf,
156 dirs: Vec<String>,
157}
158
159impl HiddenDirScope {
160 #[must_use]
161 pub const fn new(root: PathBuf, dirs: Vec<String>) -> Self {
162 Self { root, dirs }
163 }
164
165 #[must_use]
166 pub fn root(&self) -> &Path {
167 &self.root
168 }
169
170 #[must_use]
171 pub fn dirs(&self) -> &[String] {
172 &self.dirs
173 }
174}
175
176#[derive(Debug, Clone)]
178pub struct AnalysisDiscovery {
179 files: Vec<DiscoveredFile>,
180 workspaces: Vec<WorkspaceInfo>,
181 root_pkg: Option<PackageJson>,
182 config_candidates: Vec<PathBuf>,
183 discover_ms: f64,
184 workspaces_ms: f64,
185}
186
187impl AnalysisDiscovery {
188 fn from_parts(
189 files: Vec<DiscoveredFile>,
190 workspaces: Vec<WorkspaceInfo>,
191 root_pkg: Option<PackageJson>,
192 config_candidates: Vec<PathBuf>,
193 discover_ms: f64,
194 workspaces_ms: f64,
195 ) -> Self {
196 Self {
197 files,
198 workspaces,
199 root_pkg,
200 config_candidates,
201 discover_ms,
202 workspaces_ms,
203 }
204 }
205
206 #[must_use]
208 pub fn files(&self) -> &[DiscoveredFile] {
209 &self.files
210 }
211
212 #[must_use]
214 pub fn workspaces(&self) -> &[WorkspaceInfo] {
215 &self.workspaces
216 }
217
218 pub(crate) fn root_pkg(&self) -> Option<&PackageJson> {
219 self.root_pkg.as_ref()
220 }
221
222 pub(crate) fn config_candidates(&self) -> &[PathBuf] {
223 &self.config_candidates
224 }
225
226 pub(crate) fn discover_ms(&self) -> f64 {
227 self.discover_ms
228 }
229
230 pub(crate) fn workspaces_ms(&self) -> f64 {
231 self.workspaces_ms
232 }
233
234 #[must_use]
236 pub fn into_files(self) -> Vec<DiscoveredFile> {
237 self.files
238 }
239}
240
241#[must_use]
243pub fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
244 warn_missing_node_modules(config);
245
246 let workspaces_start = Instant::now();
247 let workspaces = discover_workspaces(&config.root);
248 let workspaces_ms = workspaces_start.elapsed().as_secs_f64() * 1000.0;
249 if !workspaces.is_empty() {
250 tracing::info!(count = workspaces.len(), "workspaces discovered");
251 }
252 warn_undeclared_workspaces(
253 &config.root,
254 &workspaces,
255 &config.ignore_patterns,
256 config.quiet,
257 );
258
259 let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
260 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
261
262 let discover_start = Instant::now();
263 let (files, config_candidates) =
264 discover_files_and_config_candidates(config, &hidden_dir_scopes);
265 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
266
267 AnalysisDiscovery::from_parts(
268 files,
269 workspaces,
270 root_pkg,
271 config_candidates,
272 discover_ms,
273 workspaces_ms,
274 )
275}
276
277#[must_use]
284pub fn prepare_analysis_discovery_with_workspaces(
285 config: &ResolvedConfig,
286 workspaces: &[WorkspaceInfo],
287 workspaces_ms: f64,
288) -> AnalysisDiscovery {
289 warn_missing_node_modules(config);
290
291 if !workspaces.is_empty() {
292 tracing::info!(count = workspaces.len(), "workspaces discovered");
293 }
294
295 let root_pkg = PackageJson::load(&config.root.join("package.json")).ok();
296 let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), workspaces);
297
298 let discover_start = Instant::now();
299 let (files, config_candidates) =
300 discover_files_and_config_candidates(config, &hidden_dir_scopes);
301 let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
302
303 AnalysisDiscovery::from_parts(
304 files,
305 workspaces.to_vec(),
306 root_pkg,
307 config_candidates,
308 discover_ms,
309 workspaces_ms,
310 )
311}
312
313fn warn_missing_node_modules(config: &ResolvedConfig) {
314 if config.root.join("node_modules").is_dir() {
315 return;
316 }
317
318 tracing::warn!(
319 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
320 );
321}
322
323fn format_undeclared_workspace_warning(
324 root: &Path,
325 undeclared: &[WorkspaceDiagnostic],
326) -> Option<String> {
327 if undeclared.is_empty() {
328 return None;
329 }
330
331 let preview = undeclared
332 .iter()
333 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
334 .map(|diagnostic| {
335 diagnostic
336 .path
337 .strip_prefix(root)
338 .unwrap_or(&diagnostic.path)
339 .display()
340 .to_string()
341 .replace('\\', "/")
342 })
343 .collect::<Vec<_>>();
344 let remaining = undeclared
345 .len()
346 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
347 let tail = if remaining > 0 {
348 format!(" (and {remaining} more)")
349 } else {
350 String::new()
351 };
352 let noun = if undeclared.len() == 1 {
353 "directory with package.json is"
354 } else {
355 "directories with package.json are"
356 };
357 let guidance = if undeclared.len() == 1 {
358 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
359 } else {
360 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
361 };
362
363 Some(format!(
364 "{} {} not declared as {}: {}{}. {}",
365 undeclared.len(),
366 noun,
367 if undeclared.len() == 1 {
368 "a workspace"
369 } else {
370 "workspaces"
371 },
372 preview.join(", "),
373 tail,
374 guidance
375 ))
376}
377
378fn warn_undeclared_workspaces(
379 root: &Path,
380 workspaces: &[WorkspaceInfo],
381 ignore_patterns: &globset::GlobSet,
382 quiet: bool,
383) {
384 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
385 if undeclared.is_empty() {
386 return;
387 }
388
389 let existing = fallow_config::workspace_diagnostics_for(root);
390 let already_flagged: FxHashSet<PathBuf> = existing
391 .iter()
392 .map(|diagnostic| {
393 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
394 })
395 .collect();
396 let undeclared: Vec<_> = undeclared
397 .into_iter()
398 .filter(|diagnostic| {
399 let canonical =
400 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
401 !already_flagged.contains(&canonical)
402 })
403 .collect();
404 if undeclared.is_empty() {
405 return;
406 }
407
408 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
409
410 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
411 tracing::warn!("{message}");
412 }
413}
414
415#[must_use]
417pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
418 ALLOWED_HIDDEN_DIRS
419 .iter()
420 .any(|&dir| OsStr::new(dir) == name)
421}
422
423#[must_use]
425pub fn collect_plugin_hidden_dir_scopes(
426 config: &ResolvedConfig,
427 root_pkg: Option<&PackageJson>,
428 workspaces: &[WorkspaceInfo],
429) -> Vec<HiddenDirScope> {
430 let registry = PluginRegistry::new(config.external_plugins.clone());
431 let mut scopes = Vec::new();
432
433 if let Some(pkg) = root_pkg {
434 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
435 }
436
437 for ws in workspaces {
438 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
439 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
440 }
441 }
442
443 scopes
444}
445
446fn push_plugin_hidden_dir_scope(
447 scopes: &mut Vec<HiddenDirScope>,
448 registry: &PluginRegistry,
449 pkg: &PackageJson,
450 root: &Path,
451) {
452 let dirs = registry.discovery_hidden_dirs(pkg, root);
453 if !dirs.is_empty() {
454 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
455 }
456}
457
458#[must_use]
460pub fn collect_hidden_dir_scopes(
461 config: &ResolvedConfig,
462 root_pkg: Option<&PackageJson>,
463 workspaces: &[WorkspaceInfo],
464) -> Vec<HiddenDirScope> {
465 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
466 let registry = PluginRegistry::new(config.external_plugins.clone());
467 let mut scopes = Vec::new();
468
469 if let Some(pkg) = root_pkg {
470 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
471 push_script_hidden_dir_scope(&mut scopes, pkg, &config.root);
472 }
473
474 for ws in workspaces {
475 if let Ok(pkg) = PackageJson::load(&ws.root.join("package.json")) {
476 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
477 push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
478 }
479 }
480
481 scopes
482}
483
484fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
485 if let Some(scope) = build_script_scope(pkg, root) {
486 scopes.push(scope);
487 }
488}
489
490fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
491 let scripts = pkg.scripts.as_ref()?;
492 let mut seen = FxHashSet::default();
493 let mut dirs: Vec<String> = Vec::new();
494
495 for (script_name, script_value) in scripts {
496 for cmd in parse_script_value(script_value) {
497 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
498 for hidden in extract_hidden_segments(path) {
499 if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
500 continue;
501 }
502 if seen.insert(hidden.clone()) {
503 tracing::debug!(
504 dir = %hidden,
505 script = %script_name,
506 package_root = %root.display(),
507 "inferred hidden_dir_scope from package.json#scripts"
508 );
509 dirs.push(hidden);
510 }
511 }
512 }
513 }
514 }
515
516 if dirs.is_empty() {
517 None
518 } else {
519 Some(HiddenDirScope::new(root.to_path_buf(), dirs))
520 }
521}
522
523#[derive(Debug, PartialEq, Eq)]
524struct ScriptCommand {
525 config_args: Vec<String>,
526 file_args: Vec<String>,
527}
528
529fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
530 let mut commands = Vec::new();
531
532 for segment in split_shell_operators(script) {
533 let segment = segment.trim();
534 if segment.is_empty() {
535 continue;
536 }
537 if let Some(cmd) = parse_command_segment(segment) {
538 commands.push(cmd);
539 }
540 }
541
542 commands
543}
544
545fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
546 let tokens: Vec<&str> = segment
547 .split_whitespace()
548 .map(strip_surrounding_quotes)
549 .collect();
550 if tokens.is_empty() {
551 return None;
552 }
553
554 let idx = skip_initial_wrappers(&tokens, 0)?;
555 let idx = advance_past_package_manager(&tokens, idx)?;
556 let binary = tokens[idx];
557
558 if SCRIPT_MULTIPLEXERS.contains(&binary) {
559 return Some(ScriptCommand {
560 config_args: Vec::new(),
561 file_args: Vec::new(),
562 });
563 }
564
565 let is_node_runner = NODE_RUNNERS.contains(&binary);
566 let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
567
568 Some(ScriptCommand {
569 config_args,
570 file_args,
571 })
572}
573
574fn split_shell_operators(script: &str) -> Vec<&str> {
575 let mut segments = Vec::new();
576 let mut start = 0;
577 let bytes = script.as_bytes();
578 let len = bytes.len();
579 let mut index = 0;
580 let mut in_single_quote = false;
581 let mut in_double_quote = false;
582
583 while index < len {
584 let byte = bytes[index];
585
586 if byte == b'\'' && !in_double_quote {
587 in_single_quote = !in_single_quote;
588 index += 1;
589 continue;
590 }
591 if byte == b'"' && !in_single_quote {
592 in_double_quote = !in_double_quote;
593 index += 1;
594 continue;
595 }
596
597 if in_single_quote || in_double_quote {
598 index += 1;
599 continue;
600 }
601
602 if let Some(op_len) = shell_operator_len(bytes, index) {
603 segments.push(&script[start..index]);
604 index += op_len;
605 start = index;
606 continue;
607 }
608
609 index += 1;
610 }
611
612 if start < len {
613 segments.push(&script[start..]);
614 }
615
616 segments
617}
618
619fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
620 let byte = bytes[index];
621 let next = bytes.get(index + 1).copied();
622
623 if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
624 return Some(2);
625 }
626
627 if byte == b';' {
628 return Some(1);
629 }
630 if byte == b'|' && next != Some(b'|') {
631 return Some(1);
632 }
633 if byte == b'&' && next != Some(b'&') {
634 return Some(1);
635 }
636
637 None
638}
639
640fn strip_surrounding_quotes(token: &str) -> &str {
641 if token.len() >= 2 {
642 let first = token.as_bytes()[0];
643 let last = token.as_bytes()[token.len() - 1];
644 if (first == b'\'' || first == b'"') && first == last {
645 return &token[1..token.len() - 1];
646 }
647 }
648 token
649}
650
651fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
652 while index < tokens.len() && is_env_assignment(tokens[index]) {
653 index += 1;
654 }
655 if index >= tokens.len() {
656 return None;
657 }
658
659 while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
660 index += 1;
661 while index < tokens.len() && is_env_assignment(tokens[index]) {
662 index += 1;
663 }
664 if index < tokens.len() && tokens[index] == "--" {
665 index += 1;
666 }
667 }
668 if index >= tokens.len() {
669 return None;
670 }
671
672 Some(index)
673}
674
675fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
676 let token = tokens[index];
677 if matches!(token, "npx" | "pnpx" | "bunx") {
678 index += 1;
679 while index < tokens.len() && tokens[index].starts_with('-') {
680 let flag = tokens[index];
681 index += 1;
682 if matches!(flag, "--package" | "-p") && index < tokens.len() {
683 index += 1;
684 }
685 }
686 } else if token == "bun" {
687 index += 1;
688 let mut saw_runtime_flag = false;
689 while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
690 index += 1;
691 saw_runtime_flag = true;
692 }
693 if index >= tokens.len() {
694 return None;
695 }
696 let subcmd = tokens[index];
697 if subcmd == "exec" || subcmd == "x" {
698 index += 1;
699 } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
700 return None;
701 }
702 } else if matches!(token, "yarn" | "pnpm" | "npm") {
703 if index + 1 < tokens.len() {
704 let subcmd = tokens[index + 1];
705 if subcmd == "exec" || subcmd == "dlx" {
706 index += 2;
707 } else {
708 return None;
709 }
710 } else {
711 return None;
712 }
713 }
714 if index >= tokens.len() {
715 return None;
716 }
717
718 Some(index)
719}
720
721fn extract_args_for_binary(
722 tokens: &[&str],
723 mut index: usize,
724 is_node_runner: bool,
725) -> (Vec<String>, Vec<String>) {
726 let mut file_args = Vec::new();
727 let mut config_args = Vec::new();
728
729 while index < tokens.len() {
730 let token = tokens[index];
731
732 if is_node_runner
733 && matches!(
734 token,
735 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
736 )
737 {
738 index += 2;
739 continue;
740 }
741
742 if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
743 config_args.push(config);
744 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
745 index += 1;
746 } else {
747 index += 2;
748 }
749 continue;
750 }
751
752 if token.starts_with('-') {
753 index += 1;
754 continue;
755 }
756
757 if looks_like_file_path(token) {
758 file_args.push(token.to_string());
759 }
760 index += 1;
761 }
762
763 (file_args, config_args)
764}
765
766fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
767 if let Some(value) = token.strip_prefix("--config=")
768 && !value.is_empty()
769 {
770 return Some(value.to_string());
771 }
772 if let Some(value) = token.strip_prefix("-c=")
773 && !value.is_empty()
774 {
775 return Some(value.to_string());
776 }
777 if matches!(token, "--config" | "-c")
778 && let Some(next_token) = next
779 && !next_token.starts_with('-')
780 {
781 return Some(next_token.to_string());
782 }
783 None
784}
785
786fn is_env_assignment(token: &str) -> bool {
787 token.find('=').is_some_and(|eq_pos| {
788 let name = &token[..eq_pos];
789 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
790 })
791}
792
793fn looks_like_file_path(token: &str) -> bool {
794 if !could_be_file_path(token) {
795 return false;
796 }
797
798 const EXTENSIONS: &[&str] = &[
799 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
800 ".toml",
801 ];
802 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
803 return true;
804 }
805 token.starts_with("./")
806 || token.starts_with("../")
807 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
808}
809
810fn could_be_file_path(token: &str) -> bool {
811 if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
812 return false;
813 }
814
815 if token.contains('\\') {
816 return false;
817 }
818
819 if let Some(open) = token.find('[') {
820 let after_open = &token[open + 1..];
821 let close_offset = after_open.find(']');
822 if !matches!(close_offset, Some(offset) if offset > 0) {
823 return false;
824 }
825 }
826
827 true
828}
829
830fn extract_hidden_segments(path: &str) -> Vec<String> {
831 let path = Path::new(path);
832 if path.is_absolute() {
833 return Vec::new();
834 }
835
836 let mut hidden = Vec::new();
837 let components = path.components().collect::<Vec<_>>();
838 if components.iter().any(|component| {
839 matches!(
840 component,
841 std::path::Component::ParentDir | std::path::Component::RootDir
842 )
843 }) {
844 return Vec::new();
845 }
846
847 for (index, component) in components.iter().enumerate() {
848 let std::path::Component::Normal(value) = component else {
849 continue;
850 };
851 let value = value.to_string_lossy();
852 if !value.starts_with('.') || value == "." || value == ".." {
853 continue;
854 }
855 if index == components.len().saturating_sub(1) {
856 continue;
857 }
858 hidden.push(value.to_string());
859 }
860
861 hidden
862}
863
864#[must_use]
866pub fn discover_files_and_config_candidates(
867 config: &ResolvedConfig,
868 additional_hidden_dir_scopes: &[HiddenDirScope],
869) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
870 let scopes = additional_hidden_dir_scopes
871 .iter()
872 .map(|scope| {
873 crate::discover_walk::HiddenDirScope::new(
874 scope.root().to_path_buf(),
875 scope.dirs().to_vec(),
876 )
877 })
878 .collect::<Vec<_>>();
879 crate::discover_walk::discover_files_and_config_candidates(config, &scopes)
880}
881
882#[must_use]
884pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
885 crate::entry_points::discover_entry_points(config, files)
886}
887
888#[must_use]
890pub fn discover_workspace_entry_points(
891 ws_root: &Path,
892 config: &ResolvedConfig,
893 all_files: &[DiscoveredFile],
894) -> Vec<EntryPoint> {
895 crate::entry_points::discover_workspace_entry_points(ws_root, config, all_files)
896}
897
898#[must_use]
900pub fn discover_plugin_entry_points(
901 plugin_result: &crate::plugins::AggregatedPluginResult,
902 config: &ResolvedConfig,
903 files: &[DiscoveredFile],
904) -> Vec<EntryPoint> {
905 crate::entry_points::discover_plugin_entry_points(plugin_result, config, files)
906}
907
908#[cfg(test)]
909mod tests {
910 use std::path::PathBuf;
911
912 use fallow_config::PackageJson;
913
914 use super::{
915 ALLOWED_HIDDEN_DIRS, CategorizedEntryPoints, EntryPoint, EntryPointSource, HiddenDirScope,
916 collect_hidden_dir_scopes, collect_plugin_hidden_dir_scopes, extract_hidden_segments,
917 is_allowed_hidden_dir,
918 };
919
920 #[test]
921 fn hidden_dir_scope_exposes_root_and_dirs() {
922 let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
923
924 assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
925 assert_eq!(scope.dirs(), [".next"]);
926 }
927
928 #[test]
929 fn hidden_dir_allowlist_is_engine_owned() {
930 for dir in ALLOWED_HIDDEN_DIRS {
931 assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
932 }
933 assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
934 }
935
936 #[test]
937 fn plugin_hidden_dir_scopes_are_engine_owned() {
938 let dir = tempfile::tempdir().expect("tempdir");
939 let config = fallow_config::FallowConfig::default().resolve(
940 dir.path().to_path_buf(),
941 fallow_config::OutputFormat::Human,
942 1,
943 true,
944 true,
945 None,
946 );
947 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
948 "devDependencies": {
949 "@react-router/dev": "^7.0.0"
950 }
951 }))
952 .expect("valid package fixture");
953
954 let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
955
956 assert_eq!(scopes.len(), 1);
957 assert_eq!(scopes[0].root(), dir.path());
958 assert_eq!(scopes[0].dirs(), [".client", ".server"]);
959 }
960
961 #[test]
962 fn script_hidden_dir_scopes_are_engine_owned() {
963 let dir = tempfile::tempdir().expect("tempdir");
964 let config = fallow_config::FallowConfig::default().resolve(
965 dir.path().to_path_buf(),
966 fallow_config::OutputFormat::Human,
967 1,
968 true,
969 true,
970 None,
971 );
972 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
973 "scripts": {
974 "lint": "eslint -c .config/eslint.config.js",
975 "build": "tsx ./.scripts/build.ts",
976 "cache": "tsx .nx/cache/build.ts"
977 }
978 }))
979 .expect("valid package fixture");
980
981 let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
982
983 assert_eq!(scopes.len(), 1);
984 assert_eq!(scopes[0].root(), dir.path());
985 let mut dirs = scopes[0].dirs().to_vec();
986 dirs.sort();
987 assert_eq!(dirs, [".config", ".scripts"]);
988 }
989
990 #[test]
991 fn hidden_segment_extraction_rejects_escape_paths() {
992 assert_eq!(
993 extract_hidden_segments(".foo/.bar/x.js"),
994 vec![".foo".to_string(), ".bar".to_string()]
995 );
996 assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
997 assert!(extract_hidden_segments(".env").is_empty());
998 }
999
1000 #[test]
1001 fn categorized_entry_points_dedups_each_bucket() {
1002 let entry = EntryPoint {
1003 path: PathBuf::from("/repo/src/index.ts"),
1004 source: EntryPointSource::DefaultIndex,
1005 };
1006 let engine = CategorizedEntryPoints {
1007 all: vec![entry.clone(), entry.clone()],
1008 runtime: vec![entry.clone(), entry.clone()],
1009 test: Vec::new(),
1010 }
1011 .dedup();
1012
1013 assert_eq!(engine.all.len(), 1);
1014 assert_eq!(engine.runtime.len(), 1);
1015 assert_eq!(engine.test.len(), 0);
1016 assert_eq!(engine.all[0].path, entry.path);
1017 }
1018}