lean-argparse
An applicative command-line argument parser for Lean 4, in the spirit of
Haskell's optparse-applicative —
with a machine-checked proof suite covering the parser laws, runtime progress,
determinism, -- sentinel handling, and merge soundness.
A CLI is driven by one set of specification records (AppSpec, CmdSpec,
FlagSpec, OptSpec, PosSpec): they feed the documentation renderers, the
shell-completion generator, and the built-in --help/--man/
--generate-completions handlers, while the applicative Parser combinators
(or the spec elaborator) produce typed results from the same metadata.
Highlights
- Applicative
Parsercore with provedLawfulFunctor/LawfulApplicativeinstances - Flags with short-name bundling; options with
--name value,--name=value, and-n5concatenation plus.one/.many/.somearities; positionals; recursive subcommands - Order-insensitive parsing: scanning combinators (
Core.flagScan,Core.optionScan) match flags and options anywhere in a command's argument segment, sogreet --count 2 -v Aliceandgreet Alice -v --count 2parse identically --sentinel handling with proved token-factorization lemmas- Structured errors (
unknownLong,missingValue,leftover, …) carrying context tokens and expectation metadata - Help text, mdoc man pages, and completion suggestions rendered from the spec,
optionally annotated with runtime values via
Partial.Summary - A proof suite with no
sorry, no extra axioms, and a lint-clean build
Example
Main.lean ships a demo with greet and repeat subcommands. Abridged:
import ArgParse
open ArgParse ArgParse.Spec ArgParse.Core
structure GreetConfig where
verbose : Bool
count : Nat
name : String
def greetVerboseFlag : FlagSpec :=
{ short? := some ⟨'v', by decide⟩
, long? := some "verbose"
, «meta» := { name := "verbose", help? := some "Enable verbose output." } }
def greetCountOpt : OptSpec Nat :=
{ short? := some ⟨'n', by decide⟩
, long? := some "count"
, «meta» := { name := "count", metavar? := some "COUNT", default? := some "1" }
, arity := .one }
def greetParser : Parser GreetConfig :=
pure GreetConfig.mk
<*> Core.flagScan greetVerboseFlag
<*> Parser.map (·.getD 1) (Core.optionScan greetCountOpt)
<*> greetNameParser -- positional NAME
def appParser : Parser AppCommand :=
Core.subcommand
[ { name := "greet", parser := AppCommand.greet <$> greetParser }
, { name := "repeat", parser := AppCommand.repeat <$> repeatParser } ]
The entry point normalizes argv, lets builtinOutcome? intercept
--help/--man/--generate-completions, runs the parser, and reports
leftover tokens as structured errors:
$ lake exe argparse greet -v --count 2 Alice
Hello, Alice! (verbose)
Hello, Alice! (verbose)
$ lake exe argparse greet Alice --count 2 -v # any argument order works
Hello, Alice! (verbose)
Hello, Alice! (verbose)
$ lake exe argparse greet
error: missing value
expected: argument NAME
Flags and options are scanned out of the argument stream rather than
consumed front-of-stream, so their position relative to positionals (and to
each other) doesn't matter. Scanning is confined to the current command's
segment — a parent's options never reach past a subcommand name — and never
crosses the -- sentinel, so post-sentinel tokens stay positional
(greet --count 1 -- -v greets -v). One documented ambiguity: a detached
option value that looks like a defined flag (--message -v) is claimed by the
flag scan first; use --message=-v to force the value reading.
Runtime summaries
The runner exposes runSummary/runNormalizedSummary, folding the raw
Partial accumulator into a Partial.Summary (last-write-wins flag lookups,
chronological option/positional lists). The renderers accept a summary to
annotate output with current values (renderHelpWithSummary,
renderManWithSummary, renderCompletionsWithSummary).
What is proved
All theorems live under ArgParse/Proofs/ and build with zero warnings:
- Laws (
Proofs/Laws.lean) —LawfulFunctorandLawfulApplicativeforParser, by case analysis on results. - Totality/progress (
Proofs/Totality.lean) — flag parsers (front-of-stream and scanning) always succeed with explicit witnesses (flag_result_ok,flagScan_result_ok); the generic collector loop advances the cursor by exactly the tokens it consumes (collectStepsLoop_cursorand its option/positional/scanning corollaries, backed bytakeOptionScanStep?_cursor). - Determinism (
Proofs/Determinism.lean) — successful runner outcomes are unique (runRaw_ok_unique,run_ok_unique,runSummary_ok_unique), and parsing depends only on the normalized token stream (runRaw_congr_normalize). - Scan agreement (
Proofs/Scan.lean) — the scanning combinators reduce to the front-of-stream ones on canonically ordered argv, where "canonical" is a syntactic condition on the token stream (Canonical, built on the token classifieroptionToken?) rather than an assumption:optionScan_eq_option_of_canonicalcarries no further hypothesis, andcanonicalExamplewitnesses it for a real named option. Flags are covered byflagScan_eq_flag_of_headandflagScan_eq_flag_of_scan_none; head errors propagate identically (takeOptionScanStep?_error). - Sentinel (
Proofs/Sentinel.lean) —normalizefactors tokens around the first--(sentinel_present_normalize,sentinel_absent_post_nil). - Soundness (
Proofs/Soundness.lean,Proofs/Soundness/Summary.lean) — summaries are faithful to the parsed payload (runSummary_ok_exists_partial);Partial.mergeforms a monoid-like algebra; merge-compatibility is carried from item elaboration through subcommand recursion, the runner, and the help/man/completion renderers.
The roadmap (see PLAN.md) targets the remaining big results: completeness
(conforming argv always parses) and fuel adequacy for the elaborator.
Development
lake build # library + demo executable
lake test # #guard-style unit and golden tests
lake lint # docstring and simp-hygiene linting
Generate HTML documentation:
cd docbuild
DOCGEN_SRC=file lake build ArgParse:docs