From 0eabc2e04f64b27562c79c5e2245b3aa2fdb52af Mon Sep 17 00:00:00 2001
From: Kacper Korban <kacper.f.korban@gmail.com>
Date: Mon, 12 Aug 2024 13:31:38 +0200
Subject: [PATCH] feat: implement 'convert to named lambda parameters' code
 action

---
 .../meta/internal/metals/Compilers.scala      |  14 ++
 .../meta/internal/metals/ServerCommands.scala |  14 ++
 .../codeactions/CodeActionProvider.scala      |   1 +
 .../ConvertToNamedLambdaParameters.scala      |  93 ++++++++++
 .../scala/meta/pc/PresentationCompiler.java   |   9 +
 .../internal/mtags/TermNameInference.scala    |  52 ++++++
 ...nvertToNamedLambdaParametersProvider.scala | 150 +++++++++++++++
 .../pc/ScalaPresentationCompiler.scala        |  19 ++
 .../scala/tests/BaseExtractMethodSuite.scala  |   1 -
 .../ConvertToNamedLambdaParametersSuite.scala | 175 ++++++++++++++++++
 .../feature/Scala3CodeActionLspSuite.scala    |  23 +++
 11 files changed, 550 insertions(+), 1 deletion(-)
 create mode 100644 metals/src/main/scala/scala/meta/internal/metals/codeactions/ConvertToNamedLambdaParameters.scala
 create mode 100644 mtags/src/main/scala-3/scala/meta/internal/mtags/TermNameInference.scala
 create mode 100644 mtags/src/main/scala-3/scala/meta/internal/pc/ConvertToNamedLambdaParametersProvider.scala
 create mode 100644 tests/cross/src/test/scala/tests/pc/ConvertToNamedLambdaParametersSuite.scala

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 dab4cbae11c..2fb2fa9a425 100644
--- a/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala
+++ b/metals/src/main/scala/scala/meta/internal/metals/Compilers.scala
@@ -860,6 +860,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 664f9b884ef..02a5153672c 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
@@ -59,6 +59,7 @@ final class CodeActionProvider(
     new MillifyDependencyCodeAction(buffers),
     new MillifyScalaCliDependencyCodeAction(buffers),
     new ConvertCommentCodeAction(buffers),
+    new ConvertToNamedLambdaParameters(trees, compilers, languageClient),
   )
 
   def codeActions(
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 62aa8a3a787..43097f039c0 100644
--- a/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java
+++ b/mtags-interfaces/src/main/java/scala/meta/pc/PresentationCompiler.java
@@ -159,6 +159,15 @@ public CompletableFuture<List<TextEdit>> inlineValue(OffsetParams params) {
 	public abstract CompletableFuture<List<TextEdit>> convertToNamedArguments(OffsetParams params,
 			List<Integer> argIndices);
 
+	/**
+	 * Return the text edits for converting a wildcard lambda to a named lambda.
+	 */
+	public CompletableFuture<List<TextEdit>> 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 c962543192d..02016a716dd 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
@@ -363,6 +363,25 @@ case class ScalaPresentationCompiler(
         case Right(edits: List[l.TextEdit]) => edits.asJava
       }
   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 325f16e0b3a..6e41ed61248 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
@@ -767,6 +768,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(