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
75 changes: 57 additions & 18 deletions src/github/command-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,33 +9,58 @@ export type CommandSuggestCatalog = {
/** Max Levenshtein distance for a did-you-mean suggestion. */
export const COMMAND_SUGGEST_MAX_DISTANCE = 2;

export function levenshteinDistance(left: string, right: string): number {
/** Longest verb that can still be within the did-you-mean threshold of a catalog command. */
export const COMMAND_SUGGEST_MAX_VERB_LENGTH = 64;

function boundedLevenshteinDistance(
left: string,
right: string,
maxDistance: number,
): number {
if (left === right) return 0;
if (Math.abs(left.length - right.length) > maxDistance)
return maxDistance + 1;
if (left.length === 0) return right.length;
if (right.length === 0) return left.length;
const rows = left.length + 1;
const cols = right.length + 1;
const matrix: number[][] = Array.from({ length: rows }, () => Array<number>(cols).fill(0));
for (let row = 0; row < rows; row++) matrix[row]![0] = row;
for (let col = 0; col < cols; col++) matrix[0]![col] = col;
for (let row = 1; row < rows; row++) {
for (let col = 1; col < cols; col++) {

let previous = Array.from({ length: right.length + 1 }, (_, col) => col);
let current = Array<number>(right.length + 1).fill(0);

for (let row = 1; row <= left.length; row++) {
current[0] = row;
let rowMin = current[0]!;
for (let col = 1; col <= right.length; col++) {
const cost = left[row - 1] === right[col - 1] ? 0 : 1;
matrix[row]![col] = Math.min(
matrix[row - 1]![col]! + 1,
matrix[row]![col - 1]! + 1,
matrix[row - 1]![col - 1]! + cost,
const distance = Math.min(
previous[col]! + 1,
current[col - 1]! + 1,
previous[col - 1]! + cost,
);
current[col] = distance;
rowMin = Math.min(rowMin, distance);
}
if (rowMin > maxDistance) return maxDistance + 1;
[previous, current] = [current, previous];
}
return matrix[left.length]![right.length]!;
return previous[right.length]!;
}

export function levenshteinDistance(left: string, right: string): number {
return boundedLevenshteinDistance(left, right, Number.MAX_SAFE_INTEGER);
}

function commandSuggestTargets(catalog: CommandSuggestCatalog): string[] {
return [...catalog.mentionCommands, ...catalog.actionCommands, ...Object.keys(catalog.actionAliases)];
return [
...catalog.mentionCommands,
...catalog.actionCommands,
...Object.keys(catalog.actionAliases),
];
}

export function isKnownGittensoryCommandVerb(rawVerb: string, catalog: CommandSuggestCatalog): boolean {
export function isKnownGittensoryCommandVerb(
rawVerb: string,
catalog: CommandSuggestCatalog,
): boolean {
const verb = rawVerb.trim().toLowerCase();
if (!verb) return false;
const canonical = catalog.actionAliases[verb] ?? verb;
Expand All @@ -46,13 +71,27 @@ export function isKnownGittensoryCommandVerb(rawVerb: string, catalog: CommandSu
}

/** Return the closest catalog command within {@link COMMAND_SUGGEST_MAX_DISTANCE}, or null. */
export function suggestCommand(rawVerb: string, catalog: CommandSuggestCatalog): string | null {
export function suggestCommand(
rawVerb: string,
catalog: CommandSuggestCatalog,
): string | null {
const verb = rawVerb.trim().toLowerCase();
if (!verb || isKnownGittensoryCommandVerb(verb, catalog)) return null;
if (
!verb ||
verb.length > COMMAND_SUGGEST_MAX_VERB_LENGTH ||
isKnownGittensoryCommandVerb(verb, catalog)
)
return null;
const targets = commandSuggestTargets(catalog);
let best: { name: string; distance: number } | null = null;
for (const name of targets) {
const distance = levenshteinDistance(verb, name);
if (Math.abs(verb.length - name.length) > COMMAND_SUGGEST_MAX_DISTANCE)
continue;
const distance = boundedLevenshteinDistance(
verb,
name,
COMMAND_SUGGEST_MAX_DISTANCE,
);
if (best === null || distance < best.distance) {
best = { name, distance };
}
Expand Down
11 changes: 10 additions & 1 deletion test/unit/command-suggest.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import {
COMMAND_SUGGEST_MAX_DISTANCE,
COMMAND_SUGGEST_MAX_VERB_LENGTH,
buildDidYouMeanSections,
formatDidYouMeanLine,
isKnownGittensoryCommandVerb,
Expand Down Expand Up @@ -58,6 +59,12 @@ describe("suggestCommand", () => {
expect(suggestCommand("xyzzyqwerty", catalog)).toBeNull();
});

it("skips suggestions for oversized unknown verbs", () => {
const oversizedVerb = "a".repeat(COMMAND_SUGGEST_MAX_VERB_LENGTH + 1);

expect(suggestCommand(oversizedVerb, catalog)).toBeNull();
});

it("keeps the closest catalog entry when multiple targets are within range", () => {
expect(suggestCommand("hel", catalog)).toBe("help");
});
Expand All @@ -71,7 +78,9 @@ describe("suggestCommand", () => {

describe("formatDidYouMeanLine", () => {
it("renders a public-safe markdown hint", () => {
expect(formatDidYouMeanLine("preflight")).toBe("- Did you mean `@gittensory preflight`?");
expect(formatDidYouMeanLine("preflight")).toBe(
"- Did you mean `@gittensory preflight`?",
);
});
});

Expand Down