Skip to content

Commit b01edf0

Browse files
authored
ADFA-3730 | Make K2 completion fuzzy-match consistent with Java (#1483)
* ADFA-3730 | Make K2 completion fuzzy-match inclusive, matching Java Switch K2 scope/member/extension candidate inclusion from prefix-only to matchLevel != NO_MATCH so fuzzy matches appear, matching the Java LSP. Route every match-level computation through a per-request memo cache on AnalysisContext so CompletionItem.matchLevel runs once per candidate name. * ADFA-3730 | Compute match level once per candidate in Java scope completion ScopeCompletionProvider computed matchLevel twice per candidate — once in the filter predicate and again when building the item. Cache the predicate's result and reuse it in the loop, matching the sibling completion providers.
1 parent 69ed3cc commit b01edf0

4 files changed

Lines changed: 81 additions & 15 deletions

File tree

‎lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/completion/ScopeCompletionProvider.kt‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ class ScopeCompletionProvider(
6666
val trees = Trees.instance(task.task)
6767
val list: MutableList<CompletionItem> = ArrayList()
6868
val scope = trees.getScope(path)
69+
val matchLevels = HashMap<String, MatchLevel>()
6970
val filter =
7071
Predicate<CharSequence?> {
7172
if (it == null || it.isEmpty()) {
@@ -77,7 +78,9 @@ class ScopeCompletionProvider(
7778
name = it.substring(0, it.lastIndexOf('('))
7879
}
7980

80-
return@Predicate matchLevel(name, partial) != NO_MATCH
81+
val level = matchLevel(name, partial)
82+
matchLevels[name.toString()] = level
83+
return@Predicate level != NO_MATCH
8184
}
8285

8386
abortCompletionIfCancelled()
@@ -87,7 +90,7 @@ class ScopeCompletionProvider(
8790
name = name.substring(0, name.lastIndexOf('('))
8891
}
8992

90-
val matchLevel = matchLevel(name, partial)
93+
val matchLevel = matchLevels.getOrDefault(name, NO_MATCH)
9194

9295
if (member.kind == METHOD) {
9396
val method = member as ExecutableElement

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt‎

Lines changed: 30 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import com.itsaky.androidide.lsp.models.CompletionItemKind
1717
import com.itsaky.androidide.lsp.models.CompletionParams
1818
import com.itsaky.androidide.lsp.models.CompletionResult
1919
import com.itsaky.androidide.lsp.models.InsertTextFormat
20+
import com.itsaky.androidide.lsp.models.MatchLevel
2021
import com.itsaky.androidide.preferences.utils.indentationString
2122
import com.itsaky.androidide.progress.ICancelChecker
2223
import com.itsaky.androidide.progress.ProgressManager
@@ -241,11 +242,11 @@ private fun KaSession.collectMembersFromType(
241242
val typeScope = receiverType.scope
242243
if (typeScope != null) {
243244
val callables =
244-
typeScope.getCallableSignatures { name -> matchesPrefix(name) }
245+
typeScope.getCallableSignatures { name -> matchesFilter(name) }
245246
.map { it.symbol }
246247

247248
val classifiers =
248-
typeScope.getClassifierSymbols { name -> matchesPrefix(name) }
249+
typeScope.getClassifierSymbols { name -> matchesFilter(name) }
249250

250251
to += toCompletionItems(callables)
251252
to += toCompletionItems(classifiers)
@@ -257,8 +258,8 @@ private fun KaSession.collectMembersFromType(
257258
val classSymbol = classType.symbol as? KaClassSymbol ?: return
258259
val memberScope = classSymbol.memberScope
259260

260-
val callables = memberScope.callables { name -> matchesPrefix(name) }
261-
val classifiers = memberScope.classifiers { name -> matchesPrefix(name) }
261+
val callables = memberScope.callables { name -> matchesFilter(name) }
262+
val classifiers = memberScope.classifiers { name -> matchesFilter(name) }
262263

263264
to += toCompletionItems(callables)
264265
to += toCompletionItems(classifiers)
@@ -270,7 +271,7 @@ private fun KaSession.collectExtensionFunctions(
270271
to: MutableList<CompletionItem>
271272
) {
272273
val extensionSymbols =
273-
ctx.scope.callables { name -> matchesPrefix(name) }
274+
ctx.scope.callables { name -> matchesFilter(name) }
274275
.filter { symbol ->
275276
if (!symbol.isExtension) return@filter false
276277

@@ -303,7 +304,7 @@ private fun KaSession.collectScopeCompletions(
303304
)
304305

305306
val callables =
306-
scope.callables { name -> matchesPrefix(name) }
307+
scope.callables { name -> matchesFilter(name) }
307308
.filter { symbol ->
308309

309310
abortIfCancelled()
@@ -318,7 +319,7 @@ private fun KaSession.collectScopeCompletions(
318319
}
319320
}
320321

321-
val classifiers = scope.classifiers { name -> matchesPrefix(name) }
322+
val classifiers = scope.classifiers { name -> matchesFilter(name) }
322323

323324
to += toCompletionItems(callables)
324325
to += toCompletionItems(classifiers)
@@ -685,7 +686,7 @@ private fun KaSession.ktCompletionItem(
685686
val item = KotlinCompletionItem()
686687
item.ideLabel = name
687688
item.completionKind = kind
688-
item.matchLevel = CompletionItem.matchLevel(item.ideLabel, ctx.partial)
689+
item.matchLevel = matchLevelFor(name)
689690

690691
return item
691692
}
@@ -754,11 +755,28 @@ private fun partialIdentifier(prefix: String): String {
754755
return prefix.takeLastWhile { char -> Character.isJavaIdentifierPart(char) }
755756
}
756757

758+
/**
759+
* Returns the [MatchLevel] of [name] against [partial], memoized in [cache].
760+
*
761+
* Match level depends only on (name, partial), so memoizing by name is safe even
762+
* when multiple symbols share a name. This is the single place match level is
763+
* computed for a completion request; both the inclusion predicate and item
764+
* creation route through it so [CompletionItem.matchLevel] runs at most once per
765+
* distinct candidate name.
766+
*/
767+
internal fun memoizedMatchLevel(
768+
cache: MutableMap<String, MatchLevel>,
769+
name: String,
770+
partial: String,
771+
): MatchLevel = cache.getOrPut(name) { CompletionItem.matchLevel(name, partial) }
772+
757773
context(ctx: AnalysisContext)
758-
private fun matchesPrefix(name: Name): Boolean {
759-
if (ctx.partial.isEmpty()) return true
760-
return name.asString().startsWith(ctx.partial, ignoreCase = true)
761-
}
774+
private fun matchLevelFor(name: String): MatchLevel =
775+
memoizedMatchLevel(ctx.matchLevelCache, name, ctx.partial)
776+
777+
context(ctx: AnalysisContext)
778+
private fun matchesFilter(name: Name): Boolean =
779+
matchLevelFor(name.asString()) != MatchLevel.NO_MATCH
762780

763781
private fun determineCompletionContext(element: PsiElement): CompletionContext {
764782
// Walk up to find a qualified expression where we're the selector

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextResolver.kt‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.utils
33
import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment
44
import com.itsaky.androidide.lsp.kotlin.completion.DeclarationContext
55
import com.itsaky.androidide.lsp.kotlin.completion.DeclarationKind
6+
import com.itsaky.androidide.lsp.models.MatchLevel
67
import org.jetbrains.kotlin.analysis.api.KaSession
78
import org.jetbrains.kotlin.analysis.api.components.KaScopeContext
89
import org.jetbrains.kotlin.analysis.api.scopes.KaScope
@@ -45,7 +46,10 @@ internal data class AnalysisContext(
4546
val existingModifiers: Set<KtModifierKeywordToken>,
4647
val isInsideModifierList: Boolean,
4748
val partial: String,
48-
)
49+
) {
50+
/** Per-request memo of match level by candidate name. Not part of data-class identity. */
51+
val matchLevelCache: MutableMap<String, MatchLevel> = HashMap()
52+
}
4953

5054
/**
5155
* Resolves [AnalysisContext] at the given offset in the given [KtFile].
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
package com.itsaky.androidide.lsp.kotlin.completion
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import com.itsaky.androidide.lsp.models.MatchLevel
5+
import org.junit.Test
6+
import org.junit.runner.RunWith
7+
import org.junit.runners.JUnit4
8+
9+
@RunWith(JUnit4::class)
10+
class MatchLevelMemoTest {
11+
12+
@Test
13+
fun `includes fuzzy non-prefix matches`() {
14+
// "toStirng" is a transposition of "toString" — not a prefix, but a
15+
// high fuzzy ratio (> 59), so Java admits it and K2 must too.
16+
val level = memoizedMatchLevel(HashMap(), name = "toString", partial = "toStirng")
17+
assertThat(level).isEqualTo(MatchLevel.PARTIAL_MATCH)
18+
}
19+
20+
@Test
21+
fun `keeps prefix matches in their tier`() {
22+
val level = memoizedMatchLevel(HashMap(), name = "toString", partial = "toS")
23+
assertThat(level).isEqualTo(MatchLevel.CASE_SENSITIVE_PREFIX)
24+
}
25+
26+
@Test
27+
fun `excludes candidates below the fuzzy threshold`() {
28+
val level = memoizedMatchLevel(HashMap(), name = "toString", partial = "xyzw")
29+
assertThat(level).isEqualTo(MatchLevel.NO_MATCH)
30+
}
31+
32+
@Test
33+
fun `computes match level once per name and reuses the cache`() {
34+
// Pre-seed a deliberately wrong value. A recompute would return
35+
// CASE_SENSITIVE_EQUAL; getting NO_MATCH back proves the cache was reused.
36+
val cache = HashMap<String, MatchLevel>().apply { put("toString", MatchLevel.NO_MATCH) }
37+
val level = memoizedMatchLevel(cache, name = "toString", partial = "toString")
38+
assertThat(level).isEqualTo(MatchLevel.NO_MATCH)
39+
assertThat(cache).hasSize(1)
40+
}
41+
}

0 commit comments

Comments
 (0)