Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-flags-stand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Fix CLI parsing so command-local flags can override globals without breaking global flags before subcommands.
6 changes: 5 additions & 1 deletion packages/effect/src/unstable/cli/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1535,7 +1535,11 @@ export const runWith = <const Name extends string, Input, E, R, ContextInput>(
// 2. Extract global flag tokens
const allFlagParams = allFlags.flatMap((f) => Param.extractSingleParams(f.flag))
const globalRegistry = Parser.createFlagRegistry(allFlagParams.filter(Param.isFlagParam))
const { flagMap, remainder, errors: globalFlagErrors } = Parser.consumeKnownFlags(tokens, globalRegistry)
const { flagMap, remainder, errors: globalFlagErrors } = Parser.consumeGlobalFlags(
tokens,
command,
globalRegistry
)
const emptyArgs: Param.ParsedArgs = { flags: flagMap, arguments: [] }

// 3. Parse command arguments from remaining tokens
Expand Down
214 changes: 204 additions & 10 deletions packages/effect/src/unstable/cli/internal/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,17 @@ type ConsumedFlagValue =
readonly _tag: "Error"
readonly error: CliError.InvalidValue
}
type ConsumedFlagValueWithTokens =
| {
readonly _tag: "Value"
readonly value: string | undefined
readonly tokens: ReadonlyArray<Token>
}
| {
readonly _tag: "Error"
readonly error: CliError.InvalidValue
readonly tokens: ReadonlyArray<Token>
}

const isFlagToken = (t: Token): t is FlagToken => t._tag === "LongOption" || t._tag === "ShortOption"

Expand Down Expand Up @@ -407,60 +418,88 @@ const consumeFlagValue = (
spec: FlagParam,
negated = false
): ConsumedFlagValue => {
const consumed = consumeFlagValueWithTokens(cursor, token, spec, negated)
switch (consumed._tag) {
case "Value":
return {
_tag: "Value",
value: consumed.value
}
case "Error":
return {
_tag: "Error",
error: consumed.error
}
}
}

const consumeFlagValueWithTokens = (
cursor: TokenCursor,
token: FlagToken,
spec: FlagParam,
negated = false
): ConsumedFlagValueWithTokens => {
// Inline value has highest priority
if (negated) {
if (token.value !== undefined) {
return {
_tag: "Error",
error: invalidNegatedFlagValue(token, spec, token.value)
error: invalidNegatedFlagValue(token, spec, token.value),
tokens: []
}
}

const literal = asBooleanLiteral(cursor.peek())
if (literal !== undefined) {
cursor.take()
const literalToken = cursor.take()
return {
_tag: "Error",
error: invalidNegatedFlagValue(token, spec, literal)
error: invalidNegatedFlagValue(token, spec, literal),
tokens: literalToken === undefined ? [] : [literalToken]
}
}

return {
_tag: "Value",
value: "false"
value: "false",
tokens: []
}
}

if (token.value !== undefined) {
return {
_tag: "Value",
value: token.value
value: token.value,
tokens: []
}
}

// Boolean flags: check for explicit literal or default to "true"
if (Primitive.isBoolean(spec.primitiveType)) {
const literal = asBooleanLiteral(cursor.peek())
if (literal !== undefined) cursor.take()
const literalToken = literal !== undefined ? cursor.take() : undefined
return {
_tag: "Value",
value: literal ?? "true"
value: literal ?? "true",
tokens: literalToken === undefined ? [] : [literalToken]
}
}

// Non-boolean: try to consume next Value token
const next = cursor.peek()
if (next?._tag === "Value") {
cursor.take()
const valueToken = cursor.take()
return {
_tag: "Value",
value: next.value
value: next.value,
tokens: valueToken === undefined ? [] : [valueToken]
}
}

return {
_tag: "Value",
value: undefined
value: undefined,
tokens: []
}
}

Expand Down Expand Up @@ -504,6 +543,161 @@ export const consumeKnownFlags = (
return { flagMap, remainder, errors }
}

const extractFlagParams = (command: Command.Any): ReadonlyArray<FlagParam> => {
const commandImpl = toImpl(command)
const singles = commandImpl.config.flags.flatMap(Param.extractSingleParams)
return singles.filter(Param.isFlagParam)
}

const extractContextFlagParams = (command: Command.Any): ReadonlyArray<FlagParam> => {
const commandImpl = toImpl(command)
const singles = commandImpl.contextConfig.flags.flatMap(Param.extractSingleParams)
return singles.filter(Param.isFlagParam)
}

const resolveFromRegistries = (
token: FlagToken,
registries: ReadonlyArray<FlagRegistry>
): ResolvedFlag | undefined => {
for (const registry of registries) {
const resolved = resolveFlag(token, registry)
if (resolved !== undefined) {
return resolved
}
}
return undefined
}

const preserveFlag = (
remainder: Array<Token>,
cursor: TokenCursor,
token: FlagToken,
resolved: ResolvedFlag
): void => {
remainder.push(token)
const consumed = consumeFlagValueWithTokens(cursor, token, resolved.param, resolved.negated)
remainder.push(...consumed.tokens)
}

const localFlagWouldPrecedeSubcommand = (
token: FlagToken,
remainingTokens: ReadonlyArray<Token>,
resolved: ResolvedFlag,
subIndex: Map<string, Command<string, unknown, unknown, unknown, unknown>>,
registries: ReadonlyArray<FlagRegistry>
): boolean => {
const cursor = makeCursor(remainingTokens)
consumeFlagValueWithTokens(cursor, token, resolved.param, resolved.negated)
for (let token = cursor.take(); token; token = cursor.take()) {
if (isFlagToken(token)) {
const known = resolveFromRegistries(token, registries)
if (known !== undefined) {
consumeFlagValueWithTokens(cursor, token, known.param, known.negated)
}
continue
}

if (token._tag === "Value") {
return subIndex.has(token.value)
}
}
return false
}

/**
* Consumes global flags while walking the command tree.
*
* Command-local flags take precedence over global flags at the selected command
* level. This lets commands reuse a global flag name, for example a subcommand
* with its own `--version <value>` flag overriding the built-in global
* `--version` action.
* @internal
*/
export const consumeGlobalFlags = (
tokens: ReadonlyArray<Token>,
command: Command.Any,
registry: FlagRegistry
): { flagMap: FlagMap; remainder: ReadonlyArray<Token>; errors: ReadonlyArray<CliError.InvalidValue> } => {
const flagMap = createEmptyFlagMap(registry.params)
const errors: Array<CliError.InvalidValue> = []

const consumeLevel = (
tokens: ReadonlyArray<Token>,
command: Command.Any,
ignoredRegistries: ReadonlyArray<FlagRegistry>
): ReadonlyArray<Token> => {
const localRegistry = createFlagRegistry(extractFlagParams(command))
const inheritedRegistry = createFlagRegistry(extractContextFlagParams(command))
const subIndex = buildSubcommandIndex(command.subcommands)
const cursor = makeCursor(tokens)
const remainder: Array<Token> = []
let awaitingFirstValue = true

for (let token = cursor.take(); token; token = cursor.take()) {
if (isFlagToken(token)) {
const ignored = resolveFromRegistries(token, ignoredRegistries)
if (ignored !== undefined) {
preserveFlag(remainder, cursor, token, ignored)
continue
}

const inherited = resolveFlag(token, inheritedRegistry)
if (inherited !== undefined) {
preserveFlag(remainder, cursor, token, inherited)
continue
}

const local = resolveFlag(token, localRegistry)
const global = resolveFlag(token, registry)
if (local !== undefined) {
if (
global === undefined || !awaitingFirstValue ||
!localFlagWouldPrecedeSubcommand(token, cursor.rest(), local, subIndex, [
localRegistry,
inheritedRegistry,
registry
])
) {
Comment thread
jgoux marked this conversation as resolved.
preserveFlag(remainder, cursor, token, local)
continue
}
}

if (global !== undefined) {
const consumed = consumeFlagValueWithTokens(cursor, token, global.param, global.negated)
if (consumed._tag === "Error") {
errors.push(consumed.error)
continue
}
if (consumed.value !== undefined) {
flagMap[global.param.name].push(consumed.value)
}
continue
}

remainder.push(token)
continue
}

if (token._tag === "Value" && awaitingFirstValue) {
const sub = subIndex.get(token.value)
if (sub !== undefined) {
remainder.push(token)
remainder.push(...consumeLevel(cursor.rest(), sub, [...ignoredRegistries, inheritedRegistry]))
return remainder
}
awaitingFirstValue = false
}

remainder.push(token)
}

return remainder
}

return { flagMap, remainder: consumeLevel(tokens, command, []), errors }
}

/* ========================================================================== */
/* Error Creation */
/* ========================================================================== */
Expand Down
Loading
Loading