Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions changelog.d/search-snippet-case-insensitive.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 30 additions & 1 deletion src/modules/history/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 };
Expand All @@ -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
// ============================================================================
Expand Down
82 changes: 82 additions & 0 deletions test/history-module-ux.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
Loading