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
52 changes: 39 additions & 13 deletions packages/junior-dashboard/src/client/components/quickOpenMatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,38 +16,48 @@ const SPAN_PENALTY = 20;
const TYPO_SCORE = 600;
// Shorter terms are mostly noise when edited, and need an exact match.
const TYPO_MIN_LENGTH = 4;
const WORD_CHAR = /[\p{L}\p{N}]/u;
const UPPER = /\p{Lu}/u;
const LOWER = /\p{Ll}/u;

/**
* Match `query` against `label` the way an editor's quick open does: every
* space-separated term must appear in order, contiguously or scattered, or
* within a typo of a word's start, with no case. Higher scores rank first.
* within a typo of a word's start, with no case. A word starts after a
* space or symbol, or at a capital after a lowercase letter, so
* `isolationScope` has two. Higher scores rank first.
* Returns undefined when a term misses.
*/
export function quickOpenMatch(
label: string,
query: string,
): QuickOpenMatch | undefined {
const haystack = foldCase(label);
const starts = wordStarts(label);
const terms = foldCase(query).split(/\s+/).filter(Boolean);
const indices = new Set<number>();
let score = 0;
for (const term of terms) {
const match = matchTerm(haystack, term);
const match = matchTerm(haystack, starts, term);
if (!match) return undefined;
score += match.score;
for (const index of match.indices) indices.add(index);
}
return { indices: [...indices].sort((a, b) => a - b), score };
}

function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined {
function matchTerm(
haystack: string,
starts: Set<number>,
term: string,
): QuickOpenMatch | undefined {
let substringAt = -1;
for (
let at = haystack.indexOf(term);
at !== -1;
at = haystack.indexOf(term, at + 1)
) {
if (isWordStart(haystack, at)) {
if (starts.has(at)) {
return { indices: range(at, term.length), score: 3 * TIER - at };
}
if (substringAt === -1) substringAt = at;
Expand All @@ -58,16 +68,17 @@ function matchTerm(haystack: string, term: string): QuickOpenMatch | undefined {
score: 2 * TIER - substringAt,
};
}
const scatter = matchScatter(haystack, term);
const scatter = matchScatter(haystack, starts, term);
// No typo can beat a scatter this tight, so skip the edit distances.
if (scatter && scatter.score >= TYPO_SCORE) return scatter;
const typo = matchTypo(haystack, term);
const typo = matchTypo(haystack, starts, term);
if (!scatter || !typo) return scatter ?? typo;
return scatter.score >= typo.score ? scatter : typo;
}

function matchScatter(
haystack: string,
starts: Set<number>,
term: string,
): QuickOpenMatch | undefined {
const indices: number[] = [];
Expand All @@ -76,7 +87,7 @@ function matchScatter(
for (const char of term) {
const at = haystack.indexOf(char, from);
if (at === -1) return undefined;
if (isWordStart(haystack, at)) wordStarts += 1;
if (starts.has(at)) wordStarts += 1;
indices.push(...range(at, char.length));
from = at + char.length;
}
Expand All @@ -89,16 +100,21 @@ function matchScatter(
* Compare `term` with the text from each word start, so an unfinished word
* still counts. Allows one edit for short terms and two for longer ones.
*/
function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined {
function matchTypo(
haystack: string,
starts: Set<number>,
term: string,
): QuickOpenMatch | undefined {
if (term.length < TYPO_MIN_LENGTH) return undefined;
const allowed = term.length < 6 ? 1 : 2;
let best: { at: number; length: number; distance: number } | undefined;
// Compare against the rest of the title, not only the word, so
// `slef-update` still finds "self-update".
for (const word of haystack.matchAll(/[\p{L}\p{N}]+/gu)) {
for (const at of starts) {
if (!WORD_CHAR.test(haystack[at]!)) continue;
const distances = prefixDistances(
term,
haystack.slice(word.index, word.index + term.length + allowed),
haystack.slice(at, at + term.length + allowed),
);
// Each extra or missing letter costs an edit, so lengths further than
// `allowed` from the term can't match. A tie keeps the length closest
Expand All @@ -109,7 +125,7 @@ function matchTypo(haystack: string, term: string): QuickOpenMatch | undefined {
const distance = distances[length];
if (distance === undefined) continue;
if (distance <= allowed && (!best || distance < best.distance)) {
best = { at: word.index, length, distance };
best = { at, length, distance };
}
}
}
Expand Down Expand Up @@ -154,8 +170,18 @@ function foldCase(text: string): string {
return folded;
}

function isWordStart(haystack: string, at: number): boolean {
return at === 0 || !/[\p{L}\p{N}]/u.test(haystack[at - 1]!);
// Ascending word start indices in `label`, read before case folding so
// camelCase boundaries survive.
function wordStarts(label: string): Set<number> {
const starts = new Set([0]);
for (let at = 1; at < label.length; at++) {
const char = label[at]!;
const before = label[at - 1]!;
if (!WORD_CHAR.test(before) || (UPPER.test(char) && LOWER.test(before))) {
starts.add(at);
}
}
return starts;
}

function range(start: number, length: number): number[] {
Expand Down
11 changes: 11 additions & 0 deletions packages/junior-dashboard/tests/quickOpenMatch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,17 @@ it("needs every space-separated term", () => {
);
});

it("treats a camelCase capital as a word start", () => {
const score = (label: string, query: string) =>
quickOpenMatch(label, query)!.score;
expect(score("Investigate isolationScope leak", "scope")).toBeGreaterThan(
score("Investigate telescope leak", "scope"),
);
expect(quickOpenMatch("Fix getCurrentHub usage", "curnet")?.indices).toEqual([
7, 8, 9, 10, 11, 12,
]);
});

it("ranks a word-start run over a mid-word run over a scatter", () => {
const score = (label: string) => quickOpenMatch(label, "deploy")!.score;
expect(score("Deploy notes")).toBeGreaterThan(score("Redeploy notes"));
Expand Down
Loading