From 66c3eba15ba6302d43de057a9063f7feee8c6fb3 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 9 Aug 2026 11:10:53 +0900 Subject: refactor(pcre): extract composer/pcre into the shirabe-pcre crate Move `Composer\Pcre` out of shirabe-external-packages and into its own crate, so the path is `shirabe_pcre::preg::Preg` instead of `shirabe_external_packages::composer::pcre::preg::Preg`. Co-Authored-By: Claude Opus 5 (1M context) --- crates/shirabe-pcre/src/lib.rs | 3 + crates/shirabe-pcre/src/preg.rs | 367 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 370 insertions(+) create mode 100644 crates/shirabe-pcre/src/lib.rs create mode 100644 crates/shirabe-pcre/src/preg.rs (limited to 'crates/shirabe-pcre/src') diff --git a/crates/shirabe-pcre/src/lib.rs b/crates/shirabe-pcre/src/lib.rs new file mode 100644 index 00000000..20c37d02 --- /dev/null +++ b/crates/shirabe-pcre/src/lib.rs @@ -0,0 +1,3 @@ +pub mod preg; + +pub use preg::*; diff --git a/crates/shirabe-pcre/src/preg.rs b/crates/shirabe-pcre/src/preg.rs new file mode 100644 index 00000000..06586d2c --- /dev/null +++ b/crates/shirabe-pcre/src/preg.rs @@ -0,0 +1,367 @@ +//! ref: composer/vendor/composer/pcre/src/Preg.php +//! +//! The following two exception classes are intentionally not ported: +//! +//! - `PcreException`: thrown when a `preg_*()` call returns false. Composer never feeds a pattern +//! that fails to compile at runtime, so such a failure would be a programming error rather than +//! a recoverable condition; they panic instead. +//! - `UnexpectedNullMatchException`: thrown by the `Preg::*StrictGroups()` variants when a capture +//! group did not participate. Those variants were dropped because Rust's `Option` already +//! distinguishes participating from non-participating groups. +//! +//! See docs/dev/regex-porting.md for more detailed regex porting rules. + +use indexmap::IndexMap; +pub use shirabe_php_shim::CaptureKey; +use shirabe_php_shim::{ + PREG_OFFSET_CAPTURE, PREG_SET_ORDER, PREG_SPLIT_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL, + PregPattern, +}; + +#[derive(Debug)] +pub struct Preg; + +impl Preg { + pub fn match3( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>, + ) -> bool { + Self::match5(pattern, subject, matches, 0, 0) + } + + pub fn match5( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>, + flags: i64, + offset: usize, + ) -> bool { + Self::check_offset_capture(flags, "matchWithOffsets"); + + let mut internal: IndexMap> = IndexMap::new(); + let result = shirabe_php_shim::preg_match2( + pattern, + subject, + &mut internal, + flags | PREG_UNMATCHED_AS_NULL, + offset, + ); + + if let Some(out) = matches { + *out = drop_null_matches(internal); + } + + result + } + + pub fn match_all(pattern: impl PregPattern, subject: &str) -> usize { + Self::match_all5(pattern, subject, None, 0, 0) + } + + pub fn match_all3( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>>, + ) -> usize { + Self::match_all5(pattern, subject, matches, 0, 0) + } + + pub fn match_all5( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>>, + flags: i64, + offset: usize, + ) -> usize { + Self::check_offset_capture(flags, "matchAllWithOffsets"); + Self::check_set_order(flags); + + let mut internal: IndexMap>> = IndexMap::new(); + let result = shirabe_php_shim::preg_match_all2( + pattern, + subject, + &mut internal, + flags | PREG_UNMATCHED_AS_NULL, + offset, + ); + + if let Some(out) = matches { + *out = null_to_empty_match_all(internal); + } + + result + } + + pub fn match_all_with_offsets5( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>>, + flags: i64, + offset: usize, + ) -> usize { + Self::check_set_order(flags); + + let mut internal: IndexMap, i64)>> = IndexMap::new(); + let result = shirabe_php_shim::preg_match_all_offset_capture2( + pattern, + subject, + &mut internal, + flags | PREG_UNMATCHED_AS_NULL | PREG_OFFSET_CAPTURE, + offset, + ); + + if let Some(out) = matches { + *out = null_to_empty_offset_match_all(internal); + } + + result + } + + pub fn replace(pattern: impl PregPattern, replacement: &str, subject: &str) -> String { + Self::replace_impl(pattern, replacement, subject, -1, None) + } + + pub fn replace4( + pattern: impl PregPattern, + replacement: &str, + subject: &str, + limit: i64, + ) -> String { + Self::replace_impl(pattern, replacement, subject, limit, None) + } + + pub fn replace5( + pattern: impl PregPattern, + replacement: &str, + subject: &str, + limit: i64, + count: &mut usize, + ) -> String { + Self::replace_impl(pattern, replacement, subject, limit, Some(count)) + } + + fn replace_impl( + pattern: impl PregPattern, + replacement: &str, + subject: &str, + limit: i64, + count: Option<&mut usize>, + ) -> String { + // `$subject` is statically a string here, so the is_scalar/is_array + // guards (ARRAY_MSG / INVALID_TYPE_MSG) of the PHP original are + // unreachable and not reproduced. + shirabe_php_shim::preg_replace2(pattern, replacement, subject, limit, count) + } + + pub fn replace_callback) -> String>( + pattern: impl PregPattern, + replacement: F, + subject: &str, + ) -> String { + Self::replace_callback6(pattern, replacement, subject, -1, None, 0) + } + + pub fn replace_callback6) -> String>( + pattern: impl PregPattern, + mut replacement: F, + subject: &str, + limit: i64, + count: Option<&mut usize>, + flags: i64, + ) -> String { + let adapter = |internal: &IndexMap>| -> String { + replacement(&drop_null_matches_ref(internal)) + }; + + shirabe_php_shim::preg_replace_callback2(pattern, adapter, subject, limit, count, flags) + } + + pub fn split(pattern: impl PregPattern, subject: &str) -> Vec { + Self::split4(pattern, subject, -1, 0) + } + + pub fn split4(pattern: impl PregPattern, subject: &str, limit: i64, flags: i64) -> Vec { + assert!( + flags & PREG_SPLIT_OFFSET_CAPTURE == 0, + "PREG_SPLIT_OFFSET_CAPTURE is not supported as it changes the type of $matches, use splitWithOffsets() instead" + ); + + shirabe_php_shim::preg_split2(pattern, subject, limit, flags) + } + + pub fn grep(pattern: impl PregPattern, array: &[&str]) -> Vec { + Self::grep3(pattern, array, 0) + } + + pub fn grep3(pattern: impl PregPattern, array: &[&str], flags: i64) -> Vec { + shirabe_php_shim::preg_grep2(pattern, array, flags) + } + + pub fn is_match(pattern: impl PregPattern, subject: &str) -> bool { + Self::match5(pattern, subject, None, 0, 0) + } + + pub fn is_match3( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>, + ) -> bool { + Self::match5(pattern, subject, matches, 0, 0) + } + + pub fn is_match5( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>, + flags: i64, + offset: usize, + ) -> bool { + Self::match5(pattern, subject, matches, flags, offset) + } + + pub fn is_match_named( + pattern: impl PregPattern, + subject: &str, + matches: &mut IndexMap, + ) -> bool { + let mut internal: IndexMap> = IndexMap::new(); + let result = shirabe_php_shim::preg_match2( + pattern, + subject, + &mut internal, + PREG_UNMATCHED_AS_NULL, + 0, + ); + + matches.clear(); + for (key, value) in internal { + if let (CaptureKey::ByName(name), Some(value)) = (key, value) { + matches.insert(name, value); + } + } + + result + } + + pub fn is_match_with_indexed_captures( + pattern: impl PregPattern, + subject: &str, + ) -> Option> { + // Classic preg_match semantics (no PREG_UNMATCHED_AS_NULL): trailing + // unmatched groups are truncated, interior unmatched groups become "". + let mut internal: IndexMap> = IndexMap::new(); + let result = shirabe_php_shim::preg_match2(pattern, subject, &mut internal, 0, 0); + + if !result { + return None; + } + + let max_index = internal + .keys() + .filter_map(|key| match key { + CaptureKey::ByIndex(index) => Some(*index), + CaptureKey::ByName(_) => None, + }) + .max() + .unwrap_or(0); + + let mut captures = Vec::with_capacity(max_index + 1); + for index in 0..=max_index { + let value = internal + .get(&CaptureKey::ByIndex(index)) + .and_then(|value| value.clone()) + .unwrap_or_default(); + captures.push(value); + } + + Some(captures) + } + + pub fn is_match_all3( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>>, + ) -> bool { + Self::match_all5(pattern, subject, matches, 0, 0) > 0 + } + + pub fn is_match_all_with_offsets3( + pattern: impl PregPattern, + subject: &str, + matches: Option<&mut IndexMap>>, + ) -> bool { + Self::match_all_with_offsets5(pattern, subject, matches, 0, 0) > 0 + } + + fn check_offset_capture(flags: i64, use_function_name: &str) { + assert!( + flags & PREG_OFFSET_CAPTURE == 0, + "PREG_OFFSET_CAPTURE is not supported as it changes the type of $matches, use {}() instead", + use_function_name + ); + } + + fn check_set_order(flags: i64) { + assert!( + flags & PREG_SET_ORDER == 0, + "PREG_SET_ORDER is not supported as it changes the type of $matches" + ); + } +} + +// Drops `null` (unmatched) groups, mirroring how the public `string`-valued +// `matches` map represents PHP's `string|null` entries by their absence. +fn drop_null_matches( + matches: IndexMap>, +) -> IndexMap { + matches + .into_iter() + .filter_map(|(key, value)| value.map(|value| (key, value))) + .collect() +} + +fn drop_null_matches_ref( + matches: &IndexMap>, +) -> IndexMap { + matches + .iter() + .filter_map(|(key, value)| value.clone().map(|value| (key.clone(), value))) + .collect() +} + +// In the `Vec`-valued maps a per-iteration `null` cannot be stored, so +// unmatched groups collapse to "" (the classic non-PREG_UNMATCHED_AS_NULL form). +fn null_to_empty_match_all( + matches: IndexMap>>, +) -> IndexMap> { + matches + .into_iter() + .map(|(key, values)| { + ( + key, + values + .into_iter() + .map(|value| value.unwrap_or_default()) + .collect(), + ) + }) + .collect() +} + +fn null_to_empty_offset_match_all( + matches: IndexMap, i64)>>, +) -> IndexMap> { + matches + .into_iter() + .map(|(key, values)| { + ( + key, + values + .into_iter() + .map(|(value, offset)| (value.unwrap_or_default(), offset.max(0) as usize)) + .collect(), + ) + }) + .collect() +} -- cgit v1.3.1-4-g156e