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());