Skip to content

Commit 10f4cf7

Browse files
committed
chore: improve completion ranking
Added tests, and improved the following: - improve fuzzy matches - prefer shorter completions first - ultimately follow `sortText`
1 parent 228c44c commit 10f4cf7

File tree

2 files changed

+160
-11
lines changed

2 files changed

+160
-11
lines changed

completions/src/main/kotlin/completions/lsp/util/completions/FuzzyCompletionRanking.kt

Lines changed: 45 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import org.eclipse.lsp4j.CompletionItem
88
internal object FuzzyCompletionRanking {
99
private val objectMapper = Json { ignoreUnknownKeys = true }
1010

11-
private data class RankedItem(val item: CompletionItem, val score: Int)
11+
private data class RankedItem(
12+
val item: CompletionItem,
13+
val score: Int,
14+
val matchSpan: Int,
15+
val candidateLength: Int
16+
)
1217

1318
/**
1419
* Extracts the prefix of this [CompletionItem] that triggered the completion.
@@ -23,19 +28,39 @@ internal object FuzzyCompletionRanking {
2328
* fuzzy scoring is performed on what has been typed by the user so far,
2429
* then we use [CompletionItem.sortText] to break ties.
2530
*
31+
* Tie-breakers after the fuzzy score:
32+
* - smaller match span (the window covering all matched characters)
33+
* - shorter candidate length
34+
* - sortText (ascending)
35+
*
2636
* @param query the query the user has typed so far
2737
*/
28-
fun List<CompletionItem>.rankCompletions(query: String): List<CompletionItem> =
29-
map { RankedItem(it, fuzzyScore(query, it.sortingKey())) }
30-
.sortedWith(compareByDescending<RankedItem> { it.score }.thenBy { it.item.sortText })
38+
fun List<CompletionItem>.rankCompletions(query: String): List<CompletionItem> {
39+
// Empty query should rank purely by sortText (do not involve span/length)
40+
if (query.isEmpty()) {
41+
return this.sortedBy { it.sortText }
42+
}
43+
return map { item ->
44+
val candidate = item.sortingKey()
45+
val (score, span) = fuzzyScoreWithSpan(query, candidate)
46+
RankedItem(item, score, span, candidate.length)
47+
}
48+
.sortedWith(
49+
compareByDescending<RankedItem> { it.score }
50+
.thenBy { it.matchSpan }
51+
.thenBy { it.candidateLength }
52+
.thenBy { it.item.sortText }
53+
)
3154
.map { it.item }
55+
}
3256

33-
private fun fuzzyScore(query: String, candidate: String): Int {
34-
if (query.isEmpty()) return 1
57+
private fun fuzzyScoreWithSpan(query: String, candidate: String): Pair<Int, Int> {
58+
if (query.isEmpty()) return 1 to 0
3559

3660
var score = 0
3761
var queryIndex = 0
3862
var lastMatchIndex = -1
63+
var firstMatchIndex = -1
3964

4065
for (i in candidate.indices) {
4166
if (queryIndex >= query.length) break
@@ -44,16 +69,25 @@ internal object FuzzyCompletionRanking {
4469
val cc = candidate[i].lowercaseChar()
4570

4671
if (cc == qc) {
72+
if (firstMatchIndex == -1) firstMatchIndex = i
4773
score += 10
48-
if (lastMatchIndex == i - 1) score += 5 // consecutive match bonus
49-
if (i == 0 || !candidate[i-1].isLetterOrDigit()) score += 3 // bonus if beginning
74+
if (lastMatchIndex == i - 1) {
75+
score += 5
76+
} else if (lastMatchIndex != -1) {
77+
val gap = i - lastMatchIndex - 1
78+
if (gap > 0) score -= gap
79+
}
5080
lastMatchIndex = i
5181
queryIndex++
5282
}
5383
}
54-
return if (queryIndex == query.length) score else 0
84+
return if (queryIndex == query.length) {
85+
val span = lastMatchIndex - firstMatchIndex + 1
86+
score to span
87+
} else {
88+
0 to Int.MAX_VALUE
89+
}
5590
}
5691

5792
private fun CompletionItem.sortingKey(): String = this.filterText ?: this.label
58-
}
59-
93+
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package lsp
2+
3+
import completions.lsp.util.completions.FuzzyCompletionRanking.rankCompletions
4+
import org.eclipse.lsp4j.CompletionItem
5+
import org.junit.jupiter.api.Assertions.assertIterableEquals
6+
import org.junit.jupiter.api.Test
7+
8+
class FuzzyCompletionRankingTest {
9+
10+
@Test
11+
fun `empty query ranks only by sortText`() {
12+
val query = ""
13+
14+
val a = completionItem("zeta", sortText = "3")
15+
val b = completionItem("alpha", sortText = "1")
16+
val c = completionItem("gamma", sortText = "2")
17+
val ranked = listOf(a, b, c).rankCompletions(query)
18+
19+
assertIterableEquals(listOf(b, c, a), ranked)
20+
}
21+
22+
@Test
23+
fun `Kotlin common API names should be properly ranked`() {
24+
val query = "pr"
25+
26+
val println = completionItem("println", sortText = "2")
27+
val print = completionItem("print", sortText = "1")
28+
val property = completionItem("property", sortText = "4")
29+
val map = completionItem("map", sortText = "3")
30+
31+
val ranked = listOf(map, println, property, print).rankCompletions(query)
32+
33+
assertIterableEquals(listOf(print, println, property, map), ranked)
34+
}
35+
36+
@Test
37+
fun `completions are ranked properly`() {
38+
val c1 = completionItem("toInt", sortText = "3")
39+
val c2 = completionItem("toUInt", sortText = "1")
40+
val c3 = completionItem("toInterval", sortText = "2")
41+
42+
val ranked = listOf(c2, c3, c1).rankCompletions("toIn")
43+
assertIterableEquals(listOf(c1, c3, c2), ranked)
44+
}
45+
46+
@Test
47+
fun `tie-break by sortText when fuzzy scores equal`() {
48+
val query = "pr"
49+
50+
val a = completionItem("prX", sortText = "1")
51+
val b = completionItem("prY", sortText = "2")
52+
val ranked = listOf(b, a).rankCompletions(query)
53+
54+
assertIterableEquals(listOf(a, b), ranked)
55+
}
56+
57+
@Test
58+
fun `exact and consecutive matches outrank sparse matches`() {
59+
val query = "pr"
60+
61+
val a = completionItem("print", sortText = "2") // exact
62+
val b = completionItem("p...r..", sortText = "1") // consecutive
63+
val c = completionItem("pxxx", sortText = "3") // sparse
64+
val ranked = listOf(a, b, c).rankCompletions(query)
65+
66+
assertIterableEquals(listOf(a, b, c), ranked)
67+
}
68+
69+
@Test
70+
fun `case-insensitive matching, tie-break with sortText`() {
71+
val query = "PrI"
72+
73+
val a = completionItem("pRiNtLn", sortText = "2")
74+
val b = completionItem("println", sortText = "1")
75+
val ranked = listOf(a, b).rankCompletions(query)
76+
77+
assertIterableEquals(listOf(b, a), ranked)
78+
}
79+
80+
@Test
81+
fun `non-matching candidates are ranked after matching ones`() {
82+
val query = "map"
83+
84+
val match1 = completionItem("map", sortText = "2")
85+
val match2 = completionItem("maybeApply", sortText = "3")
86+
val nonMatch = completionItem("println", sortText = "1")
87+
val ranked = listOf(nonMatch, match2, match1).rankCompletions(query)
88+
89+
assertIterableEquals(listOf(match1, match2, nonMatch), ranked)
90+
}
91+
92+
@Test
93+
fun `consecutive matches beat non-consecutive`() {
94+
val query = "io"
95+
96+
val consecutive = completionItem("ioScope", sortText = "2")
97+
val sparse = completionItem("iXoScope", sortText = "1")
98+
val ranked = listOf(sparse, consecutive).rankCompletions(query)
99+
100+
assertIterableEquals(listOf(consecutive, sparse), ranked)
101+
}
102+
103+
104+
105+
private fun completionItem(
106+
label: String,
107+
sortText: String,
108+
filterText: String? = null,
109+
dataJson: String? = null,
110+
): CompletionItem = CompletionItem(label).apply {
111+
this.sortText = sortText
112+
if (filterText != null) this.filterText = filterText
113+
if (dataJson != null) this.data = dataJson
114+
}
115+
}

0 commit comments

Comments
 (0)