cli_router 0.2.1
cli_router: ^0.2.1 copied to clipboard
Router for CLIs inspired by shelf_router that uses spaces between segments instead of /
Changelog #
All notable changes to this project will be documented in this file.
The format loosely follows Keep a Changelog and the project adheres to Semantic Versioning.
0.2.1 - 2026-09-28 #
Added #
OptionOrderingenum:permute(the new default) andstrict.CliRouter.resolve(argv, {ordering: OptionOrdering.permute})gains theorderingnamed parameter.resolve()stays pure and never reads the environment regardless ofordering.OptionOrdering.permute: GNUgetoptstyle permutation. An option written after an operand has already started is moved in front of it before the strict grammar runs, preserving the relative order of the options and of the operands. A value option moves together with its value as one unit, and a hoisted value option with no value of its own (nothing followed it in the original argv) is never left directly in front of an unrelated, relocated operand, so it still correctly reportsmissingValueinstead of absorbing that operand.--ends option parsing wherever it appears, not only before the first operand: the operands before it and the tokens after it become one combined operand sequence, in their original relative order, unless nothing follows it, in which case it is left exactly where it was. Inside a route ending in a trailing*wildcard, once the wildcard's first token appears, it and everything after it are operands: nothing from there on is ever considered an option again, however it looks (thedocker run IMAGE CMD -flagscase). An option written before the route word that declares it is stillmisplacedOption, in either mode. Negative numbers stay operands and are never moved as if they were options.OptionOrdering.strict: the plain POSIX grammar, reproducingcli_router0.2.0's behavior exactly,misplacedOptionincluded.CliRouter.rungains an injectableenvironmentnamed parameter (Map<String, String>?, defaulting toPlatform.environment) and now selectsOptionOrdering.strictwhen that environment containsPOSIXLY_CORRECT(presence, not value, matching GNUgetopt's own convention), orOptionOrdering.permuteotherwise.
0.2.0 - 2026-09-25 #
A trie based router and a declarative, typed option schema, replacing the longest-prefix matcher and the lossy GNU-style flag parser. Breaking release, no compatibility shims: this is a rewrite of the option-parsing and resolution model, not an incremental change.
Added #
OptionSpec: a route's or router's option schema, declared up front.OptionSpec.flag(name, {required abbr, required repeatable})for a presence-only option;OptionSpec.value(name, {required abbr, required required, required repeatable})for one that takes a value.abbris a required named parameter (its type staysString?; passabbr: nullfor an option with no short form) so a caller cannot silently forget it. Both validatenameandabbrimmediately, at construction.ParsedOption: one occurrence of an option exactly as written (spec,written,argvIndex,value,attached). Parsing is lossless: every occurrence of a repeatable option produces its ownParsedOption, in argv order.looksLikeOption(String): the single public predicate for "is this token option shaped" (-+letter,--+letter, or exactly--). Negative numbers and anything else are operands, never options.CliRouter({required List<OptionSpec> globalOptions}): options that apply to every route that opts in withglobals: true. Required, no default: passconst []for a router with no globals.CliRouter.cmd(pattern, handler, {required options, required globals, description}):optionsandglobalsare now required, named, and declared per route, not inferred.patternsupports required<name>parameters, a single trailing optional[<name>]parameter, and a single trailing*wildcard.CliRouter.mount(prefix, router): grafts a subrouter's routes under a single literalprefixword. Flattens transitively for nested mounts, and re-runs every build-time check (duplicate patterns, option-scope collisions, conflicting parameter names) in the parent router's context. The mounted router's own middleware (added to it withuse) is preserved: each grafted handler is wrapped in the mounted router's middleware first, then in the mounting router's own middleware, so nested mounts compose outermost-mounting-router-first at every level. A word the mounted router reserves without a route of its own (e.g. through one of its own empty mounts) stays reserved here too. The mounted router's ownglobalOptionsmust equal the mounting router's, by shape, as a set (declaration order does not matter): a program has exactly one set of global options, shared by every mounted subrouter, somount()throws anArgumentErrorwhen they differ, rather than silently dropping the mounted router's globals (only each route's ownoptionsandglobalsflag were ever carried across; the subrouter's_globalOptionsitself was not).- Grammar (spec G): a route pattern where a literal word follows a
parameter, optional parameter, or wildcard segment is an
ArgumentErroratcmd(), e.g.'show <id> details'. Route words are grammar; params, the optional param, and the wildcard are operands; and once an operand starts, the pattern cannot go back to route vocabulary. Every pattern used anywhere in this package (tests, README, the example) now places its literal words before its parameters. - Grammar, at resolution time: once the first operand of an invocation is
consumed (a required parameter, the optional parameter, or the
wildcard), the resolver is committed. No further token can be read as a
literal route word, and no further option can be read: an option after
that point is
misplacedOption, naming the route when it is already known unambiguously (which can happen even with a further required parameter still pending, as long as exactly one route remains reachable from there through parameters alone). OptionSpecnow overrides==/hashCodeby declared shape: two separately constructedOptionSpecs with the samename,abbr,takesValue,requiredandrepeatableare the same option throughout resolution (consumed-option tracking, required-option checks, scope checks), not two unrelated ones, even when registered as two distinct instances on different routes.- Build-time: two routes reachable from one another through required
parameters only (i.e. in the same
_scopeAtoption-reading position) that declare an option sharing a name or abbreviation but not the identical shape are anArgumentErroratcmd(). Declaring the identical shape on both is fine (OptionSpec.==treats it as the same option); declaring it on unrelated branches that are never reachable through each other is fine too. - Build-time: a route option colliding with a global option, by name or
abbreviation, is always an
ArgumentErroratcmd(), regardless of that route'sglobalsflag.globals: falseonly means the route does not accept its own router's globals; it is not license to redeclare their names or abbreviations for something else. CliRouter.reservedWords: every literal word reachable as a route's or mount's first token, so a caller can check a name is free before adding one.CliRouter.resolve(List<String> argv) -> CliOutcome: pure, side-effect free resolution. Never throws for a malformed invocation.CliOutcome,CliResolution,CliRejection,CliRejectionKind,CliRoute: the resolution result types.CliRejectionKindhas exactly eleven members (unknownCommand,extraArgument,incomplete,missingArgument,unknownOption,misplacedOption,missingValue,unexpectedValue,invalidShortOption,repeatedOption,missingRequiredOption); the router only classifies, it never maps a rejection to an exit code.CliRejection.optionsand.consumedalways reflect every option and route word read before the rejection, including global options like--help, even at a node that has not resolved to a route of its own yet (e.g.eval --helpon a router witheval rpn/eval infixreportsincompletecarrying[help]).- Every
CliRejectioncarries a non-null, human readablemessage, and a non-nullroutewhenever the route is already unambiguously resolved at rejection time (every literal segment leading to it consumed, no continuation left). CliRejection.argument(the implicated positional's name) andCliRejection.token(the offending argv token, exactly as written), typed instead of parsed out ofmessage.argumentis non-null only formissingArgument.tokenis non-null for every kind exceptmissingArgumentandmissingRequiredOption(always null for both) andincomplete(non-null only when a specific token caused the rejection, null when argv simply ran out at a node with no route and no pending parameter). See the doc comments onCliRejectionKindfor the exact guarantee per kind.CliRejection.option(the implicatedOptionSpec, when the router can name exactly one) andCliRejection.candidates(every route still reachable that declares an ambiguous option, never null, empty when not applicable), typed instead of described inmessage.optionis non-null formissingValue,unexpectedValue,repeatedOptionandmissingRequiredOption(route-local or global alike); forunknownOptiononly via the options-mismatch path (a spec that was read but not accepted by the resolved route); formisplacedOptiononly when one declared shape can be named (a known option read ahead of a literal child, or every route still reachable from here agreeing on the shape).candidatesis non-empty only formisplacedOption's genuinely ambiguous case: several routes still reachable from here declare the option and no single one of them could be identified. See the doc comments onCliRejectionKindfor the exact guarantee per kind.CliRejectionKind.unknownOptionnow also covers an option that is in scope while still resolving (offered by some route reachable ahead) but is not actually declared by the specific route the invocation resolves to, e.g. a global option on a route withglobals: false, or a route option declared by a sibling route (spec 8.2: "option in scope but not accepted by the resolved route:unknownOption, naming the route"). Checked once resolution lands on a route (on a successful resolution and onextraArgument), against every option parsed since the start of the invocation.CliRouter.run(args, {required onReject, stdout, stderr}): the I/O entry point built onresolve.onRejectis required at the call site; there is no silent default reporting.stdout/stderrare the one deliberate default in the router: when leftnull, they fall back toio.stdoutandio.stderr; pass a fake sink explicitly (e.g. in a test) to capture what a handler writes instead.CliRequest.param(name)and.option(name)helpers, alongsideroute,params,rest,options,originalArgs.
Changed #
- POSIX option ordering is now enforced: route words, then options, then
operands. An option written after an operand, or before the route word it
belongs to, is
misplacedOption.--still ends option parsing. - Value options require a separate value:
--file value,--file=value, or-f value.-f=valueis not a valid short form (invalidShortOption). - Flags never take a value;
--flag=xisunexpectedValue. - A short option token must be exactly
-plus one letter; short clusters (-qh) areinvalidShortOption, not expanded. - A repeated non-repeatable option is
repeatedOption, under any spelling (mixing--fileand-fstill counts as a repeat of the same option). - A required option absent at the end of the invocation is
missingRequiredOption, decided only once every option on the invocation has been read. misplacedOptionfor an option not in scope but declared somewhere ahead is now decided by whether some route still reachable from the current node (through its param child and every literal child, recursively) declares that option, not only by an uninterrupted lookahead walk. An uninterrupted lookahead that reaches exactly one route still names that route, as before; when a second option token interrupts the lookahead before it reaches a route, the option is still reported asmisplacedOption(naming the one route that declares it, if only one subtree route does; otherwiseroute: null, with every candidate route listed in the message) rather than falling back tounknownOption.- The option scope at a node not yet resolved to a route (spec 8.2) is now
the globals plus the options of every route reachable from that node
through required parameters only, not just one deterministic route. A
node with both a literal child and a param child (e.g. a root that has
its own route and a parameter shortcut, like
''and<program>) offers the union, so an option belonging only to the parameter route is still readable there; whether it is actually accepted is still decided by the specific route resolution lands on (seeunknownOptionabove). missingArgument(a required parameter with no value left in argv) now revalidates every option already read against the one route still reachable, before reporting the missing value: once the resolver knows unambiguously which route the invocation would land on, an option that route rejects isunknownOption, naming it, in preference tomissingArgument(spec 8.6: help, and any other option, loses to an option error once the route is known).missingArgumentat a position where more than one route is still reachable, or where none of the already-read options mismatch, is unaffected and still carries no route._lookAheadRoute(used only to name a route in amisplacedOptionmessage) no longer treats an option-shaped token as a literal route word or a required parameter's value while walking ahead: an option token now always makes the lookahead returnnull, rather than the node it started from, so a node's own preexisting route (for example a root that has its own''route) is never reported as the walk's conclusion just because the walk broke on the first token. Subtree declaration (spec 8.2 rule a) is now checked before, not only after, an uninterrupted lookahead: a route the lookahead actually reaches is only named when that route is itself one of the routes declaring the option somewhere in the subtree, so a route that merely happens to be where the walk lands, without declaring the option, can no longer be misreported asunknownOptionin place ofmisplacedOption.cmd()andCliRouter's constructor now storeList.unmodifiablecopies of a route'soptionsand ofglobalOptions: mutating the list passed in after registration no longer changes the registered route or router.'--'after the first operand has started is nowmisplacedOption("'--' goes before the program"), not silently absorbed. Previously<program> one --resolved the same as<program> one, andrun *withrun a -- --helpresolved withrest: [a, --help], both silently dropping or reinterpreting the--the caller actually typed.'--'before the first operand is unaffected: it still ends option parsing.'--', like an operand, now rules out any literal route word still pending on the current node once it is seen, when deciding whether an unresolved position's route is nonetheless known unambiguously (used to check an already read option against it before reportingmissingArgument). Previously only an actual operand counted as this commitment, so a node offering both a literal child and a parameter route (e.g.run <x>andrun sub) still treated the literal sibling as reachable after'--', reportingmissingArgumentinstead ofunknownOptionnaming the one route'--'had already settled on.- The shape (flag vs. value, required, repeatable) used to decide how many
tokens a misplaced option reads, and so which route the lookahead
reaches, now comes only from the declarations reachable in the current
subtree, never from whichever route
_findAnyDeclaration's router-wide search happens to find first. A route entirely outside the current subtree declaring the same name or abbreviation with a different shape (permitted on unrelated branches, spec 8.2) could previously misdirect the skip width and walk lookahead to, and name, the wrong route, with the outcome depending on that unrelated route's registration order. When the subtree's own declarations disagree on the shape, the option is stillmisplacedOption, withroute: nulland every candidate listed, rather than guessing one of their shapes. - An unrecognized first token now reports
unknownCommand, notextraArgument, even when the root itself already owns a route (a''pattern): that route only ever matches an invocation with zero operands and was never actually resolved to for a nonempty first token, so naming it in anextraArgumentrejection was wrong.extraArgumentstill applies once an operand has actually been bound, including when it was bound to a root optional parameter or wildcard route (e.g.[<arg>]alone at the root): binding those never moves the resolver off the root node, so the rootunknownCommandguard checks whether an operand has been consumed, not merely whether the resolver is still positioned at the root. - A node may now register both a required parameter and a trailing
*wildcard (e.g.run <arg>andrun *); they are no longer a build-timeStateError, in either registration order. At resolution time, while an operand token remains, it always goes to the parameter, unconditionally: there is no exception for the last token and no lookahead into the parameter's own subtree. The wildcard is reached only when argv ends exactly at that node and the node has no route of its own, where it matches zero operands. Because of this, a trailing optional[<arg>]parameter can never coexist with a trailing*wildcard at the same position, in either registration order: the optional parameter already matches every operand count, zero or one, that the wildcard could otherwise catch, so the wildcard route could never be reached. Registering both is a build-timeArgumentErrornaming the wildcard route unreachable, replacing the previous, less specificStateError. See_TrieNode's dartdoc for the full rule. - A
misplacedOptionmessage now names the option by the declaration reachable from the current subtree, never by an unrelated route's declaration that_findAnyDeclaration's router-wide search happened to find first (e.g. registeringotherwith--archive/-xbeforegroup awith--execute/-xno longer makesgroup -x adescribe-xas--archive). When the reachable declarations disagree in shape, the message falls back to the spelling the user typed. listCommands()now buildsListedCommand.commandfrom a route's full registered segments, trailing optional parameter or wildcard included (run *now lists asrun *,help [<topic>]ashelp [<topic>]), instead ofCliRoute.pattern, which deliberately omits that trailing segment.
Removed #
CliRequest.flags(the lossy, string-keyed flag map) and theflagBool/flagInt/flagDouble/flagString/isHelpRequestedhelpers. Useoption(name)on the typedParsedOption.CliRequest.matchedCommandand.positionals. Useroute.patternandparams/rest.onNotFound/CliNotFound/CliNotFoundHandler. Userun's requiredonRejectwith aCliRejection.--no-xnegation and short-option cluster expansion (-abcno longer expands toa=true, b=true, c=true); neither had a declared, typed meaning.CliRouter.printHelp. Help formatting is an application (ormodular_cli_sdk) concern; the router only exposeslistCommands().cmd(pattern, router)as sugar for mounting a subrouter. Usemount.ListedCommand.module. It was alwaysnull: mount flattening threads the mount prefix into the route's ownpattern(commands show <name>), it never tracked the mount name separately. UsereservedWordsto check whether a word is already a mount prefix.
0.1.1 - 2026-09-23 #
Fixed #
- Use one option-token predicate for route matching, flag parsing, and option value lookahead. An option starts with one or two dashes followed by an ASCII letter, and its name (the part before
=) contains no whitespace; the value after=may contain anything, so--title=two wordsis still an option. Negative numbers, bare-, quoted expressions, and other non-option tokens remain available as positionals or option values, so--offset -3now assigns-3tooffset. The--marker still ends options.
0.1.0 - 2026-07-13 #
Added #
CliRouter({CliNotFoundHandler? onNotFound}): an application can now decide how an unmatched invocation is reported and with which exit code. The hook receives aCliNotFound(original args + sinks) and is inherited by every mounted subrouter. Presentation belongs to the application; the router only knows what failed to match.ListedCommandnow carries the route metadata the router already had and used to discard:positionals(the names of the<param>segments, in order) andmodule(the mount the command was registered under).ListedCommand,CliNotFoundandCliNotFoundHandlerare exported frompackage:cli_router/cli_router.dart.
Fixed #
- Positionals after the matched route were dropped when the invocation carried no flags (
help mathreached the handler with an emptypositionals). They are now captured whether or not a flag follows.
Changed #
- Minor version bump:
^0.0.zpermits no upgrade under Dart's caret semantics, so0.0.xreleases pinned consumers to an exact patch. From^0.1.0on, consumers receive compatible updates without editing their pubspec.
Nothing breaks: with no onNotFound supplied, the router still prints Command not found or invalid usage. plus its listing to stderr and returns 64.
0.0.3 - 2026-04-18 #
Fixed #
- Empty route
''no longer acts as catch-all. It now only matches whenargsis genuinely empty, preventing it from intercepting flag-only (--help) or positional args before mounts are evaluated.
0.0.2 - 2025-10-13 #
Changed #
- Shortened
descriptioninpubspec.yamlto meet pub.flutter-io.cn guidelines. - Updated
homepage→ GitHub anddocumentation→ pub.flutter-io.cn for valid URLs. - Applied
dart format .anddart fix --applyto match Dart style. - Updated
README.mdwith version^0.0.2and added pub badge. - Completed MIT
LICENSEtext.
Removed #
- Removed unused
test/cli_router_test.dart.
Docs #
- Improved DartDoc coverage for
CliRequestmethods. - Fixed unresolved reference in doc comment for
CliRequest.matchedCommand.
Fixed #
- Minor analyzer and formatting warnings reported by pana.
0.0.1 - 2025-10-13 #
Added #
- Initial release of cli_router.
- Space-based routing for commands:
cmd('route subroute', handler). - Nested routers via
mount('prefix', subRouter)orcmd('prefix', subRouter). - Dynamic parameters
<id>and wildcard*. - GNU-style flag parsing (
--k v,--k=v,-abc,--no-k). - Shelf-like middlewares with
use(). - Helpers:
flagBool,flagInt,flagDouble,flagString,param('id'). - Simple help output and exit codes (
0,64).
- Space-based routing for commands: