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
31 changes: 9 additions & 22 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -1,32 +1,19 @@
# Top-most EditorConfig file
root = true

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

# Zig files
[*.zig]
[*.{zig,py}]
max_line_length = 100

# Markdown files
[*.md]
max_line_length = 120
trim_trailing_whitespace = false # Don't remove trailing whitespace in Markdown files
max_line_length = 150
trim_trailing_whitespace = false

# Bash scripts
[*.sh]
indent_size = 2

# YAML files
[*.{yml,yaml}]
indent_size = 2

# Python files
[*.py]
max_line_length = 100
7 changes: 5 additions & 2 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
name: Publish API Documentation

on:
workflow_dispatch:
push:
tags:
- 'v*'

workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: write
Expand All @@ -20,7 +23,7 @@ jobs:
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: '0.15.1'
version: '0.15.2'

- name: Install System Dependencies
run: |
Expand Down
36 changes: 0 additions & 36 deletions .github/workflows/lints.yml

This file was deleted.

11 changes: 8 additions & 3 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
name: Run Tests

on:
workflow_dispatch:
push:
branches:
- main
- develop
tags:
- 'v*'

pull_request:
branches:
- main

workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read
Expand All @@ -25,7 +30,7 @@ jobs:
- name: Install Zig
uses: goto-bus-stop/setup-zig@v2
with:
version: '0.15.1'
version: '0.15.2'

- name: Install Dependencies
run: |
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -104,3 +104,5 @@ docs/api/
*.dll
*.exe
latest
.claude/
.codex
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@
<h2>Chilli</h2>

[![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)
[![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)
[![Zig Version](https://img.shields.io/badge/Zig-0.15.1-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download)
[![Zig Version](https://img.shields.io/badge/Zig-0.15.2-orange?logo=zig&labelColor=282c34)](https://ziglang.org/download)
[![Docs](https://img.shields.io/badge/docs-read-blue?style=flat&labelColor=282c34&logo=read-the-docs)](https://CogitatorTech.github.io/chilli)
[![Examples](https://img.shields.io/badge/examples-view-green?style=flat&labelColor=282c34&logo=zig)](https://github.com/CogitatorTech/chilli/tree/main/examples)
[![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)
Expand Down Expand Up @@ -53,8 +52,8 @@ Run the following command in the root directory of your project to download Chil
zig fetch --save=chilli "https://github.com/CogitatorTech/chilli/archive/<branch_or_tag>.tar.gz"
```

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

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

```bash
$ ./your-cli-app --help
your-cli-app v0.2.0
your-cli-app v0.2.3
A new CLI built with Chilli

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

### Acknowledgements

* The logo is from [SVG Repo](https://www.svgrepo.com/svg/45673/chili-pepper).
* The logo is from [SVG Repo](https://www.svgrepo.com/svg/45673/chili-pepper) with some modifications.
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
.{
.name = .chilli,
.version = "0.2.2",
.version = "0.2.3",
.fingerprint = 0x6c259741ae4f5f73, // Changing this has security and trust implications.
.minimum_zig_version = "0.15.1",
.minimum_zig_version = "0.15.2",
.paths = .{
"build.zig",
"build.zig.zon",
Expand Down
12 changes: 6 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[project]
name = "chilli"
version = "0.1.0"
description = "Python environment for Chilli"
description = "The Python environment for the Chilli project"

requires-python = ">=3.10,<4.0"
dependencies = [
Expand All @@ -11,11 +11,11 @@ dependencies = [

[project.optional-dependencies]
dev = [
"pytest>=8.0.1",
"pytest-cov>=6.0.0",
"pytest-mock>=3.14.0",
"pytest (>=8.0.1,<9.0.0)",
"pytest-cov (>=6.0.0,<7.0.0)",
"pytest-mock (>=3.14.0,<4.0.0)",
"pytest-asyncio (>=0.26.0,<0.27.0)",
"mypy>=1.11.1",
"ruff>=0.9.3",
"mypy (>=1.11.1,<2.0.0)",
"ruff (>=0.9.3,<1.0.0)",
"icecream (>=2.1.4,<3.0.0)"
]
144 changes: 135 additions & 9 deletions src/chilli/command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -153,21 +153,33 @@ pub const Command = struct {
var arg_iterator = parser.ArgIterator.init(user_args);

var current_cmd: *Command = self;
out_failed_cmd.* = current_cmd;

// Reset root command state for re-entering.
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);

// Resolve the subcommand chain, parsing flags at each level.
// Flags before a subcommand name are stored on the command at that level.
while (arg_iterator.peek()) |arg| {
if (std.mem.startsWith(u8, arg, "-")) break;
if (std.mem.eql(u8, arg, "--")) break;
if (std.mem.startsWith(u8, arg, "-")) {
try parser.parseFlagsOnly(current_cmd, &arg_iterator);
continue;
}
if (current_cmd.findSubcommand(arg)) |found_sub| {
current_cmd = found_sub;
out_failed_cmd.* = current_cmd;
// Reset subcommand state for re-entrancy.
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);
arg_iterator.next();
} else {
break;
}
}
out_failed_cmd.* = current_cmd;

// Reset state from any previous run, making the command re-entrant.
current_cmd.parsed_flags.shrinkRetainingCapacity(0);
current_cmd.parsed_positionals.shrinkRetainingCapacity(0);

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

// Check for --help and --version flags BEFORE validation
Expand Down Expand Up @@ -344,10 +356,16 @@ pub const Command = struct {
return null;
}

/// (Internal) Retrieves the parsed value of a flag for the current command.
/// (Internal) Retrieves the parsed value of a flag, searching upwards through
/// parent commands. This mirrors `findFlag` and allows subcommand exec functions
/// to access flags that were parsed at a parent command level.
pub fn getFlagValue(self: *const Command, name: []const u8) ?types.FlagValue {
for (self.parsed_flags.items) |flag| {
if (std.mem.eql(u8, flag.name, name)) return flag.value;
var current: ?*const Command = self;
while (current) |cmd| {
for (cmd.parsed_flags.items) |flag| {
if (std.mem.eql(u8, flag.name, name)) return flag.value;
}
current = cmd.parent;
}
return null;
}
Expand Down Expand Up @@ -443,6 +461,114 @@ test "command: execute with args and flags" {
try testing.expectEqualStrings("input.txt", integration_arg_val);
}

// -- Tests for flags before subcommand (issue #12) --

var parent_flag_from_sub: []const u8 = "";

fn parentFlagExec(ctx: context.CommandContext) !void {
parent_flag_from_sub = try ctx.getFlag("config", []const u8);
integration_arg_val = try ctx.getArg("file", []const u8);
}

test "command: root flag before subcommand resolves subcommand" {
const allocator = testing.allocator;
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
defer root.deinit();

try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });

var sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = parentFlagExec });
try root.addSubcommand(sub);
try sub.addPositional(.{ .name = "file", .is_required = true, .description = "" });

// The exact pattern from the bug report: --config <value> run <arg>
var failed_cmd: ?*const Command = null;
const args = &[_][]const u8{ "--config", "custom.conf", "run", "input.txt" };
try root.execute(args, null, &failed_cmd);

try testing.expect(failed_cmd == null);
try testing.expectEqualStrings("input.txt", integration_arg_val);
}

test "command: root short flag before subcommand resolves subcommand" {
const allocator = testing.allocator;
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
defer root.deinit();

try root.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" });

exec_called_on = null;
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
try root.addSubcommand(sub);

var failed_cmd: ?*const Command = null;
const args = &[_][]const u8{ "-v", "run" };
try root.execute(args, null, &failed_cmd);

try testing.expect(failed_cmd == null);
try testing.expectEqualStrings("run", exec_called_on.?);
}

test "command: multiple root flags before subcommand" {
const allocator = testing.allocator;
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
defer root.deinit();

try root.addFlag(.{ .name = "verbose", .shortcut = 'v', .type = .Bool, .default_value = .{ .Bool = false }, .description = "" });
try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });

exec_called_on = null;
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
try root.addSubcommand(sub);

var failed_cmd: ?*const Command = null;
const args = &[_][]const u8{ "-v", "--config=custom.conf", "run" };
try root.execute(args, null, &failed_cmd);

try testing.expect(failed_cmd == null);
try testing.expectEqualStrings("run", exec_called_on.?);
}

test "command: getFlagValue traverses parents" {
const allocator = testing.allocator;
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
defer root.deinit();

try root.addFlag(.{ .name = "config", .type = .String, .default_value = .{ .String = "default.conf" }, .description = "" });

var sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = parentFlagExec });
try root.addSubcommand(sub);
try sub.addPositional(.{ .name = "file", .is_required = true, .description = "" });

parent_flag_from_sub = "";
var failed_cmd: ?*const Command = null;
const args = &[_][]const u8{ "--config", "custom.conf", "run", "input.txt" };
try root.execute(args, null, &failed_cmd);

try testing.expect(failed_cmd == null);
// The subcommand's exec must see the root-level --config value, not the default
try testing.expectEqualStrings("custom.conf", parent_flag_from_sub);
}

test "command: -- before subcommand stops resolution" {
const allocator = testing.allocator;
var root = try Command.init(allocator, .{ .name = "app", .description = "", .exec = dummyExec });
defer root.deinit();

exec_called_on = null;
const sub = try Command.init(allocator, .{ .name = "run", .description = "", .exec = trackingExec });
try root.addSubcommand(sub);
try root.addPositional(.{ .name = "arg", .is_required = true, .description = "" });

var failed_cmd: ?*const Command = null;
// -- stops subcommand resolution, so "run" becomes a positional for root
const args = &[_][]const u8{ "--", "run" };
try root.execute(args, null, &failed_cmd);

try testing.expect(failed_cmd == null);
try testing.expectEqualStrings("app", exec_called_on.?);
}

test "command: addSubcommand detects empty alias" {
const allocator = std.testing.allocator;
var root = try Command.init(allocator, .{ .name = "root", .description = "", .exec = dummyExec });
Expand Down
Loading
Loading