Skip to content

Commit f774497

Browse files
authored
Remove artificial CURSOR added to code in the completions (#20899)
This PR aims to remove the need for artificial `CURSOR` identifier which was appended after current cursor position: ```scala Lis@@ ``` Would result in code: ```scala 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: ```scala 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: ```scala 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: ```scala 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: - [x] - remove caching for snippets with CURSOR, - [x] - 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]
2 parents 8527a9b + a9ac829 commit f774497

33 files changed

+435
-189
lines changed

Diff for: compiler/src/dotty/tools/dotc/ast/NavigateAST.scala

+38-8
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package ast
33

44
import core.Contexts.*
55
import core.Decorators.*
6+
import core.StdNames
67
import util.Spans.*
78
import Trees.{Closure, MemberDef, DefTree, WithLazyFields}
89
import dotty.tools.dotc.core.Types.AnnotatedType
@@ -74,21 +75,50 @@ object NavigateAST {
7475
def pathTo(span: Span, from: List[Positioned], skipZeroExtent: Boolean = false)(using Context): List[Positioned] = {
7576
def childPath(it: Iterator[Any], path: List[Positioned]): List[Positioned] = {
7677
var bestFit: List[Positioned] = path
77-
while (it.hasNext) {
78-
val path1 = it.next() match {
78+
while (it.hasNext) do
79+
val path1 = it.next() match
80+
case sel: untpd.Select if isRecoveryTree(sel) => path
81+
case sel: untpd.Ident if isPatternRecoveryTree(sel) => path
7982
case p: Positioned if !p.isInstanceOf[Closure[?]] => singlePath(p, path)
8083
case m: untpd.Modifiers => childPath(m.productIterator, path)
8184
case xs: List[?] => childPath(xs.iterator, path)
8285
case _ => path
83-
}
84-
if ((path1 ne path) &&
85-
((bestFit eq path) ||
86-
bestFit.head.span != path1.head.span &&
87-
bestFit.head.span.contains(path1.head.span)))
86+
87+
if (path1 ne path) && ((bestFit eq path) || isBetterFit(bestFit, path1)) then
8888
bestFit = path1
89-
}
89+
9090
bestFit
9191
}
92+
93+
/**
94+
* When choosing better fit we compare spans. If candidate span has starting or ending point inside (exclusive)
95+
* current best fit it is selected as new best fit. This means that same spans are failing the first predicate.
96+
*
97+
* In case when spans start and end at same offsets we prefer non synthethic one.
98+
*/
99+
def isBetterFit(currentBest: List[Positioned], candidate: List[Positioned]): Boolean =
100+
if currentBest.isEmpty && candidate.nonEmpty then true
101+
else if currentBest.nonEmpty && candidate.nonEmpty then
102+
val bestSpan = currentBest.head.span
103+
val candidateSpan = candidate.head.span
104+
105+
bestSpan != candidateSpan &&
106+
envelops(bestSpan, candidateSpan) ||
107+
bestSpan.contains(candidateSpan) && bestSpan.isSynthetic && !candidateSpan.isSynthetic
108+
else false
109+
110+
def isRecoveryTree(sel: untpd.Select): Boolean =
111+
sel.span.isSynthetic
112+
&& (sel.name == StdNames.nme.??? && sel.qualifier.symbol.name == StdNames.nme.Predef)
113+
114+
def isPatternRecoveryTree(ident: untpd.Ident): Boolean =
115+
ident.span.isSynthetic && StdNames.nme.WILDCARD == ident.name
116+
117+
def envelops(a: Span, b: Span): Boolean =
118+
!b.exists || a.exists && (
119+
(a.start < b.start && a.end >= b.end ) || (a.start <= b.start && a.end > b.end)
120+
)
121+
92122
/*
93123
* Annotations trees are located in the Type
94124
*/

Diff for: compiler/src/dotty/tools/dotc/interactive/Completion.scala

+7-6
Original file line numberDiff line numberDiff line change
@@ -121,16 +121,17 @@ object Completion:
121121
case _ =>
122122
""
123123

124+
def naiveCompletionPrefix(text: String, offset: Int): String =
125+
var i = offset - 1
126+
while i >= 0 && text(i).isUnicodeIdentifierPart do i -= 1
127+
i += 1 // move to first character
128+
text.slice(i, offset)
129+
124130
/**
125131
* Inspect `path` to determine the completion prefix. Only symbols whose name start with the
126132
* returned prefix should be considered.
127133
*/
128134
def completionPrefix(path: List[untpd.Tree], pos: SourcePosition)(using Context): String =
129-
def fallback: Int =
130-
var i = pos.point - 1
131-
while i >= 0 && Character.isUnicodeIdentifierPart(pos.source.content()(i)) do i -= 1
132-
i + 1
133-
134135
path match
135136
case GenericImportSelector(sel) =>
136137
if sel.isGiven then completionPrefix(sel.bound :: Nil, pos)
@@ -148,7 +149,7 @@ object Completion:
148149
case (tree: untpd.RefTree) :: _ if tree.name != nme.ERROR =>
149150
tree.name.toString.take(pos.span.point - tree.span.point)
150151

151-
case _ => pos.source.content.slice(fallback, pos.point).mkString
152+
case _ => naiveCompletionPrefix(pos.source.content().mkString, pos.point)
152153

153154

154155
end completionPrefix

Diff for: compiler/src/dotty/tools/dotc/parsing/Parsers.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -406,7 +406,7 @@ object Parsers {
406406
false
407407
}
408408

409-
def errorTermTree(start: Offset): Tree = atSpan(start, in.offset, in.offset) { unimplementedExpr }
409+
def errorTermTree(start: Offset): Tree = atSpan(Span(start, in.offset)) { unimplementedExpr }
410410

411411
private var inFunReturnType = false
412412
private def fromWithinReturnType[T](body: => T): T = {

Diff for: language-server/test/dotty/tools/languageserver/HoverTest.scala

+1-1
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,7 @@ class HoverTest {
227227
@Test def enums: Unit = {
228228
code"""|package example
229229
|enum TestEnum3:
230-
| case ${m1}A${m2} // no tooltip
230+
| case ${m1}A${m2} // no tooltip
231231
|
232232
|"""
233233
.hover(m1 to m2, hoverContent("example.TestEnum3"))

Diff for: presentation-compiler/src/main/dotty/tools/pc/AutoImportsProvider.scala

+2-2
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ import dotty.tools.dotc.core.Symbols.*
1313
import dotty.tools.dotc.interactive.Interactive
1414
import dotty.tools.dotc.interactive.InteractiveDriver
1515
import dotty.tools.dotc.util.SourceFile
16-
import dotty.tools.pc.AutoImports.*
1716
import dotty.tools.pc.completions.CompletionPos
1817
import dotty.tools.pc.utils.InteractiveEnrichments.*
1918

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

6968
if results.nonEmpty then
70-
val correctedPos = CompletionPos.infer(pos, params, path).toSourcePosition
69+
val correctedPos =
70+
CompletionPos.infer(pos, params, path, wasCursorApplied = false).toSourcePosition
7171
val mkEdit =
7272
path match
7373
// if we are in import section just specify full name

Diff for: presentation-compiler/src/main/dotty/tools/pc/MetalsDriver.scala renamed to presentation-compiler/src/main/dotty/tools/pc/CachingDriver.scala

+4-6
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import dotty.tools.dotc.util.SourceFile
1010
import scala.compiletime.uninitialized
1111

1212
/**
13-
* MetalsDriver is a wrapper class that provides a compilation cache for InteractiveDriver.
14-
* MetalsDriver skips running compilation if
13+
* CachingDriver is a wrapper class that provides a compilation cache for InteractiveDriver.
14+
* CachingDriver skips running compilation if
1515
* - the target URI of `run` is the same as the previous target URI
1616
* - the content didn't change since the last compilation.
1717
*
@@ -27,9 +27,7 @@ import scala.compiletime.uninitialized
2727
* To avoid the complexity related to currentCtx,
2828
* we decided to cache only when the target URI only if the same as the previous run.
2929
*/
30-
class MetalsDriver(
31-
override val settings: List[String]
32-
) extends InteractiveDriver(settings):
30+
class CachingDriver(override val settings: List[String]) extends InteractiveDriver(settings):
3331

3432
@volatile private var lastCompiledURI: URI = uninitialized
3533

@@ -55,4 +53,4 @@ class MetalsDriver(
5553
lastCompiledURI = uri
5654
diags
5755

58-
end MetalsDriver
56+
end CachingDriver

Diff for: presentation-compiler/src/main/dotty/tools/pc/PcInlayHintsProvider.scala

-1
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ import scala.meta.internal.pc.LabelPart.*
1414
import scala.meta.pc.InlayHintsParams
1515
import scala.meta.pc.SymbolSearch
1616

17-
import dotty.tools.dotc.ast.tpd
1817
import dotty.tools.dotc.ast.tpd.*
1918
import dotty.tools.dotc.core.Contexts.Context
2019
import dotty.tools.dotc.core.Flags

Diff for: presentation-compiler/src/main/dotty/tools/pc/Scala3CompilerAccess.scala

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,14 @@ import scala.meta.internal.pc.CompilerAccess
88
import scala.meta.pc.PresentationCompilerConfig
99

1010
import dotty.tools.dotc.reporting.StoreReporter
11+
import dotty.tools.dotc.interactive.InteractiveDriver
1112

1213
class Scala3CompilerAccess(
1314
config: PresentationCompilerConfig,
1415
sh: Option[ScheduledExecutorService],
1516
newCompiler: () => Scala3CompilerWrapper
1617
)(using ec: ExecutionContextExecutor, rc: ReportContext)
17-
extends CompilerAccess[StoreReporter, MetalsDriver](
18+
extends CompilerAccess[StoreReporter, InteractiveDriver](
1819
config,
1920
sh,
2021
newCompiler,

Diff for: presentation-compiler/src/main/dotty/tools/pc/Scala3CompilerWrapper.scala

+4-3
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import scala.meta.internal.pc.CompilerWrapper
44
import scala.meta.internal.pc.ReporterAccess
55

66
import dotty.tools.dotc.reporting.StoreReporter
7+
import dotty.tools.dotc.interactive.InteractiveDriver
78

8-
class Scala3CompilerWrapper(driver: MetalsDriver)
9-
extends CompilerWrapper[StoreReporter, MetalsDriver]:
9+
class Scala3CompilerWrapper(driver: InteractiveDriver)
10+
extends CompilerWrapper[StoreReporter, InteractiveDriver]:
1011

11-
override def compiler(): MetalsDriver = driver
12+
override def compiler(): InteractiveDriver = driver
1213

1314
override def resetReporter(): Unit =
1415
val ctx = driver.currentCtx

Diff for: presentation-compiler/src/main/dotty/tools/pc/ScalaPresentationCompiler.scala

+15-19
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,13 @@ import dotty.tools.pc.completions.CompletionProvider
3333
import dotty.tools.pc.InferExpectedType
3434
import dotty.tools.pc.completions.OverrideCompletions
3535
import dotty.tools.pc.buildinfo.BuildInfo
36+
import dotty.tools.pc.SymbolInformationProvider
37+
import dotty.tools.dotc.interactive.InteractiveDriver
3638

3739
import org.eclipse.lsp4j.DocumentHighlight
3840
import org.eclipse.lsp4j.TextEdit
3941
import org.eclipse.lsp4j as l
40-
import dotty.tools.pc.SymbolInformationProvider
42+
4143

4244
case class ScalaPresentationCompiler(
4345
buildTargetIdentifier: String = "",
@@ -76,14 +78,20 @@ case class ScalaPresentationCompiler(
7678
override def withReportsLoggerLevel(level: String): PresentationCompiler =
7779
copy(reportsLevel = ReportLevel.fromString(level))
7880

79-
val compilerAccess: CompilerAccess[StoreReporter, MetalsDriver] =
81+
val compilerAccess: CompilerAccess[StoreReporter, InteractiveDriver] =
8082
Scala3CompilerAccess(
8183
config,
8284
sh,
83-
() => new Scala3CompilerWrapper(newDriver)
84-
)(using
85-
ec
86-
)
85+
() => new Scala3CompilerWrapper(CachingDriver(driverSettings))
86+
)(using ec)
87+
88+
val driverSettings =
89+
val implicitSuggestionTimeout = List("-Ximport-suggestion-timeout", "0")
90+
val defaultFlags = List("-color:never")
91+
val filteredOptions = removeDoubleOptions(options.filterNot(forbiddenOptions))
92+
93+
filteredOptions ::: defaultFlags ::: implicitSuggestionTimeout ::: "-classpath" :: classpath
94+
.mkString(File.pathSeparator) :: Nil
8795

8896
private def removeDoubleOptions(options: List[String]): List[String] =
8997
options match
@@ -92,19 +100,6 @@ case class ScalaPresentationCompiler(
92100
case head :: tail => head :: removeDoubleOptions(tail)
93101
case Nil => options
94102

95-
def newDriver: MetalsDriver =
96-
val implicitSuggestionTimeout = List("-Ximport-suggestion-timeout", "0")
97-
val defaultFlags = List("-color:never")
98-
val filteredOptions = removeDoubleOptions(
99-
options.filterNot(forbiddenOptions)
100-
)
101-
val settings =
102-
filteredOptions ::: defaultFlags ::: implicitSuggestionTimeout ::: "-classpath" :: classpath
103-
.mkString(
104-
File.pathSeparator
105-
) :: Nil
106-
new MetalsDriver(settings)
107-
108103
override def semanticTokens(
109104
params: VirtualFileParams
110105
): CompletableFuture[ju.List[Node]] =
@@ -146,6 +141,7 @@ case class ScalaPresentationCompiler(
146141
new CompletionProvider(
147142
search,
148143
driver,
144+
() => InteractiveDriver(driverSettings),
149145
params,
150146
config,
151147
buildTargetIdentifier,

Diff for: presentation-compiler/src/main/dotty/tools/pc/ScriptFirstImportPosition.scala

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package dotty.tools.pc
22

3-
import dotty.tools.dotc.ast.tpd.*
43
import dotty.tools.dotc.core.Comments.Comment
54

65
object ScriptFirstImportPosition:

Diff for: presentation-compiler/src/main/dotty/tools/pc/SignatureHelpProvider.scala

-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package dotty.tools.pc
22

3-
import dotty.tools.dotc.ast.tpd.*
43
import dotty.tools.dotc.core.Contexts.*
54
import dotty.tools.dotc.core.Flags
65
import dotty.tools.dotc.core.Symbols.*

Diff for: presentation-compiler/src/main/dotty/tools/pc/SymbolInformationProvider.scala

+1-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import scala.meta.pc.PcSymbolKind
77
import scala.meta.pc.PcSymbolProperty
88

99
import dotty.tools.dotc.core.Contexts.Context
10-
import dotty.tools.dotc.core.Denotations.Denotation
11-
import dotty.tools.dotc.core.Denotations.MultiDenotation
1210
import dotty.tools.dotc.core.Flags
1311
import dotty.tools.dotc.core.Names.*
1412
import dotty.tools.dotc.core.StdNames.nme
@@ -19,6 +17,7 @@ import dotty.tools.pc.utils.InteractiveEnrichments.allSymbols
1917
import dotty.tools.pc.utils.InteractiveEnrichments.stripBackticks
2018
import scala.meta.internal.pc.PcSymbolInformation
2119
import scala.meta.internal.pc.SymbolInfo
20+
import dotty.tools.dotc.core.Denotations.{Denotation, MultiDenotation}
2221

2322
class SymbolInformationProvider(using Context):
2423

Diff for: presentation-compiler/src/main/dotty/tools/pc/completions/CompletionPos.scala

+6-3
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ case class CompletionPos(
2222
identEnd: Int,
2323
query: String,
2424
originalCursorPosition: SourcePosition,
25-
sourceUri: URI
25+
sourceUri: URI,
26+
withCURSOR: Boolean
2627
):
2728
def queryEnd: Int = originalCursorPosition.point
2829
def stripSuffixEditRange: l.Range = new l.Range(originalCursorPosition.offsetToPos(queryStart), originalCursorPosition.offsetToPos(identEnd))
@@ -34,17 +35,19 @@ object CompletionPos:
3435
def infer(
3536
sourcePos: SourcePosition,
3637
offsetParams: OffsetParams,
37-
adjustedPath: List[Tree]
38+
adjustedPath: List[Tree],
39+
wasCursorApplied: Boolean
3840
)(using Context): CompletionPos =
3941
val identEnd = adjustedPath match
4042
case (refTree: RefTree) :: _ if refTree.name.toString.contains(Cursor.value) =>
4143
refTree.span.end - Cursor.value.length
44+
case (refTree: RefTree) :: _ => refTree.span.end
4245
case _ => sourcePos.end
4346

4447
val query = Completion.completionPrefix(adjustedPath, sourcePos)
4548
val start = sourcePos.end - query.length()
4649

47-
CompletionPos(start, identEnd, query.nn, sourcePos, offsetParams.uri.nn)
50+
CompletionPos(start, identEnd, query.nn, sourcePos, offsetParams.uri.nn, wasCursorApplied)
4851

4952
/**
5053
* Infer the indentation by counting the number of spaces in the given line.

0 commit comments

Comments
 (0)