From cd1a38591d02476005f413a4532611e3eff8b75b Mon Sep 17 00:00:00 2001 From: Florian Verdonck Date: Thu, 27 Aug 2026 17:53:37 +0200 Subject: [PATCH 1/3] Update FCS to 'Remove always-on language feature flag: RelaxWhitespace2', commit 4507f94a47b615329ba5ebd6afab5846ae3d898e This also takes in 'Extension members solve SRTP constraints (continuation)', commit 7727673ea13f0e09aa46abf6093da050cd16bc3a (dotnet/fsharp#19602), which changed the name of a `SynComponentInfo` from a `LongIdent` to a `SynType option` so that extension members can be declared on tuple types: type (int * int) with member this.Sum = fst this + snd this type struct (int * int) with member this.Sum = fst this + snd this `TypeNameNode.Identifier` is now a `Type` rather than an `IdentListNode`, built through `mkType`, so an ordinary name becomes `Type.LongIdent` and the tuple forms become `Type.Paren(Type.Tuple ...)` and `Type.StructTuple`. The printer uses `genType` for it, so the tuple prints as written and no layout decision was added. A nested module keeps an `IdentListNode` for its name, through `mkModuleName`, since a module name can only be an identifier. The parser only leaves the name out while recovering from a parse error, which never reaches the transformation, so that case is an invariant violation. The RelaxWhitespace2 commit (dotnet/fsharp#20320) only removes a dead feature flag from LexFilter and needed no change. --- Directory.Build.props | 2 +- src/Fantomas.Core.Tests/SignatureTests.fs | 25 ++++++++++++ .../TypeDeclarationTests.fs | 38 +++++++++++++++++++ src/Fantomas.Core/ASTTransformer.fs | 33 +++++++++++----- src/Fantomas.Core/CodePrinter.fs | 6 +-- src/Fantomas.Core/SyntaxOak.fs | 4 +- 6 files changed, 92 insertions(+), 16 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 11385b77da..55e9faa172 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -45,7 +45,7 @@ Some common use cases include: - d05075e098278aedcea3379159504d664628a495 + 4507f94a47b615329ba5ebd6afab5846ae3d898e diff --git a/src/Fantomas.Core.Tests/SignatureTests.fs b/src/Fantomas.Core.Tests/SignatureTests.fs index 88d010d7e0..6d66fabd15 100644 --- a/src/Fantomas.Core.Tests/SignatureTests.fs +++ b/src/Fantomas.Core.Tests/SignatureTests.fs @@ -577,6 +577,31 @@ type T with member Foo: int """ +[] +let ``tuple type extension member signature`` () = + formatSignatureString + """namespace ExtensionParts + +type (int * int) with + member Sum: int + +type struct (int * int) with + member Sum: int +""" + config + |> prepend newline + |> should + equal + """ +namespace ExtensionParts + +type (int * int) with + member Sum: int + +type struct (int * int) with + member Sum: int +""" + [] let ``comment above static member, 680`` () = formatSignatureString diff --git a/src/Fantomas.Core.Tests/TypeDeclarationTests.fs b/src/Fantomas.Core.Tests/TypeDeclarationTests.fs index 3ab3686427..3712ca15b5 100644 --- a/src/Fantomas.Core.Tests/TypeDeclarationTests.fs +++ b/src/Fantomas.Core.Tests/TypeDeclarationTests.fs @@ -323,6 +323,44 @@ type System.Int32 with member this.FromString(s: string) = System.Int32.Parse(s) """ +[] +let ``tuple type extension`` () = + formatSourceString + """ +type (int * int) with + member this.Sum = fst this + snd this + +type (string * int * bool) with + member this.Arity = 3 +""" + config + |> prepend newline + |> should + equal + """ +type (int * int) with + member this.Sum = fst this + snd this + +type (string * int * bool) with + member this.Arity = 3 +""" + +[] +let ``struct tuple type extension`` () = + formatSourceString + """ +type struct (int * int) with + member this.Sum = fst this + snd this +""" + config + |> prepend newline + |> should + equal + """ +type struct (int * int) with + member this.Sum = fst this + snd this +""" + [] let ``auto property`` () = formatSourceString diff --git a/src/Fantomas.Core/ASTTransformer.fs b/src/Fantomas.Core/ASTTransformer.fs index e934946949..532f879a93 100644 --- a/src/Fantomas.Core/ASTTransformer.fs +++ b/src/Fantomas.Core/ASTTransformer.fs @@ -2504,6 +2504,11 @@ let mkXmlDoc (px: PreXmlDoc) = let lines = Array.map (sprintf "///%s") xmlDoc.UnprocessedLines Some(XmlDocNode(lines, xmlDoc.Range)) +let mkModuleName (SynComponentInfo(synType = synType; range = m) as info) : IdentListNode = + match synType with + | Some(SynType.LongIdent(SynLongIdent(lid, _, _))) -> mkLongIdent lid + | _ -> invariantViolationAbout m info "module name is not an identifier" + let mkModuleDecl (creationAide: CreationAide) (decl: SynModuleDecl) = let declRange = decl.Range @@ -2535,7 +2540,7 @@ let mkModuleDecl (creationAide: CreationAide) (decl: SynModuleDecl) = | SynModuleDecl.ModuleAbbrev(ident, lid, StartRange 6 (mModule, _)) -> ModuleAbbrevNode(stn "module" mModule, mkIdent ident, mkLongIdent lid, declRange) |> ModuleDecl.ModuleAbbrev - | SynModuleDecl.NestedModule(SynComponentInfo(ats, _, _, lid, px, _, ao, _), + | SynModuleDecl.NestedModule(SynComponentInfo(ats, _, _, _, px, _, ao, _) as info, isRecursive, decls, _, @@ -2550,7 +2555,7 @@ let mkModuleDecl (creationAide: CreationAide) (decl: SynModuleDecl) = stn "module" mModule, mkSynAccess ao, isRecursive, - mkLongIdent lid, + mkModuleName info, stn "=" mEq, mkModuleDecls creationAide decls id, declRange @@ -3035,6 +3040,14 @@ let mkImplicitCtor range ) +/// The name of a type definition. Besides an identifier this can be a tuple type, for +/// `type (int * int) with ...` extensions. The parser only leaves it out while recovering from a +/// missing name, and a parse error never reaches the transformation. +let mkComponentInfoName (creationAide: CreationAide) (SynComponentInfo(synType = synType; range = m) as info) : Type = + match synType with + | Some t -> mkType creationAide t + | None -> invariantViolationAbout m info "component info without a name" + let mkTypeDefn (creationAide: CreationAide) (SynTypeDefn(typeInfo, typeRepr, members, implicitConstructor, range, trivia)) @@ -3042,9 +3055,9 @@ let mkTypeDefn = let typeNameNode = match typeInfo with - | SynComponentInfo(ats, tds, tcs, lid, px, _preferPostfix, ao, _) -> - let identifierNode = mkLongIdent lid - let mIdentifierNode = identifierNode.Range + | SynComponentInfo(ats, tds, tcs, _, px, _preferPostfix, ao, _) -> + let identifierNode = mkComponentInfoName creationAide typeInfo + let mIdentifierNode = (Type.Node identifierNode).Range let leadingKeyword = match trivia.LeadingKeyword with @@ -3905,7 +3918,7 @@ let mkModuleSigDecl (creationAide: CreationAide) (decl: SynModuleSigDecl) = | SynModuleSigDecl.ModuleAbbrev(ident, lid, StartRange 6 (mModule, _)) -> ModuleAbbrevNode(stn "module" mModule, mkIdent ident, mkLongIdent lid, declRange) |> ModuleDecl.ModuleAbbrev - | SynModuleSigDecl.NestedModule(SynComponentInfo(ats, _, _, lid, px, _, ao, _), + | SynModuleSigDecl.NestedModule(SynComponentInfo(ats, _, _, _, px, _, ao, _) as info, isRecursive, decls, _, @@ -3919,7 +3932,7 @@ let mkModuleSigDecl (creationAide: CreationAide) (decl: SynModuleSigDecl) = stn "module" mModule, mkSynAccess ao, isRecursive, - mkLongIdent lid, + mkModuleName info, stn "=" mEq, mkModuleSigDecls creationAide decls id, declRange @@ -3931,9 +3944,9 @@ let mkModuleSigDecl (creationAide: CreationAide) (decl: SynModuleSigDecl) = let mkTypeDefnSig (creationAide: CreationAide) (SynTypeDefnSig(typeInfo, typeRepr, members, range, trivia)) : TypeDefn = let typeNameNode = match typeInfo with - | SynComponentInfo(ats, tds, tcs, lid, px, _preferPostfix, ao, _) -> - let identifierNode = mkLongIdent lid - let mIdentifierNode = identifierNode.Range + | SynComponentInfo(ats, tds, tcs, _, px, _preferPostfix, ao, _) -> + let identifierNode = mkComponentInfoName creationAide typeInfo + let mIdentifierNode = (Type.Node identifierNode).Range let leadingKeyword = match trivia.LeadingKeyword with diff --git a/src/Fantomas.Core/CodePrinter.fs b/src/Fantomas.Core/CodePrinter.fs index a0a6bf0711..0a80a26b9b 100644 --- a/src/Fantomas.Core/CodePrinter.fs +++ b/src/Fantomas.Core/CodePrinter.fs @@ -4291,7 +4291,7 @@ let genImplicitConstructor (node: ImplicitConstructorNode) = ) node.Self -let hasTriviaAfterLeadingKeyword (identifier: IdentListNode) (accessibility: SingleTextNode option) = +let hasTriviaAfterLeadingKeyword (identifier: Node) (accessibility: SingleTextNode option) = let beforeAccess = match accessibility with | Some n -> n.HasContentBefore @@ -4310,7 +4310,7 @@ let genTypeDefn (td: TypeDefn) = // Workaround for https://github.com/fsprojects/fantomas/issues/628 let hasTriviaAfterLeadingKeyword = - hasTriviaAfterLeadingKeyword typeName.Identifier typeName.Accessibility + hasTriviaAfterLeadingKeyword (Type.Node typeName.Identifier) typeName.Accessibility genXml typeName.XmlDoc +> onlyIfNot hasAndKeyword (genAttributes typeName.Attributes) @@ -4319,7 +4319,7 @@ let genTypeDefn (td: TypeDefn) = +> onlyIf hasAndKeyword (sepSpace +> genOnelinerAttributes typeName.Attributes) +> sepSpace +> genAccessOpt typeName.Accessibility - +> genTypeAndParam (genIdentListNode typeName.Identifier) typeName.TypeParameters + +> genTypeAndParam (genType typeName.Identifier) typeName.TypeParameters +> onlyIfNot typeName.Constraints.IsEmpty (sepSpace +> genTypeConstraints typeName.Constraints) +> onlyIf hasTriviaAfterLeadingKeyword unindent +> leadingExpressionIsMultiline diff --git a/src/Fantomas.Core/SyntaxOak.fs b/src/Fantomas.Core/SyntaxOak.fs index c5e5f7cd3a..8421cd83cc 100644 --- a/src/Fantomas.Core/SyntaxOak.fs +++ b/src/Fantomas.Core/SyntaxOak.fs @@ -2598,7 +2598,7 @@ type TypeNameNode attributes: MultipleAttributeListNode option, leadingKeyword: SingleTextNode, ao: SingleTextNode option, - identifier: IdentListNode, + identifier: Type, typeParams: TyparDecls option, constraints: TypeConstraint list, implicitConstructor: ImplicitConstructorNode option, @@ -2615,7 +2615,7 @@ type TypeNameNode yield! noa attributes yield leadingKeyword yield! noa ao - yield identifier + yield Type.Node identifier yield! noa (Option.map TyparDecls.Node typeParams) yield! List.map TypeConstraint.Node constraints yield! noa implicitConstructor From 7874f94469172a82a4fec082db64209c5353837b Mon Sep 17 00:00:00 2001 From: Florian Verdonck Date: Thu, 27 Aug 2026 18:00:40 +0200 Subject: [PATCH 2/3] Clean up restoreRotatedReturnAttributes --- Directory.Build.props | 2 +- src/Fantomas.Core/ASTTransformer.fs | 68 +---------------------------- 2 files changed, 2 insertions(+), 68 deletions(-) diff --git a/Directory.Build.props b/Directory.Build.props index 55e9faa172..08b137a0a1 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -45,7 +45,7 @@ Some common use cases include: - 4507f94a47b615329ba5ebd6afab5846ae3d898e + 74ec4f7df70717a162d6ffd23007603cf298fb8b diff --git a/src/Fantomas.Core/ASTTransformer.fs b/src/Fantomas.Core/ASTTransformer.fs index 532f879a93..7f5cb80131 100644 --- a/src/Fantomas.Core/ASTTransformer.fs +++ b/src/Fantomas.Core/ASTTransformer.fs @@ -2227,73 +2227,11 @@ let (|OperatorWithStar|_|) (si: SynIdent) = ValueSome(IdentifierOrDot.Ident(stn $"( %s{text} )" ident.idRange)) | _ -> ValueNone -/// The parser moves a `[]` attribute written in front of a binding out of the -/// binding's attribute list and into the arity information, where nothing prints it. Put those -/// attributes back in the list they were written in, otherwise they are dropped from the output. -let restoreRotatedReturnAttributes - (attributes: SynAttributes) - (SynValData(valInfo = SynValInfo(returnInfo = SynArgInfo(attributes = arityAttributes)))) - (returnInfo: SynBindingReturnInfo option) - : SynAttributes - = - let sortAttributes (attributes: SynAttribute list) = - List.sortBy (fun (a: SynAttribute) -> a.Range.StartLine, a.Range.StartColumn) attributes - - let sortAttributeLists (attributes: SynAttributes) = - List.sortBy (fun (al: SynAttributeList) -> al.Range.StartLine, al.Range.StartColumn) attributes - - // A return attribute written on the return type, `let f x : [] int = x`, ends up in - // the arity information as well. That one is printed by the return type node, so leave it there. - let returnTypeAttributes = - match returnInfo with - | None -> [] - | Some(SynBindingReturnInfo(attributes = attributes)) -> - List.collect (fun (al: SynAttributeList) -> al.Attributes) attributes - - let rotated = - arityAttributes - |> List.collect (fun al -> al.Attributes) - |> List.filter (fun a -> - not (List.exists (fun (rta: SynAttribute) -> equals rta.Range a.Range) returnTypeAttributes) - ) - - match rotated with - | [] -> attributes - | rotated -> - let wasWrittenIn (al: SynAttributeList) (a: SynAttribute) = - RangeHelpers.rangeContainsRange al.Range a.Range - - let restored = - attributes - |> List.map (fun al -> - match List.filter (wasWrittenIn al) rotated with - | [] -> al - | inThisList -> - { al with - Attributes = sortAttributes (al.Attributes @ inThisList) - } - ) - - // An attribute list that held nothing but return attributes was removed altogether, so it - // has to be recreated. Its own range is gone, the attribute range is the closest we have. - let recreated = - rotated - |> List.choose (fun a -> - if List.exists (fun al -> wasWrittenIn al a) attributes then - None - else - Some({ Attributes = [ a ]; Range = a.Range }: SynAttributeList) - ) - - sortAttributeLists (restored @ recreated) - let mkBinding (creationAide: CreationAide) - (SynBinding(_, _, _, isMutable, attributes, xmlDoc, valData, pat, returnInfo, expr, _, _, trivia)) + (SynBinding(_, _, _, isMutable, attributes, xmlDoc, _, pat, returnInfo, expr, _, _, trivia)) (inKeyword: SingleTextNode option) = - let attributes = restoreRotatedReturnAttributes attributes valData returnInfo - let mkFunctionName (sli: SynLongIdent) : IdentListNode = match sli.IdentsWithTrivia with | [ prefix; OperatorWithStar operatorNode ] -> @@ -2380,16 +2318,12 @@ let mkExternBinding accessibility = accessibility attributes = attributes xmlDoc = xmlDoc - valData = valData headPat = pat returnInfo = returnInfo range = range trivia = trivia)) : ExternBindingNode = - let attributes: SynAttributes = - restoreRotatedReturnAttributes attributes valData returnInfo - let m = if not xmlDoc.IsEmpty then unionRanges xmlDoc.Range pat.Range From 929c9ab061210351c68d1f7ed9598f7e40c3dc4a Mon Sep 17 00:00:00 2001 From: Florian Verdonck Date: Thu, 27 Aug 2026 18:03:53 +0200 Subject: [PATCH 3/3] Update changelog --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b5519f35b0..2315ae965f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [8.0.0-alpha-022] - 2026-08-27 + +### Added + +- Extension members on tuple types, `type (int * int) with ...` and `type struct (int * int) with ...`, format instead of failing. The parser accepts them since [dotnet/fsharp#19602](https://github.com/dotnet/fsharp/pull/19602), and a type definition's name is now any type in the Oak tree rather than only an identifier. The tuple prints as written; no layout decision was added for it. [#3436](https://github.com/fsprojects/fantomas/pull/3436) + +### Changed + +- Update FCS to 'Rotate [] attributes during binding normalization', commit 74ec4f7df70717a162d6ffd23007603cf298fb8b [#3436](https://github.com/fsprojects/fantomas/pull/3436) + ## [8.0.0-alpha-021] - 2026-08-27 ### Fixed