Skip to content

Commit

Permalink
feat: use inquirer
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonnalley committed Mar 7, 2024
1 parent ec66bc3 commit c025388
Show file tree
Hide file tree
Showing 6 changed files with 137 additions and 30 deletions.
1 change: 1 addition & 0 deletions package.json
Expand Up @@ -5,6 +5,7 @@
"author": "Salesforce",
"bugs": "https://github.com/oclif/plugin-not-found/issues",
"dependencies": {
"@inquirer/confirm": "^3.0.0",
"@oclif/core": "^3.21.0",
"chalk": "^5.3.0",
"fast-levenshtein": "^3.0.0"
Expand Down
20 changes: 5 additions & 15 deletions src/index.ts
@@ -1,11 +1,7 @@
import {Hook, toConfiguredId, ux} from '@oclif/core'
import {Hook, toConfiguredId} from '@oclif/core'
import chalk from 'chalk'
import {default as levenshtein} from 'fast-levenshtein'

export const closest = (target: string, possibilities: string[]): string =>
possibilities
.map((id) => ({distance: levenshtein.get(target, id, {useCollator: true}), id}))
.sort((a, b) => a.distance - b.distance)[0]?.id ?? ''
import utils from './utils.js'

const hook: Hook.CommandNotFound = async function (opts) {
const hiddenCommandIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id))
Expand All @@ -26,21 +22,15 @@ const hook: Hook.CommandNotFound = async function (opts) {
// otherwise the user will be presented 'did you mean 'help'?' instead of 'did you mean "help <command>"?'
let suggestion = /:?help:?/.test(opts.id)
? ['help', ...opts.id.split(':').filter((cmd) => cmd !== 'help')].join(':')
: closest(opts.id, commandIDs)
: utils.closest(opts.id, commandIDs)

const readableSuggestion = toConfiguredId(suggestion, this.config)
const originalCmd = toConfiguredId(opts.id, this.config)
this.warn(`${chalk.yellow(originalCmd)} is not a ${opts.config.bin} command.`)

let response = ''
try {
response = await ux.prompt(`Did you mean ${chalk.blueBright(readableSuggestion)}? [y/n]`, {timeout: 10_000})
} catch (error) {
this.log('')
this.debug(error)
}
const response = await utils.getConfirmation(readableSuggestion).catch(() => false)

if (response === 'y') {
if (response) {
// this will split the original command from the suggested replacement, and gather the remaining args as varargs to help with situations like:
// confit set foo-bar -> confit:set:foo-bar -> config:set:foo-bar -> config:set foo-bar
let argv = opts.argv?.length ? opts.argv : opts.id.split(':').slice(suggestion.split(':').length)
Expand Down
34 changes: 34 additions & 0 deletions src/utils.ts
@@ -0,0 +1,34 @@
import confirm from '@inquirer/confirm'
import chalk from 'chalk'
import {default as levenshtein} from 'fast-levenshtein'
import {setTimeout} from 'node:timers/promises'

const getConfirmation = async (suggestion: string): Promise<boolean> => {
const confirmation = confirm({
default: true,
message: `Did you mean ${chalk.blueBright(suggestion)}?`,
theme: {
prefix: '',
style: {
message: (text: string) => chalk.reset(text),
},
},
})

const defaultValue = setTimeout(10_000).then(() => {
confirmation.cancel()
return false
})

return Promise.race([defaultValue, confirmation])
}

const closest = (target: string, possibilities: string[]): string =>
possibilities
.map((id) => ({distance: levenshtein.get(target, id, {useCollator: true}), id}))
.sort((a, b) => a.distance - b.distance)[0]?.id ?? ''

export default {
closest,
getConfirmation,
}
18 changes: 9 additions & 9 deletions test/closest.test.ts
@@ -1,38 +1,38 @@
import {expect} from 'chai'

import {closest} from '../src/index.js'
import utils from '../src/utils.js'

describe('closest', () => {
const possibilities = ['abc', 'def', 'ghi', 'jkl', 'jlk']
it('exact match', () => {
expect(closest('abc', possibilities)).to.equal('abc')
expect(utils.closest('abc', possibilities)).to.equal('abc')
})

it('case mistake', () => {
expect(closest('aBc', possibilities)).to.equal('abc')
expect(utils.closest('aBc', possibilities)).to.equal('abc')
})

it('case match one letter', () => {
expect(closest('aZZ', possibilities)).to.equal('abc')
expect(utils.closest('aZZ', possibilities)).to.equal('abc')
})

it('one letter different mistake', () => {
expect(closest('ggi', possibilities)).to.equal('ghi')
expect(utils.closest('ggi', possibilities)).to.equal('ghi')
})

it('two letter different mistake', () => {
expect(closest('gki', possibilities)).to.equal('ghi')
expect(utils.closest('gki', possibilities)).to.equal('ghi')
})

it('extra letter', () => {
expect(closest('gkui', possibilities)).to.equal('ghi')
expect(utils.closest('gkui', possibilities)).to.equal('ghi')
})

it('two letter different mistake with close neighbor', () => {
expect(closest('jpp', possibilities)).to.equal('jkl')
expect(utils.closest('jpp', possibilities)).to.equal('jkl')
})

it('no possibilities gives empty string', () => {
expect(closest('jpp', [])).to.equal('')
expect(utils.closest('jpp', [])).to.equal('')
})
})
11 changes: 6 additions & 5 deletions test/hooks/not-found.test.ts
@@ -1,9 +1,10 @@
import {ux} from '@oclif/core'
import {expect, test} from '@oclif/test'

import utils from '../../src/utils.js'

describe('command_not_found', () => {
test
.stub(ux, 'prompt', (stub) => stub.returns('y'))
.stub(utils, 'getConfirmation', (stub) => stub.resolves(true))
.stub(process, 'argv', (stub) => stub.returns([]))
.stdout()
.stderr()
Expand All @@ -14,7 +15,7 @@ describe('command_not_found', () => {
})

test
.stub(ux, 'prompt', (stub) => stub.returns('y'))
.stub(utils, 'getConfirmation', (stub) => stub.resolves(true))
.stub(process, 'argv', (stub) => stub.returns(['username']))
.stdout()
.stderr()
Expand All @@ -26,7 +27,7 @@ describe('command_not_found', () => {

test
.stderr()
.stub(ux, 'prompt', (stub) => stub.returns('y'))
.stub(utils, 'getConfirmation', (stub) => stub.resolves(true))
.hook('command_not_found', {argv: ['foo', '--bar', 'baz'], id: 'commans'})
.catch((error: Error) => error.message.includes('Unexpected arguments: foo, --bar, baz\nSee more help with --help'))
.end('runs hook with suggested command and provided args on yes', (ctx) => {
Expand All @@ -35,7 +36,7 @@ describe('command_not_found', () => {

test
.stderr()
.stub(ux, 'prompt', (stub) => stub.returns('n'))
.stub(utils, 'getConfirmation', (stub) => stub.resolves(false))
.hook('command_not_found', {id: 'commans'})
.catch((error: Error) =>
error.message.includes('Run @oclif/plugin-not-found help for a list of available commands.'),
Expand Down
83 changes: 82 additions & 1 deletion yarn.lock
Expand Up @@ -1271,6 +1271,39 @@
resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz#d9fae00a2d5cb40f92cfe64b47ad749fbc38f917"
integrity sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==

"@inquirer/confirm@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-3.0.0.tgz#6e1e35d18675fe659752d11021f9fddf547950b7"
integrity sha512-LHeuYP1D8NmQra1eR4UqvZMXwxEdDXyElJmmZfU44xdNLL6+GcQBS0uE16vyfZVjH8c22p9e+DStROfE/hyHrg==
dependencies:
"@inquirer/core" "^7.0.0"
"@inquirer/type" "^1.2.0"

"@inquirer/core@^7.0.0":
version "7.0.0"
resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-7.0.0.tgz#18d2d2bb5cc6858765b4dcf3dce544ad15898e81"
integrity sha512-g13W5yEt9r1sEVVriffJqQ8GWy94OnfxLCreNSOTw0HPVcszmc/If1KIf7YBmlwtX4klmvwpZHnQpl3N7VX2xA==
dependencies:
"@inquirer/type" "^1.2.0"
"@types/mute-stream" "^0.0.4"
"@types/node" "^20.11.16"
"@types/wrap-ansi" "^3.0.0"
ansi-escapes "^4.3.2"
chalk "^4.1.2"
cli-spinners "^2.9.2"
cli-width "^4.1.0"
figures "^3.2.0"
mute-stream "^1.0.0"
run-async "^3.0.0"
signal-exit "^4.1.0"
strip-ansi "^6.0.1"
wrap-ansi "^6.2.0"

"@inquirer/type@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.2.0.tgz#a569613628a881c2104289ca868a7def54e5c49d"
integrity sha512-/vvkUkYhrjbm+RolU7V1aUFDydZVKNKqKHR5TsE+j5DXgXFwrsOPcoGUJ02K0O7q7O53CU2DOTMYCHeGZ25WHA==

"@isaacs/string-locale-compare@^1.1.0":
version "1.1.0"
resolved "https://registry.npmjs.org/@isaacs/string-locale-compare/-/string-locale-compare-1.1.0.tgz#291c227e93fd407a96ecd59879a35809120e432b"
Expand Down Expand Up @@ -2618,6 +2651,13 @@
resolved "https://registry.yarnpkg.com/@types/mocha/-/mocha-10.0.6.tgz#818551d39113081048bdddbef96701b4e8bb9d1b"
integrity sha512-dJvrYWxP/UcXm36Qn36fxhUKu8A/xMRXVT2cliFF1Z7UA9liG5Psj3ezNSZw+5puH2czDXRLcXQxf8JbJt0ejg==

"@types/mute-stream@^0.0.4":
version "0.0.4"
resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478"
integrity sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow==
dependencies:
"@types/node" "*"

"@types/node@*":
version "20.5.1"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.5.1.tgz#178d58ee7e4834152b0e8b4d30cbfab578b9bb30"
Expand All @@ -2635,6 +2675,13 @@
dependencies:
undici-types "~5.26.4"

"@types/node@^20.11.16":
version "20.11.25"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.25.tgz#0f50d62f274e54dd7a49f7704cc16bfbcccaf49f"
integrity sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==
dependencies:
undici-types "~5.26.4"

"@types/normalize-package-data@^2.4.0":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.1.tgz#d3357479a0fdfdd5907fe67e17e0a85c906e1301"
Expand Down Expand Up @@ -2667,6 +2714,11 @@
"@types/expect" "^1.20.4"
"@types/node" "*"

"@types/wrap-ansi@^3.0.0":
version "3.0.0"
resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd"
integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g==

"@typescript-eslint/eslint-plugin@^6.21.0":
version "6.21.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-6.21.0.tgz#30830c1ca81fd5f3c2714e524c4303e0194f9cd3"
Expand Down Expand Up @@ -3423,6 +3475,11 @@ cli-spinners@^2.5.0:
resolved "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.6.1.tgz#adc954ebe281c37a6319bfa401e6dd2488ffb70d"
integrity sha512-x/5fWmGMnbKQAaNwN+UZlV79qBLM9JFnJuJ03gIi5whrob0xV0ofNVHy9DhwGdsMJQc2OKv0oGmLzvaqvAVv+g==

cli-spinners@^2.9.2:
version "2.9.2"
resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41"
integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==

cli-table@^0.3.1:
version "0.3.6"
resolved "https://registry.yarnpkg.com/cli-table/-/cli-table-0.3.6.tgz#e9d6aa859c7fe636981fd3787378c2a20bce92fc"
Expand All @@ -3443,6 +3500,11 @@ cli-width@^3.0.0:
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-3.0.0.tgz#a2f48437a2caa9a22436e794bf071ec9e61cedf6"
integrity sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==

cli-width@^4.1.0:
version "4.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-4.1.0.tgz#42daac41d3c254ef38ad8ac037672130173691c5"
integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==

cliui@^7.0.2:
version "7.0.4"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
Expand Down Expand Up @@ -4430,7 +4492,7 @@ fastq@^1.6.0:
dependencies:
reusify "^1.0.4"

figures@^3.0.0:
figures@^3.0.0, figures@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
integrity sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==
Expand Down Expand Up @@ -6162,6 +6224,11 @@ mute-stream@0.0.8:
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"
integrity sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==

mute-stream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e"
integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA==

natural-compare-lite@^1.4.0:
version "1.4.0"
resolved "https://registry.yarnpkg.com/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz#17b09581988979fddafe0201e931ba933c96cbb4"
Expand Down Expand Up @@ -7109,6 +7176,11 @@ run-async@^2.0.0, run-async@^2.4.0:
resolved "https://registry.yarnpkg.com/run-async/-/run-async-2.4.1.tgz#8440eccf99ea3e70bd409d49aab88e10c189a455"
integrity sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==

run-async@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/run-async/-/run-async-3.0.0.tgz#42a432f6d76c689522058984384df28be379daad"
integrity sha512-540WwVDOMxA6dN6We19EcT9sc3hkXPw5mzRNGM3FkdN/vtE9NFvj5lFAPNwUDmJjXidm3v7TC1cTE7t17Ulm1Q==

run-parallel@^1.1.9:
version "1.1.9"
resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.1.9.tgz#c9dd3a7cf9f4b2c4b6244e173a6ed866e61dd679"
Expand Down Expand Up @@ -8019,6 +8091,15 @@ workerpool@6.2.1:
resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==

wrap-ansi@^6.2.0:
version "6.2.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53"
integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==
dependencies:
ansi-styles "^4.0.0"
string-width "^4.1.0"
strip-ansi "^6.0.0"

wrap-ansi@^7.0.0:
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
Expand Down

0 comments on commit c025388

Please sign in to comment.