diff --git a/crates/zz_cli/tests/dual_engine_parity.rs b/crates/zz_cli/tests/dual_engine_parity.rs index a3513e29..8a2f187d 100644 --- a/crates/zz_cli/tests/dual_engine_parity.rs +++ b/crates/zz_cli/tests/dual_engine_parity.rs @@ -1135,6 +1135,7 @@ parity_strict!( "stdlib", "vec_nested_str_parity.zz" ); +parity_strict!(parity_stdlib_str_find_test, "stdlib", "str_find_test.zz"); parity_strict!(parity_stdlib_console, "stdlib", "console.zz"); parity_strict!(parity_stdlib_envmod, "stdlib", "envmod.zz"); parity_strict!(parity_stdlib_env_test, "stdlib", "env_test.zz"); diff --git a/crates/zz_cli/tests/e2e.rs b/crates/zz_cli/tests/e2e.rs index f951aefe..50b8ddcd 100644 --- a/crates/zz_cli/tests/e2e.rs +++ b/crates/zz_cli/tests/e2e.rs @@ -612,6 +612,7 @@ e2e_success_test!( "stdlib", "str_extended_test.zz" ); +e2e_success_test!(e2e_stdlib_str_find_test, "stdlib", "str_find_test.zz"); e2e_success_test!(e2e_stdlib_net_tcp_test, "stdlib", "net_tcp_test.zz"); e2e_success_test!(e2e_stdlib_input_chained, "stdlib", "input_chained.zz"); e2e_success_test!(e2e_stdlib_http_client_test, "stdlib", "http_client_test.zz"); diff --git a/crates/zz_codegen/src/lower/mod.rs b/crates/zz_codegen/src/lower/mod.rs index 0371a70b..21171d31 100644 --- a/crates/zz_codegen/src/lower/mod.rs +++ b/crates/zz_codegen/src/lower/mod.rs @@ -915,6 +915,11 @@ pub(crate) fn native_impl(name: &str) -> Option<&'static str> { "str.trim_end" | "std.str.trim_end" => Some("zz_str_trim_end"), "str.join" | "std.str.join" => Some("zz_str_join"), "str.split" | "std.str.split" => Some("zz_str_split"), + "str.find" | "std.str.find" => Some("zz_str_find"), + "str.rfind" | "std.str.rfind" => Some("zz_str_rfind"), + "str.starts_with_at" | "std.str.starts_with_at" => Some("zz_str_starts_with_at"), + "str.ends_with_at" | "std.str.ends_with_at" => Some("zz_str_ends_with_at"), + "str.trim_span" | "std.str.trim_span" => Some("zz_str_trim_span"), // math "math.abs" | "std.math.abs" => Some("zz_math_abs"), "math.sqrt" | "std.math.sqrt" => Some("zz_math_sqrt"), diff --git a/crates/zz_codegen/src/runtime/strings.c b/crates/zz_codegen/src/runtime/strings.c index 5b015b7f..ce1f1d4d 100644 --- a/crates/zz_codegen/src/runtime/strings.c +++ b/crates/zz_codegen/src/runtime/strings.c @@ -1429,7 +1429,159 @@ zz_value zz_str_contains(zz_value s, zz_value sub, int *err) { return (zz_value){ZZ_BOOL, {.b = false}}; } -// str.startswith(s, prefix) +// Byte-offset search helpers. Contract is byte offsets (O(1) per call, +// backend-identical on every input); matches can only start at char +// boundaries, so non-boundary positions snap (ceil for find, floor for +// rfind/tail checks) and empty patterns return the clamped position. +static size_t snap_fwd(const char *p, size_t len, size_t pos) { + while (pos < len && ((unsigned char)p[pos] & 0xC0) == 0x80) pos++; + return pos; +} + +static size_t snap_bwd(const char *p, size_t pos) { + while (pos > 0 && ((unsigned char)p[pos] & 0xC0) == 0x80) pos--; + return pos; +} + +static int64_t find_from(zz_value s, zz_value sub, int64_t from) { + 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; + int64_t start = from < 0 ? 0 : from; + if ((uint64_t)start > src_len) start = (int64_t)src_len; + 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; + } + return -1; +} + +static int64_t rfind_from(zz_value s, zz_value sub, int64_t from) { + 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; + int64_t end_c = from < 0 ? 0 : from; + if ((uint64_t)end_c > src_len) end_c = (int64_t)src_len; + 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; + } + return best; +} + +// str.find(s, sub, from) — first match at/after byte offset `from`. +zz_value zz_str_find(zz_value s, zz_value sub, zz_value from, int *err) { + (void)err; + if (s.tag != ZZ_STR || sub.tag != ZZ_STR) return (zz_value){ZZ_INT, {.i = -1}}; + int64_t f = from.tag == ZZ_INT ? from.i : 0; + return (zz_value){ZZ_INT, {.i = find_from(s, sub, f)}}; +} + +// str.rfind(s, sub, from) — last match starting at/before `from`. +zz_value zz_str_rfind(zz_value s, zz_value sub, zz_value from, int *err) { + (void)err; + if (s.tag != ZZ_STR || sub.tag != ZZ_STR) return (zz_value){ZZ_INT, {.i = -1}}; + int64_t f = from.tag == ZZ_INT ? from.i : 0; + return (zz_value){ZZ_INT, {.i = rfind_from(s, sub, f)}}; +} + +// str.starts_with_at(s, sub, pos) — match at byte offset, else false. +// Both window edges must sit on char boundaries (a partial char can +// never equal a valid pattern's bytes). +zz_value zz_str_starts_with_at(zz_value s, zz_value sub, zz_value pos, int *err) { + (void)err; + if (s.tag != ZZ_STR || sub.tag != ZZ_STR || pos.tag != ZZ_INT) return (zz_value){ZZ_BOOL, {.b = false}}; + size_t n = sub.s->len; + if (n == 0) return (zz_value){ZZ_BOOL, {.b = false}}; + if (pos.i < 0 || (uint64_t)pos.i + n > s.s->len) return (zz_value){ZZ_BOOL, {.b = false}}; + size_t base = (size_t)pos.i; + const char *src = zz_str_ptr(s.s); + if (snap_bwd(src, base) != base || snap_bwd(src, base + n) != base + n) + return (zz_value){ZZ_BOOL, {.b = false}}; + return (zz_value){ZZ_BOOL, {.b = memcmp(src + base, zz_str_ptr(sub.s), n) == 0}}; +} + +// str.ends_with_at(s, sub, pos) — match ending at byte offset `pos`. +zz_value zz_str_ends_with_at(zz_value s, zz_value sub, zz_value pos, int *err) { + (void)err; + if (s.tag != ZZ_STR || sub.tag != ZZ_STR || pos.tag != ZZ_INT) return (zz_value){ZZ_BOOL, {.b = false}}; + size_t n = sub.s->len; + if (n == 0) return (zz_value){ZZ_BOOL, {.b = false}}; + if (pos.i < 0 || (uint64_t)pos.i > s.s->len || (uint64_t)pos.i < n) return (zz_value){ZZ_BOOL, {.b = false}}; + size_t base = (size_t)pos.i - n; + const char *src = zz_str_ptr(s.s); + if (snap_bwd(src, base) != base || snap_bwd(src, (size_t)pos.i) != (size_t)pos.i) + return (zz_value){ZZ_BOOL, {.b = false}}; + return (zz_value){ZZ_BOOL, {.b = memcmp(src + base, zz_str_ptr(sub.s), n) == 0}}; +} + +// Width of whitespace at d[pos] (0 if none): ASCII ws plus the +// Unicode White_Space sequences. Explicit table so both backends agree +// (never the host trim). +static size_t ws_width_fwd(const unsigned char *d, size_t pos, size_t end) { + if (pos >= end) return 0; + unsigned char c = d[pos]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\x0b' || c == '\x0c' || c == '\r') return 1; + if (c == 0xC2 && pos + 1 < end && (d[pos + 1] == 0x85 || d[pos + 1] == 0xA0)) return 2; + if (c == 0xE1 && pos + 2 < end && d[pos + 1] == 0x9A && d[pos + 2] == 0x80) return 3; + if (c == 0xE2 && pos + 2 < end && d[pos + 1] == 0x80) { + unsigned char e = d[pos + 2]; + if ((e >= 0x80 && e <= 0x8A) || e == 0xA8 || e == 0xA9 || e == 0xAF) return 3; + } + if (c == 0xE2 && pos + 2 < end && d[pos + 1] == 0x81 && d[pos + 2] == 0x9F) return 3; + if (c == 0xE3 && pos + 2 < end && d[pos + 1] == 0x80 && d[pos + 2] == 0x80) return 3; + return 0; +} + +static size_t ws_width_bwd(const unsigned char *d, size_t s, size_t end) { + if (end <= s) return 0; + unsigned char c = d[end - 1]; + if (c == ' ' || c == '\t' || c == '\n' || c == '\x0b' || c == '\x0c' || c == '\r') return 1; + if (end - 2 >= s && d[end - 2] == 0xC2 && (c == 0x85 || c == 0xA0)) return 2; + if (end - 3 >= s && d[end - 3] == 0xE1 && d[end - 2] == 0x9A && c == 0x80) return 3; + if (end - 3 >= s && d[end - 3] == 0xE2 && d[end - 2] == 0x80) { + if ((c >= 0x80 && c <= 0x8A) || c == 0xA8 || c == 0xA9 || c == 0xAF) return 3; + } + if (end - 3 >= s && d[end - 3] == 0xE2 && d[end - 2] == 0x81 && c == 0x9F) return 3; + if (end - 3 >= s && d[end - 3] == 0xE3 && d[end - 2] == 0x80 && c == 0x80) return 3; + return 0; +} + +// str.trim_span(s, start, end) — trimmed [lo, hi] byte offsets. Unicode +// White_Space on both backends (explicit table, never the host trim). +zz_value zz_str_trim_span(zz_value s, zz_value start, zz_value end, int *err) { + (void)err; + zz_value arr = zz_array_new(); + int sub_err = 0; + int64_t lo = 0, hi = 0; + if (s.tag == ZZ_STR) { + const unsigned char *d = (const unsigned char *)zz_str_ptr(s.s); + size_t n = s.s->len; + int64_t a = start.tag == ZZ_INT ? start.i : 0; + int64_t b = end.tag == ZZ_INT ? end.i : 0; + if (a < 0) a = 0; + if ((uint64_t)a > n) a = (int64_t)n; + if (b < 0) b = 0; + if ((uint64_t)b > n) b = (int64_t)n; + lo = a; + hi = b > a ? b : a; + size_t w; + while ((size_t)lo < (size_t)hi && (w = ws_width_fwd(d, (size_t)lo, (size_t)hi)) != 0) lo += (int64_t)w; + while (hi > lo && (w = ws_width_bwd(d, (size_t)lo, (size_t)hi)) != 0) hi -= (int64_t)w; + } + zz_vec_append(arr, (zz_value){ZZ_INT, {.i = lo}}, &sub_err); + zz_vec_append(arr, (zz_value){ZZ_INT, {.i = hi}}, &sub_err); + return arr; +} + +// str.startswith(s, prefix)// str.startswith(s, prefix) zz_value zz_str_startswith(zz_value s, zz_value prefix, int *err) { (void)err; if (s.tag != ZZ_STR || prefix.tag != ZZ_STR) return (zz_value){ZZ_BOOL, {.b = false}}; diff --git a/crates/zz_codegen/src/runtime/strings.h b/crates/zz_codegen/src/runtime/strings.h index 11ad7ed6..11a0ad93 100644 --- a/crates/zz_codegen/src/runtime/strings.h +++ b/crates/zz_codegen/src/runtime/strings.h @@ -199,6 +199,11 @@ zz_value zz_str_trim_start(zz_value s, int *err); zz_value zz_str_trim_end(zz_value s, int *err); zz_value zz_str_join(zz_value items, zz_value sep, int *err); zz_value zz_str_split(zz_value s, zz_value sep, int *err); +zz_value zz_str_find(zz_value s, zz_value sub, zz_value from, int *err); +zz_value zz_str_rfind(zz_value s, zz_value sub, zz_value from, int *err); +zz_value zz_str_starts_with_at(zz_value s, zz_value sub, zz_value pos, int *err); +zz_value zz_str_ends_with_at(zz_value s, zz_value sub, zz_value pos, int *err); +zz_value zz_str_trim_span(zz_value s, zz_value start, zz_value end, int *err); // ---- string casts ------------------------------------------------------ zz_value zz_str_from_int(int64_t n); diff --git a/crates/zz_stdlib/src/funcs.rs b/crates/zz_stdlib/src/funcs.rs index 30ee686f..0263bfe9 100644 --- a/crates/zz_stdlib/src/funcs.rs +++ b/crates/zz_stdlib/src/funcs.rs @@ -188,6 +188,41 @@ fn build_stdlib_funcs() -> HashMap { "std.str.contains".into(), sig(vec![("s", Type::Str), ("sub", Type::Str)], Type::Bool), ); + m.insert( + "std.str.find".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("from", Type::Int)], + Type::Int, + ), + ); + m.insert( + "std.str.rfind".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("from", Type::Int)], + Type::Int, + ), + ); + m.insert( + "std.str.starts_with_at".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("pos", Type::Int)], + Type::Bool, + ), + ); + m.insert( + "std.str.ends_with_at".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("pos", Type::Int)], + Type::Bool, + ), + ); + m.insert( + "std.str.trim_span".into(), + sig( + vec![("s", Type::Str), ("start", Type::Int), ("end", Type::Int)], + Type::Array(Box::new(Type::Int)), + ), + ); // Canonical `std.str.*` twins of the method-dispatch entries below, so // selective imports (`import std.str(trim)`) and qualified calls // (`std.str.trim(s)`) resolve. Signatures mirror `str.*` exactly. @@ -259,6 +294,41 @@ fn build_stdlib_funcs() -> HashMap { "str.contains".into(), sig(vec![("s", Type::Str), ("sub", Type::Str)], Type::Bool), ); + m.insert( + "str.find".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("from", Type::Int)], + Type::Int, + ), + ); + m.insert( + "str.rfind".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("from", Type::Int)], + Type::Int, + ), + ); + m.insert( + "str.starts_with_at".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("pos", Type::Int)], + Type::Bool, + ), + ); + m.insert( + "str.ends_with_at".into(), + sig( + vec![("s", Type::Str), ("sub", Type::Str), ("pos", Type::Int)], + Type::Bool, + ), + ); + m.insert( + "str.trim_span".into(), + sig( + vec![("s", Type::Str), ("start", Type::Int), ("end", Type::Int)], + Type::Array(Box::new(Type::Int)), + ), + ); m.insert( "str.replace".into(), sig( @@ -4144,10 +4214,20 @@ mod tests { "time.format_rfc3339", "std.time.add_days", "time.add_days", + "std.str.starts_with_at", + "str.starts_with_at", + "std.str.ends_with_at", + "str.ends_with_at", + "std.str.trim_span", + "str.trim_span", + "std.str.find", + "str.find", + "std.str.rfind", + "str.rfind", ] { assert!(funcs.contains_key(name), "missing {name}"); } - assert_eq!(funcs.len(), 878); + assert_eq!(funcs.len(), 888); } #[test] diff --git a/crates/zz_stdlib/src/natives/mod.rs b/crates/zz_stdlib/src/natives/mod.rs index 8acff4e4..b64adde8 100644 --- a/crates/zz_stdlib/src/natives/mod.rs +++ b/crates/zz_stdlib/src/natives/mod.rs @@ -226,6 +226,62 @@ fn build_stdlib_natives() -> HashMap { f: str_mod::str_contains, }, ); + m.insert( + "std.str.find".into(), + NativeEntry { + arity: 3, + f: str_mod::str_find, + }, + ); + m.insert( + "std.str.rfind".into(), + NativeEntry { + arity: 3, + f: str_mod::str_rfind, + }, + ); + m.insert( + "std.str.starts_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_starts_with_at, + }, + ); + m.insert( + "std.str.ends_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_ends_with_at, + }, + ); + m.insert( + "std.str.trim_span".into(), + NativeEntry { + arity: 3, + f: str_mod::str_trim_span, + }, + ); + m.insert( + "std.str.starts_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_starts_with_at, + }, + ); + m.insert( + "std.str.ends_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_ends_with_at, + }, + ); + m.insert( + "std.str.trim_span".into(), + NativeEntry { + arity: 3, + f: str_mod::str_trim_span, + }, + ); // Canonical `std.str.*` twins of the method-dispatch entries below. // Same implementations, qualified names — keeps selective imports // (`import std.str(trim)`) and the runtime registry in lockstep. @@ -335,6 +391,62 @@ fn build_stdlib_natives() -> HashMap { f: str_mod::str_contains, }, ); + m.insert( + "str.find".into(), + NativeEntry { + arity: 3, + f: str_mod::str_find, + }, + ); + m.insert( + "str.rfind".into(), + NativeEntry { + arity: 3, + f: str_mod::str_rfind, + }, + ); + m.insert( + "str.starts_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_starts_with_at, + }, + ); + m.insert( + "str.ends_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_ends_with_at, + }, + ); + m.insert( + "str.trim_span".into(), + NativeEntry { + arity: 3, + f: str_mod::str_trim_span, + }, + ); + m.insert( + "str.starts_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_starts_with_at, + }, + ); + m.insert( + "str.ends_with_at".into(), + NativeEntry { + arity: 3, + f: str_mod::str_ends_with_at, + }, + ); + m.insert( + "str.trim_span".into(), + NativeEntry { + arity: 3, + f: str_mod::str_trim_span, + }, + ); m.insert( "str.replace".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 6d84c87b..7d694bbe 100644 --- a/crates/zz_stdlib/src/natives/str_mod/mod.rs +++ b/crates/zz_stdlib/src/natives/str_mod/mod.rs @@ -1,4 +1,4 @@ -use crate::natives::{arg, expect_str}; +use crate::natives::{arg, expect_int, expect_str}; use zz_runtime::{EvalError, Interp, Span, Value}; pub(crate) fn str_length( @@ -34,6 +34,235 @@ pub(crate) fn str_contains( Ok(Value::Bool(s.contains(&sub))) } +pub(crate) fn str_find( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.find")?; + let sub = expect_str(args, 1, "std.str.find")?; + let from = expect_int(args, 2, "std.str.find")?; + Ok(Value::Int(byte_find(&s, &sub, from))) +} + +pub(crate) fn str_rfind( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.rfind")?; + let sub = expect_str(args, 1, "std.str.rfind")?; + let from = expect_int(args, 2, "std.str.rfind")?; + Ok(Value::Int(byte_rfind(&s, &sub, from))) +} + +// Byte offsets throughout: O(1) per call, O(n) streaming total, and +// backend-identical on every input (no char-counting divergence on +// invalid sequences). Matches Rust str::find/rfind byte semantics. +// `len()`/slicing stay char-oriented; convert explicitly when mixing. +// Empty `sub` returns clamped `from`; negatives clamp to 0. +fn byte_find(s: &str, sub: &str, from: i64) -> i64 { + let bytes = s.as_bytes(); + let n = bytes.len() as i64; + let mut start = from.clamp(0, n) as usize; + while start < bytes.len() && !s.is_char_boundary(start) { + start += 1; + } + if sub.is_empty() { + return start.min(bytes.len()) as i64; + } + if start >= bytes.len() { + return -1; + } + match s[start..].find(sub) { + Some(rel) => start as i64 + rel as i64, + None => -1, + } +} + +fn byte_rfind(s: &str, sub: &str, from: i64) -> i64 { + let bytes = s.as_bytes(); + let n = bytes.len() as i64; + let mut end = from.clamp(0, n) as usize; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + if sub.is_empty() { + return end as i64; + } + // A match starting exactly at `end` may extend past it. + let mut window = end + sub.len(); + if window > bytes.len() { + window = bytes.len(); + } + while window > end && !s.is_char_boundary(window) { + window -= 1; + } + let mut best: Option = s[..window].rfind(sub).map(|b| b as i64); + if s.is_char_boundary(end) && end + sub.len() <= bytes.len() && s[end..].starts_with(sub) { + best = Some(match best { + Some(prev) => prev.max(end as i64), + None => end as i64, + }); + } + best.unwrap_or(-1) +} + +pub(crate) fn str_starts_with_at( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.starts_with_at")?; + let sub = expect_str(args, 1, "std.str.starts_with_at")?; + let pos = expect_int(args, 2, "std.str.starts_with_at")?; + Ok(Value::Bool(starts_at(&s, &sub, pos))) +} + +pub(crate) fn str_ends_with_at( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.ends_with_at")?; + let sub = expect_str(args, 1, "std.str.ends_with_at")?; + let pos = expect_int(args, 2, "std.str.ends_with_at")?; + Ok(Value::Bool(ends_at(&s, &sub, pos))) +} + +pub(crate) fn str_trim_span( + _interp: &mut Interp, + args: &mut Vec, + _span: Span, +) -> Result { + let s = expect_str(args, 0, "std.str.trim_span")?; + let start = expect_int(args, 1, "std.str.trim_span")?; + let end = expect_int(args, 2, "std.str.trim_span")?; + let (ns, ne) = trim_span(&s, start, end); + Ok(Value::Array(Box::new(vec![Value::Int(ns), Value::Int(ne)]))) +} + +fn starts_at(s: &str, sub: &str, pos: i64) -> bool { + if sub.is_empty() { + return false; + } + let bytes = s.as_bytes(); + if pos < 0 || pos as usize + sub.len() > bytes.len() { + return false; + } + let base = pos as usize; + // Floor: a match may only start at a char boundary, and the window + // must end at one too (slicing panics mid-char). + if !s.is_char_boundary(base) || !s.is_char_boundary(base + sub.len()) { + return false; + } + &s[base..base + sub.len()] == sub +} + +// True when `sub` ends at byte offset `pos` (exclusive end). +fn ends_at(s: &str, sub: &str, pos: i64) -> bool { + if sub.is_empty() { + return false; + } + let bytes = s.as_bytes(); + if pos < 0 || pos as usize > bytes.len() || (pos as usize) < sub.len() { + return false; + } + let base = (pos as usize) - sub.len(); + if !s.is_char_boundary(base) || !s.is_char_boundary(pos as usize) { + return false; + } + &s[base..pos as usize] == sub +} + +// Trimmed span of s[start..end] as byte offsets. Unicode White_Space on +// BOTH backends (explicit table, not the host trim): new API, no +// back-compat baggage, and backend-identical by construction. +fn trim_span(s: &str, start: i64, end: i64) -> (i64, i64) { + let bytes = s.as_bytes(); + let n = bytes.len() as i64; + let mut lo = start.clamp(0, n); + let hi0 = end.clamp(0, n); + let mut hi = hi0.max(lo); + while lo < hi { + let w = ws_width(bytes, lo as usize, hi as usize); + if w == 0 { + break; + } + lo += w as i64; + } + while hi > lo { + let w = ws_width_back(bytes, lo as usize, hi as usize); + if w == 0 { + break; + } + hi -= w as i64; + } + (lo, hi) +} + +// Width of whitespace at byte pos (0 if none): ASCII ws plus the +// Unicode White_Space sequences (NBSP, NEL, Ogham, En/Em quad, +// line/paragraph separators, narrow nbsp, medium space, ideographic). +fn ws_width(b: &[u8], pos: usize, end: usize) -> usize { + if pos >= end { + return 0; + } + let c = b[pos]; + if c == 32 || c == 9 || c == 10 || c == 11 || c == 12 || c == 13 { + return 1; + } + if c == 0xC2 && pos + 1 < end && (b[pos + 1] == 0x85 || b[pos + 1] == 0xA0) { + return 2; + } + if c == 0xE1 && pos + 2 < end && b[pos + 1] == 0x9A && b[pos + 2] == 0x80 { + return 3; + } + if c == 0xE2 && pos + 2 < end && b[pos + 1] == 0x80 { + let d = b[pos + 2]; + if (0x80..=0x8A).contains(&d) || d == 0xA8 || d == 0xA9 || d == 0xAF { + return 3; + } + } + if c == 0xE2 && pos + 2 < end && b[pos + 1] == 0x81 && b[pos + 2] == 0x9F { + return 3; + } + if c == 0xE3 && pos + 2 < end && b[pos + 1] == 0x80 && b[pos + 2] == 0x80 { + return 3; + } + 0 +} + +fn ws_width_back(b: &[u8], s: usize, end: usize) -> usize { + if end <= s { + return 0; + } + let c = b[end - 1]; + if c == 32 || c == 9 || c == 10 || c == 11 || c == 12 || c == 13 { + return 1; + } + if end - 2 >= s && b[end - 2] == 0xC2 && (c == 0x85 || c == 0xA0) { + return 2; + } + if end - 3 >= s && b[end - 3] == 0xE1 && b[end - 2] == 0x9A && c == 0x80 { + return 3; + } + if end - 3 >= s + && b[end - 3] == 0xE2 + && b[end - 2] == 0x80 + && ((0x80..=0x8A).contains(&c) || c == 0xA8 || c == 0xA9 || c == 0xAF) + { + return 3; + } + if end - 3 >= s && b[end - 3] == 0xE2 && b[end - 2] == 0x81 && c == 0x9F { + return 3; + } + if end - 3 >= s && b[end - 3] == 0xE3 && b[end - 2] == 0x80 && c == 0x80 { + return 3; + } + 0 +} + pub(crate) fn str_trim( _interp: &mut Interp, args: &mut Vec, diff --git a/docs/stdlib.md b/docs/stdlib.md index fe285ecf..61a389d7 100644 --- a/docs/stdlib.md +++ b/docs/stdlib.md @@ -79,6 +79,17 @@ import std.str | `str.replace` | `str.replace(s: str, old: str, new: str) -> str` | Replace substring | | `str.starts_with` | `str.starts_with(s: str, prefix: str) -> bool` | Check prefix | | `str.ends_with` | `str.ends_with(s: str, suffix: str) -> bool` | Check suffix | +| `str.find` | `str.find(s: str, sub: str, from: int) -> int` | First match at/after byte offset `from` (-1 on miss) | +| `str.rfind` | `str.rfind(s: str, sub: str, from: int) -> int` | Last match starting at/before `from` (-1 on miss) | +| `str.starts_with_at` | `str.starts_with_at(s: str, sub: str, pos: int) -> bool` | Match at byte offset (empty never matches) | +| `str.ends_with_at` | `str.ends_with_at(s: str, sub: str, pos: int) -> bool` | Match ending at byte offset `pos` | +| `str.trim_span` | `str.trim_span(s: str, start: int, end: int) -> [int]` | Trimmed `[lo, hi]` byte offsets (Unicode ws, both backends) | + +Offsets are bytes (O(1) per call, O(n) streaming total; matches Rust +`str::find` semantics). `length`/slicing stay char-oriented — convert +explicitly when mixing. Negative inputs clamp to 0; empty `sub` returns +the clamped position for `find`/`rfind` and never matches for `_at`. +`trim_span` strips Unicode White_Space identically on VM and AOT. ```zz import std.str @@ -91,6 +102,12 @@ str.split("a,b,c", ",") // ["a", "b", "c"] str.replace("foo bar", "bar", "baz") // "foo baz" str.starts_with("hello", "he") // true str.ends_with("hello", "lo") // true +str.find("hello world", "o", 0) // 4 +str.find("hello world", "o", 5) // 7 +str.rfind("hello world", "o", 10) // 7 +str.starts_with_at("hello", "ell", 1) // true +str.ends_with_at("hello", "ell", 4) // true +str.trim_span(" hi ", 0, 6) // [2, 4] ``` ### Method Call Syntax diff --git a/tests/fixtures/stdlib/str_find_test.zz b/tests/fixtures/stdlib/str_find_test.zz new file mode 100644 index 00000000..aa75867a --- /dev/null +++ b/tests/fixtures/stdlib/str_find_test.zz @@ -0,0 +1,45 @@ +// features: imports, locals, print, std-str, string-literal +// E2E: std.str.find/rfind — char offsets, clamps, empty patterns, unicode. +import std.str + + +func main() { + hay := "hello world, hello moon" + hello_pat := "hello" + moon_pat := "moon" + zzz_pat := "zzz" + empty_pat := "" + uni := "café constaté" + constat_pat := "constat" + caf_pat := "caf" + aa_pat := "aa" + aaa_str := "aaa" + println("find0={str.find(hay, hello_pat, 0)}") + println("find6={str.find(hay, hello_pat, 6)}") + println("findmiss={str.find(hay, zzz_pat, 0)}") + println("findpast={str.find(hay, moon_pat, 100)}") + println("findneg={str.find(hay, hello_pat, -5)}") + println("findempty={str.find(hay, empty_pat, 7)}") + println("findemptybig={str.find(hay, empty_pat, 999)}") + println("rfind0={str.rfind(hay, hello_pat, 24)}") + println("rfind6={str.rfind(hay, hello_pat, 6)}") + println("rfindmiss={str.rfind(hay, zzz_pat, 24)}") + println("rfindempty={str.rfind(hay, empty_pat, 7)}") + println("unifind={str.find(uni, constat_pat, 0)}") + println("unirfind={str.rfind(uni, caf_pat, 14)}") + println("swat0={str.starts_with_at(hay, hello_pat, 0)}") + println("swat1={str.starts_with_at(hay, hello_pat, 1)}") + println("swat13={str.starts_with_at(hay, hello_pat, 13)}") + println("swatmid={str.starts_with_at(uni, caf_pat, 1)}") + println("ewat5={str.ends_with_at(hay, hello_pat, 5)}") + println("ewat4={str.ends_with_at(hay, hello_pat, 4)}") + println("ewat18={str.ends_with_at(hay, hello_pat, 18)}") + tsp := str.trim_span(" hi ", 0, 6) + println("trimspan={tsp[0]},{tsp[1]}") + nbsp_str := "  X  " + nsp := str.trim_span(nbsp_str, 0, 99) + println("nbspan={nsp[0]},{nsp[1]}") + println("overlap={str.find(aaa_str, aa_pat, 0)}") + println("stdspell={std.str.find(hay, moon_pat, 0)}") + println("str_find_ok") +}