Skip to content

Proposal: move fsc/fsi command-line parsing to System.CommandLine without adding a dependency to FSharp.Compiler.Service (revisits #13880) #20395

Description

@xperiandri

Revisits #13880, which was closed in Oct 2022 with "we shouldn't do it". This proposal takes that
decision seriously and is written specifically to answer the objections raised there — in particular
@dsyme's point that FSharp.Compiler.Service should keep a very low dependency base and stay
fully-F#-implemented for Fable. The design below adds no dependency to FSharp.Compiler.Service at all.
If the answer is still "no", the compatibility inventory in section 3 should at least be useful on its own.

1. Current state

Command-line parsing for both fsc and fsi lives in
src/Compiler/Driver/CompilerOptions.fs,
built on a hand-rolled model:

type OptionSpec =
    | OptionClear of bool ref
    | OptionFloat of (float -> unit)
    | OptionInt of (int -> unit)
    | OptionSwitch of (OptionSwitch -> unit)
    | OptionIntList of (int -> unit)
    | OptionIntListSwitch of (int -> OptionSwitch -> unit)
    | OptionRest of (string -> unit)
    | OptionSet of bool ref
    | OptionString of (string -> unit)
    | OptionStringList of (string -> unit)
    | OptionStringListSwitch of (string -> OptionSwitch -> unit)
    | OptionUnit of (unit -> unit)
    | OptionConsoleOnly of (CompilerOptionBlock list -> unit)
    | OptionGeneral of (string list -> bool) * (string list -> string list)

and CompilerOption =
    | CompilerOption of
        name: string * argumentDescriptionString: string * actionSpec: OptionSpec *
        deprecationError: exn option * helpText: string option

Measured inventory on current main:

Item Count
CompilerOption definitions in CompilerOptions.fs ~185
of which OptionSwitch (the +/- suffix forms) 26
of which OptionConsoleOnly (--help, --version, …) 13
of which OptionGeneral (fully custom matcher) 1
flags carrying a deprecation warning 24
FSI-specific options in src/Compiler/Interactive/fsi.fs ~20
opts* help strings in FSComp.txt 95
fsi* help strings in FSIstrings.txt 56
localised .xlf languages 13
test files under tests/.../CompilerOptions/ 111

Why this is worth revisiting

The parser is ~250 lines of bespoke string slicing (parseOption, getSwitchOpt, getSwitch,
PostProcessCompilerArgs, GetAbbrevFlagSet). Its quirks are load-bearing and under-specified,
and they leak: #10819, #18983 and #19214 are all defects in this layer. The -- handling in
fsi.fs in particular needs a manual argv split before PostProcessCompilerArgs to avoid
rewriting user script args. There is no completion support, and the help renderer
(getCompilerOption, fixed 42-column flag field) is hand-written.

The part that constrains everything

ParseCompilerOptions is not just an fsc/fsi entry-point concern. It is also how the
language service interprets project otherOptions:

  • src/Compiler/Driver/fsc.fs:270
  • src/Compiler/Interactive/fsi.fs:1265
  • src/Compiler/Service/BackgroundCompiler.fs:1305
  • src/Compiler/Service/FSharpCheckerResults.fs:4098
  • src/Compiler/Service/TransparentCompiler.fs:497
  • src/Compiler/Service/IncrementalBuild.fs:1504 (via ApplyCommandLineArgs)
  • src/Compiler/Service/service.fs:615 (via ApplyCommandLineArgs)

Any change in parsing semantics is therefore an IDE-behaviour change, not only a CLI change.

2. What changed since #13880

#13880 was closed with four objections. Where they stand in 2026:

Objection (2022) Status now
"Whatever else we use will have some weird compat problems" Still the main risk — but now measurable rather than hypothetical. Section 3 is the actual measurement; only one syntax form is genuinely unsupported.
"What is there works, is stable" Mostly true, but #10819 / #18983 / #19214 are open/recent defects in exactly this layer.
"The Fable version of the F# compiler would need an implementation of that library" Fully addressed by the design in section 4FSharp.Compiler.Service takes no new dependency; the adapter lives in the fsc/fsi executables only.
"Structural advantage in low dependency base for FCS" Same as above — FCS dependency base is unchanged.

Also new: System.CommandLine reached 2.0.0 GA in Oct 2025 after years in beta (the churn
that made it unattractive in 2022 is over), and @baronfel's 2022 point about standardising on it
across the .NET stack still holds.

3. Compatibility surface (measured, not assumed)

I built a probe against System.CommandLine 2.0.11 (highest stable) targeting net472 and
modern .NET, and exercised the syntax forms the F# compiler accepts today:

Current syntax Example System.CommandLine 2.0.11
--flag:value --define:FOO ✅ works natively
--flag=value --define=FOO
--flag value --warn 3
short alias + colon -d:FOO ✅ via registered alias
slash prefix /define:FOO, /optimize ✅ via explicitly registered alias
response files @args.rsp ✅ built in
-- passthrough fsi a.fsx -- x y
+/- switch suffix --optimize+, --debug- not supported
unknown token --bogus ⚠️ behaviour differs (see below)

Two real gaps:

  1. +/- suffixes (26 flags). --optimize+ is not parsed; it falls through as a positional
    argument. --optimize:true / --optimize:false do work, so this is solvable with a small
    argv pre-normalisation pass (--opt+--opt:true, --opt---opt:false) driven by the
    known set of switch flags. This replaces PostProcessCompilerArgs/GetAbbrevFlagSet rather
    than adding to them.
  2. Unknown-token handling. Today unrecognised options are an error; with a ZeroOrMore
    positional argument for source files they are silently collected. Needs an explicit validator
    so fsc --bogus a.fs still fails, while a.fs is still accepted as a file.

Everything else — including the colon separator, which was my main worry — already works.

Not yet measured, and needed before committing: startup cost. fsc/fsi startup is
performance-sensitive and there is R2R/MIBC work in the build (fscProject/fsc.fsproj); loading
and constructing a ~185-option System.CommandLine tree must be benchmarked against the current
parser before this is accepted.

4. Variant A — plain System.CommandLine

Key design point: keep CompilerOptions.fs as the single source of truth and add the
System.CommandLine layer only in the executables.

FSharp.Compiler.Service already grants InternalsVisibleTo to fsc, fscAnyCpu, fscArm64,
fsi, fsiAnyCpu, fsiArm64
(FSharp.Compiler.Service.fsproj:85-90),
so the exes can consume the internal CompilerOption model directly.

FSharp.Compiler.Service (netstandard2.0)          ← no new dependency, Fable unaffected
  CompilerOptions.fs
    CompilerOption / OptionSpec model              ← unchanged, still the source of truth
    ParseCompilerOptions                           ← retained for the language service
                │
                │  (internal model, via IVT)
                ▼
fsc.exe / fsi.exe                                 ← System.CommandLine lives ONLY here
  CompilerOptionsToSystemCommandLine.fs
    CompilerOption list -> RootCommand
    argv normalisation (+/- suffixes)

The adapter is mechanical: each OptionSpec case maps to an Option<T> whose action invokes the
existing setter closure, so TcConfigBuilder mutation stays exactly where it is today.

OptionSpec maps to
OptionUnit / OptionSet / OptionClear Option<bool> (no arg)
OptionString / OptionInt / OptionFloat Option<string/int/float>
OptionStringList / OptionIntList Option<T[]>, AllowMultipleArgumentsPerToken
OptionSwitch Option<bool> + argv normalisation for +/-
OptionStringListSwitch / OptionIntListSwitch Option<T[]> + same normalisation
OptionRest trailing Argument<string[]>
OptionConsoleOnly System.CommandLine help/version actions
OptionGeneral (1 use, FSI script.fsx arg1 …) custom Action on the root command

Pros: no third-party dependency; Microsoft-supported; matches @baronfel's and
@vzarytovskii's stated preference in #13880; completions and a maintained help renderer come free;
FCS/Fable untouched.

Cons: the C#-shaped API is verbose from F# (Option<T> construction, ParseResult.GetValue);
help output changes, so both .bsl baselines need rebaselining and the localised .xlf strings
need re-review against System.CommandLine's own localised chrome ("Usage:", "Options:").

5. Variant B — FSharp.SystemCommandLine

FSharp.SystemCommandLine (MIT,
Jordan Marr) is a thin F# wrapper over System.CommandLine, offering a CE-based API:

open FSharp.SystemCommandLine
open FSharp.SystemCommandLine.Input

let define   = option<string array> "--define" |> alias "-d" |> alias "/define" |> def [||]
let optimize = option<bool> "--optimize" |> alias "/optimize" |> def false
let files    = argument<string array> "files" |> arity ArgumentArity.ZeroOrMore

rootCommand argv {
    description "F# Compiler"
    inputs (define, optimize, files)
    setAction (fun (d, o, f) -> ...)
}

Version facts (verified against nuget.org):

  • Latest stable 2.2.0, targeting netstandard2.0 and net6.0 — so net472 is covered.
  • Declares System.CommandLine >= 2.0.0; I verified it builds and runs correctly against
    2.0.11
    , the highest stable System.CommandLine, on both net472 and modern .NET. So
    "use the newest possible System.CommandLine" is compatible with this wrapper today.
  • System.CommandLine 3.0.0 is preview-only; a major-version bump against a wrapper compiled
    for 2.x should be assumed binary-breaking until proven otherwise.

Pros: substantially less ceremony; strongly-typed tupled inputs; idiomatic F#.

Cons — and these are decisive against using it inside this repo:

  • It is a single-maintainer community package. dotnet/fsharp taking a build-time dependency
    on it puts the F# compiler's CLI on a third-party release cadence — exactly what
    @vzarytovskii argued against in Investigate the options of migrating to some library for parsing cmd line args #13880 ("I'd vote for not using anything 3rd-party").
  • The CE inputs API is arity-limited (tuples). It is designed for tools with a handful of
    options, not ~185, so the compiler would end up bypassing the ergonomic layer anyway and
    driving System.CommandLine directly — losing the benefit that motivates the dependency.
  • Two dependencies to service instead of one, including one outside Microsoft's support boundary.

Recommendation: Variant A. Variant B is documented here because it was explicitly asked
about, and it remains an excellent choice for F# tooling in general — just not for the compiler
itself, where the option count defeats its ergonomics and the third-party dependency is the very
thing #13880 objected to.

6. Suggested phasing

Each phase is independently shippable and revertible.

  1. Characterisation tests first. Before any refactor, lock current behaviour: every syntax
    form in section 3 × the option kinds, including the ugly ones (-d 5 space-separated
    abbreviation, /flag, --flag+, @rsp, -- in fsi). This is the real deliverable even if
    the migration is declined — it is what makes fsi.CommandLineArgs different behavior on -d/-r/-I args, ignores --. #10819/dotnet fsi command line: -- has no effect #18983/fsi incorrect handling of some script arguments #19214-class bugs regression-proof.
  2. Extract the adapter CompilerOption list -> RootCommand plus argv normalisation, in
    src/fsc/src/fsi only. No behaviour change yet; parse with both parsers and assert the
    resulting TcConfigBuilder state matches, across the test corpus.
  3. Switch fsi.exe to the new front end first — smaller surface, and the FSI-specific
    options (--use, --load, --exec, --gui, --readline, --fsi-server*, script
    passthrough) are where the known bugs are. Rebaseline expected-help-output.bsl.
  4. Switch fsc.exe. Rebaseline compiler_help_output.bsl; re-review the 13 .xlf locales
    for the help chrome that System.CommandLine now owns.
  5. Leave the language-service path on ParseCompilerOptions — or migrate it separately and
    deliberately, since it is IDE-visible and has no CLI-compat requirement to honour.
  6. Optional follow-up: dotnet fsi / dotnet fsc shell completions, now essentially free.

7. Open questions

  • Is the startup-time cost of building a ~185-option command tree acceptable for fsc? (Must be
    benchmarked in phase 2; a negative result should kill the proposal.)
  • Is changing --help output acceptable, given it is baselined and localised into 13 languages?
  • Should the language-service path (ApplyCommandLineArgs) ever move, or stay on the current
    parser permanently?
  • Does confining System.CommandLine to the executables genuinely resolve the Fable concern from
    Investigate the options of migrating to some library for parsing cmd line args #13880, or does Fable also consume the fsc entry point?

I'm willing to do the work, starting with phase 1 (characterisation tests), which has standalone
value regardless of the outcome. But given #13880 I'd like a maintainer signal before writing the
adapter — happy to have this closed again if the trade-off hasn't changed.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions