Contents

F# CLI Parsing: When to Ditch DIY and Embrace System.CommandLine

Why Choose System.CommandLine Over Traditional Pattern Matching?

/1-ts-8v80bu7ecnzedj4phnq-jpeg.jpg

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.

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.

Building the CLI Using Pattern Matching

Here’s how you might implement this CLI without using any external libraries, relying solely on pattern matching.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
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'

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:

1
dotnet add package System.CommandLine --prerelease

Refactored Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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

Help for add Command

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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

Required Argument Validation

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
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

Verbose Output for add

1
2
3
dotnet run -- add test.md -v 
File 'test.md' added 
Verbose output for 'add'

Advantages of Using System.CommandLine

  1. Built-in Help Messages: Automatically generate user-friendly help messages for all commands and options.
  2. Validation and Error Handling: Ensure that required options are provided, and automatically handle errors like missing arguments.
  3. Scalability: Easily extend your CLI by adding new commands and options without having to refactor large sections of code.
  4. 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.

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.