use crate::PhpMixed; use crate::PhpResource; use crate::UnexpectedValueException; use indexmap::IndexMap; pub const PHP_EOL: &str = "\n"; pub const FILE_APPEND: i64 = 8; pub const STDIN: PhpResource = PhpResource::Stdin; pub const PATHINFO_FILENAME: i64 = 64; pub const PATHINFO_EXTENSION: i64 = 4; pub const PATHINFO_DIRNAME: i64 = 1; pub const PATHINFO_BASENAME: i64 = 2; pub const PATH_SEPARATOR: &str = ":"; pub const DIRECTORY_SEPARATOR: &str = "/"; pub const FILE_IGNORE_NEW_LINES: i64 = 2; pub const SEEK_SET: i64 = 0; pub const SEEK_CUR: i64 = 1; pub const SEEK_END: i64 = 2; pub const SKIP_DOTS: i64 = 4096; pub const CHILD_FIRST: i64 = 16; pub const SELF_FIRST: i64 = 0; pub struct FilesystemIterator; impl FilesystemIterator { pub const KEY_AS_PATHNAME: i64 = 256; pub const CURRENT_AS_FILEINFO: i64 = 0; } #[derive(Debug)] pub struct DirectoryIteratorEntry; impl DirectoryIteratorEntry { // TODO(phase-d): DirectoryIterator is a unit struct carrying no entry data; giving it real // behavior requires the same field redesign as RecursiveIteratorFileInfo. It has no callers, so // it is left unimplemented. pub fn get_basename(&self) -> String { todo!() } pub fn is_file(&self) -> bool { todo!() } pub fn get_extension(&self) -> String { todo!() } } #[derive(Debug)] pub struct RecursiveDirectoryIterator { root: std::path::PathBuf, flags: i64, } impl RecursiveDirectoryIterator { pub const SKIP_DOTS: i64 = 4096; pub const FOLLOW_SYMLINKS: i64 = 512; } #[derive(Debug)] pub struct RecursiveIteratorIterator { entries: Vec, // Index of the entry the iteration is currently on, so get_sub_pathname() can report it. cursor: std::cell::Cell, } impl RecursiveIteratorIterator { pub const SELF_FIRST: i64 = 0; pub const CHILD_FIRST: i64 = 16; pub fn get_sub_pathname(&self) -> String { self.entries[self.cursor.get()].sub_pathname() } } pub struct RecursiveIteratorIter<'a> { inner: &'a RecursiveIteratorIterator, index: usize, } impl Iterator for RecursiveIteratorIter<'_> { type Item = RecursiveIteratorFileInfo; fn next(&mut self) -> Option { if self.index < self.inner.entries.len() { // Publish the current position so get_sub_pathname() called inside the loop sees it. self.inner.cursor.set(self.index); let item = self.inner.entries[self.index].clone(); self.index += 1; Some(item) } else { None } } } impl<'a> IntoIterator for &'a RecursiveIteratorIterator { type Item = RecursiveIteratorFileInfo; type IntoIter = RecursiveIteratorIter<'a>; fn into_iter(self) -> Self::IntoIter { RecursiveIteratorIter { inner: self, index: 0, } } } #[derive(Debug, Clone)] pub struct RecursiveIteratorFileInfo { path: std::path::PathBuf, root: std::path::PathBuf, } impl RecursiveIteratorFileInfo { pub fn is_dir(&self) -> bool { // SplFileInfo::isDir() follows symlinks. std::fs::metadata(&self.path) .map(|m| m.is_dir()) .unwrap_or(false) } pub fn is_file(&self) -> bool { std::fs::metadata(&self.path) .map(|m| m.is_file()) .unwrap_or(false) } pub fn is_link(&self) -> bool { std::fs::symlink_metadata(&self.path) .map(|m| m.file_type().is_symlink()) .unwrap_or(false) } pub fn get_pathname(&self) -> String { self.path.to_string_lossy().into_owned() } pub fn get_size(&self) -> i64 { std::fs::metadata(&self.path) .map(|m| m.len() as i64) .unwrap_or(0) } fn sub_pathname(&self) -> String { self.path .strip_prefix(&self.root) .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_else(|_| self.get_pathname()) } } pub fn recursive_directory_iterator( _path: impl AsRef, _flags: i64, ) -> Result { let root = _path.as_ref().to_path_buf(); if !root.is_dir() { return Err(UnexpectedValueException { message: format!( "RecursiveDirectoryIterator::__construct({}): Failed to open directory", root.to_string_lossy() ), code: 0, }); } Ok(RecursiveDirectoryIterator { root, flags: _flags, }) } pub fn recursive_iterator_iterator( _iter: RecursiveDirectoryIterator, _mode: i64, ) -> RecursiveIteratorIterator { let mut entries = Vec::new(); rii_walk(&_iter.root, &_iter.root, _iter.flags, _mode, &mut entries); RecursiveIteratorIterator { entries, cursor: std::cell::Cell::new(0), } } // Recursively collects directory entries in filesystem order, matching SplFileInfo recursion: // real subdirectories are descended into (also symlinked dirs when FOLLOW_SYMLINKS is set), with the // directory itself yielded before its children for SELF_FIRST and after them for CHILD_FIRST. fn rii_walk( dir: &std::path::Path, root: &std::path::Path, flags: i64, mode: i64, out: &mut Vec, ) { let rd = match std::fs::read_dir(dir) { Ok(rd) => rd, Err(_) => return, }; for entry in rd.flatten() { let path = entry.path(); let is_real_dir = std::fs::symlink_metadata(&path) .map(|m| m.is_dir()) .unwrap_or(false); let follows_symlink_dir = (flags & RecursiveDirectoryIterator::FOLLOW_SYMLINKS != 0) && std::fs::metadata(&path) .map(|m| m.is_dir()) .unwrap_or(false); let info = RecursiveIteratorFileInfo { path: path.clone(), root: root.to_path_buf(), }; if is_real_dir || follows_symlink_dir { if mode == RecursiveIteratorIterator::CHILD_FIRST { rii_walk(&path, root, flags, mode, out); out.push(info); } else { out.push(info); rii_walk(&path, root, flags, mode, out); } } else { out.push(info); } } } pub fn directory_iterator(_path: &str) -> Vec { // TODO(phase-d): see DirectoryIteratorEntry; the entry type carries no data yet and there are no // callers. todo!() } // TODO(phase-d): the fopen-family stream API is keyed on PhpMixed, but PhpMixed has no stream/ // resource variant, so an opened stream cannot be represented or threaded through fread/fwrite/etc. // Wiring a stream representation into PhpMixed is Phase C type-design work. pub fn fopen(_file: &str, _mode: &str) -> PhpMixed { todo!() } pub fn fwrite(_file: PhpMixed, _data: &str, _length: i64) -> Option { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fread(_handle: PhpMixed, _length: i64) -> Option { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn feof(_stream: PhpMixed) -> bool { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fclose(_file: PhpMixed) { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fgets(_handle: PhpMixed) -> Option { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fgetc(_resource: &PhpResource) -> Option { // TODO(phase-d): PhpResource models stdio/file write sinks (see fwrite_resource) but not // buffered reads with a tracked position; fgetc needs a readable, seekable stream wrapper. todo!() } pub fn ftell(_resource: &PhpResource) -> i64 { // TODO(phase-d): PhpResource does not track a stream position; see fgetc. todo!() } pub fn fseek(_stream: PhpMixed, _offset: i64) -> i64 { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn rewind(_stream: PhpMixed) -> bool { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fstat(_stream: PhpResource) -> PhpMixed { // TODO(phase-d): PhpResource::File holds a File, but the stdio variants have no fd to stat; a // faithful fstat needs a uniform stream handle. todo!() } pub fn lstat(_filename: &str) -> Option> { use std::os::unix::fs::MetadataExt; let m = std::fs::symlink_metadata(_filename).ok()?; // PHP stat/lstat return the 13 fields both by numeric index (0..12) and by name. let fields: [(&str, i64); 13] = [ ("dev", m.dev() as i64), ("ino", m.ino() as i64), ("mode", m.mode() as i64), ("nlink", m.nlink() as i64), ("uid", m.uid() as i64), ("gid", m.gid() as i64), ("rdev", m.rdev() as i64), ("size", m.size() as i64), ("atime", m.atime()), ("mtime", m.mtime()), ("ctime", m.ctime()), ("blksize", m.blksize() as i64), ("blocks", m.blocks() as i64), ]; let mut map = IndexMap::new(); for (i, (_, v)) in fields.iter().enumerate() { map.insert(i.to_string(), PhpMixed::Int(*v)); } for (name, v) in &fields { map.insert(name.to_string(), PhpMixed::Int(*v)); } Some(map) } /// PHP `ftell()` over a PhpMixed stream resource. (`ftell` itself is already defined for the /// `PhpResource`-typed stream API used elsewhere.) pub fn ftell_stream(_stream: &PhpMixed) -> i64 { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn fseek3(_stream: PhpMixed, _offset: i64, _whence: i64) -> i64 { // TODO(phase-d): see fopen; no PhpMixed stream representation exists. todo!() } pub fn touch(_path: &str) -> bool { // TODO(phase-d): for an existing file PHP also bumps its mtime/atime to now; std exposes no // portable utime, so only the create-if-absent case is handled here. std::fs::OpenOptions::new() .write(true) .create(true) .truncate(false) .open(_path) .is_ok() } pub fn fflush_resource(resource: &PhpResource) { use std::io::Write; match resource { PhpResource::Stdin => {} PhpResource::Stdout => { let _ = std::io::stdout().flush(); } PhpResource::Stderr => { let _ = std::io::stderr().flush(); } PhpResource::File(file) => { let _ = file.borrow_mut().flush(); } } } pub fn fwrite_resource(resource: &PhpResource, data: &str) { use std::io::Write; let bytes = data.as_bytes(); match resource { PhpResource::Stdin => {} PhpResource::Stdout => { let _ = std::io::stdout().write_all(bytes); } PhpResource::Stderr => { let _ = std::io::stderr().write_all(bytes); } PhpResource::File(file) => { let _ = file.borrow_mut().write_all(bytes); } } } pub fn touch2(_path: &str, _mtime: i64) -> bool { // TODO(phase-d): setting an explicit mtime needs utimensat(2), not exposed by std (no // libc/filetime crate available). todo!() } pub fn touch3(_path: &str, _mtime: i64, _atime: i64) -> bool { // TODO(phase-d): setting explicit mtime/atime needs utimensat(2); see touch2. todo!() } pub fn chmod(_path: &str, _mode: u32) -> bool { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(_path, std::fs::Permissions::from_mode(_mode)).is_ok() } pub fn fileperms(_path: &str) -> i64 { use std::os::unix::fs::MetadataExt; // PHP returns the full st_mode (file type bits included). // TODO(phase-d): PHP returns false on error; this i64 signature reports 0 instead. std::fs::metadata(_path) .map(|m| m.mode() as i64) .unwrap_or(0) } pub fn filesize(path: impl AsRef) -> Option { std::fs::metadata(path).ok().map(|m| m.len() as i64) } pub fn file_exists(path: impl AsRef) -> bool { path.as_ref().exists() } // TODO(phase-c): PHP's is_writable() resolves to access(2) with W_OK, honoring the effective // user/group and ACLs. This std-only approximation only inspects the permission bits, so it can // diverge for files the current user does not own. Refine with a syscall (libc/rustix) crate later. pub fn is_writable(_path: &str) -> bool { match std::fs::metadata(_path) { Ok(meta) => !meta.permissions().readonly(), Err(_) => false, } } pub fn is_readable(_path: &str) -> bool { let path = std::path::Path::new(_path); match std::fs::metadata(path) { Ok(meta) => { if meta.is_dir() { std::fs::read_dir(path).is_ok() } else { std::fs::File::open(path).is_ok() } } Err(_) => false, } } pub fn is_executable(_path: &str) -> bool { use std::os::unix::fs::PermissionsExt; // TODO(phase-d): like is_writable, this only inspects the permission bits and ignores the // effective user/group, so it can diverge from PHP's access(2, X_OK) check. match std::fs::metadata(_path) { Ok(m) => (m.permissions().mode() & 0o111) != 0, Err(_) => false, } } pub fn is_file(path: impl AsRef) -> bool { path.as_ref().is_file() } pub fn is_link(path: impl AsRef) -> bool { std::fs::symlink_metadata(path) .map(|m| m.file_type().is_symlink()) .unwrap_or(false) } pub fn is_dir(path: impl AsRef) -> bool { path.as_ref().is_dir() } pub fn fileatime(_filename: &str) -> Option { std::fs::metadata(_filename) .ok() .and_then(|m| m.accessed().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) } pub fn filemtime(_filename: &str) -> Option { std::fs::metadata(_filename) .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs() as i64) } pub fn fileowner(_filename: &str) -> Option { use std::os::unix::fs::MetadataExt; std::fs::metadata(_filename).ok().map(|m| m.uid() as i64) } pub fn unlink(path: impl AsRef) -> bool { std::fs::remove_file(path).is_ok() } pub fn unlink_silent(_path: &str) -> bool { // PHP's `@unlink`: delete the file, suppressing any warning. std::fs::remove_file(_path).is_ok() } pub fn file_put_contents(_path: &str, _data: &[u8]) -> Option { std::fs::write(_path, _data) .ok() .map(|_| _data.len() as i64) } pub fn file_put_contents3(_filename: &str, _data: &str, _flags: i64) -> Option { use std::io::Write; // TODO(phase-d): the LOCK_EX and FILE_USE_INCLUDE_PATH flags are ignored; only FILE_APPEND is // honored. let append = _flags & FILE_APPEND != 0; let mut opts = std::fs::OpenOptions::new(); opts.write(true).create(true); if append { opts.append(true); } else { opts.truncate(true); } let mut file = opts.open(_filename).ok()?; file.write_all(_data.as_bytes()).ok()?; Some(_data.len() as i64) } pub fn file_get_contents(_path: &str) -> Option { std::fs::read(_path) .ok() .map(|bytes| String::from_utf8_lossy(&bytes).into_owned()) } pub fn file_get_contents5( _path: &str, _use_include_path: bool, _context: PhpMixed, _offset: i64, _length: Option, ) -> Option { // TODO(phase-d): the stream $context and FILE_USE_INCLUDE_PATH are ignored; only $offset and // $length are applied (to the file read from the local filesystem). let bytes = std::fs::read(_path).ok()?; let len = bytes.len() as i64; let start = if _offset < 0 { (len + _offset).max(0) } else { _offset.min(len) } as usize; let slice = &bytes[start..]; let slice = match _length { Some(l) if l >= 0 => &slice[..(l as usize).min(slice.len())], _ => slice, }; Some(String::from_utf8_lossy(slice).into_owned()) } pub fn getcwd() -> Option { std::env::current_dir() .ok() .map(|p| p.to_string_lossy().into_owned()) } pub fn chdir(_path: &str) -> anyhow::Result<()> { Ok(std::env::set_current_dir(_path)?) } pub fn glob(_pattern: &str) -> Vec { glob_with_flags(_pattern, 0) } pub const FILE_SKIP_EMPTY_LINES: i64 = 4; pub fn file(_filename: &str, _flags: i64) -> Option> { let content = std::fs::read(_filename).ok()?; let s = String::from_utf8_lossy(&content); let ignore_newlines = _flags & FILE_IGNORE_NEW_LINES != 0; let skip_empty = _flags & FILE_SKIP_EMPTY_LINES != 0; let mut lines = Vec::new(); // PHP keeps the trailing newline on each element unless FILE_IGNORE_NEW_LINES is set. for line in s.split_inclusive('\n') { let mut l = line.to_string(); if ignore_newlines { if l.ends_with('\n') { l.pop(); } if l.ends_with('\r') { l.pop(); } } if skip_empty && l.is_empty() { continue; } lines.push(l); } Some(lines) } pub fn umask() -> u32 { // Linux exposes the current umask via /proc/self/status. // TODO(phase-d): other platforms have no /proc; reading the umask there needs the // read-modify-write umask(2), which std does not expose (no libc/syscall crate available). std::fs::read_to_string("/proc/self/status") .ok() .and_then(|status| { status.lines().find_map(|line| { line.strip_prefix("Umask:") .and_then(|v| u32::from_str_radix(v.trim(), 8).ok()) }) }) .unwrap_or(0o022) } pub fn mkdir(_pathname: &str, _mode: u32, _recursive: bool) -> bool { use std::os::unix::fs::DirBuilderExt; // DirBuilder::mode passes the mode to mkdir(2), which applies the process umask, matching PHP. let mut builder = std::fs::DirBuilder::new(); builder.mode(_mode).recursive(_recursive); builder.create(_pathname).is_ok() } pub fn rmdir(dir: impl AsRef) -> bool { std::fs::remove_dir(dir).is_ok() } pub fn rename( old_name: impl AsRef, new_name: impl AsRef, ) -> bool { std::fs::rename(old_name, new_name).is_ok() } pub fn copy(_source: &str, _dest: &str) -> bool { std::fs::copy(_source, _dest).is_ok() } pub fn ftruncate(_stream: &PhpMixed, _size: i64) -> bool { // TODO(phase-d): see fopen; no PhpMixed stream representation exists to truncate. todo!() } pub fn symlink(_target: &str, _link: &str) -> bool { std::os::unix::fs::symlink(_target, _link).is_ok() } pub fn sys_get_temp_dir() -> String { std::env::temp_dir().to_string_lossy().into_owned() } pub fn tempnam(_dir: &str, _prefix: &str) -> Option { use std::os::unix::fs::PermissionsExt; // TODO(phase-d): PHP falls back to the system temp dir when $dir is not writable; that fallback // is not implemented here. for _ in 0..1000 { let name = format!("{}{:08x}", _prefix, fastrand::u32(..)); let path = std::path::Path::new(_dir).join(name); match std::fs::OpenOptions::new() .write(true) .create_new(true) .open(&path) { Ok(_) => { // PHP creates the file with 0600 permissions. let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)); return path.to_str().map(ToOwned::to_owned); } Err(_) => continue, } } None } pub fn opendir(_path: &str) -> Option { // TODO(phase-d): opendir returns a directory-handle resource consumed by readdir/closedir, but // PhpMixed has no resource variant to carry it (see fopen). todo!() } pub fn pathinfo(path: PhpMixed, option: i64) -> PhpMixed { let path = path.as_string().unwrap_or(""); let component = match option { PATHINFO_DIRNAME => dirname(path), PATHINFO_BASENAME => basename(path), PATHINFO_EXTENSION => { let base = basename(path); match base.rfind('.') { Some(index) => base[index + 1..].to_string(), None => String::new(), } } PATHINFO_FILENAME => { let base = basename(path); match base.rfind('.') { Some(index) => base[..index].to_string(), None => base, } } _ => unreachable!("pathinfo called with an unsupported single-component option"), }; PhpMixed::String(component) } // TODO(phase-c): takes &Path and returns Option pub fn realpath(path: &str) -> Option { std::path::Path::new(path) .canonicalize() .ok() .and_then(|p| p.to_str().map(ToOwned::to_owned)) } pub fn dirname(path: &str) -> String { if path.is_empty() { return String::new(); } match std::path::Path::new(path).parent() { // No parent: the root itself, or a path made up solely of slashes. None => "/".to_string(), // Path::parent yields an empty path where PHP's dirname returns ".". Some(parent) if parent.as_os_str().is_empty() => ".".to_string(), Some(parent) => parent.to_str().expect("input was valid UTF-8").to_string(), } } pub fn dirname_levels(path: &str, levels: i64) -> String { let mut result = path.to_string(); for _ in 0..levels { result = dirname(&result); } result } pub fn basename(path: &str) -> String { // PHP basename(): the trailing name component, after stripping trailing directory separators. let trimmed = path.trim_end_matches(['/', '\\']); match trimmed.rfind(['/', '\\']) { Some(index) => trimmed[index + 1..].to_string(), None => trimmed.to_string(), } } pub fn basename_with_suffix(path: &str, suffix: &str) -> String { let base = basename(path); // PHP strips the suffix only when it is a proper trailing part of the name, // never when it equals the whole basename. if base != suffix && base.ends_with(suffix) { base[..base.len() - suffix.len()].to_string() } else { base } } pub fn clearstatcache() { // Rust performs a fresh syscall for every metadata query; there is no stat // cache to invalidate. } pub fn clearstatcache2(_clear_realpath_cache: bool, _filename: &str) { // Rust performs a fresh syscall for every metadata query; there is no stat // cache to invalidate. } pub fn disk_free_space(_directory: &str) -> Option { // TODO(phase-d): reading free space for an arbitrary path requires statvfs(3); std exposes no // equivalent and no /proc file gives per-path free space (no libc/syscall crate available). todo!() } pub const GLOB_MARK: i64 = 8; pub const GLOB_ONLYDIR: i64 = 1024; pub const GLOB_BRACE: i64 = 4096; pub fn glob_with_flags(_pattern: &str, _flags: i64) -> Vec { let patterns = if _flags & GLOB_BRACE != 0 { glob_expand_braces(_pattern) } else { vec![_pattern.to_string()] }; let mut results: Vec = Vec::new(); for pattern in patterns { glob_collect(&pattern, _flags, &mut results); } // PHP sorts the result set by default (GLOB_NOSORT is not modeled here). results.sort(); results.dedup(); results } fn glob_collect(pattern: &str, flags: i64, out: &mut Vec) { let (mut current, rest) = match pattern.strip_prefix('/') { Some(rest) => (vec!["/".to_string()], rest), None => (vec![String::new()], pattern), }; let segments: Vec<&str> = rest.split('/').collect(); for (idx, seg) in segments.iter().enumerate() { let is_last = idx == segments.len() - 1; let mut next: Vec = Vec::new(); for base in ¤t { if seg.is_empty() { next.push(base.clone()); continue; } if glob_has_wildcard(seg) { let read_base = if base.is_empty() { "." } else { base.as_str() }; if let Ok(rd) = std::fs::read_dir(read_base) { for entry in rd.flatten() { let name = entry.file_name().to_string_lossy().into_owned(); if glob_fnmatch(seg, &name) { let path = glob_join(base, &name); if is_last || std::path::Path::new(&path).is_dir() { next.push(path); } } } } } else { let path = glob_join(base, seg); let p = std::path::Path::new(&path); if (is_last && p.exists()) || (!is_last && p.is_dir()) { next.push(path); } } } current = next; } for mut path in current { let is_dir = std::path::Path::new(&path).is_dir(); if flags & GLOB_ONLYDIR != 0 && !is_dir { continue; } if flags & GLOB_MARK != 0 && is_dir && !path.ends_with('/') { path.push('/'); } out.push(path); } } fn glob_join(base: &str, seg: &str) -> String { if base.is_empty() { seg.to_string() } else if base == "/" { format!("/{}", seg) } else { format!("{}/{}", base, seg) } } fn glob_has_wildcard(seg: &str) -> bool { seg.bytes().any(|b| matches!(b, b'*' | b'?' | b'[')) } fn glob_fnmatch(pattern: &str, name: &str) -> bool { // A leading '.' is only matched by an explicit leading '.' in the pattern. if name.starts_with('.') && !pattern.starts_with('.') { return false; } glob_fnmatch_bytes(pattern.as_bytes(), name.as_bytes()) } fn glob_fnmatch_bytes(p: &[u8], s: &[u8]) -> bool { let mut pi = 0; let mut si = 0; let mut star: Option = None; let mut star_s = 0; while si < s.len() { if pi < p.len() { match p[pi] { b'*' => { star = Some(pi); star_s = si; pi += 1; continue; } b'?' => { pi += 1; si += 1; continue; } b'[' => { if let Some((matched, next_pi)) = glob_match_bracket(p, pi, s[si]) { if matched { pi = next_pi; si += 1; continue; } } else if p[pi] == s[si] { // Unterminated '[' is treated as a literal. pi += 1; si += 1; continue; } } c => { if c == s[si] { pi += 1; si += 1; continue; } } } } if let Some(sp) = star { pi = sp + 1; star_s += 1; si = star_s; } else { return false; } } while pi < p.len() && p[pi] == b'*' { pi += 1; } pi == p.len() } // Returns (matched, index-after-']') for a `[...]` class, or None when the bracket is unterminated. fn glob_match_bracket(p: &[u8], start: usize, c: u8) -> Option<(bool, usize)> { let mut i = start + 1; if i >= p.len() { return None; } let negate = p[i] == b'!' || p[i] == b'^'; if negate { i += 1; } let mut matched = false; let mut first = true; while i < p.len() { if p[i] == b']' && !first { return Some((matched ^ negate, i + 1)); } first = false; if i + 2 < p.len() && p[i + 1] == b'-' && p[i + 2] != b']' { if p[i] <= c && c <= p[i + 2] { matched = true; } i += 3; } else { if p[i] == c { matched = true; } i += 1; } } None } fn glob_expand_braces(pattern: &str) -> Vec { let bytes = pattern.as_bytes(); let Some(open) = pattern.find('{') else { return vec![pattern.to_string()]; }; // Find the matching '}'. let mut depth = 0; let mut close = None; for (i, &b) in bytes.iter().enumerate().skip(open) { match b { b'{' => depth += 1, b'}' => { depth -= 1; if depth == 0 { close = Some(i); break; } } _ => {} } } let Some(close) = close else { return vec![pattern.to_string()]; }; let prefix = &pattern[..open]; let suffix = &pattern[close + 1..]; let inner = &pattern[open + 1..close]; let mut result = Vec::new(); for alt in glob_split_top_commas(inner) { let combined = format!("{}{}{}", prefix, alt, suffix); result.extend(glob_expand_braces(&combined)); } result } fn glob_split_top_commas(inner: &str) -> Vec { let mut parts = Vec::new(); let mut depth = 0; let mut start = 0; let bytes = inner.as_bytes(); for (i, &b) in bytes.iter().enumerate() { match b { b'{' => depth += 1, b'}' => depth -= 1, b',' if depth == 0 => { parts.push(inner[start..i].to_string()); start = i + 1; } _ => {} } } parts.push(inner[start..].to_string()); parts }