// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. //! Path utilities for expanding file lists with exclusion and extension filtering. //! //! This module resolves a mixed list of files and directories into a flat list //! of files, applying glob-based exclusions and extension filters. It is used //! by the batch runner to turn mozlint's input paths into the concrete set of //! files each checker should process. use std::path::Path; use globset::{Glob, GlobSet, GlobSetBuilder}; use ignore::overrides::OverrideBuilder; use ignore::types::TypesBuilder; use ignore::WalkBuilder; /// Expands a list of files and directories into individual file paths, /// filtering by `extensions` and removing entries matching `exclude` patterns. /// /// Paths are resolved relative to `root`. When `find_dotfiles` is true, /// hidden files and directories (starting with `.`) are included. pub fn expand_exclusions( paths: &[String], extensions: &[String], exclude: &[String], root: &str, find_dotfiles: bool, ) -> Vec { let root_path = Path::new(root); let excludes: Vec = exclude.iter().map(|e| normalize(e, root_path)).collect(); let glob_excludes = build_glob_excludes(&excludes); let mut result = Vec::new(); for path in paths { let p = Path::new(path); if p.is_file() { if is_excluded(path, &excludes, &glob_excludes) { continue; } result.push(path.clone()); } else if p.is_dir() { walk_directory(path, extensions, &excludes, find_dotfiles, &mut result); } } result } /// Converts a relative path to absolute by joining it with `root`. fn normalize(path: &str, root: &Path) -> String { if Path::new(path).is_absolute() { path.to_string() } else { root.join(path).to_string_lossy().to_string() } } /// Builds a [`GlobSet`] from exclude patterns that contain wildcards. fn build_glob_excludes(excludes: &[String]) -> GlobSet { let mut builder = GlobSetBuilder::new(); for pattern in excludes.iter().filter(|e| e.contains('*')) { if let Ok(glob) = Glob::new(pattern) { builder.add(glob); } } builder .build() .unwrap_or_else(|_| GlobSetBuilder::new().build().unwrap()) } /// Returns `true` if `path` matches any literal prefix or glob in the exclude list. fn is_excluded(path: &str, excludes: &[String], glob_excludes: &GlobSet) -> bool { let p = Path::new(path); excludes .iter() .any(|e| !e.contains('*') && p.starts_with(e)) || glob_excludes.is_match(p) } /// Recursively walks `dir`, collecting files that match `extensions` while /// skipping paths in `excludes`. Uses the `ignore` crate for efficient /// directory traversal with override-based filtering. fn walk_directory( dir: &str, extensions: &[String], excludes: &[String], find_dotfiles: bool, result: &mut Vec, ) { if extensions.is_empty() { return; } let mut builder = WalkBuilder::new(dir); builder .hidden(!find_dotfiles) .ignore(false) .git_ignore(false) .git_global(false) .git_exclude(false); // Use TypesBuilder for extension filtering. let mut types = TypesBuilder::new(); for ext in extensions { let _ = types.add("lint", &format!("*.{ext}")); } types.select("lint"); if let Ok(t) = types.build() { builder.types(t); } // Use overrides for exclusion patterns (both literal paths and globs). let mut ob = OverrideBuilder::new("/"); if !find_dotfiles { let _ = ob.add("!.*"); let _ = ob.add("!.*/**"); } for pattern in excludes { let _ = ob.add(&format!("!{pattern}")); if !pattern.contains('*') { let _ = ob.add(&format!("!{pattern}/**")); } } if let Ok(overrides) = ob.build() { builder.overrides(overrides); } for entry in builder.build().flatten() { let Some(ft) = entry.file_type() else { continue; }; if ft.is_file() { result.push(entry.path().to_string_lossy().to_string()); } } } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn test_expand_exclusions_files() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let f1 = dir.path().join("foo.js"); let f2 = dir.path().join("bar.js"); let f3 = dir.path().join("excluded.js"); fs::write(&f1, "").unwrap(); fs::write(&f2, "").unwrap(); fs::write(&f3, "").unwrap(); let paths = vec![ f1.to_str().unwrap().to_string(), f2.to_str().unwrap().to_string(), f3.to_str().unwrap().to_string(), ]; let exclude = vec![f3.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 2); assert!(result.contains(&f1.to_str().unwrap().to_string())); assert!(result.contains(&f2.to_str().unwrap().to_string())); } #[test] fn test_expand_exclusions_directory() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); fs::write(sub.join("a.js"), "").unwrap(); fs::write(sub.join("b.py"), "").unwrap(); fs::write(sub.join("c.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &[], &root, false); assert_eq!(result.len(), 2); assert!(result.iter().all(|p| Path::new(p) .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("js")))); } #[test] fn test_expand_exclusions_glob_exclude() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); let test_dir = sub.join("test"); fs::create_dir(&test_dir).unwrap(); fs::write(sub.join("main.js"), "").unwrap(); fs::write(test_dir.join("test.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let exclude = vec![format!("{}/**/test/**", root)]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("main.js")); } #[test] fn test_expand_exclusions_no_extensions_skips_dirs() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); fs::write(sub.join("a.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &[], &[], &root, false); assert!(result.is_empty()); } #[test] fn test_expand_exclusions_relative_exclude() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let f1 = dir.path().join("keep.js"); let sub = dir.path().join("skip"); fs::create_dir(&sub).unwrap(); let f2 = sub.join("bad.js"); fs::write(&f1, "").unwrap(); fs::write(&f2, "").unwrap(); let paths = vec![ f1.to_str().unwrap().to_string(), f2.to_str().unwrap().to_string(), ]; let exclude = vec!["skip".to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("keep.js")); } #[test] fn test_expand_exclusions_skips_dotfiles() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); fs::write(sub.join("visible.js"), "").unwrap(); fs::write(sub.join(".hidden.js"), "").unwrap(); let dotdir = sub.join(".dotdir"); fs::create_dir(&dotdir).unwrap(); fs::write(dotdir.join("inside.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &[], &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("visible.js")); } #[test] fn test_exclude_uses_path_components_not_string_prefix() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); let test_dir = sub.join("test"); fs::create_dir(&test_dir).unwrap(); let testing_dir = sub.join("testing"); fs::create_dir(&testing_dir).unwrap(); fs::write(test_dir.join("a.js"), "").unwrap(); fs::write(testing_dir.join("b.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let exclude = vec![test_dir.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); // "testing/b.js" must NOT be excluded by the "test" exclude assert_eq!(result.len(), 1); assert!(result[0].ends_with("testing/b.js")); } #[test] fn test_exclude_specific_file_during_directory_walk() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); fs::write(sub.join("keep.js"), "").unwrap(); fs::write(sub.join("skip.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let exclude = vec![sub.join("skip.js").to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("keep.js")); } #[test] fn test_exclude_prunes_directory_walk() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let sub = dir.path().join("src"); fs::create_dir(&sub).unwrap(); let excluded_dir = sub.join("vendor"); fs::create_dir(&excluded_dir).unwrap(); let deep = excluded_dir.join("deep"); fs::create_dir(&deep).unwrap(); fs::write(sub.join("main.js"), "").unwrap(); fs::write(excluded_dir.join("lib.js"), "").unwrap(); fs::write(deep.join("nested.js"), "").unwrap(); let paths = vec![sub.to_str().unwrap().to_string()]; let exclude = vec![excluded_dir.to_str().unwrap().to_string()]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("main.js")); } #[test] fn test_glob_exclude_on_individual_files() { let dir = tempfile::tempdir().unwrap(); let root = dir.path().to_str().unwrap().to_string(); let f1 = dir.path().join("keep.js"); let f2 = dir.path().join("skip.test.js"); fs::write(&f1, "").unwrap(); fs::write(&f2, "").unwrap(); let paths = vec![ f1.to_str().unwrap().to_string(), f2.to_str().unwrap().to_string(), ]; let exclude = vec![format!("{}/*.test.js", root)]; let result = expand_exclusions(&paths, &["js".to_string()], &exclude, &root, false); assert_eq!(result.len(), 1); assert!(result[0].ends_with("keep.js")); } }