Skip to content

Conversation

S-furi
Copy link
Collaborator

@S-furi S-furi commented Oct 1, 2025

This PR introduces a new way of performing completions requests leveraging Kotlin-lsp. Two approaches are included:

  • RESTful on api/compiler/lsp/complete;
  • WebSocket approach.

Currently completions with this approach supports only the latest Kotlin version, but future developments include the support of such feature by using a dedicated client and workspace for each Kotlin version supported by the kotlin-compiler-server.
Moreover, the support for different platforms than Kotlin/JVM is strictly bound to what Kotlin-lsp supports, namely only Kotlin/JVM projects as of today.

@S-furi S-furi self-assigned this Oct 1, 2025
@S-furi S-furi requested a review from dkrasnoff as a code owner October 1, 2025 15:10
@dkrasnoff
Copy link
Collaborator

Please, resolve conflicts

@dkrasnoff
Copy link
Collaborator

Please add kdocs for all public methods and classes

}

fun KotlinLspProxy.onClientConnected(clientId: String) {
val project = Project(files = listOf(ProjectFile(name = "$clientId.kt")))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd say each project should have clientId, but files can then be called file1-$clientId.kt, file2-$clientId.kt, etc, so that we will have a possibility to work with mutliple files

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it hard to rewrite it to list of files instead of one file now?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It shouldn't introduce big problems, but right now the LSP logic is implemented considering only a single file, meaning that every file is treated in isolation regardless of the same user. Unfortunately it is not trivial to make the user's files see each other with the current isolated documents LSP mode.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it, thanks!
Anyway, let's make multiple files here and pass to lsp only the first one. And leave a comment or todo, with the place where we choose only one file, with the explanation about LSP mode.
Note: if it is a todo, then it should have the number of the task connected to it. If we don't have such a task, then you should create it.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added support for multiple documents, let me know if it's ok.
As a sidenote, my main doubt is how we handle which document among the project's documents has to be picked for providing completions. Right now in the KotlinLspProxy I've changed completion functions signatures in order to accept a ProjectFile (which by default is the first file of the project), which now are unused due to the API format, i.e frontend just provide project + line + character. Once multiple files will be supported, the APIs should change in order to include such logic.

fun KotlinLspProxy.onClientConnected(clientId: String) {
val project = Project(files = listOf(ProjectFile(name = "$clientId.kt")))
.also { clientsProjects[clientId] = it }
val lspProject = LspProject.fromProject(project).also { lspProjects[project] = it }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not clear for me, why do we have project and lspProject, can it be merged into one entity here?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They can be merged, but I needed a way to keep track of files in the file system, retrieve their lsp-compliant URIs and lsp documents versions. I though it was better to keep them separate being Project a simpler entity and already containing what's needed for most compiler server tasks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I expect that after extracting completion to a separate subproject, you will not have a Project entity, so that you can only leave a LspProject

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even after extracting the completion logic to a separate subproject, the dependency on Project is still present due to the API contract that we have with frontend. Frontend will send a Project instance to completion endpoints (both REST and WS as of now), so we still need a way to map the incoming project plus the convenient LspProject. If the subproject will be lsp-based only I can merge the two entities, while I suggest to keep them separated in case it must be shared by other completion logics. In the meantime I'll take care in documenting LspProject so that its purpose is clear.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to copy the whole project from the old API, just save fields, that you need

@S-furi S-furi force-pushed the exp/isolated-documents branch 2 times, most recently from d1fd552 to 7731e29 Compare October 8, 2025 14:35
@S-furi
Copy link
Collaborator Author

S-furi commented Oct 8, 2025

@dkrasnoff With recent commits I've made some changes that have probably changed the references to the request changes above. In short, I've

  • Resolved conflicts kotlin-community/dev base branch
  • Moved completion logic into a new springboot application module
  • LSP-based completions now leverage WebFlux
    • Both REST endpoint (marked as suspendable) and WebSocket Implementation.
  • Moved LSP related files from project root to its corresponding resources folder (conversation reference)
  • Applied suggested refactors

I remain available for any eventual question.

@S-furi S-furi force-pushed the exp/isolated-documents branch 5 times, most recently from 95cc73a to c4c51ad Compare October 14, 2025 09:50
import org.springframework.web.bind.annotation.ExceptionHandler

@ControllerAdvice
class LspAvailabilityAdvice {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's maybe move the controller package and write a class global exception handler for all exceptions, where we can handle all custom exceptions like this.

*
* @param query the query the user has typed so far
*/
fun List<CompletionItem>.rankCompletions(query: String): List<CompletionItem> =
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I used to conventions, where all public methods go on top and private go down

?.jsonObject?.get("prefix")
?.jsonPrimitive?.content

private fun fuzzyScore(query: String, candidate: String): Int {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Was it taken from VS code plugin?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's inspired and a simplification of how Monaco editor (the actual editor inside VS-Code) performs completion matching. With LSP it's largely up to the editor to determine completion sorting, LSP just provides a filterText: int field that rarely yields good completion results on top.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it than, lets make it util and cover this fuzzyScore with differnet test scenarios, to show what do we expect from it in the tests

import com.fasterxml.jackson.annotation.JsonValue

@JsonIgnoreProperties(ignoreUnknown = true)
data class Project(
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets move it to package completions.dto.api call it CompletionsRequest and remove args and confType as we don't need this fields

fun KotlinLspProxy.onClientConnected(clientId: String) {
val project = Project(files = listOf(ProjectFile(name = "$clientId.kt")))
.also { clientsProjects[clientId] = it }
val lspProject = LspProject.fromProject(project).also { lspProjects[project] = it }
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to copy the whole project from the old API, just save fields, that you need

@S-furi S-furi force-pushed the exp/isolated-documents branch from aa74429 to ff47e3a Compare October 16, 2025 11:26
import kotlin.test.Ignore
import kotlin.test.assertEquals

// TODO(Dmitrii Krasnov): this test is disabled until KTL-2807 is fixed
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought we could remove this todo and ignore the annotation here and in the concurrency test

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we can, but I wanted to discuss with you about a little detail: having developed basically two ways of performing completion (REST and WebSocket), we could take inspiration from current's CompletionTest, i.e. an abstract test that is reified for both implementations. In this way we reuse current tests, testing both completions approaches adding the least amount of code as possible.

I was trying to implement this with ImportTest, but apparently LSP completions differs a little bit from what is expected in that test (e.g. old completions: Random (kotlin.random.Random), LSP-based completions: Random (kotlin.random)). In these cases, should I change the tests or should I adapt LSP-based completions to make those tests pass? (it is mostly regex and string manipulation).

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeap, you can manipulate with test data if it doesn't affect the main logic of the check like in your example.


plugins {
id("base-kotlin-jvm-conventions")
alias(libs.plugins.spring.dependency.management)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What do you think about extracting base spring related things to Grdle convention plugin like base-kotlin-jvm-conventions, so we can share the same spring build setting across different modules.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a great idea! I'm going to try and see what I can do after fixing requested changes!

?.jsonObject?.get("prefix")
?.jsonPrimitive?.content

private fun fuzzyScore(query: String, candidate: String): Int {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it than, lets make it util and cover this fuzzyScore with differnet test scenarios, to show what do we expect from it in the tests

@S-furi S-furi force-pushed the exp/isolated-documents branch from 0b4bc80 to 0180d35 Compare October 16, 2025 12:59
@S-furi
Copy link
Collaborator Author

S-furi commented Oct 16, 2025

@dkrasnoff I don't remember if we've already discussed this, but should I move also Completion defined in common subproject into the new completions subproject?

@S-furi S-furi force-pushed the exp/isolated-documents branch from 0180d35 to 69ed0ed Compare October 16, 2025 13:16
fix: fix relative path when running locally fix: use different client id when running stateful test chore: re-introduce `objectReader` fix: propagate refactor in payload of WS tests
Added tests, and improved the following: - improve fuzzy matches - prefer shorter completions first - ultimately follow `sortText`
@S-furi S-furi force-pushed the exp/isolated-documents branch from c24863c to 5ea84b9 Compare October 17, 2025 07:11
""".trimIndent()

val foundCompletionsTexts = getCompletions(codeWithCaret = code, isJs = isJs).map { it.text }
val completions = listOf("kotlin.math.sin(")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@dkrasnoff I think I'm not getting what is the expected behaviour here, and also in current version of playground. Even when looking at the HTTP responses of playground if I insert this code snippet, I'm receiving the following completion:

{ "1": { "text": "kotlin.math.sin(", "displayText": "sin(x: Double) (kotlin.math.sin)", "tail": "Double", "import": "kotlin.math.sin", "icon": "method", "hasOtherImports": true	} }

hasOtherImports is marked as true, so the text field is replaced from sin( to the fully qualified name kotlin.math.sin(. However, I think this logic is wrong, because hasOtherImports should not be true due to the fact that the there aren't actually other imports that matches the same function signature, therefore text should be sin(.

On the other hand, if this is a design choice (i.e. prefer fully qualified names over auto-import), please let me know.
I think the best way to understand how I am figuring out this logic can be found in how I cleanup completions to Completion before sending them to clients.

@S-furi S-furi force-pushed the exp/isolated-documents branch from 2cc6d44 to 40ad0bb Compare October 17, 2025 11:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

2 participants