From ebc0386bb9537ac8a3f629ccefd90fc3cffce97e Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:02:22 +0200 Subject: [PATCH 1/4] Add a refactoring between F# and .NET-compatible optional member parameters ?x: T is optional for F# callers and gives the body an option; [] x: T is optional for C# and VB callers too and gives the body the value. The refactoring moves the default between defaultArg x c in the body and the attribute, adds the InteropServices open when it is missing, and leaves call sites alone: F# accepts both forms the same way. 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 + .../ConvertOptionalParameterDefaultValue.fs | 460 ++++++++++++++++++ .../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 + ...nvertOptionalParameterDefaultValueTests.fs | 132 +++++ 19 files changed, 731 insertions(+) create mode 100644 vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs create mode 100644 vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index ba03f663967..9a10e85c38b 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 F# `?x: T` and the .NET-compatible `[] x: T`, moving the default between `defaultArg x c` in the body and the attribute; F# call sites keep working unchanged. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj b/vsintegration/src/FSharp.Editor/FSharp.Editor.fsproj index 319bdd5a264..48a1317eb29 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..074eda7aa43 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 [<Optional; DefaultParameterValue>] for optional parameter + + + Use F# '?' optional parameter + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs new file mode 100644 index 00000000000..65c0bdf547c --- /dev/null +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs @@ -0,0 +1,460 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +namespace Microsoft.VisualStudio.FSharp.Editor + +open System +open System.Composition + +open Microsoft.CodeAnalysis +open Microsoft.CodeAnalysis.CodeActions +open Microsoft.CodeAnalysis.CodeRefactorings +open Microsoft.CodeAnalysis.Formatting +open Microsoft.CodeAnalysis.Text + +open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Symbols +open FSharp.Compiler.Syntax +open FSharp.Compiler.Text + +open CancellableTasks + +[] +module private OptionalParameterDefaultValueConversion = + + [] + type Form = + /// `?x: T` + | FSharp of optionalValRange: range + /// `[] x: T`, with the attribute lists holding only those attributes. + | DotNet of lists: SynAttributeList list * defaultValue: SynExpr voption + + [] + type Parameter = + { + Ident: Ident + TypeName: SynType + Form: Form + MemberName: Ident + MemberBinding: SynBinding + } + + [] + type private Search = + | Searching + | Found of Parameter voption + + 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 (|SingleIdent|_|) (expr: SynExpr) = + match expr with + | SynExpr.Ident ident + | SynExpr.LongIdent(longDotId = SynLongIdent(id = [ ident ])) -> ValueSome ident + | _ -> ValueNone + + let private isAttribute (names: string list) (attribute: SynAttribute) = + attribute.Target.IsNone + && match List.tryLast attribute.TypeName.LongIdent with + | Some name -> names |> List.exists (fun candidate -> hasText candidate name) + | None -> false + + let private isOptional = isAttribute [ "Optional"; "OptionalAttribute" ] + + let private isDefaultParameterValue = + isAttribute [ "DefaultParameterValue"; "DefaultParameterValueAttribute" ] + + let private isStruct = isAttribute [ "Struct"; "StructAttribute" ] + + /// The annotated parameter the pattern declares, with the attributes around it and the range of `?x`, if any. + let rec private tryShape (attributes: SynAttributes) (pat: SynPat) = + match pat with + | SynPat.Paren(pat = inner) -> tryShape attributes inner + | SynPat.Attrib(pat = inner; attributes = more) -> tryShape [ yield! attributes; yield! more ] inner + | SynPat.Typed(pat = SynPat.OptionalVal(ident, m); targetType = typeName) -> + ValueSome(struct (ident, typeName, attributes, ValueSome m)) + | SynPat.Typed(pat = SynPat.Named(ident = SynIdent(ident, _); isThisVal = false); targetType = typeName) -> + ValueSome(struct (ident, typeName, attributes, ValueNone)) + | _ -> ValueNone + + let private tryForm (attributes: SynAttributes) (optionalValRange: range voption) = + match optionalValRange with + | ValueSome _ when attributes |> List.exists (fun list -> List.exists isStruct list.Attributes) -> ValueNone + | ValueSome m -> ValueSome(Form.FSharp m) + | ValueNone -> + let isOptionalAttribute attribute = + isOptional attribute || isDefaultParameterValue attribute + + let lists = + attributes + |> List.filter (fun list -> List.exists isOptionalAttribute list.Attributes) + + let listed = lists |> List.collect _.Attributes + + if not (List.exists isOptional listed && List.forall isOptionalAttribute listed) then + ValueNone + else + match List.tryFind isDefaultParameterValue listed with + | None -> ValueSome(Form.DotNet(lists, ValueNone)) + | Some attribute -> + match attribute.ArgExpr with + | SynExpr.Paren(expr = SynExpr.Const _ as constant) -> ValueSome(Form.DotNet(lists, ValueSome constant)) + | _ -> ValueNone + + 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)) + | None -> ValueNone + | SyntaxNode.SynPat _ :: rest -> tryMember rest + | _ -> ValueNone + + let tryParameter (caret: pos) (parseTree: ParsedInput) = + let search = + (Search.Searching, parseTree) + ||> ParsedInput.fold (fun search path node -> + match search, node with + | Search.Searching, SyntaxNode.SynPat pat when Position.posGeq caret pat.Range.Start && Position.posGeq pat.Range.End caret -> + match tryShape [] pat, tryMember path with + | ValueSome(struct (ident, typeName, attributes, optionalValRange)), ValueSome(struct (memberName, binding)) -> + tryForm attributes optionalValRange + |> ValueOption.map (fun form -> + { + Ident = ident + TypeName = typeName + Form = form + MemberName = memberName + MemberBinding = binding + }) + |> Search.Found + | _ -> Search.Searching + | _ -> search) + + match search with + | Search.Found parameter -> parameter + | Search.Searching -> ValueNone + + /// Whether the constant can be the `DefaultParameterValue` of a parameter of the named type. + let private isConstantOfType (typeText: string) (constant: SynConst) = + match typeText, constant with + | ("int" | "int32"), SynConst.Int32 _ + | "int64", SynConst.Int64 _ + | "int16", SynConst.Int16 _ + | "sbyte", SynConst.SByte _ + | "byte", SynConst.Byte _ + | "uint16", SynConst.UInt16 _ + | "uint32", SynConst.UInt32 _ + | "uint64", SynConst.UInt64 _ + | ("float" | "double"), SynConst.Double _ + | ("float32" | "single"), SynConst.Single _ + | "bool", SynConst.Bool _ + | "char", SynConst.Char _ + | "string", SynConst.String _ -> true + | _ -> false + + 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) + + /// `defaultArg x c` around a use of the parameter, with its constant and what contains it. + let private tryDefaultArgUse (parseTree: ParsedInput) (useRange: range) = + match tryUseNode parseTree useRange with + | Some(node, + SyntaxNode.SynExpr(SynExpr.App(isInfix = false; funcExpr = SingleIdent func; argExpr = arg) as inner) :: SyntaxNode.SynExpr(SynExpr.App( + isInfix = false; funcExpr = funcExpr; argExpr = (SynExpr.Const _ as defaultValue)) as application) :: rest) when + isSame arg node && hasText "defaultArg" func && isSame funcExpr inner + -> + ValueSome(struct (application, defaultValue, rest)) + | _ -> ValueNone + + /// The whole line of `let x = defaultArg x c` when that line does nothing but rebind the parameter. + let private tryShadowingLine (sourceText: SourceText) (name: string) (application: SynExpr) (path: SyntaxVisitorPath) = + match path with + | SyntaxNode.SynBinding(SynBinding(headPat = SynPat.Named(ident = SynIdent(ident, _)); expr = rhs; trivia = trivia)) :: SyntaxNode.SynExpr(SynExpr.LetOrUse letOrUse) :: _ when + isSame rhs application + && hasText name ident + && not letOrUse.IsRecursive + && not letOrUse.IsBang + && letOrUse.Bindings.Length = 1 + -> + let keyword = trivia.LeadingKeyword.Range + let line = sourceText.Lines[Line.toZ keyword.StartLine] + let lineText = line.ToString() + + if + application.Range.EndLine = keyword.StartLine + && String.IsNullOrWhiteSpace(lineText.Substring(0, keyword.StartColumn)) + && String.IsNullOrWhiteSpace(lineText.Substring application.Range.EndColumn) + then + ValueSome(TextSpan.FromBounds(line.Start, line.EndIncludingLineBreak)) + else + ValueNone + | _ -> ValueNone + + /// `?x: T` with every use being `defaultArg x c` becomes `[] x: T`. + let tryToDotNetChanges + (sourceText: SourceText) + (parseTree: ParsedInput) + (parameter: Parameter) + (optionalValRange: range) + (uses: range list) + = + let found = + (ValueSome [], uses) + ||> List.fold (fun found useRange -> + match found, tryDefaultArgUse parseTree useRange with + | ValueSome found, ValueSome defaultArgUse -> ValueSome(defaultArgUse :: found) + | _ -> ValueNone) + + let textOf (expr: SynExpr) = + sourceText.ToString(spanOf sourceText expr.Range) + + let typeText = + sourceText.ToString(spanOf sourceText parameter.TypeName.Range).Trim() + + let attributeText = + match found with + | ValueSome [] -> ValueSome "[] " + | ValueSome(struct (_, (SynExpr.Const(constant, _) as defaultValue), _) :: others) when + isConstantOfType typeText constant + && others + |> List.forall (fun struct (_, other, _) -> String.Equals(textOf other, textOf defaultValue, StringComparison.Ordinal)) + -> + ValueSome $"[] " + | _ -> ValueNone + + match found, attributeText with + | ValueSome found, ValueSome attributeText -> + [ + TextChange(TextSpan((spanOf sourceText optionalValRange).Start, 1), attributeText) + + for struct (application, _, path) in found do + match tryShadowingLine sourceText parameter.Ident.idText application path with + | ValueSome line -> TextChange(line, "") + | ValueNone -> TextChange(spanOf sourceText application.Range, parameter.Ident.idText) + ] + |> List.sortBy _.Span.Start + |> ValueSome + | _ -> ValueNone + + let private interopServices = [ "System"; "Runtime"; "InteropServices" ] + + let private hasInteropServicesOpen (parseTree: ParsedInput) = + (false, parseTree) + ||> ParsedInput.fold (fun found _ node -> + found + || match node with + | SyntaxNode.SynModule(SynModuleDecl.Open(target = SynOpenDeclTarget.ModuleOrNamespace(longId = SynLongIdent(id = ids)))) -> + (ids |> List.map _.idText) = interopServices + | _ -> false) + + let withInteropServicesOpen (parseTree: ParsedInput) (memberName: Ident) (text: SourceText) = + if hasInteropServicesOpen parseTree then + text + else + let insertionContext = + FSharp.Compiler.EditorServices.ParsedInput.FindNearestPointToInsertOpenDeclaration + memberName.idRange.StartLine + parseTree + (List.toArray interopServices) + FSharp.Compiler.EditorServices.OpenStatementInsertionPoint.TopLevel + + OpenDeclarationHelper.insertOpenDeclaration text insertionContext (String.Join(".", interopServices)) + |> fst + + let private leadingSpaces (line: TextLine) = + let text = line.ToString() + text.Length - text.TrimStart(' ').Length + + let private lineBreakOf (sourceText: SourceText) (line: TextLine) = + match line.EndIncludingLineBreak - line.End with + | 0 -> Environment.NewLine + | length -> sourceText.ToString(TextSpan(line.End, length)) + + let private listRemoval (sourceText: SourceText) (list: SynAttributeList) = + let listSpan = spanOf sourceText list.Range + let line = sourceText.Lines.GetLineFromPosition listSpan.End + let after = sourceText.ToString(TextSpan.FromBounds(listSpan.End, line.End)) + TextSpan(listSpan.Start, listSpan.Length + after.Length - after.TrimStart().Length) + + /// `[] x: T` becomes `?x: T` with `let x = defaultArg x c` starting the body. + let tryToFSharpChanges + (sourceText: SourceText) + (indentSize: int) + (parameter: Parameter) + (lists: SynAttributeList list) + (defaultValue: SynExpr voption) + = + let name = parameter.Ident.idText + + let defaultText = + match defaultValue with + | ValueSome value -> sourceText.ToString(spanOf sourceText value.Range) + | ValueNone -> "Unchecked.defaultof<_>" + + let declaration = $"let {name} = defaultArg {name} {defaultText}" + + let (SynBinding(expr = body; returnInfo = returnInfo; trivia = trivia)) = + parameter.MemberBinding + + let body = + match returnInfo, body with + | Some _, SynExpr.Typed(expr = inner) -> inner + | _ -> body + + let memberLine = sourceText.Lines[Line.toZ trivia.LeadingKeyword.Range.StartLine] + let lineBreak = lineBreakOf sourceText memberLine + + let bodyChange = + match trivia.EqualsRange with + | Some equals when body.Range.StartLine > equals.EndLine -> + let line = sourceText.Lines[Line.toZ body.Range.StartLine] + ValueSome(TextChange(TextSpan(line.Start, 0), $"{String(' ', body.Range.StartColumn)}{declaration}{lineBreak}")) + | Some equals when body.Range.StartLine = body.Range.EndLine -> + let indent = String(' ', leadingSpaces memberLine + indentSize) + + ValueSome( + TextChange( + TextSpan.FromBounds((spanOf sourceText equals).End, (spanOf sourceText body.Range).Start), + $"{lineBreak}{indent}{declaration}{lineBreak}{indent}" + ) + ) + | _ -> ValueNone + + bodyChange + |> ValueOption.map (fun bodyChange -> + [ + for list in lists do + TextChange(listRemoval sourceText list, "") + + TextChange(TextSpan((spanOf sourceText parameter.Ident.idRange).Start, 0), "?") + bodyChange + ] + |> List.sortBy _.Span.Start) + +[] +type internal FSharpConvertOptionalParameterDefaultValueRefactoring [] () = + 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 FSharpConvertOptionalParameterDefaultValueRefactoring) + + let caret = + let linePosition = sourceText.Lines.GetLinePosition context.Span.Start + Position.mkPos (Line.fromZ linePosition.Line) linePosition.Character + + match OptionalParameterDefaultValueConversion.tryParameter caret parseResults.ParseTree with + | ValueNone -> () + | ValueSome parameter -> + let! _, checkResults = + document.GetFSharpParseAndCheckResultsAsync(nameof FSharpConvertOptionalParameterDefaultValueRefactoring) + + match tryGetSymbolUse checkResults sourceText parameter.MemberName with + | Some memberUse when isConvertibleMember memberUse.Symbol -> + match parameter.Form with + | OptionalParameterDefaultValueConversion.Form.FSharp optionalValRange -> + match tryGetSymbolUse checkResults sourceText parameter.Ident with + | Some parameterUse -> + let memberRange = parameter.MemberBinding.RangeOfBindingWithRhs + + let uses = + checkResults.GetUsesOfSymbolInFile(parameterUse.Symbol, cancellationToken = cancellationToken) + // Named arguments at call sites are uses of the parameter too; only the body matters here. + |> Seq.filter (fun symbolUse -> + not symbolUse.IsFromDefinition + && Position.posGeq symbolUse.Range.Start memberRange.Start + && Position.posGeq memberRange.End symbolUse.Range.End) + |> Seq.map _.Range + |> List.ofSeq + + match + OptionalParameterDefaultValueConversion.tryToDotNetChanges + sourceText + parseResults.ParseTree + parameter + optionalValRange + uses + with + | ValueSome changes -> + let title = SR.UseDotNetOptionalParameter() + + let changedDocument = + cancellableTask { + let changed = + sourceText.WithChanges changes + |> OptionalParameterDefaultValueConversion.withInteropServicesOpen + parseResults.ParseTree + parameter.MemberName + + return document.WithText changed + } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | ValueNone -> () + | None -> () + | OptionalParameterDefaultValueConversion.Form.DotNet(lists, defaultValue) -> + let! options = document.GetOptionsAsync cancellationToken + + let indentSize = + options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) + + match + OptionalParameterDefaultValueConversion.tryToFSharpChanges + sourceText + indentSize + parameter + lists + defaultValue + with + | ValueSome changes -> + let title = SR.UseFSharpOptionalParameter() + + let changedDocument = + cancellableTask { return document.WithText(sourceText.WithChanges changes) } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | ValueNone -> () + | _ -> () + } + |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index cd8c46bf705..fa48527d978 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf @@ -388,11 +388,21 @@ Zobrazit poznámky v Rychlých informacích Formátování + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Použít syntaxi lambda jazyka F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Pokud chcete změnit hodnotu, 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..da4b4a85d91 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.de.xlf @@ -388,11 +388,21 @@ Hinweise in QuickInfo anzeigen Formatierung + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax F#-Lambdasyntax verwenden + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value "<-" zum Ändern des Werts 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..eea53c1c27c 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf @@ -388,11 +388,21 @@ Mostrar comentarios en Información rápida Formato + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Usar la sintaxis lambda de F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Usar "<-" para mutar el valor diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index e7ec71e839e..ca9c704879a 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf @@ -388,11 +388,21 @@ Afficher les notes dans Info express Mise en forme + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Utiliser la syntaxe lambda F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Utiliser '<-' pour muter la valeur diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 327a7ca362f..8f79594c622 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf @@ -388,11 +388,21 @@ Mostra i commenti in Informazioni rapide Formattazione + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Usa la sintassi lambda di F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Usare '<-' per modificare il valore diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index d45234c011a..0f8b6f188ea 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf @@ -388,11 +388,21 @@ F# 構文規則に準拠した改行を追加して、署名を指定された 書式設定 + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax F# のラムダ構文を使用する + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value '<-' を使用して値を変換する diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 3248e0641ea..65d266dd192 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf @@ -388,11 +388,21 @@ F# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 서식 + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax F# 람다 구문 사용 + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value '<-'를 사용하여 값 변경 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index abc39f15da5..47129620200 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf @@ -388,11 +388,21 @@ Pokaż uwagi w szybkich informacjach Formatowanie + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Użyj składni wyrażenia lambda języka F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Użyj znaku „<-” w celu zmodyfikowania wartoś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..8fc55f527b8 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf @@ -388,11 +388,21 @@ Mostrar os comentários nas Informações Rápidas Formatação + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Usar a sintaxe lambda F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Usar '<-' para modificar o valor diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 47cda215312..0b96c32921c 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf @@ -388,11 +388,21 @@ Show remarks in Quick Info Форматирование + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax Использовать синтаксис лямбда F# + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Используйте "<-", чтобы изменить значение diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 58aa5d54c43..2571a367425 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf @@ -388,11 +388,21 @@ Açıklamaları Hızlı Bilgide göster Biçimlendirme + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax F# lambda söz dizimini kullan + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value Değeri değiştirmek 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..b235ed3f940 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hans.xlf @@ -388,11 +388,21 @@ Show remarks in Quick Info 正在格式化 + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax 使用 F# lambda 语法 + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value 使用 "<-" 来更改值 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..2d54a381145 100644 --- a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf +++ b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.zh-Hant.xlf @@ -388,11 +388,21 @@ Show remarks in Quick Info 格式化 + + Use [<Optional; DefaultParameterValue>] for optional parameter + Use [<Optional; DefaultParameterValue>] for optional parameter + + Use F# lambda syntax 使用 F# lambda 語法 + + Use F# '?' optional parameter + Use F# '?' optional parameter + + Use '<-' to mutate value 使用 '<-' 來變動值 diff --git a/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj b/vsintegration/tests/FSharp.Editor.Tests/FSharp.Editor.Tests.fsproj index ecce1205b8c..24b23edc9f7 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/ConvertOptionalParameterDefaultValueTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs new file mode 100644 index 00000000000..792ca51f0fc --- /dev/null +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs @@ -0,0 +1,132 @@ +module FSharp.Editor.Tests.Refactors.ConvertOptionalParameterDefaultValueTests + +open System +open System.Threading + +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 FSharpConvertOptionalParameterDefaultValueRefactoring()) + + 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 actionsAt (code: string) (marker: string) = + use context = TestContext.CreateWithCode code + tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertOptionalParameterDefaultValueRefactoring()) + +[] +let ``Optional parameter with a default value becomes a .NET optional parameter and the open is added`` () = + let before = + "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\")\n" + + let after = + "module M\n\nopen System.Runtime.InteropServices\n\ntype Greeter() =\n member _.Greet(name: string, [] greeting: string) =\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\n" + + Assert.Equal(after, refactored before "?greeting") + +[] +[] step: int) =\n step + 1\n\nlet n = Counter.Next()\n")>] +[] step: float) =\n float value + step\n")>] +let ``Shadowing default converts both ways`` (fsharpForm: string, dotNetForm: string) = + Assert.Equal(dotNetForm, refactored fsharpForm "?step") + Assert.Equal(fsharpForm, refactored dotNetForm "step:") + +[] +let ``Inline defaults are replaced by the parameter`` () = + let before = + "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, ?step: int) = value + defaultArg step 1\n" + + let after = + "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, [] step: int) = value + step\n" + + Assert.Equal(after, refactored before "?step") + +[] +let ``Body on the member line moves below it when converting back`` () = + let before = + "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, [] step: int) = value + step\n" + + let after = + "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, ?step: int) =\n let step = defaultArg step 1\n value + step\n" + + Assert.Equal(after, refactored before "step:") + +[] +[] flag: bool) = 1\n")>] +[] step: int) =\n step + 1\n", + "step:", + "module M\n\nopen System.Runtime.InteropServices\n\ntype C() =\n static member M(?step: int) =\n let step = defaultArg step Unchecked.defaultof<_>\n step + 1\n")>] +let ``Optional without a default value`` (before: string, marker: string, after: string) = + Assert.Equal(after, refactored before marker) + +[] +let ``Title names the target form`` () = + let fsharpForm = + "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" + + let dotNetForm = + "module M\n\nopen System.Runtime.InteropServices\n\ntype C() =\n static member M([] x: int) = x\n" + + Assert.Equal("Use [] for optional parameter", (actionsAt fsharpForm "?x" |> Seq.exactlyOne).Title) + Assert.Equal("Use F# '?' optional parameter", (actionsAt dotNetForm "x:" |> Seq.exactlyOne).Title) + +[] +[] +[] +[] +[] +[] +[] ?x: int) = defaultValueArg x 0\n", "?x")>] +[] x: int) = x\n", "x:")>] +[ int\n default _.M(?x: int) = defaultArg x 0\n", "?x: int)")>] +[] +let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) + +[] +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 FSharpConvertOptionalParameterDefaultValueRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() + + Assert.False(document.IsFSharpSignatureFile) + Assert.Empty(actions) From 78c528e68d02a2a2035af022b7182017c344bf1f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 15:42:13 +0200 Subject: [PATCH 2/4] Write the .NET optional parameter test code as multi-line strings Co-Authored-By: Claude Opus 5 (1M context) --- ...nvertOptionalParameterDefaultValueTests.fs | 259 +++++++++++++++--- 1 file changed, 227 insertions(+), 32 deletions(-) diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs index 792ca51f0fc..93f7e6c5793 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs @@ -44,18 +44,77 @@ let private actionsAt (code: string) (marker: string) = [] let ``Optional parameter with a default value becomes a .NET optional parameter and the open is added`` () = let before = - "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\")\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 after = - "module M\n\nopen System.Runtime.InteropServices\n\ntype Greeter() =\n member _.Greet(name: string, [] greeting: string) =\n $\"{greeting}, {name}\"\n\nlet a = Greeter().Greet(\"Ada\")\nlet b = Greeter().Greet(\"Ada\", greeting = \"Hi\")\n" + """ +module M + +open System.Runtime.InteropServices + +type Greeter() = + member _.Greet(name: string, [] greeting: string) = + $"{greeting}, {name}" + +let a = Greeter().Greet("Ada") +let b = Greeter().Greet("Ada", greeting = "Hi") +""" Assert.Equal(after, refactored before "?greeting") [] -[] step: int) =\n step + 1\n\nlet n = Counter.Next()\n")>] -[] step: float) =\n float value + step\n")>] +[] step: int) = + step + 1 + +let n = Counter.Next() +""")>] +[] step: float) = + float value + step +""")>] let ``Shadowing default converts both ways`` (fsharpForm: string, dotNetForm: string) = Assert.Equal(dotNetForm, refactored fsharpForm "?step") Assert.Equal(fsharpForm, refactored dotNetForm "step:") @@ -63,68 +122,204 @@ let ``Shadowing default converts both ways`` (fsharpForm: string, dotNetForm: st [] let ``Inline defaults are replaced by the parameter`` () = let before = - "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, ?step: int) = value + defaultArg step 1\n" + """ +module M + +open System.Runtime.InteropServices + +type Counter() = + static member Next(value: int, ?step: int) = value + defaultArg step 1 +""" let after = - "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, [] step: int) = value + step\n" + """ +module M + +open System.Runtime.InteropServices + +type Counter() = + static member Next(value: int, [] step: int) = value + step +""" Assert.Equal(after, refactored before "?step") [] let ``Body on the member line moves below it when converting back`` () = let before = - "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, [] step: int) = value + step\n" + """ +module M + +open System.Runtime.InteropServices + +type Counter() = + static member Next(value: int, [] step: int) = value + step +""" let after = - "module M\n\nopen System.Runtime.InteropServices\n\ntype Counter() =\n static member Next(value: int, ?step: int) =\n let step = defaultArg step 1\n value + step\n" + """ +module M + +open System.Runtime.InteropServices + +type Counter() = + static member Next(value: int, ?step: int) = + let step = defaultArg step 1 + value + step +""" Assert.Equal(after, refactored before "step:") [] -[] flag: bool) = 1\n")>] -[] step: int) =\n step + 1\n", + """ +module M + +open System.Runtime.InteropServices + +type C() = + static member M([] flag: bool) = 1 +""")>] +[] step: int) = + step + 1 +""", "step:", - "module M\n\nopen System.Runtime.InteropServices\n\ntype C() =\n static member M(?step: int) =\n let step = defaultArg step Unchecked.defaultof<_>\n step + 1\n")>] + """ +module M + +open System.Runtime.InteropServices + +type C() = + static member M(?step: int) = + let step = defaultArg step Unchecked.defaultof<_> + step + 1 +""")>] let ``Optional without a default value`` (before: string, marker: string, after: string) = Assert.Equal(after, refactored before marker) +let private fsharpForm = + """ +module M + +type C() = + static member M(?x: int) = defaultArg x 0 +""" + [] let ``Title names the target form`` () = - let fsharpForm = - "module M\n\ntype C() =\n static member M(?x: int) = defaultArg x 0\n" - let dotNetForm = - "module M\n\nopen System.Runtime.InteropServices\n\ntype C() =\n static member M([] x: int) = x\n" + """ +module M + +open System.Runtime.InteropServices + +type C() = + static member M([] x: int) = x +""" Assert.Equal("Use [] for optional parameter", (actionsAt fsharpForm "?x" |> Seq.exactlyOne).Title) Assert.Equal("Use F# '?' optional parameter", (actionsAt dotNetForm "x:" |> Seq.exactlyOne).Title) [] -[] -[] -[] -[] -[] -[] ?x: int) = defaultValueArg x 0\n", "?x")>] -[] x: int) = x\n", "x:")>] -[ int\n default _.M(?x: int) = defaultArg x 0\n", "?x: int)")>] -[] +[] +[] +[] +[] +[] +[] ?x: int) = defaultValueArg x 0 +""", + "?x")>] +[] x: int) = x +""", + "x:")>] +[ int + default _.M(?x: int) = defaultArg x 0 +""", + "?x: int)")>] +[] let ``No action`` (code: string, marker: string) = Assert.Empty(actionsAt code marker) [] 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 fsharpForm |> Seq.last - 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) + CodeRefactoringContext(document, TextSpan(caretAt fsharpForm "?x", 1), (fun action -> actions.Add action), CancellationToken.None) (new FSharpConvertOptionalParameterDefaultValueRefactoring()).ComputeRefactoringsAsync(context).GetAwaiter().GetResult() From e0dedf6bd488894cb470f9ae266e69d3e20be84f Mon Sep 17 00:00:00 2001 From: Andrii Chebukin Date: Mon, 14 Sep 2026 20:03:04 +0200 Subject: [PATCH 3/4] Offer a struct optional parameter when converting back from the .NET form [] x: T now converts back to either ?x: T with let x = defaultArg x c, or, from F# 10, [] ?x: T with let x = defaultValueArg x c. Co-Authored-By: Claude Opus 5 (1M context) --- docs/release-notes/.VisualStudio/18.vNext.md | 2 +- .../src/FSharp.Editor/FSharp.Editor.resx | 3 + .../ConvertOptionalParameterDefaultValue.fs | 55 ++++++++---- .../FSharp.Editor/xlf/FSharp.Editor.cs.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.de.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.es.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.fr.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.it.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.ja.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.ko.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.pl.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.pt-BR.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.ru.xlf | 5 ++ .../FSharp.Editor/xlf/FSharp.Editor.tr.xlf | 5 ++ .../xlf/FSharp.Editor.zh-Hans.xlf | 5 ++ .../xlf/FSharp.Editor.zh-Hant.xlf | 5 ++ ...nvertOptionalParameterDefaultValueTests.fs | 87 +++++++++++++++++-- 17 files changed, 184 insertions(+), 28 deletions(-) diff --git a/docs/release-notes/.VisualStudio/18.vNext.md b/docs/release-notes/.VisualStudio/18.vNext.md index 9a10e85c38b..fe6156f1250 100644 --- a/docs/release-notes/.VisualStudio/18.vNext.md +++ b/docs/release-notes/.VisualStudio/18.vNext.md @@ -2,7 +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 F# `?x: T` and the .NET-compatible `[] x: T`, moving the default between `defaultArg x c` in the body and the attribute; F# call sites keep working unchanged. +* Refactoring to switch a member's optional parameter between F# `?x: T` and the .NET-compatible `[] x: T`, moving the default between `defaultArg x c` in the body and the attribute; F# call sites keep working unchanged. From F# 10 the .NET form also converts back to `[] ?x: T` with `defaultValueArg x c`. ### Fixed diff --git a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx index 074eda7aa43..26722fc9e22 100644 --- a/vsintegration/src/FSharp.Editor/FSharp.Editor.resx +++ b/vsintegration/src/FSharp.Editor/FSharp.Editor.resx @@ -374,4 +374,7 @@ Use live (unsaved) buffers for analysis Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + \ No newline at end of file diff --git a/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs index 65c0bdf547c..0b8d85cf6db 100644 --- a/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs +++ b/vsintegration/src/FSharp.Editor/Refactor/ConvertOptionalParameterDefaultValue.fs @@ -12,6 +12,7 @@ open Microsoft.CodeAnalysis.Formatting open Microsoft.CodeAnalysis.Text open FSharp.Compiler.CodeAnalysis +open FSharp.Compiler.Features open FSharp.Compiler.Symbols open FSharp.Compiler.Syntax open FSharp.Compiler.Text @@ -291,13 +292,15 @@ module private OptionalParameterDefaultValueConversion = let after = sourceText.ToString(TextSpan.FromBounds(listSpan.End, line.End)) TextSpan(listSpan.Start, listSpan.Length + after.Length - after.TrimStart().Length) - /// `[] x: T` becomes `?x: T` with `let x = defaultArg x c` starting the body. + /// `[] x: T` becomes `?x: T` with `let x = defaultArg x c` starting the body, + /// or `[] ?x: T` with `let x = defaultValueArg x c`. let tryToFSharpChanges (sourceText: SourceText) (indentSize: int) (parameter: Parameter) (lists: SynAttributeList list) (defaultValue: SynExpr voption) + (asStruct: bool) = let name = parameter.Ident.idText @@ -306,7 +309,13 @@ module private OptionalParameterDefaultValueConversion = | ValueSome value -> sourceText.ToString(spanOf sourceText value.Range) | ValueNone -> "Unchecked.defaultof<_>" - let declaration = $"let {name} = defaultArg {name} {defaultText}" + let struct (prefix, defaultFunction) = + if asStruct then + struct ("[] ?", "defaultValueArg") + else + struct ("?", "defaultArg") + + let declaration = $"let {name} = {defaultFunction} {name} {defaultText}" let (SynBinding(expr = body; returnInfo = returnInfo; trivia = trivia)) = parameter.MemberBinding @@ -341,7 +350,7 @@ module private OptionalParameterDefaultValueConversion = for list in lists do TextChange(listRemoval sourceText list, "") - TextChange(TextSpan((spanOf sourceText parameter.Ident.idRange).Start, 0), "?") + TextChange(TextSpan((spanOf sourceText parameter.Ident.idRange).Start, 0), prefix) bodyChange ] |> List.sortBy _.Span.Start) @@ -436,25 +445,33 @@ type internal FSharpConvertOptionalParameterDefaultValueRefactoring [ let! options = document.GetOptionsAsync cancellationToken + let! _, langVersion = + document.GetFsharpParsingOptionsAsync(nameof FSharpConvertOptionalParameterDefaultValueRefactoring) + let indentSize = options.GetOption(FormattingOptions.IndentationSize, FSharpConstants.FSharpLanguageName) - match - OptionalParameterDefaultValueConversion.tryToFSharpChanges - sourceText - indentSize - parameter - lists - defaultValue - with - | ValueSome changes -> - let title = SR.UseFSharpOptionalParameter() - - let changedDocument = - cancellableTask { return document.WithText(sourceText.WithChanges changes) } - - context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) - | ValueNone -> () + let register (asStruct: bool) (title: string) = + match + OptionalParameterDefaultValueConversion.tryToFSharpChanges + sourceText + indentSize + parameter + lists + defaultValue + asStruct + with + | ValueSome changes -> + let changedDocument = + cancellableTask { return document.WithText(sourceText.WithChanges changes) } + + context.RegisterRefactoring(CodeAction.Create(title, changedDocument, title)) + | ValueNone -> () + + register false (SR.UseFSharpOptionalParameter()) + + if LanguageVersion(langVersion).SupportsFeature LanguageFeature.SupportValueOptionsAsOptionalParameters then + register true (SR.UseFSharpStructOptionalParameter()) | _ -> () } |> CancellableTask.startAsTask context.CancellationToken diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.cs.xlf index fa48527d978..9de62478b06 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Pokud chcete změnit hodnotu, 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 da4b4a85d91..9d21fbbb3ef 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value "<-" zum Ändern des Werts verwenden diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.es.xlf index eea53c1c27c..c12b16c7bbb 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Usar "<-" para mutar el valor diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.fr.xlf index ca9c704879a..099e7f54fa8 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Utiliser '<-' pour muter la valeur diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.it.xlf index 8f79594c622..9c24afb31e6 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Usare '<-' per modificare il valore diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ja.xlf index 0f8b6f188ea..099521eaee0 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# 構文規則に準拠した改行を追加して、署名を指定された Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value '<-' を使用して値を変換する diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ko.xlf index 65d266dd192..2d96216f379 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# 구문 규칙에 맞는 줄 바꿈을 추가하여 지정된 너비에 시그 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value '<-'를 사용하여 값 변경 diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.pl.xlf index 47129620200..8177c4ed95f 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Użyj znaku „<-” w celu zmodyfikowania wartoś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 8fc55f527b8..597540ff90d 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Usar '<-' para modificar o valor diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.ru.xlf index 0b96c32921c..5cba6e3fb1d 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Используйте "<-", чтобы изменить значение diff --git a/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf b/vsintegration/src/FSharp.Editor/xlf/FSharp.Editor.tr.xlf index 2571a367425..7e21aa1886a 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value Değeri değiştirmek 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 b235ed3f940..8fb03ac17dd 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value 使用 "<-" 来更改值 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 2d54a381145..997ce973180 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 Use F# '?' optional parameter + + Use F# '[<Struct>] ?' optional parameter + Use F# '[<Struct>] ?' optional parameter + + Use '<-' to mutate value 使用 '<-' 來變動值 diff --git a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs index 93f7e6c5793..9219dc541fe 100644 --- a/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs +++ b/vsintegration/tests/FSharp.Editor.Tests/Refactors/ConvertOptionalParameterDefaultValueTests.fs @@ -20,11 +20,22 @@ 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) = +let private fsharpTitle = "Use F# '?' optional parameter" +let private structTitle = "Use F# '[] ?' optional parameter" + +let private refactoredBy (pick: CodeAction seq -> CodeAction) (code: string) (marker: string) = use context = TestContext.CreateWithCode code - let document = - tryRefactor code (caretAt code marker) context (new FSharpConvertOptionalParameterDefaultValueRefactoring()) + let action = + tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertOptionalParameterDefaultValueRefactoring()) + |> pick + + for operation in action.GetOperationsAsync CancellationToken.None |> GetTaskResult do + let applyChanges = operation :?> ApplyChangesOperation + applyChanges.Apply(context.Solution.Workspace, CancellationToken.None) + context.Solution <- applyChanges.ChangedSolution + + let document = RoslynTestHelpers.GetLastDocument context.Solution let _, checkResults = document.GetFSharpParseAndCheckResultsAsync "test" @@ -37,6 +48,11 @@ let private refactored (code: string) (marker: string) = (document.GetTextAsync() |> GetTaskResult).ToString() +let private refactored (code: string) (marker: string) = refactoredBy Seq.head code marker + +let private refactoredWith (title: string) (code: string) (marker: string) = + refactoredBy (Seq.find (fun action -> String.Equals(action.Title, title, StringComparison.Ordinal))) code marker + let private actionsAt (code: string) (marker: string) = use context = TestContext.CreateWithCode code tryGetRefactoringActions code (caretAt code marker) context (new FSharpConvertOptionalParameterDefaultValueRefactoring()) @@ -218,10 +234,8 @@ type C() = static member M(?x: int) = defaultArg x 0 """ -[] -let ``Title names the target form`` () = - let dotNetForm = - """ +let private dotNetForm = + """ module M open System.Runtime.InteropServices @@ -230,8 +244,65 @@ type C() = static member M([] x: int) = x """ +let private titlesOf (actions: CodeAction seq) = + actions |> Seq.map _.Title |> List.ofSeq + +[] +let ``Title names the target form`` () = Assert.Equal("Use [] for optional parameter", (actionsAt fsharpForm "?x" |> Seq.exactlyOne).Title) - Assert.Equal("Use F# '?' optional parameter", (actionsAt dotNetForm "x:" |> Seq.exactlyOne).Title) + Assert.Equal([ fsharpTitle; structTitle ], titlesOf (actionsAt dotNetForm "x:")) + +[] +[] step: int) = + value + step +""", + """ +module M + +open System.Runtime.InteropServices + +type Counter() = + static member Next(value: int, [] ?step: int) = + let step = defaultValueArg step 1 + value + step +""")>] +[] step: int) = + step + 1 +""", + """ +module M + +open System.Runtime.InteropServices + +type C() = + static member M([] ?step: int) = + let step = defaultValueArg step Unchecked.defaultof<_> + step + 1 +""")>] +let ``Converting back to a struct optional parameter uses defaultValueArg`` (dotNetForm: string, structForm: string) = + Assert.Equal(structForm, refactoredWith structTitle dotNetForm "step:") + +[] +let ``Struct optional parameter is not offered before F# 10`` () = + use context = + new TestContext(RoslynTestHelpers.CreateSolution(dotNetForm, extraFSharpProjectOtherOptions = [| "--langversion:9.0" |])) + + let actions = + tryGetRefactoringActions dotNetForm (caretAt dotNetForm "x:") context (new FSharpConvertOptionalParameterDefaultValueRefactoring()) + + Assert.Equal([ fsharpTitle ], titlesOf actions) [] [ Date: Mon, 14 Sep 2026 21:36:22 +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 fe6156f1250..660430f4793 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 F# `?x: T` and the .NET-compatible `[] x: T`, moving the default between `defaultArg x c` in the body and the attribute; F# call sites keep working unchanged. From F# 10 the .NET form also converts back to `[] ?x: T` with `defaultValueArg x c`. ([PR #20547](https://github.com/dotnet/fsharp/pull/20547)) * 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 F# `?x: T` and the .NET-compatible `[] x: T`, moving the default between `defaultArg x c` in the body and the attribute; F# call sites keep working unchanged. From F# 10 the .NET form also converts back to `[] ?x: T` with `defaultValueArg x c`. ### Fixed