Skip to content

Commit

Permalink
feature: completions inside of backticks
Browse files Browse the repository at this point in the history
Scala 2 only - the [corresponding Scala 3 change][0] has already been
merged. Completions can now be triggered from inside of backticks.

In addition, the backtick itself is added as a completion character.
Especially when the editor is configured to auto-close backticks, this
plays nicely with completions now producing useful results inside of
backticks.

Closes [this feature request][1]

[0]: scala/scala3#22555
[1]: scalameta/metals-feature-requests#418
  • Loading branch information
harpocrates committed Feb 10, 2025
1 parent 34e995e commit 7cc0f9d
Show file tree
Hide file tree
Showing 4 changed files with 151 additions and 37 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,7 @@ class WorkspaceLspService(
capabilities.setCompletionProvider(
new lsp4j.CompletionOptions(
clientConfig.isCompletionItemResolve(),
List(".", "*", "$").asJava,
List(".", "*", "$", "`").asJava,
)
)
capabilities.setCallHierarchyProvider(true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,15 @@ class CompletionProvider(
val pos = unit.position(params.offset)
val isSnippet = isSnippetEnabled(pos, params.text())

val (i, completion, editRange, query) = safeCompletionsAt(pos, params.uri())

val start = inferIdentStart(pos, params.text())
val end = inferIdentEnd(pos, params.text())
val (i, completion, identOffsets, editRange, query) =
safeCompletionsAt(pos, params.uri())

val InferredIdentOffsets(
start,
end,
hadLeadingBacktick,
hadTrailingBacktick
) = identOffsets
val oldText = params.text().substring(start, end)
val stripSuffix = pos.withStart(start).withEnd(end).toLsp

Expand Down Expand Up @@ -275,7 +280,12 @@ class CompletionProvider(
if (item.getTextEdit == null) {
val editText = member match {
case _: NamedArgMember => item.getLabel
case _ => ident
case _ =>
val ident0 =
if (hadLeadingBacktick) ident.stripPrefix("`") else ident
val ident1 =
if (hadTrailingBacktick) ident0.stripSuffix("`") else ident0
ident1
}
item.setTextEdit(textEdit(editText))
}
Expand Down Expand Up @@ -502,9 +512,16 @@ class CompletionProvider(
private def safeCompletionsAt(
pos: Position,
source: URI
): (InterestingMembers, CompletionPosition, l.Range, String) = {
): (
InterestingMembers,
CompletionPosition,
InferredIdentOffsets,
l.Range,
String
) = {
lazy val inferredIdentOffsets = inferIdentOffsets(pos, params.text())
lazy val editRange = pos
.withStart(inferIdentStart(pos, params.text()))
.withStart(inferredIdentOffsets.start)
.withEnd(pos.point)
.toLsp
val noQuery = "$a"
Expand All @@ -522,6 +539,7 @@ class CompletionProvider(
(
InterestingMembers(Nil, SymbolSearch.Result.COMPLETE),
NoneCompletion,
inferredIdentOffsets,
editRange,
noQuery
)
Expand All @@ -532,6 +550,7 @@ class CompletionProvider(
SymbolSearch.Result.COMPLETE
),
completion,
inferredIdentOffsets,
editRange,
noQuery
)
Expand Down Expand Up @@ -579,7 +598,7 @@ class CompletionProvider(
params.text()
)
params.checkCanceled()
(items, completion, editRange, query)
(items, completion, inferredIdentOffsets, editRange, query)
} catch {
case e: CyclicReference
if e.getMessage.contains("illegal cyclic reference") =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -905,57 +905,93 @@ trait Completions { this: MetalsGlobal =>
result
}

def inferStart(
case class InferredIdentOffsets(
start: Int,
end: Int,
strippedLeadingBacktick: Boolean,
strippedTrailingBacktick: Boolean
)

def inferIdentOffsets(
pos: Position,
text: String,
charPred: Char => Boolean
): Int = {
def fallback: Int = {
text: String
): InferredIdentOffsets = {

// If we fail to find a tree, approximate with a heurstic about ident characters
def fallbackStart: Int = {
var i = pos.point - 1
while (i >= 0 && charPred(text.charAt(i))) {
while (i >= 0 && Chars.isIdentifierPart(text.charAt(i))) {
i -= 1
}
i + 1
}
def loop(enclosing: List[Tree]): Int =
def fallbackEnd: Int = {
findEnd(false)
}

def findEnd(hasBacktick: Boolean): Int = {
val predicate: Char => Boolean = if (hasBacktick) { (ch: Char) =>
!Chars.isLineBreakChar(ch) && ch != '`'
} else {
Chars.isIdentifierPart(_)
}

var i = pos.point
while (i < text.length && predicate(text.charAt(i))) {
i += 1
}
i
}
def fallback =
InferredIdentOffsets(fallbackStart, fallbackEnd, false, false)

def refTreePos(refTree: RefTree): InferredIdentOffsets = {
val refTreePos = treePos(refTree)
var startPos = refTreePos.point
var strippedLeadingBacktick = false
if (text.charAt(startPos) == '`') {
startPos += 1
strippedLeadingBacktick = true
}

val endPos = findEnd(strippedLeadingBacktick)
var strippedTrailingBacktick = false
if (endPos < text.length) {
if (text.charAt(endPos) == '`') {
strippedTrailingBacktick = true
}
}
InferredIdentOffsets(
Math.min(startPos, pos.point),
endPos,
strippedLeadingBacktick,
strippedTrailingBacktick
)
}

def loop(enclosing: List[Tree]): InferredIdentOffsets =
enclosing match {
case Nil => fallback
case head :: tl =>
if (!treePos(head).includes(pos)) loop(tl)
else {
head match {
case i: Ident =>
treePos(i).point
case Select(qual, _) if !treePos(qual).includes(pos) =>
treePos(head).point
refTreePos(i)
case sel @ Select(qual, _) if !treePos(qual).includes(pos) =>
refTreePos(sel)
case _ => fallback
}
}
}
val start = loop(lastVisitedParentTrees)
Math.min(start, pos.point)
loop(lastVisitedParentTrees)
}

/** Can character form part of an alphanumeric Scala identifier? */
private def isIdentifierPart(c: Char) =
(c == '$') || Character.isUnicodeIdentifierPart(c)

/**
* Returns the start offset of the identifier starting as the given offset position.
*/
def inferIdentStart(pos: Position, text: String): Int =
inferStart(pos, text, isIdentifierPart)

/**
* Returns the end offset of the identifier starting as the given offset position.
*/
def inferIdentEnd(pos: Position, text: String): Int = {
var i = pos.point
while (i < text.length && Chars.isIdentifierPart(text.charAt(i))) {
i += 1
}
i
}
inferIdentOffsets(pos, text).start

def isSnippetEnabled(pos: Position, text: String): Boolean = {
pos.point < text.length() && {
Expand Down
59 changes: 59 additions & 0 deletions tests/cross/src/test/scala/tests/pc/CompletionSuite.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2091,4 +2091,63 @@ class CompletionSuite extends BaseCompletionSuite {
}
)

val BacktickCompletionsTag =
IgnoreScala3.and(IgnoreScalaVersion.forLessThan("2.13.17"))

checkEdit(
"add-backticks-around-identifier".tag(BacktickCompletionsTag),
"""|object Main {
| def `Foo Bar` = 123
| Foo@@
|}
|""".stripMargin,
"""|object Main {
| def `Foo Bar` = 123
| `Foo Bar`
|}
|""".stripMargin
)

checkEdit(
"complete-inside-backticks".tag(BacktickCompletionsTag),
"""|object Main {
| def `Foo Bar` = 123
| `Foo@@`
|}
|""".stripMargin,
"""|object Main {
| def `Foo Bar` = 123
| `Foo Bar`
|}
|""".stripMargin
)

checkEdit(
"complete-inside-backticks-after-space".tag(BacktickCompletionsTag),
"""|object Main {
| def `Foo Bar` = 123
| `Foo B@@`
|}
|""".stripMargin,
"""|object Main {
| def `Foo Bar` = 123
| `Foo Bar`
|}
|""".stripMargin
)

checkEdit(
"complete-inside-empty-backticks".tag(BacktickCompletionsTag),
"""|object Main {
| def `Foo Bar` = 123
| `@@`
|}
|""".stripMargin,
"""|object Main {
| def `Foo Bar` = 123
| `Foo Bar`
|}
|""".stripMargin,
filter = _ == "`Foo Bar`: Int"
)
}

0 comments on commit 7cc0f9d

Please sign in to comment.