use std::path::{Path, PathBuf};
use fallow_config::{PackageJson, ResolvedConfig};
use fallow_types::discover::{DiscoveredFile, EntryPoint, EntryPointSource};
use super::parse_scripts::extract_script_file_refs;
use super::walk::SOURCE_EXTENSIONS;
const OUTPUT_DIRS: &[&str] = &["dist", "build", "out", "esm", "cjs"];
pub fn resolve_entry_path(
base: &Path,
entry: &str,
canonical_root: &Path,
source: EntryPointSource,
) -> Option<EntryPoint> {
let resolved = base.join(entry);
let canonical_resolved = resolved.canonicalize().unwrap_or_else(|_| resolved.clone());
if !canonical_resolved.starts_with(canonical_root) {
tracing::warn!(path = %entry, "Skipping entry point outside project root");
return None;
}
if let Some(source_path) = try_output_to_source_path(base, entry) {
if let Ok(canonical_source) = source_path.canonicalize()
&& canonical_source.starts_with(canonical_root)
{
return Some(EntryPoint {
path: source_path,
source,
});
}
}
if resolved.exists() {
return Some(EntryPoint {
path: resolved,
source,
});
}
for ext in SOURCE_EXTENSIONS {
let with_ext = resolved.with_extension(ext);
if with_ext.exists() {
return Some(EntryPoint {
path: with_ext,
source,
});
}
}
None
}
fn try_output_to_source_path(base: &Path, entry: &str) -> Option<PathBuf> {
let entry_path = Path::new(entry);
let components: Vec<_> = entry_path.components().collect();
let output_pos = components.iter().rposition(|c| {
if let std::path::Component::Normal(s) = c
&& let Some(name) = s.to_str()
{
return OUTPUT_DIRS.contains(&name);
}
false
})?;
let prefix: PathBuf = components[..output_pos]
.iter()
.filter(|c| !matches!(c, std::path::Component::CurDir))
.collect();
let suffix: PathBuf = components[output_pos + 1..].iter().collect();
for ext in SOURCE_EXTENSIONS {
let source_candidate = base
.join(&prefix)
.join("src")
.join(suffix.with_extension(ext));
if source_candidate.exists() {
return Some(source_candidate);
}
}
None
}
const DEFAULT_INDEX_PATTERNS: &[&str] = &[
"src/index.{ts,tsx,js,jsx}",
"src/main.{ts,tsx,js,jsx}",
"index.{ts,tsx,js,jsx}",
"main.{ts,tsx,js,jsx}",
];
fn apply_default_fallback(
files: &[DiscoveredFile],
root: &Path,
ws_filter: Option<&Path>,
) -> Vec<EntryPoint> {
let default_matchers: Vec<globset::GlobMatcher> = DEFAULT_INDEX_PATTERNS
.iter()
.filter_map(|p| globset::Glob::new(p).ok().map(|g| g.compile_matcher()))
.collect();
let mut entries = Vec::new();
for file in files {
if let Some(ws_root) = ws_filter
&& file.path.strip_prefix(ws_root).is_err()
{
continue;
}
let relative = file.path.strip_prefix(root).unwrap_or(&file.path);
let relative_str = relative.to_string_lossy();
if default_matchers
.iter()
.any(|m| m.is_match(relative_str.as_ref()))
{
entries.push(EntryPoint {
path: file.path.clone(),
source: EntryPointSource::DefaultIndex,
});
}
}
entries
}
pub fn discover_entry_points(config: &ResolvedConfig, files: &[DiscoveredFile]) -> Vec<EntryPoint> {
let _span = tracing::info_span!("discover_entry_points").entered();
let mut entries = Vec::new();
let relative_paths: Vec<String> = files
.iter()
.map(|f| {
f.path
.strip_prefix(&config.root)
.unwrap_or(&f.path)
.to_string_lossy()
.into_owned()
})
.collect();
{
let mut builder = globset::GlobSetBuilder::new();
for pattern in &config.entry_patterns {
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
}
}
if let Ok(glob_set) = builder.build()
&& !glob_set.is_empty()
{
for (idx, rel) in relative_paths.iter().enumerate() {
if glob_set.is_match(rel) {
entries.push(EntryPoint {
path: files[idx].path.clone(),
source: EntryPointSource::ManualEntry,
});
}
}
}
}
let canonical_root = config
.root
.canonicalize()
.unwrap_or_else(|_| config.root.clone());
let pkg_path = config.root.join("package.json");
if let Ok(pkg) = PackageJson::load(&pkg_path) {
for entry_path in pkg.entry_points() {
if let Some(ep) = resolve_entry_path(
&config.root,
&entry_path,
&canonical_root,
EntryPointSource::PackageJsonMain,
) {
entries.push(ep);
}
}
if let Some(scripts) = &pkg.scripts {
for script_value in scripts.values() {
for file_ref in extract_script_file_refs(script_value) {
if let Some(ep) = resolve_entry_path(
&config.root,
&file_ref,
&canonical_root,
EntryPointSource::PackageJsonScript,
) {
entries.push(ep);
}
}
}
}
}
discover_nested_package_entries(&config.root, files, &mut entries, &canonical_root);
if entries.is_empty() {
entries = apply_default_fallback(files, &config.root, None);
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries.dedup_by(|a, b| a.path == b.path);
entries
}
fn discover_nested_package_entries(
root: &Path,
_files: &[DiscoveredFile],
entries: &mut Vec<EntryPoint>,
canonical_root: &Path,
) {
let search_dirs = [
"packages", "apps", "libs", "modules", "plugins", "services", "tools", "utils",
];
for dir_name in &search_dirs {
let search_dir = root.join(dir_name);
if !search_dir.is_dir() {
continue;
}
let Ok(read_dir) = std::fs::read_dir(&search_dir) else {
continue;
};
for entry in read_dir.flatten() {
let pkg_path = entry.path().join("package.json");
if !pkg_path.exists() {
continue;
}
let Ok(pkg) = PackageJson::load(&pkg_path) else {
continue;
};
let pkg_dir = entry.path();
for entry_path in pkg.entry_points() {
if let Some(ep) = resolve_entry_path(
&pkg_dir,
&entry_path,
canonical_root,
EntryPointSource::PackageJsonExports,
) {
entries.push(ep);
}
}
if let Some(scripts) = &pkg.scripts {
for script_value in scripts.values() {
for file_ref in extract_script_file_refs(script_value) {
if let Some(ep) = resolve_entry_path(
&pkg_dir,
&file_ref,
canonical_root,
EntryPointSource::PackageJsonScript,
) {
entries.push(ep);
}
}
}
}
}
}
}
#[must_use]
pub fn discover_workspace_entry_points(
ws_root: &Path,
_config: &ResolvedConfig,
all_files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
let mut entries = Vec::new();
let pkg_path = ws_root.join("package.json");
if let Ok(pkg) = PackageJson::load(&pkg_path) {
let canonical_ws_root = ws_root
.canonicalize()
.unwrap_or_else(|_| ws_root.to_path_buf());
for entry_path in pkg.entry_points() {
if let Some(ep) = resolve_entry_path(
ws_root,
&entry_path,
&canonical_ws_root,
EntryPointSource::PackageJsonMain,
) {
entries.push(ep);
}
}
if let Some(scripts) = &pkg.scripts {
for script_value in scripts.values() {
for file_ref in extract_script_file_refs(script_value) {
if let Some(ep) = resolve_entry_path(
ws_root,
&file_ref,
&canonical_ws_root,
EntryPointSource::PackageJsonScript,
) {
entries.push(ep);
}
}
}
}
}
if entries.is_empty() {
entries = apply_default_fallback(all_files, ws_root, None);
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries.dedup_by(|a, b| a.path == b.path);
entries
}
#[must_use]
pub fn discover_plugin_entry_points(
plugin_result: &crate::plugins::AggregatedPluginResult,
config: &ResolvedConfig,
files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
let mut entries = Vec::new();
let relative_paths: Vec<String> = files
.iter()
.map(|f| {
f.path
.strip_prefix(&config.root)
.unwrap_or(&f.path)
.to_string_lossy()
.into_owned()
})
.collect();
let mut builder = globset::GlobSetBuilder::new();
let mut glob_plugin_names: Vec<&str> = Vec::new();
for (pattern, pname) in plugin_result
.entry_patterns
.iter()
.chain(plugin_result.discovered_always_used.iter())
.chain(plugin_result.always_used.iter())
.chain(plugin_result.fixture_patterns.iter())
{
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
glob_plugin_names.push(pname);
}
}
if let Ok(glob_set) = builder.build()
&& !glob_set.is_empty()
{
for (idx, rel) in relative_paths.iter().enumerate() {
let matches = glob_set.matches(rel);
if !matches.is_empty() {
let name = glob_plugin_names[matches[0]].to_string();
entries.push(EntryPoint {
path: files[idx].path.clone(),
source: EntryPointSource::Plugin { name },
});
}
}
}
for (setup_file, pname) in &plugin_result.setup_files {
let resolved = if setup_file.is_absolute() {
setup_file.clone()
} else {
config.root.join(setup_file)
};
if resolved.exists() {
entries.push(EntryPoint {
path: resolved,
source: EntryPointSource::Plugin {
name: pname.clone(),
},
});
} else {
for ext in SOURCE_EXTENSIONS {
let with_ext = resolved.with_extension(ext);
if with_ext.exists() {
entries.push(EntryPoint {
path: with_ext,
source: EntryPointSource::Plugin {
name: pname.clone(),
},
});
break;
}
}
}
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
entries.dedup_by(|a, b| a.path == b.path);
entries
}
#[must_use]
pub fn discover_dynamically_loaded_entry_points(
config: &ResolvedConfig,
files: &[DiscoveredFile],
) -> Vec<EntryPoint> {
if config.dynamically_loaded.is_empty() {
return Vec::new();
}
let mut builder = globset::GlobSetBuilder::new();
for pattern in &config.dynamically_loaded {
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
}
}
let Ok(glob_set) = builder.build() else {
return Vec::new();
};
if glob_set.is_empty() {
return Vec::new();
}
let mut entries = Vec::new();
for file in files {
let rel = file
.path
.strip_prefix(&config.root)
.unwrap_or(&file.path)
.to_string_lossy();
if glob_set.is_match(rel.as_ref()) {
entries.push(EntryPoint {
path: file.path.clone(),
source: EntryPointSource::DynamicallyLoaded,
});
}
}
entries
}
#[must_use]
pub fn compile_glob_set(patterns: &[String]) -> Option<globset::GlobSet> {
if patterns.is_empty() {
return None;
}
let mut builder = globset::GlobSetBuilder::new();
for pattern in patterns {
if let Ok(glob) = globset::Glob::new(pattern) {
builder.add(glob);
}
}
builder.build().ok()
}
#[cfg(test)]
mod tests {
use super::*;
use fallow_types::discover::FileId;
use proptest::prelude::*;
proptest! {
#[test]
fn glob_patterns_never_panic_on_compile(
prefix in "[a-zA-Z0-9_]{1,20}",
ext in prop::sample::select(vec!["ts", "tsx", "js", "jsx", "vue", "svelte", "astro", "mdx"]),
) {
let pattern = format!("**/{prefix}*.{ext}");
let result = globset::Glob::new(&pattern);
prop_assert!(result.is_ok(), "Glob::new should not fail for well-formed patterns");
}
#[test]
fn non_source_extensions_not_in_list(
ext in prop::sample::select(vec!["py", "rb", "rs", "go", "java", "html", "xml", "yaml", "toml", "md", "txt", "png", "jpg", "wasm", "lock"]),
) {
prop_assert!(
!SOURCE_EXTENSIONS.contains(&ext),
"Extension '{ext}' should NOT be in SOURCE_EXTENSIONS"
);
}
#[test]
fn compile_glob_set_no_panic(
patterns in prop::collection::vec("[a-zA-Z0-9_*/.]{1,30}", 0..10),
) {
let _ = compile_glob_set(&patterns);
}
}
#[test]
fn compile_glob_set_empty_input() {
assert!(
compile_glob_set(&[]).is_none(),
"empty patterns should return None"
);
}
#[test]
fn compile_glob_set_valid_patterns() {
let patterns = vec!["**/*.ts".to_string(), "src/**/*.js".to_string()];
let set = compile_glob_set(&patterns);
assert!(set.is_some(), "valid patterns should compile");
let set = set.unwrap();
assert!(set.is_match("src/foo.ts"));
assert!(set.is_match("src/bar.js"));
assert!(!set.is_match("src/bar.py"));
}
mod resolve_entry_path_tests {
use super::*;
#[test]
fn resolves_existing_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
let canonical = dir.path().canonicalize().unwrap();
let result = resolve_entry_path(
dir.path(),
"src/index.ts",
&canonical,
EntryPointSource::PackageJsonMain,
);
assert!(result.is_some(), "should resolve an existing file");
assert!(result.unwrap().path.ends_with("src/index.ts"));
}
#[test]
fn resolves_with_extension_fallback() {
let dir = tempfile::tempdir().expect("create temp dir");
let canonical = dir.path().canonicalize().unwrap();
let src = canonical.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("index.ts"), "export const a = 1;").unwrap();
let result = resolve_entry_path(
&canonical,
"src/index",
&canonical,
EntryPointSource::PackageJsonMain,
);
assert!(
result.is_some(),
"should resolve via extension fallback when exact path doesn't exist"
);
let ep = result.unwrap();
assert!(
ep.path.to_string_lossy().contains("index.ts"),
"should find index.ts via extension fallback"
);
}
#[test]
fn returns_none_for_nonexistent_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let canonical = dir.path().canonicalize().unwrap();
let result = resolve_entry_path(
dir.path(),
"does/not/exist.ts",
&canonical,
EntryPointSource::PackageJsonMain,
);
assert!(result.is_none(), "should return None for nonexistent files");
}
#[test]
fn maps_dist_output_to_src() {
let dir = tempfile::tempdir().expect("create temp dir");
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
let dist = dir.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("utils.js"), "// compiled").unwrap();
let canonical = dir.path().canonicalize().unwrap();
let result = resolve_entry_path(
dir.path(),
"./dist/utils.js",
&canonical,
EntryPointSource::PackageJsonExports,
);
assert!(result.is_some(), "should resolve dist/ path to src/");
let ep = result.unwrap();
assert!(
ep.path
.to_string_lossy()
.replace('\\', "/")
.contains("src/utils.ts"),
"should map ./dist/utils.js to src/utils.ts"
);
}
#[test]
fn maps_build_output_to_src() {
let dir = tempfile::tempdir().expect("create temp dir");
let canonical = dir.path().canonicalize().unwrap();
let src = canonical.join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("index.tsx"), "export default () => {};").unwrap();
let result = resolve_entry_path(
&canonical,
"./build/index.js",
&canonical,
EntryPointSource::PackageJsonExports,
);
assert!(result.is_some(), "should map build/ output to src/");
let ep = result.unwrap();
assert!(
ep.path
.to_string_lossy()
.replace('\\', "/")
.contains("src/index.tsx"),
"should map ./build/index.js to src/index.tsx"
);
}
#[test]
fn preserves_entry_point_source() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join("index.ts"), "export const a = 1;").unwrap();
let canonical = dir.path().canonicalize().unwrap();
let result = resolve_entry_path(
dir.path(),
"index.ts",
&canonical,
EntryPointSource::PackageJsonScript,
);
assert!(result.is_some());
assert!(
matches!(result.unwrap().source, EntryPointSource::PackageJsonScript),
"should preserve the source kind"
);
}
}
mod output_to_source_tests {
use super::*;
#[test]
fn maps_dist_to_src_with_ts_extension() {
let dir = tempfile::tempdir().expect("create temp dir");
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("utils.ts"), "export const u = 1;").unwrap();
let result = try_output_to_source_path(dir.path(), "./dist/utils.js");
assert!(result.is_some());
assert!(
result
.unwrap()
.to_string_lossy()
.replace('\\', "/")
.contains("src/utils.ts")
);
}
#[test]
fn returns_none_when_no_source_file_exists() {
let dir = tempfile::tempdir().expect("create temp dir");
let result = try_output_to_source_path(dir.path(), "./dist/missing.js");
assert!(result.is_none());
}
#[test]
fn ignores_non_output_directories() {
let dir = tempfile::tempdir().expect("create temp dir");
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("foo.ts"), "export const f = 1;").unwrap();
let result = try_output_to_source_path(dir.path(), "./lib/foo.js");
assert!(result.is_none());
}
#[test]
fn maps_nested_output_path_preserving_prefix() {
let dir = tempfile::tempdir().expect("create temp dir");
let modules_src = dir.path().join("modules").join("src");
std::fs::create_dir_all(&modules_src).unwrap();
std::fs::write(modules_src.join("helper.ts"), "export const h = 1;").unwrap();
let result = try_output_to_source_path(dir.path(), "./modules/dist/helper.js");
assert!(result.is_some());
assert!(
result
.unwrap()
.to_string_lossy()
.replace('\\', "/")
.contains("modules/src/helper.ts")
);
}
}
mod default_fallback_tests {
use super::*;
#[test]
fn finds_src_index_ts_as_fallback() {
let dir = tempfile::tempdir().expect("create temp dir");
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
let index_path = src.join("index.ts");
std::fs::write(&index_path, "export const a = 1;").unwrap();
let files = vec![DiscoveredFile {
id: FileId(0),
path: index_path.clone(),
size_bytes: 20,
}];
let entries = apply_default_fallback(&files, dir.path(), None);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, index_path);
assert!(matches!(entries[0].source, EntryPointSource::DefaultIndex));
}
#[test]
fn finds_root_index_js_as_fallback() {
let dir = tempfile::tempdir().expect("create temp dir");
let index_path = dir.path().join("index.js");
std::fs::write(&index_path, "module.exports = {};").unwrap();
let files = vec![DiscoveredFile {
id: FileId(0),
path: index_path.clone(),
size_bytes: 21,
}];
let entries = apply_default_fallback(&files, dir.path(), None);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, index_path);
}
#[test]
fn returns_empty_when_no_index_file() {
let dir = tempfile::tempdir().expect("create temp dir");
let other_path = dir.path().join("src").join("utils.ts");
let files = vec![DiscoveredFile {
id: FileId(0),
path: other_path,
size_bytes: 10,
}];
let entries = apply_default_fallback(&files, dir.path(), None);
assert!(
entries.is_empty(),
"non-index files should not match default fallback"
);
}
#[test]
fn workspace_filter_restricts_scope() {
let dir = tempfile::tempdir().expect("create temp dir");
let ws_a = dir.path().join("packages").join("a").join("src");
std::fs::create_dir_all(&ws_a).unwrap();
let ws_b = dir.path().join("packages").join("b").join("src");
std::fs::create_dir_all(&ws_b).unwrap();
let index_a = ws_a.join("index.ts");
let index_b = ws_b.join("index.ts");
let files = vec![
DiscoveredFile {
id: FileId(0),
path: index_a.clone(),
size_bytes: 10,
},
DiscoveredFile {
id: FileId(1),
path: index_b,
size_bytes: 10,
},
];
let ws_root = dir.path().join("packages").join("a");
let entries = apply_default_fallback(&files, &ws_root, Some(&ws_root));
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].path, index_a);
}
}
}