use std::ffi::OsStr;
use std::path::Path;
use std::sync::Mutex;
use fallow_config::ResolvedConfig;
use fallow_types::discover::{DiscoveredFile, FileId};
use ignore::WalkBuilder;
use super::ALLOWED_HIDDEN_DIRS;
struct FileVisitor<'a> {
root: &'a Path,
ignore_patterns: &'a globset::GlobSet,
production_excludes: &'a Option<globset::GlobSet>,
shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
local: Vec<(std::path::PathBuf, u64)>,
}
impl ignore::ParallelVisitor for FileVisitor<'_> {
fn visit(&mut self, result: Result<ignore::DirEntry, ignore::Error>) -> ignore::WalkState {
let Ok(entry) = result else {
return ignore::WalkState::Continue;
};
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
return ignore::WalkState::Continue;
}
let relative = entry
.path()
.strip_prefix(self.root)
.unwrap_or_else(|_| entry.path());
if self.ignore_patterns.is_match(relative) {
return ignore::WalkState::Continue;
}
if self
.production_excludes
.as_ref()
.is_some_and(|excludes| excludes.is_match(relative))
{
return ignore::WalkState::Continue;
}
let size_bytes = entry.metadata().map_or(0, |m| m.len());
self.local.push((entry.into_path(), size_bytes));
ignore::WalkState::Continue
}
}
impl Drop for FileVisitor<'_> {
fn drop(&mut self) {
if !self.local.is_empty() {
self.shared
.lock()
.expect("walk collector lock poisoned")
.append(&mut self.local);
}
}
}
struct FileVisitorBuilder<'a> {
root: &'a Path,
ignore_patterns: &'a globset::GlobSet,
production_excludes: &'a Option<globset::GlobSet>,
shared: &'a Mutex<Vec<(std::path::PathBuf, u64)>>,
}
impl<'s> ignore::ParallelVisitorBuilder<'s> for FileVisitorBuilder<'s> {
fn build(&mut self) -> Box<dyn ignore::ParallelVisitor + 's> {
Box::new(FileVisitor {
root: self.root,
ignore_patterns: self.ignore_patterns,
production_excludes: self.production_excludes,
shared: self.shared,
local: Vec::new(),
})
}
}
pub const SOURCE_EXTENSIONS: &[&str] = &[
"ts", "tsx", "mts", "cts", "js", "jsx", "mjs", "cjs", "vue", "svelte", "astro", "mdx", "css",
"scss", "html",
];
pub const PRODUCTION_EXCLUDE_PATTERNS: &[&str] = &[
"**/*.test.*",
"**/*.spec.*",
"**/*.e2e.*",
"**/*.e2e-spec.*",
"**/*.bench.*",
"**/*.fixture.*",
"**/*.stories.*",
"**/*.story.*",
"**/__tests__/**",
"**/__mocks__/**",
"**/__snapshots__/**",
"**/__fixtures__/**",
"**/test/**",
"**/tests/**",
"*.config.*",
"**/.*.js",
"**/.*.ts",
"**/.*.mjs",
"**/.*.cjs",
];
pub fn is_allowed_hidden_dir(name: &OsStr) -> bool {
ALLOWED_HIDDEN_DIRS.iter().any(|&d| OsStr::new(d) == name)
}
fn is_allowed_hidden(entry: &ignore::DirEntry) -> bool {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with('.') {
return true;
}
if entry.file_type().is_some_and(|ft| !ft.is_dir()) {
return true;
}
is_allowed_hidden_dir(name)
}
#[expect(
clippy::cast_possible_truncation,
reason = "file count is bounded by project size, well under u32::MAX"
)]
pub fn discover_files(config: &ResolvedConfig) -> Vec<DiscoveredFile> {
let _span = tracing::info_span!("discover_files").entered();
let mut types_builder = ignore::types::TypesBuilder::new();
for ext in SOURCE_EXTENSIONS {
types_builder
.add("source", &format!("*.{ext}"))
.expect("valid glob");
}
types_builder.select("source");
let types = types_builder.build().expect("valid types");
let mut walk_builder = WalkBuilder::new(&config.root);
walk_builder
.hidden(false)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.types(types)
.threads(config.threads)
.filter_entry(is_allowed_hidden);
let production_excludes = if config.production {
let mut builder = globset::GlobSetBuilder::new();
for pattern in PRODUCTION_EXCLUDE_PATTERNS {
if let Ok(glob) = globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
{
builder.add(glob);
}
}
builder.build().ok()
} else {
None
};
let collected: Mutex<Vec<(std::path::PathBuf, u64)>> = Mutex::new(Vec::new());
let mut visitor_builder = FileVisitorBuilder {
root: &config.root,
ignore_patterns: &config.ignore_patterns,
production_excludes: &production_excludes,
shared: &collected,
};
walk_builder.build_parallel().visit(&mut visitor_builder);
let mut raw = collected
.into_inner()
.expect("walk collector lock poisoned");
raw.sort_unstable_by(|a, b| a.0.cmp(&b.0));
let files: Vec<DiscoveredFile> = raw
.into_iter()
.enumerate()
.map(|(idx, (path, size_bytes))| DiscoveredFile {
id: FileId(idx as u32),
path,
size_bytes,
})
.collect();
files
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use super::*;
#[test]
fn allowed_hidden_dirs() {
assert!(is_allowed_hidden_dir(OsStr::new(".storybook")));
assert!(is_allowed_hidden_dir(OsStr::new(".vitepress")));
assert!(is_allowed_hidden_dir(OsStr::new(".well-known")));
assert!(is_allowed_hidden_dir(OsStr::new(".changeset")));
assert!(is_allowed_hidden_dir(OsStr::new(".github")));
}
#[test]
fn disallowed_hidden_dirs() {
assert!(!is_allowed_hidden_dir(OsStr::new(".git")));
assert!(!is_allowed_hidden_dir(OsStr::new(".cache")));
assert!(!is_allowed_hidden_dir(OsStr::new(".vscode")));
assert!(!is_allowed_hidden_dir(OsStr::new(".fallow")));
assert!(!is_allowed_hidden_dir(OsStr::new(".next")));
}
#[test]
fn non_hidden_dirs_not_in_allowlist() {
assert!(!is_allowed_hidden_dir(OsStr::new("src")));
assert!(!is_allowed_hidden_dir(OsStr::new("node_modules")));
}
#[test]
fn source_extensions_include_typescript() {
assert!(SOURCE_EXTENSIONS.contains(&"ts"));
assert!(SOURCE_EXTENSIONS.contains(&"tsx"));
assert!(SOURCE_EXTENSIONS.contains(&"mts"));
assert!(SOURCE_EXTENSIONS.contains(&"cts"));
}
#[test]
fn source_extensions_include_javascript() {
assert!(SOURCE_EXTENSIONS.contains(&"js"));
assert!(SOURCE_EXTENSIONS.contains(&"jsx"));
assert!(SOURCE_EXTENSIONS.contains(&"mjs"));
assert!(SOURCE_EXTENSIONS.contains(&"cjs"));
}
#[test]
fn source_extensions_include_sfc_formats() {
assert!(SOURCE_EXTENSIONS.contains(&"vue"));
assert!(SOURCE_EXTENSIONS.contains(&"svelte"));
assert!(SOURCE_EXTENSIONS.contains(&"astro"));
}
#[test]
fn source_extensions_include_styles() {
assert!(SOURCE_EXTENSIONS.contains(&"css"));
assert!(SOURCE_EXTENSIONS.contains(&"scss"));
}
#[test]
fn source_extensions_exclude_non_source() {
assert!(!SOURCE_EXTENSIONS.contains(&"json"));
assert!(!SOURCE_EXTENSIONS.contains(&"yaml"));
assert!(!SOURCE_EXTENSIONS.contains(&"md"));
assert!(!SOURCE_EXTENSIONS.contains(&"png"));
assert!(!SOURCE_EXTENSIONS.contains(&"htm"));
}
#[test]
fn source_extensions_include_html() {
assert!(SOURCE_EXTENSIONS.contains(&"html"));
}
fn build_production_glob_set() -> globset::GlobSet {
let mut builder = globset::GlobSetBuilder::new();
for pattern in PRODUCTION_EXCLUDE_PATTERNS {
builder.add(
globset::GlobBuilder::new(pattern)
.literal_separator(true)
.build()
.expect("valid glob pattern"),
);
}
builder.build().expect("valid glob set")
}
#[test]
fn production_excludes_test_files() {
let set = build_production_glob_set();
assert!(set.is_match("src/Button.test.ts"));
assert!(set.is_match("src/utils.spec.tsx"));
assert!(set.is_match("src/__tests__/helper.ts"));
assert!(!set.is_match("src/Button.ts"));
assert!(!set.is_match("src/utils.tsx"));
}
#[test]
fn production_excludes_story_files() {
let set = build_production_glob_set();
assert!(set.is_match("src/Button.stories.tsx"));
assert!(set.is_match("src/Card.story.ts"));
assert!(!set.is_match("src/Button.tsx"));
}
#[test]
fn production_excludes_config_files_at_root_only() {
let set = build_production_glob_set();
assert!(set.is_match("vitest.config.ts"));
assert!(set.is_match("jest.config.js"));
assert!(!set.is_match("src/app/app.config.ts"));
assert!(!set.is_match("src/app/app.config.server.ts"));
assert!(!set.is_match("packages/foo/vitest.config.ts"));
assert!(!set.is_match("src/config.ts"));
}
#[test]
fn production_patterns_are_valid_globs() {
let _ = build_production_glob_set();
}
#[test]
fn disallowed_hidden_dirs_idea() {
assert!(!is_allowed_hidden_dir(OsStr::new(".idea")));
}
#[test]
fn source_extensions_include_mdx() {
assert!(SOURCE_EXTENSIONS.contains(&"mdx"));
}
#[test]
fn source_extensions_exclude_image_and_data_formats() {
assert!(!SOURCE_EXTENSIONS.contains(&"png"));
assert!(!SOURCE_EXTENSIONS.contains(&"jpg"));
assert!(!SOURCE_EXTENSIONS.contains(&"svg"));
assert!(!SOURCE_EXTENSIONS.contains(&"txt"));
assert!(!SOURCE_EXTENSIONS.contains(&"csv"));
assert!(!SOURCE_EXTENSIONS.contains(&"wasm"));
}
mod discover_files_integration {
use std::path::PathBuf;
use fallow_config::{
DuplicatesConfig, FallowConfig, FlagsConfig, HealthConfig, OutputFormat, RulesConfig,
};
use super::*;
fn make_config(root: PathBuf, production: bool) -> ResolvedConfig {
FallowConfig {
production,
..Default::default()
}
.resolve(root, OutputFormat::Human, 1, true, true)
}
fn file_names(files: &[DiscoveredFile], root: &std::path::Path) -> Vec<String> {
files
.iter()
.map(|f| {
f.path
.strip_prefix(root)
.unwrap_or(&f.path)
.to_string_lossy()
.replace('\\', "/")
})
.collect()
}
#[test]
fn discovers_source_files_with_valid_extensions() {
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("app.ts"), "export const a = 1;").unwrap();
std::fs::write(src.join("component.tsx"), "export default () => {};").unwrap();
std::fs::write(src.join("utils.js"), "module.exports = {};").unwrap();
std::fs::write(src.join("helper.jsx"), "export const h = 1;").unwrap();
std::fs::write(src.join("config.mjs"), "export default {};").unwrap();
std::fs::write(src.join("legacy.cjs"), "module.exports = {};").unwrap();
std::fs::write(src.join("types.mts"), "export type T = string;").unwrap();
std::fs::write(src.join("compat.cts"), "module.exports = {};").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(names.contains(&"src/app.ts".to_string()));
assert!(names.contains(&"src/component.tsx".to_string()));
assert!(names.contains(&"src/utils.js".to_string()));
assert!(names.contains(&"src/helper.jsx".to_string()));
assert!(names.contains(&"src/config.mjs".to_string()));
assert!(names.contains(&"src/legacy.cjs".to_string()));
assert!(names.contains(&"src/types.mts".to_string()));
assert!(names.contains(&"src/compat.cts".to_string()));
}
#[test]
fn excludes_non_source_extensions() {
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("app.ts"), "export const a = 1;").unwrap();
std::fs::write(src.join("data.json"), "{}").unwrap();
std::fs::write(src.join("readme.md"), "# Hello").unwrap();
std::fs::write(src.join("notes.txt"), "notes").unwrap();
std::fs::write(src.join("logo.png"), [0u8; 8]).unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(names.len(), 1, "only the .ts file should be discovered");
assert!(names.contains(&"src/app.ts".to_string()));
}
#[test]
fn excludes_disallowed_hidden_directories() {
let dir = tempfile::tempdir().expect("create temp dir");
let git_dir = dir.path().join(".git");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("hooks.ts"), "// git hook").unwrap();
let idea_dir = dir.path().join(".idea");
std::fs::create_dir_all(&idea_dir).unwrap();
std::fs::write(idea_dir.join("workspace.ts"), "// idea").unwrap();
let cache_dir = dir.path().join(".cache");
std::fs::create_dir_all(&cache_dir).unwrap();
std::fs::write(cache_dir.join("cached.js"), "// cached").unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(names.len(), 1, "only src/app.ts should be discovered");
assert!(names.contains(&"src/app.ts".to_string()));
}
#[test]
fn includes_allowed_hidden_directories() {
let dir = tempfile::tempdir().expect("create temp dir");
let storybook = dir.path().join(".storybook");
std::fs::create_dir_all(&storybook).unwrap();
std::fs::write(storybook.join("main.ts"), "export default {};").unwrap();
let github = dir.path().join(".github");
std::fs::create_dir_all(&github).unwrap();
std::fs::write(github.join("actions.js"), "module.exports = {};").unwrap();
let changeset = dir.path().join(".changeset");
std::fs::create_dir_all(&changeset).unwrap();
std::fs::write(changeset.join("config.js"), "module.exports = {};").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(
names.contains(&".storybook/main.ts".to_string()),
"files in .storybook should be discovered"
);
assert!(
names.contains(&".github/actions.js".to_string()),
"files in .github should be discovered"
);
assert!(
names.contains(&".changeset/config.js".to_string()),
"files in .changeset should be discovered"
);
}
#[test]
fn excludes_root_build_directory() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join(".ignore"), "/build/\n").unwrap();
let build_dir = dir.path().join("build");
std::fs::create_dir_all(&build_dir).unwrap();
std::fs::write(build_dir.join("output.js"), "// build output").unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(names.len(), 1, "root build/ should be excluded via .ignore");
assert!(names.contains(&"src/app.ts".to_string()));
}
#[test]
fn includes_nested_build_directory() {
let dir = tempfile::tempdir().expect("create temp dir");
let nested_build = dir.path().join("src").join("build");
std::fs::create_dir_all(&nested_build).unwrap();
std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(
names.contains(&"src/build/helper.ts".to_string()),
"nested build/ directories should be included"
);
}
#[test]
#[expect(
clippy::cast_possible_truncation,
reason = "test file counts are trivially small"
)]
fn file_ids_are_sequential_after_sorting() {
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("z_last.ts"), "export const z = 1;").unwrap();
std::fs::write(src.join("a_first.ts"), "export const a = 1;").unwrap();
std::fs::write(src.join("m_middle.ts"), "export const m = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
for (idx, file) in files.iter().enumerate() {
assert_eq!(file.id, FileId(idx as u32), "FileId should be sequential");
}
for pair in files.windows(2) {
assert!(
pair[0].path < pair[1].path,
"files should be sorted by path"
);
}
}
#[test]
fn production_mode_excludes_test_files() {
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("app.ts"), "export const a = 1;").unwrap();
std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
std::fs::write(src.join("app.spec.ts"), "describe('a', () => {});").unwrap();
std::fs::write(src.join("app.stories.tsx"), "export default {};").unwrap();
let config = make_config(dir.path().to_path_buf(), true);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(
names.contains(&"src/app.ts".to_string()),
"source files should be included in production mode"
);
assert!(
!names.contains(&"src/app.test.ts".to_string()),
"test files should be excluded in production mode"
);
assert!(
!names.contains(&"src/app.spec.ts".to_string()),
"spec files should be excluded in production mode"
);
assert!(
!names.contains(&"src/app.stories.tsx".to_string()),
"story files should be excluded in production mode"
);
}
#[test]
fn non_production_mode_includes_test_files() {
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("app.ts"), "export const a = 1;").unwrap();
std::fs::write(src.join("app.test.ts"), "test('a', () => {});").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(names.contains(&"src/app.ts".to_string()));
assert!(
names.contains(&"src/app.test.ts".to_string()),
"test files should be included in non-production mode"
);
}
#[test]
fn empty_directory_returns_no_files() {
let dir = tempfile::tempdir().expect("create temp dir");
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
assert!(files.is_empty(), "empty project should discover no files");
}
#[test]
fn hidden_files_not_discovered_as_source() {
let dir = tempfile::tempdir().expect("create temp dir");
std::fs::write(dir.path().join(".env"), "SECRET=abc").unwrap();
std::fs::write(dir.path().join(".gitignore"), "node_modules").unwrap();
std::fs::write(dir.path().join(".eslintrc.js"), "module.exports = {};").unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("app.ts"), "export const a = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert!(
!names.contains(&".env".to_string()),
".env should not be discovered"
);
assert!(
!names.contains(&".gitignore".to_string()),
".gitignore should not be discovered"
);
}
fn make_config_with_ignores(root: PathBuf, ignores: Vec<String>) -> ResolvedConfig {
FallowConfig {
schema: None,
extends: vec![],
entry: vec![],
ignore_patterns: ignores,
framework: vec![],
workspaces: None,
ignore_dependencies: vec![],
ignore_exports: vec![],
used_class_members: vec![],
duplicates: DuplicatesConfig::default(),
health: HealthConfig::default(),
rules: RulesConfig::default(),
boundaries: fallow_config::BoundaryConfig::default(),
production: false,
plugins: vec![],
dynamically_loaded: vec![],
overrides: vec![],
regression: None,
codeowners: None,
public_packages: vec![],
flags: FlagsConfig::default(),
sealed: false,
}
.resolve(root, OutputFormat::Human, 1, true, true)
}
#[test]
fn custom_ignore_patterns_exclude_matching_files() {
let dir = tempfile::tempdir().expect("create temp dir");
let generated = dir.path().join("src").join("api").join("generated");
std::fs::create_dir_all(&generated).unwrap();
std::fs::write(generated.join("client.ts"), "export const api = {};").unwrap();
let client = dir.path().join("src").join("api").join("client");
std::fs::create_dir_all(&client).unwrap();
std::fs::write(client.join("fetch.ts"), "export const fetch = {};").unwrap();
let src = dir.path().join("src");
std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
let config = make_config_with_ignores(
dir.path().to_path_buf(),
vec![
"src/api/generated/**".to_string(),
"src/api/client/**".to_string(),
],
);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(names.len(), 1, "only non-ignored files: {names:?}");
assert!(names.contains(&"src/index.ts".to_string()));
}
#[test]
fn default_ignore_patterns_exclude_node_modules_and_dist() {
let dir = tempfile::tempdir().expect("create temp dir");
let nm = dir.path().join("node_modules").join("lodash");
std::fs::create_dir_all(&nm).unwrap();
std::fs::write(nm.join("lodash.js"), "module.exports = {};").unwrap();
let dist = dir.path().join("dist");
std::fs::create_dir_all(&dist).unwrap();
std::fs::write(dist.join("bundle.js"), "// bundled").unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(names.len(), 1);
assert!(names.contains(&"src/index.ts".to_string()));
}
#[test]
fn default_ignore_patterns_exclude_root_build() {
let dir = tempfile::tempdir().expect("create temp dir");
let build = dir.path().join("build");
std::fs::create_dir_all(&build).unwrap();
std::fs::write(build.join("output.js"), "// built").unwrap();
let nested_build = dir.path().join("src").join("build");
std::fs::create_dir_all(&nested_build).unwrap();
std::fs::write(nested_build.join("helper.ts"), "export const h = 1;").unwrap();
let src = dir.path().join("src");
std::fs::write(src.join("index.ts"), "export const x = 1;").unwrap();
let config = make_config(dir.path().to_path_buf(), false);
let files = discover_files(&config);
let names = file_names(&files, dir.path());
assert_eq!(
names.len(),
2,
"root build/ excluded, nested kept: {names:?}"
);
assert!(names.contains(&"src/index.ts".to_string()));
assert!(names.contains(&"src/build/helper.ts".to_string()));
}
}
}