fsharp-refactor
0.6.7
See the version list below for details.
dotnet tool install --global fsharp-refactor --version 0.6.7
dotnet new tool-manifest
dotnet tool install --local fsharp-refactor --version 0.6.7
#tool dotnet:?package=fsharp-refactor&version=0.6.7
nuke :add-package fsharp-refactor --version 0.6.7
FSharp.Refactor
ReSharper-style functional refactoring suggestions for F# — light-bulb quick fixes in your editor, and a command-line tool that applies them in bulk. Built on FSharp.Analyzers.SDK.
Suggestions are Hint severity: they mark an opportunity, not a defect, and
never gate your build.
Using it
Quick start
Nothing to configure — the tool reads your project, reports what it would change, and only edits when you tell it to:
dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj --dry-run
That prints every fix it would make, with file and position, and writes nothing. When the list looks right, drop the flag to apply them:
fsharp-refactor Your.fsproj
It refuses a compilation that does not already build, and fails loudly if applying ever introduces an error. For light bulbs while you type, see VS Code / Ionide below.
Point it at whatever you have — the kind is read off the path:
Your.fsproj |
one project |
Thing.fs |
one source file — its project is found and analysed, but only that file is edited |
build.fsx |
one script — no MSBuild step at all, so it starts instantly. A script with unresolvable references is not refused: the syntactic rules still run over it (an fsi-run script may reference things only the run supplies) |
Your.sln, Your.slnx |
every F# project the solution lists |
src/ |
the solution in that directory, or the projects beneath it |
"src/**/*.fsproj" |
everything the glob matches |
What it changes
Examples:
| Before | After | |
|---|---|---|
| Correctness | let s = new FileStream(p, m) |
use s = new FileStream(p, m) |
| Correctness | raise (Exception "boom") |
failwith "boom" |
| Performance | s.Contains "x" |
s.Contains 'x' |
| Performance | xs \|> Seq.toList \|> List.map f |
xs \|> Seq.map f \|> Seq.toList |
| Idiom | match b with \| true -> 1 \| false -> 0 |
if b then 1 else 0 |
| Redundancy | new StringBuilder() |
StringBuilder() |
| Redundancy | [<SerializableAttribute>] |
[<Serializable>] |
| Diagnostics | failwith "Error" |
failwith $"Error, calling f with x: {x}" |
A spread of what the 100-odd rules do — the full list is in Refactorings:
Editor and CI setup
The analyzers ship as FSharp.Refactor.Analyzers.
The package is a development dependency: it only produces hints and quick
fixes — nothing from it flows into your compiled output.
NOTE: EVEN WHEN ADDING A NUGET REFERENCE, THIS ANALYSER WILL NOT COME TO OUTPUT PATH (BIN) AND WILL NOT BE PART OF YOUR PROJECT.
VS Code / Ionide
Reference the package from the project you want analyzed:
<PackageReference Include="FSharp.Refactor.Analyzers" Version="*" PrivateAssets="all" />
then point Ionide at the restored analyzers in .vscode/settings.json:
{
"FSharp.enableAnalyzers": true,
"FSharp.analyzersPath": [
"~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs"
]
}
replacing <version> with the version restore actually picked (the NuGet
cache always keys folders by version, so this one path cannot float; check
with ls ~/.nuget/packages/fsharp.refactor.analyzers/). On Windows the
cache lives under %USERPROFILE%\.nuget\packages. Suggestions
appear as Hint-severity diagnostics with a light-bulb one-click fix.
CLI / CI
dotnet tool install --global fsharp-analyzers
fsharp-analyzers --project src/YourProject.fsproj --analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs --code-root . --report analysis.sarif
fsharp-analyzers is the analyzer HOST, and it is the dotnet tool you
install. This package is not a tool: it is a library of analyzer assemblies
the host loads, so it is passed as a directory rather than installed. That
directory sits in the NuGet cache because an analyzer package deliberately
has no lib/ folder and is marked a development dependency — a
PackageReference therefore puts nothing in your bin, and after a restore
the cache is where the assemblies live. --analyzers-path takes any folder
holding them and searches it recursively.
The CLI only REPORTS; it never edits your files, which is what you want in
CI (SARIF output works in GitHub code scanning). To apply the fixes, use our
own tool below. Individual rules can be turned off per repository with a
fsharprefactor.json — see Configuration below.
Applying fixes from the command line
The fsharp-refactor
dotnet tool applies the quick fixes directly to your files:
dotnet tool install --global fsharp-refactor
fsharp-refactor Your.fsproj [--dry-run] [--codes FR0002,FR0031] [--api-changes] [--jobs 4] [--max-passes 5]
(or from this repository:
dotnet run --project src/FSharp.Refactor.Tool -c Release -- Your.fsproj ...)
For a project it takes the exact compiler arguments from MSBuild; for a script FCS resolves the references itself and MSBuild never runs. Either way it then runs every analyzer, applies non-overlapping fixes bottom-up, and re-analyzes until a pass applies nothing — a fix can enable further fixes. It refuses a compilation that already has errors, and fails loudly if applying ever introduces one.
| Flag | |
|---|---|
--dry-run |
Report only: lists every fix it would make, with file and position, and writes nothing. Rewriting is never implicit — drop the flag to let it edit. |
--codes FR0002,FR0031 |
Restrict the run to chosen rules. |
--categories <list> |
Restrict the run to kinds of rule: correctness, performance, idiom, cosmetic. Combined with --codes it narrows further, in either order. See Someone else's codebase. |
--jobs <n> |
Typecheck that many files at once (default 4, clamped to 2–4 by core count). Trades CPU for wall clock; because FCS reuses each file's prefix within one incremental build, the gain peaks around 4 and reverses if pushed higher. --jobs 1 is the sequential sweep. |
--framework <tfm> |
Analyse against this target framework instead of the narrowest one — see below. |
--max-passes <n> |
Fix-then-reanalyze iterations (default 5). |
--help |
The same list, from the tool itself (-h and /? also work). |
--api-changes |
Also apply the cross-file fixes described below. |
--no-if-defs |
Never emit #if/#else/#endif pairs for capability fixes on multi-targeted projects (see below). The fixes stay plain, and any the legacy frameworks reject are put back by the final build check. |
--report <file> |
Write every finding the run surfaced as SARIF 2.1.0 — the format GitHub code scanning renders as inline PR annotations. Pairs naturally with --dry-run for a CI lint gate. See CI setup below. |
--baseline <sarif> |
The ratchet: findings whose fingerprints appear in this earlier --report output are neither reported nor fixed — only what is NEW surfaces. Fingerprints hash the rule code, file name and normalized surrounding source, so they survive line shifts, other edits in the file, and different checkouts. Triage once, ratchet forever. |
--fail-on-findings |
Exit 3 when any finding survives the filters — the hard CI gate. The full exit contract: 0 clean, 1 analysis or apply failure, 2 usage error, 3 findings (only with this flag). |
--format json |
Machine-readable stdout: progress prose moves to stderr and the run's findings leave as one JSON document (code, severity, fixable, position, message, fingerprint, source snippet). The default output stays human-readable. |
--rules |
Print the rule catalog — code, category, enabled-by-default (honors --format json). |
--mcp |
Serve the tool as an MCP server over stdio (newline-delimited JSON-RPC, no extra dependencies): tools analyze (target, codes/categories, parseOnly, apply) and list_rules. One warm typechecker lives across calls, so the first analyze pays the reference parse and the rest answer from a hot cache — the economics agent loops need. |
--parse-only |
For a codebase that cannot COMPILE on this machine — a type provider needing its database, references that cannot restore. No MSBuild, no reference resolution: sources come straight from the fsproj's <Compile> items, and only the ~40 analyzers that never consult the typechecker run (the typed rules are excluded outright, not trusted to self-silence). Safety shifts accordingly: instead of a build, the gate is that a pass must not RAISE the compilation's error count over its baseline, and the usual parse-level protections (comment guard, overlap holds) still apply. Limitations: #if branches behind conditional or computed DefineConstants are not parsed, wildcard <Compile> globs are refused, and multi-framework passes collapse to one. Review the diff — the all-frameworks build arbiter is exactly what this mode does without. |
CI setup (SARIF)
A dry run plus --report gives CI the full findings list without
touching a file; uploading the SARIF turns each finding into an inline
annotation on the pull request. Paths in the report are relative to the
working directory, so run the tool from the repository root (that is
what code scanning matches blobs by):
refactor-lint:
runs-on: ubuntu-latest
permissions:
security-events: write # required by upload-sarif
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
- run: dotnet tool install --global fsharp-refactor
- run: fsharp-refactor src/Your.fsproj --dry-run --report findings.sarif
- uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: findings.sarif
category: fsharp-refactor
Two honest notes. First, a dry run exits 0 whether or not it found anything — findings are hints, not errors, and only a broken build or a crashed run fails the step. Annotations therefore inform without blocking; to make findings HARD-fail the job, add an explicit check:
jq -e '.runs[0].results | length == 0' findings.sarif
Second, scope the gate before turning it on: --categories correctness,performance keeps the signal defensible on a shared
repository (see Someone else's codebase),
and a fsharprefactor.json turns off anything the team has decided
against. The suppression comments described under
Configuration silence individual findings at the
line, for both the gate and editors, in one place.
Running alongside other analyzer packages
The fsharp-analyzers host loads every analyzer assembly it is
pointed at, so one invocation — and one FCS typecheck, the expensive
part — can run this package together with others built on the same
SDK, all findings landing in one report. With
G-Research's analyzers,
whose current release pins the same FSharp.Analyzers.SDK as this
package (0.37.2):
fsharp-analyzers --project src/Your.fsproj \
--analyzers-path ~/.nuget/packages/fsharp.refactor.analyzers/<version>/analyzers/dotnet/fs \
~/.nuget/packages/g-research.fsharp.analyzers/<version>/analyzers/dotnet/fs \
--code-root . --report findings.sarif
Rule codes are disjoint (FR* here, GRA* there), and the
fsharpanalyzer: ignore-line suppression comments work for their codes
too — the machinery lives in the shared SDK, not in any one package.
The SDK is strict about version agreement between the host and every
analyzer assembly it loads; a mismatch means analyzers silently fail
to load rather than erroring loudly. The day the two packages pin
different SDK minors, fall back to separate CI jobs — each producing
its own SARIF and uploading under its own category: (code scanning
keeps the streams apart) — at the cost of typechecking the project
once per job. Applying fixes stays this package's own tool either way:
fsharp-refactor applies only its own rules, and report-only
analyzers have nothing to collide with.
Someone else's codebase
Every rule is one of four kinds, shown in the last column of Refactorings:
| Kind | Count | |
|---|---|---|
correctness |
The code does something other than what it looks like it does: a race, a swallowed exception, a disposable that leaks, a comparison that never holds | 31 |
performance |
Correct, but doing work it need not: allocations that need not happen, repeated work, a scan where a lookup would do | 29 |
idiom |
The same behaviour written the way F# writes it. Worth doing, and worth agreeing on first — it is a matter of house style as much as anything | 40 |
cosmetic |
The punctuation and spelling of code. Real cleanups, and nobody's idea of a welcome pull request from a stranger | 15 |
This matters when the repository is not yours. Running everything over a project you do not maintain and opening a pull request from the result is a good way to waste an afternoon of someone's life: no maintainer wants "removed an empty attribute argument list" across two hundred files, and a diff that size buries anything that mattered. A disposable that is never disposed is a different conversation entirely.
So for a codebase you are a guest in:
fsharp-refactor Their.fsproj --categories correctness,performance --dry-run
That is the set that earns its review time. For your own code, run the lot.
The category claims are measured, not assumed:
dotnet run -c Release --project benchmarks/PerfClaims
re-checks them on your machine, on BOTH axes — wall clock and allocation
(GC pressure is performance too). The contract: a performance rule's
rewrite must win on at least one axis, and an idiom rule's must hold
parity. FR0050 once emitted Seq.sum for a list — ~50%% slower than the
mutable loop it replaced, plus an enumerator allocation — which is how
the benchmark file, the rule's module-resolved output, and its idiom
recategorization all came to exist.
Multi-targeted projects
Nothing extra to do: a multi-targeted project is worked through framework by framework, narrowest first.
Capability fixes get both worlds — using the project's own vocabulary.
When a project also targets frameworks older than an overload (net4x,
netstandard2.0), the tool reads the fsproj's DefineConstants and looks
for a framework-shaped constant — NETSTANDARD21, NET8, digits
required — whose '$(TargetFramework)' == '...' conditions cover only
the modern frameworks. Names denoting a legacy framework (NET48,
NET451, NETSTANDARD2_0) are refused outright whatever their
conditions say: the SDK defines exactly those constants during the
legacy compilations themselves, where no fsproj parse can see them. Flavor names sharing the same condition
(SQLProvider defines MICROSOFTSQL right beside NETSTANDARD21) are
passed over: their meaning is the flavor, and a sibling project
compiling the same shared file may define them on legacy frameworks
too. If a constant qualifies and the file already uses conditional
compilation, FR0038 and FR0106 emit a pair instead of a fix the legacy
half cannot compile:
#if NETSTANDARD21
let orderNumber (s: string) = Int32.Parse(s.AsSpan(6, 5))
#else
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))
#endif
No invented constants, ever: a project defining no such constant gets the
plain fix, and the final all-frameworks build stays the arbiter (a fix
the legacy half rejects is put back). Constants appearing in a
DefineConstants element whose condition the tool cannot fully read
(anything beyond '$(TargetFramework)' == 'X' chained with Or) are
disqualified rather than guessed at. A line already inside a positive
region of the chosen constant — or a hand-written NET*_OR_GREATER —
gets the plain fix (nothing legacy compiles it), a file with no #if
anywhere stays free of them, and editors always suggest the plain form.
--no-if-defs turns the pairing off entirely for a run that should never
add conditional compilation, whatever the project defines.
Large solutions stay affordable through three levers: one FCS checker
serves the whole run (twenty projects share nearly all their reference
assemblies, parsed once); a shared source file swept under one set of
conditional-compilation defines is never re-swept by the next project
that compiles it identically; and a multi-targeted project whose sources
contain no #if at all gets a single-framework sweep, with the final
all-frameworks build still verifying the rest.
That is not busywork. A rule gated on what the target can resolve behaves
differently per framework — s.Contains 'x' is offered under net8.0,
where the char overload exists, and does not compile for a netstandard2.0
target that lacks it. And each framework activates its own #if branches,
so code behind another one's is not in the parse tree at all. One pass
could only ever see part of the code.
Narrowest first means the fixes valid everywhere land before any that suit only a wider surface, and every pass ends by building all the frameworks, so a fix that does not generalise fails loudly instead of passing as success.
Given this, one plain fsharp-refactor Your.fsproj produces:
let has (s: string) =
#if NETSTANDARD2_0
s.Contains "x" // still a string: no char overload here
#else
s.Contains 'x' // rewritten under the net8.0 pass
#endif
--framework <tfm> restricts a run to one framework if you want it.
Allow changes to public API like types
Public types and function signature changes are not done by default. Sometimes they would make the program more efficient:
Changing type Item = { X: Option System.Guid } to type Item = { X: VOption System.Guid }
would often make sense because Guid is already a struct, so ValueOption is better here.
But that could affect to external users and serialization.
--api-changes opts into rewrites that change internal or public
signatures — currying a tupled function (FR0090) and reordering its
parameters data-last (FR0091) — rewriting every call site in the project.
Without it those are held back and only counted. It also widens the
contained-type hints (FR0022, FR0069, FR0070, FR0093) to public types.
Consumers outside the project are why this is opt-in: their call sites
cannot be rewritten, so each rule only fires where a missed one would fail
to compile rather than change behaviour silently. Naming a single source
file skips these entirely — asking for one file and getting edits in its
callers would be a surprise.
Refactorings
Roadmap based on "F# refactoring possibilities":
| Code | Refactoring | Kind |
|---|---|---|
| FR0001 | Boolean match → if-else |
idiom |
| FR0002 | Manual Some/None (and ValueSome/ValueNone) match → Option/ValueOption map/bind/flatten/defaultValue/defaultWith/isSome/isNone/iter/exists/forall + map-then-default combos. OFF BY DEFAULT: the measured board's one rewrite that slows the rewritten code (+53%, one closure per call) — enable via "FR0002": true or --codes FR0002 when the readability trade suits |
idiom |
| FR0003 | Extract function composition (f >> g) from pipeline/nested-application lambdas |
idiom |
| FR0004 | Move List/Seq/Array conversion past the next pipeline operation (or drop it before consuming ops) |
performance |
| FR0005 | Strip do-nothing CE wrapping (async { return! c }, rewrap identity, immediately-run wraps, task { return x } → Task.FromResult) |
idiom |
| FR0006 | Extract a when guard into an active pattern |
idiom |
| FR0007 | Remove mutable from never-mutated local bindings and type-level let mutable fields (class lets are private to the type, so the whole mutation scope is visible) |
idiom |
| FR0008 | Tupled → curried parameters for private functions (definition + all call sites) |
idiom |
| FR0009 | Manual Ok/Error match → Result.map/bind/mapError/isOk/isError/defaultValue/defaultWith/iter |
idiom |
| FR0010 | Simplifications: if c then true else false → c; x = None → Option.isNone x; List.length xs = 0 → List.isEmpty xs (List/Seq/Array/Set/Map) |
idiom |
| FR0011 | Trivial partial active patterns → [<return: Struct>] ValueSome/ValueNone (perf: no allocation per match attempt) |
performance |
| FR0012 | Term-rewriting hints (fsharplint-style lhs ===> rhs rules): comparison flips, x = true, null checks via isNull, map fusion, isEmpty (filter ...) → exists, sum (map ...) → sumBy, map id, id >>, compare ... = 0, and more — extensible per repository |
idiom |
| FR0013 | Redundant parentheses around single atomic arguments to a function: List.max([4; 3]) → List.max [4; 3], Some("x") → Some "x" |
cosmetic |
| FR0014 | ContainsKey + indexer double lookup → single TryGetValue (two lookups become one — measured 1.26x — and on ConcurrentDictionary also a race fix); F# Map gets the TryFind option idiom |
performance |
| FR0015 | Literal regex patterns → StartsWith/EndsWith/Contains; static Regex calls inside loops are hoisted to a let private xRegex = Regex "..." module binding (advice-only when the open is missing or the name is taken) |
performance |
| FR0016 | Small value-type-only unions → [<Struct>] (perf: no heap allocation per value) |
performance |
| FR0017 | Async discarded with ignore (never runs) — fix-less hint pointing at Async.Ignore/Async.Start |
correctness |
| FR0018 | Check-then-add → single TryAdd (race fix on ConcurrentDictionary, double-lookup fix on Dictionary) |
correctness |
| FR0019 | Equals override without GetHashCode (hash-based collections misbehave) |
correctness |
| FR0020 | Abstract member used during construction (override runs before derived init) | correctness |
| FR0021 | Redundant .ToString() inside interpolated strings |
performance |
| FR0022 | Non-public union cases with unnamed tuple fields take the field names their match sites already bind (Line of int * decimal → Line of qty: int * price: decimal); definition-only edit, positional sites stay valid |
idiom |
| FR0023 | Private two-parameter functions called as fun x -> f x k are reordered data-last, all in one fix: the definition swaps to let private f k x, direct calls swap their arguments, and the lambda — which under the new order would read fun x -> f k x — eta-reduces to the partial application f k (List.map (fun x -> scale x 2) ends up as List.map (scale 2)) |
idiom |
| FR0024 | raise (Exception msg) → failwith msg (plain System.Exception only — the raised type and message are unchanged) |
idiom |
| FR0025 | Null test wrapping a value into an option → Option.ofObj / ValueOption.ofObj (if isNull x then None else Some x, the negated and = null forms, and the two-clause match x with null -> ...; Some/None typed-gated to FSharp.Core) |
idiom |
| FR0026 | Mutable backing field + trivial get/set member → member val X = init with get, set (field must be untouched elsewhere in the type; pure-atom initializer) |
idiom |
| FR0027 | GC-lifetime note (no fix): a lambda that captures this — directly or through an instance let field — handed to an event/observable sink (.Add, .Subscribe, .AddHandler, Observable.add, ...) keeps the whole object alive until the handler is removed; sinks are typed-gated so collection .Adds never fire |
correctness |
| FR0028 | N+1 note (no fix): a for over an IQueryable nested inside another loop executes one database query per outer iteration; typed-gated so in-memory sequences never fire, and an outer loop batched with chunkBySize suppresses the note |
performance |
| FR0029 | Task state-machine advice (no fix; FS3511 itself is emitted at codegen, invisible to analyzers): a let rec in a resumable task { } body is flagged always (definite dynamic-fallback producer); oversized tasks (≥8 awaits or ≥60 lines) get the applicable shrinking moves — hoist plain leading lets out, split an if/match whose branches each await into per-branch tasks, extract a long non-awaiting tail into a plain function |
performance |
| FR0030 | A loop whose whole body is a single ResizeArray.Add becomes one AddRange call (for x in xs do acc.Add(x * 2) → acc.AddRange(xs \|> Seq.map (fun x -> x * 2))); Add is typed-gated to List<'T> so HashSet.Add never matches |
performance |
| FR0031 | String + chains mixing literals and string values → interpolated string ("Hello " + name + "!" → $"Hello {name}!"); every operand must be a literal or typed-string identifier/path and the + itself must resolve to FSharp.Core, so a custom (+) never rewrites; literals containing {/}/% leave the chain alone. AT MOST TWO holes: a 3-hole interpolation falls off the compiler's String.Concat optimization onto String.Format — measured 4.9x slower with 2.3x the allocation of the + chain, which is itself already ONE String.Concat call |
idiom |
| FR0032 | A type that creates a disposable field (let stream = new FileStream(...)) without implementing IDisposable is noted (no fix); injected constructor parameters don't count — the injector owns them |
correctness |
| FR0033 | An instance member touching no instance state — no self identifier, instance let field, primary-constructor parameter, or base — can be static member (note only: call sites change) |
idiom |
| FR0034 | if x.IsSome then x.Value + 1 else e → match x with \| Some v -> v + 1 \| None -> e (.Value throws when misused; the match cannot); handles the IsNone/negated forms, else-less unit if, x.Value.P prefixes, and spells ValueSome/ValueNone when the receiver is a voption (typed-gated, so custom IsSome/Value members never match); boolean combos rewrite to combinators — x.IsSome && p x.Value → Option.exists, x.IsNone \|\| p x.Value → Option.forall, chains join inside the lambda |
idiom |
| FR0035 | List/Array/Seq.contains x ys inside a loop — or inside a callback given to a collection function — scans ys linearly per iteration; note suggests building a Set once outside (probing the loop variable itself never fires) |
performance |
| FR0036 | Fragile runtime type comparisons (notes): GetType().Name = "..." breaks silently on renames/namespaces — compare types instead; x.GetType() = typeof<T> is exact-type equality — x :? T if subtypes are fine |
correctness |
| FR0037 | Build-once types constructed inside a loop: ConcurrentDictionary, HttpClient, JsonSerializerOptions (CA1869), Regex, SearchValues.Create (CA1870) — all expensive by design; note suggests hoisting out or making static. HttpClient gets its own wording: per-iteration construction exhausts sockets under load, and the right lifetime (a shared instance, or IHttpClientFactory under DI) is the author's call |
performance |
| FR0038 | Char overloads for single-character strings (CA1834/1847/1865-67): s.Contains "x" → s.Contains 'x' and sb.Append "x" → sb.Append 'x' (both ordinal already — fix); s.StartsWith("x", StringComparison.Ordinal) → s.StartsWith('x') (fix); bare StartsWith/EndsWith/IndexOf are culture-sensitive where the char overload is ordinal, so those get an advisory note only; receivers typed-gated to String/StringBuilder |
performance |
| FR0039 | Allocating case-insensitive comparisons (CA1862): x.ToLower() = "literal" gets a FIX to String.Equals(x, "literal", StringComparison.OrdinalIgnoreCase) when the literal is pure ASCII — measured across all of Unicode, the two spellings then diverge for exactly two compatibility characters no config value or role string contains (U+212A KELVIN SIGN when the literal has a k; U+017F LONG S in the upper direction when it has an s); qualified spelling when the file lacks open System, <> wraps in not. In EDITORS the light bulb offers a second action — the culture-aware InvariantCultureIgnoreCase spelling — while the CLI auto-applies only the ordinal primary: a bulk tool does not guess at linguistics. FR0031 offers the same pairing: interpolation primary, one explicit String.Concat call as the editor alternative. Everything else — non-ASCII literals, a.ToLower() = b.ToLower(), s.ToLower().StartsWith "abc" (the comparison overloads are netstandard2.1+) — stays a note: the comparison type is the author's deliberate choice |
performance |
| FR0040 | Redundant membership guards (CA1853/1868, fix): if d.ContainsKey k then d.Remove k \|> ignore → d.Remove k \|> ignore, if not (s.Contains x) then s.Add x \|> ignore → s.Add x \|> ignore — the operations already return false on a miss; typed-gated to Dictionary/HashSet/SortedSet |
performance |
| FR0041 | Array.sum/average/min/max/contains on int[]/int64[] is a scalar loop; on .NET 8+ System.Linq's Sum()/Average()/Min()/Max()/Contains() are SIMD-vectorized (Contains measured ~5x at 1000 elements, ~6x at 100k; note only: LINQ Sum throws on overflow where Array.sum wraps; floats excluded — NaN semantics differ; quiet inside query { }, where the code is a quotation for a provider's translator and the LINQ spelling may not translate) |
performance |
| FR0042 | Fully applied sprintf → typed interpolated string (sprintf "asdf %s" x → $"asdf %s{x}"); specifiers are kept verbatim so the output is byte-identical; guards: regular literal with no {/}, simple arguments only, no %a/%t/*-widths, partial applications never match |
idiom |
| FR0043 | In an interpolated string that already has a typed hole, the remaining plain holes gain specifiers ($"%s{name} is {age}" → $"%s{name} is %d{age}") — free compile-time type pinning since the string is on the printf path anyway; specifier-free strings are left on the F# 8 String.Concat fast path, and only ToString-identical specifiers are used (%s/%d/%c; never %b or %f) |
idiom |
| FR0044 | raise ex in a with handler resets the stack trace → reraise () (CA2200, fix); skipped inside lambdas/CEs/nested trys where reraise would not compile or would mean a different exception |
correctness |
| FR0045 | x = nan / x <> Double.NaN never holds (IEEE 754) → System.Double.IsNaN x / negated (CA2242, fix); Single.NaN uses Single.IsNaN |
correctness |
| FR0046 | lock "str" / lock typeof<T> / lock (x.GetType()) — weak-identity objects are process-wide singletons, so the monitor is shared with strangers (CA2002, note): use a dedicated let lockObj = obj () |
correctness |
| FR0047 | A type implementing IDisposable whose Dispose never touches one of its new-constructed disposable fields (CA2213, note) — the mirror of FR0032 |
correctness |
| FR0048 | String.Format("{0} of {1}", x) — a placeholder without an argument throws FormatException at runtime (CA2241, note); {{ escapes handled, culture-first overload ignored |
correctness |
| FR0049 | Sync-over-async (CA1849/VSTHRD): .Result, .Wait(), GetAwaiter().GetResult(), Async.RunSynchronously, Thread.Sleep inside async/task { } invite thread-pool starvation and deadlocks (typed-gated receivers; Thread.Sleep n gets a do! Async.Sleep n / do! Task.Delay n fix in statement position); .Result/.Wait()/GetResult() outside CEs get the boundary note — wrap in task { } or use the sync API (Async.RunSynchronously outside a CE is F#'s intended sync boundary and stays quiet) |
correctness |
| FR0050 | let mutable total = 0 + for x in xs do total <- total + x → let total = xs \|> List.sum (fix); projections → sumBy, general combines → fold (fun acc x -> ...) init — same expression, same bindings, no mutable. The module matches the source's resolved kind: measured, List.sum/Array.sum run LEVEL with the loop while Seq.sum is ~50% slower on a list, so this is an idiom rule, and the rewrite never spells Seq when it knows better |
idiom |
| FR0107 | let mutable found = false + for x in xs do if p x then found <- true → let found = xs \|> List.exists (fun x -> p x) (fix); the true-initialized dual becomes forall with the predicate negated. Tightly gated because exists SHORT-CIRCUITS where the flag loop kept iterating: the loop body must be exactly the one if (no else, no second statement), the predicate must never mention the flag and must be visibly effect-free (any assignment, sequencing, statement construct or ignore inside it disqualifies), nothing may reassign the flag afterward, and the source must resolve to a real List/Array/Seq. Module-resolved like FR0050; measured level with the loop on the no-hit worst case, faster on any hit |
idiom |
| FR0108 | Boolean identity literals drop (fix): x && true, true && x, x \|\| false, false \|\| x — the literal contributes nothing, the expression is the other operand. x && false and true \|\| x stay: their value is constant but x's evaluation (and its effects) changes. Deliberately fires inside query { } too — removing a node leaves a strictly simpler tree of shapes the translator already accepted |
idiom |
| FR0110 | An incomplete DU match with no wildcard (the FS0025 warning shape) gains the missing arm(s) as \| Case -> raise (System.NotImplementedException()) (fix) — FR0072's dual: that rule expands a wildcard hiding real cases, this one closes a match that has none. Coverage counts only unguarded plain case patterns (a when may reject); at most three missing cases, past that a wildcard was probably the intent; multi-line matches only, new arms adopt the last clause's \| column |
correctness |
| FR0111 | else holding a whole nested if flattens to elif (fix) — only when the else sits at the outer if's column (offside rules for elif) and nothing but whitespace separates the keywords |
cosmetic |
| FR0112 | An if/elif chain comparing ONE identifier against distinct int/string/char literals becomes a match (fix). The scrutinee must be a bare identifier — a call re-evaluated per comparison today would be evaluated once after the rewrite — and every = must resolve to FSharp.Core's (match patterns use structural equality) |
idiom |
| FR0113 | Nested ifs merge into one && (fix), in the two exactly-semantics-preserving shapes: identical else-branches (if a then (if b then X else E) else E — one branch runs either way, so even an effectful E is unchanged), and no else at all (unit result). An \|\|-topped condition gains parens before joining the &&. The tempting third shape — inner if without else while the outer has one — is deliberately absent: the merge would run E where the original ran nothing |
idiom |
| FR0114 | Pyramid-of-doom flip (fix, OFF by default): a then-branch of 20+ lines behind an else of 3 or fewer (both thresholds configurable, see Configuration) flips — condition negated (an existing not unwraps instead), short exit first, big block last. Off because plenty of teams hold the exact opposite style (happy path first); turn on per repository when short-exit-first IS the house style |
idiom |
| FR0115 | Base case first behind a compound guard (note): match v with \| x when a && b -> base \| _ -> err hides the base case behind a guard every new error condition must be threaded into; inverted — error guards first, base case as the final arm — the match reads top-down and extends by appending. Advice only: which case is "the base" is intent |
idiom |
| FR0116 | A member of a let rec ... and group that references no sibling takes part in no recursion and moves out, as a plain let above the group (fix) — callers in the group still see it, and it can call nothing in the group by construction. A self-recursive member (calls itself, nobody else) leaves as its own let rec; when the group's HEAD is the non-recursive one nothing moves at all — its let rec becomes let and the next binding is re-crowned let rec. No attributes on moved bindings, membership judged conservatively (any textual mention of a sibling keeps it in) |
idiom |
| FR0109 | Idempotent duplicates collapse (fix): a \|\| a and a && a → a — only when the operands are textually identical and contain no function or method call (operators, not, property chains and indexing pass). tryConnect () \|\| tryConnect () is the deliberate retry idiom and never matches; the message also flags the likelier truth, a copy-paste that meant another operand |
idiom |
| FR0051 | acc <- acc @ [x] / acc <- Array.append acc [\|x\|] inside a loop copies the accumulator per iteration — O(n²) (note): use a ResizeArray, or cons and List.rev. Also acc <- acc + s on a STRING (typed-proven) in any loop — the slowest string builder measured, 36x a StringBuilder at 1000 pieces; the note names StringBuilder or collect-then-String.concat |
performance |
| FR0052 | q.Count = 0 on ConcurrentQueue/Stack/Bag → q.IsEmpty (CA1836, fix): their Count walks segments, IsEmpty peeks |
performance |
| FR0053 | BitConverter.ToString(bytes).Replace("-", "") → System.Convert.ToHexString bytes (CA1872, fix) |
performance |
| FR0054 | raise/failwith inside Equals/GetHashCode/ToString/Dispose overrides (CA1065, note): implicit callers (hash containers, debuggers, formatting, finalization) never expect them to throw; raises inside the member's own try stay quiet |
correctness |
| FR0055 | try ... with _ -> () (or :? Exception -> ()) swallows every exception including cancellation, and with _ -> "" / 0 / false / Unchecked.defaultof / None / ValueNone / null / [] additionally disguises the failure as a result (note): log or reraise (), and catch the specific type; deliberately ignoring a specific exception stays quiet, and a bool fallback is quiet only for the genuine probe idiom — a try body answering with the opposite literal (try ...; true with _ -> false) |
correctness |
| FR0057 | XML doc drift (note): a doc comment with <param> tags that misses some actual parameters — the compiler warns about unknown names (FS3390) but not missing ones; fully undocumented functions are a style choice and stay quiet |
cosmetic |
| FR0058 | A let rec re-entering itself through seq/taskSeq/asyncSeq { } builds a fresh enumerator per recursion level — every element pays O(depth) MoveNexts (note): walk with an explicit Stack/queue inside a single builder |
performance |
| FR0059 | A private function returning Some/None moves to ValueSome/ValueNone (fix): definition constructors and every match site rewritten together — no heap allocation per call; any use where option is load-bearing (List.tryPick f, Option.* pipelines, let-bound results, explicit annotations) suppresses the whole suggestion |
performance |
| FR0060 | Consecutive attribute brackets merge: [<Attr1>] [<Attr2>] (stacked or same-line) → [<Attr1; Attr2>] (fix); comments between brackets and [<assembly: ...>] targets suppress it |
cosmetic |
| FR0061 | invalidArg "facotr" ... / ArgumentException("msg", "wrongName") — the parameter-name string must name a real parameter of the enclosing function (CA2208, note); nameof keeps it honest |
correctness |
| FR0062 | Non-private module-level let mutable is visible global mutable state (CA2211, note); private mutables and private/internal modules stay quiet |
correctness |
| FR0063 | raise/failwith inside finally replaces any exception already in flight (CA2219, note); raises the finally itself catches stay quiet |
correctness |
| FR0064 | Raising runtime-reserved exceptions (OutOfMemoryException, StackOverflowException, IndexOutOfRangeException, NullReferenceException, ...) misleads catchers and debuggers (CA2201, note) |
correctness |
| FR0065 | Weak cryptography (CA5350/5351, note): MD5/SHA1/DES/TripleDES/RC2 construction, plus TLS certificate-validation bypass via ServerCertificateValidationCallback |
correctness |
| FR0066 | SQL assembled from strings (CA2100, note): interpolation holes, + chains or sprintf flowing into CommandText or a *Command constructor — parameterize instead |
correctness |
| FR0067 | DateTime.Parse s / Double.Parse s without a culture reads differently per server culture (CA1305, note); integer parses are low-risk and stay quiet |
correctness |
| FR0068 | Duplicate enum literal values (Red = 1 ... Crimson = 1) silently conflate cases (CA1069, note) |
correctness |
| FR0069 | A private/internal record field X: int option / DateTime option / Guid option boxes the struct payload; voption keeps it flat — contained visibility keeps the migration contained (note) |
performance |
| FR0070 | A private/internal record of at most four small struct fields can be [<Struct>], removing a heap allocation per instance (note) |
performance |
| FR0071 | A pure binding inside a for/while/collection lambda that depends on nothing the loop changes is re-evaluated every iteration; the fix hoists it above the loop |
performance |
| FR0072 | A DU match wildcard standing in for only 1-2 concrete cases is an open else; the fix expands them (_ → D), so future union growth raises incomplete-match warnings |
correctness |
| FR0073 | let! x = comp whose binder exists only to be matched collapses to match! comp with (F# 4.5+) |
idiom |
| FR0074 | Nested record copy-and-update flattens to F# 8 path syntax: { r with X = { r.X with Y = v } } → { r with X.Y = v } (LangVersion-gated; fields named after their type stay nested — the flattened path would resolve as the type) |
idiom |
| FR0075 | A locally constructed disposable bound with let is never disposed: fix to use when every mention stays in scope, advice when it escapes; manual Dispose() calls exempt |
correctness |
| FR0076 | List/Array.map f \|> ignore allocates a discarded list — fix to iter (f >> ignore); Seq.map f \|> ignore is lazy and runs NOTHING (advice, the FR0017 family) |
performance |
| FR0077 | An object expression missing interface members (FS0366) gets NotImplementedException stubs for every missing method/property, inherited interfaces in their own interface X with sections — the only rule that runs on non-compiling code, which is its point |
correctness |
| FR0078 | The three-part mutable-condition loop idiom (let! first / let mutable go / rebind at loop end) collapses to F# 8 while! — a lone stale-bool while never matches, while! re-evaluates per iteration |
idiom |
| FR0079 | Task.WhenAll [\| t \|] / Task.WaitAll / Async.Parallel [ c ] over a single-element literal adds indirection for nothing (CA1842/CA1843, note — the direct form changes the result type, so the author picks the landing shape) |
performance |
| FR0080 | Leading TABs (FS1161 — pasted code often brings them) expand to four spaces per tab, every affected line in one fix; files with triple-quoted/verbatim strings are skipped (a tab could be string content) | correctness |
| FR0081 | Path fragments joined with a hard-coded / or \ separator → Path.Combine advice; \ fires alone, / needs positive path evidence (path-ish names, rooted/extension literals, or a literal existing on disk) and URL-smelling chains never fire |
idiom |
| FR0082 | [<FooAttribute>] → [<Foo>] — the compiler resolves the short form |
cosmetic |
| FR0083 | [<Foo()>] → [<Foo>] — an empty attribute argument list says nothing |
cosmetic |
| FR0084 | ``name`` backticks around a plain non-keyword identifier do nothing; stripped per site |
cosmetic |
| FR0085 | new on a non-IDisposable construction is noise — F# convention reserves new for disposables (the compiler warns the inverse as FS0760); typed-gated |
cosmetic |
| FR0086 | $"no holes" → "no holes" — hole-free interpolation; skipped when escaped braces would need unescaping |
cosmetic |
| FR0087 | The pattern x :: [] → [ x ] |
idiom |
| FR0088 | Case(_, _) → Case _ when every field is a wildcard (typed-gated to real union cases; survives field-count changes) |
cosmetic |
| FR0089 | [ 1, 2 ] is a SINGLE-tuple list — , builds a tuple, ; separates elements (note; the classic paste trap, single-tuple lists are sometimes intended) |
correctness |
| FR0090 | Tupled → curried for internal/public functions with every project call site rewritten (cross-file; fsharp-refactor --api-changes only, editors get the private-only FR0008) |
idiom |
| FR0091 | Data-last parameter reorder for internal/public functions with every project call site rewritten (cross-file; fsharp-refactor --api-changes only, editors get the private-only FR0023). The two parameters must have different concrete types, so that a call site outside the project — which we can neither see nor fix — fails to compile rather than silently swapping two interchangeable arguments |
idiom |
| FR0092 | A constant failwith "Error" gains the enclosing function's arguments — failwith $"Error, calling mymethod with x: {x}" — so the log says which call failed, not just which line. Static messages only; an already-interpolated one was written deliberately, as was one that already names a parameter. NOTE: the exception TEXT is observable behavior — a test asserting the exact message will need updating (found the honest way: one such assertion in a 4,949-test suite) |
idiom |
| FR0093 | A private/internal record field X: int * int is a reference tuple — one heap object per value — where struct (int * int) stores it inline. At most four elements, since a struct tuple is copied by value. Note only: every construction and destructuring of the field needs the struct keyword too |
performance |
| FR0094 | Redundant parentheses around a single atomic argument to an instance method: s.Contains("x") → s.Contains "x". Separate from FR0013 so either preference can be switched off alone. Left alone where the line continues into an application (s.Contains("x") <> false would read as if "x" <> false were the argument), under a projection, and for uppercase-headed paths — System.Uri("x") is a constructor, whose parens are load-bearing |
cosmetic |
| FR0095 | A lambda that restates a built-in: fun x -> x → id, fun (a, b) -> a → fst, fun (a, b) -> b → snd. One unannotated parameter only, and never as a direct argument to a .NET method, where the lambda-to-delegate conversion is doing work a function value may not |
idiom |
| FR0096 | Redundant parentheses around a pattern: \| (Some y) -> → \| Some y ->, let f (x) = x → let f x = x. The whole pattern of a match clause, or a bare atom elsewhere — Some (x, y), Some (Some x), f (x: int) and member parameters all keep theirs |
cosmetic |
| FR0097 | Redundant parentheses around a type: (x: (int)) → (x: int), (string) list → string list. Function and tuple types keep theirs, where the parens bind the type together |
cosmetic |
| FR0098 | The BCL name of a type F# abbreviates: System.Int32 → int, System.String → string, System.Object → obj. Only the fully qualified form; a bare Int32 depends on the opens and on what the file declares |
cosmetic |
| FR0099 | A ; ending a line does nothing in light syntax: let x = 1; → let x = 1. Kept where it separates rather than terminates — inside a list, array, record, anonymous record or attribute group — and everywhere in a file that sets #light "off". ;; is left alone. OFF BY DEFAULT: it lexes every file containing a line-ending ; and rarely finds anything — enable via "FR0099": true in fsharprefactor.json, or ask for it with --codes FR0099 |
cosmetic |
| FR0100 | A match branch that says it is unfinished and then returns a stand-in — \| Jordan -> / // Not supported yet / None / ValueNone / false — becomes raise (NotImplementedException()), so the gap reports itself instead of reaching callers as a real-looking result. The comment must sit inside the branch, between the arrow and the value, where it describes that branch and nothing else; a bare TODO elsewhere never counts, and \| Unknown -> None with no such comment is left alone. null and Unchecked.defaultof<_> need the comment too — \| [] -> Unchecked.defaultof<'T> is the entire contract of a SingleOrDefault, and \| null -> null passes a sentinel through. Only fires where sibling branches actually compute, so a table of constants is not mistaken for a stub |
correctness |
| FR0101 | The Python range(len(xs)) loop: for i in 0 .. xs.Length - 1 do ... xs.[i] → for x in xs do ... x, when the index's every use is indexing that same collection. Fix rewrites the header and each xs.[i]/xs[i]; an index also used as a value wants iteri, which changes shape enough to stay the author's call, and any xs.[i] <- ... keeps the loop |
idiom |
| FR0102 | Positional indexing into an F# LIST inside a loop — names.[i] walks i cons cells per access, the quietest quadratic in F#. Typed: arrays, ResizeArray and dictionaries share the syntax and are fine; List.item/List.nth pin the type by name. Constant indexes (xs.[0]) and receivers bound inside the loop are skipped. Advice: iterate directly (FR0101 fixes the canonical shape) or convert once with List.toArray |
performance |
| FR0103 | The Python isinstance ladder: an if/elif chain of shape :? T tests with shape :?> T casts in the branches becomes one match with \| :? T as v -> patterns — one type test per branch instead of test-plus-cast, and the unsafe :?> (an InvalidCastException waiting for a branch reorder) disappears. Needs two or more bare type-tests on the same plain identifier, single-line branches, and every cast targeting its own branch's type; a compound condition or a cross-cast keeps the chain |
idiom |
| FR0104 | A singleton append to an accumulator in a RECURSIVE call — collect (acc @ [x]) rest copies the whole accumulator every step, O(n²), and it is the shape first drafts produce more when told to avoid mutation. Note only: the repair is x :: acc with one List.rev in the base case, or an array/ResizeArray when the result is consumed positionally — the base case changes either way. A general a @ b merge is left alone |
performance |
| FR0105 | Arithmetic (+, -, *) on a NEAR-LIMIT integer constant — within a factor of two of Int32.MaxValue, or ten-ish digits into int64 territory. F# operators are unchecked by default, so overflow wraps silently and the corrupted value flows on. Note only: open Microsoft.FSharp.Core.Operators.Checked makes the scope throw instead, a wider type removes the ceiling, or the wraparound is intended and deserves saying so. Decimal spellings only (hex is a mask), unsigned skipped, and a file already opening Checked is left alone |
correctness |
| FR0106 | Int32.Parse(s.Substring(6, 5)) → Int32.Parse(s.AsSpan(6, 5)) (fix — a one-identifier swap). The Substring copy is discarded the moment the parser reads it; AsSpan parses in place, measured 2.6x and allocation-free. Fires only when the Substring is DIRECTLY the parser's argument (no escape), the receiver is typed-proven string, and the compilation actually offers the ReadOnlySpan<char> overload — which is how netstandard2.0/net4x stay untouched with no TFM sniffing. Framework methods (StartsWith, Contains, interpolation) already run on spans internally and need no rule |
performance |
| — | DU case payload → named record (cross-file) | needs FSAC codefix infra |
Configuration
Rules can be disabled per repository with an optional fsharprefactor.json,
searched upward from each analyzed file, stopping at the repository root (the
nearest file wins). Keys are rule codes or analyzer names, case-insensitive;
a malformed file fails open so it can never break the editor. Comments and
trailing commas are tolerated:
{
"rules": {
"FR0003": false,
"conversionMove": { "enabled": false }
}
}
Rules with tunable thresholds read numeric properties from the same
object-valued entries. FR0114 is the first: thenAtLeast (default 20)
is how long a then-branch must be before flipping is suggested, and
elseAtMost (default 3) is how short the else must stay:
{
"rules": {
"FR0114": { "enabled": true, "thenAtLeast": 30, "elseAtMost": 2 }
}
}
Paths can be excluded too — additively over the built-in defaults
(paket-files, .paket, node_modules), which cover generated and
vendored code a compilation nonetheless includes:
{
"ignorePaths": [ "generated", "external/imported" ]
}
A bare name matches as a whole path segment; an entry containing a slash
matches anywhere in the normalized path; an entry containing * is a
glob — * stays within a segment, ** crosses them (*.g.fs,
src/generated/**). Ignored files are neither analyzed nor even
type-checked by the apply tool's sweep — on a paket-heavy solution that
is a lot of vendored source nobody wants "fixed". Files opening with the
conventional // <auto-generated> marker are skipped automatically
wherever they sit, as is everything under obj/.
Individual findings can be silenced in place with the F# analyzer SDK's own suppression comments — the same ones editors honor, so one comment silences both the light bulb and the apply tool (a suppressed finding is neither reported nor fixed):
// fsharpanalyzer: ignore-line-next FR0106
let orderNumber (s: string) = Int32.Parse(s.Substring(6, 5))
let inline dodgy (s: string) = s.Substring(0, 3) // fsharpanalyzer: ignore-line FR0106
// fsharpanalyzer: ignore-file FR0031, FR0038
// fsharpanalyzer: ignore-region-start FR0002
// fsharpanalyzer: ignore-region-end
Suppression comments are also easy to reach for, and a team may not want
a correctness finding silenceable with one line of punctuation the way a
naming nit is. The "suppressions" policy draws that line:
{ "suppressions": "no-correctness" }
"all"(default) — every suppression comment silences its finding."no-correctness"— comments on correctness-category rules are reported anyway; idiom, cosmetic, and performance suppressions still work. An overridden finding is never auto-FIXED — the tool does not rewrite code over someone's explicit comment — it is reported (and fails--fail-on-findings) until addressed or the comment is judged worth honoring."none"— every suppression comment is reported anyway.
--honor-suppressions on the command line overrides the policy to
"all" for that run. A repo that wants suppressions inert on developer
machines but honored by the pipeline commits "no-correctness" (or
"none") in its config and passes the flag in CI only. Whatever the
policy, the run summary counts what comments silenced — suppression is
never silent. Note the policy only governs this tool: editors honor the
SDK's comments natively, so the light bulb stays silenceable regardless.
A disabled rule skips its analysis entirely, so the file also works as a performance lever on large codebases. Internally all analyzers share one memoized AST traversal per file version, so the editor pays for a single walk per keystroke regardless of how many rules are active.
Every rule defaults to enabled except two:
- FR0099 (line-ending semicolons) lexes every file containing one and rarely finds anything — cost out of proportion to a cosmetic default.
- FR0002 (match option → Option combinators) is the one measured rewrite that makes YOUR code slower — +53% and a closure allocation per call on its benchmark pair. Nice to read, costs to run; opt in when that trade suits the codebase.
Turn either on with "FR0099": true / "FR0002": true, or ask
explicitly: the apply tool treats --codes FR0002 as outranking both the
default-off status and a config disable — naming a rule is an ask. A
--categories filter deliberately is not one: --categories idiom runs
the idiom rules that are on, and does not quietly wake the default-off
ones.
The same file can add custom FR0012 term-rewriting rules using FSharpLint's hint syntax (single-letter identifiers are metavariables):
{
"hints": {
"add": [
"Option.isSome x |> not ===> Option.isNone x"
]
}
}
Custom rules get the same safety treatment as the built-ins: bindings are parenthesized as needed, and a rule whose right side drops or duplicates a metavariable only fires on pure atoms (never discarding a side effect).
Improving it
Contributions welcome. This section is for working ON the analyzers; everything above is for using them.
Trying your changes
Build the analyzers, then point either host at the build output instead of the NuGet cache.
In an editor, via the target repo's .vscode/settings.json:
{
"FSharp.enableAnalyzers": true,
"FSharp.analyzersPath": ["<path-to>/FSharp.Refactor.Analyzers/bin/Debug/net8.0"]
}
Open an F# file containing e.g. match x with | true -> 1 | false -> 2 — a
hint appears offering if x then 1 else 2.
Or from the CLI:
fsharp-analyzers --project YourProject.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
Note: analyzers must be built against an FSharp.Compiler.Service compatible with the host FsAutoComplete. This project currently pins FSharp.Analyzers.SDK 0.37.2 (FCS 43.12.201). See the SDK's version-pairing table when updating.
Building and testing
dotnet build
dotnet test
This project eats its own dog food. Before committing:
dotnet tool restore
dotnet fantomas src tests
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj
dotnet dotnet-fsharplint lint src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj
dotnet dotnet-fsharplint lint tests/FSharp.Refactor.Tests/FSharp.Refactor.Tests.fsproj
and the analyzers are run against their own source, expecting zero findings. Both projects, not just the analyzers — the apply tool is F# we ship too, and it went a long time unchecked:
dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Analyzers/FSharp.Refactor.Analyzers.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
dotnet tool run fsharp-analyzers --project src/FSharp.Refactor.Tool/FSharp.Refactor.Tool.fsproj --analyzers-path src/FSharp.Refactor.Analyzers/bin/Debug/net8.0 --code-root .
Test inputs are string literals, so formatting tools never touch the deliberately-shaped source fragments the tests exercise.
Design principles
- Never break user code. A fix is only offered when it is provably safe to apply; borderline cases simply don't produce a suggestion. Fixes are minimal range-based text edits applied by the editor, so they are always a single native undo step.
- Minimal edits. Original formatting outside the edited range is untouched — no whole-file reformatting.
- Pure core, thin adapters. Each refactoring is a pure function
ParsedInput -> ISourceText -> Suggestion list, unit-tested directly against source strings. The SDK analyzer entry points inAnalyzers.fsare one-liners. - Hints point toward idiomatic F# only. Every analyzer rewrites
a → bwherebis the more idiomatic form; we never ship a hint that moves code away from idiomatic F#. That is why suggestions areHintseverity, not warnings: they mark an opportunity, not a defect, and they never gate CI. Genuinely reversible rewrites where neither direction is more idiomatic (if ↔ match, tupled ↔ curried) belong in FsAutoComplete's codefix infrastructure as user-invokedrefactor.rewriteactions, and should be contributed there rather than here.
Our Vision, and Other projects in the same field
AI Agent compatibility: This project does distinct the F# code from generated Python smell. Meanwhile, some past rules (like function length and cyclomatic complexity) are expected to gains less attention in the future. This project has focus on idiomatic F#, code performance and best practices, and less interest on code structure/naming/maintainability.
This project aims to be compatible with other products, so you won't end-up having oscillation/fight between suggested changes.
| Tool | Same rules | Status |
|---|---|---|
| FxCop and MS Code Analysis | Many | We have implemented the MinimumRecommendedRules, and some performance etc. rules relevant to F# |
| FSharpLint | Many | Instead of just listing, we have quick-fixes and auto-fix. Rules are compatible with this project. |
| Resharper F# | Many | Have many same features, meanwhile using totally different AST. |
| Resharper C# | Partial | Resharper has heavy focus on OO meanwhile we focus on FP. Many C# issues don't exist in F# at all (like clojure captures, etc.). |
| Linq.Expression.Optimizer | Some | We optimize compile-time, meanwhile this tool optimize runtime-code |
| G-Research FSharp Analyzers | Not really | Good rules to focus maintainability. Different focus. Should work well together. |
| Fantomas | None | Different focus: Fantomas is a code layout tool. We are compatible so you can use both. |
| FSharp.Analyzers.SDK | None | We use this tool under hood. |
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.8.2 | 39 | 9/4/2026 |
| 0.8.1 | 43 | 9/3/2026 |
| 0.8.0 | 41 | 9/3/2026 |
| 0.7.7 | 43 | 9/3/2026 |
| 0.7.6 | 44 | 9/2/2026 |
| 0.7.4 | 46 | 9/2/2026 |
| 0.7.3 | 59 | 9/2/2026 |
| 0.7.2 | 68 | 9/1/2026 |
| 0.7.1 | 62 | 9/1/2026 |
| 0.7.0 | 82 | 8/31/2026 |
| 0.6.9 | 83 | 8/31/2026 |
| 0.6.8 | 73 | 8/30/2026 |
| 0.6.7 | 79 | 8/30/2026 |
| 0.6.6 | 77 | 8/29/2026 |
| 0.6.5 | 85 | 8/29/2026 |
| 0.6.3 | 76 | 8/29/2026 |
| 0.6.2 | 77 | 8/29/2026 |
| 0.6.1 | 78 | 8/28/2026 |
| 0.6.0 | 78 | 8/28/2026 |
| 0.5.0 | 86 | 8/28/2026 |
0.6.7: agent- and CI-native surfaces — stable finding fingerprints, --baseline ratchet, --fail-on-findings exit contract, --format json, --rules, and --mcp (an MCP server over stdio with a warm typechecker). --parse-only runs the syntactic rules where compilation is impossible; directories now sweep loose .fsx scripts; dacpac projects are skipped; the tool rolls forward to the newest runtime. New rules FR0108-FR0116, per-rule config parameters, and a "suppressions" policy with --honor-suppressions for CI. Multi-case fixes offer alternatives in editors while the CLI applies the primary.