From 8d093a24a9b61dccd5b53000fc7d4b3535fac720 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Wed, 2 Sep 2026 10:25:31 +0200 Subject: [PATCH] fix(strings): count and slice strings by Unicode code point str_length and str_slice used UTF-8 byte offsets, so any input with multi-byte characters produced wrong results: str_length("cafe") with an accented final character returned 5 instead of 4, and str_slice(0, 4, ...) on the same string returned an empty string because the byte index fell inside the encoding of the last character. This also broke the higher-level helpers built on top of them (str_find, str_contains, str_replace). Count characters with chars().count() and index str_slice by character. Out-of-range slices still return an empty string, so behavior for ASCII input is unchanged. --- examples/tests/strings.nbt | 8 ++++++++ numbat/src/ffi/strings.rs | 13 ++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/examples/tests/strings.nbt b/examples/tests/strings.nbt index 2678dee..be01dec 100644 --- a/examples/tests/strings.nbt +++ b/examples/tests/strings.nbt @@ -1,11 +1,19 @@ assert_eq(str_length(""), 0) assert_eq(str_length("foo"), 3) +# Length and slicing operate on Unicode code points, not on UTF-8 bytes +assert_eq(str_length("café"), 4) +assert_eq(str_length("❤"), 1) + assert_eq(str_slice(0, 5, "hello world"), "hello") assert_eq(str_slice(6, 11, "hello world"), "world") assert_eq(str_slice(0, 0, "hello world"), "") assert_eq(str_slice(0, 100, "hello world"), "") +assert_eq(str_slice(0, 4, "café"), "café") +assert_eq(str_slice(3, 4, "café"), "é") +assert_eq(str_slice(0, 3, "café"), "caf") + assert_eq(chr(65), "A") assert_eq(chr(97), "a") assert_eq(chr(0x2764), "❤") diff --git a/numbat/src/ffi/strings.rs b/numbat/src/ffi/strings.rs index a810080..1ec031c 100644 --- a/numbat/src/ffi/strings.rs +++ b/numbat/src/ffi/strings.rs @@ -12,7 +12,7 @@ pub fn str_length( mut args: Args, _return_type: &TypeScheme, ) -> Result> { - let len = string_arg!(args).len(); + let len = string_arg!(args).chars().count(); return_scalar!(len as f64) } @@ -41,9 +41,16 @@ pub fn str_slice( let end = quantity_arg!(args).unsafe_value().to_f64() as usize; let input = string_arg!(args); - let output = input.get(start..end).unwrap_or_default(); + // Indices refer to code points, not bytes; out-of-range requests yield an + // empty string as before. + let characters = input.chars().collect::>(); + let output = if start <= end && end <= characters.len() { + characters[start..end].iter().collect::() + } else { + String::new() + }; - return_string!(borrowed = output) + return_string!(from = &output) } pub fn chr(