Skip to content

Commit fd5b49c

Browse files
authored
Provide suggestions for unknown options (#10)
* Add String#editDistance(to:) Uses levenshtein distance to determine how much two strings differ. See: https://en.wikipedia.org/wiki/Levenshtein_distance * Simplify unknownOptionMessage The logic of Name#synopsisString was repeated in unknownOptionMessage. This change sit so that unknownOptionMessage defer to Name's implementation instead. * Provide suggestions for unknown options
1 parent e21b10e commit fd5b49c

File tree

5 files changed

+137
-12
lines changed

5 files changed

+137
-12
lines changed

Sources/ArgumentParser/Parsing/CommandParser.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@ extension CommandParser {
7878

7979
// We should have used up all arguments at this point:
8080
guard split.isEmpty else {
81+
// Check if one of the arguments is an unknown option
82+
for (index, element) in split.elements {
83+
if case .option(let argument) = element {
84+
throw ParserError.unknownOption(InputOrigin.Element.argumentIndex(index), argument.name)
85+
}
86+
}
87+
8188
let extra = split.coalescedExtraElements()
8289
throw ParserError.unexpectedExtraValues(extra)
8390
}

Sources/ArgumentParser/Usage/UsageGenerator.swift

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -246,16 +246,35 @@ extension ErrorMessageGenerator {
246246
var invalidState: String {
247247
return "Internal error. Invalid state while parsing command-line arguments."
248248
}
249-
249+
250+
250251
func unknownOptionMessage(origin: InputOrigin.Element, name: Name) -> String {
251-
switch name {
252-
case .long(let n):
253-
return "Unknown option '--\(n)'"
254-
case .short(let n):
255-
return "Unknown option '-\(n)'"
256-
case .longWithSingleDash(let n):
257-
return "Unknown option '-\(n)'"
252+
if case .short = name {
253+
return "Unknown option '\(name.synopsisString)'"
254+
}
255+
256+
// An empirically derived magic number
257+
let SIMILARITY_FLOOR = 4
258+
259+
let notShort: (Name) -> Bool = { (name: Name) in
260+
switch name {
261+
case .short: return false
262+
case .long: return true
263+
case .longWithSingleDash: return true
264+
}
265+
}
266+
let suggestion = arguments
267+
.flatMap({ $0.names })
268+
.filter({ $0.synopsisString.editDistance(to: name.synopsisString) < SIMILARITY_FLOOR }) // only include close enough suggestion
269+
.filter(notShort) // exclude short option suggestions
270+
.min(by: { lhs, rhs in // find the suggestion closest to the argument
271+
lhs.synopsisString.editDistance(to: name.synopsisString) < rhs.synopsisString.editDistance(to: name.synopsisString)
272+
})
273+
274+
if let suggestion = suggestion {
275+
return "Unknown option '\(name.synopsisString)'. Did you mean '\(suggestion.synopsisString)'?"
258276
}
277+
return "Unknown option '\(name.synopsisString)'"
259278
}
260279

261280
func missingValueForOptionMessage(origin: InputOrigin, name: Name) -> String {

Sources/ArgumentParser/Utilities/StringExtensions.swift

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,4 +144,51 @@ extension String {
144144
self[range].lowercased()
145145
}.joined(separator: String(separator))
146146
}
147+
148+
/// Returns the edit distance between this string and the provided target string.
149+
///
150+
/// Uses the Levenshtein distance algorithm internally.
151+
///
152+
/// See: https://en.wikipedia.org/wiki/Levenshtein_distance
153+
///
154+
/// Examples:
155+
///
156+
/// "kitten".editDistance(to: "sitting")
157+
/// // 3
158+
/// "bar".editDistance(to: "baz")
159+
/// // 1
160+
161+
func editDistance(to target: String) -> Int {
162+
let rows = self.count
163+
let columns = target.count
164+
165+
if rows <= 0 || columns <= 0 {
166+
return max(rows, columns)
167+
}
168+
169+
var matrix = Array(repeating: Array(repeating: 0, count: columns + 1), count: rows + 1)
170+
171+
for row in 1...rows {
172+
matrix[row][0] = row
173+
}
174+
for column in 1...columns {
175+
matrix[0][column] = column
176+
}
177+
178+
for row in 1...rows {
179+
for column in 1...columns {
180+
let source = self[self.index(self.startIndex, offsetBy: row - 1)]
181+
let target = target[target.index(target.startIndex, offsetBy: column - 1)]
182+
let cost = source == target ? 0 : 1
183+
184+
matrix[row][column] = Swift.min(
185+
matrix[row - 1][column] + 1,
186+
matrix[row][column - 1] + 1,
187+
matrix[row - 1][column - 1] + cost
188+
)
189+
}
190+
}
191+
192+
return matrix.last!.last!
193+
}
147194
}

Tests/UnitTests/ErrorMessageTests.swift

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,19 +32,19 @@ extension ErrorMessageTests {
3232
}
3333

3434
func testUnknownOption_1() {
35-
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "--verbose"], "Unexpected argument '--verbose'")
35+
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "--verbose"], "Unknown option '--verbose'")
3636
}
3737

3838
func testUnknownOption_2() {
39-
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "-q"], "Unexpected argument '-q'")
39+
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "-q"], "Unknown option '-q'")
4040
}
4141

4242
func testUnknownOption_3() {
43-
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "-bar"], "Unexpected argument '-bar'")
43+
AssertErrorMessage(Bar.self, ["--name", "a", "--format", "b", "-bar"], "Unknown option '-bar'")
4444
}
4545

4646
func testUnknownOption_4() {
47-
AssertErrorMessage(Bar.self, ["--name", "a", "-foz", "b"], "2 unexpected arguments: '-o', '-z'")
47+
AssertErrorMessage(Bar.self, ["--name", "a", "-foz", "b"], "Unknown option '-o'")
4848
}
4949

5050
func testMissingValue_1() {
@@ -109,3 +109,28 @@ extension ErrorMessageTests {
109109
AssertErrorMessage(Qux.self, ["--number-two", "a", "1"], "The value 'a' is invalid for '--number-two <number-two>'")
110110
}
111111
}
112+
113+
fileprivate struct Qwz: ParsableArguments {
114+
@Option() var name: String?
115+
@Option(name: [.customLong("title", withSingleDash: true)]) var title: String?
116+
}
117+
118+
extension ErrorMessageTests {
119+
func testMispelledArgument_1() {
120+
AssertErrorMessage(Qwz.self, ["--nme"], "Unknown option '--nme'. Did you mean '--name'?")
121+
AssertErrorMessage(Qwz.self, ["-name"], "Unknown option '-name'. Did you mean '--name'?")
122+
}
123+
124+
func testMispelledArgument_2() {
125+
AssertErrorMessage(Qwz.self, ["-ttle"], "Unknown option '-ttle'. Did you mean '-title'?")
126+
AssertErrorMessage(Qwz.self, ["--title"], "Unknown option '--title'. Did you mean '-title'?")
127+
}
128+
129+
func testMispelledArgument_3() {
130+
AssertErrorMessage(Qwz.self, ["--not-similar"], "Unknown option '--not-similar'")
131+
}
132+
133+
func testMispelledArgument_4() {
134+
AssertErrorMessage(Qwz.self, ["-x"], "Unknown option '-x'")
135+
}
136+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//===----------------------------------------------------------*- swift -*-===//
2+
//
3+
// This source file is part of the Swift Argument Parser open source project
4+
//
5+
// Copyright (c) 2020 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See https://swift.org/LICENSE.txt for license information
9+
//
10+
//===----------------------------------------------------------------------===//
11+
12+
import XCTest
13+
@testable import ArgumentParser
14+
15+
final class StringEditDistanceTests: XCTestCase {}
16+
17+
extension StringEditDistanceTests {
18+
func testStringEditDistance() {
19+
XCTAssertEqual("".editDistance(to: ""), 0)
20+
XCTAssertEqual("".editDistance(to: "foo"), 3)
21+
XCTAssertEqual("foo".editDistance(to: ""), 3)
22+
XCTAssertEqual("foo".editDistance(to: "bar"), 3)
23+
XCTAssertEqual("bar".editDistance(to: "foo"), 3)
24+
XCTAssertEqual("bar".editDistance(to: "baz"), 1)
25+
XCTAssertEqual("baz".editDistance(to: "bar"), 1)
26+
}
27+
}

0 commit comments

Comments
 (0)