diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-14 12:04:51 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-14 12:14:41 +0900 |
| commit | 4ace644474d3d0e5575d19616ce46ba7d521fc64 (patch) | |
| tree | 66a100c91058c17a14df3173b97360aaf806274f /crates/shirabe-php-shim | |
| parent | 716f44031a39c5e43fb441ecc470db76efc23dd4 (diff) | |
| download | php-shirabe-4ace644474d3d0e5575d19616ce46ba7d521fc64.tar.gz php-shirabe-4ace644474d3d0e5575d19616ce46ba7d521fc64.tar.zst php-shirabe-4ace644474d3d0e5575d19616ce46ba7d521fc64.zip | |
refactor(pcre): move preg_* free functions into php-shim
The free preg_* functions, the CaptureKey type, and the PREG_* constants
move from the pcre crate into shirabe-php-shim, where the rest of the
PHP standard-library shims live. Colliding names take a 2 suffix
(preg_match2, preg_split2, etc.) since the shim already exposes
differently-shaped variants. Preg keeps its public API and delegates to
the relocated functions; CaptureKey is re-exported from composer::pcre
so consumers are unaffected.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-shim')
| -rw-r--r-- | crates/shirabe-php-shim/src/lib.rs | 332 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/preg.rs | 618 |
2 files changed, 622 insertions, 328 deletions
diff --git a/crates/shirabe-php-shim/src/lib.rs b/crates/shirabe-php-shim/src/lib.rs index 00973ac..40b0060 100644 --- a/crates/shirabe-php-shim/src/lib.rs +++ b/crates/shirabe-php-shim/src/lib.rs @@ -1,3 +1,7 @@ +mod preg; + +pub use preg::*; + use indexmap::IndexMap; #[derive(Debug, Clone, Default)] @@ -1041,59 +1045,6 @@ pub fn json_encode<T: serde::Serialize + ?Sized>(_value: &T) -> Option<String> { todo!() } -pub fn preg_quote(str: &str, delimiter: Option<char>) -> 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<Option<String>>) -> 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<String> { - let re = compile_php_pattern(pattern).ok()?; - let mut out: Vec<u8> = 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<Vec<String>> { - let re = compile_php_pattern(pattern).ok()?; - Some(php_split_impl(&re, subject)) -} - pub fn dirname(_path: &str) -> String { todo!() } @@ -2738,7 +2689,6 @@ pub const OPENSSL_VERSION_NUMBER: i64 = 0; pub const OPENSSL_VERSION_TEXT: &str = ""; pub const PHP_BINARY: &str = ""; pub const PHP_WINDOWS_VERSION_BUILD: i64 = 0; -pub const PREG_BACKTRACK_LIMIT_ERROR: i64 = 2; #[derive(Debug, Clone)] pub struct ArrayObject { @@ -2937,280 +2887,6 @@ pub fn exit(status: i64) -> ! { std::process::exit(status as i32); } -// 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<Vec<String>> { - 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<String>> = 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<Vec<String>>, -) -> anyhow::Result<i64> { - let re = compile_php_pattern(pattern)?; - let group_count = re.captures_len(); - let mut groups: Vec<Vec<String>> = 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<Vec<String>>, -) -> anyhow::Result<i64> { - let re = compile_php_pattern(pattern)?; - let mut rows: Vec<Vec<String>> = 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<String>, - _flags: i64, - offset: i64, -) -> bool { - 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<Vec<String>> { - 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<String>) -> Vec<String> { - 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<String> { - let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); - php_split_impl(&re, subject) -} - -#[derive(Debug, Default)] -pub struct PregOffsetCaptureMatches { - groups: Vec<Vec<(String, usize)>>, -} -impl PregOffsetCaptureMatches { - pub fn group(&self, i: usize) -> &[(String, usize)] { - &self.groups[i] - } -} -// Translates a PHP PCRE pattern (delimiters + trailing modifiers) into a regex -// the `regex` crate can compile. Only delimiter stripping and the i/x/s/m -// modifiers are handled; PCRE-only constructs (possessive quantifiers, -// 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. -pub fn compile_php_pattern(pattern: &str) -> anyhow::Result<regex::Regex> { - let delimiter = pattern - .chars() - .next() - .ok_or_else(|| anyhow::anyhow!("empty regex pattern"))?; - let end = pattern - .rfind(delimiter) - .filter(|&i| i > 0) - .ok_or_else(|| anyhow::anyhow!("unterminated regex pattern: {pattern}"))?; - let inner = &pattern[delimiter.len_utf8()..end]; - let modifiers = &pattern[end + delimiter.len_utf8()..]; - - let flags: String = modifiers - .chars() - .filter(|c| matches!(c, 'i' | 'x' | 's' | 'm')) - .collect(); - - let translated = if flags.is_empty() { - inner.to_string() - } else { - format!("(?{flags}){inner}") - }; - - 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<u8>) { - 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<String> { - 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<String> { - 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, - matches: &mut PregOffsetCaptureMatches, -) -> anyhow::Result<i64> { - let re = compile_php_pattern(pattern)?; - let group_count = re.captures_len(); - matches.groups = vec![Vec::new(); group_count]; - - let mut count = 0; - for caps in re.captures_iter(subject) { - count += 1; - for g in 0..group_count { - // PHP stores ["", -1] for non-participating groups under - // PREG_OFFSET_CAPTURE; the unsigned offset here approximates -1 as 0, - // which callers must not rely on for absent groups. - let entry = caps - .get(g) - .map(|m| (m.as_str().to_string(), m.start())) - .unwrap_or_else(|| (String::new(), 0)); - matches.groups[g].push(entry); - } - } - - Ok(count) -} -pub fn preg_replace_callback<F>( - pattern: &str, - mut callback: F, - subject: &str, -) -> anyhow::Result<String> -where - F: FnMut(&[Option<String>]) -> anyhow::Result<String>, -{ - let re = compile_php_pattern(pattern)?; - let mut out: Vec<u8> = 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<Option<String>> = (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 { true } diff --git a/crates/shirabe-php-shim/src/preg.rs b/crates/shirabe-php-shim/src/preg.rs new file mode 100644 index 0000000..e1b05b6 --- /dev/null +++ b/crates/shirabe-php-shim/src/preg.rs @@ -0,0 +1,618 @@ +pub const PREG_PATTERN_ORDER: i64 = 1; +pub const PREG_SET_ORDER: i64 = 2; +pub const PREG_OFFSET_CAPTURE: i64 = 256; +pub const PREG_UNMATCHED_AS_NULL: i64 = 512; +pub const PREG_SPLIT_NO_EMPTY: i64 = 1; +pub const PREG_SPLIT_DELIM_CAPTURE: i64 = 2; +pub const PREG_SPLIT_OFFSET_CAPTURE: i64 = 4; +pub const PREG_GREP_INVERT: i64 = 1; + +#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)] +pub enum CaptureKey { + ByIndex(usize), + ByName(String), +} + +#[derive(Debug, Default)] +pub struct PregOffsetCaptureMatches { + groups: Vec<Vec<(String, usize)>>, +} + +impl PregOffsetCaptureMatches { + pub fn group(&self, i: usize) -> &[(String, usize)] { + &self.groups[i] + } +} + +pub fn preg_quote(str: &str, delimiter: Option<char>) -> 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<Option<String>>) -> 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<String> { + let re = compile_php_pattern(pattern).ok()?; + let mut out: Vec<u8> = 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<Vec<String>> { + let re = compile_php_pattern(pattern).ok()?; + Some(php_split_impl(&re, subject)) +} + +// 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<Vec<String>> { + 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<String>> = 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<Vec<String>>, +) -> anyhow::Result<i64> { + let re = compile_php_pattern(pattern)?; + let group_count = re.captures_len(); + let mut groups: Vec<Vec<String>> = 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<Vec<String>>, +) -> anyhow::Result<i64> { + let re = compile_php_pattern(pattern)?; + let mut rows: Vec<Vec<String>> = 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<String>, + _flags: i64, + offset: i64, +) -> bool { + 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<Vec<String>> { + 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<String>) -> Vec<String> { + 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<String> { + let re = compile_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}")); + php_split_impl(&re, subject) +} + +pub fn preg_match_all_offset_capture( + pattern: &str, + subject: &str, + matches: &mut PregOffsetCaptureMatches, +) -> anyhow::Result<i64> { + let re = compile_php_pattern(pattern)?; + let group_count = re.captures_len(); + matches.groups = vec![Vec::new(); group_count]; + + let mut count = 0; + for caps in re.captures_iter(subject) { + count += 1; + for g in 0..group_count { + // PHP stores ["", -1] for non-participating groups under + // PREG_OFFSET_CAPTURE; the unsigned offset here approximates -1 as 0, + // which callers must not rely on for absent groups. + let entry = caps + .get(g) + .map(|m| (m.as_str().to_string(), m.start())) + .unwrap_or_else(|| (String::new(), 0)); + matches.groups[g].push(entry); + } + } + + Ok(count) +} +pub fn preg_replace_callback<F>( + pattern: &str, + mut callback: F, + subject: &str, +) -> anyhow::Result<String> +where + F: FnMut(&[Option<String>]) -> anyhow::Result<String>, +{ + let re = compile_php_pattern(pattern)?; + let mut out: Vec<u8> = 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<Option<String>> = (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()) +} + +// Returns Some(0|1) on success or None when the underlying preg_match returned +// false. Unmatched groups are reported as None (PREG_UNMATCHED_AS_NULL). +pub fn preg_match2( + pattern: &str, + subject: &str, + matches: Option<&mut indexmap::IndexMap<CaptureKey, Option<String>>>, + flags: i64, + offset: usize, +) -> Option<i64> { + let re = compile_php_pattern(pattern).ok()?; + let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; + let caps = re.captures_at(subject, offset); + + if let Some(out) = matches { + out.clear(); + if let Some(caps) = &caps { + let names: Vec<Option<&str>> = re.capture_names().collect(); + *out = single_match_map(caps, &names, unmatched_as_null); + } + } + + Some(if caps.is_some() { 1 } else { 0 }) +} + +pub fn preg_match_all2( + pattern: &str, + subject: &str, + matches: Option<&mut indexmap::IndexMap<CaptureKey, Vec<Option<String>>>>, + flags: i64, + offset: usize, +) -> Option<i64> { + let re = compile_php_pattern(pattern).ok()?; + let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; + let group_count = re.captures_len(); + let names: Vec<Option<&str>> = re.capture_names().collect(); + + // PREG_PATTERN_ORDER: one column per group, one row per match occurrence. + let mut groups: Vec<Vec<Option<String>>> = vec![Vec::new(); group_count]; + let mut count = 0i64; + for caps in re.captures_iter(&subject[offset..]) { + count += 1; + for (g, column) in groups.iter_mut().enumerate() { + let value = caps.get(g).map(|m| m.as_str().to_string()); + column.push(if unmatched_as_null { + value + } else { + Some(value.unwrap_or_default()) + }); + } + } + + if let Some(out) = matches { + out.clear(); + for (g, column) in groups.into_iter().enumerate() { + if let Some(Some(name)) = names.get(g) { + out.insert(CaptureKey::ByName((*name).to_string()), column.clone()); + } + out.insert(CaptureKey::ByIndex(g), column); + } + } + + Some(count) +} + +pub fn preg_match_all_offset_capture2( + pattern: &str, + subject: &str, + matches: Option<&mut indexmap::IndexMap<CaptureKey, Vec<(Option<String>, i64)>>>, + flags: i64, + offset: usize, +) -> Option<i64> { + let re = compile_php_pattern(pattern).ok()?; + let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; + let group_count = re.captures_len(); + let names: Vec<Option<&str>> = re.capture_names().collect(); + + let mut groups: Vec<Vec<(Option<String>, i64)>> = vec![Vec::new(); group_count]; + let mut count = 0i64; + for caps in re.captures_iter(&subject[offset..]) { + count += 1; + for (g, column) in groups.iter_mut().enumerate() { + let entry = match caps.get(g) { + Some(m) => (Some(m.as_str().to_string()), (m.start() + offset) as i64), + None if unmatched_as_null => (None, -1), + None => (Some(String::new()), -1), + }; + column.push(entry); + } + } + + if let Some(out) = matches { + out.clear(); + for (g, column) in groups.into_iter().enumerate() { + if let Some(Some(name)) = names.get(g) { + out.insert(CaptureKey::ByName((*name).to_string()), column.clone()); + } + out.insert(CaptureKey::ByIndex(g), column); + } + } + + Some(count) +} + +pub fn preg_replace2( + pattern: &str, + replacement: &str, + subject: &str, + limit: i64, + count: Option<&mut usize>, +) -> Option<String> { + let re = compile_php_pattern(pattern).ok()?; + let limit = if limit < 0 { + usize::MAX + } else { + limit as usize + }; + + let mut out: Vec<u8> = Vec::new(); + let mut last = 0usize; + let mut n = 0usize; + for caps in re.captures_iter(subject) { + if n >= limit { + break; + } + 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(); + n += 1; + } + out.extend_from_slice(&subject.as_bytes()[last..]); + + if let Some(count) = count { + *count = n; + } + Some(String::from_utf8_lossy(&out).into_owned()) +} + +pub fn preg_replace_callback2< + F: FnMut(&indexmap::IndexMap<CaptureKey, Option<String>>) -> String, +>( + pattern: &str, + mut callback: F, + subject: &str, + limit: i64, + count: Option<&mut usize>, + flags: i64, +) -> Option<String> { + let re = compile_php_pattern(pattern).ok()?; + let unmatched_as_null = flags & PREG_UNMATCHED_AS_NULL != 0; + let names: Vec<Option<&str>> = re.capture_names().collect(); + let limit = if limit < 0 { + usize::MAX + } else { + limit as usize + }; + + let mut out: Vec<u8> = Vec::new(); + let mut last = 0usize; + let mut n = 0usize; + for caps in re.captures_iter(subject) { + if n >= limit { + break; + } + let m = caps.get(0).unwrap(); + out.extend_from_slice(&subject.as_bytes()[last..m.start()]); + let map = single_match_map(&caps, &names, unmatched_as_null); + out.extend_from_slice(callback(&map).as_bytes()); + last = m.end(); + n += 1; + } + out.extend_from_slice(&subject.as_bytes()[last..]); + + if let Some(count) = count { + *count = n; + } + Some(String::from_utf8_lossy(&out).into_owned()) +} + +pub fn preg_split2(pattern: &str, subject: &str, limit: i64, flags: i64) -> Option<Vec<String>> { + let re = compile_php_pattern(pattern).ok()?; + let no_empty = flags & PREG_SPLIT_NO_EMPTY != 0; + let delim_capture = flags & PREG_SPLIT_DELIM_CAPTURE != 0; + // `limit` counts the resulting pieces; a non-positive value means no limit. + let max_delims = if limit > 0 { + (limit as usize).saturating_sub(1) + } else { + usize::MAX + }; + + let mut result: Vec<String> = Vec::new(); + let mut push = |s: &str, result: &mut Vec<String>| { + if !(no_empty && s.is_empty()) { + result.push(s.to_string()); + } + }; + + let mut last = 0usize; + let mut delims = 0usize; + for caps in re.captures_iter(subject) { + if delims >= max_delims { + break; + } + let m = caps.get(0).unwrap(); + push(&subject[last..m.start()], &mut result); + if delim_capture { + // Mirror preg_match: trailing unmatched groups are dropped, interior + // unmatched groups are emitted as "". + if let Some(last_g) = (1..caps.len()).rev().find(|&g| caps.get(g).is_some()) { + for g in 1..=last_g { + push(caps.get(g).map(|x| x.as_str()).unwrap_or(""), &mut result); + } + } + } + last = m.end(); + delims += 1; + } + push(&subject[last..], &mut result); + + Some(result) +} + +pub fn preg_grep2(pattern: &str, array: &[&str], flags: i64) -> Option<Vec<String>> { + let re = compile_php_pattern(pattern).ok()?; + let invert = flags & PREG_GREP_INVERT != 0; + Some( + array + .iter() + .filter(|s| re.is_match(s) != invert) + .map(|s| s.to_string()) + .collect(), + ) +} + +// Translates a PHP PCRE pattern (delimiters + trailing modifiers) into a regex +// the `regex` crate can compile. Only delimiter stripping and the i/x/s/m +// modifiers are handled; PCRE-only constructs (possessive quantifiers, +// 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<regex::Regex> { + let delimiter = pattern + .chars() + .next() + .ok_or_else(|| anyhow::anyhow!("empty regex pattern"))?; + let end = pattern + .rfind(delimiter) + .filter(|&i| i > 0) + .ok_or_else(|| anyhow::anyhow!("unterminated regex pattern: {pattern}"))?; + let inner = &pattern[delimiter.len_utf8()..end]; + let modifiers = &pattern[end + delimiter.len_utf8()..]; + + let flags: String = modifiers + .chars() + .filter(|c| matches!(c, 'i' | 'x' | 's' | 'm')) + .collect(); + + let translated = if flags.is_empty() { + inner.to_string() + } else { + format!("(?{flags}){inner}") + }; + + 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<u8>) { + 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<String> { + 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<String> { + 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() +} + +// Builds a single match's `$matches` map with both named and numbered keys +// (the named key precedes its number). With PREG_UNMATCHED_AS_NULL, every group +// is present and non-participating ones are None; otherwise classic semantics +// apply: trailing unmatched groups are dropped and interior ones become "". +fn single_match_map( + caps: ®ex::Captures, + names: &[Option<&str>], + unmatched_as_null: bool, +) -> indexmap::IndexMap<CaptureKey, Option<String>> { + let mut out = indexmap::IndexMap::new(); + let group_count = caps.len(); + let last_participating = (0..group_count).rev().find(|&i| caps.get(i).is_some()); + + for i in 0..group_count { + let m = caps.get(i); + if !unmatched_as_null && m.is_none() { + if let Some(last) = last_participating { + if i > last { + break; + } + } + } + let value = if unmatched_as_null { + m.map(|m| m.as_str().to_string()) + } else { + Some(m.map(|m| m.as_str().to_string()).unwrap_or_default()) + }; + if let Some(Some(name)) = names.get(i) { + out.insert(CaptureKey::ByName((*name).to_string()), value.clone()); + } + out.insert(CaptureKey::ByIndex(i), value); + } + out +} |
