diff --git a/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala b/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala index 842390e9cd1..d7bce2d8bb0 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala @@ -918,6 +918,20 @@ class Compilers( } }.getOrElse(Future.successful(Nil.asJava)) + def convertToNamedLambdaParameters( + position: TextDocumentPositionParams, + token: CancelToken, + ): Future[ju.List[TextEdit]] = { + withPCAndAdjustLsp(position) { (pc, pos, adjust) => + pc.convertToNamedLambdaParameters( + CompilerOffsetParamsUtils.fromPos(pos, token) + ).asScala + .map { edits => + adjust.adjustTextEdits(edits) + } + } + }.getOrElse(Future.successful(Nil.asJava)) + def implementAbstractMembers( params: TextDocumentPositionParams, token: CancelToken, diff --git a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala index 6b0c84d06b4..da11a7246c1 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/ServerCommands.scala @@ -662,6 +662,20 @@ object ServerCommands { |""".stripMargin, ) + final case class ConvertToNamedLambdaParametersRequest( + position: TextDocumentPositionParams + ) + val ConvertToNamedLambdaParameters = + new ParametrizedCommand[ConvertToNamedLambdaParametersRequest]( + "convert-to-named-lambda-parameters", + "Convert wildcard lambda parameters to named parameters", + """|Whenever a user chooses code action to convert to named lambda parameters, this command is later run to + |rewrite the lambda to use named parameters. + |""".stripMargin, + """|Object with [TextDocumentPositionParams](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#textDocumentPositionParams) of the target lambda + |""".stripMargin, + ) + val GotoLog = new Command( "goto-log", "Check logs", diff --git a/metals/src/main/scala/scala/meta/internal/metals/codeactions/CodeActionProvider.scala b/metals/src/main/scala/scala/meta/internal/metals/codeactions/CodeActionProvider.scala index c084717014a..2b2384562d0 100644 --- a/metals/src/main/scala/scala/meta/internal/metals/codeactions/CodeActionProvider.scala +++ b/metals/src/main/scala/scala/meta/internal/metals/codeactions/CodeActionProvider.scala @@ -47,6 +47,7 @@ final class CodeActionProvider( new MillifyDependencyCodeAction(buffers), new MillifyScalaCliDependencyCodeAction(buffers), new ConvertCommentCodeAction(buffers), + new ConvertToNamedLambdaParameters(trees, compilers, languageClient), ) def actionsForParams(params: l.CodeActionParams): List[CodeAction] = { diff --git a/metals/src/main/scala/scala/meta/internal/metals/codeactions/ConvertToNamedLambdaParameters.scala b/metals/src/main/scala/scala/meta/internal/metals/codeactions/ConvertToNamedLambdaParameters.scala new file mode 100644 index 00000000000..a166b7f53a0 --- /dev/null +++ b/metals/src/main/scala/scala/meta/internal/metals/codeactions/ConvertToNamedLambdaParameters.scala @@ -0,0 +1,93 @@ +package scala.meta.internal.metals.codeactions + +import scala.concurrent.ExecutionContext +import scala.concurrent.Future + +import scala.meta.Term +import scala.meta.internal.metals.Compilers +import scala.meta.internal.metals.MetalsEnrichments._ +import scala.meta.internal.metals.ServerCommands +import scala.meta.internal.metals.clients.language.MetalsLanguageClient +import scala.meta.internal.metals.codeactions.CodeAction +import scala.meta.internal.metals.codeactions.CodeActionBuilder +import scala.meta.internal.metals.logging +import scala.meta.internal.parsing.Trees +import scala.meta.pc.CancelToken + +import org.eclipse.{lsp4j => l} + +/** + * Code action to convert a wildcard lambda to a lambda with named parameters + * e.g. + * + * List(1, 2).map(<<_>> + 1) => List(1, 2).map(i => i + 1) + */ +class ConvertToNamedLambdaParameters( + trees: Trees, + compilers: Compilers, + languageClient: MetalsLanguageClient, +) extends CodeAction { + + override val kind: String = l.CodeActionKind.RefactorRewrite + + override type CommandData = + ServerCommands.ConvertToNamedLambdaParametersRequest + + override def command: Option[ActionCommand] = Some( + ServerCommands.ConvertToNamedLambdaParameters + ) + + override def handleCommand( + data: ServerCommands.ConvertToNamedLambdaParametersRequest, + token: CancelToken, + )(implicit ec: ExecutionContext): Future[Unit] = { + val uri = data.position.getTextDocument().getUri() + for { + edits <- compilers.convertToNamedLambdaParameters( + data.position, + token, + ) + _ = logging.logErrorWhen( + edits.isEmpty(), + s"Could not convert lambda at position ${data.position} to named lambda", + ) + workspaceEdit = new l.WorkspaceEdit(Map(uri -> edits).asJava) + _ <- languageClient + .applyEdit(new l.ApplyWorkspaceEditParams(workspaceEdit)) + .asScala + } yield () + } + + override def contribute( + params: l.CodeActionParams, + token: CancelToken, + )(implicit ec: ExecutionContext): Future[Seq[l.CodeAction]] = { + val path = params.getTextDocument().getUri().toAbsolutePath + val range = params.getRange() + val maybeLambda = + trees.findLastEnclosingAt[Term.AnonymousFunction](path, range.getStart()) + maybeLambda + .map { lambda => + val position = new l.TextDocumentPositionParams( + params.getTextDocument(), + new l.Position(lambda.pos.startLine, lambda.pos.startColumn), + ) + val command = + ServerCommands.ConvertToNamedLambdaParameters.toLsp( + ServerCommands.ConvertToNamedLambdaParametersRequest(position) + ) + val codeAction = CodeActionBuilder.build( + title = ConvertToNamedLambdaParameters.title, + kind = kind, + command = Some(command), + ) + Future.successful(Seq(codeAction)) + } + .getOrElse(Future.successful(Nil)) + } + +} + +object ConvertToNamedLambdaParameters { + def title: String = "Convert to named lambda parameters" +} diff --git a/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java b/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java index efeb8bbf737..fc65a5f5dba 100644 --- a/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java +++ b/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java @@ -186,6 +186,15 @@ public CompletableFuture> inlineValue(OffsetParams params) { public abstract CompletableFuture> convertToNamedArguments(OffsetParams params, List argIndices); + /** + * Return the text edits for converting a wildcard lambda to a named lambda. + */ + public CompletableFuture> convertToNamedLambdaParameters(OffsetParams params) { + return CompletableFuture.supplyAsync(() -> { + throw new DisplayableException("Convert to named lambda parameters is not available in this version of Scala"); + }); + }; + /** * The text contents of the given file changed. */ diff --git a/mtags/src/main/scala-3/scala/meta/internal/mtags/TermNameInference.scala b/mtags/src/main/scala-3/scala/meta/internal/mtags/TermNameInference.scala new file mode 100644 index 00000000000..a611b6204e9 --- /dev/null +++ b/mtags/src/main/scala-3/scala/meta/internal/mtags/TermNameInference.scala @@ -0,0 +1,52 @@ +package scala.meta.internal.mtags + +/** + * Helpers for generating variable names based on the desired types. + */ +object TermNameInference { + + /** Single character names for types. (`Int` => `i`, `i1`, `i2`, ...) */ + def singleLetterNameStream(typeName: String): LazyList[String] = { + val typeName1 = sanitizeInput(typeName) + val firstCharStr = typeName1.headOption.getOrElse('x').toLower.toString + numberedStreamFromName(firstCharStr) + } + + /** Names only from upper case letters (`OnDemandSymbolIndex` => `odsi`, `odsi1`, `odsi2`, ...) */ + def shortNameStream(typeName: String): LazyList[String] = { + val typeName1 = sanitizeInput(typeName) + val upperCases = typeName1.filter(_.isUpper).map(_.toLower) + val name = if (upperCases.isEmpty) typeName1 else upperCases + numberedStreamFromName(name) + } + + /** Names from lower case letters (`OnDemandSymbolIndex` => `onDemandSymbolIndex`, `onDemandSymbolIndex1`, ...) */ + def fullNameStream(typeName: String): LazyList[String] = { + val typeName1 = sanitizeInput(typeName) + val withFirstLower = + typeName1.headOption.map(_.toLower).getOrElse('x') + typeName1.drop(1) + numberedStreamFromName(withFirstLower) + } + + /** A lazy list of names: a, b, ..., z, aa, ab, ..., az, ba, bb, ... */ + def saneNamesStream: LazyList[String] = { + val letters = ('a' to 'z').map(_.toString) + def computeNext(acc: String): String = { + if (acc.last == 'z') + computeNext(acc.init) + letters.head + else + acc.init + letters(letters.indexOf(acc.last) + 1) + } + def loop(acc: String): LazyList[String] = + acc #:: loop(computeNext(acc)) + loop("a") + } + + private def sanitizeInput(typeName: String): String = + typeName.filter(_.isLetterOrDigit) + + private def numberedStreamFromName(name: String): LazyList[String] = { + val rest = LazyList.from(1).map(name + _) + name #:: rest + } +} diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/ConvertToNamedLambdaParametersProvider.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/ConvertToNamedLambdaParametersProvider.scala new file mode 100644 index 00000000000..ecc2796cf72 --- /dev/null +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/ConvertToNamedLambdaParametersProvider.scala @@ -0,0 +1,150 @@ +package scala.meta.internal.pc + +import java.nio.file.Paths + +import scala.meta.internal.mtags.MtagsEnrichments.* +import scala.meta.internal.mtags.TermNameInference.* +import scala.meta.pc.OffsetParams + +import dotty.tools.dotc.ast.tpd +import dotty.tools.dotc.core.Contexts.Context +import dotty.tools.dotc.core.Flags +import dotty.tools.dotc.interactive.Interactive +import dotty.tools.dotc.interactive.InteractiveDriver +import dotty.tools.dotc.util.SourceFile +import dotty.tools.dotc.util.SourcePosition +import org.eclipse.lsp4j as l + +/** + * Facilitates the code action that converts a wildcard lambda to a lambda with named parameters + * e.g. + * + * List(1, 2).map(<<_>> + 1) => List(1, 2).map(i => i + 1) + */ +final class ConvertToNamedLambdaParametersProvider( + driver: InteractiveDriver, + params: OffsetParams +): + import ConvertToNamedLambdaParametersProvider._ + + def convertToNamedLambdaParameters: Either[String, List[l.TextEdit]] = { + val uri = params.uri + val filePath = Paths.get(uri) + driver.run( + uri, + SourceFile.virtual(filePath.toString, params.text), + ) + val unit = driver.latestRun + given newctx: Context = driver.currentCtx.fresh.setCompilationUnit(unit) + val pos = driver.sourcePosition(params) + val trees = driver.openedTrees(uri) + val treeList = Interactive.pathTo(trees, pos) + // Extractor for a lambda function (needs context, so has to be defined here) + val LambdaExtractor = Lambda(using newctx) + // select the most inner wildcard lambda + val firstLambda = treeList.collectFirst { + case LambdaExtractor(params, rhsFn) if params.forall(isWildcardParam) => + params -> rhsFn + } + + firstLambda match { + case Some((params, lambda)) => + // avoid names that are either defined or referenced in the lambda + val namesToAvoid = allDefAndRefNamesInTree(lambda) + // compute parameter names based on the type of the parameter + val computedParamNames: List[String] = + params.foldLeft(List.empty[String]) { (acc, param) => + val name = singleLetterNameStream(param.tpe.typeSymbol.name.toString()) + .find(n => !namesToAvoid.contains(n) && !acc.contains(n)) + acc ++ name.toList + } + if computedParamNames.size == params.size then + val paramReferenceEdits = params.zip(computedParamNames).flatMap { (param, paramName) => + val paramReferencePosition = findParamReferencePosition(param, lambda) + paramReferencePosition.toList.map { pos => + val position = pos.toLsp + val range = new l.Range( + position.getStart(), + position.getEnd() + ) + new l.TextEdit(range, paramName) + } + } + val paramNamesStr = computedParamNames.mkString(", ") + val paramDefsStr = + if params.size == 1 then paramNamesStr + else s"($paramNamesStr)" + val defRange = new l.Range( + lambda.sourcePos.toLsp.getStart(), + lambda.sourcePos.toLsp.getStart() + ) + val paramDefinitionEdits = List( + new l.TextEdit(defRange, s"$paramDefsStr => ") + ) + Right(paramDefinitionEdits ++ paramReferenceEdits) + else + Right(Nil) + case _ => + Right(Nil) + } + } + +end ConvertToNamedLambdaParametersProvider + +object ConvertToNamedLambdaParametersProvider: + class Lambda(using Context): + def unapply(tree: tpd.Block): Option[(List[tpd.ValDef], tpd.Tree)] = tree match { + case tpd.Block((ddef @ tpd.DefDef(_, tpd.ValDefs(params) :: Nil, _, body: tpd.Tree)) :: Nil, tpd.Closure(_, meth, _)) + if ddef.symbol == meth.symbol => + params match { + case List(param) => + // lambdas with multiple wildcard parameters are represented as a single parameter function and a block with wildcard valdefs + Some(multipleUnderscoresFromBody(param, body)) + case _ => Some(params -> body) + } + case _ => None + } + end Lambda + + private def multipleUnderscoresFromBody(param: tpd.ValDef, body: tpd.Tree)(using Context): (List[tpd.ValDef], tpd.Tree) = body match { + case tpd.Block(defs, expr) if param.symbol.is(Flags.Synthetic) => + val wildcardParamDefs = defs.collect { + case valdef: tpd.ValDef if isWildcardParam(valdef) => valdef + } + if wildcardParamDefs.size == defs.size then wildcardParamDefs -> expr + else List(param) -> body + case _ => List(param) -> body + } + + def isWildcardParam(param: tpd.ValDef)(using Context): Boolean = + param.name.toString.startsWith("_$") && param.symbol.is(Flags.Synthetic) + + def findParamReferencePosition(param: tpd.ValDef, lambda: tpd.Tree)(using Context): Option[SourcePosition] = + var pos: Option[SourcePosition] = None + object FindParamReference extends tpd.TreeTraverser: + override def traverse(tree: tpd.Tree)(using Context): Unit = + tree match + case ident @ tpd.Ident(_) if ident.symbol == param.symbol => + pos = Some(tree.sourcePos) + case _ => + traverseChildren(tree) + FindParamReference.traverse(lambda) + pos + end findParamReferencePosition + + def allDefAndRefNamesInTree(tree: tpd.Tree)(using Context): List[String] = + object FindDefinitionsAndRefs extends tpd.TreeAccumulator[List[String]]: + override def apply(x: List[String], tree: tpd.Tree)(using Context): List[String] = + tree match + case tpd.DefDef(name, _, _, _) => + super.foldOver(x :+ name.toString, tree) + case tpd.ValDef(name, _, _) => + super.foldOver(x :+ name.toString, tree) + case tpd.Ident(name) => + super.foldOver(x :+ name.toString, tree) + case _ => + super.foldOver(x, tree) + FindDefinitionsAndRefs.foldOver(Nil, tree) + end allDefAndRefNamesInTree + +end ConvertToNamedLambdaParametersProvider diff --git a/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala b/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala index 41ecb44f786..befe6d9ae86 100644 --- a/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala +++ b/mtags/src/main/scala-3/scala/meta/internal/pc/ScalaPresentationCompiler.scala @@ -364,6 +364,23 @@ case class ScalaPresentationCompiler( } end convertToNamedArguments + override def convertToNamedLambdaParameters( + params: OffsetParams + ): ju.concurrent.CompletableFuture[ju.List[l.TextEdit]] = + val empty: Either[String, List[l.TextEdit]] = Right(List()) + (compilerAccess + .withInterruptableCompiler(Some(params))(empty, params.token) { pc => + new ConvertToNamedLambdaParametersProvider( + pc.compiler(), + params + ).convertToNamedLambdaParameters + }) + .thenApplyAsync { + case Left(error: String) => throw new DisplayableException(error) + case Right(edits: List[l.TextEdit]) => edits.asJava + } + end convertToNamedLambdaParameters + override def selectionRange( params: ju.List[OffsetParams] ): CompletableFuture[ju.List[l.SelectionRange]] = diff --git a/tests/cross/src/main/scala/tests/BaseExtractMethodSuite.scala b/tests/cross/src/main/scala/tests/BaseExtractMethodSuite.scala index 480bc0125f1..320ca0e3471 100644 --- a/tests/cross/src/main/scala/tests/BaseExtractMethodSuite.scala +++ b/tests/cross/src/main/scala/tests/BaseExtractMethodSuite.scala @@ -10,7 +10,6 @@ import scala.meta.internal.metals.TextEdits import munit.Location import munit.TestOptions import org.eclipse.{lsp4j => l} -import tests.BaseCodeActionSuite class BaseExtractMethodSuite extends BaseCodeActionSuite { def checkEdit( diff --git a/tests/cross/src/test/scala/tests/pc/ConvertToNamedLambdaParametersSuite.scala b/tests/cross/src/test/scala/tests/pc/ConvertToNamedLambdaParametersSuite.scala new file mode 100644 index 00000000000..c454e92fe12 --- /dev/null +++ b/tests/cross/src/test/scala/tests/pc/ConvertToNamedLambdaParametersSuite.scala @@ -0,0 +1,175 @@ +package tests.pc + +import java.net.URI + +import scala.meta.internal.jdk.CollectionConverters._ +import scala.meta.internal.metals.CompilerOffsetParams +import scala.meta.internal.metals.TextEdits + +import munit.Location +import munit.TestOptions +import org.eclipse.{lsp4j => l} +import tests.BaseCodeActionSuite + +class ConvertToNamedLambdaParametersSuite extends BaseCodeActionSuite { + + override protected def ignoreScalaVersion: Option[IgnoreScalaVersion] = Some( + IgnoreScala2 + ) + + checkEdit( + "Int => Int function in map", + """|object A{ + | val a = List(1, 2).map(<<_>> + 1) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).map(i => i + 1) + |}""".stripMargin + ) + + checkEdit( + "Int => Int function in map with another wildcard lambda", + """|object A{ + | val a = List(1, 2).map(<<_>> + 1).map(_ + 1) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).map(i => i + 1).map(_ + 1) + |}""".stripMargin + ) + + checkEdit( + "String => String function in map", + """|object A{ + | val a = List("a", "b").map(<<_>> + "c") + |}""".stripMargin, + """|object A{ + | val a = List("a", "b").map(s => s + "c") + |}""".stripMargin + ) + + checkEdit( + "Person => Person function to custom method", + """|object A{ + | case class Person(name: String, age: Int) + | val bob = Person("Bob", 30) + | def m[A](f: Person => A): A = f(bob) + | m(_<<.>>name) + |} + |""".stripMargin, + """|object A{ + | case class Person(name: String, age: Int) + | val bob = Person("Bob", 30) + | def m[A](f: Person => A): A = f(bob) + | m(p => p.name) + |} + |""".stripMargin + ) + + checkEdit( + "(String, Int) => Int function in map with multiple underscores", + """|object A{ + | val a = List(("a", 1), ("b", 2)).map(<<_>> + _) + |}""".stripMargin, + """|object A{ + | val a = List(("a", 1), ("b", 2)).map((s, i) => s + i) + |}""".stripMargin + ) + + checkEdit( + "Int => Int function in map with multiple underscores", + """|object A{ + | val a = List(1, 2).map(x => x -> (x + 1)).map(<<_>> + _) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).map(x => x -> (x + 1)).map((i, i1) => i + i1) + |}""".stripMargin + ) + + checkEdit( + "Int => Float function in nested lambda 1", + """|object A{ + | val a = List(1, 2).flatMap(List(_).flatMap(v => List(v, v + 1).map(<<_>>.toFloat))) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).flatMap(List(_).flatMap(v => List(v, v + 1).map(i => i.toFloat))) + |}""".stripMargin + ) + + checkEdit( + "Int => Float function in nested lambda 1", + """|object A{ + | val a = List(1, 2).flatMap(List(<<_>>).flatMap(v => List(v, v + 1).map(_.toFloat))) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).flatMap(i => List(i).flatMap(v => List(v, v + 1).map(_.toFloat))) + |}""".stripMargin + ) + + checkEdit( + "Int => Float function in nested lambda with shadowing", + """|object A{ + | val a = List(1, 2).flatMap(List(<<_>>).flatMap(i => List(i, i + 1).map(_.toFloat))) + |}""".stripMargin, + """|object A{ + | val a = List(1, 2).flatMap(i1 => List(i1).flatMap(i => List(i, i + 1).map(_.toFloat))) + |}""".stripMargin + ) + + checkEdit( + "(String, String, String, String, String, String, String) => String function in map", + """|object A{ + | val a = List( + | ("a", "b", "c", "d", "e", "f", "g"), + | ("h", "i", "j", "k", "l", "m", "n") + | ).map(_<< >>+ _ + _ + _ + _ + _ + _) + |}""".stripMargin, + """|object A{ + | val a = List( + | ("a", "b", "c", "d", "e", "f", "g"), + | ("h", "i", "j", "k", "l", "m", "n") + | ).map((s, s1, s2, s3, s4, s5, s6) => s + s1 + s2 + s3 + s4 + s5 + s6) + |}""".stripMargin + ) + + checkEdit( + "Long => Long with match and wildcard pattern", + """|object A{ + | val a = List(1L, 2L).map(_ match { + | case 1L => 1L + | case _ => <<2L>> + | }) + |}""".stripMargin, + """|object A{ + | val a = List(1L, 2L).map(l => l match { + | case 1L => 1L + | case _ => 2L + | }) + |}""".stripMargin + ) + + def checkEdit( + name: TestOptions, + original: String, + expected: String, + compat: Map[String, String] = Map.empty + )(implicit location: Location): Unit = + test(name) { + val edits = convertToNamedLambdaParameters(original) + val (code, _, _) = params(original) + val obtained = TextEdits.applyEdits(code, edits) + assertNoDiff(obtained, getExpected(expected, compat, scalaVersion)) + } + + def convertToNamedLambdaParameters( + original: String, + filename: String = "file:/A.scala" + ): List[l.TextEdit] = { + val (code, _, offset) = params(original) + val result = presentationCompiler + .convertToNamedLambdaParameters( + CompilerOffsetParams(URI.create(filename), code, offset, cancelToken) + ) + .get() + result.asScala.toList + } +} diff --git a/tests/slow/src/test/scala/tests/feature/Scala3CodeActionLspSuite.scala b/tests/slow/src/test/scala/tests/feature/Scala3CodeActionLspSuite.scala index a93682e436f..bdd7202e9d3 100644 --- a/tests/slow/src/test/scala/tests/feature/Scala3CodeActionLspSuite.scala +++ b/tests/slow/src/test/scala/tests/feature/Scala3CodeActionLspSuite.scala @@ -2,6 +2,7 @@ package tests.feature import scala.meta.internal.metals.BuildInfo import scala.meta.internal.metals.codeactions.ConvertToNamedArguments +import scala.meta.internal.metals.codeactions.ConvertToNamedLambdaParameters import scala.meta.internal.metals.codeactions.CreateCompanionObjectCodeAction import scala.meta.internal.metals.codeactions.ExtractMethodCodeAction import scala.meta.internal.metals.codeactions.ExtractRenameMember @@ -771,6 +772,28 @@ class Scala3CodeActionLspSuite |""".stripMargin, ) + check( + "wildcard lambda", + """|package a + | + |object A { + | val l = List(1, 2, 3) + | l.map(_ + <<1>>) + |} + |""".stripMargin, + s"""|${ConvertToNamedArguments.title("l.map(...)")} + |${ConvertToNamedLambdaParameters.title} + |""".stripMargin, + """|package a + | + |object A { + | val l = List(1, 2, 3) + | l.map(i => i + 1) + |} + |""".stripMargin, + selectedActionIndex = 1, + ) + private def getPath(name: String) = s"a/src/main/scala/a/$name" def checkExtractedMember(