Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Remove artificial CURSOR added to code in the completions #20899

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

rochala
Copy link
Contributor

@rochala rochala commented Jun 30, 2024

This PR aims to remove the need for artificial CURSOR identifier which was appended after current cursor position:

Lis@@

Would result in code:

Lis@@CURSOR

This lead to super inefficient IDE experience in terms of number of compilations done after each change, but let's be a bit more specific. Let's imagine that we have 2 scenarios in which IDE events arrive to metals:

  • Scenario A: Completion (CURSOR compilation) -> Inlay Hints (No CURSOR compilation)
  • Scenario B: Semantic Highlight (No CURSOR compilation) -> Completion (CURSOR compilation) -> Inlay Hints (No CURSOR compilation)

On top of that, we've implemented a compilation caching, where code snippet and compiler configuration is a key. Now you should notice the issue, that adding a CURSOR into a code has different compilation result with cache invalidations.

In theory, we could handle CURSOR compilation as normal ones, but in reality it is a completely different run with different result (for example in diagnostics, as each one will contain CURSOR in the message). This is a no-go, especially if we would want to have diagnostics coming from presentation compiler in the future.

Because of that, each keypress results in at least 2 compilation and in the worst case scenario in 3. This also make metals way more battery heavy.

This PR is an attempt to drop CURSOR insertion for most cases.

A bit of history, how we ended up with CURSOR in a first place.
Most of the reasons are caused by parser and its recovery.
For example, finding a proper scope in this snippet:

def outer: Int =
  def inner: Int =
    val innerVal = 1
  @@ // completion triggered here

We have to find the correct scope in which we are (inner vs outer). We can achieve this in multiple ways, for example, count indents. This solution may not be so straightforward, as there can be different indentations etc. Inserting a synthetic CURSOR into this place:

def outer: Int =
  def inner: Int =
    val innerVal = 1
  @@CURSOR // completion triggered here

Will actually parse into an identifier with scope provided to us by Scala compiler. This is way easier and will always be correct.

Second example are keywords, let's say we have the following snippet:

var value = 0
val newValue = 1
value = new@@

This code will expect a type, as the parser found a new keyword. Adding a CURSOR here resolves the problem, as now we're dealing with newCURSOR, not new keyword (identifier vs keyword).

This PR is basically a change, which disables adding a CURSOR in all cases but 2 mentioned above. Those cases are very, very, very rare and is something that we can deal with. With this change, each compilation will now be cached and reused as intended resulting in way longer battery life, performance, response times and will enable us to access diagnostics for free without risking recompilation.

TODO:

  • - remove caching for snippets with CURSOR,
  • - add tests to verify it.

I'd also love to have this backported to LTS, as it is a significant performance tweak and will allow me to add diagnostics on the fly for the Scastie.

[test_windows_full]

def isBetterFit(currentBest: List[Positioned], candidate: List[Positioned]): Boolean =
if currentBest.isEmpty && candidate.nonEmpty then true
else if currentBest.nonEmpty && candidate.nonEmpty then
val bestSpan= currentBest.head.span
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
val bestSpan= currentBest.head.span
val bestSpan = currentBest.head.span

@@ -67,7 +66,7 @@ final class AutoImportsProvider(
val results = symbols.result.filter(isExactMatch(_, name))

if results.nonEmpty then
val correctedPos = CompletionPos.infer(pos, params, path).toSourcePosition
val correctedPos = CompletionPos.infer(pos, params, path, false).toSourcePosition
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
val correctedPos = CompletionPos.infer(pos, params, path, false).toSourcePosition
val correctedPos = CompletionPos.infer(pos, params, path, withCURSOR = false).toSourcePosition

Comment on lines -532 to -533
|until(end: Long): Exclusive[Long]
|until(end: Long, step: Long): Exclusive[Long]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why did this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This change probably was caused by fact, that ImplicitSearch now worked with a fully typed trees (with CURSOR it was an error), and this was the match. I'm curious tho what would be the output for 1.unt@@

@@ -614,8 +614,9 @@ class CompletionArgSuite extends BaseCompletionSuite:
check(
s"""|case class Context()
|
|def foo(arg1: (Context) ?=> Int, arg2: Int): String = ???
|val m = foo(ar@@)
|object Main:
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do you wrap those in an object?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We're reusing the presentation compiler, and we still have a problem with mutating context (I may have a fix for this in the future). We should always wrap entries into something, as this will override all previous state.

* The scenario in which "CURSOR" is applied (empty query or query equal to any keyword) has a slim chance of happening.
*/

val driver = if wasCursorApplied then freshDriver() else cachingDriver
Copy link
Collaborator

@kasiaMarek kasiaMarek Jul 8, 2024

Choose a reason for hiding this comment

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

The cursor should be applied rarely, does it make sense to treat it specially here?

Copy link
Contributor Author

@rochala rochala Jul 8, 2024

Choose a reason for hiding this comment

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

We have to treat it specially. Running a compilation mutates the context / changes diagnostics. There is no way to use a Driver without mutating a context. This means that to not change and reuse previous compilation, we have to create a new Driver

Copy link
Contributor Author

Choose a reason for hiding this comment

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

We may try saving a context and replacing it, but I'm not whether this is the best idea. As far as I know, this approach was tried when @tanishiking was adding compilation caching in a very first place. I also think that this is unnecessary overengineering.


val tpdPath = tpdPath0 match
case Select(qual, name) :: tail
// If for any reason we end up in param after lifting, we want to inline the synthetic val
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you add an example here?

private def checkCompilationCount(params: VirtualFileParams, expected: Int): Unit =
presentationCompiler match
case pc: ScalaPresentationCompiler =>
val compilations= pc.compilerAccess.withNonInterruptableCompiler(Some(params))(-1, EmptyCancelToken) { driver =>
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
val compilations= pc.compilerAccess.withNonInterruptableCompiler(Some(params))(-1, EmptyCancelToken) { driver =>
val compilations = pc.compilerAccess.withNonInterruptableCompiler(Some(params))(-1, EmptyCancelToken) { driver =>

def beforeEach: Unit =
presentationCompiler.restart()

// We want to run art least one compilation, so runId points at 3.
Copy link
Collaborator

Choose a reason for hiding this comment

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

Suggested change
// We want to run art least one compilation, so runId points at 3.
// We want to run at least one compilation, so runId points at 3.

private def checkCompilationCount(params: VirtualFileParams, expected: Int): Unit =
presentationCompiler match
case pc: ScalaPresentationCompiler =>
val compilations= pc.compilerAccess.withNonInterruptableCompiler(Some(params))(-1, EmptyCancelToken) { driver =>
Copy link
Collaborator

Choose a reason for hiding this comment

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

params in withNonInterruptableCompiler are just for reporting, it should be a bit cleaner if you don't pass it

Copy link
Contributor

@tgodzik tgodzik left a comment

Choose a reason for hiding this comment

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

Looks good! I think this would be a great improvement. I don't have anything to add aside from what @kasiaMarek already commented

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

3 participants