From 4573629d9fc7321171cd8b134e68869950b02045 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Thu, 25 Jun 2026 13:43:49 +0900 Subject: feat(semver): hand-port constraint AND-split to drop look-around regex The PCRE delimiter `(?< ,]) *(? --- crates/shirabe-php-shim/src/runtime.rs | 13 +- crates/shirabe-semver/src/version_parser.rs | 148 ++++++++++++++++++++- .../src/package/loader/root_package_loader.rs | 5 +- crates/shirabe/tests/installed_versions_test.rs | 27 ++-- 4 files changed, 169 insertions(+), 24 deletions(-) (limited to 'crates') diff --git a/crates/shirabe-php-shim/src/runtime.rs b/crates/shirabe-php-shim/src/runtime.rs index bf4d355..c1c5f61 100644 --- a/crates/shirabe-php-shim/src/runtime.rs +++ b/crates/shirabe-php-shim/src/runtime.rs @@ -71,9 +71,16 @@ pub fn defined(name: &str) -> bool { ) } -pub fn method_exists(_object: &PhpMixed, _method_name: &str) -> bool { - // TODO(phase-d): requires runtime class/method reflection, which PhpMixed::Object does not carry. - todo!() +// Models methods available on Composer's own runtime classes when given a class name string. +// Shirabe is a native binary that does not run under a Composer-dumped autoloader, so the dumped +// `Composer\Autoload\ClassLoader` (and its `getRegisteredLoaders`) is absent from the running +// process; the class-name form therefore reports no such method. The object form needs runtime +// reflection that PhpMixed::Object does not carry. +pub fn method_exists(object_or_class: &PhpMixed, _method_name: &str) -> bool { + match object_or_class { + PhpMixed::String(_) => false, + _ => todo!(), + } } // Models the classes available in a standard PHP CLI environment running Composer: diff --git a/crates/shirabe-semver/src/version_parser.rs b/crates/shirabe-semver/src/version_parser.rs index ddde07e..3e4a748 100644 --- a/crates/shirabe-semver/src/version_parser.rs +++ b/crates/shirabe-semver/src/version_parser.rs @@ -297,10 +297,7 @@ impl VersionParser { let mut or_groups: Vec = Vec::new(); for or_constraint in &or_constraints { - let and_constraints = php::preg_split( - "{(?< ,]) *(? = if and_constraints.len() > 1 { let mut objs: Vec = Vec::new(); @@ -762,3 +759,146 @@ impl VersionParser { } } } + +/// Splits a single OR-group of a constraint string into its AND-separated parts. +/// +/// Regex pattern compatibility: +/// Composer splits on the PCRE delimiter `(?< ,]) *(? Vec { + let b = subject.as_bytes(); + let n = b.len(); + + let mut pieces: Vec = Vec::new(); + let mut last = 0usize; + let mut i = 0usize; + while i <= n { + if let Some(end) = match_and_delimiter(b, i) { + pieces.push(subject[last..i].to_string()); + last = end; + i = end; + } else { + i += 1; + } + } + pieces.push(subject[last..].to_string()); + pieces +} + +/// Tries to match the AND-delimiter regex anchored at `i`, returning the match end on success. +fn match_and_delimiter(b: &[u8], i: usize) -> Option { + let n = b.len(); + + // (?< ,]): no match at the start, right after "as", or after one of `=><`, space, comma. + if i == 0 { + return None; + } + let prev = b[i - 1]; + if matches!(prev, b'=' | b'>' | b'<' | b' ' | b',') { + return None; + } + if i >= 2 && b[i - 2] == b'a' && b[i - 1] == b's' { + return None; + } + + // ` *` is greedy; find the end of the leading space run, then backtrack `j` toward `i`. + let mut space_end = i; + while space_end < n && b[space_end] == b' ' { + space_end += 1; + } + + let mut j = space_end; + loop { + if j < n && matches!(b[j], b',' | b' ') { + // (?= n || b[j + 1] != b'-'; + if ok_before && ok_after { + // Trailing ` *` is greedy; find its end, then backtrack `k`. + let mut trailing_end = j + 1; + while trailing_end < n && b[trailing_end] == b' ' { + trailing_end += 1; + } + let mut k = trailing_end; + loop { + // (?!,|as|$): the delimiter cannot abut a comma, an "as", or the string end. + let at_end = k >= n; + let is_comma = k < n && b[k] == b','; + let is_as = k + 1 < n && b[k] == b'a' && b[k + 1] == b's'; + if !(at_end || is_comma || is_as) { + return Some(k); + } + if k == j + 1 { + break; + } + k -= 1; + } + } + } + if j == i { + break; + } + j -= 1; + } + None +} + +#[cfg(test)] +mod split_and_constraints_tests { + use super::split_and_constraints; + + fn split(s: &str) -> Vec { + split_and_constraints(s) + } + + #[test] + fn splits_space_separated() { + assert_eq!(split(">=1.0 <2.0"), vec![">=1.0", "<2.0"]); + } + + #[test] + fn splits_comma_separated() { + assert_eq!(split(">=1.0,<2.0"), vec![">=1.0", "<2.0"]); + } + + #[test] + fn splits_comma_space_separated() { + assert_eq!(split(">=1.0, <2.0"), vec![">=1.0", "<2.0"]); + } + + #[test] + fn collapses_multiple_spaces() { + assert_eq!(split(">=1.0 <2.0"), vec![">=1.0", "<2.0"]); + } + + #[test] + fn does_not_split_after_operator() { + assert_eq!(split(">= 1.0"), vec![">= 1.0"]); + } + + #[test] + fn does_not_split_hyphen_range() { + assert_eq!(split("1.0 - 2.0"), vec!["1.0 - 2.0"]); + } + + #[test] + fn does_not_split_around_as() { + assert_eq!(split("1.0 as 1.1"), vec!["1.0 as 1.1"]); + } + + #[test] + fn single_constraint_is_unchanged() { + assert_eq!(split("^1.0"), vec!["^1.0"]); + } + + #[test] + fn three_and_constraints() { + assert_eq!( + split(">=1.0 <2.0 !=1.5"), + vec![">=1.0", "<2.0", "!=1.5"] + ); + } +} diff --git a/crates/shirabe/src/package/loader/root_package_loader.rs b/crates/shirabe/src/package/loader/root_package_loader.rs index 16b905e..9cd1eac 100644 --- a/crates/shirabe/src/package/loader/root_package_loader.rs +++ b/crates/shirabe/src/package/loader/root_package_loader.rs @@ -322,10 +322,7 @@ impl RootPackageLoader { let or_split = Preg::split(r"\s*\|\|?\s*", req_version.trim()); for or_constraint in &or_split { - let and_split = Preg::split( - r"(?< ,]) *(? with the tmp root substituted for `$dir`. use indexmap::IndexMap; +use serial_test::serial; use shirabe::installed_versions::InstalledVersions; use shirabe_php_shim::{PhpMixed, realpath}; use shirabe_semver::VersionParser; @@ -163,7 +164,7 @@ fn set_up() -> (TempDir, String) { (root, dir) } -#[ignore] +#[serial] #[test] fn test_get_installed_packages() { let (_root, _dir) = set_up(); @@ -182,7 +183,7 @@ fn test_get_installed_packages() { assert_eq!(names, InstalledVersions::get_installed_packages()); } -#[ignore] +#[serial] #[test] fn test_is_installed() { let (_root, _dir) = set_up(); @@ -206,7 +207,7 @@ fn test_is_installed() { } } -#[ignore] +#[serial] #[test] fn test_satisfies() { let (_root, _dir) = set_up(); @@ -241,7 +242,7 @@ fn test_satisfies() { } } -#[ignore] +#[serial] #[test] fn test_get_version_ranges() { let (_root, _dir) = set_up(); @@ -265,7 +266,7 @@ fn test_get_version_ranges() { } } -#[ignore] +#[serial] #[test] fn test_get_version() { let (_root, _dir) = set_up(); @@ -289,7 +290,7 @@ fn test_get_version() { } } -#[ignore] +#[serial] #[test] fn test_get_pretty_version() { let (_root, _dir) = set_up(); @@ -313,7 +314,7 @@ fn test_get_pretty_version() { } } -#[ignore] +#[serial] #[test] fn test_get_version_out_of_bounds() { let (_root, _dir) = set_up(); @@ -321,7 +322,7 @@ fn test_get_version_out_of_bounds() { assert!(InstalledVersions::get_version("not/installed").is_err()); } -#[ignore] +#[serial] #[test] fn test_get_root_package() { let (_root, dir) = set_up(); @@ -342,7 +343,7 @@ fn test_get_root_package() { assert_eq!(expected, InstalledVersions::get_root_package()); } -#[ignore] +#[serial] #[test] fn test_get_raw_data() { let (_root, dir) = set_up(); @@ -350,7 +351,7 @@ fn test_get_raw_data() { assert_eq!(fixture(&dir), InstalledVersions::get_raw_data()); } -#[ignore] +#[serial] #[test] fn test_get_reference() { let (_root, _dir) = set_up(); @@ -374,7 +375,7 @@ fn test_get_reference() { } } -#[ignore] +#[serial] #[test] fn test_get_installed_packages_by_type() { let (_root, _dir) = set_up(); @@ -393,7 +394,7 @@ fn test_get_installed_packages_by_type() { ); } -#[ignore] +#[serial] #[test] fn test_get_install_path() { let (_root, dir) = set_up(); @@ -416,7 +417,7 @@ fn test_get_install_path() { ); } -#[ignore] +#[serial] #[test] fn test_with_class_loader_loaded() { let (_root, _dir) = set_up(); -- cgit v1.3.1