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