gren-argparse
Declarative command-line argument parsing for Gren. Describe your CLI as a
value; get back a typed command to pattern-match on. A Program package
is also included, providing common functionality for a CLI program for
parsing a command-line, running code, and exiting with a proper return code.
Built-in --help, --version, and ANSI-colored error messages are included.
Example of what is supported
% todo add "buy milk" --done
% todo add "buy milk" -d
% todo list
The walkthrough below builds this todo tool step by step.
How it works
You describe your CLI as a value, hand it the raw argv, and get back a typed
result:
Argparse.Parser.run argv app → CommandParseResult YourCommand
Parsing is a pure function — no I/O, no process exits. You get a result back and decide what to print and what exit code to use.
Argparse.Program is an optional wrapper that handles the bootstrapping for
you: CLI errors go to stderr, help goes to stdout, and a successful parse calls your
handler. Then your handler tells Program which return code the process should
exit with.
1. Define your program's options type
This is your type; the parser's job is to produce it.
type ProgramOptions
= Add { text : String, done : Bool }
| List
2. Describe your commands
An App ties the tool name, version, and a list of commands together.
import Argparse.Parser
import Argparse.PrettyPrinter as PP
parser : Argparse.Parser.App ProgramOptions
parser =
{ name = "todo"
, version = "1.0.0"
, intro = PP.words "A small todo list."
, outro = PP.empty
, commands =
Argparse.Parser.defineGroup
|> Argparse.Parser.withCommand
{ word = "add"
, arguments =
Argparse.Parser.oneArg
{ value = Argparse.Parser.stringParser
, help = "The task to add"
}
, flags =
Argparse.Parser.initFlags (\done -> { done = done })
|> Argparse.Parser.toggle (Argparse.Parser.Both { long = "done", short = "d" }) "Mark it already done"
, commonDescription = Just "Add a task to the list."
, summary = "The `add` command appends a task:"
, example = PP.words "todo add \"buy milk\""
, builder =
\text flags ->
Add { text = text, done = flags.done }
}
|> Argparse.Parser.withCommand
{ word = "list"
, arguments = Argparse.Parser.noArgs
, flags = Argparse.Parser.noFlags
, commonDescription = Just "Show every task."
, summary = "The `list` command prints the tasks:"
, example = PP.words "todo list"
, builder = \_args _flags -> List
}
}
Note: You can nest groups under a prefix word with withPrefix to get
git-style subcommands like todo remote add origin.
Arguments
Each command declares the positional arguments it accepts. Pick the combinator for the arity you want:
noArgs— no positional argumentsoneArg/twoArgs/threeArgs— exact arityoptionalArg— zero or one (Maybe)zeroOrMoreArgs— zero or moreoneOrMoreArgs— one or more
Flags
Flags are type-safe: initFlags takes a record constructor, and each
toggle/flag/requiredFlag call fills in one field. The compiler checks that
you've wired everything up correctly.
toggle name desc— aBool(true when the flag is present)flag name valueParser desc— an optional value, so you get aMaybe valuerequiredFlag name valueParser desc— a mandatory value, so you get thevaluedirectly (not aMaybe); if the user leaves the flag out, the parse fails and they're told it's missing. Handy for inputs a command can't run without when a positional argument would be awkward — say several unordered path options. Required flags are marked(required)in--help.
A flag's name controls which spellings the user can type:
LongOnly "verbose"→--verboseShortOnly "v"→-vBoth { long = "verbose", short = "v" }→--verboseor-v
--help (and -h) and --version are always available — you don't need to
declare them.
ValueParser
A ValueParser converts a raw string into a typed value. Two are built in:
stringParser (any String) and pathParser (FileSystem.Path). Write your
own in a few lines:
myParser : Argparse.Parser.ValueParser MyType
myParser =
{ label = "mytype", fn = MyType.fromString, examples = [ "example" ] }
3. Run it
Argparse.Program.run wires the parser into a Node program:
import Argparse.Program
import Node
import Stream.Log
import Task
main : Node.SimpleProgram a
main =
Argparse.Program.run
{ parser = parser
, onCommand =
\env cmd ->
(when cmd is
Add { text, done } ->
Stream.Log.line env.stdout
((if done then "Added (done): " else "Added: ") ++ text)
List ->
Stream.Log.line env.stdout "(no tasks yet)"
)
|> Task.map (\_ -> {})
}
onCommand receives the Node.Environment (stdout, stderr, args, …) and your
parsed command.
Exit codes
Your handler returns a Task Argparse.Program.Failure {}. Task.succeed {} is
the only exit-0 path; all non-zero exits go through Task.fail:
| Return | Exit | Behavior |
|---|---|---|
Task.succeed {} |
0 |
command succeeded |
Task.fail ExitFailure |
1 |
silent exit 1 (you already printed your report) |
Task.fail (ExitMessage "msg") |
1 |
print msg to stderr, then exit 1 |
Task.fail (ExitValue n) |
n |
exit with code n, print nothing |
Task.fail (ExitMessageValue { message = "msg", value = n }) |
n |
print msg to stderr, then exit n |
If your command always succeeds, just end with:
|> Task.map (\_ -> {})
Need a full model/update loop? Call Argparse.Parser.run directly and handle
each constructor yourself:
main : Node.SimpleProgram a
main =
Node.defineSimpleProgram <| \env ->
let
args =
Array.dropFirst 2 env.args
in
Node.endSimpleProgram <|
when Argparse.Parser.run args parser is
Argparse.Parser.UnknownCommand name ->
Stream.Log.line env.stderr ("Unknown command: " ++ name)
|> Task.andThen (\_ -> Node.setExitCode 1)
Argparse.Parser.BadFlags err ->
Stream.Log.line env.stderr (PP.toString (Argparse.Parser.flagErrorPrettified err))
|> Task.andThen (\_ -> Node.setExitCode 1)
Argparse.Parser.BadArguments err ->
Stream.Log.line env.stderr (PP.toString (Argparse.Parser.argumentErrorPrettified err))
|> Task.andThen (\_ -> Node.setExitCode 1)
Argparse.Parser.HelpText doc ->
Stream.Log.line env.stdout (PP.toString doc)
Argparse.Parser.Success cmd ->
runCommand env cmd
Choosing a runner
Argparse.Program has four entry points:
run— the standard case: anAppwith one or more subcommands.runRoot— no subcommand word; the whole tool is one command (likegreet --loud World). You pass a singleCommandinstead of anApp.runWithContext— likerun, but runs anInit.awaitchain first so you can acquire permissions (FileSystem, terminal, …) before any command runs.runRootWithContext—runRootplus the up-front permission acquisition.
Examples
Each example in examples/ is a self-contained app with a run.sh:
| Directory | What it shows |
|---|---|
no-subcommand/ |
runRoot — no command word |
one-level/ |
run + withCommand |
two-level/ |
withPrefix — nested subcommands |
manual/ |
Argparse.Parser.run directly, custom exit code |
with-permissions/ |
runWithContext — acquiring a FileSystem.Permission |
root-with-permissions/ |
runRootWithContext — rootless tool with permissions |
cd examples/one-level
./run.sh add "buy milk"
./run.sh --help
If you use devbox, you can build all examlpes with:
devbox run examples
Testing
The parser is pure, so you can test it directly with gren-lang/test:
cd tests
./run-tests.sh
If you use devbox, just run:
devbox run test