aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe-php-shim/src/string.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe-php-shim/src/string.rs')
-rw-r--r--crates/shirabe-php-shim/src/string.rs623
1 files changed, 623 insertions, 0 deletions
diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs
new file mode 100644
index 0000000..8b6f361
--- /dev/null
+++ b/crates/shirabe-php-shim/src/string.rs
@@ -0,0 +1,623 @@
+use crate::PhpMixed;
+use indexmap::IndexMap;
+
+pub fn str_replace(search: &str, replace: &str, subject: &str) -> String {
+ // PHP returns the subject unchanged when the search string is empty, whereas Rust's
+ // `str::replace` would insert `replace` between every character.
+ if search.is_empty() {
+ return subject.to_string();
+ }
+
+ subject.replace(search, replace)
+}
+
+pub fn str_contains(_haystack: &str, _needle: &str) -> bool {
+ _haystack.contains(_needle)
+}
+
+pub fn str_starts_with(_haystack: &str, _needle: &str) -> bool {
+ _haystack.starts_with(_needle)
+}
+
+pub fn str_ends_with(_haystack: &str, _needle: &str) -> bool {
+ _haystack.ends_with(_needle)
+}
+
+pub fn substr_count(haystack: &str, needle: &str) -> i64 {
+ if needle.is_empty() {
+ panic!("substr_count(): Argument #2 ($needle) cannot be empty");
+ }
+ // str::matches counts non-overlapping occurrences, matching PHP's substr_count.
+ haystack.matches(needle).count() as i64
+}
+
+pub fn substr_replace(_string: &str, _replace: &str, _start: usize, _length: usize) -> String {
+ todo!()
+}
+
+pub fn str_repeat(_s: &str, _count: usize) -> String {
+ _s.repeat(_count)
+}
+
+pub fn str_replace_array(search: &[String], replace: &[String], subject: &str) -> String {
+ // PHP's array form of str_replace replaces each search element in order with the replace
+ // element at the same index, falling back to an empty string when replace is shorter.
+ let mut result = subject.to_string();
+ for (i, s) in search.iter().enumerate() {
+ let r = replace.get(i).map(String::as_str).unwrap_or("");
+ result = str_replace(s, r, &result);
+ }
+ result
+}
+
+pub fn str_pad(_input: &str, _length: usize, _pad_string: &str, _pad_type: i64) -> String {
+ // PHP str_pad() works on bytes: it pads up to `length` bytes by repeating `pad_string`.
+ let input_len = _input.len();
+ if _length <= input_len || _pad_string.is_empty() {
+ return _input.to_string();
+ }
+ let pad = _pad_string.as_bytes();
+ let make = |n: usize| -> Vec<u8> { (0..n).map(|i| pad[i % pad.len()]).collect() };
+ let total = _length - input_len;
+ let mut out: Vec<u8> = Vec::with_capacity(_length);
+ match _pad_type {
+ STR_PAD_LEFT => {
+ out.extend(make(total));
+ out.extend_from_slice(_input.as_bytes());
+ }
+ STR_PAD_BOTH => {
+ let left = total / 2;
+ out.extend(make(left));
+ out.extend_from_slice(_input.as_bytes());
+ out.extend(make(total - left));
+ }
+ _ => {
+ out.extend_from_slice(_input.as_bytes());
+ out.extend(make(total));
+ }
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+pub const STR_PAD_LEFT: i64 = 0;
+pub const STR_PAD_RIGHT: i64 = 1;
+pub const STR_PAD_BOTH: i64 = 2;
+
+pub fn str_split(_s: &str, _length: i64) -> Vec<String> {
+ // PHP str_split() chunks the string by bytes into pieces of `length` bytes.
+ let length = _length.max(1) as usize;
+ let bytes = _s.as_bytes();
+ if bytes.is_empty() {
+ return vec![String::new()];
+ }
+ bytes
+ .chunks(length)
+ .map(|c| String::from_utf8_lossy(c).into_owned())
+ .collect()
+}
+
+pub fn str_bitand(_a: &str, _b: &str) -> String {
+ todo!()
+}
+
+pub fn str_replace_arrays(search: &[String], replace: &[String], subject: &str) -> String {
+ str_replace_array(search, replace, subject)
+}
+
+pub fn str_replace_arr(search: &[&str], replace: &str, subject: &str) -> String {
+ // PHP str_replace(array, string, subject): every search element is replaced with
+ // the same replacement string, applied in order.
+ let mut result = subject.to_string();
+ for s in search {
+ result = str_replace(s, replace, &result);
+ }
+ result
+}
+
+pub fn strcasecmp(_s1: &str, _s2: &str) -> i64 {
+ _s1.to_ascii_lowercase().cmp(&_s2.to_ascii_lowercase()) as i64
+}
+
+pub fn strpos(_haystack: &str, _needle: &str) -> Option<usize> {
+ _haystack.find(_needle)
+}
+
+pub fn strtoupper(_s: &str) -> String {
+ _s.to_ascii_uppercase()
+}
+
+pub fn strlen(_s: &str) -> i64 {
+ _s.len() as i64
+}
+
+pub fn strtr(str: &str, from: &str, to: &str) -> String {
+ let from: Vec<char> = from.chars().collect();
+ let to: Vec<char> = to.chars().collect();
+ let n = from.len().min(to.len());
+ str.chars()
+ .map(|c| match from[..n].iter().position(|&f| f == c) {
+ Some(i) => to[i],
+ None => c,
+ })
+ .collect()
+}
+
+pub fn strpbrk(haystack: &str, char_list: &str) -> Option<String> {
+ let set = char_list.as_bytes();
+ let bytes = haystack.as_bytes();
+ for i in 0..bytes.len() {
+ if set.contains(&bytes[i]) {
+ return Some(String::from_utf8_lossy(&bytes[i..]).into_owned());
+ }
+ }
+ None
+}
+
+pub fn strnatcasecmp(_s1: &str, _s2: &str) -> i64 {
+ todo!()
+}
+
+pub fn strrpos(_haystack: &str, _needle: &str) -> Option<usize> {
+ _haystack.rfind(_needle)
+}
+
+pub fn strtolower(_s: &str) -> String {
+ _s.to_ascii_lowercase()
+}
+
+pub fn stripos(_haystack: &str, _needle: &str) -> Option<usize> {
+ _haystack
+ .to_ascii_lowercase()
+ .find(_needle.to_ascii_lowercase().as_str())
+}
+
+// Byte-based, matching PHP's array form of strtr: at each position the longest
+// matching key wins (insertion order breaks ties), and replacements are not
+// re-scanned. Empty keys are ignored.
+pub fn strtr_array(s: &str, pairs: &IndexMap<String, String>) -> String {
+ let mut keys: Vec<&String> = pairs.keys().filter(|k| !k.is_empty()).collect();
+ keys.sort_by_key(|k| std::cmp::Reverse(k.len()));
+
+ let bytes = s.as_bytes();
+ let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ let mut matched = false;
+ for key in &keys {
+ let kb = key.as_bytes();
+ if bytes[i..].starts_with(kb) {
+ result.extend_from_slice(pairs[*key].as_bytes());
+ i += kb.len();
+ matched = true;
+ break;
+ }
+ }
+ if !matched {
+ result.push(bytes[i]);
+ i += 1;
+ }
+ }
+ String::from_utf8_lossy(&result).into_owned()
+}
+
+pub fn strcmp(_s1: &str, _s2: &str) -> i64 {
+ _s1.cmp(_s2) as i64
+}
+
+pub fn strnatcmp(_s1: &str, _s2: &str) -> i64 {
+ todo!()
+}
+
+pub fn strcspn(string: &str, characters: &str) -> usize {
+ let set = characters.as_bytes();
+ let mut count = 0;
+ for &b in string.as_bytes() {
+ if set.contains(&b) {
+ break;
+ }
+ count += 1;
+ }
+ count
+}
+
+pub fn strstr(haystack: &str, needle: &str) -> Option<String> {
+ haystack.find(needle).map(|i| haystack[i..].to_string())
+}
+
+/// PHP's default trim character mask: " \t\n\r\0\x0B".
+const PHP_TRIM_DEFAULT_CHARS: &[u8] = b" \t\n\r\0\x0B";
+
+/// Build the set of bytes to strip from a PHP trim `$characters` argument,
+/// expanding `a..b` range syntax as PHP does.
+fn php_trim_mask(chars: &[u8]) -> [bool; 256] {
+ let mut mask = [false; 256];
+ let mut i = 0;
+ while i < chars.len() {
+ if i + 3 < chars.len() && chars[i + 1] == b'.' && chars[i + 2] == b'.' {
+ let start = chars[i];
+ let end = chars[i + 3];
+ if start <= end {
+ for b in start..=end {
+ mask[b as usize] = true;
+ }
+ i += 4;
+ continue;
+ }
+ }
+ mask[chars[i] as usize] = true;
+ i += 1;
+ }
+ mask
+}
+
+pub fn rtrim(s: &str, chars: Option<&str>) -> String {
+ let mask = php_trim_mask(
+ chars
+ .map(|c| c.as_bytes())
+ .unwrap_or(PHP_TRIM_DEFAULT_CHARS),
+ );
+ let bytes = s.as_bytes();
+ let mut end = bytes.len();
+ while end > 0 && mask[bytes[end - 1] as usize] {
+ end -= 1;
+ }
+ String::from_utf8_lossy(&bytes[..end]).into_owned()
+}
+
+pub fn ltrim(s: &str, chars: Option<&str>) -> String {
+ let mask: Vec<char> = match chars {
+ Some(c) => c.chars().collect(),
+ None => vec![' ', '\t', '\n', '\r', '\0', '\x0B'],
+ };
+ s.trim_start_matches(|c| mask.contains(&c)).to_string()
+}
+
+pub fn trim(s: &str, chars: Option<&str>) -> String {
+ let mask: Vec<char> = match chars {
+ Some(c) => c.chars().collect(),
+ None => vec![' ', '\t', '\n', '\r', '\0', '\x0B'],
+ };
+ s.trim_matches(|c| mask.contains(&c)).to_string()
+}
+
+// Byte-based, matching PHP's substr. A negative start/length counts from the end.
+// The result is reinterpreted as UTF-8 (lossily), which only matters when a slice
+// boundary falls inside a multibyte sequence.
+pub fn substr(s: &str, start: i64, length: Option<i64>) -> String {
+ let bytes = s.as_bytes();
+ let len = bytes.len() as i64;
+ let start = if start < 0 {
+ (len + start).max(0)
+ } else {
+ start.min(len)
+ };
+ let end = match length {
+ None => len,
+ Some(l) if l < 0 => (len + l).max(start),
+ Some(l) => (start + l).min(len),
+ };
+ String::from_utf8_lossy(&bytes[start as usize..end as usize]).into_owned()
+}
+
+pub fn implode(_glue: &str, _pieces: &[String]) -> String {
+ _pieces.join(_glue)
+}
+
+pub fn explode(delimiter: &str, string: &str) -> Vec<String> {
+ string.split(delimiter).map(|s| s.to_string()).collect()
+}
+
+fn explode_limit_impl(delimiter: &str, string: &str, limit: i64) -> Vec<String> {
+ if limit > 0 {
+ string
+ .splitn(limit as usize, delimiter)
+ .map(|s| s.to_string())
+ .collect()
+ } else if limit == 0 {
+ // PHP treats a zero limit as 1: the whole string is returned as one element.
+ vec![string.to_string()]
+ } else {
+ let parts: Vec<String> = string.split(delimiter).map(|s| s.to_string()).collect();
+ let keep = parts.len() as i64 + limit;
+ if keep <= 0 {
+ Vec::new()
+ } else {
+ parts[..keep as usize].to_vec()
+ }
+ }
+}
+
+pub fn explode_with_limit(delimiter: &str, string: &str, limit: i64) -> Vec<String> {
+ explode_limit_impl(delimiter, string, limit)
+}
+
+pub fn explode_limit(delimiter: &str, string: &str, limit: i64) -> Vec<String> {
+ explode_limit_impl(delimiter, string, limit)
+}
+
+/// Normalizes an mbstring encoding label to a canonical spelling (e.g. `utf8` -> `UTF-8`).
+fn canonical_encoding(name: &str) -> String {
+ match name.to_ascii_uppercase().replace('-', "").as_str() {
+ "UTF8" => "UTF-8".to_string(),
+ "ASCII" | "USASCII" => "ASCII".to_string(),
+ _ => name.to_ascii_uppercase(),
+ }
+}
+
+pub fn mb_convert_encoding(_string: Vec<u8>, _to_encoding: &str, _from_encoding: &str) -> String {
+ let to = canonical_encoding(_to_encoding);
+ let from = canonical_encoding(_from_encoding);
+ // ASCII is a subset of UTF-8, so converting among ASCII/UTF-8 is a byte-level no-op. Other
+ // encodings need conversion tables that have not been ported yet.
+ if matches!(to.as_str(), "UTF-8" | "ASCII") && matches!(from.as_str(), "UTF-8" | "ASCII") {
+ return String::from_utf8_lossy(&_string).into_owned();
+ }
+ todo!("mb_convert_encoding {} -> {}", from, to)
+}
+
+pub fn mb_strlen(s: &str, _encoding: &str) -> i64 {
+ // `s` is valid UTF-8, so the character count is its number of code points.
+ s.chars().count() as i64
+}
+
+pub fn mb_check_encoding(_value: &str, _encoding: &str) -> bool {
+ match _encoding.to_ascii_uppercase().replace('-', "").as_str() {
+ // A Rust &str is, by construction, valid UTF-8.
+ "UTF8" => true,
+ _ => todo!(),
+ }
+}
+
+pub fn mb_detect_encoding(
+ _s: &str,
+ _encodings: Option<Vec<String>>,
+ _strict: bool,
+) -> Option<String> {
+ // PHP's default detection order is ASCII then UTF-8. `_s` is already valid UTF-8, so detection
+ // reduces to: pure-ASCII content matches "ASCII", anything else matches "UTF-8".
+ let order = _encodings.unwrap_or_else(|| vec!["ASCII".to_string(), "UTF-8".to_string()]);
+ for enc in order {
+ match canonical_encoding(&enc).as_str() {
+ "ASCII" if _s.is_ascii() => return Some(enc),
+ "UTF-8" => return Some(enc),
+ _ => {}
+ }
+ }
+ None
+}
+
+pub fn mb_strwidth(s: &str, _encoding: Option<&str>) -> i64 {
+ // TODO(phase-c): calculate actual width
+ s.len() as i64
+}
+
+pub fn mb_substr(s: &str, start: i64, length: Option<i64>, _encoding: Option<&str>) -> String {
+ // Code-point based, mirroring substr's byte-based offset/length handling.
+ let chars: Vec<char> = s.chars().collect();
+ let (start, end) = php_slice_bounds(chars.len() as i64, start, length);
+ chars[start..end].iter().collect()
+}
+
+pub fn mb_str_split(s: &str, length: i64) -> Vec<String> {
+ let length = length.max(1) as usize;
+ let chars: Vec<char> = s.chars().collect();
+ chars
+ .chunks(length)
+ .map(|chunk| chunk.iter().collect())
+ .collect()
+}
+
+pub fn mb_convert_variables(_to: &str, _from: &str, _vars: &mut Vec<String>) -> Option<String> {
+ // Converts each variable in place from `_from` to `_to`, returning the source encoding (PHP
+ // returns the detected source encoding; here `_from` is a single named encoding).
+ for v in _vars.iter_mut() {
+ *v = mb_convert_encoding(std::mem::take(v).into_bytes(), _to, _from);
+ }
+ Some(_from.to_string())
+}
+
+pub fn iconv(_in_charset: &str, _out_charset: &str, _string: &str) -> Option<String> {
+ todo!()
+}
+
+/// Resolve PHP array_slice/substr-style (offset, length) into a `[start, end)`
+/// pair of indices, honouring negative offsets and lengths.
+fn php_slice_bounds(len: i64, offset: i64, length: Option<i64>) -> (usize, usize) {
+ let start = if offset < 0 {
+ (len + offset).max(0)
+ } else {
+ offset.min(len)
+ };
+ let end = match length {
+ None => len,
+ Some(l) if l < 0 => (len + l).max(start),
+ Some(l) => (start + l).min(len),
+ };
+ (start as usize, end as usize)
+}
+
+pub fn rawurldecode(s: &str) -> String {
+ let bytes = s.as_bytes();
+ let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' && i + 2 < bytes.len() {
+ if let (Some(h), Some(l)) =
+ (hex_digit_value(bytes[i + 1]), hex_digit_value(bytes[i + 2]))
+ {
+ out.push((h << 4) | l);
+ i += 3;
+ continue;
+ }
+ }
+ out.push(bytes[i]);
+ i += 1;
+ }
+ String::from_utf8_lossy(&out).into_owned()
+}
+
+pub fn rawurlencode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for &b in s.as_bytes() {
+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'~') {
+ out.push(b as char);
+ } else {
+ out.push_str(&format!("%{:02X}", b));
+ }
+ }
+ out
+}
+
+pub fn urlencode(s: &str) -> String {
+ let mut out = String::with_capacity(s.len());
+ for &b in s.as_bytes() {
+ if b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.') {
+ out.push(b as char);
+ } else if b == b' ' {
+ out.push('+');
+ } else {
+ out.push_str(&format!("%{:02X}", b));
+ }
+ }
+ out
+}
+
+pub fn base64_encode(_data: &str) -> String {
+ todo!()
+}
+
+pub fn base64_decode(_data: &str) -> Option<Vec<u8>> {
+ todo!()
+}
+
+pub fn ctype_alnum(_s: &str) -> bool {
+ !_s.is_empty() && _s.bytes().all(|b| b.is_ascii_alphanumeric())
+}
+
+pub fn ctype_digit(s: &str) -> bool {
+ !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
+}
+
+pub fn ord(_c: &str) -> i64 {
+ _c.as_bytes().first().copied().unwrap_or(0) as i64
+}
+
+pub fn ucwords(s: &str) -> String {
+ // PHP's default word delimiters: space, tab, CR, LF, FF and VT.
+ let delimiters = [' ', '\t', '\r', '\n', '\x0C', '\x0B'];
+ let mut out = String::with_capacity(s.len());
+ let mut capitalize_next = true;
+ for c in s.chars() {
+ if capitalize_next {
+ out.push(c.to_ascii_uppercase());
+ } else {
+ out.push(c);
+ }
+ capitalize_next = delimiters.contains(&c);
+ }
+ out
+}
+
+fn hex_digit_value(b: u8) -> Option<u8> {
+ match b {
+ b'0'..=b'9' => Some(b - b'0'),
+ b'a'..=b'f' => Some(b - b'a' + 10),
+ b'A'..=b'F' => Some(b - b'A' + 10),
+ _ => None,
+ }
+}
+
+pub fn pack(_format: &str, _values: &[PhpMixed]) -> Vec<u8> {
+ todo!()
+}
+
+pub fn unpack(_format: &str, _data: &[u8]) -> Option<IndexMap<String, PhpMixed>> {
+ todo!()
+}
+
+pub fn sscanf(_subject: &str, _format: &str, _a: &mut i64, _b: &mut i64) -> i64 {
+ todo!()
+}
+
+pub fn sprintf(_format: &str, _args: &[PhpMixed]) -> String {
+ todo!()
+}
+
+pub fn strip_tags(_str: &str) -> String {
+ todo!()
+}
+
+pub fn html_entity_decode(_s: &str) -> String {
+ todo!()
+}
+
+pub fn bin2hex(_data: &[u8]) -> String {
+ _data.iter().map(|b| format!("{:02x}", b)).collect()
+}
+
+pub fn ucfirst(s: &str) -> String {
+ let mut chars = s.chars();
+ match chars.next() {
+ None => String::new(),
+ Some(first) => format!("{}{}", first.to_ascii_uppercase(), chars.as_str()),
+ }
+}
+
+pub fn chr(_value: u8) -> String {
+ todo!()
+}
+
+pub fn addcslashes(_string: &str, _charlist: &str) -> String {
+ todo!()
+}
+
+pub fn php_strip_whitespace(_path: &str) -> String {
+ todo!()
+}
+
+pub fn hexdec(_s: &str) -> i64 {
+ todo!()
+}
+
+pub fn byte_at(s: &str, i: usize) -> u8 {
+ s.as_bytes().get(i).copied().unwrap_or(0)
+}
+
+pub fn stripcslashes(_s: &str) -> String {
+ todo!()
+}
+
+pub fn wordwrap(_s: &str, _width: i64, _break_str: &str, _cut: bool) -> String {
+ todo!()
+}
+
+pub fn levenshtein(string1: &str, string2: &str) -> i64 {
+ // PHP's levenshtein() is byte-based with unit insertion/deletion/replacement costs.
+ let a = string1.as_bytes();
+ let b = string2.as_bytes();
+ let n = b.len();
+ let mut prev: Vec<usize> = (0..=n).collect();
+ let mut curr = vec![0usize; n + 1];
+ for (i, &ca) in a.iter().enumerate() {
+ curr[0] = i + 1;
+ for (j, &cb) in b.iter().enumerate() {
+ let cost = if ca == cb { 0 } else { 1 };
+ curr[j + 1] = (prev[j + 1] + 1).min(curr[j] + 1).min(prev[j] + cost);
+ }
+ std::mem::swap(&mut prev, &mut curr);
+ }
+ prev[n] as i64
+}
+
+pub fn number_format(
+ _number: f64,
+ _decimals: i64,
+ _decimal_separator: &str,
+ _thousands_separator: &str,
+) -> String {
+ todo!()
+}
+
+pub fn uniqid(_prefix: &str, _more_entropy: bool) -> String {
+ todo!()
+}