Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions src/Fantomas.Core.Tests/CompilerDirectivesTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -3454,3 +3454,28 @@ let x =
3
#endif
"""

[<Test>]
let ``hash directive between attribute lists in mutually recursive type definition should not throw, 3174`` () =
formatSourceString
"""
type X = int
and
#if NET5_0_OR_GREATER
[<Interface>]
#endif
[<Class>] Y = int
"""
config
|> prepend newline
|> should
equal
"""
type X = int

and
#if NET5_0_OR_GREATER
[<Interface>]
#endif
[<Class>] Y = int
"""
22 changes: 22 additions & 0 deletions src/Fantomas.Core.Tests/PatternMatchingTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -2340,6 +2340,28 @@ let v, x =
| _ -> saepe, delectus
"""

[<Test>]
let ``trailing indented comment after last match clause preserves indentation, 2653`` () =
formatSourceString
"""
let foo =
match bar with
| Value1 -> 1
| Value2 -> 2
// | Value3 -> 3
"""
config
|> prepend newline
|> should
equal
"""
let foo =
match bar with
| Value1 -> 1
| Value2 -> 2
// | Value3 -> 3
"""

[<Test>]
let ``match on long anonymous record type discriminant does not cause indentation warning, 1903`` () =
let config60 = { config with MaxLineLength = 60 }
Expand Down
20 changes: 20 additions & 0 deletions src/Fantomas.Core.Tests/UnionTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -1090,6 +1090,26 @@ type Foo =
| Thing
"""

[<Test>]
let ``trailing indented comment after last union case preserves indentation, 2606`` () =
formatSourceString
"""
type Foo =
| A
| B
// | C
"""
config
|> prepend newline
|> should
equal
"""
type Foo =
| A
| B
// | C
"""

[<Test>]
let ``anonymous types in a DU formats correctly, 2621`` () =
formatSourceString
Expand Down
111 changes: 93 additions & 18 deletions src/Fantomas.Core/CodePrinter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -295,24 +295,58 @@ let genOnelinerAttributes (n: MultipleAttributeListNode option) =
match n with
| None -> sepNone
| Some n ->
let ats =
List.collect (fun (al: AttributeListNode) -> al.Attributes) n.AttributeLists
// If any attribute list after the first has a compiler directive in its trivia
// (e.g. #endif from a surrounding #if block), render each AttributeListNode
// individually via genNode so that ContentBefore directives are preserved.
// Without this, the per-list ContentBefore directives are silently dropped,
// causing mergeMultipleFormatResults to receive different hash-fragment counts
// per define variant and throw a FormatException.
// Unlike genAttributes, we do NOT add a trailing newline after the last list
// so that the type name (e.g. for `and` definitions) follows on the same line.
let hasDirectiveBetweenLists =
n.AttributeLists
|> List.skip (min 1 n.AttributeLists.Length)
|> List.exists (fun (al: AttributeListNode) ->
al.ContentBefore
|> Seq.exists (fun t ->
match t.Content with
| TriviaContent.Directive _ -> true
| _ -> false))

if hasDirectiveBetweenLists then
let lastIdx = n.AttributeLists.Length - 1

let genLists =
coli sepNone n.AttributeLists (fun idx al ->
let attrContent =
genSingleTextNode al.Opening
+> genAttributesCore al.Attributes
+> genSingleTextNode al.Closing

let suffix = if idx < lastIdx then sepNln else sepSpace
(attrContent |> genNode al) +> suffix)

genLists |> genNode n
else

let ats =
List.collect (fun (al: AttributeListNode) -> al.Attributes) n.AttributeLists

let openingToken =
List.tryHead n.AttributeLists
|> Option.map (fun (a: AttributeListNode) -> a.Opening)
let openingToken =
List.tryHead n.AttributeLists
|> Option.map (fun (a: AttributeListNode) -> a.Opening)

let closingToken =
List.tryLast n.AttributeLists
|> Option.map (fun (a: AttributeListNode) -> a.Closing)
let closingToken =
List.tryLast n.AttributeLists
|> Option.map (fun (a: AttributeListNode) -> a.Closing)

let genAttrs =
optSingle genSingleTextNode openingToken
+> genAttributesCore ats
+> optSingle genSingleTextNode closingToken
|> genNode n
let genAttrs =
optSingle genSingleTextNode openingToken
+> genAttributesCore ats
+> optSingle genSingleTextNode closingToken
|> genNode n

ifElse ats.IsEmpty sepNone (genAttrs +> sepSpace)
ifElse ats.IsEmpty sepNone (genAttrs +> sepSpace)

let genAttributes (node: MultipleAttributeListNode option) =
match node with
Expand Down Expand Up @@ -3492,16 +3526,42 @@ let genTypeDefn (td: TypeDefn) =
let hasTriviaAfterLeadingKeyword =
hasTriviaAfterLeadingKeyword typeName.Identifier typeName.Accessibility

// For `and` type definitions whose attribute lists contain compiler directives
// (e.g. `#if`/`#endif` between attribute lists), we must indent the attributes
// and type name so that the formatted output is valid F# in both define variants.
// Without indentation, `and\n[<Attr>] Y = int` at column 0 is a parse error.
let hasAttributeDirectivesForAndDefinition =
hasAndKeyword
&& (match typeName.Attributes with
| None -> false
| Some n ->
n.ContentBefore
|> Seq.exists (fun t ->
match t.Content with
| TriviaContent.Directive _ -> true
| _ -> false)
|| n.AttributeLists
|> List.skip (min 1 n.AttributeLists.Length)
|> List.exists (fun (al: AttributeListNode) ->
al.ContentBefore
|> Seq.exists (fun t ->
match t.Content with
| TriviaContent.Directive _ -> true
| _ -> false)))

let shouldIndentAfterKeyword =
hasTriviaAfterLeadingKeyword || hasAttributeDirectivesForAndDefinition

genXml typeName.XmlDoc
+> onlyIfNot hasAndKeyword (genAttributes typeName.Attributes)
+> genSingleTextNode typeName.LeadingKeyword
+> onlyIf hasTriviaAfterLeadingKeyword indent
+> onlyIf shouldIndentAfterKeyword indent
+> onlyIf hasAndKeyword (sepSpace +> genOnelinerAttributes typeName.Attributes)
+> sepSpace
+> genAccessOpt typeName.Accessibility
+> genTypeAndParam (genIdentListNode typeName.Identifier) typeName.TypeParameters
+> onlyIfNot typeName.Constraints.IsEmpty (sepSpace +> genTypeConstraints typeName.Constraints)
+> onlyIf hasTriviaAfterLeadingKeyword unindent
+> onlyIf shouldIndentAfterKeyword unindent
+> leadingExpressionIsMultiline
(optSingle
(fun imCtor -> sepSpaceBeforeClassConstructor +> genImplicitConstructor imCtor)
Expand Down Expand Up @@ -4090,9 +4150,24 @@ let genModule (m: ModuleOrNamespaceNode) =
|> genNode m

let addFinalNewline ctx =
let lastEvent = ctx.WriterEvents.TryHead
// Skip non-content events (indent restoration, etc.) to find the last "real" write event.
// A trailing comment inside an `atCurrentColumn` block emits RestoreAtColumn/RestoreIndent
// after its trailing WriteLineBecauseOfTrivia, so we must look past those.
let isNonContentEvent e =
match e with
| RestoreIndent _
| RestoreAtColumn _
| UnIndentBy _ -> true
| Write v -> String.IsNullOrWhiteSpace v
| _ -> false

let lastContentEvent =
ctx.WriterEvents
|> Queue.rev
|> Seq.skipWhile isNonContentEvent
|> Seq.tryHead

match lastEvent with
match lastContentEvent with
| Some WriteLineBecauseOfTrivia ->
if ctx.Config.InsertFinalNewline then
ctx
Expand Down
33 changes: 28 additions & 5 deletions src/Fantomas.Core/Trivia.fs
Original file line number Diff line number Diff line change
Expand Up @@ -244,12 +244,35 @@ let lineCommentAfterSourceCodeToTriviaInstruction (containerNode: Node) (trivia:
let node = visitLastChildNode node
node.AddAfter(trivia))

/// Find the shallowest node in the tree that ends on the given line and starts at the given column.
/// "Shallowest" means the largest-scope structural node (e.g. a match clause rather than its bar token).
/// Used to attach trailing indented single-line comments to the correct structural context.
let rec private findNodeEndingOnLine (containerNode: Node) (line: int) (column: int) : Node option =
// Check the current node first — if it matches, return it without going deeper.
// This gives us the shallowest (largest-scope) match, which is the structural node we want.
if containerNode.Range.EndLine = line && containerNode.Range.StartColumn = column then
Some containerNode
else
containerNode.Children |> Array.tryPick (fun c -> findNodeEndingOnLine c line column)

let simpleTriviaToTriviaInstruction (containerNode: Node) (trivia: TriviaNode) : unit =
containerNode.Children
|> Array.tryFind (fun node -> node.Range.StartLine > trivia.Range.StartLine)
|> Option.map (fun n -> n.AddBefore)
|> Option.orElseWith (fun () -> Array.tryLast containerNode.Children |> Option.map (fun n -> n.AddAfter))
|> Option.iter (fun f -> f trivia)
match containerNode.Children |> Array.tryFind (fun node -> node.Range.StartLine > trivia.Range.StartLine) with
| Some n -> n.AddBefore(trivia)
| None ->
// No child starts after the trivia. For indented single-line comments, try to find a more
// specific attachment point: a node that ends on the line just before the comment and starts
// at the same column. This preserves the comment's intended indentation level (e.g. a
// trailing commented-out match clause should stay at match-clause indentation, not fall to
// column 0 because it is attached to a module-level node).
let specificNode =
match trivia.Content with
| CommentOnSingleLine _ when trivia.Range.StartColumn > 0 ->
findNodeEndingOnLine containerNode (trivia.Range.StartLine - 1) trivia.Range.StartColumn
| _ -> None

match specificNode with
| Some node -> node.AddAfter(trivia)
| None -> containerNode.Children |> Array.tryLast |> Option.iter (fun n -> n.AddAfter(trivia))

let blockCommentToTriviaInstruction (containerNode: Node) (trivia: TriviaNode) : unit =
let nodeAfter =
Expand Down