diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md
index 8f47e2e0dbe..bdb38e8ab95 100644
--- a/docs/release-notes/.VisualStudio/18.vNext.md
+++ b/docs/release-notes/.VisualStudio/18.vNext.md
@@ -4,6 +4,7 @@
* Code-fixes for FS3888 (compiler-semantic attribute on the `.fs` but not the `.fsi`): copy the attribute into the `.fsi`, or remove it from the `.fs`. ([Issue #19560](https://github.com/dotnet/fsharp/issues/19560), [PR #19880](https://github.com/dotnet/fsharp/pull/19880))
* Expand `` in IDE tooltips, completion, and signature help, inheriting XML documentation from base classes, interfaces, overridden members, and constructors. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188))
* Added a **Tools > Options > F# Tools > Compiler** option, **"Use the .NET SDK F# compiler for builds"**, on by default. Visual Studio uses the .NET SDK F# compiler when the project supplies SDK paths, matching `dotnet build`. Otherwise, it uses the bundled .NET Framework compiler. Turn the option off to use the bundled compiler for all projects. Changes are saved on Apply or OK; Cancel discards unsaved changes. Command-line builds are unaffected. ([Issue #20484](https://github.com/dotnet/fsharp/issues/20484), [PR #20485](https://github.com/dotnet/fsharp/pull/20485))
+* Add a refactoring that converts between a `fun x -> x.Prop` lambda and the F# 8 `_.Prop` shorthand. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234), [PR #20535](https://github.com/dotnet/fsharp/pull/20535))
### Fixed
diff --git a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs
index 5f157cca8f4..8742049612f 100644
--- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs
+++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs
@@ -67,51 +67,46 @@ let mkSynSimplePatVar isOpt id =
let mkSynCompGenSimplePatVar id =
SynSimplePat.Id(id, None, true, false, false, id.idRange)
-let rec pushUnaryArg expr arg =
+let rec private splitDotLambdaChain expr : struct (SynExpr * (SynExpr -> SynExpr)) =
+ let inside inner wrap =
+ let struct (head, rebuild) = splitDotLambdaChain inner
+ struct (head, rebuild >> wrap)
+
match expr with
- | SynExpr.App(ExprAtomicFlag.Atomic, infix, SynExpr.Ident ident, x1, m1) ->
- SynExpr.App(
- ExprAtomicFlag.Atomic,
- infix,
- SynExpr.LongIdent(false, SynLongIdent(arg :: [ ident ], [ ident.idRange ], [ None ]), None, ident.idRange),
- x1,
- m1
- )
- | SynExpr.App(ExprAtomicFlag.Atomic,
- infix,
- SynExpr.LongIdent(isOptional, SynLongIdent(id, dotRanges, trivia), altNameRefCell, range),
- x1,
- m1) ->
- SynExpr.App(
- ExprAtomicFlag.Atomic,
- infix,
- SynExpr.LongIdent(isOptional, SynLongIdent(arg :: id, dotRanges, trivia), altNameRefCell, range),
- x1,
- m1
- )
- | SynExpr.App(ExprAtomicFlag.Atomic, infix, (SynExpr.App _ as innerApp), x1, m1) ->
- SynExpr.App(ExprAtomicFlag.Atomic, infix, (pushUnaryArg innerApp arg), x1, m1)
- | SynExpr.App(ExprAtomicFlag.Atomic, infix, SynExpr.DotGet(synExpr, rangeOfDot, synLongIdent, range), x1, m1) ->
- SynExpr.App(ExprAtomicFlag.Atomic, infix, SynExpr.DotGet((pushUnaryArg synExpr arg), rangeOfDot, synLongIdent, range), x1, m1)
- | SynExpr.App(ExprAtomicFlag.Atomic, infix, innerExpr, x1, m1) ->
- SynExpr.App(ExprAtomicFlag.Atomic, infix, pushUnaryArg innerExpr arg, x1, m1)
- | SynExpr.Ident ident -> SynExpr.LongIdent(false, SynLongIdent(arg :: [ ident ], [ ident.idRange ], [ None ]), None, ident.idRange)
- | SynExpr.LongIdent(isOptional, SynLongIdent(id, dotRanges, trivia), altNameRefCell, range) ->
- SynExpr.LongIdent(isOptional, SynLongIdent(arg :: id, dotRanges, trivia), altNameRefCell, range)
- | SynExpr.DotGet(synExpr, rangeOfDot, synLongIdent, range) -> SynExpr.DotGet(pushUnaryArg synExpr arg, rangeOfDot, synLongIdent, range)
+ | SynExpr.App(ExprAtomicFlag.Atomic, infix, funcExpr, argExpr, m) ->
+ inside funcExpr (fun funcExpr -> SynExpr.App(ExprAtomicFlag.Atomic, infix, funcExpr, argExpr, m))
+ | SynExpr.DotGet(synExpr, rangeOfDot, synLongIdent, range) ->
+ inside synExpr (fun synExpr -> SynExpr.DotGet(synExpr, rangeOfDot, synLongIdent, range))
| SynExpr.DotIndexedGet(objectExpr, indexArgs, dotRange, range) ->
- SynExpr.DotIndexedGet(pushUnaryArg objectExpr arg, indexArgs, dotRange, range)
+ inside objectExpr (fun objectExpr -> SynExpr.DotIndexedGet(objectExpr, indexArgs, dotRange, range))
| SynExpr.TypeApp(innerExpr, mLess, tyargs, mCommas, mGreater, mTypars, m) ->
- let innerExpr = pushUnaryArg innerExpr arg
- SynExpr.TypeApp(innerExpr, mLess, tyargs, mCommas, mGreater, mTypars, m)
+ inside innerExpr (fun innerExpr -> SynExpr.TypeApp(innerExpr, mLess, tyargs, mCommas, mGreater, mTypars, m))
+ | _ -> struct (expr, id)
+
+let rec pushUnaryArg expr arg =
+ let struct (head, rebuild) = splitDotLambdaChain expr
+
+ match head with
+ | SynExpr.Ident ident ->
+ rebuild (SynExpr.LongIdent(false, SynLongIdent(arg :: [ ident ], [ ident.idRange ], [ None ]), None, ident.idRange))
+ | SynExpr.LongIdent(isOptional, SynLongIdent(id, dotRanges, trivia), altNameRefCell, range) ->
+ rebuild (SynExpr.LongIdent(isOptional, SynLongIdent(arg :: id, dotRanges, trivia), altNameRefCell, range))
| SynExpr.ArbitraryAfterError(_, m) when m.Start = m.End ->
- SynExpr.DiscardAfterMissingQualificationAfterDot(SynExpr.Ident arg, m.StartRange, unionRanges arg.idRange m)
+ rebuild (SynExpr.DiscardAfterMissingQualificationAfterDot(SynExpr.Ident arg, m.StartRange, unionRanges arg.idRange m))
| SynExpr.DiscardAfterMissingQualificationAfterDot(synExpr, dotRange, m) ->
- SynExpr.DiscardAfterMissingQualificationAfterDot(pushUnaryArg synExpr arg, dotRange, unionRanges arg.idRange m)
+ rebuild (SynExpr.DiscardAfterMissingQualificationAfterDot(pushUnaryArg synExpr arg, dotRange, unionRanges arg.idRange m))
| _ ->
- errorR (Error(FSComp.SR.tcDotLambdaAtNotSupportedExpression (), expr.Range))
+ errorR (Error(FSComp.SR.tcDotLambdaAtNotSupportedExpression (), head.Range))
expr
+let tryPopUnaryArg body =
+ let struct (head, rebuild) = splitDotLambdaChain body
+
+ match head with
+ | SynExpr.LongIdent(isOptional, SynLongIdent(root :: (_ :: _ as rest), dotRanges, trivia), altNameRefCell, range) ->
+ ValueSome(struct (root, rebuild (SynExpr.LongIdent(isOptional, SynLongIdent(rest, dotRanges, trivia), altNameRefCell, range))))
+ | _ -> ValueNone
+
/// CAUTION: This function operates over the untyped tree, so should be used only when absolutely necessary. It doesn't verify assembly origine nor does it respect type aliases.
/// Also, keep in mind that it will only check last part of the assembly (with or without the `Attribute` suffix).
let inline findSynAttribute (attrName: string) (synAttrs: SynAttributes) =
diff --git a/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi b/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi
index d65abed05d6..8fd72eeb6de 100644
--- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi
+++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi
@@ -45,6 +45,8 @@ val mkSynCompGenSimplePatVar: id: Ident -> SynSimplePat
val pushUnaryArg: expr: SynExpr -> arg: Ident -> SynExpr
+val tryPopUnaryArg: body: SynExpr -> struct (Ident * SynExpr) voption
+
val inline findSynAttribute: attrName: string -> synAttrs: SynAttributes -> bool
[]
diff --git a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
index c8bcf19a133..db4d3178bcc 100644
--- a/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
+++ b/tests/FSharp.Compiler.Service.Tests/FSharp.Compiler.Service.Tests.fsproj
@@ -91,6 +91,7 @@
+
diff --git a/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs b/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs
new file mode 100644
index 00000000000..5b9a00ce182
--- /dev/null
+++ b/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs
@@ -0,0 +1,39 @@
+module FSharp.Compiler.Service.Tests.SyntaxTreeOpsTests
+
+open FSharp.Compiler.Service.Tests.Common
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.SyntaxTreeOps
+open Xunit
+
+let private exprOf source =
+ match getParseResults source with
+ | ParsedInput.ImplFile(ParsedImplFileInput(contents = [ SynModuleOrNamespace(decls = [ SynModuleDecl.Expr(expr = expr) ]) ])) -> expr
+ | tree -> failwith $"Expected a single expression, got %A{tree}"
+
+[]
+[]
+[]
+[]
+[]
+[]
+[()")>]
+[]
+let ``tryPopUnaryArg splits the root off a chain that pushUnaryArg restores`` (source: string) =
+ let body = exprOf source
+
+ match tryPopUnaryArg body with
+ | ValueSome(struct (root, popped)) ->
+ Assert.Equal("x", root.idText)
+ Assert.Equal($"%A{body}", $"%A{pushUnaryArg popped root}")
+ | ValueNone -> Assert.Fail $"Expected {source} to have a root"
+
+[]
+[]
+[]
+[]
+[]
+[]
+[]
+[]
+let ``tryPopUnaryArg rejects what _. cannot express`` (source: string) =
+ Assert.True((tryPopUnaryArg (exprOf source)).IsNone)
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
index 319bdd5a264..e2df0ed12e3 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj
@@ -105,6 +105,7 @@
+
diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
index 1f1f632d770..cb93aa0cc7d 100644
--- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
+++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx
@@ -368,4 +368,10 @@ Use live (unsaved) buffers for analysis
Returns:
+
+ Convert to '_.' shorthand lambda
+
+
+ Convert to 'fun' lambda
+
\ No newline at end of file
diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs
new file mode 100644
index 00000000000..9afb438900d
--- /dev/null
+++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs
@@ -0,0 +1,207 @@
+// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information.
+
+namespace Microsoft.VisualStudio.FSharp.Editor
+
+open System
+open System.Composition
+
+open Microsoft.CodeAnalysis.CodeActions
+open Microsoft.CodeAnalysis.CodeRefactorings
+open Microsoft.CodeAnalysis.Text
+open Microsoft.VisualStudio.FSharp.Editor.Telemetry
+
+open FSharp.Compiler.Syntax
+open FSharp.Compiler.SyntaxTreeOps
+open FSharp.Compiler.SyntaxTrivia
+open FSharp.Compiler.Text
+
+open CancellableTasks
+
+[]
+module private DotLambdaConversion =
+
+ []
+ type Conversion =
+ {
+ Title: string
+ Direction: string
+ Change: TextChange
+ }
+
+ let hasName (name: string) (ident: Ident) =
+ String.Equals(ident.idText, name, StringComparison.Ordinal)
+
+ let identifiersIn (body: SynExpr) =
+ ([], [ SyntaxNode.SynExpr body ])
+ ||> SyntaxNodes.fold (fun identifiers _ node ->
+ match node with
+ | SyntaxNode.SynExpr(SynExpr.Ident ident)
+ | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _)))
+ | SyntaxNode.SynExpr(SynExpr.LongIdentSet(longDotId = SynLongIdent(id = ident :: _)))
+ | SyntaxNode.SynExpr(SynExpr.NamedIndexedPropertySet(longDotId = SynLongIdent(id = ident :: _)))
+ | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> ident :: identifiers
+ | _ -> identifiers)
+
+ let parameterNameFor (body: SynExpr) =
+ let used = identifiersIn body
+
+ Seq.initInfinite (fun i -> if i = 0 then "x" else $"x{i}")
+ |> Seq.find (fun name -> not (List.exists (hasName name) used))
+
+ let canBeCapturedByGeneratedArgument (ident: Ident) =
+ let prefix = "_arg"
+ let name = ident.idText
+
+ let rec isDigitsFrom i =
+ i = name.Length || (Char.IsDigit name[i] && isDigitsFrom (i + 1))
+
+ name.Length > prefix.Length
+ && name.StartsWith(prefix, StringComparison.Ordinal)
+ && isDigitsFrom prefix.Length
+
+ let hasCommentIn (comments: CommentTrivia list) (m: range) =
+ comments
+ |> List.exists (fun (CommentTrivia.LineComment comment | CommentTrivia.BlockComment comment) -> Range.rangeContainsRange m comment)
+
+ let rec lastOffsideSensitiveLine (expr: SynExpr) =
+ match expr with
+ | SynExpr.App(flag = ExprAtomicFlag.Atomic; funcExpr = func) -> lastOffsideSensitiveLine func
+ | SynExpr.DotGet(longDotId = longDotId) -> longDotId.Range.EndLine
+ | SynExpr.DotIndexedGet(dotRange = dot) -> dot.EndLine
+ | _ -> expr.Range.EndLine
+
+ let isInQuotation (path: SyntaxVisitorPath) =
+ path
+ |> List.exists (function
+ | SyntaxNode.SynExpr(SynExpr.Quote _) -> true
+ | _ -> false)
+
+ let rec isAppliedDirectly (expr: SynExpr) (path: SyntaxVisitorPath) =
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren _ as paren) :: outer -> isAppliedDirectly paren outer
+ | SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> obj.ReferenceEquals(func, expr)
+ | _ -> false
+
+ let isBlankBetween (sourceText: SourceText) start finish =
+ start <= finish
+ && String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(start, finish)))
+
+ []
+ let (|SoleParenthesizedLambdaArgument|_|) (sourceText: SourceText) (lambda: SynExpr) (lambdaSpan: TextSpan) (path: SyntaxVisitorPath) =
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner; leftParenRange = leftParen; rightParenRange = Some rightParen; range = parenRange) as paren) :: SyntaxNode.SynExpr(SynExpr.App(
+ flag = ExprAtomicFlag.NonAtomic; isInfix = false; argExpr = arg)) :: _ when
+ obj.ReferenceEquals(inner, lambda)
+ && obj.ReferenceEquals(arg, paren)
+ && isBlankBetween sourceText (RoslynHelpers.FSharpRangeToTextSpan(sourceText, leftParen)).End lambdaSpan.Start
+ && isBlankBetween sourceText lambdaSpan.End (RoslynHelpers.FSharpRangeToTextSpan(sourceText, rightParen)).Start
+ ->
+ ValueSome(RoslynHelpers.FSharpRangeToTextSpan(sourceText, parenRange))
+ | _ -> ValueNone
+
+ let toShorthand (sourceText: SourceText) (path: SyntaxVisitorPath) (lambda: SynExpr) (root: Ident) =
+ let lambdaSpan = RoslynHelpers.FSharpRangeToTextSpan(sourceText, lambda.Range)
+ let rootEnd = RoslynHelpers.FSharpRangeToTextSpan(sourceText, root.idRange).End
+
+ match path with
+ | SoleParenthesizedLambdaArgument sourceText lambda lambdaSpan parenSpan ->
+ let chain = sourceText.ToString(TextSpan.FromBounds(rootEnd, lambdaSpan.End))
+ TextChange(parenSpan, $"_{chain}")
+ | _ -> TextChange(TextSpan.FromBounds(lambdaSpan.Start, rootEnd), "_")
+
+ let toLambda (sourceText: SourceText) (path: SyntaxVisitorPath) (body: SynExpr) (range: range) (trivia: SynExprDotLambdaTrivia) =
+ let name = parameterNameFor body
+ let lambda = $"fun {name} -> {name}"
+
+ let underscore =
+ RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.UnderscoreRange)
+
+ match path with
+ | SyntaxNode.SynExpr(SynExpr.Paren _) :: _
+ | SyntaxNode.SynBinding _ :: _ -> TextChange(underscore, lambda)
+ | _ ->
+ let span = RoslynHelpers.FSharpRangeToTextSpan(sourceText, range)
+ let chain = sourceText.ToString(TextSpan.FromBounds(underscore.End, span.End))
+ TextChange(span, $"({lambda}{chain})")
+
+ let tryFind (sourceText: SourceText) (position: pos) (parseTree: ParsedInput) =
+ let comments =
+ match parseTree with
+ | ParsedInput.ImplFile file -> file.Trivia.CodeComments
+ | ParsedInput.SigFile file -> file.Trivia.CodeComments
+
+ (position, parseTree)
+ ||> ParsedInput.tryPickLast (fun path node ->
+ match node with
+ | SyntaxNode.SynExpr(SynExpr.Lambda(
+ fromMethod = false
+ parsedData = Some([ SynPat.Named(ident = SynIdent(parameter, _); isThisVal = false; accessibility = None) ], body)) as lambda) when
+ not (isInQuotation path) && not (isAppliedDirectly lambda path)
+ ->
+ let identifiers = identifiersIn body
+
+ match tryPopUnaryArg body with
+ | ValueSome(struct (root, _)) when
+ hasName parameter.idText root
+ && (identifiers |> List.filter (hasName parameter.idText) |> List.length) = 1
+ && not (List.exists canBeCapturedByGeneratedArgument identifiers)
+ && not (hasCommentIn comments (Range.withEnd root.idRange.End lambda.Range))
+ ->
+ Some
+ {
+ Title = SR.ConvertToShorthandLambda()
+ Direction = "shorthand"
+ Change = toShorthand sourceText path lambda root
+ }
+ | _ -> None
+
+ | SyntaxNode.SynExpr(SynExpr.DotLambda(expr = body; range = range; trivia = trivia)) when
+ not body.IsArbExprAndThusAlreadyReportedError
+ && not (isInQuotation path)
+ && lastOffsideSensitiveLine body = range.StartLine
+ ->
+ Some
+ {
+ Title = SR.ConvertToFunLambda()
+ Direction = "lambda"
+ Change = toLambda sourceText path body range trivia
+ }
+
+ | _ -> None)
+
+[]
+type internal FSharpConvertDotLambdaRefactoring [] () =
+ inherit CodeRefactoringProvider()
+
+ override _.ComputeRefactoringsAsync context =
+ cancellableTask {
+ let document = context.Document
+
+ if not document.IsFSharpSignatureFile then
+ let! cancellationToken = CancellableTask.getCancellationToken ()
+ let! sourceText = document.GetTextAsync cancellationToken
+
+ let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpConvertDotLambdaRefactoring)
+
+ let caret = sourceText.Lines.GetLinePosition context.Span.Start
+ let position = Position.fromZ caret.Line caret.Character
+
+ match DotLambdaConversion.tryFind sourceText position parseResults.ParseTree with
+ | Some conversion ->
+ let changedDocument =
+ cancellableTask {
+ TelemetryReporter.ReportSingleEvent(
+ TelemetryEvents.RefactoringActivated,
+ [|
+ "name", box (nameof FSharpConvertDotLambdaRefactoring)
+ "direction", box conversion.Direction
+ |]
+ )
+
+ return document.WithText(sourceText.WithChanges conversion.Change)
+ }
+
+ context.RegisterRefactoring(CodeAction.Create(conversion.Title, changedDocument, conversion.Title))
+ | None -> ()
+ }
+ |> CancellableTask.startAsTask context.CancellationToken
diff --git a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs
index 230f4988438..a5bf5e6b4ef 100644
--- a/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs
+++ b/vsintegration/src/FSharp.Editor/Telemetry/TelemetryReporter.fs
@@ -18,6 +18,9 @@ module TelemetryEvents =
[]
let CodefixActivated = "codefixactivated"
+ []
+ let RefactoringActivated = "refactoringactivated"
+
[]
let Hints = "hints"
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
index cd8c46bf705..3ef136d2536 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf
@@ -90,11 +90,21 @@ Navrhnout názvy pro nerozpoznané identifikátory;
Převést na anonymní záznam
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Pro kontrolu nerovnosti použijte <>.
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Pro kontrolu rovnosti použijte =.
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
index bce1941f0b1..ca93d64614c 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf
@@ -90,11 +90,21 @@ Namen für nicht aufgelöste Bezeichner vorschlagen;
In anonymen Datensatz konvertieren
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
"<>" für die Überprüfung auf Ungleichheit verwenden
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
"=" für Gleichheitsüberprüfung verwenden
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
index fa8cb62c422..4fc14b5665e 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf
@@ -90,11 +90,21 @@ Sugerir nombres para identificadores sin resolver;
Convertir en registro anónimo
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Usar "<>" para la comprobación de desigualdad
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Usar "=" para la comprobación de igualdad
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
index e7ec71e839e..a4fd8ad0d5b 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf
@@ -90,11 +90,21 @@ Suggérer des noms pour les identificateurs non résolus ;
Convertir en enregistrement anonyme
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Utiliser '<>' pour vérifier l'inégalité
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Utiliser '=' pour vérifier l'égalité
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
index 327a7ca362f..5bd08ff3e58 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf
@@ -90,11 +90,21 @@ Suggerisci i nomi per gli identificatori non risolti;
Converti in record anonimo
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Usare '<>' per il controllo di disuguaglianza
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Usare '=' per il controllo di uguaglianza
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
index d45234c011a..dc75026bfd3 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf
@@ -90,11 +90,21 @@ Suggest names for unresolved identifiers;
匿名レコードに変換
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
非等値のチェックには '<>' を使用します
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
等値性のチェックには '=' を使用します
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
index 3248e0641ea..833d2c81ab5 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf
@@ -90,11 +90,21 @@ Suggest names for unresolved identifiers;
익명 레코드로 변환
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
같지 않음 검사에 '<>' 사용
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
같음 검사에 '=' 사용
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
index abc39f15da5..ff8463459ae 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf
@@ -90,11 +90,21 @@ Sugeruj nazwy dla nierozpoznanych identyfikatorów;
Konwertuj na rekord anonimowy
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Użyj operatora „<>” do sprawdzenia nierówności
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Użyj znaku „=” w celu sprawdzenia równości
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
index dfde43120f5..9e2d2e92599 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf
@@ -90,11 +90,21 @@ Sugerir nomes para identificadores não resolvidos;
Converter em Registro Anônimo
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Usar '<>' para a verificação de desigualdade
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Usar '=' para verificação de igualdade
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
index 47cda215312..66b7e0c9c65 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf
@@ -90,11 +90,21 @@ Suggest names for unresolved identifiers;
Преобразовать в анонимную запись
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Используйте "<>" для проверки на неравенство
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Используйте "=" для проверки равенства
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
index 58aa5d54c43..d5071371a7a 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf
@@ -90,11 +90,21 @@ Kullanılmayan değerleri analiz et ve bunlara düzeltmeler öner;
Anonim Kayda Dönüştür
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
Eşitsizlik denetimi için '<>' kullanın
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
Eşitlik denetimi için '=' kullan
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
index 4fa703776fb..ddaa7832d82 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf
@@ -90,11 +90,21 @@ Suggest names for unresolved identifiers;
转换为匿名记录
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
使用 "<>" 进行不相等检查
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
使用 "=" 进行同等性检查
diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
index fd46ef9919a..fa5ebeecedf 100644
--- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
+++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf
@@ -90,11 +90,21 @@ Suggest names for unresolved identifiers;
轉換為匿名記錄
+
+ Convert to 'fun' lambda
+ Convert to 'fun' lambda
+
+
Use '<>' for inequality check
使用 '<>' 進行不等式檢查
+
+ Convert to '_.' shorthand lambda
+ Convert to '_.' shorthand lambda
+
+
Use '=' for equality check
使用 '=' 檢查是否相等
diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
index a62b25af1cf..7164d6de08e 100644
--- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
+++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj
@@ -75,6 +75,7 @@
+
diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs
new file mode 100644
index 00000000000..a6dacb16c3f
--- /dev/null
+++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs
@@ -0,0 +1,125 @@
+module FSharp.Editor.Tests.Refactors.ConvertDotLambdaTests
+
+open System
+
+open Microsoft.VisualStudio.FSharp.Editor
+open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks
+
+open Xunit
+
+open FSharp.Editor.Tests.Refactors.RefactorTestFramework
+
+let private inModule (binding: string) =
+ let definition =
+ if binding.StartsWith("let ", StringComparison.Ordinal) then
+ binding
+ else
+ $"let r = {binding}"
+
+ $"""
+module M
+
+{definition}
+"""
+
+let private caretAt (code: string) (marker: string) =
+ code.IndexOf(marker, StringComparison.Ordinal)
+
+let private refactored (code: string) (marker: string) =
+ use context = TestContext.CreateWithCode code
+
+ let document =
+ tryRefactor code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring())
+
+ let parseResults =
+ document.GetFSharpParseResultsAsync "test"
+ |> CancellableTask.runSynchronouslyWithoutCancellation
+
+ Assert.False(parseResults.ParseHadErrors, $"%A{parseResults.Diagnostics}")
+
+ (document.GetTextAsync() |> GetTaskResult).ToString()
+
+[]
+[ List.map (fun x -> x.Prop)", "xs |> List.map _.Prop")>]
+[ x.A.B) xs", "List.map _.A.B xs")>]
+[ x.M(y))", "Seq.filter _.M(y)")>]
+[ x.Xs[0]) arr", "Array.map _.Xs[0] arr")>]
+[ x.M()) xs", "List.map _.M() xs")>]
+[ x.Xs.[0]) xs", "List.map _.Xs.[0] xs")>]
+[ x.M().P) xs", "List.map _.M().P xs")>]
+[ x.P ) xs", "List.map _.P xs")>]
+[ x.P) xs", "List.map _.P xs")>]
+[ ``x``.P) xs", "List.map _.P xs")>]
+[ y.P)", "x.M(_.P)")>]
+[ x.P", "_.P")>]
+[ x.P)", "(_.P)")>]
+[ x .P", "_ .P")>]
+[ x (* why *) .P", "_ (* why *) .P")>]
+[ x.M(_argument)) xs", "List.map _.M(_argument) xs")>]
+[\n x.Prop) xs", "List.map _.Prop xs")>]
+[\n x.P",
+ "let r =\n _.P",
+ TestDisplayName = "Lambda spanning multiple lines still converts to shorthand")>]
+let ``Lambda reading a member of its parameter converts to shorthand`` (before: string, after: string) =
+ Assert.Equal(inModule after, refactored (inModule before) "fun")
+
+[]
+[ x.P")>]
+[ x.A.B")>]
+[ x.Xs[0]")>]
+[ x.P) xs")>]
+[ x.P)")>]
+[ List.map _.M(x)", "xs |> List.map (fun x1 -> x1.M(x))")>]
+[ x.M(_.P)")>]
+[ x.Replace(\"a\", \"\"\"x\ny\"\"\")")>]
+[ x .P")>]
+[ x (* why *) .P")>]
+[ x.P)", TestDisplayName = "Nested shorthand converts on its own")>]
+[ string =\n _.PadLeft(\n 3\n )",
+ "let f : string -> string =\n (fun x -> x.PadLeft(\n 3\n ))",
+ TestDisplayName = "Shorthand whose arguments span multiple lines converts to lambda")>]
+let ``Shorthand converts to lambda`` (marker: string, before: string, after: string) =
+ Assert.Equal(inModule after, refactored (inModule before) marker)
+
+[]
+[ x.P")>]
+[ x.P")>]
+[ x.P")>]
+[ (x.P)")>]
+[ f x.P")>]
+[ x.M y")>]
+[ x.P + 1")>]
+[ x.M(x)")>]
+[ x.M(fun x -> x)")>]
+[ x.M(x <- 1)")>]
+[ x.M(x.P <- 1)")>]
+[ x.M(x.Item(0) <- 1)")>]
+[ x.M(x.M().P <- 1)")>]
+[ x.M(x.Xs[0] <- 1)")>]
+[ x.")>]
+[ y.P")>]
+[ x")>]
+[ x[0]")>]
+[ 1")>]
+[ x.P) y")>]
+[ x.P)) y")>]
+[ (* why *) x.P")>]
+[ x.P")>]
+[ // why\n x.P")>]
+[ (* why *) x.P) xs")>]
+[ x.CompareTo(_arg1)")>]
+[ x.P @>")>]
+[")>]
+[]
+[]
+[]
+let ``No action`` (binding: string) =
+ let code = inModule binding
+ use context = TestContext.CreateWithCode code
+
+ let marker =
+ [ "fun"; "_."; "+" ]
+ |> List.find (fun marker -> binding.IndexOf(marker, StringComparison.Ordinal) >= 0)
+
+ Assert.Empty(tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring()))