@thegetty/quire-cli 1.0.0-rc.40 → 1.0.0-rc.42
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +14 -0
- package/package.json +1 -1
- package/src/commands/config.js +6 -21
- package/src/commands/config.test.js +109 -20
- package/src/errors/help/help-topic-not-found-error.js +12 -4
- package/src/errors/index.js +5 -1
- package/src/errors/validation/index.js +3 -0
- package/src/errors/validation/unknown-config-key-error.js +27 -0
- package/src/errors/validation/unknown-config-operation-error.js +24 -0
- package/src/helpers/suggest-similar.js +124 -0
- package/src/helpers/suggest-similar.test.js +108 -0
- package/src/lib/commander/index.js +1 -0
- package/src/lib/commander/options.js +18 -0
- package/src/lib/commander/options.test.js +52 -1
- package/src/lib/conf/defaults.js +7 -0
- package/src/lib/conf/schema.js +4 -0
- package/src/lib/help/index.js +12 -1
- package/src/lib/help/index.test.js +29 -1
- package/src/lib/reporter/index.js +96 -1
- package/src/lib/reporter/index.test.js +269 -0
- package/src/main.js +24 -4
package/bin/cli.js
CHANGED
|
@@ -19,6 +19,20 @@ process.removeAllListeners('warning')
|
|
|
19
19
|
*/
|
|
20
20
|
process.env.QUIRE_LOG_LEVEL = config.get('logLevel') || 'info'
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Set REDUCED_MOTION env var from config before importing CLI modules
|
|
24
|
+
*
|
|
25
|
+
* The reporter reads REDUCED_MOTION to decide whether to use animated
|
|
26
|
+
* spinners or static text output. Setting it here ensures the reporter
|
|
27
|
+
* picks up the config value at module load time.
|
|
28
|
+
* If REDUCED_MOTION is already set in the shell environment, we do not override it.
|
|
29
|
+
*
|
|
30
|
+
* @see lib/reporter/index.js
|
|
31
|
+
*/
|
|
32
|
+
if (process.env.REDUCED_MOTION === undefined && config.get('reducedMotion') === true) {
|
|
33
|
+
process.env.REDUCED_MOTION = '1'
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* Set NO_COLOR env var from config before importing CLI modules
|
|
24
38
|
*
|
package/package.json
CHANGED
package/src/commands/config.js
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
getDefault,
|
|
8
8
|
formatSettings,
|
|
9
9
|
} from '#lib/conf/index.js'
|
|
10
|
+
import { UnknownConfigKeyError, UnknownConfigOperationError } from '#src/errors/index.js'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Valid operations for the settings command
|
|
@@ -77,16 +78,6 @@ Examples:
|
|
|
77
78
|
super(SettingsCommand.definition)
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
/**
|
|
81
|
-
* Log an unknown key error with valid keys hint
|
|
82
|
-
*
|
|
83
|
-
* @param {string} key - The unknown configuration key
|
|
84
|
-
*/
|
|
85
|
-
#logUnknownKey(key) {
|
|
86
|
-
this.logger.error(`Unknown configuration key: ${key}`)
|
|
87
|
-
this.logger.info(`Valid keys: ${getValidKeys().join(', ')}`)
|
|
88
|
-
}
|
|
89
|
-
|
|
90
81
|
/**
|
|
91
82
|
* Get a single configuration value
|
|
92
83
|
*
|
|
@@ -100,8 +91,7 @@ Examples:
|
|
|
100
91
|
}
|
|
101
92
|
|
|
102
93
|
if (!isValidKey(key) && !key.startsWith('__internal__')) {
|
|
103
|
-
|
|
104
|
-
return
|
|
94
|
+
throw new UnknownConfigKeyError(key, getValidKeys())
|
|
105
95
|
}
|
|
106
96
|
|
|
107
97
|
const value = this.config.get(key)
|
|
@@ -131,8 +121,7 @@ Examples:
|
|
|
131
121
|
}
|
|
132
122
|
|
|
133
123
|
if (!isValidKey(key)) {
|
|
134
|
-
|
|
135
|
-
return
|
|
124
|
+
throw new UnknownConfigKeyError(key, getValidKeys())
|
|
136
125
|
}
|
|
137
126
|
|
|
138
127
|
const coercedValue = coerceValue(key, value)
|
|
@@ -157,8 +146,7 @@ Examples:
|
|
|
157
146
|
}
|
|
158
147
|
|
|
159
148
|
if (!isValidKey(key)) {
|
|
160
|
-
|
|
161
|
-
return
|
|
149
|
+
throw new UnknownConfigKeyError(key, getValidKeys())
|
|
162
150
|
}
|
|
163
151
|
|
|
164
152
|
this.config.delete(key)
|
|
@@ -174,8 +162,7 @@ Examples:
|
|
|
174
162
|
#reset(key) {
|
|
175
163
|
if (key) {
|
|
176
164
|
if (!isValidKey(key)) {
|
|
177
|
-
|
|
178
|
-
return
|
|
165
|
+
throw new UnknownConfigKeyError(key, getValidKeys())
|
|
179
166
|
}
|
|
180
167
|
this.config.reset(key)
|
|
181
168
|
const defaultValue = getDefault(key)
|
|
@@ -229,9 +216,7 @@ Examples:
|
|
|
229
216
|
|
|
230
217
|
// Validate operation if provided
|
|
231
218
|
if (operation && !OPERATIONS.includes(operation)) {
|
|
232
|
-
|
|
233
|
-
this.logger.info(`Valid operations: ${OPERATIONS.join(', ')}`)
|
|
234
|
-
return
|
|
219
|
+
throw new UnknownConfigOperationError(operation, [...OPERATIONS])
|
|
235
220
|
}
|
|
236
221
|
|
|
237
222
|
// Dispatch to operation handler
|
|
@@ -200,7 +200,7 @@ test('conf get should show error when key is missing', async (t) => {
|
|
|
200
200
|
t.true(mockLogger.error.calledWith('Usage: quire conf get <key>'))
|
|
201
201
|
})
|
|
202
202
|
|
|
203
|
-
test('conf get should
|
|
203
|
+
test('conf get should throw UnknownConfigKeyError for unknown key', async (t) => {
|
|
204
204
|
const { sandbox, mockLogger, mockConfig } = t.context
|
|
205
205
|
|
|
206
206
|
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
@@ -214,12 +214,55 @@ test('conf get should show error for unknown key', async (t) => {
|
|
|
214
214
|
command.logger = mockLogger
|
|
215
215
|
command.debug = sandbox.stub()
|
|
216
216
|
|
|
217
|
-
await
|
|
217
|
+
const error = await t.throwsAsync(
|
|
218
|
+
() => command.action('get', 'unknownKey', undefined, {}),
|
|
219
|
+
{ code: 'UNKNOWN_CONFIG_KEY' }
|
|
220
|
+
)
|
|
221
|
+
t.true(error.message.includes('Unknown configuration key: unknownKey'))
|
|
222
|
+
t.true(error.suggestion.includes('Valid keys:'))
|
|
223
|
+
})
|
|
218
224
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
225
|
+
test('conf get should suggest similar key for close misspelling', async (t) => {
|
|
226
|
+
const { sandbox, mockLogger, mockConfig } = t.context
|
|
227
|
+
|
|
228
|
+
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
229
|
+
'#lib/logger/index.js': {
|
|
230
|
+
default: () => mockLogger
|
|
231
|
+
}
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const command = new ConfCommand()
|
|
235
|
+
command.config = mockConfig
|
|
236
|
+
command.logger = mockLogger
|
|
237
|
+
command.debug = sandbox.stub()
|
|
238
|
+
|
|
239
|
+
const error = await t.throwsAsync(
|
|
240
|
+
() => command.action('get', 'logLeve', undefined, {}),
|
|
241
|
+
{ code: 'UNKNOWN_CONFIG_KEY' }
|
|
242
|
+
)
|
|
243
|
+
t.true(error.message.includes('Unknown configuration key: logLeve'))
|
|
244
|
+
t.true(error.suggestion.includes('Did you mean: logLevel?'))
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
test('conf get should suggest similar key for case difference', async (t) => {
|
|
248
|
+
const { sandbox, mockLogger, mockConfig } = t.context
|
|
249
|
+
|
|
250
|
+
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
251
|
+
'#lib/logger/index.js': {
|
|
252
|
+
default: () => mockLogger
|
|
253
|
+
}
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const command = new ConfCommand()
|
|
257
|
+
command.config = mockConfig
|
|
258
|
+
command.logger = mockLogger
|
|
259
|
+
command.debug = sandbox.stub()
|
|
260
|
+
|
|
261
|
+
const error = await t.throwsAsync(
|
|
262
|
+
() => command.action('get', 'loglevel', undefined, {}),
|
|
263
|
+
{ code: 'UNKNOWN_CONFIG_KEY' }
|
|
264
|
+
)
|
|
265
|
+
t.true(error.suggestion.includes('Did you mean: logLevel?'))
|
|
223
266
|
})
|
|
224
267
|
|
|
225
268
|
// =============================================================================
|
|
@@ -420,7 +463,7 @@ test('conf delete should show error when key is missing', async (t) => {
|
|
|
420
463
|
t.false(mockConfig.delete.called)
|
|
421
464
|
})
|
|
422
465
|
|
|
423
|
-
test('conf delete should
|
|
466
|
+
test('conf delete should throw UnknownConfigKeyError for unknown key', async (t) => {
|
|
424
467
|
const { sandbox, mockLogger, mockConfig } = t.context
|
|
425
468
|
|
|
426
469
|
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
@@ -434,9 +477,10 @@ test('conf delete should show error for unknown key', async (t) => {
|
|
|
434
477
|
command.logger = mockLogger
|
|
435
478
|
command.debug = sandbox.stub()
|
|
436
479
|
|
|
437
|
-
await
|
|
438
|
-
|
|
439
|
-
|
|
480
|
+
await t.throwsAsync(
|
|
481
|
+
() => command.action('delete', 'unknownKey', undefined, {}),
|
|
482
|
+
{ code: 'UNKNOWN_CONFIG_KEY' }
|
|
483
|
+
)
|
|
440
484
|
t.false(mockConfig.delete.called)
|
|
441
485
|
})
|
|
442
486
|
|
|
@@ -485,7 +529,7 @@ test('conf reset should reset all config when no key provided', async (t) => {
|
|
|
485
529
|
t.true(mockLogger.info.calledWith('Configuration reset to defaults'))
|
|
486
530
|
})
|
|
487
531
|
|
|
488
|
-
test('conf reset should
|
|
532
|
+
test('conf reset should throw UnknownConfigKeyError for unknown key', async (t) => {
|
|
489
533
|
const { sandbox, mockLogger, mockConfig } = t.context
|
|
490
534
|
|
|
491
535
|
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
@@ -499,9 +543,10 @@ test('conf reset should show error for unknown key', async (t) => {
|
|
|
499
543
|
command.logger = mockLogger
|
|
500
544
|
command.debug = sandbox.stub()
|
|
501
545
|
|
|
502
|
-
await
|
|
503
|
-
|
|
504
|
-
|
|
546
|
+
await t.throwsAsync(
|
|
547
|
+
() => command.action('reset', 'unknownKey', undefined, {}),
|
|
548
|
+
{ code: 'UNKNOWN_CONFIG_KEY' }
|
|
549
|
+
)
|
|
505
550
|
t.false(mockConfig.reset.called)
|
|
506
551
|
})
|
|
507
552
|
|
|
@@ -533,7 +578,7 @@ test('conf path should show config file path', async (t) => {
|
|
|
533
578
|
// Invalid operation
|
|
534
579
|
// =============================================================================
|
|
535
580
|
|
|
536
|
-
test('conf should
|
|
581
|
+
test('conf should throw UnknownConfigOperationError for unknown operation', async (t) => {
|
|
537
582
|
const { sandbox, mockLogger, mockConfig } = t.context
|
|
538
583
|
|
|
539
584
|
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
@@ -547,12 +592,56 @@ test('conf should show error for unknown operation', async (t) => {
|
|
|
547
592
|
command.logger = mockLogger
|
|
548
593
|
command.debug = sandbox.stub()
|
|
549
594
|
|
|
550
|
-
await
|
|
595
|
+
const error = await t.throwsAsync(
|
|
596
|
+
() => command.action('unknownOp', undefined, undefined, {}),
|
|
597
|
+
{ code: 'UNKNOWN_CONFIG_OPERATION' }
|
|
598
|
+
)
|
|
599
|
+
t.true(error.message.includes('Unknown operation: unknownOp'))
|
|
600
|
+
t.true(error.suggestion.includes('Valid operations:'))
|
|
601
|
+
})
|
|
551
602
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
603
|
+
test('conf should suggest similar operation for close misspelling', async (t) => {
|
|
604
|
+
const { sandbox, mockLogger, mockConfig } = t.context
|
|
605
|
+
|
|
606
|
+
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
607
|
+
'#lib/logger/index.js': {
|
|
608
|
+
default: () => mockLogger
|
|
609
|
+
}
|
|
610
|
+
})
|
|
611
|
+
|
|
612
|
+
const command = new ConfCommand()
|
|
613
|
+
command.config = mockConfig
|
|
614
|
+
command.logger = mockLogger
|
|
615
|
+
command.debug = sandbox.stub()
|
|
616
|
+
|
|
617
|
+
const error = await t.throwsAsync(
|
|
618
|
+
() => command.action('gt', undefined, undefined, {}),
|
|
619
|
+
{ code: 'UNKNOWN_CONFIG_OPERATION' }
|
|
620
|
+
)
|
|
621
|
+
t.true(error.message.includes('Unknown operation: gt'))
|
|
622
|
+
t.true(error.suggestion.includes('Did you mean: get?'))
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
test('conf should suggest similar operation for transposition', async (t) => {
|
|
626
|
+
const { sandbox, mockLogger, mockConfig } = t.context
|
|
627
|
+
|
|
628
|
+
const { default: ConfCommand } = await esmock('./config.js', {}, {
|
|
629
|
+
'#lib/logger/index.js': {
|
|
630
|
+
default: () => mockLogger
|
|
631
|
+
}
|
|
632
|
+
})
|
|
633
|
+
|
|
634
|
+
const command = new ConfCommand()
|
|
635
|
+
command.config = mockConfig
|
|
636
|
+
command.logger = mockLogger
|
|
637
|
+
command.debug = sandbox.stub()
|
|
638
|
+
|
|
639
|
+
const error = await t.throwsAsync(
|
|
640
|
+
() => command.action('ste', undefined, undefined, {}),
|
|
641
|
+
{ code: 'UNKNOWN_CONFIG_OPERATION' }
|
|
642
|
+
)
|
|
643
|
+
t.true(error.message.includes('Unknown operation: ste'))
|
|
644
|
+
t.true(error.suggestion.includes('Did you mean: set?'))
|
|
556
645
|
})
|
|
557
646
|
|
|
558
647
|
// =============================================================================
|
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
import InvalidInputError from '../input/invalid-input-error.js'
|
|
2
|
-
import
|
|
2
|
+
import { docsUrl } from '#helpers/docs-url.js'
|
|
3
|
+
import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Error thrown when a requested help topic does not exist
|
|
7
|
+
*
|
|
8
|
+
* @param {string} topic - The unrecognized topic name
|
|
9
|
+
* @param {string[]} validTopics - Array of available topic names
|
|
6
10
|
*/
|
|
7
11
|
export default class HelpTopicNotFoundError extends InvalidInputError {
|
|
8
|
-
constructor(topic) {
|
|
12
|
+
constructor(topic, validTopics = []) {
|
|
13
|
+
const suggestion = formatSuggestion(suggestSimilar(topic, validTopics))
|
|
14
|
+
|| 'Run "quire help --list" to see available topics'
|
|
15
|
+
|
|
9
16
|
super(
|
|
10
17
|
`Unknown help topic: ${topic}`,
|
|
11
18
|
{
|
|
@@ -13,8 +20,9 @@ export default class HelpTopicNotFoundError extends InvalidInputError {
|
|
|
13
20
|
exitCode: 2,
|
|
14
21
|
inputValue: topic,
|
|
15
22
|
inputName: 'topic',
|
|
16
|
-
suggestion
|
|
17
|
-
docsUrl:
|
|
23
|
+
suggestion,
|
|
24
|
+
docsUrl: docsUrl('quire-commands/'),
|
|
25
|
+
showDebugHint: false
|
|
18
26
|
}
|
|
19
27
|
)
|
|
20
28
|
this.topic = topic
|
package/src/errors/index.js
CHANGED
|
@@ -45,7 +45,11 @@ export {
|
|
|
45
45
|
} from './install/index.js'
|
|
46
46
|
|
|
47
47
|
// Validation errors (exit code: 4)
|
|
48
|
-
export {
|
|
48
|
+
export {
|
|
49
|
+
ValidationError,
|
|
50
|
+
UnknownConfigKeyError,
|
|
51
|
+
UnknownConfigOperationError
|
|
52
|
+
} from './validation/index.js'
|
|
49
53
|
|
|
50
54
|
// Input errors (exit code: 2)
|
|
51
55
|
export { InvalidInputError } from './input/index.js'
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import QuireError from '../quire-error.js'
|
|
2
|
+
import { docsUrl } from '#helpers/docs-url.js'
|
|
3
|
+
import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Error thrown when an unrecognized configuration key is used
|
|
7
|
+
*
|
|
8
|
+
* @param {string} key - The unrecognized key
|
|
9
|
+
* @param {string[]} validKeys - Array of valid configuration keys
|
|
10
|
+
*/
|
|
11
|
+
export default class UnknownConfigKeyError extends QuireError {
|
|
12
|
+
constructor(key, validKeys) {
|
|
13
|
+
const suggestion = formatSuggestion(suggestSimilar(key, validKeys))
|
|
14
|
+
|| `Valid keys: ${validKeys.join(', ')}`
|
|
15
|
+
|
|
16
|
+
super(
|
|
17
|
+
`Unknown configuration key: ${key}`,
|
|
18
|
+
{
|
|
19
|
+
code: 'UNKNOWN_CONFIG_KEY',
|
|
20
|
+
exitCode: 4,
|
|
21
|
+
suggestion,
|
|
22
|
+
docsUrl: docsUrl('quire-commands/'),
|
|
23
|
+
showDebugHint: false
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import QuireError from '../quire-error.js'
|
|
2
|
+
import { docsUrl } from '#helpers/docs-url.js'
|
|
3
|
+
import { suggestSimilar, formatSuggestion } from '#helpers/suggest-similar.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Error thrown when an unrecognized settings operation is used
|
|
7
|
+
*/
|
|
8
|
+
export default class UnknownConfigOperationError extends QuireError {
|
|
9
|
+
constructor(operation, validOperations) {
|
|
10
|
+
const suggestion = formatSuggestion(suggestSimilar(operation, validOperations))
|
|
11
|
+
|| `Valid operations: ${validOperations.join(', ')}`
|
|
12
|
+
|
|
13
|
+
super(
|
|
14
|
+
`Unknown operation: ${operation}`,
|
|
15
|
+
{
|
|
16
|
+
code: 'UNKNOWN_CONFIG_OPERATION',
|
|
17
|
+
exitCode: 4,
|
|
18
|
+
suggestion,
|
|
19
|
+
docsUrl: docsUrl('quire-commands/'),
|
|
20
|
+
showDebugHint: false
|
|
21
|
+
}
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fuzzy string matching for CLI suggestions
|
|
3
|
+
*
|
|
4
|
+
* Uses Damerau-Levenshtein distance (optimal string alignment variant)
|
|
5
|
+
* to find close matches for misspelled user input. The algorithm and
|
|
6
|
+
* parameters match Commander.js's built-in suggestion implementation
|
|
7
|
+
* for consistency.
|
|
8
|
+
*
|
|
9
|
+
* @module helpers/suggest-similar
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const MAX_DISTANCE = 3
|
|
13
|
+
const MIN_SIMILARITY = 0.4
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Calculate Damerau-Levenshtein distance between two strings
|
|
17
|
+
*
|
|
18
|
+
* Supports insertions, deletions, substitutions, and transpositions
|
|
19
|
+
* of adjacent characters. Each substring is edited at most once
|
|
20
|
+
* (optimal string alignment variant).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} a - First string
|
|
23
|
+
* @param {string} b - Second string
|
|
24
|
+
* @returns {number} Edit distance
|
|
25
|
+
* @private
|
|
26
|
+
*/
|
|
27
|
+
function editDistance(a, b) {
|
|
28
|
+
if (Math.abs(a.length - b.length) > MAX_DISTANCE) {
|
|
29
|
+
return Math.max(a.length, b.length)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const d = []
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i <= a.length; ++i) d[i] = [i]
|
|
35
|
+
for (let j = 0; j <= b.length; ++j) d[0][j] = j
|
|
36
|
+
|
|
37
|
+
for (let j = 1; j <= b.length; ++j) {
|
|
38
|
+
for (let i = 1; i <= a.length; ++i) {
|
|
39
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
|
40
|
+
d[i][j] = Math.min(
|
|
41
|
+
d[i - 1][j] + 1, // deletion
|
|
42
|
+
d[i][j - 1] + 1, // insertion
|
|
43
|
+
d[i - 1][j - 1] + cost, // substitution
|
|
44
|
+
)
|
|
45
|
+
// transposition
|
|
46
|
+
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
|
|
47
|
+
d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return d[a.length][b.length]
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Find similar strings from a list of candidates
|
|
57
|
+
*
|
|
58
|
+
* Returns the closest matches by edit distance, filtered by a minimum
|
|
59
|
+
* similarity threshold (40%). Only returns matches at the best distance
|
|
60
|
+
* found (does not mix distances).
|
|
61
|
+
*
|
|
62
|
+
* @param {string} word - The misspelled input
|
|
63
|
+
* @param {string[]} candidates - Valid values to match against
|
|
64
|
+
* @returns {string[]} Sorted array of best matches (empty if none close enough)
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
|
|
68
|
+
* // → ['get']
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
|
|
72
|
+
* // → ['logLevel']
|
|
73
|
+
*/
|
|
74
|
+
export function suggestSimilar(word, candidates) {
|
|
75
|
+
if (!word || !candidates?.length) return []
|
|
76
|
+
|
|
77
|
+
const unique = [...new Set(candidates)]
|
|
78
|
+
let similar = []
|
|
79
|
+
let bestDistance = MAX_DISTANCE
|
|
80
|
+
|
|
81
|
+
for (const candidate of unique) {
|
|
82
|
+
if (candidate.length <= 1) continue
|
|
83
|
+
|
|
84
|
+
const distance = editDistance(word, candidate)
|
|
85
|
+
const length = Math.max(word.length, candidate.length)
|
|
86
|
+
const similarity = (length - distance) / length
|
|
87
|
+
|
|
88
|
+
if (similarity > MIN_SIMILARITY) {
|
|
89
|
+
if (distance < bestDistance) {
|
|
90
|
+
bestDistance = distance
|
|
91
|
+
similar = [candidate]
|
|
92
|
+
} else if (distance === bestDistance) {
|
|
93
|
+
similar.push(candidate)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return similar.sort((a, b) => a.localeCompare(b))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Format suggestion matches into a user-facing string
|
|
103
|
+
*
|
|
104
|
+
* @param {string[]} matches - Array of suggested matches
|
|
105
|
+
* @returns {string|undefined} Formatted suggestion or undefined if no matches
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* formatSuggestion(['logLevel'])
|
|
109
|
+
* // → 'Did you mean: logLevel?'
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* formatSuggestion(['get', 'set'])
|
|
113
|
+
* // → 'Did you mean one of: get, set?'
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* formatSuggestion([])
|
|
117
|
+
* // → undefined
|
|
118
|
+
*/
|
|
119
|
+
export function formatSuggestion(matches) {
|
|
120
|
+
if (!matches?.length) return undefined
|
|
121
|
+
return (matches.length === 1)
|
|
122
|
+
? `Did you mean: ${matches[0]}?`
|
|
123
|
+
: `Did you mean one of: ${matches.join(', ')}?`
|
|
124
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import test from 'ava'
|
|
2
|
+
import { suggestSimilar, formatSuggestion } from './suggest-similar.js'
|
|
3
|
+
|
|
4
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
5
|
+
// suggestSimilar
|
|
6
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
test('suggestSimilar returns match for single-character deletion', (t) => {
|
|
9
|
+
const result = suggestSimilar('gt', ['get', 'set', 'delete', 'reset', 'path'])
|
|
10
|
+
t.deepEqual(result, ['get'])
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
test('suggestSimilar returns match for single-character insertion', (t) => {
|
|
14
|
+
const result = suggestSimilar('geet', ['get', 'set', 'delete'])
|
|
15
|
+
t.deepEqual(result, ['get'])
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test('suggestSimilar returns match for single-character substitution', (t) => {
|
|
19
|
+
const result = suggestSimilar('gat', ['get', 'set', 'delete'])
|
|
20
|
+
t.deepEqual(result, ['get'])
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('suggestSimilar returns match for transposition', (t) => {
|
|
24
|
+
const result = suggestSimilar('gte', ['get', 'set', 'delete'])
|
|
25
|
+
t.deepEqual(result, ['get'])
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
test('suggestSimilar returns match for camelCase near-miss', (t) => {
|
|
29
|
+
const result = suggestSimilar('logLeve', ['logLevel', 'logPrefix', 'verbose'])
|
|
30
|
+
t.deepEqual(result, ['logLevel'])
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('suggestSimilar returns match for case difference', (t) => {
|
|
34
|
+
// 'loglevel' vs 'logLevel' differs by one character (L vs l)
|
|
35
|
+
const result = suggestSimilar('loglevel', ['logLevel', 'logPrefix', 'verbose'])
|
|
36
|
+
t.deepEqual(result, ['logLevel'])
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
test('suggestSimilar returns empty array when no candidates are similar', (t) => {
|
|
40
|
+
const result = suggestSimilar('xyzabc', ['get', 'set', 'delete', 'reset', 'path'])
|
|
41
|
+
t.deepEqual(result, [])
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('suggestSimilar returns empty array for empty input', (t) => {
|
|
45
|
+
t.deepEqual(suggestSimilar('', ['get', 'set']), [])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
test('suggestSimilar returns empty array for null input', (t) => {
|
|
49
|
+
t.deepEqual(suggestSimilar(null, ['get', 'set']), [])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test('suggestSimilar returns empty array for empty candidates', (t) => {
|
|
53
|
+
t.deepEqual(suggestSimilar('get', []), [])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('suggestSimilar returns empty array for null candidates', (t) => {
|
|
57
|
+
t.deepEqual(suggestSimilar('get', null), [])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
test('suggestSimilar excludes single-character candidates', (t) => {
|
|
61
|
+
// 'g' is distance 0 from 'g', but should be excluded
|
|
62
|
+
const result = suggestSimilar('g', ['g', 'get', 'set'])
|
|
63
|
+
t.deepEqual(result, [])
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('suggestSimilar deduplicates candidates', (t) => {
|
|
67
|
+
const result = suggestSimilar('gt', ['get', 'get', 'get'])
|
|
68
|
+
t.deepEqual(result, ['get'])
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('suggestSimilar returns multiple matches sorted alphabetically', (t) => {
|
|
72
|
+
// 'bild' is distance 1 from 'bind' and 'build' — both should match
|
|
73
|
+
// but 'build' is distance 1 (deletion), 'bind' is distance 2 (substitution + deletion)
|
|
74
|
+
// Let's use a case where two candidates have equal distance
|
|
75
|
+
const result = suggestSimilar('delet', ['delete', 'reset'])
|
|
76
|
+
// 'delet' → 'delete' distance 1, 'delet' → 'reset' distance 2
|
|
77
|
+
t.deepEqual(result, ['delete'])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
test('suggestSimilar returns multiple equally-close matches', (t) => {
|
|
81
|
+
// Both 'bar' and 'baz' are distance 1 from 'bax'
|
|
82
|
+
const result = suggestSimilar('bax', ['bar', 'baz', 'foo'])
|
|
83
|
+
t.deepEqual(result, ['bar', 'baz'])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
// formatSuggestion
|
|
88
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('formatSuggestion returns undefined for empty array', (t) => {
|
|
91
|
+
t.is(formatSuggestion([]), undefined)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
test('formatSuggestion returns undefined for null', (t) => {
|
|
95
|
+
t.is(formatSuggestion(null), undefined)
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
test('formatSuggestion returns undefined for undefined', (t) => {
|
|
99
|
+
t.is(formatSuggestion(undefined), undefined)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
test('formatSuggestion formats single match', (t) => {
|
|
103
|
+
t.is(formatSuggestion(['logLevel']), 'Did you mean: logLevel?')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
test('formatSuggestion formats multiple matches', (t) => {
|
|
107
|
+
t.is(formatSuggestion(['bar', 'baz']), 'Did you mean one of: bar, baz?')
|
|
108
|
+
})
|
|
@@ -124,6 +124,24 @@ export const debugOption = [
|
|
|
124
124
|
{ conflicts: ['quiet'] }
|
|
125
125
|
]
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Reduced motion option - disable spinner animation and line overwriting
|
|
129
|
+
*
|
|
130
|
+
* When enabled, the reporter outputs static text on new lines instead of
|
|
131
|
+
* animated spinners that overwrite the current line. This is useful for:
|
|
132
|
+
* - Screen reader users (animated overwriting disrupts reading flow)
|
|
133
|
+
* - Users who prefer reduced motion
|
|
134
|
+
* - Environments where terminal animation is problematic
|
|
135
|
+
*
|
|
136
|
+
* Respects config default: `quire settings set reducedMotion true`
|
|
137
|
+
* Also respects REDUCED_MOTION environment variable.
|
|
138
|
+
*
|
|
139
|
+
* @type {Array}
|
|
140
|
+
*/
|
|
141
|
+
export const reducedMotionOption = [
|
|
142
|
+
'--reduced-motion', 'disable spinner animation and line overwriting',
|
|
143
|
+
]
|
|
144
|
+
|
|
127
145
|
/**
|
|
128
146
|
* Color options - enable/disable colored output
|
|
129
147
|
*
|