From 4e95c9ad9fec00ccdb59a47f6fa9954ae2b6ec4e Mon Sep 17 00:00:00 2001 From: CMaintz Date: Fri, 9 Oct 2026 13:18:14 +0200 Subject: [PATCH] feat(runtime): rank file and folder suggestions by closest match Path suggestions sharing a priority showed up in filesystem order, so with fuzzy filtering `cd s` could list docs before src. Within a priority band, files and folders are now ranked by match quality (exact, prefix, substring), then shorter names, then alphabetically. Paths only swap places with other paths, so subcommands, options and spec suggestions keep their existing order. --- src/runtime/suggestion.ts | 51 +++++++---- .../__snapshots__/runtime.test.ts.snap | 91 ++++++++++++++++++- src/tests/runtime/runtime.test.ts | 1 + 3 files changed, 122 insertions(+), 21 deletions(-) diff --git a/src/runtime/suggestion.ts b/src/runtime/suggestion.ts index 17c14be..558a458 100644 --- a/src/runtime/suggestion.ts +++ b/src/runtime/suggestion.ts @@ -126,6 +126,37 @@ const getSuggestionPriority = (suggestion: Fig.Suggestion): number => { return suggestion.priority ?? 50; }; +// How well a name matches the text typed so far: 3 = exact, 2 = prefix, 1 = substring, 0 = fuzzy only. +const getMatchRelevance = (name: string, partialCmd: string): number => { + const haystack = name.toLowerCase(); + const needle = partialCmd.toLowerCase(); + if (haystack === needle) return 3; + const matchIndex = haystack.indexOf(needle); + if (matchIndex === 0) return 2; + if (matchIndex > 0) return 1; + return 0; +}; + +// Sorts by priority, then ranks file and folder suggestions within a priority band by match quality, +// shorter names and alphabetically. Paths only swap places with other paths, so subcommands, options +// and spec-authored suggestions keep their source order. +const sortSuggestions = (suggestions: Suggestion[], partialCmd: string | undefined): Suggestion[] => { + const sorted = suggestions.sort((a, b) => b.priority - a.priority); + if (!partialCmd) return sorted; + const pathIndexes = sorted.flatMap((s, i) => (getPathy(s.type) ? [i] : [])); + const rankedPaths = pathIndexes + .map((i) => sorted[i]) + .sort( + (a, b) => + b.priority - a.priority || + getMatchRelevance(b.name, partialCmd) - getMatchRelevance(a.name, partialCmd) || + a.name.length - b.name.length || + a.name.localeCompare(b.name), + ); + pathIndexes.forEach((sortedIndex, rank) => (sorted[sortedIndex] = rankedPaths[rank])); + return sorted; +}; + function filter; type?: Fig.SuggestionType | undefined }>( suggestions: T[], filterStrategy: FilterStrategy | undefined, @@ -367,15 +398,7 @@ export const getSubcommandDrivenRecommendation = async ( removeEmptySuggestion( removeHiddenSuggestions( removeHomeDirectorySuggestion( - removeAcceptedSuggestions( - adjustPathSuggestions( - suggestions.sort((a, b) => b.priority - a.priority), - partialToken, - lastToken, - shell, - ), - acceptedTokens, - ), + removeAcceptedSuggestions(adjustPathSuggestions(sortSuggestions(suggestions, partialCmd), partialToken, lastToken, shell), acceptedTokens), allTokens, ), partialToken, @@ -422,15 +445,7 @@ export const getArgDrivenRecommendation = async ( removeEmptySuggestion( removeHiddenSuggestions( removeHomeDirectorySuggestion( - removeAcceptedSuggestions( - adjustPathSuggestions( - suggestions.sort((a, b) => b.priority - a.priority), - partialToken, - lastToken, - shell, - ), - acceptedTokens, - ), + removeAcceptedSuggestions(adjustPathSuggestions(sortSuggestions(suggestions, partialCmd), partialToken, lastToken, shell), acceptedTokens), allTokens, ), partialToken, diff --git a/src/tests/runtime/__snapshots__/runtime.test.ts.snap b/src/tests/runtime/__snapshots__/runtime.test.ts.snap index 907f5dc..a5e1089 100644 --- a/src/tests/runtime/__snapshots__/runtime.test.ts.snap +++ b/src/tests/runtime/__snapshots__/runtime.test.ts.snap @@ -701,6 +701,91 @@ exports[`parseCommand pathNestedSuggestion 1`] = ` } `; +exports[`parseCommand pathPrefixMatchesRankedFirst 1`] = ` +{ + "activeToken": { + "complete": false, + "isOption": false, + "token": "s", + "tokenLength": 1, + }, + "suggestions": [ + { + "allNames": [ + "src/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": "src/", + "name": "src", + "priority": 60, + "type": "folder", + }, + { + "allNames": [ + "shell/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": "shell/", + "name": "shell", + "priority": 60, + "type": "folder", + }, + { + "allNames": [ + "scripts/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": "scripts/", + "name": "scripts", + "priority": 60, + "type": "folder", + }, + { + "allNames": [ + "docs/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": "docs/", + "name": "docs", + "priority": 60, + "type": "folder", + }, + { + "allNames": [ + ".husky/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": ".husky/", + "name": ".husky", + "priority": 60, + "type": "folder", + }, + { + "allNames": [ + "node_modules/", + ], + "description": undefined, + "hidden": undefined, + "icon": "📁", + "insertValue": "node_modules/", + "name": "node_modules", + "priority": 60, + "type": "folder", + }, + ], +} +`; + exports[`parseCommand pathSuggestion 1`] = ` { "activeToken": { @@ -739,13 +824,13 @@ exports[`parseCommand pathWithFileFilteredSuggestion 1`] = ` "suggestions": [ { "allNames": [ - "shellIntegration.bash", + "shellIntegration.nu", ], "description": undefined, "hidden": undefined, "icon": "📄", - "insertValue": "shell/shellIntegration.bash", - "name": "shellIntegration.bash", + "insertValue": "shell/shellIntegration.nu", + "name": "shellIntegration.nu", "priority": 55, "type": "file", }, diff --git a/src/tests/runtime/runtime.test.ts b/src/tests/runtime/runtime.test.ts index 1454330..8aa0f40 100644 --- a/src/tests/runtime/runtime.test.ts +++ b/src/tests/runtime/runtime.test.ts @@ -32,6 +32,7 @@ const testData = [ { name: "pathNestedSuggestion", command: "source .github/work" }, { name: "pathWithFileSuggestion", command: "source shell/", maxSuggestions: 1 }, { name: "pathWithFileFilteredSuggestion", command: "source shell/shellIntegration.", maxSuggestions: 1 }, + { name: "pathPrefixMatchesRankedFirst", command: "cd s" }, ]; beforeAll(async () => await unpackResources());