diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-08-01 04:52:50 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-08-01 04:52:50 +0900 |
| commit | b8d46b0495d00815a699932ced0b43955c949ab9 (patch) | |
| tree | 17c340f51cbf259ea631ca7da7549714c91b916f /crates/shirabe-php-src/src/standard/string.rs | |
| parent | 8e8a3c147aa388c4b0fc021a08b30113eb590727 (diff) | |
| download | php-shirabe-b8d46b0495d00815a699932ced0b43955c949ab9.tar.gz php-shirabe-b8d46b0495d00815a699932ced0b43955c949ab9.tar.zst php-shirabe-b8d46b0495d00815a699932ced0b43955c949ab9.zip | |
feat(php-src): add a BSD-licensed crate for php-src derived code
The audit in .ken/php-shim-copying.md judged 14 functions in
shirabe-php-shim (plus php_wordwrap in shirabe-external-packages) to be
line-by-line transcriptions or structural imitations of php-src. PHP's
relicensing to 3-clause BSD makes keeping them legal, but the boundary
between BSD-derived and MIT code was invisible in the source tree.
Moving them into their own crate puts the license into the build
metadata (so NOTICE generation follows the binary), makes a reverse
dependency a compile error, and encodes the origin in the module path,
which mirrors php-src's ext tree. Each function records its origin in a
fixed-format doc comment, and a new php_src_derivation_boundary linter
fails if `php-src` appears in any Rust source outside the crate.
Public paths under shirabe_php_shim:: are unchanged: functions that are
themselves derived are re-exported with `pub use`, and the wrappers that
only validate arguments stay on the MIT side.
This also resolves the duplicate wordwrap implementation.
shirabe_php_shim::wordwrap was todo!(), so SymfonyStyle::block panicked,
while shirabe-external-packages carried its own copy. Both now go
through the single port, verified against real PHP on 13 cases covering
multi-character breaks and cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/shirabe-php-src/src/standard/string.rs')
| -rw-r--r-- | crates/shirabe-php-src/src/standard/string.rs | 317 |
1 files changed, 317 insertions, 0 deletions
diff --git a/crates/shirabe-php-src/src/standard/string.rs b/crates/shirabe-php-src/src/standard/string.rs new file mode 100644 index 00000000..5af60f5f --- /dev/null +++ b/crates/shirabe-php-src/src/standard/string.rs @@ -0,0 +1,317 @@ +/// php-src: ext/standard/string.c `php_charmask` (PHP 8.5.2) +/// +/// Build the set of bytes to strip from a PHP trim `$characters` argument, expanding `a..b` +/// range syntax as PHP does. The original's four warning branches for malformed `..` ranges are +/// not ported. +pub 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 +} + +/// php-src: ext/standard/string.c `php_addcslashes_str` (PHP 8.5.2) +/// +/// Every byte that falls in the (range-expanded) charlist is backslash-escaped, with +/// non-printable bytes rendered as the C escape or a three-digit octal. +pub fn addcslashes(_string: &str, _charlist: &str) -> String { + let mask = php_trim_mask(_charlist.as_bytes()); + let mut out: Vec<u8> = Vec::with_capacity(_string.len()); + for &c in _string.as_bytes() { + if mask[c as usize] { + if !(32..=126).contains(&c) { + out.push(b'\\'); + match c { + b'\n' => out.push(b'n'), + b'\t' => out.push(b't'), + b'\r' => out.push(b'r'), + 0x07 => out.push(b'a'), + 0x0B => out.push(b'v'), + 0x08 => out.push(b'b'), + 0x0C => out.push(b'f'), + _ => out.extend_from_slice(format!("{:03o}", c).as_bytes()), + } + } else { + out.push(b'\\'); + out.push(c); + } + } else { + out.push(c); + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// php-src: ext/standard/string.c `php_stripcslashes` (PHP 8.5.2) +/// +/// The inverse of `addcslashes`, decoding C escape sequences including octal (\ooo) and hex +/// (\xHH). +pub fn stripcslashes(_s: &str) -> String { + let bytes = _s.as_bytes(); + let n = bytes.len(); + let mut out: Vec<u8> = Vec::with_capacity(n); + let mut i = 0; + while i < n { + if bytes[i] == b'\\' && i + 1 < n { + i += 1; + match bytes[i] { + b'n' => { + out.push(b'\n'); + i += 1; + } + b'r' => { + out.push(b'\r'); + i += 1; + } + b'a' => { + out.push(0x07); + i += 1; + } + b't' => { + out.push(b'\t'); + i += 1; + } + b'v' => { + out.push(0x0B); + i += 1; + } + b'b' => { + out.push(0x08); + i += 1; + } + b'f' => { + out.push(0x0C); + i += 1; + } + b'\\' => { + out.push(b'\\'); + i += 1; + } + b'x' => { + if i + 1 < n && bytes[i + 1].is_ascii_hexdigit() { + let mut val: u8 = 0; + let mut count = 0; + i += 1; + while i < n && count < 2 && bytes[i].is_ascii_hexdigit() { + val = val.wrapping_mul(16) + hex_digit_value(bytes[i]).unwrap(); + i += 1; + count += 1; + } + out.push(val); + } else { + out.push(b'x'); + i += 1; + } + } + b'0'..=b'7' => { + let mut val: u8 = 0; + let mut count = 0; + while i < n && count < 3 && (b'0'..=b'7').contains(&bytes[i]) { + val = val.wrapping_mul(8).wrapping_add(bytes[i] - b'0'); + i += 1; + count += 1; + } + out.push(val); + } + other => { + out.push(other); + i += 1; + } + } + } else { + out.push(bytes[i]); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +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, + } +} + +/// php-src: ext/standard/string.c `php_strip_tags_ex` (PHP 8.5.2) +/// +/// The allowed-tags parameter is omitted from this signature. +/// State: 0 = text, 1 = inside a tag, 2 = inside an HTML comment, 3 = inside `<? ... ?>` / `<!`. +/// TODO(phase-d): this omits allowed-tags handling and the tag-depth counter, so it can diverge +/// from PHP on malformed markup (unterminated comments/quotes, nested `<`). +pub fn strip_tags(_str: &str) -> String { + let bytes = _str.as_bytes(); + let n = bytes.len(); + let mut out: Vec<u8> = Vec::with_capacity(n); + let mut state: u8 = 0; + // Quote char while inside a quoted attribute value, or 0. + let mut in_q: u8 = 0; + let mut i = 0; + while i < n { + let c = bytes[i]; + match c { + b'<' => { + if in_q == 0 { + if state == 0 && i + 1 < n && bytes[i + 1].is_ascii_whitespace() { + // PHP keeps "< " (a `<` followed by whitespace) as literal text. + out.push(c); + } else if state == 0 { + state = 1; + } + } + } + b'>' => { + if in_q == 0 { + match state { + 1 | 3 => state = 0, + 2 => { + if i >= 2 && bytes[i - 1] == b'-' && bytes[i - 2] == b'-' { + state = 0; + } + } + _ => out.push(c), + } + } + } + b'"' | b'\'' => { + if state == 1 { + if in_q == 0 { + in_q = c; + } else if in_q == c && !(i > 0 && bytes[i - 1] == b'\\') { + in_q = 0; + } + } else if state == 0 { + out.push(c); + } + } + b'!' => { + if state == 1 && i > 0 && bytes[i - 1] == b'<' { + state = 3; + } else if state == 0 { + out.push(c); + } + } + b'?' => { + if state == 1 && i > 0 && bytes[i - 1] == b'<' { + state = 3; + } else if state == 0 { + out.push(c); + } + } + b'-' => { + if state == 3 && i >= 2 && bytes[i - 1] == b'-' && bytes[i - 2] == b'!' { + state = 2; + } else if state == 0 { + out.push(c); + } + } + _ => { + if state == 0 { + out.push(c); + } + } + } + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +/// php-src: ext/standard/string.c `PHP_FUNCTION(wordwrap)` (PHP 8.5.2) +/// +/// Byte-based, matching PHP's single-byte/multi-byte break and cut handling. The original's +/// output buffer pre-allocation and growth (`chk` / `alloced` / `newtextlen`) is replaced by +/// pushing onto a `Vec`. Argument validation stays with the caller. +pub fn php_wordwrap(text: &str, linelength: i64, breakchar: &str, docut: bool) -> String { + let text = text.as_bytes(); + let breakchar = breakchar.as_bytes(); + let textlen = text.len() as i64; + let breaklen = breakchar.len() as i64; + + if textlen == 0 { + return String::new(); + } + + let mut laststart: i64 = 0; + let mut lastspace: i64 = 0; + + // Special case for a single-character break that needs no extra storage. + if breaklen == 1 && !docut { + let mut out = text.to_vec(); + let mut current = 0i64; + while current < textlen { + let c = out[current as usize]; + if c == breakchar[0] { + laststart = current + 1; + lastspace = current + 1; + } else if c == b' ' { + if current - laststart >= linelength { + out[current as usize] = breakchar[0]; + laststart = current + 1; + } + lastspace = current; + } else if current - laststart >= linelength && laststart != lastspace { + out[lastspace as usize] = breakchar[0]; + laststart = lastspace + 1; + } + current += 1; + } + return String::from_utf8_lossy(&out).into_owned(); + } + + // Multiple character line break or forced cut. + let mut out: Vec<u8> = Vec::new(); + let mut current = 0i64; + while current < textlen { + // When we hit an existing break, copy to the new buffer and fix up laststart/lastspace. + if text[current as usize] == breakchar[0] + && current + breaklen < textlen + && &text[current as usize..(current + breaklen) as usize] == breakchar + { + out.extend_from_slice(&text[laststart as usize..(current + breaklen) as usize]); + current += breaklen - 1; + laststart = current + 1; + lastspace = current + 1; + } else if text[current as usize] == b' ' { + if current - laststart >= linelength { + out.extend_from_slice(&text[laststart as usize..current as usize]); + out.extend_from_slice(breakchar); + laststart = current + 1; + } + lastspace = current; + } else if current - laststart >= linelength && docut && laststart >= lastspace { + out.extend_from_slice(&text[laststart as usize..current as usize]); + out.extend_from_slice(breakchar); + laststart = current; + lastspace = current; + } else if current - laststart >= linelength && laststart < lastspace { + out.extend_from_slice(&text[laststart as usize..lastspace as usize]); + out.extend_from_slice(breakchar); + laststart = lastspace + 1; + lastspace += 1; + } + current += 1; + } + + // Copy over any stragglers. + if laststart != current { + out.extend_from_slice(&text[laststart as usize..current as usize]); + } + + String::from_utf8_lossy(&out).into_owned() +} |
