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