|
| 1 | +exportinterfaceSuggestionPolicy{ |
| 2 | +/** Minimum `fuzzysort` score for a subsequence match to be believed. */ |
| 3 | +threshold: number |
| 4 | +/** How far ahead of the runner up a subsequence match has to be. */ |
| 5 | +margin?: number |
| 6 | +/** Maximum number of edits between the input and a candidate. */ |
| 7 | +tolerance?: (input: string)=>number |
| 8 | +/** Whether a unique prefix of exactly one candidate is accepted outright. */ |
| 9 | +prefix?: boolean |
| 10 | +/** Whether candidates tied on edit distance are rejected rather than picked between. */ |
| 11 | +requireUnique?: boolean |
| 12 | +} |
| 13 | + |
| 14 | +/** |
| 15 | + * A wrong command suggestion replaces the help output the user would otherwise |
| 16 | + * have read, so it has to be right; a wrong flag suggestion sits next to the |
| 17 | + * flag they typed, so a lower bar is worth the extra hits. |
| 18 | + */ |
| 19 | +exportconstcommandPolicy: SuggestionPolicy={ |
| 20 | +threshold: 0.6, |
| 21 | +margin: 0.1, |
| 22 | +tolerance: input=>input.length<=4 ? 1 : 2, |
| 23 | +prefix: true, |
| 24 | +requireUnique: true, |
| 25 | +} |
| 26 | + |
| 27 | +exportconstflagPolicy: SuggestionPolicy={ |
| 28 | +threshold: 0.3, |
| 29 | +tolerance: input=>input.length<=4 ? 1 : 2, |
| 30 | +} |
| 31 | + |
| 32 | +/** |
| 33 | + * Best guess at the candidate an input was meant to be, or `undefined` when |
| 34 | + * nothing is close enough to be worth printing. |
| 35 | + * |
| 36 | + * `fuzzysort` matches prefixes and subsequences well but scores transpositions |
| 37 | + * at zero (`biuld` against `build`, `dotnev` against `dotenv`), which is the |
| 38 | + * most common typo of all, so edit distance covers what it rejects. |
| 39 | + */ |
| 40 | +exportasyncfunctionsuggestClosest(input: string,candidates: string[],policy: SuggestionPolicy): Promise<string|undefined>{ |
| 41 | +constquery=input.toLowerCase() |
| 42 | +if(!query||candidates.includes(input)){ |
| 43 | +returnundefined |
| 44 | +} |
| 45 | + |
| 46 | +constlowerCased=candidates.map(candidate=>candidate.toLowerCase()) |
| 47 | + |
| 48 | +if(policy.prefix){ |
| 49 | +constprefixMatches=candidates.filter((_,index)=>lowerCased[index]!.startsWith(query)) |
| 50 | +if(prefixMatches.length===1){ |
| 51 | +returnprefixMatches[0] |
| 52 | +} |
| 53 | +} |
| 54 | + |
| 55 | +const{default: fuzzysort}=awaitimport('fuzzysort') |
| 56 | +const[best,runnerUp]=fuzzysort.go(query,candidates,{threshold: policy.threshold}) |
| 57 | +if(best&&(!runnerUp||best.score-runnerUp.score>=(policy.margin??0))){ |
| 58 | +returnbest.target |
| 59 | +} |
| 60 | + |
| 61 | +consttolerance=policy.tolerance?.(query)??0 |
| 62 | +if(tolerance<=0){ |
| 63 | +returnundefined |
| 64 | +} |
| 65 | + |
| 66 | +letclosest: string|undefined |
| 67 | +letclosestDistance=Number.POSITIVE_INFINITY |
| 68 | +lettied=false |
| 69 | +for(const[index,candidate]oflowerCased.entries()){ |
| 70 | +constdistance=editDistance(query,candidate) |
| 71 | +if(distance<closestDistance){ |
| 72 | +closestDistance=distance |
| 73 | +closest=candidates[index] |
| 74 | +tied=false |
| 75 | +} |
| 76 | +elseif(distance===closestDistance){ |
| 77 | +tied=true |
| 78 | +} |
| 79 | +} |
| 80 | + |
| 81 | +if(closestDistance>tolerance||(tied&&policy.requireUnique)){ |
| 82 | +returnundefined |
| 83 | +} |
| 84 | +returnclosest |
| 85 | +} |
| 86 | + |
| 87 | +/** Damerau-Levenshtein distance, so a single transposition counts as one edit. */ |
| 88 | +functioneditDistance(a: string,b: string): number{ |
| 89 | +constrows=Array.from({length: a.length+1},()=>newUint16Array(b.length+1)) |
| 90 | +for(leti=0;i<=a.length;i++){ |
| 91 | +rows[i]![0]=i |
| 92 | +} |
| 93 | +for(letj=0;j<=b.length;j++){ |
| 94 | +rows[0]![j]=j |
| 95 | +} |
| 96 | +for(leti=1;i<=a.length;i++){ |
| 97 | +for(letj=1;j<=b.length;j++){ |
| 98 | +constcost=a[i-1]===b[j-1] ? 0 : 1 |
| 99 | +rows[i]![j]=Math.min( |
| 100 | +rows[i-1]![j]!+1, |
| 101 | +rows[i]![j-1]!+1, |
| 102 | +rows[i-1]![j-1]!+cost, |
| 103 | +) |
| 104 | +if(i>1&&j>1&&a[i-1]===b[j-2]&&a[i-2]===b[j-1]){ |
| 105 | +rows[i]![j]=Math.min(rows[i]![j]!,rows[i-2]![j-2]!+cost) |
| 106 | +} |
| 107 | +} |
| 108 | +} |
| 109 | +returnrows[a.length]![b.length]! |
| 110 | +} |
0 commit comments