Skip to content

Commit 32a256e

Browse files
authored
Fix flags before subcommand causing "Too many arguments" error (#13)
1 parent ea21e75 commit 32a256e

10 files changed

Lines changed: 340 additions & 154 deletions

File tree

.editorconfig

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,19 @@
1-
# Top-most EditorConfig file
21
root = true
32

4-
# Global settings (applicable to all files unless overridden)
53
[*]
6-
charset = utf-8 # Default character encoding
7-
end_of_line = lf # Use LF for line endings (Unix-style)
8-
indent_style = space # Use spaces for indentation
9-
indent_size = 4 # Default indentation size
10-
insert_final_newline = true # Make sure files end with a newline
11-
trim_trailing_whitespace = true # Remove trailing whitespace
4+
charset = utf-8
5+
end_of_line = lf
6+
indent_style = space
7+
indent_size = 4
8+
insert_final_newline = true
9+
trim_trailing_whitespace = true
1210

13-
# Zig files
14-
[*.zig]
11+
[*.{zig,py}]
1512
max_line_length = 100
1613

17-
# Markdown files
1814
[*.md]
19-
max_line_length = 120
20-
trim_trailing_whitespace = false # Don't remove trailing whitespace in Markdown files
15+
max_line_length = 150
16+
trim_trailing_whitespace = false
2117

22-
# Bash scripts
23-
[*.sh]
24-
indent_size = 2
25-
26-
# YAML files
2718
[*.{yml,yaml}]
2819
indent_size = 2
29-
30-
# Python files
31-
[*.py]
32-
max_line_length = 100

.github/workflows/docs.yml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
name: Publish API Documentation
22

33
on:
4+
workflow_dispatch:
45
push:
56
tags:
67
- 'v*'
78

8-
workflow_dispatch:
9+
concurrency:
10+
group: ${{ github.workflow }}-${{ github.ref }}
11+
cancel-in-progress: true
912

1013
permissions:
1114
contents: write
@@ -20,7 +23,7 @@ jobs:
2023
- name: Install Zig
2124
uses: goto-bus-stop/setup-zig@v2
2225
with:
23-
version: '0.15.1'
26+
version: '0.15.2'
2427

2528
- name: Install System Dependencies
2629
run: |

.github/workflows/lints.yml

Lines changed: 0 additions & 36 deletions
This file was deleted.

.github/workflows/tests.yml

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,20 @@
11
name: Run Tests
22

33
on:
4+
workflow_dispatch:
45
push:
6+
branches:
7+
- main
8+
- develop
59
tags:
610
- 'v*'
7-
811
pull_request:
912
branches:
1013
- main
1114

12-
workflow_dispatch:
15+
concurrency:
16+
group: ${{ github.workflow }}-${{ github.ref }}
17+
cancel-in-progress: true
1318

1419
permissions:
1520
contents: read
@@ -25,7 +30,7 @@ jobs:
2530
- name: Install Zig
2631
uses: goto-bus-stop/setup-zig@v2
2732
with:
28-
version: '0.15.1'
33+
version: '0.15.2'
2934

3035
- name: Install Dependencies
3136
run: |

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,5 @@ docs/api/
104104
*.dll
105105
*.exe
106106
latest
107+
.claude/
108+
.codex

README.md

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,7 @@
77
<h2>Chilli</h2>
88

99
[![Tests](https://img.shields.io/github/actions/workflow/status/CogitatorTech/chilli/tests.yml?label=tests&style=flat&labelColor=282c34&logo=github)](https://github.com/CogitatorTech/chilli/actions/workflows/tests.yml)
10-
[![CodeFactor](https://img.shields.io/codefactor/grade/github/CogitatorTech/chilli?label=code%20quality&style=flat&labelColor=282c34&logo=codefactor)](https://www.codefactor.io/repository/github/CogitatorTech/chilli)
11-
[![Zig Version](https://img.shields.io/badge/Zig-0.15.1-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download)
10+
[![Zig Version](https://img.shields.io/badge/Zig-0.15.2-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download)
1211
[![Docs](https://img.shields.io/badge/docs-read-blue?style=flat&labelColor=282c34&logo=read-the-docs)](https://CogitatorTech.github.io/chilli)
1312
[![Examples](https://img.shields.io/badge/examples-view-green?style=flat&labelColor=282c34&logo=zig)](https://github.com/CogitatorTech/chilli/tree/main/examples)
1413
[![Release](https://img.shields.io/github/release/CogitatorTech/chilli.svg?label=release&style=flat&labelColor=282c34&logo=github)](https://github.com/CogitatorTech/chilli/releases/latest)
@@ -53,8 +52,8 @@ Run the following command in the root directory of your project to download Chil
5352
zig fetch --save=chilli "https://github.com/CogitatorTech/chilli/archive/<branch_or_tag>.tar.gz"
5453
```
5554

56-
Replace `<branch_or_tag>` with the desired branch or tag, like `main` (for the development version) or `v0.2.0`
57-
(for the latest release).
55+
Replace `<branch_or_tag>` with the desired branch or tag, like `main` (for the development version) or `v0.2.3`
56+
(for the specified release version).
5857
This command will download Chilli and add it to Zig's global cache and update your project's `build.zig.zon` file.
5958

6059
#### Adding to Build Script
@@ -151,7 +150,7 @@ You can now run your CLI application with the `--help` flag to see the output be
151150

152151
```bash
153152
$ ./your-cli-app --help
154-
your-cli-app v0.2.0
153+
your-cli-app v0.2.3
155154
A new CLI built with Chilli
156155

157156
USAGE:
@@ -192,4 +191,4 @@ Chilli is licensed under the MIT License (see [LICENSE](LICENSE)).
192191

193192
### Acknowledgements
194193

195-
* The logo is from [SVG Repo](https://www.svgrepo.com/svg/45673/chili-pepper).
194+
* The logo is from [SVG Repo](https://www.svgrepo.com/svg/45673/chili-pepper) with some modifications.

build.zig.zon

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
.{
22
.name = .chilli,
3-
.version = "0.2.2",
3+
.version = "0.2.3",
44
.fingerprint = 0x6c259741ae4f5f73, // Changing this has security and trust implications.
5-
.minimum_zig_version = "0.15.1",
5+
.minimum_zig_version = "0.15.2",
66
.paths = .{
77
"build.zig",
88
"build.zig.zon",

pyproject.toml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[project]
22
name = "chilli"
33
version = "0.1.0"
4-
description = "Python environment for Chilli"
4+
description = "The Python environment for the Chilli project"
55

66
requires-python = ">=3.10,<4.0"
77
dependencies = [
@@ -11,11 +11,11 @@ dependencies = [
1111

1212
[project.optional-dependencies]
1313
dev = [
14-
"pytest>=8.0.1",
15-
"pytest-cov>=6.0.0",
16-
"pytest-mock>=3.14.0",
14+
"pytest (>=8.0.1,<9.0.0)",
15+
"pytest-cov (>=6.0.0,<7.0.0)",
16+
"pytest-mock (>=3.14.0,<4.0.0)",
1717
"pytest-asyncio (>=0.26.0,<0.27.0)",
18-
"mypy>=1.11.1",
19-
"ruff>=0.9.3",
18+
"mypy (>=1.11.1,<2.0.0)",
19+
"ruff (>=0.9.3,<1.0.0)",
2020
"icecream (>=2.1.4,<3.0.0)"
2121
]

src/chilli/command.zig

Lines changed: 135 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -153,21 +153,33 @@ pub const Command = struct {
153153
var arg_iterator = parser.ArgIterator.init(user_args);
154154

155155
var current_cmd: *Command = self;
156+
out_failed_cmd.* = current_cmd;
157+
158+
// Reset root command state for re-entering.
159+
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
160+
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);
161+
162+
// Resolve the subcommand chain, parsing flags at each level.
163+
// Flags before a subcommand name are stored on the command at that level.
156164
while (arg_iterator.peek()) |arg| {
157-
if (std.mem.startsWith(u8, arg, "-")) break;
165+
if (std.mem.eql(u8, arg, "--")) break;
166+
if (std.mem.startsWith(u8, arg, "-")) {
167+
try parser.parseFlagsOnly(current_cmd, &arg_iterator);
168+
continue;
169+
}
158170
if (current_cmd.findSubcommand(arg)) |found_sub| {
159171
current_cmd = found_sub;
172+
out_failed_cmd.* = current_cmd;
173+
// Reset subcommand state for re-entrancy.
174+
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
175+
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);
160176
arg_iterator.next();
161177
} else {
162178
break;
163179
}
164180
}
165-
out_failed_cmd.* = current_cmd;
166-
167-
// Reset state from any previous run, making the command re-entrant.
168-
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
169-
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);
170181

182+
// Parse remaining flags and positional arguments for the final resolved command.
171183
try parser.parseArgsAndFlags(current_cmd, &arg_iterator);
172184

173185
// Check for --help and --version flags BEFORE validation
@@ -344,10 +356,16 @@ pub const Command = struct {
344356
return null;
345357
}
346358

347-
/// (Internal) Retrieves the parsed value of a flag for the current command.
359+
/// (Internal) Retrieves the parsed value of a flag, searching upwards through
360+
/// parent commands. This mirrors `findFlag` and allows subcommand exec functions
361+
/// to access flags that were parsed at a parent command level.
348362
pub fn getFlagValue(self: *const Command, name: []const u8) ?types.FlagValue {
349-
for (self.parsed_flags.items) |flag| {
350-
if (std.mem.eql(u8, flag.name, name)) return flag.value;
363+
var current: ?*const Command = self;
364+
while (current) |cmd| {
365+
for (cmd.parsed_flags.items) |flag| {
366+
if (std.mem.eql(u8, flag.name, name)) return flag.value;
367+
}
368+
current = cmd.parent;
351369
}
352370
return null;
353371
}
@@ -443,6 +461,114 @@ test "command: execute with args and flags" {
443461
try testing.expectEqualStrings("input.txt", integration_arg_val);
444462
}
445463

464+
// -- Tests for flags before subcommand (issue #12) --
465+
466+
var parent_flag_from_sub: []const u8 = "";
467+
468+
fn parentFlagExec(ctx: context.CommandContext) !void {
469+
parent_flag_from_sub = try ctx.getFlag("config", []const u8);
470+
integration_arg_val = try ctx.getArg("file", []const u8);
471+
}
472+
473+
test "command: root flag before subcommand resolves subcommand" {
474+
const allocator = testing.allocator;
475+
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
476+
defer root.deinit();
477+
478+
try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });
479+
480+
var sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = parentFlagExec });
481+
try root.addSubcommand(sub);
482+
try sub.addPositional(.{ .name = "file", .is_required = true, .description = "" });
483+
484+
// The exact pattern from the bug report: --config <value> run <arg>
485+
var failed_cmd: ?*const Command = null;
486+
const args = &[_][]const u8{ "--config", "custom.conf", "run", "input.txt" };
487+
try root.execute(args, null, &failed_cmd);
488+
489+
try testing.expect(failed_cmd == null);
490+
try testing.expectEqualStrings("input.txt", integration_arg_val);
491+
}
492+
493+
test "command: root short flag before subcommand resolves subcommand" {
494+
const allocator = testing.allocator;
495+
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
496+
defer root.deinit();
497+
498+
try root.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" });
499+
500+
exec_called_on = null;
501+
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
502+
try root.addSubcommand(sub);
503+
504+
var failed_cmd: ?*const Command = null;
505+
const args = &[_][]const u8{ "-v", "run" };
506+
try root.execute(args, null, &failed_cmd);
507+
508+
try testing.expect(failed_cmd == null);
509+
try testing.expectEqualStrings("run", exec_called_on.?);
510+
}
511+
512+
test "command: multiple root flags before subcommand" {
513+
const allocator = testing.allocator;
514+
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
515+
defer root.deinit();
516+
517+
try root.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" });
518+
try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });
519+
520+
exec_called_on = null;
521+
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
522+
try root.addSubcommand(sub);
523+
524+
var failed_cmd: ?*const Command = null;
525+
const args = &[_][]const u8{ "-v", "--config=custom.conf", "run" };
526+
try root.execute(args, null, &failed_cmd);
527+
528+
try testing.expect(failed_cmd == null);
529+
try testing.expectEqualStrings("run", exec_called_on.?);
530+
}
531+
532+
test "command: getFlagValue traverses parents" {
533+
const allocator = testing.allocator;
534+
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
535+
defer root.deinit();
536+
537+
try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });
538+
539+
var sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = parentFlagExec });
540+
try root.addSubcommand(sub);
541+
try sub.addPositional(.{ .name = "file", .is_required = true, .description = "" });
542+
543+
parent_flag_from_sub = "";
544+
var failed_cmd: ?*const Command = null;
545+
const args = &[_][]const u8{ "--config", "custom.conf", "run", "input.txt" };
546+
try root.execute(args, null, &failed_cmd);
547+
548+
try testing.expect(failed_cmd == null);
549+
// The subcommand's exec must see the root-level --config value, not the default
550+
try testing.expectEqualStrings("custom.conf", parent_flag_from_sub);
551+
}
552+
553+
test "command: -- before subcommand stops resolution" {
554+
const allocator = testing.allocator;
555+
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
556+
defer root.deinit();
557+
558+
exec_called_on = null;
559+
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
560+
try root.addSubcommand(sub);
561+
try root.addPositional(.{ .name = "arg", .is_required = true, .description = "" });
562+
563+
var failed_cmd: ?*const Command = null;
564+
// -- stops subcommand resolution, so "run" becomes a positional for root
565+
const args = &[_][]const u8{ "--", "run" };
566+
try root.execute(args, null, &failed_cmd);
567+
568+
try testing.expect(failed_cmd == null);
569+
try testing.expectEqualStrings("app", exec_called_on.?);
570+
}
571+
446572
test "command: addSubcommand detects empty alias" {
447573
const allocator = std.testing.allocator;
448574
var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec });

0 commit comments

Comments
 (0)