zio / zio/zio-cli

Options are silently dropped/misparsed when they appear after a positional argument

Open
#627 1 comment 0 reactions 0 assignees View on GitHub

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 bobCommandDirective.UserDefined(List(), (Some("hi"), "bob")) (works)
  • greet bob --greeting hiCommandDirective.UserDefined(List("--greeting", "hi"), (None, "bob"))
    greeting is None, and --greeting hi end up in the leftover list
    (the first field of UserDefined) 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").repeat1 instead:
    greet bob --greeting hiCommandDirective.UserDefined(List(), (None, List("bob", "--greeting", "hi")))
    flag fully swallowed into the positional name values, no leftover at all.

(Verified against current master by running these three cases through Command#parse directly.)

Root cause

  • Options.Single.parse only pattern-matches on args.head
    (Options.scala, processArgs/parse around 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 produces MissingFlag and 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.isEmpty check). 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 Args parsing/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

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.