From e3e8806aec771e482899ed3470e920f7b291fa95 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 11:17:08 +0900 Subject: refactor(symfony-finder): extract symfony/finder into the shirabe-symfony-finder crate Move `Symfony\Component\Finder` out of shirabe-external-packages and into its own crate, so the path is `shirabe_symfony_finder::Finder` instead of `shirabe_external_packages::symfony::finder::Finder`. Co-Authored-By: Claude Opus 5 (1M context) --- Cargo.lock | 13 + Cargo.toml | 1 + crates/shirabe-class-map-generator/Cargo.toml | 1 + .../src/class_map_generator.rs | 2 +- crates/shirabe-external-packages/src/symfony.rs | 1 - .../src/symfony/finder.rs | 7 - .../src/symfony/finder/finder.rs | 827 --------------------- .../src/symfony/finder/glob.rs | 466 ------------ .../src/symfony/finder/spl_file_info.rs | 84 --- crates/shirabe-symfony-finder/Cargo.toml | 14 + crates/shirabe-symfony-finder/src/finder.rs | 827 +++++++++++++++++++++ crates/shirabe-symfony-finder/src/glob.rs | 466 ++++++++++++ crates/shirabe-symfony-finder/src/lib.rs | 7 + crates/shirabe-symfony-finder/src/spl_file_info.rs | 84 +++ crates/shirabe/Cargo.toml | 1 + crates/shirabe/src/cache.rs | 2 +- .../shirabe/src/command/create_project_command.rs | 2 +- .../shirabe/src/downloader/archive_downloader.rs | 2 +- .../package/archiver/archivable_files_finder.rs | 2 +- .../src/package/archiver/base_exclude_filter.rs | 2 +- crates/shirabe/src/util/filesystem.rs | 2 +- 21 files changed, 1421 insertions(+), 1392 deletions(-) delete mode 100644 crates/shirabe-external-packages/src/symfony/finder.rs delete mode 100644 crates/shirabe-external-packages/src/symfony/finder/finder.rs delete mode 100644 crates/shirabe-external-packages/src/symfony/finder/glob.rs delete mode 100644 crates/shirabe-external-packages/src/symfony/finder/spl_file_info.rs create mode 100644 crates/shirabe-symfony-finder/Cargo.toml create mode 100644 crates/shirabe-symfony-finder/src/finder.rs create mode 100644 crates/shirabe-symfony-finder/src/glob.rs create mode 100644 crates/shirabe-symfony-finder/src/lib.rs create mode 100644 crates/shirabe-symfony-finder/src/spl_file_info.rs diff --git a/Cargo.lock b/Cargo.lock index db7fcc1b..e05da218 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2105,6 +2105,7 @@ dependencies = [ "shirabe-semver", "shirabe-spdx-licenses", "shirabe-symfony-filesystem", + "shirabe-symfony-finder", "shirabe-symfony-process", "tempfile", "tokio", @@ -2122,6 +2123,7 @@ dependencies = [ "shirabe-external-packages", "shirabe-pcre", "shirabe-php-shim", + "shirabe-symfony-finder", ] [[package]] @@ -2227,6 +2229,17 @@ dependencies = [ "shirabe-php-shim", ] +[[package]] +name = "shirabe-symfony-finder" +version = "0.0.1" +dependencies = [ + "chrono", + "indexmap", + "regex", + "shirabe-pcre", + "shirabe-php-shim", +] + [[package]] name = "shirabe-symfony-process" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index f0a414a0..dfc07f50 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ shirabe-php-src = { path = "crates/shirabe-php-src" } shirabe-semver = { path = "crates/shirabe-semver" } shirabe-spdx-licenses = { path = "crates/shirabe-spdx-licenses" } shirabe-symfony-filesystem = { path = "crates/shirabe-symfony-filesystem" } +shirabe-symfony-finder = { path = "crates/shirabe-symfony-finder" } shirabe-symfony-process = { path = "crates/shirabe-symfony-process" } shirabe-symfony-string = { path = "crates/shirabe-symfony-string" } anyhow = "1.0.102" diff --git a/crates/shirabe-class-map-generator/Cargo.toml b/crates/shirabe-class-map-generator/Cargo.toml index 69889ef7..9143487d 100644 --- a/crates/shirabe-class-map-generator/Cargo.toml +++ b/crates/shirabe-class-map-generator/Cargo.toml @@ -7,6 +7,7 @@ edition.workspace = true shirabe-external-packages.workspace = true shirabe-pcre.workspace = true shirabe-php-shim.workspace = true +shirabe-symfony-finder.workspace = true anyhow.workspace = true indexmap.workspace = true diff --git a/crates/shirabe-class-map-generator/src/class_map_generator.rs b/crates/shirabe-class-map-generator/src/class_map_generator.rs index 4a71b28d..7262fd8a 100644 --- a/crates/shirabe-class-map-generator/src/class_map_generator.rs +++ b/crates/shirabe-class-map-generator/src/class_map_generator.rs @@ -4,13 +4,13 @@ use crate::class_map::ClassMap; use crate::file_list::FileList; use crate::php_file_parser::PhpFileParser; use indexmap::indexmap; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ InvalidArgumentException, LogicException, PATHINFO_EXTENSION, PHP_INT_MAX, RuntimeException, explode, getcwd, implode, is_dir, is_file, pathinfo, php_regex, preg_quote, realpath, str_replace, str_starts_with, stream_get_wrappers, strlen, strpos, strrpos, strtr, substr, }; +use shirabe_symfony_finder::Finder; use std::path::PathBuf; #[derive(Debug)] diff --git a/crates/shirabe-external-packages/src/symfony.rs b/crates/shirabe-external-packages/src/symfony.rs index 1cfe2a47..5b9849fd 100644 --- a/crates/shirabe-external-packages/src/symfony.rs +++ b/crates/shirabe-external-packages/src/symfony.rs @@ -1,2 +1 @@ pub mod console; -pub mod finder; diff --git a/crates/shirabe-external-packages/src/symfony/finder.rs b/crates/shirabe-external-packages/src/symfony/finder.rs deleted file mode 100644 index 4b2d4b84..00000000 --- a/crates/shirabe-external-packages/src/symfony/finder.rs +++ /dev/null @@ -1,7 +0,0 @@ -pub mod finder; -pub mod glob; -pub mod spl_file_info; - -pub use finder::*; -pub use glob::*; -pub use spl_file_info::*; diff --git a/crates/shirabe-external-packages/src/symfony/finder/finder.rs b/crates/shirabe-external-packages/src/symfony/finder/finder.rs deleted file mode 100644 index 4cdcf392..00000000 --- a/crates/shirabe-external-packages/src/symfony/finder/finder.rs +++ /dev/null @@ -1,827 +0,0 @@ -//! ref: composer/vendor/symfony/finder/Finder.php -//! -//! The iterator pipeline of `searchInDirectory()` is reproduced inline here -//! instead of as separate `Iterator\*` classes. Entries are materialized as -//! `PathBuf` (the SplFileInfo replacement); the relative-path information that -//! `RecursiveDirectoryIterator` attaches to each `SplFileInfo` is carried on the -//! private `Entry` struct so the path/exclude filters keep their exact behavior. - -use crate::symfony::finder::glob::Glob; -use chrono::{NaiveDate, NaiveDateTime}; -use indexmap::{IndexMap, IndexSet}; -use shirabe_pcre::{CaptureKey, Preg}; -use shirabe_php_shim::{file_exists, glob, is_dir, php_regex, preg_quote, rtrim}; -use std::path::{Path, PathBuf}; -use std::time::UNIX_EPOCH; - -const IGNORE_VCS_FILES: i64 = 1; -const IGNORE_DOT_FILES: i64 = 2; - -const ONLY_FILES: i64 = 1; -const ONLY_DIRECTORIES: i64 = 2; - -const VCS_PATTERNS: [&str; 9] = [ - ".svn", - "_svn", - "CVS", - "_darcs", - ".arch-params", - ".monotone", - ".bzr", - ".git", - ".hg", -]; - -/// Helper trait so `Finder::exclude` accepts both single strings and slices -/// (PHP's variadic / array argument compatibility). -pub trait IntoFinderExclude { - fn into_exclude(self) -> Vec; -} -impl IntoFinderExclude for &str { - fn into_exclude(self) -> Vec { - vec![self.to_string()] - } -} -impl IntoFinderExclude for String { - fn into_exclude(self) -> Vec { - vec![self] - } -} -impl IntoFinderExclude for &String { - fn into_exclude(self) -> Vec { - vec![self.clone()] - } -} -impl IntoFinderExclude for &[String] { - fn into_exclude(self) -> Vec { - self.to_vec() - } -} -impl IntoFinderExclude for &Vec { - fn into_exclude(self) -> Vec { - self.clone() - } -} -impl IntoFinderExclude for Vec { - fn into_exclude(self) -> Vec { - self - } -} - -/// The sort strategy. Mirrors the `$sort` property which is either `false`, an -/// `Iterator\SortableIterator::SORT_BY_*` constant, or a PHP callback. -enum Sort { - None, - ByName, - ByAccessedTime, - Closure(std::cell::RefCell i64>>), -} - -/// One traversal result, replacing `Symfony\Component\Finder\SplFileInfo`. -struct Entry { - pathname: PathBuf, - /// `getRelativePath()`: the directory of the entry relative to the search root. - relative_path: String, - /// `getRelativePathname()`: the full path of the entry relative to the search root. - relative_pathname: String, - /// `getFilename()`: the basename. - filename: String, - depth: i64, - is_dir: bool, - is_file: bool, -} - -pub struct Finder { - mode: i64, - names: Vec, - not_names: Vec, - exclude: Vec, - filters: Vec bool>>>, - depths: Vec<(String, i64)>, - follow_links: bool, - reverse_sorting: bool, - sort: Sort, - ignore: i64, - dirs: Vec, - dates: Vec<(String, i64)>, - paths: Vec, - not_paths: Vec, -} - -impl std::fmt::Debug for Finder { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("Finder") - .field("mode", &self.mode) - .field("names", &self.names) - .field("not_names", &self.not_names) - .field("exclude", &self.exclude) - .field("depths", &self.depths) - .field("follow_links", &self.follow_links) - .field("ignore", &self.ignore) - .field("dirs", &self.dirs) - .field("dates", &self.dates) - .field("paths", &self.paths) - .field("not_paths", &self.not_paths) - .finish_non_exhaustive() - } -} - -impl Default for Finder { - fn default() -> Self { - Self::new() - } -} - -impl Finder { - pub fn new() -> Self { - Self { - mode: 0, - names: Vec::new(), - not_names: Vec::new(), - exclude: Vec::new(), - filters: Vec::new(), - depths: Vec::new(), - follow_links: false, - reverse_sorting: false, - sort: Sort::None, - ignore: IGNORE_VCS_FILES | IGNORE_DOT_FILES, - dirs: Vec::new(), - dates: Vec::new(), - paths: Vec::new(), - not_paths: Vec::new(), - } - } - - pub fn create() -> Self { - Self::new() - } - - pub fn files(&mut self) -> &mut Self { - self.mode = ONLY_FILES; - - self - } - - pub fn directories(&mut self) -> &mut Self { - self.mode = ONLY_DIRECTORIES; - - self - } - - pub fn depth(&mut self, level: i64) -> &mut Self { - // `NumberComparator` over an integer always yields the `==` operator. - self.depths.push(("==".to_string(), level)); - - self - } - - pub fn r#in(&mut self, dirs: impl AsRef) -> &mut Self { - let dir = dirs.as_ref().to_string_lossy().into_owned(); - let mut resolved_dirs: Vec = Vec::new(); - - if is_dir(&dir) { - resolved_dirs.push(self.normalize_dir(&dir)); - } else { - // GLOB_ONLYDIR is emulated by retaining directory matches only. - // TODO(phase-c): wildcard `in()` paths depend on `shirabe_php_shim::glob`, which is - // still `todo!()`; only the real-directory branch above currently resolves. - let mut globbed: Vec = - glob(&dir).into_iter().filter(|path| is_dir(path)).collect(); - if !globbed.is_empty() { - globbed.sort(); - for g in &globbed { - resolved_dirs.push(self.normalize_dir(g)); - } - } else { - panic!("The \"{dir}\" directory does not exist."); - } - } - - self.dirs.extend(resolved_dirs); - - self - } - - pub fn filter(&mut self, closure: Box bool>) -> &mut Self { - self.filters.push(std::cell::RefCell::new(closure)); - - self - } - - pub fn follow_links(&mut self) -> &mut Self { - self.follow_links = true; - - self - } - - pub fn exclude(&mut self, exclude: E) -> &mut Self { - self.exclude.extend(exclude.into_exclude()); - - self - } - - pub fn ignore_vcs(&mut self, ignore_vcs: bool) -> &mut Self { - if ignore_vcs { - self.ignore |= IGNORE_VCS_FILES; - } else { - self.ignore &= !IGNORE_VCS_FILES; - } - - self - } - - pub fn ignore_dot_files(&mut self, ignore_dot_files: bool) -> &mut Self { - if ignore_dot_files { - self.ignore |= IGNORE_DOT_FILES; - } else { - self.ignore &= !IGNORE_DOT_FILES; - } - - self - } - - pub fn not_name(&mut self, pattern: &str) -> &mut Self { - self.not_names.push(pattern.to_string()); - - self - } - - pub fn not_path(&mut self, pattern: &str) -> &mut Self { - self.not_paths.push(pattern.to_string()); - - self - } - - pub fn name(&mut self, pattern: &str) -> &mut Self { - self.names.push(pattern.to_string()); - - self - } - - pub fn sort(&mut self, comparator: F) -> &mut Self - where - F: FnMut(&PathBuf, &PathBuf) -> i64 + 'static, - { - self.sort = Sort::Closure(std::cell::RefCell::new(Box::new(comparator))); - - self - } - - pub fn sort_by_name(&mut self) -> &mut Self { - self.sort = Sort::ByName; - - self - } - - pub fn sort_by_accessed_time(&mut self) -> &mut Self { - self.sort = Sort::ByAccessedTime; - - self - } - - pub fn date(&mut self, date: &str) -> &mut Self { - self.dates.push(parse_date_comparator(date)); - - self - } - - pub fn get_iterator(&self) -> FinderIterator { - FinderIterator { - items: self.collect_paths(), - pos: 0, - } - } - - pub fn iter(&self) -> impl Iterator { - self.get_iterator() - } - - pub fn len(&self) -> usize { - self.collect_paths().len() - } - - pub fn is_empty(&self) -> bool { - self.len() == 0 - } - - fn normalize_dir(&self, dir: &str) -> String { - if dir == "/" { - return dir.to_string(); - } - - let dir = rtrim(dir, Some("/")); - - if Preg::is_match(php_regex!("#^(ssh2\\.)?s?ftp://#"), &dir) { - format!("{dir}/") - } else { - dir - } - } - - fn collect_paths(&self) -> Vec { - if self.dirs.is_empty() { - panic!("You must call one of in() or append() methods before iterating over a Finder."); - } - - let mut entries: Vec = Vec::new(); - for dir in &self.dirs { - self.search_in_directory(dir, &mut entries); - } - - if !matches!(self.sort, Sort::None) || self.reverse_sorting { - self.apply_sort(&mut entries); - } - - entries.into_iter().map(|entry| entry.pathname).collect() - } - - fn search_in_directory(&self, dir: &str, out: &mut Vec) { - let mut exclude = self.exclude.clone(); - let mut not_paths = self.not_paths.clone(); - - if IGNORE_VCS_FILES == (IGNORE_VCS_FILES & self.ignore) { - exclude.extend(VCS_PATTERNS.iter().map(|p| p.to_string())); - } - - if IGNORE_DOT_FILES == (IGNORE_DOT_FILES & self.ignore) { - not_paths.push("#(^|/)\\..+(/|$)#".to_string()); - } - - let mut min_depth = 0i64; - let mut max_depth = i64::MAX; - for (operator, target) in &self.depths { - match operator.as_str() { - ">" => min_depth = target + 1, - ">=" => min_depth = *target, - "<" => max_depth = target - 1, - "<=" => max_depth = *target, - _ => { - min_depth = *target; - max_depth = *target; - } - } - } - - let (excluded_dirs, excluded_pattern) = build_exclude(&exclude); - - let mut raw: Vec = Vec::new(); - let root = Path::new(dir); - self.walk( - root, - "", - 0, - max_depth, - &excluded_dirs, - &excluded_pattern, - &mut raw, - ); - - let match_names: Vec = self.names.iter().map(|p| to_regex_filename(p)).collect(); - let nomatch_names: Vec = self - .not_names - .iter() - .map(|p| to_regex_filename(p)) - .collect(); - let match_paths: Vec = self.paths.iter().map(|p| to_regex_path(p)).collect(); - let nomatch_paths: Vec = not_paths.iter().map(|p| to_regex_path(p)).collect(); - - let has_name_filter = !self.names.is_empty() || !self.not_names.is_empty(); - let has_path_filter = !self.paths.is_empty() || !not_paths.is_empty(); - - for entry in raw { - if entry.depth < min_depth { - continue; - } - - if self.mode != 0 { - if ONLY_DIRECTORIES == (ONLY_DIRECTORIES & self.mode) && entry.is_file { - continue; - } - if ONLY_FILES == (ONLY_FILES & self.mode) && entry.is_dir { - continue; - } - } - - if has_name_filter && !is_accepted(&entry.filename, &match_names, &nomatch_names) { - continue; - } - - if !self.dates.is_empty() { - if !file_exists(&entry.pathname) { - continue; - } - let filedate = mtime(&entry.pathname); - if !self - .dates - .iter() - .all(|(operator, target)| comparator_test(operator, filedate, *target)) - { - continue; - } - } - - if !self.filters.is_empty() - && !self - .filters - .iter() - .all(|filter| (*filter.borrow_mut())(&entry.pathname)) - { - continue; - } - - if has_path_filter - && !is_accepted(&entry.relative_pathname, &match_paths, &nomatch_paths) - { - continue; - } - - out.push(entry); - } - } - - #[allow(clippy::too_many_arguments)] - fn walk( - &self, - dir: &Path, - relative_dir: &str, - depth: i64, - max_depth: i64, - excluded_dirs: &IndexSet, - excluded_pattern: &Option, - out: &mut Vec, - ) { - // `RecursiveDirectoryIterator::SKIP_DOTS` is implicit: read_dir omits "." and "..". - // TODO(phase-c): unreadable directories are skipped here; the SplFileInfo-less, - // non-fallible iterator signatures cannot surface the AccessDeniedException that PHP - // throws when ignoreUnreadableDirs is false. - let read = match std::fs::read_dir(dir) { - Ok(read) => read, - Err(_) => return, - }; - - for entry in read { - let entry = match entry { - Ok(entry) => entry, - Err(_) => continue, - }; - - let filename = entry.file_name().to_string_lossy().into_owned(); - let pathname = entry.path(); - let relative_pathname = if relative_dir.is_empty() { - filename.clone() - } else { - format!("{relative_dir}/{filename}") - }; - - let metadata = std::fs::metadata(&pathname); - let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); - let is_file = metadata.as_ref().map(|m| m.is_file()).unwrap_or(false); - - let entry = Entry { - pathname: pathname.clone(), - relative_path: relative_dir.to_string(), - relative_pathname: relative_pathname.clone(), - filename, - depth, - is_dir, - is_file, - }; - - if !exclude_accept(&entry, excluded_dirs, excluded_pattern) { - continue; - } - - let is_symlink = std::fs::symlink_metadata(&pathname) - .map(|m| m.file_type().is_symlink()) - .unwrap_or(false); - let can_recurse = is_dir && (self.follow_links || !is_symlink); - - out.push(entry); - - if can_recurse && (max_depth == i64::MAX || depth < max_depth) { - self.walk( - &pathname, - &relative_pathname, - depth + 1, - max_depth, - excluded_dirs, - excluded_pattern, - out, - ); - } - } - } - - fn apply_sort(&self, entries: &mut [Entry]) { - let order: i64 = if self.reverse_sorting { -1 } else { 1 }; - - match &self.sort { - Sort::None => { - if self.reverse_sorting { - entries.reverse(); - } - } - Sort::ByName => { - entries.sort_by(|a, b| { - let pa = realpath_or_pathname(&a.pathname); - let pb = realpath_or_pathname(&b.pathname); - apply_order(pa.as_bytes().cmp(pb.as_bytes()), order) - }); - } - Sort::ByAccessedTime => { - entries.sort_by(|a, b| { - apply_order((atime(&a.pathname) - atime(&b.pathname)).cmp(&0), order) - }); - } - Sort::Closure(comparator) => { - let mut comparator = comparator.borrow_mut(); - entries.sort_by(|a, b| { - let result = (*comparator)(&a.pathname, &b.pathname); - let result = if self.reverse_sorting { - -result - } else { - result - }; - result.cmp(&0) - }); - } - } - } -} - -/// Reproduces `ExcludeDirectoryFilterIterator`'s constructor split between simple -/// directory names and `/`-containing path patterns. -fn build_exclude(directories: &[String]) -> (IndexSet, Option) { - let mut excluded_dirs = IndexSet::new(); - let mut patterns: Vec = Vec::new(); - - for directory in directories { - let directory = rtrim(directory, Some("/")); - // The inner iterator is always recursive, so only `/`-containing names become patterns. - if directory.contains('/') { - patterns.push(preg_quote(&directory, Some('#'))); - } else { - excluded_dirs.insert(directory); - } - } - - let excluded_pattern = if patterns.is_empty() { - None - } else { - Some(format!("#(?:^|/)(?:{})(?:/|$)#", patterns.join("|"))) - }; - - (excluded_dirs, excluded_pattern) -} - -/// `ExcludeDirectoryFilterIterator::accept`. -fn exclude_accept( - entry: &Entry, - excluded_dirs: &IndexSet, - excluded_pattern: &Option, -) -> bool { - if excluded_dirs.contains(&entry.filename) && entry.is_dir { - return false; - } - - if let Some(pattern) = excluded_pattern { - let path = if entry.is_dir { - &entry.relative_pathname - } else { - &entry.relative_path - }; - let path = path.replace('\\', "/"); - - return !Preg::is_match(pattern, &path); - } - - true -} - -/// `FilenameFilterIterator::toRegex`. -fn to_regex_filename(pattern: &str) -> String { - if is_regex(pattern) { - pattern.to_string() - } else { - Glob::to_regex(pattern, true, true) - } -} - -/// `PathFilterIterator::toRegex`. -fn to_regex_path(pattern: &str) -> String { - if is_regex(pattern) { - pattern.to_string() - } else { - format!("/{}/", preg_quote(pattern, Some('/'))) - } -} - -/// `MultiplePcreFilterIterator::isAccepted`. -fn is_accepted(string: &str, match_regexps: &[String], nomatch_regexps: &[String]) -> bool { - for regex in nomatch_regexps { - if Preg::is_match(regex, string) { - return false; - } - } - - if !match_regexps.is_empty() { - for regex in match_regexps { - if Preg::is_match(regex, string) { - return true; - } - } - - return false; - } - - true -} - -/// `MultiplePcreFilterIterator::isRegex`. -fn is_regex(str: &str) -> bool { - // PHP 8.2+ available modifiers. - let available_modifiers = "imsxuADUn"; - - let mut matches: IndexMap = IndexMap::new(); - let pattern = format!("/^(.{{3,}}?)[{available_modifiers}]*$/"); - if Preg::is_match3(&pattern, str, Some(&mut matches)) { - let group = matches - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_default(); - let bytes = group.as_bytes(); - let start = bytes - .first() - .map(|b| (*b as char).to_string()) - .unwrap_or_default(); - let end = bytes - .last() - .map(|b| (*b as char).to_string()) - .unwrap_or_default(); - - if start == end { - return !Preg::is_match(php_regex!("/[*?[:alnum:] \\\\]/"), &start); - } - - for (open, close) in [("{", "}"), ("(", ")"), ("[", "]"), ("<", ">")] { - if start == open && end == close { - return true; - } - } - } - - false -} - -/// `Comparator::test`. -fn comparator_test(operator: &str, test: i64, target: i64) -> bool { - match operator { - ">" => test > target, - ">=" => test >= target, - "<" => test < target, - "<=" => test <= target, - "!=" => test != target, - _ => test == target, - } -} - -/// `DateComparator::__construct`, returning `(operator, target unix timestamp)`. -fn parse_date_comparator(test: &str) -> (String, i64) { - let pattern = "#^\\s*(==|!=|[<>]=?|after|since|before|until)?\\s*(.+?)\\s*$#i"; - let mut matches: IndexMap = IndexMap::new(); - if !Preg::is_match3(pattern, test, Some(&mut matches)) { - panic!("Don't understand \"{test}\" as a date test."); - } - - let date = matches - .get(&CaptureKey::ByIndex(2)) - .cloned() - .unwrap_or_default(); - let target = parse_datetime_to_unix(&date); - - let mut operator = matches - .get(&CaptureKey::ByIndex(1)) - .cloned() - .unwrap_or_else(|| "==".to_string()); - if operator == "since" || operator == "after" { - operator = ">".to_string(); - } - if operator == "until" || operator == "before" { - operator = "<".to_string(); - } - - (operator, target) -} - -/// `(new \DateTime($s))->format('U')`. -/// -/// TODO(phase-c): PHP's `\DateTime` accepts any strtotime() expression, but only the -/// `Y-m-d H:i:s` / `Y-m-d` shapes produced by the callers are parsed here. The components are -/// interpreted as UTC (not PHP's local timezone) so the timestamp round-trips with the -/// `chrono::Utc`-derived thresholds the callers format from. -fn parse_datetime_to_unix(s: &str) -> i64 { - if let Ok(datetime) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { - return datetime.and_utc().timestamp(); - } - if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { - return date - .and_hms_opt(0, 0, 0) - .expect("midnight is always valid") - .and_utc() - .timestamp(); - } - - panic!("\"{s}\" is not a valid date."); -} - -fn apply_order(ordering: std::cmp::Ordering, order: i64) -> std::cmp::Ordering { - if order < 0 { - ordering.reverse() - } else { - ordering - } -} - -/// `$file->getRealPath() ?: $file->getPathname()`. -fn realpath_or_pathname(path: &Path) -> String { - std::fs::canonicalize(path) - .map(|resolved| resolved.to_string_lossy().into_owned()) - .unwrap_or_else(|_| path.to_string_lossy().into_owned()) -} - -fn mtime(path: &Path) -> i64 { - file_unix_time(path, |metadata| metadata.modified()) -} - -fn atime(path: &Path) -> i64 { - file_unix_time(path, |metadata| metadata.accessed()) -} - -fn file_unix_time( - path: &Path, - accessor: fn(&std::fs::Metadata) -> std::io::Result, -) -> i64 { - std::fs::metadata(path) - .ok() - .and_then(|metadata| accessor(&metadata).ok()) - .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) - .map(|duration| duration.as_secs() as i64) - .unwrap_or(0) -} - -#[derive(Debug)] -pub struct FinderIterator { - items: Vec, - pos: usize, -} - -impl FinderIterator { - pub fn valid(&self) -> bool { - self.pos < self.items.len() - } - - pub fn current(&self) -> PathBuf { - self.items[self.pos].clone() - } -} - -impl Iterator for FinderIterator { - type Item = PathBuf; - - fn next(&mut self) -> Option { - if self.pos < self.items.len() { - let item = self.items[self.pos].clone(); - self.pos += 1; - Some(item) - } else { - None - } - } -} - -impl IntoIterator for &Finder { - type Item = PathBuf; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.collect_paths().into_iter() - } -} - -impl IntoIterator for Finder { - type Item = PathBuf; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.collect_paths().into_iter() - } -} - -impl IntoIterator for &mut Finder { - type Item = PathBuf; - type IntoIter = std::vec::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.collect_paths().into_iter() - } -} diff --git a/crates/shirabe-external-packages/src/symfony/finder/glob.rs b/crates/shirabe-external-packages/src/symfony/finder/glob.rs deleted file mode 100644 index 247b772f..00000000 --- a/crates/shirabe-external-packages/src/symfony/finder/glob.rs +++ /dev/null @@ -1,466 +0,0 @@ -//! ref: composer/vendor/symfony/finder/Glob.php - -// Regex pattern compatibility: -// PHP's Glob::toRegex builds its regex in a single character loop and emits PCRE-only -// constructs: the look-ahead `(?=[^\.])` enforcing the strict-leading-dot rule, and a -// possessive `[^/]++` inside the `/**/` construct. The regex crate supports neither, so -// instead of a verbatim port of the loop, the glob is first tokenized (mirroring the -// loop's dispatch exactly) and every no-dot constraint is then resolved by recursive -// union expansion, producing a regex-crate-compatible pattern that matches the same -// inputs: -// -// (?=[^.])c => c (branch is unmatchable when c is `\.`) -// (?=[^.])[^/] => [^/.] -// (?=[^.])[abc] => [[abc]&&[^.]] (character class intersection) -// (?=[^.])[^/]*R => (?:[^/.][^/]*R | expand((?=[^.])R)) -// (?=[^.])(a|b)R => (?:expand((?=[^.])aR) | expand((?=[^.])bR)) -// (?=[^.])$ => branch dropped (no next character can exist) -// (?=[^.])(?:$|/) => / (dir-boundary variant only) -// [^/]++ => [^/]+ (the regex crate never backtracks) -// -// Unmatchable branches are dropped; a wholly unmatchable glob renders as `\b\B`, which -// compiles and never matches, just like PHP's own output for such globs. - -#[derive(Debug)] -pub struct Glob; - -#[derive(Debug, Clone, Copy)] -struct Ctx { - strict_leading_dot: bool, - strict_wildcard_slash: bool, -} - -#[derive(Debug, Clone, Copy)] -enum Terminal { - /// The `$` anchor of `to_regex` follows the body. - EndAnchor, - /// The `(?:$|/)` suffix of `to_regex_dir_boundary` ends the body. - EndOrSlash, -} - -#[derive(Debug, Clone)] -enum Token { - /// A literal regex piece for one glob character, escaped as needed. - Text(String), - /// An unescaped `*`. - Star, - /// An unescaped `?`. - Question, - /// A `/**/` (or trailing `/**`) segment, leading slash included. - Globstar { trailing_slash_optional: bool }, - /// An `{a,b}` alternation. `closed` is false when the `}` is missing. - Group { - alternatives: Vec>, - closed: bool, - }, - /// The position of PHP's `(?=[^\.])` look-ahead. - NoDot, - /// End of the pattern. - End(Terminal), -} - -#[derive(Debug)] -enum Out { - /// Fully rendered. - Ok(String), - /// Cannot match anything; the branch must be dropped. - Dead, - /// A no-dot constraint reached the end of an isolated sub-sequence (e.g. `{a/,b}` - /// puts a look-ahead right before the closing brace); the caller must splice in the - /// following context and re-render. - Escapes, -} - -impl Glob { - pub fn to_regex(glob: &str, strict_leading_dot: bool, strict_wildcard_slash: bool) -> String { - format!( - "#^{}$#", - expand( - glob, - strict_leading_dot, - strict_wildcard_slash, - Terminal::EndAnchor - ) - ) - } - - /// Not part of Symfony's Glob. Composer's BaseExcludeFilter derives its patterns - /// from toRegex output by stripping the delimiters and anchors and appending the - /// `(?=$|/)` dir-boundary look-ahead. Here that suffix has to take part in the - /// union expansion — when a trailing `*` matches zero characters, the no-dot - /// constraint falls onto the boundary itself — so it cannot be appended by the - /// caller after the fact. Returns the undelimited, unanchored body with the - /// boundary woven in as the consuming `(?:$|/)` (equivalent under boolean - /// matching, since nothing follows it). - pub fn to_regex_dir_boundary( - glob: &str, - strict_leading_dot: bool, - strict_wildcard_slash: bool, - ) -> String { - expand( - glob, - strict_leading_dot, - strict_wildcard_slash, - Terminal::EndOrSlash, - ) - } -} - -fn expand( - glob: &str, - strict_leading_dot: bool, - strict_wildcard_slash: bool, - terminal: Terminal, -) -> String { - let ctx = Ctx { - strict_leading_dot, - strict_wildcard_slash, - }; - let mut tokens = tokenize(glob, ctx); - tokens.push(Token::End(terminal)); - match render_seq(&tokens, ctx) { - Out::Ok(body) => body, - Out::Dead => "\\b\\B".to_string(), - Out::Escapes => unreachable!("the terminal token resolves every constraint"), - } -} - -// PHP iterates over bytes; iterating over chars is equivalent for valid UTF-8 because -// every character the loop treats specially is ASCII. -fn tokenize(glob: &str, ctx: Ctx) -> Vec { - let chars: Vec = glob.chars().collect(); - let mut first_byte = true; - let mut escaping = false; - // stack[0] is the root sequence (a pseudo-group with a single alternative); deeper - // frames are open `{` groups, so PHP's $inCurlies == stack.len() - 1. - let mut stack: Vec>> = vec![vec![Vec::new()]]; - - fn push(stack: &mut [Vec>], token: Token) { - stack.last_mut().unwrap().last_mut().unwrap().push(token); - } - - let mut i = 0; - while i < chars.len() { - let car = chars[i]; - if first_byte && ctx.strict_leading_dot && car != '.' { - push(&mut stack, Token::NoDot); - } - - first_byte = car == '/'; - - if first_byte - && ctx.strict_wildcard_slash - && i + 2 < chars.len() - && chars[i + 1] == '*' - && chars[i + 2] == '*' - && (i + 3 >= chars.len() || chars[i + 3] == '/') - { - push( - &mut stack, - Token::Globstar { - trailing_slash_optional: i + 3 >= chars.len(), - }, - ); - i += 2 + usize::from(i + 3 < chars.len()); - escaping = false; - i += 1; - continue; - } - - if car == '#' - || car == '.' - || car == '(' - || car == ')' - || car == '|' - || car == '+' - || car == '^' - || car == '$' - { - push(&mut stack, Token::Text(format!("\\{car}"))); - } else if car == '*' { - push( - &mut stack, - if escaping { - Token::Text("\\*".to_string()) - } else { - Token::Star - }, - ); - } else if car == '?' { - push( - &mut stack, - if escaping { - Token::Text("\\?".to_string()) - } else { - Token::Question - }, - ); - } else if car == '{' { - if escaping { - push(&mut stack, Token::Text("\\{".to_string())); - } else { - stack.push(vec![Vec::new()]); - } - } else if car == '}' && stack.len() > 1 { - if escaping { - push(&mut stack, Token::Text("}".to_string())); - } else { - let alternatives = stack.pop().unwrap(); - push( - &mut stack, - Token::Group { - alternatives, - closed: true, - }, - ); - } - } else if car == ',' && stack.len() > 1 { - if escaping { - push(&mut stack, Token::Text(",".to_string())); - } else { - stack.last_mut().unwrap().push(Vec::new()); - } - } else if car == '\\' { - if escaping { - push(&mut stack, Token::Text("\\\\".to_string())); - escaping = false; - } else { - escaping = true; - } - i += 1; - continue; - } else { - push(&mut stack, Token::Text(car.to_string())); - } - escaping = false; - i += 1; - } - - // PHP leaves an unterminated `(` for an unclosed `{`; keep the broken shape so the - // resulting pattern fails to compile just as the PCRE one does. - while stack.len() > 1 { - let alternatives = stack.pop().unwrap(); - push( - &mut stack, - Token::Group { - alternatives, - closed: false, - }, - ); - } - stack.pop().unwrap().pop().unwrap() -} - -fn star(ctx: Ctx) -> &'static str { - if ctx.strict_wildcard_slash { - "[^/]*" - } else { - ".*" - } -} - -fn question(ctx: Ctx) -> &'static str { - if ctx.strict_wildcard_slash { - "[^/]" - } else { - "." - } -} - -fn globstar(ctx: Ctx, trailing_slash_optional: bool) -> String { - // PHP emits `/(?:(?=[^\.])[^/]++/)*`; `(?=[^\.])[^/]+` collapses to `[^/.][^/]*`. - let step = if ctx.strict_leading_dot { - "[^/.][^/]*" - } else { - "[^/]+" - }; - let slash = if trailing_slash_optional { "/?" } else { "/" }; - format!("/(?:{step}{slash})*") -} - -fn render_seq(tokens: &[Token], ctx: Ctx) -> Out { - let mut out = String::new(); - for (idx, token) in tokens.iter().enumerate() { - match token { - Token::Text(text) => out.push_str(text), - Token::Star => out.push_str(star(ctx)), - Token::Question => out.push_str(question(ctx)), - Token::Globstar { - trailing_slash_optional, - } => out.push_str(&globstar(ctx, *trailing_slash_optional)), - Token::End(Terminal::EndAnchor) => {} - Token::End(Terminal::EndOrSlash) => out.push_str("(?:$|/)"), - Token::NoDot => { - return prefix(out, apply_no_dot(&tokens[idx + 1..], ctx)); - } - Token::Group { - alternatives, - closed, - } => match render_group_isolated(alternatives, *closed, ctx) { - Out::Ok(group) => out.push_str(&group), - Out::Dead => return Out::Dead, - Out::Escapes => { - // A constraint inside the group applies to what follows it; splice - // the remainder into each alternative and re-render. - let rest = &tokens[idx + 1..]; - let mut branches = Vec::new(); - for alternative in alternatives { - let mut seq = alternative.clone(); - seq.extend_from_slice(rest); - match render_seq(&seq, ctx) { - Out::Ok(branch) => branches.push(branch), - Out::Dead => {} - Out::Escapes => return Out::Escapes, - } - } - return match join_branches(branches) { - Some(joined) => Out::Ok(out + &joined), - None => Out::Dead, - }; - } - }, - } - } - Out::Ok(out) -} - -fn render_group_isolated(alternatives: &[Vec], closed: bool, ctx: Ctx) -> Out { - let mut rendered = Vec::new(); - for alternative in alternatives { - match render_seq(alternative, ctx) { - Out::Ok(branch) => rendered.push(branch), - Out::Dead => {} - Out::Escapes => { - if !closed { - // The unterminated `(` makes the output uncompilable either way; - // resolve the dangling constraint as if the pattern ended here. - let mut seq = alternative.clone(); - seq.push(Token::End(Terminal::EndAnchor)); - if let Out::Ok(branch) = render_seq(&seq, ctx) { - rendered.push(branch); - } - } else { - return Out::Escapes; - } - } - } - } - if rendered.is_empty() { - return Out::Dead; - } - let close = if closed { ")" } else { "" }; - Out::Ok(format!("({}{close}", rendered.join("|"))) -} - -/// Renders the remainder of the pattern under the constraint that the next matched -/// character must not be a dot. -fn apply_no_dot(tokens: &[Token], ctx: Ctx) -> Out { - let Some((first, rest)) = tokens.split_first() else { - return Out::Escapes; - }; - match first { - Token::Text(text) if text == "\\." => Out::Dead, - Token::Text(text) if text == "[" => { - if let Some((class, after)) = scan_char_class(tokens) { - prefix( - format!("[{class}&&[^.]]"), - render_seq(&tokens[after..], ctx), - ) - } else { - prefix("[".to_string(), render_seq(rest, ctx)) - } - } - Token::Text(text) => prefix(text.clone(), render_seq(rest, ctx)), - Token::Star => { - // Either the `*` consumes at least one character, which then carries the - // constraint, or it consumes none and the constraint moves past it. - let head = if ctx.strict_wildcard_slash { - "[^/.][^/]*" - } else { - "[^.\\n].*" - }; - let consumed = prefix(head.to_string(), render_seq(rest, ctx)); - let skipped = apply_no_dot(rest, ctx); - match (consumed, skipped) { - (Out::Escapes, _) | (_, Out::Escapes) => Out::Escapes, - (Out::Ok(a), Out::Ok(b)) => Out::Ok(format!("(?:{a}|{b})")), - (Out::Ok(a), Out::Dead) => Out::Ok(a), - (Out::Dead, Out::Ok(b)) => Out::Ok(b), - (Out::Dead, Out::Dead) => Out::Dead, - } - } - Token::Question => { - let head = if ctx.strict_wildcard_slash { - "[^/.]" - } else { - "[^.\\n]" - }; - prefix(head.to_string(), render_seq(rest, ctx)) - } - // The construct starts with a literal `/`, which satisfies the constraint. - Token::Globstar { .. } => render_seq(tokens, ctx), - Token::Group { - alternatives, - closed: true, - } => { - let mut branches = Vec::new(); - for alternative in alternatives { - let mut seq = alternative.clone(); - seq.extend_from_slice(rest); - match apply_no_dot(&seq, ctx) { - Out::Ok(branch) => branches.push(branch), - Out::Dead => {} - Out::Escapes => return Out::Escapes, - } - } - match join_branches(branches) { - Some(joined) => Out::Ok(joined), - None => Out::Dead, - } - } - // The unterminated `(` makes the output uncompilable either way; skip the - // expansion. - Token::Group { closed: false, .. } => render_seq(tokens, ctx), - // `(?=[^.])$`: no next character can exist. - Token::End(Terminal::EndAnchor) => Out::Dead, - // `(?=[^.])(?=$|/)`: the next character must exist, must not be a dot, and must - // be either the end (impossible) or a slash — exactly one consumable `/`. - Token::End(Terminal::EndOrSlash) => Out::Ok("/".to_string()), - Token::NoDot => unreachable!("PHP never emits two consecutive look-aheads"), - } -} - -fn prefix(head: String, tail: Out) -> Out { - match tail { - Out::Ok(tail) => Out::Ok(head + &tail), - other => other, - } -} - -fn join_branches(mut branches: Vec) -> Option { - match branches.len() { - 0 => None, - 1 => branches.pop(), - _ => Some(format!("(?:{})", branches.join("|"))), - } -} - -/// A `[` passes through the PHP loop untouched and opens a character class in the -/// final regex, so a no-dot constraint has to intersect the class rather than be -/// satisfied by the `[` itself. Collects a class spanning plain text tokens; None when -/// no plain closing `]` follows (the `[` is then treated as a literal). -fn scan_char_class(tokens: &[Token]) -> Option<(String, usize)> { - let mut class = String::from("["); - let mut idx = 1; - loop { - match tokens.get(idx)? { - Token::Text(text) if text == "]" => { - class.push(']'); - return Some((class, idx + 1)); - } - Token::Text(text) => class.push_str(text), - _ => return None, - } - idx += 1; - } -} diff --git a/crates/shirabe-external-packages/src/symfony/finder/spl_file_info.rs b/crates/shirabe-external-packages/src/symfony/finder/spl_file_info.rs deleted file mode 100644 index 6f03fe14..00000000 --- a/crates/shirabe-external-packages/src/symfony/finder/spl_file_info.rs +++ /dev/null @@ -1,84 +0,0 @@ -//! ref: composer/vendor/symfony/finder/SplFileInfo.php - -#[derive(Debug)] -pub struct SplFileInfo { - // The path passed to the underlying \SplFileInfo constructor. - pathname: String, - relative_path: String, - relative_pathname: String, -} - -impl std::fmt::Display for SplFileInfo { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{}", self.get_pathname()) - } -} - -impl SplFileInfo { - pub fn new(file: &str, relative_path: &str, relative_pathname: &str) -> Self { - Self { - pathname: file.to_string(), - relative_path: relative_path.to_string(), - relative_pathname: relative_pathname.to_string(), - } - } - - pub fn get_pathname(&self) -> String { - self.pathname.clone() - } - - pub fn get_path(&self) -> String { - shirabe_php_shim::dirname(&self.pathname) - } - - pub fn get_filename(&self) -> String { - shirabe_php_shim::basename(&self.pathname) - } - - pub fn get_basename(&self, suffix: Option<&str>) -> String { - match suffix { - Some(suffix) => shirabe_php_shim::basename_with_suffix(&self.pathname, suffix), - None => shirabe_php_shim::basename(&self.pathname), - } - } - - pub fn get_extension(&self) -> String { - // \SplFileInfo::getExtension() returns the extension (without the dot), or "" if none. - let base = shirabe_php_shim::basename(&self.pathname); - match base.rfind('.') { - Some(index) => base[index + 1..].to_string(), - None => String::new(), - } - } - - pub fn get_relative_path_name(&self) -> String { - self.relative_pathname.clone() - } - - pub fn get_relative_path(&self) -> String { - self.relative_path.clone() - } - - pub fn is_dir(&self) -> bool { - shirabe_php_shim::is_dir(&self.pathname) - } - - pub fn is_file(&self) -> bool { - shirabe_php_shim::is_file(&self.pathname) - } - - pub fn is_link(&self) -> bool { - shirabe_php_shim::is_link(&self.pathname) - } - - pub fn get_real_path(&self) -> Option { - // \SplFileInfo::getRealPath() returns the canonicalized absolute path, or false on failure. - shirabe_php_shim::realpath(&self.pathname) - } - - pub fn get_size(&self) -> i64 { - // \SplFileInfo::getSize() returns the file size in bytes (throws on failure). - // TODO(phase-c): PHP throws a \RuntimeException on stat failure; this returns 0 instead. - shirabe_php_shim::filesize(&self.pathname).unwrap_or(0) - } -} diff --git a/crates/shirabe-symfony-finder/Cargo.toml b/crates/shirabe-symfony-finder/Cargo.toml new file mode 100644 index 00000000..bae1d20a --- /dev/null +++ b/crates/shirabe-symfony-finder/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "shirabe-symfony-finder" +version.workspace = true +edition.workspace = true + +[dependencies] +shirabe-pcre.workspace = true +shirabe-php-shim.workspace = true +chrono.workspace = true +indexmap.workspace = true +regex.workspace = true + +[lints] +workspace = true diff --git a/crates/shirabe-symfony-finder/src/finder.rs b/crates/shirabe-symfony-finder/src/finder.rs new file mode 100644 index 00000000..2ab096e5 --- /dev/null +++ b/crates/shirabe-symfony-finder/src/finder.rs @@ -0,0 +1,827 @@ +//! ref: composer/vendor/symfony/finder/Finder.php +//! +//! The iterator pipeline of `searchInDirectory()` is reproduced inline here +//! instead of as separate `Iterator\*` classes. Entries are materialized as +//! `PathBuf` (the SplFileInfo replacement); the relative-path information that +//! `RecursiveDirectoryIterator` attaches to each `SplFileInfo` is carried on the +//! private `Entry` struct so the path/exclude filters keep their exact behavior. + +use crate::glob::Glob; +use chrono::{NaiveDate, NaiveDateTime}; +use indexmap::{IndexMap, IndexSet}; +use shirabe_pcre::{CaptureKey, Preg}; +use shirabe_php_shim::{file_exists, glob, is_dir, php_regex, preg_quote, rtrim}; +use std::path::{Path, PathBuf}; +use std::time::UNIX_EPOCH; + +const IGNORE_VCS_FILES: i64 = 1; +const IGNORE_DOT_FILES: i64 = 2; + +const ONLY_FILES: i64 = 1; +const ONLY_DIRECTORIES: i64 = 2; + +const VCS_PATTERNS: [&str; 9] = [ + ".svn", + "_svn", + "CVS", + "_darcs", + ".arch-params", + ".monotone", + ".bzr", + ".git", + ".hg", +]; + +/// Helper trait so `Finder::exclude` accepts both single strings and slices +/// (PHP's variadic / array argument compatibility). +pub trait IntoFinderExclude { + fn into_exclude(self) -> Vec; +} +impl IntoFinderExclude for &str { + fn into_exclude(self) -> Vec { + vec![self.to_string()] + } +} +impl IntoFinderExclude for String { + fn into_exclude(self) -> Vec { + vec![self] + } +} +impl IntoFinderExclude for &String { + fn into_exclude(self) -> Vec { + vec![self.clone()] + } +} +impl IntoFinderExclude for &[String] { + fn into_exclude(self) -> Vec { + self.to_vec() + } +} +impl IntoFinderExclude for &Vec { + fn into_exclude(self) -> Vec { + self.clone() + } +} +impl IntoFinderExclude for Vec { + fn into_exclude(self) -> Vec { + self + } +} + +/// The sort strategy. Mirrors the `$sort` property which is either `false`, an +/// `Iterator\SortableIterator::SORT_BY_*` constant, or a PHP callback. +enum Sort { + None, + ByName, + ByAccessedTime, + Closure(std::cell::RefCell i64>>), +} + +/// One traversal result, replacing `Symfony\Component\Finder\SplFileInfo`. +struct Entry { + pathname: PathBuf, + /// `getRelativePath()`: the directory of the entry relative to the search root. + relative_path: String, + /// `getRelativePathname()`: the full path of the entry relative to the search root. + relative_pathname: String, + /// `getFilename()`: the basename. + filename: String, + depth: i64, + is_dir: bool, + is_file: bool, +} + +pub struct Finder { + mode: i64, + names: Vec, + not_names: Vec, + exclude: Vec, + filters: Vec bool>>>, + depths: Vec<(String, i64)>, + follow_links: bool, + reverse_sorting: bool, + sort: Sort, + ignore: i64, + dirs: Vec, + dates: Vec<(String, i64)>, + paths: Vec, + not_paths: Vec, +} + +impl std::fmt::Debug for Finder { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Finder") + .field("mode", &self.mode) + .field("names", &self.names) + .field("not_names", &self.not_names) + .field("exclude", &self.exclude) + .field("depths", &self.depths) + .field("follow_links", &self.follow_links) + .field("ignore", &self.ignore) + .field("dirs", &self.dirs) + .field("dates", &self.dates) + .field("paths", &self.paths) + .field("not_paths", &self.not_paths) + .finish_non_exhaustive() + } +} + +impl Default for Finder { + fn default() -> Self { + Self::new() + } +} + +impl Finder { + pub fn new() -> Self { + Self { + mode: 0, + names: Vec::new(), + not_names: Vec::new(), + exclude: Vec::new(), + filters: Vec::new(), + depths: Vec::new(), + follow_links: false, + reverse_sorting: false, + sort: Sort::None, + ignore: IGNORE_VCS_FILES | IGNORE_DOT_FILES, + dirs: Vec::new(), + dates: Vec::new(), + paths: Vec::new(), + not_paths: Vec::new(), + } + } + + pub fn create() -> Self { + Self::new() + } + + pub fn files(&mut self) -> &mut Self { + self.mode = ONLY_FILES; + + self + } + + pub fn directories(&mut self) -> &mut Self { + self.mode = ONLY_DIRECTORIES; + + self + } + + pub fn depth(&mut self, level: i64) -> &mut Self { + // `NumberComparator` over an integer always yields the `==` operator. + self.depths.push(("==".to_string(), level)); + + self + } + + pub fn r#in(&mut self, dirs: impl AsRef) -> &mut Self { + let dir = dirs.as_ref().to_string_lossy().into_owned(); + let mut resolved_dirs: Vec = Vec::new(); + + if is_dir(&dir) { + resolved_dirs.push(self.normalize_dir(&dir)); + } else { + // GLOB_ONLYDIR is emulated by retaining directory matches only. + // TODO(phase-c): wildcard `in()` paths depend on `shirabe_php_shim::glob`, which is + // still `todo!()`; only the real-directory branch above currently resolves. + let mut globbed: Vec = + glob(&dir).into_iter().filter(|path| is_dir(path)).collect(); + if !globbed.is_empty() { + globbed.sort(); + for g in &globbed { + resolved_dirs.push(self.normalize_dir(g)); + } + } else { + panic!("The \"{dir}\" directory does not exist."); + } + } + + self.dirs.extend(resolved_dirs); + + self + } + + pub fn filter(&mut self, closure: Box bool>) -> &mut Self { + self.filters.push(std::cell::RefCell::new(closure)); + + self + } + + pub fn follow_links(&mut self) -> &mut Self { + self.follow_links = true; + + self + } + + pub fn exclude(&mut self, exclude: E) -> &mut Self { + self.exclude.extend(exclude.into_exclude()); + + self + } + + pub fn ignore_vcs(&mut self, ignore_vcs: bool) -> &mut Self { + if ignore_vcs { + self.ignore |= IGNORE_VCS_FILES; + } else { + self.ignore &= !IGNORE_VCS_FILES; + } + + self + } + + pub fn ignore_dot_files(&mut self, ignore_dot_files: bool) -> &mut Self { + if ignore_dot_files { + self.ignore |= IGNORE_DOT_FILES; + } else { + self.ignore &= !IGNORE_DOT_FILES; + } + + self + } + + pub fn not_name(&mut self, pattern: &str) -> &mut Self { + self.not_names.push(pattern.to_string()); + + self + } + + pub fn not_path(&mut self, pattern: &str) -> &mut Self { + self.not_paths.push(pattern.to_string()); + + self + } + + pub fn name(&mut self, pattern: &str) -> &mut Self { + self.names.push(pattern.to_string()); + + self + } + + pub fn sort(&mut self, comparator: F) -> &mut Self + where + F: FnMut(&PathBuf, &PathBuf) -> i64 + 'static, + { + self.sort = Sort::Closure(std::cell::RefCell::new(Box::new(comparator))); + + self + } + + pub fn sort_by_name(&mut self) -> &mut Self { + self.sort = Sort::ByName; + + self + } + + pub fn sort_by_accessed_time(&mut self) -> &mut Self { + self.sort = Sort::ByAccessedTime; + + self + } + + pub fn date(&mut self, date: &str) -> &mut Self { + self.dates.push(parse_date_comparator(date)); + + self + } + + pub fn get_iterator(&self) -> FinderIterator { + FinderIterator { + items: self.collect_paths(), + pos: 0, + } + } + + pub fn iter(&self) -> impl Iterator { + self.get_iterator() + } + + pub fn len(&self) -> usize { + self.collect_paths().len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + fn normalize_dir(&self, dir: &str) -> String { + if dir == "/" { + return dir.to_string(); + } + + let dir = rtrim(dir, Some("/")); + + if Preg::is_match(php_regex!("#^(ssh2\\.)?s?ftp://#"), &dir) { + format!("{dir}/") + } else { + dir + } + } + + fn collect_paths(&self) -> Vec { + if self.dirs.is_empty() { + panic!("You must call one of in() or append() methods before iterating over a Finder."); + } + + let mut entries: Vec = Vec::new(); + for dir in &self.dirs { + self.search_in_directory(dir, &mut entries); + } + + if !matches!(self.sort, Sort::None) || self.reverse_sorting { + self.apply_sort(&mut entries); + } + + entries.into_iter().map(|entry| entry.pathname).collect() + } + + fn search_in_directory(&self, dir: &str, out: &mut Vec) { + let mut exclude = self.exclude.clone(); + let mut not_paths = self.not_paths.clone(); + + if IGNORE_VCS_FILES == (IGNORE_VCS_FILES & self.ignore) { + exclude.extend(VCS_PATTERNS.iter().map(|p| p.to_string())); + } + + if IGNORE_DOT_FILES == (IGNORE_DOT_FILES & self.ignore) { + not_paths.push("#(^|/)\\..+(/|$)#".to_string()); + } + + let mut min_depth = 0i64; + let mut max_depth = i64::MAX; + for (operator, target) in &self.depths { + match operator.as_str() { + ">" => min_depth = target + 1, + ">=" => min_depth = *target, + "<" => max_depth = target - 1, + "<=" => max_depth = *target, + _ => { + min_depth = *target; + max_depth = *target; + } + } + } + + let (excluded_dirs, excluded_pattern) = build_exclude(&exclude); + + let mut raw: Vec = Vec::new(); + let root = Path::new(dir); + self.walk( + root, + "", + 0, + max_depth, + &excluded_dirs, + &excluded_pattern, + &mut raw, + ); + + let match_names: Vec = self.names.iter().map(|p| to_regex_filename(p)).collect(); + let nomatch_names: Vec = self + .not_names + .iter() + .map(|p| to_regex_filename(p)) + .collect(); + let match_paths: Vec = self.paths.iter().map(|p| to_regex_path(p)).collect(); + let nomatch_paths: Vec = not_paths.iter().map(|p| to_regex_path(p)).collect(); + + let has_name_filter = !self.names.is_empty() || !self.not_names.is_empty(); + let has_path_filter = !self.paths.is_empty() || !not_paths.is_empty(); + + for entry in raw { + if entry.depth < min_depth { + continue; + } + + if self.mode != 0 { + if ONLY_DIRECTORIES == (ONLY_DIRECTORIES & self.mode) && entry.is_file { + continue; + } + if ONLY_FILES == (ONLY_FILES & self.mode) && entry.is_dir { + continue; + } + } + + if has_name_filter && !is_accepted(&entry.filename, &match_names, &nomatch_names) { + continue; + } + + if !self.dates.is_empty() { + if !file_exists(&entry.pathname) { + continue; + } + let filedate = mtime(&entry.pathname); + if !self + .dates + .iter() + .all(|(operator, target)| comparator_test(operator, filedate, *target)) + { + continue; + } + } + + if !self.filters.is_empty() + && !self + .filters + .iter() + .all(|filter| (*filter.borrow_mut())(&entry.pathname)) + { + continue; + } + + if has_path_filter + && !is_accepted(&entry.relative_pathname, &match_paths, &nomatch_paths) + { + continue; + } + + out.push(entry); + } + } + + #[allow(clippy::too_many_arguments)] + fn walk( + &self, + dir: &Path, + relative_dir: &str, + depth: i64, + max_depth: i64, + excluded_dirs: &IndexSet, + excluded_pattern: &Option, + out: &mut Vec, + ) { + // `RecursiveDirectoryIterator::SKIP_DOTS` is implicit: read_dir omits "." and "..". + // TODO(phase-c): unreadable directories are skipped here; the SplFileInfo-less, + // non-fallible iterator signatures cannot surface the AccessDeniedException that PHP + // throws when ignoreUnreadableDirs is false. + let read = match std::fs::read_dir(dir) { + Ok(read) => read, + Err(_) => return, + }; + + for entry in read { + let entry = match entry { + Ok(entry) => entry, + Err(_) => continue, + }; + + let filename = entry.file_name().to_string_lossy().into_owned(); + let pathname = entry.path(); + let relative_pathname = if relative_dir.is_empty() { + filename.clone() + } else { + format!("{relative_dir}/{filename}") + }; + + let metadata = std::fs::metadata(&pathname); + let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false); + let is_file = metadata.as_ref().map(|m| m.is_file()).unwrap_or(false); + + let entry = Entry { + pathname: pathname.clone(), + relative_path: relative_dir.to_string(), + relative_pathname: relative_pathname.clone(), + filename, + depth, + is_dir, + is_file, + }; + + if !exclude_accept(&entry, excluded_dirs, excluded_pattern) { + continue; + } + + let is_symlink = std::fs::symlink_metadata(&pathname) + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + let can_recurse = is_dir && (self.follow_links || !is_symlink); + + out.push(entry); + + if can_recurse && (max_depth == i64::MAX || depth < max_depth) { + self.walk( + &pathname, + &relative_pathname, + depth + 1, + max_depth, + excluded_dirs, + excluded_pattern, + out, + ); + } + } + } + + fn apply_sort(&self, entries: &mut [Entry]) { + let order: i64 = if self.reverse_sorting { -1 } else { 1 }; + + match &self.sort { + Sort::None => { + if self.reverse_sorting { + entries.reverse(); + } + } + Sort::ByName => { + entries.sort_by(|a, b| { + let pa = realpath_or_pathname(&a.pathname); + let pb = realpath_or_pathname(&b.pathname); + apply_order(pa.as_bytes().cmp(pb.as_bytes()), order) + }); + } + Sort::ByAccessedTime => { + entries.sort_by(|a, b| { + apply_order((atime(&a.pathname) - atime(&b.pathname)).cmp(&0), order) + }); + } + Sort::Closure(comparator) => { + let mut comparator = comparator.borrow_mut(); + entries.sort_by(|a, b| { + let result = (*comparator)(&a.pathname, &b.pathname); + let result = if self.reverse_sorting { + -result + } else { + result + }; + result.cmp(&0) + }); + } + } + } +} + +/// Reproduces `ExcludeDirectoryFilterIterator`'s constructor split between simple +/// directory names and `/`-containing path patterns. +fn build_exclude(directories: &[String]) -> (IndexSet, Option) { + let mut excluded_dirs = IndexSet::new(); + let mut patterns: Vec = Vec::new(); + + for directory in directories { + let directory = rtrim(directory, Some("/")); + // The inner iterator is always recursive, so only `/`-containing names become patterns. + if directory.contains('/') { + patterns.push(preg_quote(&directory, Some('#'))); + } else { + excluded_dirs.insert(directory); + } + } + + let excluded_pattern = if patterns.is_empty() { + None + } else { + Some(format!("#(?:^|/)(?:{})(?:/|$)#", patterns.join("|"))) + }; + + (excluded_dirs, excluded_pattern) +} + +/// `ExcludeDirectoryFilterIterator::accept`. +fn exclude_accept( + entry: &Entry, + excluded_dirs: &IndexSet, + excluded_pattern: &Option, +) -> bool { + if excluded_dirs.contains(&entry.filename) && entry.is_dir { + return false; + } + + if let Some(pattern) = excluded_pattern { + let path = if entry.is_dir { + &entry.relative_pathname + } else { + &entry.relative_path + }; + let path = path.replace('\\', "/"); + + return !Preg::is_match(pattern, &path); + } + + true +} + +/// `FilenameFilterIterator::toRegex`. +fn to_regex_filename(pattern: &str) -> String { + if is_regex(pattern) { + pattern.to_string() + } else { + Glob::to_regex(pattern, true, true) + } +} + +/// `PathFilterIterator::toRegex`. +fn to_regex_path(pattern: &str) -> String { + if is_regex(pattern) { + pattern.to_string() + } else { + format!("/{}/", preg_quote(pattern, Some('/'))) + } +} + +/// `MultiplePcreFilterIterator::isAccepted`. +fn is_accepted(string: &str, match_regexps: &[String], nomatch_regexps: &[String]) -> bool { + for regex in nomatch_regexps { + if Preg::is_match(regex, string) { + return false; + } + } + + if !match_regexps.is_empty() { + for regex in match_regexps { + if Preg::is_match(regex, string) { + return true; + } + } + + return false; + } + + true +} + +/// `MultiplePcreFilterIterator::isRegex`. +fn is_regex(str: &str) -> bool { + // PHP 8.2+ available modifiers. + let available_modifiers = "imsxuADUn"; + + let mut matches: IndexMap = IndexMap::new(); + let pattern = format!("/^(.{{3,}}?)[{available_modifiers}]*$/"); + if Preg::is_match3(&pattern, str, Some(&mut matches)) { + let group = matches + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_default(); + let bytes = group.as_bytes(); + let start = bytes + .first() + .map(|b| (*b as char).to_string()) + .unwrap_or_default(); + let end = bytes + .last() + .map(|b| (*b as char).to_string()) + .unwrap_or_default(); + + if start == end { + return !Preg::is_match(php_regex!("/[*?[:alnum:] \\\\]/"), &start); + } + + for (open, close) in [("{", "}"), ("(", ")"), ("[", "]"), ("<", ">")] { + if start == open && end == close { + return true; + } + } + } + + false +} + +/// `Comparator::test`. +fn comparator_test(operator: &str, test: i64, target: i64) -> bool { + match operator { + ">" => test > target, + ">=" => test >= target, + "<" => test < target, + "<=" => test <= target, + "!=" => test != target, + _ => test == target, + } +} + +/// `DateComparator::__construct`, returning `(operator, target unix timestamp)`. +fn parse_date_comparator(test: &str) -> (String, i64) { + let pattern = "#^\\s*(==|!=|[<>]=?|after|since|before|until)?\\s*(.+?)\\s*$#i"; + let mut matches: IndexMap = IndexMap::new(); + if !Preg::is_match3(pattern, test, Some(&mut matches)) { + panic!("Don't understand \"{test}\" as a date test."); + } + + let date = matches + .get(&CaptureKey::ByIndex(2)) + .cloned() + .unwrap_or_default(); + let target = parse_datetime_to_unix(&date); + + let mut operator = matches + .get(&CaptureKey::ByIndex(1)) + .cloned() + .unwrap_or_else(|| "==".to_string()); + if operator == "since" || operator == "after" { + operator = ">".to_string(); + } + if operator == "until" || operator == "before" { + operator = "<".to_string(); + } + + (operator, target) +} + +/// `(new \DateTime($s))->format('U')`. +/// +/// TODO(phase-c): PHP's `\DateTime` accepts any strtotime() expression, but only the +/// `Y-m-d H:i:s` / `Y-m-d` shapes produced by the callers are parsed here. The components are +/// interpreted as UTC (not PHP's local timezone) so the timestamp round-trips with the +/// `chrono::Utc`-derived thresholds the callers format from. +fn parse_datetime_to_unix(s: &str) -> i64 { + if let Ok(datetime) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") { + return datetime.and_utc().timestamp(); + } + if let Ok(date) = NaiveDate::parse_from_str(s, "%Y-%m-%d") { + return date + .and_hms_opt(0, 0, 0) + .expect("midnight is always valid") + .and_utc() + .timestamp(); + } + + panic!("\"{s}\" is not a valid date."); +} + +fn apply_order(ordering: std::cmp::Ordering, order: i64) -> std::cmp::Ordering { + if order < 0 { + ordering.reverse() + } else { + ordering + } +} + +/// `$file->getRealPath() ?: $file->getPathname()`. +fn realpath_or_pathname(path: &Path) -> String { + std::fs::canonicalize(path) + .map(|resolved| resolved.to_string_lossy().into_owned()) + .unwrap_or_else(|_| path.to_string_lossy().into_owned()) +} + +fn mtime(path: &Path) -> i64 { + file_unix_time(path, |metadata| metadata.modified()) +} + +fn atime(path: &Path) -> i64 { + file_unix_time(path, |metadata| metadata.accessed()) +} + +fn file_unix_time( + path: &Path, + accessor: fn(&std::fs::Metadata) -> std::io::Result, +) -> i64 { + std::fs::metadata(path) + .ok() + .and_then(|metadata| accessor(&metadata).ok()) + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_secs() as i64) + .unwrap_or(0) +} + +#[derive(Debug)] +pub struct FinderIterator { + items: Vec, + pos: usize, +} + +impl FinderIterator { + pub fn valid(&self) -> bool { + self.pos < self.items.len() + } + + pub fn current(&self) -> PathBuf { + self.items[self.pos].clone() + } +} + +impl Iterator for FinderIterator { + type Item = PathBuf; + + fn next(&mut self) -> Option { + if self.pos < self.items.len() { + let item = self.items[self.pos].clone(); + self.pos += 1; + Some(item) + } else { + None + } + } +} + +impl IntoIterator for &Finder { + type Item = PathBuf; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.collect_paths().into_iter() + } +} + +impl IntoIterator for Finder { + type Item = PathBuf; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.collect_paths().into_iter() + } +} + +impl IntoIterator for &mut Finder { + type Item = PathBuf; + type IntoIter = std::vec::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.collect_paths().into_iter() + } +} diff --git a/crates/shirabe-symfony-finder/src/glob.rs b/crates/shirabe-symfony-finder/src/glob.rs new file mode 100644 index 00000000..247b772f --- /dev/null +++ b/crates/shirabe-symfony-finder/src/glob.rs @@ -0,0 +1,466 @@ +//! ref: composer/vendor/symfony/finder/Glob.php + +// Regex pattern compatibility: +// PHP's Glob::toRegex builds its regex in a single character loop and emits PCRE-only +// constructs: the look-ahead `(?=[^\.])` enforcing the strict-leading-dot rule, and a +// possessive `[^/]++` inside the `/**/` construct. The regex crate supports neither, so +// instead of a verbatim port of the loop, the glob is first tokenized (mirroring the +// loop's dispatch exactly) and every no-dot constraint is then resolved by recursive +// union expansion, producing a regex-crate-compatible pattern that matches the same +// inputs: +// +// (?=[^.])c => c (branch is unmatchable when c is `\.`) +// (?=[^.])[^/] => [^/.] +// (?=[^.])[abc] => [[abc]&&[^.]] (character class intersection) +// (?=[^.])[^/]*R => (?:[^/.][^/]*R | expand((?=[^.])R)) +// (?=[^.])(a|b)R => (?:expand((?=[^.])aR) | expand((?=[^.])bR)) +// (?=[^.])$ => branch dropped (no next character can exist) +// (?=[^.])(?:$|/) => / (dir-boundary variant only) +// [^/]++ => [^/]+ (the regex crate never backtracks) +// +// Unmatchable branches are dropped; a wholly unmatchable glob renders as `\b\B`, which +// compiles and never matches, just like PHP's own output for such globs. + +#[derive(Debug)] +pub struct Glob; + +#[derive(Debug, Clone, Copy)] +struct Ctx { + strict_leading_dot: bool, + strict_wildcard_slash: bool, +} + +#[derive(Debug, Clone, Copy)] +enum Terminal { + /// The `$` anchor of `to_regex` follows the body. + EndAnchor, + /// The `(?:$|/)` suffix of `to_regex_dir_boundary` ends the body. + EndOrSlash, +} + +#[derive(Debug, Clone)] +enum Token { + /// A literal regex piece for one glob character, escaped as needed. + Text(String), + /// An unescaped `*`. + Star, + /// An unescaped `?`. + Question, + /// A `/**/` (or trailing `/**`) segment, leading slash included. + Globstar { trailing_slash_optional: bool }, + /// An `{a,b}` alternation. `closed` is false when the `}` is missing. + Group { + alternatives: Vec>, + closed: bool, + }, + /// The position of PHP's `(?=[^\.])` look-ahead. + NoDot, + /// End of the pattern. + End(Terminal), +} + +#[derive(Debug)] +enum Out { + /// Fully rendered. + Ok(String), + /// Cannot match anything; the branch must be dropped. + Dead, + /// A no-dot constraint reached the end of an isolated sub-sequence (e.g. `{a/,b}` + /// puts a look-ahead right before the closing brace); the caller must splice in the + /// following context and re-render. + Escapes, +} + +impl Glob { + pub fn to_regex(glob: &str, strict_leading_dot: bool, strict_wildcard_slash: bool) -> String { + format!( + "#^{}$#", + expand( + glob, + strict_leading_dot, + strict_wildcard_slash, + Terminal::EndAnchor + ) + ) + } + + /// Not part of Symfony's Glob. Composer's BaseExcludeFilter derives its patterns + /// from toRegex output by stripping the delimiters and anchors and appending the + /// `(?=$|/)` dir-boundary look-ahead. Here that suffix has to take part in the + /// union expansion — when a trailing `*` matches zero characters, the no-dot + /// constraint falls onto the boundary itself — so it cannot be appended by the + /// caller after the fact. Returns the undelimited, unanchored body with the + /// boundary woven in as the consuming `(?:$|/)` (equivalent under boolean + /// matching, since nothing follows it). + pub fn to_regex_dir_boundary( + glob: &str, + strict_leading_dot: bool, + strict_wildcard_slash: bool, + ) -> String { + expand( + glob, + strict_leading_dot, + strict_wildcard_slash, + Terminal::EndOrSlash, + ) + } +} + +fn expand( + glob: &str, + strict_leading_dot: bool, + strict_wildcard_slash: bool, + terminal: Terminal, +) -> String { + let ctx = Ctx { + strict_leading_dot, + strict_wildcard_slash, + }; + let mut tokens = tokenize(glob, ctx); + tokens.push(Token::End(terminal)); + match render_seq(&tokens, ctx) { + Out::Ok(body) => body, + Out::Dead => "\\b\\B".to_string(), + Out::Escapes => unreachable!("the terminal token resolves every constraint"), + } +} + +// PHP iterates over bytes; iterating over chars is equivalent for valid UTF-8 because +// every character the loop treats specially is ASCII. +fn tokenize(glob: &str, ctx: Ctx) -> Vec { + let chars: Vec = glob.chars().collect(); + let mut first_byte = true; + let mut escaping = false; + // stack[0] is the root sequence (a pseudo-group with a single alternative); deeper + // frames are open `{` groups, so PHP's $inCurlies == stack.len() - 1. + let mut stack: Vec>> = vec![vec![Vec::new()]]; + + fn push(stack: &mut [Vec>], token: Token) { + stack.last_mut().unwrap().last_mut().unwrap().push(token); + } + + let mut i = 0; + while i < chars.len() { + let car = chars[i]; + if first_byte && ctx.strict_leading_dot && car != '.' { + push(&mut stack, Token::NoDot); + } + + first_byte = car == '/'; + + if first_byte + && ctx.strict_wildcard_slash + && i + 2 < chars.len() + && chars[i + 1] == '*' + && chars[i + 2] == '*' + && (i + 3 >= chars.len() || chars[i + 3] == '/') + { + push( + &mut stack, + Token::Globstar { + trailing_slash_optional: i + 3 >= chars.len(), + }, + ); + i += 2 + usize::from(i + 3 < chars.len()); + escaping = false; + i += 1; + continue; + } + + if car == '#' + || car == '.' + || car == '(' + || car == ')' + || car == '|' + || car == '+' + || car == '^' + || car == '$' + { + push(&mut stack, Token::Text(format!("\\{car}"))); + } else if car == '*' { + push( + &mut stack, + if escaping { + Token::Text("\\*".to_string()) + } else { + Token::Star + }, + ); + } else if car == '?' { + push( + &mut stack, + if escaping { + Token::Text("\\?".to_string()) + } else { + Token::Question + }, + ); + } else if car == '{' { + if escaping { + push(&mut stack, Token::Text("\\{".to_string())); + } else { + stack.push(vec![Vec::new()]); + } + } else if car == '}' && stack.len() > 1 { + if escaping { + push(&mut stack, Token::Text("}".to_string())); + } else { + let alternatives = stack.pop().unwrap(); + push( + &mut stack, + Token::Group { + alternatives, + closed: true, + }, + ); + } + } else if car == ',' && stack.len() > 1 { + if escaping { + push(&mut stack, Token::Text(",".to_string())); + } else { + stack.last_mut().unwrap().push(Vec::new()); + } + } else if car == '\\' { + if escaping { + push(&mut stack, Token::Text("\\\\".to_string())); + escaping = false; + } else { + escaping = true; + } + i += 1; + continue; + } else { + push(&mut stack, Token::Text(car.to_string())); + } + escaping = false; + i += 1; + } + + // PHP leaves an unterminated `(` for an unclosed `{`; keep the broken shape so the + // resulting pattern fails to compile just as the PCRE one does. + while stack.len() > 1 { + let alternatives = stack.pop().unwrap(); + push( + &mut stack, + Token::Group { + alternatives, + closed: false, + }, + ); + } + stack.pop().unwrap().pop().unwrap() +} + +fn star(ctx: Ctx) -> &'static str { + if ctx.strict_wildcard_slash { + "[^/]*" + } else { + ".*" + } +} + +fn question(ctx: Ctx) -> &'static str { + if ctx.strict_wildcard_slash { + "[^/]" + } else { + "." + } +} + +fn globstar(ctx: Ctx, trailing_slash_optional: bool) -> String { + // PHP emits `/(?:(?=[^\.])[^/]++/)*`; `(?=[^\.])[^/]+` collapses to `[^/.][^/]*`. + let step = if ctx.strict_leading_dot { + "[^/.][^/]*" + } else { + "[^/]+" + }; + let slash = if trailing_slash_optional { "/?" } else { "/" }; + format!("/(?:{step}{slash})*") +} + +fn render_seq(tokens: &[Token], ctx: Ctx) -> Out { + let mut out = String::new(); + for (idx, token) in tokens.iter().enumerate() { + match token { + Token::Text(text) => out.push_str(text), + Token::Star => out.push_str(star(ctx)), + Token::Question => out.push_str(question(ctx)), + Token::Globstar { + trailing_slash_optional, + } => out.push_str(&globstar(ctx, *trailing_slash_optional)), + Token::End(Terminal::EndAnchor) => {} + Token::End(Terminal::EndOrSlash) => out.push_str("(?:$|/)"), + Token::NoDot => { + return prefix(out, apply_no_dot(&tokens[idx + 1..], ctx)); + } + Token::Group { + alternatives, + closed, + } => match render_group_isolated(alternatives, *closed, ctx) { + Out::Ok(group) => out.push_str(&group), + Out::Dead => return Out::Dead, + Out::Escapes => { + // A constraint inside the group applies to what follows it; splice + // the remainder into each alternative and re-render. + let rest = &tokens[idx + 1..]; + let mut branches = Vec::new(); + for alternative in alternatives { + let mut seq = alternative.clone(); + seq.extend_from_slice(rest); + match render_seq(&seq, ctx) { + Out::Ok(branch) => branches.push(branch), + Out::Dead => {} + Out::Escapes => return Out::Escapes, + } + } + return match join_branches(branches) { + Some(joined) => Out::Ok(out + &joined), + None => Out::Dead, + }; + } + }, + } + } + Out::Ok(out) +} + +fn render_group_isolated(alternatives: &[Vec], closed: bool, ctx: Ctx) -> Out { + let mut rendered = Vec::new(); + for alternative in alternatives { + match render_seq(alternative, ctx) { + Out::Ok(branch) => rendered.push(branch), + Out::Dead => {} + Out::Escapes => { + if !closed { + // The unterminated `(` makes the output uncompilable either way; + // resolve the dangling constraint as if the pattern ended here. + let mut seq = alternative.clone(); + seq.push(Token::End(Terminal::EndAnchor)); + if let Out::Ok(branch) = render_seq(&seq, ctx) { + rendered.push(branch); + } + } else { + return Out::Escapes; + } + } + } + } + if rendered.is_empty() { + return Out::Dead; + } + let close = if closed { ")" } else { "" }; + Out::Ok(format!("({}{close}", rendered.join("|"))) +} + +/// Renders the remainder of the pattern under the constraint that the next matched +/// character must not be a dot. +fn apply_no_dot(tokens: &[Token], ctx: Ctx) -> Out { + let Some((first, rest)) = tokens.split_first() else { + return Out::Escapes; + }; + match first { + Token::Text(text) if text == "\\." => Out::Dead, + Token::Text(text) if text == "[" => { + if let Some((class, after)) = scan_char_class(tokens) { + prefix( + format!("[{class}&&[^.]]"), + render_seq(&tokens[after..], ctx), + ) + } else { + prefix("[".to_string(), render_seq(rest, ctx)) + } + } + Token::Text(text) => prefix(text.clone(), render_seq(rest, ctx)), + Token::Star => { + // Either the `*` consumes at least one character, which then carries the + // constraint, or it consumes none and the constraint moves past it. + let head = if ctx.strict_wildcard_slash { + "[^/.][^/]*" + } else { + "[^.\\n].*" + }; + let consumed = prefix(head.to_string(), render_seq(rest, ctx)); + let skipped = apply_no_dot(rest, ctx); + match (consumed, skipped) { + (Out::Escapes, _) | (_, Out::Escapes) => Out::Escapes, + (Out::Ok(a), Out::Ok(b)) => Out::Ok(format!("(?:{a}|{b})")), + (Out::Ok(a), Out::Dead) => Out::Ok(a), + (Out::Dead, Out::Ok(b)) => Out::Ok(b), + (Out::Dead, Out::Dead) => Out::Dead, + } + } + Token::Question => { + let head = if ctx.strict_wildcard_slash { + "[^/.]" + } else { + "[^.\\n]" + }; + prefix(head.to_string(), render_seq(rest, ctx)) + } + // The construct starts with a literal `/`, which satisfies the constraint. + Token::Globstar { .. } => render_seq(tokens, ctx), + Token::Group { + alternatives, + closed: true, + } => { + let mut branches = Vec::new(); + for alternative in alternatives { + let mut seq = alternative.clone(); + seq.extend_from_slice(rest); + match apply_no_dot(&seq, ctx) { + Out::Ok(branch) => branches.push(branch), + Out::Dead => {} + Out::Escapes => return Out::Escapes, + } + } + match join_branches(branches) { + Some(joined) => Out::Ok(joined), + None => Out::Dead, + } + } + // The unterminated `(` makes the output uncompilable either way; skip the + // expansion. + Token::Group { closed: false, .. } => render_seq(tokens, ctx), + // `(?=[^.])$`: no next character can exist. + Token::End(Terminal::EndAnchor) => Out::Dead, + // `(?=[^.])(?=$|/)`: the next character must exist, must not be a dot, and must + // be either the end (impossible) or a slash — exactly one consumable `/`. + Token::End(Terminal::EndOrSlash) => Out::Ok("/".to_string()), + Token::NoDot => unreachable!("PHP never emits two consecutive look-aheads"), + } +} + +fn prefix(head: String, tail: Out) -> Out { + match tail { + Out::Ok(tail) => Out::Ok(head + &tail), + other => other, + } +} + +fn join_branches(mut branches: Vec) -> Option { + match branches.len() { + 0 => None, + 1 => branches.pop(), + _ => Some(format!("(?:{})", branches.join("|"))), + } +} + +/// A `[` passes through the PHP loop untouched and opens a character class in the +/// final regex, so a no-dot constraint has to intersect the class rather than be +/// satisfied by the `[` itself. Collects a class spanning plain text tokens; None when +/// no plain closing `]` follows (the `[` is then treated as a literal). +fn scan_char_class(tokens: &[Token]) -> Option<(String, usize)> { + let mut class = String::from("["); + let mut idx = 1; + loop { + match tokens.get(idx)? { + Token::Text(text) if text == "]" => { + class.push(']'); + return Some((class, idx + 1)); + } + Token::Text(text) => class.push_str(text), + _ => return None, + } + idx += 1; + } +} diff --git a/crates/shirabe-symfony-finder/src/lib.rs b/crates/shirabe-symfony-finder/src/lib.rs new file mode 100644 index 00000000..4b2d4b84 --- /dev/null +++ b/crates/shirabe-symfony-finder/src/lib.rs @@ -0,0 +1,7 @@ +pub mod finder; +pub mod glob; +pub mod spl_file_info; + +pub use finder::*; +pub use glob::*; +pub use spl_file_info::*; diff --git a/crates/shirabe-symfony-finder/src/spl_file_info.rs b/crates/shirabe-symfony-finder/src/spl_file_info.rs new file mode 100644 index 00000000..6f03fe14 --- /dev/null +++ b/crates/shirabe-symfony-finder/src/spl_file_info.rs @@ -0,0 +1,84 @@ +//! ref: composer/vendor/symfony/finder/SplFileInfo.php + +#[derive(Debug)] +pub struct SplFileInfo { + // The path passed to the underlying \SplFileInfo constructor. + pathname: String, + relative_path: String, + relative_pathname: String, +} + +impl std::fmt::Display for SplFileInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.get_pathname()) + } +} + +impl SplFileInfo { + pub fn new(file: &str, relative_path: &str, relative_pathname: &str) -> Self { + Self { + pathname: file.to_string(), + relative_path: relative_path.to_string(), + relative_pathname: relative_pathname.to_string(), + } + } + + pub fn get_pathname(&self) -> String { + self.pathname.clone() + } + + pub fn get_path(&self) -> String { + shirabe_php_shim::dirname(&self.pathname) + } + + pub fn get_filename(&self) -> String { + shirabe_php_shim::basename(&self.pathname) + } + + pub fn get_basename(&self, suffix: Option<&str>) -> String { + match suffix { + Some(suffix) => shirabe_php_shim::basename_with_suffix(&self.pathname, suffix), + None => shirabe_php_shim::basename(&self.pathname), + } + } + + pub fn get_extension(&self) -> String { + // \SplFileInfo::getExtension() returns the extension (without the dot), or "" if none. + let base = shirabe_php_shim::basename(&self.pathname); + match base.rfind('.') { + Some(index) => base[index + 1..].to_string(), + None => String::new(), + } + } + + pub fn get_relative_path_name(&self) -> String { + self.relative_pathname.clone() + } + + pub fn get_relative_path(&self) -> String { + self.relative_path.clone() + } + + pub fn is_dir(&self) -> bool { + shirabe_php_shim::is_dir(&self.pathname) + } + + pub fn is_file(&self) -> bool { + shirabe_php_shim::is_file(&self.pathname) + } + + pub fn is_link(&self) -> bool { + shirabe_php_shim::is_link(&self.pathname) + } + + pub fn get_real_path(&self) -> Option { + // \SplFileInfo::getRealPath() returns the canonicalized absolute path, or false on failure. + shirabe_php_shim::realpath(&self.pathname) + } + + pub fn get_size(&self) -> i64 { + // \SplFileInfo::getSize() returns the file size in bytes (throws on failure). + // TODO(phase-c): PHP throws a \RuntimeException on stat failure; this returns 0 instead. + shirabe_php_shim::filesize(&self.pathname).unwrap_or(0) + } +} diff --git a/crates/shirabe/Cargo.toml b/crates/shirabe/Cargo.toml index 17b348ce..b5b84f73 100644 --- a/crates/shirabe/Cargo.toml +++ b/crates/shirabe/Cargo.toml @@ -13,6 +13,7 @@ shirabe-php-shim.workspace = true shirabe-semver.workspace = true shirabe-spdx-licenses.workspace = true shirabe-symfony-filesystem.workspace = true +shirabe-symfony-finder.workspace = true shirabe-symfony-process.workspace = true anyhow.workspace = true async-trait.workspace = true diff --git a/crates/shirabe/src/cache.rs b/crates/shirabe/src/cache.rs index 11afe2cb..435e45c6 100644 --- a/crates/shirabe/src/cache.rs +++ b/crates/shirabe/src/cache.rs @@ -6,13 +6,13 @@ use crate::util::Filesystem; use crate::util::Platform; use crate::util::Silencer; use chrono::Utc; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_pcre::{CaptureKey, Preg}; use shirabe_php_shim::{ ErrorException, bin2hex, clearstatcache, date_format_to_strftime, dirname, disk_free_space, file_exists, file_get_contents, file_put_contents, filemtime, function_exists, hash_file, is_dir, is_writable, mkdir, php_regex, random_bytes, random_int, rename, time, unlink, }; +use shirabe_symfony_finder::Finder; use std::sync::Mutex; /// Reads/writes to a filesystem cache diff --git a/crates/shirabe/src/command/create_project_command.rs b/crates/shirabe/src/command/create_project_command.rs index 4d2f259b..7a11e44e 100644 --- a/crates/shirabe/src/command/create_project_command.rs +++ b/crates/shirabe/src/command/create_project_command.rs @@ -40,7 +40,6 @@ use shirabe_external_packages::seld::signal::SignalHandler; use shirabe_external_packages::symfony::console::command::command::Command; use shirabe_external_packages::symfony::console::input::InputInterface; use shirabe_external_packages::symfony::console::output::OutputInterface; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_pcre::{CaptureKey, Preg}; use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ @@ -48,6 +47,7 @@ use shirabe_php_shim::{ chdir, explode_with_limit, file_exists, getcwd, impl_php_class, implode, is_dir, is_file, mkdir, realpath, rtrim, strtolower, unlink, }; +use shirabe_symfony_finder::Finder; use std::path::PathBuf; /// Install a package as new project into new directory. diff --git a/crates/shirabe/src/downloader/archive_downloader.rs b/crates/shirabe/src/downloader/archive_downloader.rs index 097fc1af..894782e4 100644 --- a/crates/shirabe/src/downloader/archive_downloader.rs +++ b/crates/shirabe/src/downloader/archive_downloader.rs @@ -8,11 +8,11 @@ use crate::package::PackageInterfaceHandle; use crate::util::Filesystem; use crate::util::Platform; use indexmap::IndexMap; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_php_shim::Catch as _; use shirabe_php_shim::{ PhpMixed, RuntimeException, bin2hex, file_exists, is_dir, random_bytes, realpath, }; +use shirabe_symfony_finder::Finder; use std::path::{Path, PathBuf}; pub trait ArchiveDownloader { diff --git a/crates/shirabe/src/package/archiver/archivable_files_finder.rs b/crates/shirabe/src/package/archiver/archivable_files_finder.rs index 7c524283..72fe0646 100644 --- a/crates/shirabe/src/package/archiver/archivable_files_finder.rs +++ b/crates/shirabe/src/package/archiver/archivable_files_finder.rs @@ -4,9 +4,9 @@ use crate::package::archiver::BaseExcludeFilter; use crate::package::archiver::ComposerExcludeFilter; use crate::package::archiver::GitExcludeFilter; use crate::util::Filesystem; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_pcre::Preg; use shirabe_php_shim::{RuntimeException, preg_quote, realpath}; +use shirabe_symfony_finder::Finder; use std::path::{Path, PathBuf}; pub struct ArchivableFilesFinder { diff --git a/crates/shirabe/src/package/archiver/base_exclude_filter.rs b/crates/shirabe/src/package/archiver/base_exclude_filter.rs index a899d90f..e5487038 100644 --- a/crates/shirabe/src/package/archiver/base_exclude_filter.rs +++ b/crates/shirabe/src/package/archiver/base_exclude_filter.rs @@ -1,7 +1,7 @@ //! ref: composer/src/Composer/Package/Archiver/BaseExcludeFilter.php -use shirabe_external_packages::symfony::finder::Glob; use shirabe_pcre::Preg; +use shirabe_symfony_finder::Glob; #[derive(Debug)] pub struct BaseExcludeFilterBase { diff --git a/crates/shirabe/src/util/filesystem.rs b/crates/shirabe/src/util/filesystem.rs index bf7107f7..b46e7913 100644 --- a/crates/shirabe/src/util/filesystem.rs +++ b/crates/shirabe/src/util/filesystem.rs @@ -3,7 +3,6 @@ use crate::util::Platform; use crate::util::ProcessExecutor; use crate::util::Silencer; -use shirabe_external_packages::symfony::finder::Finder; use shirabe_pcre::Preg; use shirabe_php_shim::{ ErrorException, LogicException, PhpMixed, RuntimeException, array_pop, basename, chdir, @@ -15,6 +14,7 @@ use shirabe_php_shim::{ var_export, }; use shirabe_symfony_filesystem::exception::IOException; +use shirabe_symfony_finder::Finder; use std::path::Path; #[derive(Debug)] -- cgit v1.3.1-4-g156e