Logo fantomas

Upgrade guide

We wish to capture all changes required to upgrade to a new version. Please note that the focus of this document is about how to upgrade.
New features are not covered in detail here, for those please refer to our changelog.
If you find something to be missing from this guide, please consider opening a PR to mend the gap instead of opening an issue.

v5.0

.editorconfig

fsharp_multiline_block_brackets_on_same_column = true
fsharp_experimental_stroustrup_style = true

console application

Miscellaneous

v5.1

.editorconfig

v5.2

.editorconfig

v6.0

.editorconfig

console application

Miscellaneous

v6.1

Miscellaneous

v7

console application

.editorconfig

v8 beta

.editorconfig

console application

What a run prints now

Each file that changed gets a sentence of its own, opening with a character that says what happened to it, and the run ends with a line of counts:

$ dotnet fantomas src
+ src/A.fs was formatted.

1 file formatted, 30 unchanged.
$ dotnet fantomas check src
! src/A.fs needs formatting.

1 file needs formatting, 30 already formatted. Run dotnet fantomas src to format it.

A run over a single named file is answered on its own terms, with no counts added to the one line. A check that finds nothing prints nothing and exits 0, as it always did.

The characters are + formatted, = unchanged, - skipped, ! needs formatting and x failed. Where the output goes to a terminal that can draw them, they are , =, , ! and instead; both sets carry the same five states and the words beside them say the same thing either way, so nothing is lost by the plainer set. Colour is used where the terminal takes it, dropped where the stream is redirected, and NO_COLOR is honoured. Standard out and standard error are decided separately, so piping one of them does not take the colour off the other.

If you parse this output, move to --json, which puts one document on standard out describing every file the run looked at.

Which .fantomasignore applies

Up to now the command line resolved one ignore file for the whole run, the nearest at or above the directory it started in. It is now found by walking up from the file being formatted, which is what the daemon has always done. Of everything in this release this is the change most likely to be noticed.

The first thing it changes is that the directory you run from no longer decides which ignore file applies. Up to v7 this skipped every file under src, on the strength of an ignore file that had nothing to do with them, and never read the one sitting above src:

$ cd tools && dotnet fantomas ../src     # v7: matched ../src/*.fs against tools/.fantomasignore

The second is that an ignore file in a subfolder was honoured by an editor and invisible to a pipeline, so the same file was skipped in one and formatted in the other:

repo/.fantomasignore          # found by both
repo/sub/.fantomasignore      # found by the editor, ignored by the command line
repo/sub/S.fs                 # skipped in the editor, formatted by CI

Both now resolve per file, so a nested ignore file that used to have no effect on a command line run has one. If a subfolder of your repository carries a .fantomasignore, check whether it names files you have been formatting all along.

Note that this is still the nearest ignore file and not the union of every one above it, which is where Fantomas differs from .gitignore. A pattern you wrote at the root of a repository therefore has no effect on a folder that carries an ignore file of its own, and fantomas doctor <file> will say so: it names the ignore file that governs the file, and names one further up whose pattern would have skipped it.

A folder that .fantomasignore names is no longer opened at all. Up to now every file inside it was found and then rejected one at a time. Nothing is formatted that was not formatted before, and a run over a repository that ignores a vendored checkout no longer reads it.

What a run says about skipped files

It no longer says how many, and --json no longer names them at all: a file an ignore pattern matched used to be listed in files with a status of ignored, and is absent now.

The number could not be honest. An ignore pattern that names a file can be counted, because the file is found and then set aside. One that names a folder cannot, because the folder is never opened and what is inside it is unknown by design. A count right about the first and blind to the second reads as though it covered both: Fantomas's own repository ignores three folders holding ninety six F# files, and the count said nought.

A file you name on the command line is the exception and still gets a line of its own, because a count is the only other place a path could be accounted for and no count carries this one:

$ dotnet fantomas A.fs Skipped.fs
- Skipped.fs was ignored by .fantomasignore.

1 file unchanged.

Everything else that was skipped is named at --verbosity d, a file and a folder each in its own words:

$ dotnet fantomas check --verbosity d .
[... DBG] './.deps' was not opened, .fantomasignore names it
[... DBG] './src/A.fs' was ignored

A run that looked at no file at all still says so on standard error and exits 0, so a glob that matches nothing and an ignore file that grew too wide are both still caught.

--out <folder> mirrors the input folder

Up to v7, every file found under the input folder was written straight into the root of the output folder, whatever its depth. Nesting collapsed, and two files with the same name in different subfolders overwrote each other without a warning. From v8, the path of each file relative to the input folder is preserved:

#input
src/A.fs
src/nested/A.fs

#v7: dotnet fantomas src --out out
out/A.fs           # whichever of the two was formatted last

#v8: dotnet fantomas src --out out
out/A.fs
out/nested/A.fs

This is what Getting Started has always described, so no action is needed if you followed the documentation. If you relied on the flattening to collect a tree of files into a single folder, that step now has to be done by whatever calls Fantomas.

An output folder that sits inside the input folder is left out of the scan. Up to v7, running dotnet fantomas src --out src/formatted picked the previous run's output back up as input, which the flattening hid; with the tree preserved it would nest one folder deeper on every run.

--out creates the folders it writes into

Up to v7, --out <file> failed with Failed to format file and exit code 1 when the folder of the path given to it did not exist. The root of an --out <folder> was always created for you. From v8, Fantomas creates whatever folder it has to write into, which includes the subfolders the mirroring above needs:

# v7: fails unless ./output exists
# v8: creates ./output
dotnet fantomas ./input/array.fs --out ./output/array.fs

If your build script creates the output folders before calling Fantomas, it can keep doing so. mkdir -p and its equivalents are unaffected by this change.

Formatting

Chains (dotted member access and calls) are laid out by a new set of rules, written up in full in Formatting chain expressions. They are a proposal for the F# style guide and may still change before v8.0.0 is final.

The layout rules only apply once a chain has to break, so a chain that already fits on one line is left alone. The spacing rule directly below is the exception: it applies whether or not the chain breaks, and it reaches a few things that are not chains at all. The changes here are the ones you are most likely to notice.

A call keeps its space only when the whole name is plain

fsharp_space_before_uppercase_invocation and fsharp_space_before_lowercase_invocation ask for a space before the parenthesis of a call. They now get a say only when the whole thing being called is a plain dotted name. A call, an index, a receiver that is not a name, or a type application anywhere in it, and the parenthesis stays tight whatever the settings say.

On default settings, where fsharp_space_before_lowercase_invocation is true:

// v7
xs.map(fun a -> a + 1).filter (fun a -> a > 1)
Foo().bar ()
myList.[7].someFunction (arg)
unbox<bool> (value)
jsOptions<Vis.Options> (fun o -> o.autoResize <- Some true)

// v8
xs.map(fun a -> a + 1).filter(fun a -> a > 1)
Foo().bar()
myList.[7].someFunction(arg)
unbox<bool>(value)
jsOptions<Vis.Options>(fun o -> o.autoResize <- Some true)

A plain dotted name is untouched, however long it is, so module-qualified functions keep the space they had:

// v7 and v8 agree
List.map (f)
Fantomas.FCS.Text.Range.unionRanges (r1, r2)

Fantomas does not add parentheses, so a generic application written without them never comes into it and unbox<int> obj is left as written.

This is the rule agreed at fslang-design#648, where the reasoning is laid out in full.

A run of property access wraps instead of overflowing

Navigation that does not fit is spread over balanced lines, chosen so the longest resulting line is as short as possible. Previously it was left to overflow the margin. At max_line_length = 80:

// v7
let navigation =
    builder.Services.Configuration.Providers.Defaults.Primary.Fallback.Value.Inner

// v8
let navigation =
    builder.Services.Configuration.Providers
        .Defaults.Primary.Fallback.Value.Inner

A comment no longer fans the chain out one step per line

A comment between the steps forces the chain to break, whatever the line length allows. In v7 that break was taken by every step. In v8 only a call claims a line of its own, and plain property access rides along at the front of the line belonging to the call it introduces:

// v7
let a =
    config
        // note
        .Settings
        .GetValue(key)

// v8
let a =
    config
        // note
        .Settings.GetValue(key)

A chain whose steps are all calls is unaffected, because each of those claims a line either way.

A match lambda keeps function beside the (

This applies with fsharp_multi_line_lambda_closing_newline = false, which is the default. With the setting set to true nothing changes.

In v7 a call reached through a dot pushed function onto its own line, while the very same call without a receiver kept it beside the (. The two disagreed about the same argument. A chain now follows what the receiverless call already did:

// v7 and v8 agree here: no receiver, `function` stays beside the `(`
let a =
    configureTheThing (function
        | Some v -> handleSome v
        | None -> handleNone ())

// v7: the same argument, reached through a dot, was laid out differently
let b =
    builder
        .Build()
        .Configure(
            function
            | Some v -> handleSome v
            | None -> handleNone ()
        )

// v8: the dot makes no difference any more
let b =
    builder
        .Build()
        .Configure(function
            | Some v -> handleSome v
            | None -> handleNone ())

A lambda whose opening line does not fit moves to its own line

This applies with fsharp_multi_line_lambda_closing_newline = true. With the setting left at its default of false nothing changes.

In v7 the parameters were hung underneath the opening parenthesis, which pushed them far to the right and could force the pattern itself to break. Now the whole argument moves down one line and indents normally. This affects calls with and without a receiver alike. At max_line_length = 80:

// v7
let dotted ifaces =
    ifaces
    |> List.tryPick (fun
                         (SynInterfaceImpl(
                             interfaceTy = ty; withKeyword = withRange)) ->
        Some(ty, withRange)
    )

// v8
let dotted ifaces =
    ifaces
    |> List.tryPick
        (fun (SynInterfaceImpl(interfaceTy = ty; withKeyword = withRange)) ->
            Some(ty, withRange)
        )

Fantomas.Core API

These only affect you if you consume Fantomas.Core as a library. Formatting source text through CodeFormatter.FormatDocumentAsync is unaffected.

No longer binary compatible with v7

Several discriminated unions are structs now. That changes nothing about how they are constructed, matched or compared, so no source of yours has to be edited, but an assembly compiled against v7 has to be rebuilt against v8.

Exceptions

CodeFormatter

Oak: chains

Expr.Chain no longer holds a flat ChainLink list. A chain is now a head expression, a list of dot-prefixed segments, and a terminal call:

type ExprChain(head: Expr, segments: ChainSegment list, terminal: ChainTerminal, range)

type ChainCall =
    | Paren of ExprParenNode
    | Unit of UnitNode

type ChainSegment =
    | DotMember of dot: SingleTextNode * expr: Expr
    | DotApplication of dot: SingleTextNode * expr: Expr * call: ChainCall
    | DotIndex of dot: SingleTextNode * indexExpr: Expr

type ChainTerminal =
    | SpaceAllowed of ChainCall
    | NoSpaceAllowed of ChainCall
    | NoTerminal

Mapping from the old model:

Removed

Replacement

ChainLink.Identifier

ExprChain.Head, when it is the first link

ChainLink.Dot

the dot field of the segment that follows it

ChainLink.Expr

ChainSegment.DotMember

ChainLink.AppParen

ChainSegment.DotApplication with ChainCall.Paren, or ExprChain.Terminal when last

ChainLink.AppUnit

ChainSegment.DotApplication with ChainCall.Unit, or ExprChain.Terminal when last

ChainLink.IndexExpr

ChainSegment.DotIndex

LinkSingleAppParen, LinkSingleAppUnit

ChainCall

A dot now always belongs to the step that follows it, so two adjacent dots are unrepresentable and you no longer have to pair links up yourself. The final call is Terminal rather than the last element of the list, and ChainTerminal.NoSpaceAllowed records that no space may precede its parenthesis. That is a grammar constraint, not a style choice: a space there reparses a.Foo (x).Bar() as a.Foo ((x).Bar()).

Oak: expressions absorbed into Expr.Chain

These Expr cases were removed. Each was a chain in all but name, and all four now arrive as Expr.Chain:

Expr.AppWithLambda is unchanged, but no longer receives calls that have no prefix arguments; those are chains now.

A dotted long identifier such as a.b.c yields Expr.Chain in expression position, where it previously yielded Expr.OptVar. Expr.OptVar still exists, and is still produced for long identifiers without dots and for the optional-argument form ?a.b. A single identifier is Expr.Ident, as before.

Oak: other node changes

val unbox: value: objnull -> 'T
type bool = System.Boolean
union case Option.Some: Value: 'T -> Option<'T>
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T member IsEmpty: bool member Item: index: int -> 'T with get ...
val map: mapping: ('T -> 'U) -> list: 'T list -> 'U list
union case Option.None: Option<'T>
val tryPick: chooser: ('T -> 'U option) -> list: 'T list -> 'U option
type 'T list = List<'T>

Type something to start searching.