From 21d5db85b2bb23e416d151df7b0e5c97149da7fb Mon Sep 17 00:00:00 2001 From: Rupert-2634 Date: Fri, 16 Oct 2026 15:52:39 -0400 Subject: [PATCH] fix(history): a case-insensitive search's snippet stays on its match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit history--search found a case-insensitive match in text.toLowerCase() and cut its snippet from the text itself at the index and length it found there. İ (U+0130) is the one code point whose lower case is longer than itself, one UTF-16 unit becoming two. So each İ before a match made its index in the text one unit too large, and a run of them left the match outside the snippet. A message of 100 İ and then " the needle is here", searched for "needle", got the snippet "…". The lowered match length could be too large too, as for İstanbul matched against İstanbul. matchSubstring now returns its hit in the text's own units. The match itself moves into firstOccurrence, so the mapping runs in one place after either match mode. Where the lowered text is as long as the text, they're the same units. Otherwise inTextUnits walks the text a code point at a time, adding up each one's lowered length, and a hit that starts or ends inside a character's lower case takes in the whole character. What matches is unchanged. A case-insensitive search still compares both sides lower-cased, with and without wholeWord, and a case-sensitive one compares them as they are. Tests, in history-module-ux. Each of these fails on main: a match after 100 İ, with and without wholeWord; a match after emoji and an İ, where each emoji is two units that lower to themselves; a query holding an İ; and three queries that start or end inside an İ's lower case and get the whole İ. Those are U+0307, which starts inside one; "ai" after earlier İs, which starts before an İ and ends inside its lower case; and U+0307 then i on two İs, which starts inside one and ends inside the next, and gets both. Two pass on main, as they should, since main maps nothing: a case-sensitive search after 100 İ, and a message with an İ and no match beside one that matches. Co-Authored-By: Claude Opus 5.5 --- .../search-snippet-case-insensitive.fixed.md | 7 ++ src/modules/history/index.ts | 31 ++++++- test/history-module-ux.test.ts | 82 +++++++++++++++++++ 3 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 changelog.d/search-snippet-case-insensitive.fixed.md diff --git a/changelog.d/search-snippet-case-insensitive.fixed.md b/changelog.d/search-snippet-case-insensitive.fixed.md new file mode 100644 index 000000000..9d4ad2dc2 --- /dev/null +++ b/changelog.d/search-snippet-case-insensitive.fixed.md @@ -0,0 +1,7 @@ +- **`history--search`'s snippet for a case-insensitive match now always + holds the match.** The search found the match in the lower-cased text but + cut the snippet from the text itself at the same position. `İ` (U+0130) + lower-cases to two UTF-16 units, so each one before a match made the + match's index in the text one unit too large, and enough of them left the + match outside the snippet. The snippet now sits on the match in the text + itself. diff --git a/src/modules/history/index.ts b/src/modules/history/index.ts index 6a763bdb1..aa0f50552 100644 --- a/src/modules/history/index.ts +++ b/src/modules/history/index.ts @@ -2219,7 +2219,9 @@ function codePointBefore(s: string, i: number): string | undefined { } /** - * First occurrence of `needle` in `text`. With `wholeWord`, an occurrence + * First occurrence of `needle` in `text`, as the units of `text` it covers. + * Without `caseSensitive`, `needle` is lower case already, and is found in + * `text.toLowerCase()`. With `wholeWord`, an occurrence * only counts when it isn't glued to a letter/digit/underscore on either * side (Unicode-aware, so Cyrillic words work too) — "mission" no longer * hits inside "uncommissioned". Word-ness is checked only on the sides @@ -2228,6 +2230,13 @@ function codePointBefore(s: string, i: number): string | undefined { */ function matchSubstring(text: string, needle: string, caseSensitive: boolean, wholeWord = false): MatchHit | null { const haystack = caseSensitive ? text : text.toLowerCase(); + const hit = firstOccurrence(haystack, needle, wholeWord); + // Lowering can lengthen a character, as it does `İ` (one unit to two). Then + // the lowered text's units aren't the text's, and the snippet cuts the text. + return hit !== null && haystack.length !== text.length ? inTextUnits(text, hit) : hit; +} + +function firstOccurrence(haystack: string, needle: string, wholeWord: boolean): MatchHit | null { if (!wholeWord) { const index = haystack.indexOf(needle); return index === -1 ? null : { index, length: needle.length }; @@ -2245,6 +2254,26 @@ function matchSubstring(text: string, needle: string, caseSensitive: boolean, wh } } +/** + * The units of `text` that a hit in `text.toLowerCase()` covers. Each code + * point of `text` takes as many units there as its own lower case has, so a + * hit that starts or ends inside a character's lower case takes in the whole + * character. + */ +function inTextUnits(text: string, hit: MatchHit): MatchHit { + const end = hit.index + hit.length; + let start = -1; + let lowered = 0; + let at = 0; + while (at < text.length && lowered < end) { + const cp = text.codePointAt(at)!; + lowered += String.fromCodePoint(cp).toLowerCase().length; + if (start === -1 && lowered > hit.index) start = at; + at += cp > 0xffff ? 2 : 1; + } + return { index: start, length: at - start }; +} + // ============================================================================ // author filtering // ============================================================================ diff --git a/test/history-module-ux.test.ts b/test/history-module-ux.test.ts index cd0ae9491..b40af88bb 100644 --- a/test/history-module-ux.test.ts +++ b/test/history-module-ux.test.ts @@ -354,3 +354,85 @@ describe('HistoryModule UX: real-store ordering (catch-up backfill)', () => { assert.equal(c.hasMore, true); }); }); + +describe('HistoryModule UX: a case-insensitive match\'s snippet', () => { + // `İ` (U+0130) is the one character whose lower case is longer (two UTF-16 + // units: `i` and a combining dot), so each one before a match moves the + // match's index in the lowered text one unit past its index in the text. + it('stays on its match when İ comes before it', async () => { + const text = `${'İ'.repeat(100)} the needle is here`; + const { mod } = stub([mk({ id: 'i1', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'needle' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${text.slice(105 - 80)}`); + }); + + it('stays on its match when the search is case-sensitive, which lowers nothing', async () => { + const text = `${'İ'.repeat(100)} the Needle is here`; + const { mod } = stub([mk({ id: 'i5', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'Needle', caseSensitive: true })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${text.slice(105 - 80)}`); + }); + + it('stays on its match with wholeWord', async () => { + const text = `${'İ'.repeat(100)} the needle is here`; + const { mod } = stub([mk({ id: 'i2', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'needle', wholeWord: true })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${text.slice(105 - 80)}`); + }); + + it('spans the match in the text when the query holds an İ', async () => { + const text = `${'x'.repeat(200)} İstanbul ${'y'.repeat(200)}`; + const { mod } = stub([mk({ id: 'i3', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'İstanbul' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${'x'.repeat(79)} İstanbul ${'y'.repeat(79)}…`); + }); + + // `İ` lowers to `i` and then U+0307, a combining dot, so a match can start + // or end inside its lower case. The snippet then takes in the whole `İ`. + it('takes in the whole İ when the match starts inside its lower case', async () => { + const text = `${'a'.repeat(100)}İ${'b'.repeat(100)}`; + const { mod } = stub([mk({ id: 'i4', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: '\u0307' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${'a'.repeat(80)}İ${'b'.repeat(80)}…`); + }); + + it('takes in the whole İ when the match starts before it and ends inside its lower case, after earlier İs', async () => { + const text = `${'İ'.repeat(5)}${'a'.repeat(100)}İ${'b'.repeat(100)}`; + const { mod } = stub([mk({ id: 'i6', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'ai' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${'a'.repeat(81)}İ${'b'.repeat(80)}…`); + }); + + it('takes in both İs when the match starts inside one and ends inside the next', async () => { + const text = `${'a'.repeat(100)}İİ${'b'.repeat(100)}`; + const { mod } = stub([mk({ id: 'i7', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: '\u0307i' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${'a'.repeat(80)}İİ${'b'.repeat(80)}…`); + }); + + it('stays on its match with characters outside the BMP before it', async () => { + // Each emoji is two UTF-16 units, and lowers to itself. + const text = `${'😀'.repeat(50)}${'x'.repeat(100)}İ the needle is here`; + const { mod } = stub([mk({ id: 'i8', ms: min(1), text })]); + const d = data(await call(mod, 'search', { query: 'needle' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, `…${'x'.repeat(74)}İ the needle is here`); + }); + + it('passes over a message with an İ that holds no match', async () => { + const { mod } = stub([ + mk({ id: 'i9', ms: min(1), text: 'İstanbul trip' }), + mk({ id: 'i10', ms: min(2), text: 'the needle is here' }), + ]); + const d = data(await call(mod, 'search', { query: 'needle' })); + assert.equal(d.matches.length, 1); + assert.equal(d.matches[0].snippet, 'the needle is here'); + }); +});