Skip to content

Commit 3c74e66

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 3c74e66

File tree

2 files changed

+137
-11
lines changed

2 files changed

+137
-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: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package lsp
2+
3+
import completions.lsp.util.completions.FuzzyCompletionRanking.rankCompletions
4+
import lsp.RankingTestDSL.rankingTest
5+
import org.eclipse.lsp4j.CompletionItem
6+
import org.junit.jupiter.api.Assertions.assertIterableEquals
7+
import org.junit.jupiter.api.Test
8+
9+
class FuzzyCompletionRankingTest {
10+
11+
@Test
12+
fun `empty query ranks only by sortText`() = rankingTest("") {
13+
val a = item("zeta", sortText = "3")
14+
val b = item("alpha", sortText = "1")
15+
val c = item("gamma", sortText = "2")
16+
expectOrder(b, c, a)
17+
}
18+
19+
@Test
20+
fun `Kotlin common API names should be properly ranked`() = rankingTest("pr") {
21+
val println = item("println", sortText = "2")
22+
val print = item("print", sortText = "1")
23+
val property = item("property", sortText = "4")
24+
val map = item("map", sortText = "3")
25+
expectOrder(print, println, property, map)
26+
}
27+
28+
@Test
29+
fun `completions are ranked by relevance and length (shortest first)`() = rankingTest("toIn") {
30+
val c1 = item("toInt", sortText = "3")
31+
val c2 = item("toUInt", sortText = "1")
32+
val c3 = item("toInterval", sortText = "2")
33+
expectOrder(c1, c3, c2)
34+
}
35+
36+
@Test
37+
fun `tie break by sortText when scores equal`() = rankingTest("pr") {
38+
val a = item("prX", sortText = "1")
39+
val b = item("prY", sortText = "2")
40+
expectOrder(a, b)
41+
}
42+
43+
@Test
44+
fun `exact and consecutive matches outrank sparse matches`() = rankingTest("pr") {
45+
val a = item("print", sortText = "2") // exact
46+
val b = item("p...r..", sortText = "1") // non-consecutive
47+
val c = item("pxxx", sortText = "3") // sparse/non-full
48+
expectOrder(a, b, c)
49+
}
50+
51+
@Test
52+
fun `case insensitive matching, tie-break with sortText`() = rankingTest("PrI") {
53+
val a = item("pRiNtLn", sortText = "2")
54+
val b = item("println", sortText = "1")
55+
expectOrder(b, a)
56+
}
57+
58+
@Test
59+
fun `consecutive matches beat non-consecutive`() = rankingTest("io") {
60+
val consecutive = item("ioScope", sortText = "2")
61+
val sparse = item("iXoScope", sortText = "1")
62+
expectOrder(consecutive, sparse)
63+
}
64+
}
65+
66+
private object RankingTestDSL {
67+
data class RankingCase(private val query: String) {
68+
val items = mutableListOf<CompletionItem>()
69+
val expected = mutableListOf<CompletionItem>()
70+
71+
fun item(
72+
label: String,
73+
sortText: String,
74+
dataJson: String? = null,
75+
): CompletionItem = CompletionItem(label).apply {
76+
this.sortText = sortText
77+
if (dataJson != null) this.data = dataJson
78+
items += this
79+
}
80+
81+
fun expectOrder(vararg items: CompletionItem) {
82+
expected.clear()
83+
expected += items
84+
}
85+
}
86+
87+
fun rankingTest(query: String, build: RankingCase.() -> Unit) {
88+
val case = RankingCase(query).apply(build)
89+
val ranked = case.items.rankCompletions(query)
90+
assertIterableEquals(case.expected, ranked, "Expected(query=$query): ${case.expected} but got: $ranked")
91+
}
92+
}

0 commit comments

Comments
 (0)