FSharp.Refactor.Analyzers 0.6.2

There is a newer version of this package available.
See the version list below for details.
dotnet add package FSharp.Refactor.Analyzers --version 0.6.2
                    
NuGet\Install-Package FSharp.Refactor.Analyzers -Version 0.6.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="FSharp.Refactor.Analyzers" Version="0.6.2">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="FSharp.Refactor.Analyzers" Version="0.6.2" />
                    
Directory.Packages.props
<PackageReference Include="FSharp.Refactor.Analyzers">
  <PrivateAssets>all</PrivateAssets>
  <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add FSharp.Refactor.Analyzers --version 0.6.2
                    
#r "nuget: FSharp.Refactor.Analyzers, 0.6.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package FSharp.Refactor.Analyzers@0.6.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=FSharp.Refactor.Analyzers&version=0.6.2
                    
Install as a Cake Addin
#tool nuget:?package=FSharp.Refactor.Analyzers&version=0.6.2
                    
Install as a Cake Tool

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
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="0.6.1" PrivateAssets="all" />

then point Ionide at the restored analyzers in .vscode/settings.json:

{
  "FSharp.enableAnalyzers": true,
  "FSharp.analyzersPath": [
    "~/.nuget/packages/fsharp.refactor.analyzers/0.6.1/analyzers/dotnet/fs"
  ]
}

(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/0.6.1/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.

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 30
performance Correct, but doing work it need not: allocations that need not happen, repeated work, a scan where a lookup would do 28
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 32
cosmetic The punctuation and spelling of code. Real cleanups, and nobody's idea of a welcome pull request from a stranger 14

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.

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.

--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 matchif-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 falsec; x = NoneOption.isNone x; List.length xs = 0List.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 * decimalLine 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 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 ematch 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.ValueOption.exists, x.IsNone \|\| p x.ValueOption.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, note): a.ToLower() = b.ToLower() and s.ToLower().StartsWith "abc" allocate lowered copies just to compare; String.Equals(a, b, StringComparison...IgnoreCase) / the comparison overloads are allocation-free — comparison type stays the author's deliberate choice performance
FR0040 Redundant membership guards (CA1853/1868, fix): if d.ContainsKey k then d.Remove k \|> ignored.Remove k \|> ignore, if not (s.Contains x) then s.Add x \|> ignores.Add x \|> ignore — the operations already return false on a miss; typed-gated to Dictionary/HashSet/SortedSet performance
FR0041 Array.sum/average/min/max on int[]/int64[] is a scalar loop; on .NET 8+ System.Linq's Sum()/Average()/Min()/Max() are SIMD-vectorized (note only: LINQ Sum throws on overflow where Array.sum wraps; floats excluded — NaN semantics differ) 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 + xlet 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
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 performance
FR0052 q.Count = 0 on ConcurrentQueue/Stack/Bagq.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 / Unchecked.defaultof / None / null / [] additionally disguises the failure as a result (note): log or reraise (), and catch the specific type; deliberately ignoring a specific exception stays quiet, as does the try ...; true with _ -> false probe idiom 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 -> xid, fun (a, b) -> afst, fun (a, b) -> bsnd. 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) = xlet 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) liststring 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.Int32int, System.Stringstring, System.Objectobj. 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 — 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
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 }
  }
}

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

  1. 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.
  2. Minimal edits. Original formatting outside the edited range is untouched — no whole-file reformatting.
  3. 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 in Analyzers.fs are one-liners.
  4. Hints point toward idiomatic F# only. Every analyzer rewrites a → b where b is the more idiomatic form; we never ship a hint that moves code away from idiomatic F#. That is why suggestions are Hint severity, 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-invoked refactor.rewrite actions, 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.
There are no supported framework assets in this package.

Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.8.2 33 9/4/2026
0.8.1 38 9/3/2026
0.8.0 44 9/3/2026
0.7.7 38 9/3/2026
0.7.6 42 9/2/2026
0.7.4 45 9/2/2026
0.7.3 49 9/2/2026
0.7.2 62 9/1/2026
0.7.1 66 9/1/2026
0.7.0 77 8/31/2026
0.6.9 84 8/31/2026
0.6.8 84 8/30/2026
0.6.7 79 8/30/2026
0.6.6 82 8/29/2026
0.6.5 79 8/29/2026
0.6.3 83 8/29/2026
0.6.2 83 8/29/2026
0.6.1 77 8/28/2026
0.6.0 79 8/28/2026
0.5.0 83 8/28/2026

0.3.0: new rules FR0090-FR0093 (project-wide tupled-to-curried and parameter reorder under --api-changes, static failwith messages gaining their arguments, reference-tuple fields). Fixes a case where a substituted method call lost needed parentheses, and three FR0081 false positives found by sweeping 3684 files of real F#.