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
68 changes: 55 additions & 13 deletions packages/cli/src/lib/fuzzy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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>,
): 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>,
): number | undefined {
let first = -1;
let last = -1;
let wordStarts = 0;
Expand All @@ -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) {
Expand All @@ -158,18 +170,25 @@ 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>,
): number | undefined {
if (partial.length < TYPO_MIN_LENGTH) {
return;
}
const allowed = partial.length < 6 ? 1 : 2;
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_START_RE.test(candidate.slice(at, at + 2))) {
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.
Expand All @@ -180,18 +199,41 @@ 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;
// 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;

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 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<number> {
const starts = new Set([0]);
let at = 0;
let before = "";
for (const char of candidate) {
if (
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;
}

/**
Expand Down
23 changes: 23 additions & 0 deletions packages/cli/test/lib/fuzzy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,29 @@ 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("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(
Expand Down
Loading