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    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/// Run source discovery with workspace metadata already resolved by config load.
260///
261/// This is the normal [`AnalysisSession`](crate::session::AnalysisSession) path:
262/// config loading already expanded workspace globs and collected diagnostics, so
263/// source discovery can reuse that set instead of walking workspace manifests a
264/// second time.
265#[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/// Check if a hidden directory name is on the discovery allowlist.
399#[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/// Collect plugin-derived hidden directory scopes.
407#[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, &registry, 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, &registry, &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/// Collect plugin and script-derived hidden directory scopes.
442#[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, &registry, 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, &registry, &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
506/// Whether the last component of a root-relative hidden directory path is on
507/// [`SCRIPT_SCOPE_DENYLIST`].
508///
509/// The denylist names directories, so it is matched against the directory's
510/// own name rather than the path that reaches it.
511fn 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
825/// Extract the root-relative path of every hidden (dot-prefixed) directory
826/// component on a relative path.
827///
828/// Each hidden directory on the path gets its own entry, because the walker
829/// filters one component at a time and every hidden level has to be admitted
830/// for the next one to be reached: `.foo/.bar/x.js` yields `[".foo",
831/// ".foo/.bar"]`. A hidden directory under a visible parent keeps that parent
832/// in its entry, so `tools/.bar/x.js` admits nothing named `.bar` anywhere
833/// else in the tree (issue #461).
834///
835/// Mirrors `fallow_core::discover::extract_hidden_dir_paths`.
836fn 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/// Discover source files and non-source config candidates in one traversal.
873#[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/// The same traversal, keeping the source-discovery diagnostics it produced.
882#[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/// Discover configured and inferred entry points.
894#[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/// Discover entry points for a workspace package.
903#[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/// Discover entry points from plugin results.
913#[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}