diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-06-21 20:12:28 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-06-21 20:12:28 +0900 |
| commit | aa270b53045cc1ce779447f4f19448236e9c4c67 (patch) | |
| tree | 635fbbeb6699f5bd4b831c4d850211ff2428a209 /crates/shirabe-php-shim/src | |
| parent | 4575b55e7769e14d2d88d79ec97e9939c978ed9a (diff) | |
| download | php-shirabe-aa270b53045cc1ce779447f4f19448236e9c4c67.tar.gz php-shirabe-aa270b53045cc1ce779447f4f19448236e9c4c67.tar.zst php-shirabe-aa270b53045cc1ce779447f4f19448236e9c4c67.zip | |
feat(php-shim): implement array sort/merge/unique/splice helpers
Implement array_unique, array_splice, array_merge_recursive, krsort,
ksort, asort, sort_with_flags and sort_natural_flag_case, plus the
strnatcmp/strnatcasecmp port they rely on. Drop array_key_last and
array_splice_mixed in favour of existing helpers at the call sites.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-shim/src')
| -rw-r--r-- | crates/shirabe-php-shim/src/array.rs | 185 | ||||
| -rw-r--r-- | crates/shirabe-php-shim/src/string.rs | 151 |
2 files changed, 300 insertions, 36 deletions
diff --git a/crates/shirabe-php-shim/src/array.rs b/crates/shirabe-php-shim/src/array.rs index 8c1988d..dc06b01 100644 --- a/crates/shirabe-php-shim/src/array.rs +++ b/crates/shirabe-php-shim/src/array.rs @@ -144,8 +144,17 @@ pub fn array_diff(_array1: &[String], _array2: &[String]) -> Vec<String> { .collect() } -pub fn array_unique<T: Clone>(_array: &[T]) -> Vec<T> { - todo!() +// PHP's default array_unique flag is SORT_STRING, comparing elements as +// strings. For the `String`/`&str`-like element types used by every caller, +// `PartialEq` is equivalent. First occurrence is kept, matching PHP. +pub fn array_unique<T: Clone + PartialEq>(array: &[T]) -> Vec<T> { + let mut result: Vec<T> = Vec::new(); + for item in array { + if !result.contains(item) { + result.push(item.clone()); + } + } + result } pub fn array_intersect_key( @@ -376,12 +385,13 @@ pub fn array_is_list(array: &PhpMixed) -> bool { } pub fn array_splice<T>( - _array: &mut Vec<T>, - _offset: i64, - _length: Option<i64>, - _replacement: Vec<T>, + array: &mut Vec<T>, + offset: i64, + length: Option<i64>, + replacement: Vec<T>, ) -> Vec<T> { - todo!() + let (start, end) = php_slice_bounds(array.len() as i64, offset, length); + array.splice(start..end, replacement).collect() } pub fn array_pop_first<T>(array: &mut Vec<T>) -> Option<T> { @@ -392,8 +402,113 @@ pub fn array_pop_first<T>(array: &mut Vec<T>) -> Option<T> { } } -pub fn array_merge_recursive(_arrays: Vec<PhpMixed>) -> PhpMixed { - todo!() +pub fn array_merge_recursive(arrays: Vec<PhpMixed>) -> PhpMixed { + let mut acc: Vec<(MergeKey, PhpMixed)> = Vec::new(); + let mut next_int: i64 = 0; + for arr in arrays { + merge_recursive_into(&mut acc, &mut next_int, arr); + } + merge_build(acc) +} + +#[derive(Clone)] +enum MergeKey { + Int(i64), + Str(String), +} + +// Split a PHP array value into (key, value) entries. Integer and integer-like +// string keys become `Int`, everything else stays `Str`, matching how PHP +// normalises array keys. +fn merge_entries(value: PhpMixed) -> Vec<(MergeKey, PhpMixed)> { + match value { + PhpMixed::List(items) => items + .into_iter() + .enumerate() + .map(|(i, v)| (MergeKey::Int(i as i64), v)) + .collect(), + PhpMixed::Array(map) => map + .into_iter() + .map(|(k, v)| (merge_parse_key(k), v)) + .collect(), + _ => panic!("array_merge_recursive(): Argument must be of type array"), + } +} + +fn merge_parse_key(key: String) -> MergeKey { + if let Ok(n) = key.parse::<i64>() { + if n.to_string() == key { + return MergeKey::Int(n); + } + } + MergeKey::Str(key) +} + +// Wrap a scalar into a single-element list, mirroring PHP's behaviour of +// promoting a non-array value to an array before a recursive merge. +fn merge_wrap_array(value: PhpMixed) -> PhpMixed { + match value { + PhpMixed::List(_) | PhpMixed::Array(_) => value, + scalar => PhpMixed::List(vec![scalar]), + } +} + +fn merge_recursive_into(acc: &mut Vec<(MergeKey, PhpMixed)>, next_int: &mut i64, src: PhpMixed) { + for (key, value) in merge_entries(src) { + match key { + MergeKey::Int(_) => { + acc.push((MergeKey::Int(*next_int), value)); + *next_int += 1; + } + MergeKey::Str(s) => { + let existing = acc + .iter() + .position(|(k, _)| matches!(k, MergeKey::Str(es) if *es == s)); + match existing { + Some(pos) => { + let merged = merge_two_recursive(acc[pos].1.clone(), value); + acc[pos].1 = merged; + } + None => acc.push((MergeKey::Str(s), value)), + } + } + } + } +} + +fn merge_two_recursive(a: PhpMixed, b: PhpMixed) -> PhpMixed { + let mut acc = merge_entries(merge_wrap_array(a)); + let mut next_int = acc + .iter() + .filter_map(|(k, _)| match k { + MergeKey::Int(n) => Some(*n + 1), + MergeKey::Str(_) => None, + }) + .max() + .unwrap_or(0); + merge_recursive_into(&mut acc, &mut next_int, merge_wrap_array(b)); + merge_build(acc) +} + +// Re-assemble entries into a PhpMixed, preferring a `List` when the keys are a +// dense 0..n integer sequence (PHP renders such an array as a list). +fn merge_build(acc: Vec<(MergeKey, PhpMixed)>) -> PhpMixed { + let is_list = acc + .iter() + .enumerate() + .all(|(i, (k, _))| matches!(k, MergeKey::Int(n) if *n == i as i64)); + if is_list { + PhpMixed::List(acc.into_iter().map(|(_, v)| v).collect()) + } else { + PhpMixed::Array( + acc.into_iter() + .map(|(k, v)| match k { + MergeKey::Int(n) => (n.to_string(), v), + MergeKey::Str(s) => (s, v), + }) + .collect(), + ) + } } pub fn array_slice<V: Clone>( @@ -442,19 +557,6 @@ pub fn array_diff_key( .collect() } -pub fn array_key_last(_array: &IndexMap<String, PhpMixed>) -> usize { - todo!() -} - -pub fn array_splice_mixed( - _array: &mut Vec<PhpMixed>, - _offset: i64, - _length: i64, - _replacement: Vec<PhpMixed>, -) { - todo!() -} - /// Map a PHP array key (always stored as a `String` here) back to its PHP value /// type: an integer-like key becomes an int, anything else stays a string. fn php_key_to_mixed(key: &str) -> PhpMixed { @@ -498,8 +600,8 @@ pub fn in_array(needle: PhpMixed, haystack: &PhpMixed, strict: bool) -> bool { values.iter().any(|value| **value == needle) } -pub fn krsort<V>(_array: &mut IndexMap<i64, V>) { - todo!() +pub fn krsort<V>(array: &mut IndexMap<i64, V>) { + array.sort_by(|k1, _, k2, _| k2.cmp(k1)); } pub fn uasort<T, F>(array: &mut Vec<T>, compare: F) @@ -522,8 +624,14 @@ pub fn sort<T: Ord>(_array: &mut Vec<T>) { _array.sort(); } -pub fn sort_with_flags<T: Ord>(_array: &mut Vec<T>, _flags: i64) { - todo!() +pub fn sort_with_flags<T: Ord>(array: &mut Vec<T>, flags: i64) { + if flags != SORT_REGULAR { + // TODO(phase-d): flag-specific comparison (SORT_NUMERIC/SORT_STRING/ + // SORT_NATURAL/SORT_FLAG_CASE) cannot be expressed for a generic + // `T: Ord` element. No caller passes a non-regular flag yet. + todo!("sort() with flags other than SORT_REGULAR"); + } + array.sort(); } pub const SORT_REGULAR: i64 = 0; @@ -540,12 +648,25 @@ where _array.sort_by(|a, b| compare(a, b).cmp(&0)); } -pub fn ksort<V>(_array: &mut IndexMap<String, V>) { - todo!() +pub fn ksort<V>(array: &mut IndexMap<String, V>) { + array.sort_by(|k1, _, k2, _| php_sort_regular_key(k1, k2)); } -pub fn asort<V: Ord>(_array: &mut IndexMap<String, V>) { - todo!() +// PHP's default SORT_REGULAR comparison for array keys: two integer-like keys +// compare numerically, otherwise byte-wise as strings. +// TODO(phase-d): full SORT_REGULAR semantics for mixed integer/non-numeric-string +// keys are not reproduced; every current caller uses homogeneous string keys. +fn php_sort_regular_key(a: &str, b: &str) -> std::cmp::Ordering { + if let (Ok(na), Ok(nb)) = (a.parse::<i64>(), b.parse::<i64>()) { + if na.to_string() == a && nb.to_string() == b { + return na.cmp(&nb); + } + } + a.cmp(b) +} + +pub fn asort<V: Ord>(array: &mut IndexMap<String, V>) { + array.sort_by(|_, v1, _, v2| v1.cmp(v2)); } pub fn uksort<V, F>(array: &mut IndexMap<String, V>, callback: F) @@ -556,8 +677,8 @@ where array.sort_by(|k1, _, k2, _| callback(k1, k2).cmp(&0)); } -pub fn sort_natural_flag_case(_values: &mut Vec<String>) { - todo!() +pub fn sort_natural_flag_case(values: &mut Vec<String>) { + values.sort_by(|a, b| crate::strnatcasecmp(a, b).cmp(&0)); } pub fn count_mixed(value: &PhpMixed) -> i64 { diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs index 8b6f361..0521ad4 100644 --- a/crates/shirabe-php-shim/src/string.rs +++ b/crates/shirabe-php-shim/src/string.rs @@ -153,8 +153,8 @@ pub fn strpbrk(haystack: &str, char_list: &str) -> Option<String> { None } -pub fn strnatcasecmp(_s1: &str, _s2: &str) -> i64 { - todo!() +pub fn strnatcasecmp(s1: &str, s2: &str) -> i64 { + strnatcmp_ex(s1.as_bytes(), s2.as_bytes(), true) } pub fn strrpos(_haystack: &str, _needle: &str) -> Option<usize> { @@ -204,8 +204,151 @@ pub fn strcmp(_s1: &str, _s2: &str) -> i64 { _s1.cmp(_s2) as i64 } -pub fn strnatcmp(_s1: &str, _s2: &str) -> i64 { - todo!() +pub fn strnatcmp(s1: &str, s2: &str) -> i64 { + strnatcmp_ex(s1.as_bytes(), s2.as_bytes(), false) +} + +// Port of PHP's strnatcmp_ex (ext/standard/strnatcmp.c). Operating on byte +// slices, an out-of-range index reads as 0, reproducing the NUL terminator that +// the C implementation relies on. +fn strnatcmp_ex(a: &[u8], b: &[u8], fold_case: bool) -> i64 { + let a_len = a.len(); + let b_len = b.len(); + if a_len == 0 || b_len == 0 { + return match a_len.cmp(&b_len) { + std::cmp::Ordering::Less => -1, + std::cmp::Ordering::Greater => 1, + std::cmp::Ordering::Equal => 0, + }; + } + + let mut ap = 0usize; + let mut bp = 0usize; + let mut leading = true; + loop { + let mut ca = natcmp_at(a, ap); + let mut cb = natcmp_at(b, bp); + + // Skip over leading zeros. + while leading && ca == b'0' && natcmp_at(a, ap + 1).is_ascii_digit() { + ap += 1; + ca = natcmp_at(a, ap); + } + while leading && cb == b'0' && natcmp_at(b, bp + 1).is_ascii_digit() { + bp += 1; + cb = natcmp_at(b, bp); + } + leading = false; + + // Skip consecutive whitespace. + while natcmp_is_space(ca) { + ap += 1; + ca = natcmp_at(a, ap); + } + while natcmp_is_space(cb) { + bp += 1; + cb = natcmp_at(b, bp); + } + + // Process a run of digits. + if ca.is_ascii_digit() && cb.is_ascii_digit() { + let fractional = ca == b'0' || cb == b'0'; + let result = if fractional { + natcmp_compare_left(a, &mut ap, b, &mut bp) + } else { + natcmp_compare_right(a, &mut ap, b, &mut bp) + }; + if result != 0 { + return result; + } + } + + if ap == a_len && bp == b_len { + return 0; + } else if ap == a_len { + return -1; + } else if bp == b_len { + return 1; + } + + if fold_case { + ca = natcmp_at(a, ap).to_ascii_uppercase(); + cb = natcmp_at(b, bp).to_ascii_uppercase(); + } else { + ca = natcmp_at(a, ap); + cb = natcmp_at(b, bp); + } + + if ca < cb { + return -1; + } else if ca > cb { + return 1; + } + + ap += 1; + bp += 1; + } +} + +fn natcmp_at(s: &[u8], i: usize) -> u8 { + if i < s.len() { s[i] } else { 0 } +} + +fn natcmp_is_space(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r') +} + +// Compare two right-aligned numbers: the longest run of digits wins; failing +// that, the first differing digit decides, but only once magnitudes are known +// equal (tracked in `bias`). +fn natcmp_compare_right(a: &[u8], ap: &mut usize, b: &[u8], bp: &mut usize) -> i64 { + let mut bias = 0i64; + loop { + let ca = natcmp_at(a, *ap); + let cb = natcmp_at(b, *bp); + let a_digit = ca.is_ascii_digit(); + let b_digit = cb.is_ascii_digit(); + if !a_digit && !b_digit { + return bias; + } else if !a_digit { + return -1; + } else if !b_digit { + return 1; + } else if ca < cb { + if bias == 0 { + bias = -1; + } + } else if ca > cb { + if bias == 0 { + bias = 1; + } + } + *ap += 1; + *bp += 1; + } +} + +// Compare two left-aligned numbers: the first differing digit decides. +fn natcmp_compare_left(a: &[u8], ap: &mut usize, b: &[u8], bp: &mut usize) -> i64 { + loop { + let ca = natcmp_at(a, *ap); + let cb = natcmp_at(b, *bp); + let a_digit = ca.is_ascii_digit(); + let b_digit = cb.is_ascii_digit(); + if !a_digit && !b_digit { + return 0; + } else if !a_digit { + return -1; + } else if !b_digit { + return 1; + } else if ca < cb { + return -1; + } else if ca > cb { + return 1; + } + *ap += 1; + *bp += 1; + } } pub fn strcspn(string: &str, characters: &str) -> usize { |
