From aa270b53045cc1ce779447f4f19448236e9c4c67 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sun, 21 Jun 2026 20:12:28 +0900 Subject: 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) --- .../symfony/console/completion/completion_input.rs | 12 +- .../src/symfony/console/helper/table.rs | 4 +- crates/shirabe-php-shim/src/array.rs | 185 +++++++++++++++++---- crates/shirabe-php-shim/src/string.rs | 151 ++++++++++++++++- 4 files changed, 307 insertions(+), 45 deletions(-) diff --git a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs index 482e7e1..bd51b8e 100644 --- a/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs +++ b/crates/shirabe-external-packages/src/symfony/console/completion/completion_input.rs @@ -135,13 +135,11 @@ impl CompletionInput { let argument_value = self.inner.inner.arguments[¤t_argument_name].clone(); self.completion_name = Some(current_argument_name.clone()); - if let PhpMixed::Array(argument_value) = &argument_value { - self.completion_value = if !argument_value.is_empty() { - argument_value[shirabe_php_shim::array_key_last(argument_value)].to_string() - } else { - // null - String::new() - }; + if let PhpMixed::List(argument_value) = &argument_value { + self.completion_value = argument_value + .last() + .map(|v| v.to_string()) + .unwrap_or_default(); } else { self.completion_value = argument_value.to_string(); } diff --git a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs index 9c868c4..27b07aa 100644 --- a/crates/shirabe-external-packages/src/symfony/console/helper/table.rs +++ b/crates/shirabe-external-packages/src/symfony/console/helper/table.rs @@ -990,10 +990,10 @@ impl Table { Self::vec_set(&mut row, *column, unmerged_row[column].clone()); } } - shirabe_php_shim::array_splice_mixed( + shirabe_php_shim::array_splice( &mut rows, unmerged_row_key, - 0, + Some(0), vec![Self::from_row_vec(row)], ); } 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 { .collect() } -pub fn array_unique(_array: &[T]) -> Vec { - 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(array: &[T]) -> Vec { + let mut result: Vec = 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( - _array: &mut Vec, - _offset: i64, - _length: Option, - _replacement: Vec, + array: &mut Vec, + offset: i64, + length: Option, + replacement: Vec, ) -> Vec { - todo!() + let (start, end) = php_slice_bounds(array.len() as i64, offset, length); + array.splice(start..end, replacement).collect() } pub fn array_pop_first(array: &mut Vec) -> Option { @@ -392,8 +402,113 @@ pub fn array_pop_first(array: &mut Vec) -> Option { } } -pub fn array_merge_recursive(_arrays: Vec) -> PhpMixed { - todo!() +pub fn array_merge_recursive(arrays: Vec) -> 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::() { + 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( @@ -442,19 +557,6 @@ pub fn array_diff_key( .collect() } -pub fn array_key_last(_array: &IndexMap) -> usize { - todo!() -} - -pub fn array_splice_mixed( - _array: &mut Vec, - _offset: i64, - _length: i64, - _replacement: Vec, -) { - 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(_array: &mut IndexMap) { - todo!() +pub fn krsort(array: &mut IndexMap) { + array.sort_by(|k1, _, k2, _| k2.cmp(k1)); } pub fn uasort(array: &mut Vec, compare: F) @@ -522,8 +624,14 @@ pub fn sort(_array: &mut Vec) { _array.sort(); } -pub fn sort_with_flags(_array: &mut Vec, _flags: i64) { - todo!() +pub fn sort_with_flags(array: &mut Vec, 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(_array: &mut IndexMap) { - todo!() +pub fn ksort(array: &mut IndexMap) { + array.sort_by(|k1, _, k2, _| php_sort_regular_key(k1, k2)); } -pub fn asort(_array: &mut IndexMap) { - 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::(), b.parse::()) { + if na.to_string() == a && nb.to_string() == b { + return na.cmp(&nb); + } + } + a.cmp(b) +} + +pub fn asort(array: &mut IndexMap) { + array.sort_by(|_, v1, _, v2| v1.cmp(v2)); } pub fn uksort(array: &mut IndexMap, 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) { - todo!() +pub fn sort_natural_flag_case(values: &mut Vec) { + 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 { 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 { @@ -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 { -- cgit v1.3.1