From 4b31150c5a6209228582e438e58fad787a773c83 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:56:15 +0000 Subject: [PATCH 1/2] feat(cli): Treat camelCase capitals as word starts in search mode Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- packages/cli/src/lib/fuzzy.ts | 79 +++++++++++++++++++++++------ packages/cli/test/lib/fuzzy.test.ts | 11 ++++ 2 files changed, 75 insertions(+), 15 deletions(-) diff --git a/packages/cli/src/lib/fuzzy.ts b/packages/cli/src/lib/fuzzy.ts index 82265b18d..39418220c 100644 --- a/packages/cli/src/lib/fuzzy.ts +++ b/packages/cli/src/lib/fuzzy.ts @@ -87,8 +87,8 @@ function matchScore( candidate: string, search: boolean, ): number | undefined { - const lowerPartial = partial.toLowerCase(); - const lowerCandidate = candidate.toLowerCase(); + const lowerPartial = foldCase(partial); + const lowerCandidate = foldCase(candidate); if (lowerCandidate === lowerPartial) { return EXACT_SCORE; } @@ -99,7 +99,11 @@ function matchScore( return CONTAINS_SCORE; } if (search) { - const match = searchScore(lowerPartial, lowerCandidate); + const match = searchScore( + lowerPartial, + lowerCandidate, + wordStarts(candidate), + ); if (match !== undefined) { return CONTAINS_SCORE + 1 - match / SEARCH_SCALE; } @@ -115,20 +119,28 @@ function matchScore( } /** The better of a scatter and a typo match, between 0 and 1000. */ -function searchScore(partial: string, candidate: string): number | undefined { - const scatter = scatterScore(partial, candidate); +function searchScore( + partial: string, + candidate: string, + starts: Set, +): number | undefined { + const scatter = scatterScore(partial, candidate, starts); // No typo can beat a scatter this tight, so skip the edit distances. if (scatter !== undefined && scatter >= TYPO_SCORE) { return scatter; } - const typo = typoScore(partial, candidate); + const typo = typoScore(partial, candidate, starts); if (scatter === undefined || typo === undefined) { return scatter ?? typo; } return Math.max(scatter, typo); } -function scatterScore(partial: string, candidate: string): number | undefined { +function scatterScore( + partial: string, + candidate: string, + starts: Set, +): number | undefined { let first = -1; let last = -1; let wordStarts = 0; @@ -138,7 +150,7 @@ function scatterScore(partial: string, candidate: string): number | undefined { if (at === -1) { return; } - if (isWordStart(candidate, at)) { + if (starts.has(at)) { wordStarts += 1; } if (first === -1) { @@ -158,7 +170,11 @@ function scatterScore(partial: string, candidate: string): number | undefined { * Compare `partial` with the text from each word start, so an unfinished * word still counts. Allows one edit for short input and two for longer input. */ -function typoScore(partial: string, candidate: string): number | undefined { +function typoScore( + partial: string, + candidate: string, + starts: Set, +): number | undefined { if (partial.length < TYPO_MIN_LENGTH) { return; } @@ -166,10 +182,13 @@ function typoScore(partial: string, candidate: string): number | undefined { let best: { at: number; distance: number } | undefined; // Compare against the rest of the candidate, not only the word, so // `sentyr-javascrpt` still finds `sentry-javascript`. - for (const word of candidate.matchAll(WORD_RE)) { + for (const at of starts) { + if (!WORD_CHAR_RE.test(candidate[at] ?? "")) { + continue; + } const distances = prefixDistances( partial, - candidate.slice(word.index, word.index + partial.length + allowed), + candidate.slice(at, at + partial.length + allowed), ); // Each extra or missing letter costs an edit, so lengths further than // `allowed` from the input can't match. @@ -180,18 +199,48 @@ function typoScore(partial: string, candidate: string): number | undefined { distance <= allowed && (!best || distance < best.distance) ) { - best = { at: word.index, distance }; + best = { at, distance }; } } } return best && Math.max(TYPO_SCORE / best.distance - best.at, 1); } -const WORD_RE = /[\p{L}\p{N}]+/gu; const WORD_CHAR_RE = /[\p{L}\p{N}]/u; +const UPPER_RE = /\p{Lu}/u; +const LOWER_RE = /\p{Ll}/u; + +/** + * Lowercase one character at a time and keep each one's UTF-16 length, so + * word start indices from the original candidate stay valid. "İ" lowercases + * to "i" plus a combining dot; only the "i" is kept. + */ +function foldCase(text: string): string { + let folded = ""; + for (const char of text) { + folded += char.toLowerCase().slice(0, char.length); + } + return folded; +} -function isWordStart(text: string, at: number): boolean { - return at === 0 || !WORD_CHAR_RE.test(text[at - 1] ?? ""); +/** + * Ascending word start indices: after a symbol or space, or at a capital + * after a lowercase letter, so `isolationScope` has two. Reads the + * candidate before {@link foldCase} so camelCase boundaries survive. + */ +function wordStarts(candidate: string): Set { + const starts = new Set([0]); + for (let at = 1; at < candidate.length; at++) { + const char = candidate[at] ?? ""; + const before = candidate[at - 1] ?? ""; + if ( + !WORD_CHAR_RE.test(before) || + (UPPER_RE.test(char) && LOWER_RE.test(before)) + ) { + starts.add(at); + } + } + return starts; } /** diff --git a/packages/cli/test/lib/fuzzy.test.ts b/packages/cli/test/lib/fuzzy.test.ts index 59a7e981a..1c0f5cf29 100644 --- a/packages/cli/test/lib/fuzzy.test.ts +++ b/packages/cli/test/lib/fuzzy.test.ts @@ -219,6 +219,17 @@ describe("fuzzyMatch: search mode", () => { expect(fuzzyMatch("isu", ["sui"], { mode: "search" })).toEqual([]); }); + test("treats a camelCase capital as a word start", () => { + expect(fuzzyMatch("curnet", ["getCurrentHub"], { mode: "search" })).toEqual( + ["getCurrentHub"], + ); + expect( + fuzzyMatch("iss", ["isolationscope", "isolationScope"], { + mode: "search", + }), + ).toEqual(["isolationScope", "isolationscope"]); + }); + test("ranks a tight scatter over a typo over a loose scatter", () => { expect( fuzzyMatch( From f3af018626ee2f550257956615a1e188b3f5e672 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 22:08:24 +0000 Subject: [PATCH 2/2] fix(cli): Lowercase search queries the same way as before MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit foldCase dropped the combining dot that toLowerCase adds to "İ", so a query that a caller had already lowercased no longer matched. Keep toLowerCase on both sides and map word starts into its indices instead. --- packages/cli/src/lib/fuzzy.ts | 39 ++++++++++++----------------- packages/cli/test/lib/fuzzy.test.ts | 12 +++++++++ 2 files changed, 28 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/lib/fuzzy.ts b/packages/cli/src/lib/fuzzy.ts index 39418220c..a708ee182 100644 --- a/packages/cli/src/lib/fuzzy.ts +++ b/packages/cli/src/lib/fuzzy.ts @@ -87,8 +87,8 @@ function matchScore( candidate: string, search: boolean, ): number | undefined { - const lowerPartial = foldCase(partial); - const lowerCandidate = foldCase(candidate); + const lowerPartial = partial.toLowerCase(); + const lowerCandidate = candidate.toLowerCase(); if (lowerCandidate === lowerPartial) { return EXACT_SCORE; } @@ -183,7 +183,7 @@ function typoScore( // Compare against the rest of the candidate, not only the word, so // `sentyr-javascrpt` still finds `sentry-javascript`. for (const at of starts) { - if (!WORD_CHAR_RE.test(candidate[at] ?? "")) { + if (!WORD_START_RE.test(candidate.slice(at, at + 2))) { continue; } const distances = prefixDistances( @@ -207,38 +207,31 @@ function typoScore( } const WORD_CHAR_RE = /[\p{L}\p{N}]/u; +// Reads two code units so a letter outside the BMP still counts. +const WORD_START_RE = /^[\p{L}\p{N}]/u; const UPPER_RE = /\p{Lu}/u; const LOWER_RE = /\p{Ll}/u; -/** - * Lowercase one character at a time and keep each one's UTF-16 length, so - * word start indices from the original candidate stay valid. "İ" lowercases - * to "i" plus a combining dot; only the "i" is kept. - */ -function foldCase(text: string): string { - let folded = ""; - for (const char of text) { - folded += char.toLowerCase().slice(0, char.length); - } - return folded; -} - /** * Ascending word start indices: after a symbol or space, or at a capital - * after a lowercase letter, so `isolationScope` has two. Reads the - * candidate before {@link foldCase} so camelCase boundaries survive. + * after a lowercase letter, so `isolationScope` has two. Reads case from the + * candidate but returns indices into `candidate.toLowerCase()`, which can be + * longer: "İ" lowercases to "i" plus a combining dot. */ function wordStarts(candidate: string): Set { const starts = new Set([0]); - for (let at = 1; at < candidate.length; at++) { - const char = candidate[at] ?? ""; - const before = candidate[at - 1] ?? ""; + let at = 0; + let before = ""; + for (const char of candidate) { if ( - !WORD_CHAR_RE.test(before) || - (UPPER_RE.test(char) && LOWER_RE.test(before)) + at > 0 && + (!WORD_CHAR_RE.test(before) || + (UPPER_RE.test(char) && LOWER_RE.test(before))) ) { starts.add(at); } + at += char.toLowerCase().length; + before = char; } return starts; } diff --git a/packages/cli/test/lib/fuzzy.test.ts b/packages/cli/test/lib/fuzzy.test.ts index 1c0f5cf29..8baf7b4aa 100644 --- a/packages/cli/test/lib/fuzzy.test.ts +++ b/packages/cli/test/lib/fuzzy.test.ts @@ -230,6 +230,18 @@ describe("fuzzyMatch: search mode", () => { ).toEqual(["isolationScope", "isolationscope"]); }); + test("matches a query that was lowercased before the call", () => { + // "İ".toLowerCase() is "i" plus a combining dot, two code units. + expect( + fuzzyMatch("İs".toLowerCase(), ["İstanbul"], { mode: "search" }), + ).toEqual(["İstanbul"]); + expect( + fuzzyMatch("iss", ["İsolationscope", "İsolationScope"], { + mode: "search", + }), + ).toEqual(["İsolationScope", "İsolationscope"]); + }); + test("ranks a tight scatter over a typo over a loose scatter", () => { expect( fuzzyMatch(