From 6aeda8b237fcbf7a56ca0e8c0fff415477d31d22 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 18 Aug 2026 01:57:02 +0900 Subject: refactor(preg): make preg_match_all yield matches per occurrence PHP's PREG_PATTERN_ORDER is column-oriented, but 7 of the 10 call sites read it row-wise, rebuilding each occurrence by indexing every column at the same offset. Return an iterator of PregMatches instead, which is also what the set-order and offset-capture variants were carrying, so the three functions collapse into one and PregMatchesAll, PregMatchesAllWithOffsets, CaptureKey and preg_match_map! all go away. The offset-capture call sites are served by the new PregMatches get_offset/name_offset accessors. The search stays eager: regex::Captures borrows only the subject, so the matches outlive the pattern resolved for the call, and PHP's preg_match_all is eager too. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/php_file_parser.rs | 40 ++--- crates/shirabe-php-shim/src/preg.rs | 176 +++------------------ .../src/completion/completion_input.rs | 8 +- .../src/formatter/output_formatter.rs | 90 +++++------ crates/shirabe/src/command/init_command.rs | 26 ++- crates/shirabe/src/downloader/git_downloader.rs | 45 +++--- .../shirabe/src/package/version/version_bumper.rs | 32 ++-- 7 files changed, 117 insertions(+), 300 deletions(-) (limited to 'crates') diff --git a/crates/shirabe-class-map-generator/src/php_file_parser.rs b/crates/shirabe-class-map-generator/src/php_file_parser.rs index 7cc96228..b7fb595c 100644 --- a/crates/shirabe-class-map-generator/src/php_file_parser.rs +++ b/crates/shirabe-class-map-generator/src/php_file_parser.rs @@ -2,9 +2,9 @@ use crate::php_file_cleaner::PhpFileCleaner; use shirabe_php_shim::{ - CaptureKey, PHP_EOL, RuntimeException, file_exists, file_get_contents, function_exists, - is_file, is_readable, ltrim, php_strip_whitespace, preg_match_all, str_replace_array, strrpos, - substr, trim, + PHP_EOL, RuntimeException, file_exists, file_get_contents, function_exists, is_file, + is_readable, ltrim, php_strip_whitespace, preg_match_all, str_replace_array, strrpos, substr, + trim, }; use std::sync::OnceLock; @@ -58,7 +58,7 @@ impl PhpFileParser { // return early if there is no chance of matching anything in this file let pattern = format!("{{\\b(?:class|interface|trait{})\\s}}i", extra_types); - let max_matches = preg_match_all(&pattern, &contents).occurrence_count(); + let max_matches = preg_match_all(&pattern, &contents).count(); if max_matches == 0 { return Ok(vec![]); } @@ -89,21 +89,10 @@ impl PhpFileParser { let mut classes = vec![]; let mut namespace = String::new(); - let len = matches - .get(&CaptureKey::ByName("type".to_owned())) - .map(|v| v.len()) - .unwrap_or(0); - for i in 0..len { - let ns = matches - .get(&CaptureKey::ByName("ns".to_owned())) - .and_then(|v| v.get(i)) - .and_then(|s| s.as_deref()); + for r#match in matches { + let ns = r#match.name("ns"); if ns.is_some_and(|ns| !ns.is_empty()) { - let nsname = matches - .get(&CaptureKey::ByName("nsname".to_owned())) - .and_then(|v| v.get(i)) - .and_then(|s| s.as_deref()) - .unwrap_or(""); + let nsname = r#match.name("nsname").unwrap_or(""); namespace = str_replace_array( &[ " ".to_string(), @@ -115,10 +104,8 @@ impl PhpFileParser { nsname, ) + "\\"; } else { - let name = matches - .get(&CaptureKey::ByName("name".to_owned())) - .and_then(|v| v.get(i)) - .and_then(|s| s.as_deref()) + let name = r#match + .name("name") .expect("the `name` group participates whenever `ns` does not"); // skip anon classes extending/implementing if name == "extends" { @@ -136,14 +123,7 @@ impl PhpFileParser { &["_".to_string(), "__".to_string()], stripped, ) - } else if matches - .get(&CaptureKey::ByName("type".to_owned())) - .and_then(|v| v.get(i)) - .and_then(|s| s.as_deref()) - .unwrap_or("") - .to_lowercase() - == "enum" - { + } else if r#match.name("type").unwrap_or("").to_lowercase() == "enum" { // something like: // enum Foo: int { HERP = '123'; } // The regex above captures the colon, which isn't part of diff --git a/crates/shirabe-php-shim/src/preg.rs b/crates/shirabe-php-shim/src/preg.rs index c4060a34..a9809205 100644 --- a/crates/shirabe-php-shim/src/preg.rs +++ b/crates/shirabe-php-shim/src/preg.rs @@ -16,62 +16,6 @@ use indexmap::IndexMap; use std::sync::{Arc, LazyLock, Mutex}; -#[derive(Debug, Clone, PartialOrd, Ord, PartialEq, Eq, Hash)] -pub enum CaptureKey { - ByIndex(usize), - ByName(String), -} - -/// Defines a newtype over `IndexMap` for one of the `$matches` shapes the `preg_*` functions fill -/// in. -macro_rules! preg_match_map { - ($(#[$attr:meta])* $vis:vis struct $name:ident($key:ty => $value:ty);) => { - $(#[$attr])* - #[derive(Debug, Default, Clone, PartialEq, Eq)] - $vis struct $name(::indexmap::IndexMap<$key, $value>); - - impl $name { - pub fn new() -> Self { - Self(::indexmap::IndexMap::new()) - } - - pub fn clear(&mut self) { - self.0.clear(); - } - - pub fn get(&self, key: &Q) -> Option<&$value> - where - Q: ?Sized + ::std::hash::Hash + ::indexmap::Equivalent<$key>, - { - self.0.get(key) - } - - pub fn insert(&mut self, key: $key, value: $value) -> Option<$value> { - self.0.insert(key, value) - } - - pub fn iter(&self) -> ::indexmap::map::Iter<'_, $key, $value> { - self.0.iter() - } - } - - impl IntoIterator for $name { - type Item = ($key, $value); - type IntoIter = ::indexmap::map::IntoIter<$key, $value>; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } - } - - impl FromIterator<($key, $value)> for $name { - fn from_iter>(iter: I) -> Self { - Self(iter.into_iter().collect()) - } - } - }; -} - /// A single match's `$matches`: the `regex::Captures` the search produced, read by either the named /// or the numbered form of a capture group. `'h` is the lifetime of the searched subject, which the /// group values borrow from. @@ -96,35 +40,16 @@ impl<'h> PregMatches<'h> { pub fn name(&self, name: &str) -> Option<&'h str> { self.caps.name(name).map(|group| group.as_str()) } -} - -preg_match_map! { - /// `PREG_PATTERN_ORDER` `$matches`: one entry per capture group, holding that group's value - /// across every match occurrence. - pub struct PregMatchesAll(CaptureKey => Vec>); -} -preg_match_map! { - /// `PREG_OFFSET_CAPTURE` counterpart of `PregMatchesAll`, pairing each value with the byte - /// offset it was captured at (`-1` for a group that did not participate). - pub struct PregMatchesAllWithOffsets(CaptureKey => Vec<(Option, i64)>); -} - -impl PregMatchesAll { - /// The number PHP's `preg_match_all` returns: every column holds one entry per occurrence. - pub fn occurrence_count(&self) -> usize { - self.get(&CaptureKey::ByIndex(0)) - .expect("group 0 is always present") - .len() + /// The byte offset the capture group at `index` starts at, under the same rules as `get`. + /// `PREG_OFFSET_CAPTURE` reports a non-participating group at offset `-1`. + pub fn get_offset(&self, index: usize) -> Option { + self.caps.get(index).map(|group| group.start()) } -} -impl PregMatchesAllWithOffsets { - /// The number PHP's `preg_match_all` returns: every column holds one entry per occurrence. - pub fn occurrence_count(&self) -> usize { - self.get(&CaptureKey::ByIndex(0)) - .expect("group 0 is always present") - .len() + /// The byte offset of the capture group called `name`, under the same rules as `get_offset`. + pub fn name_offset(&self, name: &str) -> Option { + self.caps.name(name).map(|group| group.start()) } } @@ -166,84 +91,21 @@ pub fn preg_match<'h>(pattern: impl PregPattern, subject: &'h str) -> Option
 PregMatchesAll {
-    let __resolved = pattern.resolve();
-    let re = __resolved.regex();
-    let group_count = re.captures_len();
-    let names: Vec> = re.capture_names().collect();
-
-    // PREG_PATTERN_ORDER: one column per group, one row per match occurrence.
-    let mut groups: Vec>> = vec![Vec::new(); group_count];
-    for caps in re.captures_iter(subject) {
-        for (g, column) in groups.iter_mut().enumerate() {
-            let value = caps.get(g).map(|m| m.as_str().to_string());
-            column.push(value);
-        }
-    }
-
-    let mut matches = PregMatchesAll::new();
-    for (g, column) in groups.into_iter().enumerate() {
-        if let Some(Some(name)) = names.get(g) {
-            matches.insert(CaptureKey::ByName((*name).to_string()), column.clone());
-        }
-        matches.insert(CaptureKey::ByIndex(g), column);
-    }
-
-    matches
-}
-
-// PREG_SET_ORDER: the outer vec is indexed by match occurrence, the inner by
-// capture group (a `$matches` row). A non-participating group is reported as
-// None.
-pub fn preg_match_all_set_order(
-    pattern: impl PregPattern,
-    subject: &str,
-) -> Vec>> {
-    let __resolved = pattern.resolve();
-    let re = __resolved.regex();
-    re.captures_iter(subject)
-        .map(|caps| {
-            (0..caps.len())
-                .map(|g| caps.get(g).map(|m| m.as_str().to_string()))
-                .collect()
-        })
-        .collect()
-}
-
-// A non-participating group is reported as None, at offset -1. The number of occurrences the
-// caller would get from PHP's return value is the length of any one column, as
-// `PregMatchesAllWithOffsets::occurrence_count` reports it.
-pub fn preg_match_all_offset_capture(
+// Every occurrence of the pattern in `subject`, in match order. The search runs eagerly, as PHP's
+// does: a match borrows `subject` alone, so the matches outlive the compiled pattern, which is only
+// resolved for the duration of this call.
+pub fn preg_match_all<'h>(
     pattern: impl PregPattern,
-    subject: &str,
-) -> PregMatchesAllWithOffsets {
+    subject: &'h str,
+) -> impl Iterator> {
     let __resolved = pattern.resolve();
-    let re = __resolved.regex();
-    let group_count = re.captures_len();
-    let names: Vec> = re.capture_names().collect();
-
-    let mut groups: Vec, i64)>> = vec![Vec::new(); group_count];
-    for caps in re.captures_iter(subject) {
-        for (g, column) in groups.iter_mut().enumerate() {
-            let entry = match caps.get(g) {
-                Some(m) => (Some(m.as_str().to_string()), m.start() as i64),
-                None => (None, -1),
-            };
-            column.push(entry);
-        }
-    }
-
-    let mut matches = PregMatchesAllWithOffsets::new();
-    for (g, column) in groups.into_iter().enumerate() {
-        if let Some(Some(name)) = names.get(g) {
-            matches.insert(CaptureKey::ByName((*name).to_string()), column.clone());
-        }
-        matches.insert(CaptureKey::ByIndex(g), column);
-    }
+    let matches: Vec> = __resolved
+        .regex()
+        .captures_iter(subject)
+        .map(PregMatches::new)
+        .collect();
 
-    matches
+    matches.into_iter()
 }
 
 pub fn preg_grep>(
diff --git a/crates/shirabe-symfony-console/src/completion/completion_input.rs b/crates/shirabe-symfony-console/src/completion/completion_input.rs
index 41c15ec5..e7f1e0a2 100644
--- a/crates/shirabe-symfony-console/src/completion/completion_input.rs
+++ b/crates/shirabe-symfony-console/src/completion/completion_input.rs
@@ -3,7 +3,7 @@
 use crate::input::ArgvInput;
 use crate::input::InputDefinition;
 use crate::input::InputOption;
-use shirabe_php_shim::{CaptureKey, PhpMixed, php_regex, preg_match_all};
+use shirabe_php_shim::{PhpMixed, php_regex, preg_match_all};
 
 /// An input specialized for shell completion.
 ///
@@ -36,13 +36,11 @@ impl CompletionInput {
 
         Self::from_tokens(
             tokens
-                .get(&CaptureKey::ByIndex(0))
-                .expect("group 0 is always present")
-                .iter()
                 .map(|token| {
                     token
-                        .clone()
+                        .get(0)
                         .expect("group 0 participates whenever the pattern matches")
+                        .to_string()
                 })
                 .collect(),
             current_index,
diff --git a/crates/shirabe-symfony-console/src/formatter/output_formatter.rs b/crates/shirabe-symfony-console/src/formatter/output_formatter.rs
index f4c55573..cc457351 100644
--- a/crates/shirabe-symfony-console/src/formatter/output_formatter.rs
+++ b/crates/shirabe-symfony-console/src/formatter/output_formatter.rs
@@ -6,10 +6,7 @@ use crate::formatter::output_formatter_style::OutputFormatterStyle;
 use crate::formatter::output_formatter_style_interface::OutputFormatterStyleInterface;
 use crate::formatter::output_formatter_style_stack::OutputFormatterStyleStack;
 use crate::formatter::wrappable_output_formatter_interface::WrappableOutputFormatterInterface;
-use shirabe_php_shim::{
-    CaptureKey, php_regex, preg_match, preg_match_all, preg_match_all_offset_capture,
-    preg_match_all_set_order, preg_replace,
-};
+use shirabe_php_shim::{PregMatches, php_regex, preg_match, preg_match_all, preg_replace};
 use shirabe_symfony_string::b;
 
 /// Formatter class for console output.
@@ -109,43 +106,37 @@ impl OutputFormatter {
             return Ok(Some(style.borrow().clone_box()));
         }
 
-        let matches = preg_match_all_set_order(php_regex!("/([^=]+)=([^;]+)(;|$)/"), string);
+        let matches: Vec =
+            preg_match_all(php_regex!("/([^=]+)=([^;]+)(;|$)/"), string).collect();
         if matches.is_empty() {
             return Ok(None);
         }
 
         let mut style = OutputFormatterStyle::new(None, None, vec![]);
         for r#match in &matches {
-            let mut r#match: Vec = r#match
-                .iter()
-                .map(|group| {
-                    group
-                        .clone()
-                        .expect("every group participates whenever the pattern matches")
-                })
-                .collect();
-            shirabe_php_shim::array_shift(&mut r#match);
-            r#match[0] = shirabe_php_shim::strtolower(&r#match[0]);
-
-            if r#match[0] == "fg" {
-                style.set_foreground(Some(&shirabe_php_shim::strtolower(&r#match[1])));
-            } else if r#match[0] == "bg" {
-                style.set_background(Some(&shirabe_php_shim::strtolower(&r#match[1])));
-            } else if r#match[0] == "href" {
-                let url = preg_replace(php_regex!("{\\\\([<>])}"), "$1", &r#match[1]);
+            let key = shirabe_php_shim::strtolower(
+                r#match
+                    .get(1)
+                    .expect("every group participates whenever the pattern matches"),
+            );
+            let value = r#match
+                .get(2)
+                .expect("every group participates whenever the pattern matches");
+
+            if key == "fg" {
+                style.set_foreground(Some(&shirabe_php_shim::strtolower(value)));
+            } else if key == "bg" {
+                style.set_background(Some(&shirabe_php_shim::strtolower(value)));
+            } else if key == "href" {
+                let url = preg_replace(php_regex!("{\\\\([<>])}"), "$1", value);
                 style.set_href(&url);
-            } else if r#match[0] == "options" {
-                let options = preg_match_all(
-                    php_regex!("([^,;]+)"),
-                    &shirabe_php_shim::strtolower(&r#match[1]),
-                );
-                let options = options
-                    .get(&CaptureKey::ByIndex(0))
-                    .expect("group 0 is always present");
+            } else if key == "options" {
+                let value = shirabe_php_shim::strtolower(value);
+                let options = preg_match_all(php_regex!("([^,;]+)"), &value);
                 for option in options {
                     style.set_option(
                         option
-                            .as_deref()
+                            .get(0)
                             .expect("group 0 participates whenever the pattern matches"),
                     );
                 }
@@ -296,19 +287,17 @@ impl WrappableOutputFormatterInterface for OutputFormatter {
         let open_tag_regex = "[a-z](?:[^\\\\<>]* | \\\\.)*";
         let close_tag_regex = "[a-z][^<>]*";
         let mut current_line_length: i64 = 0;
-        let matches = preg_match_all_offset_capture(
+        let matches = preg_match_all(
             format!("#<(({open_tag_regex}) | /({close_tag_regex})?)>#ix"),
             message,
         );
-        let full_matches = matches
-            .get(&CaptureKey::ByIndex(0))
-            .cloned()
-            .unwrap_or_default();
-        for (i, match_) in full_matches.iter().enumerate() {
-            let pos = match_.1;
+        for match_ in matches {
+            let pos = match_
+                .get_offset(0)
+                .expect("group 0 participates whenever the pattern matches")
+                as i64;
             let text = match_
-                .0
-                .clone()
+                .get(0)
                 .expect("group 0 participates whenever the pattern matches");
 
             if pos != 0 && shirabe_php_shim::byte_at(message, (pos - 1) as usize) == b'\\' {
@@ -320,29 +309,22 @@ impl WrappableOutputFormatterInterface for OutputFormatter {
             let applied =
                 self.apply_current_style(&segment, &output, width, &mut current_line_length);
             output.push_str(&applied);
-            offset = pos + shirabe_php_shim::strlen(&text);
+            offset = pos + shirabe_php_shim::strlen(text);
 
             // opening tag?
-            let open = shirabe_php_shim::byte_at(&text, 1) != b'/';
+            let open = shirabe_php_shim::byte_at(text, 1) != b'/';
             let tag = if open {
-                matches
-                    .get(&CaptureKey::ByIndex(1))
-                    .expect("group 1 exists in the tag pattern")[i]
-                    .0
-                    .clone()
+                match_
+                    .get(1)
                     .expect("group 1 participates whenever the pattern matches")
             } else {
-                matches
-                    .get(&CaptureKey::ByIndex(3))
-                    .and_then(|group| group.get(i))
-                    .and_then(|m| m.0.clone())
-                    .unwrap_or_default()
+                match_.get(3).unwrap_or_default()
             };
 
             if !open && tag.is_empty() {
                 // 
                 self.style_stack.pop(None)?.ok();
-            } else if let Some(style) = self.create_style_from_string(&tag)? {
+            } else if let Some(style) = self.create_style_from_string(tag)? {
                 if open {
                     self.style_stack.push(style);
                 } else {
@@ -350,7 +332,7 @@ impl WrappableOutputFormatterInterface for OutputFormatter {
                 }
             } else {
                 let applied =
-                    self.apply_current_style(&text, &output, width, &mut current_line_length);
+                    self.apply_current_style(text, &output, width, &mut current_line_length);
                 output.push_str(&applied);
             }
         }
diff --git a/crates/shirabe/src/command/init_command.rs b/crates/shirabe/src/command/init_command.rs
index 54ebc995..add7c237 100644
--- a/crates/shirabe/src/command/init_command.rs
+++ b/crates/shirabe/src/command/init_command.rs
@@ -21,7 +21,7 @@ use crate::util::Silencer;
 use indexmap::IndexMap;
 use shirabe_php_shim::Catch as _;
 use shirabe_php_shim::{
-    CaptureKey, FILE_IGNORE_NEW_LINES, InvalidArgumentException, PHP_EOL, PHP_SERVER, PhpMixed,
+    FILE_IGNORE_NEW_LINES, InvalidArgumentException, PHP_EOL, PHP_SERVER, PhpMixed,
     array_flip_strings, array_intersect_key, array_map, basename, empty, explode, file,
     file_exists, file_get_contents, file_put_contents, get_current_user, impl_php_class, implode,
     is_dir, is_string, php_regex, preg_is_match, preg_match, preg_match_all, preg_quote,
@@ -167,21 +167,15 @@ impl InitCommand {
         ) == 0
         {
             *self.git_config.borrow_mut() = Some(IndexMap::new());
-            let m = preg_match_all(php_regex!(r"{^([^=]+)=(.*)$}m"), &output);
-            if m.occurrence_count() > 0 {
-                let keys: Vec> =
-                    m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default();
-                let values: Vec> =
-                    m.get(&CaptureKey::ByIndex(2)).cloned().unwrap_or_default();
-                for (key, value) in keys.iter().zip(values.iter()) {
-                    self.git_config.borrow_mut().as_mut().unwrap().insert(
-                        key.clone()
-                            .expect("group 1 participates whenever the pattern matches"),
-                        value
-                            .clone()
-                            .expect("group 2 participates whenever the pattern matches"),
-                    );
-                }
+            for m in preg_match_all(php_regex!(r"{^([^=]+)=(.*)$}m"), &output) {
+                self.git_config.borrow_mut().as_mut().unwrap().insert(
+                    m.get(1)
+                        .expect("group 1 participates whenever the pattern matches")
+                        .to_string(),
+                    m.get(2)
+                        .expect("group 2 participates whenever the pattern matches")
+                        .to_string(),
+                );
             }
 
             return self.git_config.borrow().clone().unwrap_or_default();
diff --git a/crates/shirabe/src/downloader/git_downloader.rs b/crates/shirabe/src/downloader/git_downloader.rs
index a8a4344d..2d506fdf 100644
--- a/crates/shirabe/src/downloader/git_downloader.rs
+++ b/crates/shirabe/src/downloader/git_downloader.rs
@@ -18,10 +18,9 @@ use crate::util::ProcessExecutor;
 use crate::util::Url;
 use indexmap::IndexMap;
 use shirabe_php_shim::{
-    CaptureKey, CmpOp, PhpMixed, RuntimeException, array_map, basename, dirname, impl_php_class,
-    implode, in_array_strict, is_dir, php_regex, preg_is_match, preg_match, preg_match_all,
-    preg_quote, preg_replace, preg_split, realpath, rtrim, strlen, strpos, substr, trim,
-    version_compare,
+    CmpOp, PhpMixed, RuntimeException, array_map, basename, dirname, impl_php_class, implode,
+    in_array_strict, is_dir, php_regex, preg_is_match, preg_match, preg_match_all, preg_quote,
+    preg_replace, preg_split, realpath, rtrim, strlen, strpos, substr, trim, version_compare,
 };
 
 #[derive(Debug)]
@@ -101,21 +100,21 @@ impl GitDownloader {
         };
         let head_ref = head_match.get(1).unwrap_or_default().to_string();
 
-        let branches_match = preg_match_all(
+        let candidate_branches: Vec = preg_match_all(
             format!("{{^{} refs/heads/(.+)$}}mi", preg_quote(&head_ref, None)),
             &refs,
-        );
-        if branches_match.occurrence_count() == 0 {
+        )
+        .map(|branch_match| {
+            branch_match
+                .get(1)
+                .expect("group 1 participates whenever the pattern matches")
+                .to_string()
+        })
+        .collect();
+        if candidate_branches.is_empty() {
             // not on a branch, we are either on a not-modified tag or some sort of detached head, so skip this
             return Ok(None);
         }
-        let candidate_branches: Vec = branches_match
-            .get(&CaptureKey::ByIndex(1))
-            .cloned()
-            .unwrap_or_default()
-            .into_iter()
-            .map(|branch| branch.expect("group 1 participates whenever the pattern matches"))
-            .collect();
 
         // use the first match as branch name for now
         let mut branch = candidate_branches[0].clone();
@@ -128,21 +127,23 @@ impl GitDownloader {
 
             // try to find matching branch names in remote repos
             for candidate in &candidate_branches {
-                let m = preg_match_all(
+                let matches: Vec = preg_match_all(
                     format!(
                         "{{^[a-f0-9]+ refs/remotes/((?:[^/]+)/{})$}}mi",
                         preg_quote(candidate, None)
                     ),
                     &refs,
-                );
-                if m.occurrence_count() > 0 {
-                    let matches: Vec> =
-                        m.get(&CaptureKey::ByIndex(1)).cloned().unwrap_or_default();
+                )
+                .map(|m| {
+                    m.get(1)
+                        .expect("group 1 participates whenever the pattern matches")
+                        .to_string()
+                })
+                .collect();
+                if !matches.is_empty() {
                     for match_ in matches {
                         branch = candidate.clone();
-                        remote_branches.push(
-                            match_.expect("group 1 participates whenever the pattern matches"),
-                        );
+                        remote_branches.push(match_);
                     }
                     break;
                 }
diff --git a/crates/shirabe/src/package/version/version_bumper.rs b/crates/shirabe/src/package/version/version_bumper.rs
index 075663d4..4bb95ef1 100644
--- a/crates/shirabe/src/package/version/version_bumper.rs
+++ b/crates/shirabe/src/package/version/version_bumper.rs
@@ -5,9 +5,7 @@ use crate::package::dumper::ArrayDumper;
 use crate::package::loader::ArrayLoader;
 use crate::package::version::VersionParser;
 use crate::util::Platform;
-use shirabe_php_shim::{
-    CaptureKey, php_regex, preg_is_match, preg_match_all_offset_capture, preg_replace,
-};
+use shirabe_php_shim::{php_regex, preg_is_match, preg_match_all, preg_replace};
 use shirabe_semver::Intervals;
 use shirabe_semver::constraint::AnyConstraint;
 
@@ -78,19 +76,21 @@ impl VersionBumper {
             major = major
         );
 
-        let matches = preg_match_all_offset_capture(&pattern, &pretty_constraint);
-        if matches.occurrence_count() > 0 {
-            let mut modified = pretty_constraint.clone();
-            let constraint_matches = matches
-                .get(&CaptureKey::ByName("constraint".to_string()))
-                .cloned()
-                .unwrap_or_default();
-            for match_ in constraint_matches.iter().rev() {
-                let match_str = match_
-                    .0
-                    .as_deref()
+        // Collected eagerly: a match borrows `pretty_constraint`, which the returns below move.
+        let constraint_matches: Vec<(String, i64)> = preg_match_all(&pattern, &pretty_constraint)
+            .map(|match_| {
+                let constraint = match_
+                    .name("constraint")
+                    .expect("the `constraint` group participates whenever the pattern matches");
+                let offset = match_
+                    .name_offset("constraint")
                     .expect("the `constraint` group participates whenever the pattern matches");
-                let match_offset = match_.1;
+                (constraint.to_string(), offset as i64)
+            })
+            .collect();
+        if !constraint_matches.is_empty() {
+            let mut modified = pretty_constraint.clone();
+            for (match_str, match_offset) in constraint_matches.into_iter().rev() {
                 let suffix = if match_str.matches('.').count() == 2
                     && version_without_suffix.matches('.').count() == 1
                 {
@@ -119,7 +119,7 @@ impl VersionBumper {
                     &modified,
                     &replacement,
                     match_offset,
-                    Some(Platform::strlen(match_str)),
+                    Some(Platform::strlen(&match_str)),
                 );
             }
 
-- 
cgit v1.3.1-4-g156e