Skip to content

Commit 07e7981

Browse files
committed
feat: warn on unknown options and suggest the intended one
1 parent 967f709 commit 07e7981

3 files changed

Lines changed: 214 additions & 0 deletions

File tree

‎packages/nuxt-cli/src/main.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { setupProxySupport } from './utils/network'
2020
import{withLocalBinPath}from'./utils/path-env'
2121
import{resolveProjectDir}from'./utils/paths'
2222
import{templateNames}from'./utils/templates/names'
23+
import{findUnknownFlags,suggestFlags}from'./utils/unknown-args'
2324
import{scheduleUpdateNudge}from'./utils/update-lazy'
2425

2526
// Node.js only reads `NODE_USE_ENV_PROXY` during bootstrap, so this cannot make
@@ -68,6 +69,10 @@ const _main = defineCommand({
6869
process.exit(0)
6970
}
7071

72+
if(command&&Object.hasOwn(commands,command)){
73+
awaitwarnUnknownFlags(command,ctx.rawArgs)
74+
}
75+
7176
// allow running arbitrary commands if there's a locally registered binary with `nuxt-` prefix
7277
if(ctx.args.command&&!Object.hasOwn(commands,ctx.args.command)){
7378
constcwd=resolve(ctx.args.cwd)
@@ -93,4 +98,39 @@ const _main = defineCommand({
9398
},
9499
})
95100

101+
/**
102+
* Report long flags the resolved command does not declare. Unknown flags are
103+
* otherwise parsed and silently ignored, so a misspelling looks like the flag
104+
* simply had no effect.
105+
*/
106+
asyncfunctionwarnUnknownFlags(command: string,rawArgs: string[]): Promise<void>{
107+
letdef: CommandDef<any>
108+
try{
109+
def=awaitcommands[commandaskeyoftypeofcommands]()asCommandDef<any>
110+
constsubCommands=awaitresolveLazy(def.subCommands)
111+
constsubCommand=rawArgs.slice(1).find(arg=>!arg.startsWith('-'))
112+
if(subCommands&&subCommand&&Object.hasOwn(subCommands,subCommand)){
113+
def=awaitresolveLazy(subCommands[subCommand])asCommandDef<any>
114+
}
115+
}
116+
catch(err){
117+
debug('Could not check arguments:',err)
118+
return
119+
}
120+
121+
constargsDef={ ...cwdArgs, ...awaitresolveLazy(def.args)}
122+
constunknown=findUnknownFlags(argsDef,rawArgs.slice(1))
123+
if(unknown.flags.length===0){
124+
return
125+
}
126+
127+
for(const{ flag, suggestion }ofawaitsuggestFlags(unknown)){
128+
logger.warn(`Unknown option ${styleText('cyan',flag)}.${suggestion ? ` Did you mean ${styleText('cyan',suggestion)}?` : ''}`)
129+
}
130+
}
131+
132+
functionresolveLazy<T>(value: T|(()=>T|Promise<T>)|undefined): Promise<T|undefined>{
133+
returnPromise.resolve(typeofvalue==='function' ? (valueas()=>T|Promise<T>)() : value)
134+
}
135+
96136
exportconstmain=_mainasCommandDef<any>
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
importtype{ArgsDef}from'citty'
2+
3+
/** Always accepted by citty, so never reported as unknown. */
4+
constBUILTIN_FLAGS=['help','version']
5+
6+
/** Low enough to catch a wrong case (`--loglevel`) or a dropped letter, high enough not to invent a match. */
7+
constSUGGESTION_THRESHOLD=0.3
8+
9+
constLEADING_DASHES_RE=/^--/
10+
constNEGATION_RE=/^no-/
11+
12+
exportinterfaceUnknownFlags{
13+
flags: string[]
14+
known: string[]
15+
}
16+
17+
/**
18+
* Long flags in `rawArgs` that `argsDef` does not declare.
19+
*
20+
* Only `--long` forms are considered. A bundle of short flags (`-abc`) cannot be
21+
* told apart from a single misspelled one, and guessing wrong is worse than
22+
* staying quiet.
23+
*/
24+
exportfunctionfindUnknownFlags(argsDef: ArgsDef,rawArgs: string[]): UnknownFlags{
25+
constknown=newSet<string>(BUILTIN_FLAGS)
26+
for(const[name,def]ofObject.entries(argsDef)){
27+
known.add(name)
28+
constalias=(defas{alias?: string|string[]}).alias
29+
for(constentryofArray.isArray(alias) ? alias : alias ? [alias] : []){
30+
known.add(entry)
31+
}
32+
}
33+
34+
constseparator=rawArgs.indexOf('--')
35+
constargv=separator===-1 ? rawArgs : rawArgs.slice(0,separator)
36+
37+
constflags: string[]=[]
38+
for(constargofargv){
39+
if(!arg.startsWith('--')||arg==='--'){
40+
continue
41+
}
42+
constequals=arg.indexOf('=')
43+
constname=(equals===-1 ? arg : arg.slice(0,equals)).replace(LEADING_DASHES_RE,'')
44+
if(name&&!isKnown(known,name)&&!flags.includes(name)){
45+
flags.push(name)
46+
}
47+
}
48+
return{ flags,known: [...known]}
49+
}
50+
51+
/**
52+
* The declared flag each unknown flag was most likely meant to be. Resolved
53+
* separately so the fuzzy matcher is only loaded once something is wrong.
54+
*/
55+
exportasyncfunctionsuggestFlags({ flags, known }: UnknownFlags): Promise<Array<{flag: string,suggestion?: string}>>{
56+
const{default: fuzzysort}=awaitimport('fuzzysort')
57+
returnflags.map((flag)=>{
58+
const[match]=fuzzysort.go(flag.replace(NEGATION_RE,''),known,{limit: 1,threshold: SUGGESTION_THRESHOLD})
59+
return{flag: `--${flag}`,suggestion: match&&`--${match.target}`}
60+
})
61+
}
62+
63+
/**
64+
* Dotted flags are declared either whole (`https.cert`) or as the object that
65+
* holds them (`https`), and a boolean may be negated with a `no-` prefix.
66+
*/
67+
functionisKnown(known: Set<string>,name: string): boolean{
68+
for(constcandidateofnewSet([name,name.replace(NEGATION_RE,'')])){
69+
if(known.has(candidate)){
70+
returntrue
71+
}
72+
for(letdot=candidate.lastIndexOf('.');dot!==-1;dot=candidate.lastIndexOf('.',dot-1)){
73+
if(known.has(candidate.slice(0,dot))){
74+
returntrue
75+
}
76+
}
77+
}
78+
returnfalse
79+
}
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
importtype{ArgsDef,CommandDef}from'citty'
2+
3+
import{describe,expect,it}from'vitest'
4+
5+
import{commands}from'../../src/commands'
6+
import{cwdArgs}from'../../src/commands/_shared'
7+
import{findUnknownFlags,suggestFlags}from'../../src/utils/unknown-args'
8+
9+
constargsDef={
10+
'cwd': {type: 'string'},
11+
'json': {type: 'boolean'},
12+
'logLevel': {type: 'string'},
13+
'fork': {type: 'boolean'},
14+
'https': {type: 'boolean'},
15+
'open.url': {type: 'string'},
16+
'port': {type: 'string',alias: ['p']},
17+
}satisfiesArgsDef
18+
19+
describe('findUnknownFlags',()=>{
20+
it('should accept declared flags, aliases and negations',()=>{
21+
const{ flags }=findUnknownFlags(argsDef,['--cwd=.','--json','--no-fork','--port','3000','--help','--version'])
22+
expect(flags).toEqual([])
23+
})
24+
25+
it('should accept a dotted flag declared whole or by its parent',()=>{
26+
expect(findUnknownFlags(argsDef,['--open.url=/admin','--https.cert=x','--https.key','y']).flags).toEqual([])
27+
})
28+
29+
it('should report a flag that is not declared',()=>{
30+
expect(findUnknownFlags(argsDef,['--strictport']).flags).toEqual(['strictport'])
31+
})
32+
33+
it('should report each unknown flag once',()=>{
34+
expect(findUnknownFlags(argsDef,['--nope','--nope=1']).flags).toEqual(['nope'])
35+
})
36+
37+
it('should ignore short flags and everything after a separator',()=>{
38+
expect(findUnknownFlags(argsDef,['-abc','-x','--','--nope']).flags).toEqual([])
39+
})
40+
41+
it('should ignore positionals and a bare separator',()=>{
42+
expect(findUnknownFlags(argsDef,['build','./app','--']).flags).toEqual([])
43+
})
44+
})
45+
46+
describe('suggestFlags',()=>{
47+
it('should suggest the declared flag a misspelling is closest to',async()=>{
48+
awaitexpect(suggestFlags(findUnknownFlags(argsDef,['--loglevel','--jsn']))).resolves.toEqual([
49+
{flag: '--loglevel',suggestion: '--logLevel'},
50+
{flag: '--jsn',suggestion: '--json'},
51+
])
52+
})
53+
54+
it('should not invent a suggestion for an unrelated flag',async()=>{
55+
awaitexpect(suggestFlags(findUnknownFlags(argsDef,['--xyzzy']))).resolves.toEqual([{flag: '--xyzzy',suggestion: undefined}])
56+
})
57+
})
58+
59+
describe('declared command arguments',()=>{
60+
it('should never be reported as unknown',async()=>{
61+
constreported: Record<string,string[]>={}
62+
63+
for(constnameofObject.keys(commands)){
64+
for(const[commandName,def]ofawaitresolveCommands(name)){
65+
constargs=awaitresolveLazy((defasCommandDef<any>).args)??{}
66+
constargv: string[]=[]
67+
for(const[flag,definition]ofObject.entries(argsasArgsDef)){
68+
if((definitionas{type?: string}).type==='positional'){
69+
continue
70+
}
71+
argv.push(`--${flag}`)
72+
}
73+
const{ flags }=findUnknownFlags({ ...cwdArgs, ...args},argv)
74+
if(flags.length>0){
75+
reported[commandName]=flags
76+
}
77+
}
78+
}
79+
80+
expect(reported).toEqual({})
81+
})
82+
})
83+
84+
asyncfunctionresolveCommands(name: string): Promise<Array<[string,unknown]>>{
85+
constdef=awaitcommands[nameaskeyoftypeofcommands]()asCommandDef<any>
86+
constsubCommands=awaitresolveLazy(def.subCommands)
87+
if(!subCommands){
88+
return[[name,def]]
89+
}
90+
returnPromise.all(Object.entries(subCommands).map(async([subName,subDef])=>[`${name}${subName}`,awaitresolveLazy(subDef)]as[string,unknown]))
91+
}
92+
93+
functionresolveLazy<T>(value: T|(()=>T|Promise<T>)|undefined): Promise<T|undefined>{
94+
returnPromise.resolve(typeofvalue==='function' ? (valueas()=>T|Promise<T>)() : value)
95+
}

0 commit comments

Comments
 (0)