aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-21 21:03:31 +0900
committernsfisis <nsfisis@gmail.com>2026-06-21 21:05:13 +0900
commit8db88c2da2cf387bc58843ac5093bb15b9fc3252 (patch)
treea4114195195dd86d32851a40ac81b890d9078d5a
parent80c2870c5c38ba9b93d5f0ee4e594d72e5628723 (diff)
downloadphp-shirabe-8db88c2da2cf387bc58843ac5093bb15b9fc3252.tar.gz
php-shirabe-8db88c2da2cf387bc58843ac5093bb15b9fc3252.tar.zst
php-shirabe-8db88c2da2cf387bc58843ac5093bb15b9fc3252.zip
feat(php-shim): implement fs helpers and recursive directory iterators
Give RecursiveDirectoryIterator/RecursiveIteratorIterator real backing data: the iterator walks the tree in SELF_FIRST/CHILD_FIRST order, yields SplFileInfo-like entries (is_dir/is_file/is_link/get_pathname/get_size), and a Cell cursor lets get_sub_pathname() report the current entry. Implement the std-backed file helpers: lstat, mkdir (umask-aware via DirBuilder), symlink, chmod, fileperms, fileowner, is_executable, unlink_silent, touch (create-if-absent), file()/file_put_contents3/ file_get_contents5, tempnam, umask (via /proc/self/status), and a glob() supporting *, ?, [...] and {..} brace expansion. The PhpMixed-keyed fopen stream family, disk_free_space, touch with an explicit time, opendir and DirectoryIterator remain TODO(phase-d): they need a PhpMixed stream representation or syscalls std does not expose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
-rw-r--r--crates/shirabe-php-shim/src/fs.rs557
1 files changed, 526 insertions, 31 deletions
diff --git a/crates/shirabe-php-shim/src/fs.rs b/crates/shirabe-php-shim/src/fs.rs
index df7d3b3..a97433a 100644
--- a/crates/shirabe-php-shim/src/fs.rs
+++ b/crates/shirabe-php-shim/src/fs.rs
@@ -37,6 +37,9 @@ impl FilesystemIterator {
#[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!()
}
@@ -49,7 +52,10 @@ impl DirectoryIteratorEntry {
}
#[derive(Debug)]
-pub struct RecursiveDirectoryIterator;
+pub struct RecursiveDirectoryIterator {
+ root: std::path::PathBuf,
+ flags: i64,
+}
impl RecursiveDirectoryIterator {
pub const SKIP_DOTS: i64 = 4096;
@@ -57,48 +63,95 @@ impl RecursiveDirectoryIterator {
}
#[derive(Debug)]
-pub struct RecursiveIteratorIterator;
+pub struct RecursiveIteratorIterator {
+ entries: Vec<RecursiveIteratorFileInfo>,
+ // Index of the entry the iteration is currently on, so get_sub_pathname() can report it.
+ cursor: std::cell::Cell<usize>,
+}
impl RecursiveIteratorIterator {
pub const SELF_FIRST: i64 = 0;
pub const CHILD_FIRST: i64 = 16;
pub fn get_sub_pathname(&self) -> String {
- todo!()
+ 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<Self::Item> {
+ 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 IntoIterator for &RecursiveIteratorIterator {
+impl<'a> IntoIterator for &'a RecursiveIteratorIterator {
type Item = RecursiveIteratorFileInfo;
- type IntoIter = std::vec::IntoIter<RecursiveIteratorFileInfo>;
+ type IntoIter = RecursiveIteratorIter<'a>;
fn into_iter(self) -> Self::IntoIter {
- todo!()
+ RecursiveIteratorIter {
+ inner: self,
+ index: 0,
+ }
}
}
-#[derive(Debug)]
-pub struct RecursiveIteratorFileInfo;
+#[derive(Debug, Clone)]
+pub struct RecursiveIteratorFileInfo {
+ path: std::path::PathBuf,
+ root: std::path::PathBuf,
+}
impl RecursiveIteratorFileInfo {
pub fn is_dir(&self) -> bool {
- todo!()
+ // SplFileInfo::isDir() follows symlinks.
+ std::fs::metadata(&self.path)
+ .map(|m| m.is_dir())
+ .unwrap_or(false)
}
pub fn is_file(&self) -> bool {
- todo!()
+ std::fs::metadata(&self.path)
+ .map(|m| m.is_file())
+ .unwrap_or(false)
}
pub fn is_link(&self) -> bool {
- todo!()
+ std::fs::symlink_metadata(&self.path)
+ .map(|m| m.file_type().is_symlink())
+ .unwrap_or(false)
}
pub fn get_pathname(&self) -> String {
- todo!()
+ self.path.to_string_lossy().into_owned()
}
pub fn get_size(&self) -> i64 {
- todo!()
+ 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())
}
}
@@ -106,80 +159,190 @@ pub fn recursive_directory_iterator(
_path: impl AsRef<std::path::Path>,
_flags: i64,
) -> Result<RecursiveDirectoryIterator, UnexpectedValueException> {
- todo!()
+ 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 {
- todo!()
+ 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<RecursiveIteratorFileInfo>,
+) {
+ 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<DirectoryIteratorEntry> {
+ // 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<i64> {
+ // TODO(phase-d): see fopen; no PhpMixed stream representation exists.
todo!()
}
pub fn fread(_handle: PhpMixed, _length: i64) -> Option<String> {
+ // 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<String> {
+ // TODO(phase-d): see fopen; no PhpMixed stream representation exists.
todo!()
}
pub fn fgetc(_resource: &PhpResource) -> Option<String> {
+ // 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<IndexMap<String, PhpMixed>> {
- todo!()
+ 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!()
+ // 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) {
@@ -216,19 +379,28 @@ pub fn fwrite_resource(resource: &PhpResource, data: &str) {
}
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 {
- todo!()
+ 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 {
- todo!()
+ 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<std::path::Path>) -> Option<i64> {
@@ -264,7 +436,13 @@ pub fn is_readable(_path: &str) -> bool {
}
pub fn is_executable(_path: &str) -> bool {
- todo!()
+ 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<std::path::Path>) -> bool {
@@ -298,7 +476,8 @@ pub fn filemtime(_filename: &str) -> Option<i64> {
}
pub fn fileowner(_filename: &str) -> Option<i64> {
- todo!()
+ use std::os::unix::fs::MetadataExt;
+ std::fs::metadata(_filename).ok().map(|m| m.uid() as i64)
}
pub fn unlink(path: impl AsRef<std::path::Path>) -> bool {
@@ -306,7 +485,8 @@ pub fn unlink(path: impl AsRef<std::path::Path>) -> bool {
}
pub fn unlink_silent(_path: &str) -> bool {
- todo!()
+ // 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<i64> {
@@ -316,7 +496,20 @@ pub fn file_put_contents(_path: &str, _data: &[u8]) -> Option<i64> {
}
pub fn file_put_contents3(_filename: &str, _data: &str, _flags: i64) -> Option<i64> {
- todo!()
+ 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<String> {
@@ -332,7 +525,21 @@ pub fn file_get_contents5(
_offset: i64,
_length: Option<i64>,
) -> Option<String> {
- todo!()
+ // 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<String> {
@@ -346,19 +553,57 @@ pub fn chdir(_path: &str) -> anyhow::Result<()> {
}
pub fn glob(_pattern: &str) -> Vec<String> {
- todo!()
+ glob_with_flags(_pattern, 0)
}
+pub const FILE_SKIP_EMPTY_LINES: i64 = 4;
+
pub fn file(_filename: &str, _flags: i64) -> Option<Vec<String>> {
- todo!()
+ 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 {
- todo!()
+ // 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 {
- todo!()
+ 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<std::path::Path>) -> bool {
@@ -377,11 +622,12 @@ pub fn copy(_source: &str, _dest: &str) -> bool {
}
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 {
- todo!()
+ std::os::unix::fs::symlink(_target, _link).is_ok()
}
pub fn sys_get_temp_dir() -> String {
@@ -389,10 +635,31 @@ pub fn sys_get_temp_dir() -> String {
}
pub fn tempnam(_dir: &str, _prefix: &str) -> Option<String> {
- todo!()
+ 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<PhpMixed> {
+ // 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!()
}
@@ -480,6 +747,8 @@ pub fn clearstatcache2(_clear_realpath_cache: bool, _filename: &str) {
}
pub fn disk_free_space(_directory: &str) -> Option<f64> {
+ // 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!()
}
@@ -488,5 +757,231 @@ pub const GLOB_ONLYDIR: i64 = 1024;
pub const GLOB_BRACE: i64 = 4096;
pub fn glob_with_flags(_pattern: &str, _flags: i64) -> Vec<String> {
- todo!()
+ let patterns = if _flags & GLOB_BRACE != 0 {
+ glob_expand_braces(_pattern)
+ } else {
+ vec![_pattern.to_string()]
+ };
+ let mut results: Vec<String> = 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<String>) {
+ 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<String> = Vec::new();
+ for base in &current {
+ 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<usize> = 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<String> {
+ 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<String> {
+ 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
}