Skip to content

Commit 4543cb7

Browse files
authored
Improve typo correction suggestions (#150)
1 parent ba6b66d commit 4543cb7

File tree

15 files changed

+263
-40
lines changed

15 files changed

+263
-40
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- `registeredSubcommands`, `registeredOptions`, `registeredArguments`, and `registeredParameterGroups` methods on `CliktCommand`.
66
- Ability to [read default option values](https://ajalt.github.io/clikt/api/clikt/com.github.ajalt.clikt.sources/-value-source/index.md) from configuration files and other sources. Support for Java property files is built in on JVM, and other formats can be added easily.
77
- `allowMultipleSubcommands` parameter to `CliktCommand` that allows you to pass multiple subcommands in the same call. ([docs](docs/commands.md#chaining-and-repeating-subcommands))
8+
- Errors from typos in subcommand names will now include suggested corrections. Corrections for options and subcommands are now based on a Jaro-Winkler similarity metric.
89

910
### Changed
1011
- Update Kotlin to 1.3.70
@@ -13,7 +14,6 @@
1314
- `CliktCommand.toString` now includes the class name
1415

1516
## [2.5.0] - 2020-02-22
16-
1717
### Added
1818
- Clikt is now available as a Kotlin Multiplatform Project, supporting JVM, NodeJS, and native Windows, Linux, and macOS.
1919
- `eagerOption {}` function to more easily register eager options.

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/Context.kt

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import com.github.ajalt.clikt.sources.ValueSource
1010
import kotlin.properties.ReadOnlyProperty
1111
import kotlin.reflect.KProperty
1212

13+
typealias TypoSuggestor = (enteredValue: String, possibleValues: List<String>) -> List<String>
14+
1315
/**
1416
* A object used to control command line parsing and pass data between commands.
1517
*
@@ -34,6 +36,9 @@ import kotlin.reflect.KProperty
3436
* @property console The console to use to print messages.
3537
* @property expandArgumentFiles If true, arguments starting with `@` will be expanded as argument
3638
* files. If false, they will be treated as normal arguments.
39+
* @property correctionSuggestor A callback called when the command line contains an invalid option or
40+
* subcommand name. It takes the entered name and a list of all registered names option/subcommand
41+
* names and filters the list down to values to suggest to the user.
3742
*/
3843
@OptIn(ExperimentalValueSourceApi::class)
3944
class Context(
@@ -49,7 +54,8 @@ class Context(
4954
val console: CliktConsole,
5055
val expandArgumentFiles: Boolean,
5156
val readEnvvarBeforeValueSource: Boolean,
52-
val valueSource: ValueSource?
57+
val valueSource: ValueSource?,
58+
val correctionSuggestor: TypoSuggestor
5359
) {
5460
var invokedSubcommand: CliktCommand? = null
5561
internal set
@@ -177,6 +183,13 @@ class Context(
177183
fun valueSources(vararg sources: ValueSource) {
178184
valueSource = ChainedValueSource(sources.toList())
179185
}
186+
187+
/**
188+
* A callback called when the command line contains an invalid option or
189+
* subcommand name. It takes the entered name and a list of all registered names option/subcommand
190+
* names and filters the list down to values to suggest to the user.
191+
*/
192+
var correctionSuggestor: TypoSuggestor = DEFAULT_CORRECTION_SUGGESTOR
180193
}
181194

182195
companion object {
@@ -188,7 +201,7 @@ class Context(
188201
return Context(
189202
parent, command, interspersed, autoEnvvarPrefix, printExtraMessages,
190203
helpOptionNames, helpOptionMessage, helpFormatter, tokenTransformer, console,
191-
expandArgumentFiles, readEnvvarBeforeValueSource, valueSource
204+
expandArgumentFiles, readEnvvarBeforeValueSource, valueSource, correctionSuggestor
192205
)
193206
}
194207
}
@@ -230,3 +243,10 @@ inline fun <reified T : Any> CliktCommand.findOrSetObject(crossinline default: (
230243
}
231244
}
232245
}
246+
247+
private val DEFAULT_CORRECTION_SUGGESTOR : TypoSuggestor = { enteredValue, possibleValues ->
248+
possibleValues.map { it to jaroWinklerSimilarity(enteredValue, it) }
249+
.filter { it.second > 0.8 }
250+
.sortedByDescending { it.second }
251+
.map { it.first }
252+
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package com.github.ajalt.clikt.core
2+
3+
import kotlin.math.max
4+
import kotlin.math.min
5+
6+
7+
private fun jaroSimilarity(s1: String, s2: String): Double {
8+
if (s1.isEmpty() && s2.isEmpty()) return 1.0
9+
else if (s1.isEmpty() || s2.isEmpty()) return 0.0
10+
else if (s1.length == 1 && s2.length == 1) return if (s1[0] == s2[0]) 1.0 else 0.0
11+
12+
val searchRange: Int = max(s1.length, s2.length) / 2 - 1
13+
val s2Consumed = BooleanArray(s2.length)
14+
var matches = 0.0
15+
var transpositions = 0
16+
var s2MatchIndex = 0
17+
18+
for ((i, c1) in s1.withIndex()) {
19+
val start = max(0, i - searchRange)
20+
val end = min(s2.lastIndex, i + searchRange)
21+
for (j in start..end) {
22+
val c2 = s2[j]
23+
if (c1 != c2 || s2Consumed[j]) continue
24+
s2Consumed[j] = true
25+
matches += 1
26+
if (j < s2MatchIndex) transpositions += 1
27+
s2MatchIndex = j
28+
break
29+
}
30+
}
31+
32+
return when (matches) {
33+
0.0 -> 0.0
34+
else -> (matches / s1.length +
35+
matches / s2.length +
36+
(matches - transpositions) / matches) / 3.0
37+
}
38+
}
39+
40+
internal fun jaroWinklerSimilarity(s1: String, s2: String): Double {
41+
// Unlike classic Jaro-Winkler, we don't set a limit on the prefix length
42+
val prefixLength = s1.commonPrefixWith(s2).length
43+
val jaro = jaroSimilarity(s1, s2)
44+
val winkler = jaro + (0.1 * prefixLength * (1 - jaro))
45+
return min(winkler, 1.0)
46+
}

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/core/exceptions.kt

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,22 +123,38 @@ open class MissingParameter : UsageError {
123123
}
124124
}
125125

126-
/** An option was provided that does not exist. */
127-
open class NoSuchOption(
126+
/** A parameter was provided that does not exist. */
127+
open class NoSuchParameter(
128+
protected val parameterType: String,
128129
protected val givenName: String,
129130
protected val possibilities: List<String> = emptyList(),
130131
context: Context? = null
131132
) : UsageError("", context = context) {
132133
override fun formatMessage(): String {
133-
return "no such option: \"$givenName\"." + when {
134+
return "no such ${parameterType}: \"$givenName\"." + when {
134135
possibilities.size == 1 -> " Did you mean \"${possibilities[0]}\"?"
135136
possibilities.size > 1 -> possibilities.joinToString(
136-
prefix = " (Possible options: ", postfix = ")")
137+
prefix = " (Possible ${parameterType}s: ", postfix = ")")
137138
else -> ""
138139
}
139140
}
140141
}
141142

143+
/** A subcommand was provided that does not exist. */
144+
open class NoSuchSubcommand(
145+
givenName: String,
146+
possibilities: List<String> = emptyList(),
147+
context: Context? = null
148+
) : NoSuchParameter("subcommand", givenName, possibilities, context)
149+
150+
151+
/** An option was provided that does not exist. */
152+
open class NoSuchOption(
153+
givenName: String,
154+
possibilities: List<String> = emptyList(),
155+
context: Context? = null
156+
) : NoSuchParameter("option", givenName, possibilities, context)
157+
142158
/** An option was supplied but the number of values supplied to the option was incorrect. */
143159
open class IncorrectOptionValueCount(
144160
option: Option,
@@ -178,9 +194,9 @@ open class MutuallyExclusiveGroupException(
178194
}
179195

180196
/** A required configuration file was not found. */
181-
class FileNotFoundError(filename: String) : UsageError("$filename not found")
197+
class FileNotFound(filename: String) : UsageError("$filename not found")
182198

183199
/** A configuration file failed to parse correctly */
184-
class FileFormatError(filename: String, message: String, lineno: Int? = null) : UsageError(
200+
class InvalidFileFormat(filename: String, message: String, lineno: Int? = null) : UsageError(
185201
"incorrect format in file $filename${lineno?.let { " line $it" } ?: ""}}: $message"
186202
)

clikt/src/commonMain/kotlin/com/github/ajalt/clikt/parsers/Parser.kt

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,9 @@ internal object Parser {
124124
if (excess > 0) {
125125
if (hasMultipleSubAncestor) {
126126
i = tokens.size - excess
127+
} else if (excess == 1 && subcommands.isNotEmpty()) {
128+
val actual = positionalArgs.last()
129+
throw NoSuchSubcommand(actual, context.correctionSuggestor(actual, subcommands.keys.toList()))
127130
} else {
128131
val actual = positionalArgs.takeLast(excess).joinToString(" ", limit = 3, prefix = "(", postfix = ")")
129132
throw UsageError("Got unexpected extra argument${if (excess == 1) "" else "s"} $actual")
@@ -180,8 +183,10 @@ internal object Parser {
180183
tok to null
181184
}
182185
name = context.tokenTransformer(context, name)
183-
val option = optionsByName[name] ?: throw NoSuchOption(name,
184-
possibilities = optionsByName.keys.filter { it.startsWith(name) })
186+
val option = optionsByName[name] ?: throw NoSuchOption(
187+
givenName = name,
188+
possibilities = context.correctionSuggestor(name, optionsByName.keys.toList())
189+
)
185190
val result = option.parser.parseLongOpt(option, name, tokens, index, value)
186191
return option to result
187192
}
@@ -241,13 +246,13 @@ internal object Parser {
241246
}
242247

243248
private fun loadArgFile(filename: String): List<String> {
244-
val text = readFileIfExists(filename) ?: throw FileNotFoundError(filename)
249+
val text = readFileIfExists(filename) ?: throw FileNotFound(filename)
245250
val toks = mutableListOf<String>()
246251
var inQuote: Char? = null
247252
val sb = StringBuilder()
248253
var i = 0
249254
fun err(msg: String): Nothing {
250-
throw FileFormatError(filename, msg, text.take(i).count { it == '\n' })
255+
throw InvalidFileFormat(filename, msg, text.take(i).count { it == '\n' })
251256
}
252257
loop@ while (i < text.length) {
253258
val c = text[i]
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.github.ajalt.clikt.core
2+
3+
import io.kotest.data.forall
4+
import io.kotest.matchers.doubles.plusOrMinus
5+
import io.kotest.matchers.shouldBe
6+
import io.kotest.tables.row
7+
import kotlin.test.Test
8+
9+
10+
class JaroWinkerSimilarityTest {
11+
@Test
12+
fun jaroWinklerSimilarity() = forall(
13+
row("", "", 1.0),
14+
row("", "a", 0.0),
15+
row("a", "", 0.0),
16+
row("a", "a", 1.0),
17+
row("aa", "aa", 1.0),
18+
row("aaapppp", "", 0.0),
19+
row("fly", "ant", 0.0),
20+
row("cheeseburger", "cheese fries", 0.91),
21+
row("frog", "fog", 0.93),
22+
row("elephant", "hippo", 0.44),
23+
row("hippo", "elephant", 0.44),
24+
row("hippo", "zzzzzzzz", 0.0),
25+
row("hello", "hallo", 0.88)
26+
) { s1, s2, expected ->
27+
jaroWinklerSimilarity(s1, s2) shouldBe (expected plusOrMinus 0.01)
28+
}
29+
}
30+

clikt/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/OptionTest.kt

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,24 +34,22 @@ class OptionTest {
3434

3535
@Test
3636
@JsName("no_such_option")
37-
fun `no such option`() {
37+
fun `no such option`() = forall(
38+
row("--qux", "no such option: \"--qux\"."),
39+
row("--fo", "no such option: \"--fo\". Did you mean \"--foo\"?"),
40+
row("--fop", "no such option: \"--fop\". Did you mean \"--foo\"?"),
41+
row("--car", "no such option: \"--car\". Did you mean \"--bar\"?"),
42+
row("--ba", "no such option: \"--ba\". (Possible options: --bar, --baz)")
43+
) { argv, message ->
3844
class C : TestCommand(called = false) {
3945
val foo by option()
4046
val bar by option()
4147
val baz by option()
4248
}
4349

4450
shouldThrow<NoSuchOption> {
45-
C().parse("--qux")
46-
}.message shouldBe "no such option: \"--qux\"."
47-
48-
shouldThrow<NoSuchOption> {
49-
C().parse("--fo")
50-
}.message shouldBe "no such option: \"--fo\". Did you mean \"--foo\"?"
51-
52-
shouldThrow<NoSuchOption> {
53-
C().parse("--ba")
54-
}.message shouldBe "no such option: \"--ba\". (Possible options: --bar, --baz)"
51+
C().parse(argv)
52+
}.message shouldBe message
5553
}
5654

5755
@Test

clikt/src/commonTest/kotlin/com/github/ajalt/clikt/parameters/SubcommandTest.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.github.ajalt.clikt.parameters
22

3+
import com.github.ajalt.clikt.core.NoSuchSubcommand
34
import com.github.ajalt.clikt.core.UsageError
45
import com.github.ajalt.clikt.core.context
56
import com.github.ajalt.clikt.core.subcommands
@@ -254,4 +255,19 @@ class SubcommandTest {
254255
|Error: Missing argument "ARG".
255256
""".trimMargin()
256257
}
258+
259+
@Test
260+
fun noSuchSubcommand() = forall(
261+
row("qux", "no such subcommand: \"qux\"."),
262+
row("fo", "no such subcommand: \"fo\". Did you mean \"foo\"?"),
263+
row("fop", "no such subcommand: \"fop\". Did you mean \"foo\"?"),
264+
row("bart", "no such subcommand: \"bart\". Did you mean \"bar\"?"),
265+
row("ba", "no such subcommand: \"ba\". (Possible subcommands: bar, baz)")
266+
) { argv, message ->
267+
shouldThrow<NoSuchSubcommand> {
268+
TestCommand()
269+
.subcommands(TestCommand(name = "foo"), TestCommand(name = "bar"), TestCommand(name = "baz"))
270+
.parse(argv)
271+
}.message shouldBe message
272+
}
257273
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
/*
2+
Copyright 2016 sksamuel
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
package io.kotest.matchers.doubles
17+
18+
import io.kotest.matchers.Matcher
19+
import io.kotest.matchers.MatcherResult
20+
import kotlin.math.abs
21+
22+
/**
23+
* Creates a matcher for the interval [[this] - [tolerance] , [this] + [tolerance]]
24+
*
25+
*
26+
* ```
27+
* 0.1 shouldBe (0.4 plusOrMinus 0.5) // Assertion passes
28+
* 0.1 shouldBe (0.4 plusOrMinus 0.2) // Assertion fails
29+
* ```
30+
*/
31+
infix fun Double.plusOrMinus(tolerance: Double): ToleranceMatcher = ToleranceMatcher(
32+
this,
33+
tolerance)
34+
35+
class ToleranceMatcher(private val expected: Double?, private val tolerance: Double) : Matcher<Double?> {
36+
override fun test(value: Double?): MatcherResult {
37+
return if(value == null || expected == null) {
38+
MatcherResult(value == expected, "$value should be equal to $expected", "$value should not be equal to $expected")
39+
} else if (expected.isNaN() && value.isNaN()) {
40+
println("[WARN] By design, Double.Nan != Double.Nan; see https://stackoverflow.com/questions/8819738/why-does-double-nan-double-nan-return-false/8819776#8819776")
41+
MatcherResult(false,
42+
"By design, Double.Nan != Double.Nan; see https://stackoverflow.com/questions/8819738/why-does-double-nan-double-nan-return-false/8819776#8819776",
43+
"By design, Double.Nan != Double.Nan; see https://stackoverflow.com/questions/8819738/why-does-double-nan-double-nan-return-false/8819776#8819776"
44+
)
45+
} else {
46+
if (tolerance == 0.0)
47+
println("[WARN] When comparing doubles consider using tolerance, eg: a shouldBe (b plusOrMinus c)")
48+
val diff = abs(value - expected)
49+
MatcherResult(diff <= tolerance, "$value should be equal to $expected", "$value should not be equal to $expected")
50+
}
51+
}
52+
}

clikt/src/jvmMain/kotlin/com/github/ajalt/clikt/sources/PropertiesValueSource.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
package com.github.ajalt.clikt.sources
22

33
import com.github.ajalt.clikt.core.Context
4-
import com.github.ajalt.clikt.core.FileFormatError
4+
import com.github.ajalt.clikt.core.InvalidFileFormat
55
import com.github.ajalt.clikt.parameters.options.Option
66
import com.github.ajalt.clikt.sources.MapValueSource.Companion.defaultKey
77
import java.io.File
@@ -18,7 +18,7 @@ object PropertiesValueSource {
1818
* If the [file] does not exist, an empty value source will be returned.
1919
*
2020
* @param file The file to read from.
21-
* @param requireValid If true, a [FileFormatError] will be thrown if the file doesn't parse correctly.
21+
* @param requireValid If true, a [InvalidFileFormat] will be thrown if the file doesn't parse correctly.
2222
* @param getKey A function that will return the property key for a given option.
2323
*/
2424
fun from(
@@ -31,7 +31,7 @@ object PropertiesValueSource {
3131
try {
3232
file.bufferedReader().use { properties.load(it) }
3333
} catch (e: Throwable) {
34-
if (requireValid) throw FileFormatError(file.name, e.message ?: "could not read file")
34+
if (requireValid) throw InvalidFileFormat(file.name, e.message ?: "could not read file")
3535
}
3636
}
3737

0 commit comments

Comments
 (0)