diff --git a/Cargo.lock b/Cargo.lock index c35a5fc977e6..af3697250e50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1255,6 +1255,9 @@ dependencies = [ [[package]] name = "bun_lsquic_sys" version = "0.0.0" +dependencies = [ + "bun_core", +] [[package]] name = "bun_md" @@ -2005,7 +2008,6 @@ dependencies = [ "enum-map", "enumset", "libc", - "memchr", "rustix", "scopeguard", "strum", diff --git a/Cargo.toml b/Cargo.toml index 50c3cf75d9ec..3c3f997ad213 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -350,7 +350,6 @@ const_format = "0.2" enum-map = "2" enumset = "1" libc = "0.2" -memchr = "2" rustix = { version = "0.38", default-features = false, features = ["std", "fs", "event", "process", "net"] } bitflags = "2" thiserror = "2" diff --git a/clippy.toml b/clippy.toml index 283d211d4ce4..818137d1e4ec 100644 --- a/clippy.toml +++ b/clippy.toml @@ -22,6 +22,54 @@ disallowed-methods = [ { path = "bun_core::output::debug_warn", reason = "renders args then tag-walks the rendered bytes; use the debug_warn! macro — allow only for runtime-built payloads, with justification" }, { path = "alloc::string::String::from_utf8", reason = "keep data as bytes; for display use bstr::BStr, for JS-visible strings use bun_core::String::clone_utf8" }, { path = "alloc::string::String::from_utf8_lossy", reason = "silently corrupts non-UTF-8 bytes and allocates; keep data as bytes (bstr::BStr for Display)" }, + # == byte/substring search must go through bun_core::strings (highway, runtime-dispatched SIMD) == + # libcore's searchers are scalar or compile-time-gated SSE2/NEON (we build with + # -Ctarget-cpu=nehalem on x64); highway picks AVX2/AVX-512 at runtime. The + # element-generic forms (`<[u8]>::contains`, `iter().position(|b| ..)`) can't be + # expressed here and are covered by test/internal/source-lints/byte-search.test.ts. + { path = "str::find", reason = "use bun_core::strings::index_of_char / index_of on .as_bytes() (highway SIMD)" }, + { path = "str::rfind", reason = "use bun_core::strings::last_index_of_char / last_index_of on .as_bytes() (highway SIMD)" }, + { path = "str::contains", reason = "use bun_core::strings::contains_char / contains on .as_bytes() (highway SIMD)" }, + { path = "str::split_once", reason = "use bun_core::strings::split_once_char / split_once on .as_bytes() (highway SIMD)" }, + { path = "str::rsplit_once", reason = "use bun_core::strings::rsplit_once_char / rsplit_once on .as_bytes() (highway SIMD)" }, + { path = "str::split", reason = "use bun_core::strings::split on .as_bytes() (highway SIMD)" }, + { path = "str::rsplit", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::splitn", reason = "use bun_core::strings::split / split_once on .as_bytes() (highway SIMD)" }, + { path = "str::rsplitn", reason = "use bun_core::strings::rsplit_once on .as_bytes() (highway SIMD)" }, + { path = "str::split_terminator", reason = "use bun_core::strings::split on .as_bytes() (highway SIMD)" }, + { path = "str::rsplit_terminator", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::split_inclusive", reason = "use bun_core::strings::index_of_char in a loop on .as_bytes() (highway SIMD)" }, + { path = "str::lines", reason = "use bun_core::strings::split(bytes, b\"\\n\") and strip a trailing b'\\r' per field (str::lines does) (highway SIMD)" }, + { path = "str::matches", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::rmatches", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::match_indices", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::rmatch_indices", reason = "use bun_core::strings on .as_bytes() (highway SIMD)" }, + { path = "str::replace", reason = "use bun_core::strings::replace_owned on .as_bytes() (highway SIMD)" }, + { path = "str::replacen", reason = "use bun_core::strings::replace_owned on .as_bytes() (highway SIMD)" }, + { path = "slice::windows", reason = "substring search must use bun_core::strings::index_of / index_of_t (highway memmem); for genuine sliding-window iteration, #[allow] with a reason" }, + { path = "memchr::memchr", reason = "use bun_core::strings::index_of_char (highway SIMD)" }, + { path = "memchr::memchr2", reason = "use bun_core::strings::index_of_any (highway SIMD)" }, + { path = "memchr::memchr3", reason = "use bun_core::strings::index_of_any (highway SIMD)" }, + { path = "memchr::memrchr", reason = "use bun_core::strings::last_index_of_char (highway SIMD)" }, + { path = "memchr::memchr_iter", reason = "use bun_core::strings::index_of_char in a loop (highway SIMD)" }, + { path = "memchr::memmem::find", reason = "use bun_core::strings::index_of (highway memmem)" }, + { path = "memchr::memmem::rfind", reason = "use bun_core::strings::last_index_of (highway memrmem)" }, + { path = "memchr::memmem::find_iter", reason = "use bun_core::strings::index_of in a loop (highway memmem)" }, + { path = "bstr::ByteSlice::find", reason = "use bun_core::strings::index_of (highway memmem)" }, + { path = "bstr::ByteSlice::rfind", reason = "use bun_core::strings::last_index_of (highway memrmem)" }, + { path = "bstr::ByteSlice::find_byte", reason = "use bun_core::strings::index_of_char (highway SIMD)" }, + { path = "bstr::ByteSlice::rfind_byte", reason = "use bun_core::strings::last_index_of_char (highway SIMD)" }, + { path = "bstr::ByteSlice::find_char", reason = "use bun_core::strings::index_of_char / index_of (highway SIMD)" }, + { path = "bstr::ByteSlice::rfind_char", reason = "use bun_core::strings::last_index_of_char / last_index_of (highway SIMD)" }, + { path = "bstr::ByteSlice::find_byteset", reason = "use bun_core::strings::index_of_any (highway SIMD)" }, + { path = "bstr::ByteSlice::contains_str", reason = "use bun_core::strings::contains (highway memmem)" }, + { path = "bstr::ByteSlice::find_iter", reason = "use bun_core::strings::index_of in a loop (highway memmem)" }, + { path = "bstr::ByteSlice::rfind_iter", reason = "use bun_core::strings::last_index_of in a loop (highway memrmem)" }, + { path = "bstr::ByteSlice::split_str", reason = "use bun_core::strings::split (highway memmem)" }, + { path = "bstr::ByteSlice::rsplit_str", reason = "use bun_core::strings (highway memrmem)" }, + { path = "bstr::ByteSlice::split_once_str", reason = "use bun_core::strings::split_once (highway memmem)" }, + { path = "bstr::ByteSlice::rsplit_once_str", reason = "use bun_core::strings::rsplit_once (highway memrmem)" }, + { path = "bstr::ByteSlice::replace", reason = "use bun_core::strings::replace_owned (highway memmem)" }, ] disallowed-types = [ diff --git a/scripts/verify-baseline-static/allowlist-aarch64.txt b/scripts/verify-baseline-static/allowlist-aarch64.txt index b5699b9d3012..70a2d22281d5 100644 --- a/scripts/verify-baseline-static/allowlist-aarch64.txt +++ b/scripts/verify-baseline-static/allowlist-aarch64.txt @@ -5,7 +5,7 @@ # ---------------------------------------------------------------------------- # Bun's Highway SVE/SVE2 targets. Gate: hwy::SupportedTargets via getauxval(AT_HWCAP). -# (133 symbols) +# (161 symbols) # ---------------------------------------------------------------------------- _ZN3bun10N_SVE2_12810MemMemImplEPKhmS2_m [SVE] _ZN3bun10N_SVE2_12811MemRMemImplEPKhmS2_m [SVE] @@ -17,6 +17,10 @@ _ZN3bun10N_SVE2_12814LowerAsciiImplEPKhmPh [S _ZN3bun10N_SVE2_12815CopyU16ToU8ImplEPKtmPh [SVE] _ZN3bun10N_SVE2_12815DecodeHex16ImplEPKtPhm [SVE] _ZN3bun10N_SVE2_12815IndexOfCharImplEPKhmh [SVE] +_ZN3bun10N_SVE2_12813CountCharImplEPKhmh [SVE] +_ZN3bun10N_SVE2_12818IndexOfNotCharImplEPKhmh [SVE] +_ZN3bun10N_SVE2_12819LastIndexOfCharImplEPKhmh [SVE] +_ZN3bun10N_SVE2_12822LastIndexOfAnyCharImplEPKhmS2_m [SVE] _ZN3bun10N_SVE2_12816LowerAscii16ImplEPKtmPt [SVE] _ZN3bun10N_SVE2_12818EncodeHexLowerImplEPKhmPh [SVE] _ZN3bun10N_SVE2_12818FirstNonAscii8ImplEPKhm [SVE] @@ -53,6 +57,10 @@ _ZN3bun5N_SVE14LowerAsciiImplEPKhmPh [S _ZN3bun5N_SVE15CopyU16ToU8ImplEPKtmPh [SVE] _ZN3bun5N_SVE15DecodeHex16ImplEPKtPhm [SVE] _ZN3bun5N_SVE15IndexOfCharImplEPKhmh [SVE] +_ZN3bun5N_SVE13CountCharImplEPKhmh [SVE] +_ZN3bun5N_SVE18IndexOfNotCharImplEPKhmh [SVE] +_ZN3bun5N_SVE19LastIndexOfCharImplEPKhmh [SVE] +_ZN3bun5N_SVE22LastIndexOfAnyCharImplEPKhmS2_m [SVE] _ZN3bun5N_SVE16LowerAscii16ImplEPKtmPt [SVE] _ZN3bun5N_SVE18EncodeHexLowerImplEPKhmPh [SVE] _ZN3bun5N_SVE18FirstNonAscii8ImplEPKhm [SVE] @@ -89,6 +97,10 @@ _ZN3bun6N_SVE214LowerAsciiImplEPKhmPh [S _ZN3bun6N_SVE215CopyU16ToU8ImplEPKtmPh [SVE] _ZN3bun6N_SVE215DecodeHex16ImplEPKtPhm [SVE] _ZN3bun6N_SVE215IndexOfCharImplEPKhmh [SVE] +_ZN3bun6N_SVE213CountCharImplEPKhmh [SVE] +_ZN3bun6N_SVE218IndexOfNotCharImplEPKhmh [SVE] +_ZN3bun6N_SVE219LastIndexOfCharImplEPKhmh [SVE] +_ZN3bun6N_SVE222LastIndexOfAnyCharImplEPKhmS2_m [SVE] _ZN3bun6N_SVE216LowerAscii16ImplEPKtmPt [SVE] _ZN3bun6N_SVE218EncodeHexLowerImplEPKhmPh [SVE] _ZN3bun6N_SVE218FirstNonAscii8ImplEPKhm [SVE] @@ -125,6 +137,10 @@ _ZN3bun9N_SVE_25614LowerAsciiImplEPKhmPh [S _ZN3bun9N_SVE_25615CopyU16ToU8ImplEPKtmPh [SVE] _ZN3bun9N_SVE_25615DecodeHex16ImplEPKtPhm [SVE] _ZN3bun9N_SVE_25615IndexOfCharImplEPKhmh [SVE] +_ZN3bun9N_SVE_25613CountCharImplEPKhmh [SVE] +_ZN3bun9N_SVE_25618IndexOfNotCharImplEPKhmh [SVE] +_ZN3bun9N_SVE_25619LastIndexOfCharImplEPKhmh [SVE] +_ZN3bun9N_SVE_25622LastIndexOfAnyCharImplEPKhmS2_m [SVE] _ZN3bun9N_SVE_25616LowerAscii16ImplEPKtmPt [SVE] _ZN3bun9N_SVE_25618EncodeHexLowerImplEPKhmPh [SVE] _ZN3bun9N_SVE_25618FirstNonAscii8ImplEPKhm [SVE] diff --git a/scripts/verify-baseline-static/allowlist-x64-windows.txt b/scripts/verify-baseline-static/allowlist-x64-windows.txt index d3aed5ccb403..103da35b999d 100644 --- a/scripts/verify-baseline-static/allowlist-x64-windows.txt +++ b/scripts/verify-baseline-static/allowlist-x64-windows.txt @@ -437,7 +437,7 @@ ctiMasmProbeTrampolineAVX [AVX] # ---------------------------------------------------------------------------- # Highway. MSVC-mangled bun::N_AVX* names. -# (203 symbols) +# (243 symbols) # ---------------------------------------------------------------------------- bun::N_AVX10_2::ContainsNewlineOrNonASCIIOrQuoteImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::CopyAsciiPrefixImpl [AVX, AVX512BW, AVX512F, AVX512VL] @@ -453,6 +453,10 @@ bun::N_AVX10_2::HtmlEscapeExtraLen16Impl [AVX, AVX2, bun::N_AVX10_2::HtmlEscapeExtraLen8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] bun::N_AVX10_2::IndexOfAnyCharImpl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_FP16] bun::N_AVX10_2::IndexOfCharImpl [AVX, AVX512BW, BMI2] +bun::N_AVX10_2::CountCharImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +bun::N_AVX10_2::IndexOfNotCharImpl [AVX, AVX512BW] +bun::N_AVX10_2::LastIndexOfCharImpl [AVX, AVX512BW] +bun::N_AVX10_2::LastIndexOfAnyCharImpl [AVX, AVX512BW, AVX512F] bun::N_AVX10_2::IndexOfEscapeChar16Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_FP16, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX10_2::IndexOfEscapeChar8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_FP16, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX10_2::IndexOfFirstAsciiUpper16Impl [AVX, AVX512BW, AVX512F] @@ -486,6 +490,10 @@ bun::N_AVX2::HtmlEscapeExtraLen16Impl [AVX, AVX2] bun::N_AVX2::HtmlEscapeExtraLen8Impl [AVX, AVX2] bun::N_AVX2::IndexOfAnyCharImpl [AVX, AVX2] bun::N_AVX2::IndexOfCharImpl [AVX, AVX2] +bun::N_AVX2::CountCharImpl [AVX, AVX2] +bun::N_AVX2::IndexOfNotCharImpl [AVX, AVX2] +bun::N_AVX2::LastIndexOfCharImpl [AVX, AVX2] +bun::N_AVX2::LastIndexOfAnyCharImpl [AVX, AVX2] bun::N_AVX2::IndexOfEscapeChar16Impl [AVX, AVX2, BMI1, BMI2] bun::N_AVX2::IndexOfEscapeChar8Impl [AVX, AVX2, BMI1, BMI2] bun::N_AVX2::IndexOfFirstAsciiUpper16Impl [AVX, AVX2, BMI2] @@ -523,6 +531,10 @@ bun::N_AVX3::HtmlEscapeExtraLen16Impl [AVX, AVX2, bun::N_AVX3::HtmlEscapeExtraLen8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] bun::N_AVX3::IndexOfAnyCharImpl [AVX, AVX512BW, AVX512F, AVX512VL] bun::N_AVX3::IndexOfCharImpl [AVX, AVX512BW, BMI2] +bun::N_AVX3::CountCharImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +bun::N_AVX3::IndexOfNotCharImpl [AVX, AVX512BW] +bun::N_AVX3::LastIndexOfCharImpl [AVX, AVX512BW] +bun::N_AVX3::LastIndexOfAnyCharImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3::IndexOfEscapeChar16Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] bun::N_AVX3::IndexOfEscapeChar8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, BMI1, BMI2] bun::N_AVX3::IndexOfFirstAsciiUpper16Impl [AVX, AVX512BW, AVX512F] @@ -561,6 +573,10 @@ bun::N_AVX3_DL::HtmlEscapeExtraLen16Impl [AVX, AVX2, bun::N_AVX3_DL::HtmlEscapeExtraLen8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] bun::N_AVX3_DL::IndexOfAnyCharImpl [AVX, AVX512BW, AVX512F, AVX512VL] bun::N_AVX3_DL::IndexOfCharImpl [AVX, AVX512BW, BMI2] +bun::N_AVX3_DL::CountCharImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +bun::N_AVX3_DL::IndexOfNotCharImpl [AVX, AVX512BW] +bun::N_AVX3_DL::LastIndexOfCharImpl [AVX, AVX512BW] +bun::N_AVX3_DL::LastIndexOfAnyCharImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_DL::IndexOfEscapeChar16Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_DL::IndexOfEscapeChar8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_DL::IndexOfFirstAsciiUpper16Impl [AVX, AVX512BW, AVX512F] @@ -598,6 +614,10 @@ bun::N_AVX3_SPR::HtmlEscapeExtraLen16Impl [AVX, AVX2, bun::N_AVX3_SPR::HtmlEscapeExtraLen8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] bun::N_AVX3_SPR::IndexOfAnyCharImpl [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_FP16] bun::N_AVX3_SPR::IndexOfCharImpl [AVX, AVX512BW, BMI2] +bun::N_AVX3_SPR::CountCharImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +bun::N_AVX3_SPR::IndexOfNotCharImpl [AVX, AVX512BW] +bun::N_AVX3_SPR::LastIndexOfCharImpl [AVX, AVX512BW] +bun::N_AVX3_SPR::LastIndexOfAnyCharImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_SPR::IndexOfEscapeChar16Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_FP16, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_SPR::IndexOfEscapeChar8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_FP16, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_SPR::IndexOfFirstAsciiUpper16Impl [AVX, AVX512BW, AVX512F] @@ -635,6 +655,10 @@ bun::N_AVX3_ZEN4::HtmlEscapeExtraLen16Impl [AVX, AVX2, bun::N_AVX3_ZEN4::HtmlEscapeExtraLen8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] bun::N_AVX3_ZEN4::IndexOfAnyCharImpl [AVX, AVX512BW, AVX512F, AVX512VL] bun::N_AVX3_ZEN4::IndexOfCharImpl [AVX, AVX512BW, BMI2] +bun::N_AVX3_ZEN4::CountCharImpl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +bun::N_AVX3_ZEN4::IndexOfNotCharImpl [AVX, AVX512BW] +bun::N_AVX3_ZEN4::LastIndexOfCharImpl [AVX, AVX512BW] +bun::N_AVX3_ZEN4::LastIndexOfAnyCharImpl [AVX, AVX512BW, AVX512F] bun::N_AVX3_ZEN4::IndexOfEscapeChar16Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_ZEN4::IndexOfEscapeChar8Impl [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI, BMI1, BMI2, GFNI] bun::N_AVX3_ZEN4::IndexOfFirstAsciiUpper16Impl [AVX, AVX512BW, AVX512F] diff --git a/scripts/verify-baseline-static/allowlist-x64.txt b/scripts/verify-baseline-static/allowlist-x64.txt index 1ea40bc2b1f0..4606dd92bfdb 100644 --- a/scripts/verify-baseline-static/allowlist-x64.txt +++ b/scripts/verify-baseline-static/allowlist-x64.txt @@ -459,7 +459,7 @@ ctiMasmProbeTrampolineAVX [AVX] # ---------------------------------------------------------------------------- # Bun's Highway SIMD. Gate: HWY_DYNAMIC_DISPATCH via hwy::SupportedTargets. -# (203 symbols) +# (243 symbols) # ---------------------------------------------------------------------------- _ZN3bun10N_AVX3_SPR10MemMemImplEPKhmS2_m [AVX, AVX512BW, AVX512F, BMI1] _ZN3bun10N_AVX3_SPR11MemRMemImplEPKhmS2_m [AVX, AVX512BW, AVX512F, BMI2] @@ -471,6 +471,10 @@ _ZN3bun10N_AVX3_SPR14LowerAsciiImplEPKhmPh [A _ZN3bun10N_AVX3_SPR15CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun10N_AVX3_SPR15DecodeHex16ImplEPKtPhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun10N_AVX3_SPR15IndexOfCharImplEPKhmh [AVX, AVX512BW, BMI2] +_ZN3bun10N_AVX3_SPR13CountCharImplEPKhmh [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +_ZN3bun10N_AVX3_SPR18IndexOfNotCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun10N_AVX3_SPR19LastIndexOfCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun10N_AVX3_SPR22LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX512BW, AVX512F] _ZN3bun10N_AVX3_SPR16LowerAscii16ImplEPKtmPt [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun10N_AVX3_SPR18EncodeHexLowerImplEPKhmPh [AVX, AVX2, AVX512BW, AVX512F, AVX512_VBMI, GFNI] _ZN3bun10N_AVX3_SPR18FirstNonAscii8ImplEPKhm [AVX, AVX512BW] @@ -508,6 +512,10 @@ _ZN3bun11N_AVX3_ZEN414LowerAsciiImplEPKhmPh [A _ZN3bun11N_AVX3_ZEN415CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun11N_AVX3_ZEN415DecodeHex16ImplEPKtPhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun11N_AVX3_ZEN415IndexOfCharImplEPKhmh [AVX, AVX512BW, BMI2] +_ZN3bun11N_AVX3_ZEN413CountCharImplEPKhmh [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +_ZN3bun11N_AVX3_ZEN418IndexOfNotCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun11N_AVX3_ZEN419LastIndexOfCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun11N_AVX3_ZEN422LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX512BW, AVX512F] _ZN3bun11N_AVX3_ZEN416LowerAscii16ImplEPKtmPt [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun11N_AVX3_ZEN418EncodeHexLowerImplEPKhmPh [AVX, AVX2, AVX512BW, AVX512F, AVX512_VBMI, GFNI] _ZN3bun11N_AVX3_ZEN418FirstNonAscii8ImplEPKhm [AVX, AVX512BW] @@ -545,6 +553,10 @@ _ZN3bun6N_AVX214LowerAsciiImplEPKhmPh [A _ZN3bun6N_AVX215CopyU16ToU8ImplEPKtmPh [AVX, AVX2] _ZN3bun6N_AVX215DecodeHex16ImplEPKtPhm [AVX, AVX2] _ZN3bun6N_AVX215IndexOfCharImplEPKhmh [AVX, AVX2] +_ZN3bun6N_AVX213CountCharImplEPKhmh [AVX, AVX2] +_ZN3bun6N_AVX218IndexOfNotCharImplEPKhmh [AVX, AVX2] +_ZN3bun6N_AVX219LastIndexOfCharImplEPKhmh [AVX, AVX2] +_ZN3bun6N_AVX222LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX2] _ZN3bun6N_AVX216LowerAscii16ImplEPKtmPt [AVX, AVX2] _ZN3bun6N_AVX218EncodeHexLowerImplEPKhmPh [AVX, AVX2] _ZN3bun6N_AVX218FirstNonAscii8ImplEPKhm [AVX, AVX2] @@ -583,6 +595,10 @@ _ZN3bun6N_AVX314LowerAsciiImplEPKhmPh [A _ZN3bun6N_AVX315CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun6N_AVX315DecodeHex16ImplEPKtPhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] _ZN3bun6N_AVX315IndexOfCharImplEPKhmh [AVX, AVX512BW, BMI2] +_ZN3bun6N_AVX313CountCharImplEPKhmh [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +_ZN3bun6N_AVX318IndexOfNotCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun6N_AVX319LastIndexOfCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun6N_AVX322LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX512BW, AVX512F] _ZN3bun6N_AVX316LowerAscii16ImplEPKtmPt [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun6N_AVX318EncodeHexLowerImplEPKhmPh [AVX, AVX2, AVX512BW, AVX512F] _ZN3bun6N_AVX318FirstNonAscii8ImplEPKhm [AVX, AVX512BW] @@ -616,6 +632,10 @@ _ZN3bun9N_AVX10_214LowerAsciiImplEPKhmPh [A _ZN3bun9N_AVX10_215CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun9N_AVX10_215DecodeHex16ImplEPKtPhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun9N_AVX10_215IndexOfCharImplEPKhmh [AVX, AVX512BW, BMI2] +_ZN3bun9N_AVX10_213CountCharImplEPKhmh [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +_ZN3bun9N_AVX10_218IndexOfNotCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun9N_AVX10_219LastIndexOfCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun9N_AVX10_222LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX10_216LowerAscii16ImplEPKtmPt [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun9N_AVX10_218EncodeHexLowerImplEPKhmPh [AVX, AVX2, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI, GFNI] _ZN3bun9N_AVX10_218FirstNonAscii8ImplEPKhm [AVX, AVX512BW] @@ -653,6 +673,10 @@ _ZN3bun9N_AVX3_DL14LowerAsciiImplEPKhmPh [A _ZN3bun9N_AVX3_DL15CopyU16ToU8ImplEPKtmPh [AVX, AVX512BW, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun9N_AVX3_DL15DecodeHex16ImplEPKtPhm [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL, AVX512_VBMI] _ZN3bun9N_AVX3_DL15IndexOfCharImplEPKhmh [AVX, AVX512BW, BMI2] +_ZN3bun9N_AVX3_DL13CountCharImplEPKhmh [AVX, AVX2, AVX512BW, AVX512DQ, AVX512F, AVX512VL] +_ZN3bun9N_AVX3_DL18IndexOfNotCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun9N_AVX3_DL19LastIndexOfCharImplEPKhmh [AVX, AVX512BW] +_ZN3bun9N_AVX3_DL22LastIndexOfAnyCharImplEPKhmS2_m [AVX, AVX512BW, AVX512F] _ZN3bun9N_AVX3_DL16LowerAscii16ImplEPKtmPt [AVX, AVX512BW, AVX512F, AVX512VL] _ZN3bun9N_AVX3_DL18EncodeHexLowerImplEPKhmPh [AVX, AVX2, AVX512BW, AVX512F, AVX512_VBMI, GFNI] _ZN3bun9N_AVX3_DL18FirstNonAscii8ImplEPKhm [AVX, AVX512BW] diff --git a/src/CLAUDE.md b/src/CLAUDE.md index 53c8159a6cae..01e4d563a11d 100644 --- a/src/CLAUDE.md +++ b/src/CLAUDE.md @@ -101,19 +101,26 @@ let s = bun_core::String::from_js(value, global)?; let err = s.to_error_instance(global); ``` -`bun_core::strings` is the SIMD-backed `&[u8]` toolkit. Use it instead of -`std::str` / `std::iter` for searching and comparing byte slices: +`bun_core::strings` is the SIMD-backed `&[u8]` toolkit (Google Highway kernels +with runtime CPU dispatch). Byte and substring search **must** go through it — +`str::find`/`contains`/`split*`, `slice::windows`, `memchr::*` and +`bstr::ByteSlice::find*` are denied in `clippy.toml`, and the byte-literal forms +of `<[u8]>::contains`, `iter().position/rposition/any(|b| b == b'x')` and +`.split(|b| ..)` are rejected by `test/internal/source-lints/byte-search.test.ts`: ```rust use bun_core::strings; -strings::index_of(haystack, needle) // Option +strings::index_of_char_usize(s, b'x') // Option (not .iter().position()) +strings::index_of_any(s, b"\r\n") // Option first byte in set +strings::last_index_of_char(s, b'x') // Option (not .iter().rposition()) +strings::contains_char(s, b'x') // bool (not .contains(&b'x')) +strings::count_char(s, b'\n') // usize +strings::index_of(haystack, needle) // Option substring (memmem) strings::contains(haystack, needle) // bool -strings::eql(a, b) // bool -strings::starts_with(s, prefix) // bool -strings::ends_with(s, suffix) // bool +strings::split(s, b",") / split_any(s, b" \t") / tokenize(s, b" ") / split_once_char(s, b'=') +strings::eql(a, b) // bool (== / starts_with / ends_with are memcmp and fine as-is) strings::has_prefix_comptime(s, b"x") // 'static comparand -strings::has_suffix_comptime(s, b"x") strings::first_non_ascii(s) // Option strings::to_utf16_alloc(...) // encoding conversions ``` diff --git a/src/base64/lib.rs b/src/base64/lib.rs index be3b7add9326..627be555a6f1 100644 --- a/src/base64/lib.rs +++ b/src/base64/lib.rs @@ -79,7 +79,7 @@ pub fn decode_lenient(destination: &mut [u8], source: &[u8], is_urlsafe: bool) - // that keeps decoding past the '='. Apply the rule up front in that case // so both strategies agree. let source = if destination.len() < decode_lenient_len(source.len()) { - match source.iter().position(|&c| c == b'=') { + match bun_core::strings::index_of_char_usize(source, b'=') { Some(index) => &source[..index], None => source, } diff --git a/src/bun_core/fmt.rs b/src/bun_core/fmt.rs index 8bf7e860f354..ca8def71e64a 100644 --- a/src/bun_core/fmt.rs +++ b/src/bun_core/fmt.rs @@ -2322,7 +2322,7 @@ pub fn format_ip<'a>( let mut end = written; // Strip `:` - if let Some(colon) = into[start..end].iter().rposition(|&b| b == b':') { + if let Some(colon) = strings::last_index_of_char(&into[start..end], b':') { end = start + colon; } // Strip brackets @@ -2333,7 +2333,7 @@ pub fn format_ip<'a>( // Strip `%` — Node formats addresses via uv_inet_ntop on the bare // in6_addr and never includes the zone identifier; the scope is exposed // separately (e.g. `scopeid` in os.networkInterfaces()). - if let Some(percent) = into[start..end].iter().position(|&b| b == b'%') { + if let Some(percent) = strings::index_of_char_usize(&into[start..end], b'%') { end = start + percent; } Ok(&mut into[start..end]) diff --git a/src/bun_core/ip_address.rs b/src/bun_core/ip_address.rs index e04e48e32771..7c993bb298e0 100644 --- a/src/bun_core/ip_address.rs +++ b/src/bun_core/ip_address.rs @@ -72,7 +72,7 @@ pub fn to_ip_address(input: &[u8]) -> Option { return None; } // A `%zone` suffix belongs to a numeric v6 host; resolving the zone is the caller's business. - let head = input.iter().position(|b| *b == b'%').unwrap_or(input.len()); + let head = crate::strings::index_of_char_usize(input, b'%').unwrap_or(input.len()); buf[..head].copy_from_slice(&input[..head]); let mut v6 = [0u8; 16]; if pton(AF_INET6, &buf[..=head], &mut v6) { diff --git a/src/bun_core/lib.rs b/src/bun_core/lib.rs index 389d5dd3c5df..4469ce38bf33 100644 --- a/src/bun_core/lib.rs +++ b/src/bun_core/lib.rs @@ -1215,7 +1215,7 @@ pub(crate) mod strings_impl { } let mut size = input.len(); let mut i = 0usize; - while let Some(pos) = ::bstr::ByteSlice::find(&input[i..], needle) { + while let Some(pos) = ::bun_highway::memmem(&input[i..], needle) { size = size - needle.len() + replacement.len(); i += pos + needle.len(); } @@ -1234,7 +1234,7 @@ pub(crate) mod strings_impl { let mut o = 0usize; let mut count = 0usize; loop { - match ::bstr::ByteSlice::find(&input[i..], needle) { + match ::bun_highway::memmem(&input[i..], needle) { Some(pos) => { output[o..o + pos].copy_from_slice(&input[i..i + pos]); o += pos; @@ -1259,7 +1259,7 @@ pub(crate) mod strings_impl { } let mut out = Vec::with_capacity(replacement_size(input, needle, replacement)); let mut i = 0usize; - while let Some(pos) = ::bstr::ByteSlice::find(&input[i..], needle) { + while let Some(pos) = ::bun_highway::memmem(&input[i..], needle) { out.extend_from_slice(&input[i..i + pos]); out.extend_from_slice(replacement); i += pos + needle.len(); @@ -2089,15 +2089,15 @@ pub(crate) mod strings_impl { return None; }; let mut rest = &s[scheme_end..]; - if let Some(nl) = rest.iter().position(|&b| b == b'\n') { + if let Some(nl) = crate::strings::index_of_char_usize(rest, b'\n') { rest = &rest[..nl]; } - if let Some(end) = rest.iter().position(|&b| matches!(b, b'/' | b'?' | b'#')) { + if let Some(end) = crate::strings::index_of_any(rest, b"/?#") { rest = &rest[..end]; } - let at = rest.iter().position(|&b| b == b'@')?; + let at = crate::strings::index_of_char_usize(rest, b'@')?; let userinfo = &rest[..at]; - let colon = userinfo.iter().position(|&b| b == b':')?; + let colon = crate::strings::index_of_char_usize(userinfo, b':')?; // Reject empty password (`user:@host`). if colon == at - 1 { return None; @@ -2165,7 +2165,7 @@ pub(crate) mod strings_impl { // Minimal code-unit trait so the generic basename impls can live at T0 // without pulling `bun_paths::PathChar` (T1) down. `PathChar` and // `PathUnit` both add `: PathByte` as a supertrait and inherit `from_u8`. - pub trait PathByte: Copy + Eq + 'static { + pub trait PathByte: Copy + Eq + crate::NoUninit + 'static { fn from_u8(b: u8) -> Self; } impl PathByte for u8 { @@ -2385,7 +2385,7 @@ pub mod ffi { /// re-exported as `bun_core::slice_to_nul`. #[inline] pub fn slice_to_nul(buf: &[u8]) -> &[u8] { - &buf[..buf.iter().position(|&b| b == 0).unwrap_or(buf.len())] + &buf[..crate::strings::index_of_char_usize(buf, 0).unwrap_or(buf.len())] } /// Heap-allocate a `T` filled with zero bytes. Safe by virtue of the @@ -2435,7 +2435,7 @@ pub mod ffi { // `c_char` is a type alias for `i8`/`u8`; both are `bytemuck::Pod`, so // the byte-sized reinterpretation is a safe `cast_slice`. let b: &[u8] = bytemuck::cast_slice(s); - &b[..b.iter().position(|&c| c == 0).unwrap_or(b.len())] + &b[..crate::strings::index_of_char_usize(b, 0).unwrap_or(b.len())] } /// All-bits-zero value of `T` for `#[repr(C)]` FFI structs. diff --git a/src/bun_core/string/immutable.rs b/src/bun_core/string/immutable.rs index 96129b4b1c77..800dd2e630e4 100644 --- a/src/bun_core/string/immutable.rs +++ b/src/bun_core/string/immutable.rs @@ -2,8 +2,6 @@ //! SIMD-accelerated immutable string utilities operating on `&[u8]` (NOT `&str`). use core::cmp::Ordering; -#[cfg(any(target_os = "linux", target_os = "android"))] -use core::ffi::c_int; use crate::BoundedArray; use crate::CrateError as Error; @@ -280,9 +278,27 @@ pub fn memmem(haystack: &[u8], needle: &[u8]) -> Option { highway::memmem(haystack, needle) } -/// `bun.reinterpretSlice` — `&[T]` → `&[u8]` view (T must be u8/u16 in practice). -/// Safe via [`crate::cast_slice`]: the `NoUninit` bound proves every byte of -/// `T` is initialized, and `u8` is `AnyBitPattern` with align 1. +/// How the width-generic (`_t`) scanners below hand a `&[T]` to highway: as +/// the 8- or 16-bit lanes the kernels take, or `Wide` for element types they +/// don't (e.g. diff-match-patch's `usize` line hashes), which keep a scalar +/// arm. Safe via [`crate::cast_slice`]: `NoUninit` proves every byte of `T` is +/// initialized; the `u16` view additionally requires `T`'s own alignment. +enum Lanes<'a> { + U8(&'a [u8]), + U16(&'a [u16]), + Wide, +} + +#[inline(always)] +fn lanes(s: &[T]) -> Lanes<'_> { + match (core::mem::size_of::(), core::mem::align_of::()) { + (1, _) => Lanes::U8(crate::cast_slice::(s)), + (2, 2) => Lanes::U16(crate::cast_slice::(s)), + _ => Lanes::Wide, + } +} + +/// `bun.reinterpretSlice` — `&[T]` → `&[u8]` byte view (any width). #[inline] fn reinterpret_to_u8(s: &[T]) -> &[u8] { crate::cast_slice::(s) @@ -326,13 +342,13 @@ pub fn contains_char(self_: &[u8], char: u8) -> bool { index_of_char(self_, char).is_some() } +/// `char` is an ASCII byte compared against each (possibly wider) element. #[inline] pub fn contains_char_t>(self_: &[T], char: u8) -> bool { - // Branch on size_of (const-folded). - if core::mem::size_of::() == 1 { - contains_char(reinterpret_to_u8(self_), char) - } else { - self_.iter().any(|c| (*c).into() == char as u32) + match lanes(self_) { + Lanes::U8(s) => contains_char(s, char), + Lanes::U16(s) => highway::memmem16(s, &[u16::from(char)]).is_some(), + Lanes::Wide => self_.iter().any(|c| (*c).into() == u32::from(char)), } } @@ -343,6 +359,9 @@ pub fn contains(self_: &[u8], str: &[u8]) -> bool { index_of(self_, str).is_some() } +/// The kernels compare against at most this many set bytes per pass. +const ANY_CHAR_SET_MAX: usize = 16; + /// Index of the first byte in `slice` that appears in `chars` (SIMD via /// highway). Returns `usize` (unlike the `u32`-returning single-char /// scanners above) so callers can index with the result directly. @@ -351,39 +370,53 @@ pub fn index_of_any(slice: &[u8], chars: &[u8]) -> Option { match chars.len() { 0 => None, 1 => index_of_char_usize(slice, chars[0]), - _ => highway::index_of_any_char(slice, chars), + 2..=ANY_CHAR_SET_MAX => highway::index_of_any_char(slice, chars), + // Larger sets (none today): one pass per 16-byte chunk, earliest hit wins. + _ => chars + .chunks(ANY_CHAR_SET_MAX) + .filter_map(|set| index_of_any(slice, set)) + .min(), } } -pub fn index_of_any16(self_: &[u16], chars: &[u16]) -> Option { - index_of_any_t(self_, chars) +/// [`index_of_any`] starting at `start_index`; the result is absolute. +pub fn index_of_any_pos(slice: &[u8], chars: &[u8], start_index: usize) -> Option { + if start_index >= slice.len() { + return None; + } + index_of_any(&slice[start_index..], chars).map(|i| i + start_index) } -pub fn index_of_any_t(str: &[T], chars: &[T]) -> Option { - // Rust cannot dispatch on type identity without specialization; - // callers with u8 should call index_of_any directly (highway-accelerated). - str.iter().position(|c| chars.contains(c)) +/// Index of the last byte in `slice` that appears in `chars` (SIMD via highway). +#[inline] +pub fn last_index_of_any(slice: &[u8], chars: &[u8]) -> Option { + match chars.len() { + 0 => None, + 1 => last_index_of_char(slice, chars[0]), + 2..=ANY_CHAR_SET_MAX => highway::last_index_of_any_char(slice, chars), + _ => chars + .chunks(ANY_CHAR_SET_MAX) + .filter_map(|set| last_index_of_any(slice, set)) + .max(), + } } +/// Whether any byte of `slice` appears in `chars` (SIMD via highway). #[inline] -pub fn contains_comptime(self_: &[u8], str: &'static [u8]) -> bool { - debug_assert!(!str.is_empty(), "Don't call this with an empty string plz."); +pub fn contains_any(slice: &[u8], chars: &[u8]) -> bool { + index_of_any(slice, chars).is_some() +} - let Some(start) = self_.iter().position(|&b| b == str[0]) else { - return false; - }; - let mut remain = &self_[start..]; - // PERF: slice equality; LLVM should emit good code for small fixed lengths. - while remain.len() >= str.len() { - if &remain[..str.len()] == str { - return true; - } - let Some(next_start) = remain[1..].iter().position(|&b| b == str[0]) else { - return false; - }; - remain = &remain[1 + next_start..]; +pub fn index_of_any16(self_: &[u16], chars: &[u16]) -> Option { + index_of_any_t(self_, chars) +} + +pub fn index_of_any_t(str: &[T], chars: &[T]) -> Option { + if let (Lanes::U8(s), Lanes::U8(c)) = (lanes(str), lanes(chars)) { + return index_of_any(s, c); } - false + // No multi-needle highway kernel for u16; `chars` is a short constant set. + str.iter().position(|c| chars.iter().any(|d| d == c)) } pub use contains as includes; @@ -462,17 +495,6 @@ pub(crate) use crate::strings_impl::{ find_url_password, is_uuid, starts_with_npm_secret, starts_with_secret, starts_with_uuid, }; -pub fn index_any_comptime(target: &[u8], chars: &'static [u8]) -> Option { - for (i, &parent) in target.iter().enumerate() { - for &char in chars { - if char == parent { - return Some(i); - } - } - } - None -} - pub fn index_equal_any(in_: &[&[u8]], target: &[u8]) -> Option { for (i, str) in in_.iter().enumerate() { if eql_long(str, target, true) { @@ -488,12 +510,10 @@ pub fn repeating_alloc(count: usize, char: u8) -> Result, AllocError> } pub fn index_of_char_neg(self_: &[u8], char: u8) -> i32 { - for (i, &c) in self_.iter().enumerate() { - if c == char { - return i32::try_from(i).expect("int cast"); - } + match index_of_char_usize(self_, char) { + Some(i) => i32::try_from(i).expect("int cast"), + None => -1, } - -1 } /// Returns last index of `char` before a character `before`. @@ -504,39 +524,40 @@ pub fn last_index_before_char(in_: &[u8], char: u8, before: u8) -> Option #[inline] pub fn last_index_of_char(self_: &[u8], char: u8) -> Option { - #[cfg(any(target_os = "linux", target_os = "android"))] - { - // SAFETY: memrchr scans within [self_.ptr, self_.ptr + self_.len). - let start = unsafe { libc::memrchr(self_.as_ptr().cast(), char as c_int, self_.len()) }; - if start.is_null() { - return None; - } - return Some(start as usize - self_.as_ptr() as usize); - } - #[cfg(not(any(target_os = "linux", target_os = "android")))] - { - last_index_of_char_t(self_, char) - } + highway::last_index_of_char(self_, char) } +/// Width-generic [`last_index_of_char`]. #[inline] -pub fn last_index_of_char_t(self_: &[T], char: T) -> Option { - self_.iter().rposition(|c| *c == char) +pub fn last_index_of_char_t(self_: &[T], char: T) -> Option { + match (lanes(self_), lanes(core::slice::from_ref(&char))) { + (Lanes::U8(s), Lanes::U8(c)) => last_index_of_char(s, c[0]), + (Lanes::U16(s), Lanes::U16(c)) => highway::memrmem16(s, c), + _ => self_.iter().rposition(|c| *c == char), + } } +/// Start index of the last occurrence of `str`. Empty needle → `Some(len)`. #[inline] pub fn last_index_of(self_: &[u8], str: &[u8]) -> Option { - // u8 fast path: bstr → memchr SIMD memmem (rfind). Empty needle → Some(len). - bstr::ByteSlice::rfind(self_, str) + highway::memrmem(self_, str) } -/// Generic reverse substring search (last occurrence of `needle`). -/// For `T = u8` prefer [`last_index_of`] (SIMD memmem). -pub fn last_index_of_t(haystack: &[T], needle: &[T]) -> Option { - if needle.is_empty() { - return Some(haystack.len()); +/// Width-generic reverse substring search (last occurrence of `needle`). +/// Empty needle → `Some(len)`. +pub fn last_index_of_t(haystack: &[T], needle: &[T]) -> Option { + match (lanes(haystack), lanes(needle)) { + (Lanes::U8(h), Lanes::U8(n)) => last_index_of(h, n), + (Lanes::U16(h), Lanes::U16(n)) => highway::memrmem16(h, n), + _ => { + if needle.len() > haystack.len() { + return None; + } + (0..=haystack.len() - needle.len()) + .rev() + .find(|&i| haystack[i..i + needle.len()] == *needle) + } } - haystack.windows(needle.len()).rposition(|w| w == needle) } pub fn index_of(self_: &[u8], str: &[u8]) -> Option { @@ -559,13 +580,19 @@ pub fn index_of(self_: &[u8], str: &[u8]) -> Option { Some(i) } -pub fn index_of_t(haystack: &[T], needle: &[T]) -> Option { - // Callers with u8 should call index_of directly (memmem); - // this generic path uses naive search. - if needle.is_empty() { - return Some(0); +/// Width-generic substring search. Unlike [`index_of`], an empty needle +/// matches at `Some(0)`. +pub fn index_of_t(haystack: &[T], needle: &[T]) -> Option { + match (lanes(haystack), lanes(needle)) { + (Lanes::U8(h), Lanes::U8(n)) => memmem(h, n), + (Lanes::U16(h), Lanes::U16(n)) => highway::memmem16(h, n), + _ => { + if needle.len() > haystack.len() { + return None; + } + (0..=haystack.len() - needle.len()).find(|&i| haystack[i..i + needle.len()] == *needle) + } } - haystack.windows(needle.len()).position(|w| w == needle) } pub fn split<'a>(self_: &'a [u8], delimiter: &'a [u8]) -> SplitIterator<'a> { @@ -576,6 +603,39 @@ pub fn split<'a>(self_: &'a [u8], delimiter: &'a [u8]) -> SplitIterator<'a> { } } +/// `str::split_once` for bytes: the text before and after the first `delimiter`. +#[inline] +pub fn split_once_char(self_: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { + let i = index_of_char_usize(self_, delimiter)?; + Some((&self_[..i], &self_[i + 1..])) +} + +/// `str::rsplit_once` for bytes: the text before and after the last `delimiter`. +#[inline] +pub fn rsplit_once_char(self_: &[u8], delimiter: u8) -> Option<(&[u8], &[u8])> { + let i = last_index_of_char(self_, delimiter)?; + Some((&self_[..i], &self_[i + 1..])) +} + +/// `str::split_once` for bytes with a multi-byte delimiter. An empty +/// delimiter never matches. +#[inline] +pub fn split_once<'a>(self_: &'a [u8], delimiter: &[u8]) -> Option<(&'a [u8], &'a [u8])> { + let i = index_of(self_, delimiter)?; + Some((&self_[..i], &self_[i + delimiter.len()..])) +} + +/// `str::rsplit_once` for bytes with a multi-byte delimiter. An empty +/// delimiter never matches. +#[inline] +pub fn rsplit_once<'a>(self_: &'a [u8], delimiter: &[u8]) -> Option<(&'a [u8], &'a [u8])> { + if delimiter.is_empty() { + return None; + } + let i = last_index_of(self_, delimiter)?; + Some((&self_[..i], &self_[i + delimiter.len()..])) +} + pub struct SplitIterator<'a> { pub(crate) buffer: &'a [u8], pub(crate) index: Option, @@ -606,6 +666,102 @@ impl<'a> SplitIterator<'a> { } } +impl<'a> Iterator for SplitIterator<'a> { + type Item = &'a [u8]; + + #[inline] + fn next(&mut self) -> Option<&'a [u8]> { + SplitIterator::next(self) + } +} + +// Concrete (not `impl Iterator`) so the borrow of the input visibly ends at +// the iterator's last use rather than at end of scope. +pub type TokenizeIterator<'a> = core::iter::Filter, fn(&&'a [u8]) -> bool>; +pub type TokenizeAnyIterator<'a> = core::iter::Filter, fn(&&'a [u8]) -> bool>; + +fn is_non_empty_field(s: &&[u8]) -> bool { + !s.is_empty() +} + +/// `std.mem.tokenizeSequence` — [`split`] without the empty fields, so runs +/// of the delimiter and leading/trailing delimiters yield nothing. +pub fn tokenize<'a>(self_: &'a [u8], delimiter: &'a [u8]) -> TokenizeIterator<'a> { + split(self_, delimiter).filter(is_non_empty_field as fn(&&[u8]) -> bool) +} + +/// `std.mem.tokenizeAny` — [`split_any`] without the empty fields. +pub fn tokenize_any<'a>(self_: &'a [u8], chars: &'a [u8]) -> TokenizeAnyIterator<'a> { + split_any(self_, chars).filter(is_non_empty_field as fn(&&[u8]) -> bool) +} + +/// `<[u8]>::split` with a multi-byte predicate — `s.split(|b| b == x || b == y)` +/// — as a highway scan: every byte that appears in `chars` is a delimiter. +pub fn split_any<'a>(self_: &'a [u8], chars: &'a [u8]) -> SplitAnyIterator<'a> { + SplitAnyIterator { + buffer: self_, + index: Some(0), + chars, + } +} + +pub struct SplitAnyIterator<'a> { + buffer: &'a [u8], + index: Option, + chars: &'a [u8], +} + +impl<'a> Iterator for SplitAnyIterator<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option<&'a [u8]> { + let start = self.index?; + let end = if let Some(i) = index_of_any(&self.buffer[start..], self.chars) { + self.index = Some(start + i + 1); + start + i + } else { + self.index = None; + self.buffer.len() + }; + Some(&self.buffer[start..end]) + } +} + +/// `<[u8]>::rsplit` — fields of `self_` separated by `delimiter`, last to first. +pub fn rsplit<'a>(self_: &'a [u8], delimiter: &'a [u8]) -> RSplitIterator<'a> { + RSplitIterator { + buffer: self_, + end: Some(self_.len()), + delimiter, + } +} + +pub struct RSplitIterator<'a> { + buffer: &'a [u8], + end: Option, + delimiter: &'a [u8], +} + +impl<'a> Iterator for RSplitIterator<'a> { + type Item = &'a [u8]; + + fn next(&mut self) -> Option<&'a [u8]> { + let end = self.end?; + if self.delimiter.is_empty() { + self.end = None; + return Some(&self.buffer[..end]); + } + let start = if let Some(i) = last_index_of(&self.buffer[..end], self.delimiter) { + self.end = Some(i); + i + self.delimiter.len() + } else { + self.end = None; + 0 + }; + Some(&self.buffer[start..end]) + } +} + pub fn cat(first: &[u8], second: &[u8]) -> Result, AllocError> { // allocator param dropped (global mimalloc). let mut out = Vec::with_capacity(first.len() + second.len()); @@ -887,13 +1043,27 @@ pub fn eql_any_comptime(self_: &[u8], list: &'static [&'static [u8]]) -> bool { /// Count the occurrences of a character in an ASCII byte array /// uses SIMD +#[inline] pub fn count_char(self_: &[u8], char: u8) -> usize { - // PERF: scalar count; consider portable_simd or a highway intrinsic if hot. - let mut total: usize = 0; - for &c in self_ { - total += (c == char) as usize; + highway::count_char(self_, char) +} + +/// `std.mem.count` — number of non-overlapping occurrences of `needle`. +/// An empty needle counts as zero occurrences. +pub fn count(self_: &[u8], needle: &[u8]) -> usize { + match needle.len() { + 0 => 0, + 1 => count_char(self_, needle[0]), + n => { + let mut total = 0usize; + let mut rest = self_; + while let Some(i) = memmem(rest, needle) { + total += 1; + rest = &rest[i + n..]; + } + total + } } - total } pub fn eql(self_: &[u8], other: &[u8]) -> bool { @@ -1358,23 +1528,6 @@ pub fn index_of_char_pos(slice: &[u8], char: u8, start_index: usize) -> Option Option { - if chars.len() == 1 { - return index_of_char_pos(slice, chars[0], start_index); - } - if start_index >= slice.len() { - return None; - } - slice[start_index..] - .iter() - .position(|b| chars.contains(b)) - .map(|i| i + start_index) -} - pub fn index_of_not_char(slice: &[u8], char: u8) -> Option { if slice.is_empty() { return None; @@ -1384,15 +1537,8 @@ pub fn index_of_not_char(slice: &[u8], char: u8) -> Option { return Some(0); } - // PERF: scalar loop; consider a SIMD entry point if hot. - for (i, ¤t) in slice.iter().enumerate() { - if current != char { - // Wrapping cast. - return Some(i as u32); - } - } - - None + // Wrapping cast. + highway::index_of_not_char(slice, char).map(|i| i as u32) } use crate::fmt::{HEX_DECODE_TABLE as HEX_TABLE, HEX_INVALID as INVALID_CHAR}; @@ -1977,15 +2123,14 @@ impl core::fmt::Display for QuoteEscapeFormat<'_> { } } -/// Generic. Works on &[u8], &[u16], etc +/// Width-generic [`index_of_char_usize`]. #[inline] pub fn index_of_scalar(input: &[T], scalar: T) -> Option { - // Branch on size_of (const-folded): byte-sized T → index_of_char_usize (highway). - if core::mem::size_of::() == 1 { - let scalar_u8 = reinterpret_to_u8(core::slice::from_ref(&scalar))[0]; - return index_of_char_usize(reinterpret_to_u8(input), scalar_u8); + match (lanes(input), lanes(core::slice::from_ref(&scalar))) { + (Lanes::U8(s), Lanes::U8(c)) => index_of_char_usize(s, c[0]), + (Lanes::U16(s), Lanes::U16(c)) => highway::memmem16(s, c), + _ => input.iter().position(|c| *c == scalar), } - input.iter().position(|c| *c == scalar) } pub fn without_suffix_comptime<'a>(input: &'a [u8], suffix: &'static [u8]) -> &'a [u8] { diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index a781c3b5eb78..4bd44f0741ca 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -184,7 +184,7 @@ impl ZStr { #[inline] pub fn as_cstr(&self) -> &core::ffi::CStr { debug_assert!( - !self.0.contains(&0), + !crate::strings::contains_char(&self.0, 0), "ZStr::as_cstr: interior NUL would truncate the C view", ); // SAFETY: `as_bytes_with_nul()` is `[.., 0]` by the ZStr invariant; @@ -376,7 +376,7 @@ pub fn getenv_z_any_case(key: &ZStr) -> Option<&'static [u8]> { let mut p = c_environ(); while !(*p).is_null() { let line = core::slice::from_raw_parts((*p).cast::(), libc::strlen(*p)); - let key_end = line.iter().position(|&b| b == b'=').unwrap_or(line.len()); + let key_end = crate::strings::index_of_char_usize(line, b'=').unwrap_or(line.len()); if crate::strings::eql_case_insensitive_ascii_check_length( &line[..key_end], key.as_bytes(), @@ -408,7 +408,7 @@ pub fn getenv_z_any_case(key: &ZStr) -> Option<&'static [u8]> { } core::slice::from_raw_parts(entry.cast::(), len) }; - let key_end = line.iter().position(|&b| b == b'=').unwrap_or(line.len()); + let key_end = crate::strings::index_of_char_usize(line, b'=').unwrap_or(line.len()); if crate::strings::eql_case_insensitive_ascii_check_length( &line[..key_end], key.as_bytes(), @@ -4256,7 +4256,7 @@ pub(crate) fn which<'a>( return check(buf, cwd, bin).map(|n| ZStr::from_buf(&buf.0, n)); } // Bare names go straight to PATH — do NOT consult cwd. - for dir in path.split(|&b| b == b':') { + for dir in crate::strings::split(path, b":") { if dir.is_empty() { continue; } @@ -4649,7 +4649,7 @@ fn spawn_sync_inherit_impl( let pid: libc::pid_t = { let arg0 = argv[0].as_ref(); let mut pathbuf = PathBuffer::uninit(); - let exe: *const core::ffi::c_char = if arg0.contains(&b'/') { + let exe: *const core::ffi::c_char = if crate::strings::contains_char(arg0, b'/') { // Contains a separator → use as-is (execve resolves relative // to cwd, matching posix_spawnp semantics for non-bare names). ptrs[0] diff --git a/src/bundler/HTMLScanner.rs b/src/bundler/HTMLScanner.rs index c2065d3e1d40..88cdf4323b10 100644 --- a/src/bundler/HTMLScanner.rs +++ b/src/bundler/HTMLScanner.rs @@ -43,7 +43,8 @@ impl<'a> HTMLScanner<'a> { // Check if imports to (e.g) "App.tsx" are actually relative imoprts w/o the "./" else if input_path.len() > 2 && input_path[0] != b'.' && input_path[1] != b'/' { 'blk: { - let Some(index_of_dot) = input_path.iter().rposition(|&b| b == b'.') else { + let Some(index_of_dot) = bun_core::strings::last_index_of_char(input_path, b'.') + else { break 'blk input_path; }; let ext = &input_path[index_of_dot..]; diff --git a/src/bundler/defines.rs b/src/bundler/defines.rs index 9ba2ee84c7dd..642ea433363f 100644 --- a/src/bundler/defines.rs +++ b/src/bundler/defines.rs @@ -386,7 +386,7 @@ impl DefineDataExt for DefineData { log: &mut bun_ast::Log, bump: &bun_alloc::Arena, ) -> Result { - let mut key_splitter = key.split(|b| *b == b'.'); + let mut key_splitter = strings::split(key, b"."); while let Some(part) = key_splitter.next() { if !js_lexer::is_identifier(part) { if strings::eql(part, key) { @@ -414,7 +414,7 @@ impl DefineDataExt for DefineData { } // check for nested identifiers - let mut value_splitter = value_str.split(|b| *b == b'.'); + let mut value_splitter = strings::split(value_str, b"."); let mut is_ident = true; while let Some(part) = value_splitter.next() { diff --git a/src/cares_sys/c_ares.rs b/src/cares_sys/c_ares.rs index eb29f0dd8024..ca8c1a717c18 100644 --- a/src/cares_sys/c_ares.rs +++ b/src/cares_sys/c_ares.rs @@ -836,7 +836,7 @@ impl Channel { pub fn resolve(&mut self, name: &[u8], ctx: &mut T) { if name.len() >= 1023 - || name.contains(&0) + || bun_core::strings::contains_char(name, 0) || (name.is_empty() && !(T::LOOKUP_NAME == b"ns" || T::LOOKUP_NAME == b"soa")) { // SAFETY: thunk handles ARES_EBADNAME path. diff --git a/src/clap/streaming.rs b/src/clap/streaming.rs index 6c7b2ef2f2f4..d495f549dba2 100644 --- a/src/clap/streaming.rs +++ b/src/clap/streaming.rs @@ -1,6 +1,7 @@ use core::sync::atomic::{AtomicBool, Ordering}; use bun_core::Output; +use bun_core::strings; use crate as clap; use crate::args::ArgIter; @@ -95,7 +96,7 @@ where match arg_info.kind { ArgKind::Long => { - let eql_index = arg.iter().position(|&b| b == b'='); + let eql_index = strings::index_of_char_usize(arg, b'='); let name: &[u8] = if let Some(i) = eql_index { &arg[0..i] } else { @@ -416,10 +417,11 @@ mod tests { } else { &diag.arg }; + let quoted = [b"'".as_slice(), captured, b"'"].concat(); + // Naive search: `cargo test -p bun_clap` does not link the + // highway kernels behind `bun_core::strings::contains`. assert!( - expected.windows(captured.len() + 2).any(|w| w[0] == b'\'' - && w[w.len() - 1] == b'\'' - && &w[1..w.len() - 1] == captured), + (0..expected.len()).any(|i| expected[i..].starts_with("ed)), "expected message {:?} does not name captured arg {:?}", bstr::BStr::new(expected), bstr::BStr::new(captured), diff --git a/src/css/printer.rs b/src/css/printer.rs index e8de3e9ece39..c37427280041 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -3,6 +3,7 @@ use core::fmt; use bun_alloc::Arena as Bump; use bun_alloc::ArenaVec as BumpVec; use bun_ast::ImportRecord; +use bun_core::strings; use crate::css_parser as css; use crate::values as css_values; @@ -427,14 +428,11 @@ impl<'a> Printer<'a> { if self.dest.write_all(comment).is_err() { return Err(self.add_fmt_error()); } - let new_lines = comment.iter().filter(|&&b| b == b'\n').count(); + let new_lines = strings::count_char(comment, b'\n'); self.line += u32::try_from(new_lines).expect("int cast"); self.col = 0; - let last_line_start = comment.len() - - comment - .iter() - .rposition(|&b| b == b'\n') - .unwrap_or(comment.len()); + let last_line_start = + comment.len() - strings::last_index_of_char(comment, b'\n').unwrap_or(comment.len()); self.col += u32::try_from(last_line_start).expect("int cast"); Ok(()) } @@ -447,7 +445,7 @@ impl<'a> Printer<'a> { let s = s.as_ref(); #[cfg(debug_assertions)] { - debug_assert!(!s.contains(&b'\n')); + debug_assert!(!strings::contains_char(s, b'\n')); } self.col += u32::try_from(s.len()).expect("int cast"); if self.dest.write_all(s).is_err() { @@ -462,7 +460,7 @@ impl<'a> Printer<'a> { let s = &self.scratchbuf[range]; #[cfg(debug_assertions)] { - debug_assert!(!s.contains(&b'\n')); + debug_assert!(!strings::contains_char(s, b'\n')); } self.col += u32::try_from(s.len()).expect("int cast"); if self.dest.write_all(s).is_err() { @@ -480,8 +478,8 @@ impl<'a> Printer<'a> { pub(crate) fn write_bytes(&mut self, s: &[u8]) -> PrintResult<()> { // Unlike `write_str`, newlines are allowed here; track line/col across them // (matching `write_char` applied byte-by-byte) so source maps stay correct. - if let Some(last_newline) = s.iter().rposition(|&b| b == b'\n') { - let new_lines = s.iter().filter(|&&b| b == b'\n').count(); + if let Some(last_newline) = strings::last_index_of_char(s, b'\n') { + let new_lines = strings::count_char(s, b'\n'); self.line += u32::try_from(new_lines).expect("int cast"); self.col = u32::try_from(s.len() - last_newline - 1).expect("int cast"); } else { diff --git a/src/css/properties/font.rs b/src/css/properties/font.rs index 808cb4cdd04e..1f97fadfa022 100644 --- a/src/css/properties/font.rs +++ b/src/css/properties/font.rs @@ -392,7 +392,7 @@ impl FontFamily { // AST crate: std.Io.Writer.Allocating on dest.arena (arena) → bumpalo Vec let mut id = bun_alloc::ArenaVec::::new_in(dest.arena); let mut first = true; - for slice in val.split(|b| *b == b' ') { + for slice in bun_core::strings::split(val, b" ") { if first { first = false; } else { diff --git a/src/css/targets.rs b/src/css/targets.rs index 08ab53c0d29c..164159d18feb 100644 --- a/src/css/targets.rs +++ b/src/css/targets.rs @@ -371,9 +371,7 @@ impl Browsers { let (major, minor) = 'major_minor: { let version_str = &entry[idx..]; - let dot_index = version_str - .iter() - .position(|&b| b == b'.') + let dot_index = strings::index_of_char_usize(version_str, b'.') .unwrap_or(version_str.len()); let Some(major) = strings::parse_int::(&version_str[0..dot_index], 10).ok() diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index d1fd92cec9ca..6a7e23c3f9a4 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -76,7 +76,7 @@ impl DotEnvBehavior { Ok((Self::load_all, None)) } else if s == b"disable" { Ok((Self::disable, None)) - } else if let Some(asterisk) = s.iter().position(|&b| b == b'*') { + } else if let Some(asterisk) = strings::index_of_char_usize(s, b'*') { if asterisk > 0 { Ok((Self::prefix, Some(&s[..asterisk]))) } else { @@ -352,7 +352,7 @@ impl Loader { return false; } - for no_proxy_item in no_proxy_text.split(|&b| b == b',') { + for no_proxy_item in strings::split(no_proxy_text, b",") { let mut no_proxy_entry = strings::trim(no_proxy_item, &strings::WHITESPACE_CHARS); if no_proxy_entry.is_empty() { continue; @@ -372,7 +372,7 @@ impl Loader { // IPv6 addresses contain multiple colons (e.g., "::1", "2001:db8::1") // Bracketed IPv6 with port: "[::1]:8080" // Host with port: "localhost:8080" (single colon) - let colon_count = no_proxy_entry.iter().filter(|&&b| b == b':').count(); + let colon_count = strings::count_char(no_proxy_entry, b':'); let is_bracketed_ipv6 = strings::starts_with_char(no_proxy_entry, b'['); let has_port = 'blk: { if is_bracketed_ipv6 { @@ -659,7 +659,7 @@ impl Loader { let arg_value = strings::trim(env_files[i - 1], b" "); if !arg_value.is_empty() { // ignore blank args - for file_path in arg_value.rsplit(|&b| b == b',') { + for file_path in strings::rsplit(arg_value, b",") { if !file_path.is_empty() { self.load_env_file_dynamic::(file_path, value_buffer)?; analytics::Features::dotenv_inc(); diff --git a/src/glob/lib.rs b/src/glob/lib.rs index 1c6aae4e1723..5aa5fbdacdfe 100644 --- a/src/glob/lib.rs +++ b/src/glob/lib.rs @@ -30,18 +30,20 @@ pub fn detect_glob_syntax(potential_pattern: &[u8]) -> bool { for &token in SPECIAL_SYNTAX.iter() { let mut slice = potential_pattern; while !slice.is_empty() { - if let Some(idx) = slice.iter().position(|&b| b == token) { + if let Some(idx) = bun_core::strings::index_of_char_usize(slice, token) { // Check for even number of backslashes preceding the - // token to know that it's not escaped + // token to know that it's not escaped. `idx` is relative to + // `slice`; a backslash run can't extend past its start (the + // byte before it is the previous, unescaped-or-not, token). let mut i = idx; - let mut backslash_count: u16 = 0; + let mut escaped = false; - while i > 0 && potential_pattern[i - 1] == b'\\' { - backslash_count += 1; + while i > 0 && slice[i - 1] == b'\\' { + escaped = !escaped; i -= 1; } - if backslash_count.is_multiple_of(2) { + if !escaped { return true; } slice = &slice[idx + 1..]; diff --git a/src/highway/lib.rs b/src/highway/lib.rs index 5caf6379fab1..fa4a35fc2d95 100644 --- a/src/highway/lib.rs +++ b/src/highway/lib.rs @@ -4,6 +4,12 @@ unsafe extern "C" { fn highway_index_of_char(haystack: *const u8, haystack_len: usize, needle: u8) -> usize; + fn highway_last_index_of_char(haystack: *const u8, haystack_len: usize, needle: u8) -> usize; + + fn highway_index_of_not_char(haystack: *const u8, haystack_len: usize, value: u8) -> usize; + + fn highway_count_char(haystack: *const u8, haystack_len: usize, needle: u8) -> usize; + fn highway_memmem( haystack: *const u8, haystack_len: usize, @@ -11,6 +17,29 @@ unsafe extern "C" { needle_len: usize, ) -> *const u8; + // These three return `usize::MAX` for not-found (the empty needle matches at + // 0 / `haystack_len` respectively). + fn highway_memrmem( + haystack: *const u8, + haystack_len: usize, + needle: *const u8, + needle_len: usize, + ) -> usize; + + fn highway_memmem16( + haystack: *const u16, + haystack_len: usize, + needle: *const u16, + needle_len: usize, + ) -> usize; + + fn highway_memrmem16( + haystack: *const u16, + haystack_len: usize, + needle: *const u16, + needle_len: usize, + ) -> usize; + fn highway_index_of_interesting_character_in_string_literal( text: *const u8, text_len: usize, @@ -49,6 +78,13 @@ unsafe extern "C" { chars_len: usize, ) -> usize; + fn highway_last_index_of_any_char( + text: *const u8, + text_len: usize, + chars: *const u8, + chars_len: usize, + ) -> usize; + fn highway_fill_with_skip_mask( mask: *const u8, mask_len: usize, @@ -105,7 +141,8 @@ unsafe extern "C" { } // NOTE: every public wrapper below is `#[inline(always)]`. They are thin -// ptr/len shims around the `extern "C"` highway_* dispatch stubs; inlining +// ptr/len shims around the `extern "C"` highway_* dispatch stubs (plus, for the +// single-byte scans, a <16-byte scalar prologue — see `SCALAR_CUTOFF`); inlining // them puts the FFI call directly at the hot lexer/printer call site so that // (a) the Rust-side frame disappears unconditionally, and (b) cross-language // LTO (`--profile=btg`, crossLangLto=true) can fold the C dispatch shim @@ -113,22 +150,78 @@ unsafe extern "C" { // distinct hot leaf (e.g. `highway_index_of_newline_or_non_ascii` self-samples // in lint/create-vue benches). +/// Below the narrowest vector the kernels dispatch to (16 lanes: NEON, SSE4) +/// they do at most one (masked) vector op or just their scalar tail, so for +/// haystacks this short the FFI hop plus the dispatch-table call dominates — +/// do those bytes inline. Also keeps cold call sites from dragging an FFI +/// call into a caller's hot loop (see `pop_last_segment_t` in node/path.rs). +const SCALAR_CUTOFF: usize = 16; + +/// The single-byte kernels return `haystack_len` for "not found". #[inline(always)] -pub fn index_of_char(haystack: &[u8], needle: u8) -> Option { - if haystack.is_empty() { - return None; +fn found_at(result: usize, haystack_len: usize) -> Option { + if result == haystack_len { + None + } else { + Some(result) } +} +/// The `mem*mem*` kernels return `usize::MAX` for "not found". +#[inline(always)] +fn match_at(result: usize) -> Option { + if result == usize::MAX { + None + } else { + Some(result) + } +} + +#[inline(always)] +pub fn index_of_char(haystack: &[u8], needle: u8) -> Option { + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().position(|&b| b == needle); + } // SAFETY: haystack.ptr/len are a valid readable range. let result = unsafe { highway_index_of_char(haystack.as_ptr(), haystack.len(), needle) }; + let found = found_at(result, haystack.len()); + debug_assert!(found.is_none_or(|i| haystack[i] == needle)); + found +} - if result == haystack.len() { - return None; +#[inline(always)] +pub fn last_index_of_char(haystack: &[u8], needle: u8) -> Option { + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().rposition(|&b| b == needle); } + // SAFETY: haystack.ptr/len are a valid readable range. + let result = unsafe { highway_last_index_of_char(haystack.as_ptr(), haystack.len(), needle) }; + let found = found_at(result, haystack.len()); + debug_assert!(found.is_none_or(|i| haystack[i] == needle)); + found +} - debug_assert!(haystack[result] == needle); +/// Index of the first byte that is not `value` (i.e. the length of the leading +/// run of `value`), or `None` if every byte is `value`. +#[inline(always)] +pub fn index_of_not_char(haystack: &[u8], value: u8) -> Option { + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().position(|&b| b != value); + } + // SAFETY: haystack.ptr/len are a valid readable range. + let result = unsafe { highway_index_of_not_char(haystack.as_ptr(), haystack.len(), value) }; + let found = found_at(result, haystack.len()); + debug_assert!(found.is_none_or(|i| haystack[i] != value)); + found +} - Some(result) +#[inline(always)] +pub fn count_char(haystack: &[u8], needle: u8) -> usize { + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().filter(|&&b| b == needle).count(); + } + // SAFETY: haystack.ptr/len are a valid readable range. + unsafe { highway_count_char(haystack.as_ptr(), haystack.len(), needle) } } #[inline(always)] @@ -156,6 +249,76 @@ pub fn memmem(haystack: &[u8], needle: &[u8]) -> Option { } } +/// Start index of the last occurrence of `needle` in `haystack`. An empty +/// needle matches at `haystack.len()`. +#[inline(always)] +pub fn memrmem(haystack: &[u8], needle: &[u8]) -> Option { + if needle.is_empty() { + return Some(haystack.len()); + } + if haystack.len() < needle.len() { + return None; + } + // SAFETY: both (ptr,len) pairs are valid readable ranges. + let result = unsafe { + highway_memrmem( + haystack.as_ptr(), + haystack.len(), + needle.as_ptr(), + needle.len(), + ) + }; + let found = match_at(result); + debug_assert!(found.is_none_or(|i| haystack[i..].starts_with(needle))); + found +} + +#[inline(always)] +pub fn memmem16(haystack: &[u16], needle: &[u16]) -> Option { + if needle.is_empty() { + return Some(0); + } + if haystack.len() < needle.len() { + return None; + } + // SAFETY: both (ptr,len) pairs are valid readable ranges (`&[u16]` is 2-byte aligned). + let result = unsafe { + highway_memmem16( + haystack.as_ptr(), + haystack.len(), + needle.as_ptr(), + needle.len(), + ) + }; + let found = match_at(result); + debug_assert!(found.is_none_or(|i| haystack[i..].starts_with(needle))); + found +} + +/// Start index of the last occurrence of `needle`. An empty needle matches at +/// `haystack.len()`. +#[inline(always)] +pub fn memrmem16(haystack: &[u16], needle: &[u16]) -> Option { + if needle.is_empty() { + return Some(haystack.len()); + } + if haystack.len() < needle.len() { + return None; + } + // SAFETY: both (ptr,len) pairs are valid readable ranges (`&[u16]` is 2-byte aligned). + let result = unsafe { + highway_memrmem16( + haystack.as_ptr(), + haystack.len(), + needle.as_ptr(), + needle.len(), + ) + }; + let found = match_at(result); + debug_assert!(found.is_none_or(|i| haystack[i..].starts_with(needle))); + found +} + #[inline(always)] pub fn index_of_interesting_character_in_string_literal( slice: &[u8], @@ -294,9 +457,13 @@ pub fn index_of_needs_escape_for_javascript_string(slice: &[u8], quote_char: u8) #[inline(always)] pub fn index_of_any_char(haystack: &[u8], chars: &[u8]) -> Option { - if haystack.is_empty() || chars.is_empty() { + if chars.is_empty() { return None; } + debug_assert!(chars.len() >= 2 && chars.len() <= 16); + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().position(|b| chars.contains(b)); + } // SAFETY: haystack and chars ptr/len are valid readable ranges. let result = unsafe { @@ -329,6 +496,32 @@ pub fn index_of_any_char(haystack: &[u8], chars: &[u8]) -> Option { Some(result) } +/// `chars.len()` must be in 2..=16 (single-byte callers use [`last_index_of_char`]). +#[inline(always)] +pub fn last_index_of_any_char(haystack: &[u8], chars: &[u8]) -> Option { + if chars.is_empty() { + return None; + } + debug_assert!(chars.len() >= 2 && chars.len() <= 16); + if haystack.len() < SCALAR_CUTOFF { + return haystack.iter().rposition(|b| chars.contains(b)); + } + + // SAFETY: haystack and chars ptr/len are valid readable ranges. + let result = unsafe { + highway_last_index_of_any_char( + haystack.as_ptr(), + haystack.len(), + chars.as_ptr(), + chars.len(), + ) + }; + + let found = found_at(result, haystack.len()); + debug_assert!(found.is_none_or(|i| chars.contains(&haystack[i]))); + found +} + // `&[u16]` requires // 2-byte alignment. Callers with unaligned data must go through the raw extern. #[inline(always)] diff --git a/src/http/h2_client/dispatch.rs b/src/http/h2_client/dispatch.rs index b09a67ff686f..82e7bbaa04fa 100644 --- a/src/http/h2_client/dispatch.rs +++ b/src/http/h2_client/dispatch.rs @@ -776,7 +776,7 @@ pub(crate) fn is_malformed_response_field(name: &[u8]) -> bool { /// verbatim, breaking the no-CR/LF invariant the HTTP/1.1 parser provides and /// enabling header injection when values are forwarded downstream. pub(crate) fn is_malformed_response_value(value: &[u8]) -> bool { - value.iter().any(|&c| c == 0 || c == b'\r' || c == b'\n') + bun_core::strings::contains_any(value, b"\0\r\n") } pub(crate) fn error_code_for(err: crate::Error) -> wire::ErrorCode { diff --git a/src/http/h3_client/AltSvc.rs b/src/http/h3_client/AltSvc.rs index 0461f1ecf82a..208498c59e30 100644 --- a/src/http/h3_client/AltSvc.rs +++ b/src/http/h3_client/AltSvc.rs @@ -58,13 +58,13 @@ pub(crate) fn parse(field_value: &[u8]) -> Result, ParseError> { return Err(ParseError::Clear); } - for raw_entry in value.split(|b| *b == b',') { + for raw_entry in strings::split(value, b",") { let entry = strings::trim(raw_entry, b" \t"); if entry.is_empty() { continue; } - let mut params = entry.split(|b| *b == b';'); + let mut params = strings::split(entry, b";"); // `splitScalar.first()` == first split segment; always present. let alternative = strings::trim(params.next().unwrap(), b" \t"); @@ -84,7 +84,7 @@ pub(crate) fn parse(field_value: &[u8]) -> Result, ParseError> { if auth.len() >= 2 && auth[0] == b'"' && auth[auth.len() - 1] == b'"' { auth = &auth[1..auth.len() - 1]; } - let Some(colon) = auth.iter().rposition(|&b| b == b':') else { + let Some(colon) = strings::last_index_of_char(auth, b':') else { continue; }; // Same-host alternatives only (empty uri-host). diff --git a/src/http/lib.rs b/src/http/lib.rs index 566cabc80df4..fc09c8b32956 100644 --- a/src/http/lib.rs +++ b/src/http/lib.rs @@ -365,14 +365,14 @@ pub(crate) fn strip_port_from_host(host: &[u8]) -> &[u8] { } // IPv6 with brackets: "[::1]:port" if host[0] == b'[' { - if let Some(bracket) = host.iter().rposition(|&b| b == b']') { + if let Some(bracket) = strings::last_index_of_char(host, b']') { // Return everything up to and including ']' return &host[0..bracket + 1]; } return host; } // IPv4 or hostname: find last colon - if let Some(colon) = host.iter().rposition(|&b| b == b':') { + if let Some(colon) = strings::last_index_of_char(host, b':') { return &host[0..colon]; } host @@ -734,7 +734,7 @@ fn no_proxy_matches(no_proxy_text: &[u8], hostname: &[u8], host: &[u8]) -> bool if hostname.is_empty() { return false; } - for item in no_proxy_text.split(|&b| b == b',') { + for item in strings::split(no_proxy_text, b",") { let mut entry = strings::trim(item, &strings::WHITESPACE_CHARS); if entry.is_empty() { continue; @@ -751,7 +751,7 @@ fn no_proxy_matches(no_proxy_text: &[u8], hostname: &[u8], host: &[u8]) -> bool // IPv6 literals contain multiple colons (e.g., "::1"); bracketed IPv6 // with port is "[::1]:8080"; host:port has a single colon. - let colon_count = entry.iter().filter(|&&b| b == b':').count(); + let colon_count = strings::count_char(entry, b':'); let has_port = if strings::starts_with_char(entry, b'[') { strings::index_of(entry, b"]:").is_some() } else { diff --git a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs index daaf807ddb48..3b466c6f711f 100644 --- a/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs +++ b/src/http_jsc/websocket_client/WebSocketUpgradeClient.rs @@ -1426,14 +1426,14 @@ impl HTTPClient { return; } // This is a simplified parser. A full parser would handle multiple extensions and quoted values. - for ext_str in header.value().split(|b| *b == b',') { - let mut ext_it = strings::trim(ext_str, b" \t").split(|b| *b == b';'); + for ext_str in strings::split(header.value(), b",") { + let mut ext_it = strings::split(strings::trim(ext_str, b" \t"), b";"); let ext_name = strings::trim(ext_it.next().unwrap_or(b""), b" \t"); if ext_name == b"permessage-deflate" { deflate_result.enabled = true; for param_str in ext_it { let mut param_it = - strings::trim(param_str, b" \t").split(|b| *b == b'='); + strings::split(strings::trim(param_str, b" \t"), b"="); let key = strings::trim(param_it.next().unwrap_or(b""), b" \t"); let value = strings::trim(param_it.next().unwrap_or(b""), b" \t"); diff --git a/src/http_types/MimeType.rs b/src/http_types/MimeType.rs index 7207b19f0e16..58deff475551 100644 --- a/src/http_types/MimeType.rs +++ b/src/http_types/MimeType.rs @@ -285,7 +285,7 @@ impl MimeType { pub fn init(str_: &[u8], dupe: bool, allocated: Option<&mut bool>) -> MimeType { let mut str = str_; - if let Some(slash) = str.iter().position(|&b| b == b'/') { + if let Some(slash) = strings::index_of_char_usize(str, b'/') { let category_ = &str[0..slash]; if category_.is_empty() || category_[0] == b'*' || str.len() <= slash + 1 { @@ -294,7 +294,7 @@ impl MimeType { str = &str[slash + 1..]; - if let Some(semicolon) = str.iter().position(|&b| b == b';') { + if let Some(semicolon) = strings::index_of_char_usize(str, b';') { str = &str[0..semicolon]; } diff --git a/src/http_types/URLPath.rs b/src/http_types/URLPath.rs index a303fba8510e..76cd9e03444f 100644 --- a/src/http_types/URLPath.rs +++ b/src/http_types/URLPath.rs @@ -136,7 +136,7 @@ pub fn parse(possibly_encoded_pathname_: &[u8]) -> Result b".map".len() { let stripped = &path[0..path.len() - b".map".len()]; - if stripped.contains(&b'.') { + if strings::contains_char(stripped, b'.') { path = stripped; } } diff --git a/src/ini/lib.rs b/src/ini/lib.rs index 8f6a68307544..5caf8c3727a1 100644 --- a/src/ini/lib.rs +++ b/src/ini/lib.rs @@ -41,7 +41,7 @@ pub(crate) fn is_quoted(val: &[u8]) -> bool { #[inline] pub(crate) fn next_dot(key: &[u8]) -> Option { - key.iter().position(|&b| b == b'.') + bun_core::strings::index_of_char_usize(key, b'.') } // ────────────────────────────────────────────────────────────────────────── @@ -277,7 +277,7 @@ mod draft { let src = self.src; let env = self.env; let source_path = self.source.path.text; - let mut iter = src.split(|&b| b == b'\n'); + let mut iter = bun_core::strings::split(src, b"\n"); // `StoreRef` is the arena-backed handle `ExprData` already stores; // it is `Copy`, so keeping the root and the current-section head as // separate values is a split borrow, not an alias. @@ -305,7 +305,9 @@ mod draft { let mut treat_as_key = false; 'treat_as_key: { skip_until_next_section = false; - let Some(close_bracket_idx) = line.iter().position(|&b| b == b']') else { + let Some(close_bracket_idx) = + bun_core::strings::index_of_char_usize(line, b']') + else { // Skip the whole line: treat_as_key stays false and // we fall through to `continue` below. break 'treat_as_key; @@ -391,7 +393,7 @@ mod draft { let line_offset = i32::try_from(line.as_ptr() as usize - src.as_ptr() as usize) .expect("int cast"); - let maybe_eq_sign_idx = line.iter().position(|&b| b == b'='); + let maybe_eq_sign_idx = bun_core::strings::index_of_char_usize(line, b'='); let key_raw: &[u8] = Self::prepare_str( env, @@ -1885,7 +1887,8 @@ mod draft { return Ok(()); } let username_password = &decoded[..result.count]; - let Some(colon_idx) = username_password.iter().position(|&b| b == b':') else { + let Some(colon_idx) = bun_core::strings::index_of_char_usize(username_password, b':') + else { log.add_error_opts( b"invalid _auth value, expected base64 encoded \":\"", bun_ast::AddErrorOptions { diff --git a/src/install/NetworkTask.rs b/src/install/NetworkTask.rs index 9f1edc07278d..e1ea90a189e5 100644 --- a/src/install/NetworkTask.rs +++ b/src/install/NetworkTask.rs @@ -2,8 +2,6 @@ use core::mem::{ManuallyDrop, MaybeUninit}; use core::ptr::{self, NonNull}; use core::sync::atomic::Ordering; -use bstr::ByteSlice; - use crate::bun_fs::{FileSystem, FilenameStore}; use bun_collections::HashMap; use bun_core::{self, fmt::quote}; @@ -457,7 +455,7 @@ impl NetworkTask { // "npm" CLI requests the manifest with the encoded name. let encoded_name_storage; let encoded_name: &[u8] = if strings::index_of_char(name, b'/').is_some() { - encoded_name_storage = name.replace(b"/", b"%2f"); + encoded_name_storage = strings::replace_owned(name, b"/", b"%2f"); &encoded_name_storage } else { name diff --git a/src/install/PackageInstaller.rs b/src/install/PackageInstaller.rs index 7157a416bea4..a74f458d2cbc 100644 --- a/src/install/PackageInstaller.rs +++ b/src/install/PackageInstaller.rs @@ -412,17 +412,12 @@ impl<'a> LazyPackageDestinationDir<'a> { /// components, absolute paths, drive letters, backslashes, NUL bytes, and any /// separator other than the single `/` in a scoped name (`@scope/name`). pub(crate) fn alias_is_safe_install_target(alias: &[u8]) -> bool { - if alias.is_empty() - || alias.len() >= MAX_PATH_BYTES - || alias.contains(&b'\\') - || alias.contains(&b':') - || alias.contains(&0) - { + if alias.is_empty() || alias.len() >= MAX_PATH_BYTES || strings::contains_any(alias, b"\\:\0") { return false; } let mut component_count = 0usize; - for component in alias.split(|&c| c == b'/') { + for component in strings::split(alias, b"/") { component_count += 1; if component.is_empty() || component == b"." || component == b".." { return false; diff --git a/src/install/TarballStream.rs b/src/install/TarballStream.rs index 79bf56e4568f..f23144a68747 100644 --- a/src/install/TarballStream.rs +++ b/src/install/TarballStream.rs @@ -23,7 +23,6 @@ use core::mem::ManuallyDrop; use core::sync::atomic::{AtomicBool, Ordering}; use bun_collections::VecExt; -#[cfg(windows)] use bun_core::strings; use bun_core::{self, Output, ZBox, env_var, fmt as bun_fmt}; use bun_libarchive::lib; @@ -1464,7 +1463,7 @@ fn make_symlink( let symlink_dir = bun_paths::dirname(path_slice).unwrap_or(b""); let target_bytes = target.as_bytes(); let mut seen_named_component = false; - for component in target_bytes.split(|c| *c == b'/') { + for component in strings::split(target_bytes, b"/") { match component { b"" | b"." => {} b".." => { diff --git a/src/install/bin.rs b/src/install/bin.rs index 169e05e19b71..176a382377ff 100644 --- a/src/install/bin.rs +++ b/src/install/bin.rs @@ -736,10 +736,7 @@ pub(crate) type PriorityQueue = bun_collections::PriorityQueue &[u8] { - let name = match name - .iter() - .rposition(|&b| b == b'/' || b == b'\\' || b == b':') - { + let name = match strings::last_index_of_any(name, b"/\\:") { Some(i) => &name[i + 1..], None => name, }; @@ -769,15 +766,14 @@ pub(crate) fn bin_target_escapes_package_dir(target: &[u8]) -> bool { // be a drive prefix (or an NTFS alternate-data-stream on the leading // segment) — reject it. Colons in later components are left alone so Unix // filenames containing `:` keep working. - if target - .split(|&b| b == b'/' || b == b'\\') + if strings::split_any(target, b"/\\") .next() - .is_some_and(|first| first.contains(&b':')) + .is_some_and(|first| strings::contains_char(first, b':')) { return true; } let mut depth: isize = 0; - for component in target.split(|&b| b == b'/' || b == b'\\') { + for component in strings::split_any(target, b"/\\") { match component { b"" | b"." => {} b".." => { @@ -793,9 +789,7 @@ pub(crate) fn bin_target_escapes_package_dir(target: &[u8]) -> bool { } fn bin_target_needs_resolved_containment_check(target: &[u8]) -> bool { - let mut components = target - .split(|&b| b == b'/' || b == b'\\') - .filter(|component| !component.is_empty()); + let mut components = strings::tokenize_any(target, b"/\\"); let Some(first) = components.next() else { return false; }; diff --git a/src/install/build.rs b/src/install/build.rs index b987126a62b6..0a35ea2eb6ec 100644 --- a/src/install/build.rs +++ b/src/install/build.rs @@ -38,6 +38,10 @@ fn main() { "default-trusted-dependencies.txt is too large, please increase \ 'MAX_DEFAULT_TRUSTED_DEPENDENCIES' in lockfile.rs" ); + #[allow( + clippy::disallowed_methods, + reason = "adjacent-pair check, not a byte search" + )] for w in names.windows(2) { assert!(w[0] != w[1], "Duplicate trusted dependency: {}", w[0]); } diff --git a/src/install/dependency.rs b/src/install/dependency.rs index fb8b6ed62dda..3a2661f63ab6 100644 --- a/src/install/dependency.rs +++ b/src/install/dependency.rs @@ -574,14 +574,12 @@ pub(crate) fn is_safe_install_folder_name(name: &[u8]) -> bool { return false; } - for component in name.split(|&c| c == b'/') { + for component in strings::split(name, b"/") { if component.is_empty() || component == b"." || component == b".." { return false; } - for &c in component { - if c == b'\\' || c == b':' || c == 0 { - return false; - } + if strings::contains_any(component, b"\\:\0") { + return false; } } diff --git a/src/install/hosted_git_info.rs b/src/install/hosted_git_info.rs index 39e96a4f4cce..62937c9302c6 100644 --- a/src/install/hosted_git_info.rs +++ b/src/install/hosted_git_info.rs @@ -1044,7 +1044,7 @@ pub(crate) mod formatters { let pathname_owned = url.pathname().to_owned_slice(); let pathname = strings::trim_prefix(&pathname_owned, b"/"); - let mut iter = pathname.split(|&b| b == b'/'); + let mut iter = strings::split(pathname, b"/"); let Some(user_part) = iter.next() else { return Ok(None); }; @@ -1112,7 +1112,7 @@ pub(crate) mod formatters { let pathname_owned = url.pathname().to_owned_slice(); let pathname = strings::trim_prefix(&pathname_owned, b"/"); - let mut iter = pathname.split(|&b| b == b'/'); + let mut iter = strings::split(pathname, b"/"); let Some(user_part) = iter.next() else { return Ok(None); }; @@ -1224,7 +1224,7 @@ pub(crate) mod formatters { let pathname_owned = url.pathname().to_owned_slice(); let pathname = strings::trim_prefix(&pathname_owned, b"/"); - let mut iter = pathname.split(|&b| b == b'/'); + let mut iter = strings::split(pathname, b"/"); let Some(mut user_part) = iter.next() else { return Ok(None); }; @@ -1309,7 +1309,7 @@ pub(crate) mod formatters { let pathname_owned = url.pathname().to_owned_slice(); let pathname = strings::trim_prefix(&pathname_owned, b"/"); - let mut iter = pathname.split(|&b| b == b'/'); + let mut iter = strings::split(pathname, b"/"); let Some(user_part) = iter.next() else { return Ok(None); }; diff --git a/src/install/lockfile.rs b/src/install/lockfile.rs index d518d76b761d..fdc2d43b1e3f 100644 --- a/src/install/lockfile.rs +++ b/src/install/lockfile.rs @@ -3071,12 +3071,8 @@ const MAX_DEFAULT_TRUSTED_DEPENDENCIES: usize = 512; /// --default` need not re-sort. pub static DEFAULT_TRUSTED_DEPENDENCIES_LIST: std::sync::LazyLock> = std::sync::LazyLock::new(|| { - const DATA: &str = include_str!("default-trusted-dependencies.txt"); - let mut names: Vec<&'static [u8]> = DATA - .split([' ', '\r', '\n', '\t']) - .filter(|s| !s.is_empty()) - .map(str::as_bytes) - .collect(); + const DATA: &[u8] = include_bytes!("default-trusted-dependencies.txt"); + let mut names: Vec<&'static [u8]> = strings::tokenize_any(DATA, b" \r\n\t").collect(); names.sort_unstable(); debug_assert!( names.len() <= MAX_DEFAULT_TRUSTED_DEPENDENCIES, diff --git a/src/io/ParentDeathWatchdog.rs b/src/io/ParentDeathWatchdog.rs index 07b1e0875cd5..fe05cc8c709a 100644 --- a/src/io/ParentDeathWatchdog.rs +++ b/src/io/ParentDeathWatchdog.rs @@ -612,12 +612,10 @@ fn parent_pid_of(pid: libc::pid_t) -> libc::pid_t { // Format: "pid (comm) state ppid …". `comm` may contain spaces and // parens; the *last* ')' terminates it. Field 1 after that is state, // field 2 is ppid. - let Some(rparen) = stat.iter().rposition(|&b| b == b')') else { + let Some(rparen) = bun_core::strings::last_index_of_char(stat, b')') else { return 0; }; - let mut it = stat[rparen + 1..] - .split(|&b| b == b' ') - .filter(|s| !s.is_empty()); + let mut it = bun_core::strings::tokenize(&stat[rparen + 1..], b" "); let _ = it.next(); // state let Some(ppid_str) = it.next() else { return 0; @@ -717,9 +715,7 @@ fn list_child_pids_linux(parent: libc::pid_t, out: &mut [libc::pid_t]) -> Option let Some(data) = read_file_once(children_path, &mut read_buf) else { continue; }; - let tok = data - .split(|&b| b == b' ' || b == b'\n') - .filter(|s| !s.is_empty()); + let tok = bun_core::strings::tokenize_any(data, b" \n"); for pid_str in tok { if written >= out.len() { break; diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 39663d1026fb..9e1e7669c3f7 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -23,6 +23,24 @@ export const xxHash3ForTesting: (view: ArrayBufferView, seed?: number | bigint) 2, ); +// Runtime-dispatched SIMD byte-search kernels (src/jsc/bindings/highway_strings.cpp) +// behind `bun_core::strings`, driven directly so tests can sweep lengths and +// alignments. Returns the kernel's raw result: an index (`haystack.length` = +// not found), a count, or for memmem/memrmem the offset with -1 = not found. +export const highwayStringsForTesting: ( + op: + | "indexOfChar" + | "lastIndexOfChar" + | "indexOfNotChar" + | "countChar" + | "indexOfAny" + | "lastIndexOfAny" + | "memmem" + | "memrmem", + haystack: Uint8Array, + arg: number | Uint8Array, +) => number = $newCppFunction("highway_strings_testing.cpp", "Bun__highwayStringsForTesting", 3); + export const SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor"); export const patchInternals = { diff --git a/src/js_parser/lib.rs b/src/js_parser/lib.rs index b3d03048fc56..6668dce79446 100644 --- a/src/js_parser/lib.rs +++ b/src/js_parser/lib.rs @@ -528,9 +528,9 @@ pub mod defines { if let Some(last_dot) = strings::last_index_of_char(key, b'.') { let tail = &key[last_dot + 1..key.len()]; let remainder = &key[0..last_dot]; - let count = remainder.iter().filter(|&&b| b == b'.').count() + 1; + let count = strings::count_char(remainder, b'.') + 1; let mut parts: Vec> = Vec::with_capacity(count + 1); - for split in remainder.split(|b| *b == b'.') { + for split in strings::split(remainder, b".") { parts.push(Box::from(split)); } parts.push(Box::from(tail)); diff --git a/src/js_parser/parser.rs b/src/js_parser/parser.rs index 46ce914d860b..e2d35c0b52ae 100644 --- a/src/js_parser/parser.rs +++ b/src/js_parser/parser.rs @@ -861,8 +861,8 @@ impl<'a> JSXTag<'a> { // Certain identifiers are strings //
= b'a' && name[0] <= b'z') { return Ok(JSXTag { diff --git a/src/jsc/bindings/JSBuffer.cpp b/src/jsc/bindings/JSBuffer.cpp index 6c44fd1949a4..826523833ed2 100644 --- a/src/jsc/bindings/JSBuffer.cpp +++ b/src/jsc/bindings/JSBuffer.cpp @@ -90,6 +90,7 @@ extern "C" size_t highway_memrmem(const uint8_t* haystack, size_t haystack_len, extern "C" size_t highway_memmem16(const uint16_t* haystack, size_t haystack_len, const uint16_t* needle, size_t needle_len); extern "C" size_t highway_memrmem16(const uint16_t* haystack, size_t haystack_len, const uint16_t* needle, size_t needle_len); extern "C" size_t highway_index_of_char(const uint8_t* haystack, size_t haystack_len, uint8_t needle); +extern "C" size_t highway_last_index_of_char(const uint8_t* haystack, size_t haystack_len, uint8_t needle); static constexpr size_t kHighwayNotFound = ~static_cast(0); // export fn Bun__inspect_singleline(globalThis: *JSGlobalObject, value: JSValue) bun.String @@ -1604,10 +1605,6 @@ static int64_t lastIndexOf(const uint8_t* thisPtr, int64_t thisLength, const uin { int64_t haystackLen = std::min(thisLength, byteOffset + valueLength); if (haystackLen < valueLength) return -1; - if (valueLength == 1) { - auto span = std::span(thisPtr, static_cast(haystackLen)); - return WTF::reverseFind(span, valuePtr[0]); - } size_t result = highway_memrmem(thisPtr, static_cast(haystackLen), valuePtr, static_cast(valueLength)); if (result == kHighwayNotFound) return -1; @@ -1663,15 +1660,15 @@ static int64_t indexOfNumber(JSC::JSGlobalObject* lexicalGlobalObject, bool last if (!computeIndexOfRange(byteLength, byteOffsetD, endD, 1, !last, false, &byteOffset, &searchEnd, &immediateResult)) return immediateResult; - auto span = std::span(typedVector, searchEnd); if (last) { - span = span.subspan(0, byteOffset + 1); - return WTF::reverseFind(span, byteValue); - } - span = span.subspan(byteOffset); - auto result = WTF::find(span, byteValue); - if (result == WTF::notFound) return -1; - return result + byteOffset; + size_t len = byteOffset + 1; + size_t result = highway_last_index_of_char(typedVector, len, byteValue); + return result == len ? -1 : static_cast(result); + } + size_t len = searchEnd - byteOffset; + size_t result = highway_index_of_char(typedVector + byteOffset, len, byteValue); + if (result == len) return -1; + return static_cast(result + byteOffset); } // ucs2 and utf16le name the same encoding (the parser normalizes every alias diff --git a/src/jsc/bindings/highway_strings.cpp b/src/jsc/bindings/highway_strings.cpp index bbd77b4effb5..78485db85533 100644 --- a/src/jsc/bindings/highway_strings.cpp +++ b/src/jsc/bindings/highway_strings.cpp @@ -226,6 +226,85 @@ size_t IndexOfCharImpl(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len return (pos < haystack_len) ? pos : haystack_len; } +// Index of the last `needle` in `haystack`, or haystack_len if absent. +size_t LastIndexOfCharImpl(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, + uint8_t needle) +{ + D8 d; + const size_t N = hn::Lanes(d); + const auto broadcasted = hn::Set(d, needle); + + size_t i = haystack_len; + // Two vectors per iteration: one mask→scalar transfer (the expensive part on + // NEON) per 2N bytes instead of per N. + while (i >= 2 * N) { + i -= 2 * N; + const auto eq_hi = hn::Eq(broadcasted, hn::LoadU(d, haystack + i + N)); + const auto eq_lo = hn::Eq(broadcasted, hn::LoadU(d, haystack + i)); + if (HWY_UNLIKELY(!hn::AllFalse(d, hn::Or(eq_hi, eq_lo)))) { + const intptr_t hi = hn::FindLastTrue(d, eq_hi); + if (hi >= 0) return i + N + static_cast(hi); + return i + hn::FindKnownLastTrue(d, eq_lo); + } + } + if (i >= N) { + i -= N; + const intptr_t pos = hn::FindLastTrue(d, hn::Eq(broadcasted, hn::LoadU(d, haystack + i))); + if (pos >= 0) return i + static_cast(pos); + } + // Remaining prefix [0, i); fewer than N bytes. + while (i-- > 0) { + if (haystack[i] == needle) return i; + } + return haystack_len; +} + +// Index of the first byte that is NOT `value`, or haystack_len if every byte is `value`. +size_t IndexOfNotCharImpl(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, + uint8_t value) +{ + D8 d; + const size_t N = hn::Lanes(d); + const auto broadcasted = hn::Set(d, value); + + size_t i = 0; + if (haystack_len >= N) { + for (; i <= haystack_len - N; i += N) { + const intptr_t pos = hn::FindFirstTrue(d, hn::Ne(broadcasted, hn::LoadU(d, haystack + i))); + if (pos >= 0) return i + static_cast(pos); + } + } + for (; i < haystack_len; ++i) { + if (haystack[i] != value) return i; + } + return haystack_len; +} + +size_t CountCharImpl(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, uint8_t needle) +{ + D8 d; + const hn::Repartition d64; + const size_t N = hn::Lanes(d); + const auto broadcasted = hn::Set(d, needle); + + size_t count = 0; + size_t i = 0; + while (haystack_len - i >= N) { + // Per-lane u8 counters: an Eq lane is 0xFF (-1), so subtracting the mask + // vector adds 1 per match. Flush every <=255 vectors so no lane overflows. + const size_t block_end = i + HWY_MIN((haystack_len - i) / N, size_t { 255 }) * N; + auto acc = hn::Zero(d); + for (; i < block_end; i += N) { + acc = hn::Sub(acc, hn::VecFromMask(d, hn::Eq(broadcasted, hn::LoadU(d, haystack + i)))); + } + count += static_cast(hn::ReduceSum(d64, hn::SumsOf8(acc))); + } + for (; i < haystack_len; ++i) { + count += haystack[i] == needle ? 1 : 0; + } + return count; +} + // --- Implementation Details --- size_t IndexOfAnyCharImpl(const uint8_t* HWY_RESTRICT text, size_t text_len, const uint8_t* HWY_RESTRICT chars, size_t chars_len) @@ -322,6 +401,53 @@ size_t IndexOfAnyCharImpl(const uint8_t* HWY_RESTRICT text, size_t text_len, con return text_len; } +// Reverse of IndexOfAnyCharImpl: index of the last byte in `text` that is any of +// `chars[0..chars_len]` (chars_len in 2..=16), or text_len if none are present. +size_t LastIndexOfAnyCharImpl(const uint8_t* HWY_RESTRICT text, size_t text_len, const uint8_t* HWY_RESTRICT chars, size_t chars_len) +{ + ASSERT(chars_len >= 2 && chars_len <= 16); + D8 d; + const size_t N = hn::Lanes(d); + // Callers split larger sets; clamp so a bad length can never overrun char_vecs. + chars_len = std::min(chars_len, size_t { 16 }); + + size_t i = text_len; +#if !HWY_HAVE_SCALABLE && !HWY_TARGET_IS_SVE + // Preload the set into registers (same scheme as IndexOfAnyCharImpl). + hn::Vec char_vecs[16]; + for (size_t c = 0; c < chars_len; ++c) { + char_vecs[c] = hn::Set(d, chars[c]); + } + while (i >= N) { + i -= N; + const auto text_vec = hn::LoadU(d, text + i); + auto found_mask = hn::Or(hn::Eq(text_vec, char_vecs[0]), hn::Eq(text_vec, char_vecs[1])); + for (size_t c = 2; c < chars_len; ++c) { + found_mask = hn::Or(found_mask, hn::Eq(text_vec, char_vecs[c])); + } +#else + // SVE vectors are sizeless and cannot be stored in arrays; broadcast per use. + while (i >= N) { + i -= N; + const auto text_vec = hn::LoadU(d, text + i); + auto found_mask = hn::Or(hn::Eq(text_vec, hn::Set(d, chars[0])), hn::Eq(text_vec, hn::Set(d, chars[1]))); + for (size_t c = 2; c < chars_len; ++c) { + found_mask = hn::Or(found_mask, hn::Eq(text_vec, hn::Set(d, chars[c]))); + } +#endif + const intptr_t pos = hn::FindLastTrue(d, found_mask); + if (pos >= 0) return i + static_cast(pos); + } + // Remaining prefix [0, i); fewer than N bytes. + while (i-- > 0) { + const uint8_t text_char = text[i]; + for (size_t c = 0; c < chars_len; ++c) { + if (text_char == chars[c]) return i; + } + } + return text_len; +} + // Index of the first byte that HTML-escapes: one of " & ' < >. // Returns text_len if none are present. size_t IndexOfHTMLEscapeChar8Impl(const uint8_t* HWY_RESTRICT text, size_t text_len) @@ -979,10 +1105,8 @@ size_t MemRMemImpl(const uint8_t* haystack, size_t haystack_len, if (HWY_UNLIKELY(needle_len == 0)) return haystack_len; if (HWY_UNLIKELY(haystack_len < needle_len)) return kNotFound; if (HWY_UNLIKELY(needle_len == 1)) { - for (size_t i = haystack_len; i-- > 0;) { - if (haystack[i] == needle[0]) return i; - } - return kNotFound; + size_t index = LastIndexOfCharImpl(haystack, haystack_len, needle[0]); + return index != haystack_len ? index : kNotFound; } size_t a, b; @@ -2066,6 +2190,7 @@ namespace bun { HWY_EXPORT(ContainsNewlineOrNonASCIIOrQuoteImpl); HWY_EXPORT(CopyAsciiPrefixImpl); HWY_EXPORT(CopyU16ToU8Impl); +HWY_EXPORT(CountCharImpl); HWY_EXPORT(CountPrintableAscii16Impl); HWY_EXPORT(DecodeHex16Impl); HWY_EXPORT(DecodeHex8Impl); @@ -2089,7 +2214,10 @@ HWY_EXPORT(IndexOfNeedsEscapeForJavaScriptStringImplBacktick); HWY_EXPORT(IndexOfNeedsEscapeForJavaScriptStringImplQuote); HWY_EXPORT(IndexOfNewlineOrNonASCIIImpl); HWY_EXPORT(IndexOfNewlineOrNonASCIIOrHashOrAtImpl); +HWY_EXPORT(IndexOfNotCharImpl); HWY_EXPORT(IndexOfSpaceOrNewlineOrNonASCIIImpl); +HWY_EXPORT(LastIndexOfAnyCharImpl); +HWY_EXPORT(LastIndexOfCharImpl); HWY_EXPORT(LowerAscii16Impl); HWY_EXPORT(LowerAsciiImpl); HWY_EXPORT(MemMemImpl); @@ -2181,12 +2309,35 @@ size_t highway_index_of_any_char(const uint8_t* HWY_RESTRICT text, size_t text_l return HWY_DYNAMIC_DISPATCH(IndexOfAnyCharImpl)(text, text_len, chars, chars_len); } +size_t highway_last_index_of_any_char(const uint8_t* HWY_RESTRICT text, size_t text_len, const uint8_t* HWY_RESTRICT chars, size_t chars_len) +{ + return HWY_DYNAMIC_DISPATCH(LastIndexOfAnyCharImpl)(text, text_len, chars, chars_len); +} + size_t highway_index_of_char(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, uint8_t needle) { return HWY_DYNAMIC_DISPATCH(IndexOfCharImpl)(haystack, haystack_len, needle); } +size_t highway_last_index_of_char(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, + uint8_t needle) +{ + return HWY_DYNAMIC_DISPATCH(LastIndexOfCharImpl)(haystack, haystack_len, needle); +} + +size_t highway_index_of_not_char(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, + uint8_t value) +{ + return HWY_DYNAMIC_DISPATCH(IndexOfNotCharImpl)(haystack, haystack_len, value); +} + +size_t highway_count_char(const uint8_t* HWY_RESTRICT haystack, size_t haystack_len, + uint8_t needle) +{ + return HWY_DYNAMIC_DISPATCH(CountCharImpl)(haystack, haystack_len, needle); +} + size_t highway_index_of_escape_char8(const uint8_t* HWY_RESTRICT input, size_t len) { return HWY_DYNAMIC_DISPATCH(IndexOfEscapeChar8Impl)(input, len); diff --git a/src/jsc/bindings/highway_strings_testing.cpp b/src/jsc/bindings/highway_strings_testing.cpp new file mode 100644 index 000000000000..42dfc50a8526 --- /dev/null +++ b/src/jsc/bindings/highway_strings_testing.cpp @@ -0,0 +1,96 @@ +// Testing-only JS binding for the byte-search kernels in highway_strings.cpp. +// +// Kept in its own TU so the Highway kernels stay free of JSC/WebKit headers +// (same reason as xxhash3_testing.cpp). This wrapper just forwards to the C +// entry points and returns their raw result. + +#include "root.h" + +#include "highway_strings_testing.h" + +#include "ZigGlobalObject.h" +#include +#include +#include + +extern "C" size_t highway_index_of_char(const uint8_t* haystack, size_t haystack_len, uint8_t needle); +extern "C" size_t highway_last_index_of_char(const uint8_t* haystack, size_t haystack_len, uint8_t needle); +extern "C" size_t highway_index_of_not_char(const uint8_t* haystack, size_t haystack_len, uint8_t value); +extern "C" size_t highway_count_char(const uint8_t* haystack, size_t haystack_len, uint8_t needle); +extern "C" size_t highway_index_of_any_char(const uint8_t* text, size_t text_len, const uint8_t* chars, size_t chars_len); +extern "C" size_t highway_last_index_of_any_char(const uint8_t* text, size_t text_len, const uint8_t* chars, size_t chars_len); +extern "C" void* highway_memmem(const uint8_t* haystack, size_t haystack_len, const uint8_t* needle, size_t needle_len); +extern "C" size_t highway_memrmem(const uint8_t* haystack, size_t haystack_len, const uint8_t* needle, size_t needle_len); + +namespace Bun { + +// (op: string, haystack: Uint8Array, arg: number | Uint8Array) -> number +// +// `arg` is the byte for the *Char ops and the needle / char-set view for the +// others. Returns exactly what the kernel returns: an index (with +// `haystack.length` meaning "not found" for the index_of family), a count, or +// for memmem/memrmem the match offset with -1 for "not found". +BUN_DEFINE_HOST_FUNCTION(Bun__highwayStringsForTesting, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + auto op = callFrame->argument(0).toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); + + auto* view = dynamicDowncast(callFrame->argument(1)); + if (!view || view->isDetached()) { + throwTypeError(globalObject, scope, "haystack must be an attached ArrayBufferView"_s); + return {}; + } + const uint8_t* haystack = static_cast(view->vector()); + size_t len = view->byteLength(); + + JSC::JSValue arg = callFrame->argument(2); + uint8_t byte = 0; + const uint8_t* needle = nullptr; + size_t needle_len = 0; + if (arg.isNumber()) { + byte = static_cast(arg.toUInt32(globalObject)); + RETURN_IF_EXCEPTION(scope, {}); + } else if (auto* needleView = dynamicDowncast(arg); needleView && !needleView->isDetached()) { + needle = static_cast(needleView->vector()); + needle_len = needleView->byteLength(); + } else { + throwTypeError(globalObject, scope, "arg must be a byte (number) or an attached ArrayBufferView"_s); + return {}; + } + + size_t result; + if (op == "indexOfChar"_s) { + result = highway_index_of_char(haystack, len, byte); + } else if (op == "lastIndexOfChar"_s) { + result = highway_last_index_of_char(haystack, len, byte); + } else if (op == "indexOfNotChar"_s) { + result = highway_index_of_not_char(haystack, len, byte); + } else if (op == "countChar"_s) { + result = highway_count_char(haystack, len, byte); + } else if (op == "indexOfAny"_s || op == "lastIndexOfAny"_s) { + if (needle_len < 2 || needle_len > 16) { + throwRangeError(globalObject, scope, "char set must have 2..=16 bytes"_s); + return {}; + } + result = op == "indexOfAny"_s + ? highway_index_of_any_char(haystack, len, needle, needle_len) + : highway_last_index_of_any_char(haystack, len, needle, needle_len); + } else if (op == "memmem"_s) { + if (!needle_len) + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(0))); + void* p = highway_memmem(haystack, len, needle, needle_len); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(p ? static_cast(static_cast(p) - haystack) : -1.0))); + } else if (op == "memrmem"_s) { + size_t r = highway_memrmem(haystack, len, needle, needle_len); + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(r == static_cast(-1) ? -1.0 : static_cast(r)))); + } else { + throwTypeError(globalObject, scope, "unknown op"_s); + return {}; + } + RELEASE_AND_RETURN(scope, JSC::JSValue::encode(JSC::jsNumber(static_cast(result)))); +} + +} // namespace Bun diff --git a/src/jsc/bindings/highway_strings_testing.h b/src/jsc/bindings/highway_strings_testing.h new file mode 100644 index 000000000000..ca68b349dc03 --- /dev/null +++ b/src/jsc/bindings/highway_strings_testing.h @@ -0,0 +1,14 @@ +#pragma once + +#include "root.h" + +namespace Bun { + +// Testing-only entry point for the runtime-dispatched byte-search kernels in +// highway_strings.cpp, exposed via `bun:internal-for-testing` so a test can +// drive each kernel directly across lengths/alignments instead of only through +// whichever runtime path happens to call it. Signature: +// (op: string, haystack: Uint8Array, arg: number | Uint8Array) -> number +BUN_DECLARE_HOST_FUNCTION(Bun__highwayStringsForTesting); + +} // namespace Bun diff --git a/src/jsc/btjs.rs b/src/jsc/btjs.rs index 76c90904e355..068f1d64d877 100644 --- a/src/jsc/btjs.rs +++ b/src/jsc/btjs.rs @@ -5,6 +5,8 @@ use core::ffi::c_char; #[cfg(debug_assertions)] use crate::{CallFrame, VirtualMachineRef as VirtualMachine}; #[cfg(debug_assertions)] +use bun_core::strings; +#[cfg(debug_assertions)] use bun_crash_handler::Error; // `SelfInfo`, `StackIterator`, plus the symbol-lookup helpers. The @@ -398,7 +400,7 @@ fn print_line_from_file_any_os( let mut next_line: usize = 1; while next_line != source_location.line as usize { let slice = &buf[current_line_start..amt_read]; - if let Some(pos) = slice.iter().position(|&b| b == b'\n') { + if let Some(pos) = strings::index_of_char_usize(slice, b'\n') { next_line += 1; if pos == slice.len() - 1 { amt_read = f.read(&mut buf[..]).map_err(Into::::into)?; @@ -416,7 +418,7 @@ fn print_line_from_file_any_os( break 'seek current_line_start; }; let slice = &mut buf[line_start..amt_read]; - if let Some(pos) = slice.iter().position(|&b| b == b'\n') { + if let Some(pos) = strings::index_of_char_usize(slice, b'\n') { let line = &mut slice[0..pos + 1]; replace_scalar(line, b'\t', b' '); out_stream.extend_from_slice(line); @@ -427,7 +429,7 @@ fn print_line_from_file_any_os( out_stream.extend_from_slice(slice); while amt_read == buf.len() { amt_read = f.read(&mut buf[..]).map_err(Into::::into)?; - if let Some(pos) = buf[0..amt_read].iter().position(|&b| b == b'\n') { + if let Some(pos) = strings::index_of_char_usize(&buf[0..amt_read], b'\n') { let line = &mut buf[0..pos + 1]; replace_scalar(line, b'\t', b' '); out_stream.extend_from_slice(line); diff --git a/src/jsc/resolver_jsc.rs b/src/jsc/resolver_jsc.rs index 67416c2436e9..3318f9aaf687 100644 --- a/src/jsc/resolver_jsc.rs +++ b/src/jsc/resolver_jsc.rs @@ -4,7 +4,7 @@ use bstr::BStr; use crate::{CallFrame, JSGlobalObject, JSValue, JsResult}; -use bun_core::{OwnedString, String as BunString}; +use bun_core::{OwnedString, String as BunString, strings}; use bun_paths::resolve_path; use bun_paths::{Platform, SEP, SEP_STR}; @@ -77,7 +77,7 @@ extern "C" fn node_module_paths_js_value( let mut index: Option = Some(suffix.len()); while let Some(end) = index { let part: &[u8]; - match suffix[..end].iter().rposition(|&b| b == SEP) { + match strings::last_index_of_char(&suffix[..end], SEP) { Some(delim) => { part = &suffix[delim + 1..end]; index = Some(delim); diff --git a/src/libarchive/lib.rs b/src/libarchive/lib.rs index 7169b2f728f4..5fbea29c9067 100644 --- a/src/libarchive/lib.rs +++ b/src/libarchive/lib.rs @@ -1007,7 +1007,7 @@ fn is_symlink_target_safe( } let mut seen_named_component = false; - for component in link_target_bytes.split(|c| *c == b'/') { + for component in strings::split(link_target_bytes, b"/") { match component { b"" | b"." => {} b".." => { @@ -1288,7 +1288,7 @@ impl Archiver { if remaining.is_empty() { continue 'loop_; } - match remaining.iter().position(|&b| b == SEP) { + match strings::index_of_char_usize(remaining, SEP) { Some(i) => remaining = &remaining[i..], None => remaining = &remaining[remaining.len()..], } @@ -1345,7 +1345,7 @@ impl Archiver { break 'brk __pathname; } - let index = __pathname.iter().position(|&b| b == SEP).unwrap(); + let index = strings::index_of_char_usize(__pathname, SEP).unwrap(); break 'brk &__pathname[..index]; }; let mut temp_buf = [0u8; 1024]; @@ -1481,7 +1481,7 @@ impl Archiver { if remaining.is_empty() { continue 'loop_; } - match remaining.iter().position(|&c| c == sep) { + match strings::index_of_scalar(remaining, sep) { Some(j) => remaining = &remaining[j..], None => remaining = &remaining[remaining.len()..], } diff --git a/src/lsquic_sys/Cargo.toml b/src/lsquic_sys/Cargo.toml index 001758d9b53d..b4db5f42f197 100644 --- a/src/lsquic_sys/Cargo.toml +++ b/src/lsquic_sys/Cargo.toml @@ -8,3 +8,6 @@ path = "lib.rs" [lints] workspace = true + +[dependencies] +bun_core.workspace = true diff --git a/src/lsquic_sys/lib.rs b/src/lsquic_sys/lib.rs index 655de5362996..11894e7021cb 100644 --- a/src/lsquic_sys/lib.rs +++ b/src/lsquic_sys/lib.rs @@ -3,6 +3,8 @@ use core::ffi::{c_char, c_int, c_uint, c_ulong, c_void}; +use bun_core::strings; + #[repr(C)] pub struct lsquic_engine { _opaque: [u8; 0], @@ -816,7 +818,7 @@ impl HeaderSet { // SAFETY: the shim guarantees `p[..len]` is valid until free. let bytes = unsafe { core::slice::from_raw_parts(p.cast::(), len) }; let bytes = bytes.strip_suffix(&[0u8][..]).unwrap_or(bytes); - bytes.split(|&b| b == 0).map(<[u8]>::to_vec).collect() + strings::split(bytes, b"\0").map(<[u8]>::to_vec).collect() } } @@ -895,7 +897,7 @@ pub const MAX_CID_LEN: usize = 20; impl NqTransportParams { fn cid_str(buf: &[u8; 2 * MAX_CID_LEN + 1]) -> &str { - let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let nul = strings::index_of_char_usize(buf, 0).unwrap_or(buf.len()); core::str::from_utf8(&buf[..nul]).unwrap_or("") } pub fn initial_scid_str(&self) -> &str { diff --git a/src/md/ansi_renderer.rs b/src/md/ansi_renderer.rs index 142dc1822a4b..5d5622419dec 100644 --- a/src/md/ansi_renderer.rs +++ b/src/md/ansi_renderer.rs @@ -2170,7 +2170,7 @@ impl<'s> CellAnsiState<'s> { // Stateful parse: 38/48 consume 2 extra params for `5;N` or // 4 extra for `2;R;G;B`. Snapshot the whole seq for fg/bg // since we don't need to recompute it — just replay it. - let mut iter = params.split(|b| *b == b';'); + let mut iter = strings::split(params, b";"); while let Some(p) = iter.next() { let n = match bun_core::fmt::parse_int::(p, 10).ok() { Some(n) => n, @@ -2445,7 +2445,7 @@ pub fn detect_light_background() -> bool { // (bright white) are light terminal backgrounds. Bright colors // 9-14 are high-intensity foreground codes, not light backgrounds. let mut last: &[u8] = b""; - for part in value.split(|b| *b == b';') { + for part in strings::split(value, b";") { last = part; } if !last.is_empty() { diff --git a/src/options_types/jsx.rs b/src/options_types/jsx.rs index 646795562166..1e955f543209 100644 --- a/src/options_types/jsx.rs +++ b/src/options_types/jsx.rs @@ -268,7 +268,7 @@ impl Pragma { let mut needs_alloc = false; let mut current_i: usize = 0; - for str in new.split(|b| *b == b'.') { + for str in strings::split(new, b".") { if str.is_empty() { continue; } @@ -286,7 +286,7 @@ impl Pragma { } let mut out: Vec> = Vec::with_capacity(count); - for str in new.split(|b| *b == b'.') { + for str in strings::split(new, b".") { if str.is_empty() { continue; } diff --git a/src/parsers/json.rs b/src/parsers/json.rs index 2fe9d5a7d2db..3996a89b9ef7 100644 --- a/src/parsers/json.rs +++ b/src/parsers/json.rs @@ -1934,7 +1934,7 @@ mod tests { let full = probe(doc, Which::Utf8); let immutable = probe(doc, Which::Immutable); assert_eq!(full, immutable); - let name_key_offset = doc.windows(6).position(|w| w == b"\"name\"").unwrap(); + let name_key_offset = bun_core::strings::index_of(doc, b"\"name\"").unwrap(); assert!( full.starts_with(&format!("name@{name_key_offset}=\"pkg\"\n")), "{full:?}" diff --git a/src/parsers/json_stage2.rs b/src/parsers/json_stage2.rs index bcdbdd3c70f1..29b15e5dcaaf 100644 --- a/src/parsers/json_stage2.rs +++ b/src/parsers/json_stage2.rs @@ -333,18 +333,13 @@ impl<'a, 's, 'i> Parser<'a, 's, 'i> { let b = self.contents[q]; let run = self.run(j); if (b >= 0x80 || b == 0x0B || b == 0x0C) && self.rest_is_ws_cold(run) { - if self.contents[q..hi] - .iter() - .any(|&b| matches!(b, b'\n' | b'\r')) - { + if strings::contains_any(&self.contents[q..hi], b"\n\r") { return true; } hi = q; continue; } - return self.contents[q + 1..hi] - .iter() - .any(|&b| matches!(b, b'\n' | b'\r')); + return strings::contains_any(&self.contents[q + 1..hi], b"\n\r"); } false } @@ -603,12 +598,10 @@ impl<'a, 's, 'i> Parser<'a, 's, 'i> { b'/' => match rest.get(i + 1) { Some(b'/') => { i += 2; - while i < rest.len() && !matches!(rest[i], b'\n' | b'\r') { - i += 1; - } + i += strings::index_of_any(&rest[i..], b"\n\r").unwrap_or(rest.len() - i); } Some(b'*') => { - let Some(close) = rest[i + 2..].windows(2).position(|w| w == b"*/") else { + let Some(close) = strings::index_of(&rest[i + 2..], b"*/") else { return false; }; i += 2 + close + 2; diff --git a/src/parsers/native_test_shims.rs b/src/parsers/native_test_shims.rs index cfb77002e966..e5c77c476541 100644 --- a/src/parsers/native_test_shims.rs +++ b/src/parsers/native_test_shims.rs @@ -38,3 +38,99 @@ unsafe extern "C" fn highway_index_of_any_char( }; t.iter().position(|c| cs.contains(c)).unwrap_or(text_len) } + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_memmem( + haystack: *const u8, + haystack_len: usize, + needle: *const u8, + needle_len: usize, +) -> *const u8 { + let (h, n) = unsafe { + ( + core::slice::from_raw_parts(haystack, haystack_len), + core::slice::from_raw_parts(needle, needle_len), + ) + }; + if n.is_empty() { + return haystack; + } + if h.len() < n.len() { + return core::ptr::null(); + } + match (0..=h.len() - n.len()).find(|&i| h[i..i + n.len()] == *n) { + Some(i) => unsafe { haystack.add(i) }, + None => core::ptr::null(), + } +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_last_index_of_char( + haystack: *const u8, + haystack_len: usize, + needle: u8, +) -> usize { + let h = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + h.iter().rposition(|&c| c == needle).unwrap_or(haystack_len) +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_index_of_not_char( + haystack: *const u8, + haystack_len: usize, + value: u8, +) -> usize { + let h = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + h.iter().position(|&c| c != value).unwrap_or(haystack_len) +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_count_char( + haystack: *const u8, + haystack_len: usize, + needle: u8, +) -> usize { + let h = unsafe { core::slice::from_raw_parts(haystack, haystack_len) }; + h.iter().filter(|&&c| c == needle).count() +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_last_index_of_any_char( + text: *const u8, + text_len: usize, + chars: *const u8, + chars_len: usize, +) -> usize { + let (t, cs) = unsafe { + ( + core::slice::from_raw_parts(text, text_len), + core::slice::from_raw_parts(chars, chars_len), + ) + }; + t.iter().rposition(|c| cs.contains(c)).unwrap_or(text_len) +} + +#[unsafe(no_mangle)] +unsafe extern "C" fn highway_memrmem( + haystack: *const u8, + haystack_len: usize, + needle: *const u8, + needle_len: usize, +) -> usize { + let (h, n) = unsafe { + ( + core::slice::from_raw_parts(haystack, haystack_len), + core::slice::from_raw_parts(needle, needle_len), + ) + }; + if n.is_empty() { + return h.len(); + } + if h.len() < n.len() { + return usize::MAX; + } + (0..=h.len() - n.len()) + .rev() + .find(|&i| h[i..i + n.len()] == *n) + .unwrap_or(usize::MAX) +} diff --git a/src/patch/lib.rs b/src/patch/lib.rs index cff2f6694d93..5b076717975e 100644 --- a/src/patch/lib.rs +++ b/src/patch/lib.rs @@ -299,7 +299,7 @@ fn apply_patch(patch: &FilePatch<'_>, patch_dir: Fd, state: &mut ApplyState) -> let file_line_count: usize; let lines_count: usize = { let mut count: usize = 0; - for _ in filebuf.split(|b| *b == b'\n') { + for _ in strings::split(&filebuf, b"\n") { count += 1; } file_line_count = count; @@ -333,7 +333,7 @@ fn apply_patch(patch: &FilePatch<'_>, patch_dir: Fd, state: &mut ApplyState) -> let mut lines: Vec<&[u8]> = Vec::with_capacity(lines_count); { let mut i: usize = 0; - for line in filebuf.split(|b| *b == b'\n') { + for line in strings::split(&filebuf, b"\n") { lines.push(line); i += 1; } @@ -1038,11 +1038,9 @@ fn parse_file_mode(mode: &[u8]) -> Option { fn is_safe_patch_path(path: &[u8]) -> bool { !path.is_empty() - && !path.contains(&0) + && !strings::contains_char(path, 0) && !paths::is_absolute_loose(path) - && !path - .split(|&c| c == b'/' || c == b'\\') - .any(|part| part == b"..") + && !strings::split_any(path, b"/\\").any(|part| part == b"..") } // ────────────────────────────────────────────────────────────────────────── @@ -1171,7 +1169,7 @@ impl<'a> PatchLinesParser<'a> { let end = 'brk: { // Peek at the last segment after the final '\n'. let mut prev: usize = file_.len(); - let last_nl = file_.iter().rposition(|b| *b == b'\n'); + let last_nl = strings::last_index_of_char(file_, b'\n'); let last_line = match last_nl { Some(i) => &file_[i + 1..], None => file_, diff --git a/src/paths/lib.rs b/src/paths/lib.rs index 0a18dc14d838..27218cb3dd16 100644 --- a/src/paths/lib.rs +++ b/src/paths/lib.rs @@ -267,8 +267,7 @@ pub fn join_sep_maybe_z(parts: &[&[u8]]) -> Box<[u8]> { /// `dirname` semantics (Option, trailing-slash handling, root preservation) /// use `bun_core::dirname`. pub fn dirname_simple(p: &[u8]) -> &[u8] { - p.iter() - .rposition(|&c| c == b'/' || (cfg!(windows) && c == b'\\')) + crate::resolve_path::last_index_of_sep(p) .map(|i| &p[..i]) .unwrap_or(b"") } @@ -282,7 +281,7 @@ pub use bun_core::strings::{PathByte, basename, basename_posix, basename_windows /// and basenames whose only `.` is at index 0 report no extension. pub fn extension(p: &[u8]) -> &[u8] { let filename = basename(p); - match filename.iter().rposition(|&c| c == b'.') { + match strings::last_index_of_char(filename, b'.') { Some(dot) if dot > 0 => &filename[dot..], _ => &p[p.len()..], } @@ -293,7 +292,7 @@ pub fn extension(p: &[u8]) -> &[u8] { /// leading dot (`.gitignore` → `.gitignore`). pub fn stem(p: &[u8]) -> &[u8] { let filename = basename(p); - match filename.iter().rposition(|&c| c == b'.') { + match strings::last_index_of_char(filename, b'.') { Some(0) => p, Some(dot) => &filename[..dot], None => filename, @@ -644,7 +643,7 @@ pub mod fs { pub fn find_extname(path: &[u8]) -> &[u8] { let start = last_index_of_sep(path).map(|i| i + 1).unwrap_or(0); let base = &path[start..]; - if let Some(dot) = base.iter().rposition(|&c| c == b'.') { + if let Some(dot) = crate::strings::last_index_of_char(base, b'.') { if dot > 0 { return &base[dot..]; } @@ -668,7 +667,7 @@ pub mod fs { // "/index" -> "index" return PathName::init(self.dir).base; } - debug_assert!(!self.base.contains(&b'/')); + debug_assert!(!crate::strings::contains_char(self.base, b'/')); // /bar/foo.js -> foo self.base } @@ -739,7 +738,7 @@ pub mod fs { } // Strip off the extension - if let Some(dot) = base.iter().rposition(|&c| c == b'.') { + if let Some(dot) = crate::strings::last_index_of_char(base, b'.') { ext = &base[dot..]; base = &base[0..dot]; } else { @@ -913,7 +912,7 @@ pub mod fs { #[inline] pub fn assert_pretty_is_valid(&self) { #[cfg(all(windows, debug_assertions))] - if self.pretty.contains(&b'\\') { + if crate::strings::contains_char(self.pretty, b'\\') { panic!( "Expected pretty file path to have only forward slashes, got '{}'", bstr::BStr::new(self.pretty) @@ -967,8 +966,7 @@ pub mod fs { /// Checks for `node_modules` in the /// parsed dir component (`name.dir`, NOT `text`). pub fn is_node_module(&self) -> bool { - use bstr::ByteSlice; - self.name().dir.rfind(crate::NODE_MODULES_NEEDLE).is_some() + crate::strings::contains(self.name().dir, crate::NODE_MODULES_NEEDLE) } /// Key used to identify this path in the incremental graph: the real diff --git a/src/paths/resolve_path.rs b/src/paths/resolve_path.rs index ab0d02fcf2bb..3f56319539ab 100644 --- a/src/paths/resolve_path.rs +++ b/src/paths/resolve_path.rs @@ -765,8 +765,6 @@ fn windows_volume_name_len_t(path: &[T]) -> (usize, usize) { && !Platform::Windows.is_separator_t::(path[2]) && path[2] != T::from_u8(b'.') { - // PERF: the single generic helper checks elementwise (no T==u8 SIMD - // branch) — profile if hot. if let Some(idx) = strings::index_of_any_t::(&path[3..], T::lit(b"/\\")) { // TODO: handle input "//abc//def" should be picked up as a unc path if path.len() > idx + 4 && !Platform::Windows.is_separator_t::(path[idx + 4]) { @@ -1903,6 +1901,10 @@ fn last_index_of_separator_windows(slice: &[u8]) -> Option { } fn last_index_of_separator_windows_t(slice: &[T]) -> Option { + if core::mem::size_of::() == 1 { + return strings::last_index_of_any(bun_core::cast_slice::(slice), b"/\\"); + } + // No two-needle reverse kernel for u16. slice.iter().rposition(|&c| is_sep_any_t::(c)) } @@ -1911,7 +1913,7 @@ fn last_index_of_separator_posix(slice: &[u8]) -> Option { } fn last_index_of_separator_posix_t(slice: &[T]) -> Option { - slice.iter().rposition(|&c| c == T::from_u8(SEP_POSIX)) + strings::last_index_of_char_t::(slice, T::from_u8(SEP_POSIX)) } fn last_index_of_separator_loose(slice: &[u8]) -> Option { @@ -2071,7 +2073,7 @@ fn last_index_of_sep_t(path: &[T]) -> Option { } #[cfg(windows)] { - path.iter().rposition(|&c| is_sep_any_t::(c)) + last_index_of_separator_windows_t::(path) } } @@ -2389,10 +2391,8 @@ pub fn dangerously_convert_path_to_windows_in_place(path: &mut [T]) pub fn path_to_posix_buf<'a, T: PathChar>(path: &[T], buf: &'a mut [T]) -> &'a mut [T] { let mut idx: usize = 0; - while let Some(index) = path[idx..] - .iter() - .position(|&c| c == T::from_u8(SEP_WINDOWS)) - .map(|p| p + idx) + while let Some(index) = + strings::index_of_scalar(&path[idx..], T::from_u8(SEP_WINDOWS)).map(|p| p + idx) { buf[idx..index].copy_from_slice(&path[idx..index]); buf[index] = T::from_u8(SEP_POSIX); @@ -2407,10 +2407,7 @@ pub fn platform_to_posix_buf<'a, T: PathChar>(path: &'a [T], buf: &'a mut [T]) - return path; } let mut idx: usize = 0; - while let Some(index) = path[idx..] - .iter() - .position(|&c| c == T::from_u8(SEP)) - .map(|p| p + idx) + while let Some(index) = strings::index_of_scalar(&path[idx..], T::from_u8(SEP)).map(|p| p + idx) { buf[idx..index].copy_from_slice(&path[idx..index]); buf[index] = T::from_u8(b'/'); diff --git a/src/ptr/ref_count.rs b/src/ptr/ref_count.rs index 13a3916c9ef6..7da0ce1a3007 100644 --- a/src/ptr/ref_count.rs +++ b/src/ptr/ref_count.rs @@ -48,8 +48,9 @@ fn dump_stack_hook(trace: Option<&StoredTrace>, ret_addr: usize) { /// subslice so the result stays `&'static str`. /// `"a::b::Foo"` → `"Foo"`. fn type_base_name(name: &'static str) -> &'static str { - let end = name.find('<').unwrap_or(name.len()); - match name[..end].rfind("::") { + let bytes = name.as_bytes(); + let end = bun_core::strings::index_of_char_usize(bytes, b'<').unwrap_or(bytes.len()); + match bun_core::strings::last_index_of(&bytes[..end], b"::") { Some(i) => &name[i + 2..], None => name, } diff --git a/src/react_compiler/lowering/build_hir/expr.rs b/src/react_compiler/lowering/build_hir/expr.rs index 69596394f38b..5f3227ac80fd 100644 --- a/src/react_compiler/lowering/build_hir/expr.rs +++ b/src/react_compiler/lowering/build_hir/expr.rs @@ -1220,7 +1220,7 @@ fn lower_template( let value = match &tmpl.head { E::TemplateContents::Raw(r) => { let raw_bytes = r.slice(); - if raw_bytes.contains(&b'\\') { + if bun_core::strings::contains_char(raw_bytes, b'\\') { builder.record_error(CompilerErrorDetail { category: ErrorCategory::Todo, reason: "(BuildHIR::lowerExpression) Handle tagged template where cooked value is different from raw value".to_string(), diff --git a/src/react_compiler/lowering/build_hir/jsx.rs b/src/react_compiler/lowering/build_hir/jsx.rs index d28fae86882c..3e588e01b911 100644 --- a/src/react_compiler/lowering/build_hir/jsx.rs +++ b/src/react_compiler/lowering/build_hir/jsx.rs @@ -57,10 +57,10 @@ fn lower_jsx_element_name(builder: &mut HirBuilder, tag: &Expr) -> Result { let name = estring_to_store_str(&s); let bytes = name.slice(); - if let Some(idx) = bytes.iter().position(|&b| b == b':') { + if let Some(idx) = bun_core::strings::index_of_char_usize(bytes, b':') { let namespace = &bytes[..idx]; let local = &bytes[idx + 1..]; - if local.contains(&b':') { + if bun_core::strings::contains_char(local, b':') { builder.record_error(CompilerErrorDetail { category: ErrorCategory::Syntax, reason: diff --git a/src/react_compiler/optimization/optimize_for_ssr.rs b/src/react_compiler/optimization/optimize_for_ssr.rs index 4949d76054d8..a5b1cc30584a 100644 --- a/src/react_compiler/optimization/optimize_for_ssr.rs +++ b/src/react_compiler/optimization/optimize_for_ssr.rs @@ -184,7 +184,7 @@ pub(crate) fn optimize_for_ssr(func: &mut HirFunction, env: &Environment) { InstructionValue::JsxExpression { tag, .. } => { if let crate::hir::JsxTag::Builtin(builtin) = tag { // Only optimize non-custom-element builtin tags - if !builtin.name.contains(&b'-') { + if !bun_core::strings::contains_char(&builtin.name, b'-') { let tag_name = builtin.name; // Retain only props that are not known event handlers and not "ref" if let InstructionValue::JsxExpression { props, .. } = &mut instr.value diff --git a/src/react_compiler/program.rs b/src/react_compiler/program.rs index 990f75ec2e80..3182cf9bcee8 100644 --- a/src/react_compiler/program.rs +++ b/src/react_compiler/program.rs @@ -303,28 +303,30 @@ fn is_valid_gating_identifier(s: &[u8]) -> bool { /// `(` for the `@key(value)` form). #[cfg(any(debug_assertions, bun_asan, feature = "fixtures"))] fn split_pragma(pragma: &[u8]) -> impl Iterator)> { - pragma.split(|&b| b == b'@').skip(1).filter_map(|entry| { - let entry = trim_ascii(entry); - if entry.is_empty() { - return None; - } - if let Some(i) = entry.iter().position(|&b| b == b':' || b == b'(') { - let key = &entry[..i]; - let mut val = &entry[i + 1..]; - if entry[i] == b'(' { - if let Some(close) = val.iter().position(|&b| b == b')') { - val = &val[..close]; + bun_core::strings::split(pragma, b"@") + .skip(1) + .filter_map(|entry| { + let entry = trim_ascii(entry); + if entry.is_empty() { + return None; + } + if let Some(i) = bun_core::strings::index_of_any(entry, b":(") { + let key = &entry[..i]; + let mut val = &entry[i + 1..]; + if entry[i] == b'(' { + if let Some(close) = bun_core::strings::index_of_char_usize(val, b')') { + val = &val[..close]; + } } + Some((key, Some(trim_ascii(val)))) + } else { + let key = entry + .iter() + .position(|b| b.is_ascii_whitespace()) + .map_or(entry, |i| &entry[..i]); + Some((key, None)) } - Some((key, Some(trim_ascii(val)))) - } else { - let key = entry - .iter() - .position(|b| b.is_ascii_whitespace()) - .map_or(entry, |i| &entry[..i]); - Some((key, None)) - } - }) + }) } #[cfg(any(debug_assertions, bun_asan, feature = "fixtures"))] @@ -372,7 +374,7 @@ fn pragma_bool(val: Option<&[u8]>) -> Option { #[cfg(any(debug_assertions, bun_asan, feature = "fixtures"))] fn leading_comment_pragma(source: &[u8]) -> Vec { let mut out = Vec::new(); - for line in source.split(|&b| b == b'\n') { + for line in bun_core::strings::split(source, b"\n") { let t = trim_ascii(line); if t.is_empty() { continue; @@ -450,9 +452,9 @@ pub(crate) fn parse_fixture_pragmas(source: &[u8], opts: &mut ReactCompilerOptio let parsed = val.and_then(|v| { let i = bun_core::strings::index_of(v, b"\"source\"")?; let rest = &v[i + b"\"source\"".len()..]; - let open = rest.iter().position(|&b| b == b'"')?; + let open = bun_core::strings::index_of_char_usize(rest, b'"')?; let rest = &rest[open + 1..]; - let close = rest.iter().position(|&b| b == b'"')?; + let close = bun_core::strings::index_of_char_usize(rest, b'"')?; core::str::from_utf8(&rest[..close]).ok().map(str::to_owned) }); if let Some(source) = parsed { @@ -568,7 +570,10 @@ pub(crate) fn parse_fixture_pragmas(source: &[u8], opts: &mut ReactCompilerOptio b"validateBlocklistedImports" => {} b"customMacros" => { if let Some(v) = val.and_then(pragma_string_value) { - let head = v.split('.').next().unwrap_or(&v).to_owned(); + let head = match bun_core::strings::index_of_char_usize(v.as_bytes(), b'.') { + Some(dot) => v[..dot].to_owned(), + None => v, + }; env.custom_macros = Some(vec![head]); } } diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index edc0325bef15..6976d8f56a5f 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -631,7 +631,7 @@ pub mod fs { // If `pretty` contains no backslashes it is already POSIX-style. // Short-circuiting preserves the `pretty.ptr == text.ptr` aliasing // optimisation inside `dupe_alloc` and avoids a fresh FilenameStore alloc. - if !self.pretty.iter().any(|&b| b == b'\\') { + if !bun_core::strings::contains_char(self.pretty, b'\\') { return self.dupe_alloc(alloc); } let mut new = self.clone(); diff --git a/src/resolver/node_fallbacks.rs b/src/resolver/node_fallbacks.rs index f21311bc5843..10957b310e0f 100644 --- a/src/resolver/node_fallbacks.rs +++ b/src/resolver/node_fallbacks.rs @@ -175,10 +175,8 @@ pub fn contents_from_path(path: &[u8]) -> Option<&'static [u8]> { debug_assert!(path.starts_with(IMPORT_PATH)); let module_name = &path[IMPORT_PATH.len()..]; - let module_name = &module_name[..module_name - .iter() - .position(|&b| b == b'/') - .unwrap_or(module_name.len())]; + let module_name = &module_name + [..bun_core::strings::index_of_char_usize(module_name, b'/').unwrap_or(module_name.len())]; if let Some(module) = map().get(module_name) { return Some((module.code)().as_bytes()); diff --git a/src/resolver/package_json.rs b/src/resolver/package_json.rs index 2d98667bd61a..001fee422afb 100644 --- a/src/resolver/package_json.rs +++ b/src/resolver/package_json.rs @@ -1459,7 +1459,7 @@ impl<'a> Package<'a> { }; if strings::starts_with(package.name, b".") - || strings::index_any_comptime(package.name, b"\\%").is_some() + || strings::index_of_any(package.name, b"\\%").is_some() { return None; } @@ -2234,14 +2234,14 @@ impl<'a> ESModule<'a> { } fn find_invalid_segment(path_: &[u8]) -> Option<&[u8]> { - let Some(slash) = strings::index_any_comptime(path_, b"/\\") else { + let Some(slash) = strings::index_of_any(path_, b"/\\") else { return Some(b""); }; let mut path = &path_[slash + 1..]; while !path.is_empty() { let mut segment = path; - if let Some(new_slash) = strings::index_any_comptime(path, b"/\\") { + if let Some(new_slash) = strings::index_of_any(path, b"/\\") { segment = &path[0..new_slash]; path = &path[new_slash + 1..]; } else { @@ -2264,7 +2264,7 @@ fn find_invalid_subpath_segment(path_: &[u8]) -> Option<&[u8]> { let mut path = path_; while !path.is_empty() { let mut segment = path; - if let Some(new_slash) = strings::index_any_comptime(path, b"/\\") { + if let Some(new_slash) = strings::index_of_any(path, b"/\\") { segment = &path[0..new_slash]; path = &path[new_slash + 1..]; } else { diff --git a/src/resolver/tsconfig_json.rs b/src/resolver/tsconfig_json.rs index 86eaca87fcbc..402ee82c0505 100644 --- a/src/resolver/tsconfig_json.rs +++ b/src/resolver/tsconfig_json.rs @@ -712,7 +712,7 @@ impl TSConfigJSON { // foo.bar.baz == 3 // foo.bar.baz.bun == 4 let parts_count = - text.iter().filter(|&&b| b == b'.').count() + usize::from(text[text.len() - 1] != b'.'); + strings::count_char(text, b'.') + usize::from(text[text.len() - 1] != b'.'); let mut parts: Vec> = Vec::with_capacity(parts_count); if parts_count == 1 { @@ -733,7 +733,7 @@ impl TSConfigJSON { return Ok(parts.into_boxed_slice()); } - let iter = text.split(|b| *b == b'.').filter(|s| !s.is_empty()); + let iter = strings::tokenize(text, b"."); for part in iter { if !js_lexer::is_identifier(part) { diff --git a/src/router/lib.rs b/src/router/lib.rs index 5aaebf7857fb..98bfb8cf3785 100644 --- a/src/router/lib.rs +++ b/src/router/lib.rs @@ -1170,8 +1170,8 @@ pub mod pattern { match pattern.value { Value::Static(str_) => { - let segment = - &path_[0..path_.iter().position(|&b| b == b'/').unwrap_or(path_.len())]; + let segment = &path_ + [0..strings::index_of_char_usize(path_, b'/').unwrap_or(path_.len())]; if !str_.eql_bytes(segment) { params.clear(); // shrinkRetainingCapacity(0) return false; @@ -1188,7 +1188,7 @@ pub mod pattern { } } Value::Dynamic(dynamic) => { - if let Some(i) = path_.iter().position(|&b| b == b'/') { + if let Some(i) = strings::index_of_char_usize(path_, b'/') { params.push(Param { name: dynamic.str(name), value: &path_[0..i], diff --git a/src/runtime/api/Archive.rs b/src/runtime/api/Archive.rs index bf84e474a71f..518d61c5da0b 100644 --- a/src/runtime/api/Archive.rs +++ b/src/runtime/api/Archive.rs @@ -6,7 +6,7 @@ use crate::webcore::Blob; use crate::webcore::BlobExt as _; use crate::webcore::blob::{Store as BlobStore, StoreRef}; use bun_core::zig_string::Slice as ZigStringSlice; -use bun_core::{self, Output, ZBox}; +use bun_core::{self, Output, ZBox, strings}; use bun_event_loop::{TaskTag, Taskable, task_tag}; use bun_glob as glob; use bun_io::KeepAlive; @@ -1393,12 +1393,12 @@ pub(crate) fn is_safe_path(pathname: &[u8]) -> bool { } // Reject paths with ".." components - for component in pathname.split(|b| *b == b'/') { + for component in strings::split(pathname, b"/") { if component == b".." { return false; } // Also check Windows-style separators - for win_component in component.split(|b| *b == b'\\') { + for win_component in strings::split(component, b"\\") { if win_component == b".." { return false; } diff --git a/src/runtime/api/bun/h2_frame_parser.rs b/src/runtime/api/bun/h2_frame_parser.rs index 4f46d47fef75..1ad7bb0ce521 100644 --- a/src/runtime/api/bun/h2_frame_parser.rs +++ b/src/runtime/api/bun/h2_frame_parser.rs @@ -23,6 +23,7 @@ use bstr::BStr; use bun_collections::{ByteVecExt, HashMap as BunHashMap, HiveArrayFallback, VecExt}; use bun_core::MutableString; use bun_core::String as BunString; +use bun_core::strings; use bun_http::lshpack; use bun_jsc::AbortSignal; use bun_jsc::ErrorCode as JscErrorCode; @@ -653,7 +654,7 @@ fn is_valid_request_pseudo_header(name: &[u8]) -> bool { #[inline] fn is_valid_header_value(value: &[u8]) -> bool { - !value.iter().any(|&c| matches!(c, 0 | b'\n' | b'\r')) + !strings::contains_any(value, b"\0\n\r") } #[inline] @@ -690,7 +691,7 @@ pub(crate) fn is_malformed_field_name(name: &[u8]) -> bool { #[inline] pub(crate) fn is_malformed_field_value(value: &[u8]) -> bool { - value.iter().any(|&c| c == 0 || c == b'\r' || c == b'\n') + strings::contains_any(value, b"\0\r\n") } const SINGLE_VALUE_HEADERS_LEN: usize = 40; diff --git a/src/runtime/api/bun/js_bun_spawn_bindings.rs b/src/runtime/api/bun/js_bun_spawn_bindings.rs index fe4756c04964..016ae2183482 100644 --- a/src/runtime/api/bun/js_bun_spawn_bindings.rs +++ b/src/runtime/api/bun/js_bun_spawn_bindings.rs @@ -2147,10 +2147,7 @@ fn append_envp_from_js( // object carrying `Path` (the usual casing there) must still drive // the executable lookup, like libuv's spawn does. let line_bytes = line.as_bytes(); - let key_end = line_bytes - .iter() - .position(|&b| b == b'=') - .unwrap_or(line_bytes.len()); + let key_end = strings::index_of_char_usize(line_bytes, b'=').unwrap_or(line_bytes.len()); let is_path_key = if cfg!(windows) { strings::eql_case_insensitive_ascii(&line_bytes[..key_end], b"PATH", true) } else { diff --git a/src/runtime/api/cron.rs b/src/runtime/api/cron.rs index b248b711a0a3..186b4338ccf5 100644 --- a/src/runtime/api/cron.rs +++ b/src/runtime/api/cron.rs @@ -41,6 +41,7 @@ use crate::api::bun::process::SpawnResultExt as _; use crate::api::bun::process::{self as spawn, Process, Rusage, SpawnOptions, Status}; use crate::timer::{EventLoopTimer, EventLoopTimerState, EventLoopTimerTag}; use bun_core::ZStr; +use bun_core::strings; use bun_io::pipe_reader::BufferedReaderParent; #[cfg(target_os = "macos")] use bun_sys::FdDirExt as _; @@ -2607,7 +2608,7 @@ pub(crate) fn filter_crontab( let mut marker = Vec::new(); let _ = write!(&mut marker, "# bun-cron: {}", bstr::BStr::new(title)); let mut skip_next = false; - for line in content.split(|&b| b == b'\n') { + for line in strings::split(content, b"\n") { if skip_next { skip_next = false; continue; @@ -2664,7 +2665,7 @@ pub enum CalendarError { pub(crate) fn cron_to_calendar_interval(schedule: &[u8]) -> Result, CalendarError> { let mut fields: [&[u8]; 5] = [b""; 5]; let mut count: usize = 0; - for field in schedule.split(|&b| b == b' ').filter(|s| !s.is_empty()) { + for field in strings::tokenize(schedule, b" ") { if count >= 5 { return Err(CalendarError::InvalidCron); } @@ -2682,7 +2683,7 @@ pub(crate) fn cron_to_calendar_interval(schedule: &[u8]) -> Result, Cale continue; } let mut vals: Vec = Vec::new(); - for part in field.split(|&b| b == b',') { + for part in strings::split(field, b",") { // parse_unsigned (not parse_int) keeps '-5' → InvalidCron. let val: i32 = bun_core::parse_unsigned(part, 10).map_err(|_| CalendarError::InvalidCron)?; diff --git a/src/runtime/api/cron_parser.rs b/src/runtime/api/cron_parser.rs index d4a161222f2d..bfdd6a274437 100644 --- a/src/runtime/api/cron_parser.rs +++ b/src/runtime/api/cron_parser.rs @@ -101,9 +101,7 @@ impl CronExpression { let mut count: usize = 0; let mut fields: [&[u8]; 5] = [&[]; 5]; - let mut iter = expr - .split(|b| *b == b' ' || *b == b'\t') - .filter(|s| !s.is_empty()); + let mut iter = strings::tokenize_any(expr, b" \t"); while let Some(field) = iter.next() { if count >= 5 { return Err(CronError::TooManyFields); @@ -403,13 +401,13 @@ fn parse_field(field: &[u8], min: u8, max: u8, kind: NameKind) -> Res return Err(CronError::InvalidField); } let mut result: T = T::ZERO; - let mut parts = field.split(|b| *b == b','); + let mut parts = strings::split(field, b","); while let Some(part) = parts.next() { if part.is_empty() { return Err(CronError::InvalidField); } // Split by / for step - let mut step_iter = part.split(|b| *b == b'/'); + let mut step_iter = strings::split(part, b"/"); let base = step_iter.next().ok_or(CronError::InvalidField)?; let step_str = step_iter.next(); if step_iter.next().is_some() { diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 06c11fbe4386..0cb24cc21960 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -2828,7 +2828,7 @@ impl DevServer { b".html", ); // TODO: function for URL safe chars - if !strings::is_all_ascii(display_name) || display_name.contains(&b'"') { + if !strings::is_all_ascii(display_name) || strings::contains_char(display_name, b'"') { display_name = b"page"; } diff --git a/src/runtime/bake/FrameworkRouter.rs b/src/runtime/bake/FrameworkRouter.rs index 2c7e7755e3b2..ba6c27270bfa 100644 --- a/src/runtime/bake/FrameworkRouter.rs +++ b/src/runtime/bake/FrameworkRouter.rs @@ -802,7 +802,7 @@ impl Style { NextRoutingConvention::Pages => b"[", NextRoutingConvention::App => b"[(@", }; - while let Some(start) = strings::index_of_any_pos_comptime(route_segment, stop_chars, i) { + while let Some(start) = strings::index_of_any_pos(route_segment, stop_chars, i) { if matches!(CONVENTIONS, NextRoutingConvention::Pages) || route_segment[start] == b'[' { let mut end = match strings::index_of_char_pos(route_segment, b']', start + 1) { Some(e) => e, @@ -914,7 +914,7 @@ impl Style { } let between = &route_segment[i..start]; - for part in between.split(|b| *b == b'/').filter(|s| !s.is_empty()) { + for part in strings::tokenize(between, b"/") { parts.push(Part::Text(part)); } parts.push(if is_optional { @@ -968,7 +968,7 @@ impl Style { } let between = &route_segment[i..start]; - for part in between.split(|b| *b == b'/').filter(|s| !s.is_empty()) { + for part in strings::tokenize(between, b"/") { parts.push(Part::Text(part)); } parts.push(Part::Group(group_name)); @@ -990,10 +990,7 @@ impl Style { } } if !route_segment[i..].is_empty() { - for part in route_segment[i..] - .split(|b| *b == b'/') - .filter(|s| !s.is_empty()) - { + for part in strings::tokenize(&route_segment[i..], b"/") { parts.push(Part::Text(part)); } } diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index b657063d83ea..95e94076d091 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -1396,7 +1396,7 @@ pub(crate) use super::HmrRuntime; fn hmr_runtime_init(code: &'static ZStr) -> HmrRuntime { HmrRuntime { code, - line_count: u32::try_from(code.as_bytes().iter().filter(|&&b| b == b'\n').count()).unwrap(), + line_count: u32::try_from(strings::count_char(code.as_bytes(), b'\n')).unwrap(), } } diff --git a/src/runtime/bake/dev_server/source_map_store.rs b/src/runtime/bake/dev_server/source_map_store.rs index 92c5defa689b..feb1f2866de6 100644 --- a/src/runtime/bake/dev_server/source_map_store.rs +++ b/src/runtime/bake/dev_server/source_map_store.rs @@ -257,7 +257,7 @@ impl Entry { const HMR_CHUNK_PREFIX: &[u8] = b"self[Symbol.for(\"bun:hmr\")]({\n"; let runtime_line_count: u32 = match kind { ChunkKind::InitialResponse => bake::get_hmr_runtime(Side::Client).line_count, - ChunkKind::HmrChunk => HMR_CHUNK_PREFIX.iter().filter(|&&b| b == b'\n').count() as u32, + ChunkKind::HmrChunk => bun_core::strings::count_char(HMR_CHUNK_PREFIX, b'\n') as u32, }; let mut prev_end_state = SourceMapState { diff --git a/src/runtime/cli/bunx_command.rs b/src/runtime/cli/bunx_command.rs index f7dc22bfa707..15e2b2287af6 100644 --- a/src/runtime/cli/bunx_command.rs +++ b/src/runtime/cli/bunx_command.rs @@ -580,11 +580,8 @@ impl BunxCommand { }; let mut start = temp_dir_len + 1; loop { - let end = match cache_root[start..] - .iter() - .position(|b| *b == bun_paths::SEP) - { - Some(i) => start + i, + let end = match strings::index_of_char_pos(cache_root, bun_paths::SEP, start) { + Some(i) => i, None => cache_root.len(), }; if end == start { @@ -648,7 +645,7 @@ impl BunxCommand { _ => return false, } is_leaf = false; - match cache_dir[..end].iter().rposition(|b| *b == bun_paths::SEP) { + match strings::last_index_of_char(&cache_dir[..end], bun_paths::SEP) { Some(idx) if idx > temp_dir_len => end = idx, _ => return true, } @@ -901,9 +898,7 @@ impl BunxCommand { // Remove the cwd passed through BUN_WHICH_IGNORE_CWD from path. This prevents temp node-gyp script from finding and running itself let mut new_path: Vec = Vec::with_capacity(path.len()); - let mut path_iter = path - .split(|b| *b == DELIMITER) - .filter(|s: &&[u8]| !s.is_empty()); + let mut path_iter = strings::tokenize(&path, &[DELIMITER]); if let Some(segment) = path_iter.next() { if !strings::eql_long( strings::without_trailing_slash(segment), diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 41d1f2c42dac..b0ec6e3ab5b4 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -66,7 +66,7 @@ fn exec_task(task_: &[u8], cwd: &[u8], _path: &[u8], npm_client: Option bool { /// (absolute paths or any `..` segment), so `bun init` only creates files /// inside the current working directory. fn is_safe_entry_point_path(path: &[u8]) -> bool { - !bun_paths::is_absolute_loose(path) - && !path - .split(|&c| c == b'/' || c == b'\\') - .any(|seg| seg == b"..") + !bun_paths::is_absolute_loose(path) && !strings::split_any(path, b"/\\").any(|seg| seg == b"..") } #[inline] diff --git a/src/runtime/cli/install_completions_command.rs b/src/runtime/cli/install_completions_command.rs index b91ab15cc557..f3563ed9b30b 100644 --- a/src/runtime/cli/install_completions_command.rs +++ b/src/runtime/cli/install_completions_command.rs @@ -111,10 +111,8 @@ impl InstallCompletionsCommand { // `bunx.exe` on windows is a hardlink to `bun.exe` // for this to work, we need to delete and recreate the hardlink every time let image_path: &[u16] = windows::exe_path_w(); - let last_sep = image_path - .iter() - .rposition(|&c| c == b'\\' as u16) - .expect("unreachable"); + let last_sep = + strings::last_index_of_char_t(image_path, u16::from(b'\\')).expect("unreachable"); let image_dirname = &image_path[..last_sep + 1]; let mut bunx_path_buf = WPathBuffer::uninit(); @@ -181,10 +179,8 @@ impl InstallCompletionsCommand { // powershell `install.ps1` was used to install. let image_path: &[u16] = windows::exe_path_w(); - let last_sep = image_path - .iter() - .rposition(|&c| c == b'\\' as u16) - .expect("unreachable"); + let last_sep = + strings::last_index_of_char_t(image_path, u16::from(b'\\')).expect("unreachable"); let image_dirname = &image_path[..last_sep]; if !image_dirname.ends_with(w!("bun\\bin")) { @@ -395,7 +391,7 @@ impl InstallCompletionsCommand { } Shell::Zsh => { if let Some(fpath) = env_var::fpath.get() { - for dir in fpath.split(|b| *b == b' ') { + for dir in strings::split(fpath, b" ") { completions_dir = dir; if let Ok(d) = bun_sys::open_dir_absolute(dir) { break 'found d; diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index cebb8c8d6d49..6d84455f4406 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -883,11 +883,11 @@ pub mod command { } // Has a `.` in the basename — `foo.js`, `dir/foo.ts`, `.dotfile`, … // (no subcommand keyword contains a `.`). - let basename = match arg.iter().rposition(|&b| b == b'/' || b == b'\\') { + let basename = match strings::last_index_of_any(arg, b"/\\") { Some(i) => &arg[i + 1..], None => arg, }; - basename.contains(&b'.') + strings::contains_char(basename, b'.') } /// `#[inline(never)]`: argv→`Tag` classification, called once from diff --git a/src/runtime/cli/multi_run.rs b/src/runtime/cli/multi_run.rs index 301273ee0796..bd97c1b2aa6e 100644 --- a/src/runtime/cli/multi_run.rs +++ b/src/runtime/cli/multi_run.rs @@ -317,7 +317,7 @@ impl<'a> State<'a> { }; // Process complete lines - while let Some(newline_pos) = pipe.line_buffer.iter().position(|&b| b == b'\n') { + while let Some(newline_pos) = strings::index_of_char_usize(&pipe.line_buffer, b'\n') { let line = &pipe.line_buffer[0..newline_pos + 1]; // SAFETY: pipe.handle backref set in ProcessHandle::start() let handle = unsafe { &*pipe.handle }; @@ -957,7 +957,7 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result = Vec::new(); for key in pkg.scripts.keys() { @@ -1039,7 +1039,7 @@ pub(crate) fn run(ctx: &mut Command::ContextData) -> Result = Vec::new(); diff --git a/src/runtime/cli/pack_command.rs b/src/runtime/cli/pack_command.rs index 4ca1099785f0..855960f9d37e 100644 --- a/src/runtime/cli/pack_command.rs +++ b/src/runtime/cli/pack_command.rs @@ -2994,10 +2994,8 @@ fn run_lifecycle_script( /// drive/ADS colons, NUL); other unusual-but-harmless names (e.g. empty scope /// segments) keep packing as before. fn has_unsafe_tarball_filename_part(value: &[u8]) -> bool { - value - .split(|&c| c == b'/') - .any(|component| component == b"." || component == b"..") - || value.iter().any(|&c| matches!(c, b'\\' | b':' | 0)) + strings::split(value, b"/").any(|component| component == b"." || component == b"..") + || strings::contains_any(value, b"\\:\0") } fn tarball_destination<'a>( @@ -3766,7 +3764,7 @@ impl IgnorePatterns { let mut has_rel_path = false; - for line in contents.split(|&b| b == b'\n') { + for line in strings::split(&contents, b"\n") { if line.is_empty() { continue; } diff --git a/src/runtime/cli/pm_pkg_command.rs b/src/runtime/cli/pm_pkg_command.rs index 07c0a784d2a3..556f5cfd0d0c 100644 --- a/src/runtime/cli/pm_pkg_command.rs +++ b/src/runtime/cli/pm_pkg_command.rs @@ -494,7 +494,7 @@ impl PmPkgCommand { return Err(crate::Error::NotFound); } - let mut parts = key.split(|b| *b == b'.').filter(|s| !s.is_empty()); + let mut parts = strings::tokenize(key, b"."); let mut current = root; while let Some(part) = parts.next() { @@ -575,7 +575,7 @@ impl PmPkgCommand { fn parse_key_path(key: &[u8]) -> Result, Error> { let mut path_parts: Vec<&[u8]> = Vec::new(); - let mut parts = key.split(|b| *b == b'.').filter(|s| !s.is_empty()); + let mut parts = strings::tokenize(key, b"."); while let Some(part) = parts.next() { if let Some(first_bracket) = strings::index_of(part, b"[") { @@ -725,7 +725,7 @@ impl PmPkgCommand { } let mut path_parts: Vec<&[u8]> = Vec::new(); - for part in key.split(|b| *b == b'.').filter(|s| !s.is_empty()) { + for part in strings::tokenize(key, b".") { path_parts.push(part); } diff --git a/src/runtime/cli/pm_version_command.rs b/src/runtime/cli/pm_version_command.rs index 6c5bd938d163..63e4598b76c9 100644 --- a/src/runtime/cli/pm_version_command.rs +++ b/src/runtime/cli/pm_version_command.rs @@ -1,6 +1,6 @@ use std::io::Write as _; -use bstr::{BStr, ByteSlice}; +use bstr::BStr; use crate::api::bun::process::Status as ProcStatus; use crate::api::bun::process::sync::{ @@ -834,7 +834,7 @@ impl PmVersionCommand { } let commit_message: Vec = if let Some(msg) = custom_message { - msg.replace(b"%s", version) + strings::replace_owned(msg, b"%s", version) } else { fmt_bytes(format_args!("v{}", BStr::new(version))) }; diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index e7c488153a0c..1211f1c8739a 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -203,7 +203,7 @@ impl History { sys::Result::Err(_) => return Ok(()), }; - for line in content.split(|b: &u8| *b == b'\n') { + for line in strings::split(&content, b"\n") { if !line.is_empty() { self.entries.push(Box::<[u8]>::from(line)); } diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index fb001d360ae8..75e3c90e86f2 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -3097,10 +3097,7 @@ const EVAL_TRIGGER: &[u8] = b"/[eval]"; /// embedding in a double-quoted JS string literal. Used by the cron-execution /// wrapper script to inline the entry path and cron period. fn escape_for_js_string(input: &[u8]) -> Vec { - if !input - .iter() - .any(|&c| matches!(c, b'\\' | b'"' | b'\n' | b'\r' | b'\t')) - { + if !strings::contains_any(input, b"\\\"\n\r\t") { return input.to_vec(); } let mut result: Vec = Vec::with_capacity(input.len() + 16); @@ -3944,9 +3941,7 @@ impl BunXFastPath { // Trigger quoting only on // space/tab/quote — compare the FULL u16, not the truncated low byte. - let needs_quote = warg - .iter() - .any(|&c| c == b' ' as u16 || c == b'\t' as u16 || c == b'"' as u16); + let needs_quote = strings::index_of_any16(warg, bun_core::w!(" \t\"")).is_some(); if !needs_quote { buffer[..warg.len()].copy_from_slice(warg); @@ -3954,7 +3949,7 @@ impl BunXFastPath { } // Fast path: no embedded `"`/`\` → simple wrap. - let has_quote_or_backslash = warg.iter().any(|&c| c == b'"' as u16 || c == b'\\' as u16); + let has_quote_or_backslash = strings::index_of_any16(warg, bun_core::w!("\"\\")).is_some(); if !has_quote_or_backslash { buffer[0] = b'"' as u16; buffer[1..1 + warg.len()].copy_from_slice(warg); diff --git a/src/runtime/cli/test/ChangedFilesFilter.rs b/src/runtime/cli/test/ChangedFilesFilter.rs index 9f553b4b80d0..a2b1be85e98e 100644 --- a/src/runtime/cli/test/ChangedFilesFilter.rs +++ b/src/runtime/cli/test/ChangedFilesFilter.rs @@ -414,13 +414,7 @@ fn consume_watch_trigger() -> Option { let _ = sys::unlink(&trigger_path); let mut set = StringSet::new(); - for path in contents - .split(|b| *b == b'\r' || *b == b'\n') - .filter(|s| !s.is_empty()) - { - if path.is_empty() { - continue; - } + for path in strings::tokenize_any(&contents, b"\r\n") { // The watcher may see a file disappear (delete/rename). A path // that no longer exists cannot appear in the module graph this // run, so drop it; its importers will still be picked up if the @@ -641,10 +635,7 @@ fn run_git(git_path: &[u8], cwd: &[u8], args: &[&[u8]]) -> GitResult { /// with the repository root, and insert existing files into `set`. fn append_paths(set: &mut StringSet, git_root: &[u8], stdout: &[u8]) { let mut buf = PathBuffer::uninit(); - for line in stdout - .split(|b| *b == b'\r' || *b == b'\n') - .filter(|s| !s.is_empty()) - { + for line in strings::tokenize_any(stdout, b"\r\n") { let rel = strings::trim(line, b" \t"); if rel.is_empty() { continue; diff --git a/src/runtime/cli/test/parallel/aggregate.rs b/src/runtime/cli/test/parallel/aggregate.rs index 93c7902936e1..c29f11d380e1 100644 --- a/src/runtime/cli/test/parallel/aggregate.rs +++ b/src/runtime/cli/test/parallel/aggregate.rs @@ -147,7 +147,7 @@ pub(crate) fn merge_coverage_fragments( for &data in chunks { let mut cur: Option = None; // index into by_file; raw &mut would alias across getOrPut // reshaped for borrowck — store index instead of *mut FileCoverage - for raw in data.split(|b| *b == b'\n') { + for raw in strings::split(data, b"\n") { let line = strings::trim_right(raw, b"\r"); if line.starts_with(b"SF:") { let name = &line[3..]; @@ -166,7 +166,7 @@ pub(crate) fn merge_coverage_fragments( } else if let Some(i) = cur { let fc = &mut by_file.values_mut()[i]; if line.starts_with(b"DA:") { - let mut parts = line[3..].split(|b| *b == b','); + let mut parts = strings::split(&line[3..], b","); let Some(ln_s) = parts.next() else { continue }; let Ok(ln) = strings::parse_int::(ln_s, 10) else { continue; diff --git a/src/runtime/cli/update_interactive_command.rs b/src/runtime/cli/update_interactive_command.rs index 8fe077545b57..fbab8741e3a3 100644 --- a/src/runtime/cli/update_interactive_command.rs +++ b/src/runtime/cli/update_interactive_command.rs @@ -2170,9 +2170,8 @@ impl UpdateInteractiveCommand { if c == b'M' || c == b'm' { // Parse SGR mouse event: ESC[ Default for GlobPattern<'a> { impl<'a> GlobPattern<'a> { fn init(pattern: &'a [u8]) -> GlobPattern<'a> { - if let Some(at_pos) = pattern.iter().position(|&b| b == b'@') { + if let Some(at_pos) = strings::index_of_char_usize(pattern, b'@') { if at_pos > 0 && at_pos < pattern.len() - 1 { let pkg_pattern = &pattern[0..at_pos]; let version_pattern = &pattern[at_pos + 1..]; @@ -176,7 +176,7 @@ impl<'a> GlobPattern<'a> { } fn init_for_name(pattern: &'a [u8]) -> GlobPattern<'a> { - if !pattern.contains(&b'*') { + if !strings::contains_char(pattern, b'*') { return GlobPattern { pattern_type: PatternType::Exact, ..Default::default() @@ -185,7 +185,7 @@ impl<'a> GlobPattern<'a> { if pattern.len() >= 3 && pattern[0] == b'*' && pattern[pattern.len() - 1] == b'*' { let substring = &pattern[1..pattern.len() - 1]; - if !substring.is_empty() && !substring.contains(&b'*') { + if !substring.is_empty() && !strings::contains_char(substring, b'*') { return GlobPattern { pattern_type: PatternType::Contains, substring, @@ -194,7 +194,7 @@ impl<'a> GlobPattern<'a> { } } - if let Some(wildcard_pos) = pattern.iter().position(|&b| b == b'*') { + if let Some(wildcard_pos) = strings::index_of_char_usize(pattern, b'*') { if wildcard_pos == pattern.len() - 1 { return GlobPattern { pattern_type: PatternType::Prefix, @@ -211,7 +211,7 @@ impl<'a> GlobPattern<'a> { }; } - if pattern[wildcard_pos + 1..].contains(&b'*') { + if strings::contains_char(&pattern[wildcard_pos + 1..], b'*') { return GlobPattern { pattern_type: PatternType::Invalid, ..Default::default() diff --git a/src/runtime/crypto/pwhash.rs b/src/runtime/crypto/pwhash.rs index 6ad2e0f373c6..b3f9084c5c57 100644 --- a/src/runtime/crypto/pwhash.rs +++ b/src/runtime/crypto/pwhash.rs @@ -36,6 +36,7 @@ pub enum Encoding { pub mod argon2 { use super::{Encoding, Error}; + use bun_core::strings; // The `rust-argon2` package exports its lib as crate name `argon2`; refer to // it via the absolute `::argon2` path so it doesn't collide with this module. @@ -178,22 +179,24 @@ pub mod argon2 { // rust-argon2's `verify_encoded` instead accepts `v=16` (computing // with Version10) and defaults a missing segment to Version10, so // pre-scan and normalise here before delegating. + // `encoded` is 7-bit ASCII (checked above), so every byte index below + // is a char boundary. let normalised: std::borrow::Cow<'_, str> = 'norm: { // Encoded shape is `$$[v=N$]m=..,t=..,p=..$$`. // Locate the segment immediately after the alg-id. - let Some(after_dollar) = encoded.strip_prefix('$') else { + let Some(after_dollar) = encoded.as_bytes().strip_prefix(b"$") else { // Malformed; let rust-argon2 reject it. break 'norm std::borrow::Cow::Borrowed(encoded); }; - let Some(sep) = after_dollar.find('$') else { + let Some(sep) = strings::index_of_char_usize(after_dollar, b'$') else { break 'norm std::borrow::Cow::Borrowed(encoded); }; // Absolute index of the '$' terminating the alg-id. let alg_end = 1 + sep; let rest = &encoded[alg_end + 1..]; - if let Some(v) = rest.strip_prefix("v=") { - let end = v.find('$').unwrap_or(v.len()); - if &v[..end] != "19" { + if let Some(v) = rest.as_bytes().strip_prefix(b"v=") { + let end = strings::index_of_char_usize(v, b'$').unwrap_or(v.len()); + if &v[..end] != b"19" { return Err(crate::Error::InvalidEncoding); } std::borrow::Cow::Borrowed(encoded) @@ -208,29 +211,36 @@ pub mod argon2 { } }; - if let Some(after_dollar) = normalised.strip_prefix('$') { - if let Some(sep) = after_dollar.find('$') { + if let Some(after_dollar) = normalised.as_bytes().strip_prefix(b"$") { + if let Some(sep) = strings::index_of_char_usize(after_dollar, b'$') { let mut rest = &after_dollar[sep + 1..]; - if let Some(after_version) = rest.strip_prefix("v=") { - rest = match after_version.find('$') { + if let Some(after_version) = rest.strip_prefix(b"v=") { + rest = match strings::index_of_char_usize(after_version, b'$') { Some(end) => &after_version[end + 1..], - None => "", + None => b"", }; } - let params = &rest[..rest.find('$').unwrap_or(rest.len())]; - for pair in params.split(',') { - let Some((key, value)) = pair.split_once('=') else { - continue; - }; - let Ok(value) = value.parse::() else { + let params = + &rest[..strings::index_of_char_usize(rest, b'$').unwrap_or(rest.len())]; + for pair in strings::split(params, b",") { + let Some((key, value)) = strings::split_once_char(pair, b'=') else { continue; }; let limit = match key { - "m" => MAX_VERIFY_MEMORY_COST, - "t" => MAX_VERIFY_TIME_COST, - "p" => MAX_VERIFY_PARALLELISM, + b"m" => MAX_VERIFY_MEMORY_COST, + b"t" => MAX_VERIFY_TIME_COST, + b"p" => MAX_VERIFY_PARALLELISM, _ => continue, }; + // Same grammar as rust-argon2's `decode_u32` (`str::parse`, + // which accepts a leading `+`); anything it can't parse the + // decoder can't either, so fail closed rather than skip the cap. + let Some(value) = core::str::from_utf8(value) + .ok() + .and_then(|v| v.parse::().ok()) + else { + return Err(crate::Error::InvalidEncoding); + }; if value > limit { return Err(crate::Error::WeakParameters); } @@ -250,6 +260,7 @@ pub mod argon2 { pub mod bcrypt { use super::{Encoding, Error}; + use bun_core::strings; use ::bcrypt as vendor; @@ -384,46 +395,45 @@ pub mod bcrypt { let invalid = || crate::Error::InvalidEncoding; // alg_id - let rest = encoded.strip_prefix('$').ok_or_else(invalid)?; - let (alg_id, rest) = rest.split_once('$').ok_or_else(invalid)?; - if alg_id != "bcrypt" { + let rest = encoded.as_bytes().strip_prefix(b"$").ok_or_else(invalid)?; + let (alg_id, rest) = strings::split_once_char(rest, b'$').ok_or_else(invalid)?; + if alg_id != b"bcrypt" { return Err(crate::Error::PasswordVerificationFailed); } // r=N (rounds must fit in 6 bits; checked below) - let (params, rest) = rest.split_once('$').ok_or_else(invalid)?; - let rounds_str = params.strip_prefix("r=").ok_or_else(invalid)?; - let rounds_log: u8 = rounds_str.parse().map_err(|_| invalid())?; + let (params, rest) = strings::split_once_char(rest, b'$').ok_or_else(invalid)?; + let rounds_str = params.strip_prefix(b"r=").ok_or_else(invalid)?; + let rounds_log: u8 = + bun_core::fmt::parse_unsigned(rounds_str, 10).map_err(|_| invalid())?; if rounds_log > 63 { return Err(invalid()); } // salt / hash — standard no-pad base64. - let (salt_b64, hash_b64) = rest.split_once('$').ok_or_else(invalid)?; + let (salt_b64, hash_b64) = strings::split_once_char(rest, b'$').ok_or_else(invalid)?; let decoder = &bun_base64::zig_base64::STANDARD_NO_PAD.decoder; let mut salt = [0u8; SALT_LENGTH]; if decoder - .calc_size_for_slice(salt_b64.as_bytes()) + .calc_size_for_slice(salt_b64) .map_err(|_| invalid())? != SALT_LENGTH { return Err(invalid()); } - decoder - .decode(&mut salt, salt_b64.as_bytes()) - .map_err(|_| invalid())?; + decoder.decode(&mut salt, salt_b64).map_err(|_| invalid())?; let mut expected = [0u8; DK_LENGTH]; if decoder - .calc_size_for_slice(hash_b64.as_bytes()) + .calc_size_for_slice(hash_b64) .map_err(|_| invalid())? != DK_LENGTH { return Err(invalid()); } decoder - .decode(&mut expected, hash_b64.as_bytes()) + .decode(&mut expected, hash_b64) .map_err(|_| invalid())?; // The crate's raw `bcrypt()` asserts `cost < 32`, so reject the diff --git a/src/runtime/dns_jsc/dns.rs b/src/runtime/dns_jsc/dns.rs index d933138e1e8e..2f5dd6fe635c 100644 --- a/src/runtime/dns_jsc/dns.rs +++ b/src/runtime/dns_jsc/dns.rs @@ -5049,7 +5049,7 @@ impl Resolver { // stack before null-terminating it. Reject anything that cannot fit so we never // index past that buffer. RFC 1035 caps hostnames at 253 octets and NI_MAXHOST // is 1025, so this never rejects a name that could have resolved. - if name.len() >= MAX_PATH_BYTES || name.contains(&0) { + if name.len() >= MAX_PATH_BYTES || strings::contains_char(name, 0) { let mut promise = JSPromiseStrong::init(global_this); let promise_value = promise.value(); error_to_deferred( diff --git a/src/runtime/ffi/ffi_body.rs b/src/runtime/ffi/ffi_body.rs index 7433123df426..5e957df3b328 100644 --- a/src/runtime/ffi/ffi_body.rs +++ b/src/runtime/ffi/ffi_body.rs @@ -752,7 +752,7 @@ impl CompileC { // Check standard C compiler environment variables for include paths. // These are used by systems like NixOS where standard FHS paths don't exist. if let Some(c_include_path) = env_var::C_INCLUDE_PATH.get() { - for path in c_include_path.split(|b| *b == b':') { + for path in bun_core::strings::split(c_include_path, b":") { if !path.is_empty() { let path_z = ZBox::from_bytes(path); if state.add_sys_include_path(&path_z).is_err() { @@ -768,7 +768,7 @@ impl CompileC { // Check standard C compiler environment variable for library paths. if let Some(library_path) = env_var::LIBRARY_PATH.get() { - for path in library_path.split(|b| *b == b':') { + for path in bun_core::strings::split(library_path, b":") { if !path.is_empty() { let path_z = ZBox::from_bytes(path); if state.add_library_path(&path_z).is_err() { diff --git a/src/runtime/image/codecs.rs b/src/runtime/image/codecs.rs index c2476a88c8d4..469b049ed1d8 100644 --- a/src/runtime/image/codecs.rs +++ b/src/runtime/image/codecs.rs @@ -173,7 +173,7 @@ impl Format { /// final dotted segment is considered; case-insensitive. Returns `None` /// when there's no extension or it's not one we recognise. pub(crate) fn from_extension(path: &[u8]) -> Option { - let dot = path.iter().rposition(|&b| b == b'.')?; + let dot = bun_core::strings::last_index_of_char(path, b'.')?; let mut buf = [0u8; 5]; let src = &path[dot + 1..]; let n = src.len().min(buf.len()); diff --git a/src/runtime/node/dir_iterator.rs b/src/runtime/node/dir_iterator.rs index 39e719694060..1aab5848dd5c 100644 --- a/src/runtime/node/dir_iterator.rs +++ b/src/runtime/node/dir_iterator.rs @@ -396,7 +396,7 @@ mod platform { // instead of dereferencing the raw `*const dirent64`. let name_off = entry_idx + offset_of!(libc::dirent64, d_name); let region = &self.buf.0[name_off..next_index]; - let nul = region.iter().position(|&b| b == 0).unwrap_or(region.len()); + let nul = bun_core::strings::index_of_char_usize(region, 0).unwrap_or(region.len()); let name = ®ion[..nul]; // skip . and .. entries diff --git a/src/runtime/node/memory_pressure.rs b/src/runtime/node/memory_pressure.rs index ecaa2aa92165..d0962eafdc3f 100644 --- a/src/runtime/node/memory_pressure.rs +++ b/src/runtime/node/memory_pressure.rs @@ -115,7 +115,7 @@ mod posix { let mut read = [0u8; 256]; let n = bun_sys::read(fd, &mut read).unwrap_or(0); let _ = bun_sys::close(fd); - for line in read[..n].split(|&b| b == b'\n') { + for line in bun_core::strings::split(&read[..n], b"\n") { let Some(rest) = line.strip_prefix(b"0::") else { continue; }; diff --git a/src/runtime/node/node_os.rs b/src/runtime/node/node_os.rs index 2b63c27dcf23..ccff60e67c66 100644 --- a/src/runtime/node/node_os.rs +++ b/src/runtime/node/node_os.rs @@ -321,7 +321,7 @@ mod _impl { file.read_to_end_with_array_list(&mut file_buf, bun_sys::SizeHint::ProbablySmall)?; let contents = file_buf.as_slice(); - let mut line_iter = contents.split(|b| *b == b'\n').filter(|s| !s.is_empty()); + let mut line_iter = strings::tokenize(contents, b"\n"); // Skip the first line (aggregate of all CPUs) let _ = line_iter.next(); @@ -329,9 +329,7 @@ mod _impl { // Read each CPU line while let Some(line) = line_iter.next() { // CPU lines are formatted as `cpu0 user nice sys idle iowait irq softirq` - let mut toks = line - .split(|b| *b == b' ' || *b == b'\t') - .filter(|s| !s.is_empty()); + let mut toks = strings::tokenize_any(line, b" \t"); let cpu_name = toks.next(); if cpu_name.is_none() || !cpu_name.unwrap().starts_with(b"cpu") { break; // done with CPUs @@ -371,7 +369,7 @@ mod _impl { file.read_to_end_with_array_list(&mut file_buf, bun_sys::SizeHint::ProbablySmall)?; let contents = file_buf.as_slice(); - let mut line_iter = contents.split(|b| *b == b'\n').filter(|s| !s.is_empty()); + let mut line_iter = strings::tokenize(contents, b"\n"); const KEY_PROCESSOR: &[u8] = b"processor\t: "; const KEY_MODEL_NAME: &[u8] = b"model name\t: "; @@ -1712,6 +1710,6 @@ fn parse_u32(s: &[u8]) -> crate::Result { #[cfg(windows)] #[inline] fn slice_to_nul_u16(buf: &[u16]) -> &[u16] { - let nul = buf.iter().position(|&b| b == 0).unwrap_or(buf.len()); + let nul = bun_core::strings::index_of_scalar(buf, 0).unwrap_or(buf.len()); &buf[..nul] } diff --git a/src/runtime/node/node_process.rs b/src/runtime/node/node_process.rs index c2b197518270..61150b0befc1 100644 --- a/src/runtime/node/node_process.rs +++ b/src/runtime/node/node_process.rs @@ -226,10 +226,7 @@ mod _impl { } if !compile_exec_argv.is_empty() { - for token in compile_exec_argv - .split(|b| matches!(*b, b' ' | b'\t' | b'\n' | b'\r')) - .filter(|s: &&[u8]| !s.is_empty()) - { + for token in strings::tokenize_any(compile_exec_argv, b" \t\n\r") { args.push(BunString::clone_utf8(token)); } } diff --git a/src/runtime/node/path.rs b/src/runtime/node/path.rs index d4da1a4db33d..5750e07e314f 100644 --- a/src/runtime/node/path.rs +++ b/src/runtime/node/path.rs @@ -280,9 +280,7 @@ fn posix_cwd_t(buf: &mut [T]) -> MaybeBuf<'_, T> { // Translated from the following JS code: // return StringPrototypeSlice(cwd, StringPrototypeIndexOf(cwd, '/')); - let index = normalized_cwd - .iter() - .position(|&b| b == T::from_u8(CHAR_FORWARD_SLASH)); + let index = strings::index_of_scalar(normalized_cwd, T::from_u8(CHAR_FORWARD_SLASH)); // Account for the -1 case of String#slice in JS land if let Some(_index) = index { return Ok(&mut normalized_cwd[_index..len]); @@ -1593,6 +1591,33 @@ pub(crate) fn join( join_js_t::(global_object, pool, is_windows, &paths) } +/// Handles a `..` segment for `normalize_string_t`: drops the last segment of +/// `res` and returns `(res.len, lastSegmentLength)`. Kept out of line so the +/// per-byte loop in the caller stays call-free. +#[inline(never)] +fn pop_last_segment_t(res: &[T], separator: T) -> (usize, usize) { + match strings::last_index_of_char_t(res, separator) { + None => (0, 0), + Some(idx) => { + // Translated from the following JS code: + // lastSegmentLength = + // res.length - 1 - StringPrototypeLastIndexOf(res, separator); + let last_segment_length = match strings::last_index_of_char_t(&res[0..idx], separator) { + // Yes (>ლ), Node relies on the -1 result of + // StringPrototypeLastIndexOf(res, separator). + // A - -1 is a positive 1. + // So the code becomes + // lastSegmentLength = res.length - 1 + 1; + // or + // lastSegmentLength = res.length; + None => idx, + Some(sep) => idx - 1 - sep, + }; + (idx, last_segment_length) + } + } +} + /// Based on Node v21.6.1 private helper normalizeString: /// https://github.com/nodejs/node/blob/6ae20aa63de78294b18d5015481485b7cd8fbb60/lib/path.js#L65C1-L66C77 /// @@ -1643,30 +1668,8 @@ fn normalize_string_t( || buf[buf_size - 2] != T::from_u8(CHAR_DOT) { if buf_size > 2 { - match buf[0..buf_size].iter().rposition(|&b| b == separator) { - None => { - buf_size = 0; - last_segment_length = 0; - } - Some(idx) => { - buf_size = idx; - // Translated from the following JS code: - // lastSegmentLength = - // res.length - 1 - StringPrototypeLastIndexOf(res, separator); - last_segment_length = - match buf[0..buf_size].iter().rposition(|&b| b == separator) { - // Yes (>ლ), Node relies on the -1 result of - // StringPrototypeLastIndexOf(res, separator). - // A - -1 is a positive 1. - // So the code becomes - // lastSegmentLength = res.length - 1 + 1; - // or - // lastSegmentLength = res.length; - None => buf_size, - Some(sep) => buf_size - 1 - sep, - }; - } - } + (buf_size, last_segment_length) = + pop_last_segment_t(&buf[0..buf_size], separator); last_slash = Some(i); dots = Some(0); continue; diff --git a/src/runtime/node/quic/endpoint.rs b/src/runtime/node/quic/endpoint.rs index 3efbc423bf38..425961e37d68 100644 --- a/src/runtime/node/quic/endpoint.rs +++ b/src/runtime/node/quic/endpoint.rs @@ -831,7 +831,7 @@ fn match_sni<'a>(entries: &'a [(Vec, TlsContext)], host: &[u8]) -> Option<&' if let Some((_, ctx)) = entries.iter().find(|(h, _)| eq(h, host)) { return Some(ctx); } - if let Some(dot) = host.iter().position(|&b| b == b'.') { + if let Some(dot) = bun_core::strings::index_of_char_usize(host, b'.') { let suffix = &host[dot..]; if let Some((_, ctx)) = entries .iter() diff --git a/src/runtime/node/quic/tls.rs b/src/runtime/node/quic/tls.rs index baa4b13fb94c..b072647d323a 100644 --- a/src/runtime/node/quic/tls.rs +++ b/src/runtime/node/quic/tls.rs @@ -65,7 +65,7 @@ fn tls13_policy_for_ciphers(ciphers: &[u8]) -> Option { let mut has_128 = false; let mut has_256 = false; let mut has_chacha = false; - for name in ciphers.split(|&b| b == b':') { + for name in bun_core::strings::split(ciphers, b":") { if name == TLS13_AES_128_GCM_SHA256 { has_128 = true; } else if name == TLS13_AES_256_GCM_SHA384 { diff --git a/src/runtime/server/DirectoryRoute.rs b/src/runtime/server/DirectoryRoute.rs index 79977e1f11ca..96201de0034e 100644 --- a/src/runtime/server/DirectoryRoute.rs +++ b/src/runtime/server/DirectoryRoute.rs @@ -555,7 +555,7 @@ fn resolve_subpath(url: &[u8], url_prefix: &[u8], out: &mut [u8]) -> Option<(usi as usize; let decoded = &out[..decoded_len]; - if decoded.iter().filter(|&&b| b == b'/').count() != raw_slashes { + if strings::count_char(decoded, b'/') != raw_slashes { return None; } if decoded_len == 0 { diff --git a/src/runtime/server/RequestContext.rs b/src/runtime/server/RequestContext.rs index e03c15383a3e..9a7e2f089d16 100644 --- a/src/runtime/server/RequestContext.rs +++ b/src/runtime/server/RequestContext.rs @@ -222,6 +222,7 @@ where // stream handling, error handling. use bun_collections::VecExt; use bun_core::Output; +use bun_core::strings; use bun_http_types as HTTP; use bun_http_types::MimeType::MimeType; use bun_paths::PathBuffer; @@ -3899,10 +3900,7 @@ where // we may not know the content-type when streaming && (!blob.is_detached() || content_type.value.as_ptr() != bun_http_types::MimeType::OTHER.value.as_ptr()) - && !content_type - .value - .iter() - .any(|&b| matches!(b, b'\r' | b'\n' | 0)) + && !strings::contains_any(&content_type.value, b"\r\n\0") { resp.write_header(b"content-type", &content_type.value); } @@ -3925,10 +3923,7 @@ where if !basename.is_empty() { let mut filename_buf = [0u8; 1024]; let truncated = &basename[..basename.len().min(1024 - 32)]; - if !truncated - .iter() - .any(|&b| matches!(b, b'\r' | b'\n' | 0 | b'"')) - { + if !strings::contains_any(truncated, b"\r\n\0\"") { let header_value = { let mut w = &mut filename_buf[..]; if write!(w, "filename=\"{}\"", bstr::BStr::new(truncated)).is_ok() { diff --git a/src/runtime/server/server_body.rs b/src/runtime/server/server_body.rs index d085f59fdeab..b045d367b59c 100644 --- a/src/runtime/server/server_body.rs +++ b/src/runtime/server/server_body.rs @@ -2036,9 +2036,7 @@ where // A request that does not name "websocket" in its |Upgrade| token list, // or whose |Sec-WebSocket-Key| is not base64 of 16 bytes, is not a // WebSocket handshake; fall through so the caller's fetch() can respond. - if !upgrade_header - .slice() - .split(|&c| c == b',') + if !strings::split(upgrade_header.slice(), b",") .any(|t| strings::eql_case_insensitive_ascii(t.trim_ascii(), b"websocket", true)) { return Ok(JSValue::FALSE); diff --git a/src/runtime/shell/builtin/export.rs b/src/runtime/shell/builtin/export.rs index 4e7d74c65cf6..fa336fc25f2b 100644 --- a/src/runtime/shell/builtin/export.rs +++ b/src/runtime/shell/builtin/export.rs @@ -29,7 +29,7 @@ impl Export { if s.is_empty() { continue; } - let (name, value) = match s.iter().position(|&b| b == b'=') { + let (name, value) = match bun_core::strings::index_of_char_usize(s, b'=') { Some(eq) => (&s[..eq], &s[eq + 1..]), None => (s, &b""[..]), }; diff --git a/src/runtime/shell/builtin/rm.rs b/src/runtime/shell/builtin/rm.rs index 4952edb51796..3e108781f557 100644 --- a/src/runtime/shell/builtin/rm.rs +++ b/src/runtime/shell/builtin/rm.rs @@ -1,6 +1,6 @@ use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize, Ordering}; -use bun_core::{ZBox, ZStr}; +use bun_core::{ZBox, ZStr, strings}; use bun_paths::resolve_path::{self, Platform, platform}; use bun_sys::{E, FdExt, dir_iterator}; @@ -566,8 +566,8 @@ impl JoinStyle { if cfg!(unix) { return JoinStyle::Posix; } - let backslash = p.iter().position(|&c| c == b'\\').unwrap_or(usize::MAX); - let forwardslash = p.iter().position(|&c| c == b'/').unwrap_or(usize::MAX); + let backslash = strings::index_of_char_usize(p, b'\\').unwrap_or(usize::MAX); + let forwardslash = strings::index_of_char_usize(p, b'/').unwrap_or(usize::MAX); if forwardslash <= backslash { JoinStyle::Posix } else { diff --git a/src/runtime/socket/Handlers.rs b/src/runtime/socket/Handlers.rs index cde8a3463988..49d90fd8f7e5 100644 --- a/src/runtime/socket/Handlers.rs +++ b/src/runtime/socket/Handlers.rs @@ -547,7 +547,7 @@ impl SocketConfig { } result.hostname_or_unix = hostname.to_utf8(); let slice = result.hostname_or_unix.slice(); - if slice.contains(&0) { + if bun_core::strings::contains_char(slice, 0) { return Err(global.throw_invalid_arguments(format_args!( "\"hostname\" must not contain null bytes" ))); diff --git a/src/runtime/socket/SocketAddress.rs b/src/runtime/socket/SocketAddress.rs index f87c39caef4f..fd9432d61216 100644 --- a/src/runtime/socket/SocketAddress.rs +++ b/src/runtime/socket/SocketAddress.rs @@ -10,7 +10,7 @@ use core::ffi::{c_int, c_void}; use core::mem; use bun_cares_sys::c_ares as ares; -use bun_core::{OwnedString, String as BunString, ZStr}; +use bun_core::{OwnedString, String as BunString, ZStr, strings}; use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsClass, JsError, JsResult, StringJsc, URL}; // The JsClass derive / codegen wires toJS/fromJS/fromJSDirect. @@ -242,7 +242,7 @@ impl SocketAddress { let addr = if paddr[0] == b'[' && paddr[paddr.len() - 1] == b']' { let mut inner = &paddr[1..paddr.len() - 1]; let mut scope_id: u32 = 0; - if let Some(pct) = inner.iter().position(|&b| b == b'%') { + if let Some(pct) = strings::index_of_char_usize(inner, b'%') { let zone = &inner[pct + 1..]; inner = &inner[..pct]; // Numeric zone → scope_id directly. @@ -1054,7 +1054,7 @@ pub unsafe extern "C" fn Bun__parseIpAddress( }; let mut addr = bun_sys::net::Address::from_ip(ip, port); if ip.is_ipv6() { - if let Some(pct) = bytes.iter().position(|b| *b == b'%') { + if let Some(pct) = strings::index_of_char_usize(bytes, b'%') { addr.set_scope_id(scope_index(&bytes[pct + 1..])); } } diff --git a/src/runtime/socket/udp_socket.rs b/src/runtime/socket/udp_socket.rs index 34195fd7532a..368db401045b 100644 --- a/src/runtime/socket/udp_socket.rs +++ b/src/runtime/socket/udp_socket.rs @@ -1569,7 +1569,9 @@ impl UDPSocket { }; let mut addr = Address::from_ip(ip, port); if ip.is_ipv6() { - if let Some(percent) = address_slice[..bytes_len].iter().position(|&b| b == b'%') { + if let Some(percent) = + bun_core::strings::index_of_char_usize(&address_slice[..bytes_len], b'%') + { if percent + 1 < bytes_len { let iface_id: u32 = 'blk: { #[cfg(windows)] diff --git a/src/runtime/test_runner/diff/printDiff.rs b/src/runtime/test_runner/diff/printDiff.rs index 4d558d4a47b6..28a9f7d28072 100644 --- a/src/runtime/test_runner/diff/printDiff.rs +++ b/src/runtime/test_runner/diff/printDiff.rs @@ -149,7 +149,7 @@ pub(crate) fn print_diff_main( for diff_segment in &diff_segments { if diff_segment.mode == DiffSegmentMode::Equal { - for line in diff_segment.removed.split(|&b| b == b'\n') { + for line in strings::split(diff_segment.removed, b"\n") { new_diff_segments.push(DiffSegment { removed: line, inserted: line, @@ -420,7 +420,7 @@ fn print_segment( config: &DiffConfig, style: Style, ) -> std::fmt::Result { - let mut lines = text.split(|&b| b == b'\n'); + let mut lines = strings::split(text, b"\n"); print_truncated_line(lines.next().unwrap(), writer, config, style)?; diff --git a/src/runtime/test_runner/expect.rs b/src/runtime/test_runner/expect.rs index ac844bbcd697..7dc3edc60f3c 100644 --- a/src/runtime/test_runner/expect.rs +++ b/src/runtime/test_runner/expect.rs @@ -1022,7 +1022,7 @@ impl Expect { dst_idx += line_newline; } } - let Some(c) = str_in.iter().rposition(|&b| b == b'\n') else { return give_up_2!(); }; // there has to have been at least a single newline to get here + let Some(c) = strings::last_index_of_char(str_in, b'\n') else { return give_up_2!(); }; // there has to have been at least a single newline to get here let end_indent = c + 1; for &c in &str_in[end_indent..] { if c != b' ' && c != b'\t' { return give_up_2!(); } // we already checked, but the last line is not all whitespace again @@ -1822,12 +1822,11 @@ impl fmt::Display for CustomMatcherParamsFormatter<'_> { let source_slice = source_str.to_utf8(); let source: &[u8] = source_slice.slice(); - if let Some(lparen) = source.iter().position(|&b| b == b'(') { - if let Some(rparen_rel) = source[lparen..].iter().position(|&b| b == b')') { - let rparen = lparen + rparen_rel; + if let Some(lparen) = strings::index_of_char_usize(source, b'(') { + if let Some(rparen) = strings::index_of_char_pos(source, b')', lparen) { let params_str = &source[lparen + 1..rparen]; let mut param_index: usize = 0; - for param_name in params_str.split(|&b| b == b',') { + for param_name in strings::split(params_str, b",") { if param_index > 0 { // skip the first param from the matcher_fn, which is the received value if param_index > 1 { @@ -3281,8 +3280,8 @@ mod tests { fn sanity_check(input: &[u8], res: &TrimResult<'_>) { // sanity check: output has same number of lines & all input lines endWith output lines - let mut input_iter = input.split(|&b| b == b'\n'); - let mut output_iter = res.trimmed.split(|&b| b == b'\n'); + let mut input_iter = strings::split(input, b"\n"); + let mut output_iter = strings::split(res.trimmed, b"\n"); loop { let next_input = input_iter.next(); let next_output = output_iter.next(); diff --git a/src/runtime/test_runner/expect/toIncludeRepeated.rs b/src/runtime/test_runner/expect/toIncludeRepeated.rs index 1507834cd685..e8d320ad37a0 100644 --- a/src/runtime/test_runner/expect/toIncludeRepeated.rs +++ b/src/runtime/test_runner/expect/toIncludeRepeated.rs @@ -1,4 +1,3 @@ -use bstr::ByteSlice; use bun_jsc::{CallFrame, JSGlobalObject, JSValue, JsResult}; use super::{Expect, get_signature, throw}; @@ -73,7 +72,7 @@ impl Expect { } // Non-overlapping occurrence count. - let actual_count = expect_string_as_str.find_iter(sub_string_as_str).count(); + let actual_count = bun_core::strings::count(expect_string_as_str, sub_string_as_str); let mut pass = actual_count == count_as_num as usize; if not { diff --git a/src/runtime/test_runner/snapshot.rs b/src/runtime/test_runner/snapshot.rs index 379275165dc1..ff586c680b4f 100644 --- a/src/runtime/test_runner/snapshot.rs +++ b/src/runtime/test_runner/snapshot.rs @@ -711,7 +711,7 @@ impl<'a> Snapshots<'a> { None => 'd: { let source_until_final_start = &source.contents[..final_start_usize]; let line_start = - match source_until_final_start.iter().rposition(|&b| b == b'\n') { + match strings::last_index_of_char(source_until_final_start, b'\n') { Some(newline_loc) => newline_loc + 1, None => 0, }; @@ -731,11 +731,11 @@ impl<'a> Snapshots<'a> { re_indented_string.extend_from_slice(b"\n"); let mut re_indented_source = &ils.value[1..]; while !re_indented_source.is_empty() { - let next_newline = match re_indented_source.iter().position(|&b| b == b'\n') - { - Some(a) => a + 1, - None => re_indented_source.len(), - }; + let next_newline = + match strings::index_of_char_usize(re_indented_source, b'\n') { + Some(a) => a + 1, + None => re_indented_source.len(), + }; let segment = &re_indented_source[..next_newline]; if segment.is_empty() { // last line; loop already exited diff --git a/src/runtime/webcore/Blob.rs b/src/runtime/webcore/Blob.rs index 6d221254745d..ce3ead8dc495 100644 --- a/src/runtime/webcore/Blob.rs +++ b/src/runtime/webcore/Blob.rs @@ -3976,13 +3976,12 @@ impl FormDataContext<'_> { // Borrowed from the blob, which the `DOMFormData` keeps alive // past `joiner.done()`. let blob_ct = blob.content_type_slice(); - let content_type: &[u8] = if !blob_ct.is_empty() - && !blob_ct.iter().any(|&b| matches!(b, b'\r' | b'\n')) - { - blob_ct - } else { - b"application/octet-stream" - }; + let content_type: &[u8] = + if !blob_ct.is_empty() && !strings::contains_any(blob_ct, b"\r\n") { + blob_ct + } else { + b"application/octet-stream" + }; joiner.push_static(b"Content-Type: "); // SAFETY: either a `'static` literal or borrowed from the entry's Blob, // which the `DOMFormData` keeps alive past `joiner.done()` in diff --git a/src/runtime/webview/ChromeProcess.rs b/src/runtime/webview/ChromeProcess.rs index d2ee1b1345cd..3b9235f9531a 100644 --- a/src/runtime/webview/ChromeProcess.rs +++ b/src/runtime/webview/ChromeProcess.rs @@ -658,7 +658,7 @@ fn read_dev_tools_active_port(out_buf: &mut Vec) -> Option<()> { }; // Parse: line 1 = port, line 2 = path. - let mut lines = contents.split(|b| *b == b'\n'); + let mut lines = strings::split(&contents, b"\n"); let port_str = match lines.next() { Some(s) => strings::trim(s, b" \r\t"), None => continue, diff --git a/src/s3_signing/credentials.rs b/src/s3_signing/credentials.rs index d9de006e97fa..3996162fe2b4 100644 --- a/src/s3_signing/credentials.rs +++ b/src/s3_signing/credentials.rs @@ -405,7 +405,7 @@ impl S3Credentials { // The bucket is interpolated into the host; a `/` (or `\`, // which encode_uri_component normalizes to `/`) would let a // crafted bucket redirect the signed request to another host. - if bucket.contains(&b'/') { + if strings::contains_char(bucket, b'/') { return Err(SignError::InvalidEndpoint); } // default to https://.s3..amazonaws.com/ diff --git a/src/sourcemap/lib.rs b/src/sourcemap/lib.rs index ae3a5d9ed148..5ea264247843 100644 --- a/src/sourcemap/lib.rs +++ b/src/sourcemap/lib.rs @@ -855,7 +855,8 @@ pub(crate) fn parse_url( match source[DATA_PREFIX.len()] { b';' => { let after = &source[DATA_PREFIX.len() + 1..]; - let Some(comma) = after.iter().position(|&b| b == b',') else { + let Some(comma) = bun_core::strings::index_of_char_usize(after, b',') + else { break 'try_data_url; }; if &after[..comma] != b"base64" { @@ -1142,11 +1143,7 @@ fn find_source_mapping_url_u8(source: &[u8]) -> Option Option bool { let mut buf = [0u8; 512]; let n = read(fd, &mut buf).unwrap_or(0); let _ = close(fd); - if buf[..n] - .windows(7) - .any(|w| w.eq_ignore_ascii_case(b"freebsd")) - { + if bun_core::strings::contains_case_insensitive_ascii(&buf[..n], b"freebsd") { 2 } else { 1 diff --git a/src/sys/windows/mod.rs b/src/sys/windows/mod.rs index dbdae6c253e2..8375d9f4ea51 100644 --- a/src/sys/windows/mod.rs +++ b/src/sys/windows/mod.rs @@ -1402,7 +1402,7 @@ pub mod rescle { // Basic validation: check format and ranges let mut parts_count: u32 = 0; - for part in v.split(|b| *b == b'.').filter(|s| !s.is_empty()) { + for part in bun_core::strings::tokenize(v, b".") { if parts_count >= 4 { return Err(RescleError::InvalidVersionFormat.into()); } diff --git a/src/which/lib.rs b/src/which/lib.rs index ba340abd8f93..9613ace0d929 100644 --- a/src/which/lib.rs +++ b/src/which/lib.rs @@ -69,7 +69,7 @@ pub fn which_for_spawn<'a>( ) -> Option<&'a ZStr> { #[cfg(windows)] { - let has_sep = bin.iter().any(|&b| b == b'/' || b == b'\\'); + let has_sep = strings::contains_any(bin, b"/\\"); // The NoDefaultCurrentDirectoryInExePath env var is Windows' standard // binary-planting opt-out; libuv gates its cwd search on it via // NeedCurrentDirectoryForExePathW (libuv/libuv#3895), so spawn must too. @@ -166,7 +166,7 @@ pub fn which<'a>(buf: &'a mut PathBuffer, path: &[u8], cwd: &[u8], bin: &[u8]) - } let cwd_for_relative_segment: &[u8] = if is_absolute(cwd) { cwd_trimmed } else { b"" }; - for segment in path.split(|b| *b == DELIMITER).filter(|s| !s.is_empty()) { + for segment in strings::tokenize(path, &[DELIMITER]) { // execvp resolves relative $PATH entries after the child's chdir. let cwd_prefix: &[u8] = if is_absolute(segment) { b"" @@ -235,12 +235,7 @@ pub fn is_batch_file(path: &[u8]) -> bool { /// escape characters in unquoted positions. None of these can be escaped for /// `cmd.exe`, so callers must reject the spawn instead. pub fn batch_arg_has_cmd_metachars(arg: &[u8]) -> bool { - arg.iter().any(|&c| { - matches!( - c, - b'"' | b'%' | b'&' | b'|' | b'<' | b'>' | b'^' | b'\r' | b'\n' - ) - }) + strings::contains_any(arg, b"\"%&|<>^\r\n") } /// Check if the WPathBuffer holds a existing file path, checking also for windows extensions variants like .exe, .cmd and .bat (internally used by which_win) @@ -379,7 +374,7 @@ pub(crate) fn which_win<'a>( } // iterate over system path delimiter - for segment_part in path.split(|b| *b == b';').filter(|s| !s.is_empty()) { + for segment_part in strings::tokenize(path, b";") { // NLL/Polonius limitation — re-borrowing `buf` across loop iterations // when returning a reference tied to its lifetime. // SAFETY: on None the borrow ends; on Some we return immediately. diff --git a/test/bundler/transpiler/transpiler.test.js b/test/bundler/transpiler/transpiler.test.js index f19b5896a37d..75ff51849b6f 100644 --- a/test/bundler/transpiler/transpiler.test.js +++ b/test/bundler/transpiler/transpiler.test.js @@ -2481,6 +2481,29 @@ console.log(
);`), ); }); + it("JSX tag names containing '-' or ':' are string tags regardless of case", () => { + // Matches esbuild/Babel/TypeScript: a dashed (custom element) or namespaced + // name is never a component reference, even when it starts uppercase. + const bun = new Bun.Transpiler({ + loader: "jsx", + define: { + "process.env.NODE_ENV": JSON.stringify("development"), + }, + }); + for (const [tag, expected] of [ + ["Foo-Bar", `"Foo-Bar"`], + ["Ns:Comp", `"Ns:Comp"`], + ["my-el", `"my-el"`], + ["svg:path", `"svg:path"`], + ["Foo", `Foo`], + ["div", `"div"`], + ]) { + expect(bun.transformSync(`export var foo = <${tag} />`)).toBe( + `export var foo = jsxDEV_7x81h0kn(${expected}, {}, undefined, false, undefined, this);\n`, + ); + } + }); + // https://github.com/oven-sh/bun/issues/30958 // A numeric JSX entity outside the Unicode range (0..=0x10FFFF) used to // trip a debug_assert in u16_lead (src/bun_core/lib.rs) when the lexer diff --git a/test/internal/source-lints/byte-search.test.ts b/test/internal/source-lints/byte-search.test.ts new file mode 100644 index 000000000000..893293768a36 --- /dev/null +++ b/test/internal/source-lints/byte-search.test.ts @@ -0,0 +1,163 @@ +import { file } from "bun"; +import { expect, test } from "bun:test"; +import { realpathSync } from "fs"; +import path from "path"; +import { globAllSources } from "../../../scripts/glob-sources.ts"; + +// Byte / substring search over `&[u8]` must go through `bun_core::strings` +// (highway, runtime-dispatched SIMD), not libcore's element-generic slice and +// iterator methods, which compile to one-byte-at-a-time scalar loops (or, for +// `<[u8]>::contains`, a usize-at-a-time SWAR loop with no vector registers). +// +// The methods whose *every* use is a text search (`str::find`, `str::contains`, +// `slice::windows`, `memchr::*`, `bstr::ByteSlice::find*`, ...) are banned +// type-precisely via `disallowed-methods` in clippy.toml. What's left for this +// file are the element-generic forms clippy can't distinguish by element type — +// `<[T]>::contains` and `Iterator::{position,rposition,any,all,find}` — matched +// here only when the comparand is a byte literal, so `ids.contains(&id)` on a +// `&[u32]` never trips it. +// +// .contains(&b'x') / .contains(&0) → strings::contains_char(s, b'x') +// .iter().position(|&b| b == b'x') → strings::index_of_char_usize(s, b'x') +// .iter().position(|&b| b == x || b == y) → strings::index_of_any(s, b"xy") +// .iter().rposition(|&b| b == b'x') → strings::last_index_of_char(s, b'x') +// .iter().any(|&b| b == b'x') → strings::contains_char(s, b'x') +// .iter().all(|&b| b != b'x') → !strings::contains_char(s, b'x') +// .iter().filter(|&&b| b == b'x').count() → strings::count_char(s, b'x') +// .split(|&b| b == b'x') → strings::split(s, b"x") +// +// A comparand held in a variable (`|&b| b == sep`) is invisible to this lint; +// use the `strings::` form anyway. + +const root = path.resolve(import.meta.dir, "..", "..", ".."); +// Proc-macro crates and cargo build scripts run on the host at compile time and +// cannot link the highway C++ objects, so libcore is all they have. Both are +// read off each crate's Cargo.toml rather than guessed from its name. +const hostOnly: string[] = []; +for (const manifest of new Bun.Glob("src/*/Cargo.toml").scanSync({ cwd: root })) { + const dir = path.dirname(manifest).replaceAll(path.sep, "/"); + const toml = await file(path.join(root, manifest)).text(); + if (/^\s*proc-macro\s*=\s*true\b/m.test(toml)) hostOnly.push(dir + "/"); + const build = /^\s*build\s*=\s*"([^"]+)"/m.exec(toml); + hostOnly.push(path.posix.join(dir, build ? build[1] : "build.rs")); +} +const rustSources = globAllSources().rust.filter(abs => { + if (!abs.endsWith(".rs")) return false; + const rel = path.relative(root, abs).replaceAll(path.sep, "/"); + return !hostOnly.some(h => (h.endsWith("/") ? rel.startsWith(h) : rel === h)); +}); + +// Only scan files tracked in HEAD (a `git stash` round-trip can leave stray +// `.rs` files in the working tree; CI runs on a clean checkout). +const tracked: Set | null = (() => { + const r = Bun.spawnSync({ + cmd: ["git", "-C", root, "ls-tree", "-r", "--name-only", "-z", "HEAD"], + stdout: "pipe", + stderr: "ignore", + }); + if (!r.success) return null; + return new Set(r.stdout.toString().split("\0").filter(Boolean)); +})(); + +// A byte literal (`b'x'`, `b'\n'`, `b'\x1b'`, `b'\''`) or a bare `0` (NUL scans). +const BYTE = String.raw`(?:b'(?:[^'\\]|\\.|\\x[0-9a-fA-F]{2})'|0)`; +// Closure header binding one name, by value or by `&`/`&&` pattern: |b|, |&b|, |&&b|, |b: &u8|. +const PARAM = String.raw`\|\s*&{0,2}\s*(\w+)\s*(?::\s*&?\s*u8\s*)?\|`; +// The bound name, optionally dereferenced. +const USE = String.raw`\*{0,2}\s*\1`; +// `b == b'x'`, `b'x' == b`, or `matches!(b, b'x' | b'y')`, optionally chained with `||`. +// (A `matches!` with range patterns like `b'0'..=b'9'` has no set-membership +// equivalent and is deliberately not matched.) +const CMP1 = String.raw`(?:${USE}\s*==\s*${BYTE}|${BYTE}\s*==\s*${USE}|matches!\(\s*${USE}\s*,\s*${BYTE}(?:\s*\|\s*${BYTE})*\s*\))`; +const BODY_EQ = String.raw`\s*(?:${CMP1})(?:\s*\|\|\s*${CMP1})*\s*\)`; +const BODY_NE = String.raw`\s*${USE}\s*!=\s*${BYTE}\s*\)`; + +const BANNED: { name: string; re: RegExp; hint: string }[] = [ + { + name: "<[u8]>::contains(&byte)", + re: new RegExp(String.raw`\.contains\(\s*&\s*${BYTE}\s*\)`, "g"), + hint: "strings::contains_char(s, b)", + }, + { + name: "iter().position(|b| b == byte)", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.position\(\s*${PARAM}${BODY_EQ}`, "g"), + hint: 'strings::index_of_char_usize(s, b) / strings::index_of_any(s, b"..")', + }, + { + name: "iter().rposition(|b| b == byte)", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.rposition\(\s*${PARAM}${BODY_EQ}`, "g"), + hint: "strings::last_index_of_char(s, b)", + }, + { + name: "iter().any(|b| b == byte)", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.any\(\s*${PARAM}${BODY_EQ}`, "g"), + hint: 'strings::contains_char(s, b) / strings::index_of_any(s, b"..").is_some()', + }, + { + name: "iter().all(|b| b != byte)", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.all\(\s*${PARAM}${BODY_NE}`, "g"), + hint: "!strings::contains_char(s, b)", + }, + { + name: "iter().find(|b| b == byte)", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.find\(\s*${PARAM}${BODY_EQ}`, "g"), + hint: "strings::index_of_char_usize(s, b)", + }, + { + name: "iter().filter(|b| b == byte).count()", + re: new RegExp(String.raw`\.(?:iter|bytes)\(\)\s*\.filter\(\s*${PARAM}${BODY_EQ}\s*\.count\(\)`, "g"), + hint: "strings::count_char(s, b)", + }, + { + name: "<[u8]>::split(|b| b == byte)", + re: new RegExp(String.raw`\.split\(\s*${PARAM}${BODY_EQ}`, "g"), + hint: 'strings::split(s, b"x") / strings::split_any(s, b"xy")', + }, +]; + +// Documented, ratcheted exceptions: `file: count`. Prefer converting over +// adding an entry here. +const ALLOW: Record = { + // `#[cfg(test)]` unit test built only by `cargo test -p bun_collections`, + // which does not link the highway objects. + "src/collections/linear_fifo.rs": 1, +}; + +const counts: Record = {}; +const offenders: string[] = []; +let scanned = 0; +for (const abs of rustSources) { + const source = path.relative(root, abs).replaceAll(path.sep, "/"); + // `src/cli` is a symlink into `src/runtime/cli`; count each file once under + // its canonical path. + if (path.relative(root, realpathSync(abs)).replaceAll(path.sep, "/") !== source) continue; + if (tracked !== null && !tracked.has(source)) continue; + scanned++; + const content = await file(abs).text(); + // Blank out full-line comments (keeping the newline) so prose mentions don't + // count and reported line numbers stay accurate. + const stripped = content.replace(/^[ \t]*\/\/.*$/gm, ""); + for (const { name, re, hint } of BANNED) { + for (const m of stripped.matchAll(re)) { + const line = stripped.slice(0, m.index).split("\n").length; + counts[source] = (counts[source] ?? 0) + 1; + if ((counts[source] ?? 0) > (ALLOW[source] ?? 0)) { + offenders.push(`${source}:${line}: ${name}: \`${m[0].replace(/\s+/g, " ")}\` → ${hint}`); + } + } + } +} + +test("scans a non-empty set of tracked Rust sources", () => { + expect(scanned).toBeGreaterThan(0); +}); + +test("byte search goes through bun_core::strings (highway), not libcore scalar loops", () => { + expect(offenders).toEqual([]); +}); + +test("allowlisted files still carry exactly their documented count", () => { + for (const [f, n] of Object.entries(ALLOW)) { + expect(counts[f] ?? 0).toBe(n); + } +}); diff --git a/test/js/bun/util/highway-strings.test.ts b/test/js/bun/util/highway-strings.test.ts new file mode 100644 index 000000000000..15fb5078aca4 --- /dev/null +++ b/test/js/bun/util/highway-strings.test.ts @@ -0,0 +1,244 @@ +// Drives the runtime-dispatched byte-search kernels in +// src/jsc/bindings/highway_strings.cpp (the backend of `bun_core::strings`) +// directly, sweeping haystack lengths across vector-width boundaries and +// misaligned base pointers, and checks each against a scalar reference. + +import { highwayStringsForTesting as hw } from "bun:internal-for-testing"; +import { describe, expect, it } from "bun:test"; + +// Lengths that straddle 16/32/64-byte lanes and the scalar tails on either side. +const LENGTHS = [0, 1, 2, 3, 8, 15, 16, 17, 31, 32, 33, 48, 63, 64, 65, 127, 128, 129, 255, 256, 257, 1024, 1029]; +// Misalign the base pointer so the unaligned-load and prefix/suffix paths run. +const OFFSETS = [0, 1, 13]; + +// Deterministic filler that never contains the bytes we plant (all >= 0x80). +function filler(len: number, offset: number): Uint8Array { + const backing = new Uint8Array(len + offset + 16); + let x = 0x9e3779b9 ^ len ^ (offset << 8); + for (let i = 0; i < backing.length; i++) { + x = (Math.imul(x, 1103515245) + 12345) >>> 0; + backing[i] = 0x80 | (x >>> 24); + } + return backing.subarray(offset, offset + len); +} + +const enc = (s: string) => new TextEncoder().encode(s); + +function refIndexOf(h: Uint8Array, b: number) { + const i = h.indexOf(b); + return i === -1 ? h.length : i; +} +function refLastIndexOf(h: Uint8Array, b: number) { + const i = h.lastIndexOf(b); + return i === -1 ? h.length : i; +} +function refIndexOfNot(h: Uint8Array, b: number) { + for (let i = 0; i < h.length; i++) if (h[i] !== b) return i; + return h.length; +} +function refCount(h: Uint8Array, b: number) { + let n = 0; + for (let i = 0; i < h.length; i++) if (h[i] === b) n++; + return n; +} +function refIndexOfAny(h: Uint8Array, set: Uint8Array) { + for (let i = 0; i < h.length; i++) if (set.includes(h[i])) return i; + return h.length; +} +function refLastIndexOfAny(h: Uint8Array, set: Uint8Array) { + for (let i = h.length - 1; i >= 0; i--) if (set.includes(h[i])) return i; + return h.length; +} +// Naive references (Buffer.indexOf/lastIndexOf are themselves served by these +// kernels, so they can't be the oracle). +function matchesAt(h: Uint8Array, n: Uint8Array, i: number) { + for (let j = 0; j < n.length; j++) if (h[i + j] !== n[j]) return false; + return true; +} +function refMemmem(h: Uint8Array, n: Uint8Array) { + if (n.length === 0) return 0; + for (let i = 0; i + n.length <= h.length; i++) if (matchesAt(h, n, i)) return i; + return -1; +} +function refMemrmem(h: Uint8Array, n: Uint8Array) { + if (n.length === 0) return h.length; + for (let i = h.length - n.length; i >= 0; i--) if (matchesAt(h, n, i)) return i; + return -1; +} + +// Positions worth planting a needle at for a haystack of length `len`: both +// ends, each side of every 16-byte lane boundary, and the middle. +function positions(len: number): number[] { + const set = new Set(); + for (const p of [0, 1, len >> 1, len - 2, len - 1]) if (p >= 0 && p < len) set.add(p); + for (let lane = 16; lane < len + 16; lane += 16) { + for (const p of [lane - 1, lane, lane + 1]) if (p >= 0 && p < len) set.add(p); + } + return [...set].sort((a, b) => a - b); +} + +describe("highway byte-search kernels", () => { + it("indexOfChar / lastIndexOfChar / countChar: absent needle", () => { + for (const len of LENGTHS) { + for (const off of OFFSETS) { + const h = filler(len, off); + const at = `len=${len} off=${off}`; + expect(hw("indexOfChar", h, 0x2c), at).toBe(len); + expect(hw("lastIndexOfChar", h, 0x2c), at).toBe(len); + expect(hw("countChar", h, 0x2c), at).toBe(0); + } + } + }); + + it("indexOfChar / lastIndexOfChar: single planted needle at every interesting position", () => { + for (const len of LENGTHS) { + for (const off of OFFSETS) { + for (const pos of positions(len)) { + const h = filler(len, off); + h[pos] = 0x2c; + const at = `len=${len} off=${off} pos=${pos}`; + expect(hw("indexOfChar", h, 0x2c), at).toBe(pos); + expect(hw("lastIndexOfChar", h, 0x2c), at).toBe(pos); + expect(hw("countChar", h, 0x2c), at).toBe(1); + } + } + } + }); + + it("indexOfChar / lastIndexOfChar / countChar: two needles pick first / last", () => { + for (const len of LENGTHS) { + const ps = positions(len); + for (let i = 0; i < ps.length; i++) { + const h = filler(len, 1); + h[ps[i]] = 0x0a; + h[ps[ps.length - 1 - i]] = 0x0a; + const at = `len=${len} pos=${ps[i]},${ps[ps.length - 1 - i]}`; + expect(hw("indexOfChar", h, 0x0a), at).toBe(refIndexOf(h, 0x0a)); + expect(hw("lastIndexOfChar", h, 0x0a), at).toBe(refLastIndexOf(h, 0x0a)); + expect(hw("countChar", h, 0x0a), at).toBe(refCount(h, 0x0a)); + } + } + }); + + it("countChar: dense input", () => { + for (const len of LENGTHS) { + const h = new Uint8Array(len).fill(0x61); + const at = `len=${len}`; + expect(hw("countChar", h, 0x61), at).toBe(len); + for (let i = 0; i < len; i += 3) h[i] = 0x62; + expect(hw("countChar", h, 0x61), at).toBe(refCount(h, 0x61)); + expect(hw("countChar", h, 0x62), at).toBe(refCount(h, 0x62)); + } + }); + + it("indexOfNotChar: leading run lengths across lane boundaries", () => { + for (const len of LENGTHS) { + for (const off of OFFSETS) { + const all = new Uint8Array(len + off).subarray(off).fill(0x2f); + expect(hw("indexOfNotChar", all, 0x2f), `len=${len} off=${off}`).toBe(len); + for (const run of positions(len)) { + const h = new Uint8Array(len + off).subarray(off).fill(0x2f); + h[run] = 0x61; + expect(hw("indexOfNotChar", h, 0x2f), `len=${len} off=${off} run=${run}`).toBe(refIndexOfNot(h, 0x2f)); + } + } + } + }); + + it("indexOfAny / lastIndexOfAny: 2, 3 and 16-byte sets", () => { + const sets = [enc("\r\n"), enc("/\\:"), enc("0123456789abcdef")]; + for (const set of sets) { + for (const len of LENGTHS) { + for (const off of [0, 13]) { + const none = filler(len, off); + const at0 = `set=${set.length} len=${len} off=${off}`; + expect(hw("indexOfAny", none, set), at0).toBe(len); + expect(hw("lastIndexOfAny", none, set), at0).toBe(len); + for (const pos of positions(len)) { + const h = filler(len, off); + // Alternate which member of the set is planted so every lane compare matters. + h[pos] = set[pos % set.length]; + const at = `${at0} pos=${pos}`; + expect(hw("indexOfAny", h, set), at).toBe(pos); + expect(hw("lastIndexOfAny", h, set), at).toBe(pos); + if (pos + 2 < len) { + h[pos + 2] = set[(pos + 1) % set.length]; + expect(hw("indexOfAny", h, set), at).toBe(refIndexOfAny(h, set)); + expect(hw("lastIndexOfAny", h, set), at).toBe(refLastIndexOfAny(h, set)); + } + } + } + } + } + }); + + it("memmem / memrmem: needle lengths 0..5 and 17, planted across boundaries", () => { + const needles = ["", "a", "ab", "abc", "abcab", "needle-longer-17b"].map(enc); + for (const n of needles) { + for (const len of LENGTHS) { + const none = filler(len, 3); + const at0 = `needle=${n.length} len=${len}`; + expect(hw("memmem", none, n), at0).toBe(refMemmem(none, n)); + expect(hw("memrmem", none, n), at0).toBe(refMemrmem(none, n)); + if (n.length === 0 || n.length > len) continue; + for (const pos of positions(len - n.length + 1)) { + const h = filler(len, 3); + h.set(n, pos); + const at = `${at0} pos=${pos}`; + expect(hw("memmem", h, n), at).toBe(pos); + expect(hw("memrmem", h, n), at).toBe(pos); + // A second copy later on: memmem keeps the first, memrmem takes the last. + const later = len - n.length; + if (later >= pos + n.length) { + h.set(n, later); + expect(hw("memmem", h, n), at).toBe(refMemmem(h, n)); + expect(hw("memrmem", h, n), at).toBe(refMemrmem(h, n)); + } + } + } + } + }); + + it("memmem / memrmem: partial-prefix decoys do not match", () => { + const n = enc("abcd"); + for (const len of [8, 16, 17, 33, 64, 130]) { + const h = filler(len, 0); + // "abc" decoys everywhere, one real "abcd". + for (let i = 0; i + 3 <= len; i += 5) h.set(enc("abc"), i); + expect(hw("memmem", h, n)).toBe(refMemmem(h, n)); + expect(hw("memrmem", h, n)).toBe(refMemrmem(h, n)); + if (len >= 24) { + h.set(n, 19); + expect(hw("memmem", h, n)).toBe(refMemmem(h, n)); + expect(hw("memrmem", h, n)).toBe(refMemrmem(h, n)); + } + } + }); + + it("Buffer.indexOf / lastIndexOf / includes(byte) through the public API", () => { + // These go through JSBuffer.cpp's indexOfNumber (offset/end plumbing) into + // the same kernels; assert against the planted positions, not a Buffer + // method (which would be the code under test). + for (const len of LENGTHS) { + for (const pos of positions(len)) { + const buf = Buffer.from(filler(len, 0)); + const last = pos + 16 < len ? pos + 16 : pos; + buf[pos] = 0x21; + buf[last] = 0x21; + const at = `len=${len} pos=${pos} last=${last}`; + expect(buf.indexOf(0x21), at).toBe(pos); + expect(buf.lastIndexOf(0x21), at).toBe(last); + expect(buf.includes(0x21), at).toBe(true); + expect(buf.includes(0x22), at).toBe(false); + expect(buf.lastIndexOf(Buffer.from("!"), last), at).toBe(last); + if (last !== pos) { + // byteOffset plumbing: start just past the first hit / just before the last one. + expect(buf.indexOf(0x21, pos + 1), at).toBe(last); + expect(buf.lastIndexOf(0x21, last - 1), at).toBe(pos); + } + } + } + expect(Buffer.alloc(0).indexOf(1)).toBe(-1); + expect(Buffer.alloc(0).lastIndexOf(1)).toBe(-1); + }); +}); diff --git a/test/js/bun/util/password.test.ts b/test/js/bun/util/password.test.ts index 5093a3eca027..374b472c830e 100644 --- a/test/js/bun/util/password.test.ts +++ b/test/js/bun/util/password.test.ts @@ -490,6 +490,18 @@ test("verify rejects encoded argon2 hashes with cost parameters above the suppor expect(hugeParallelism).not.toBe(hashed); expect(() => password.verifySync("correct horse", hugeParallelism)).toThrow("WeakParameters"); await expect(password.verify("correct horse", hugeParallelism)).rejects.toThrow("WeakParameters"); + + // The argon2 decoder accepts a leading `+` on the cost fields (Rust's integer + // grammar), so the ceiling check must too rather than skipping the field. + const plusMemory = hashed.replace("$m=8,", "$m=+4294967294,"); + expect(plusMemory).not.toBe(hashed); + expect(() => password.verifySync("correct horse", plusMemory)).toThrow("WeakParameters"); + await expect(password.verify("correct horse", plusMemory)).rejects.toThrow("WeakParameters"); + + // A cost field the decoder can't parse is rejected up front as well. + const junkMemory = hashed.replace("$m=8,", "$m=8x,"); + expect(() => password.verifySync("correct horse", junkMemory)).toThrow("InvalidEncoding"); + await expect(password.verify("correct horse", junkMemory)).rejects.toThrow("InvalidEncoding"); }); test("verifySync reads the password buffer only after every argument has been coerced", () => {