Skip to content

Commit d927ea7

Browse files
committed
Fix: Serial match verification mid-character
1 parent 29e9cd2 commit d927ea7

2 files changed

Lines changed: 38 additions & 41 deletions

File tree

include/stringzilla/utf8_case.h

Lines changed: 22 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1177,7 +1177,8 @@ SZ_INTERNAL sz_bool_t sz_utf8_case_insensitive_verify_head_(sz_cptr_t needle_sta
11771177
for (;;) {
11781178
sz_bool_t have_needle = sz_utf8_folded_reverse_iter_prev_(&needle_riter, &needle_rune);
11791179

1180-
// Needle exhausted - success!
1180+
// Needle exhausted - success! Unconsumed haystack runes are OK.
1181+
// Example: "fi" matches suffix of "ffi" (folds to "ffi"), leaving first 'f' unused.
11811182
if (!have_needle) {
11821183
*match_length = (sz_size_t)(haystack_end - haystack_riter.ptr);
11831184
return sz_true_k;
@@ -1510,10 +1511,10 @@ SZ_INTERNAL sz_cptr_t sz_utf8_case_insensitive_find_in_danger_zone_( //
15101511
if (needle_riter_rune != haystack_riter_rune) goto consider_third_haystack_folded_rune;
15111512
}
15121513

1513-
// First match the tail
1514+
// First match the tail (from safe window start forward)
15141515
sz_rune_t needle_iter_rune = 0, haystack_iter_rune = 0;
15151516
sz_utf8_folded_iter_t_ needle_iter, haystack_iter;
1516-
sz_utf8_folded_iter_init_(&needle_iter, needle_ptr + needle_length - needle_first_safe_folded_rune_offset,
1517+
sz_utf8_folded_iter_init_(&needle_iter, needle_ptr + needle_first_safe_folded_rune_offset,
15171518
needle_length - needle_first_safe_folded_rune_offset);
15181519
sz_utf8_folded_iter_init_(&haystack_iter, danger_ptr + haystack_rune_length,
15191520
haystack_end - (danger_ptr + haystack_rune_length));
@@ -1591,10 +1592,10 @@ SZ_INTERNAL sz_cptr_t sz_utf8_case_insensitive_find_in_danger_zone_( //
15911592
if (needle_riter_rune != haystack_riter_rune) goto consider_following_haystack_runes;
15921593
}
15931594

1594-
// First match the tail
1595+
// First match the tail (from safe window start forward)
15951596
sz_rune_t needle_iter_rune = 0, haystack_iter_rune = 0;
15961597
sz_utf8_folded_iter_t_ needle_iter, haystack_iter;
1597-
sz_utf8_folded_iter_init_(&needle_iter, needle_ptr + needle_length - needle_first_safe_folded_rune_offset,
1598+
sz_utf8_folded_iter_init_(&needle_iter, needle_ptr + needle_first_safe_folded_rune_offset,
15981599
needle_length - needle_first_safe_folded_rune_offset);
15991600
sz_utf8_folded_iter_init_(&haystack_iter, danger_ptr + haystack_rune_length,
16001601
haystack_end - (danger_ptr + haystack_rune_length));
@@ -1846,8 +1847,6 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
18461847
sz_rune_t needle_runes[32];
18471848
sz_size_t needle_prefix_count = 0, needle_total_count = 0;
18481849
sz_u64_t needle_hash = 0;
1849-
sz_cptr_t needle_tail = SZ_NULL_CHAR;
1850-
sz_size_t needle_tail_length = 0;
18511850
{
18521851
sz_utf8_folded_iter_t_ needle_iter;
18531852
sz_utf8_folded_iter_init_(&needle_iter, needle, needle_length);
@@ -1857,11 +1856,8 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
18571856
needle_hash = needle_hash * 257 + rune;
18581857
}
18591858
needle_total_count = needle_prefix_count;
1860-
if (needle_prefix_count == ring_capacity) {
1861-
needle_tail = needle_iter.ptr;
1862-
while (sz_utf8_folded_iter_next_(&needle_iter, &rune)) needle_total_count++;
1863-
needle_tail_length = needle_length - (sz_size_t)(needle_tail - needle);
1864-
}
1859+
// For long needles, count remaining runes beyond ring buffer
1860+
while (sz_utf8_folded_iter_next_(&needle_iter, &rune)) needle_total_count++;
18651861
}
18661862
if (!needle_prefix_count) {
18671863
*match_length = 0;
@@ -1872,7 +1868,7 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
18721868
for (sz_size_t i = 1; i < needle_prefix_count; ++i) hash_multiplier *= 257;
18731869

18741870
sz_rune_t window_runes[32];
1875-
sz_cptr_t window_sources[32];
1871+
sz_cptr_t window_sources[32]; // Byte position of character that produced each window rune
18761872
sz_size_t ring_head = 0;
18771873
sz_u64_t window_hash = 0;
18781874
sz_utf8_folded_iter_t_ haystack_iter;
@@ -1887,7 +1883,8 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
18871883
sz_rune_t rune;
18881884
if (!sz_utf8_folded_iter_next_(&haystack_iter, &rune)) break;
18891885
window_runes[window_count] = rune;
1890-
current_source = haystack_iter.pending_idx <= 1 ? before_ptr : current_source;
1886+
// Update source only when starting a new character (not mid-expansion)
1887+
if (haystack_iter.pending_idx <= 1 || haystack_iter.pending_count == 0) current_source = before_ptr;
18911888
window_sources[window_count] = current_source;
18921889
window_hash = window_hash * 257 + rune;
18931890
window_count++;
@@ -1911,34 +1908,17 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
19111908
for (sz_size_t i = 0; i < ring_head; ++i) mismatches += window_runes[i] != needle_runes[first_segment + i];
19121909

19131910
if (!mismatches) {
1911+
// Short needle: rune comparison above is sufficient verification
19141912
if (needle_total_count <= ring_capacity) {
1915-
// Verify the match to handle edge cases where window_start includes partial
1916-
// multi-rune expansions (e.g., ß→ss where only the second 's' is in the match)
1917-
sz_size_t verified_match_length = 0;
1918-
if (sz_utf8_case_insensitive_verify_tail_(needle, needle + needle_length, window_start, window_end,
1919-
&verified_match_length)) {
1920-
*match_length = verified_match_length;
1921-
return window_start;
1922-
}
1923-
// Rune hash matched but byte-level verification failed, continue searching
1924-
}
1925-
else {
1926-
sz_size_t haystack_tail_length = haystack_length - (sz_size_t)(window_end - haystack);
1927-
sz_utf8_folded_iter_t_ needle_tail_iter, haystack_tail_iter;
1928-
sz_utf8_folded_iter_init_(&needle_tail_iter, needle_tail, needle_tail_length);
1929-
sz_utf8_folded_iter_init_(&haystack_tail_iter, window_end, haystack_tail_length);
1930-
sz_rune_t needle_tail_rune, haystack_tail_rune;
1931-
sz_bool_t tail_matches = sz_true_k;
1932-
while (tail_matches && sz_utf8_folded_iter_next_(&needle_tail_iter, &needle_tail_rune)) {
1933-
if (!sz_utf8_folded_iter_next_(&haystack_tail_iter, &haystack_tail_rune) ||
1934-
needle_tail_rune != haystack_tail_rune)
1935-
tail_matches = sz_false_k;
1936-
}
1937-
if (tail_matches) {
1938-
*match_length = (sz_size_t)(haystack_tail_iter.ptr - window_start);
1939-
return window_start;
1940-
}
1913+
*match_length = (sz_size_t)(window_end - window_start);
1914+
return window_start;
19411915
}
1916+
// Long needle: verify FULL needle from window_start (not just tail from window_end).
1917+
// This handles mid-expansion matches correctly. Example: if window ends mid-way through
1918+
// ffi→"ffi", starting verification from window_start ensures proper rune alignment.
1919+
if (sz_utf8_case_insensitive_verify_tail_(needle, needle + needle_length, window_start,
1920+
haystack + haystack_length, match_length))
1921+
return window_start;
19421922
}
19431923
}
19441924

@@ -1954,7 +1934,8 @@ SZ_PUBLIC sz_cptr_t sz_utf8_case_insensitive_find_serial( //
19541934
next_head = next_head == needle_prefix_count ? 0 : next_head;
19551935

19561936
window_runes[ring_head] = new_rune;
1957-
current_source = haystack_iter.pending_idx <= 1 ? before_ptr : current_source;
1937+
// Update source only when starting a new character (not mid-expansion)
1938+
if (haystack_iter.pending_idx <= 1 || haystack_iter.pending_count == 0) current_source = before_ptr;
19581939
window_sources[ring_head] = current_source;
19591940

19601941
ring_head = next_head;

scripts/test_stringzilla.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3105,6 +3105,22 @@ void test_utf8_case() {
31053105
let_assert(auto m = str("alffiJaCä").utf8_case_insensitive_find("fija"), m.offset == 2 && m.length == 5);
31063106
let_assert(auto m = str("alffiJaCä").utf8_case_insensitive_find("fija"), m.offset == 2 && m.length == 5);
31073107

3108+
// Combining diacritical marks: ǰ (U+01F0) folds to 'j' + combining caron (U+030C)
3109+
// Needle starts with combining caron - can match mid-expansion of ǰ
3110+
let_assert(auto m = str("ǰ0").utf8_case_insensitive_find("\xCC\x8C"
3111+
"0"), // caron + '0'
3112+
m.offset == 0 && m.length == 3); // Match entire ǰ0 (2 byte ǰ + 1 byte 0)
3113+
let_assert(auto m = str("abcǰ0def")
3114+
.utf8_case_insensitive_find("\xCC\x8C"
3115+
"0"),
3116+
m.offset == 3 && m.length == 3); // "abc" = 3 bytes
3117+
3118+
// Mid-expansion matches with ß (U+00DF) → "ss"
3119+
// Needle "sfoxeepmº" should match in "ßfoxeEPMº" (folded: "ssfoxeepmº") at position 1 of folded text
3120+
// Return position is byte 0 where ß starts (first contributing character)
3121+
let_assert(auto m = str("ßfoxeEPMº").utf8_case_insensitive_find("sfoxeepmº"),
3122+
m.offset == 0 && m.length == 11); // Entire haystack
3123+
31083124
// 'st' (U+FB06, EF AC 86)
31093125
let_assert(auto m = str("Big st").utf8_case_insensitive_find("st"), m.offset == 4 && m.length == 3);
31103126

0 commit comments

Comments
 (0)