From 5ed4a3f7e8ed2c8f1f740161e56a1ad25e2e6db0 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 14:48:43 +0200 Subject: [PATCH 1/4] Add a refactoring between option and struct optional member parameters ?x and [] ?x give the member body an option or a value option. The refactoring toggles the attribute, renames what keeps its type in the body (defaultArg, Option functions that do not return an option, match cases on the parameter) and rewrites ?x = value arguments at the call sites in the solution. Bodies that pass the parameter on or map it are refused. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 1 + .../src/FSharp.Editor/FSharp.Editor.fsproj | 1 + .../src/FSharp.Editor/FSharp.Editor.resx | 6 + .../ConvertOptionalParameterStruct.fs | 451 ++++++++++++++++++ .../FSharp.Editor.Tests.fsproj | 1 + .../ConvertOptionalParameterStructTests.fs | 121 +++++ 6 files changed, 581 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterStruct.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..aa8fba0d31e 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,6 +2,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)) +* Refactoring to switch a member's optional parameter between `?x` (`option`) and `[] ?x` (`voption`), rewriting `defaultArg`, `Option` functions and `match` cases on it in the body and `?x = value` arguments at its call sites in the solution. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..9c42bb8bb73 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..8222df5af71 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: + + Use 'voption' for optional parameter + + + Use 'option' for optional parameter + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterStruct.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterStruct.fs new file mode 100644 index 00000000000..0f6a5777446 --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterStruct.fs @@ -0,0 +1,451 @@ +// 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 System.Threading +open System.Threading.Tasks + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Text + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Features +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +open CancellableTasks + +[] +module private OptionalParameterConversion = + + [] + type Parameter = + { + Ident: Ident + OptionalValRange: range + StructAttribute: struct (SynAttributeList * SynAttribute) voption + MemberName: Ident + MemberRange: range + } + + member this.IsStruct = this.StructAttribute.IsSome + + let private spanOf (sourceText: SourceText) (m: range) = + RoslynHelpers.FSharpRangeToTextSpan(sourceText, m) + + let private hasText (text: string) (ident: Ident) = + String.Equals(ident.idText, text, StringComparison.Ordinal) + + let private isSame (expr: SynExpr) (other: SynExpr) = obj.ReferenceEquals(expr, other) + + let private isOperator (name: string) (expr: SynExpr) = + match expr with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ operator ])) -> hasText name operator + | _ -> false + + [] + let private (|SingleIdent|_|) (expr: SynExpr) = + match expr with + | SynExpr.Ident ident + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ ident ])) -> ValueSome ident + | _ -> ValueNone + + /// Functions of both option modules that take the option last and return the same type for either kind. + let private moduleFunctions = + set + [ + "contains" + "count" + "defaultValue" + "defaultWith" + "exists" + "forall" + "get" + "isNone" + "isSome" + "iter" + "toArray" + "toList" + "toNullable" + "toObj" + ] + + let private isStructAttribute (attribute: SynAttribute) = + attribute.Target.IsNone + && match List.tryLast attribute.TypeName.LongIdent with + | Some name -> hasText "Struct" name || hasText "StructAttribute" name + | None -> false + + let rec private tryOptionalVal (pat: SynPat) = + match pat with + | SynPat.OptionalVal(ident, m) -> ValueSome(struct (ident, m)) + | SynPat.Typed(pat = inner) + | SynPat.Attrib(pat = inner) + | SynPat.Paren(pat = inner) -> tryOptionalVal inner + | _ -> ValueNone + + let rec private attributesOf (pat: SynPat) = + [ + match pat with + | SynPat.Attrib(pat = inner; attributes = attributes) -> + yield! attributes + yield! attributesOf inner + | SynPat.Typed(pat = inner) + | SynPat.Paren(pat = inner) -> yield! attributesOf inner + | _ -> () + ] + + let private tryStructAttribute (attributes: SynAttributes) = + attributes + |> Seq.tryPickV (fun list -> + list.Attributes + |> Seq.tryFindV isStructAttribute + |> ValueOption.map (fun attribute -> struct (list, attribute))) + + /// The name and the whole range of the member whose parameters contain the pattern, when it is a member of a type. + let rec private tryMember (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynBinding(SynBinding(headPat = SynPat.LongIdent(longDotId = SynLongIdent(id = ids))) as binding) :: SyntaxNode.SynMemberDefn(SynMemberDefn.Member _) :: _ -> + match List.tryLast ids with + | Some name -> ValueSome(struct (name, binding.RangeOfBindingWithRhs)) + | None -> ValueNone + | SyntaxNode.SynPat _ :: rest -> tryMember rest + | _ -> ValueNone + + let tryParameter (caret: pos) (parseTree: ParsedInput) = + (ValueNone, parseTree) + ||> ParsedInput.fold (fun found path node -> + match found, node with + | ValueNone, SyntaxNode.SynPat pat when Position.posGeq caret pat.Range.Start && Position.posGeq pat.Range.End caret -> + match tryOptionalVal pat, tryMember path with + | ValueSome(struct (ident, m)), ValueSome(struct (memberName, memberRange)) -> + ValueSome + { + Ident = ident + OptionalValRange = m + StructAttribute = tryStructAttribute (attributesOf pat) + MemberName = memberName + MemberRange = memberRange + } + | _ -> ValueNone + | _ -> found) + + let rec private functionOf (expr: SynExpr) = + match expr with + | SynExpr.App(isInfix = false; funcExpr = funcExpr) -> functionOf funcExpr + | _ -> expr + + let private tryModuleQualifier (isStruct: bool) (func: SynExpr) = + match functionOf func with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ qualifier; name ])) when + hasText (if isStruct then "ValueOption" else "Option") qualifier + && moduleFunctions.Contains name.idText + -> + ValueSome [ qualifier ] + | _ -> ValueNone + + let private isCase (isStruct: bool) (ident: Ident) = + if isStruct then + hasText "ValueSome" ident || hasText "ValueNone" ident + else + hasText "Some" ident || hasText "None" ident + + let private tryClauseHeads (isStruct: bool) (clauses: SynMatchClause list) = + (ValueSome [], clauses) + ||> List.fold (fun heads (SynMatchClause(pat = pat)) -> + match heads, pat with + | ValueSome heads, (SynPat.LongIdent(longDotId = SynLongIdent(id = [ head ])) | SynPat.Named(ident = SynIdent(head, _))) when + isCase isStruct head + -> + ValueSome(head :: heads) + | ValueSome heads, (SynPat.Wild _ | SynPat.Named _) -> ValueSome heads + | _ -> ValueNone) + + /// The identifiers to rename for one use of the parameter in the member body, when the use keeps its type. + let private tryUseRenames (isStruct: bool) (node: SynExpr) (path: SyntaxVisitorPath) = + match node, path with + | SynExpr.LongIdent(longDotId = SynLongIdent(id = _ :: property :: _)), _ when + hasText "IsSome" property + || hasText "IsNone" property + || hasText "Value" property + -> + ValueSome [] + | _, SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SingleIdent func; argExpr = arg)) :: _ when + isSame arg node + && hasText (if isStruct then "defaultValueArg" else "defaultArg") func + -> + ValueSome [ func ] + | _, SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = func; argExpr = arg)) :: _ when isSame arg node -> + tryModuleQualifier isStruct func + | _, + SyntaxNode.SynExpr(SynExpr.App(isInfix = true; funcExpr = pipe; argExpr = arg)) :: SyntaxNode.SynExpr(SynExpr.App( + isInfix = false; argExpr = func)) :: _ when isSame arg node && isOperator "op_PipeRight" pipe -> + tryModuleQualifier isStruct func + | _, SyntaxNode.SynExpr(SynExpr.Match(expr = scrutinee; clauses = clauses)) :: _ when isSame scrutinee node -> + tryClauseHeads isStruct clauses + | _ -> ValueNone + + let private tryUseNode (parseTree: ParsedInput) (useRange: range) = + (useRange.Start, parseTree) + ||> ParsedInput.tryPickLast (fun path node -> + match node with + | SyntaxNode.SynExpr(SynExpr.Ident ident as expr) + | SyntaxNode.SynExpr(SynExpr.LongIdent(longDotId = SynLongIdent(id = ident :: _)) as expr) when + Position.posEq ident.idRange.Start useRange.Start + && Position.posEq ident.idRange.End useRange.End + -> + Some(expr, path) + | _ -> None) + + /// The identifiers to rename in the member body, when every use of the parameter keeps its type. + let tryBodyRenames (parseTree: ParsedInput) (isStruct: bool) (uses: range seq) = + (ValueSome [], uses) + ||> Seq.fold (fun renames useRange -> + match renames, tryUseNode parseTree useRange with + | ValueSome renames, Some(node, path) -> + tryUseRenames isStruct node path + |> ValueOption.map (fun more -> [ yield! more; yield! renames ]) + | _ -> ValueNone) + + /// `Some` ⟷ `ValueSome`, `Option` ⟷ `ValueOption`, `defaultArg` ⟷ `defaultValueArg`. + let private renamed (sourceText: SourceText) (isStruct: bool) (ident: Ident) = + let text = + match isStruct, ident.idText with + | false, "defaultArg" -> "defaultValueArg" + | true, "defaultValueArg" -> "defaultArg" + | false, name -> "Value" + name + | true, name -> name.Substring "Value".Length + + TextChange(spanOf sourceText ident.idRange, text) + + let private attributeRemoval (sourceText: SourceText) (list: SynAttributeList) (attribute: SynAttribute) = + match list.Attributes with + | [ _ ] -> + let listSpan = spanOf sourceText list.Range + let line = sourceText.Lines.GetLineFromPosition listSpan.Start + let after = sourceText.ToString(TextSpan.FromBounds(listSpan.End, line.End)) + TextSpan(listSpan.Start, listSpan.Length + after.Length - after.TrimStart().Length) + | attributes -> + let index = + attributes + |> List.findIndex (fun other -> obj.ReferenceEquals(other, attribute)) + + let attributeSpan = spanOf sourceText attribute.Range + + if index + 1 < attributes.Length then + TextSpan.FromBounds(attributeSpan.Start, (spanOf sourceText (List.item (index + 1) attributes).Range).Start) + else + TextSpan.FromBounds((spanOf sourceText (List.item (index - 1) attributes).Range).End, attributeSpan.End) + + let definitionChanges (sourceText: SourceText) (parameter: Parameter) (renames: Ident list) = + [ + match parameter.StructAttribute with + | ValueSome(struct (list, attribute)) -> TextChange(attributeRemoval sourceText list attribute, "") + | ValueNone -> TextChange(TextSpan((spanOf sourceText parameter.OptionalValRange).Start, 0), "[] ") + + for ident in renames do + renamed sourceText parameter.IsStruct ident + ] + + let private isAtomic (expr: SynExpr) = + match expr with + | SynExpr.Ident _ + | SynExpr.LongIdent _ + | SynExpr.Paren _ + | SynExpr.Const _ -> true + | _ -> false + + /// The values passed as `?name = value` in the arguments of a call. + let private optionalArgumentValues (name: string) (arguments: SynExpr) = + let arguments = + match arguments with + | SynExpr.Paren(expr = SynExpr.Tuple(exprs = exprs)) -> exprs + | SynExpr.Paren(expr = single) -> [ single ] + | _ -> [] + + arguments + |> List.choose (function + | SynExpr.App( + isInfix = false + funcExpr = SynExpr.App( + isInfix = true + funcExpr = equals + argExpr = SynExpr.LongIdent(isOptional = true; longDotId = SynLongIdent(id = [ argumentName ]))) + argExpr = value) when isOperator "op_Equality" equals && hasText name argumentName -> Some value + | _ -> None) + + let private valueChanges (sourceText: SourceText) (isStruct: bool) (value: SynExpr) = + let valueSpan = spanOf sourceText value.Range + let conversion = if isStruct then "toOption" else "ofOption" + let inverse = if isStruct then "ofOption" else "toOption" + + match functionOf value, value with + | SingleIdent head, _ when isCase isStruct head -> [ renamed sourceText isStruct head ] + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ qualifier; name ])), SynExpr.App(isInfix = false; argExpr = inner) when + hasText "ValueOption" qualifier && hasText inverse name + -> + [ TextChange(valueSpan, sourceText.ToString(spanOf sourceText inner.Range)) ] + | _ when isAtomic value -> [ TextChange(TextSpan(valueSpan.Start, 0), $"ValueOption.{conversion} ") ] + | _ -> + [ + TextChange(TextSpan(valueSpan.Start, 0), $"ValueOption.{conversion} (") + TextChange(TextSpan(valueSpan.End, 0), ")") + ] + + /// Changes to the `?name = value` arguments of the call whose function ends at the use of the member. + let callSiteChanges (sourceText: SourceText) (parseTree: ParsedInput) (isStruct: bool) (name: string) (useRange: range) = + let arguments = + (useRange.Start, parseTree) + ||> ParsedInput.tryPickLast (fun _ node -> + match node with + | SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = func; argExpr = arguments)) when + Position.posEq func.Range.End useRange.End + -> + Some arguments + | _ -> None) + + match arguments with + | Some arguments -> + optionalArgumentValues name arguments + |> List.collect (valueChanges sourceText isStruct) + | None -> [] + +[] +type internal FSharpConvertOptionalParameterStructRefactoring [] () = + inherit CodeRefactoringProvider() + + static let hasSignatureFile (document: Document) = + let signaturePath = document.FilePath + "i" + + document.Project.Documents + |> Seq.exists (fun d -> String.Equals(d.FilePath, signaturePath, StringComparison.OrdinalIgnoreCase)) + + static let tryGetSymbolUse (checkResults: FSharpCheckFileResults) (sourceText: SourceText) (ident: Ident) = + let line = sourceText.Lines[Line.toZ ident.idRange.EndLine].ToString() + checkResults.GetSymbolUseAtLocation(ident.idRange.EndLine, ident.idRange.EndColumn, line, [ ident.idText ]) + + static let isConvertibleMember (symbol: FSharpSymbol) = + match symbol with + | :? FSharpMemberOrFunctionOrValue as mfv -> + not ( + mfv.IsOverrideOrExplicitInterfaceImplementation + || mfv.IsDispatchSlot + || mfv.IsConstructor + || mfv.IsExtensionMember + ) + | _ -> false + + override _.ComputeRefactoringsAsync context = + cancellableTask { + let document = context.Document + + if not (document.IsFSharpSignatureFile || hasSignatureFile document) then + let! cancellationToken = CancellableTask.getCancellationToken () + let! sourceText = document.GetTextAsync cancellationToken + let! parseResults = document.GetFSharpParseResultsAsync(nameof FSharpConvertOptionalParameterStructRefactoring) + + let caret = + let linePosition = sourceText.Lines.GetLinePosition context.Span.Start + Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character + + match OptionalParameterConversion.tryParameter caret parseResults.ParseTree with + | ValueNone -> () + | ValueSome parameter -> + let! _, langVersion = document.GetFsharpParsingOptionsAsync(nameof FSharpConvertOptionalParameterStructRefactoring) + + if + parameter.IsStruct + || LanguageVersion(langVersion).SupportsFeature LanguageFeature.SupportValueOptionsAsOptionalParameters + then + let! _, checkResults = + document.GetFSharpParseAndCheckResultsAsync(nameof FSharpConvertOptionalParameterStructRefactoring) + + match + tryGetSymbolUse checkResults sourceText parameter.MemberName, + tryGetSymbolUse checkResults sourceText parameter.Ident + with + | Some memberUse, Some parameterUse when isConvertibleMember memberUse.Symbol -> + let uses = + checkResults.GetUsesOfSymbolInFile(parameterUse.Symbol, cancellationToken = cancellationToken) + // Named arguments at call sites are uses of the parameter too; only the body is rewritten here. + |> Seq.filter (fun symbolUse -> + not symbolUse.IsFromDefinition + && Position.posGeq symbolUse.Range.Start parameter.MemberRange.Start + && Position.posGeq parameter.MemberRange.End symbolUse.Range.End) + |> Seq.map _.Range + + match OptionalParameterConversion.tryBodyRenames parseResults.ParseTree parameter.IsStruct uses with + | ValueSome renames -> + let title = + if parameter.IsStruct then + SR.UseOptionForOptionalParameter() + else + SR.UseValueOptionForOptionalParameter() + + let definitionChanges = + OptionalParameterConversion.definitionChanges sourceText parameter renames + + let changedSolution = + cancellableTask { + let! cancellationToken = CancellableTask.getCancellationToken () + let! memberUses = SymbolHelpers.getSymbolUses memberUse document checkResults + + let usesByDocument = + memberUses + |> Seq.groupBy (fun (useDocument: Document, _) -> useDocument.Id) + |> Seq.toArray + + let mutable solution = document.Project.Solution + + for documentId, documentUses in usesByDocument do + let useDocument = solution.GetDocument documentId + let! text = useDocument.GetTextAsync cancellationToken + + let! useParseResults = + useDocument.GetFSharpParseResultsAsync( + nameof FSharpConvertOptionalParameterStructRefactoring + ) + + let changes = + [ + if documentId = document.Id then + yield! definitionChanges + + for _, useRange in documentUses do + yield! + OptionalParameterConversion.callSiteChanges + text + useParseResults.ParseTree + parameter.IsStruct + parameter.Ident.idText + useRange + ] + |> List.distinctBy _.Span + |> List.sortBy _.Span.Start + + solution <- solution.WithDocumentText(documentId, text.WithChanges changes) + + if not (usesByDocument |> Array.exists (fun (documentId, _) -> documentId = document.Id)) then + solution <- solution.WithDocumentText(document.Id, sourceText.WithChanges definitionChanges) + + return solution + } + + let action = + CodeAction.Create( + title, + Func>(fun cancellationToken -> + CancellableTask.start cancellationToken changedSolution), + title + ) + + context.RegisterRefactoring action + | ValueNone -> () + | _ -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..3ab9a058623 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj +++ b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj @@ -74,6 +74,7 @@ + diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs new file mode 100644 index 00000000000..356ff016d7f --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs @@ -0,0 +1,121 @@ +module FSharp.Editor.Tests.Refactors.ConvertOptionalParameterStructTests + +open System +open System.Threading + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Text + +open Microsoft.VisualStudio.FSharp.Editor +open Microsoft.VisualStudio.FSharp.Editor.CancellableTasks + +open Xunit + +open FSharp.Compiler.Diagnostics + +open FSharp.Editor.Tests.Helpers +open FSharp.Editor.Tests.Refactors.RefactorTestFramework + +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 FSharpConvertOptionalParameterStructRefactoring()) + + let _, checkResults = + document.GetFSharpParseAndCheckResultsAsync "test" + |> CancellableTask.runSynchronouslyWithoutCancellation + + Assert.Empty( + checkResults.Diagnostics + |> Array.filter (fun diagnostic -> diagnostic.Severity = FSharpDiagnosticSeverity.Error) + ) + + (document.GetTextAsync() |> GetTaskResult).ToString() + +let private actionsIn (context: TestContext) (code: string) (marker: string) = + tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertOptionalParameterStructRefactoring()) + +let private actionsAt (code: string) (marker: string) = + use context = TestContext.CreateWithCode code + actionsIn context code marker + +let private greeter = + "module M\n\ntype Greeter() =\n member _.Greet(name: string, ?greeting: string) =\n let greeting = defaultArg greeting \"Hello\"\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\nlet c = Greeter().Greet(\"Ada\", ?greeting = Some \"Hey\")\nlet d (g: string option) = Greeter().Greet(\"Ada\", ?greeting = g)\n" + +let private structGreeter = + "module M\n\ntype Greeter() =\n member _.Greet(name: string, [] ?greeting: string) =\n let greeting = defaultValueArg greeting \"Hello\"\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\nlet c = Greeter().Greet(\"Ada\", ?greeting = ValueSome \"Hey\")\nlet d (g: string option) = Greeter().Greet(\"Ada\", ?greeting = ValueOption.ofOption g)\n" + +[] +let ``Optional parameter and its optional arguments convert to value options`` () = + Assert.Equal(structGreeter, refactored greeter "?greeting") + +[] +let ``Struct optional parameter and its optional arguments convert back to options`` () = + Assert.Equal(greeter, refactored structGreeter "?greeting") + +[] +[ s\n | None -> 1\n", + "module M\n\ntype Counter() =\n member _.Next([] ?step: int) =\n match step with\n | ValueSome s -> s\n | ValueNone -> 1\n")>] +[ Option.defaultValue 0 else 1\n", + "module M\n\ntype Counter() =\n static member Describe([] ?step: int) =\n if ValueOption.isSome step && step.IsSome then step |> ValueOption.defaultValue 0 else 1\n")>] +[] ?step: int) = defaultValueArg step 1\n\nlet next (step: int option) = Counter.Next(?step = ValueOption.ofOption (if true then step else None))\n")>] +let ``Uses of the parameter in the member body follow the conversion`` (before: string, after: string) = + Assert.Equal(after, refactored before "?step") + Assert.Equal(before, refactored after "?step") + +[] +let ``Value option passed to a struct optional parameter is converted to an option`` () = + let before = + "module M\n\ntype Counter() =\n static member Next([] ?step: int) = defaultValueArg step 1\n\nlet next (step: int voption) = Counter.Next(?step = step)\n" + + let after = + "module M\n\ntype Counter() =\n static member Next(?step: int) = defaultArg step 1\n\nlet next (step: int voption) = Counter.Next(?step = ValueOption.toOption step)\n" + + Assert.Equal(after, refactored before "?step") + +[] +let ``Title names the target option kind`` () = + Assert.Equal("Use 'voption' for optional parameter", (actionsAt greeter "?greeting" |> Seq.exactlyOne).Title) + Assert.Equal("Use 'option' for optional parameter", (actionsAt structGreeter "?greeting" |> Seq.exactlyOne).Title) + +[] +[] +[] +[ int\n default _.M(?x) = defaultArg x 0\n", "?x)")>] +[] +[] +let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) + +[] +let ``Value option is not offered before F# 10`` () = + let code = "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" + + use context = + new TestContext(RoslynTestHelpers.CreateSolution(code, extraFSharpProjectOtherOptions = [| "--langversion:9.0" |])) + + Assert.Empty(actionsIn context code "?x") + +[] +let ``No action when the file has a signature`` () = + let code = "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" + + let signature = + "module M\n\ntype C =\n new: unit -> C\n static member M: ?x: int -> int\n" + + let document = RoslynTestHelpers.GetFsiAndFsDocuments signature code |> Seq.last + let actions = ResizeArray() + + let context = + CodeRefactoringContext(document, TextSpan(caretAt code "?x", 1), (fun action -> actions.Add action), CancellationToken.None) + + (new FSharpConvertOptionalParameterStructRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() + + Assert.False(document.IsFSharpSignatureFile) + Assert.Empty(actions) From 8245910bfef9d368a586c4c707a530ff2d22dca7 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 14:50:23 +0200 Subject: [PATCH 2/4] Add the optional parameter refactoring titles to the xlf files Co-Authored-By: Claude Opus 5 (1M context) --- .../src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.de.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.es.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.it.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf | 10 ++++++++++ .../src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf | 10 ++++++++++ 13 files changed, 130 insertions(+) diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..87260cb8df5 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -403,6 +403,11 @@ Zobrazit poznámky v Rychlých informacích Použít nameof + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Použijte interpolaci řetězce v trojitých uvozovkách. @@ -428,6 +433,11 @@ Zobrazit poznámky v Rychlých informacích Pokud chcete k výrazu přistupovat přes ukazatel, použijte .Value. + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Uzavřít výraz do závorek diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf index bce1941f0b1..ba5a472f1c0 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -403,6 +403,11 @@ Hinweise in QuickInfo anzeigen "nameof" verwenden + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Verwenden Sie die Interpolation von dreifachen Zeichenfolgen in Anführungszeichen. @@ -428,6 +433,11 @@ Hinweise in QuickInfo anzeigen ".Value" zum Dereferenzieren eines Ausdrucks verwenden + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Ausdruck in Klammern einschließen diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index fa8cb62c422..6f4f75ae800 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -403,6 +403,11 @@ Mostrar comentarios en Información rápida Usar 'nameof' + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Use la interpolación de cadenas entre comillas triples. @@ -428,6 +433,11 @@ Mostrar comentarios en Información rápida Usar ".Value" para desreferenciar la expresión + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Encapsular la expresión entre paréntesis diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index e7ec71e839e..443a491d75e 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -403,6 +403,11 @@ Afficher les notes dans Info express Utiliser « nameof » + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Utilisez l’interpolation de chaîne entre guillemets triples. @@ -428,6 +433,11 @@ Afficher les notes dans Info express Utilisez '.Value' pour déréférencer l'expression + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Mettre l'expression entre parenthèses diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 327a7ca362f..f893a71be9f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -403,6 +403,11 @@ Mostra i commenti in Informazioni rapide Usa 'nameof' + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Usare l'interpolazione di stringhe con virgolette triple. @@ -428,6 +433,11 @@ Mostra i commenti in Informazioni rapide Usa '.Value' per dereferenziare l'espressione + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Racchiudere l'espressione tra parentesi diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index d45234c011a..054c728f2f2 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -403,6 +403,11 @@ F# 構文規則に準拠した改行を追加して、署名を指定された 'nameof' を使用する + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. 三重引用符で囲まれた文字列補間を使用します。 @@ -428,6 +433,11 @@ F# 構文規則に準拠した改行を追加して、署名を指定された '.Value' を使用して式を逆参照する + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses 式をかっこで囲む diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 3248e0641ea..1b3d4772c0f 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -403,6 +403,11 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 'nameof' 사용 + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. 삼중 따옴표로 묶인 분자열 보간을 사용합니다. @@ -428,6 +433,11 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 식을 역참조하려면 '.Value' 사용 + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses 식을 괄호로 래핑 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index abc39f15da5..2ef5c44b2e6 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -403,6 +403,11 @@ Pokaż uwagi w szybkich informacjach Użyj wyrażenia "nameof" + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Użyj interpolacji ciągu z potrójnym cudzysłowem. @@ -428,6 +433,11 @@ Pokaż uwagi w szybkich informacjach Użyj elementu „.Value”, aby wyłuskać wyrażenie + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Ujmij wyrażenie w nawiasy 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..cf0522cf37d 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -403,6 +403,11 @@ Mostrar os comentários nas Informações Rápidas Usar 'nameof' + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Usar interpolação de cadeia de caracteres entre aspas triplas. @@ -428,6 +433,11 @@ Mostrar os comentários nas Informações Rápidas Use '.Value' para desreferenciar a expressão + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Coloque a expressão entre parênteses diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 47cda215312..8f239bc42c8 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -403,6 +403,11 @@ Show remarks in Quick Info Использовать "nameof" + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Использовать интерполяции строк в тройных кавычках. @@ -428,6 +433,11 @@ Show remarks in Quick Info Использовать синтаксис ".значение" для разыменования выражения + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses Заключите выражение в круглые скобки. diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 58aa5d54c43..778335735f6 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -403,6 +403,11 @@ Açıklamaları Hızlı Bilgide göster “Nameof” kullanın + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. Üçlü tırnak içine alınmış dize ilişkilendirmesini kullanın. @@ -428,6 +433,11 @@ Açıklamaları Hızlı Bilgide göster İfadeye başvurmak için '.Value' kullanın + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses İfadeyi parantez içinde sarmalayın 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..755993bcd3c 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -403,6 +403,11 @@ Show remarks in Quick Info 使用 "nameof" + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. 使用三引号字符串内插。 @@ -428,6 +433,11 @@ Show remarks in Quick Info 对取消引用表达式使用 ".Value" + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses 将表达式用括号括起来 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..7ec6fe94b79 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -403,6 +403,11 @@ Show remarks in Quick Info 使用 'nameof' + + Use 'option' for optional parameter + Use 'option' for optional parameter + + Use triple quoted string interpolation. 使用三引號字串插補。 @@ -428,6 +433,11 @@ Show remarks in Quick Info 使用 '.Value' 擷取運算式的值 + + Use 'voption' for optional parameter + Use 'voption' for optional parameter + + Wrap expression in parentheses 使用括弧包裝運算式 From 034ef5b9350621f702be3782801b24ee07f8d088 Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:39:30 +0200 Subject: [PATCH 3/4] Write the optional parameter test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- .../ConvertOptionalParameterStructTests.fs | 169 +++++++++++++++--- 1 file changed, 145 insertions(+), 24 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs index 356ff016d7f..f009148a511 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterStructTests.fs @@ -46,10 +46,34 @@ let private actionsAt (code: string) (marker: string) = actionsIn context code marker let private greeter = - "module M\n\ntype Greeter() =\n member _.Greet(name: string, ?greeting: string) =\n let greeting = defaultArg greeting \"Hello\"\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\nlet c = Greeter().Greet(\"Ada\", ?greeting = Some \"Hey\")\nlet d (g: string option) = Greeter().Greet(\"Ada\", ?greeting = g)\n" + """ +module M + +type Greeter() = + member _.Greet(name: string, ?greeting: string) = + let greeting = defaultArg greeting "Hello" + $"{greeting}, {name}" + +let a = Greeter().Greet("Ada") +let b = Greeter().Greet("Ada", greeting = "Hi") +let c = Greeter().Greet("Ada", ?greeting = Some "Hey") +let d (g: string option) = Greeter().Greet("Ada", ?greeting = g) +""" let private structGreeter = - "module M\n\ntype Greeter() =\n member _.Greet(name: string, [] ?greeting: string) =\n let greeting = defaultValueArg greeting \"Hello\"\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\nlet c = Greeter().Greet(\"Ada\", ?greeting = ValueSome \"Hey\")\nlet d (g: string option) = Greeter().Greet(\"Ada\", ?greeting = ValueOption.ofOption g)\n" + """ +module M + +type Greeter() = + member _.Greet(name: string, [] ?greeting: string) = + let greeting = defaultValueArg greeting "Hello" + $"{greeting}, {name}" + +let a = Greeter().Greet("Ada") +let b = Greeter().Greet("Ada", greeting = "Hi") +let c = Greeter().Greet("Ada", ?greeting = ValueSome "Hey") +let d (g: string option) = Greeter().Greet("Ada", ?greeting = ValueOption.ofOption g) +""" [] let ``Optional parameter and its optional arguments convert to value options`` () = @@ -60,12 +84,54 @@ let ``Struct optional parameter and its optional arguments convert back to optio Assert.Equal(greeter, refactored structGreeter "?greeting") [] -[ s\n | None -> 1\n", - "module M\n\ntype Counter() =\n member _.Next([] ?step: int) =\n match step with\n | ValueSome s -> s\n | ValueNone -> 1\n")>] -[ Option.defaultValue 0 else 1\n", - "module M\n\ntype Counter() =\n static member Describe([] ?step: int) =\n if ValueOption.isSome step && step.IsSome then step |> ValueOption.defaultValue 0 else 1\n")>] -[] ?step: int) = defaultValueArg step 1\n\nlet next (step: int option) = Counter.Next(?step = ValueOption.ofOption (if true then step else None))\n")>] +[ s + | None -> 1 +""", + """ +module M + +type Counter() = + member _.Next([] ?step: int) = + match step with + | ValueSome s -> s + | ValueNone -> 1 +""")>] +[ Option.defaultValue 0 else 1 +""", + """ +module M + +type Counter() = + static member Describe([] ?step: int) = + if ValueOption.isSome step && step.IsSome then step |> ValueOption.defaultValue 0 else 1 +""")>] +[] ?step: int) = defaultValueArg step 1 + +let next (step: int option) = Counter.Next(?step = ValueOption.ofOption (if true then step else None)) +""")>] let ``Uses of the parameter in the member body follow the conversion`` (before: string, after: string) = Assert.Equal(after, refactored before "?step") Assert.Equal(before, refactored after "?step") @@ -73,10 +139,24 @@ let ``Uses of the parameter in the member body follow the conversion`` (before: [] let ``Value option passed to a struct optional parameter is converted to an option`` () = let before = - "module M\n\ntype Counter() =\n static member Next([] ?step: int) = defaultValueArg step 1\n\nlet next (step: int voption) = Counter.Next(?step = step)\n" + """ +module M + +type Counter() = + static member Next([] ?step: int) = defaultValueArg step 1 + +let next (step: int voption) = Counter.Next(?step = step) +""" let after = - "module M\n\ntype Counter() =\n static member Next(?step: int) = defaultArg step 1\n\nlet next (step: int voption) = Counter.Next(?step = ValueOption.toOption step)\n" + """ +module M + +type Counter() = + static member Next(?step: int) = defaultArg step 1 + +let next (step: int voption) = Counter.Next(?step = ValueOption.toOption step) +""" Assert.Equal(after, refactored before "?step") @@ -85,35 +165,76 @@ let ``Title names the target option kind`` () = Assert.Equal("Use 'voption' for optional parameter", (actionsAt greeter "?greeting" |> Seq.exactlyOne).Title) Assert.Equal("Use 'option' for optional parameter", (actionsAt structGreeter "?greeting" |> Seq.exactlyOne).Title) +let private counter = + """ +module M + +type C() = + static member M(?x: int) = defaultArg x 0 +""" + [] -[] -[] -[ int\n default _.M(?x) = defaultArg x 0\n", "?x)")>] -[] -[] +[] +[] +[ int + default _.M(?x) = defaultArg x 0 +""", + "?x)")>] +[] +[] let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) [] let ``Value option is not offered before F# 10`` () = - let code = "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" - use context = - new TestContext(RoslynTestHelpers.CreateSolution(code, extraFSharpProjectOtherOptions = [| "--langversion:9.0" |])) + new TestContext(RoslynTestHelpers.CreateSolution(counter, extraFSharpProjectOtherOptions = [| "--langversion:9.0" |])) - Assert.Empty(actionsIn context code "?x") + Assert.Empty(actionsIn context counter "?x") [] let ``No action when the file has a signature`` () = - let code = "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" - let signature = - "module M\n\ntype C =\n new: unit -> C\n static member M: ?x: int -> int\n" + """ +module M + +type C = + new: unit -> C + static member M: ?x: int -> int +""" - let document = RoslynTestHelpers.GetFsiAndFsDocuments signature code |> Seq.last + let document = RoslynTestHelpers.GetFsiAndFsDocuments signature counter |> Seq.last let actions = ResizeArray() let context = - CodeRefactoringContext(document, TextSpan(caretAt code "?x", 1), (fun action -> actions.Add action), CancellationToken.None) + CodeRefactoringContext(document, TextSpan(caretAt counter "?x", 1), (fun action -> actions.Add action), CancellationToken.None) (new FSharpConvertOptionalParameterStructRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() From f5492e1360d765b6f016b2aee1ba213b491503cf Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 21:36:19 +0200 Subject: [PATCH 4/4] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index aa8fba0d31e..b4a81fd20fd 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -1,8 +1,8 @@ ### Added +* Refactoring to switch a member's optional parameter between `?x` (`option`) and `[] ?x` (`voption`), rewriting `defaultArg`, `Option` functions and `match` cases on it in the body and `?x = value` arguments at its call sites in the solution. ([PR #20546](https://github.com/dotnet/fsharp/pull/20546)) * 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)) -* Refactoring to switch a member's optional parameter between `?x` (`option`) and `[] ?x` (`voption`), rewriting `defaultArg`, `Option` functions and `match` cases on it in the body and `?x = value` arguments at its call sites in the solution. ### Fixed