The match-and-rank algorithm is pure and both the legacy bar and the React port rank with it (#247).
225 lines
7.0 KiB
TypeScript
225 lines
7.0 KiB
TypeScript
export type CandidateKind = "session" | "command" | "directory";
|
|
|
|
export interface SessionCandidate {
|
|
kind: "session";
|
|
fields: {
|
|
name: string;
|
|
directory?: string;
|
|
command?: string;
|
|
tool?: string;
|
|
};
|
|
}
|
|
|
|
export interface CommandCandidate {
|
|
kind: "command";
|
|
fields: { label: string };
|
|
}
|
|
|
|
export interface DirectoryCandidate {
|
|
kind: "directory";
|
|
fields: { path: string };
|
|
}
|
|
|
|
export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate;
|
|
export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path";
|
|
export type MatchRange = readonly [start: number, end: number];
|
|
|
|
export interface MatchResult<C extends MatchCandidate = MatchCandidate> {
|
|
candidate: C;
|
|
score: number;
|
|
field: MatchFieldName | undefined;
|
|
ranges: MatchRange[];
|
|
}
|
|
|
|
interface Field {
|
|
name: MatchFieldName;
|
|
text: string;
|
|
weight: number;
|
|
}
|
|
|
|
interface Embedding {
|
|
score: number;
|
|
positions: number[];
|
|
}
|
|
|
|
interface RankedResult<C extends MatchCandidate> extends MatchResult<C> {
|
|
matchedText: string;
|
|
inputIndex: number;
|
|
}
|
|
|
|
const KIND_ORDER: Record<CandidateKind, number> = {
|
|
session: 0,
|
|
command: 1,
|
|
directory: 2,
|
|
};
|
|
|
|
function fields(candidate: MatchCandidate): Field[] {
|
|
switch (candidate.kind) {
|
|
case "session": {
|
|
const values: Array<[MatchFieldName, string | undefined, number]> = [
|
|
["name", candidate.fields.name, 0],
|
|
["directory", candidate.fields.directory, -24],
|
|
["command", candidate.fields.command, -32],
|
|
["tool", candidate.fields.tool, -40],
|
|
];
|
|
return values.flatMap(([name, text, weight]) =>
|
|
text === undefined ? [] : [{ name, text, weight }],
|
|
);
|
|
}
|
|
case "command":
|
|
return [{ name: "label", text: candidate.fields.label, weight: 0 }];
|
|
case "directory":
|
|
return [{ name: "path", text: candidate.fields.path, weight: 0 }];
|
|
}
|
|
}
|
|
|
|
function normalizeQuery(query: string): string {
|
|
return query.toLowerCase().replace(/\s/g, "");
|
|
}
|
|
|
|
function boundaryBonus(text: string, position: number): number {
|
|
if (position === 0) return 18;
|
|
|
|
const previous = text[position - 1];
|
|
if ("/\\-_ .:".includes(previous)) return 16;
|
|
|
|
const current = text[position];
|
|
const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase();
|
|
const previousIsLowercase =
|
|
previous !== previous.toUpperCase() && previous === previous.toLowerCase();
|
|
if (isUppercase && previousIsLowercase) return 12;
|
|
|
|
const isDigit = current >= "0" && current <= "9";
|
|
const previousIsDigit = previous >= "0" && previous <= "9";
|
|
if (isDigit && !previousIsDigit) return 12;
|
|
|
|
return 0;
|
|
}
|
|
|
|
function contribution(text: string, position: number, previousPosition: number): number {
|
|
const gap = position - previousPosition - 1;
|
|
return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12));
|
|
}
|
|
|
|
function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean {
|
|
if (!incumbent || candidate.score !== incumbent.score) {
|
|
return !incumbent || candidate.score > incumbent.score;
|
|
}
|
|
for (let index = 0; index < candidate.positions.length; index++) {
|
|
if (candidate.positions[index] !== incumbent.positions[index]) {
|
|
return candidate.positions[index] < incumbent.positions[index];
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function bestEmbedding(query: string, text: string): Embedding | undefined {
|
|
const lowerText = text.toLowerCase();
|
|
let previousStates = new Map<number, Embedding>();
|
|
|
|
for (let queryIndex = 0; queryIndex < query.length; queryIndex++) {
|
|
const states = new Map<number, Embedding>();
|
|
for (let position = 0; position < lowerText.length; position++) {
|
|
if (lowerText[position] !== query[queryIndex]) continue;
|
|
|
|
if (queryIndex === 0) {
|
|
states.set(position, {
|
|
score: contribution(text, position, -1),
|
|
positions: [position],
|
|
});
|
|
continue;
|
|
}
|
|
|
|
for (const [previousPosition, previous] of previousStates) {
|
|
if (previousPosition >= position) continue;
|
|
const candidate = {
|
|
score: previous.score + contribution(text, position, previousPosition),
|
|
positions: [...previous.positions, position],
|
|
};
|
|
if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate);
|
|
}
|
|
}
|
|
if (states.size === 0) return undefined;
|
|
previousStates = states;
|
|
}
|
|
|
|
let best: Embedding | undefined;
|
|
for (const embedding of previousStates.values()) {
|
|
if (isBetterEmbedding(embedding, best)) best = embedding;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
function toRanges(positions: number[]): MatchRange[] {
|
|
const ranges: MatchRange[] = [];
|
|
for (const position of positions) {
|
|
const previous = ranges[ranges.length - 1];
|
|
if (previous && previous[1] === position) {
|
|
ranges[ranges.length - 1] = [previous[0], position + 1];
|
|
} else {
|
|
ranges.push([position, position + 1]);
|
|
}
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function compareByCodePoint(left: string, right: string): number {
|
|
const leftPoints = Array.from(left, (character) => character.codePointAt(0)!);
|
|
const rightPoints = Array.from(right, (character) => character.codePointAt(0)!);
|
|
const length = Math.min(leftPoints.length, rightPoints.length);
|
|
for (let index = 0; index < length; index++) {
|
|
if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index];
|
|
}
|
|
return leftPoints.length - rightPoints.length;
|
|
}
|
|
|
|
export function matchAndRank<C extends MatchCandidate>(
|
|
query: string,
|
|
candidates: readonly C[],
|
|
): MatchResult<C>[] {
|
|
const normalizedQuery = normalizeQuery(query);
|
|
if (normalizedQuery === "") {
|
|
return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] }));
|
|
}
|
|
|
|
const results: RankedResult<C>[] = [];
|
|
candidates.forEach((candidate, inputIndex) => {
|
|
let best:
|
|
| { field: Field; score: number; positions: number[] }
|
|
| undefined;
|
|
|
|
for (const field of fields(candidate)) {
|
|
const embedding = bestEmbedding(normalizedQuery, field.text);
|
|
if (!embedding) continue;
|
|
|
|
const compactText = field.text.toLowerCase().replace(/\s/g, "");
|
|
const adjustment =
|
|
compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0;
|
|
const score = embedding.score + adjustment + field.weight;
|
|
if (!best || score > best.score) best = { field, score, positions: embedding.positions };
|
|
}
|
|
|
|
if (best) {
|
|
results.push({
|
|
candidate,
|
|
score: best.score,
|
|
field: best.field.name,
|
|
ranges: toRanges(best.positions),
|
|
matchedText: best.field.text,
|
|
inputIndex,
|
|
});
|
|
}
|
|
});
|
|
|
|
results.sort(
|
|
(left, right) =>
|
|
right.score - left.score ||
|
|
KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] ||
|
|
left.matchedText.length - right.matchedText.length ||
|
|
compareByCodePoint(left.matchedText, right.matchedText) ||
|
|
left.inputIndex - right.inputIndex,
|
|
);
|
|
|
|
return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges }));
|
|
}
|