Skip to main content

fallow_engine/
discover.rs

1//! Discovery helpers and types exposed through the engine boundary.
2
3use 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
13/// Discovery vocabulary shared with the analysis backend: file extensions,
14/// production-exclusion globs, and the hidden directories traversal allows.
15pub 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/// Discover workspace packages through the engine boundary.
77///
78/// Use this for callers that only need workspace metadata and do not yet own an
79/// `AnalysisSession`. Session-backed flows should prefer
80/// [`AnalysisSession::workspaces`](crate::session::AnalysisSession::workspaces)
81/// so discovery is reused with the rest of the analysis context.
82#[must_use]
83pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
84    discover_workspaces(root)
85}
86
87/// Discover workspace packages and diagnostics through the engine boundary.
88///
89/// This is for CLI/API surfaces that need to render workspace diagnostics but
90/// do not otherwise need a full [`AnalysisSession`](crate::session::AnalysisSession).
91///
92/// # Errors
93///
94/// Returns an engine error when workspace manifest loading fails.
95pub 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/// How a [`HiddenDirScope`] matches a hidden directory during the walk.
104///
105/// Mirrors `fallow_core::discover::HiddenDirMatch`; the two are kept in step
106/// by the conversion in `core_backend`.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum HiddenDirMatch {
109    /// Match by directory NAME at any depth beneath the scope root.
110    AnyDepth,
111    /// Match the exact root-relative directory PATH (issue #461).
112    ExactPath,
113}
114
115/// Package-scoped hidden directories that source discovery should traverse.
116#[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/// Reusable engine discovery prelude for one resolved project.
159#[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    /// Discovered source files, indexed by stable `FileId` for this session.
190    #[must_use]
191    pub(crate) fn files(&self) -> &[DiscoveredFile] {
192        &self.files
193    }
194
195    /// Discovered workspace packages for this session.
196    #[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    /// Source-discovery diagnostics this session's OWN walk produced. Held by
210    /// value because a concurrent walk on the same root replaces the process
211    /// registry's source-discovery set (issue #2366).
212    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    /// Consume this discovery prelude and return its source file registry.
225    #[must_use]
226    pub fn into_files(self) -> Vec<DiscoveredFile> {
227        self.files
228    }
229}
230
231/// Run engine-owned workspace and source discovery for a resolved project.
232#[must_use]
233pub(crate) fn prepare_analysis_discovery(config: &ResolvedConfig) -> AnalysisDiscovery {
234    let workspaces_start = Instant::now();
235    let workspaces = discover_workspaces(&config.root);
236    let workspaces_ms = workspaces_start.elapsed().as_secs_f64() * 1000.0;
237    if !workspaces.is_empty() {
238        tracing::info!(count = workspaces.len(), "workspaces discovered");
239    }
240    warn_undeclared_workspaces(
241        &config.root,
242        &workspaces,
243        &config.ignore_patterns,
244        config.quiet,
245    );
246
247    let root_pkg = fallow_config::load_dir_package_json(&config.root);
248    let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), &workspaces);
249
250    let discover_start = Instant::now();
251    let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
252    let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
253
254    AnalysisDiscovery::from_parts(sources, workspaces, root_pkg, discover_ms, workspaces_ms)
255}
256
257/// Run source discovery with workspace metadata already resolved by config load.
258///
259/// This is the normal [`AnalysisSession`](crate::session::AnalysisSession) path:
260/// config loading already expanded workspace globs and collected diagnostics, so
261/// source discovery can reuse that set instead of walking workspace manifests a
262/// second time.
263#[must_use]
264pub(crate) fn prepare_analysis_discovery_with_workspaces(
265    config: &ResolvedConfig,
266    workspaces: &[WorkspaceInfo],
267    workspaces_ms: f64,
268) -> AnalysisDiscovery {
269    if !workspaces.is_empty() {
270        tracing::info!(count = workspaces.len(), "workspaces discovered");
271    }
272
273    let root_pkg = fallow_config::load_dir_package_json(&config.root);
274    let hidden_dir_scopes = collect_hidden_dir_scopes(config, root_pkg.as_ref(), workspaces);
275
276    let discover_start = Instant::now();
277    let sources = discover_files_config_candidates_and_diagnostics(config, &hidden_dir_scopes);
278    let discover_ms = discover_start.elapsed().as_secs_f64() * 1000.0;
279
280    AnalysisDiscovery::from_parts(
281        sources,
282        workspaces.to_vec(),
283        root_pkg,
284        discover_ms,
285        workspaces_ms,
286    )
287}
288
289fn format_undeclared_workspace_warning(
290    root: &Path,
291    undeclared: &[WorkspaceDiagnostic],
292) -> Option<String> {
293    if undeclared.is_empty() {
294        return None;
295    }
296
297    let preview = undeclared
298        .iter()
299        .take(UNDECLARED_WORKSPACE_WARNING_PREVIEW)
300        .map(|diagnostic| {
301            diagnostic
302                .path
303                .strip_prefix(root)
304                .unwrap_or(&diagnostic.path)
305                .display()
306                .to_string()
307                .replace('\\', "/")
308        })
309        .collect::<Vec<_>>();
310    let remaining = undeclared
311        .len()
312        .saturating_sub(UNDECLARED_WORKSPACE_WARNING_PREVIEW);
313    let tail = if remaining > 0 {
314        format!(" (and {remaining} more)")
315    } else {
316        String::new()
317    };
318    let noun = if undeclared.len() == 1 {
319        "directory with package.json is"
320    } else {
321        "directories with package.json are"
322    };
323    let guidance = if undeclared.len() == 1 {
324        "Add that path to package.json workspaces or pnpm-workspace.yaml if it should be analyzed as a workspace."
325    } else {
326        "Add those paths to package.json workspaces or pnpm-workspace.yaml if they should be analyzed as workspaces."
327    };
328
329    Some(format!(
330        "{} {} not declared as {}: {}{}. {}",
331        undeclared.len(),
332        noun,
333        if undeclared.len() == 1 {
334            "a workspace"
335        } else {
336            "workspaces"
337        },
338        preview.join(", "),
339        tail,
340        guidance
341    ))
342}
343
344fn warn_undeclared_workspaces(
345    root: &Path,
346    workspaces: &[WorkspaceInfo],
347    ignore_patterns: &globset::GlobSet,
348    quiet: bool,
349) {
350    let undeclared = find_undeclared_workspaces_with_ignores(root, workspaces, ignore_patterns);
351    if undeclared.is_empty() {
352        return;
353    }
354
355    let existing = fallow_config::workspace_diagnostics_for(root);
356    let already_flagged: FxHashSet<PathBuf> = existing
357        .iter()
358        .map(|diagnostic| {
359            dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone())
360        })
361        .collect();
362    let undeclared: Vec<_> = undeclared
363        .into_iter()
364        .filter(|diagnostic| {
365            let canonical =
366                dunce::canonicalize(&diagnostic.path).unwrap_or_else(|_| diagnostic.path.clone());
367            !already_flagged.contains(&canonical)
368        })
369        .collect();
370    if undeclared.is_empty() {
371        return;
372    }
373
374    fallow_config::append_workspace_diagnostics(root, undeclared.clone());
375
376    if !quiet && let Some(message) = format_undeclared_workspace_warning(root, &undeclared) {
377        tracing::warn!("{message}");
378    }
379}
380
381/// Check if a hidden directory name is on the discovery allowlist.
382#[must_use]
383pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
384    ALLOWED_HIDDEN_DIRS
385        .iter()
386        .any(|&dir| OsStr::new(dir) == name)
387}
388
389/// Collect plugin-derived hidden directory scopes.
390#[must_use]
391pub fn collect_plugin_hidden_dir_scopes(
392    config: &ResolvedConfig,
393    root_pkg: Option<&PackageJson>,
394    workspaces: &[WorkspaceInfo],
395) -> Vec<HiddenDirScope> {
396    let registry = PluginRegistry::new(config.external_plugins.clone());
397    let mut scopes = Vec::new();
398
399    if let Some(pkg) = root_pkg {
400        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
401    }
402
403    for ws in workspaces {
404        if let Some(pkg) = fallow_config::load_dir_package_json(&ws.root) {
405            push_plugin_hidden_dir_scope(&mut scopes, &registry, &pkg, &ws.root);
406        }
407    }
408
409    scopes
410}
411
412fn push_plugin_hidden_dir_scope(
413    scopes: &mut Vec<HiddenDirScope>,
414    registry: &PluginRegistry,
415    pkg: &PackageJson,
416    root: &Path,
417) {
418    let dirs = registry.discovery_hidden_dirs(pkg, root);
419    if !dirs.is_empty() {
420        scopes.push(HiddenDirScope::new(root.to_path_buf(), dirs));
421    }
422}
423
424/// Collect plugin and script-derived hidden directory scopes.
425#[must_use]
426pub(crate) fn collect_hidden_dir_scopes(
427    config: &ResolvedConfig,
428    root_pkg: Option<&PackageJson>,
429    workspaces: &[WorkspaceInfo],
430) -> Vec<HiddenDirScope> {
431    let _span = tracing::info_span!("collect_hidden_dir_scopes").entered();
432    let registry = PluginRegistry::new(config.external_plugins.clone());
433    let mut scopes = Vec::new();
434
435    if let Some(pkg) = root_pkg {
436        push_plugin_hidden_dir_scope(&mut scopes, &registry, pkg, &config.root);
437        push_script_hidden_dir_scope(&mut scopes, 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, &registry, &pkg, &ws.root);
443            push_script_hidden_dir_scope(&mut scopes, &pkg, &ws.root);
444        }
445    }
446
447    scopes
448}
449
450fn push_script_hidden_dir_scope(scopes: &mut Vec<HiddenDirScope>, pkg: &PackageJson, root: &Path) {
451    if let Some(scope) = build_script_scope(pkg, root) {
452        scopes.push(scope);
453    }
454}
455
456fn build_script_scope(pkg: &PackageJson, root: &Path) -> Option<HiddenDirScope> {
457    let scripts = pkg.scripts.as_ref()?;
458    let mut seen = FxHashSet::default();
459    let mut dirs: Vec<String> = Vec::new();
460
461    for (script_name, script_value) in scripts {
462        for cmd in parse_script_value(script_value) {
463            for path in cmd.config_args.iter().chain(cmd.file_args.iter()) {
464                for hidden in extract_hidden_dir_paths(path) {
465                    if hidden_dir_path_is_denied(&hidden) {
466                        continue;
467                    }
468                    if seen.insert(hidden.clone()) {
469                        tracing::debug!(
470                            dir = %hidden,
471                            script = %script_name,
472                            package_root = %root.display(),
473                            "inferred hidden_dir_scope from package.json#scripts"
474                        );
475                        dirs.push(hidden);
476                    }
477                }
478            }
479        }
480    }
481
482    if dirs.is_empty() {
483        None
484    } else {
485        Some(HiddenDirScope::new_exact_paths(root.to_path_buf(), dirs))
486    }
487}
488
489/// Whether the last component of a root-relative hidden directory path is on
490/// [`SCRIPT_SCOPE_DENYLIST`].
491///
492/// The denylist names directories, so it is matched against the directory's
493/// own name rather than the path that reaches it.
494fn hidden_dir_path_is_denied(path: &str) -> bool {
495    Path::new(path)
496        .file_name()
497        .and_then(|name| name.to_str())
498        .is_some_and(|name| SCRIPT_SCOPE_DENYLIST.contains(&name))
499}
500
501#[derive(Debug, PartialEq, Eq)]
502struct ScriptCommand {
503    config_args: Vec<String>,
504    file_args: Vec<String>,
505}
506
507fn parse_script_value(script: &str) -> Vec<ScriptCommand> {
508    let mut commands = Vec::new();
509
510    for segment in split_shell_operators(script) {
511        let segment = segment.trim();
512        if segment.is_empty() {
513            continue;
514        }
515        if let Some(cmd) = parse_command_segment(segment) {
516            commands.push(cmd);
517        }
518    }
519
520    commands
521}
522
523fn parse_command_segment(segment: &str) -> Option<ScriptCommand> {
524    let tokens: Vec<&str> = segment
525        .split_whitespace()
526        .map(strip_surrounding_quotes)
527        .collect();
528    if tokens.is_empty() {
529        return None;
530    }
531
532    let idx = skip_initial_wrappers(&tokens, 0)?;
533    let idx = advance_past_package_manager(&tokens, idx)?;
534    let binary = tokens[idx];
535
536    if SCRIPT_MULTIPLEXERS.contains(&binary) {
537        return Some(ScriptCommand {
538            config_args: Vec::new(),
539            file_args: Vec::new(),
540        });
541    }
542
543    let is_node_runner = NODE_RUNNERS.contains(&binary);
544    let (file_args, config_args) = extract_args_for_binary(&tokens, idx + 1, is_node_runner);
545
546    Some(ScriptCommand {
547        config_args,
548        file_args,
549    })
550}
551
552fn split_shell_operators(script: &str) -> Vec<&str> {
553    let mut segments = Vec::new();
554    let mut start = 0;
555    let bytes = script.as_bytes();
556    let len = bytes.len();
557    let mut index = 0;
558    let mut in_single_quote = false;
559    let mut in_double_quote = false;
560
561    while index < len {
562        let byte = bytes[index];
563
564        if byte == b'\'' && !in_double_quote {
565            in_single_quote = !in_single_quote;
566            index += 1;
567            continue;
568        }
569        if byte == b'"' && !in_single_quote {
570            in_double_quote = !in_double_quote;
571            index += 1;
572            continue;
573        }
574
575        if in_single_quote || in_double_quote {
576            index += 1;
577            continue;
578        }
579
580        if let Some(op_len) = shell_operator_len(bytes, index) {
581            segments.push(&script[start..index]);
582            index += op_len;
583            start = index;
584            continue;
585        }
586
587        index += 1;
588    }
589
590    if start < len {
591        segments.push(&script[start..]);
592    }
593
594    segments
595}
596
597fn shell_operator_len(bytes: &[u8], index: usize) -> Option<usize> {
598    let byte = bytes[index];
599    let next = bytes.get(index + 1).copied();
600
601    if matches!((byte, next), (b'&', Some(b'&')) | (b'|', Some(b'|'))) {
602        return Some(2);
603    }
604
605    if byte == b';' {
606        return Some(1);
607    }
608    if byte == b'|' && next != Some(b'|') {
609        return Some(1);
610    }
611    if byte == b'&' && next != Some(b'&') {
612        return Some(1);
613    }
614
615    None
616}
617
618fn strip_surrounding_quotes(token: &str) -> &str {
619    if token.len() >= 2 {
620        let first = token.as_bytes()[0];
621        let last = token.as_bytes()[token.len() - 1];
622        if (first == b'\'' || first == b'"') && first == last {
623            return &token[1..token.len() - 1];
624        }
625    }
626    token
627}
628
629fn skip_initial_wrappers(tokens: &[&str], mut index: usize) -> Option<usize> {
630    while index < tokens.len() && is_env_assignment(tokens[index]) {
631        index += 1;
632    }
633    if index >= tokens.len() {
634        return None;
635    }
636
637    while index < tokens.len() && ENV_WRAPPERS.contains(&tokens[index]) {
638        index += 1;
639        while index < tokens.len() && is_env_assignment(tokens[index]) {
640            index += 1;
641        }
642        if index < tokens.len() && tokens[index] == "--" {
643            index += 1;
644        }
645    }
646    if index >= tokens.len() {
647        return None;
648    }
649
650    Some(index)
651}
652
653fn advance_past_package_manager(tokens: &[&str], mut index: usize) -> Option<usize> {
654    let token = tokens[index];
655    if matches!(token, "npx" | "pnpx" | "bunx") {
656        index += 1;
657        while index < tokens.len() && tokens[index].starts_with('-') {
658            let flag = tokens[index];
659            index += 1;
660            if matches!(flag, "--package" | "-p") && index < tokens.len() {
661                index += 1;
662            }
663        }
664    } else if token == "bun" {
665        index += 1;
666        let mut saw_runtime_flag = false;
667        while index < tokens.len() && BUN_RUNTIME_FLAGS.contains(&tokens[index]) {
668            index += 1;
669            saw_runtime_flag = true;
670        }
671        if index >= tokens.len() {
672            return None;
673        }
674        let subcmd = tokens[index];
675        if subcmd == "exec" || subcmd == "x" {
676            index += 1;
677        } else if matches!(subcmd, "run" | "run-script") || !saw_runtime_flag {
678            return None;
679        }
680    } else if matches!(token, "yarn" | "pnpm" | "npm") {
681        if index + 1 < tokens.len() {
682            let subcmd = tokens[index + 1];
683            if subcmd == "exec" || subcmd == "dlx" {
684                index += 2;
685            } else {
686                return None;
687            }
688        } else {
689            return None;
690        }
691    }
692    if index >= tokens.len() {
693        return None;
694    }
695
696    Some(index)
697}
698
699fn extract_args_for_binary(
700    tokens: &[&str],
701    mut index: usize,
702    is_node_runner: bool,
703) -> (Vec<String>, Vec<String>) {
704    let mut file_args = Vec::new();
705    let mut config_args = Vec::new();
706
707    while index < tokens.len() {
708        let token = tokens[index];
709
710        if is_node_runner
711            && matches!(
712                token,
713                "-e" | "--eval" | "-p" | "--print" | "-r" | "--require"
714            )
715        {
716            index += 2;
717            continue;
718        }
719
720        if let Some(config) = extract_config_arg(token, tokens.get(index + 1).copied()) {
721            config_args.push(config);
722            if token.contains('=') || token.starts_with("--config=") || token.starts_with("-c=") {
723                index += 1;
724            } else {
725                index += 2;
726            }
727            continue;
728        }
729
730        if token.starts_with('-') {
731            index += 1;
732            continue;
733        }
734
735        if looks_like_file_path(token) {
736            file_args.push(token.to_string());
737        }
738        index += 1;
739    }
740
741    (file_args, config_args)
742}
743
744fn extract_config_arg(token: &str, next: Option<&str>) -> Option<String> {
745    if let Some(value) = token.strip_prefix("--config=")
746        && !value.is_empty()
747    {
748        return Some(value.to_string());
749    }
750    if let Some(value) = token.strip_prefix("-c=")
751        && !value.is_empty()
752    {
753        return Some(value.to_string());
754    }
755    if matches!(token, "--config" | "-c")
756        && let Some(next_token) = next
757        && !next_token.starts_with('-')
758    {
759        return Some(next_token.to_string());
760    }
761    None
762}
763
764fn is_env_assignment(token: &str) -> bool {
765    token.find('=').is_some_and(|eq_pos| {
766        let name = &token[..eq_pos];
767        !name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_')
768    })
769}
770
771fn looks_like_file_path(token: &str) -> bool {
772    if !could_be_file_path(token) {
773        return false;
774    }
775
776    const EXTENSIONS: &[&str] = &[
777        ".js", ".ts", ".mjs", ".cjs", ".mts", ".cts", ".jsx", ".tsx", ".json", ".yaml", ".yml",
778        ".toml",
779    ];
780    if EXTENSIONS.iter().any(|ext| token.ends_with(ext)) {
781        return true;
782    }
783    token.starts_with("./")
784        || token.starts_with("../")
785        || (token.contains('/') && !token.starts_with('@') && !token.contains("://"))
786}
787
788fn could_be_file_path(token: &str) -> bool {
789    if token.contains("${{") || (token.contains("}}") && !token.contains("{{")) {
790        return false;
791    }
792
793    if token.contains('\\') {
794        return false;
795    }
796
797    if let Some(open) = token.find('[') {
798        let after_open = &token[open + 1..];
799        let close_offset = after_open.find(']');
800        if !matches!(close_offset, Some(offset) if offset > 0) {
801            return false;
802        }
803    }
804
805    true
806}
807
808/// Extract the root-relative path of every hidden (dot-prefixed) directory
809/// component on a relative path.
810///
811/// Each hidden directory on the path gets its own entry, because the walker
812/// filters one component at a time and every hidden level has to be admitted
813/// for the next one to be reached: `.foo/.bar/x.js` yields `[".foo",
814/// ".foo/.bar"]`. A hidden directory under a visible parent keeps that parent
815/// in its entry, so `tools/.bar/x.js` admits nothing named `.bar` anywhere
816/// else in the tree (issue #461).
817///
818/// Mirrors `fallow_core::discover::extract_hidden_dir_paths`.
819fn extract_hidden_dir_paths(path: &str) -> Vec<String> {
820    let path = Path::new(path);
821    if path.is_absolute() {
822        return Vec::new();
823    }
824
825    let mut hidden = Vec::new();
826    let components = path.components().collect::<Vec<_>>();
827    if components.iter().any(|component| {
828        matches!(
829            component,
830            std::path::Component::ParentDir | std::path::Component::RootDir
831        )
832    }) {
833        return Vec::new();
834    }
835
836    let mut prefix = PathBuf::new();
837    for (index, component) in components.iter().enumerate() {
838        let std::path::Component::Normal(value) = component else {
839            continue;
840        };
841        if index == components.len().saturating_sub(1) {
842            continue;
843        }
844        prefix.push(value);
845        let value = value.to_string_lossy();
846        if !value.starts_with('.') || value == "." || value == ".." {
847            continue;
848        }
849        hidden.push(prefix.to_string_lossy().into_owned());
850    }
851
852    hidden
853}
854
855/// Discover source files and non-source config candidates in one traversal.
856#[must_use]
857pub fn discover_files_and_config_candidates(
858    config: &ResolvedConfig,
859    additional_hidden_dir_scopes: &[HiddenDirScope],
860) -> (Vec<DiscoveredFile>, Vec<PathBuf>) {
861    crate::core_backend::discover_files_and_config_candidates(config, additional_hidden_dir_scopes)
862}
863
864/// The same traversal, keeping the source-discovery diagnostics it produced.
865#[must_use]
866pub(crate) fn discover_files_config_candidates_and_diagnostics(
867    config: &ResolvedConfig,
868    additional_hidden_dir_scopes: &[HiddenDirScope],
869) -> crate::core_backend::DiscoveredSources {
870    crate::core_backend::discover_files_config_candidates_and_diagnostics(
871        config,
872        additional_hidden_dir_scopes,
873    )
874}
875
876/// Discover configured and inferred entry points.
877#[must_use]
878pub(crate) fn discover_entry_points(
879    config: &ResolvedConfig,
880    files: &[DiscoveredFile],
881) -> Vec<EntryPoint> {
882    crate::core_backend::discover_entry_points(config, files)
883}
884
885/// Discover entry points for a workspace package.
886#[must_use]
887pub(crate) fn discover_workspace_entry_points(
888    ws_root: &Path,
889    config: &ResolvedConfig,
890    all_files: &[DiscoveredFile],
891) -> Vec<EntryPoint> {
892    crate::core_backend::discover_workspace_entry_points(ws_root, config, all_files)
893}
894
895/// Discover entry points from plugin results.
896#[must_use]
897pub(crate) fn discover_plugin_entry_points(
898    plugin_result: &crate::plugins::AggregatedPluginResult,
899    config: &ResolvedConfig,
900    files: &[DiscoveredFile],
901) -> Vec<EntryPoint> {
902    crate::core_backend::discover_plugin_entry_points(plugin_result.backend(), config, files)
903}
904
905#[cfg(test)]
906mod tests {
907    use std::path::PathBuf;
908
909    use fallow_config::PackageJson;
910
911    use super::{
912        ALLOWED_HIDDEN_DIRS, HiddenDirScope, collect_hidden_dir_scopes,
913        collect_plugin_hidden_dir_scopes, extract_hidden_dir_paths, is_allowed_hidden_dir,
914    };
915
916    #[test]
917    fn hidden_dir_scope_exposes_root_and_dirs() {
918        let scope = HiddenDirScope::new(PathBuf::from("/repo/packages/app"), vec![".next".into()]);
919
920        assert_eq!(scope.root(), PathBuf::from("/repo/packages/app"));
921        assert_eq!(scope.dirs(), [".next"]);
922    }
923
924    #[test]
925    fn hidden_dir_allowlist_is_engine_owned() {
926        for dir in ALLOWED_HIDDEN_DIRS {
927            assert!(is_allowed_hidden_dir(std::ffi::OsStr::new(dir)));
928        }
929        assert!(!is_allowed_hidden_dir(std::ffi::OsStr::new(".git")));
930    }
931
932    #[test]
933    fn plugin_hidden_dir_scopes_are_engine_owned() {
934        let dir = tempfile::tempdir().expect("tempdir");
935        let config = fallow_config::FallowConfig::default().resolve(
936            dir.path().to_path_buf(),
937            fallow_config::OutputFormat::Human,
938            1,
939            true,
940            true,
941            None,
942        );
943        let pkg: PackageJson = serde_json::from_value(serde_json::json!({
944            "devDependencies": {
945                "@react-router/dev": "^7.0.0"
946            }
947        }))
948        .expect("valid package fixture");
949
950        let scopes = collect_plugin_hidden_dir_scopes(&config, Some(&pkg), &[]);
951
952        assert_eq!(scopes.len(), 1);
953        assert_eq!(scopes[0].root(), dir.path());
954        assert_eq!(scopes[0].dirs(), [".client", ".server"]);
955    }
956
957    #[test]
958    fn script_hidden_dir_scopes_are_engine_owned() {
959        let dir = tempfile::tempdir().expect("tempdir");
960        let config = fallow_config::FallowConfig::default().resolve(
961            dir.path().to_path_buf(),
962            fallow_config::OutputFormat::Human,
963            1,
964            true,
965            true,
966            None,
967        );
968        let pkg: PackageJson = serde_json::from_value(serde_json::json!({
969            "scripts": {
970                "lint": "eslint -c .config/eslint.config.js",
971                "build": "tsx ./.scripts/build.ts",
972                "cache": "tsx .nx/cache/build.ts",
973                "pnpm": "node node_modules/.pnpm/tool/bin.js"
974            }
975        }))
976        .expect("valid package fixture");
977
978        let scopes = collect_hidden_dir_scopes(&config, Some(&pkg), &[]);
979
980        assert_eq!(scopes.len(), 1);
981        assert_eq!(scopes[0].root(), dir.path());
982        let mut dirs = scopes[0].dirs().to_vec();
983        dirs.sort();
984        assert_eq!(dirs, [".config", ".scripts"]);
985    }
986
987    #[test]
988    fn hidden_dir_path_extraction_rejects_escape_paths() {
989        assert_eq!(
990            extract_hidden_dir_paths(".foo/.bar/x.js"),
991            vec![
992                ".foo".to_string(),
993                format!(".foo{}.bar", std::path::MAIN_SEPARATOR)
994            ]
995        );
996        assert!(extract_hidden_dir_paths("../../.config/eslint.config.js").is_empty());
997        assert!(extract_hidden_dir_paths(".env").is_empty());
998    }
999}