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
41 changes: 37 additions & 4 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ export type HelpJsonResult =
| ({ routes: RouteInfo[] } & { _banner?: string })
| CommandInfo
| RouteInfo
| { error: string };
| { error: string; suggestions?: string[] };

/**
* Introspect the full command tree.
Expand All @@ -166,19 +166,48 @@ export function introspectAllCommands(): { routes: RouteInfo[] } {
/**
* Introspect a specific command or group.
* Returns the resolved command/group info, or an error object
* if the path doesn't resolve.
* with optional fuzzy suggestions if the path doesn't resolve.
*/
export function introspectCommand(
commandPath: string[]
): CommandInfo | RouteInfo | { error: string } {
): CommandInfo | RouteInfo | { error: string; suggestions?: string[] } {
const routeMap = routes as unknown as RouteMap;
const resolved = resolveCommandPath(routeMap, commandPath);
if (!resolved) {
return { error: `Command not found: ${commandPath.join(" ")}` };
}
if (resolved.kind === "unresolved") {
const { suggestions } = resolved;
return {
error: `Command not found: ${commandPath.join(" ")}`,
suggestions: suggestions.length > 0 ? suggestions : undefined,
};
}
return resolved.info;
}

// ---------------------------------------------------------------------------
// Suggestion Formatting
// ---------------------------------------------------------------------------

/**
* Join suggestion strings with Oxford-comma grammar.
*
* Matches Stricli's "did you mean" style:
* - 1 item: `"issue"`
* - 2 items: `"issue or trace"`
* - 3 items: `"issue, trace, or auth"`
*/
function formatSuggestionList(items: string[]): string {
if (items.length <= 1) {
return items.join("");
}
if (items.length === 2) {
return `${items[0]} or ${items[1]}`;
}
return `${items.slice(0, -1).join(", ")}, or ${items.at(-1)}`;
}

// ---------------------------------------------------------------------------
// Human Rendering of Introspection Data
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -288,8 +317,12 @@ export function formatHelpHuman(data: HelpJsonResult): string {
return formatCommandHuman(data as CommandInfo);
}

// Error
// Error (with optional fuzzy suggestions)
if ("error" in data) {
const { suggestions } = data;
if (suggestions && suggestions.length > 0) {
return `Error: ${data.error}\n\nDid you mean: ${formatSuggestionList(suggestions)}?`;
}
return `Error: ${data.error}`;
}

Expand Down
66 changes: 53 additions & 13 deletions src/lib/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* for introspection and documentation generation.
*/

import { fuzzyMatch } from "./fuzzy.js";

// ---------------------------------------------------------------------------
// Stricli Runtime Types (simplified for introspection)
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -105,6 +107,19 @@ export type ResolvedPath =
| { kind: "command"; info: CommandInfo }
| { kind: "group"; info: RouteInfo };

/**
* Returned when a path segment fails to match any route.
* Includes fuzzy-matched suggestions (up to 3) from the available
* routes at the level where matching failed.
*/
export type UnresolvedPath = {
kind: "unresolved";
/** The input segment that didn't match any route */
input: string;
/** Fuzzy-matched suggestions from available route names */
suggestions: string[];
};

// ---------------------------------------------------------------------------
// Type Guards
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -279,6 +294,9 @@ export function extractAllRoutes(routeMap: RouteMap): RouteInfo[] {
return result;
}

/** Maximum number of fuzzy suggestions to include in an UnresolvedPath. */
const MAX_SUGGESTIONS = 3;

/**
* Resolve a command path through the route tree.
*
Expand All @@ -287,27 +305,40 @@ export function extractAllRoutes(routeMap: RouteMap): RouteInfo[] {
* or the command if it's a standalone command
* - Two segments (e.g. ["issue", "list"]) → returns the specific subcommand
*
* When a segment doesn't match, returns an {@link UnresolvedPath} with
* fuzzy-matched suggestions from the available routes at that level.
*
* @param routeMap - Top-level Stricli route map
* @param path - Command path segments (e.g. ["issue", "list"])
* @returns Resolved command or group info, or null if not found
* @returns Resolved command/group, unresolved with suggestions, or null for empty paths
*/
export function resolveCommandPath(
routeMap: RouteMap,
path: string[]
): ResolvedPath | null {
): ResolvedPath | UnresolvedPath | null {
if (path.length === 0) {
return null;
}

const [first, ...rest] = path;
const first = path[0];
const rest = path.slice(1);

// length === 0 is handled above; this guard helps TS narrow the type
if (first === undefined) {
return null;
}

// Find the top-level entry matching the first segment
const entry = routeMap
.getAllEntries()
.find((e) => e.name.original === first && !e.hidden);
// Collect visible entries once — used for both exact match and fuzzy fallback
const visibleEntries = routeMap.getAllEntries().filter((e) => !e.hidden);
const entry = visibleEntries.find((e) => e.name.original === first);

if (!entry) {
return null;
const names = visibleEntries.map((e) => e.name.original);
return {
kind: "unresolved",
input: first,
suggestions: fuzzyMatch(first, names, { maxResults: MAX_SUGGESTIONS }),
};
}

const target = entry.target;
Expand Down Expand Up @@ -344,14 +375,23 @@ export function resolveCommandPath(
return null;
}

// Find the subcommand
// Find the subcommand, with fuzzy fallback
const subName = rest[0];
const subEntry = target
.getAllEntries()
.find((e) => e.name.original === subName && !e.hidden);
if (subName === undefined) {
return null;
}
const visibleSubEntries = target.getAllEntries().filter((e) => !e.hidden);
const subEntry = visibleSubEntries.find((e) => e.name.original === subName);

if (!subEntry) {
return null;
const subNames = visibleSubEntries.map((e) => e.name.original);
return {
kind: "unresolved",
input: subName,
suggestions: fuzzyMatch(subName, subNames, {
maxResults: MAX_SUGGESTIONS,
}),
};
}

if (isCommand(subEntry.target)) {
Expand Down
65 changes: 65 additions & 0 deletions test/commands/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,68 @@ describe("introspectCommand error cases", () => {
expect(result).toHaveProperty("error");
});
});

describe("introspectCommand fuzzy suggestions", () => {
test("top-level typo includes suggestions", async () => {
const { introspectCommand } = await import("../../src/lib/help.js");
const result = introspectCommand(["isseu"]);
expect(result).toHaveProperty("error");
if ("error" in result) {
expect(result.error).toContain("isseu");
expect(result.suggestions).toContain("issue");
}
});

test("subcommand typo includes suggestions", async () => {
const { introspectCommand } = await import("../../src/lib/help.js");
const result = introspectCommand(["issue", "lis"]);
expect(result).toHaveProperty("error");
if ("error" in result) {
expect(result.suggestions).toContain("list");
}
});

test("completely unrelated input has no suggestions", async () => {
const { introspectCommand } = await import("../../src/lib/help.js");
const result = introspectCommand(["xyzfoo123456"]);
expect(result).toHaveProperty("error");
if ("error" in result) {
expect(result.suggestions).toBeUndefined();
}
});
});

describe("formatHelpHuman with suggestions", () => {
test("renders 'Did you mean' with single suggestion", async () => {
const { formatHelpHuman } = await import("../../src/lib/help.js");
const output = formatHelpHuman({
error: "Command not found: isseu",
suggestions: ["issue"],
});
expect(output).toContain("Did you mean: issue?");
});

test("renders 'Did you mean' with multiple suggestions", async () => {
const { formatHelpHuman } = await import("../../src/lib/help.js");
const output = formatHelpHuman({
error: "Command not found: trc",
suggestions: ["trace", "trial"],
});
expect(output).toContain("Did you mean: trace or trial?");
});

test("renders three suggestions with Oxford comma", async () => {
const { formatHelpHuman } = await import("../../src/lib/help.js");
const output = formatHelpHuman({
error: "Command not found: x",
suggestions: ["alpha", "beta", "gamma"],
});
expect(output).toContain("Did you mean: alpha, beta, or gamma?");
});

test("no 'Did you mean' when suggestions are absent", async () => {
const { formatHelpHuman } = await import("../../src/lib/help.js");
const output = formatHelpHuman({ error: "Command not found: xyz123" });
expect(output).not.toContain("Did you mean");
});
});
46 changes: 42 additions & 4 deletions test/lib/introspect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,13 @@ describe("resolveCommandPath", () => {
expect(resolveCommandPath(topLevel, [])).toBeNull();
});

test("returns null for unknown top-level entry", () => {
expect(resolveCommandPath(topLevel, ["nonexistent"])).toBeNull();
test("returns unresolved for unknown top-level entry", () => {
const result = resolveCommandPath(topLevel, ["nonexistent"]);
expect(result).not.toBeNull();
expect(result?.kind).toBe("unresolved");
if (result?.kind === "unresolved") {
expect(result.input).toBe("nonexistent");
}
});

test("resolves route group", () => {
Expand Down Expand Up @@ -377,8 +382,13 @@ describe("resolveCommandPath", () => {
}
});

test("returns null for unknown subcommand", () => {
expect(resolveCommandPath(topLevel, ["issue", "unknown"])).toBeNull();
test("returns unresolved for unknown subcommand", () => {
const result = resolveCommandPath(topLevel, ["issue", "unknown"]);
expect(result).not.toBeNull();
expect(result?.kind).toBe("unresolved");
if (result?.kind === "unresolved") {
expect(result.input).toBe("unknown");
}
});

test("returns null when navigating deeper into standalone command", () => {
Expand All @@ -391,6 +401,34 @@ describe("resolveCommandPath", () => {
resolveCommandPath(topLevel, ["issue", "list", "extra", "more"])
).toBeNull();
});

// -------------------------------------------------------------------------
// Fuzzy suggestions
// -------------------------------------------------------------------------

test("suggests close top-level match for typo", () => {
const result = resolveCommandPath(topLevel, ["issu"]);
expect(result?.kind).toBe("unresolved");
if (result?.kind === "unresolved") {
expect(result.suggestions).toContain("issue");
}
});

test("suggests close subcommand match for typo", () => {
const result = resolveCommandPath(topLevel, ["issue", "lis"]);
expect(result?.kind).toBe("unresolved");
if (result?.kind === "unresolved") {
expect(result.suggestions).toContain("list");
}
});

test("returns empty suggestions for completely unrelated input", () => {
const result = resolveCommandPath(topLevel, ["xyzfoo123"]);
expect(result?.kind).toBe("unresolved");
if (result?.kind === "unresolved") {
expect(result.suggestions).toEqual([]);
}
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading