aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-06-14 12:04:51 +0900
committernsfisis <nsfisis@gmail.com>2026-06-14 12:14:41 +0900
commit4ace644474d3d0e5575d19616ce46ba7d521fc64 (patch)
tree66a100c91058c17a14df3173b97360aaf806274f /crates/shirabe-php-shim
parent716f44031a39c5e43fb441ecc470db76efc23dd4 (diff)
downloadphp-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.rs332
-rw-r--r--crates/shirabe-php-shim/src/preg.rs618
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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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: &regex::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
+}