@thegetty/quire-cli 1.0.0-rc.40 → 1.0.0-rc.41
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/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/help/index.js +12 -1
- package/src/lib/help/index.test.js +29 -1
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
|
+
})
|
package/src/lib/help/index.js
CHANGED
|
@@ -59,6 +59,16 @@ async function listTopics() {
|
|
|
59
59
|
return topics.sort((a, b) => a.name.localeCompare(b.name))
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Get available topic names from the topics directory
|
|
64
|
+
* @returns {Promise<string[]>} Topic names (filenames without .md)
|
|
65
|
+
*/
|
|
66
|
+
async function getTopicNames() {
|
|
67
|
+
if (!await fs.pathExists(TOPICS_DIR)) return []
|
|
68
|
+
const files = await fs.readdir(TOPICS_DIR)
|
|
69
|
+
return files.filter((f) => f.endsWith('.md')).map((f) => path.basename(f, '.md'))
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
/**
|
|
63
73
|
* Load a help topic by name
|
|
64
74
|
* @param {string} name - Topic name (without .md extension)
|
|
@@ -69,7 +79,8 @@ async function loadTopic(name) {
|
|
|
69
79
|
const filePath = path.join(TOPICS_DIR, `${name}.md`)
|
|
70
80
|
|
|
71
81
|
if (!await fs.pathExists(filePath)) {
|
|
72
|
-
|
|
82
|
+
const validTopics = await getTopicNames()
|
|
83
|
+
throw new HelpTopicNotFoundError(name, validTopics)
|
|
73
84
|
}
|
|
74
85
|
|
|
75
86
|
const content = await fs.readFile(filePath, 'utf-8')
|
|
@@ -134,7 +134,10 @@ test('getTopicContent() throws HelpTopicNotFoundError for missing topic', async
|
|
|
134
134
|
const { sandbox } = t.context
|
|
135
135
|
|
|
136
136
|
const mockFs = {
|
|
137
|
-
pathExists: sandbox.stub()
|
|
137
|
+
pathExists: sandbox.stub()
|
|
138
|
+
.onFirstCall().resolves(false) // topic file does not exist
|
|
139
|
+
.onSecondCall().resolves(true), // topics directory exists
|
|
140
|
+
readdir: sandbox.stub().resolves(['epub.md', 'pdf.md', 'debugging.md'])
|
|
138
141
|
}
|
|
139
142
|
|
|
140
143
|
const { getTopicContent } = await esmock('./index.js', {
|
|
@@ -150,6 +153,31 @@ test('getTopicContent() throws HelpTopicNotFoundError for missing topic', async
|
|
|
150
153
|
t.is(error.topic, 'nonexistent')
|
|
151
154
|
})
|
|
152
155
|
|
|
156
|
+
test('getTopicContent() suggests similar topic for typo', async (t) => {
|
|
157
|
+
const { sandbox } = t.context
|
|
158
|
+
|
|
159
|
+
const mockFs = {
|
|
160
|
+
pathExists: sandbox.stub()
|
|
161
|
+
.onFirstCall().resolves(false)
|
|
162
|
+
.onSecondCall().resolves(true),
|
|
163
|
+
readdir: sandbox.stub().resolves([
|
|
164
|
+
'configuration.md', 'debugging.md', 'epub.md',
|
|
165
|
+
'pdf.md', 'publishing.md', 'workflows.md'
|
|
166
|
+
])
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { getTopicContent } = await esmock('./index.js', {
|
|
170
|
+
'fs-extra': mockFs
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
const error = await t.throwsAsync(
|
|
174
|
+
() => getTopicContent('edub'),
|
|
175
|
+
{ name: 'HelpTopicNotFoundError' }
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
t.is(error.suggestion, 'Did you mean: epub?')
|
|
179
|
+
})
|
|
180
|
+
|
|
153
181
|
test('getTopicsDir() returns the topics directory path', async (t) => {
|
|
154
182
|
const { getTopicsDir } = await import('./index.js')
|
|
155
183
|
|