Skip to content

Commit cf00dd7

Browse files
committed
Add ordering matches by score
Closer to start of the string, letters closer together wins
1 parent 23445dc commit cf00dd7

File tree

5 files changed

+137
-8
lines changed

5 files changed

+137
-8
lines changed

Sources/FuzzyFinderCLI/CLI.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ struct FuzzyCLI: AsyncParsableCommand {
1616
@Flag
1717
var reverse: Bool = false
1818

19+
@Flag(inversion: .prefixedNo)
20+
var score: Bool = true
21+
1922
@Option(name: [.customShort("C"), .customLong("case")])
2023
var caseSensitivity: CaseSensitivity = .smart
2124

@@ -31,6 +34,7 @@ struct FuzzyCLI: AsyncParsableCommand {
3134
installSignalHandlers: self.installSignalHandlers,
3235
matchCaseSensitivity: self.caseSensitivity.matchCaseSensitivity,
3336
multipleSelection: self.multipleSelection,
37+
orderMatchesByScore: self.score,
3438
reverse: self.reverse
3539
)
3640
choices = try await selector.run()

Sources/FuzzyTUI/FuzzySelector.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ public final class FuzzySelector<T: Selectable, E: Error, Seq> where Seq: AsyncS
456456
installSignalHandlers: Bool = true,
457457
matchCaseSensitivity: MatchCaseSensitivity? = nil,
458458
multipleSelection: Bool = true,
459+
orderMatchesByScore: Bool = true,
459460
reverse: Bool = true
460461
) throws(TerminalError) {
461462
let appearance = appearance ?? .default
@@ -476,6 +477,7 @@ public final class FuzzySelector<T: Selectable, E: Error, Seq> where Seq: AsyncS
476477
let viewState = ViewState(
477478
choices: [T](),
478479
matchCaseSensitivity: matchCaseSensitivity ?? .caseSensitiveIfFilterContainsUppercase,
480+
orderMatchesByScore: orderMatchesByScore,
479481
reverse: reverse,
480482
size: terminalSize
481483
)

Sources/FuzzyTUI/ViewState.swift

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ final class ViewState<T: Selectable> {
55
var current: Int?
66

77
private let choiceFilter: ChoiceFilter<T>
8+
private let orderMatchesByScore: Bool
89
private let outputStream: AsyncStream<Void>
910
private let reverse: Bool
1011

@@ -19,13 +20,15 @@ final class ViewState<T: Selectable> {
1920
init(
2021
choices: [T],
2122
matchCaseSensitivity: MatchCaseSensitivity,
23+
orderMatchesByScore: Bool,
2224
reverse: Bool,
2325
size: TerminalSize
2426
) {
2527
self.choices = choices.enumerated().map(FilteredChoiceItem.init(index:choice:))
2628
self.unfilteredChoices = choices
2729
self.choiceFilter = ChoiceFilter(matchCaseSensitivity: matchCaseSensitivity)
2830
self.current = choices.isEmpty ? nil : choices.count - 1
31+
self.orderMatchesByScore = orderMatchesByScore
2932
self.reverse = reverse
3033
self.size = size
3134
self.visibleLines = max(choices.count - size.height + 2, 0)...max(choices.count - 1, 0)
@@ -71,7 +74,14 @@ final class ViewState<T: Selectable> {
7174

7275
func addChoices(_ choices: [T]) {
7376
self.unfilteredChoices.append(contentsOf: choices)
74-
self.choiceFilter.addJob(.init(choices: self.unfilteredChoices, filter: self.filter, reverse: self.reverse))
77+
self.choiceFilter.addJob(
78+
.init(
79+
choices: self.unfilteredChoices,
80+
filter: self.filter,
81+
orderByScore: self.orderMatchesByScore,
82+
reverse: self.reverse
83+
)
84+
)
7585
}
7686

7787
func editFilter(_ action: EditAction) {
@@ -134,7 +144,14 @@ final class ViewState<T: Selectable> {
134144
get { self._filter }
135145
set {
136146
self._filter = newValue
137-
self.choiceFilter.addJob(.init(choices: self.unfilteredChoices, filter: newValue, reverse: self.reverse))
147+
self.choiceFilter.addJob(
148+
.init(
149+
choices: self.unfilteredChoices,
150+
filter: newValue,
151+
orderByScore: self.orderMatchesByScore,
152+
reverse: self.reverse
153+
)
154+
)
138155
}
139156
}
140157

@@ -257,6 +274,7 @@ private actor ChoiceFilter<T: Selectable> {
257274
struct Job {
258275
var choices: [T]
259276
var filter: String
277+
var orderByScore: Bool
260278
var reverse: Bool
261279
}
262280

@@ -320,14 +338,27 @@ extension ChoiceFilter {
320338
}
321339

322340
let enumeratedChoices = job.choices.enumerated()
323-
if job.reverse {
324-
return self.runFilter(enumeratedChoices.reversed(), filter: job.filter, caseSensitive: caseSensitive)
325-
} else {
326-
return self.runFilter(enumeratedChoices, filter: job.filter, caseSensitive: caseSensitive)
341+
switch (job.reverse, job.orderByScore) {
342+
case (true, false):
343+
return self.runOrderPreservingFilter(
344+
enumeratedChoices.reversed(), filter: job.filter, caseSensitive: caseSensitive
345+
)
346+
case (false, false):
347+
return self.runOrderPreservingFilter(
348+
enumeratedChoices, filter: job.filter, caseSensitive: caseSensitive
349+
)
350+
case (true, true):
351+
return self.runScoringFilter(
352+
enumeratedChoices.reversed(), filter: job.filter, caseSensitive: caseSensitive
353+
)
354+
case (false, true):
355+
return self.runScoringFilter(
356+
enumeratedChoices, filter: job.filter, caseSensitive: caseSensitive
357+
)
327358
}
328359
}
329360

330-
private func runFilter<S: Sequence>(
361+
private func runOrderPreservingFilter<S: Sequence>(
331362
_ choices: S,
332363
filter: String,
333364
caseSensitive: Bool
@@ -336,6 +367,42 @@ extension ChoiceFilter {
336367
isMatch($1.description, filter: filter, caseSensitive: caseSensitive)
337368
}.map(FilteredChoiceItem.init(index:choice:))
338369
}
370+
371+
private func runScoringFilter<S: Sequence>(
372+
_ choices: S,
373+
filter: String,
374+
caseSensitive: Bool
375+
) -> [FilteredChoiceItem<T>] where S.Element == (offset: Int, element: T) {
376+
return choices.compactMap { (choice: S.Element) -> (Int, S.Element)? in
377+
switch scoreMatch(choice.element.description, filter: filter, caseSensitive: caseSensitive) {
378+
case .noMatch: return nil
379+
case let .match(score: score): return (score, choice)
380+
}
381+
}.sorted { (lhs: (Int, (offset: Int, element: T)), rhs: (Int, (offset: Int, element: T))) in
382+
lhs.0 > rhs.0
383+
}.map { _, choice in
384+
FilteredChoiceItem.init(index: choice.offset, choice: choice.element)
385+
}
386+
}
387+
}
388+
389+
enum ScoredMatchResult: Equatable {
390+
case noMatch
391+
case match(score: Int)
392+
}
393+
394+
func scoreMatch(_ string: String, filter: String, caseSensitive: Bool) -> ScoredMatchResult {
395+
var characters = Array(caseSensitive ? string : string.lowercased())
396+
let filterCharacters = Array(caseSensitive ? filter : filter.lowercased())
397+
var score = 0
398+
for filterCharacter in filterCharacters {
399+
guard let index = characters.firstIndex(of: filterCharacter) else {
400+
return .noMatch
401+
}
402+
score += index
403+
characters.removeFirst(index + 1)
404+
}
405+
return .match(score: score)
339406
}
340407

341408
func isMatch(_ string: String, filter: String, caseSensitive: Bool) -> Bool {

Tests/FuzzyTUITests/MatchTests.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import Testing
99

1010
@testable import FuzzyTUI
1111

12-
struct FuzzyTUITests {
12+
struct MatchTests {
1313
@Test(
1414
"Successful matches",
1515
arguments: [
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
//
2+
// ScoringMatchTests.swift
3+
// tui-fuzzy-finder
4+
//
5+
// Created by Juri Pakaste on 17.12.2024.
6+
//
7+
8+
import Testing
9+
10+
@testable import FuzzyTUI
11+
12+
struct ScoringMatchTests {
13+
@Test(
14+
"Successful matches",
15+
arguments: [
16+
("", "", 0),
17+
("foo", "foo", 0),
18+
("foob", "foo", 0),
19+
("afoo", "foo", 1),
20+
("foao", "foo", 1),
21+
("faoao", "foo", 2),
22+
("afaoao", "foo", 3),
23+
]
24+
)
25+
func successfulMatches(_ string: String, _ filter: String, _ score: Int) throws {
26+
#expect(scoreMatch(string, filter: filter, caseSensitive: true) == ScoredMatchResult.match(score: score))
27+
}
28+
29+
@Test(
30+
"Failing matches",
31+
arguments: [
32+
("", "a"),
33+
("a", "aa"),
34+
("aaa", "aaaa"),
35+
("a", "b"),
36+
("aa", "bb"),
37+
("aaa", "ba"),
38+
("aaa", "ab"),
39+
("abcd", "ba"),
40+
("abcd", "ca"),
41+
("abcd", "cb"),
42+
("abcd", "dc"),
43+
("abcd", "da"),
44+
("abcd", "db"),
45+
("abcd", "e"),
46+
("👯", "👯‍♀️"),
47+
("a", "A"),
48+
("aa", "AA"),
49+
("aaa", "A"),
50+
("aaa", "AA"),
51+
]
52+
)
53+
func failingMatches(_ string: String, _ filter: String) throws {
54+
#expect(scoreMatch(string, filter: filter, caseSensitive: true) == .noMatch)
55+
}
56+
}

0 commit comments

Comments
 (0)