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(crate) all: Vec<EntryPoint>,
119 runtime: Vec<EntryPoint>,
120 test: Vec<EntryPoint>,
121}
122
123impl CategorizedEntryPoints {
124 pub(crate) fn push_runtime(&mut self, entry: EntryPoint) {
125 self.runtime.push(entry.clone());
126 self.all.push(entry);
127 }
128
129 pub(crate) fn push_test(&mut self, entry: EntryPoint) {
130 self.test.push(entry.clone());
131 self.all.push(entry);
132 }
133
134 pub(crate) fn push_support(&mut self, entry: EntryPoint) {
135 self.all.push(entry);
136 }
137
138 #[must_use]
139 pub(crate) 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 const fn new(root: PathBuf, dirs: Vec<String>) -> Self {
162 Self { root, dirs }
163 }
164
165 #[must_use]
166 fn root(&self) -> &Path {
167 &self.root
168 }
169
170 #[must_use]
171 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(crate) fn files(&self) -> &[DiscoveredFile] {
209 &self.files
210 }
211
212 #[must_use]
214 pub(crate) 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(crate) 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 = fallow_config::load_dir_package_json(&config.root);
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(crate) 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 = fallow_config::load_dir_package_json(&config.root);
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 if fallow_config::is_deno_without_node_modules(&config.root) {
318 return;
319 }
320
321 tracing::warn!(
322 "node_modules directory not found. Run `npm install` / `pnpm install` first for accurate results."
323 );
324}
325
326fn format_undeclared_workspace_warning(
327 root: &Path,
328 undeclared: &[WorkspaceDiagnostic],
329) -> Option<String> {
330 if undeclared.is_empty() {
331 return None;
332 }
333
334 let preview = undeclared
335 .iter()
336 .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
337 .map(|diagnostic| {
338 diagnostic
339 .path
340 .strip_prefix(root)
341 .unwrap_or(&diagnostic.path)
342 .display()
343 .to_string()
344 .replace('\\', "/")
345 })
346 .collect::<Vec<_>>();
347 let remaining = undeclared
348 .len()
349 .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
350 let tail = if remaining > 0 {
351 format!(" (and {remaining} more)")
352 } else {
353 String::new()
354 };
355 let noun = if undeclared.len() == 1 {
356 "directory with package.json is"
357 } else {
358 "directories with package.json are"
359 };
360 let guidance = if undeclared.len() == 1 {
361 "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
362 } else {
363 "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
364 };
365
366 Some(format!(
367 "{} {} not declared as {}: {}{}. {}",
368 undeclared.len(),
369 noun,
370 if undeclared.len() == 1 {
371 "a workspace"
372 } else {
373 "workspaces"
374 },
375 preview.join(", "),
376 tail,
377 guidance
378 ))
379}
380
381fn warn_undeclared_workspaces(
382 root: &Path,
383 workspaces: &[WorkspaceInfo],
384 ignore_patterns: &globset::GlobSet,
385 quiet: bool,
386) {
387 let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
388 if undeclared.is_empty() {
389 return;
390 }
391
392 let existing = fallow_config::workspace_diagnostics_for(root);
393 let already_flagged: FxHashSet<PathBuf> = existing
394 .iter()
395 .map(|diagnostic| {
396 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
397 })
398 .collect();
399 let undeclared: Vec<_> = undeclared
400 .into_iter()
401 .filter(|diagnostic| {
402 let canonical =
403 dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
404 !already_flagged.contains(&canonical)
405 })
406 .collect();
407 if undeclared.is_empty() {
408 return;
409 }
410
411 fallow_config::append_workspace_diagnostics(root, undeclared.clone());
412
413 if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
414 tracing::warn!("{message}");
415 }
416}
417
418#[must_use]
420pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
421 ALLOWED_HIDDEN_DIRS
422 .iter()
423 .any(|&dir| OsStr::new(dir) == name)
424}
425
426#[must_use]
428pub fn collect_plugin_hidden_dir_scopes(
429 config: &ResolvedConfig,
430 root_pkg: Option<&PackageJson>,
431 workspaces: &[WorkspaceInfo],
432) -> Vec<HiddenDirScope> {
433 let registry = PluginRegistry::new(config.external_plugins.clone());
434 let mut scopes = Vec::new();
435
436 if let Some(pkg) = root_pkg {
437 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
438 }
439
440 for ws in workspaces {
441 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
442 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
443 }
444 }
445
446 scopes
447}
448
449fn push_plugin_hidden_dir_scope(
450 scopes: &mut Vec<HiddenDirScope>,
451 registry: &PluginRegistry,
452 pkg: &PackageJson,
453 root: &Path,
454) {
455 let dirs = registry.discovery_hidden_dirs(pkg, root);
456 if !dirs.is_empty() {
457 scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
458 }
459}
460
461#[must_use]
463pub(crate) fn collect_hidden_dir_scopes(
464 config: &ResolvedConfig,
465 root_pkg: Option<&PackageJson>,
466 workspaces: &[WorkspaceInfo],
467) -> Vec<HiddenDirScope> {
468 let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
469 let registry = PluginRegistry::new(config.external_plugins.clone());
470 let mut scopes = Vec::new();
471
472 if let Some(pkg) = root_pkg {
473 push_plugin_hidden_dir_scope(&mut scopes, ®istry, pkg, &config.root);
474 push_script_hidden_dir_scope(&mut scopes, pkg, &config.root);
475 }
476
477 for ws in workspaces {
478 if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
479 push_plugin_hidden_dir_scope(&mut scopes, ®istry, &pkg, &ws.root);
480 push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
481 }
482 }
483
484 scopes
485}
486
487fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
488 if let Some(scope) = build_script_scope(pkg, root) {
489 scopes.push(scope);
490 }
491}
492
493fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
494 let scripts = pkg.scripts.as_ref()?;
495 let mut seen = FxHashSet::default();
496 let mut dirs: Vec<String> = Vec::new();
497
498 for (script_name, script_value) in scripts {
499 for cmd in parse_script_value(script_value) {
500 for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
501 for hidden in extract_hidden_segments(path) {
502 if SCRIPT_SCOPE_DENYLIST.contains(&hidden.as_str()) {
503 continue;
504 }
505 if seen.insert(hidden.clone()) {
506 tracing::debug!(
507 dir = %hidden,
508 script = %script_name,
509 package_root = %root.display(),
510 "inferred hidden_dir_scope from package.json#scripts"
511 );
512 dirs.push(hidden);
513 }
514 }
515 }
516 }
517 }
518
519 if dirs.is_empty() {
520 None
521 } else {
522 Some(HiddenDirScope::new(root.to_path_buf(), dirs))
523 }
524}
525
526#[derive(Debug, PartialEq, Eq)]
527struct ScriptCommand {
528 config_args: Vec<String>,
529 file_args: Vec<String>,
530}
531
532fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
533 let mut commands = Vec::new();
534
535 for segment in split_shell_operators(script) {
536 let segment = segment.trim();
537 if segment.is_empty() {
538 continue;
539 }
540 if let Some(cmd) = parse_command_segment(segment) {
541 commands.push(cmd);
542 }
543 }
544
545 commands
546}
547
548fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
549 let tokens: Vec<&str> = segment
550 .split_whitespace()
551 .map(strip_surrounding_quotes)
552 .collect();
553 if tokens.is_empty() {
554 return None;
555 }
556
557 let idx = skip_initial_wrappers(&tokens, 0)?;
558 let idx = advance_past_package_manager(&tokens, idx)?;
559 let binary = tokens[idx];
560
561 if SCRIPT_MULTIPLEXERS.contains(&binary) {
562 return Some(ScriptCommand {
563 config_args: Vec::new(),
564 file_args: Vec::new(),
565 });
566 }
567
568 let is_node_runner = NODE_RUNNERS.contains(&binary);
569 let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
570
571 Some(ScriptCommand {
572 config_args,
573 file_args,
574 })
575}
576
577fn split_shell_operators(script: &str) -> Vec<&str> {
578 let mut segments = Vec::new();
579 let mut start = 0;
580 let bytes = script.as_bytes();
581 let len = bytes.len();
582 let mut index = 0;
583 let mut in_single_quote = false;
584 let mut in_double_quote = false;
585
586 while index < len {
587 let byte = bytes[index];
588
589 if byte == b'\'' && !in_double_quote {
590 in_single_quote = !in_single_quote;
591 index += 1;
592 continue;
593 }
594 if byte == b'"' && !in_single_quote {
595 in_double_quote = !in_double_quote;
596 index += 1;
597 continue;
598 }
599
600 if in_single_quote || in_double_quote {
601 index += 1;
602 continue;
603 }
604
605 if let Some(op_len) = shell_operator_len(bytes, index) {
606 segments.push(&script[start..index]);
607 index += op_len;
608 start = index;
609 continue;
610 }
611
612 index += 1;
613 }
614
615 if start < len {
616 segments.push(&script[start..]);
617 }
618
619 segments
620}
621
622fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
623 let byte = bytes[index];
624 let next = bytes.get(index + 1).copied();
625
626 if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
627 return Some(2);
628 }
629
630 if byte == b';' {
631 return Some(1);
632 }
633 if byte == b'|' && next != Some(b'|') {
634 return Some(1);
635 }
636 if byte == b'&' && next != Some(b'&') {
637 return Some(1);
638 }
639
640 None
641}
642
643fn strip_surrounding_quotes(token: &str) -> &str {
644 if token.len() >= 2 {
645 let first = token.as_bytes()[0];
646 let last = token.as_bytes()[token.len() - 1];
647 if (first == b'\'' || first == b'"') && first == last {
648 return &token[1..token.len() - 1];
649 }
650 }
651 token
652}
653
654fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
655 while index < tokens.len() && is_env_assignment(tokens[index]) {
656 index += 1;
657 }
658 if index >= tokens.len() {
659 return None;
660 }
661
662 while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
663 index += 1;
664 while index < tokens.len() && is_env_assignment(tokens[index]) {
665 index += 1;
666 }
667 if index < tokens.len() && tokens[index] == "--" {
668 index += 1;
669 }
670 }
671 if index >= tokens.len() {
672 return None;
673 }
674
675 Some(index)
676}
677
678fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
679 let token = tokens[index];
680 if matches!(token, "npx" | "pnpx" | "bunx") {
681 index += 1;
682 while index < tokens.len() && tokens[index].starts_with('-') {
683 let flag = tokens[index];
684 index += 1;
685 if matches!(flag, "--package" | "-p") && index < tokens.len() {
686 index += 1;
687 }
688 }
689 } else if token == "bun" {
690 index += 1;
691 let mut saw_runtime_flag = false;
692 while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
693 index += 1;
694 saw_runtime_flag = true;
695 }
696 if index >= tokens.len() {
697 return None;
698 }
699 let subcmd = tokens[index];
700 if subcmd == "exec" || subcmd == "x" {
701 index += 1;
702 } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
703 return None;
704 }
705 } else if matches!(token, "yarn" | "pnpm" | "npm") {
706 if index + 1 < tokens.len() {
707 let subcmd = tokens[index + 1];
708 if subcmd == "exec" || subcmd == "dlx" {
709 index += 2;
710 } else {
711 return None;
712 }
713 } else {
714 return None;
715 }
716 }
717 if index >= tokens.len() {
718 return None;
719 }
720
721 Some(index)
722}
723
724fn extract_args_for_binary(
725 tokens: &[&str],
726 mut index: usize,
727 is_node_runner: bool,
728) -> (Vec<String>, Vec<String>) {
729 let mut file_args = Vec::new();
730 let mut config_args = Vec::new();
731
732 while index < tokens.len() {
733 let token = tokens[index];
734
735 if is_node_runner
736 && matches!(
737 token,
738 "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
739 )
740 {
741 index += 2;
742 continue;
743 }
744
745 if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
746 config_args.push(config);
747 if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
748 index += 1;
749 } else {
750 index += 2;
751 }
752 continue;
753 }
754
755 if token.starts_with('-') {
756 index += 1;
757 continue;
758 }
759
760 if looks_like_file_path(token) {
761 file_args.push(token.to_string());
762 }
763 index += 1;
764 }
765
766 (file_args, config_args)
767}
768
769fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
770 if let Some(value) = token.strip_prefix("--config=")
771 && !value.is_empty()
772 {
773 return Some(value.to_string());
774 }
775 if let Some(value) = token.strip_prefix("-c=")
776 && !value.is_empty()
777 {
778 return Some(value.to_string());
779 }
780 if matches!(token, "--config" | "-c")
781 && let Some(next_token) = next
782 && !next_token.starts_with('-')
783 {
784 return Some(next_token.to_string());
785 }
786 None
787}
788
789fn is_env_assignment(token: &str) -> bool {
790 token.find('=').is_some_and(|eq_pos| {
791 let name = &token[..eq_pos];
792 !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
793 })
794}
795
796fn looks_like_file_path(token: &str) -> bool {
797 if !could_be_file_path(token) {
798 return false;
799 }
800
801 const EXTENSIONS: &[&str] = &[
802 ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
803 ".toml",
804 ];
805 if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
806 return true;
807 }
808 token.starts_with("./")
809 || token.starts_with("../")
810 || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
811}
812
813fn could_be_file_path(token: &str) -> bool {
814 if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
815 return false;
816 }
817
818 if token.contains('\\') {
819 return false;
820 }
821
822 if let Some(open) = token.find('[') {
823 let after_open = &token[open + 1..];
824 let close_offset = after_open.find(']');
825 if !matches!(close_offset, Some(offset) if offset > 0) {
826 return false;
827 }
828 }
829
830 true
831}
832
833fn extract_hidden_segments(path: &str) -> Vec<String> {
834 let path = Path::new(path);
835 if path.is_absolute() {
836 return Vec::new();
837 }
838
839 let mut hidden = Vec::new();
840 let components = path.components().collect::<Vec<_>>();
841 if components.iter().any(|component| {
842 matches!(
843 component,
844 std::path::Component::ParentDir | std::path::Component::RootDir
845 )
846 }) {
847 return Vec::new();
848 }
849
850 for (index, component) in components.iter().enumerate() {
851 let std::path::Component::Normal(value) = component else {
852 continue;
853 };
854 let value = value.to_string_lossy();
855 if !value.starts_with('.') || value == "." || value == ".." {
856 continue;
857 }
858 if index == components.len().saturating_sub(1) {
859 continue;
860 }
861 hidden.push(value.to_string());
862 }
863
864 hidden
865}
866
867#[must_use]
869pub fn discover_files_and_config_candidates(
870 config: &ResolvedConfig,
871 additional_hidden_dir_scopes: &[HiddenDirScope],
872) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
873 let scopes = additional_hidden_dir_scopes
874 .iter()
875 .map(|scope| {
876 crate::discover_walk::HiddenDirScope::new(
877 scope.root().to_path_buf(),
878 scope.dirs().to_vec(),
879 )
880 })
881 .collect::<Vec<_>>();
882 crate::discover_walk::discover_files_and_config_candidates(config, &scopes)
883}
884
885#[must_use]
887pub(crate) fn discover_entry_points(
888 config: &ResolvedConfig,
889 files: &[DiscoveredFile],
890) -> Vec<EntryPoint> {
891 crate::entry_points::discover_entry_points(config, files)
892}
893
894#[must_use]
896pub(crate) fn discover_workspace_entry_points(
897 ws_root: &Path,
898 config: &ResolvedConfig,
899 all_files: &[DiscoveredFile],
900) -> Vec<EntryPoint> {
901 crate::entry_points::discover_workspace_entry_points(ws_root, config, all_files)
902}
903
904#[must_use]
906pub(crate) fn discover_plugin_entry_points(
907 plugin_result: &crate::plugins::AggregatedPluginResult,
908 config: &ResolvedConfig,
909 files: &[DiscoveredFile],
910) -> Vec<EntryPoint> {
911 crate::entry_points::discover_plugin_entry_points(plugin_result, config, files)
912}
913
914#[cfg(test)]
915mod tests {
916 use std::path::PathBuf;
917
918 use fallow_config::PackageJson;
919
920 use super::{
921 ALLOWED_HIDDEN_DIRS, CategorizedEntryPoints, EntryPoint, EntryPointSource, HiddenDirScope,
922 collect_hidden_dir_scopes, collect_plugin_hidden_dir_scopes, extract_hidden_segments,
923 is_allowed_hidden_dir,
924 };
925
926 #[test]
927 fn hidden_dir_scope_exposes_root_and_dirs() {
928 let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
929
930 assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
931 assert_eq!(scope.dirs(), [".next"]);
932 }
933
934 #[test]
935 fn hidden_dir_allowlist_is_engine_owned() {
936 for dir in ALLOWED_HIDDEN_DIRS {
937 assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
938 }
939 assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
940 }
941
942 #[test]
943 fn plugin_hidden_dir_scopes_are_engine_owned() {
944 let dir = tempfile::tempdir().expect("tempdir");
945 let config = fallow_config::FallowConfig::default().resolve(
946 dir.path().to_path_buf(),
947 fallow_config::OutputFormat::Human,
948 1,
949 true,
950 true,
951 None,
952 );
953 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
954 "devDependencies": {
955 "@react-router/dev": "^7.0.0"
956 }
957 }))
958 .expect("valid package fixture");
959
960 let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
961
962 assert_eq!(scopes.len(), 1);
963 assert_eq!(scopes[0].root(), dir.path());
964 assert_eq!(scopes[0].dirs(), [".client", ".server"]);
965 }
966
967 #[test]
968 fn script_hidden_dir_scopes_are_engine_owned() {
969 let dir = tempfile::tempdir().expect("tempdir");
970 let config = fallow_config::FallowConfig::default().resolve(
971 dir.path().to_path_buf(),
972 fallow_config::OutputFormat::Human,
973 1,
974 true,
975 true,
976 None,
977 );
978 let pkg: PackageJson = serde_json::from_value(serde_json::json!({
979 "scripts": {
980 "lint": "eslint -c .config/eslint.config.js",
981 "build": "tsx ./.scripts/build.ts",
982 "cache": "tsx .nx/cache/build.ts"
983 }
984 }))
985 .expect("valid package fixture");
986
987 let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
988
989 assert_eq!(scopes.len(), 1);
990 assert_eq!(scopes[0].root(), dir.path());
991 let mut dirs = scopes[0].dirs().to_vec();
992 dirs.sort();
993 assert_eq!(dirs, [".config", ".scripts"]);
994 }
995
996 #[test]
997 fn hidden_segment_extraction_rejects_escape_paths() {
998 assert_eq!(
999 extract_hidden_segments(".foo/.bar/x.js"),
1000 vec![".foo".to_string(), ".bar".to_string()]
1001 );
1002 assert!(extract_hidden_segments("../../.config/eslint.config.js").is_empty());
1003 assert!(extract_hidden_segments(".env").is_empty());
1004 }
1005
1006 #[test]
1007 fn categorized_entry_points_dedups_each_bucket() {
1008 let entry = EntryPoint {
1009 path: PathBuf::from("/repo/src/index.ts"),
1010 source: EntryPointSource::DefaultIndex,
1011 };
1012 let engine = CategorizedEntryPoints {
1013 all: vec![entry.clone(), entry.clone()],
1014 runtime: vec![entry.clone(), entry.clone()],
1015 test: Vec::new(),
1016 }
1017 .dedup();
1018
1019 assert_eq!(engine.all.len(), 1);
1020 assert_eq!(engine.runtime.len(), 1);
1021 assert_eq!(engine.test.len(), 0);
1022 assert_eq!(engine.all[0].path, entry.path);
1023 }
1024}