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};
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
23/// Glob patterns for test/dev/story files excluded in production mode.
24pub 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/// Discover workspace packages through the engine boundary.
89///
90/// Use this for callers that only need workspace metadata and do not yet own an
91/// `AnalysisSession`. Session-backed flows should prefer
92/// [`AnalysisSession::workspaces`](crate::session::AnalysisSession::workspaces)
93/// so discovery is reused with the rest of the analysis context.
94#[must_use]
95pub fn discover_workspace_packages(root: &Path) -> Vec<WorkspaceInfo> {
96    discover_workspaces(root)
97}
98
99/// Discover workspace packages and diagnostics through the engine boundary.
100///
101/// This is for CLI/API surfaces that need to render workspace diagnostics but
102/// do not otherwise need a full [`AnalysisSession`](crate::session::AnalysisSession).
103///
104/// # Errors
105///
106/// Returns an engine error when workspace manifest loading fails.
107pub 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/// Entry points grouped by reachability role.
116#[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/// Package-scoped hidden directories that source discovery should traverse.
153#[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/// Reusable engine discovery prelude for one resolved project.
177#[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    /// Discovered source files, indexed by stable `FileId` for this session.
207    #[must_use]
208    pub(crate) fn files(&self) -> &[DiscoveredFile] {
209        &self.files
210    }
211
212    /// Discovered workspace packages for this session.
213    #[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    /// Consume this discovery prelude and return its source file registry.
235    #[must_use]
236    pub fn into_files(self) -> Vec<DiscoveredFile> {
237        self.files
238    }
239}
240
241/// Run engine-owned workspace and source discovery for a resolved project.
242#[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/// Run source discovery with workspace metadata already resolved by config load.
278///
279/// This is the normal [`AnalysisSession`](crate::session::AnalysisSession) path:
280/// config loading already expanded workspace globs and collected diagnostics, so
281/// source discovery can reuse that set instead of walking workspace manifests a
282/// second time.
283#[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/// Check if a hidden directory name is on the discovery allowlist.
419#[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/// Collect plugin-derived hidden directory scopes.
427#[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, &registry, 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        }
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/// Collect plugin and script-derived hidden directory scopes.
462#[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, &registry, 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, &registry, &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/// Discover source files and non-source config candidates in one traversal.
868#[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/// Discover configured and inferred entry points.
886#[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/// Discover entry points for a workspace package.
895#[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/// Discover entry points from plugin results.
905#[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}