From 8db88c2da2cf387bc58843ac5093bb15b9fc3252 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 21 Jun 2026 21:03:31 +0900 Subject: 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) --- crates/shirabe-php-shim/src/fs.rs | 557 +++++++++++++++++++++++++++++++++++--- 1 file changed, 526 insertions(+), 31 deletions(-) (limited to 'crates/shirabe-php-shim/src') 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, + // 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 { - todo!() + self.entries[self.cursor.get()].sub_pathname() } } -impl IntoIterator for &RecursiveIteratorIterator { +pub struct RecursiveIteratorIter<'a> { + inner: &'a RecursiveIteratorIterator, + index: usize, +} + +impl Iterator for RecursiveIteratorIter<'_> { type Item = RecursiveIteratorFileInfo; - type IntoIter = std::vec::IntoIter; + + 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 { - 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, _flags: i64, ) -> Result { - 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, +) { + 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> { - 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) -> Option { @@ -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) -> bool { @@ -298,7 +476,8 @@ pub fn filemtime(_filename: &str) -> Option { } pub fn fileowner(_filename: &str) -> Option { - todo!() + use std::os::unix::fs::MetadataExt; + std::fs::metadata(_filename).ok().map(|m| m.uid() as i64) } pub fn unlink(path: impl AsRef) -> bool { @@ -306,7 +485,8 @@ pub fn unlink(path: impl AsRef) -> 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 { @@ -316,7 +496,20 @@ pub fn file_put_contents(_path: &str, _data: &[u8]) -> Option { } pub fn file_put_contents3(_filename: &str, _data: &str, _flags: i64) -> Option { - 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 { @@ -332,7 +525,21 @@ pub fn file_get_contents5( _offset: i64, _length: Option, ) -> Option { - 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 { @@ -346,19 +553,57 @@ pub fn chdir(_path: &str) -> anyhow::Result<()> { } pub fn glob(_pattern: &str) -> Vec { - todo!() + glob_with_flags(_pattern, 0) } +pub const FILE_SKIP_EMPTY_LINES: i64 = 4; + pub fn file(_filename: &str, _flags: i64) -> Option> { - 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) -> 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 { - 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 { + // 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 { + // 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 { - todo!() + 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 } -- cgit v1.3.1