Skip to content
Merged
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## [Unreleased]

### Changed

- Improved formatting performance. Formatting is roughly 1.5 to 2 times faster and allocates around 65% less memory, depending on hardware. [#3388](https://github.com/fsprojects/fantomas/pull/3388)
- The command line tool now runs with Server GC, which speeds up formatting multiple files in parallel at the cost of a larger memory footprint. [#3388](https://github.com/fsprojects/fantomas/pull/3388)

## [8.0.0-alpha-012] - 2026-04-16

### Changed
Expand Down
4 changes: 2 additions & 2 deletions global.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestPatch"
"rollForward": "latestMinor"
}
}
}
3 changes: 2 additions & 1 deletion src/Fantomas.Benchmarks/Fantomas.Benchmarks.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<Compile Include="Runners.fs" />
Expand All @@ -16,4 +17,4 @@
<ItemGroup>
<ProjectReference Include="..\Fantomas.Core\Fantomas.Core.fsproj" />
</ItemGroup>
</Project>
</Project>
32 changes: 23 additions & 9 deletions src/Fantomas.Core/CodePrinter.fs
Original file line number Diff line number Diff line change
Expand Up @@ -144,18 +144,32 @@ let recordCursorNode f (node: Node) (ctx: Context) =
{ ctxAfter with
FormattedCursor = Some formattedCursor }

// Most nodes carry no trivia at all. `col` over an empty sequence returns the context unchanged,
// so skipping it is equivalent to `sepNone` and saves allocating an enumerator per node.
let enterNode<'n when 'n :> Node> (n: 'n) =
col sepNone n.ContentBefore (genTrivia n)
if n.HasAnyContentBefore then
col sepNone n.ContentBefore (genTrivia n)
else
sepNone

let leaveNode<'n when 'n :> Node> (n: 'n) =
col sepNone n.ContentAfter (genTrivia n)

let genNode<'n when 'n :> Node> (n: 'n) (f: Context -> Context) =
onlyIfCtx (fun ctx -> ctx.DebugMode) (writerEvent (NodeStart(n.GetType().Name, sprintf "%O" n.Range)))
+> enterNode n
+> recordCursorNode f n
+> leaveNode n
+> onlyIfCtx (fun ctx -> ctx.DebugMode) (writerEvent (NodeEnd(n.GetType().Name, sprintf "%O" n.Range)))
if n.HasAnyContentAfter then
col sepNone n.ContentAfter (genTrivia n)
else
sepNone

let genNode<'n when 'n :> Node> (n: 'n) (f: Context -> Context) (ctx: Context) =
// The NodeStart/NodeEnd payloads are only ever observed via CodeFormatter.GetWriterEventsAsync.
// Keep them out of the default path entirely: building them costs a reflection call and a sprintf per node.
if ctx.DebugMode then
(writerEvent (NodeStart(n.GetType().Name, sprintf "%O" n.Range))
+> enterNode n
+> recordCursorNode f n
+> leaveNode n
+> writerEvent (NodeEnd(n.GetType().Name, sprintf "%O" n.Range)))
ctx
else
(enterNode n +> recordCursorNode f n +> leaveNode n) ctx

let genSingleTextNode (node: SingleTextNode) = !-node.Text |> genNode node

Expand Down
74 changes: 47 additions & 27 deletions src/Fantomas.Core/Context.fs
Original file line number Diff line number Diff line change
Expand Up @@ -253,15 +253,29 @@ type Context =
let writerEvent (e: WriterEvent) (ctx: Context) : Context =
// One event could contain a multiline string or code comments.
// These need to be split up in multiple events.
let evs = WriterEvents.normalize e
// Splitting is rare, so keep the common case off the list-building path entirely.
let isSingleEvent =
match e with
| Write s
// IndexOf(char) is the ordinal single-character search; string.Contains(char) is not available on netstandard2.0.
| WriteTrivia s -> isNull s || s.IndexOf('\n') < 0
| _ -> true

if isSingleEvent then
ctx.WriterEvents.Append(e) |> ignore

{ ctx with
WriterModel = WriterModel.update ctx.Config.MaxLineLength e ctx.WriterModel }
else
let evs = WriterEvents.normalize e

for ev in evs do
ctx.WriterEvents.Append(ev) |> ignore
for ev in evs do
ctx.WriterEvents.Append(ev) |> ignore

{ ctx with
WriterModel =
(ctx.WriterModel, evs)
||> List.fold (fun m e -> WriterModel.update ctx.Config.MaxLineLength e m) }
{ ctx with
WriterModel =
(ctx.WriterModel, evs)
||> List.fold (fun m e -> WriterModel.update ctx.Config.MaxLineLength e m) }

let hasWriteBeforeNewlineContent ctx =
String.isNotNullOrEmpty ctx.WriterModel.WriteBeforeNewline
Expand Down Expand Up @@ -333,32 +347,41 @@ type Context with

member x.Dump() = (dump false x).Code

let writeEventsOnLastLine ctx =
ctx.WriterEvents.ToRevSeq()
|> Seq.takeWhile (function
/// Walk backward to the first non-empty write on the current line, stopping at the line start.
let lastWriteEventOnLastLine ctx =
let mutable current = ctx.WriterEvents.Tail
let mutable result = None

while not (isNull current) do
match current.Event with
| WriteLine
| WriteLineBecauseOfTrivia
| WriteLineInsideStringConst -> false
| _ -> true)
|> Seq.choose (function
| WriteLineInsideStringConst -> current <- null
| Write w
| WriteTrivia w when (String.length w > 0) -> Some w
| _ -> None)
| WriteTrivia w when (String.length w > 0) ->
result <- Some w
current <- null
| _ -> current <- current.Prev

result

let lastWriteEventIsNewline ctx =
ctx.WriterEvents.ToRevSeq()
|> Seq.skipWhile (function
let mutable current = ctx.WriterEvents.Tail
let mutable result = false

while not (isNull current) do
match current.Event with
| RestoreIndent _
| RestoreAtColumn _
| UnIndentBy _
| EmptyWrite -> true
| _ -> false)
|> Seq.tryHead
|> Option.map (function
| EmptyWrite -> current <- current.Prev
| WriteLineBecauseOfTrivia
| WriteLine -> true
| _ -> false)
|> Option.defaultValue false
| WriteLine ->
result <- true
current <- null
| _ -> current <- null

result

/// Check if the DLL tail has a complete blank line (two or more newline events)
/// before any content. Walks backward, skipping indent/restore events.
Expand All @@ -383,9 +406,6 @@ let hasBlankLineBeforeLastWrite ctx =

newlineCount > 1

let lastWriteEventOnLastLine ctx =
writeEventsOnLastLine ctx |> Seq.tryHead

// A few utility functions from https://github.com/fsharp/powerpack/blob/master/src/FSharp.Compiler.CodeDom/generator.fs

/// Indent one more level based on configuration
Expand Down
60 changes: 44 additions & 16 deletions src/Fantomas.Core/SyntaxOak.fs
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,47 @@ type TriviaNode(content: TriviaContent, range: range) =
[<Interface>]
type Node =
abstract ContentBefore: TriviaNode seq
/// True when there is trivia before this node that should influence layout.
/// <c>Cursor</c> trivia is excluded on purpose: the caret position must never change the
/// formatted output. Use this to decide indentation, newlines and spacing.
/// This is NOT the negation of <c>HasAnyContentBefore</c> — see that member.
abstract HasContentBefore: bool
abstract ContentAfter: TriviaNode seq
/// See <c>HasContentBefore</c>.
abstract HasContentAfter: bool
/// True when there is any trivia before this node at all, <c>Cursor</c> included.
/// Use this to decide whether generating trivia can be skipped entirely: a node whose only
/// trivia is a <c>Cursor</c> still has to be generated, so <c>HasContentBefore</c> is the
/// wrong test for that and would silently drop the cursor. O(1), where <c>HasContentBefore</c>
/// enumerates.
abstract HasAnyContentBefore: bool
/// See <c>HasAnyContentBefore</c>.
abstract HasAnyContentAfter: bool
abstract Range: range
abstract Children: Node array
abstract AddBefore: triviaNode: TriviaNode -> unit
abstract AddAfter: triviaNode: TriviaNode -> unit
abstract AddCursor: pos -> unit
abstract TryGetCursor: pos option

/// True when the queue holds trivia that should influence layout, i.e. anything other than a
/// <c>Cursor</c>. Most nodes carry no trivia at all, so the O(1) count is tested first; beyond that
/// the queue's struct enumerator is walked directly, because going through <c>Seq</c> boxes it
/// (measured: 40 bytes and roughly 2x the time per call).
let private hasLayoutAffectingTrivia (nodes: Queue<TriviaNode>) =
if nodes.Count = 0 then
false
else
let mutable found = false
let mutable e = nodes.GetEnumerator()

while not found && e.MoveNext() do
match e.Current.Content with
| Cursor -> ()
| _ -> found <- true

found

/// Base implementation of <see cref="Node"/> shared by all concrete Oak node types.
/// Manages the mutable trivia queues (<c>ContentBefore</c> / <c>ContentAfter</c>) and
/// the optional in-editor cursor position. Concrete node types inherit from this class
Expand All @@ -75,25 +106,14 @@ type NodeBase(range: range) =

member _.ContentBefore: TriviaNode seq = nodesBefore

member _.HasContentBefore =
nodesBefore
|> Seq.filter (fun tn ->
match tn.Content with
| Cursor -> false
| _ -> true)
|> Seq.isEmpty
|> not
member _.HasContentBefore = hasLayoutAffectingTrivia nodesBefore

member _.ContentAfter: TriviaNode seq = nodesAfter

member _.HasContentAfter =
nodesAfter
|> Seq.filter (fun tn ->
match tn.Content with
| Cursor -> false
| _ -> true)
|> Seq.isEmpty
|> not
member _.HasContentAfter = hasLayoutAffectingTrivia nodesAfter

member _.HasAnyContentBefore = nodesBefore.Count > 0
member _.HasAnyContentAfter = nodesAfter.Count > 0

member _.Range = range
member _.AddBefore triviaNode = nodesBefore.Enqueue triviaNode
Expand Down Expand Up @@ -160,6 +180,8 @@ type NodeBase(range: range) =
member x.HasContentBefore = x.HasContentBefore
member x.ContentAfter = x.ContentAfter
member x.HasContentAfter = x.HasContentAfter
member x.HasAnyContentBefore = x.HasAnyContentBefore
member x.HasAnyContentAfter = x.HasAnyContentAfter
member x.Range = x.Range
member x.AddBefore triviaNode = x.AddBefore triviaNode
member x.AddAfter triviaNode = x.AddAfter triviaNode
Expand Down Expand Up @@ -1567,6 +1589,8 @@ type ElseIfNode(mElse: range, mIf: range, condition: Node, range) as elseIfNode
member _.HasContentBefore: bool = false
member _.ContentAfter: TriviaNode seq = Seq.empty
member _.HasContentAfter: bool = false
member _.HasAnyContentBefore: bool = false
member _.HasAnyContentAfter: bool = false
member _.Range = mElse

member _.AddBefore(triviaNode: TriviaNode) =
Expand All @@ -1585,6 +1609,8 @@ type ElseIfNode(mElse: range, mIf: range, condition: Node, range) as elseIfNode
member _.HasContentBefore: bool = false
member _.ContentAfter: TriviaNode seq = Seq.empty
member _.HasContentAfter: bool = false
member _.HasAnyContentBefore: bool = false
member _.HasAnyContentAfter: bool = false
member _.Range = mIf

member _.AddBefore(triviaNode: TriviaNode) =
Expand All @@ -1605,6 +1631,8 @@ type ElseIfNode(mElse: range, mIf: range, condition: Node, range) as elseIfNode
member _.HasContentBefore: bool = not (Seq.isEmpty nodesBefore)
member _.ContentAfter: TriviaNode seq = nodesAfter
member _.HasContentAfter: bool = not (Seq.isEmpty nodesAfter)
member _.HasAnyContentBefore: bool = nodesBefore.Count > 0
member _.HasAnyContentAfter: bool = nodesAfter.Count > 0
member _.Range = range
member _.AddBefore(triviaNode: TriviaNode) = nodesBefore.Enqueue triviaNode

Expand Down
3 changes: 2 additions & 1 deletion src/Fantomas/Fantomas.fsproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<IsPackable>true</IsPackable>
<RootNamespace>Fantomas</RootNamespace>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Fantomas.Client\Fantomas.Client.fsproj" />
Expand Down Expand Up @@ -46,4 +47,4 @@
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="Spectre.Console" />
</ItemGroup>
</Project>
</Project>
Loading