From 92a3eac744b203b14faa02dbf085116cedeb7e9d Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 01:31:29 +0200 Subject: [PATCH 01/10] Add a refactoring between 'fun x -> x.Prop' and the '_.Prop' shorthand A lambda whose only use of its parameter is the head of a member, method, indexer or type-application chain converts to the F# 8 shorthand, and a shorthand converts back to a 'fun' lambda with a parameter name the body does not use. The accepted chains mirror SyntaxTreeOps.pushUnaryArg, so the shorthand produced always type checks like the lambda it replaces. A lambda spanning multiple lines still converts as long as its body is such a chain; the result collapses onto the line the '_.' replaces 'fun' on. Fixes #16234 Co-Authored-By: Claude Sonnet 5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../Refactor/ConvertDotLambda.fs | 187 ++++++++++++++++++ .../Telemetry/TelemetryReporter.fs | 3 + .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 + .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 + .../xlf/FSharp.Editor.zh-Hans.xlf | 10 + .../xlf/FSharp.Editor.zh-Hant.xlf | 10 + .../FSharp.Editor.Tests.fsproj | 1 + .../Refactors/ConvertDotLambdaTests.fs | 98 +++++++++ 20 files changed, 427 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 8f47e2e0dbe..38b5b9516bb 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)) +* Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234)) ### Fixed 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..96bbff82e7d --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs @@ -0,0 +1,187 @@ +// 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.Collections.Generic +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.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) + + // The shapes SyntaxTreeOps.pushUnaryArg accepts under `_.`, with the parameter as the head of the chain. + let rec tryChainRoot expr = + match expr with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = root :: _ :: _; dotRanges = dot :: _)) when + Position.posEq dot.Start root.idRange.End + -> + ValueSome root + | SynExpr.DotGet(expr = inner) + | SynExpr.DotIndexedGet(objectExpr = inner) + | SynExpr.TypeApp(expr = inner) + | SynExpr.App(flag = ExprAtomicFlag.Atomic; isInfix = false; funcExpr = inner) -> tryChainRoot inner + | _ -> ValueNone + + let occurrencesOf (name: string) (body: SynExpr) = + (0, [ SyntaxNode.SynExpr body ]) + ||> SyntaxNodes.fold (fun count _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Ident ident) + | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) when hasName name ident -> count + 1 + | _ -> count) + + let parameterNameFor (body: SynExpr) = + let used = + (HashSet(StringComparer.Ordinal), [ SyntaxNode.SynExpr body ]) + ||> SyntaxNodes.fold (fun names _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Ident ident) + | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) + | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> ignore (names.Add ident.idText) + | _ -> () + + names) + + Seq.initInfinite (fun i -> if i = 0 then "x" else $"x{i}") + |> Seq.find (used.Contains >> not) + + let isInQuotation (path: SyntaxVisitorPath) = + path + |> List.exists (function + | SyntaxNode.SynExpr(SynExpr.Quote _) -> true + | _ -> false) + + let isAppliedDirectly (lambda: SynExpr) (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner) as paren) :: SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> + obj.ReferenceEquals(inner, lambda) && obj.ReferenceEquals(func, paren) + | SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> obj.ReferenceEquals(func, lambda) + | _ -> false + + let spanOf (sourceText: SourceText) (m: range) = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, m) + + let isBlankBetween (sourceText: SourceText) start finish = + start <= finish + && String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(start, finish))) + + let toShorthand (sourceText: SourceText) (path: SyntaxVisitorPath) (lambda: SynExpr) (root: Ident) = + let lambdaSpan = spanOf sourceText lambda.Range + let rootEnd = (spanOf sourceText root.idRange).End + + 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 (spanOf sourceText leftParen).End lambdaSpan.Start + && isBlankBetween sourceText lambdaSpan.End (spanOf sourceText rightParen).Start + -> + let chain = sourceText.ToString(TextSpan.FromBounds(rootEnd, lambdaSpan.End)) + TextChange(spanOf sourceText parenRange, $"_{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 underscoreStart = (spanOf sourceText trivia.UnderscoreRange).Start + let dotStart = (spanOf sourceText trivia.DotRange).Start + + match path with + | SyntaxNode.SynExpr(SynExpr.Paren _) :: _ + | SyntaxNode.SynBinding _ :: _ -> TextChange(TextSpan.FromBounds(underscoreStart, dotStart), lambda) + | _ -> + let span = spanOf sourceText range + let chain = sourceText.ToString(TextSpan.FromBounds(dotStart, span.End)) + TextChange(span, $"({lambda}{chain})") + + let tryFind (sourceText: SourceText) (position: pos) (parseTree: ParsedInput) = + (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) + -> + match tryChainRoot body with + | ValueSome root when hasName parameter.idText root && occurrencesOf parameter.idText body = 1 -> + 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 + -> + 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.mkPos (Line.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..7f58a1a3f64 --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs @@ -0,0 +1,98 @@ +module FSharp.Editor.Tests.Refactors.ConvertDotLambdaTests + +open System + +open Microsoft.VisualStudio.FSharp.Editor + +open Xunit + +open FSharp.Editor.Tests.Refactors.RefactorTestFramework + +let private inModule (binding: string) = $"module M\n\nlet r = {binding}\n" + +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()) + + (document.GetTextAsync() |> GetTaskResult).ToString() + +let private actionsIn (context: TestContext) (code: string) (marker: string) = + tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring()) + +[] +[ 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")>] +[ y.P)", "x.M(_.P)")>] +[ x.P", "_.P")>] +[ x.P)", "(_.P)")>] +[\n x.Prop) xs", "List.map _.Prop xs")>] +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)")>] +let ``Shorthand converts to lambda`` (before: string, after: string) = + Assert.Equal(inModule after, refactored (inModule before) "_.") + +[] +let ``Nested shorthand converts on its own`` () = + Assert.Equal(inModule "_.M(fun x -> x.P)", refactored (inModule "_.M(_.P)") "_.P") + +[] +[ 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)")>] +[ y.P")>] +[ x")>] +[ x[0]")>] +[ 1")>] +[ x.P) y")>] +[ x.P @>")>] +[] +let ``No action`` (binding: string) = + let code = inModule binding + use context = TestContext.CreateWithCode code + + let marker = + if binding.IndexOf("fun", StringComparison.Ordinal) >= 0 then + "fun" + else + "+" + + Assert.Empty(actionsIn context code marker) + +[] +let ``Lambda spanning multiple lines still converts to shorthand`` () = + let before = "module M\n\nlet r =\n fun x ->\n x.P\n" + let after = "module M\n\nlet r =\n _.P\n" + Assert.Equal(after, refactored before "fun") + +[] +let ``Converting to shorthand and back restores the lambda`` () = + let original = inModule "List.map (fun x -> x.P) xs" + let shorthand = refactored original "fun" + Assert.Equal(original, refactored shorthand "_.") From 3427c9801eb4aa92c48ca703d44ba03c10df0443 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:22:54 +0200 Subject: [PATCH 02/10] Write the refactoring test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../Refactors/ConvertDotLambdaTests.fs | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs index 7f58a1a3f64..e09ee2f0af0 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs @@ -8,7 +8,12 @@ open Xunit open FSharp.Editor.Tests.Refactors.RefactorTestFramework -let private inModule (binding: string) = $"module M\n\nlet r = {binding}\n" +let private inModule (binding: string) = + $""" +module M + +let r = {binding} +""" let private caretAt (code: string) (marker: string) = code.IndexOf(marker, StringComparison.Ordinal) @@ -37,7 +42,9 @@ let private actionsIn (context: TestContext) (code: string) (marker: string) = [ y.P)", "x.M(_.P)")>] [ x.P", "_.P")>] [ x.P)", "(_.P)")>] -[\n x.Prop) xs", "List.map _.Prop xs")>] +[ + x.Prop) xs""", + "List.map _.Prop xs")>] let ``Lambda reading a member of its parameter converts to shorthand`` (before: string, after: string) = Assert.Equal(inModule after, refactored (inModule before) "fun") @@ -87,8 +94,23 @@ let ``No action`` (binding: string) = [] let ``Lambda spanning multiple lines still converts to shorthand`` () = - let before = "module M\n\nlet r =\n fun x ->\n x.P\n" - let after = "module M\n\nlet r =\n _.P\n" + let before = + """ +module M + +let r = + fun x -> + x.P +""" + + let after = + """ +module M + +let r = + _.P +""" + Assert.Equal(after, refactored before "fun") [] From 0cbf251f7b84d2166e485d9e2d7c1c137fd243e8 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:35:58 +0200 Subject: [PATCH 03/10] Link the release note to the pull request and move it to a random line of its section Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 38b5b9516bb..57aa5b098ae 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -5,6 +5,7 @@ * 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)) * Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234)) +* Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234), [PR #20535](https://github.com/dotnet/fsharp/pull/20535)) ### Fixed From 064a97f84386315ddb38c2a33bd1415d218525c6 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:26:38 +0200 Subject: [PATCH 04/10] Share the _. body chain walk between pushUnaryArg and its inverse pushUnaryArg now finds the head of the atomic chain (atomic applications, member and index lookups, type applications) through a zipper that also rebuilds the chain, and tryPopUnaryArg uses the same zipper to split the root identifier off a lambda body. The editor refactoring between `fun x -> x.P` and `_.P` can then accept exactly the bodies the compiler accepts instead of keeping its own copy of the rule. pushUnaryArg keeps its recovery branches and reports FS3584 on the same node as before. Both functions stay internal. Co-Authored-By: Claude Opus 5.5 --- .../.FSharp.Compiler.Service/11.0.200.md | 1 + src/Compiler/SyntaxTree/SyntaxTreeOps.fs | 71 +++++++++---------- src/Compiler/SyntaxTree/SyntaxTreeOps.fsi | 5 ++ .../FSharp.Compiler.Service.Tests.fsproj | 1 + .../SyntaxTreeOpsTests.fs | 39 ++++++++++ 5 files changed, 80 insertions(+), 37 deletions(-) create mode 100644 tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 99464a277dd..f2f138d88ca 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -23,6 +23,7 @@ ### Changed * Remove the unused internal `--test:NoErrorText` switch and its argument-only diagnostic formatting. ([PR #20742](https://github.com/dotnet/fsharp/pull/20742)) +* `SyntaxTreeOps.pushUnaryArg` walks the `_.` body chain through a shared helper that also backs the new internal `tryPopUnaryArg`, its inverse, so the Visual Studio `_.` refactoring accepts exactly the bodies the compiler does. ([PR #20535](https://github.com/dotnet/fsharp/pull/20535)) * Assign seven previously preview-only features to [F# 11.2](https://fsharp.github.io/fsharp-compiler-docs/release-notes/Language.html#11.2), now the default language version. `default`, `latest`, and `latestmajor` select F# 11.2; explicit `11` and `11.0` retain F# 11.0 behavior. Preview SDK projects use the same compiler default unless `LangVersion` is explicitly set. `FromEndSlicing` remains preview-only. ([PR #20617](https://github.com/dotnet/fsharp/pull/20617)) * Add `SynExceptionDefnReprTrivia` with the range of the `exception` keyword to `SynExceptionDefnRepr`. ([Fantomas Issue #3483](https://github.com/fsprojects/fantomas/issues/3483), [PR #20612](https://github.com/dotnet/fsharp/pull/20612)) * Add `WhenKeyword` to `SynMatchClauseTrivia` with the range of the `when` keyword in match clauses. ([PR #20734](https://github.com/dotnet/fsharp/pull/20734)) diff --git a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs index 5f157cca8f4..9221abaf94a 100644 --- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs +++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs @@ -67,51 +67,48 @@ let mkSynSimplePatVar isOpt id = let mkSynCompGenSimplePatVar id = SynSimplePat.Id(id, None, true, false, false, id.idRange) -let rec pushUnaryArg expr arg = +/// The head of a chain of atomic applications, member and index lookups and type applications, +/// and the function that rebuilds the chain around a replacement head. +let rec private atomicChainHead expr : struct (SynExpr * (SynExpr -> SynExpr)) = + let inside inner wrap = + let struct (head, rebuild) = atomicChainHead 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) = atomicChainHead 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) = atomicChainHead 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..8b63c07c190 100644 --- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi +++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi @@ -43,8 +43,13 @@ val mkSynSimplePatVar: isOpt: bool -> id: Ident -> SynSimplePat val mkSynCompGenSimplePatVar: id: Ident -> SynSimplePat +/// Turns the body of `_.` into the body of `fun arg -> ...` by making `arg` the root of its chain. +/// Reports FS3584 when the body is not such a chain. val pushUnaryArg: expr: SynExpr -> arg: Ident -> SynExpr +/// The inverse of pushUnaryArg: splits the root identifier off a body that `_.` can express. +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..907d13fcb45 --- /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(sprintf "%A" body, sprintf "%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) From df3974c96d2ca58e4faef9053d4026ccb856c534 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Tue, 29 Sep 2026 19:26:48 +0200 Subject: [PATCH 05/10] Ask the compiler which lambda bodies _. can express, and count assignment targets as uses The refactoring drops its own chain walker for SyntaxTreeOps.tryPopUnaryArg, the inverse of what the compiler does to a `_.` body, so the two cannot drift. The check that the dot follows the root without a space goes with it: `x .P` is the same chain and `_ .P` is a valid shorthand. `fun x -> x.M(x.P <- 1)` was offered as `_.M(x.P <- 1)`, where `x` then names an outer value: an assignment to `x`, `x.P` or `x.Xs[0]` keeps its target as a long identifier rather than an expression, so the use was not counted. The condition for dropping the parentheses around the lambda argument is now the ParenthesizedLambdaArgument active pattern, both folds over the body share one identifier collector, and spans come from RoslynHelpers directly. Co-Authored-By: Claude Opus 5.5 --- .../Refactor/ConvertDotLambda.fs | 84 +++++++++---------- .../Refactors/ConvertDotLambdaTests.fs | 12 ++- 2 files changed, 46 insertions(+), 50 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs index 96bbff82e7d..d533344fc65 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs @@ -3,7 +3,6 @@ namespace Microsoft.VisualStudio.FSharp.Editor open System -open System.Collections.Generic open System.Composition open Microsoft.CodeAnalysis.CodeActions @@ -12,6 +11,7 @@ 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 @@ -31,42 +31,22 @@ module private DotLambdaConversion = let hasName (name: string) (ident: Ident) = String.Equals(ident.idText, name, StringComparison.Ordinal) - // The shapes SyntaxTreeOps.pushUnaryArg accepts under `_.`, with the parameter as the head of the chain. - let rec tryChainRoot expr = - match expr with - | SynExpr.LongIdent(longDotId = SynLongIdent(id = root :: _ :: _; dotRanges = dot :: _)) when - Position.posEq dot.Start root.idRange.End - -> - ValueSome root - | SynExpr.DotGet(expr = inner) - | SynExpr.DotIndexedGet(objectExpr = inner) - | SynExpr.TypeApp(expr = inner) - | SynExpr.App(flag = ExprAtomicFlag.Atomic; isInfix = false; funcExpr = inner) -> tryChainRoot inner - | _ -> ValueNone - - let occurrencesOf (name: string) (body: SynExpr) = - (0, [ SyntaxNode.SynExpr body ]) - ||> SyntaxNodes.fold (fun count _ node -> + 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.SynPat(SynPat.Named(ident = SynIdent(ident, _))) when hasName name ident -> count + 1 - | _ -> count) + | 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 = - (HashSet(StringComparer.Ordinal), [ SyntaxNode.SynExpr body ]) - ||> SyntaxNodes.fold (fun names _ node -> - match node with - | SyntaxNode.SynExpr(SynExpr.Ident ident) - | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _))) - | SyntaxNode.SynPat(SynPat.Named(ident = SynIdent(ident, _))) -> ignore (names.Add ident.idText) - | _ -> () - - names) + let used = identifiersIn body Seq.initInfinite (fun i -> if i = 0 then "x" else $"x{i}") - |> Seq.find (used.Contains >> not) + |> Seq.find (fun name -> not (List.exists (hasName name) used)) let isInQuotation (path: SyntaxVisitorPath) = path @@ -81,40 +61,49 @@ module private DotLambdaConversion = | SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> obj.ReferenceEquals(func, lambda) | _ -> false - let spanOf (sourceText: SourceText) (m: range) = - RoslynHelpers.FSharpRangeToTextSpan(sourceText, m) - let isBlankBetween (sourceText: SourceText) start finish = start <= finish && String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(start, finish))) - let toShorthand (sourceText: SourceText) (path: SyntaxVisitorPath) (lambda: SynExpr) (root: Ident) = - let lambdaSpan = spanOf sourceText lambda.Range - let rootEnd = (spanOf sourceText root.idRange).End - + /// The parentheses around a lambda argument that hold nothing else, so `f (fun x -> x.P)` can become `f _.P`. + [] + let (|ParenthesizedLambdaArgument|_|) (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 (spanOf sourceText leftParen).End lambdaSpan.Start - && isBlankBetween sourceText lambdaSpan.End (spanOf sourceText rightParen).Start + && 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 + | ParenthesizedLambdaArgument sourceText lambda lambdaSpan parenSpan -> let chain = sourceText.ToString(TextSpan.FromBounds(rootEnd, lambdaSpan.End)) - TextChange(spanOf sourceText parenRange, $"_{chain}") + 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 underscoreStart = (spanOf sourceText trivia.UnderscoreRange).Start - let dotStart = (spanOf sourceText trivia.DotRange).Start + + let underscoreStart = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.UnderscoreRange).Start + + let dotStart = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.DotRange).Start match path with | SyntaxNode.SynExpr(SynExpr.Paren _) :: _ | SyntaxNode.SynBinding _ :: _ -> TextChange(TextSpan.FromBounds(underscoreStart, dotStart), lambda) | _ -> - let span = spanOf sourceText range + let span = RoslynHelpers.FSharpRangeToTextSpan(sourceText, range) let chain = sourceText.ToString(TextSpan.FromBounds(dotStart, span.End)) TextChange(span, $"({lambda}{chain})") @@ -127,8 +116,11 @@ module private DotLambdaConversion = parsedData = Some([ SynPat.Named(ident = SynIdent(parameter, _); isThisVal = false; accessibility = None) ], body)) as lambda) when not (isInQuotation path) && not (isAppliedDirectly lambda path) -> - match tryChainRoot body with - | ValueSome root when hasName parameter.idText root && occurrencesOf parameter.idText body = 1 -> + match tryPopUnaryArg body with + | ValueSome(struct (root, _)) when + hasName parameter.idText root + && (identifiersIn body |> List.filter (hasName parameter.idText) |> List.length) = 1 + -> Some { Title = SR.ConvertToShorthandLambda() @@ -164,7 +156,7 @@ type internal FSharpConvertDotLambdaRefactoring [] () = let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpConvertDotLambdaRefactoring) let caret = sourceText.Lines.GetLinePosition context.Span.Start - let position = Position.mkPos (Line.fromZ caret.Line) caret.Character + let position = Position.fromZ caret.Line caret.Character match DotLambdaConversion.tryFind sourceText position parseResults.ParseTree with | Some conversion -> diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs index e09ee2f0af0..528ebb5a5ab 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs @@ -26,9 +26,6 @@ let private refactored (code: string) (marker: string) = (document.GetTextAsync() |> GetTaskResult).ToString() -let private actionsIn (context: TestContext) (code: string) (marker: string) = - tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring()) - [] [ List.map (fun x -> x.Prop)", "xs |> List.map _.Prop")>] [ x.A.B) xs", "List.map _.A.B xs")>] @@ -42,6 +39,7 @@ let private actionsIn (context: TestContext) (code: string) (marker: string) = [ y.P)", "x.M(_.P)")>] [ x.P", "_.P")>] [ x.P)", "(_.P)")>] +[ x .P", "_ .P")>] [ x.Prop) xs""", "List.map _.Prop xs")>] @@ -73,6 +71,12 @@ let ``Nested shorthand converts on its own`` () = [ 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]")>] @@ -90,7 +94,7 @@ let ``No action`` (binding: string) = else "+" - Assert.Empty(actionsIn context code marker) + Assert.Empty(tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring())) [] let ``Lambda spanning multiple lines still converts to shorthand`` () = From b15bd3788701b24e3c87bd6677c65510cf3c6b22 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 09:27:03 +0200 Subject: [PATCH 06/10] Withhold the _. conversions that lose a comment, capture a value or break the layout Lambda to shorthand is no longer offered when - a comment stands in the `fun x -> x` text that `_` replaces; - the body uses a name such as `_arg1`: the compiler names the parameter of `_.` that way, so the use would bind to it instead of the outer value; - the lambda is applied through more than one pair of parentheses, `((fun x -> x.P)) y`, like the single pair that was already excluded. Shorthand to lambda is no longer offered - inside a quotation, where the name of the lambda variable is observable; - when the chain itself continues on a later line: `_.Trim()` followed by `.Length` on the next line is valid after `_` but offside as the body of a lambda that starts further right. Arguments may still span lines. It replaces only the `_` token, so what stands between `_` and the dot, a space or a comment, stays. The tests now parse every converted document. Co-Authored-By: Claude Opus 5.5 --- .../Refactor/ConvertDotLambda.fs | 56 +++++++++++++++---- .../Refactors/ConvertDotLambdaTests.fs | 55 ++++++++++++++++-- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs index d533344fc65..2d3b5215efa 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs @@ -48,17 +48,41 @@ module private DotLambdaConversion = Seq.initInfinite (fun i -> if i = 0 then "x" else $"x{i}") |> Seq.find (fun name -> not (List.exists (hasName name) used)) + /// Whether the name is one the compiler gives the parameter of `_.`: a use of an outer value with it would bind to that parameter. + let isGeneratedArgumentName (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) + + /// The line the chain ends on, not counting what its argument brackets hold. A chain that goes on below `_` is + /// offside once it is the body of a lambda that starts further right; what the brackets hold is valid on any line. + let rec lastLineOutsideArguments (expr: SynExpr) = + match expr with + | SynExpr.App(flag = ExprAtomicFlag.Atomic; funcExpr = func) -> lastLineOutsideArguments 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 isAppliedDirectly (lambda: SynExpr) (path: SyntaxVisitorPath) = + let rec isAppliedDirectly (expr: SynExpr) (path: SyntaxVisitorPath) = match path with - | SyntaxNode.SynExpr(SynExpr.Paren(expr = inner) as paren) :: SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> - obj.ReferenceEquals(inner, lambda) && obj.ReferenceEquals(func, paren) - | SyntaxNode.SynExpr(SynExpr.App(funcExpr = func)) :: _ -> obj.ReferenceEquals(func, lambda) + | 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 = @@ -93,21 +117,23 @@ module private DotLambdaConversion = let name = parameterNameFor body let lambda = $"fun {name} -> {name}" - let underscoreStart = - RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.UnderscoreRange).Start - - let dotStart = - RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.DotRange).Start + let underscore = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, trivia.UnderscoreRange) match path with | SyntaxNode.SynExpr(SynExpr.Paren _) :: _ - | SyntaxNode.SynBinding _ :: _ -> TextChange(TextSpan.FromBounds(underscoreStart, dotStart), lambda) + | SyntaxNode.SynBinding _ :: _ -> TextChange(underscore, lambda) | _ -> let span = RoslynHelpers.FSharpRangeToTextSpan(sourceText, range) - let chain = sourceText.ToString(TextSpan.FromBounds(dotStart, span.End)) + 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 @@ -116,10 +142,14 @@ module private DotLambdaConversion = 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 - && (identifiersIn body |> List.filter (hasName parameter.idText) |> List.length) = 1 + && (identifiers |> List.filter (hasName parameter.idText) |> List.length) = 1 + && not (List.exists isGeneratedArgumentName identifiers) + && not (hasCommentIn comments (Range.withEnd root.idRange.End lambda.Range)) -> Some { @@ -131,6 +161,8 @@ module private DotLambdaConversion = | SyntaxNode.SynExpr(SynExpr.DotLambda(expr = body; range = range; trivia = trivia)) when not body.IsArbExprAndThusAlreadyReportedError + && not (isInQuotation path) + && lastLineOutsideArguments body = range.StartLine -> Some { diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs index 528ebb5a5ab..9ef2560fdc3 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs @@ -3,6 +3,7 @@ module FSharp.Editor.Tests.Refactors.ConvertDotLambdaTests open System open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks open Xunit @@ -24,6 +25,12 @@ let private refactored (code: string) (marker: string) = 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() [] @@ -40,6 +47,8 @@ let private refactored (code: string) (marker: string) = [ x.P", "_.P")>] [ x.P)", "(_.P)")>] [ x .P", "_ .P")>] +[ x (* why *) .P", "_ (* why *) .P")>] +[ x.M(_argument)) xs", "List.map _.M(_argument) xs")>] [ x.Prop) xs""", "List.map _.Prop xs")>] @@ -54,9 +63,40 @@ let ``Lambda reading a member of its parameter converts to shorthand`` (before: [ x.P)")>] [ List.map _.M(x)", "xs |> List.map (fun x1 -> x1.M(x))")>] [ x.M(_.P)")>] +[ x.Replace(\"a\", \"\"\"x\ny\"\"\")")>] let ``Shorthand converts to lambda`` (before: string, after: string) = Assert.Equal(inModule after, refactored (inModule before) "_.") +[] +[ x .P")>] +[ x (* why *) .P")>] +let ``Shorthand keeps what stands between the underscore and the dot`` (before: string, after: string) = + Assert.Equal(inModule after, refactored (inModule before) "_ ") + +[] +let ``Shorthand whose arguments span multiple lines converts to lambda`` () = + let before = + """ +module M + +let f : string -> string = + _.PadLeft( + 3 + ) +""" + + let after = + """ +module M + +let f : string -> string = + (fun x -> x.PadLeft( + 3 + )) +""" + + Assert.Equal(after, refactored before "_.") + [] let ``Nested shorthand converts on its own`` () = Assert.Equal(inModule "_.M(fun x -> x.P)", refactored (inModule "_.M(_.P)") "_.P") @@ -82,17 +122,24 @@ let ``Nested shorthand converts on its own`` () = [ 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 = - if binding.IndexOf("fun", StringComparison.Ordinal) >= 0 then - "fun" - else - "+" + [ "fun"; "_."; "+" ] + |> List.find (fun marker -> binding.IndexOf(marker, StringComparison.Ordinal) >= 0) Assert.Empty(tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring())) From e18b12836f34b1717d3661c8c57076b4d4a0af65 Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Sun, 11 Oct 2026 09:27:03 +0200 Subject: [PATCH 07/10] Remove the duplicated release note The bullet was listed twice, once without the pull request link. Co-Authored-By: Claude Opus 5.5 --- docs/release-notes/.VisualStudio/18.vNext.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 57aa5b098ae..6f5621b4408 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,7 +4,6 @@ * 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)) -* Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234)) * Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234), [PR #20535](https://github.com/dotnet/fsharp/pull/20535)) ### Fixed From e47c07c305352f8050dd7f4d088400c7274ffccd Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:09:52 +0100 Subject: [PATCH 08/10] Name the dot-lambda conversion helpers instead of documenting them Delete the nine implementation comment lines and let the helper names say what the comments said: - splitDotLambdaChain: the head of a dot-lambda body and the function that rebuilds the chain around another head (was atomicChainHead) - canBeCapturedByGeneratedArgument: a name the generated parameter of _. would capture (was isGeneratedArgumentName) - lastOffsideSensitiveLine: the last line of a chain outside its argument brackets (was lastLineOutsideArguments) - SoleParenthesizedLambdaArgument: the parentheses that hold only the lambda (was ParenthesizedLambdaArgument) SyntaxTreeOps is an internal module, so pushUnaryArg and tryPopUnaryArg lose their doc comments as well. Co-Authored-By: Claude Sonnet 5.5 --- src/Compiler/SyntaxTree/SyntaxTreeOps.fs | 10 ++++------ src/Compiler/SyntaxTree/SyntaxTreeOps.fsi | 3 --- .../FSharp.Editor/Refactor/ConvertDotLambda.fs | 18 +++++++----------- 3 files changed, 11 insertions(+), 20 deletions(-) diff --git a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs index 9221abaf94a..8742049612f 100644 --- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fs +++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fs @@ -67,11 +67,9 @@ let mkSynSimplePatVar isOpt id = let mkSynCompGenSimplePatVar id = SynSimplePat.Id(id, None, true, false, false, id.idRange) -/// The head of a chain of atomic applications, member and index lookups and type applications, -/// and the function that rebuilds the chain around a replacement head. -let rec private atomicChainHead expr : struct (SynExpr * (SynExpr -> SynExpr)) = +let rec private splitDotLambdaChain expr : struct (SynExpr * (SynExpr -> SynExpr)) = let inside inner wrap = - let struct (head, rebuild) = atomicChainHead inner + let struct (head, rebuild) = splitDotLambdaChain inner struct (head, rebuild >> wrap) match expr with @@ -86,7 +84,7 @@ let rec private atomicChainHead expr : struct (SynExpr * (SynExpr -> SynExpr)) = | _ -> struct (expr, id) let rec pushUnaryArg expr arg = - let struct (head, rebuild) = atomicChainHead expr + let struct (head, rebuild) = splitDotLambdaChain expr match head with | SynExpr.Ident ident -> @@ -102,7 +100,7 @@ let rec pushUnaryArg expr arg = expr let tryPopUnaryArg body = - let struct (head, rebuild) = atomicChainHead body + let struct (head, rebuild) = splitDotLambdaChain body match head with | SynExpr.LongIdent(isOptional, SynLongIdent(root :: (_ :: _ as rest), dotRanges, trivia), altNameRefCell, range) -> diff --git a/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi b/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi index 8b63c07c190..8fd72eeb6de 100644 --- a/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi +++ b/src/Compiler/SyntaxTree/SyntaxTreeOps.fsi @@ -43,11 +43,8 @@ val mkSynSimplePatVar: isOpt: bool -> id: Ident -> SynSimplePat val mkSynCompGenSimplePatVar: id: Ident -> SynSimplePat -/// Turns the body of `_.` into the body of `fun arg -> ...` by making `arg` the root of its chain. -/// Reports FS3584 when the body is not such a chain. val pushUnaryArg: expr: SynExpr -> arg: Ident -> SynExpr -/// The inverse of pushUnaryArg: splits the root identifier off a body that `_.` can express. val tryPopUnaryArg: body: SynExpr -> struct (Ident * SynExpr) voption val inline findSynAttribute: attrName: string -> synAttrs: SynAttributes -> bool diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs index 2d3b5215efa..9afb438900d 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertDotLambda.fs @@ -48,8 +48,7 @@ module private DotLambdaConversion = Seq.initInfinite (fun i -> if i = 0 then "x" else $"x{i}") |> Seq.find (fun name -> not (List.exists (hasName name) used)) - /// Whether the name is one the compiler gives the parameter of `_.`: a use of an outer value with it would bind to that parameter. - let isGeneratedArgumentName (ident: Ident) = + let canBeCapturedByGeneratedArgument (ident: Ident) = let prefix = "_arg" let name = ident.idText @@ -64,11 +63,9 @@ module private DotLambdaConversion = comments |> List.exists (fun (CommentTrivia.LineComment comment | CommentTrivia.BlockComment comment) -> Range.rangeContainsRange m comment) - /// The line the chain ends on, not counting what its argument brackets hold. A chain that goes on below `_` is - /// offside once it is the body of a lambda that starts further right; what the brackets hold is valid on any line. - let rec lastLineOutsideArguments (expr: SynExpr) = + let rec lastOffsideSensitiveLine (expr: SynExpr) = match expr with - | SynExpr.App(flag = ExprAtomicFlag.Atomic; funcExpr = func) -> lastLineOutsideArguments func + | 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 @@ -89,9 +86,8 @@ module private DotLambdaConversion = start <= finish && String.IsNullOrWhiteSpace(sourceText.ToString(TextSpan.FromBounds(start, finish))) - /// The parentheses around a lambda argument that hold nothing else, so `f (fun x -> x.P)` can become `f _.P`. [] - let (|ParenthesizedLambdaArgument|_|) (sourceText: SourceText) (lambda: SynExpr) (lambdaSpan: TextSpan) (path: SyntaxVisitorPath) = + 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 @@ -108,7 +104,7 @@ module private DotLambdaConversion = let rootEnd = RoslynHelpers.FSharpRangeToTextSpan(sourceText, root.idRange).End match path with - | ParenthesizedLambdaArgument sourceText lambda lambdaSpan parenSpan -> + | SoleParenthesizedLambdaArgument sourceText lambda lambdaSpan parenSpan -> let chain = sourceText.ToString(TextSpan.FromBounds(rootEnd, lambdaSpan.End)) TextChange(parenSpan, $"_{chain}") | _ -> TextChange(TextSpan.FromBounds(lambdaSpan.Start, rootEnd), "_") @@ -148,7 +144,7 @@ module private DotLambdaConversion = | ValueSome(struct (root, _)) when hasName parameter.idText root && (identifiers |> List.filter (hasName parameter.idText) |> List.length) = 1 - && not (List.exists isGeneratedArgumentName identifiers) + && not (List.exists canBeCapturedByGeneratedArgument identifiers) && not (hasCommentIn comments (Range.withEnd root.idRange.End lambda.Range)) -> Some @@ -162,7 +158,7 @@ module private DotLambdaConversion = | SyntaxNode.SynExpr(SynExpr.DotLambda(expr = body; range = range; trivia = trivia)) when not body.IsArbExprAndThusAlreadyReportedError && not (isInQuotation path) - && lastLineOutsideArguments body = range.StartLine + && lastOffsideSensitiveLine body = range.StartLine -> Some { From 12d14fd1ee0c6e3c6e17d39cdf70b519ffd2b6ff Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:09:55 +0100 Subject: [PATCH 09/10] Fold the standalone dot-lambda conversion tests into the tables The four success Facts and the multi-line cases become rows of the two conversion tables; the rows that were Facts keep their names as display names. The shorthand-to-lambda table takes the caret marker as its first argument, which also absorbs the table that needed another marker, and a binding that starts with "let " spells out its own definition. Test methods go from 8 to 3 and the cases stay at 61. SyntaxTreeOpsTests interpolates instead of calling sprintf. Co-Authored-By: Claude Sonnet 5.5 --- .../SyntaxTreeOpsTests.fs | 2 +- .../Refactors/ConvertDotLambdaTests.fs | 104 +++++------------- 2 files changed, 30 insertions(+), 76 deletions(-) diff --git a/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs b/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs index 907d13fcb45..5b9a00ce182 100644 --- a/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs +++ b/tests/FSharp.Compiler.Service.Tests/SyntaxTreeOpsTests.fs @@ -24,7 +24,7 @@ let ``tryPopUnaryArg splits the root off a chain that pushUnaryArg restores`` (s match tryPopUnaryArg body with | ValueSome(struct (root, popped)) -> Assert.Equal("x", root.idText) - Assert.Equal(sprintf "%A" body, sprintf "%A" (pushUnaryArg popped root)) + Assert.Equal($"%A{body}", $"%A{pushUnaryArg popped root}") | ValueNone -> Assert.Fail $"Expected {source} to have a root" [] diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs index 9ef2560fdc3..a6dacb16c3f 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertDotLambdaTests.fs @@ -10,10 +10,16 @@ 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 -let r = {binding} +{definition} """ let private caretAt (code: string) (marker: string) = @@ -42,6 +48,7 @@ let private refactored (code: string) (marker: string) = [ 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")>] @@ -49,57 +56,31 @@ let private refactored (code: string) (marker: string) = [ x .P", "_ .P")>] [ x (* why *) .P", "_ (* why *) .P")>] [ x.M(_argument)) xs", "List.map _.M(_argument) xs")>] -[ - x.Prop) xs""", - "List.map _.Prop 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\"\"\")")>] -let ``Shorthand converts to lambda`` (before: string, after: string) = - Assert.Equal(inModule after, refactored (inModule before) "_.") - -[] -[ x .P")>] -[ x (* why *) .P")>] -let ``Shorthand keeps what stands between the underscore and the dot`` (before: string, after: string) = - Assert.Equal(inModule after, refactored (inModule before) "_ ") - -[] -let ``Shorthand whose arguments span multiple lines converts to lambda`` () = - let before = - """ -module M - -let f : string -> string = - _.PadLeft( - 3 - ) -""" - - let after = - """ -module M - -let f : string -> string = - (fun x -> x.PadLeft( - 3 - )) -""" - - Assert.Equal(after, refactored before "_.") - -[] -let ``Nested shorthand converts on its own`` () = - Assert.Equal(inModule "_.M(fun x -> x.P)", refactored (inModule "_.M(_.P)") "_.P") +[ 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")>] @@ -142,30 +123,3 @@ let ``No action`` (binding: string) = |> List.find (fun marker -> binding.IndexOf(marker, StringComparison.Ordinal) >= 0) Assert.Empty(tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertDotLambdaRefactoring())) - -[] -let ``Lambda spanning multiple lines still converts to shorthand`` () = - let before = - """ -module M - -let r = - fun x -> - x.P -""" - - let after = - """ -module M - -let r = - _.P -""" - - Assert.Equal(after, refactored before "fun") - -[] -let ``Converting to shorthand and back restores the lambda`` () = - let original = inModule "List.map (fun x -> x.P) xs" - let shorthand = refactored original "fun" - Assert.Equal(original, refactored shorthand "_.") From b5496e8f88e9cfbb3f06162d782800e08e09440f Mon Sep 17 00:00:00 2001 From: XperiAndri Date: Thu, 15 Oct 2026 03:09:59 +0100 Subject: [PATCH 10/10] Keep one compact Visual Studio release note for the dot-lambda refactoring The shared internal syntax walker and its inverse are not user-visible FCS behavior, so the FCS note is deleted. The Visual Studio note names the refactoring action and nothing else. Co-Authored-By: Claude Sonnet 5.5 --- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 1 - docs/release-notes/.VisualStudio/18.vNext.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index f2f138d88ca..99464a277dd 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -23,7 +23,6 @@ ### Changed * Remove the unused internal `--test:NoErrorText` switch and its argument-only diagnostic formatting. ([PR #20742](https://github.com/dotnet/fsharp/pull/20742)) -* `SyntaxTreeOps.pushUnaryArg` walks the `_.` body chain through a shared helper that also backs the new internal `tryPopUnaryArg`, its inverse, so the Visual Studio `_.` refactoring accepts exactly the bodies the compiler does. ([PR #20535](https://github.com/dotnet/fsharp/pull/20535)) * Assign seven previously preview-only features to [F# 11.2](https://fsharp.github.io/fsharp-compiler-docs/release-notes/Language.html#11.2), now the default language version. `default`, `latest`, and `latestmajor` select F# 11.2; explicit `11` and `11.0` retain F# 11.0 behavior. Preview SDK projects use the same compiler default unless `LangVersion` is explicitly set. `FromEndSlicing` remains preview-only. ([PR #20617](https://github.com/dotnet/fsharp/pull/20617)) * Add `SynExceptionDefnReprTrivia` with the range of the `exception` keyword to `SynExceptionDefnRepr`. ([Fantomas Issue #3483](https://github.com/fsprojects/fantomas/issues/3483), [PR #20612](https://github.com/dotnet/fsharp/pull/20612)) * Add `WhenKeyword` to `SynMatchClauseTrivia` with the range of the `when` keyword in match clauses. ([PR #20734](https://github.com/dotnet/fsharp/pull/20734)) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 6f5621b4408..bdb38e8ab95 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -4,7 +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)) -* Refactoring to convert a lambda that only reads a member of its parameter (`fun x -> x.Prop`, `fun x -> x.M(y)`, `fun x -> x.Items[0]`) into the F# 8 shorthand `_.Prop`, and a `_.Prop` shorthand back into a `fun` lambda. ([Issue #16234](https://github.com/dotnet/fsharp/issues/16234), [PR #20535](https://github.com/dotnet/fsharp/pull/20535)) +* 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