Options are silently dropped/misparsed when they appear after a positional argument
Nobody has claimed this yet.
- Dominant language
- Scala
- Stars
- 138
- Forks
- 91
- Avg merge
- 1d 8h
- Merged PRs (30d)
- 13
Description
Originally raised on Discord: https://discord.com/channels/629491597070827530/710819352370479124/1512516533795160096
Description
Options.matchOptions stops scanning for options at the first token that
doesn't match any remaining option. Everything from that point on —
including options that appear later — is returned as leftover
input/operands, with no validation error. This means flags placed after a
positional argument are silently ignored (fixed-arity) or swallowed as
positional values (variadic Args), instead of being rejected or parsed.
Flags-after-positional is common muscle memory carried over from other
CLI parsers (git add . -A, docker run img -it, GNU getopt-style
permutation), so this can silently produce wrong results with no
indication anything went wrong.
Reproduction
val command = Command("greet", Options.text("greeting").optional, Args.text("name"))
greet --greeting hi bob→CommandDirective.UserDefined(List(), (Some("hi"), "bob"))(works)greet bob --greeting hi→CommandDirective.UserDefined(List("--greeting", "hi"), (None, "bob"))—
greetingisNone, and--greeting hiend up in the leftover list
(the first field ofUserDefined) instead of an error. Nothing crashes,
but leftover is silently ignored by most callers (e.g.CliApp), so in
practice the flag just vanishes.- With
Args.text("name").repeat1instead:
greet bob --greeting hi→CommandDirective.UserDefined(List(), (None, List("bob", "--greeting", "hi")))—
flag fully swallowed into the positionalnamevalues, no leftover at all.
(Verified against current master by running these three cases through Command#parse directly.)
Root cause
Options.Single.parseonly pattern-matches onargs.head
(Options.scala,processArgs/parsearound line 432) — there's no
look-ahead scan past the head of the list.findOptions(Options.scala:203) tries each remaining option against
the current head; a miss producesMissingFlagand falls through to
the next option, but always against the same head.matchOptions(Options.scala:281) recurses only while the current
pass matched at least one option (map1.isEmptycheck). Once a pass
matches nothing, it returns immediately with the entire remaining
input list as leftover — including any options further down the list.- That leftover is handed to
Argsparsing/validation as-is, with no
distinction between "these are positional values" and "these look like
options that never got a chance to match."
This produces POSIX-style "stop at first positional" behavior, but
without ever emitting an error when a later token that isn't consumed by
Args looks like an option (e.g. starts with -/--). It's unclear
whether the strict stop-at-first-positional behavior itself is
intentional, but the silent, unerrored swallowing of subsequent options
looks like a gap rather than a deliberate design choice.
Proposal
Add an opt-in CliConfig flag (default false, preserving current
behavior) to enable GNU-style option/positional permutation, e.g.:
final case class CliConfig(
caseSensitive: Boolean,
autoCorrectLimit: Int,
finalCheckBuiltIn: Boolean,
permuteOptionsAndArgs: Boolean = false // name TBD
)
When enabled, option scanning would continue past non-matching tokens
(treating them as candidate positionals) instead of stopping at the
first miss, so greet bob --greeting hi parses the same as
greet --greeting hi bob.
Separately from the opt-in permutation flag: even with default (false)
behavior, it may be worth considering whether a token that looks like an
option (-x/--foo) landing in leftover input that Args doesn't
consume should raise a validation error rather than being silently
accepted as a positional value — right now there's no way to tell
"deliberate positional string that happens to start with -" apart from
"option that arrived too late to be recognized."
Happy to put together a PR for the CliConfig flag if this approach
sounds right.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Options.scala, especially findOptions, matchOptions, and Options.Single.parse around the referenced lines, then reproduce the three cases through Command#parse. Review CliConfig and define the opt-in behavior so option/positional permutation works while the default remains unchanged; done means later options are parsed correctly when enabled and existing behavior is preserved by default.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- scala
- Domain
- cli
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100