F# CLI Parsing: When to Ditch DIY and Embrace System.CommandLine
Why Choose System.CommandLine Over Traditional Pattern Matching?
In the realm of F# console applications, parsing command-line arguments is a fundamental task. It’s common to start with simple argument parsing using pattern matching. While this approach works for basic requirements, it often leads to cumbersome and error-prone code as your application grows in complexity. This is where System.CommandLine
comes into play, offering a structured, robust, and feature-rich solution for parsing command-line arguments.
In this blog post, I’ll walk you through an example of building a Git-like CLI application in F#, demonstrating both the approach of using pattern matching and the more elegant approach using System.CommandLine
. We'll explore the benefits of using System.CommandLine
, and see how its features like built-in help messages, error handling, and flexible argument parsing can make your code cleaner and your user experience better.
Contents
- Starting with a Git-like CLI in F#
- Building the CLI Using Naive Pattern Matching
- Refactoring with System.CommandLine
- Advantages of Using System.CommandLine
- Conclusion
1. Starting with a Git-like CLI in F#
Let’s say we’re building a Git-like CLI tool in F# called “yagc” (Yet Another Git Clone). This tool will have commands like add
and commit
, along with various parameters:
Commands:
add
- Adds files to a staging area
Arguments and Options:
<pathspec>
: A mandatory argument for specifying the file to add.-n
,--dry-run
: An optional flag to show what would happen without making changes.-v
,--verbose
: An optional flag for verbose output.
commit
- Creates a commit with a message
Options:
-m
,--message
: A mandatory option for providing the commit message.
2. Building the CLI Using Pattern Matching
Here’s how you might implement this CLI without using any external libraries, relying solely on pattern matching.
let printUsage () =
printfn "Usage: <program> <command>"
printfn "Commands: add, commit"
let handleCommand (args: string array) =
if args.Length < 1 then
printUsage()
1
else
match args with
| [| "add" |] ->
printfn "Error: File to add is missing."
1
| [| "add"; pathspec |] ->
printfn "File '%s' added" pathspec
0
| [| "add"; "-n"; pathspec |]
| [| "add"; "--dry-run"; pathspec |] ->
printfn "Would add file '%s' (dry-run)" pathspec
0
| [| "add"; "-v"; pathspec |]
| [| "add"; "--verbose"; pathspec |] ->
printfn "File '%s' added (verbose)" pathspec
0
| [| "commit" |] ->
printfn "Error: Commit-Message is missing. Please use '-m' or '--message'."
1
| [| "commit"; "-m"; message |]
| [| "commit"; "--message"; message |] ->
printfn "Commit created, with message: '%s'" message
0
| _ ->
printfn "Unknown command"
1
[<EntryPoint>]
let main args =
handleCommand args
Sample Output
Running the program with various inputs:
dotnet run -- add
Error: File to add is missing.
dotnet run -- add test.md
File 'test.md' added
dotnet run -- add --dry-run test.md
Would add file 'test.md' (dry-run)
dotnet run -- commit -m "Initial commit"
Commit created, with message: 'Initial commit'
While this code is simple and effective for a small CLI tool, it quickly becomes hard to maintain and scale as you add more commands, options, and error handling. Additionally, it lacks features like built-in help messages or validation for required options.
3. Refactoring with System.CommandLine
System.CommandLine
is a modern and powerful library in .NET that helps you define and parse CLI commands, arguments, and options in a structured way. Here's how we can refactor the code using System.CommandLine
.
NuGet Package Installation
First, add the System.CommandLine
package to your project:
dotnet add package System.CommandLine --prerelease
Refactored Code
open System.CommandLine
// Define 'add' command
let addCommand =
let dryOption = Option<bool>("--dry-run", "Dry-run, only show what would happen")
dryOption.AddAlias("-n")
let verboseOption = Option<bool>("--verbose", "Verbose output")
verboseOption.AddAlias("-v")
let fileArgument = Argument<string>("pathspec", "Path to the file")
Command("add", "Add file to index")
|> fun cmd ->
cmd.AddOption(dryOption)
cmd.AddOption(verboseOption)
cmd.AddArgument(fileArgument)
cmd.SetHandler(fun (pathspec: string) (dry: bool) (verbose: bool) ->
if dry then
printfn $"Would add file '{pathspec}' (dry-run)"
else
printfn $"File '{pathspec}' added"
if verbose then
printfn "Verbose output for 'add'"
, fileArgument
, dryOption
, verboseOption)
cmd
// Define 'commit' command
let commitCommand =
let messageOption = Option<string>("--message", "Commit message" )
messageOption.AddAlias("-m")
messageOption.IsRequired <- true
Command("commit", "Commit changes")
|> fun cmd ->
cmd.AddOption(messageOption)
cmd.SetHandler(fun (message: string) ->
printfn $"Committing with message: {message}"
, messageOption)
cmd
// Define the root command that will host the subcommands
let rootCommand =
RootCommand("A git-like F# CLI tool")
|> fun root ->
root.AddCommand(addCommand)
root.AddCommand(commitCommand)
root
// Entry point
[<EntryPoint>]
let main argv =
rootCommand.InvokeAsync(argv) |> Async.AwaitTask |> Async.RunSynchronously
Sample Output
Now let’s see what happens when you run the application:
- Displaying Help
dotnet run -- -h
Description:
A git-like F# CLI tool
Usage:
yagc [command] [options]
Options:
--version Show version information
-?, -h, --help Show help and usage information
Commands:
add <pathspec> Add file to index
commit Commit changes
2. Help for add
Command
dotnet run -- add -h
Description:
Add file to index
Usage:
yagc add <pathspec> [options]
Arguments:
<pathspec> Path to the file
Options:
-n, --dry-run Dry-run, only show what would happen
-v, --verbose Verbose output
-?, -h, --help Show help and usage information
3. Required Argument Validation
dotnet run -- commit
Option '--message' is required.
Description:
Commit changes
Usage:
yagc commit [options]
Options:
-m, --message <message> (REQUIRED) Commit message
-?, -h, --help Show help and usage information
4. Verbose Output for add
dotnet run -- add test.md -v
File 'test.md' added
Verbose output for 'add'
4. Advantages of Using System.CommandLine
- Built-in Help Messages: Automatically generate user-friendly help messages for all commands and options.
- Validation and Error Handling: Ensure that required options are provided, and automatically handle errors like missing arguments.
- Scalability: Easily extend your CLI by adding new commands and options without having to refactor large sections of code.
- Code Readability: By defining commands, options, and arguments in a structured way, the codebase is easier to maintain and understand.
When Should You Use System.CommandLine?
If your CLI application:
- Has multiple commands and nested subcommands.
- Requires validation for mandatory options or arguments.
- Needs a user-friendly help output.
- Needs to scale with additional options and commands in the future.
Then it’s time to consider using System.CommandLine
. For small scripts or quick-and-dirty prototypes, pattern matching might suffice, but anything beyond that greatly benefits from the power of this library.
5. Conclusion
While pattern matching is a viable option for simple command-line argument parsing in .NET, it quickly shows its limitations as your application’s complexity grows. System.CommandLine
provides a structured, scalable, and user-friendly approach that makes your code cleaner and significantly improves the user experience of your CLI tool. If you're building any tool beyond the most basic, System.CommandLine
it is a must-have in your .NET toolkit.