Skip to content

Commit 449b189

Browse files
committed
feat: implement 'convert to named lambda parameters' code action
1 parent a482647 commit 449b189

File tree

11 files changed

+548
-1
lines changed

11 files changed

+548
-1
lines changed

metals/src/main/scala/scala/meta/internal/metals/Compilers.scala

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,20 @@ class Compilers(
918918
}
919919
}.getOrElse(Future.successful(Nil.asJava))
920920

921+
def convertToNamedLambdaParameters(
922+
position: TextDocumentPositionParams,
923+
token: CancelToken,
924+
): Future[ju.List[TextEdit]] = {
925+
withPCAndAdjustLsp(position) { (pc, pos, adjust) =>
926+
pc.convertToNamedLambdaParameters(
927+
CompilerOffsetParamsUtils.fromPos(pos, token)
928+
).asScala
929+
.map { edits =>
930+
adjust.adjustTextEdits(edits)
931+
}
932+
}
933+
}.getOrElse(Future.successful(Nil.asJava))
934+
921935
def implementAbstractMembers(
922936
params: TextDocumentPositionParams,
923937
token: CancelToken,

metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -662,6 +662,20 @@ object ServerCommands {
662662
|""".stripMargin,
663663
)
664664

665+
final case class ConvertToNamedLambdaParametersRequest(
666+
position: TextDocumentPositionParams
667+
)
668+
val ConvertToNamedLambdaParameters =
669+
new ParametrizedCommand[ConvertToNamedLambdaParametersRequest](
670+
"convert-to-named-lambda-parameters",
671+
"Convert wildcard lambda parameters to named parameters",
672+
"""|Whenever a user chooses code action to convert to named lambda parameters, this command is later run to
673+
|rewrite the lambda to use named parameters.
674+
|""".stripMargin,
675+
"""|Object with [TextDocumentPositionParams](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentPositionParams) of the target lambda
676+
|""".stripMargin,
677+
)
678+
665679
val GotoLog = new Command(
666680
"goto-log",
667681
"Check logs",

metals/src/main/scala/scala/meta/internal/metals/codeactions/CodeActionProvider.scala

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ final class CodeActionProvider(
4747
new MillifyDependencyCodeAction(buffers),
4848
new MillifyScalaCliDependencyCodeAction(buffers),
4949
new ConvertCommentCodeAction(buffers),
50+
new ConvertToNamedLambdaParameters(trees, compilers, languageClient),
5051
)
5152

5253
def actionsForParams(params: l.CodeActionParams): List[CodeAction] = {
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package scala.meta.internal.metals.codeactions
2+
3+
import scala.concurrent.ExecutionContext
4+
import scala.concurrent.Future
5+
6+
import scala.meta.Term
7+
import scala.meta.internal.metals.Compilers
8+
import scala.meta.internal.metals.MetalsEnrichments._
9+
import scala.meta.internal.metals.ServerCommands
10+
import scala.meta.internal.metals.clients.language.MetalsLanguageClient
11+
import scala.meta.internal.metals.codeactions.CodeAction
12+
import scala.meta.internal.metals.codeactions.CodeActionBuilder
13+
import scala.meta.internal.metals.logging
14+
import scala.meta.internal.parsing.Trees
15+
import scala.meta.pc.CancelToken
16+
17+
import org.eclipse.{lsp4j => l}
18+
19+
/**
20+
* Code action to convert a wildcard lambda to a lambda with named parameters
21+
* e.g.
22+
*
23+
* List(1, 2).map(<<_>> + 1) => List(1, 2).map(i => i + 1)
24+
*/
25+
class ConvertToNamedLambdaParameters(
26+
trees: Trees,
27+
compilers: Compilers,
28+
languageClient: MetalsLanguageClient,
29+
) extends CodeAction {
30+
31+
override val kind: String = l.CodeActionKind.RefactorRewrite
32+
33+
override type CommandData =
34+
ServerCommands.ConvertToNamedLambdaParametersRequest
35+
36+
override def command: Option[ActionCommand] = Some(
37+
ServerCommands.ConvertToNamedLambdaParameters
38+
)
39+
40+
override def handleCommand(
41+
data: ServerCommands.ConvertToNamedLambdaParametersRequest,
42+
token: CancelToken,
43+
)(implicit ec: ExecutionContext): Future[Unit] = {
44+
val uri = data.position.getTextDocument().getUri()
45+
for {
46+
edits <- compilers.convertToNamedLambdaParameters(
47+
data.position,
48+
token,
49+
)
50+
_ = logging.logErrorWhen(
51+
edits.isEmpty(),
52+
s"Could not convert lambda at position ${data.position} to named lambda",
53+
)
54+
workspaceEdit = new l.WorkspaceEdit(Map(uri -> edits).asJava)
55+
_ <- languageClient
56+
.applyEdit(new l.ApplyWorkspaceEditParams(workspaceEdit))
57+
.asScala
58+
} yield ()
59+
}
60+
61+
override def contribute(
62+
params: l.CodeActionParams,
63+
token: CancelToken,
64+
)(implicit ec: ExecutionContext): Future[Seq[l.CodeAction]] = {
65+
val path = params.getTextDocument().getUri().toAbsolutePath
66+
val range = params.getRange()
67+
val maybeLambda =
68+
trees.findLastEnclosingAt[Term.AnonymousFunction](path, range.getStart())
69+
maybeLambda
70+
.map { lambda =>
71+
val position = new l.TextDocumentPositionParams(
72+
params.getTextDocument(),
73+
new l.Position(lambda.pos.startLine, lambda.pos.startColumn),
74+
)
75+
val command =
76+
ServerCommands.ConvertToNamedLambdaParameters.toLsp(
77+
ServerCommands.ConvertToNamedLambdaParametersRequest(position)
78+
)
79+
val codeAction = CodeActionBuilder.build(
80+
title = ConvertToNamedLambdaParameters.title,
81+
kind = kind,
82+
command = Some(command),
83+
)
84+
Future.successful(Seq(codeAction))
85+
}
86+
.getOrElse(Future.successful(Nil))
87+
}
88+
89+
}
90+
91+
object ConvertToNamedLambdaParameters {
92+
def title: String = "Convert to named lambda parameters"
93+
}

mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,15 @@ public CompletableFuture<List<TextEdit>> inlineValue(OffsetParams params) {
186186
public abstract CompletableFuture<List<TextEdit>> convertToNamedArguments(OffsetParams params,
187187
List<Integer> argIndices);
188188

189+
/**
190+
* Return the text edits for converting a wildcard lambda to a named lambda.
191+
*/
192+
public CompletableFuture<List<TextEdit>> convertToNamedLambdaParameters(OffsetParams params) {
193+
return CompletableFuture.supplyAsync(() -> {
194+
throw new DisplayableException("Convert to named lambda parameters is not available in this version of Scala");
195+
});
196+
};
197+
189198
/**
190199
* The text contents of the given file changed.
191200
*/
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
package scala.meta.internal.mtags
2+
3+
/**
4+
* Helpers for generating variable names based on the desired types.
5+
*/
6+
object TermNameInference {
7+
8+
/** Single character names for types. (`Int` => `i`, `i1`, `i2`, ...) */
9+
def singleLetterNameStream(typeName: String): LazyList[String] = {
10+
val typeName1 = sanitizeInput(typeName)
11+
val firstCharStr = typeName1.headOption.getOrElse('x').toLower.toString
12+
numberedStreamFromName(firstCharStr)
13+
}
14+
15+
/** Names only from upper case letters (`OnDemandSymbolIndex` => `odsi`, `odsi1`, `odsi2`, ...) */
16+
def shortNameStream(typeName: String): LazyList[String] = {
17+
val typeName1 = sanitizeInput(typeName)
18+
val upperCases = typeName1.filter(_.isUpper).map(_.toLower)
19+
val name = if (upperCases.isEmpty) typeName1 else upperCases
20+
numberedStreamFromName(name)
21+
}
22+
23+
/** Names from lower case letters (`OnDemandSymbolIndex` => `onDemandSymbolIndex`, `onDemandSymbolIndex1`, ...) */
24+
def fullNameStream(typeName: String): LazyList[String] = {
25+
val typeName1 = sanitizeInput(typeName)
26+
val withFirstLower =
27+
typeName1.headOption.map(_.toLower).getOrElse('x') + typeName1.drop(1)
28+
numberedStreamFromName(withFirstLower)
29+
}
30+
31+
/** A lazy list of names: a, b, ..., z, aa, ab, ..., az, ba, bb, ... */
32+
def saneNamesStream: LazyList[String] = {
33+
val letters = ('a' to 'z').map(_.toString)
34+
def computeNext(acc: String): String = {
35+
if (acc.last == 'z')
36+
computeNext(acc.init) + letters.head
37+
else
38+
acc.init + letters(letters.indexOf(acc.last) + 1)
39+
}
40+
def loop(acc: String): LazyList[String] =
41+
acc #:: loop(computeNext(acc))
42+
loop("a")
43+
}
44+
45+
private def sanitizeInput(typeName: String): String =
46+
typeName.filter(_.isLetterOrDigit)
47+
48+
private def numberedStreamFromName(name: String): LazyList[String] = {
49+
val rest = LazyList.from(1).map(name + _)
50+
name #:: rest
51+
}
52+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package scala.meta.internal.pc
2+
3+
import java.nio.file.Paths
4+
5+
import scala.meta.internal.mtags.MtagsEnrichments.*
6+
import scala.meta.internal.mtags.TermNameInference.*
7+
import scala.meta.pc.OffsetParams
8+
9+
import dotty.tools.dotc.ast.tpd
10+
import dotty.tools.dotc.core.Contexts.Context
11+
import dotty.tools.dotc.core.Flags
12+
import dotty.tools.dotc.interactive.Interactive
13+
import dotty.tools.dotc.interactive.InteractiveDriver
14+
import dotty.tools.dotc.util.SourceFile
15+
import dotty.tools.dotc.util.SourcePosition
16+
import org.eclipse.lsp4j as l
17+
18+
/**
19+
* Facilitates the code action that converts a wildcard lambda to a lambda with named parameters
20+
* e.g.
21+
*
22+
* List(1, 2).map(<<_>> + 1) => List(1, 2).map(i => i + 1)
23+
*/
24+
final class ConvertToNamedLambdaParametersProvider(
25+
driver: InteractiveDriver,
26+
params: OffsetParams
27+
):
28+
import ConvertToNamedLambdaParametersProvider._
29+
30+
def convertToNamedLambdaParameters: Either[String, List[l.TextEdit]] = {
31+
val uri = params.uri
32+
val filePath = Paths.get(uri)
33+
driver.run(
34+
uri,
35+
SourceFile.virtual(filePath.toString, params.text),
36+
)
37+
val unit = driver.latestRun
38+
given newctx: Context = driver.currentCtx.fresh.setCompilationUnit(unit)
39+
val pos = driver.sourcePosition(params)
40+
val trees = driver.openedTrees(uri)
41+
val treeList = Interactive.pathTo(trees, pos)
42+
// Extractor for a lambda function (needs context, so has to be defined here)
43+
val LambdaExtractor = Lambda(using newctx)
44+
// select the most inner wildcard lambda
45+
val firstLambda = treeList.collectFirst {
46+
case LambdaExtractor(params, rhsFn) if params.forall(isWildcardParam) =>
47+
params -> rhsFn
48+
}
49+
50+
firstLambda match {
51+
case Some((params, lambda)) =>
52+
// avoid names that are either defined or referenced in the lambda
53+
val namesToAvoid = allDefAndRefNamesInTree(lambda)
54+
// compute parameter names based on the type of the parameter
55+
val computedParamNames: List[String] =
56+
params.foldLeft(List.empty[String]) { (acc, param) =>
57+
val name = singleLetterNameStream(param.tpe.typeSymbol.name.toString())
58+
.find(n => !namesToAvoid.contains(n) && !acc.contains(n))
59+
acc ++ name.toList
60+
}
61+
if computedParamNames.size == params.size then
62+
val paramReferenceEdits = params.zip(computedParamNames).flatMap { (param, paramName) =>
63+
val paramReferencePosition = findParamReferencePosition(param, lambda)
64+
paramReferencePosition.toList.map { pos =>
65+
val position = pos.toLsp
66+
val range = new l.Range(
67+
position.getStart(),
68+
position.getEnd()
69+
)
70+
new l.TextEdit(range, paramName)
71+
}
72+
}
73+
val paramNamesStr = computedParamNames.mkString(", ")
74+
val paramDefsStr =
75+
if params.size == 1 then paramNamesStr
76+
else s"($paramNamesStr)"
77+
val defRange = new l.Range(
78+
lambda.sourcePos.toLsp.getStart(),
79+
lambda.sourcePos.toLsp.getStart()
80+
)
81+
val paramDefinitionEdits = List(
82+
new l.TextEdit(defRange, s"$paramDefsStr => ")
83+
)
84+
Right(paramDefinitionEdits ++ paramReferenceEdits)
85+
else
86+
Right(Nil)
87+
case _ =>
88+
Right(Nil)
89+
}
90+
}
91+
92+
end ConvertToNamedLambdaParametersProvider
93+
94+
object ConvertToNamedLambdaParametersProvider:
95+
class Lambda(using Context):
96+
def unapply(tree: tpd.Block): Option[(List[tpd.ValDef], tpd.Tree)] = tree match {
97+
case tpd.Block((ddef @ tpd.DefDef(_, tpd.ValDefs(params) :: Nil, _, body: tpd.Tree)) :: Nil, tpd.Closure(_, meth, _))
98+
if ddef.symbol == meth.symbol =>
99+
params match {
100+
case List(param) =>
101+
// lambdas with multiple wildcard parameters are represented as a single parameter function and a block with wildcard valdefs
102+
Some(multipleUnderscoresFromBody(param, body))
103+
case _ => Some(params -> body)
104+
}
105+
case _ => None
106+
}
107+
end Lambda
108+
109+
private def multipleUnderscoresFromBody(param: tpd.ValDef, body: tpd.Tree)(using Context): (List[tpd.ValDef], tpd.Tree) = body match {
110+
case tpd.Block(defs, expr) if param.symbol.is(Flags.Synthetic) =>
111+
val wildcardParamDefs = defs.collect {
112+
case valdef: tpd.ValDef if isWildcardParam(valdef) => valdef
113+
}
114+
if wildcardParamDefs.size == defs.size then wildcardParamDefs -> expr
115+
else List(param) -> body
116+
case _ => List(param) -> body
117+
}
118+
119+
def isWildcardParam(param: tpd.ValDef)(using Context): Boolean =
120+
param.name.toString.startsWith("_$") && param.symbol.is(Flags.Synthetic)
121+
122+
def findParamReferencePosition(param: tpd.ValDef, lambda: tpd.Tree)(using Context): Option[SourcePosition] =
123+
var pos: Option[SourcePosition] = None
124+
object FindParamReference extends tpd.TreeTraverser:
125+
override def traverse(tree: tpd.Tree)(using Context): Unit =
126+
tree match
127+
case ident @ tpd.Ident(_) if ident.symbol == param.symbol =>
128+
pos = Some(tree.sourcePos)
129+
case _ =>
130+
traverseChildren(tree)
131+
FindParamReference.traverse(lambda)
132+
pos
133+
end findParamReferencePosition
134+
135+
def allDefAndRefNamesInTree(tree: tpd.Tree)(using Context): List[String] =
136+
object FindDefinitionsAndRefs extends tpd.TreeAccumulator[List[String]]:
137+
override def apply(x: List[String], tree: tpd.Tree)(using Context): List[String] =
138+
tree match
139+
case tpd.DefDef(name, _, _, _) =>
140+
super.foldOver(x :+ name.toString, tree)
141+
case tpd.ValDef(name, _, _) =>
142+
super.foldOver(x :+ name.toString, tree)
143+
case tpd.Ident(name) =>
144+
super.foldOver(x :+ name.toString, tree)
145+
case _ =>
146+
super.foldOver(x, tree)
147+
FindDefinitionsAndRefs.foldOver(Nil, tree)
148+
end allDefAndRefNamesInTree
149+
150+
end ConvertToNamedLambdaParametersProvider

mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,23 @@ case class ScalaPresentationCompiler(
364364
}
365365
end convertToNamedArguments
366366

367+
override def convertToNamedLambdaParameters(
368+
params: OffsetParams
369+
): ju.concurrent.CompletableFuture[ju.List[l.TextEdit]] =
370+
val empty: Either[String, List[l.TextEdit]] = Right(List())
371+
(compilerAccess
372+
.withInterruptableCompiler(Some(params))(empty, params.token) { pc =>
373+
new ConvertToNamedLambdaParametersProvider(
374+
pc.compiler(),
375+
params
376+
).convertToNamedLambdaParameters
377+
})
378+
.thenApplyAsync {
379+
case Left(error: String) => throw new DisplayableException(error)
380+
case Right(edits: List[l.TextEdit]) => edits.asJava
381+
}
382+
end convertToNamedLambdaParameters
383+
367384
override def selectionRange(
368385
params: ju.List[OffsetParams]
369386
): CompletableFuture[ju.List[l.SelectionRange]] =

tests/cross/src/main/scala/tests/BaseExtractMethodSuite.scala

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import scala.meta.internal.metals.TextEdits
1010
import munit.Location
1111
import munit.TestOptions
1212
import org.eclipse.{lsp4j => l}
13-
import tests.BaseCodeActionSuite
1413

1514
class BaseExtractMethodSuite extends BaseCodeActionSuite {
1615
def checkEdit(

0 commit comments

Comments
 (0)