From ef9118c788c1cbb22ca7721b6a9e40c2bf2fe243 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 14 Jun 2026 05:06:27 +0900 Subject: feat(pcre): implement preg_* functions Replace the todo!() stubs backing the Preg class and the standalone preg_* shim helpers with regex-crate implementations. --- crates/shirabe-php-shim/src/lib.rs | 261 ++++++++++++++++++++++++++++++++----- 1 file changed, 227 insertions(+), 34 deletions(-) (limited to 'crates/shirabe-php-shim') diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index 4a0a3d9..00973ac 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -1041,24 +1041,57 @@ pub fn json_encode(_value: &T) -> Option { todo!() } -pub fn preg_quote(_str: &str, _delimiter: Option) -> String { - todo!() +pub fn preg_quote(str: &str, delimiter: Option) -> String { + const SPECIAL: &str = ".\\+*?[^]$(){}=!<>|:-#"; + let mut out = String::new(); + for c in str.chars() { + if c == '\0' { + out.push_str("\\000"); + } else if SPECIAL.contains(c) || Some(c) == delimiter { + out.push('\\'); + out.push(c); + } else { + out.push(c); + } + } + out } // Returns 1 on match, 0 on no match; populates matches[0]=full match, matches[1..]=captures. // Optional groups that did not participate in the match are stored as None. -pub fn preg_match(_pattern: &str, _subject: &str, _matches: &mut Vec>) -> i64 { - todo!() +pub fn preg_match(pattern: &str, subject: &str, matches: &mut Vec>) -> i64 { + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + matches.clear(); + match re.captures(subject) { + Some(caps) => { + for g in 0..caps.len() { + matches.push(caps.get(g).map(|m| m.as_str().to_string())); + } + 1 + } + None => 0, + } } // Returns Some(result) on success, None on error. -pub fn preg_replace(_pattern: &str, _replacement: &str, _subject: &str) -> Option { - todo!() +pub fn preg_replace(pattern: &str, replacement: &str, subject: &str) -> Option { + let re = compile_php_pattern(pattern).ok()?; + let mut out: Vec = Vec::new(); + let mut last = 0; + for caps in re.captures_iter(subject) { + let m = caps.get(0).unwrap(); + out.extend_from_slice(&subject.as_bytes()[last..m.start()]); + php_replacement_expand(replacement, &caps, &mut out); + last = m.end(); + } + out.extend_from_slice(&subject.as_bytes()[last..]); + Some(String::from_utf8_lossy(&out).into_owned()) } // Returns Some(parts) on success, None on error. -pub fn preg_split(_pattern: &str, _subject: &str) -> Option> { - todo!() +pub fn preg_split(pattern: &str, subject: &str) -> Option> { + let re = compile_php_pattern(pattern).ok()?; + Some(php_split_impl(&re, subject)) } pub fn dirname(_path: &str) -> String { @@ -2904,40 +2937,92 @@ pub fn exit(status: i64) -> ! { std::process::exit(status as i32); } -pub fn preg_match_all(_pattern: &str, _subject: &str) -> Vec> { - todo!() +// PREG_PATTERN_ORDER: the outer vec is indexed by capture group, the inner by +// match occurrence. Non-participating groups are reported as "". +pub fn preg_match_all(pattern: &str, subject: &str) -> Vec> { + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + let group_count = re.captures_len(); + let mut groups: Vec> = vec![Vec::new(); group_count]; + for caps in re.captures_iter(subject) { + for g in 0..group_count { + groups[g].push( + caps.get(g) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + ); + } + } + groups } pub fn preg_match_all_simple( - _pattern: &str, - _subject: &str, - _matches: &mut Vec>, + pattern: &str, + subject: &str, + matches: &mut Vec>, ) -> anyhow::Result { - todo!() + let re = compile_php_pattern(pattern)?; + let group_count = re.captures_len(); + let mut groups: Vec> = vec![Vec::new(); group_count]; + let mut count = 0i64; + for caps in re.captures_iter(subject) { + count += 1; + for g in 0..group_count { + groups[g].push( + caps.get(g) + .map(|m| m.as_str().to_string()) + .unwrap_or_default(), + ); + } + } + *matches = groups; + Ok(count) } +// PREG_SET_ORDER: the outer vec is indexed by match occurrence, the inner by +// capture group (a classic `$matches` row). pub fn preg_match_all_set_order( - _pattern: &str, - _subject: &str, - _matches: &mut Vec>, + pattern: &str, + subject: &str, + matches: &mut Vec>, ) -> anyhow::Result { - todo!() + let re = compile_php_pattern(pattern)?; + let mut rows: Vec> = Vec::new(); + for caps in re.captures_iter(subject) { + rows.push(php_match_row(&caps)); + } + let count = rows.len() as i64; + *matches = rows; + Ok(count) } pub fn preg_match_offset( - _pattern: &str, - _subject: &str, - _matches: &mut Vec, + pattern: &str, + subject: &str, + matches: &mut Vec, _flags: i64, - _offset: i64, + offset: i64, ) -> bool { - todo!() + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + match re.captures_at(subject, offset as usize) { + Some(caps) => { + *matches = php_match_row(&caps); + true + } + None => { + matches.clear(); + false + } + } } -pub fn preg_match_groups(_pattern: &str, _subject: &str) -> Option> { - todo!() +pub fn preg_match_groups(pattern: &str, subject: &str) -> Option> { + let re = compile_php_pattern(pattern).ok()?; + let caps = re.captures(subject)?; + Some(php_match_row(&caps)) } -pub fn preg_grep(_pattern: &str, _input: &Vec) -> Vec { - todo!() +pub fn preg_grep(pattern: &str, input: &Vec) -> Vec { + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + input.iter().filter(|s| re.is_match(s)).cloned().collect() } -pub fn preg_split_chars(_pattern: &str, _subject: &str) -> Vec { - todo!() +pub fn preg_split_chars(pattern: &str, subject: &str) -> Vec { + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + php_split_impl(&re, subject) } #[derive(Debug, Default)] @@ -2955,7 +3040,7 @@ impl PregOffsetCaptureMatches { // lookaround, backreferences) are not supported by `regex` and must be avoided // in the caller's pattern. // TODO(phase-c): replace with a faithful PCRE engine to restore full semantics. -fn compile_php_pattern(pattern: &str) -> anyhow::Result { +pub fn compile_php_pattern(pattern: &str) -> anyhow::Result { let delimiter = pattern .chars() .next() @@ -2981,6 +3066,100 @@ fn compile_php_pattern(pattern: &str) -> anyhow::Result { Ok(regex::Regex::new(&translated)?) } +// Expands a PHP preg replacement template against `caps`, appending bytes to +// `out`. Backreferences are written as `$1`, `${1}`, `\1` or `\\1`; a literal +// `$` or `\` not forming a reference is emitted verbatim. Out-of-range or +// non-participating groups expand to nothing. +fn php_replacement_expand(template: &str, caps: ®ex::Captures, out: &mut Vec) { + let bytes = template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() => { + let (group, consumed) = php_replacement_group(&bytes[i + 1..]); + if let Some(m) = caps.get(group) { + out.extend_from_slice(m.as_str().as_bytes()); + } + i += 1 + consumed; + } + b'\\' if i + 1 < bytes.len() && bytes[i + 1] == b'\\' => { + out.push(b'\\'); + i += 2; + } + b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'{' => { + let rest = &bytes[i + 2..]; + match rest.iter().position(|&b| b == b'}') { + Some(c) if c > 0 && rest[..c].iter().all(|b| b.is_ascii_digit()) => { + let group: usize = + std::str::from_utf8(&rest[..c]).unwrap().parse().unwrap(); + if let Some(m) = caps.get(group) { + out.extend_from_slice(m.as_str().as_bytes()); + } + i += 2 + c + 1; + } + _ => { + out.push(b'$'); + i += 1; + } + } + } + b'$' if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() => { + let (group, consumed) = php_replacement_group(&bytes[i + 1..]); + if let Some(m) = caps.get(group) { + out.extend_from_slice(m.as_str().as_bytes()); + } + i += 1 + consumed; + } + b => { + out.push(b); + i += 1; + } + } + } +} + +// Reads up to two leading ASCII digits as a PHP backreference group number. +fn php_replacement_group(bytes: &[u8]) -> (usize, usize) { + let mut group = 0usize; + let mut consumed = 0usize; + while consumed < 2 && consumed < bytes.len() && bytes[consumed].is_ascii_digit() { + group = group * 10 + (bytes[consumed] - b'0') as usize; + consumed += 1; + } + (group, consumed) +} + +// PHP `preg_split($pattern, $subject)` with no flags or limit: the text between +// successive matches, including the leading and trailing pieces (which may be +// empty). Zero-width matches split between every position. +fn php_split_impl(re: ®ex::Regex, subject: &str) -> Vec { + let mut result = Vec::new(); + let mut last = 0; + for caps in re.captures_iter(subject) { + let m = caps.get(0).unwrap(); + result.push(subject[last..m.start()].to_string()); + last = m.end(); + } + result.push(subject[last..].to_string()); + result +} + +// Classic preg_match `$matches` row: index 0 is the full match, trailing +// unmatched groups are truncated and interior unmatched groups become "". +fn php_match_row(caps: ®ex::Captures) -> Vec { + let last = (0..caps.len()) + .rev() + .find(|&g| caps.get(g).is_some()) + .unwrap_or(0); + (0..=last) + .map(|g| { + caps.get(g) + .map(|m| m.as_str().to_string()) + .unwrap_or_default() + }) + .collect() +} + pub fn preg_match_all_offset_capture( pattern: &str, subject: &str, @@ -3008,14 +3187,28 @@ pub fn preg_match_all_offset_capture( Ok(count) } pub fn preg_replace_callback( - _pattern: &str, - _callback: F, - _subject: &str, + pattern: &str, + mut callback: F, + subject: &str, ) -> anyhow::Result where F: FnMut(&[Option]) -> anyhow::Result, { - todo!() + let re = compile_php_pattern(pattern)?; + let mut out: Vec = Vec::new(); + let mut last = 0; + for caps in re.captures_iter(subject) { + let m = caps.get(0).unwrap(); + out.extend_from_slice(&subject.as_bytes()[last..m.start()]); + let groups: Vec> = (0..caps.len()) + .map(|g| caps.get(g).map(|x| x.as_str().to_string())) + .collect(); + let replaced = callback(&groups)?; + out.extend_from_slice(replaced.as_bytes()); + last = m.end(); + } + out.extend_from_slice(&subject.as_bytes()[last..]); + Ok(String::from_utf8_lossy(&out).into_owned()) } pub fn is_resource_value(_resource: &PhpResource) -> bool { -- cgit v1.3.1