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'); + }); +});