F# through Fantomas
Curb formats the F# in a repository as well as the C#, from the same command and the same
.editorconfig. It does not format F# itself: it hands the files to Fantomas,
the version your repository pins, and keeps everything around it its own: which files, the cache, the
write, the byte-order mark, the exit code and the build integration. Nothing changes until a tool
manifest declares Fantomas.
Declare Fantomas as a local tool, and commit the manifest:
dotnet new tool-manifest
dotnet tool install fantomas
- once, if the repository has no .config/dotnet-tools.json yet
From then on curb format and curb check cover .fs, .fsi and .fsx files, and CI needs a
dotnet tool restore before it runs Curb, as it would before running Fantomas.
Curb finds the manifest the way the SDK does: from each file's folder upwards, reading
.config/dotnet-tools.json and dotnet-tools.json, and stopping at one marked "isRoot": true. The
nearest manifest that declares fantomas (or the older fantomas-tool) decides the version, so two
parts of a repository can pin two versions. Curb never installs anything and never uses a global
Fantomas.
| Situation | What happens |
|---|---|
| No manifest declares Fantomas | The F# files are not read and not counted. One line, fsharp: N file(s) skipped — no Fantomas in a tool manifest, says so. Exit code unaffected. |
| A manifest the SDK would refuse (not valid JSON, or Fantomas without a version) | The files it governs fail with the parse error and the manifest's path: exit 3, nothing written. A check does not pass by checking nothing. |
| Declared, but not restored | Exit 3, with a message to run dotnet tool restore. Detected without starting anything. A failure in check too: a check that could not check has passed nothing. |
| The Fantomas that starts is not the version the manifest pins | Exit 3, with the same message. Build metadata (+9b5cde1) is ignored. |
Each manifest's files succeed or fail on their own: one broken or unrestored manifest does not stop the files under another from being formatted.
| Fantomas version | What works |
|---|---|
| 8.0.0-alpha-016 and later | The daemon, and the command line fallback (fantomas check --json). Tested with 8.1.0. |
| Earlier | The daemon only (started with --daemon). If it cannot be used, the files fail with a message naming the version the fallback needs. |
Fantomas decides the layout, entirely. What Curb adds is the same rule it holds C# to: nothing is written that was not checked first.
The Fantomas daemon does not validate what it produces. Run over the 518 F# files in dotnet/fsharp's src/,
it returned 6 results that do not parse and 4 that change again when formatted a second time, all reported
as formatted. (The Fantomas command line rejects the 6 and writes the 4.) So Curb sends every
formatted result back to Fantomas, and writes it only if the answer is "unchanged". On that run every one
of the 136 files Curb wrote was "unchanged" to fantomas check afterwards.
| Fantomas says | Curb reports | The file |
|---|---|---|
| Unchanged | unchanged | Left alone, and remembered in the cache |
| Formatted, and unchanged when sent back | changed (check: would change) |
Written by Curb |
| Formatted, and the result does not parse | failed: invalid output | Left alone |
| Formatted, and the result changes again | failed: unstable | Left alone |
| Error | unparsable | Left alone |
Ignored (.fantomasignore) |
skipped | Left alone |
The second request costs time only for files that needed formatting; a file that is already formatted is
sent once. check applies the same rule, so check and format never disagree about a file.
Fantomas reads its settings from .editorconfig itself (the fsharp_* keys and the core keys such as
indent_size and max_line_length), and Curb passes nothing over the top. Curb reads only
charset, to decide the byte-order mark, as it does for C#. A setting Fantomas does not recognise is
reported once at the end of the run.
generated_code = true skips a file, and so does an <auto-generated> marker in the comments a file
opens with, which is what AssemblyInfo.fs and similar files carry.
Without end_of_line, Fantomas writes the line endings of the machine it runs on. A repository checked out
with LF on one machine and formatted on Windows changes every line. Set it for F# files:
[*.{fs,fsi,fsx}]
end_of_line = lf
With --cache (always on in the build integration) a file is skipped when its bytes and its resolved
settings match a run that watched Fantomas leave it unchanged. For F# the key also holds the exact
Fantomas version the manifest pins, the file's extension, and the content of the .fantomasignore that
governs it. Upgrading Fantomas re-checks the F# files and nothing else. Editing .fantomasignore
re-checks the files under it.
A run whose F# files all come from the cache starts no process at all.
One Fantomas daemon per run, started on the first F# file that misses the cache and stopped at the end,
with about one request per processor in flight. Curb starts it with dotnet exec on the tool's own
.dll, found from the SDK's tool resolver cache or the NuGet packages folder, which saves the extra
process dotnet tool run would cost.
If the daemon cannot be started or does not answer, Curb falls back to the Fantomas command line:
one fantomas check --json per few thousand files, never one per file. To format, the command line has to
write in place, so Curb lets it, checks the result a second time, and puts every file it handed over
back to its original bytes, whatever happened in between, before writing the stable results itself. A file
that cannot be put back is failed and reported. Set CURB_FANTOMAS_DAEMON=false to use the command line
from the start.
Nothing waits on Fantomas for ever. Each request to the daemon gets 60 seconds per 256 KB of source, and
each command line run 60 seconds per MB; set CURB_FANTOMAS_TIMEOUT to a number of seconds to change the
base. When time runs out, or the run is cancelled with Ctrl+C, Fantomas is stopped, none of that
manifest's files are written, and the run exits 3.
curb format --language fsharp formats only F#.
Measured on an M2 Pro (12 cores) with the native binary and Fantomas 8.1.0, medians of 5 to 9 runs. The
clean set is Fantomas's own src/ (150 files, repeated to 1,000 and 10.8 MB); the set that needs
formatting is the 140 files in dotnet/fsharp's src/ that Fantomas 8.1.0 would change, repeated to 1,000.
| Wall time | |
|---|---|
curb check --cache, 1,000 F# files, all cached |
34 ms, no process started |
| The same tree with no manifest declaring Fantomas | 19 ms |
curb check, 1 / 100 / 1,000 formatted files, nothing cached |
0.51 s / 0.72 s / 2.3 s |
curb format, 1 / 100 / 1,000 files that need formatting |
0.61 s / 3.9 s / 17.6 s |
| The same with the command line fallback | 1.01 s / 4.4 s / 28.2 s |
curb check on 10.8 MB of C#, for comparison |
0.51 s |
Fantomas does the formatting, so F# runs at Fantomas's speed: about 5 MB/s of already formatted source, against about 21 MB/s for Curb's own C#. The cache is what keeps it off the common path.
The curb package adds a CurbFSharp target to F# projects. It runs before CoreCompile with the same
Curb_Check, Curb_Bypass, Curb_Cache and Curb_LogLevel properties as the C# target, and raises
the same CURB0001 and CURB0002 diagnostics. Set Curb_FSharp to false to leave F# projects alone.
The target hands over the project's Compile items, minus anything under obj/ (the SDK generates
AssemblyAttributes.fs there with no header). It is skipped as up to date unless one of these changed
since the last clean run: a source, the project file, a tool manifest, a .fantomasignore, or an
.editorconfig above the project or beside a source. An unchanged project starts no process.
A project with no manifest declaring Fantomas builds as before; the target says why it did nothing at
Curb_LogLevel. A manifest that is not restored fails the build with CURB0002.
Nothing changes for a repository whose manifests do not declare Fantomas, except one line in the
summary when the run finds F# files, saying they were skipped and why. Once Fantomas is declared,
curb check fails on F# files Fantomas would change, so run curb format . once and commit the result,
or set Curb_FSharp=false in the build until you are ready.