Skip to main content

fallow_core/discover/
entry_points.rs

1use std::path::{Path, PathBuf};
2
3use super::parse_scripts::extract_script_file_refs;
4use super::walk::SOURCE_EXTENSIONS;
5use fallow_config::{EntryPointRole, PackageJson, ResolvedConfig};
6use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
7
8/// Known output directory names from exports maps.
9/// When an entry point path is inside one of these directories, we also try
10/// the `src/` equivalent to find the tracked source file.
11const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
12
13/// Entry points grouped by reachability role.
14#[derive(Debug, Clone, Default)]
15pub struct CategorizedEntryPoints {
16    pub all: Vec<EntryPoint>,
17    pub runtime: Vec<EntryPoint>,
18    pub test: Vec<EntryPoint>,
19}
20
21impl CategorizedEntryPoints {
22    pub fn push_runtime(&mut self, entry: EntryPoint) {
23        self.runtime.push(entry.clone());
24        self.all.push(entry);
25    }
26
27    pub fn push_test(&mut self, entry: EntryPoint) {
28        self.test.push(entry.clone());
29        self.all.push(entry);
30    }
31
32    pub fn push_support(&mut self, entry: EntryPoint) {
33        self.all.push(entry);
34    }
35
36    pub fn extend_runtime<I>(&mut self, entries: I)
37    where
38        I: IntoIterator<Item = EntryPoint>,
39    {
40        for entry in entries {
41            self.push_runtime(entry);
42        }
43    }
44
45    pub fn extend_test<I>(&mut self, entries: I)
46    where
47        I: IntoIterator<Item = EntryPoint>,
48    {
49        for entry in entries {
50            self.push_test(entry);
51        }
52    }
53
54    pub fn extend_support<I>(&mut self, entries: I)
55    where
56        I: IntoIterator<Item = EntryPoint>,
57    {
58        for entry in entries {
59            self.push_support(entry);
60        }
61    }
62
63    pub fn extend(&mut self, other: Self) {
64        self.all.extend(other.all);
65        self.runtime.extend(other.runtime);
66        self.test.extend(other.test);
67    }
68
69    #[must_use]
70    pub fn dedup(mut self) -> Self {
71        dedup_entry_paths(&mut self.all);
72        dedup_entry_paths(&mut self.runtime);
73        dedup_entry_paths(&mut self.test);
74        self
75    }
76}
77
78fn dedup_entry_paths(entries: &mut Vec<EntryPoint>) {
79    entries.sort_by(|a, b| a.path.cmp(&b.path));
80    entries.dedup_by(|a, b| a.path == b.path);
81}
82
83/// Resolve a path relative to a base directory, with security check and extension fallback.
84///
85/// Returns `Some(EntryPoint)` if the path resolves to an existing file within `canonical_root`,
86/// trying source extensions as fallback when the exact path doesn't exist.
87/// Also handles exports map targets in output directories (e.g., `./dist/utils.js`)
88/// by trying to map back to the source file (e.g., `./src/utils.ts`).
89pub fn resolve_entry_path(
90    base: &Path,
91    entry: &str,
92    canonical_root: &Path,
93    source: EntryPointSource,
94) -> Option<EntryPoint> {
95    // Wildcard exports (e.g., `./src/themes/*.css`) can't be resolved to a single
96    // file. Return None and let the caller expand them separately.
97    if entry.contains('*') {
98        return None;
99    }
100
101    let resolved = base.join(entry);
102    // Security: ensure resolved path stays within the allowed root
103    let canonical_resolved = dunce::canonicalize(&resolved).unwrap_or_else(|_| resolved.clone());
104    if !canonical_resolved.starts_with(canonical_root) {
105        tracing::warn!(path = %entry, "Skipping entry point outside project root");
106        return None;
107    }
108
109    // If the path is in an output directory (dist/, build/, etc.), try mapping to src/ first.
110    // This handles exports map targets like `./dist/utils.js` → `./src/utils.ts`.
111    // We check this BEFORE the exists() check because even if the dist file exists,
112    // fallow ignores dist/ by default, so we need the source file instead.
113    if let Some(source_path) = try_output_to_source_path(base, entry) {
114        // Security: ensure the mapped source path stays within the project root
115        if let Ok(canonical_source) = dunce::canonicalize(&source_path)
116            && canonical_source.starts_with(canonical_root)
117        {
118            return Some(EntryPoint {
119                path: source_path,
120                source,
121            });
122        }
123    }
124
125    if resolved.exists() {
126        return Some(EntryPoint {
127            path: resolved,
128            source,
129        });
130    }
131    // Try with source extensions
132    for ext in SOURCE_EXTENSIONS {
133        let with_ext = resolved.with_extension(ext);
134        if with_ext.exists() {
135            return Some(EntryPoint {
136                path: with_ext,
137                source,
138            });
139        }
140    }
141    None
142}
143
144/// Try to map an entry path from an output directory to its source equivalent.
145///
146/// Given `base=/project/packages/ui` and `entry=./dist/utils.js`, this tries:
147/// - `/project/packages/ui/src/utils.ts`
148/// - `/project/packages/ui/src/utils.tsx`
149/// - etc. for all source extensions
150///
151/// Preserves any path prefix between the package root and the output dir,
152/// e.g. `./modules/dist/utils.js` → `base/modules/src/utils.ts`.
153///
154/// Returns `Some(path)` if a source file is found.
155fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
156    let entry_path = Path::new(entry);
157    let components: Vec<_> = entry_path.components().collect();
158
159    // Find the last output directory component in the entry path
160    let output_pos = components.iter().rposition(|c| {
161        if let std::path::Component::Normal(s) = c
162            && let Some(name) = s.to_str()
163        {
164            return OUTPUT_DIRS.contains(&name);
165        }
166        false
167    })?;
168
169    // Build the relative prefix before the output dir, filtering out CurDir (".")
170    let prefix: PathBuf = components[..output_pos]
171        .iter()
172        .filter(|c| !matches!(c, std::path::Component::CurDir))
173        .collect();
174
175    // Build the relative path after the output dir (e.g., "utils.js")
176    let suffix: PathBuf = components[output_pos + 1..].iter().collect();
177
178    // Try base + prefix + "src" + suffix-with-source-extension
179    for ext in SOURCE_EXTENSIONS {
180        let source_candidate = base
181            .join(&prefix)
182            .join("src")
183            .join(suffix.with_extension(ext));
184        if source_candidate.exists() {
185            return Some(source_candidate);
186        }
187    }
188
189    None
190}
191
192/// Default index patterns used when no other entry points are found.
193const DEFAULT_INDEX_PATTERNS: &[&str] = &[
194    "src/index.{ts,tsx,js,jsx}",
195    "src/main.{ts,tsx,js,jsx}",
196    "index.{ts,tsx,js,jsx}",
197    "main.{ts,tsx,js,jsx}",
198];
199
200/// Fall back to default index patterns if no entries were found.
201///
202/// When `ws_filter` is `Some`, only files whose path starts with the given
203/// workspace root are considered (used for workspace-scoped discovery).
204fn apply_default_fallback(
205    files: &[DiscoveredFile],
206    root: &Path,
207    ws_filter: Option<&Path>,
208) -> Vec<EntryPoint> {
209    let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
210        .iter()
211        .filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
212        .collect();
213
214    let mut entries = Vec::new();
215    for file in files {
216        // Use strip_prefix instead of canonicalize for workspace filtering
217        if let Some(ws_root) = ws_filter
218            && file.path.strip_prefix(ws_root).is_err()
219        {
220            continue;
221        }
222        let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
223        let relative_str = relative.to_string_lossy();
224        if default_matchers
225            .iter()
226            .any(|m| m.is_match(relative_str.as_ref()))
227        {
228            entries.push(EntryPoint {
229                path: file.path.clone(),
230                source: EntryPointSource::DefaultIndex,
231            });
232        }
233    }
234    entries
235}
236
237/// Discover entry points from package.json, framework rules, and defaults.
238pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
239    let _span = tracing::info_span!("discover_entry_points").entered();
240    let mut entries = Vec::new();
241
242    // Pre-compute relative paths for all files (once, not per pattern)
243    let relative_paths: Vec<String> = files
244        .iter()
245        .map(|f| {
246            f.path
247                .strip_prefix(&config.root)
248                .unwrap_or(&f.path)
249                .to_string_lossy()
250                .into_owned()
251        })
252        .collect();
253
254    // 1. Manual entries from config — batch all patterns into a single GlobSet
255    // for O(files) matching instead of O(patterns × files).
256    {
257        let mut builder = globset::GlobSetBuilder::new();
258        for pattern in &config.entry_patterns {
259            if let Ok(glob) = globset::Glob::new(pattern) {
260                builder.add(glob);
261            }
262        }
263        if let Ok(glob_set) = builder.build()
264            && !glob_set.is_empty()
265        {
266            for (idx, rel) in relative_paths.iter().enumerate() {
267                if glob_set.is_match(rel) {
268                    entries.push(EntryPoint {
269                        path: files[idx].path.clone(),
270                        source: EntryPointSource::ManualEntry,
271                    });
272                }
273            }
274        }
275    }
276
277    // 2. Package.json entries
278    // Pre-compute canonical root once for all resolve_entry_path calls
279    let canonical_root = dunce::canonicalize(&config.root).unwrap_or_else(|_| config.root.clone());
280    let pkg_path = config.root.join("package.json");
281    let root_pkg = PackageJson::load(&pkg_path).ok();
282    if let Some(pkg) = &root_pkg {
283        for entry_path in pkg.entry_points() {
284            if let Some(ep) = resolve_entry_path(
285                &config.root,
286                &entry_path,
287                &canonical_root,
288                EntryPointSource::PackageJsonMain,
289            ) {
290                entries.push(ep);
291            }
292        }
293
294        // 2b. Package.json scripts — extract file references as entry points
295        if let Some(scripts) = &pkg.scripts {
296            for script_value in scripts.values() {
297                for file_ref in extract_script_file_refs(script_value) {
298                    if let Some(ep) = resolve_entry_path(
299                        &config.root,
300                        &file_ref,
301                        &canonical_root,
302                        EntryPointSource::PackageJsonScript,
303                    ) {
304                        entries.push(ep);
305                    }
306                }
307            }
308        }
309
310        // Framework rules now flow through PluginRegistry via external_plugins.
311    }
312
313    // 4. Auto-discover nested package.json entry points
314    // For monorepo-like structures without explicit workspace config, scan for
315    // package.json files in subdirectories and use their main/exports as entries.
316    let exports_dirs = root_pkg
317        .map(|pkg| pkg.exports_subdirectories())
318        .unwrap_or_default();
319    discover_nested_package_entries(
320        &config.root,
321        files,
322        &mut entries,
323        &canonical_root,
324        &exports_dirs,
325    );
326
327    // 5. Default index files (if no other entries found)
328    if entries.is_empty() {
329        entries = apply_default_fallback(files, &config.root, None);
330    }
331
332    // Deduplicate by path
333    entries.sort_by(|a, b| a.path.cmp(&b.path));
334    entries.dedup_by(|a, b| a.path == b.path);
335
336    entries
337}
338
339/// Discover entry points from nested package.json files in subdirectories.
340///
341/// Scans two sources for sub-packages:
342/// 1. Common monorepo directory patterns (`packages/`, `apps/`, `libs/`, etc.)
343/// 2. Directories derived from the root package.json `exports` map keys
344///    (e.g., `"./compat": {...}` implies `compat/` may be a sub-package)
345///
346/// For each discovered sub-package with a `package.json`, the `main`, `module`,
347/// `source`, `exports`, and `bin` fields are treated as entry points.
348fn discover_nested_package_entries(
349    root: &Path,
350    _files: &[DiscoveredFile],
351    entries: &mut Vec<EntryPoint>,
352    canonical_root: &Path,
353    exports_subdirectories: &[String],
354) {
355    let mut visited = rustc_hash::FxHashSet::default();
356
357    // 1. Walk common monorepo patterns
358    let search_dirs = [
359        "packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
360    ];
361    for dir_name in &search_dirs {
362        let search_dir = root.join(dir_name);
363        if !search_dir.is_dir() {
364            continue;
365        }
366        let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
367            continue;
368        };
369        for entry in read_dir.flatten() {
370            let pkg_dir = entry.path();
371            if visited.insert(pkg_dir.clone()) {
372                collect_nested_package_entries(&pkg_dir, entries, canonical_root);
373            }
374        }
375    }
376
377    // 2. Scan directories derived from the root exports map
378    for dir_name in exports_subdirectories {
379        let pkg_dir = root.join(dir_name);
380        if pkg_dir.is_dir() && visited.insert(pkg_dir.clone()) {
381            collect_nested_package_entries(&pkg_dir, entries, canonical_root);
382        }
383    }
384}
385
386/// Collect entry points from a single sub-package directory.
387fn collect_nested_package_entries(
388    pkg_dir: &Path,
389    entries: &mut Vec<EntryPoint>,
390    canonical_root: &Path,
391) {
392    let pkg_path = pkg_dir.join("package.json");
393    if !pkg_path.exists() {
394        return;
395    }
396    let Ok(pkg) = PackageJson::load(&pkg_path) else {
397        return;
398    };
399    for entry_path in pkg.entry_points() {
400        if entry_path.contains('*') {
401            expand_wildcard_entries(pkg_dir, &entry_path, canonical_root, entries);
402        } else if let Some(ep) = resolve_entry_path(
403            pkg_dir,
404            &entry_path,
405            canonical_root,
406            EntryPointSource::PackageJsonExports,
407        ) {
408            entries.push(ep);
409        }
410    }
411    if let Some(scripts) = &pkg.scripts {
412        for script_value in scripts.values() {
413            for file_ref in extract_script_file_refs(script_value) {
414                if let Some(ep) = resolve_entry_path(
415                    pkg_dir,
416                    &file_ref,
417                    canonical_root,
418                    EntryPointSource::PackageJsonScript,
419                ) {
420                    entries.push(ep);
421                }
422            }
423        }
424    }
425}
426
427/// Expand wildcard subpath exports to matching files on disk.
428///
429/// Handles patterns like `./src/themes/*.css` from package.json exports maps
430/// (`"./themes/*": { "import": "./src/themes/*.css" }`). Expands the `*` to
431/// match actual files in the target directory.
432fn expand_wildcard_entries(
433    base: &Path,
434    pattern: &str,
435    canonical_root: &Path,
436    entries: &mut Vec<EntryPoint>,
437) {
438    let full_pattern = base.join(pattern).to_string_lossy().to_string();
439    let Ok(matches) = glob::glob(&full_pattern) else {
440        return;
441    };
442    for path_result in matches {
443        let Ok(path) = path_result else {
444            continue;
445        };
446        if let Ok(canonical) = dunce::canonicalize(&path)
447            && canonical.starts_with(canonical_root)
448        {
449            entries.push(EntryPoint {
450                path,
451                source: EntryPointSource::PackageJsonExports,
452            });
453        }
454    }
455}
456
457/// Discover entry points for a workspace package.
458#[must_use]
459pub fn discover_workspace_entry_points(
460    ws_root: &Path,
461    _config: &ResolvedConfig,
462    all_files: &[DiscoveredFile],
463) -> Vec<EntryPoint> {
464    let mut entries = Vec::new();
465
466    let pkg_path = ws_root.join("package.json");
467    if let Ok(pkg) = PackageJson::load(&pkg_path) {
468        let canonical_ws_root =
469            dunce::canonicalize(ws_root).unwrap_or_else(|_| ws_root.to_path_buf());
470        for entry_path in pkg.entry_points() {
471            if entry_path.contains('*') {
472                expand_wildcard_entries(ws_root, &entry_path, &canonical_ws_root, &mut entries);
473            } else if let Some(ep) = resolve_entry_path(
474                ws_root,
475                &entry_path,
476                &canonical_ws_root,
477                EntryPointSource::PackageJsonMain,
478            ) {
479                entries.push(ep);
480            }
481        }
482
483        // Scripts field — extract file references as entry points
484        if let Some(scripts) = &pkg.scripts {
485            for script_value in scripts.values() {
486                for file_ref in extract_script_file_refs(script_value) {
487                    if let Some(ep) = resolve_entry_path(
488                        ws_root,
489                        &file_ref,
490                        &canonical_ws_root,
491                        EntryPointSource::PackageJsonScript,
492                    ) {
493                        entries.push(ep);
494                    }
495                }
496            }
497        }
498
499        // Framework rules now flow through PluginRegistry via external_plugins.
500    }
501
502    // Fall back to default index files if no entry points found for this workspace
503    if entries.is_empty() {
504        entries = apply_default_fallback(all_files, ws_root, Some(ws_root));
505    }
506
507    entries.sort_by(|a, b| a.path.cmp(&b.path));
508    entries.dedup_by(|a, b| a.path == b.path);
509    entries
510}
511
512/// Discover entry points from plugin results (dynamic config parsing).
513///
514/// Converts plugin-discovered patterns and setup files into concrete entry points
515/// by matching them against the discovered file list.
516#[must_use]
517pub fn discover_plugin_entry_points(
518    plugin_result: &crate::plugins::AggregatedPluginResult,
519    config: &ResolvedConfig,
520    files: &[DiscoveredFile],
521) -> Vec<EntryPoint> {
522    discover_plugin_entry_point_sets(plugin_result, config, files).all
523}
524
525/// Discover plugin-derived entry points with runtime/test/support roles preserved.
526#[must_use]
527pub fn discover_plugin_entry_point_sets(
528    plugin_result: &crate::plugins::AggregatedPluginResult,
529    config: &ResolvedConfig,
530    files: &[DiscoveredFile],
531) -> CategorizedEntryPoints {
532    let mut entries = CategorizedEntryPoints::default();
533
534    // Pre-compute relative paths
535    let relative_paths: Vec<String> = files
536        .iter()
537        .map(|f| {
538            f.path
539                .strip_prefix(&config.root)
540                .unwrap_or(&f.path)
541                .to_string_lossy()
542                .into_owned()
543        })
544        .collect();
545
546    // Match plugin entry patterns against files using a single GlobSet for
547    // include globs, then filter candidate matches through any exclusions.
548    let mut builder = globset::GlobSetBuilder::new();
549    let mut glob_meta: Vec<CompiledEntryRule<'_>> = Vec::new();
550    for (rule, pname) in &plugin_result.entry_patterns {
551        if let Some((include, compiled)) = compile_entry_rule(rule, pname, plugin_result) {
552            builder.add(include);
553            glob_meta.push(compiled);
554        }
555    }
556    for (pattern, pname) in plugin_result
557        .discovered_always_used
558        .iter()
559        .chain(plugin_result.always_used.iter())
560        .chain(plugin_result.fixture_patterns.iter())
561    {
562        if let Ok(glob) = globset::GlobBuilder::new(pattern)
563            .literal_separator(true)
564            .build()
565        {
566            builder.add(glob);
567            if let Some(path) = crate::plugins::CompiledPathRule::for_entry_rule(
568                &crate::plugins::PathRule::new(pattern.clone()),
569                "support entry pattern",
570            ) {
571                glob_meta.push(CompiledEntryRule {
572                    path,
573                    plugin_name: pname,
574                    role: EntryPointRole::Support,
575                });
576            }
577        }
578    }
579    if let Ok(glob_set) = builder.build()
580        && !glob_set.is_empty()
581    {
582        for (idx, rel) in relative_paths.iter().enumerate() {
583            let matches: Vec<usize> = glob_set
584                .matches(rel)
585                .into_iter()
586                .filter(|match_idx| glob_meta[*match_idx].matches(rel))
587                .collect();
588            if !matches.is_empty() {
589                let name = glob_meta[matches[0]].plugin_name;
590                let entry = EntryPoint {
591                    path: files[idx].path.clone(),
592                    source: EntryPointSource::Plugin {
593                        name: name.to_string(),
594                    },
595                };
596
597                let mut has_runtime = false;
598                let mut has_test = false;
599                let mut has_support = false;
600                for match_idx in matches {
601                    match glob_meta[match_idx].role {
602                        EntryPointRole::Runtime => has_runtime = true,
603                        EntryPointRole::Test => has_test = true,
604                        EntryPointRole::Support => has_support = true,
605                    }
606                }
607
608                if has_runtime {
609                    entries.push_runtime(entry.clone());
610                }
611                if has_test {
612                    entries.push_test(entry.clone());
613                }
614                if has_support || (!has_runtime && !has_test) {
615                    entries.push_support(entry);
616                }
617            }
618        }
619    }
620
621    // Add setup files (absolute paths from plugin config parsing)
622    for (setup_file, pname) in &plugin_result.setup_files {
623        let resolved = if setup_file.is_absolute() {
624            setup_file.clone()
625        } else {
626            config.root.join(setup_file)
627        };
628        if resolved.exists() {
629            entries.push_support(EntryPoint {
630                path: resolved,
631                source: EntryPointSource::Plugin {
632                    name: pname.clone(),
633                },
634            });
635        } else {
636            // Try with extensions
637            for ext in SOURCE_EXTENSIONS {
638                let with_ext = resolved.with_extension(ext);
639                if with_ext.exists() {
640                    entries.push_support(EntryPoint {
641                        path: with_ext,
642                        source: EntryPointSource::Plugin {
643                            name: pname.clone(),
644                        },
645                    });
646                    break;
647                }
648            }
649        }
650    }
651
652    entries.dedup()
653}
654
655/// Discover entry points from `dynamicallyLoaded` config patterns.
656///
657/// Matches the configured glob patterns against the discovered file list and
658/// marks matching files as entry points so they are never flagged as unused.
659#[must_use]
660pub fn discover_dynamically_loaded_entry_points(
661    config: &ResolvedConfig,
662    files: &[DiscoveredFile],
663) -> Vec<EntryPoint> {
664    if config.dynamically_loaded.is_empty() {
665        return Vec::new();
666    }
667
668    let mut builder = globset::GlobSetBuilder::new();
669    for pattern in &config.dynamically_loaded {
670        if let Ok(glob) = globset::Glob::new(pattern) {
671            builder.add(glob);
672        }
673    }
674    let Ok(glob_set) = builder.build() else {
675        return Vec::new();
676    };
677    if glob_set.is_empty() {
678        return Vec::new();
679    }
680
681    let mut entries = Vec::new();
682    for file in files {
683        let rel = file
684            .path
685            .strip_prefix(&config.root)
686            .unwrap_or(&file.path)
687            .to_string_lossy();
688        if glob_set.is_match(rel.as_ref()) {
689            entries.push(EntryPoint {
690                path: file.path.clone(),
691                source: EntryPointSource::DynamicallyLoaded,
692            });
693        }
694    }
695    entries
696}
697
698struct CompiledEntryRule<'a> {
699    path: crate::plugins::CompiledPathRule,
700    plugin_name: &'a str,
701    role: EntryPointRole,
702}
703
704impl CompiledEntryRule<'_> {
705    fn matches(&self, path: &str) -> bool {
706        self.path.matches(path)
707    }
708}
709
710fn compile_entry_rule<'a>(
711    rule: &'a crate::plugins::PathRule,
712    plugin_name: &'a str,
713    plugin_result: &'a crate::plugins::AggregatedPluginResult,
714) -> Option<(globset::Glob, CompiledEntryRule<'a>)> {
715    let include = match globset::GlobBuilder::new(&rule.pattern)
716        .literal_separator(true)
717        .build()
718    {
719        Ok(glob) => glob,
720        Err(err) => {
721            tracing::warn!("invalid entry pattern '{}': {err}", rule.pattern);
722            return None;
723        }
724    };
725    let role = plugin_result
726        .entry_point_roles
727        .get(plugin_name)
728        .copied()
729        .unwrap_or(EntryPointRole::Support);
730    Some((
731        include,
732        CompiledEntryRule {
733            path: crate::plugins::CompiledPathRule::for_entry_rule(rule, "entry pattern")?,
734            plugin_name,
735            role,
736        },
737    ))
738}
739
740/// Pre-compile a set of glob patterns for efficient matching against many paths.
741#[must_use]
742pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
743    if patterns.is_empty() {
744        return None;
745    }
746    let mut builder = globset::GlobSetBuilder::new();
747    for pattern in patterns {
748        if let Ok(glob) = globset::GlobBuilder::new(pattern)
749            .literal_separator(true)
750            .build()
751        {
752            builder.add(glob);
753        }
754    }
755    builder.build().ok()
756}
757
758#[cfg(test)]
759mod tests {
760    use super::*;
761    use fallow_config::{FallowConfig, OutputFormat, RulesConfig};
762    use fallow_types::discover::FileId;
763    use proptest::prelude::*;
764
765    proptest! {
766        /// Valid glob patterns should never panic when compiled via globset.
767        #[test]
768        fn glob_patterns_never_panic_on_compile(
769            prefix in "[a-zA-Z0-9_]{1,20}",
770            ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
771        ) {
772            let pattern = format!("**/{prefix}*.{ext}");
773            // Should not panic — either compiles or returns Err gracefully
774            let result = globset::Glob::new(&pattern);
775            prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
776        }
777
778        /// Non-source extensions should NOT be in the SOURCE_EXTENSIONS list.
779        #[test]
780        fn non_source_extensions_not_in_list(
781            ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
782        ) {
783            prop_assert!(
784                !SOURCE_EXTENSIONS.contains(&ext),
785                "Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
786            );
787        }
788
789        /// compile_glob_set should never panic on arbitrary well-formed glob patterns.
790        #[test]
791        fn compile_glob_set_no_panic(
792            patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
793        ) {
794            // Should not panic regardless of input
795            let _ = compile_glob_set(&patterns);
796        }
797    }
798
799    // compile_glob_set unit tests
800    #[test]
801    fn compile_glob_set_empty_input() {
802        assert!(
803            compile_glob_set(&[]).is_none(),
804            "empty patterns should return None"
805        );
806    }
807
808    #[test]
809    fn compile_glob_set_valid_patterns() {
810        let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
811        let set = compile_glob_set(&patterns);
812        assert!(set.is_some(), "valid patterns should compile");
813        let set = set.unwrap();
814        assert!(set.is_match("src/foo.ts"));
815        assert!(set.is_match("src/bar.js"));
816        assert!(!set.is_match("src/bar.py"));
817    }
818
819    #[test]
820    fn compile_glob_set_keeps_star_within_a_single_path_segment() {
821        let patterns = vec!["composables/*.{ts,js}".to_string()];
822        let set = compile_glob_set(&patterns).expect("pattern should compile");
823
824        assert!(set.is_match("composables/useFoo.ts"));
825        assert!(!set.is_match("composables/nested/useFoo.ts"));
826    }
827
828    #[test]
829    fn plugin_entry_point_sets_preserve_runtime_test_and_support_roles() {
830        let dir = tempfile::tempdir().expect("create temp dir");
831        let root = dir.path();
832        std::fs::create_dir_all(root.join("src")).unwrap();
833        std::fs::create_dir_all(root.join("tests")).unwrap();
834        std::fs::write(root.join("src/runtime.ts"), "export const runtime = 1;").unwrap();
835        std::fs::write(root.join("src/setup.ts"), "export const setup = 1;").unwrap();
836        std::fs::write(root.join("tests/app.test.ts"), "export const test = 1;").unwrap();
837
838        let config = FallowConfig {
839            schema: None,
840            extends: vec![],
841            entry: vec![],
842            ignore_patterns: vec![],
843            framework: vec![],
844            workspaces: None,
845            ignore_dependencies: vec![],
846            ignore_exports: vec![],
847            duplicates: fallow_config::DuplicatesConfig::default(),
848            health: fallow_config::HealthConfig::default(),
849            rules: RulesConfig::default(),
850            boundaries: fallow_config::BoundaryConfig::default(),
851            production: false,
852            plugins: vec![],
853            dynamically_loaded: vec![],
854            overrides: vec![],
855            regression: None,
856            codeowners: None,
857            public_packages: vec![],
858            flags: fallow_config::FlagsConfig::default(),
859        }
860        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
861
862        let files = vec![
863            DiscoveredFile {
864                id: FileId(0),
865                path: root.join("src/runtime.ts"),
866                size_bytes: 1,
867            },
868            DiscoveredFile {
869                id: FileId(1),
870                path: root.join("src/setup.ts"),
871                size_bytes: 1,
872            },
873            DiscoveredFile {
874                id: FileId(2),
875                path: root.join("tests/app.test.ts"),
876                size_bytes: 1,
877            },
878        ];
879
880        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
881        plugin_result.entry_patterns.push((
882            crate::plugins::PathRule::new("src/runtime.ts"),
883            "runtime-plugin".to_string(),
884        ));
885        plugin_result.entry_patterns.push((
886            crate::plugins::PathRule::new("tests/app.test.ts"),
887            "test-plugin".to_string(),
888        ));
889        plugin_result
890            .always_used
891            .push(("src/setup.ts".to_string(), "support-plugin".to_string()));
892        plugin_result
893            .entry_point_roles
894            .insert("runtime-plugin".to_string(), EntryPointRole::Runtime);
895        plugin_result
896            .entry_point_roles
897            .insert("test-plugin".to_string(), EntryPointRole::Test);
898        plugin_result
899            .entry_point_roles
900            .insert("support-plugin".to_string(), EntryPointRole::Support);
901
902        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
903
904        assert_eq!(entries.runtime.len(), 1, "expected one runtime entry");
905        assert!(
906            entries.runtime[0].path.ends_with("src/runtime.ts"),
907            "runtime entry should stay runtime-only"
908        );
909        assert_eq!(entries.test.len(), 1, "expected one test entry");
910        assert!(
911            entries.test[0].path.ends_with("tests/app.test.ts"),
912            "test entry should stay test-only"
913        );
914        assert_eq!(
915            entries.all.len(),
916            3,
917            "support entries should stay in all entries"
918        );
919        assert!(
920            entries
921                .all
922                .iter()
923                .any(|entry| entry.path.ends_with("src/setup.ts")),
924            "support entries should remain in the overall entry-point set"
925        );
926        assert!(
927            !entries
928                .runtime
929                .iter()
930                .any(|entry| entry.path.ends_with("src/setup.ts")),
931            "support entries should not bleed into runtime reachability"
932        );
933        assert!(
934            !entries
935                .test
936                .iter()
937                .any(|entry| entry.path.ends_with("src/setup.ts")),
938            "support entries should not bleed into test reachability"
939        );
940    }
941
942    #[test]
943    fn plugin_entry_point_rules_respect_exclusions() {
944        let dir = tempfile::tempdir().expect("create temp dir");
945        let root = dir.path();
946        std::fs::create_dir_all(root.join("app/pages")).unwrap();
947        std::fs::write(
948            root.join("app/pages/index.tsx"),
949            "export default function Page() { return null; }",
950        )
951        .unwrap();
952        std::fs::write(
953            root.join("app/pages/-helper.ts"),
954            "export const helper = 1;",
955        )
956        .unwrap();
957
958        let config = FallowConfig {
959            schema: None,
960            extends: vec![],
961            entry: vec![],
962            ignore_patterns: vec![],
963            framework: vec![],
964            workspaces: None,
965            ignore_dependencies: vec![],
966            ignore_exports: vec![],
967            duplicates: fallow_config::DuplicatesConfig::default(),
968            health: fallow_config::HealthConfig::default(),
969            rules: RulesConfig::default(),
970            boundaries: fallow_config::BoundaryConfig::default(),
971            production: false,
972            plugins: vec![],
973            dynamically_loaded: vec![],
974            overrides: vec![],
975            regression: None,
976            codeowners: None,
977            public_packages: vec![],
978            flags: fallow_config::FlagsConfig::default(),
979        }
980        .resolve(root.to_path_buf(), OutputFormat::Human, 4, true, true);
981
982        let files = vec![
983            DiscoveredFile {
984                id: FileId(0),
985                path: root.join("app/pages/index.tsx"),
986                size_bytes: 1,
987            },
988            DiscoveredFile {
989                id: FileId(1),
990                path: root.join("app/pages/-helper.ts"),
991                size_bytes: 1,
992            },
993        ];
994
995        let mut plugin_result = crate::plugins::AggregatedPluginResult::default();
996        plugin_result.entry_patterns.push((
997            crate::plugins::PathRule::new("app/pages/**/*.{ts,tsx,js,jsx}")
998                .with_excluded_globs(["app/pages/**/-*", "app/pages/**/-*/**/*"]),
999            "tanstack-router".to_string(),
1000        ));
1001        plugin_result
1002            .entry_point_roles
1003            .insert("tanstack-router".to_string(), EntryPointRole::Runtime);
1004
1005        let entries = discover_plugin_entry_point_sets(&plugin_result, &config, &files);
1006        let entry_paths: Vec<_> = entries
1007            .all
1008            .iter()
1009            .map(|entry| {
1010                entry
1011                    .path
1012                    .strip_prefix(root)
1013                    .unwrap()
1014                    .to_string_lossy()
1015                    .into_owned()
1016            })
1017            .collect();
1018
1019        assert!(entry_paths.contains(&"app/pages/index.tsx".to_string()));
1020        assert!(!entry_paths.contains(&"app/pages/-helper.ts".to_string()));
1021    }
1022
1023    // resolve_entry_path unit tests
1024    mod resolve_entry_path_tests {
1025        use super::*;
1026
1027        #[test]
1028        fn resolves_existing_file() {
1029            let dir = tempfile::tempdir().expect("create temp dir");
1030            let src = dir.path().join("src");
1031            std::fs::create_dir_all(&src).unwrap();
1032            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1033
1034            let canonical = dunce::canonicalize(dir.path()).unwrap();
1035            let result = resolve_entry_path(
1036                dir.path(),
1037                "src/index.ts",
1038                &canonical,
1039                EntryPointSource::PackageJsonMain,
1040            );
1041            assert!(result.is_some(), "should resolve an existing file");
1042            assert!(result.unwrap().path.ends_with("src/index.ts"));
1043        }
1044
1045        #[test]
1046        fn resolves_with_extension_fallback() {
1047            let dir = tempfile::tempdir().expect("create temp dir");
1048            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1049            let canonical = dunce::canonicalize(dir.path()).unwrap();
1050            let src = canonical.join("src");
1051            std::fs::create_dir_all(&src).unwrap();
1052            std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
1053
1054            // Provide path without extension — should try adding .ts, .tsx, etc.
1055            let result = resolve_entry_path(
1056                &canonical,
1057                "src/index",
1058                &canonical,
1059                EntryPointSource::PackageJsonMain,
1060            );
1061            assert!(
1062                result.is_some(),
1063                "should resolve via extension fallback when exact path doesn't exist"
1064            );
1065            let ep = result.unwrap();
1066            assert!(
1067                ep.path.to_string_lossy().contains("index.ts"),
1068                "should find index.ts via extension fallback"
1069            );
1070        }
1071
1072        #[test]
1073        fn returns_none_for_nonexistent_file() {
1074            let dir = tempfile::tempdir().expect("create temp dir");
1075            let canonical = dunce::canonicalize(dir.path()).unwrap();
1076            let result = resolve_entry_path(
1077                dir.path(),
1078                "does/not/exist.ts",
1079                &canonical,
1080                EntryPointSource::PackageJsonMain,
1081            );
1082            assert!(result.is_none(), "should return None for nonexistent files");
1083        }
1084
1085        #[test]
1086        fn maps_dist_output_to_src() {
1087            let dir = tempfile::tempdir().expect("create temp dir");
1088            let src = dir.path().join("src");
1089            std::fs::create_dir_all(&src).unwrap();
1090            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1091
1092            // Also create the dist/ file to make sure it prefers src/
1093            let dist = dir.path().join("dist");
1094            std::fs::create_dir_all(&dist).unwrap();
1095            std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
1096
1097            let canonical = dunce::canonicalize(dir.path()).unwrap();
1098            let result = resolve_entry_path(
1099                dir.path(),
1100                "./dist/utils.js",
1101                &canonical,
1102                EntryPointSource::PackageJsonExports,
1103            );
1104            assert!(result.is_some(), "should resolve dist/ path to src/");
1105            let ep = result.unwrap();
1106            assert!(
1107                ep.path
1108                    .to_string_lossy()
1109                    .replace('\\', "/")
1110                    .contains("src/utils.ts"),
1111                "should map ./dist/utils.js to src/utils.ts"
1112            );
1113        }
1114
1115        #[test]
1116        fn maps_build_output_to_src() {
1117            let dir = tempfile::tempdir().expect("create temp dir");
1118            // Use canonical base to avoid macOS /var → /private/var symlink mismatch
1119            let canonical = dunce::canonicalize(dir.path()).unwrap();
1120            let src = canonical.join("src");
1121            std::fs::create_dir_all(&src).unwrap();
1122            std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
1123
1124            let result = resolve_entry_path(
1125                &canonical,
1126                "./build/index.js",
1127                &canonical,
1128                EntryPointSource::PackageJsonExports,
1129            );
1130            assert!(result.is_some(), "should map build/ output to src/");
1131            let ep = result.unwrap();
1132            assert!(
1133                ep.path
1134                    .to_string_lossy()
1135                    .replace('\\', "/")
1136                    .contains("src/index.tsx"),
1137                "should map ./build/index.js to src/index.tsx"
1138            );
1139        }
1140
1141        #[test]
1142        fn preserves_entry_point_source() {
1143            let dir = tempfile::tempdir().expect("create temp dir");
1144            std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
1145
1146            let canonical = dunce::canonicalize(dir.path()).unwrap();
1147            let result = resolve_entry_path(
1148                dir.path(),
1149                "index.ts",
1150                &canonical,
1151                EntryPointSource::PackageJsonScript,
1152            );
1153            assert!(result.is_some());
1154            assert!(
1155                matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
1156                "should preserve the source kind"
1157            );
1158        }
1159    }
1160
1161    // try_output_to_source_path unit tests
1162    mod output_to_source_tests {
1163        use super::*;
1164
1165        #[test]
1166        fn maps_dist_to_src_with_ts_extension() {
1167            let dir = tempfile::tempdir().expect("create temp dir");
1168            let src = dir.path().join("src");
1169            std::fs::create_dir_all(&src).unwrap();
1170            std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
1171
1172            let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
1173            assert!(result.is_some());
1174            assert!(
1175                result
1176                    .unwrap()
1177                    .to_string_lossy()
1178                    .replace('\\', "/")
1179                    .contains("src/utils.ts")
1180            );
1181        }
1182
1183        #[test]
1184        fn returns_none_when_no_source_file_exists() {
1185            let dir = tempfile::tempdir().expect("create temp dir");
1186            // No src/ directory at all
1187            let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
1188            assert!(result.is_none());
1189        }
1190
1191        #[test]
1192        fn ignores_non_output_directories() {
1193            let dir = tempfile::tempdir().expect("create temp dir");
1194            let src = dir.path().join("src");
1195            std::fs::create_dir_all(&src).unwrap();
1196            std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
1197
1198            // "lib" is not in OUTPUT_DIRS, so no mapping should occur
1199            let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
1200            assert!(result.is_none());
1201        }
1202
1203        #[test]
1204        fn maps_nested_output_path_preserving_prefix() {
1205            let dir = tempfile::tempdir().expect("create temp dir");
1206            let modules_src = dir.path().join("modules").join("src");
1207            std::fs::create_dir_all(&modules_src).unwrap();
1208            std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
1209
1210            let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
1211            assert!(result.is_some());
1212            assert!(
1213                result
1214                    .unwrap()
1215                    .to_string_lossy()
1216                    .replace('\\', "/")
1217                    .contains("modules/src/helper.ts")
1218            );
1219        }
1220    }
1221
1222    // apply_default_fallback unit tests
1223    mod default_fallback_tests {
1224        use super::*;
1225
1226        #[test]
1227        fn finds_src_index_ts_as_fallback() {
1228            let dir = tempfile::tempdir().expect("create temp dir");
1229            let src = dir.path().join("src");
1230            std::fs::create_dir_all(&src).unwrap();
1231            let index_path = src.join("index.ts");
1232            std::fs::write(&index_path, "export const a = 1;").unwrap();
1233
1234            let files = vec![DiscoveredFile {
1235                id: FileId(0),
1236                path: index_path.clone(),
1237                size_bytes: 20,
1238            }];
1239
1240            let entries = apply_default_fallback(&files, dir.path(), None);
1241            assert_eq!(entries.len(), 1);
1242            assert_eq!(entries[0].path, index_path);
1243            assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
1244        }
1245
1246        #[test]
1247        fn finds_root_index_js_as_fallback() {
1248            let dir = tempfile::tempdir().expect("create temp dir");
1249            let index_path = dir.path().join("index.js");
1250            std::fs::write(&index_path, "module.exports = {};").unwrap();
1251
1252            let files = vec![DiscoveredFile {
1253                id: FileId(0),
1254                path: index_path.clone(),
1255                size_bytes: 21,
1256            }];
1257
1258            let entries = apply_default_fallback(&files, dir.path(), None);
1259            assert_eq!(entries.len(), 1);
1260            assert_eq!(entries[0].path, index_path);
1261        }
1262
1263        #[test]
1264        fn returns_empty_when_no_index_file() {
1265            let dir = tempfile::tempdir().expect("create temp dir");
1266            let other_path = dir.path().join("src").join("utils.ts");
1267
1268            let files = vec![DiscoveredFile {
1269                id: FileId(0),
1270                path: other_path,
1271                size_bytes: 10,
1272            }];
1273
1274            let entries = apply_default_fallback(&files, dir.path(), None);
1275            assert!(
1276                entries.is_empty(),
1277                "non-index files should not match default fallback"
1278            );
1279        }
1280
1281        #[test]
1282        fn workspace_filter_restricts_scope() {
1283            let dir = tempfile::tempdir().expect("create temp dir");
1284            let ws_a = dir.path().join("packages").join("a").join("src");
1285            std::fs::create_dir_all(&ws_a).unwrap();
1286            let ws_b = dir.path().join("packages").join("b").join("src");
1287            std::fs::create_dir_all(&ws_b).unwrap();
1288
1289            let index_a = ws_a.join("index.ts");
1290            let index_b = ws_b.join("index.ts");
1291
1292            let files = vec![
1293                DiscoveredFile {
1294                    id: FileId(0),
1295                    path: index_a.clone(),
1296                    size_bytes: 10,
1297                },
1298                DiscoveredFile {
1299                    id: FileId(1),
1300                    path: index_b,
1301                    size_bytes: 10,
1302                },
1303            ];
1304
1305            // Filter to workspace A only
1306            let ws_root = dir.path().join("packages").join("a");
1307            let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
1308            assert_eq!(entries.len(), 1);
1309            assert_eq!(entries[0].path, index_a);
1310        }
1311    }
1312
1313    // expand_wildcard_entries unit tests
1314    mod wildcard_entry_tests {
1315        use super::*;
1316
1317        #[test]
1318        fn expands_wildcard_css_entries() {
1319            // Wildcard subpath exports like `"./themes/*": { "import": "./src/themes/*.css" }`
1320            // should expand to actual CSS files on disk.
1321            let dir = tempfile::tempdir().expect("create temp dir");
1322            let themes = dir.path().join("src").join("themes");
1323            std::fs::create_dir_all(&themes).unwrap();
1324            std::fs::write(themes.join("dark.css"), ":root { --bg: #000; }").unwrap();
1325            std::fs::write(themes.join("light.css"), ":root { --bg: #fff; }").unwrap();
1326
1327            let canonical = dunce::canonicalize(dir.path()).unwrap();
1328            let mut entries = Vec::new();
1329            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1330
1331            assert_eq!(entries.len(), 2, "should expand wildcard to 2 CSS files");
1332            let paths: Vec<String> = entries
1333                .iter()
1334                .map(|ep| ep.path.file_name().unwrap().to_string_lossy().to_string())
1335                .collect();
1336            assert!(paths.contains(&"dark.css".to_string()));
1337            assert!(paths.contains(&"light.css".to_string()));
1338            assert!(
1339                entries
1340                    .iter()
1341                    .all(|ep| matches!(ep.source, EntryPointSource::PackageJsonExports))
1342            );
1343        }
1344
1345        #[test]
1346        fn wildcard_does_not_match_nonexistent_files() {
1347            let dir = tempfile::tempdir().expect("create temp dir");
1348            // No files matching the pattern
1349            std::fs::create_dir_all(dir.path().join("src/themes")).unwrap();
1350
1351            let canonical = dunce::canonicalize(dir.path()).unwrap();
1352            let mut entries = Vec::new();
1353            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1354
1355            assert!(
1356                entries.is_empty(),
1357                "should return empty when no files match the wildcard"
1358            );
1359        }
1360
1361        #[test]
1362        fn wildcard_only_matches_specified_extension() {
1363            // Wildcard pattern `*.css` should not match `.ts` files
1364            let dir = tempfile::tempdir().expect("create temp dir");
1365            let themes = dir.path().join("src").join("themes");
1366            std::fs::create_dir_all(&themes).unwrap();
1367            std::fs::write(themes.join("dark.css"), ":root {}").unwrap();
1368            std::fs::write(themes.join("index.ts"), "export {};").unwrap();
1369
1370            let canonical = dunce::canonicalize(dir.path()).unwrap();
1371            let mut entries = Vec::new();
1372            expand_wildcard_entries(dir.path(), "./src/themes/*.css", &canonical, &mut entries);
1373
1374            assert_eq!(entries.len(), 1, "should only match CSS files");
1375            assert!(
1376                entries[0]
1377                    .path
1378                    .file_name()
1379                    .unwrap()
1380                    .to_string_lossy()
1381                    .ends_with(".css")
1382            );
1383        }
1384    }
1385}