From b8d46b0495d00815a699932ced0b43955c949ab9 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 1 Aug 2026 04:52:50 +0900 Subject: 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) --- Cargo.lock | 5 + Cargo.toml | 1 + LICENSE.md | 69 ++++ .../src/symfony/string/code_point_string.rs | 83 +---- crates/shirabe-php-shim/Cargo.toml | 1 + crates/shirabe-php-shim/src/process.rs | 39 +-- crates/shirabe-php-shim/src/runtime.rs | 111 +----- crates/shirabe-php-shim/src/string.rs | 373 +-------------------- crates/shirabe-php-src/Cargo.toml | 8 + crates/shirabe-php-src/src/lib.rs | 19 ++ crates/shirabe-php-src/src/standard.rs | 4 + crates/shirabe-php-src/src/standard/exec.rs | 40 +++ crates/shirabe-php-src/src/standard/string.rs | 317 +++++++++++++++++ crates/shirabe-php-src/src/standard/strnatcmp.rs | 146 ++++++++ crates/shirabe-php-src/src/standard/versioning.rs | 116 +++++++ scripts/linters/lint | 2 + .../src/Linters/PhpSrcDerivationBoundary.php | 54 +++ 17 files changed, 798 insertions(+), 590 deletions(-) create mode 100644 crates/shirabe-php-src/Cargo.toml create mode 100644 crates/shirabe-php-src/src/lib.rs create mode 100644 crates/shirabe-php-src/src/standard.rs create mode 100644 crates/shirabe-php-src/src/standard/exec.rs create mode 100644 crates/shirabe-php-src/src/standard/string.rs create mode 100644 crates/shirabe-php-src/src/standard/strnatcmp.rs create mode 100644 crates/shirabe-php-src/src/standard/versioning.rs create mode 100644 scripts/linters/src/Linters/PhpSrcDerivationBoundary.php diff --git a/Cargo.lock b/Cargo.lock index a61ab57e..df724be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2145,11 +2145,16 @@ dependencies = [ "serde_urlencoded", "sha1", "sha2 0.11.0", + "shirabe-php-src", "tempfile", "twox-hash", "zip", ] +[[package]] +name = "shirabe-php-src" +version = "0.0.1" + [[package]] name = "shirabe-semver" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index e414c493..396ae913 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ shirabe-external-packages = { path = "crates/shirabe-external-packages" } shirabe-metadata-minifier = { path = "crates/shirabe-metadata-minifier" } shirabe-php-rpc = { path = "crates/shirabe-php-rpc" } shirabe-php-shim = { path = "crates/shirabe-php-shim" } +shirabe-php-src = { path = "crates/shirabe-php-src" } shirabe-semver = { path = "crates/shirabe-semver" } shirabe-spdx-licenses = { path = "crates/shirabe-spdx-licenses" } anyhow = "1.0.102" diff --git a/LICENSE.md b/LICENSE.md index fed39f5a..851a1877 100644 --- a/LICENSE.md +++ b/LICENSE.md @@ -23,3 +23,72 @@ THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR I Shirabe is a port of Composer v2.9.7. See [composer/LICENSE](composer/LICENSE) for the original Composer license. + +## PHP + +The `shirabe-php-src` crate (`crates/shirabe-php-src/`) contains Rust port from the +C implementation of PHP's standard library in [php-src](https://github.com/php/php-src). + +### PHP License 4.0 (3-clause BSD) + +Applies to everything in `shirabe-php-src` except `src/standard/strnatcmp.rs`. + +--- + +Copyright © 1999–2026, The PHP Group and Contributors. +Copyright © 1999–2026, Zend Technologies Ltd., a subsidiary company of Perforce Software, Inc. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +### zlib License + +`crates/shirabe-php-src/src/standard/strnatcmp.rs` derives from +`ext/standard/strnatcmp.c`, which php-src itself carries under the zlib license. + +--- + +strnatcmp.c -- Perform 'natural order' comparisons of strings in C. +Copyright (C) 2000 by Martin Pool + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution. + +--- diff --git a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs index 97ee2508..dcbaa1f6 100644 --- a/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs +++ b/crates/shirabe-external-packages/src/symfony/string/code_point_string.rs @@ -42,7 +42,7 @@ impl CodePointString { let mut j: usize = 0; // PHP seeds both `$b` and `$i` at -1; mirror with signed indices. let mut i: i64 = -1; - let mask = php_wordwrap(&mask, width, "#", cut); + let mask = shirabe_php_shim::wordwrap(&mask, width, "#", cut); let mask_bytes = mask.as_bytes(); let mut b: i64 = -1; @@ -91,84 +91,3 @@ impl CodePointString { ) } } - -/// Port of PHP's built-in `wordwrap()` (`PHP_FUNCTION(wordwrap)` in ext/standard/string.c). -/// Byte-based, matching PHP's single-byte/multi-byte break and cut handling. -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 = 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() -} diff --git a/crates/shirabe-php-shim/Cargo.toml b/crates/shirabe-php-shim/Cargo.toml index d3b64a2c..a0e0ab3b 100644 --- a/crates/shirabe-php-shim/Cargo.toml +++ b/crates/shirabe-php-shim/Cargo.toml @@ -4,6 +4,7 @@ version.workspace = true edition.workspace = true [dependencies] +shirabe-php-src.workspace = true anyhow.workspace = true chrono.workspace = true fastrand.workspace = true diff --git a/crates/shirabe-php-shim/src/process.rs b/crates/shirabe-php-shim/src/process.rs index 4615ff91..57d9b35a 100644 --- a/crates/shirabe-php-shim/src/process.rs +++ b/crates/shirabe-php-shim/src/process.rs @@ -1,5 +1,6 @@ use crate::{ChildPipe, PhpMixed, PhpResource, StreamBacking, StreamState}; use indexmap::IndexMap; +pub use shirabe_php_src::standard::exec::escapeshellcmd; pub const SIGINT: i64 = 2; pub const SIGTERM: i64 = 15; @@ -77,44 +78,6 @@ pub fn system(command: &str, result_code: Option<&mut i64>) -> Option { Some(lines.last().cloned().unwrap_or_default()) } -// Port of PHP's escapeshellcmd (Unix branch). Shell metacharacters are backslash-escaped; quote -// characters are escaped only when unpaired, paired quotes being left intact. -pub fn escapeshellcmd(command: &str) -> String { - let bytes = command.as_bytes(); - let len = bytes.len(); - let mut out: Vec = Vec::with_capacity(len); - // Byte index of the matching closing quote while inside a paired quote run. - let mut paired: Option = None; - let mut x = 0; - while x < len { - let c = bytes[x]; - match c { - b'"' | b'\'' => { - if paired.is_none() { - if let Some(rel) = bytes[x + 1..].iter().position(|&b| b == c) { - paired = Some(x + 1 + rel); - } else { - out.push(b'\\'); - } - } else if paired == Some(x) { - paired = None; - } else { - out.push(b'\\'); - } - out.push(c); - } - b'#' | b'&' | b';' | b'`' | b'|' | b'*' | b'?' | b'~' | b'<' | b'>' | b'^' | b'(' - | b')' | b'[' | b']' | b'{' | b'}' | b'$' | b'\\' | 0x0A | 0xFF => { - out.push(b'\\'); - out.push(c); - } - _ => out.push(c), - } - x += 1; - } - String::from_utf8_lossy(&out).into_owned() -} - // Unix branch of PHP's escapeshellarg: wrap in single quotes, escaping embedded single quotes. pub fn escapeshellarg(arg: &str) -> String { let mut out = String::with_capacity(arg.len() + 2); diff --git a/crates/shirabe-php-shim/src/runtime.rs b/crates/shirabe-php-shim/src/runtime.rs index f65ce871..00104051 100644 --- a/crates/shirabe-php-shim/src/runtime.rs +++ b/crates/shirabe-php-shim/src/runtime.rs @@ -1,5 +1,6 @@ use crate::PhpMixed; use indexmap::IndexMap; +use shirabe_php_src::standard::versioning::php_version_compare; pub const PHP_VERSION_ID: i64 = 80100; pub const PHP_VERSION: &str = "8.1.0"; @@ -276,116 +277,6 @@ pub fn version_compare_2(_v1: &str, _v2: &str) -> i64 { php_version_compare(_v1, _v2) as i64 } -// Port of PHP's php_version_compare (ext/standard/versioning.c). Returns -1, 0 or 1. -fn php_version_compare(v1: &str, v2: &str) -> i32 { - if v1.is_empty() || v2.is_empty() { - return match (v1.is_empty(), v2.is_empty()) { - (true, true) => 0, - (false, _) => 1, - (_, false) => -1, - }; - } - let c1 = canonicalize_version(v1); - let c2 = canonicalize_version(v2); - let t1: Vec<&str> = c1.split('.').filter(|s| !s.is_empty()).collect(); - let t2: Vec<&str> = c2.split('.').filter(|s| !s.is_empty()).collect(); - - let mut compare = 0; - let mut i = 0; - while i < t1.len() && i < t2.len() && compare == 0 { - compare = version_token_compare(t1[i], t2[i]); - i += 1; - } - if compare == 0 { - // A leftover numeric token wins; a leftover special form is compared against the implicit - // release baseline ("#", order 4). - if i < t1.len() { - let p = t1[i]; - compare = if p.as_bytes()[0].is_ascii_digit() { - 1 - } else { - special_form_order(p).cmp(&4) as i32 - }; - } else if i < t2.len() { - let p = t2[i]; - compare = if p.as_bytes()[0].is_ascii_digit() { - -1 - } else { - 4.cmp(&special_form_order(p)) as i32 - }; - } - } - compare -} - -// PHP's php_canonicalize_version: separators (-, _, +, .) collapse to a single '.', and a '.' is -// inserted at every digit <-> non-digit boundary. -fn canonicalize_version(version: &str) -> String { - let bytes = version.as_bytes(); - if bytes.is_empty() { - return String::new(); - } - let mut q: Vec = Vec::with_capacity(bytes.len() * 2); - q.push(bytes[0]); - for &raw in &bytes[1..] { - let ch = if matches!(raw, b'-' | b'_' | b'+') { - b'.' - } else { - raw - }; - let last = *q.last().unwrap(); - if ch == b'.' { - if last != b'.' { - q.push(b'.'); - } - } else if last.is_ascii_digit() != ch.is_ascii_digit() { - q.push(b'.'); - q.push(ch); - } else { - q.push(ch); - } - } - String::from_utf8_lossy(&q).into_owned() -} - -fn version_token_compare(t1: &str, t2: &str) -> i32 { - let d1 = t1.as_bytes()[0].is_ascii_digit(); - let d2 = t2.as_bytes()[0].is_ascii_digit(); - if d1 && d2 { - let l1 = t1.parse::().unwrap_or(0); - let l2 = t2.parse::().unwrap_or(0); - l1.cmp(&l2) as i32 - } else if !d1 && !d2 { - special_form_order(t1).cmp(&special_form_order(t2)) as i32 - } else if d1 { - // A numeric token is treated as the "#" form (order 4). - 4.cmp(&special_form_order(t2)) as i32 - } else { - special_form_order(t1).cmp(&4) as i32 - } -} - -fn special_form_order(form: &str) -> i32 { - const FORMS: &[(&str, i32)] = &[ - ("dev", 0), - ("alpha", 1), - ("a", 1), - ("beta", 2), - ("b", 2), - ("RC", 3), - ("rc", 3), - ("#", 4), - ("pl", 5), - ("p", 5), - ]; - for (name, order) in FORMS { - if form.starts_with(name) { - return *order; - } - } - -1 -} - // TODO(php-runtime): the previous handler should be restored in the PHP runtime. // Paired with set_error_handler, which is a no-op in this shim. pub fn restore_error_handler() {} diff --git a/crates/shirabe-php-shim/src/string.rs b/crates/shirabe-php-shim/src/string.rs index dc566bec..8a96a8c3 100644 --- a/crates/shirabe-php-shim/src/string.rs +++ b/crates/shirabe-php-shim/src/string.rs @@ -1,5 +1,8 @@ use crate::PhpMixed; use indexmap::IndexMap; +pub use shirabe_php_src::standard::string::{addcslashes, strip_tags, stripcslashes}; +use shirabe_php_src::standard::string::{php_trim_mask, php_wordwrap}; +use shirabe_php_src::standard::strnatcmp::strnatcmp_ex; 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 @@ -223,147 +226,6 @@ 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 && 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 { let set = characters.as_bytes(); let mut count = 0; @@ -393,29 +255,6 @@ pub fn strstr3(haystack: &str, needle: &str, before_needle: bool) -> Option [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 @@ -1159,87 +998,6 @@ fn php_to_float(v: &PhpMixed) -> f64 { } } -// Port of PHP's php_strip_tags without the allowed-tags parameter (which this signature omits). -// State: 0 = text, 1 = inside a tag, 2 = inside an HTML comment, 3 = inside `` / ` String { - let bytes = _str.as_bytes(); - let n = bytes.len(); - let mut out: Vec = 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() -} - pub fn html_entity_decode(_s: &str) -> String { // TODO(phase-d): only numeric entities and the most common named entities (the HTML 4.01 markup // set PHP enables by default) are decoded; the full named-entity table is not ported. @@ -1299,36 +1057,6 @@ pub fn ucfirst(s: &str) -> String { } } -// Port of PHP's addcslashes: 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 = 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() -} - pub fn php_strip_whitespace(path: &str) -> String { // PHP `php_strip_whitespace()` tokenizes the source and re-emits it with comments removed and // each run of whitespace collapsed to a single space. There is no PHP tokenizer in the shim, so @@ -1487,92 +1215,17 @@ pub fn byte_at(s: &str, i: usize) -> u8 { s.as_bytes().get(i).copied().unwrap_or(0) } -// Port of PHP's stripcslashes: 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 = 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() -} - pub fn wordwrap(_s: &str, _width: i64, _break_str: &str, _cut: bool) -> String { - // TODO(phase-d): an exact byte-for-byte port of php_string_wordwrap (with its lastspace/cut - // bookkeeping) is intricate; left unported as it has no current callers. - todo!() + // PHP throws a ValueError for either argument combination before reaching the wrapping loop. + assert!( + !_break_str.is_empty(), + "wordwrap(): Argument #3 ($break) must not be empty" + ); + assert!( + !(_width == 0 && _cut), + "wordwrap(): Argument #4 ($cut) cannot be true when argument #2 ($width) is 0" + ); + php_wordwrap(_s, _width, _break_str, _cut) } pub fn levenshtein(string1: &str, string2: &str) -> i64 { diff --git a/crates/shirabe-php-src/Cargo.toml b/crates/shirabe-php-src/Cargo.toml new file mode 100644 index 00000000..e2028c40 --- /dev/null +++ b/crates/shirabe-php-src/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "shirabe-php-src" +version.workspace = true +edition.workspace = true +license = "BSD-3-Clause AND Zlib" + +[lints] +workspace = true diff --git a/crates/shirabe-php-src/src/lib.rs b/crates/shirabe-php-src/src/lib.rs new file mode 100644 index 00000000..1e2c2a0f --- /dev/null +++ b/crates/shirabe-php-src/src/lib.rs @@ -0,0 +1,19 @@ +//! Rust port from the original C implementation in php-src. +//! See `LICENSE.md` at the repository root. +//! +//! Rules for this crate: +//! +//! * Any code written while looking at php-src belongs here, and nowhere else. `shirabe-php-shim` +//! must not contain php-src derived code; it depends on this crate instead. +//! * The dependency direction is `shirabe-php-shim` -> `shirabe-php-src` only. This crate must not +//! depend on any other crate in the workspace, so that BSD-derived code cannot pull in +//! MIT-licensed types. +//! * The module layout mirrors php-src's own tree (`src/standard/string.rs` holds code derived +//! from `ext/standard/string.c`), so the origin of a function is visible from its path. +//! * Every item carries a fixed-format origin comment: +//! +//! ```text +//! /// php-src: ext/standard/strnatcmp.c `strnatcmp_ex` (PHP 8.5.2) +//! ``` + +pub mod standard; diff --git a/crates/shirabe-php-src/src/standard.rs b/crates/shirabe-php-src/src/standard.rs new file mode 100644 index 00000000..c1bd8bf4 --- /dev/null +++ b/crates/shirabe-php-src/src/standard.rs @@ -0,0 +1,4 @@ +pub mod exec; +pub mod string; +pub mod strnatcmp; +pub mod versioning; diff --git a/crates/shirabe-php-src/src/standard/exec.rs b/crates/shirabe-php-src/src/standard/exec.rs new file mode 100644 index 00000000..80ab26ae --- /dev/null +++ b/crates/shirabe-php-src/src/standard/exec.rs @@ -0,0 +1,40 @@ +/// php-src: ext/standard/exec.c `php_escape_shell_cmd` (PHP 8.5.2) +/// +/// Unix branch only. Shell metacharacters are backslash-escaped; quote characters are escaped +/// only when unpaired, paired quotes being left intact. The multibyte skip (`php_mblen`), the +/// command length check and the Windows branch are not ported. +pub fn escapeshellcmd(command: &str) -> String { + let bytes = command.as_bytes(); + let len = bytes.len(); + let mut out: Vec = Vec::with_capacity(len); + // Byte index of the matching closing quote while inside a paired quote run. + let mut paired: Option = None; + let mut x = 0; + while x < len { + let c = bytes[x]; + match c { + b'"' | b'\'' => { + if paired.is_none() { + if let Some(rel) = bytes[x + 1..].iter().position(|&b| b == c) { + paired = Some(x + 1 + rel); + } else { + out.push(b'\\'); + } + } else if paired == Some(x) { + paired = None; + } else { + out.push(b'\\'); + } + out.push(c); + } + b'#' | b'&' | b';' | b'`' | b'|' | b'*' | b'?' | b'~' | b'<' | b'>' | b'^' | b'(' + | b')' | b'[' | b']' | b'{' | b'}' | b'$' | b'\\' | 0x0A | 0xFF => { + out.push(b'\\'); + out.push(c); + } + _ => out.push(c), + } + x += 1; + } + String::from_utf8_lossy(&out).into_owned() +} 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 = 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 = 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 { + 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 `` / ` String { + let bytes = _str.as_bytes(); + let n = bytes.len(); + let mut out: Vec = 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 = 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() +} diff --git a/crates/shirabe-php-src/src/standard/strnatcmp.rs b/crates/shirabe-php-src/src/standard/strnatcmp.rs new file mode 100644 index 00000000..30f4176a --- /dev/null +++ b/crates/shirabe-php-src/src/standard/strnatcmp.rs @@ -0,0 +1,146 @@ +/// php-src: ext/standard/strnatcmp.c `strnatcmp_ex` (PHP 8.5.2) +/// +/// Operating on byte slices, an out-of-range index reads as 0, reproducing the NUL terminator +/// that the C implementation relies on. +pub 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; + } +} + +/// php-src: ext/standard/strnatcmp.c (no direct counterpart) (PHP 8.5.2) +fn natcmp_at(s: &[u8], i: usize) -> u8 { + if i < s.len() { s[i] } else { 0 } +} + +/// php-src: ext/standard/strnatcmp.c `isspace(3)` usage (PHP 8.5.2) +fn natcmp_is_space(c: u8) -> bool { + matches!(c, b' ' | b'\t' | b'\n' | 0x0b | 0x0c | b'\r') +} + +/// php-src: ext/standard/strnatcmp.c `compare_right` (PHP 8.5.2) +/// +/// 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 && bias == 0 { + bias = 1; + } + *ap += 1; + *bp += 1; + } +} + +/// php-src: ext/standard/strnatcmp.c `compare_left` (PHP 8.5.2) +/// +/// 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; + } +} diff --git a/crates/shirabe-php-src/src/standard/versioning.rs b/crates/shirabe-php-src/src/standard/versioning.rs new file mode 100644 index 00000000..27f06309 --- /dev/null +++ b/crates/shirabe-php-src/src/standard/versioning.rs @@ -0,0 +1,116 @@ +/// php-src: ext/standard/versioning.c `php_version_compare` (PHP 8.5.2) +/// +/// Returns -1, 0 or 1. The original walks the canonicalized strings with destructive `.` splits +/// and moving pointers; this splits into a `Vec<&str>` and indexes instead. +pub fn php_version_compare(v1: &str, v2: &str) -> i32 { + if v1.is_empty() || v2.is_empty() { + return match (v1.is_empty(), v2.is_empty()) { + (true, true) => 0, + (false, _) => 1, + (_, false) => -1, + }; + } + let c1 = canonicalize_version(v1); + let c2 = canonicalize_version(v2); + let t1: Vec<&str> = c1.split('.').filter(|s| !s.is_empty()).collect(); + let t2: Vec<&str> = c2.split('.').filter(|s| !s.is_empty()).collect(); + + let mut compare = 0; + let mut i = 0; + while i < t1.len() && i < t2.len() && compare == 0 { + compare = version_token_compare(t1[i], t2[i]); + i += 1; + } + if compare == 0 { + // A leftover numeric token wins; a leftover special form is compared against the implicit + // release baseline ("#", order 4). + if i < t1.len() { + let p = t1[i]; + compare = if p.as_bytes()[0].is_ascii_digit() { + 1 + } else { + special_form_order(p).cmp(&4) as i32 + }; + } else if i < t2.len() { + let p = t2[i]; + compare = if p.as_bytes()[0].is_ascii_digit() { + -1 + } else { + 4.cmp(&special_form_order(p)) as i32 + }; + } + } + compare +} + +/// php-src: ext/standard/versioning.c `php_canonicalize_version` (PHP 8.5.2) +/// +/// Separators (-, _, +, .) collapse to a single '.', and a '.' is inserted at every digit <-> +/// non-digit boundary. The original's `!isalnum(*p)` branch is not ported. +fn canonicalize_version(version: &str) -> String { + let bytes = version.as_bytes(); + if bytes.is_empty() { + return String::new(); + } + let mut q: Vec = Vec::with_capacity(bytes.len() * 2); + q.push(bytes[0]); + for &raw in &bytes[1..] { + let ch = if matches!(raw, b'-' | b'_' | b'+') { + b'.' + } else { + raw + }; + let last = *q.last().unwrap(); + if ch == b'.' { + if last != b'.' { + q.push(b'.'); + } + } else if last.is_ascii_digit() != ch.is_ascii_digit() { + q.push(b'.'); + q.push(ch); + } else { + q.push(ch); + } + } + String::from_utf8_lossy(&q).into_owned() +} + +/// php-src: ext/standard/versioning.c `php_version_compare` loop body (PHP 8.5.2) +fn version_token_compare(t1: &str, t2: &str) -> i32 { + let d1 = t1.as_bytes()[0].is_ascii_digit(); + let d2 = t2.as_bytes()[0].is_ascii_digit(); + if d1 && d2 { + let l1 = t1.parse::().unwrap_or(0); + let l2 = t2.parse::().unwrap_or(0); + l1.cmp(&l2) as i32 + } else if !d1 && !d2 { + special_form_order(t1).cmp(&special_form_order(t2)) as i32 + } else if d1 { + // A numeric token is treated as the "#" form (order 4). + 4.cmp(&special_form_order(t2)) as i32 + } else { + special_form_order(t1).cmp(&4) as i32 + } +} + +/// php-src: ext/standard/versioning.c `compare_special_version_forms` (PHP 8.5.2) +fn special_form_order(form: &str) -> i32 { + const FORMS: &[(&str, i32)] = &[ + ("dev", 0), + ("alpha", 1), + ("a", 1), + ("beta", 2), + ("b", 2), + ("RC", 3), + ("rc", 3), + ("#", 4), + ("pl", 5), + ("p", 5), + ]; + for (name, order) in FORMS { + if form.starts_with(name) { + return *order; + } + } + -1 +} diff --git a/scripts/linters/lint b/scripts/linters/lint index bcdf1c67..44ea7f6d 100755 --- a/scripts/linters/lint +++ b/scripts/linters/lint @@ -13,6 +13,7 @@ use Shirabe\Lint\Linters\NoFormatTrailingComma; use Shirabe\Lint\Linters\NoModRs; use Shirabe\Lint\Linters\NoStdCollectionsMaps; use Shirabe\Lint\Linters\NoUseAsAlias; +use Shirabe\Lint\Linters\PhpSrcDerivationBoundary; use Shirabe\Lint\Linters\SortedDependencies; use Shirabe\Lint\Runner; @@ -32,6 +33,7 @@ $runner = new Runner($rootDir, [ [new NoModRs(), []], [new NoStdCollectionsMaps(), []], [new NoUseAsAlias(), []], + [new PhpSrcDerivationBoundary(), []], [new SortedDependencies(), []], ]); diff --git a/scripts/linters/src/Linters/PhpSrcDerivationBoundary.php b/scripts/linters/src/Linters/PhpSrcDerivationBoundary.php new file mode 100644 index 00000000..6885f171 --- /dev/null +++ b/scripts/linters/src/Linters/PhpSrcDerivationBoundary.php @@ -0,0 +1,54 @@ + $raw) { + if (!preg_match(self::MARKER_RE, $raw)) { + continue; + } + + $errors[] = "{$relative}:" . ($idx + 1) . ': ' . trim($raw); + } + } + + return $errors; + } +} -- cgit v1.3.1