Skip to content
This repository was archived by the owner on Dec 17, 2024. It is now read-only.

Commit 546b0fb

Browse files
committed
more helpful usage error for command not found and tab completion of commands
Fixes #684
1 parent 4790ac7 commit 546b0fb

File tree

15 files changed

+519
-111
lines changed

15 files changed

+519
-111
lines changed

app/content/js/command-tree.js

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2017 IBM Corporation
2+
* Copyright 2017-18 IBM Corporation
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -31,6 +31,17 @@ const util = require('util'),
3131

3232
debug('finished loading modules')
3333

34+
// for plugins.js
35+
exports.disambiguator = () => {
36+
const map = {}
37+
for (let command in disambiguator) {
38+
map[command] = disambiguator[command].map(({route, options}) => ({
39+
route, plugin: options && options.plugin
40+
}))
41+
}
42+
return map
43+
}
44+
3445
/** shallow array equality */
3546
const sameArray = (A, B) => A.length === B.length && A.every((element, idx) => element === B[idx])
3647

@@ -402,6 +413,7 @@ const withEvents = (evaluator, leaf) => {
402413
}
403414

404415
return {
416+
subtree: leaf,
405417
route: leaf.route,
406418
eval: evaluator,
407419
options: leaf && leaf.options,
@@ -492,39 +504,133 @@ const read = (model, argv) => {
492504
*
493505
*/
494506
const disambiguate = (argv, noRetry=false) => {
495-
const resolutions = (disambiguator[argv[0]] || disambiguator[argv[argv.length -1]] || []).filter(isFileFilter)
496-
debug('disambiguate', argv, resolutions)
507+
let idx
508+
const resolutions = ( (((idx=0)||true) && disambiguator[argv[idx]]) || (((idx=argv.length-1)||true) && disambiguator[argv[idx]]) || []).filter(isFileFilter)
509+
debug('disambiguate', idx, argv, resolutions)
497510

498511
if (resolutions.length === 0 && !noRetry) {
499512
// maybe we haven't loaded the plugin, yet
513+
debug('disambiguate attempting to resolve plugins')
500514
resolver.resolve(`/${argv.join('/')}`)
501515
return disambiguate(argv, true)
502516

503517
} else if (resolutions.length === 1) {
518+
// one unambiguous resolution! great, but we need to
519+
// double-check: if the resolution is a subtree, then it better have a child that matches
504520
const leaf = resolutions[0]
521+
522+
if (idx < argv.length - 1 && leaf.children) {
523+
// then the match is indeed a subtree
524+
let foundMatch = false
525+
const next = argv[argv.length - 1]
526+
for (let cmd in leaf.children) {
527+
if (cmd === next) {
528+
foundMatch = true
529+
break
530+
}
531+
}
532+
if (!foundMatch) {
533+
debug('disambiguate blocked due to subtree mismatch')
534+
return
535+
}
536+
}
537+
505538
debug('disambiguate success', leaf)
506539
return withEvents(leaf.$, leaf)
507540
}
508541
}
509542

510543
/**
511-
* Oops, we couldn't resolve the given command
544+
* Oops, we couldn't resolve the given command. But maybe we found
545+
* some partial matches that might be helpful to the user.
512546
*
513547
*/
514-
const commandNotFoundMessage = 'Command not found'
515-
const commandNotFound = argv => {
548+
const commandNotFoundMessage = 'Command not found',
549+
commandNotFoundMessageWithPartialMatches = 'The following commands are partial matches for your request.'
550+
551+
const commandNotFound = (argv, partialMatches) => {
516552
eventBus.emit('/command/resolved', {
517553
// ANONYMIZE: namespace: namespace.current(),
518554
error: commandNotFoundMessage,
519555
command: argv[0],
520556
context: exports.currentContext()
521557
})
522558

523-
const error = new Error(commandNotFoundMessage)
559+
const error = partialMatches ? formatPartialMatches(partialMatches) : new Error(commandNotFoundMessage)
524560
error.code = 404
561+
562+
// to allow for programmatic use of the partial matches, e.g. for tab completion
563+
if (partialMatches) {
564+
error.partialMatches = partialMatches.map(_ => ({ command: _.route.split('/').slice(1).join(' '),
565+
usage: _.options && _.options.usage }))
566+
}
567+
525568
throw error
526569
}
527570

571+
/**
572+
* Help the user with some partial matches for a command not found
573+
* condition. Here, we reuse the usage-error formatter, to present the
574+
* user with a list of possible completions to their (mistyped or
575+
* otherwise) command.
576+
*
577+
* We use the `available` list to present the list of available
578+
* command completions to what they typed.
579+
*
580+
*/
581+
const formatPartialMatches = matches => {
582+
return new (require('./usage-error'))({
583+
message: commandNotFoundMessage,
584+
usage: {
585+
header: commandNotFoundMessageWithPartialMatches,
586+
available: matches.map(({options}) => options.usage).filter(x=>x)
587+
}
588+
}, { noBreadcrumb: true, noHide: true })
589+
}
590+
591+
/**
592+
* Command not found: let's find partial matches at head of the given
593+
* subtree. We hope that the last part of the argv is a partial match
594+
* for some command at this root. Return all such prefix matches.
595+
*
596+
*/
597+
const findPartialMatchesAt = (subtree, partial) => {
598+
debug('scanning for partial matches', partial, subtree)
599+
600+
const matches = []
601+
602+
if (subtree && subtree.children && partial) {
603+
for (let cmd in subtree.children) {
604+
if (cmd.indexOf(partial) === 0) {
605+
const match = subtree.children[cmd]
606+
if (!match.options || (!match.options.synonymFor && !match.options.hide)) {
607+
// don't include synonyms or hidden commands
608+
matches.push(match)
609+
}
610+
}
611+
}
612+
}
613+
614+
return matches
615+
}
616+
617+
/** remove duplicates of leaf nodes from a given array */
618+
const removeDuplicates = arr => {
619+
return arr
620+
.filter(x=>x)
621+
.reduce((state, item) => {
622+
const { M, A } = state,
623+
route = item.route
624+
625+
if (item && !M[item.route]) {
626+
M[item.route] = true
627+
A.push(item)
628+
}
629+
630+
return state
631+
}, { M: {}, A: [] }).A
632+
}
633+
528634
/** here, we will use implicit context resolutions */
529635
exports.read = (argv, noRetry=false, noSubtreeRetry=false) => {
530636
let cmd = read(model, argv) || disambiguate(argv)
@@ -553,7 +659,41 @@ exports.read = (argv, noRetry=false, noSubtreeRetry=false) => {
553659
}
554660

555661
if (!cmd) {
556-
return commandNotFound(argv)
662+
debug('command not found, searching for partial matches')
663+
664+
// command not found, but maybe we can find partial matches
665+
// that might be helpful?
666+
let matches
667+
668+
if (argv.length === 1) {
669+
debug('searching for partial matches at root')
670+
671+
// disambiguatePartial takes a partial command, and
672+
// returns an array of matching full commands, which we
673+
// can turn into leafs via `disambiguate`
674+
matches = removeDuplicates(findPartialMatchesAt(model, argv[0])
675+
.concat(resolver.disambiguatePartial(argv[0]).map(_ => [_]).map(_ => disambiguate(_))))
676+
677+
} else {
678+
const allButLast = argv.slice(0, argv.length - 1),
679+
last = argv[argv.length - 1]
680+
681+
debug('searching for partial matches for subcommand', allButLast)
682+
683+
const parent = read(model, allButLast) || disambiguate(allButLast)
684+
if (parent) {
685+
matches = removeDuplicates(findPartialMatchesAt(parent.subtree, last))
686+
}
687+
}
688+
689+
// found some partial matches?
690+
if (matches && matches.length > 0) {
691+
debug('found partial matches', matches)
692+
} else {
693+
matches = undefined
694+
}
695+
696+
return commandNotFound(argv, matches)
557697
} else {
558698
return cmd
559699
}

app/content/js/plugins.js

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -373,9 +373,43 @@ const unify = m1 => m2 => {
373373
const makeResolver = prescan => {
374374
debug('makeResolver')
375375

376+
/** memoize resolved plugins */
376377
const isResolved = {}
378+
379+
/** resolve one given plugin */
380+
const resolveOne = plugin => {
381+
if (!plugin || isResolved[plugin]) {
382+
return
383+
}
384+
isResolved[plugin] = true
385+
const prereqs = prescan.topological[plugin]
386+
if (prereqs) {
387+
prereqs.forEach(exports.require)
388+
}
389+
exports.require(plugin)
390+
}
391+
392+
/** a plugin resolver impl */
377393
const resolver = {
378394
isOverridden: route => prescan.overrides[route],
395+
396+
/** given a partial command, do we have a disambiguation of it? e.g. "gr" => "grid" */
397+
disambiguatePartial: partial => {
398+
const matches = []
399+
if (prescan.disambiguator) {
400+
for (let command in prescan.disambiguator) {
401+
if (command.indexOf(partial) === 0) {
402+
const { route, plugin } = prescan.disambiguator[command]
403+
matches.push(command)
404+
}
405+
}
406+
}
407+
408+
debug('disambiguate partial', partial, matches)
409+
return matches
410+
},
411+
412+
/** load any plugins required by the given command */
379413
resolve: (command, {subtree=false}={}) => { // subpath if we are looking for plugins for a subtree, e.g. for cd /auth
380414
let plugin, matchLen
381415
for (let route in prescan.commandToPlugin) {
@@ -387,15 +421,7 @@ const makeResolver = prescan => {
387421
}
388422
}
389423
if (plugin) {
390-
if (isResolved[plugin]) {
391-
return
392-
}
393-
isResolved[plugin] = true
394-
const prereqs = prescan.topological[plugin]
395-
if (prereqs) {
396-
prereqs.forEach(exports.require)
397-
}
398-
exports.require(plugin)
424+
resolveOne(plugin)
399425
}
400426
}
401427
}
@@ -439,7 +465,7 @@ exports.scan = opts => {
439465
}
440466
}
441467
}
442-
return { commandToPlugin, topological, flat, overrides, usage }
468+
return { commandToPlugin, topological, flat, overrides, usage, disambiguator: commandTree.disambiguator() }
443469
})
444470
}
445471

app/content/js/repl.js

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -834,18 +834,23 @@ self.exec = (commandUntrimmed, execOptions) => {
834834
throw e
835835
}
836836

837-
console.error(e.message)
838-
console.trace()
837+
console.error(e)
839838

840839
const blockForError = block || ui.getCurrentProcessingBlock()
841840

842-
const cmd = help.show(blockForError, nextBlock, e.message || 'Unknown command')
843-
const isPromise = !!(cmd && cmd.then)
844-
const cmdPromise = isPromise ? cmd : Promise.resolve(cmd)
845-
const resultDom = blockForError.querySelector('.repl-result')
846-
return cmdPromise
847-
.then(printResults(blockForError, nextBlock, resultDom))
848-
.then(ui.installBlock(blockForError.parentNode, blockForError, nextBlock))
841+
return Promise.resolve(e.message).then(message => {
842+
if (message.nodeName) {
843+
e.message = message
844+
ui.oops(block, nextBlock)(e)
845+
846+
} else {
847+
const cmd = help.show(blockForError, nextBlock, message || 'Unknown command')
848+
const resultDom = blockForError.querySelector('.repl-result')
849+
return Promise.resolve(cmd)
850+
.then(printResults(blockForError, nextBlock, resultDom))
851+
.then(ui.installBlock(blockForError.parentNode, blockForError, nextBlock))
852+
}
853+
})
849854
}
850855
}
851856

app/content/js/ui.js

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -471,12 +471,6 @@ const ui = (function() {
471471
const message = self.oopsMessage(err),
472472
errString = err && err.toString()
473473

474-
if (!errString || (errString.indexOf('HTTP 404') < 0 && errString.indexOf('HTTP 409') < 0)) {
475-
// don't scream about 404s and 409s
476-
console.error(`${message} ${errString} ${err && err.stack}`, err)
477-
console.trace()
478-
}
479-
480474
if (!block) return // we're not attached to a prompt right now
481475

482476
ui.setStatus(block, 'error')

0 commit comments

Comments
 (0)