From 3c4998f25d20c2d012d46aeec02ddec89f155eda Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Wed, 7 Oct 2026 10:42:56 +0300 Subject: [PATCH 1/3] perf(stdlib): memchr scan cores, native str.count - Rust: str.count via memchr find_iter (no split temporaries; frozen edge semantics probed first: empty counts chars+1); find/rfind already delegate to memchr-fast std search - Pure-ZZ split-based count removed (would conflict with native entry) - C: zz_str_count twin + memchr-skip upgrade for contains/find/rfind loops (portable memchr+memcmp, same non-overlap semantics) - Fixture: count edge cases folded into str_find_test (e2e + parity); stdlib.md count row added --- Cargo.lock | 1 + crates/zz_codegen/src/lower/mod.rs | 1 + crates/zz_codegen/src/runtime/strings.c | 69 ++++++++++++++++++--- crates/zz_codegen/src/runtime/strings.h | 1 + crates/zz_stdlib/Cargo.toml | 1 + crates/zz_stdlib/src/natives/mod.rs | 14 +++++ crates/zz_stdlib/src/natives/str_mod/mod.rs | 19 ++++++ crates/zz_stdlib/zz/str/mod.zz | 7 +-- docs/stdlib.md | 1 + tests/fixtures/stdlib/str_find_test.zz | 8 +++ 10 files changed, 109 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3d6d571f..a953144d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3765,6 +3765,7 @@ dependencies = [ "crossbeam-deque", "httparse", "libc", + "memchr", "rcgen", "reqwest", "rusqlite", diff --git a/crates/zz_codegen/src/lower/mod.rs b/crates/zz_codegen/src/lower/mod.rs index 0bf36400..2afded45 100644 --- a/crates/zz_codegen/src/lower/mod.rs +++ b/crates/zz_codegen/src/lower/mod.rs @@ -903,6 +903,7 @@ pub(crate) fn native_impl(name: &str) -> Option<&'static str> { "str.to_lower" | "std.str.to_lower" | "str.lower" | "std.str.lower" => Some("zz_str_lower"), "str.to_upper" | "std.str.to_upper" | "str.upper" | "std.str.upper" => Some("zz_str_upper"), "str.replace" | "std.str.replace" => Some("zz_str_replace"), + "str.count" | "std.str.count" => Some("zz_str_count"), "str.contains" | "std.str.contains" => Some("zz_str_contains"), "str.starts_with" | "std.str.starts_with" | "str.startswith" | "std.str.startswith" => { Some("zz_str_startswith") diff --git a/crates/zz_codegen/src/runtime/strings.c b/crates/zz_codegen/src/runtime/strings.c index d1d92623..b96de61d 100644 --- a/crates/zz_codegen/src/runtime/strings.c +++ b/crates/zz_codegen/src/runtime/strings.c @@ -1414,6 +1414,46 @@ zz_value zz_str_replace(zz_value s, zz_value old_s, zz_value new_s, int *err) { return (zz_value){ZZ_STR, {.s = out}}; } +// memchr-skip search core: jump to the next first-needle-byte, then +// verify with memcmp. Portable C89 + memchr, near-memmem speed for +// short needles (the common case). +static const char *scan_skip(const char *h, const char *hend, char first) { + const char *p = h; + while (p < hend) { + const char *hit = (const char *)memchr(p, first, (size_t)(hend - p)); + if (!hit) return hend; + p = hit; + return p; + } + return hend; +} + +// str.count(s, sub) — non-overlapping occurrences, no allocation. +// Empty sub counts chars+1 (matches the split-based version it replaces). +zz_value zz_str_count(zz_value s, zz_value sub, int *err) { + (void)err; + if (s.tag != ZZ_STR || sub.tag != ZZ_STR) return (zz_value){ZZ_INT, {.i = 0}}; + const char *src = zz_str_ptr(s.s); + size_t src_len = s.s->len; + const char *needle = zz_str_ptr(sub.s); + size_t needle_len = sub.s->len; + if (needle_len == 0) return (zz_value){ZZ_INT, {.i = (int64_t)zz_str_char_len(s.s) + 1}}; + int64_t n = 0; + const char *p = src; + const char *end = src + src_len; + while (p + needle_len <= end) { + p = scan_skip(p, end, needle[0]); + if (p + needle_len > end) break; + if (memcmp(p, needle, needle_len) == 0) { + n++; + p += needle_len; + } else { + p++; + } + } + return (zz_value){ZZ_INT, {.i = n}}; +} + // str.contains(s, sub) — check if s contains sub. zz_value zz_str_contains(zz_value s, zz_value sub, int *err) { (void)err; @@ -1423,8 +1463,13 @@ zz_value zz_str_contains(zz_value s, zz_value sub, int *err) { const char *needle = zz_str_ptr(sub.s); size_t needle_len = sub.s->len; if (needle_len == 0) return (zz_value){ZZ_BOOL, {.b = true}}; - for (size_t i = 0; i + needle_len <= src_len; i++) { - if (memcmp(src + i, needle, needle_len) == 0) return (zz_value){ZZ_BOOL, {.b = true}}; + const char *end = src + src_len; + const char *p = src; + while (p + needle_len <= end) { + p = scan_skip(p, end, needle[0]); + if (p + needle_len > end) break; + if (memcmp(p, needle, needle_len) == 0) return (zz_value){ZZ_BOOL, {.b = true}}; + p++; } return (zz_value){ZZ_BOOL, {.b = false}}; } @@ -1453,8 +1498,13 @@ static int64_t find_from(zz_value s, zz_value sub, int64_t from) { size_t base = snap_fwd(src, src_len, (size_t)start); if (needle_len == 0) return (int64_t)base; if (base >= src_len) return -1; - for (size_t i = base; i + needle_len <= src_len; i++) { - if (memcmp(src + i, needle, needle_len) == 0) return (int64_t)i; + const char *end = src + src_len; + const char *p = src + base; + while (p + needle_len <= end) { + p = scan_skip(p, end, needle[0]); + if (p + needle_len > end) break; + if (memcmp(p, needle, needle_len) == 0) return (int64_t)(p - src); + p++; } return -1; } @@ -1469,9 +1519,14 @@ static int64_t rfind_from(zz_value s, zz_value sub, int64_t from) { size_t end = snap_bwd(src, (size_t)end_c); if (needle_len == 0) return (int64_t)end; int64_t best = -1; - for (size_t i = 0; i + needle_len <= src_len; i++) { - if (i > end) break; - if (memcmp(src + i, needle, needle_len) == 0) best = (int64_t)i; + const char *fin = src + src_len; + const char *p = src; + while (p + needle_len <= fin) { + p = scan_skip(p, fin, needle[0]); + if (p + needle_len > fin) break; + if ((size_t)(p - src) > end) break; + if (memcmp(p, needle, needle_len) == 0) best = (int64_t)(p - src); + p++; } return best; } diff --git a/crates/zz_codegen/src/runtime/strings.h b/crates/zz_codegen/src/runtime/strings.h index 412b2b67..84afaf8b 100644 --- a/crates/zz_codegen/src/runtime/strings.h +++ b/crates/zz_codegen/src/runtime/strings.h @@ -191,6 +191,7 @@ zz_value zz_str_length(zz_value s, int *err); zz_value zz_str_lower(zz_value s, int *err); zz_value zz_str_upper(zz_value s, int *err); zz_value zz_str_replace(zz_value s, zz_value old_s, zz_value new_s, int *err); +zz_value zz_str_count(zz_value s, zz_value sub, int *err); zz_value zz_str_contains(zz_value s, zz_value sub, int *err); zz_value zz_str_startswith(zz_value s, zz_value prefix, int *err); zz_value zz_str_endswith(zz_value s, zz_value suffix, int *err); diff --git a/crates/zz_stdlib/Cargo.toml b/crates/zz_stdlib/Cargo.toml index 89c7f515..1c5aa593 100644 --- a/crates/zz_stdlib/Cargo.toml +++ b/crates/zz_stdlib/Cargo.toml @@ -13,6 +13,7 @@ zz_native_rt = { path = "../zz_native_rt" } base64 = "0.22" crossbeam-deque = "0.8" libc = "0.2" +memchr = "2" urlencoding = "2" reqwest = { version = "0.12", features = ["blocking"] } rusqlite = { version = "0.32", features = ["bundled", "column_decltype"] } diff --git a/crates/zz_stdlib/src/natives/mod.rs b/crates/zz_stdlib/src/natives/mod.rs index 170c67fb..d5d8b039 100644 --- a/crates/zz_stdlib/src/natives/mod.rs +++ b/crates/zz_stdlib/src/natives/mod.rs @@ -268,6 +268,13 @@ fn build_stdlib_natives() -> HashMap { f: str_mod::str_contains, }, ); + m.insert( + "std.str.count".into(), + NativeEntry { + arity: 2, + f: str_mod::str_count, + }, + ); m.insert( "std.str.find".into(), NativeEntry { @@ -433,6 +440,13 @@ fn build_stdlib_natives() -> HashMap { f: str_mod::str_contains, }, ); + m.insert( + "str.count".into(), + NativeEntry { + arity: 2, + f: str_mod::str_count, + }, + ); m.insert( "str.find".into(), NativeEntry { diff --git a/crates/zz_stdlib/src/natives/str_mod/mod.rs b/crates/zz_stdlib/src/natives/str_mod/mod.rs index 8f1ec710..515ffa2c 100644 --- a/crates/zz_stdlib/src/natives/str_mod/mod.rs +++ b/crates/zz_stdlib/src/natives/str_mod/mod.rs @@ -111,6 +111,25 @@ fn byte_rfind(s: &str, sub: &str, from: i64) -> i64 { // str.bytes(s) — UTF-8 bytes as plain ints. One O(n) copy; the // result composes with every [int] API (indexing, snapshots, the // bytes.* builder vocabulary). The bridge find/rfind/trim_span need. +// str.count(s, sub) — non-overlapping occurrences, no allocation. +// Edge semantics mirror the old split-based version exactly: +// empty sub counts chars(s)+1 (split inserts between every char plus +// both ends: "abc" -> 4, "" -> 1). +pub(crate) fn str_count( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.count")?; + let sub = expect_str(args, 1, "std.str.count")?; + if sub.is_empty() { + return Ok(Value::Int(s.chars().count() as i64 + 1)); + } + Ok(Value::Int( + memchr::memmem::find_iter(s.as_bytes(), sub.as_bytes()).count() as i64, + )) +} + pub(crate) fn str_bytes( _interp: &mut Interp, args: &mut Vec, diff --git a/crates/zz_stdlib/zz/str/mod.zz b/crates/zz_stdlib/zz/str/mod.zz index ff736034..2fad5ec4 100644 --- a/crates/zz_stdlib/zz/str/mod.zz +++ b/crates/zz_stdlib/zz/str/mod.zz @@ -10,7 +10,7 @@ // - str.repeat uses doubling strategy: O(n log n) instead of O(n^2). // - str.pad_left/right build padding once, concat once: O(k) not O(k^2). -// ── Repetition & counting ──────────────────────────────────────────────────── +// ── Repetition ─────────────────────────────────────────────────────────── func str.repeat(s: str, n: int) -> str { // Doubling strategy: build result in O(log n) concatenations. @@ -31,11 +31,6 @@ func str.repeat(s: str, n: int) -> str { result } -func str.count(s: str, sub: str) -> int { - parts := str.split(s, sub) - n := vec.len(parts) - 1 - n -} // ── Queries ────────────────────────────────────────────────────────────────── diff --git a/docs/stdlib.md b/docs/stdlib.md index 9a082f84..d8ae1996 100644 --- a/docs/stdlib.md +++ b/docs/stdlib.md @@ -73,6 +73,7 @@ import std.str | `str.length` | `str.length(s: str) -> int` | String length | | `str.split` | `str.split(s: str, sep: str) -> [str]` | Split by separator | | `str.contains` | `str.contains(s: str, sub: str) -> bool` | Check substring | +| `str.count` | `str.count(s: str, sub: str) -> int` | Non-overlapping occurrences (empty `sub` counts chars+1) | | `str.trim` | `str.trim(s: str) -> str` | Trim whitespace | | `str.to_upper` | `str.to_upper(s: str) -> str` | Uppercase | | `str.to_lower` | `str.to_lower(s: str) -> str` | Lowercase | diff --git a/tests/fixtures/stdlib/str_find_test.zz b/tests/fixtures/stdlib/str_find_test.zz index aa75867a..c726d9e3 100644 --- a/tests/fixtures/stdlib/str_find_test.zz +++ b/tests/fixtures/stdlib/str_find_test.zz @@ -14,6 +14,8 @@ func main() { caf_pat := "caf" aa_pat := "aa" aaa_str := "aaa" + abc3 := "abc" + aaaa4 := "aaaa" println("find0={str.find(hay, hello_pat, 0)}") println("find6={str.find(hay, hello_pat, 6)}") println("findmiss={str.find(hay, zzz_pat, 0)}") @@ -41,5 +43,11 @@ func main() { println("nbspan={nsp[0]},{nsp[1]}") println("overlap={str.find(aaa_str, aa_pat, 0)}") println("stdspell={std.str.find(hay, moon_pat, 0)}") + aaa3 := "aaa" + println("count1={str.count(aaa3, aa_pat)}") + println("countempty={str.count(abc3, empty_pat)}") + println("countboth={str.count(empty_pat, empty_pat)}") + println("countnohay={str.count(empty_pat, moon_pat)}") + println("countfour={str.count(aaaa4, aa_pat)}") println("str_find_ok") } From 239b46b30fd37e250f4daf2a4b2493db3cf72d2c Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Wed, 7 Oct 2026 12:04:59 +0300 Subject: [PATCH 2/3] fix(ci): drop str.count from KNOWN_CODEGEN_GAPS (C impl now exists) --- crates/zz_codegen/src/tests.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/crates/zz_codegen/src/tests.rs b/crates/zz_codegen/src/tests.rs index 7a8e8ef9..093c4037 100644 --- a/crates/zz_codegen/src/tests.rs +++ b/crates/zz_codegen/src/tests.rs @@ -128,7 +128,6 @@ const KNOWN_CODEGEN_GAPS: &[(&str, &str)] = &[ "numeric constant; lowers to float literal", ), ("std.str.repeat", "pure ZZ; no C codegen"), - ("std.str.count", "pure ZZ; no C codegen"), ("std.str.is_empty", "pure ZZ; no C codegen"), ("std.str.reverse", "pure ZZ; no C codegen"), ("std.str.pad_left", "pure ZZ; no C codegen"), @@ -172,7 +171,6 @@ const KNOWN_CODEGEN_GAPS: &[(&str, &str)] = &[ ("math.mean_f", "pure ZZ alias"), ("math.median_f", "pure ZZ alias"), ("str.repeat", "pure ZZ alias"), - ("str.count", "pure ZZ alias"), ("str.is_empty", "pure ZZ alias"), ("str.reverse", "pure ZZ alias"), ("str.pad_left", "pure ZZ alias"), From 413f3c109ce0eb284d4e7031b2b6a2e475d439c9 Mon Sep 17 00:00:00 2001 From: zaidejjo Date: Wed, 7 Oct 2026 12:15:34 +0300 Subject: [PATCH 3/3] fix(ci): list str_find/str_bytes fixtures in batched parity inventory --- crates/zz_cli/tests/batch_lists.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/zz_cli/tests/batch_lists.rs b/crates/zz_cli/tests/batch_lists.rs index 07f41c18..4d142645 100644 --- a/crates/zz_cli/tests/batch_lists.rs +++ b/crates/zz_cli/tests/batch_lists.rs @@ -47,6 +47,8 @@ pub const ELIGIBLE: &[(&str, &str, bool)] = &[ ("stdlib", "vec_nested_str_parity.zz", true), ("stdlib", "console.zz", false), ("stdlib", "str_extended_test.zz", false), + ("stdlib", "str_find_test.zz", true), + ("stdlib", "str_bytes_test.zz", true), ("syntax", "match.zz", false), ("syntax", "match_assign.zz", true), ("syntax", "return_in_loops.zz", false),