aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-08-18 01:57:02 +0900
committernsfisis <nsfisis@gmail.com>2026-08-18 01:57:02 +0900
commit7061051e19dc0ccbbdb694f030aedb05802287e4 (patch)
tree50865f241ee5837312c80b1d007b21319b70950a /crates
parenta01330572b985007acae339817d171a6505bb16b (diff)
downloadphp-shirabe-7061051e19dc0ccbbdb694f030aedb05802287e4.tar.gz
php-shirabe-7061051e19dc0ccbbdb694f030aedb05802287e4.tar.zst
php-shirabe-7061051e19dc0ccbbdb694f030aedb05802287e4.zip
refactor(preg): anchor A patterns at the call site
The shim carried the PCRE A (anchored) modifier alongside every compiled pattern so preg_match2 could honour it by searching the sub-slice at the offset. Only two call sites ever passed such a pattern, and each can cut that slice itself, so the flag is gone from the cache, ResolvedPattern, PregPattern and the php_regex! macro, and a pattern still carrying A is now rejected rather than silently searched unanchored. StringInput::tokenize and PhpFileCleaner::match search from their cursor with a `^`-prefixed pattern instead. The rule is written down in docs/dev/regex-porting.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/shirabe-class-map-generator/src/php_file_cleaner.rs13
-rw-r--r--crates/shirabe-php-shim/src/preg.rs98
-rw-r--r--crates/shirabe-symfony-console/src/input/string_input.rs31
3 files changed, 60 insertions, 82 deletions
diff --git a/crates/shirabe-class-map-generator/src/php_file_cleaner.rs b/crates/shirabe-class-map-generator/src/php_file_cleaner.rs
index 78cee261..cfd8f77f 100644
--- a/crates/shirabe-class-map-generator/src/php_file_cleaner.rs
+++ b/crates/shirabe-class-map-generator/src/php_file_cleaner.rs
@@ -1,7 +1,7 @@
//! ref: composer/vendor/composer/class-map-generator/src/PhpFileCleaner.php
use indexmap::IndexMap;
-use shirabe_php_shim::{PregMatches, preg_match2};
+use shirabe_php_shim::{PregMatches, preg_match, preg_match2};
use std::sync::Mutex;
#[derive(Debug, Clone)]
@@ -52,7 +52,7 @@ impl PhpFileCleaner {
}
let keys: String = type_config.keys().collect();
- let rest_pattern = format!("{{[^?\"'</{}]+}}A", keys);
+ let rest_pattern = format!("{{^[^?\"'</{}]+}}", keys);
*REST_PATTERN.lock().unwrap() = Some(rest_pattern);
*TYPE_CONFIG.lock().unwrap() = Some(type_config);
@@ -103,7 +103,7 @@ impl PhpFileCleaner {
// no backreferences, so the three quote states (none, `'`, `"`) are expanded
// into separate alternatives, each capturing the identifier in its own group.
if let Some(r#match) = self.r#match(
- r#"{<<<[ \t]*(?:"([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)"|'([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)'|([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*))(?:\r\n|\n|\r)}A"#,
+ r#"{^<<<[ \t]*(?:"([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)"|'([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)'|([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*))(?:\r\n|\n|\r)}"#,
) {
let matched_len = r#match
.get(0)
@@ -282,7 +282,12 @@ impl PhpFileCleaner {
self.index + 1 < self.len && self.contents.as_bytes()[self.index + 1] as char == char
}
+ // Regex pattern compatibility:
+ // PHP runs `$regex` anchored (`A`) at `$this->index`, so it must match starting exactly there.
+ // The `regex` crate anchors only at the head of the haystack, so the search runs over the part
+ // of the contents that begins at the index and the patterns carry a leading `^` instead of the
+ // `A` modifier.
fn r#match(&self, regex: &str) -> Option<PregMatches<'_>> {
- preg_match2(regex, &self.contents, self.index)
+ preg_match(regex, &self.contents[self.index..])
}
}
diff --git a/crates/shirabe-php-shim/src/preg.rs b/crates/shirabe-php-shim/src/preg.rs
index e84f8463..6dc9cd43 100644
--- a/crates/shirabe-php-shim/src/preg.rs
+++ b/crates/shirabe-php-shim/src/preg.rs
@@ -161,16 +161,8 @@ pub fn preg_match2<'h>(
offset: usize,
) -> Option<PregMatches<'h>> {
let __resolved = pattern.resolve();
- let (re, anchored) = __resolved.parts();
- // An anchored (`A`) pattern must match starting exactly at `offset`; the `regex` crate cannot
- // anchor a `captures_at` search, so search the sub-slice beginning at `offset` and require the
- // match to start at its head.
- let caps = if anchored {
- re.captures(&subject[offset..])
- .filter(|c| c.get(0).map(|m| m.start()) == Some(0))
- } else {
- re.captures_at(subject, offset)
- }?;
+ let re = __resolved.regex();
+ let caps = re.captures_at(subject, offset)?;
Some(PregMatches::new(caps))
}
@@ -179,7 +171,7 @@ pub fn preg_match2<'h>(
// one column, as `PregMatchesAll::occurrence_count` reports it.
pub fn preg_match_all(pattern: impl PregPattern, subject: &str) -> PregMatchesAll {
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
let group_count = re.captures_len();
let names: Vec<Option<&str>> = re.capture_names().collect();
@@ -211,7 +203,7 @@ pub fn preg_match_all_set_order(
subject: &str,
) -> Vec<Vec<Option<String>>> {
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
re.captures_iter(subject)
.map(|caps| {
(0..caps.len())
@@ -229,7 +221,7 @@ pub fn preg_match_all_offset_capture(
subject: &str,
) -> PregMatchesAllWithOffsets {
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
let group_count = re.captures_len();
let names: Vec<Option<&str>> = re.capture_names().collect();
@@ -261,7 +253,7 @@ pub fn preg_grep<T: AsRef<str>>(
) -> impl Iterator<Item = T> {
let __resolved = pattern.resolve();
array.into_iter().filter(move |s| {
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
re.is_match(s.as_ref())
})
}
@@ -276,7 +268,7 @@ pub fn preg_split_delim_capture(pattern: impl PregPattern, subject: &str) -> Vec
fn preg_split_impl(pattern: impl PregPattern, subject: &str, delim_capture: bool) -> Vec<String> {
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
let mut result: Vec<String> = Vec::new();
let mut last = 0usize;
@@ -312,7 +304,7 @@ pub fn preg_replace2(
count: Option<&mut usize>,
) -> String {
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
let limit = if limit < 0 {
usize::MAX
} else {
@@ -349,7 +341,7 @@ where
F: FnMut(&PregMatches<'h>) -> anyhow::Result<String>,
{
let __resolved = pattern.resolve();
- let (re, _anchored) = __resolved.parts();
+ let re = __resolved.regex();
let mut out: Vec<u8> = Vec::new();
let mut last = 0usize;
@@ -407,15 +399,15 @@ fn translate_pcre_literals(inner: &str) -> String {
// `regex::Regex::clone()` does not share the underlying meta engine's search-cache pool, so
// handing out fresh clones here would pay a ~10us per-clone cache warmup cost on every single
// `preg_*` call (measured), defeating the point of this cache. `Arc::clone()` is a refcount bump.
-static PATTERN_CACHE: LazyLock<Mutex<IndexMap<String, Arc<(regex::Regex, bool)>>>> =
+static PATTERN_CACHE: LazyLock<Mutex<IndexMap<String, Arc<regex::Regex>>>> =
LazyLock::new(|| Mutex::new(IndexMap::new()));
-fn compile_php_pattern(pattern: &str) -> anyhow::Result<Arc<(regex::Regex, bool)>> {
+fn compile_php_pattern(pattern: &str) -> anyhow::Result<Arc<regex::Regex>> {
if let Some(cached) = PATTERN_CACHE.lock().unwrap().get(pattern) {
return Ok(Arc::clone(cached));
}
- let compiled = Arc::new(compile_php_pattern_uncached(pattern)?);
+ let compiled = Arc::new(regex::Regex::new(&translate_php_pattern(pattern)?)?);
PATTERN_CACHE
.lock()
.unwrap()
@@ -423,15 +415,9 @@ fn compile_php_pattern(pattern: &str) -> anyhow::Result<Arc<(regex::Regex, bool)
Ok(compiled)
}
-fn compile_php_pattern_uncached(pattern: &str) -> anyhow::Result<(regex::Regex, bool)> {
- let (translated, anchored) = translate_php_pattern(pattern)?;
- Ok((regex::Regex::new(&translated)?, anchored))
-}
-
// Strips PHP-style delimiters and modifiers from `pattern` and translates the body into
-// `regex`-crate syntax, without compiling it. Returns the translated source alongside whether the
-// PCRE `A` (anchored) modifier was present.
-fn translate_php_pattern(pattern: &str) -> anyhow::Result<(String, bool)> {
+// `regex`-crate syntax, without compiling it.
+fn translate_php_pattern(pattern: &str) -> anyhow::Result<String> {
let delimiter = pattern
.chars()
.next()
@@ -457,19 +443,20 @@ fn translate_php_pattern(pattern: &str) -> anyhow::Result<(String, bool)> {
.filter(|c| matches!(c, 'i' | 'x' | 's' | 'm'))
.collect();
- // PCRE's `A` (PCRE_ANCHORED) modifier requires the match to start exactly at the search offset.
- // The `regex` crate has no per-search anchoring, so `preg_match2` honours it by searching a
- // sub-slice that begins at the offset; here we only surface the flag.
- let anchored = modifiers.contains('A');
+ // PCRE's `A` (PCRE_ANCHORED) modifier requires the match to start exactly at the search offset,
+ // which the `regex` crate cannot express: it anchors a pattern only at the head of the haystack.
+ // Anchor the pattern at the call site instead, by searching the sub-slice that begins at the
+ // offset with a `^`-prefixed pattern.
+ if modifiers.contains('A') {
+ anyhow::bail!("anchored (A) regex pattern is not supported: {pattern}");
+ }
let inner = translate_pcre_literals(inner);
- let translated = if flags.is_empty() {
+ Ok(if flags.is_empty() {
inner
} else {
format!("(?{flags}){inner}")
- };
-
- Ok((translated, anchored))
+ })
}
/// The result of resolving a `PregPattern`. Deliberately holds either a shared `Arc` (string
@@ -479,22 +466,22 @@ fn translate_php_pattern(pattern: &str) -> anyhow::Result<(String, bool)> {
/// a ~10us per-call cache warmup cost regardless of which path produced it (measured).
#[derive(Debug)]
pub enum ResolvedPattern {
- Cached(Arc<(regex::Regex, bool)>),
- Static(&'static regex::Regex, bool),
+ Cached(Arc<regex::Regex>),
+ Static(&'static regex::Regex),
}
impl ResolvedPattern {
- pub fn parts(&self) -> (&regex::Regex, bool) {
+ pub fn regex(&self) -> &regex::Regex {
match self {
- Self::Cached(arc) => (&arc.0, arc.1),
- Self::Static(re, anchored) => (re, *anchored),
+ Self::Cached(arc) => arc,
+ Self::Static(re) => re,
}
}
}
/// Implemented by anything `preg_*` can accept as a pattern: a PHP-style pattern string (parsed
-/// and cached in `PATTERN_CACHE`) or an already-compiled `&'static regex::Regex` paired with its
-/// PCRE `A` (anchored) flag, as produced by the `php_regex!` macro.
+/// and cached in `PATTERN_CACHE`) or an already-compiled `&'static regex::Regex`, as produced by
+/// the `php_regex!` macro.
pub trait PregPattern {
fn resolve(self) -> ResolvedPattern;
}
@@ -519,41 +506,28 @@ impl PregPattern for String {
}
}
-impl PregPattern for (&'static regex::Regex, bool) {
+impl PregPattern for &'static regex::Regex {
fn resolve(self) -> ResolvedPattern {
- ResolvedPattern::Static(self.0, self.1)
+ ResolvedPattern::Static(self)
}
}
// Used by the `php_regex!` macro to obtain the `regex`-crate-syntax source for a PHP pattern.
pub fn php_regex_source(pattern: &str) -> String {
- translate_php_pattern(pattern)
- .unwrap_or_else(|e| panic!("invalid regex: {e}"))
- .0
-}
-
-// Used by the `php_regex!` macro to obtain the PCRE `A` (anchored) modifier flag for a PHP
-// pattern.
-pub fn php_regex_anchored(pattern: &str) -> bool {
- translate_php_pattern(pattern)
- .unwrap_or_else(|e| panic!("invalid regex: {e}"))
- .1
+ translate_php_pattern(pattern).unwrap_or_else(|e| panic!("invalid regex: {e}"))
}
/// Wraps `regex_macro::regex!` so a PHP-style `preg_*` pattern literal (delimiters + modifiers)
/// compiles to a per-call-site cached `&'static regex::Regex`, instead of going through the
-/// runtime `PATTERN_CACHE` lookup by string key. Expands to a `(&'static regex::Regex, bool)`
-/// tuple, ready to pass straight into any `preg_*` function.
+/// runtime `PATTERN_CACHE` lookup by string key. Expands to a `&'static regex::Regex`, ready to
+/// pass straight into any `preg_*` function.
// TODO(pcre): `$php_pattern` is still translated from PHP delimiter/modifier syntax at runtime (on
// first use at each call site). Once call sites pass native `regex`-crate syntax directly, drop
// this wrapper and call `regex_macro::regex!` directly.
#[macro_export]
macro_rules! php_regex {
($php_pattern:expr $(,)?) => {
- (
- &**$crate::regex!(&$crate::php_regex_source($php_pattern)),
- $crate::php_regex_anchored($php_pattern),
- )
+ &**$crate::regex!(&$crate::php_regex_source($php_pattern))
};
}
diff --git a/crates/shirabe-symfony-console/src/input/string_input.rs b/crates/shirabe-symfony-console/src/input/string_input.rs
index 1b14a2aa..bdd21577 100644
--- a/crates/shirabe-symfony-console/src/input/string_input.rs
+++ b/crates/shirabe-symfony-console/src/input/string_input.rs
@@ -6,7 +6,7 @@ use crate::input::InputDefinition;
use crate::input::InputInterface;
use crate::input::StreamableInputInterface;
use indexmap::IndexMap;
-use shirabe_php_shim::{PhpMixed, php_regex, preg_match2};
+use shirabe_php_shim::{PhpMixed, php_regex, preg_match};
/// StringInput represents an input provided as a string.
///
@@ -57,15 +57,21 @@ impl StringInput {
continue;
}
- if let Some(m) = preg_match2(php_regex!(r"/\s+/A"), input, cursor as usize) {
+ // Regex pattern compatibility:
+ // PHP runs these patterns anchored (`A`) at `$cursor`, so each one must match starting
+ // exactly there. The `regex` crate anchors only at the head of the haystack, so the
+ // search runs over the part of the input that begins at the cursor and each pattern
+ // carries a leading `^` instead of the `A` modifier.
+ let rest = &input[cursor as usize..];
+
+ if let Some(m) = preg_match(php_regex!(r"/^\s+/"), rest) {
if token.is_some() {
tokens.push(token.take().unwrap());
}
cursor += shirabe_php_shim::strlen(m.get(0).unwrap_or(""));
- } else if let Some(m) = preg_match2(
- format!(r#"/([^="'\s]+?)(=?)({}+)/A"#, Self::REGEX_QUOTED_STRING),
- input,
- cursor as usize,
+ } else if let Some(m) = preg_match(
+ format!(r#"/^([^="'\s]+?)(=?)({}+)/"#, Self::REGEX_QUOTED_STRING),
+ rest,
) {
let inner = shirabe_php_shim::substr(m.get(3).unwrap_or(""), 1, Some(-1));
let replaced =
@@ -78,11 +84,7 @@ impl StringInput {
shirabe_php_shim::stripcslashes(&replaced)
));
cursor += shirabe_php_shim::strlen(m.get(0).unwrap_or(""));
- } else if let Some(m) = preg_match2(
- format!(r"/{}/A", Self::REGEX_QUOTED_STRING),
- input,
- cursor as usize,
- ) {
+ } else if let Some(m) = preg_match(format!(r"/^{}/", Self::REGEX_QUOTED_STRING), rest) {
token = Some(format!(
"{}{}",
token.unwrap_or_default(),
@@ -93,11 +95,8 @@ impl StringInput {
))
));
cursor += shirabe_php_shim::strlen(m.get(0).unwrap_or(""));
- } else if let Some(m) = preg_match2(
- format!(r"/{}/A", Self::REGEX_UNQUOTED_STRING),
- input,
- cursor as usize,
- ) {
+ } else if let Some(m) = preg_match(format!(r"/^{}/", Self::REGEX_UNQUOTED_STRING), rest)
+ {
token = Some(format!(
"{}{}",
token.unwrap_or_default(),