From a077b937915f19dbea1ef3286f4e0cee20fb2179 Mon Sep 17 00:00:00 2001 From: Taiizor <41683699+Taiizor@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:13:55 +0300 Subject: [PATCH 1/3] feat: rewrite cli package with a layered, typed architecture Rebuild the command line toolkit on the ecosystem template: - facade (src/lib.alya) + data models (src/types.alya) + focused engines (src/core/spec.alya, parser.alya, formatter.alya, errors.alya, internal.alya) - complete option grammar: long, inline, short, attached, clustered, negated, repeatable options and the -- terminator - hierarchical subcommands with aliases and command-scoped options - structured CliError diagnostics instead of plain strings - typed accessors (get_int, get_bool) and a provided-value tracker - feature-gated slices: help (usage screens), strict (throwing and recovering parses), env (environment variable fallback) - fluent builder API next to the functional API - 148 assertions across six test suites, demo, and benchmarks --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/scripts/generate_release_notes.py | 2 +- .github/workflows/ci.yml | 6 + README.md | 329 +++-- alya.toml | 28 +- benches/bench_basic.alya | 79 +- examples/demo.alya | 125 +- src/core/errors.alya | 74 ++ src/core/formatter.alya | 410 +++--- src/core/internal.alya | 189 +++ src/core/parser.alya | 1468 +++++++++------------ src/core/spec.alya | 213 +++ src/core/utils.alya | 159 --- src/lib.alya | 1055 ++++++++++++++- src/types.alya | 436 ++++-- tests/test_basic.alya | 151 ++- tests/test_commands.alya | 113 +- tests/test_flags.alya | 94 ++ tests/test_flags_advanced.alya | 67 - tests/test_formatter.alya | 95 +- tests/test_strict.alya | 69 + tests/test_validation.alya | 115 +- 22 files changed, 3456 insertions(+), 1823 deletions(-) create mode 100644 src/core/errors.alya create mode 100644 src/core/internal.alya create mode 100644 src/core/spec.alya delete mode 100644 src/core/utils.alya create mode 100644 tests/test_flags.alya delete mode 100644 tests/test_flags_advanced.alya create mode 100644 tests/test_strict.alya diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index e3b104d..61ee277 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -37,7 +37,7 @@ body: attributes: label: Alya Version description: Run `alya --version` and paste the output here. - placeholder: "alya 0.0.19" + placeholder: "alya 0.0.20" validations: required: true - type: dropdown diff --git a/.github/scripts/generate_release_notes.py b/.github/scripts/generate_release_notes.py index 9f500a8..5bd4594 100644 --- a/.github/scripts/generate_release_notes.py +++ b/.github/scripts/generate_release_notes.py @@ -110,7 +110,7 @@ def main(): pkg_name = Path.cwd().name description = pkg_meta.get("description", "A modern package for the Alya programming language") - alya_version = pkg_meta.get("alya-version", "0.0.19") + alya_version = pkg_meta.get("alya-version", "0.0.20") # Resolve Repository Slug (e.g. alya-lang/toml) repo = os.environ.get("GITHUB_REPOSITORY", "") diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08a4f47..ce328c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -70,6 +70,12 @@ jobs: run: | alya test + - name: Run package test suite without default features + env: + ALYA_TEST_TIMEOUT_SECS: '600' + run: | + alya test --no-default-features + - name: Run micro-benchmarks smoke test if: hashFiles('benches/bench_basic.alya') != '' run: | diff --git a/README.md b/README.md index 822b726..5df83bb 100644 --- a/README.md +++ b/README.md @@ -5,32 +5,23 @@ [![Alya](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Falya-lang%2Fcli%2Fmain%2Falya.toml&query=%24.package.alya-version&label=Alya&color=orange&prefix=%3E%3D)](https://github.com/alya-lang/alya) [![Package Version](https://img.shields.io/badge/dynamic/toml?url=https%3A%2F%2Fraw.githubusercontent.com%2Falya-lang%2Fcli%2Fmain%2Falya.toml&query=%24.package.version&label=Version&color=brightgreen)](alya.toml) -Modern, feature-rich command-line interface, argument parsing, flag validation, and subcommand routing toolkit for the Alya programming language. +Modern command-line toolkit for Alya with typed flags, options, subcommand routing, generated help screens, and structured parse errors. --- ## 🌟 Features -- ⚑ **High Performance**: Native compiled parser with sub-microsecond option lookups and minimal allocations. -- 🏳️ **Comprehensive Flag Support**: - - Long flags: `--verbose`, `--debug` - - Short flags: `-v`, `-d` - - Clustered short flags: `-abc` (equivalent to `-a -b -c`) - - Inverted boolean flags: `--no-color`, `--no-cache` (sets `color` / `cache` to 0) -- πŸŽ›οΈ **Flexible Option Parsing**: - - Key-value options: `--output=dist`, `--output dist` - - Attached short options: `-oDist`, `-o dist` - - Repeated / multi-value options: `-I dir1 -I dir2` (accumulates into array) -- πŸ›‘οΈ **Validation & Constraints**: - - Choice restrictions: e.g. `--format` allowed only in `["json", "yaml", "text"]` - - Required options: automated reporting when mandatory options are omitted - - Argument arity and type checks -- 🌳 **Subcommand Routing**: - - Full hierarchical subcommands with isolated options and arguments (e.g. `forge build -r`, `forge test`) - - Command aliases (e.g. `build` with alias `b`) -- πŸ“„ **Automated Help & Version** (optional `help` feature, enabled by default): - - Auto-generated, column-aligned help screens for applications and subcommands - - Automatic `-h, --help` and `-V, --version` flag handling +- ⚑ **Native & Zero-Dependency**: Pure Alya implementation, no runtime dependencies, compiled by the Alya toolchain +- 🧩 **Layered Architecture**: Public facade (`src/lib.alya`), data models (`src/types.alya`), and focused engines (`src/core/`) +- πŸŽ›οΈ **Complete Option Grammar**: Long (`--output dist`), inline (`--output=dist`), short (`-o dist`), attached (`-oDist`), clustered (`-abc`), negated (`--no-color`), repeatable (`-I a -I b`), and the `--` terminator +- 🌳 **Hierarchical Subcommands**: Nested command trees, per-command options and arguments, and short aliases (`build` ⇄ `b`) +- πŸ›‘οΈ **Validation & Diagnostics**: Required options, choice lists, required arguments, unknown options/commands β€” collected as structured `CliError` values instead of aborting +- 🧾 **Generated Usage Screens**: Column-aligned help screens for the application and for every command, with defaults, choices, and environment hints +- 🌍 **Environment Fallback**: Options can read their value from an environment variable when omitted on the command line +- πŸ”’ **Public/Private Visibility**: `pub` marks the public surface; the shared internals in `src/core/internal.alya` stay private to the package +- πŸ§ͺ **Dual API Style**: A fluent builder (`app.flag(...)`) and a functional API (`cli::add_flag(app, ...)`) over the same engines +- 🚩 **Feature-Gated Slices**: `help` (usage screens), `strict` (throwing/recovering parses), and `env` (environment fallback) can be enabled independently +- 🧬 **Modern Alya**: Structural interfaces, `enum` vocabularies, nullable types, `try`/`catch` recovery, and typed accessors (`get_int`, `get_bool`) --- @@ -38,38 +29,54 @@ Modern, feature-rich command-line interface, argument parsing, flag validation, ``` cli/ -β”œβ”€β”€ alya.toml # Package manifest +β”œβ”€β”€ .alyafmt # Formatter configuration +β”œβ”€β”€ .alyalint # Linter configuration (rules, exclusions, severity overrides) +β”œβ”€β”€ .alyatest # Test runner configuration +β”œβ”€β”€ .editorconfig # Uniform formatting rules across IDEs and editors +β”œβ”€β”€ .gitattributes # Line ending and diff rules +β”œβ”€β”€ .gitignore # Ecosystem standard ignore filters +β”œβ”€β”€ alya.toml # Package manifest with dependencies, [features] and metadata +β”œβ”€β”€ LICENSE # MIT license +β”œβ”€β”€ README.md # This document +β”œβ”€β”€ .github/ # CI, release, code scanning, and issue templates +β”œβ”€β”€ .vscode/ # VS Code workspace settings, DAP launch configurations & tasks β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ lib.alya # High-level facade API -β”‚ β”œβ”€β”€ types.alya # CliApp, CliCommand, CliOption, CliArgument, CliContext +β”‚ β”œβ”€β”€ lib.alya # Public API facade (thin wrappers delegating to core/* engines) +β”‚ β”œβ”€β”€ types.alya # Data models, pub enums, pub structs, factories & error helpers β”‚ └── core/ -β”‚ β”œβ”€β”€ parser.alya # Parsing engine, flag clustering, validation logic -β”‚ β”œβ”€β”€ formatter.alya # Help screen and version formatting (`help` feature) -β”‚ └── utils.alya # String and array utilities -β”œβ”€β”€ examples/ -β”‚ └── demo.alya # Working demonstration CLI application +β”‚ β”œβ”€β”€ spec.alya # Specification builders: applications, commands, options, arguments +β”‚ β”œβ”€β”€ parser.alya # Parsing engine: token classification, binding, validation +β”‚ β”œβ”€β”€ formatter.alya # Usage screen, version banner, and diagnostic rendering (`help`) +β”‚ β”œβ”€β”€ errors.alya # Strict parsing slice: throwing and recovering entry points (`strict`) +β”‚ └── internal.alya # Private string and model helpers shared inside the package β”œβ”€β”€ tests/ -β”‚ β”œβ”€β”€ test_basic.alya # App creation, flags, options, double-dash -β”‚ β”œβ”€β”€ test_flags_advanced.alya # Clustered flags, inverted flags, multi options -β”‚ β”œβ”€β”€ test_commands.alya # Subcommands, aliases, command-scoped flags -β”‚ β”œβ”€β”€ test_validation.alya # Choice validation, required options, errors -β”‚ └── test_formatter.alya # Help screen and version formatting tests +β”‚ β”œβ”€β”€ test_basic.alya # Application setup, flags, options, typed accessors +β”‚ β”œβ”€β”€ test_flags.alya # Clustered, negated, attached, and repeatable options +β”‚ β”œβ”€β”€ test_commands.alya # Subcommands, aliases, nesting, command-scoped options +β”‚ β”œβ”€β”€ test_validation.alya # Required options, choice lists, unknown input +β”‚ β”œβ”€β”€ test_formatter.alya # Usage screens and diagnostic reports (`help` slice) +β”‚ └── test_strict.alya # Strict parsing and error recovery (`strict` slice) +β”œβ”€β”€ examples/ +β”‚ └── demo.alya # Comprehensive runnable walkthrough of the package └── benches/ - └── bench_basic.alya # Micro-benchmark suite + └── bench_basic.alya # Micro-benchmarks for the hot paths ``` +> [!NOTE] +> **Visibility & Modularity:** Symbols annotated with `pub` are exported to external consumers. `src/core/internal.alya` declares no `pub` symbol on purpose: an Alya module without public items shares its whole surface with sibling modules, so the shared helpers stay reachable inside the package while remaining invisible to package consumers. + --- ## πŸ“¦ Installation -Add `cli` to your project's `alya.toml`: +Add `cli` to the `[dependencies]` section in your `alya.toml`: ```toml [dependencies] cli = { git = "https://github.com/alya-lang/cli", branch = "main" } ``` -Or install it directly with `alya`: +Or install it directly using the Alya package CLI: ```bash alya add cli --git https://github.com/alya-lang/cli --branch main @@ -80,14 +87,16 @@ alya install | Feature | Default | Description | |:---|:---:|:---| -| `help` | βœ… | Help/version screen rendering (`help`, `print_help`, `print_version`). Without it only parsing/validation remain. | +| `help` | βœ… | Usage screen, version banner, and diagnostic report rendering (`format_help`, `format_command_help`, `format_version`, `format_errors`, `print_help`, `print_command_help`, `print_version`). | +| `strict` | βœ… | Throwing and recovering parse entry points (`parse_strict`, `try_parse`) on top of the lenient engine. | +| `env` | βœ… | Environment variable fallback for options registered with `env_option`. | ```bash # Full build (default) alya install alya test -# Slim build without help rendering +# Slim build (parsing only) alya install --no-default-features alya test --no-default-features ``` @@ -100,39 +109,29 @@ alya test --no-default-features import "cli" as cli function main() - # 1. Create CLI application - let app = cli::new("forge", "Alya build and testing tool", "1.0.0") - - # 2. Add global flags and options - cli::app_add_flag(app, "-v, --verbose", "Enable verbose logs") - cli::app_add_option(app, "-o, --output", "dist", "Output directory") - - # 3. Add subcommand - let build_cmd = cli::command("build", "Compile package artifacts", ["b"]) - cli::cmd_add_flag(build_cmd, "-r, --release", "Build optimized release") - cli::cmd_add_argument(build_cmd, "entrypoint", "Source file to compile", 0, "src/main.alya") - cli::app_add_command(app, build_cmd) - - # 4. Parse command-line arguments - let ctx = cli::parse_args(app) - - # 5. Check help or errors - if cli::ctx_help_requested(ctx) == 1 - cli::print_help(app) - return - end - - if cli::ctx_has_errors(ctx) == 1 - say cli::cli_format_errors(cli::ctx_get_errors(ctx)) + # 1. Describe the application + let app = cli::new_app("forge", "Alya build and test tool", "1.0.0") + + # 2. Register the global options + app.flag("-v, --verbose", "Enable verbose logs") + app.option("-o, --output", "dist", "Output directory") + + # 3. Register a subcommand with its own options and arguments + let build = cli::new_command("build", "Compile the target package", ["b"]) + build.flag("-r, --release", "Build optimized artifacts") + build.argument("entrypoint", "Source file to compile", false, "src/main.alya") + app.command(build) + + # 4. Parse the arguments and read the resolved values + let ctx = app.parse(["build", "-v", "--release", "server.alya"]) + if ctx.has_errors() + say cli::format_errors(ctx.get_errors()) exit(1) end - # 6. Access parsed values - if cli::ctx_has_command(ctx, "build") == 1 - let release = cli::ctx_get_flag(ctx, "release") - let out_dir = cli::ctx_get_option(ctx, "output", "dist") - let entry = cli::ctx_get_arg_named(ctx, "entrypoint", "src/main.alya") - say "Building " + entry + " (release=" + str(release) + ") into " + out_dir + if ctx.has_command("build") + say "Release: " + str(ctx.get_flag("release")) + say "Entry : " + ctx.get_arg_named("entrypoint", "src/main.alya") end end @@ -145,72 +144,155 @@ main() ### Application & Command Construction -| Function | Arguments | Description | +| Symbol | Visibility | Description | +|---|---|---| +| `new_app(name, description, version)` | `pub function` | Creates an application specification. | +| `new_command(name, description, aliases)` | `pub function` | Creates a command specification. | +| `set_author(app, author)` | `pub function` | Sets the author string of an application. | +| `set_version(app, version)` | `pub function` | Sets the version string of an application. | + +### Option & Argument Registration + +| Symbol | Visibility | Description | +|---|---|---| +| `add_flag(app, spec, description)` | `pub function` | Registers a boolean flag (`-v, --verbose`). | +| `add_option(app, spec, default, description)` | `pub function` | Registers a single-value option (`-o, --output`). | +| `add_multi_option(app, spec, description)` | `pub function` | Registers a repeatable option (`-I, --include`). | +| `add_choice_option(app, spec, default, choices, description)` | `pub function` | Registers an option restricted to a choice list. | +| `add_required_option(app, spec, description)` | `pub function` | Registers a mandatory option. | +| `add_env_option(app, spec, env_var, default, description)` | `pub function` | Registers an option falling back to an environment variable. | +| `add_argument(app, name, description, required, default)` | `pub function` | Registers a positional argument. | +| `add_variadic_argument(app, name, description)` | `pub function` | Registers an argument capturing the remaining values. | +| `add_command(app, command)` | `pub function` | Registers a top-level command. | +| `command_add_flag(cmd, spec, description)` | `pub function` | Registers a command-scoped flag. | +| `command_add_option(cmd, spec, default, description)` | `pub function` | Registers a command-scoped option. | +| `command_add_multi_option(cmd, spec, description)` | `pub function` | Registers a command-scoped repeatable option. | +| `command_add_choice_option(cmd, spec, default, choices, description)` | `pub function` | Registers a command-scoped choice option. | +| `command_add_required_option(cmd, spec, description)` | `pub function` | Registers a command-scoped mandatory option. | +| `command_add_env_option(cmd, spec, env_var, default, description)` | `pub function` | Registers a command-scoped environment option. | +| `command_add_argument(cmd, name, description, required, default)` | `pub function` | Registers a command-scoped positional argument. | +| `command_add_variadic_argument(cmd, name, description)` | `pub function` | Registers a command-scoped variadic argument. | +| `command_add_subcommand(parent, child)` | `pub function` | Nests a command under another command. | + +### Parsing + +| Symbol | Visibility | Description | |---|---|---| -| `cli::new(name, desc, ver)` | `name, desc = "", ver = "1.0.0"` | Creates a new `CliApp`. | -| `cli::app_set_author(app, author)` | `app, author` | Sets application author. | -| `cli::command(name, desc, aliases)` | `name, desc = "", aliases = []` | Creates a new `CliCommand`. | -| `cli::app_add_command(app, cmd)` | `app, cmd` | Registers a subcommand on the application. | +| `parse(app, raw_args)` | `pub function` | Parses an argument array into a `CliContext`. | +| `parse_args(app)` | `pub function` | Parses the process arguments into a `CliContext`. | +| `parse_strict(app, raw_args)` | `pub function` (`strict` feature, default-on) | Parses and throws a structured `CliError` on the first diagnostic. | +| `try_parse(app, raw_args)` | `pub function` (`strict` feature, default-on) | Parses and converts a failure into a `CliResult`. | -### Option & Flag Definition +### Context Queries -| Function | Target | Description | +| Symbol | Visibility | Description | |---|---|---| -| `cli::app_add_flag(app, spec, desc)` | `CliApp` | Adds a boolean flag (`-v, --verbose`). | -| `cli::app_add_option(app, spec, def, desc)` | `CliApp` | Adds a string option with default value. | -| `cli::app_add_required_option(app, spec, desc)` | `CliApp` | Adds a required option. | -| `cli::app_add_multi_option(app, spec, desc)` | `CliApp` | Adds a repeatable option (e.g. `-I dir`). | -| `cli::app_add_choice_option(app, spec, def, choices, desc)` | `CliApp` | Adds an option restricted to specific choice strings. | -| `cli::app_add_argument(app, name, desc, req, def, var)` | `CliApp` | Adds a positional argument. | -| `cli::cmd_add_flag(cmd, spec, desc)` | `CliCommand` | Adds a flag scoped to the subcommand. | -| `cli::cmd_add_option(cmd, spec, def, desc)` | `CliCommand` | Adds an option scoped to the subcommand. | -| `cli::cmd_add_choice_option(cmd, spec, def, choices, desc)` | `CliCommand` | Adds a choice-restricted option to the subcommand. | -| `cli::cmd_add_argument(cmd, name, desc, req, def, var)` | `CliCommand` | Adds a positional argument to the subcommand. | - -### Parsing & Context Queries - -| Function | Arguments | Description | +| `get_flag(ctx, name)` | `pub function` | Returns the state of a parsed flag. | +| `get_option(ctx, name, default)` | `pub function` | Returns the value of a parsed option. | +| `get_int(ctx, name, default)` | `pub function` | Returns the value of a parsed option converted to an integer. | +| `get_bool(ctx, name, default)` | `pub function` | Returns the boolean value of a parsed option. | +| `get_all(ctx, name)` | `pub function` | Returns every value collected for a repeatable option. | +| `get_arg(ctx, index, default)` | `pub function` | Returns a positional value by position. | +| `get_arg_named(ctx, name, default)` | `pub function` | Returns a positional value by argument name. | +| `get_args(ctx)` | `pub function` | Returns every positional value in order. | +| `get_command(ctx)` | `pub function` | Returns the name of the matched command. | +| `get_command_path(ctx)` | `pub function` | Returns the full path of the matched command. | +| `has_command(ctx, name)` | `pub function` | Reports whether the given command was matched. | +| `has(ctx, name)` | `pub function` | Reports whether the value appeared on the command line. | +| `has_errors(ctx)` | `pub function` | Reports whether the parse collected diagnostics. | +| `get_errors(ctx)` | `pub function` | Returns the collected `CliError` values. | +| `help_requested(ctx)` | `pub function` | Reports whether `-h`/`--help` was supplied. | +| `version_requested(ctx)` | `pub function` | Reports whether `-V`/`--version` was supplied. | + +### Usage Screens & Diagnostics + +| Symbol | Visibility | Description | |---|---|---| -| `cli::parse(app, raw_args)` | `app, args_array` | Parses argument array into `CliContext`. | -| `cli::parse_args(app)` | `app` | Parses current process command-line arguments. | -| `cli::ctx_get_flag(ctx, name)` | `ctx, name` | Returns 1 if flag was set, 0 otherwise. | -| `cli::ctx_get_option(ctx, name, def)` | `ctx, name, def = ""` | Returns option string value or fallback default. | -| `cli::ctx_get_multi_option(ctx, name)` | `ctx, name` | Returns array of string values for repeated option. | -| `cli::ctx_get_command(ctx)` | `ctx` | Returns the active subcommand name or `""`. | -| `cli::ctx_has_command(ctx, name)` | `ctx, name` | Returns 1 if subcommand matches, 0 otherwise. | -| `cli::ctx_get_arg(ctx, index, def)` | `ctx, index, def = ""` | Returns positional argument by index. | -| `cli::ctx_get_arg_named(ctx, name, def)` | `ctx, name, def = ""` | Returns positional argument by its defined name. | -| `cli::ctx_has_errors(ctx)` | `ctx` | Returns 1 if validation/syntax errors occurred, 0 otherwise. | -| `cli::ctx_get_errors(ctx)` | `ctx` | Returns array of error message strings. | -| `cli::ctx_help_requested(ctx)` | `ctx` | Returns 1 if `-h` or `--help` was encountered. | -| `cli::ctx_version_requested(ctx)` | `ctx` | Returns 1 if `-V` or `--version` was encountered. | - -### Formatting & Screens - -| Function | Arguments | Description | +| `format_help(app)` | `pub function` (`help` feature, default-on) | Renders the application usage screen. | +| `format_command_help(app, cmd)` | `pub function` (`help` feature, default-on) | Renders the usage screen of a command. | +| `format_version(app)` | `pub function` (`help` feature, default-on) | Renders the version banner. | +| `format_errors(errors)` | `pub function` (`help` feature, default-on) | Renders a diagnostic report. | +| `print_help(app)` | `pub function` (`help` feature, default-on) | Prints the application usage screen. | +| `print_command_help(app, cmd)` | `pub function` (`help` feature, default-on) | Prints the usage screen of a command. | +| `print_version(app)` | `pub function` (`help` feature, default-on) | Prints the version banner. | + +### Error Model + +| Symbol | Visibility | Description | |---|---|---| -| `cli::help(app, cmd = 0)` | `app, cmd = 0` | Returns formatted help screen as string. | -| `cli::print_help(app, cmd = 0)` | `app, cmd = 0` | Prints formatted help screen to stdout. | -| `cli::cli_format_version(app)` | `app` | Returns version string (e.g. `forge 1.0.0`). | -| `cli::cli_format_errors(errors)` | `errors` | Returns newline-separated error block. | +| `make_error(code, subject, message)` | `pub function` | Constructs a `CliError`. | +| `format_error(err)` | `pub function` | Renders a `CliError` as a diagnostic string. | +| `format_caught_error(err)` | `pub function` | Renders a value captured by `catch err`. | +| `CliError` | `pub struct` | Structured diagnostic (`code`, `subject`, `message`). | +| `CliResult` | `pub struct` | Outcome of a strict parse (`ok`, `context`, `error`). | + +### Data Models + +| Symbol | Visibility | Description | +|---|---|---| +| `CliApp` | `pub struct` | Application specification (name, description, version, options, arguments, commands). | +| `CliCommand` | `pub struct` | Command specification (name, aliases, options, arguments, subcommands). | +| `CliOption` | `pub struct` | Option specification (names, kind, default, required, choices, env variable). | +| `CliArgument` | `pub struct` | Positional argument specification (name, required, default, variadic). | +| `CliContext` | `pub struct` | Parsed execution context (flags, options, positionals, diagnostics). | +| `OptionKind` | `pub enum` | Option kinds (`Flag`, `Value`, `Multi`). | +| `CommandKind` | `pub enum` | Command kinds (`Root`, `Subcommand`). | +| `TokenKind` | `pub enum` | Token kinds recognised by the parser (`Inverted`, `Long`, `Short`, `Positional`). | +| `ErrorCode` | `pub enum` | Diagnostic codes (`UnknownOption`, `MissingValue`, `MissingRequired`, `InvalidChoice`, `MissingArgument`, `UnknownCommand`, `InvalidUsage`). | + +### Fluent Methods + +| Symbol | Visibility | Description | +|---|---|---| +| `CliApp.flag/option/multi_option/choice_option/required_option/env_option(...)` | `pub method` | Registers options on an application. | +| `CliApp.argument/variadic_argument(...)` | `pub method` | Registers positional arguments on an application. | +| `CliApp.command(cmd)` | `pub method` | Registers a command. | +| `CliApp.set_author/set_version(...)` | `pub method` | Updates the application metadata. | +| `CliApp.parse(args)` / `CliApp.run(args)` | `pub method` | Parses the given or the process arguments. | +| `CliCommand.flag/option/multi_option/choice_option/required_option/env_option(...)` | `pub method` | Registers options on a command. | +| `CliCommand.argument/variadic_argument(...)` | `pub method` | Registers positional arguments on a command. | +| `CliCommand.subcommand(cmd)` | `pub method` | Nests a command. | +| `CliContext.get_flag/flag/get_option/option/get_int/get_bool/get_all(...)` | `pub method` | Reads the resolved values. | +| `CliContext.get_arg/arg/get_arg_named/get_args/args(...)` | `pub method` | Reads the positional values. | +| `CliContext.get_command/command/get_command_path/has_command(...)` | `pub method` | Reads the command routing state. | +| `CliContext.has/has_errors/get_errors/errors(...)` | `pub method` | Reads the diagnostic state. | +| `CliContext.help_requested/version_requested(...)` | `pub method` | Reads the automatic switch state. | +| `CliResult.is_ok/is_error/message(...)` | `pub method` | Reads the outcome of a strict parse. | + +> [!TIP] +> **Internal Helpers & Documentation:** Public symbols are documented with `##` Markdown docstrings, enabling automatic API documentation generation via `alya doc`. The helpers in `src/core/internal.alya` are not annotated with `pub` and stay encapsulated within the package. --- ## πŸ§ͺ Running Tests & Benchmarks -Run the test suite: +Run the automated test suite using `alya test`: ```bash alya test ``` -Run micro-benchmarks: +Exercise feature selection (the `help`, `strict`, and `env` slices are default-on): + +```bash +alya test --features help +alya test --features strict +alya test --no-default-features +``` + +Generate static API documentation: + +```bash +alya doc . -o docs --markdown +``` + +Run the benchmark suite: ```bash alya run benches/bench_basic.alya ``` -Run the interactive demo: +Run the example demo: ```bash alya run examples/demo.alya @@ -228,24 +310,35 @@ Run static code linter: alya lint . --check ``` +### πŸ’» Developer Tooling & VS Code Integration + +This package comes preconfigured with recommended workspace settings and tasks for **Visual Studio Code**: +- **LSP & Formatting**: Auto-formatting on save and real-time Language Server diagnostics via `alya-lang.vscode-alya`. +- **DAP Debugging**: Launch configurations in `.vscode/launch.json` ready for interactive step-debugging via `F5`. +- **Predefined Tasks**: Press `Ctrl+Shift+B` or run tasks (`Test`, `Lint`, `Format`, `Build Docs`) directly from the Command Palette. + --- ## 🀝 Contributing -1. Fork the repository and clone locally +Contributions are welcome! Please follow these steps: + +1. Fork the repository and clone it locally 2. Install dependencies: ```bash alya install ``` -3. Run tests and verify code formatting: +3. Create your feature branch (`git checkout -b feature/my-feature`) +4. Verify tests and formatting before opening a PR: ```bash alya test alya fmt . --check + alya lint . --check ``` -4. Commit your changes and open a Pull Request +5. Commit your changes (`git commit -m "feat: add feature"`) and open a Pull Request --- ## πŸ“„ License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. diff --git a/alya.toml b/alya.toml index 2bd5ff1..d69f755 100644 --- a/alya.toml +++ b/alya.toml @@ -1,17 +1,33 @@ [package] name = "cli" -version = "0.1.0" -alya-version = "0.0.19" +version = "0.2.0" +alya-version = "0.0.21" entry = "src/lib.alya" -description = "Modern command-line interface, argument parsing, flag handling, and subcommand router for Alya" +description = "Modern command-line toolkit for Alya with typed flags, options, subcommand routing, generated help screens, and structured parse errors" authors = ["Alya Language Contributors "] license = "MIT" homepage = "https://github.com/alya-lang/alya" repository = "https://github.com/alya-lang/cli" -keywords = ["alya", "alya-lang", "package", "cli", "argparse", "flags", "subcommands", "terminal"] +keywords = ["alya", "alya-lang", "package", "cli", "argparse", "flags", "subcommands", "help", "terminal"] [dependencies] +# Optional: feature-gated API slices. Members name another feature or an +# optional dependency; gate the using code with `@cfg(feature = "...")` +# and provide a `@cfg(not(feature = "..."))` fallback stub in `src/lib.alya` +# so callers get a clear descriptive error instead of an undefined reference. +# `help` renders the usage screens and version banner +# (`src/core/formatter.alya`), `strict` adds the throwing and recovering +# parse entry points (`src/core/errors.alya`), and `env` lets options fall +# back to environment variables (`std/os`). `default` is active unless the +# caller passes `--no-default-features`; select features explicitly via +# `alya build/run/test/bench/install --features `. +[features] +default = ["help", "strict", "env"] +help = [] +strict = [] +env = [] + # [lint] # disabled_rules = [] # exclude = [] @@ -24,7 +40,3 @@ keywords = ["alya", "alya-lang", "package", "cli", "argparse", "flags", "subcomm # [bench] # exclude = [] - -[features] -default = ["help"] -help = [] diff --git a/benches/bench_basic.alya b/benches/bench_basic.alya index 639a6bc..1d63086 100644 --- a/benches/bench_basic.alya +++ b/benches/bench_basic.alya @@ -1,37 +1,44 @@ ## Benchmark suite for the cli package ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) +## +## Measures the three hot paths of the package: building a specification, +## parsing an argument array, and rendering a usage screen. -import "std/bench" +import "std/test" import "../src/lib.alya" as cli function main() let runner = bench_runner("Alya CLI Micro-Benchmarks") - # 1. App initialization and option definition + # ═══════════════════════════════════════════════════════════ + # 1. Specification building + # ═══════════════════════════════════════════════════════════ bench_start(runner, 2000) let i = 0 while i < 2000 - let app = cli::new("bench-cli", "Benchmark CLI tool", "1.0.0") - cli::app_add_flag(app, "-v, --verbose", "Enable verbose output") - cli::app_add_flag(app, "-d, --debug", "Enable debug output") - cli::app_add_option(app, "-o, --output", "dist", "Output target directory") - cli::app_add_option(app, "-f, --format", "json", "Serialization format") - let cmd = cli::command("build", "Build project artifacts") - cli::cmd_add_flag(cmd, "-r, --release", "Build in release mode") - cli::app_add_command(app, cmd) + let app = cli::new_app("bench-cli", "Benchmark CLI tool", "1.0.0") + app.flag("-v, --verbose", "Enable verbose output") + app.flag("-d, --debug", "Enable debug output") + app.option("-o, --output", "dist", "Output target directory") + app.option("-f, --format", "json", "Serialization format") + let cmd = cli::new_command("build", "Build project artifacts") + cmd.flag("-r, --release", "Build in release mode") + app.command(cmd) i += 1 end - bench_stop(runner, "cli::new + flags/options/commands (2k runs)") + bench_stop(runner, "specification building (2k runs)") + # ═══════════════════════════════════════════════════════════ # 2. Argument parsing - let app = cli::new("bench-cli", "Benchmark CLI tool", "1.0.0") - cli::app_add_flag(app, "-v, --verbose", "Enable verbose output") - cli::app_add_flag(app, "-d, --debug", "Enable debug output") - cli::app_add_option(app, "-o, --output", "dist", "Output target directory") - cli::app_add_option(app, "-f, --format", "json", "Serialization format") - let cmd = cli::command("build", "Build project artifacts") - cli::cmd_add_flag(cmd, "-r, --release", "Build in release mode") - cli::app_add_command(app, cmd) + # ═══════════════════════════════════════════════════════════ + let app = cli::new_app("bench-cli", "Benchmark CLI tool", "1.0.0") + app.flag("-v, --verbose", "Enable verbose output") + app.flag("-d, --debug", "Enable debug output") + app.option("-o, --output", "dist", "Output target directory") + app.option("-f, --format", "json", "Serialization format") + let cmd = cli::new_command("build", "Build project artifacts") + cmd.flag("-r, --release", "Build in release mode") + app.command(cmd) let test_args = ["build", "-v", "--release", "--output=target/bin", "src/main.alya"] @@ -39,34 +46,26 @@ function main() let j = 0 while j < 2000 let ctx = cli::parse(app, test_args) - cli::ctx_get_flag(ctx, "verbose") - cli::ctx_get_option(ctx, "output", "") - cli::ctx_get_command(ctx) + ctx.get_flag("verbose") + ctx.get_option("output", "") + ctx.get_command() j += 1 end - bench_stop(runner, "cli::parse + query lookups (2k runs)") + bench_stop(runner, "argument parsing + lookups (2k runs)") - # 3. Formatter benchmark - bench_help_formatter(app, runner) - - bench_summary(runner) -end - -@cfg(feature = "help") -function bench_help_formatter(app, runner) - bench_start(runner, 20) + # ═══════════════════════════════════════════════════════════ + # 3. Usage screen rendering + # ═══════════════════════════════════════════════════════════ + @cfg(feature = "help") + bench_start(runner, 200) let k = 0 - while k < 20 - cli::help(app) + while k < 200 + cli::format_help(app) k += 1 end - bench_stop(runner, "cli::help automated screen format (20 runs)") -end + bench_stop(runner, "usage screen rendering (200 runs)") -@cfg(not(feature = "help")) -# alya-lint: disable unused-param -function bench_help_formatter(app, runner) + bench_summary(runner) end -# alya-lint: enable unused-param main() diff --git a/examples/demo.alya b/examples/demo.alya index 4391647..f4efe1a 100644 --- a/examples/demo.alya +++ b/examples/demo.alya @@ -1,54 +1,101 @@ ## Demo of the cli package ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) +## +## Builds a realistic command line tool, renders its usage screen, and +## parses a simulated invocation. import "../src/lib.alya" as cli function main() - # 1. Initialize application - let app = cli::new("forge", "Alya high-performance build and test tool", "1.0.0") - cli::app_set_author(app, "Alya Language Contributors") - - # 2. Configure global flags and options - cli::app_add_flag(app, "-v, --verbose", "Enable verbose diagnostic logs") - cli::app_add_flag(app, "-q, --quiet", "Suppress all non-error output") - cli::app_add_option(app, "-c, --config", "forge.toml", "Configuration file path") - - # 3. Add 'build' command with options and arguments - let build_cmd = cli::command("build", "Compile target source packages", ["b"]) - cli::cmd_add_flag(build_cmd, "-r, --release", "Build optimized release binaries") - cli::cmd_add_option(build_cmd, "-o, --output", "dist", "Output directory") - cli::cmd_add_choice_option(build_cmd, "-t, --target", "native", ["native", "x64", "arm64"], "Target architecture") - cli::cmd_add_argument(build_cmd, "entrypoint", "Source file to compile", false, "src/main.alya") - cli::app_add_command(app, build_cmd) - - # 4. Add 'test' command - let test_cmd = cli::command("test", "Execute unit and integration tests", ["t"]) - cli::cmd_add_flag(test_cmd, "-f, --fail-fast", "Abort on first test failure") - cli::app_add_command(app, test_cmd) - - # 5. Display automated help screen - say "=== Automated Help Screen Demo ===" + say "=== 1. Building the application ===" + let app = cli::new_app("forge", "Alya high-performance build and test tool", "1.0.0") + app.set_author("Alya Language Contributors") + + # Global options + app.flag("-v, --verbose", "Enable verbose diagnostic logs") + app.flag("-q, --quiet", "Suppress all non-error output") + app.option("-c, --config", "forge.toml", "Configuration file path") + app.env_option("--token", "FORGE_TOKEN", "", "Access token for the registry") + + # The `build` command + let build = cli::new_command("build", "Compile target source packages", ["b"]) + build.flag("-r, --release", "Build optimized release binaries") + build.option("-o, --output", "dist", "Output directory") + build.choice_option("-t, --target", "native", ["native", "x64", "arm64"], "Target architecture") + build.argument("entrypoint", "Source file to compile", false, "src/main.alya") + app.command(build) + + # The `test` command + let test_cmd = cli::new_command("test", "Execute unit and integration tests", ["t"]) + test_cmd.flag("-f, --fail-fast", "Abort on the first test failure") + test_cmd.variadic_argument("filters", "Only run the matching test filters") + app.command(test_cmd) + + # The `remote` command with a nested subcommand + let remote = cli::new_command("remote", "Manage the configured remotes") + let remote_add = cli::new_command("add", "Add a new remote") + remote_add.argument("name", "Remote name") + remote_add.argument("url", "Remote URL") + remote.subcommand(remote_add) + app.command(remote) + + say "Application '" + app.name + "' v" + app.version + " is ready." + say "" + + # ═══════════════════════════════════════════════════════════ + say "=== 2. Generated usage screen ===" @cfg(feature = "help") - say cli::help(app) + say cli::format_help(app) - # 6. Simulate parsing realistic CLI arguments - say "=== Parsing Simulated Command ===" - let sample_args = ["build", "-v", "--release", "--target=native", "-o", "bin/release", "server.alya"] - say "Running with args: build -v --release --target=native -o bin/release server.alya" + # ═══════════════════════════════════════════════════════════ + say "=== 3. Parsing a simulated invocation ===" + let sample = ["build", "-v", "--release", "--target=native", "-o", "bin/release", "server.alya"] + say "forge " + "build -v --release --target=native -o bin/release server.alya" + say "" - let ctx = app.run(sample_args) + let ctx = app.parse(sample) if ctx.has_errors() - say "Errors encountered during parsing:" - for err in ctx.errors() - say " * " + str_from_ptr(err) + say "Diagnostics:" + for error in ctx.get_errors() + say " * " + error.message end else - say "Command executed : " + ctx.command() - say "Verbose mode : " + (ctx.flag("verbose") == true ? "yes" : "no") - say "Release mode : " + (ctx.flag("release") == true ? "yes" : "no") - say "Target arch : " + ctx.option("target", "native") - say "Output path : " + ctx.option("output", "dist") - say "Source file : " + ctx.get_arg_named("entrypoint", "src/main.alya") + say "Command : " + ctx.get_command() + say "Command path : " + ctx.get_command_path() + say "Verbose mode : " + (ctx.get_flag("verbose") == true ? "yes" : "no") + say "Release mode : " + (ctx.get_flag("release") == true ? "yes" : "no") + say "Target arch : " + ctx.get_option("target", "native") + say "Output path : " + ctx.get_option("output", "dist") + say "Source file : " + ctx.get_arg_named("entrypoint", "src/main.alya") + say "Config file : " + ctx.get_option("config", "forge.toml") + end + say "" + + # ═══════════════════════════════════════════════════════════ + say "=== 4. Diagnostics for an invalid invocation ===" + let broken = app.parse(["deploy", "--format=xml"]) + say "forge deploy --format=xml" + for error in broken.get_errors() + say " * " + error.message + end + say "" + + # ═══════════════════════════════════════════════════════════ + say "=== 5. Nested subcommand routing ===" + let nested = app.parse(["remote", "add", "origin", "https://github.com/alya-lang/cli.git"]) + say "Command path : " + nested.get_command_path() + say "Remote name : " + nested.get_arg_named("name") + say "Remote url : " + nested.get_arg_named("url") + say "" + + # ═══════════════════════════════════════════════════════════ + say "=== 6. Strict parsing (structured errors) ===" + @cfg(feature = "strict") + let outcome = cli::try_parse(app, ["build", "--target=wasm"]) + if outcome.is_error() + say "Rejected: " + outcome.message() + else + say "Accepted: " + outcome.context().get_option("target", "native") end end diff --git a/src/core/errors.alya b/src/core/errors.alya new file mode 100644 index 0000000..7fa1c2c --- /dev/null +++ b/src/core/errors.alya @@ -0,0 +1,74 @@ +## cli Strict Parsing Slice (feature-gated) +## +## Part of the Alya Language package ecosystem (https://github.com/alya-lang). +## Adds the throwing and recovering parse entry points on top of the lenient +## engine in `src/core/parser.alya`: `parse_strict` aborts on the first +## diagnostic, `try_parse` converts the diagnostic into a result container. +## +## Only compiled when the `strict` feature is active (default-on); +## `src/lib.alya` keeps fallback stubs so callers get a clear descriptive +## error instead of an undefined reference. + +import "../types.alya" +import "./parser.alya" as parser + +## Parses an argument array, throwing a structured error on invalid input. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse. +## +## ### Returns +## A `CliContext` without diagnostics. +## +## ### Examples +## ```alya +## try +## let ctx = parse_strict(app, args()) +## catch err +## say format_caught_error(err) +## end +## ``` +pub function parse_strict(app: CliApp, raw_args: array) -> CliContext + let ctx = parser::parse(app, raw_args) + if len(ctx.errors) > 0 + # Rebuild the diagnostic as a typed value: throwing a value read back + # from the untyped diagnostics array would lose the struct identity. + let first: CliError = ctx.errors[0] + throw make_error(first.code, first.subject, first.message) + end + return ctx +end + +## Parses an argument array, converting failures into a result container. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse. +## +## ### Returns +## A `CliResult` carrying either the parsed context or the first diagnostic. +## +## ### Examples +## ```alya +## let result = try_parse(app, args()) +## if result.is_error() +## say result.message() +## end +## ``` +pub function try_parse(app: CliApp, raw_args: array) -> CliResult + let ctx = parser::parse(app, raw_args) + if len(ctx.errors) > 0 + let first: CliError = ctx.errors[0] + return CliResult { + ok: false, + context: ctx, + error: format_error(first) + } + end + return CliResult { + ok: true, + context: ctx, + error: "" + } +end diff --git a/src/core/formatter.alya b/src/core/formatter.alya index 9c89676..67bfa81 100644 --- a/src/core/formatter.alya +++ b/src/core/formatter.alya @@ -1,238 +1,282 @@ -## CLI Help and Screen Formatter +## cli Usage Screen Formatter ## ## Part of the Alya Language package ecosystem (https://github.com/alya-lang). -## Renders formatted help screens, version information, and error diagnostics. +## Renders the banner, usage line, command list, option list, and argument +## list of an application or one of its commands. Only compiled when the +## `help` feature is active (default-on); `src/lib.alya` keeps a fallback +## stub so callers get a clear descriptive error instead of an undefined +## reference. import "../types.alya" -import "./utils.alya" +import "./internal.alya" -## Formats the complete usage and help documentation for an application or command. -## -## Constructs structured sections for Usage syntax, Description, Commands/Subcommands, -## Options, and Positional Arguments. -## -## ### Parameters -## - `app`: The target `CliApp` instance. -## - `cmd`: Optional `CliCommand` instance. If `0` or null, formats application root help. -## -## ### Returns -## The formatted multi-line help string. -## -## ### Examples -## ```alya -## let help_str = cli_format_help(app) -## say help_str -## ``` -pub function cli_format_help(app, cmd = 0) -> string - let is_cmd = (cmd is not null and cmd != 0) - let app_name = str_from_ptr(app.app_name) - let out = f"Usage: {app_name}" - if is_cmd - let cmd_name = str_from_ptr(cmd.cmd_name) - out += f" {cmd_name}" - else - if len(app.app_commands) > 0 - out += " [COMMAND]" - end +# ─────────────────────────────────────────────────────────────── +# Private helpers +# ─────────────────────────────────────────────────────────────── + +# Builds the label of a command (`name` plus its aliases). +function formatter_command_label(command: CliCommand) -> string + let label = command.name + if len(command.aliases) > 0 + label += ", " + internal_join(command.aliases, ", ") end - out += " [OPTIONS]" + return label +end - let arg_defs = app.app_arguments - if is_cmd - if len(cmd.cmd_arguments) > 0 - arg_defs = cmd.cmd_arguments +# Builds the label of an option (`-s, --long `). +function formatter_option_label(option: CliOption) -> string + let label = "" + if len(option.short_name) > 0 + label = "-" + option.short_name + if len(option.long_name) > 0 + label += ", --" + option.long_name end + else + label = " --" + option.long_name + end + if option.kind != OptionKind.Flag + label += " " end + return label +end - if len(arg_defs) > 0 - for adef in arg_defs - let aname = str_from_ptr(adef.arg_name) - if adef.arg_variadic == true - out += f" [{aname}...]" - elif adef.arg_required == true - out += f" <{aname}>" - else - out += f" [{aname}]" - end +# Builds the note of an option (description plus its constraints). +function formatter_option_note(option: CliOption) -> string + let note = option.description + if option.kind != OptionKind.Flag + if len(option.default_value) > 0 + note += " (default: " + option.default_value + ")" end end - out += "\n\n" + if option.required == true + note += " (required)" + end + if len(option.choices) > 0 + note += " [choices: " + internal_join(option.choices, ", ") + "]" + end + if len(option.env_var) > 0 + note += " [env: " + option.env_var + "]" + end + return note +end - let desc = "" - if is_cmd - if len(cmd.cmd_desc) > 0 - desc = str_from_ptr(cmd.cmd_desc) - end +# Builds the label of a positional argument (``). +function formatter_argument_label(argument: CliArgument) -> string + if argument.variadic == true + return "<" + argument.name + ">..." end + return "<" + argument.name + ">" +end - if len(desc) == 0 - if len(app.app_desc) > 0 - desc = str_from_ptr(app.app_desc) - end +# Builds the note of a positional argument. +function formatter_argument_note(argument: CliArgument) -> string + let note = argument.description + if argument.required == true + note += " (required)" + elif len(argument.default_value) > 0 + note += " (default: " + argument.default_value + ")" end + return note +end - if len(desc) > 0 - out += f"{desc}\n\n" - end - - # Commands Section - if not is_cmd - if len(app.app_commands) > 0 - out += "Commands:\n" - for c in app.app_commands - let label = str_from_ptr(c.cmd_name) - if len(c.cmd_aliases) > 0 - let alias_list = _cli_join_array(c.cmd_aliases, ", ") - label += f", {alias_list}" - end - let padded = _cli_pad_right(label, 16) - let cdesc = str_from_ptr(c.cmd_desc) - out += f" {padded} {cdesc}\n" - end - out += "\n" - end - else - if len(cmd.cmd_subcommands) > 0 - out += "Subcommands:\n" - for sc in cmd.cmd_subcommands - let label = str_from_ptr(sc.cmd_name) - if len(sc.cmd_aliases) > 0 - let alias_list = _cli_join_array(sc.cmd_aliases, ", ") - label += f", {alias_list}" - end - let padded = _cli_pad_right(label, 16) - let scdesc = str_from_ptr(sc.cmd_desc) - out += f" {padded} {scdesc}\n" - end - out += "\n" - end +# Builds the usage placeholder of a positional argument. +function formatter_argument_usage(argument: CliArgument) -> string + if argument.variadic == true + return " <" + argument.name + ">..." end + if argument.required == true + return " <" + argument.name + ">" + end + return " [" + argument.name + "]" +end - # Options Section - let opts = app.app_options - if is_cmd - let pool = [] - for p1_opt in app.app_options - pool.push(p1_opt) +# Renders aligned rows of labels and notes. +function formatter_render_rows(labels: array, notes: array) -> string + let width = 0 + for label in labels + if len(str(label)) > width + width = len(str(label)) end - for p2_opt in cmd.cmd_options - pool.push(p2_opt) - end - opts = pool - end - - out += "Options:\n" - for opt in opts - let s = str_from_ptr(opt.opt_short) - let l = str_from_ptr(opt.opt_long) - let d = str_from_ptr(opt.opt_desc) - let def = str_from_ptr(opt.opt_default) - let label = "" - if len(s) > 0 - if len(l) > 0 - label = f"-{s}, --{l}" - else - label = f"-{s}" - end - elif len(l) > 0 - label = f" --{l}" + end + let out = "" + let index = 0 + while index < len(labels) + let label = internal_pad_right(str(labels[index]), width) + let note = str(notes[index]) + if len(note) > 0 + out += " " + label + " " + note + "\n" else - label = f"-{s}" + out += " " + label + "\n" end + index += 1 + end + return out +end - if opt.opt_is_flag == false - label += " " - end +# Renders the usage screen from its resolved parts. +function formatter_render(app: CliApp, heading: string, subtitle: string, commands: array, options: array, arguments: array) -> string + let out = "" - let padded = _cli_pad_right(label, 26) - let line = f" {padded} {d}" - if opt.opt_is_flag == false - if len(def) > 0 - line += f" (default: {def})" - end - end - if opt.opt_required == true - line += " (required)" - end - if len(opt.opt_choices) > 0 - let choice_list = _cli_join_array(opt.opt_choices, ", ") - line += f" [choices: {choice_list}]" + # Banner + let banner = app.name + if len(app.version) > 0 + banner += " " + app.version + end + if len(app.description) > 0 + banner += " - " + app.description + end + out += banner + "\n" + + # Subtitle (the command description) + if len(subtitle) > 0 + out += subtitle + "\n" + end + out += "\n" + + # Usage + out += "Usage:\n " + heading + "\n" + + # Commands + if len(commands) > 0 + let labels = [] + let notes = [] + for command in commands + labels.push(formatter_command_label(command)) + notes.push(command.description) end - out += f"{line}\n" - end - out += " -h, --help Show help information\n" - out += " -V, --version Show version information\n" - - # Positional Arguments Section - if len(arg_defs) > 0 - out += "\nArguments:\n" - for adef in arg_defs - let aname = str_from_ptr(adef.arg_name) - let adesc = str_from_ptr(adef.arg_desc) - let alabel = f"<{aname}>" - let padded = _cli_pad_right(alabel, 26) - let aline = f" {padded} {adesc}" - if adef.arg_required == true - aline += " (required)" - elif len(adef.arg_default) > 0 - let adef_str = str_from_ptr(adef.arg_default) - aline += f" (default: {adef_str})" - end - out += f"{aline}\n" + out += "\nCommands:\n" + formatter_render_rows(labels, notes) + end + + # Options + let labels = [] + let notes = [] + for option in options + labels.push(formatter_option_label(option)) + notes.push(formatter_option_note(option)) + end + labels.push("-h, --help") + notes.push("Show this help and exit") + labels.push("-V, --version") + notes.push("Show version information and exit") + out += "\nOptions:\n" + formatter_render_rows(labels, notes) + + # Arguments + if len(arguments) > 0 + labels = [] + notes = [] + for argument in arguments + labels.push(formatter_argument_label(argument)) + notes.push(formatter_argument_note(argument)) end + out += "\nArguments:\n" + formatter_render_rows(labels, notes) end return out end -## Formats the application name and semantic version string. +# Builds the usage line of the application root. +function formatter_root_usage(app: CliApp) -> string + let heading = app.name + if len(app.commands) > 0 + heading += " [COMMAND]" + end + heading += " [OPTIONS]" + for argument in app.arguments + heading += formatter_argument_usage(argument) + end + return heading +end + +# Builds the usage line of a command. +function formatter_command_usage(app: CliApp, cmd: CliCommand, arguments: array) -> string + let heading = app.name + " " + cmd.name + " [OPTIONS]" + for argument in arguments + heading += formatter_argument_usage(argument) + end + return heading +end + +# ─────────────────────────────────────────────────────────────── +# Public formatters +# ─────────────────────────────────────────────────────────────── + +## Formats the usage screen of an application. +## +## The screen contains the banner, the usage line, the command list, the +## option list (including the automatic `-h`/`--help` and `-V`/`--version` +## entries), and the positional argument list. Columns are aligned. ## ## ### Parameters -## - `app`: The target `CliApp` instance. +## - `app`: The application specification. ## ## ### Returns -## Formatted version string (e.g. `"mytool 1.0.0"`). -pub function cli_format_version(app) -> string - let vname = str_from_ptr(app.app_name) - let vver = str_from_ptr(app.app_version) - return f"{vname} {vver}" +## The formatted multi-line usage screen. +## +## ### Examples +## ```alya +## say format_help(app) +## ``` +pub function format_help(app: CliApp) -> string + return formatter_render(app, formatter_root_usage(app), "", app.commands, app.options, app.arguments) end -## Formats an array of parsing and validation error messages into a diagnostics string. +## Formats the usage screen of a single command. +## +## The screen lists the global options together with the command-scoped +## ones, and the command arguments (falling back to the application +## arguments when the command declares none). ## ## ### Parameters -## - `errors`: Array of error string messages. +## - `app`: The application specification. +## - `cmd`: The command to render. ## ## ### Returns -## Formatted error diagnostics string. -pub function cli_format_errors(errors) -> string - let out = "" - for err in errors - let emsg = str_from_ptr(err) - out += f"Error: {emsg}\n" +## The formatted multi-line usage screen. +## +## ### Examples +## ```alya +## say format_command_help(app, build) +## ``` +pub function format_command_help(app: CliApp, cmd: CliCommand) -> string + let arguments = cmd.arguments + if len(arguments) == 0 + arguments = app.arguments end - return out + let options = [] + for option in cmd.options + options.push(option) + end + for option in app.options + options.push(option) + end + return formatter_render(app, formatter_command_usage(app, cmd, arguments), cmd.description, cmd.subcommands, options, arguments) end -## Formats help documentation for this application or subcommand. +## Formats the version banner of an application. ## ## ### Parameters -## - `self`: The target `CliApp` instance. -## - `cmd`: Optional subcommand instance. Defaults to `0`. +## - `app`: The application specification. ## ## ### Returns -## Formatted multi-line help string. -pub function CliApp.format_help(self, cmd = 0) -> string - return cli_format_help(self, cmd) +## The version string, e.g. `"mytool 1.2.0"`. +pub function format_version(app: CliApp) -> string + if len(app.version) == 0 + return app.name + end + return app.name + " " + app.version end -## Formats name and version string for this application. +## Formats a list of parse diagnostics into a report. ## ## ### Parameters -## - `self`: The target `CliApp` instance. +## - `errors`: The diagnostics collected by the parsing engine. ## ## ### Returns -## Formatted version string. -pub function CliApp.format_version(self) -> string - return cli_format_version(self) +## One `"Error: ..."` line per diagnostic. +pub function format_errors(errors: array) -> string + let out = "" + for error in errors + out += "Error: " + str(error.message) + "\n" + end + return out end diff --git a/src/core/internal.alya b/src/core/internal.alya new file mode 100644 index 0000000..2fce55e --- /dev/null +++ b/src/core/internal.alya @@ -0,0 +1,189 @@ +## cli Internal Helpers +## +## Part of the Alya Language package ecosystem (https://github.com/alya-lang). +## Shared string and model helpers for the specification, parser, and +## formatter engines. +## +## This module deliberately declares no `pub` symbol: an Alya module without +## public items shares its whole surface with sibling modules, so the helpers +## below stay reachable inside the package while remaining invisible to +## package consumers. + +import "std/str" +import "../types.alya" + +# ─────────────────────────────────────────────────────────────── +# String helpers +# ─────────────────────────────────────────────────────────────── + +# Reports whether `s` begins with `prefix`. +function internal_starts_with(s: string, prefix: string) -> bool + if len(s) < len(prefix) + return false + end + return s[0..len(prefix)] == prefix +end + +# Removes the leading `--` or `-` dashes from an option name. +function internal_strip_dashes(s: string) -> string + if internal_starts_with(s, "--") + return s[2..len(s)] + end + if internal_starts_with(s, "-") + return s[1..len(s)] + end + return s +end + +# Returns the index of the first occurrence of `ch`, or -1 when absent. +function internal_find_char(s: string, ch: string) -> int + let index = 0 + while index < len(s) + if char_at(s, index) == ch + return index + end + index += 1 + end + return -1 +end + +# Right-pads `s` with spaces up to `width` characters. +function internal_pad_right(s: string, width: int) -> string + let padded = s + while len(padded) < width + padded += " " + end + return padded +end + +# Joins the elements of `items` with `separator`. +function internal_join(items: array, separator: string) -> string + let out = "" + let index = 0 + while index < len(items) + if index > 0 + out += separator + end + out += str(items[index]) + index += 1 + end + return out +end + +# Splits a flag specification into its `[short_name, long_name]` parts. +# +# Accepts `"-v, --verbose"`, `"--output, -o"`, `"--verbose"`, and `"-v"`. +function internal_parse_spec(spec: string) -> array + let raw = trim(spec) + let short_name = "" + let long_name = "" + if contains(raw, ",") + let parts = split(raw, ",") + let first = trim(str(parts[0])) + let second = trim(str(parts[1])) + if internal_starts_with(first, "--") + long_name = first[2..len(first)] + elif internal_starts_with(first, "-") + short_name = first[1..len(first)] + else + long_name = first + end + if internal_starts_with(second, "--") + long_name = second[2..len(second)] + elif internal_starts_with(second, "-") + short_name = second[1..len(second)] + else + long_name = second + end + else + if internal_starts_with(raw, "--") + long_name = raw[2..len(raw)] + elif internal_starts_with(raw, "-") + short_name = raw[1..len(raw)] + else + long_name = raw + end + end + return [short_name, long_name] +end + +# Reports whether a textual value reads as a boolean true. +function internal_is_truthy(value: string) -> bool + let text = trim(value) + if text == "1" or text == "true" or text == "yes" or text == "on" + return true + end + return false +end + +# Converts a textual value to an integer, falling back when it is not numeric. +function internal_to_int(value: string, fallback: int) -> int + let text = trim(value) + if len(text) == 0 + return fallback + end + let negative = false + let body = text + if body[0..1] == "-" + negative = true + body = body[1..len(body)] + end + if body[0..1] == "+" + body = body[1..len(body)] + end + if len(body) == 0 + return fallback + end + if contains(body, ".") or contains(body, "e") or contains(body, "E") + return fallback + end + if is_numeric(body) == false + return fallback + end + let parsed = to_int(body) + if negative == true + return 0 - parsed + end + return parsed +end + +# ─────────────────────────────────────────────────────────────── +# Model helpers +# ─────────────────────────────────────────────────────────────── + +# Returns the index of the option matching `name`, or -1 when absent. +# +# Both the short and the long name of an option are accepted. +function internal_find_option(options: array, name: string) -> int + let target = internal_strip_dashes(name) + let index = 0 + while index < len(options) + let option: CliOption = options[index] + if len(option.short_name) > 0 and option.short_name == target + return index + end + if len(option.long_name) > 0 and option.long_name == target + return index + end + index += 1 + end + return -1 +end + +# Returns the index of the command matching `name` or one of its aliases. +function internal_find_command(commands: array, name: string) -> int + let index = 0 + while index < len(commands) + let command: CliCommand = commands[index] + if command.name == name + return index + end + for alias in command.aliases + if str(alias) == name + return index + end + end + index += 1 + end + return -1 +end diff --git a/src/core/parser.alya b/src/core/parser.alya index 75c3efb..e77b6f0 100644 --- a/src/core/parser.alya +++ b/src/core/parser.alya @@ -1,987 +1,721 @@ -## CLI Core Parser Implementation +## cli Parsing Engine ## ## Part of the Alya Language package ecosystem (https://github.com/alya-lang). -## Manages options, arguments, commands, subcommands, and parsing lifecycle. - -import "../types.alya" -import "./utils.alya" - -# --- Public Constructors & Mutators --- - -## Constructs a new `CliApp` root application instance. +## Turns a raw argument array into a resolved `CliContext`: token +## classification, option binding, subcommand routing, positional binding, +## and validation. Headless by design β€” no I/O, only data transformation. ## -## ### Parameters -## - `name`: The executable application name. -## - `description`: Brief description shown in help text. -## - `version`: Semantic version string. Defaults to `"1.0.0"`. -## -## ### Returns -## A newly initialized `CliApp` instance. -pub function app_new(name: string, description: string = "", version: string = "1.0.0") -> CliApp - return CliApp { app_name: name, app_desc: description, app_version: version, app_author: "", app_options: [], app_arguments: [], app_commands: [] } -end - -## Returns the application executable name. -pub function app_get_name(app: CliApp) -> string - return str_from_ptr(app.app_name) -end - -## Returns the application version string. -pub function app_get_version(app: CliApp) -> string - return str_from_ptr(app.app_version) -end - -## Sets the author string, returning the updated app. -pub function app_set_author(app: CliApp, author: string) -> CliApp - app.app_author = author - return app -end - -## Sets the version string, returning the updated app. -pub function app_set_version(app: CliApp, version: string) -> CliApp - app.app_version = version - return app -end - -## Registers a boolean toggle flag, returning the updated app. -pub function app_add_flag(app: CliApp, flags_spec, description: string = "") -> CliApp - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "0", opt_is_flag: true, opt_is_multi: false, opt_required: false, opt_choices: [] } - let opts = app.app_options - opts.push(opt) - return app -end +## The engine is forgiving: diagnostics are collected in `ctx.errors` +## instead of aborting, so a caller decides whether to print them, exit, or +## continue. The strict slice in `src/core/errors.alya` layers the throwing +## and recovering variants on top of this engine. -## Registers a single-value option, returning the updated app. -pub function app_add_option(app: CliApp, flags_spec, default_val: string = "", description: string = "") -> CliApp - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: default_val, opt_is_flag: false, opt_is_multi: false, opt_required: false, opt_choices: [] } - let opts = app.app_options - opts.push(opt) - return app -end - -## Registers a repeatable multi-value option, returning the updated app. -pub function app_add_multi_option(app: CliApp, flags_spec, description: string = "") -> CliApp - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "", opt_is_flag: false, opt_is_multi: true, opt_required: false, opt_choices: [] } - let opts = app.app_options - opts.push(opt) - return app -end - -## Registers a constrained-choice option, returning the updated app. -pub function app_add_choice_option(app: CliApp, flags_spec, default_val: string, choices: array, description: string = "") -> CliApp - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: default_val, opt_is_flag: false, opt_is_multi: false, opt_required: false, opt_choices: choices } - let opts = app.app_options - opts.push(opt) - return app -end - -## Registers a mandatory option, returning the updated app. -pub function app_add_required_option(app: CliApp, flags_spec, description: string = "") -> CliApp - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "", opt_is_flag: false, opt_is_multi: false, opt_required: true, opt_choices: [] } - let opts = app.app_options - opts.push(opt) - return app -end - -## Registers a positional argument, returning the updated app. -pub function app_add_argument(app: CliApp, name: string, description: string = "", required: int = false, default_val: string = "") -> CliApp - let arg = CliArgument { arg_name: name, arg_desc: description, arg_required: required, arg_default: default_val, arg_variadic: false } - let args_list = app.app_arguments - args_list.push(arg) - return app -end - -## Registers a variadic positional argument, returning the updated app. -pub function app_add_variadic_argument(app: CliApp, name: string, description: string = "") -> CliApp - let arg = CliArgument { arg_name: name, arg_desc: description, arg_required: false, arg_default: "", arg_variadic: true } - let args_list = app.app_arguments - args_list.push(arg) - return app -end - -## Registers a subcommand, returning the updated app. -pub function app_add_command(app: CliApp, cmd: CliCommand) -> CliApp - let cmds = app.app_commands - cmds.push(cmd) - return app -end - -# --- Subcommand Constructors & Modifiers --- - -## Constructs a new subcommand model instance. -pub function cmd_new(name: string, description: string = "", aliases: array = []) -> CliCommand - return CliCommand { cmd_name: name, cmd_aliases: aliases, cmd_desc: description, cmd_options: [], cmd_arguments: [], cmd_subcommands: [] } -end - -## Returns the subcommand name. -pub function cmd_get_name(cmd: CliCommand) -> string - return str_from_ptr(cmd.cmd_name) +import "../types.alya" +import "./internal.alya" +@cfg(feature = "env") +import "std/os" + +# ─────────────────────────────────────────────────────────────── +# Private state +# ─────────────────────────────────────────────────────────────── + +# Mutable parsing state shared by the scan helpers. +struct ParseScope + flags: map, + options: map, + multi_options: map, + provided: map, + errors: array +end + +# ─────────────────────────────────────────────────────────────── +# Private helpers +# ─────────────────────────────────────────────────────────────── + +# Classifies an argument token. +function parser_classify(token: string) -> int + if internal_starts_with(token, "--no-") == true + return TokenKind.Inverted + end + if internal_starts_with(token, "--") == true + return TokenKind.Long + end + if internal_starts_with(token, "-") == true and token != "-" + return TokenKind.Short + end + return TokenKind.Positional end -## Registers a boolean flag on a subcommand. -pub function cmd_add_flag(cmd: CliCommand, flags_spec, description: string = "") -> CliCommand - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "0", opt_is_flag: true, opt_is_multi: false, opt_required: false, opt_choices: [] } - let opts = cmd.cmd_options - opts.push(opt) - return cmd +# Merges two option lists, keeping the first list in front. +function parser_merge_options(primary: array, secondary: array) -> array + let merged = [] + for option in primary + merged.push(option) + end + for option in secondary + merged.push(option) + end + return merged end -## Registers a single-value option on a subcommand. -pub function cmd_add_option(cmd: CliCommand, flags_spec, default_val: string = "", description: string = "") -> CliCommand - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: default_val, opt_is_flag: false, opt_is_multi: false, opt_required: false, opt_choices: [] } - let opts = cmd.cmd_options - opts.push(opt) - return cmd +# Seeds the default state of the given options into the scope. +function parser_seed_defaults(options: array, scope: ParseScope) + for option in options + let spec: CliOption = option + if spec.kind == OptionKind.Flag + if len(spec.short_name) > 0 + scope.flags[spec.short_name] = false + end + if len(spec.long_name) > 0 + scope.flags[spec.long_name] = false + end + elif spec.kind == OptionKind.Multi + if len(spec.short_name) > 0 + scope.multi_options[spec.short_name] = [] + end + if len(spec.long_name) > 0 + scope.multi_options[spec.long_name] = [] + end + else + if len(spec.short_name) > 0 + scope.options[spec.short_name] = spec.default_value + end + if len(spec.long_name) > 0 + scope.options[spec.long_name] = spec.default_value + end + end + end end -## Registers a repeatable option on a subcommand. -pub function cmd_add_multi_option(cmd: CliCommand, flags_spec, description: string = "") -> CliCommand - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "", opt_is_flag: false, opt_is_multi: true, opt_required: false, opt_choices: [] } - let opts = cmd.cmd_options - opts.push(opt) - return cmd +# Collects every distinct name an option answers to. +function parser_option_names(option: CliOption, used_name: string) -> array + let names = [] + if len(used_name) > 0 + names.push(used_name) + end + let short_name = option.short_name + let long_name = option.long_name + if len(short_name) > 0 and short_name not in names + names.push(short_name) + end + if len(long_name) > 0 and long_name not in names + names.push(long_name) + end + return names end -## Registers a constrained-choice option on a subcommand. -pub function cmd_add_choice_option(cmd: CliCommand, flags_spec, default_val: string, choices: array, description: string = "") -> CliCommand - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: default_val, opt_is_flag: false, opt_is_multi: false, opt_required: false, opt_choices: choices } - let opts = cmd.cmd_options - opts.push(opt) - return cmd +# Stores the state of a boolean flag under every known name. +function parser_store_flag(option: CliOption, name: string, state: bool, scope: ParseScope) + for key in parser_option_names(option, name) + scope.flags[key] = state + scope.provided[key] = true + end end -## Registers a mandatory option on a subcommand. -pub function cmd_add_required_option(cmd: CliCommand, flags_spec, description: string = "") -> CliCommand - let names = _cli_parse_flags_spec(flags_spec) - let s = str_from_ptr(names[0]) - let l = str_from_ptr(names[1]) - let opt = CliOption { opt_short: s, opt_long: l, opt_desc: description, opt_default: "", opt_is_flag: false, opt_is_multi: false, opt_required: true, opt_choices: [] } - let opts = cmd.cmd_options - opts.push(opt) - return cmd +# Stores the value of a value or repeatable option under every known name. +# +# The short and the long name always share the same bucket, so a value given +# as `-I src` is visible through `--include` as well. +function parser_store_value(option: CliOption, name: string, value: string, scope: ParseScope) + for key in parser_option_names(option, name) + if option.kind == OptionKind.Multi + if key not in scope.multi_options + scope.multi_options[key] = [] + end + let bucket = scope.multi_options[key] + bucket.push(value) + else + scope.options[key] = value + end + scope.provided[key] = true + end end -## Registers a positional argument on a subcommand. -pub function cmd_add_argument(cmd: CliCommand, name: string, description: string = "", required: int = false, default_val: string = "") -> CliCommand - let arg = CliArgument { arg_name: name, arg_desc: description, arg_required: required, arg_default: default_val, arg_variadic: false } - let args_list = cmd.cmd_arguments - args_list.push(arg) - return cmd +# Applies a negated flag such as `--no-color`. +function parser_apply_inverted(lookup_options: array, token: string, scope: ParseScope) + let base = token[5..len(token)] + let index = internal_find_option(lookup_options, base) + if index < 0 + scope.errors.push(make_error(ErrorCode.UnknownOption, base, "Unknown option: --no-" + base)) + return + end + let option: CliOption = lookup_options[index] + if option.kind != OptionKind.Flag + scope.errors.push(make_error(ErrorCode.InvalidUsage, base, "Option --" + base + " does not support the --no- form")) + return + end + scope.flags["no-" + base] = true + scope.provided["no-" + base] = true + for key in parser_option_names(option, base) + scope.flags[key] = false + scope.provided[key] = true + end end -## Registers a variadic argument on a subcommand. -pub function cmd_add_variadic_argument(cmd: CliCommand, name: string, description: string = "") -> CliCommand - let arg = CliArgument { arg_name: name, arg_desc: description, arg_required: false, arg_default: "", arg_variadic: true } - let args_list = cmd.cmd_arguments - args_list.push(arg) - return cmd -end +# Applies a long option, returning the index of the next token. +function parser_apply_long(lookup_options: array, token: string, index: int, raw_args: array, scope: ParseScope) -> int + let token_length = len(token) + let separator = internal_find_char(token, "=") + let name = "" + let value = "" + let has_value = false + if separator >= 0 + name = token[2..separator] + value = token[(separator + 1)..token_length] + has_value = true + else + name = token[2..token_length] + end -## Nests a subcommand, returning the updated parent. -pub function cmd_add_subcommand(parent_cmd: CliCommand, sub_cmd: CliCommand) -> CliCommand - let subs = parent_cmd.cmd_subcommands - subs.push(sub_cmd) - return parent_cmd -end + let option_index = internal_find_option(lookup_options, name) + if option_index < 0 + scope.errors.push(make_error(ErrorCode.UnknownOption, name, "Unknown option: --" + name)) + scope.flags[name] = true + return index + 1 + end -# --- Lookup Helpers (Internal) --- + let option: CliOption = lookup_options[option_index] + if option.kind == OptionKind.Flag + parser_store_flag(option, name, true, scope) + return index + 1 + end -function _cli_lookup_opt_in_list(opt_list, name) - let clean = _cli_strip_dashes(name) - for opt in opt_list - let s = str_from_ptr(opt.opt_short) - let l = str_from_ptr(opt.opt_long) - if len(s) > 0 and s == clean - return opt - end - if len(l) > 0 and l == clean - return opt + if has_value == false + if index + 1 >= len(raw_args) + scope.errors.push(make_error(ErrorCode.MissingValue, name, "Missing value for option: --" + name)) + return index + 1 end + index += 1 + value = str(raw_args[index]) end - return null + + parser_store_value(option, name, value, scope) + return index + 1 end -function _cli_lookup_opt(app, active_cmd, name) - if active_cmd is not null and active_cmd != 0 - let match = _cli_lookup_opt_in_list(active_cmd.cmd_options, name) - if match is not null and match != 0 - return match - end +# Applies a short option or a cluster of short flags. +function parser_apply_short(lookup_options: array, token: string, index: int, raw_args: array, scope: ParseScope) -> int + let token_length = len(token) + let short_name = token[1..2] + let option_index = internal_find_option(lookup_options, short_name) + if option_index < 0 + scope.errors.push(make_error(ErrorCode.UnknownOption, short_name, "Unknown option: -" + short_name)) + scope.flags[short_name] = true + return index + 1 end - return _cli_lookup_opt_in_list(app.app_options, name) -end -function _cli_lookup_cmd(commands, name) - let target = str_from_ptr(name) - for c in commands - if str_from_ptr(c.cmd_name) == target - return c + let option: CliOption = lookup_options[option_index] + if token_length == 2 + if option.kind == OptionKind.Flag + parser_store_flag(option, short_name, true, scope) + return index + 1 end - if target in c.cmd_aliases - return c + if index + 1 >= len(raw_args) + scope.errors.push(make_error(ErrorCode.MissingValue, short_name, "Missing value for option: -" + short_name)) + return index + 1 end + index += 1 + parser_store_value(option, short_name, str(raw_args[index]), scope) + return index + 1 end - return null -end -function _cli_populate_defaults(opt_list, flags, options, array_options) - for opt in opt_list - let s = str_from_ptr(opt.opt_short) - let l = str_from_ptr(opt.opt_long) - let def_val = str_from_ptr(opt.opt_default) - if opt.opt_is_flag == true - if len(s) > 0 - flags[s] = false - end - if len(l) > 0 - flags[l] = false - end - else - if opt.opt_is_multi == true - if len(s) > 0 - array_options[s] = [] - end - if len(l) > 0 - array_options[l] = [] - end + if option.kind == OptionKind.Flag + # Clustered flags such as `-abc`. + let position = 1 + while position < token_length + let letter = token[position..(position + 1)] + let cluster_index = internal_find_option(lookup_options, letter) + if cluster_index < 0 + scope.errors.push(make_error(ErrorCode.UnknownOption, letter, "Unknown option: -" + letter)) + scope.flags[letter] = true else - if len(s) > 0 - options[s] = def_val - end - if len(l) > 0 - options[l] = def_val + let cluster_option: CliOption = lookup_options[cluster_index] + if cluster_option.kind != OptionKind.Flag + scope.errors.push(make_error(ErrorCode.MissingValue, letter, "Missing value for option: -" + letter)) + position = token_length + else + parser_store_flag(cluster_option, letter, true, scope) end end + position += 1 end + return index + 1 end + + # Attached value such as `-oDist`. + parser_store_value(option, short_name, token[2..token_length], scope) + return index + 1 end -function _cli_classify_token(arg_str) -> int - let s = str(arg_str) - if _cli_starts_with(s, "--no-") == true - return ArgKind.InvertedFlag - end - if _cli_starts_with(s, "--") == true - return ArgKind.LongOption +# Reads an environment variable, returning an empty string when unavailable. +@cfg(feature = "env") +function parser_env_value(name: string) -> string + if has_env(name) == false + return "" end - if _cli_starts_with(s, "-") == true and s != "-" - return ArgKind.ShortOption - end - return ArgKind.Positional + return env(name) +end + +@cfg(not(feature = "env")) +function parser_env_value(name: string) -> string + return "" end -# --- Parsing Engine --- +# ─────────────────────────────────────────────────────────────── +# Parsing engine +# ─────────────────────────────────────────────────────────────── + +## Parses a raw argument array into a resolved execution context. +## +## Supports long options (`--output dist`, `--output=dist`), short options +## (`-o dist`, `-oDist`), clustered short flags (`-abc`), negated flags +## (`--no-color`), repeatable options (`-I a -I b`), the `--` terminator, +## global options, nested subcommands with aliases, and positional +## arguments (required, optional, and variadic). +## +## ### Parameters +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse (usually `args()`). +## +## ### Returns +## A `CliContext` with the resolved values and the collected diagnostics. +## +## ### Examples +## ```alya +## let ctx = parse(app, ["--verbose", "input.txt"]) +## ``` +pub function parse(app: CliApp, raw_args: array) -> CliContext + let scope = ParseScope { + flags: {}, + options: {}, + multi_options: {}, + provided: {}, + errors: [] + } + + let global_options = app.options + let scope_options = app.options + let lookup_options = app.options + let scope_arguments = app.arguments + let scope_commands = app.commands + let scope_depth = 0 -## Parses argument strings into a resolved execution context. -pub function app_parse(app: CliApp, raw_args: array) -> CliContext - let flags = {} - let options = {} - let array_options = {} - let positionals = [] - let arg_map = {} - let errors = [] let command_name = "" - let active_cmd = null - let help_req = false - let version_req = false + let command_path = "" + let help_requested = false + let version_requested = false + let orphan_token = "" + + let positional = [] + let named = {} - # 1. Populate top-level defaults - _cli_populate_defaults(app.app_options, flags, options, array_options) + parser_seed_defaults(global_options, scope) - let num_raw = len(raw_args) - let idx = 0 + let count = len(raw_args) + let index = 0 let stop_options = false - while idx < num_raw - let arg_str = str_from_ptr(raw_args[idx]) - let arg_len = len(arg_str) + while index < count + let token = str(raw_args[index]) if stop_options == true - positionals.push(arg_str) - idx += 1 - elif arg_str == "--" + positional.push(token) + index += 1 + elif token == "--" stop_options = true - idx += 1 + index += 1 + elif token == "-h" or token == "--help" + help_requested = true + scope.flags["help"] = true + scope.flags["h"] = true + index += 1 + elif token == "-V" or token == "--version" + version_requested = true + scope.flags["version"] = true + scope.flags["V"] = true + index += 1 else - when arg_str - is "-h", "--help" then - help_req = true - flags["help"] = true - flags["h"] = true - idx += 1 - is "-V", "--version" then - version_req = true - flags["version"] = true - flags["V"] = true - idx += 1 + let kind = parser_classify(token) + if kind == TokenKind.Inverted + parser_apply_inverted(lookup_options, token, scope) + index += 1 + elif kind == TokenKind.Long + index = parser_apply_long(lookup_options, token, index, raw_args, scope) + elif kind == TokenKind.Short + index = parser_apply_short(lookup_options, token, index, raw_args, scope) + else + let command_index = internal_find_command(scope_commands, token) + if command_index >= 0 + let command: CliCommand = scope_commands[command_index] + scope_options = command.options + lookup_options = parser_merge_options(command.options, global_options) + scope_arguments = command.arguments + scope_commands = command.subcommands + scope_depth += 1 + command_name = command.name + if scope_depth == 1 + command_path = command.name + else + command_path += " " + command.name + end + parser_seed_defaults(scope_options, scope) + index += 1 else - let token_kind = _cli_classify_token(arg_str) - when token_kind - is ArgKind.InvertedFlag - # Inverted flag: --no-cache => cache=0, no-cache=1 - let positive_name = arg_str[5..arg_len] - flags[positive_name] = false - flags[f"no-{positive_name}"] = true - idx += 1 - is ArgKind.LongOption - # Long option or flag - let eq_pos = _cli_find_char(arg_str, "=") - if eq_pos != -1 - let raw_k = arg_str[2..eq_pos] - let val = arg_str[(eq_pos + 1)..arg_len] - let opt_match = _cli_lookup_opt(app, active_cmd, raw_k) - if opt_match is not null and opt_match != 0 - let s_name = str_from_ptr(opt_match.opt_short) - if opt_match.opt_is_multi == true - if raw_k not in array_options - array_options[raw_k] = [] - end - let arr_ref = array_options[raw_k] - arr_ref.push(val) - if len(s_name) > 0 - if s_name not in array_options - array_options[s_name] = [] - end - let arr_ref_s = array_options[s_name] - arr_ref_s.push(val) - end - else - options[raw_k] = val - if len(s_name) > 0 - options[s_name] = val - end - end - else - options[raw_k] = val - end - else - let raw_k = arg_str[2..arg_len] - let opt_match = _cli_lookup_opt(app, active_cmd, raw_k) - if opt_match is not null and opt_match != 0 - let s_name = str_from_ptr(opt_match.opt_short) - if opt_match.opt_is_flag == true - flags[raw_k] = true - if len(s_name) > 0 - flags[s_name] = true - end - else - if idx + 1 < num_raw - idx += 1 - let val = str_from_ptr(raw_args[idx]) - if opt_match.opt_is_multi == true - if raw_k not in array_options - array_options[raw_k] = [] - end - let arr_ref = array_options[raw_k] - arr_ref.push(val) - if len(s_name) > 0 - if s_name not in array_options - array_options[s_name] = [] - end - let arr_ref_s = array_options[s_name] - arr_ref_s.push(val) - end - else - options[raw_k] = val - if len(s_name) > 0 - options[s_name] = val - end - end - else - errors.push(f"Missing value for option: --{raw_k}") - end - end - else - flags[raw_k] = true - end - end - idx += 1 - is ArgKind.ShortOption - # Short flag(s) or option - if arg_len == 2 - let s_char = arg_str[1..2] - let opt_match = _cli_lookup_opt(app, active_cmd, s_char) - if opt_match is not null and opt_match != 0 - let l_name = str_from_ptr(opt_match.opt_long) - if opt_match.opt_is_flag == true - flags[s_char] = true - if len(l_name) > 0 - flags[l_name] = true - end - else - if idx + 1 < num_raw - idx += 1 - let val = str_from_ptr(raw_args[idx]) - if opt_match.opt_is_multi == true - if s_char not in array_options - array_options[s_char] = [] - end - let arr_ref = array_options[s_char] - arr_ref.push(val) - if len(l_name) > 0 - if l_name not in array_options - array_options[l_name] = [] - end - let arr_ref_l = array_options[l_name] - arr_ref_l.push(val) - end - else - options[s_char] = val - if len(l_name) > 0 - options[l_name] = val - end - end - else - errors.push(f"Missing value for option: -{s_char}") - end - end - else - flags[s_char] = true - end - else - # Length > 2: e.g. clustered flags -abc or attached option -oValue - let first_char = arg_str[1..2] - let first_match = _cli_lookup_opt(app, active_cmd, first_char) - let is_attached = false - if first_match is not null and first_match != 0 - if first_match.opt_is_flag == false - is_attached = true - end - end - - if is_attached == true - # Attached value: -oValue - let val = arg_str[2..arg_len] - let l_name = str_from_ptr(first_match.opt_long) - if first_match.opt_is_multi == true - if first_char not in array_options - array_options[first_char] = [] - end - let arr_ref = array_options[first_char] - arr_ref.push(val) - if len(l_name) > 0 - if l_name not in array_options - array_options[l_name] = [] - end - let arr_ref_l = array_options[l_name] - arr_ref_l.push(val) - end - else - options[first_char] = val - if len(l_name) > 0 - options[l_name] = val - end - end - else - # Clustered flags: -abc - let c_idx = 1 - while c_idx < arg_len - let ch = arg_str[c_idx..(c_idx + 1)] - flags[ch] = true - let m = _cli_lookup_opt(app, active_cmd, ch) - if m is not null and m != 0 - let lg = str_from_ptr(m.opt_long) - if len(lg) > 0 - flags[lg] = true - end - end - c_idx += 1 - end - end - end - idx += 1 - else - # Positional argument or subcommand - let matched_sub = false - if active_cmd is null or active_cmd == 0 - if len(app.app_commands) > 0 - let cmd_match = _cli_lookup_cmd(app.app_commands, arg_str) - if cmd_match is not null and cmd_match != 0 - active_cmd = cmd_match - command_name = str_from_ptr(cmd_match.cmd_name) - _cli_populate_defaults(cmd_match.cmd_options, flags, options, array_options) - matched_sub = true - idx += 1 - end - end - else - if len(active_cmd.cmd_subcommands) > 0 - let sub_match = _cli_lookup_cmd(active_cmd.cmd_subcommands, arg_str) - if sub_match is not null and sub_match != 0 - active_cmd = sub_match - let sub_name = str_from_ptr(sub_match.cmd_name) - command_name = f"{command_name} {sub_name}" - _cli_populate_defaults(sub_match.cmd_options, flags, options, array_options) - matched_sub = true - idx += 1 - end - end - end - - if matched_sub == false - positionals.push(arg_str) - idx += 1 - end + if scope_depth == 0 and len(app.commands) > 0 and len(app.arguments) == 0 + if len(orphan_token) == 0 + orphan_token = token + end end + positional.push(token) + index += 1 + end end end end - # 2. Check required options and validate choices - let opt_pool = app.app_options - if active_cmd is not null and active_cmd != 0 - let pool = [] - for opt_item in app.app_options - pool.push(opt_item) + # ── Validation of the effective option set ── + for option in lookup_options + let spec: CliOption = option + let short_name = spec.short_name + let long_name = spec.long_name + let display_name = long_name + if len(display_name) == 0 + display_name = short_name end - for cmd_opt in active_cmd.cmd_options - pool.push(cmd_opt) - end - opt_pool = pool - end - for opt in opt_pool - let s = str_from_ptr(opt.opt_short) - let l = str_from_ptr(opt.opt_long) + let current = "" + if long_name in scope.options + current = str(scope.options[long_name]) + elif short_name in scope.options + current = str(scope.options[short_name]) + end - # Required check - if opt.opt_required == true - let has_val = false - if len(l) > 0 and (l in options) - let v = str_from_ptr(options[l]) - if len(v) > 0 - has_val = true - end - end - if len(s) > 0 and (s in options) - let v = str_from_ptr(options[s]) - if len(v) > 0 - has_val = true + # Environment fallback for omitted options. + if len(current) == 0 and len(spec.env_var) > 0 + let fallback = parser_env_value(spec.env_var) + if len(fallback) > 0 + current = fallback + for key in parser_option_names(spec, display_name) + scope.options[key] = fallback end end - if has_val == false - errors.push(f"Missing required option: --{l}") - end end - # Choice validation - if len(opt.opt_choices) > 0 - let chosen_val = "" - if len(l) > 0 and (l in options) - chosen_val = str_from_ptr(options[l]) - end - if len(chosen_val) == 0 and len(s) > 0 and (s in options) - chosen_val = str_from_ptr(options[s]) - end - if len(chosen_val) > 0 - if chosen_val not in opt.opt_choices - let allowed = _cli_join_array(opt.opt_choices, ", ") - errors.push(f"Invalid value '{chosen_val}' for option --{l}. Allowed choices: [{allowed}]") - end + if spec.required == true and len(current) == 0 + scope.errors.push(make_error(ErrorCode.MissingRequired, display_name, "Missing required option: --" + display_name)) + end + + if len(spec.choices) > 0 and len(current) > 0 + if current not in spec.choices + let allowed = internal_join(spec.choices, ", ") + scope.errors.push(make_error(ErrorCode.InvalidChoice, display_name, "Invalid value '" + current + "' for option --" + display_name + ". Allowed choices: [" + allowed + "]")) end end end - # 3. Bind positional arguments - let arg_defs = app.app_arguments - if active_cmd is not null and active_cmd != 0 - if len(active_cmd.cmd_arguments) > 0 - arg_defs = active_cmd.cmd_arguments - end + # ── Unknown command detection ── + if len(orphan_token) > 0 and help_requested == false and version_requested == false + scope.errors.push(make_error(ErrorCode.UnknownCommand, orphan_token, "Unknown command: " + orphan_token)) end - let num_defs = len(arg_defs) - let num_pos = len(positionals) - let ai = 0 - let pi = 0 + # ── Positional binding ── + let definitions = scope_arguments + if scope_depth > 0 and len(scope_arguments) == 0 + definitions = app.arguments + end - while ai < num_defs - let adef = arg_defs[ai] - let aname = str_from_ptr(adef.arg_name) - if adef.arg_variadic == true + let definition_count = len(definitions) + let positional_count = len(positional) + let definition_index = 0 + let value_index = 0 + + while definition_index < definition_count + let definition: CliArgument = definitions[definition_index] + let argument_name = definition.name + if definition.variadic == true let remaining = [] - while pi < num_pos - remaining.push(positionals[pi]) - pi += 1 + while value_index < positional_count + remaining.push(positional[value_index]) + value_index += 1 end - arg_map[aname] = remaining + named[argument_name] = remaining else - if pi < num_pos - arg_map[aname] = positionals[pi] - pi += 1 + if value_index < positional_count + named[argument_name] = positional[value_index] + value_index += 1 else - if adef.arg_required == true - errors.push(f"Missing required argument: <{aname}>") - elif len(adef.arg_default) > 0 - arg_map[aname] = str_from_ptr(adef.arg_default) + if definition.required == true + scope.errors.push(make_error(ErrorCode.MissingArgument, argument_name, "Missing required argument: <" + argument_name + ">")) + elif len(definition.default_value) > 0 + named[argument_name] = definition.default_value end end end - ai += 1 + definition_index += 1 end - return CliContext { ctx_app: app, ctx_cmd_name: command_name, ctx_flags: flags, ctx_options: options, ctx_array_opts: array_options, ctx_args: positionals, ctx_arg_map: arg_map, ctx_errors: errors, ctx_help_req: help_req, ctx_version_req: version_req, ctx_raw_args: raw_args } -end - -## Parses process arguments into a resolved execution context. -pub function app_parse_args(app: CliApp) -> CliContext - return app_parse(app, args()) + return CliContext { + app: app, + command_name: command_name, + command_path: command_path, + flags: scope.flags, + options: scope.options, + multi_options: scope.multi_options, + provided: scope.provided, + positional: positional, + named: named, + errors: scope.errors, + help_requested: help_requested, + version_requested: version_requested, + raw_args: raw_args + } +end + +## Parses the process arguments into a resolved execution context. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## +## ### Returns +## A `CliContext` with the resolved values and the collected diagnostics. +## +## ### Examples +## ```alya +## let ctx = parse_args(app) +## ``` +pub function parse_args(app: CliApp) -> CliContext + return parse(app, args()) end -# --- Context Accessor Helpers --- +# ─────────────────────────────────────────────────────────────── +# Context accessors +# ─────────────────────────────────────────────────────────────── -## Returns a parsed flag value (1 or 0). -pub function ctx_get_flag(ctx: CliContext, name) -> int - let clean = _cli_strip_dashes(name) - let fl = ctx.ctx_flags - if clean in fl - return fl[clean] +## Returns the state of a parsed flag. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Flag name, with or without leading dashes. +## +## ### Returns +## `true` when the flag was enabled, `false` otherwise. +pub function ctx_get_flag(ctx: CliContext, name: string) -> bool + let clean = internal_strip_dashes(name) + let flags = ctx.flags + if clean in flags + return flags[clean] == true end return false end -## Returns a parsed option value or the default. -pub function ctx_get_option(ctx: CliContext, name, default_val = "") -> string - let clean = _cli_strip_dashes(name) - let opts = ctx.ctx_options - if clean in opts - return str_from_ptr(opts[clean]) +## Returns the value of a parsed option. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## - `default_value`: Value returned when the option is absent. Defaults to `""`. +## +## ### Returns +## The resolved option value, or `default_value`. +pub function ctx_get_option(ctx: CliContext, name: string, default_value: string = "") -> string + let clean = internal_strip_dashes(name) + let options = ctx.options + if clean in options + return str(options[clean]) end - return default_val + return default_value end -## Returns all collected values for a repeatable option. -pub function ctx_get_all(ctx: CliContext, name) -> array - let clean = _cli_strip_dashes(name) - let aopts = ctx.ctx_array_opts - if clean in aopts - return aopts[clean] - end - return [] +## Returns the value of a parsed option converted to an integer. +## +## Non-numeric values fall back to `default_value`. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## - `default_value`: Value returned when the option is absent or not numeric. Defaults to `0`. +## +## ### Returns +## The integer value of the option, or `default_value`. +pub function ctx_get_int(ctx: CliContext, name: string, default_value: int = 0) -> int + return internal_to_int(ctx_get_option(ctx, name, ""), default_value) end -## Returns a positional argument by index or the default. -pub function ctx_get_arg(ctx: CliContext, idx: int, default_val = "") -> string - let r_args = ctx.ctx_args - if idx >= 0 and idx < len(r_args) - return str_from_ptr(r_args[idx]) +## Returns the boolean value of a parsed option. +## +## Accepts `1`, `true`, `yes`, and `on` as true; falls back to the flag state. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## +## ### Returns +## `true` when the value reads as true, `false` otherwise. +pub function ctx_get_bool(ctx: CliContext, name: string) -> bool + let clean = internal_strip_dashes(name) + let options = ctx.options + if clean in options + return internal_is_truthy(str(options[clean])) end - return default_val + return ctx_get_flag(ctx, name) end -## Returns a named positional argument or the default. -pub function ctx_get_arg_named(ctx: CliContext, name, default_val = "") -> string - let amap = ctx.ctx_arg_map - let n = str_from_ptr(name) - if n in amap - return str_from_ptr(amap[n]) +## Returns every value collected for a repeatable option. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## +## ### Returns +## The collected values, or an empty array when the option is absent. +pub function ctx_get_all(ctx: CliContext, name: string) -> array + let clean = internal_strip_dashes(name) + let multi_options = ctx.multi_options + if clean in multi_options + return multi_options[clean] end - return default_val -end - -## Returns all positional argument strings. -pub function ctx_get_args(ctx: CliContext) -> array - return ctx.ctx_args -end - -## Returns the active command name. -pub function ctx_get_command(ctx: CliContext) -> string - return str_from_ptr(ctx.ctx_cmd_name) -end - -## Returns 1 when the named command is active. -pub function ctx_has_command(ctx: CliContext, name) -> bool - let n = str_from_ptr(name) - let c = str_from_ptr(ctx.ctx_cmd_name) - return c == n -end - -## Returns 1 when parsing collected errors. -pub function ctx_has_errors(ctx: CliContext) -> bool - return len(ctx.ctx_errors) > 0 -end - -## Returns parsing and validation error messages. -pub function ctx_get_errors(ctx: CliContext) -> array - return ctx.ctx_errors -end - -## Returns 1 when help was requested. -pub function ctx_help_requested(ctx: CliContext) -> int - return ctx.ctx_help_req -end - -## Returns 1 when version output was requested. -pub function ctx_version_requested(ctx: CliContext) -> int - return ctx.ctx_version_req -end - -# --- Struct Methods (Alya v0.0.18) --- - -# CliApp methods -## Sets the author string on this app. -pub function CliApp.set_author(self: CliApp, author: string) -> CliApp - return app_set_author(self, author) -end - -## Sets the version string on this app. -pub function CliApp.set_version(self: CliApp, version: string) -> CliApp - return app_set_version(self, version) -end - -## Adds a boolean flag to this app. -pub function CliApp.add_flag(self: CliApp, flags_spec, description: string = "") -> CliApp - return app_add_flag(self, flags_spec, description) -end - -## Alias for add_flag on this app. -pub function CliApp.flag(self: CliApp, flags_spec, description: string = "") -> CliApp - return app_add_flag(self, flags_spec, description) -end - -## Adds a single-value option to this app. -pub function CliApp.add_option(self: CliApp, flags_spec, default_val: string = "", description: string = "") -> CliApp - return app_add_option(self, flags_spec, default_val, description) -end - -## Alias for add_option on this app. -pub function CliApp.option(self: CliApp, flags_spec, default_val: string = "", description: string = "") -> CliApp - return app_add_option(self, flags_spec, default_val, description) -end - -## Adds a repeatable option to this app. -pub function CliApp.add_multi_option(self: CliApp, flags_spec, description: string = "") -> CliApp - return app_add_multi_option(self, flags_spec, description) -end - -## Adds a constrained-choice option to this app. -pub function CliApp.add_choice_option(self: CliApp, flags_spec, default_val: string, choices: array, description: string = "") -> CliApp - return app_add_choice_option(self, flags_spec, default_val, choices, description) -end - -## Adds a mandatory option to this app. -pub function CliApp.add_required_option(self: CliApp, flags_spec, description: string = "") -> CliApp - return app_add_required_option(self, flags_spec, description) -end - -## Adds a positional argument to this app. -pub function CliApp.add_argument(self: CliApp, name: string, description: string = "", required: int = false, default_val: string = "") -> CliApp - return app_add_argument(self, name, description, required, default_val) -end - -## Alias for add_argument on this app. -pub function CliApp.argument(self: CliApp, name: string, description: string = "", required: int = false, default_val: string = "") -> CliApp - return app_add_argument(self, name, description, required, default_val) -end - -## Adds a variadic argument to this app. -pub function CliApp.add_variadic_argument(self: CliApp, name: string, description: string = "") -> CliApp - return app_add_variadic_argument(self, name, description) -end - -## Adds a subcommand to this app. -pub function CliApp.add_command(self: CliApp, cmd: CliCommand) -> CliApp - return app_add_command(self, cmd) -end - -## Alias for add_command on this app. -pub function CliApp.command(self: CliApp, cmd: CliCommand) -> CliApp - return app_add_command(self, cmd) -end - -## Parses argument strings with this app. -pub function CliApp.parse(self: CliApp, raw_args: array) -> CliContext - return app_parse(self, raw_args) + return [] end -## Parses given or process arguments with this app. -pub function CliApp.run(self: CliApp, raw_args = null) -> CliContext - if raw_args is null - return app_parse_args(self) +## Returns a positional value by position. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `index`: Zero-based position of the value. +## - `default_value`: Value returned when the position is out of range. Defaults to `""`. +## +## ### Returns +## The positional value, or `default_value`. +pub function ctx_get_arg(ctx: CliContext, index: int, default_value: string = "") -> string + let values = ctx.positional + if index >= 0 and index < len(values) + return str(values[index]) end - return app_parse(self, raw_args) -end - -# CliCommand methods -## Adds a boolean flag to this command. -pub function CliCommand.add_flag(self: CliCommand, flags_spec, description: string = "") -> CliCommand - return cmd_add_flag(self, flags_spec, description) -end - -## Alias for add_flag on this command. -pub function CliCommand.flag(self: CliCommand, flags_spec, description: string = "") -> CliCommand - return cmd_add_flag(self, flags_spec, description) -end - -## Adds a single-value option to this command. -pub function CliCommand.add_option(self: CliCommand, flags_spec, default_val: string = "", description: string = "") -> CliCommand - return cmd_add_option(self, flags_spec, default_val, description) -end - -## Alias for add_option on this command. -pub function CliCommand.option(self: CliCommand, flags_spec, default_val: string = "", description: string = "") -> CliCommand - return cmd_add_option(self, flags_spec, default_val, description) -end - -## Adds a repeatable option to this command. -pub function CliCommand.add_multi_option(self: CliCommand, flags_spec, description: string = "") -> CliCommand - return cmd_add_multi_option(self, flags_spec, description) -end - -## Adds a constrained-choice option to this command. -pub function CliCommand.add_choice_option(self: CliCommand, flags_spec, default_val: string, choices: array, description: string = "") -> CliCommand - return cmd_add_choice_option(self, flags_spec, default_val, choices, description) -end - -## Adds a mandatory option to this command. -pub function CliCommand.add_required_option(self: CliCommand, flags_spec, description: string = "") -> CliCommand - return cmd_add_required_option(self, flags_spec, description) -end - -## Adds a positional argument to this command. -pub function CliCommand.add_argument(self: CliCommand, name: string, description: string = "", required: int = false, default_val: string = "") -> CliCommand - return cmd_add_argument(self, name, description, required, default_val) -end - -## Alias for add_argument on this command. -pub function CliCommand.argument(self: CliCommand, name: string, description: string = "", required: int = false, default_val: string = "") -> CliCommand - return cmd_add_argument(self, name, description, required, default_val) -end - -## Adds a variadic argument to this command. -pub function CliCommand.add_variadic_argument(self: CliCommand, name: string, description: string = "") -> CliCommand - return cmd_add_variadic_argument(self, name, description) + return default_value end -## Nests a subcommand under this command. -pub function CliCommand.add_subcommand(self: CliCommand, sub_cmd: CliCommand) -> CliCommand - return cmd_add_subcommand(self, sub_cmd) -end - -## Alias for add_subcommand on this command. -pub function CliCommand.subcommand(self: CliCommand, sub_cmd: CliCommand) -> CliCommand - return cmd_add_subcommand(self, sub_cmd) -end - -# CliContext methods -## Returns a parsed flag value from this context. -pub function CliContext.get_flag(self: CliContext, name) -> int - return ctx_get_flag(self, name) -end - -## Alias for get_flag on this context. -pub function CliContext.flag(self: CliContext, name) -> int - return ctx_get_flag(self, name) -end - -## Returns a parsed option value from this context. -pub function CliContext.get_option(self: CliContext, name, default_val = "") -> string - return ctx_get_option(self, name, default_val) -end - -## Alias for get_option on this context. -pub function CliContext.option(self: CliContext, name, default_val = "") -> string - return ctx_get_option(self, name, default_val) -end - -## Returns all values for a repeatable option. -pub function CliContext.get_all(self: CliContext, name) -> array - return ctx_get_all(self, name) -end - -## Alias for get_all on this context. -pub function CliContext.all(self: CliContext, name) -> array - return ctx_get_all(self, name) -end - -## Returns a positional argument from this context. -pub function CliContext.get_arg(self: CliContext, idx: int, default_val = "") -> string - return ctx_get_arg(self, idx, default_val) -end - -## Alias for get_arg on this context. -pub function CliContext.arg(self: CliContext, idx: int, default_val = "") -> string - return ctx_get_arg(self, idx, default_val) -end - -## Returns a named positional from this context. -pub function CliContext.get_arg_named(self: CliContext, name, default_val = "") -> string - return ctx_get_arg_named(self, name, default_val) -end - -## Returns all positional arguments. -pub function CliContext.get_args(self: CliContext) -> array - return ctx_get_args(self) +## Returns a positional value by argument name. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Argument name as declared in the specification. +## - `default_value`: Value returned when the argument is absent. Defaults to `""`. +## +## ### Returns +## The bound value, or `default_value`. +pub function ctx_get_arg_named(ctx: CliContext, name: string, default_value: string = "") -> string + let named = ctx.named + if name in named + return str(named[name]) + end + return default_value end -## Alias for get_args on this context. -pub function CliContext.args(self: CliContext) -> array - return ctx_get_args(self) +## Returns every positional value in order. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The positional values. +pub function ctx_get_args(ctx: CliContext) -> array + return ctx.positional end -## Returns the active command name. -pub function CliContext.get_command(self: CliContext) -> string - return ctx_get_command(self) +## Returns the name of the matched command. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The command name, or an empty string when no command was matched. +pub function ctx_get_command(ctx: CliContext) -> string + return ctx.command_name end -## Alias for get_command on this context. -pub function CliContext.command(self: CliContext) -> string - return ctx_get_command(self) +## Returns the full path of the matched command. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The space separated command path, e.g. `"remote add"`. +pub function ctx_get_command_path(ctx: CliContext) -> string + return ctx.command_path end -## Returns 1 when the named command is active. -pub function CliContext.has_command(self: CliContext, name) -> int - return ctx_has_command(self, name) +## Reports whether the given command was matched. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Command name or alias to look for. +## +## ### Returns +## `true` when the command is active, `false` otherwise. +pub function ctx_has_command(ctx: CliContext, name: string) -> bool + return ctx.command_name == name end -## Returns 1 when this context has errors. -pub function CliContext.has_errors(self: CliContext) -> int - return ctx_has_errors(self) +## Reports whether the given flag, option, or repeatable option was provided. +## +## Only values that appeared on the command line count: an option left at +## its declared default is reported as absent. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Flag or option name, with or without leading dashes. +## +## ### Returns +## `true` when the value was provided, `false` otherwise. +pub function ctx_has(ctx: CliContext, name: string) -> bool + let clean = internal_strip_dashes(name) + let provided = ctx.provided + if clean in provided + return true + end + return false end -## Returns error messages from this context. -pub function CliContext.get_errors(self: CliContext) -> array - return ctx_get_errors(self) +## Reports whether the parse collected diagnostics. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when at least one diagnostic was collected, `false` otherwise. +pub function ctx_has_errors(ctx: CliContext) -> bool + return len(ctx.errors) > 0 end -## Alias for get_errors on this context. -pub function CliContext.errors(self: CliContext) -> array - return ctx_get_errors(self) +## Returns the collected diagnostics. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The `CliError` instances in the order they were produced. +pub function ctx_get_errors(ctx: CliContext) -> array + return ctx.errors end -## Returns 1 when help was requested. -pub function CliContext.help_requested(self: CliContext) -> int - return ctx_help_requested(self) +## Reports whether help was requested. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when `-h` or `--help` was supplied. +pub function ctx_help_requested(ctx: CliContext) -> bool + return ctx.help_requested == true end -## Returns 1 when version output was requested. -pub function CliContext.version_requested(self: CliContext) -> int - return ctx_version_requested(self) +## Reports whether the version banner was requested. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when `-V` or `--version` was supplied. +pub function ctx_version_requested(ctx: CliContext) -> bool + return ctx.version_requested == true end diff --git a/src/core/spec.alya b/src/core/spec.alya new file mode 100644 index 0000000..3698140 --- /dev/null +++ b/src/core/spec.alya @@ -0,0 +1,213 @@ +## cli Specification Builders +## +## Part of the Alya Language package ecosystem (https://github.com/alya-lang). +## Owns the construction of applications, commands, options, and positional +## arguments. The public facade in `src/lib.alya` re-exports thin wrappers +## over these builders, so this directory can grow into per-domain +## subfolders without changing the public API shape. + +import "../types.alya" +import "./internal.alya" + +# ─────────────────────────────────────────────────────────────── +# Private helpers +# ─────────────────────────────────────────────────────────────── + +# Registers an option inside the given option list. +function spec_register_option(options: array, spec: string, description: string, default_value: string, kind: int, required: bool, choices: array, env_var: string) + let names = internal_parse_spec(spec) + let option = make_option(str(names[0]), str(names[1]), description, default_value, kind, required, choices, env_var) + options.push(option) +end + +# Registers a positional argument inside the given argument list. +function spec_register_argument(arguments: array, name: string, description: string, required: bool, default_value: string, variadic: bool) + let argument = make_argument(name, description, required, default_value, variadic) + arguments.push(argument) +end + +# ─────────────────────────────────────────────────────────────── +# Constructors +# ─────────────────────────────────────────────────────────────── + +## Creates an application specification. +## +## ### Parameters +## - `name`: Executable name shown in usage screens. +## - `description`: One-line summary shown in usage screens. Defaults to `""`. +## - `version`: Version string shown by the version banner. Defaults to `"1.0.0"`. +## +## ### Returns +## A newly initialized `CliApp` instance. +## +## ### Examples +## ```alya +## let app = app_new("mytool", "A build tool", "1.2.0") +## ``` +pub function app_new(name: string, description: string = "", version: string = "1.0.0") -> CliApp + return make_app(name, description, version) +end + +## Creates a command specification. +## +## ### Parameters +## - `name`: Primary command name. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## - `aliases`: Alternative names accepted for this command. Defaults to `[]`. +## +## ### Returns +## A newly initialized `CliCommand` instance. +## +## ### Examples +## ```alya +## let build = cmd_new("build", "Compile artifacts", ["b"]) +## ``` +pub function cmd_new(name: string, description: string = "", aliases: array = []) -> CliCommand + return make_command(name, description, aliases) +end + +# ─────────────────────────────────────────────────────────────── +# Application mutators +# ─────────────────────────────────────────────────────────────── + +## Sets the author string of an application. +pub function app_set_author(app: CliApp, author: string) -> CliApp + app.author = author + return app +end + +## Sets the version string of an application. +pub function app_set_version(app: CliApp, version: string) -> CliApp + app.version = version + return app +end + +## Registers a boolean flag on an application. +pub function app_add_flag(app: CliApp, spec: string, description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, "", OptionKind.Flag, false, [], "") + return app +end + +## Registers a single-value option on an application. +pub function app_add_option(app: CliApp, spec: string, default_value: string = "", description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, [], "") + return app +end + +## Registers a repeatable option on an application. +pub function app_add_multi_option(app: CliApp, spec: string, description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, "", OptionKind.Multi, false, [], "") + return app +end + +## Registers a choice-restricted option on an application. +pub function app_add_choice_option(app: CliApp, spec: string, default_value: string, choices: array, description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, choices, "") + return app +end + +## Registers a mandatory option on an application. +pub function app_add_required_option(app: CliApp, spec: string, description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, "", OptionKind.Value, true, [], "") + return app +end + +## Registers an option that falls back to an environment variable. +pub function app_add_env_option(app: CliApp, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliApp + let options = app.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, [], env_var) + return app +end + +## Registers a positional argument on an application. +pub function app_add_argument(app: CliApp, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliApp + let arguments = app.arguments + spec_register_argument(arguments, name, description, required, default_value, false) + return app +end + +## Registers a variadic positional argument on an application. +pub function app_add_variadic_argument(app: CliApp, name: string, description: string = "") -> CliApp + let arguments = app.arguments + spec_register_argument(arguments, name, description, false, "", true) + return app +end + +## Registers a command on an application. +pub function app_add_command(app: CliApp, command: CliCommand) -> CliApp + let commands = app.commands + commands.push(command) + return app +end + +# ─────────────────────────────────────────────────────────────── +# Command mutators +# ─────────────────────────────────────────────────────────────── + +## Registers a boolean flag on a command. +pub function cmd_add_flag(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, "", OptionKind.Flag, false, [], "") + return cmd +end + +## Registers a single-value option on a command. +pub function cmd_add_option(cmd: CliCommand, spec: string, default_value: string = "", description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, [], "") + return cmd +end + +## Registers a repeatable option on a command. +pub function cmd_add_multi_option(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, "", OptionKind.Multi, false, [], "") + return cmd +end + +## Registers a choice-restricted option on a command. +pub function cmd_add_choice_option(cmd: CliCommand, spec: string, default_value: string, choices: array, description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, choices, "") + return cmd +end + +## Registers a mandatory option on a command. +pub function cmd_add_required_option(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, "", OptionKind.Value, true, [], "") + return cmd +end + +## Registers an option that falls back to an environment variable. +pub function cmd_add_env_option(cmd: CliCommand, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliCommand + let options = cmd.options + spec_register_option(options, spec, description, default_value, OptionKind.Value, false, [], env_var) + return cmd +end + +## Registers a positional argument on a command. +pub function cmd_add_argument(cmd: CliCommand, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliCommand + let arguments = cmd.arguments + spec_register_argument(arguments, name, description, required, default_value, false) + return cmd +end + +## Registers a variadic positional argument on a command. +pub function cmd_add_variadic_argument(cmd: CliCommand, name: string, description: string = "") -> CliCommand + let arguments = cmd.arguments + spec_register_argument(arguments, name, description, false, "", true) + return cmd +end + +## Nests a command under another command. +pub function cmd_add_subcommand(parent: CliCommand, child: CliCommand) -> CliCommand + let subcommands = parent.subcommands + subcommands.push(child) + return parent +end diff --git a/src/core/utils.alya b/src/core/utils.alya deleted file mode 100644 index 429ec7f..0000000 --- a/src/core/utils.alya +++ /dev/null @@ -1,159 +0,0 @@ -## CLI Utility Helpers -## -## Part of the Alya Language package ecosystem (https://github.com/alya-lang). -## Internal string processing, padding, and flag parsing utilities. - -## Checks whether a string begins with the specified prefix. -## -## ### Parameters -## - `s`: The target string to inspect. -## - `prefix`: The prefix substring to search for. -## -## ### Returns -## `1` if `s` starts with `prefix`; `0` otherwise. -pub function _cli_starts_with(s, prefix) -> int - let s_str = str(s) - let p_str = str(prefix) - let slen = len(s_str) - let plen = len(p_str) - return when - slen < plen => false - s_str[0..plen] == p_str => true - else => false - end -end - -## Strips leading single (`-`) or double (`--`) dashes from a flag identifier. -## -## ### Parameters -## - `s`: The flag string to clean. -## -## ### Returns -## The identifier string with leading dashes removed. -pub function _cli_strip_dashes(s) -> string - let s_str = str(s) - let slen = len(s_str) - return when - _cli_starts_with(s_str, "--") == true => s_str[2..slen] - _cli_starts_with(s_str, "-") == true => s_str[1..slen] - else => s_str - end -end - -## Locates the first occurrence of a character within a string. -## -## ### Parameters -## - `s`: The string to search through. -## - `ch`: The single-character string to find. -## -## ### Returns -## The zero-based index of `ch`, or `-1` if not found. -pub function _cli_find_char(s, ch) -> int - let s_str = str(s) - let slen = len(s_str) - let i = 0 - while i < slen - if char_at(s_str, i) == ch - return i - end - i += 1 - end - return -1 -end - -## Pads a string on the right with spaces until it reaches the specified width. -## -## ### Parameters -## - `s`: The original string to pad. -## - `width`: The target minimum string width. -## -## ### Returns -## The padded string. -pub function _cli_pad_right(s, width: int) -> string - let res = str(s) - let slen = len(res) - while slen < width - res += " " - slen += 1 - end - return res -end - -## Parses a flag specification string into short and long flag identifiers. -## -## Handles formats such as `"-v, --verbose"`, `"--output, -o"`, `"--help"`, or `"-f"`. -## -## ### Parameters -## - `flags_str`: The comma-delimited flag specification string. -## -## ### Returns -## A 2-element array `[short_name, long_name]`. -pub function _cli_parse_flags_spec(flags_str) -> array - let s = trim(str(flags_str)) - let short_name = "" - let long_name = "" - if contains(s, ",") - let parts = split(s, ",") - let p0 = trim(str(parts[0])) - let p1 = trim(str(parts[1])) - if _cli_starts_with(p0, "--") == true - long_name = p0[2..len(p0)] - elif _cli_starts_with(p0, "-") == true - short_name = p0[1..len(p0)] - else - long_name = p0 - end - - if _cli_starts_with(p1, "--") == true - long_name = p1[2..len(p1)] - elif _cli_starts_with(p1, "-") == true - short_name = p1[1..len(p1)] - else - long_name = p1 - end - else - if _cli_starts_with(s, "--") == true - long_name = s[2..len(s)] - elif _cli_starts_with(s, "-") == true - short_name = s[1..len(s)] - else - long_name = s - end - end - return [short_name, long_name] -end - -## Checks whether a target string is present inside an array. -## -## ### Parameters -## - `arr`: The array of candidates to check. -## - `s`: The target string to test. -## -## ### Returns -## `1` if `s` is in `arr`; `0` otherwise. -pub function _cli_str_in_array(arr, s) -> int - let target = str_from_ptr(s) - return (target in arr) ? true : false -end - -## Concatenates array elements with a delimiter separator string. -## -## ### Parameters -## - `arr`: The array of elements to join. -## - `sep`: The separator string. -## -## ### Returns -## The joined string representation. -pub function _cli_join_array(arr, sep: string) -> string - let n = len(arr) - if n == 0 - return "" - end - let res = str_from_ptr(arr[0]) - let i = 1 - while i < n - res += sep + str_from_ptr(arr[i]) - i += 1 - end - return res -end diff --git a/src/lib.alya b/src/lib.alya index d33070c..1c9c991 100644 --- a/src/lib.alya +++ b/src/lib.alya @@ -1,131 +1,1068 @@ -## CLI Library Facade +## cli Library Facade ## ## Part of the Alya Language package ecosystem (https://github.com/alya-lang). -## Modern command-line interface, argument parsing, flag handling, and subcommand router for Alya. +## Public entry point of the `cli` package: application and command +## builders, the parsing entry points, the parsed-context accessors, the +## usage screen renderer, and the structured error helpers. +## +## The facade is a thin layer over the engines in `src/core/`: +## `spec.alya` builds the specifications, `parser.alya` resolves the +## arguments, `formatter.alya` renders the usage screens, and +## `errors.alya` adds the throwing and recovering parse variants. import "./types.alya" -import "./core/parser.alya" +import "./core/spec.alya" as spec +import "./core/parser.alya" as parser @cfg(feature = "help") -import "./core/formatter.alya" +import "./core/formatter.alya" as formatter +@cfg(feature = "strict") +import "./core/errors.alya" as errors + +# ─────────────────────────────────────────────────────────────── +# Constructors +# ─────────────────────────────────────────────────────────────── -## Constructs and initializes a new top-level CLI application. +## Creates an application specification. ## ## ### Parameters -## - `name`: The executable application name (e.g. `"mytool"`). -## - `description`: Brief description displayed in help screens. Defaults to `""`. -## - `version`: Semantic version string. Defaults to `"1.0.0"`. +## - `name`: Executable name shown in usage screens. +## - `description`: One-line summary shown in usage screens. Defaults to `""`. +## - `version`: Version string shown by the version banner. Defaults to `"1.0.0"`. ## ## ### Returns -## A newly initialized `CliApp` instance ready for configuration and execution. +## A newly initialized `CliApp` instance. ## ## ### Examples ## ```alya -## import "cli" -## -## let app = cli::new("mytool", "A high-performance build tool", "1.2.0") +## let app = new_app("mytool", "A build tool", "1.2.0") ## ``` -pub function new(name: string, description: string = "", version: string = "1.0.0") -> CliApp - return app_new(name, description, version) +pub function new_app(name: string, description: string = "", version: string = "1.0.0") -> CliApp + return spec::app_new(name, description, version) end -## Constructs a new subcommand model instance. +## Creates a command specification. ## ## ### Parameters -## - `name`: The primary name of the subcommand (e.g. `"build"`). -## - `description`: Description of what this subcommand does. Defaults to `""`. -## - `aliases`: Shorthand alias strings (e.g. `["b"]`). Defaults to `[]`. +## - `name`: Primary command name. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## - `aliases`: Alternative names accepted for this command. Defaults to `[]`. ## ## ### Returns ## A newly initialized `CliCommand` instance. ## ## ### Examples ## ```alya -## import "cli" -## -## let build_cmd = cli::command("build", "Compile target package", ["b"]) +## let build = new_command("build", "Compile artifacts", ["b"]) ## ``` -pub function command(name: string, description: string = "", aliases = []) -> CliCommand - return cmd_new(name, description, aliases) +pub function new_command(name: string, description: string = "", aliases: array = []) -> CliCommand + return spec::cmd_new(name, description, aliases) end -## Parses an explicit array of command-line argument strings against the application specification. +# ─────────────────────────────────────────────────────────────── +# Application builders +# ─────────────────────────────────────────────────────────────── + +## Registers a boolean flag on an application. ## -## Populates flags, options, positional arguments, subcommands, and collects validation errors. +## ### Parameters +## - `app`: The application specification. +## - `spec`: Flag names, e.g. `"-v, --verbose"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_flag(app: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_flag(app, spec, description) +end + +## Registers a single-value option on an application. ## ## ### Parameters -## - `app`: The target `CliApp` configuration model. -## - `raw_args`: Array of argument strings (typically `sys::args()` or manual array). +## - `app`: The application specification. +## - `spec`: Option names, e.g. `"-o, --output"`. +## - `default_value`: Value applied when the option is omitted. Defaults to `""`. +## - `description`: Help text shown in usage screens. Defaults to `""`. ## ## ### Returns -## A `CliContext` containing resolved values, flags, and error status. +## The updated application. +pub function add_option(app: CliApp, spec: string, default_value: string = "", description: string = "") -> CliApp + return spec::app_add_option(app, spec, default_value, description) +end + +## Registers a repeatable option on an application. ## -## ### Examples -## ```alya -## let ctx = cli::parse(app, ["--verbose", "input.txt"]) -## if ctx.get_flag("verbose") == 1 -## say "Verbose output enabled" -## end -## ``` -pub function parse(app: CliApp, raw_args) -> CliContext - return app_parse(app, raw_args) +## ### Parameters +## - `app`: The application specification. +## - `spec`: Option names, e.g. `"-I, --include"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_multi_option(app: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_multi_option(app, spec, description) +end + +## Registers a choice-restricted option on an application. +## +## ### Parameters +## - `app`: The application specification. +## - `spec`: Option names, e.g. `"--format"`. +## - `default_value`: Value applied when the option is omitted. +## - `choices`: The allowed values. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_choice_option(app: CliApp, spec: string, default_value: string, choices: array, description: string = "") -> CliApp + return spec::app_add_choice_option(app, spec, default_value, choices, description) +end + +## Registers a mandatory option on an application. +## +## ### Parameters +## - `app`: The application specification. +## - `spec`: Option names, e.g. `"-c, --config"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_required_option(app: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_required_option(app, spec, description) +end + +## Registers an option that falls back to an environment variable. +## +## Requires the `env` feature (default-on). The environment variable is +## consulted only when the option is omitted on the command line. +## +## ### Parameters +## - `app`: The application specification. +## - `spec`: Option names, e.g. `"-t, --token"`. +## - `env_var`: Environment variable name, e.g. `"MYTOOL_TOKEN"`. +## - `default_value`: Value applied when neither the option nor the environment provides a value. Defaults to `""`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_env_option(app: CliApp, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliApp + return spec::app_add_env_option(app, spec, env_var, default_value, description) +end + +## Registers a positional argument on an application. +## +## ### Parameters +## - `app`: The application specification. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## - `required`: `true` when the argument must be provided. Defaults to `false`. +## - `default_value`: Value applied when the argument is omitted. Defaults to `""`. +## +## ### Returns +## The updated application. +pub function add_argument(app: CliApp, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliApp + return spec::app_add_argument(app, name, description, required, default_value) end -## Automatically reads and parses process arguments (`sys::args()` / `args`) into a context. +## Registers a variadic positional argument on an application. +## +## The variadic argument captures every remaining positional value. ## ## ### Parameters -## - `app`: The target `CliApp` configuration model. +## - `app`: The application specification. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. Defaults to `""`. ## ## ### Returns -## A `CliContext` containing parsed command-line state. +## The updated application. +pub function add_variadic_argument(app: CliApp, name: string, description: string = "") -> CliApp + return spec::app_add_variadic_argument(app, name, description) +end + +## Registers a command on an application. +## +## ### Parameters +## - `app`: The application specification. +## - `command`: The command specification to register. +## +## ### Returns +## The updated application. +pub function add_command(app: CliApp, command: CliCommand) -> CliApp + return spec::app_add_command(app, command) +end + +## Sets the author string of an application. +## +## ### Parameters +## - `app`: The application specification. +## - `author`: The author string. +## +## ### Returns +## The updated application. +pub function set_author(app: CliApp, author: string) -> CliApp + return spec::app_set_author(app, author) +end + +## Sets the version string of an application. +## +## ### Parameters +## - `app`: The application specification. +## - `version`: The version string. +## +## ### Returns +## The updated application. +pub function set_version(app: CliApp, version: string) -> CliApp + return spec::app_set_version(app, version) +end + +# ─────────────────────────────────────────────────────────────── +# Command builders +# ─────────────────────────────────────────────────────────────── + +## Registers a boolean flag on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Flag names, e.g. `"-r, --release"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_flag(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_flag(cmd, spec, description) +end + +## Registers a single-value option on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Option names, e.g. `"-t, --target"`. +## - `default_value`: Value applied when the option is omitted. Defaults to `""`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_option(cmd: CliCommand, spec: string, default_value: string = "", description: string = "") -> CliCommand + return spec::cmd_add_option(cmd, spec, default_value, description) +end + +## Registers a repeatable option on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Option names, e.g. `"-I, --include"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_multi_option(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_multi_option(cmd, spec, description) +end + +## Registers a choice-restricted option on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Option names, e.g. `"-t, --target"`. +## - `default_value`: Value applied when the option is omitted. +## - `choices`: The allowed values. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_choice_option(cmd: CliCommand, spec: string, default_value: string, choices: array, description: string = "") -> CliCommand + return spec::cmd_add_choice_option(cmd, spec, default_value, choices, description) +end + +## Registers a mandatory option on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Option names, e.g. `"-c, --config"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_required_option(cmd: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_required_option(cmd, spec, description) +end + +## Registers an option that falls back to an environment variable. +## +## Requires the `env` feature (default-on). +## +## ### Parameters +## - `cmd`: The command specification. +## - `spec`: Option names, e.g. `"-t, --token"`. +## - `env_var`: Environment variable name. +## - `default_value`: Value applied when neither the option nor the environment provides a value. Defaults to `""`. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_env_option(cmd: CliCommand, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliCommand + return spec::cmd_add_env_option(cmd, spec, env_var, default_value, description) +end + +## Registers a positional argument on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## - `required`: `true` when the argument must be provided. Defaults to `false`. +## - `default_value`: Value applied when the argument is omitted. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_argument(cmd: CliCommand, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliCommand + return spec::cmd_add_argument(cmd, name, description, required, default_value) +end + +## Registers a variadic positional argument on a command. +## +## ### Parameters +## - `cmd`: The command specification. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## The updated command. +pub function command_add_variadic_argument(cmd: CliCommand, name: string, description: string = "") -> CliCommand + return spec::cmd_add_variadic_argument(cmd, name, description) +end + +## Nests a command under another command. +## +## ### Parameters +## - `parent`: The parent command. +## - `child`: The command to nest. +## +## ### Returns +## The updated parent command. +pub function command_add_subcommand(parent: CliCommand, child: CliCommand) -> CliCommand + return spec::cmd_add_subcommand(parent, child) +end + +# ─────────────────────────────────────────────────────────────── +# Parsing entry points +# ─────────────────────────────────────────────────────────────── + +## Parses an argument array into a resolved execution context. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse. +## +## ### Returns +## A `CliContext` with the resolved values and the collected diagnostics. ## ## ### Examples ## ```alya -## let ctx = cli::parse_args(app) +## let ctx = parse(app, ["--verbose", "input.txt"]) ## ``` +pub function parse(app: CliApp, raw_args: array) -> CliContext + return parser::parse(app, raw_args) +end + +## Parses the process arguments into a resolved execution context. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## +## ### Returns +## A `CliContext` with the resolved values and the collected diagnostics. pub function parse_args(app: CliApp) -> CliContext - return app_parse_args(app) + return parser::parse_args(app) end -## Formats and returns the complete help screen text for the application or a specific command. +## Parses an argument array, throwing a structured error on invalid input. ## -## Requires the `help` feature (default-on). +## Requires the `strict` feature (default-on). ## ## ### Parameters -## - `app`: The target `CliApp` instance. -## - `cmd`: Optional `CliCommand` instance. If omitted or `0`, renders root application help. +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse. ## ## ### Returns -## Formatted multi-line help string. +## A `CliContext` without diagnostics. ## ## ### Examples ## ```alya -## let help_text = cli::help(app) -## say help_text +## try +## let ctx = parse_strict(app, args()) +## catch err +## say format_caught_error(err) +## end ## ``` +@cfg(feature = "strict") +pub function parse_strict(app: CliApp, raw_args: array) -> CliContext + return errors::parse_strict(app, raw_args) +end + +## Parses an argument array, converting failures into a result container. +## +## Requires the `strict` feature (default-on). Never throws. +## +## ### Parameters +## - `app`: The application specification driving the parse. +## - `raw_args`: The argument array to parse. +## +## ### Returns +## A `CliResult` carrying either the context or the first diagnostic. +@cfg(feature = "strict") +pub function try_parse(app: CliApp, raw_args: array) -> CliResult + return errors::try_parse(app, raw_args) +end + +# ─────────────────────────────────────────────────────────────── +# Context accessors +# ─────────────────────────────────────────────────────────────── + +## Returns the state of a parsed flag. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Flag name, with or without leading dashes. +## +## ### Returns +## `true` when the flag was enabled, `false` otherwise. +pub function get_flag(ctx: CliContext, name: string) -> bool + return parser::ctx_get_flag(ctx, name) +end + +## Returns the value of a parsed option. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## - `default_value`: Value returned when the option is absent. Defaults to `""`. +## +## ### Returns +## The resolved option value, or `default_value`. +pub function get_option(ctx: CliContext, name: string, default_value: string = "") -> string + return parser::ctx_get_option(ctx, name, default_value) +end + +## Returns the value of a parsed option converted to an integer. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## - `default_value`: Value returned when the option is absent or not numeric. Defaults to `0`. +## +## ### Returns +## The integer value of the option, or `default_value`. +pub function get_int(ctx: CliContext, name: string, default_value: int = 0) -> int + return parser::ctx_get_int(ctx, name, default_value) +end + +## Returns the boolean value of a parsed option. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## +## ### Returns +## `true` when the value reads as true, `false` otherwise. +pub function get_bool(ctx: CliContext, name: string) -> bool + return parser::ctx_get_bool(ctx, name) +end + +## Returns every value collected for a repeatable option. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Option name, with or without leading dashes. +## +## ### Returns +## The collected values, or an empty array when the option is absent. +pub function get_all(ctx: CliContext, name: string) -> array + return parser::ctx_get_all(ctx, name) +end + +## Returns a positional value by position. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `index`: Zero-based position of the value. +## - `default_value`: Value returned when the position is out of range. Defaults to `""`. +## +## ### Returns +## The positional value, or `default_value`. +pub function get_arg(ctx: CliContext, index: int, default_value: string = "") -> string + return parser::ctx_get_arg(ctx, index, default_value) +end + +## Returns a positional value by argument name. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Argument name as declared in the specification. +## - `default_value`: Value returned when the argument is absent. Defaults to `""`. +## +## ### Returns +## The bound value, or `default_value`. +pub function get_arg_named(ctx: CliContext, name: string, default_value: string = "") -> string + return parser::ctx_get_arg_named(ctx, name, default_value) +end + +## Returns every positional value in order. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The positional values. +pub function get_args(ctx: CliContext) -> array + return parser::ctx_get_args(ctx) +end + +## Returns the name of the matched command. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The command name, or an empty string when no command was matched. +pub function get_command(ctx: CliContext) -> string + return parser::ctx_get_command(ctx) +end + +## Returns the full path of the matched command. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The space separated command path, e.g. `"remote add"`. +pub function get_command_path(ctx: CliContext) -> string + return parser::ctx_get_command_path(ctx) +end + +## Reports whether the given command was matched. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Command name or alias to look for. +## +## ### Returns +## `true` when the command is active, `false` otherwise. +pub function has_command(ctx: CliContext, name: string) -> bool + return parser::ctx_has_command(ctx, name) +end + +## Reports whether the given flag, option, or repeatable option was provided. +## +## ### Parameters +## - `ctx`: The parsed context. +## - `name`: Flag or option name, with or without leading dashes. +## +## ### Returns +## `true` when the value was provided, `false` otherwise. +pub function has(ctx: CliContext, name: string) -> bool + return parser::ctx_has(ctx, name) +end + +## Reports whether the parse collected diagnostics. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when at least one diagnostic was collected, `false` otherwise. +pub function has_errors(ctx: CliContext) -> bool + return parser::ctx_has_errors(ctx) +end + +## Returns the collected diagnostics. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## The `CliError` instances in the order they were produced. +pub function get_errors(ctx: CliContext) -> array + return parser::ctx_get_errors(ctx) +end + +## Reports whether help was requested. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when `-h` or `--help` was supplied. +pub function help_requested(ctx: CliContext) -> bool + return parser::ctx_help_requested(ctx) +end + +## Reports whether the version banner was requested. +## +## ### Parameters +## - `ctx`: The parsed context. +## +## ### Returns +## `true` when `-V` or `--version` was supplied. +pub function version_requested(ctx: CliContext) -> bool + return parser::ctx_version_requested(ctx) +end + +# ─────────────────────────────────────────────────────────────── +# Usage screens +# ─────────────────────────────────────────────────────────────── + +## Formats the complete usage screen of an application. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `app`: The application specification. +## +## ### Returns +## The formatted multi-line usage screen. +@cfg(feature = "help") +pub function format_help(app: CliApp) -> string + return formatter::format_help(app) +end + +## Formats the complete usage screen of a single command. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `app`: The application specification. +## - `cmd`: The command to render. +## +## ### Returns +## The formatted multi-line usage screen. @cfg(feature = "help") -pub function help(app: CliApp, cmd = 0) -> string - return cli_format_help(app, cmd) +pub function format_command_help(app: CliApp, cmd: CliCommand) -> string + return formatter::format_command_help(app, cmd) end -## Prints the formatted help screen directly to standard output. +## Formats the version banner of an application. ## ## Requires the `help` feature (default-on). ## ## ### Parameters -## - `app`: The target `CliApp` instance. -## - `cmd`: Optional `CliCommand` instance. Defaults to `0` (root help). +## - `app`: The application specification. +## +## ### Returns +## The version string, e.g. `"mytool 1.2.0"`. +@cfg(feature = "help") +pub function format_version(app: CliApp) -> string + return formatter::format_version(app) +end + +## Formats a list of parse diagnostics into a report. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `errors`: The diagnostics collected by the parsing engine. +## +## ### Returns +## One `"Error: ..."` line per diagnostic. +@cfg(feature = "help") +pub function format_errors(errors: array) -> string + return formatter::format_errors(errors) +end + +## Prints the usage screen of an application. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `app`: The application specification. @cfg(feature = "help") -pub function print_help(app: CliApp, cmd = 0) - say cli_format_help(app, cmd) +pub function print_help(app: CliApp) + say formatter::format_help(app) end -## Prints the application version string directly to standard output. +## Prints the usage screen of a single command. ## ## Requires the `help` feature (default-on). ## ## ### Parameters -## - `app`: The target `CliApp` instance. +## - `app`: The application specification. +## - `cmd`: The command to render. +@cfg(feature = "help") +pub function print_command_help(app: CliApp, cmd: CliCommand) + say formatter::format_command_help(app, cmd) +end + +## Prints the version banner of an application. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `app`: The application specification. @cfg(feature = "help") pub function print_version(app: CliApp) - say cli_format_version(app) + say formatter::format_version(app) +end + +# ─────────────────────────────────────────────────────────────── +# Feature-gated fallback stubs +# ─────────────────────────────────────────────────────────────── + +# The stubs below keep the public API surface stable when a feature slice is +# disabled, so callers get a clear descriptive error instead of an +# undefined reference (params unused by design). +# alya-lint: disable unused-param + +## Fallback stub for `format_help` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function format_help(app: CliApp) -> string + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `format_command_help` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function format_command_help(app: CliApp, cmd: CliCommand) -> string + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `format_version` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function format_version(app: CliApp) -> string + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `format_errors` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function format_errors(errors: array) -> string + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `print_help` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function print_help(app: CliApp) + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `print_command_help` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function print_command_help(app: CliApp, cmd: CliCommand) + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `print_version` when the `help` feature is disabled. +## +## Rebuild with default features (or `--features help`) for the full +## implementation in `src/core/formatter.alya`. +@cfg(not(feature = "help")) +pub function print_version(app: CliApp) + throw "help feature is disabled (rebuild with default features or --features help)" +end + +## Fallback stub for `parse_strict` when the `strict` feature is disabled. +## +## Rebuild with default features (or `--features strict`) for the full +## implementation in `src/core/errors.alya`. +@cfg(not(feature = "strict")) +pub function parse_strict(app: CliApp, raw_args: array) -> CliContext + throw "strict feature is disabled (rebuild with default features or --features strict)" +end + +## Fallback stub for `try_parse` when the `strict` feature is disabled. +## +## Preserves the never-throws contract by returning a failed result carrying +## the descriptive error. Rebuild with default features (or +## `--features strict`) for the full implementation in `src/core/errors.alya`. +@cfg(not(feature = "strict")) +pub function try_parse(app: CliApp, raw_args: array) -> CliResult + let empty = CliContext { + app: app, + command_name: "", + command_path: "", + flags: {}, + options: {}, + multi_options: {}, + provided: {}, + positional: [], + named: {}, + errors: [], + help_requested: false, + version_requested: false, + raw_args: [] + } + return CliResult { + ok: false, + context: empty, + error: "strict feature is disabled (rebuild with default features or --features strict)" + } +end +# alya-lint: enable unused-param + +# ─────────────────────────────────────────────────────────────── +# Struct Methods: CliApp +# ─────────────────────────────────────────────────────────────── + +## Registers a boolean flag on this application. +pub function CliApp.flag(self: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_flag(self, spec, description) +end + +## Registers a single-value option on this application. +pub function CliApp.option(self: CliApp, spec: string, default_value: string = "", description: string = "") -> CliApp + return spec::app_add_option(self, spec, default_value, description) +end + +## Registers a repeatable option on this application. +pub function CliApp.multi_option(self: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_multi_option(self, spec, description) +end + +## Registers a choice-restricted option on this application. +pub function CliApp.choice_option(self: CliApp, spec: string, default_value: string, choices: array, description: string = "") -> CliApp + return spec::app_add_choice_option(self, spec, default_value, choices, description) +end + +## Registers a mandatory option on this application. +pub function CliApp.required_option(self: CliApp, spec: string, description: string = "") -> CliApp + return spec::app_add_required_option(self, spec, description) +end + +## Registers an environment-backed option on this application. +pub function CliApp.env_option(self: CliApp, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliApp + return spec::app_add_env_option(self, spec, env_var, default_value, description) +end + +## Registers a positional argument on this application. +pub function CliApp.argument(self: CliApp, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliApp + return spec::app_add_argument(self, name, description, required, default_value) +end + +## Registers a variadic positional argument on this application. +pub function CliApp.variadic_argument(self: CliApp, name: string, description: string = "") -> CliApp + return spec::app_add_variadic_argument(self, name, description) +end + +## Registers a command on this application. +pub function CliApp.command(self: CliApp, command: CliCommand) -> CliApp + return spec::app_add_command(self, command) +end + +## Sets the author string of this application. +pub function CliApp.set_author(self: CliApp, author: string) -> CliApp + return spec::app_set_author(self, author) +end + +## Sets the version string of this application. +pub function CliApp.set_version(self: CliApp, version: string) -> CliApp + return spec::app_set_version(self, version) +end + +## Parses an argument array with this application. +pub function CliApp.parse(self: CliApp, raw_args: array) -> CliContext + return parser::parse(self, raw_args) +end + +## Parses the given arguments, or the process arguments when none are given. +pub function CliApp.run(self: CliApp, raw_args = null) -> CliContext + if raw_args is null + return parser::parse_args(self) + end + return parser::parse(self, raw_args) +end + +# ─────────────────────────────────────────────────────────────── +# Struct Methods: CliCommand +# ─────────────────────────────────────────────────────────────── + +## Registers a boolean flag on this command. +pub function CliCommand.flag(self: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_flag(self, spec, description) +end + +## Registers a single-value option on this command. +pub function CliCommand.option(self: CliCommand, spec: string, default_value: string = "", description: string = "") -> CliCommand + return spec::cmd_add_option(self, spec, default_value, description) +end + +## Registers a repeatable option on this command. +pub function CliCommand.multi_option(self: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_multi_option(self, spec, description) +end + +## Registers a choice-restricted option on this command. +pub function CliCommand.choice_option(self: CliCommand, spec: string, default_value: string, choices: array, description: string = "") -> CliCommand + return spec::cmd_add_choice_option(self, spec, default_value, choices, description) +end + +## Registers a mandatory option on this command. +pub function CliCommand.required_option(self: CliCommand, spec: string, description: string = "") -> CliCommand + return spec::cmd_add_required_option(self, spec, description) +end + +## Registers an environment-backed option on this command. +pub function CliCommand.env_option(self: CliCommand, spec: string, env_var: string, default_value: string = "", description: string = "") -> CliCommand + return spec::cmd_add_env_option(self, spec, env_var, default_value, description) +end + +## Registers a positional argument on this command. +pub function CliCommand.argument(self: CliCommand, name: string, description: string = "", required: bool = false, default_value: string = "") -> CliCommand + return spec::cmd_add_argument(self, name, description, required, default_value) +end + +## Registers a variadic positional argument on this command. +pub function CliCommand.variadic_argument(self: CliCommand, name: string, description: string = "") -> CliCommand + return spec::cmd_add_variadic_argument(self, name, description) +end + +## Nests a command under this command. +pub function CliCommand.subcommand(self: CliCommand, child: CliCommand) -> CliCommand + return spec::cmd_add_subcommand(self, child) +end + +# ─────────────────────────────────────────────────────────────── +# Struct Methods: CliContext +# ─────────────────────────────────────────────────────────────── + +## Returns the state of a parsed flag. +pub function CliContext.get_flag(self: CliContext, name: string) -> bool + return parser::ctx_get_flag(self, name) +end + +## Alias for `get_flag` on this context. +pub function CliContext.flag(self: CliContext, name: string) -> bool + return parser::ctx_get_flag(self, name) +end + +## Returns the value of a parsed option. +pub function CliContext.get_option(self: CliContext, name: string, default_value: string = "") -> string + return parser::ctx_get_option(self, name, default_value) +end + +## Alias for `get_option` on this context. +pub function CliContext.option(self: CliContext, name: string, default_value: string = "") -> string + return parser::ctx_get_option(self, name, default_value) +end + +## Returns the value of a parsed option converted to an integer. +pub function CliContext.get_int(self: CliContext, name: string, default_value: int = 0) -> int + return parser::ctx_get_int(self, name, default_value) +end + +## Returns the boolean value of a parsed option. +pub function CliContext.get_bool(self: CliContext, name: string) -> bool + return parser::ctx_get_bool(self, name) +end + +## Returns every value collected for a repeatable option. +pub function CliContext.get_all(self: CliContext, name: string) -> array + return parser::ctx_get_all(self, name) +end + +## Returns a positional value by position. +pub function CliContext.get_arg(self: CliContext, index: int, default_value: string = "") -> string + return parser::ctx_get_arg(self, index, default_value) +end + +## Alias for `get_arg` on this context. +pub function CliContext.arg(self: CliContext, index: int, default_value: string = "") -> string + return parser::ctx_get_arg(self, index, default_value) +end + +## Returns a positional value by argument name. +pub function CliContext.get_arg_named(self: CliContext, name: string, default_value: string = "") -> string + return parser::ctx_get_arg_named(self, name, default_value) +end + +## Returns every positional value in order. +pub function CliContext.get_args(self: CliContext) -> array + return parser::ctx_get_args(self) +end + +## Alias for `get_args` on this context. +pub function CliContext.args(self: CliContext) -> array + return parser::ctx_get_args(self) +end + +## Returns the name of the matched command. +pub function CliContext.get_command(self: CliContext) -> string + return parser::ctx_get_command(self) +end + +## Alias for `get_command` on this context. +pub function CliContext.command(self: CliContext) -> string + return parser::ctx_get_command(self) +end + +## Returns the full path of the matched command. +pub function CliContext.get_command_path(self: CliContext) -> string + return parser::ctx_get_command_path(self) +end + +## Reports whether the given command was matched. +pub function CliContext.has_command(self: CliContext, name: string) -> bool + return parser::ctx_has_command(self, name) +end + +## Reports whether the given flag, option, or repeatable option was provided. +pub function CliContext.has(self: CliContext, name: string) -> bool + return parser::ctx_has(self, name) +end + +## Reports whether this context carries diagnostics. +pub function CliContext.has_errors(self: CliContext) -> bool + return parser::ctx_has_errors(self) +end + +## Returns the collected diagnostics. +pub function CliContext.get_errors(self: CliContext) -> array + return parser::ctx_get_errors(self) +end + +## Alias for `get_errors` on this context. +pub function CliContext.errors(self: CliContext) -> array + return parser::ctx_get_errors(self) +end + +## Reports whether help was requested. +pub function CliContext.help_requested(self: CliContext) -> bool + return parser::ctx_help_requested(self) +end + +## Reports whether the version banner was requested. +pub function CliContext.version_requested(self: CliContext) -> bool + return parser::ctx_version_requested(self) +end + +# ─────────────────────────────────────────────────────────────── +# Struct Methods: CliResult +# ─────────────────────────────────────────────────────────────── + +## Reports whether the strict parse succeeded. +pub function CliResult.is_ok(self: CliResult) -> bool + return self.ok == true +end + +## Reports whether the strict parse failed. +pub function CliResult.is_error(self: CliResult) -> bool + return self.ok == false +end + +## Returns the diagnostic message of a failed strict parse. +pub function CliResult.message(self: CliResult) -> string + return self.error end diff --git a/src/types.alya b/src/types.alya index aa33756..763a103 100644 --- a/src/types.alya +++ b/src/types.alya @@ -1,158 +1,356 @@ -## CLI Types & Data Structures +## cli Types & Data Structures ## ## Part of the Alya Language package ecosystem (https://github.com/alya-lang). -## Defines option arity enums, argument kinds, and core CLI data models. +## Defines the option, argument, command, and application models, the token +## and error vocabularies, and the parsed execution context that every layer +## of the package shares. The models are plain data: the engines in +## `src/core/` own all behaviour. -## Public enumeration for option arity. +# ─────────────────────────────────────────────────────────────── +# Enumerations +# ─────────────────────────────────────────────────────────────── + +## Public enumeration of option kinds. ## -## Defines whether an option is a boolean flag, a single-value option, -## or a multi-value option accumulating an array of values: -## - `Flag`: Boolean toggle flag without argument (e.g. `--verbose`). -## - `Single`: Option expecting exactly one argument value (e.g. `--output file.txt`). -## - `Multiple`: Option that may be specified multiple times (e.g. `-I dir1 -I dir2`). -pub enum OptionArity - Flag = 0 - Single = 1 - Multiple = 2 +## - `Flag`: Boolean switch without a value (`--verbose`). +## - `Value`: Option carrying exactly one value (`--output dist`). +## - `Multi`: Repeatable option collecting an array of values (`-I a -I b`). +pub enum OptionKind + Flag = 0, + Value = 1, + Multi = 2 end -## Public enumeration for command type. +## Public enumeration of command kinds. ## -## Differentiates top-level root applications from subcommands: -## - `Root`: The root CLI application entry point. -## - `Subcommand`: A nested command under the root application. -pub enum CommandType - Root = 0 +## - `Root`: The top-level application entry point. +## - `Subcommand`: A command nested under the root application or another command. +pub enum CommandKind + Root = 0, Subcommand = 1 end -## Internal classification of command-line argument tokens. +## Public enumeration of argument token kinds recognised by the parser. ## -## Used by the parsing engine to dispatch argument processing: -## - `InvertedFlag`: Flag with `--no-` prefix (e.g. `--no-color`). -## - `LongOption`: Long option or flag with `--` prefix (e.g. `--output`). -## - `ShortOption`: Short option or flag with `-` prefix (e.g. `-v`, `-o`). -## - `Positional`: Positional argument or subcommand name. -pub enum ArgKind - InvertedFlag = 0 - LongOption = 1 - ShortOption = 2 +## - `Inverted`: Negated flag (`--no-color`). +## - `Long`: Long option (`--output`). +## - `Short`: Short option (`-o`). +## - `Positional`: Positional value or command name. +pub enum TokenKind + Inverted = 0, + Long = 1, + Short = 2, Positional = 3 end -## Command-line option specification (flag or value-bearing option). +## Public enumeration of structured parse error codes. ## -## Encapsulates short/long flag names, description, default value, and validation rules. +## - `UnknownOption`: Option the active specification does not declare. +## - `MissingValue`: Value option supplied without a value. +## - `MissingRequired`: Required option omitted. +## - `InvalidChoice`: Value outside the declared choice list. +## - `MissingArgument`: Required positional argument omitted. +## - `UnknownCommand`: Token that matches no declared command. +## - `InvalidUsage`: Option used in a way its kind does not support. +pub enum ErrorCode + UnknownOption = 0, + MissingValue = 1, + MissingRequired = 2, + InvalidChoice = 3, + MissingArgument = 4, + UnknownCommand = 5, + InvalidUsage = 6 +end + +# ─────────────────────────────────────────────────────────────── +# Data Models +# ─────────────────────────────────────────────────────────────── + +## Option specification (flag, value option, or repeatable option). ## ## ### Fields -## - `opt_short`: Short flag name without dash (e.g. `"v"`). -## - `opt_long`: Long flag name without dashes (e.g. `"verbose"`). -## - `opt_desc`: Informative description shown in help screens. -## - `opt_default`: Default string value when omitted. -## - `opt_is_flag`: `1` if boolean flag without value; `0` if value option. -## - `opt_is_multi`: `1` if option can be repeated to collect an array of values. -## - `opt_required`: `1` if option must be provided by user. -## - `opt_choices`: Array of permissible string choices, or empty array. +## - `short_name`: Short name without the dash (`"v"`), empty when absent. +## - `long_name`: Long name without dashes (`"verbose"`), empty when absent. +## - `description`: Help text shown in generated usage screens. +## - `default_value`: Value applied when the option is omitted. +## - `kind`: `OptionKind` variant driving the parsing behaviour. +## - `required`: `true` when the option must be provided. +## - `choices`: Allowed values, empty when unrestricted. +## - `env_var`: Environment variable consulted when the option is omitted. pub struct CliOption - opt_short: string, - opt_long: string, - opt_desc: string, - opt_default: string, - opt_is_flag: int, - opt_is_multi: int, - opt_required: int, - opt_choices: array + short_name: string, + long_name: string, + description: string, + default_value: string, + kind: int, + required: bool, + choices: array, + env_var: string end -## Positional argument definition. -## -## Represents an ordered positional argument accepted by an application or command. +## Positional argument specification. ## ## ### Fields -## - `arg_name`: Positional argument name (e.g. `"filename"`). -## - `arg_desc`: Description shown in help screens. -## - `arg_required`: `1` if argument is mandatory; `0` if optional. -## - `arg_default`: Fallback default value if omitted. -## - `arg_variadic`: `1` if captures all remaining positional arguments into an array. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in generated usage screens. +## - `required`: `true` when the argument must be provided. +## - `default_value`: Value applied when the argument is omitted. +## - `variadic`: `true` when the argument captures the remaining values. pub struct CliArgument - arg_name: string, - arg_desc: string, - arg_required: int, - arg_default: string, - arg_variadic: int + name: string, + description: string, + required: bool, + default_value: string, + variadic: bool end -## Subcommand specification. -## -## Encapsulates a command name, aliases, description, command-scoped options, -## positional arguments, and nested subcommands. +## Command specification. ## ## ### Fields -## - `cmd_name`: Command identifier (e.g. `"build"`). -## - `cmd_aliases`: Array of shorthand command aliases (e.g. `["b"]`). -## - `cmd_desc`: Description of the command. -## - `cmd_options`: Array of `CliOption` instances specific to this command. -## - `cmd_arguments`: Array of `CliArgument` instances specific to this command. -## - `cmd_subcommands`: Array of nested child `CliCommand` instances. +## - `name`: Primary command name (`"build"`). +## - `aliases`: Alternative names accepted for this command. +## - `description`: Help text shown in generated usage screens. +## - `options`: Command-scoped option specifications. +## - `arguments`: Command-scoped positional argument specifications. +## - `subcommands`: Nested command specifications. pub struct CliCommand - cmd_name: string, - cmd_aliases: array, - cmd_desc: string, - cmd_options: array, - cmd_arguments: array, - cmd_subcommands: array + name: string, + aliases: array, + description: string, + options: array, + arguments: array, + subcommands: array end -## Top-level CLI application definition. -## -## Represents the root application holding global options, arguments, commands, -## and metadata such as version and author. +## Application specification. ## ## ### Fields -## - `app_name`: Application executable name. -## - `app_desc`: Application description summary. -## - `app_version`: Semantic version string (e.g. `"1.0.0"`). -## - `app_author`: Author information string. -## - `app_options`: Array of global `CliOption` instances. -## - `app_arguments`: Array of top-level positional `CliArgument` instances. -## - `app_commands`: Array of registered `CliCommand` instances. +## - `name`: Executable name shown in usage screens. +## - `description`: One-line summary shown in usage screens. +## - `version`: Version string shown by the version banner. +## - `author`: Author string kept for packaging metadata. +## - `options`: Global option specifications. +## - `arguments`: Global positional argument specifications. +## - `commands`: Registered top-level commands. pub struct CliApp - app_name: string, - app_desc: string, - app_version: string, - app_author: string, - app_options: array, - app_arguments: array, - app_commands: array + name: string, + description: string, + version: string, + author: string, + options: array, + arguments: array, + commands: array end -## Parsed execution context resulting from command-line evaluation. -## -## Contains resolved flags, options, positional arguments, active command name, -## and parsing error diagnostics. +## Parsed execution context produced by the parsing engine. ## ## ### Fields -## - `ctx_app`: Reference to the originating `CliApp`. -## - `ctx_cmd_name`: Name of active matched command or empty string. -## - `ctx_flags`: Map of flag name to boolean indicator (`1` or `0`). -## - `ctx_options`: Map of option name to parsed string value. -## - `ctx_array_opts`: Map of option name to array of parsed string values. -## - `ctx_args`: Array of positional argument strings. -## - `ctx_arg_map`: Map of named argument to parsed string value. -## - `ctx_errors`: Array of parsing and validation error messages. -## - `ctx_help_req`: `1` if `-h` or `--help` was requested. -## - `ctx_version_req`: `1` if `-V` or `--version` was requested. -## - `ctx_raw_args`: Raw argument array provided to the parser. +## - `app`: The application specification that produced this context. +## - `command_name`: Name of the matched command, empty for the root. +## - `command_path`: Space separated path of the matched commands. +## - `flags`: Flag name to boolean state. +## - `options`: Option name to resolved value. +## - `multi_options`: Option name to collected value array. +## - `provided`: Names of the flags and options supplied on the command line. +## - `positional`: Raw positional values in order. +## - `named`: Argument name to bound value. +## - `errors`: Parsing and validation diagnostics. +## - `help_requested`: `true` when `-h` or `--help` was supplied. +## - `version_requested`: `true` when `-V` or `--version` was supplied. +## - `raw_args`: The argument array the context was parsed from. pub struct CliContext - ctx_app: CliApp, - ctx_cmd_name: string, - ctx_flags: map, - ctx_options: map, - ctx_array_opts: map, - ctx_args: array, - ctx_arg_map: map, - ctx_errors: array, - ctx_help_req: int, - ctx_version_req: int, - ctx_raw_args: array + app: CliApp, + command_name: string, + command_path: string, + flags: map, + options: map, + multi_options: map, + provided: map, + positional: array, + named: map, + errors: array, + help_requested: bool, + version_requested: bool, + raw_args: array +end + +## Structured parse error raised by the strict parsing slice. +## +## ### Fields +## - `code`: `ErrorCode` variant describing the failure category. +## - `subject`: Option, argument, or command the failure refers to. +## - `message`: Human-readable explanation. +pub struct CliError + code: int, + subject: string, + message: string +end + +## Outcome of a strict parse, carrying either a context or an error. +## +## ### Fields +## - `ok`: `true` when the parse succeeded. +## - `context`: The parsed context, empty on failure. +## - `error`: Diagnostic message, empty on success. +pub struct CliResult + ok: bool, + context: CliContext, + error: string +end + +# ─────────────────────────────────────────────────────────────── +# Factories +# ─────────────────────────────────────────────────────────────── + +## Creates an option specification. +## +## ### Parameters +## - `short_name`: Short name without the dash, empty when absent. +## - `long_name`: Long name without dashes, empty when absent. +## - `description`: Help text shown in usage screens. +## - `default_value`: Value applied when the option is omitted. +## - `kind`: `OptionKind` variant. +## - `required`: `true` when the option must be provided. +## - `choices`: Allowed values, empty when unrestricted. +## - `env_var`: Environment variable consulted when the option is omitted. +## +## ### Returns +## A populated `CliOption` instance. +pub function make_option(short_name: string, long_name: string, description: string, default_value: string, kind: int, required: bool, choices: array, env_var: string) -> CliOption + return CliOption { + short_name: short_name, + long_name: long_name, + description: description, + default_value: default_value, + kind: kind, + required: required, + choices: choices, + env_var: env_var + } +end + +## Creates a positional argument specification. +## +## ### Parameters +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. +## - `required`: `true` when the argument must be provided. +## - `default_value`: Value applied when the argument is omitted. +## - `variadic`: `true` when the argument captures the remaining values. +## +## ### Returns +## A populated `CliArgument` instance. +pub function make_argument(name: string, description: string, required: bool, default_value: string, variadic: bool) -> CliArgument + return CliArgument { + name: name, + description: description, + required: required, + default_value: default_value, + variadic: variadic + } +end + +## Creates a command specification. +## +## ### Parameters +## - `name`: Primary command name. +## - `description`: Help text shown in usage screens. +## - `aliases`: Alternative names accepted for this command. +## +## ### Returns +## A populated `CliCommand` instance. +pub function make_command(name: string, description: string, aliases: array) -> CliCommand + return CliCommand { + name: name, + aliases: aliases, + description: description, + options: [], + arguments: [], + subcommands: [] + } +end + +## Creates an application specification. +## +## ### Parameters +## - `name`: Executable name shown in usage screens. +## - `description`: One-line summary shown in usage screens. +## - `version`: Version string shown by the version banner. +## +## ### Returns +## A populated `CliApp` instance. +pub function make_app(name: string, description: string, version: string) -> CliApp + return CliApp { + name: name, + description: description, + version: version, + author: "", + options: [], + arguments: [], + commands: [] + } +end + +## Creates a structured parse error. +## +## ### Parameters +## - `code`: `ErrorCode` variant describing the failure category. +## - `subject`: Option, argument, or command the failure refers to. +## - `message`: Human-readable explanation. +## +## ### Returns +## A populated `CliError` instance. +## +## ### Examples +## ```alya +## let err = make_error(ErrorCode.MissingValue, "output", "Missing value for option: --output") +## ``` +pub function make_error(code: int, subject: string, message: string) -> CliError + return CliError { + code: code, + subject: subject, + message: message + } +end + +## Formats a structured parse error into a diagnostic string. +## +## ### Parameters +## - `err`: The `CliError` instance to render. +## +## ### Returns +## A diagnostic string such as `"CliError(output): Missing value for option: --output"`. +## +## ### Examples +## ```alya +## say format_error(err) +## ``` +pub function format_error(err: CliError) -> string + return "CliError(" + err.subject + "): " + err.message +end + +## Formats a caught error value into a readable diagnostic string. +## +## Dispatches on the error shape with `when`: structured `CliError` values +## render their subject and message, anything else renders generically. +## +## ### Parameters +## - `err`: Value captured by a `catch err` clause. +## +## ### Returns +## A diagnostic string. +## +## ### Examples +## ```alya +## try +## let ctx = parse_strict(app, args()) +## catch err +## say format_caught_error(err) +## end +## ``` +pub function format_caught_error(err) -> string + return when err + is CliError => format_error(err) + else => "error: " + str(err) + end end diff --git a/tests/test_basic.alya b/tests/test_basic.alya index f8b4861..d9e3135 100644 --- a/tests/test_basic.alya +++ b/tests/test_basic.alya @@ -1,68 +1,123 @@ -## Test suite for CLI basic features +## Test suite for the cli package: application setup, flags, options, and positional binding ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) + import "std/test" import "../src/lib.alya" as cli function main() - test_suite("CLI Basic Functionality Tests") + test_suite("CLI Core Tests") let r = runner_new() - # 1. App creation and metadata - let app = cli::new("test-cli", "A modern test CLI", "1.5.0") - runner_assert_str_eq(r, app.app_name, "test-cli", "app name is test-cli") - runner_assert_str_eq(r, app.app_version, "1.5.0", "app version is 1.5.0") + # ═══════════════════════════════════════════════════════════ + # 1. Application construction + # ═══════════════════════════════════════════════════════════ + let app = cli::new_app("test-cli", "A modern test CLI", "1.5.0") + runner_assert_str_eq(r, app.name, "test-cli", "application name is test-cli") + runner_assert_str_eq(r, app.version, "1.5.0", "application version is 1.5.0") + runner_assert_str_eq(r, app.description, "A modern test CLI", "application description is kept") + runner_assert_eq(r, len(app.options), 0, "a fresh application declares no options") - # 2. Adding flags and options - cli::app_add_flag(app, "-v, --verbose", "Enable verbose mode") - cli::app_add_flag(app, "-d, --debug", "Enable debug mode") - cli::app_add_option(app, "-o, --output", "dist", "Output directory") - cli::app_add_option(app, "-e, --env", "dev", "Environment") - cli::app_add_argument(app, "input", "Input file") + # ═══════════════════════════════════════════════════════════ + # 2. Fluent registration + # ═══════════════════════════════════════════════════════════ + app.flag("-v, --verbose", "Enable verbose mode") + app.option("-o, --output", "dist", "Output directory") + app.argument("input", "Input file") + runner_assert_eq(r, len(app.options), 2, "two options were registered") - # 3. Parsing with long options and flags - let args1 = ["--verbose", "--output=build", "main.alya"] - let ctx1 = cli::parse(app, args1) + # ═══════════════════════════════════════════════════════════ + # 3. Long options, inline values, and positional binding + # ═══════════════════════════════════════════════════════════ + let ctx = app.parse(["--verbose", "--output=build", "main.alya"]) + runner_assert_eq(r, ctx.get_flag("verbose"), true, "long flag is enabled") + runner_assert_eq(r, ctx.get_flag("v"), true, "short alias of the flag is enabled") + runner_assert_eq(r, ctx.get_flag("quiet"), false, "undeclared flag is disabled") + runner_assert_str_eq(r, ctx.get_option("output"), "build", "inline option value is parsed") + runner_assert_str_eq(r, ctx.get_option("o"), "build", "short alias of the option is parsed") + runner_assert_str_eq(r, ctx.get_arg_named("input"), "main.alya", "named argument is bound") + runner_assert_str_eq(r, ctx.get_arg(0), "main.alya", "positional value is bound by index") + runner_assert_eq(r, len(ctx.get_args()), 1, "one positional value was collected") + runner_assert_eq(r, ctx.has_errors(), false, "valid input produces no diagnostics") + runner_assert_eq(r, ctx.has("verbose"), true, "ctx.has reports the provided flag") + runner_assert_eq(r, ctx.has("output"), true, "ctx.has reports the provided option") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "verbose"), true, "flag verbose is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "v"), true, "short flag v is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "debug"), false, "unspecified flag debug is 0") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "output"), "build", "output is build") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "o"), "build", "short opt o is build") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "env"), "dev", "default env is dev") - runner_assert_eq(r, len(cli::ctx_get_args(ctx1)), 1, "positional args count is 1") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx1, "input"), "main.alya", "arg input is main.alya") - runner_assert_str_eq(r, cli::ctx_get_arg(ctx1, 0), "main.alya", "arg index 0 is main.alya") + # ═══════════════════════════════════════════════════════════ + # 4. Short options with separated values + # ═══════════════════════════════════════════════════════════ + let ctx2 = app.parse(["-v", "-o", "bin/out", "server.alya"]) + runner_assert_eq(r, ctx2.get_flag("verbose"), true, "short flag -v enables verbose") + runner_assert_str_eq(r, ctx2.get_option("output"), "bin/out", "short option -o sets the value") + runner_assert_str_eq(r, ctx2.get_arg_named("input"), "server.alya", "positional binding still applies") - # 4. Parsing with short flags and space-separated option values - let args2 = ["-v", "-o", "bin/out", "-e", "prod", "server.alya"] - let ctx2 = cli::parse(app, args2) + # ═══════════════════════════════════════════════════════════ + # 5. Defaults are applied for omitted options + # ═══════════════════════════════════════════════════════════ + let ctx3 = app.parse([]) + runner_assert_str_eq(r, ctx3.get_option("output"), "dist", "omitted option falls back to its default") + runner_assert_eq(r, ctx3.get_flag("verbose"), false, "omitted flag stays disabled") + runner_assert_eq(r, ctx3.has("output"), false, "ctx.has ignores options left at their default") - runner_assert_eq(r, cli::ctx_get_flag(ctx2, "verbose"), true, "short -v enables verbose") - runner_assert_str_eq(r, cli::ctx_get_option(ctx2, "output"), "bin/out", "short -o sets output") - runner_assert_str_eq(r, cli::ctx_get_option(ctx2, "env"), "prod", "short -e sets env") - runner_assert_str_eq(r, cli::ctx_get_arg(ctx2, 0), "server.alya", "arg 0 is server.alya") + # ═══════════════════════════════════════════════════════════ + # 6. The `--` terminator stops option parsing + # ═══════════════════════════════════════════════════════════ + let ctx4 = app.parse(["-v", "--", "--not-a-flag", "-o", "file.txt"]) + runner_assert_eq(r, ctx4.get_flag("verbose"), true, "flag before -- is parsed") + runner_assert_eq(r, len(ctx4.get_args()), 3, "values after -- are kept as positionals") + runner_assert_str_eq(r, ctx4.get_arg(0), "--not-a-flag", "first raw value is preserved") + runner_assert_str_eq(r, ctx4.get_arg(1), "-o", "second raw value is preserved") + runner_assert_str_eq(r, ctx4.get_arg(2), "file.txt", "third raw value is preserved") - # 5. Double dash terminator -- - let args3 = ["-v", "--", "--not-a-flag", "-o", "file.txt"] - let ctx3 = cli::parse(app, args3) + # ═══════════════════════════════════════════════════════════ + # 7. Optional arguments with defaults and variadic arguments + # ═══════════════════════════════════════════════════════════ + let app2 = cli::new_app("multi-arg", "Argument binding demo", "1.0.0") + app2.argument("mode", "Processing mode", false, "fast") + app2.variadic_argument("inputs", "Input files") + let ctx5 = app2.parse(["a.txt", "b.txt"]) + runner_assert_str_eq(r, ctx5.get_arg_named("mode"), "a.txt", "the first positional fills the first argument") + runner_assert_eq(r, len(ctx5.get_args()), 2, "both positionals were collected") + runner_assert_eq(r, ctx5.has_errors(), false, "variadic argument accepts the remaining values") - runner_assert_eq(r, cli::ctx_get_flag(ctx3, "verbose"), true, "verbose flag before -- parsed") - runner_assert_eq(r, len(cli::ctx_get_args(ctx3)), 3, "positional args after -- count is 3") - runner_assert_str_eq(r, cli::ctx_get_arg(ctx3, 0), "--not-a-flag", "first raw arg preserved") - runner_assert_str_eq(r, cli::ctx_get_arg(ctx3, 1), "-o", "second raw arg preserved") - runner_assert_str_eq(r, cli::ctx_get_arg(ctx3, 2), "file.txt", "third raw arg preserved") + let ctx6 = app2.parse([]) + runner_assert_str_eq(r, ctx6.get_arg_named("mode"), "fast", "the argument default is applied") + runner_assert_eq(r, ctx6.has_errors(), false, "an omitted optional argument is not an error") - # 6. v0.0.18 Struct Methods and Enums - runner_assert_eq(r, cli::OptionArity.Flag, 0, "OptionArity.Flag is 0") - runner_assert_eq(r, cli::CommandType.Root, 0, "CommandType.Root is 0") + # ═══════════════════════════════════════════════════════════ + # 8. Typed accessors + # ═══════════════════════════════════════════════════════════ + let app3 = cli::new_app("typed", "Typed accessor demo", "1.0.0") + app3.option("--port", "8080", "Service port") + app3.option("--dry-run", "false", "Dry run switch") + let ctx7 = app3.parse(["--port=9090", "--dry-run=yes"]) + runner_assert_eq(r, ctx7.get_int("port", 0), 9090, "numeric option converts to an integer") + runner_assert_eq(r, ctx7.get_int("port", 1), 9090, "numeric option ignores the fallback") + runner_assert_eq(r, ctx7.get_bool("dry-run"), true, "textual option converts to a boolean") + let ctx8 = app3.parse(["--port=not-a-number"]) + runner_assert_eq(r, ctx8.get_int("port", 42), 42, "a non-numeric option falls back to the default") - let app4 = cli::new("fluent-cli", "Fluent API test", "2.0.0") - app4.flag("-x, --execute", "Execute immediately") - app4.option("-t, --tag", "latest", "Docker tag") + # ═══════════════════════════════════════════════════════════ + # 9. Functional registration API + # ═══════════════════════════════════════════════════════════ + let app4 = cli::new_app("functional", "Functional API demo", "1.0.0") + cli::add_flag(app4, "-d, --debug", "Debug mode") + cli::add_option(app4, "-c, --config", "app.toml", "Configuration file") + cli::add_required_option(app4, "--token", "Access token") + cli::add_choice_option(app4, "--format", "json", ["json", "yaml"], "Output format") + cli::add_multi_option(app4, "-I, --include", "Include directories") + runner_assert_eq(r, len(app4.options), 5, "five options were registered functionally") + let ctx9 = app4.parse(["--token=abc", "--format=yaml"]) + runner_assert_str_eq(r, ctx9.get_option("config"), "app.toml", "functional registration keeps the default") + runner_assert_str_eq(r, ctx9.get_option("format"), "yaml", "functional registration parses the choice") + runner_assert_eq(r, ctx9.has_errors(), false, "the required option was supplied") - let ctx4 = app4.run(["-x", "--tag=v1.2"]) - runner_assert_eq(r, ctx4.flag("execute"), true, "ctx4.flag struct method works") - runner_assert_str_eq(r, ctx4.option("tag"), "v1.2", "ctx4.option struct method works") + # ═══════════════════════════════════════════════════════════ + # 10. Metadata mutators + # ═══════════════════════════════════════════════════════════ + let app5 = cli::new_app("meta", "Metadata demo", "1.0.0") + app5.set_author("Alya Language Contributors") + app5.set_version("2.0.0") + runner_assert_str_eq(r, app5.author, "Alya Language Contributors", "the author is stored") + runner_assert_str_eq(r, app5.version, "2.0.0", "the version is updated") let failed = runner_summary(r) if failed > 0 diff --git a/tests/test_commands.alya b/tests/test_commands.alya index 7054756..a3ce22e 100644 --- a/tests/test_commands.alya +++ b/tests/test_commands.alya @@ -1,5 +1,6 @@ -## Test suite for subcommands, command aliases, and command-specific options +## Test suite for the cli package: subcommands, aliases, nesting, and command-scoped options ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) + import "std/test" import "../src/lib.alya" as cli @@ -7,55 +8,85 @@ function main() test_suite("CLI Subcommands & Routing Tests") let r = runner_new() - let app = cli::new("git-tool", "A modern VCS tool", "3.0.0") - cli::app_add_flag(app, "-v, --verbose", "Global verbose flag") + let app = cli::new_app("git-tool", "A modern VCS tool", "3.0.0") + app.flag("-v, --verbose", "Global verbose flag") # Command 1: build (alias: b) - let build_cmd = cli::command("build", "Compile target project", ["b"]) - cli::cmd_add_flag(build_cmd, "-r, --release", "Build in release mode") - cli::cmd_add_option(build_cmd, "-t, --target", "x64", "Target architecture") - cli::cmd_add_argument(build_cmd, "source", "Main source entry") - cli::app_add_command(app, build_cmd) + let build = cli::new_command("build", "Compile target project", ["b"]) + build.flag("-r, --release", "Build in release mode") + build.option("-t, --target", "x64", "Target architecture") + build.argument("source", "Main source entry") + app.command(build) # Command 2: test (alias: t) - let test_cmd = cli::command("test", "Run test suite", ["t"]) - cli::cmd_add_flag(test_cmd, "--watch", "Watch file changes") - cli::app_add_command(app, test_cmd) - - # Command 3: remote with nested subcommand: add - let remote_cmd = cli::command("remote", "Manage remotes") - let remote_add = cli::command("add", "Add a new remote") - cli::cmd_add_argument(remote_add, "name", "Remote name") - cli::cmd_add_argument(remote_add, "url", "Remote URL") - cli::cmd_add_subcommand(remote_cmd, remote_add) - cli::app_add_command(app, remote_cmd) + let test_cmd = cli::new_command("test", "Run test suite", ["t"]) + test_cmd.flag("--watch", "Watch file changes") + app.command(test_cmd) - # 1. Matching command by primary name - let ctx1 = cli::parse(app, ["build", "-r", "--target=arm64", "app.alya"]) - runner_assert_str_eq(r, cli::ctx_get_command(ctx1), "build", "matched build command") - runner_assert_eq(r, cli::ctx_has_command(ctx1, "build"), true, "ctx_has_command build is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "release"), true, "command flag release is 1") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "target"), "arm64", "command option target is arm64") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx1, "source"), "app.alya", "command arg source is app.alya") + # Command 3: remote with the nested subcommand add + let remote = cli::new_command("remote", "Manage remotes") + let remote_add = cli::new_command("add", "Add a new remote") + remote_add.argument("name", "Remote name") + remote_add.argument("url", "Remote URL") + remote.subcommand(remote_add) + app.command(remote) + # ═══════════════════════════════════════════════════════════ + # 1. Matching a command by its primary name + # ═══════════════════════════════════════════════════════════ + let ctx1 = app.parse(["build", "-r", "--target=arm64", "app.alya"]) + runner_assert_str_eq(r, ctx1.get_command(), "build", "the build command is matched") + runner_assert_eq(r, ctx1.has_command("build"), true, "has_command reports the active command") + runner_assert_str_eq(r, ctx1.get_command_path(), "build", "the command path is recorded") + runner_assert_eq(r, ctx1.get_flag("release"), true, "the command-scoped flag is parsed") + runner_assert_str_eq(r, ctx1.get_option("target"), "arm64", "the command-scoped option is parsed") + runner_assert_str_eq(r, ctx1.get_arg_named("source"), "app.alya", "the command argument is bound") + runner_assert_eq(r, ctx1.has_errors(), false, "a valid command invocation produces no diagnostics") - # 2. Matching command by alias: 'b' instead of 'build' - let ctx2 = cli::parse(app, ["b", "-t", "wasm", "web.alya"]) - runner_assert_str_eq(r, cli::ctx_get_command(ctx2), "build", "alias b maps to command build") - runner_assert_str_eq(r, cli::ctx_get_option(ctx2, "target"), "wasm", "target via alias is wasm") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx2, "source"), "web.alya", "source via alias is web.alya") + # ═══════════════════════════════════════════════════════════ + # 2. Matching a command by its alias + # ═══════════════════════════════════════════════════════════ + let ctx2 = app.parse(["b", "-t", "wasm", "web.alya"]) + runner_assert_str_eq(r, ctx2.get_command(), "build", "the alias resolves to the command") + runner_assert_str_eq(r, ctx2.get_option("target"), "wasm", "command options work through the alias") + runner_assert_str_eq(r, ctx2.get_arg_named("source"), "web.alya", "command arguments work through the alias") + # ═══════════════════════════════════════════════════════════ # 3. Global flags combined with subcommands - let ctx3 = cli::parse(app, ["--verbose", "test", "--watch"]) - runner_assert_eq(r, cli::ctx_get_flag(ctx3, "verbose"), true, "global verbose flag parsed before command") - runner_assert_str_eq(r, cli::ctx_get_command(ctx3), "test", "matched test command") - runner_assert_eq(r, cli::ctx_get_flag(ctx3, "watch"), true, "test specific flag watch is 1") + # ═══════════════════════════════════════════════════════════ + let ctx3 = app.parse(["--verbose", "test", "--watch"]) + runner_assert_eq(r, ctx3.get_flag("verbose"), true, "the global flag is parsed before the command") + runner_assert_str_eq(r, ctx3.get_command(), "test", "the test command is matched") + runner_assert_eq(r, ctx3.get_flag("watch"), true, "the command-scoped flag is parsed") + + # ═══════════════════════════════════════════════════════════ + # 4. Global options keep their value across a command + # ═══════════════════════════════════════════════════════════ + let app2 = cli::new_app("globals", "Global option demo", "1.0.0") + app2.option("--profile", "default", "Build profile") + let deploy = cli::new_command("deploy", "Deploy the application") + app2.command(deploy) + let ctx4 = app2.parse(["--profile=release", "deploy"]) + runner_assert_str_eq(r, ctx4.get_option("profile"), "release", "the global option survives the command switch") + runner_assert_str_eq(r, ctx4.get_command(), "deploy", "the command is matched") + + # ═══════════════════════════════════════════════════════════ + # 5. Nested subcommands + # ═══════════════════════════════════════════════════════════ + let ctx5 = app.parse(["remote", "add", "origin", "https://github.com/alya-lang/cli.git"]) + runner_assert_str_eq(r, ctx5.get_command_path(), "remote add", "the nested command path is recorded") + runner_assert_eq(r, len(ctx5.get_args()), 2, "both positional values are collected") + runner_assert_str_eq(r, ctx5.get_arg_named("name"), "origin", "the first nested argument is bound") + runner_assert_str_eq(r, ctx5.get_arg_named("url"), "https://github.com/alya-lang/cli.git", "the second nested argument is bound") - # 4. Nested subcommands: remote add origin https://... - let ctx4 = cli::parse(app, ["remote", "add", "origin", "https://github.com/alya-lang/cli.git"]) - runner_assert_str_eq(r, cli::ctx_get_command(ctx4), "remote add", "nested command remote add matched") - runner_assert_eq(r, len(cli::ctx_get_args(ctx4)), 2, "2 positional arguments for remote add") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx4, "name"), "origin", "remote name is origin") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx4, "url"), "https://github.com/alya-lang/cli.git", "remote url matches") + # ═══════════════════════════════════════════════════════════ + # 6. A command name is not a positional value + # ═══════════════════════════════════════════════════════════ + let app3 = cli::new_app("strict-commands", "Command routing demo", "1.0.0") + let only = cli::new_command("run", "Run the task") + app3.command(only) + let ctx6 = app3.parse(["run"]) + runner_assert_eq(r, len(ctx6.get_args()), 0, "the command name is not bound as a positional") + runner_assert_str_eq(r, ctx6.get_command(), "run", "the command is still matched") let failed = runner_summary(r) if failed > 0 diff --git a/tests/test_flags.alya b/tests/test_flags.alya new file mode 100644 index 0000000..c2bd41e --- /dev/null +++ b/tests/test_flags.alya @@ -0,0 +1,94 @@ +## Test suite for the cli package: clustered flags, negated flags, attached values, and repeatable options +## Part of the Alya Language package ecosystem (https://github.com/alya-lang) + +import "std/test" +import "../src/lib.alya" as cli + +function main() + test_suite("CLI Flags & Options Tests") + let r = runner_new() + + let app = cli::new_app("adv-app", "Advanced options test", "1.0.0") + app.flag("-a, --all", "All items") + app.flag("-b, --buffer", "Buffer mode") + app.flag("-c, --clean", "Clean build") + app.flag("--color", "Colorized output") + app.option("-o, --output", "dist", "Output file") + app.multi_option("-I, --include", "Include directories") + + # ═══════════════════════════════════════════════════════════ + # 1. Clustered short flags: -abc + # ═══════════════════════════════════════════════════════════ + let ctx1 = app.parse(["-abc"]) + runner_assert_eq(r, ctx1.get_flag("a"), true, "clustered flag -a is enabled") + runner_assert_eq(r, ctx1.get_flag("all"), true, "clustered flag --all is enabled") + runner_assert_eq(r, ctx1.get_flag("b"), true, "clustered flag -b is enabled") + runner_assert_eq(r, ctx1.get_flag("buffer"), true, "clustered flag --buffer is enabled") + runner_assert_eq(r, ctx1.get_flag("c"), true, "clustered flag -c is enabled") + runner_assert_eq(r, ctx1.get_flag("clean"), true, "clustered flag --clean is enabled") + + # ═══════════════════════════════════════════════════════════ + # 2. Attached short option value: -oCustomPath.tar + # ═══════════════════════════════════════════════════════════ + let ctx2 = app.parse(["-oMyCustomPath.tar"]) + runner_assert_str_eq(r, ctx2.get_option("o"), "MyCustomPath.tar", "attached short option value is parsed") + runner_assert_str_eq(r, ctx2.get_option("output"), "MyCustomPath.tar", "attached value is visible under the long name") + + # ═══════════════════════════════════════════════════════════ + # 3. Negated flag: --no-color + # ═══════════════════════════════════════════════════════════ + let ctx3 = app.parse(["--no-color"]) + runner_assert_eq(r, ctx3.get_flag("color"), false, "--no-color disables the flag") + runner_assert_eq(r, ctx3.get_flag("no-color"), true, "the negated name is recorded as well") + + # ═══════════════════════════════════════════════════════════ + # 4. Repeatable option: -I a --include=b -I c + # ═══════════════════════════════════════════════════════════ + let ctx4 = app.parse(["-I", "src/include", "--include=vendor/include", "-I", "third_party"]) + let includes = ctx4.get_all("include") + runner_assert_eq(r, len(includes), 3, "every occurrence of the option is collected") + runner_assert_str_eq(r, str(includes[0]), "src/include", "the first include directory is kept") + runner_assert_str_eq(r, str(includes[1]), "vendor/include", "the second include directory is kept") + runner_assert_str_eq(r, str(includes[2]), "third_party", "the third include directory is kept") + runner_assert_eq(r, len(ctx4.get_all("I")), 3, "the short alias shares the same bucket") + + # ═══════════════════════════════════════════════════════════ + # 5. The automatic help and version switches + # ═══════════════════════════════════════════════════════════ + let ctx5 = app.parse(["--help"]) + runner_assert_eq(r, ctx5.help_requested(), true, "help is requested through --help") + runner_assert_eq(r, ctx5.get_flag("help"), true, "the help flag is recorded") + + let ctx6 = app.parse(["-h"]) + runner_assert_eq(r, ctx6.help_requested(), true, "help is requested through -h") + + let ctx7 = app.parse(["--version"]) + runner_assert_eq(r, ctx7.version_requested(), true, "the version is requested through --version") + runner_assert_eq(r, ctx7.get_flag("version"), true, "the version flag is recorded") + + let ctx8 = app.parse(["-V"]) + runner_assert_eq(r, ctx8.version_requested(), true, "the version is requested through -V") + + # ═══════════════════════════════════════════════════════════ + # 6. A value option without a value + # ═══════════════════════════════════════════════════════════ + let ctx9 = app.parse(["--output"]) + runner_assert_eq(r, ctx9.has_errors(), true, "a missing option value is reported") + runner_assert_eq(r, len(ctx9.get_errors()), 1, "exactly one diagnostic is produced") + runner_assert_str_eq(r, ctx9.get_errors()[0].message, "Missing value for option: --output", "the diagnostic names the option") + + # ═══════════════════════════════════════════════════════════ + # 7. Flags and options keep independent namespaces + # ═══════════════════════════════════════════════════════════ + let ctx10 = app.parse(["--all", "--output=out"]) + runner_assert_eq(r, ctx10.get_flag("all"), true, "the flag is enabled") + runner_assert_str_eq(r, ctx10.get_option("output"), "out", "the option keeps its own value") + runner_assert_eq(r, ctx10.get_flag("output"), false, "an option is not reported as a flag") + + let failed = runner_summary(r) + if failed > 0 + exit(1) + end +end + +main() diff --git a/tests/test_flags_advanced.alya b/tests/test_flags_advanced.alya deleted file mode 100644 index f1177fb..0000000 --- a/tests/test_flags_advanced.alya +++ /dev/null @@ -1,67 +0,0 @@ -## Test suite for advanced flags, clustered flags, array options, and inverted flags -## Part of the Alya Language package ecosystem (https://github.com/alya-lang) -import "std/test" -import "../src/lib.alya" as cli - -function main() - test_suite("CLI Advanced Flags & Options Tests") - let r = runner_new() - - let app = cli::new("adv-app", "Advanced options test", "1.0.0") - cli::app_add_flag(app, "-a, --all", "All items") - cli::app_add_flag(app, "-b, --buffer", "Buffer mode") - cli::app_add_flag(app, "-c, --clean", "Clean build") - cli::app_add_flag(app, "--color", "Colorized output") - cli::app_add_option(app, "-o, --output", "dist", "Output file") - cli::app_add_multi_option(app, "-I, --include", "Include directories") - - # 1. Clustered short flags: -abc - let ctx1 = cli::parse(app, ["-abc"]) - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "a"), true, "clustered flag -a is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "all"), true, "clustered flag --all is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "b"), true, "clustered flag -b is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "buffer"), true, "clustered flag --buffer is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "c"), true, "clustered flag -c is 1") - runner_assert_eq(r, cli::ctx_get_flag(ctx1, "clean"), true, "clustered flag --clean is 1") - - # 2. Attached option value: -oMyCustomPath.tar - let ctx2 = cli::parse(app, ["-oMyCustomPath.tar"]) - runner_assert_str_eq(r, cli::ctx_get_option(ctx2, "o"), "MyCustomPath.tar", "attached short option o") - runner_assert_str_eq(r, cli::ctx_get_option(ctx2, "output"), "MyCustomPath.tar", "attached short option output") - - # 3. Inverted flag: --no-color - let ctx3 = cli::parse(app, ["--no-color"]) - runner_assert_eq(r, cli::ctx_get_flag(ctx3, "color"), false, "--no-color sets color to 0") - runner_assert_eq(r, cli::ctx_get_flag(ctx3, "no-color"), true, "--no-color flag is 1") - - # 4. Multi-value / array options: -I dir1 --include=dir2 -I dir3 - let ctx4 = cli::parse(app, ["-I", "src/include", "--include=vendor/include", "-I", "third_party"]) - let incs = cli::ctx_get_all(ctx4, "include") - runner_assert_eq(r, len(incs), 3, "array options collected 3 values") - runner_assert_str_eq(r, str_from_ptr(incs[0]), "src/include", "first include dir") - runner_assert_str_eq(r, str_from_ptr(incs[1]), "vendor/include", "second include dir") - runner_assert_str_eq(r, str_from_ptr(incs[2]), "third_party", "third include dir") - - # 5. Automated -h and --help - let ctx5 = cli::parse(app, ["--help"]) - runner_assert_eq(r, cli::ctx_help_requested(ctx5), true, "help requested via --help") - runner_assert_eq(r, cli::ctx_get_flag(ctx5, "help"), true, "help flag is 1") - - let ctx6 = cli::parse(app, ["-h"]) - runner_assert_eq(r, cli::ctx_help_requested(ctx6), true, "help requested via -h") - - # 6. Automated -V and --version - let ctx7 = cli::parse(app, ["--version"]) - runner_assert_eq(r, cli::ctx_version_requested(ctx7), true, "version requested via --version") - runner_assert_eq(r, cli::ctx_get_flag(ctx7, "version"), true, "version flag is 1") - - let ctx8 = cli::parse(app, ["-V"]) - runner_assert_eq(r, cli::ctx_version_requested(ctx8), true, "version requested via -V") - - let failed = runner_summary(r) - if failed > 0 - exit(1) - end -end - -main() diff --git a/tests/test_formatter.alya b/tests/test_formatter.alya index e231aac..8d82555 100644 --- a/tests/test_formatter.alya +++ b/tests/test_formatter.alya @@ -1,10 +1,13 @@ -## Test suite for help text and error screen formatting +## Test suite for the cli package: usage screens, version banner, and diagnostic reports ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) +## +## The `help` feature owns the usage screen renderer. Without it there is +## nothing to format, so the file keeps a skip stub and stays green in the +## slim build. + import "std/test" import "../src/lib.alya" as cli -# Without the `help` feature there is nothing to format: register an -# empty suite so the file still compiles and passes in the slim view. @cfg(not(feature = "help")) function main() test_suite("CLI Formatter Tests (skipped without help feature)") @@ -20,46 +23,62 @@ function main() test_suite("CLI Formatter Tests") let r = runner_new() - let app = cli::new("dock-tool", "A container management CLI", "2.1.0") - cli::app_add_flag(app, "-v, --verbose", "Verbose logging output") - cli::app_add_option(app, "-H, --host", "tcp://127.0.0.1:2375", "Daemon socket host") + let app = cli::new_app("dock-tool", "A container management CLI", "2.1.0") + app.flag("-v, --verbose", "Verbose logging output") + app.option("-H, --host", "tcp://127.0.0.1:2375", "Daemon socket host") + app.argument("image", "Image to run", true) + + let run = cli::new_command("run", "Run a command in a new container", ["r"]) + run.flag("-d, --detach", "Run container in background") + run.argument("image", "Container image name", true) + run.argument("cmd", "Command to execute", false, "bash") + app.command(run) - let run_cmd = cli::command("run", "Run a command in a new container", ["r"]) - cli::cmd_add_flag(run_cmd, "-d, --detach", "Run container in background") - cli::cmd_add_argument(run_cmd, "image", "Container image name", true) - cli::cmd_add_argument(run_cmd, "cmd", "Command to execute", false, "bash") - cli::app_add_command(app, run_cmd) + # ═══════════════════════════════════════════════════════════ + # 1. The application usage screen + # ═══════════════════════════════════════════════════════════ + let help = cli::format_help(app) + runner_assert_eq(r, contains(help, "dock-tool 2.1.0 - A container management CLI"), true, "the banner carries name, version, and description") + runner_assert_eq(r, contains(help, "Usage:"), true, "the usage section is present") + runner_assert_eq(r, contains(help, "dock-tool [COMMAND] [OPTIONS] "), true, "the usage line lists the command, options, and arguments") + runner_assert_eq(r, contains(help, "Commands:"), true, "the command section is present") + runner_assert_eq(r, contains(help, "run, r"), true, "the command is listed with its alias") + runner_assert_eq(r, contains(help, "-v, --verbose"), true, "the flag is listed") + runner_assert_eq(r, contains(help, "-H, --host "), true, "the option is listed with its value placeholder") + runner_assert_eq(r, contains(help, "(default: tcp://127.0.0.1:2375)"), true, "the option default is shown") + runner_assert_eq(r, contains(help, "-h, --help"), true, "the automatic help switch is listed") + runner_assert_eq(r, contains(help, "-V, --version"), true, "the automatic version switch is listed") + runner_assert_eq(r, contains(help, "Arguments:"), true, "the argument section is present") + runner_assert_eq(r, contains(help, ""), true, "the argument is listed") + runner_assert_eq(r, contains(help, "(required)"), true, "the required marker is shown") - # 1. Top-level help formatting - let h = cli::help(app) - runner_assert_eq(r, contains(h, "Usage: dock-tool [COMMAND] [OPTIONS]"), true, "help usage line") - runner_assert_eq(r, contains(h, "A container management CLI"), true, "help description") - runner_assert_eq(r, contains(h, "Commands:"), true, "help commands header") - runner_assert_eq(r, contains(h, "run, r"), true, "help lists run with alias r") - runner_assert_eq(r, contains(h, "-v, --verbose"), true, "help lists verbose flag") - runner_assert_eq(r, contains(h, "-H, --host "), true, "help lists host option") - runner_assert_eq(r, contains(h, "(default: tcp://127.0.0.1:2375)"), true, "help shows host default") - runner_assert_eq(r, contains(h, "-h, --help"), true, "help lists automated help flag") - runner_assert_eq(r, contains(h, "-V, --version"), true, "help lists automated version flag") + # ═══════════════════════════════════════════════════════════ + # 2. The command usage screen + # ═══════════════════════════════════════════════════════════ + let command_help = cli::format_command_help(app, run) + runner_assert_eq(r, contains(command_help, "dock-tool run [OPTIONS] [cmd]"), true, "the command usage line lists its arguments") + runner_assert_eq(r, contains(command_help, "Run a command in a new container"), true, "the command description is shown") + runner_assert_eq(r, contains(command_help, "-d, --detach"), true, "the command flag is listed") + runner_assert_eq(r, contains(command_help, "(default: bash)"), true, "the argument default is shown") - # 2. Command-specific help formatting - let h_cmd = cli::help(app, run_cmd) - runner_assert_eq(r, contains(h_cmd, "Usage: dock-tool run [OPTIONS] [cmd]"), true, "command usage with args") - runner_assert_eq(r, contains(h_cmd, "Run a command in a new container"), true, "command description") - runner_assert_eq(r, contains(h_cmd, "-d, --detach"), true, "command detach flag") - runner_assert_eq(r, contains(h_cmd, ""), true, "command arguments image") - runner_assert_eq(r, contains(h_cmd, "(required)"), true, "command argument required indicator") - runner_assert_eq(r, contains(h_cmd, "(default: bash)"), true, "command argument default indicator") + # ═══════════════════════════════════════════════════════════ + # 3. The version banner + # ═══════════════════════════════════════════════════════════ + runner_assert_str_eq(r, cli::format_version(app), "dock-tool 2.1.0", "the version banner joins name and version") - # 3. Version string formatting - let ver_str = cli::cli_format_version(app) - runner_assert_str_eq(r, ver_str, "dock-tool 2.1.0", "cli_format_version produces app version") + # ═══════════════════════════════════════════════════════════ + # 4. The diagnostic report + # ═══════════════════════════════════════════════════════════ + let errors = [cli::make_error(cli::ErrorCode.MissingValue, "host", "Missing value for option: --host"), cli::make_error(cli::ErrorCode.InvalidChoice, "format", "Invalid value 'xml' for option --format. Allowed choices: [json, yaml]")] + let report = cli::format_errors(errors) + runner_assert_eq(r, contains(report, "Error: Missing value for option: --host"), true, "the first diagnostic is rendered") + runner_assert_eq(r, contains(report, "Error: Invalid value 'xml'"), true, "the second diagnostic is rendered") - # 4. Error formatting - let errs = ["Missing option: --host", "Invalid image name"] - let err_screen = cli::cli_format_errors(errs) - runner_assert_eq(r, contains(err_screen, "Error: Missing option: --host"), true, "formatted error 1") - runner_assert_eq(r, contains(err_screen, "Error: Invalid image name"), true, "formatted error 2") + # ═══════════════════════════════════════════════════════════ + # 5. The structured error formatter + # ═══════════════════════════════════════════════════════════ + let error = cli::make_error(cli::ErrorCode.MissingRequired, "config", "Missing required option: --config") + runner_assert_str_eq(r, cli::format_error(error), "CliError(config): Missing required option: --config", "the structured error renders subject and message") let failed = runner_summary(r) if failed > 0 diff --git a/tests/test_strict.alya b/tests/test_strict.alya new file mode 100644 index 0000000..6962c8c --- /dev/null +++ b/tests/test_strict.alya @@ -0,0 +1,69 @@ +## Test suite for the cli package: strict parsing and error recovery (`strict` feature) +## Part of the Alya Language package ecosystem (https://github.com/alya-lang) +## +## The strict slice adds the throwing and recovering parse entry points on +## top of the lenient engine. Without the feature the file keeps a skip +## stub, so every feature combination stays green. + +import "std/test" +import "../src/lib.alya" as cli + +@cfg(not(feature = "strict")) +function main() + test_suite("CLI Strict Parsing Tests (skipped without strict feature)") + let r = runner_new() + let failed = runner_summary(r) + if failed > 0 + exit(1) + end +end + +@cfg(feature = "strict") +function main() + test_suite("CLI Strict Parsing Tests") + let r = runner_new() + + let app = cli::new_app("strict-app", "Strict parsing demo", "1.0.0") + app.required_option("-c, --config", "Configuration file") + app.flag("-v, --verbose", "Verbose output") + + # ═══════════════════════════════════════════════════════════ + # 1. The strict parse succeeds on valid input + # ═══════════════════════════════════════════════════════════ + let ctx = cli::parse_strict(app, ["-c", "app.toml", "-v"]) + runner_assert_eq(r, ctx.get_flag("verbose"), true, "the strict parse resolves the flags") + runner_assert_str_eq(r, ctx.get_option("config"), "app.toml", "the strict parse resolves the options") + + # ═══════════════════════════════════════════════════════════ + # 2. The strict parse throws a structured error + # ═══════════════════════════════════════════════════════════ + let threw = false + try + cli::parse_strict(app, ["-v"]) + catch err + threw = true + runner_assert_eq(r, err.code, cli::ErrorCode.MissingRequired, "the thrown error carries the expected code") + runner_assert_str_eq(r, cli::format_caught_error(err), "CliError(config): Missing required option: --config", "the caught error renders subject and message") + end + runner_assert_true(r, threw, "the strict parse throws on invalid input") + + # ═══════════════════════════════════════════════════════════ + # 3. The recovering parse converts failures into a result + # ═══════════════════════════════════════════════════════════ + let failed_result = cli::try_parse(app, ["-v"]) + runner_assert_eq(r, failed_result.is_error(), true, "the recovering parse reports the failure") + runner_assert_eq(r, failed_result.is_ok(), false, "the recovering parse does not report success") + runner_assert_str_eq(r, failed_result.message(), "CliError(config): Missing required option: --config", "the result carries the diagnostic") + + let ok_result = cli::try_parse(app, ["-c", "app.toml"]) + runner_assert_eq(r, ok_result.is_ok(), true, "the recovering parse reports success on valid input") + runner_assert_eq(r, ok_result.is_error(), false, "the recovering parse does not report a failure") + runner_assert_str_eq(r, ok_result.message(), "", "a successful result carries no diagnostic") + + let failed = runner_summary(r) + if failed > 0 + exit(1) + end +end + +main() diff --git a/tests/test_validation.alya b/tests/test_validation.alya index 3b41306..d55a7f1 100644 --- a/tests/test_validation.alya +++ b/tests/test_validation.alya @@ -1,5 +1,6 @@ -## Test suite for CLI validation: required options, allowed choices, and required arguments +## Test suite for the cli package: required options, allowed choices, and unknown input ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) + import "std/test" import "../src/lib.alya" as cli @@ -7,40 +8,84 @@ function main() test_suite("CLI Validation Tests") let r = runner_new() - let app = cli::new("valid-app", "Validation testing tool", "1.0.0") - cli::app_add_required_option(app, "-c, --config", "Configuration file path") - cli::app_add_choice_option(app, "--format", "json", ["json", "yaml", "table"], "Output format") - cli::app_add_argument(app, "target", "Target to deploy", true) - - # 1. Valid invocation - let ctx1 = cli::parse(app, ["--config=app.conf", "--format=yaml", "production"]) - runner_assert_eq(r, cli::ctx_has_errors(ctx1), false, "valid args have 0 errors") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "config"), "app.conf", "config option set") - runner_assert_str_eq(r, cli::ctx_get_option(ctx1, "format"), "yaml", "format option set") - runner_assert_str_eq(r, cli::ctx_get_arg_named(ctx1, "target"), "production", "target arg set") - - # 2. Missing required option --config - let ctx2 = cli::parse(app, ["--format=json", "production"]) - runner_assert_eq(r, cli::ctx_has_errors(ctx2), true, "missing config triggers error") - let errs2 = cli::ctx_get_errors(ctx2) - runner_assert_eq(r, len(errs2), 1, "1 error for missing config") - let e0 = str_from_ptr(errs2[0]) - runner_assert_eq(r, contains(e0, "Missing required option: --config"), true, "error message contains config") - - # 3. Invalid choice for --format - let ctx3 = cli::parse(app, ["-c", "app.conf", "--format=xml", "production"]) - runner_assert_eq(r, cli::ctx_has_errors(ctx3), true, "invalid choice xml triggers error") - let errs3 = cli::ctx_get_errors(ctx3) - let e1 = str_from_ptr(errs3[0]) - runner_assert_eq(r, contains(e1, "Invalid value 'xml'"), true, "error message contains xml") - runner_assert_eq(r, contains(e1, "Allowed choices"), true, "error message specifies allowed choices") - - # 4. Missing required positional argument - let ctx4 = cli::parse(app, ["--config=test.conf"]) - runner_assert_eq(r, cli::ctx_has_errors(ctx4), true, "missing required argument triggers error") - let errs4 = cli::ctx_get_errors(ctx4) - let e2 = str_from_ptr(errs4[0]) - runner_assert_eq(r, contains(e2, "Missing required argument: "), true, "error message specifies ") + let app = cli::new_app("valid-app", "Validation testing tool", "1.0.0") + app.required_option("-c, --config", "Configuration file path") + app.choice_option("--format", "json", ["json", "yaml", "table"], "Output format") + app.argument("target", "Target to deploy", true) + + # ═══════════════════════════════════════════════════════════ + # 1. A valid invocation + # ═══════════════════════════════════════════════════════════ + let ctx1 = app.parse(["--config=app.conf", "--format=yaml", "production"]) + runner_assert_eq(r, ctx1.has_errors(), false, "a valid invocation produces no diagnostics") + runner_assert_str_eq(r, ctx1.get_option("config"), "app.conf", "the required option is parsed") + runner_assert_str_eq(r, ctx1.get_option("format"), "yaml", "the choice option is parsed") + runner_assert_str_eq(r, ctx1.get_arg_named("target"), "production", "the required argument is bound") + + # ═══════════════════════════════════════════════════════════ + # 2. A missing required option + # ═══════════════════════════════════════════════════════════ + let ctx2 = app.parse(["--format=json", "production"]) + runner_assert_eq(r, ctx2.has_errors(), true, "the missing option is reported") + runner_assert_eq(r, len(ctx2.get_errors()), 1, "exactly one diagnostic is produced") + let first = ctx2.get_errors()[0] + runner_assert_eq(r, first.code, cli::ErrorCode.MissingRequired, "the diagnostic carries the expected code") + runner_assert_str_eq(r, first.subject, "config", "the diagnostic names the option") + runner_assert_str_eq(r, first.message, "Missing required option: --config", "the diagnostic explains the problem") + + # ═══════════════════════════════════════════════════════════ + # 3. A value outside the allowed choices + # ═══════════════════════════════════════════════════════════ + let ctx3 = app.parse(["-c", "app.conf", "--format=xml", "production"]) + runner_assert_eq(r, ctx3.has_errors(), true, "an invalid choice is reported") + let choice_error = ctx3.get_errors()[0] + runner_assert_eq(r, choice_error.code, cli::ErrorCode.InvalidChoice, "the diagnostic carries the expected code") + runner_assert_eq(r, contains(choice_error.message, "Invalid value 'xml'"), true, "the diagnostic quotes the value") + runner_assert_eq(r, contains(choice_error.message, "Allowed choices"), true, "the diagnostic lists the allowed values") + + # ═══════════════════════════════════════════════════════════ + # 4. A missing required argument + # ═══════════════════════════════════════════════════════════ + let ctx4 = app.parse(["--config=test.conf"]) + runner_assert_eq(r, ctx4.has_errors(), true, "the missing argument is reported") + let argument_error = ctx4.get_errors()[0] + runner_assert_eq(r, argument_error.code, cli::ErrorCode.MissingArgument, "the diagnostic carries the expected code") + runner_assert_eq(r, contains(argument_error.message, "Missing required argument: "), true, "the diagnostic names the argument") + + # ═══════════════════════════════════════════════════════════ + # 5. An unknown option + # ═══════════════════════════════════════════════════════════ + let ctx5 = app.parse(["--config=test.conf", "--nope", "target"]) + runner_assert_eq(r, ctx5.has_errors(), true, "the unknown option is reported") + let unknown = ctx5.get_errors()[0] + runner_assert_eq(r, unknown.code, cli::ErrorCode.UnknownOption, "the diagnostic carries the expected code") + runner_assert_str_eq(r, unknown.message, "Unknown option: --nope", "the diagnostic names the option") + + # ═══════════════════════════════════════════════════════════ + # 6. An unknown command + # ═══════════════════════════════════════════════════════════ + let app2 = cli::new_app("router", "Command routing demo", "1.0.0") + let known = cli::new_command("build", "Compile artifacts") + app2.command(known) + let ctx6 = app2.parse(["deploy"]) + runner_assert_eq(r, ctx6.has_errors(), true, "the unknown command is reported") + let command_error = ctx6.get_errors()[0] + runner_assert_eq(r, command_error.code, cli::ErrorCode.UnknownCommand, "the diagnostic carries the expected code") + runner_assert_str_eq(r, command_error.message, "Unknown command: deploy", "the diagnostic names the command") + + # ═══════════════════════════════════════════════════════════ + # 7. A negated flag on a value option + # ═══════════════════════════════════════════════════════════ + let ctx7 = app.parse(["--config=test.conf", "--no-format", "target"]) + runner_assert_eq(r, ctx7.has_errors(), true, "the invalid negation is reported") + runner_assert_eq(r, ctx7.get_errors()[0].code, cli::ErrorCode.InvalidUsage, "the diagnostic carries the expected code") + + # ═══════════════════════════════════════════════════════════ + # 8. The help switch suppresses the unknown command diagnostic + # ═══════════════════════════════════════════════════════════ + let ctx8 = app2.parse(["--help", "deploy"]) + runner_assert_eq(r, ctx8.has_errors(), false, "the help switch silences the command diagnostic") + runner_assert_eq(r, ctx8.help_requested(), true, "the help switch is recorded") let failed = runner_summary(r) if failed > 0 From 9af67728769f7e686e5c601d9ee063b6b8396289 Mon Sep 17 00:00:00 2001 From: Taiizor <41683699+Taiizor@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:18:39 +0300 Subject: [PATCH 2/3] fix: keep command scope and global options consistent Address the findings of the automated code review on #6: - a command re-declaring a global option no longer lists it twice in the usage screen; the command scope wins, as it does in the parser - the shadowed global option is validated only once, so a diagnostic is never reported twice - a value given before the command name is no longer reset to the command default when the command scope is entered Adds coverage for the shadowed-option scenario (154 assertions). --- src/core/formatter.alya | 7 ++- src/core/parser.alya | 116 +++++++++++++++++++++++++------------- tests/test_commands.alya | 20 +++++++ tests/test_formatter.alya | 13 +++++ 4 files changed, 115 insertions(+), 41 deletions(-) diff --git a/src/core/formatter.alya b/src/core/formatter.alya index 67bfa81..3d91476 100644 --- a/src/core/formatter.alya +++ b/src/core/formatter.alya @@ -247,7 +247,12 @@ pub function format_command_help(app: CliApp, cmd: CliCommand) -> string options.push(option) end for option in app.options - options.push(option) + # A command re-declaring a global option shadows it in the parser, + # so listing both would only confuse the reader. + let spec: CliOption = option + if internal_find_option(cmd.options, spec.short_name) < 0 and internal_find_option(cmd.options, spec.long_name) < 0 + options.push(spec) + end end return formatter_render(app, formatter_command_usage(app, cmd, arguments), cmd.description, cmd.subcommands, options, arguments) end diff --git a/src/core/parser.alya b/src/core/parser.alya index e77b6f0..b84dc49 100644 --- a/src/core/parser.alya +++ b/src/core/parser.alya @@ -59,28 +59,32 @@ function parser_merge_options(primary: array, secondary: array) -> array end # Seeds the default state of the given options into the scope. +# +# Names that were already provided are left untouched: a command that +# re-declares a global option must not reset the value given before the +# command name. function parser_seed_defaults(options: array, scope: ParseScope) for option in options let spec: CliOption = option if spec.kind == OptionKind.Flag - if len(spec.short_name) > 0 + if len(spec.short_name) > 0 and spec.short_name not in scope.provided scope.flags[spec.short_name] = false end - if len(spec.long_name) > 0 + if len(spec.long_name) > 0 and spec.long_name not in scope.provided scope.flags[spec.long_name] = false end elif spec.kind == OptionKind.Multi - if len(spec.short_name) > 0 + if len(spec.short_name) > 0 and spec.short_name not in scope.provided scope.multi_options[spec.short_name] = [] end - if len(spec.long_name) > 0 + if len(spec.long_name) > 0 and spec.long_name not in scope.provided scope.multi_options[spec.long_name] = [] end else - if len(spec.short_name) > 0 + if len(spec.short_name) > 0 and spec.short_name not in scope.provided scope.options[spec.short_name] = spec.default_value end - if len(spec.long_name) > 0 + if len(spec.long_name) > 0 and spec.long_name not in scope.provided scope.options[spec.long_name] = spec.default_value end end @@ -261,6 +265,66 @@ function parser_env_value(name: string) -> string return "" end +# Reports whether an option name was already handled by an earlier entry. +function parser_option_seen(seen: array, option: CliOption) -> bool + if len(option.long_name) > 0 and option.long_name in seen + return true + end + if len(option.short_name) > 0 and option.short_name in seen + return true + end + return false +end + +# Records the names of an option so later duplicates can be skipped. +function parser_mark_seen(seen: array, option: CliOption) + if len(option.long_name) > 0 + seen.push(option.long_name) + end + if len(option.short_name) > 0 + seen.push(option.short_name) + end +end + +# Validates one option and collects the diagnostics it produces. +function parser_validate_option(spec: CliOption, scope: ParseScope) + let short_name = spec.short_name + let long_name = spec.long_name + let display_name = long_name + if len(display_name) == 0 + display_name = short_name + end + + let current = "" + if long_name in scope.options + current = str(scope.options[long_name]) + elif short_name in scope.options + current = str(scope.options[short_name]) + end + + # Environment fallback for omitted options. + if len(current) == 0 and len(spec.env_var) > 0 + let fallback = parser_env_value(spec.env_var) + if len(fallback) > 0 + current = fallback + for key in parser_option_names(spec, display_name) + scope.options[key] = fallback + end + end + end + + if spec.required == true and len(current) == 0 + scope.errors.push(make_error(ErrorCode.MissingRequired, display_name, "Missing required option: --" + display_name)) + end + + if len(spec.choices) > 0 and len(current) > 0 + if current not in spec.choices + let allowed = internal_join(spec.choices, ", ") + scope.errors.push(make_error(ErrorCode.InvalidChoice, display_name, "Invalid value '" + current + "' for option --" + display_name + ". Allowed choices: [" + allowed + "]")) + end + end +end + # ─────────────────────────────────────────────────────────────── # Parsing engine # ─────────────────────────────────────────────────────────────── @@ -374,43 +438,15 @@ pub function parse(app: CliApp, raw_args: array) -> CliContext end # ── Validation of the effective option set ── + # A command re-declaring a global option shadows it, so the first + # definition of a name wins and is validated only once. + let seen = [] for option in lookup_options let spec: CliOption = option - let short_name = spec.short_name - let long_name = spec.long_name - let display_name = long_name - if len(display_name) == 0 - display_name = short_name - end - - let current = "" - if long_name in scope.options - current = str(scope.options[long_name]) - elif short_name in scope.options - current = str(scope.options[short_name]) - end - - # Environment fallback for omitted options. - if len(current) == 0 and len(spec.env_var) > 0 - let fallback = parser_env_value(spec.env_var) - if len(fallback) > 0 - current = fallback - for key in parser_option_names(spec, display_name) - scope.options[key] = fallback - end - end - end - - if spec.required == true and len(current) == 0 - scope.errors.push(make_error(ErrorCode.MissingRequired, display_name, "Missing required option: --" + display_name)) - end - - if len(spec.choices) > 0 and len(current) > 0 - if current not in spec.choices - let allowed = internal_join(spec.choices, ", ") - scope.errors.push(make_error(ErrorCode.InvalidChoice, display_name, "Invalid value '" + current + "' for option --" + display_name + ". Allowed choices: [" + allowed + "]")) - end + if parser_option_seen(seen, spec) == false + parser_validate_option(spec, scope) end + parser_mark_seen(seen, spec) end # ── Unknown command detection ── diff --git a/tests/test_commands.alya b/tests/test_commands.alya index a3ce22e..9494b1d 100644 --- a/tests/test_commands.alya +++ b/tests/test_commands.alya @@ -88,6 +88,26 @@ function main() runner_assert_eq(r, len(ctx6.get_args()), 0, "the command name is not bound as a positional") runner_assert_str_eq(r, ctx6.get_command(), "run", "the command is still matched") + # ═══════════════════════════════════════════════════════════ + # 7. A command re-declaring a global option + # ═══════════════════════════════════════════════════════════ + let app4 = cli::new_app("shadow", "Shadowed option demo", "1.0.0") + app4.required_option("--profile", "Build profile") + let deploy = cli::new_command("deploy", "Deploy the application") + deploy.option("--profile", "release", "Deployment profile") + deploy.required_option("--token", "Access token") + app4.command(deploy) + + # The value given before the command name survives the command scope. + let ctx7 = app4.parse(["--profile=staging", "deploy", "--token=abc"]) + runner_assert_str_eq(r, ctx7.get_option("profile"), "staging", "the value given before the command is kept") + runner_assert_eq(r, ctx7.has_errors(), false, "the re-declared option produces no diagnostic") + + # The shadowed global option is validated only once. + let ctx8 = app4.parse(["deploy"]) + runner_assert_eq(r, ctx8.has_errors(), true, "the missing token is reported") + runner_assert_eq(r, len(ctx8.get_errors()), 1, "the re-declared option does not duplicate the diagnostic") + let failed = runner_summary(r) if failed > 0 exit(1) diff --git a/tests/test_formatter.alya b/tests/test_formatter.alya index 8d82555..c576928 100644 --- a/tests/test_formatter.alya +++ b/tests/test_formatter.alya @@ -6,6 +6,7 @@ ## slim build. import "std/test" +import "std/str" import "../src/lib.alya" as cli @cfg(not(feature = "help")) @@ -80,6 +81,18 @@ function main() let error = cli::make_error(cli::ErrorCode.MissingRequired, "config", "Missing required option: --config") runner_assert_str_eq(r, cli::format_error(error), "CliError(config): Missing required option: --config", "the structured error renders subject and message") + # ═══════════════════════════════════════════════════════════ + # 6. A command re-declaring a global option is listed once + # ═══════════════════════════════════════════════════════════ + let app2 = cli::new_app("shadow", "Shadowed option demo", "1.0.0") + app2.option("--profile", "default", "Build profile") + let deploy = cli::new_command("deploy", "Deploy the application") + deploy.option("--profile", "release", "Deployment profile") + app2.command(deploy) + let shadow_help = cli::format_command_help(app2, deploy) + runner_assert_eq(r, count_matches(shadow_help, "--profile"), 1, "the re-declared option is listed once") + runner_assert_eq(r, contains(shadow_help, "(default: release)"), true, "the command scope wins over the global default") + let failed = runner_summary(r) if failed > 0 exit(1) From a4cbc695b863c3535368614713e0be4db82e2bb6 Mon Sep 17 00:00:00 2001 From: Taiizor <41683699+Taiizor@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:23:06 +0300 Subject: [PATCH 3/3] fix: cover the environment fallback and the strict result accessor Audit of the rewritten package found two real defects: - the demo called \outcome.context()\, but CliResult had no such accessor; the compiler silently bound the call to the free facade function, so the success path misbehaved at runtime. Adds the accessor, exercises both strict branches in the demo, and covers the result context in the test suite - the environment fallback was guarded by an empty value, so an option with a non-empty default never fell back to the environment. The lookup now keys off whether the option was provided, and the env slice gains test coverage (the negative path always, the positive path when the variable is set) Also documents the behaviour decisions (unknown input is reported, the command scope wins, the command line wins over the environment, and diagnostics must be rebuilt before they are thrown). --- README.md | 7 +++++++ examples/demo.alya | 16 ++++++++++++---- src/core/parser.alya | 10 ++++++++-- src/lib.alya | 21 +++++++++++++++++++++ tests/test_basic.alya | 34 ++++++++++++++++++++++++++++++++++ tests/test_strict.alya | 2 ++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 5df83bb..e0580c8 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,13 @@ main() > [!TIP] > **Internal Helpers & Documentation:** Public symbols are documented with `##` Markdown docstrings, enabling automatic API documentation generation via `alya doc`. The helpers in `src/core/internal.alya` are not annotated with `pub` and stay encapsulated within the package. +### Behaviour Notes + +- **Unknown input is reported, not ignored.** An option or a command that the specification does not declare produces a diagnostic (`UnknownOption` / `UnknownCommand`) instead of being silently accepted. +- **The command scope wins.** When a command re-declares a global option, the command definition is authoritative: the option is listed once in the usage screen, it is validated once (so no diagnostic is duplicated), and a value given *before* the command name is kept instead of being reset to the command default. +- **The command line wins over the environment.** An option registered with `env_option` reads its environment variable only when the option was not given on the command line. An environment provided value does not count as provided for `has()`. +- **Diagnostics are values, not strings.** `get_errors()` returns `CliError` values: `err.code` classifies the failure, `err.subject` names the offending option, argument, or command, and `err.message` explains it. Rebuild a diagnostic with `make_error(...)` before throwing it β€” a value read back from the untyped diagnostics array loses its struct identity when it crosses a `throw`. + --- ## πŸ§ͺ Running Tests & Benchmarks diff --git a/examples/demo.alya b/examples/demo.alya index f4efe1a..370c89e 100644 --- a/examples/demo.alya +++ b/examples/demo.alya @@ -91,11 +91,19 @@ function main() # ═══════════════════════════════════════════════════════════ say "=== 6. Strict parsing (structured errors) ===" @cfg(feature = "strict") - let outcome = cli::try_parse(app, ["build", "--target=wasm"]) - if outcome.is_error() - say "Rejected: " + outcome.message() + let rejected = cli::try_parse(app, ["build", "--target=wasm"]) + if rejected.is_error() + say "Rejected: " + rejected.message() else - say "Accepted: " + outcome.context().get_option("target", "native") + say "Accepted: " + rejected.context().get_option("target", "native") + end + + @cfg(feature = "strict") + let accepted = cli::try_parse(app, ["build", "--target=native"]) + if accepted.is_ok() + say "Accepted: " + accepted.context().get_option("target", "native") + else + say "Rejected: " + accepted.message() end end diff --git a/src/core/parser.alya b/src/core/parser.alya index b84dc49..ec75e97 100644 --- a/src/core/parser.alya +++ b/src/core/parser.alya @@ -302,8 +302,14 @@ function parser_validate_option(spec: CliOption, scope: ParseScope) current = str(scope.options[short_name]) end - # Environment fallback for omitted options. - if len(current) == 0 and len(spec.env_var) > 0 + # Environment fallback for omitted options. The lookup is driven by the + # `provided` map, not by the current value: an option with a non-empty + # default would otherwise never fall back to the environment. + let was_provided = false + if long_name in scope.provided or short_name in scope.provided + was_provided = true + end + if was_provided == false and len(spec.env_var) > 0 let fallback = parser_env_value(spec.env_var) if len(fallback) > 0 current = fallback diff --git a/src/lib.alya b/src/lib.alya index 1c9c991..b97b3d9 100644 --- a/src/lib.alya +++ b/src/lib.alya @@ -1066,3 +1066,24 @@ end pub function CliResult.message(self: CliResult) -> string return self.error end + +## Returns the parsed context of a strict parse. +## +## The context is empty when the parse failed; check `is_ok()` first. +## +## ### Parameters +## - `self`: The target `CliResult` instance. +## +## ### Returns +## The parsed `CliContext`. +## +## ### Examples +## ```alya +## let result = try_parse(app, args()) +## if result.is_ok() +## say result.context().get_option("output", "dist") +## end +## ``` +pub function CliResult.context(self: CliResult) -> CliContext + return self.context +end diff --git a/tests/test_basic.alya b/tests/test_basic.alya index d9e3135..af04504 100644 --- a/tests/test_basic.alya +++ b/tests/test_basic.alya @@ -2,8 +2,37 @@ ## Part of the Alya Language package ecosystem (https://github.com/alya-lang) import "std/test" +@cfg(feature = "env") +import "std/os" import "../src/lib.alya" as cli +# Verifies the environment fallback of an option. +# +# The positive path needs the variable in the process environment; run the +# suite with `ALYA_CLI_TEST_ENV=value alya test` to cover it. The command +# line always wins over the environment. +@cfg(feature = "env") +function assert_env_fallback(r) + let app = cli::new_app("env-app", "Environment fallback demo", "1.0.0") + app.env_option("--token", "ALYA_CLI_TEST_ENV", "fallback", "Access token") + + let ctx = app.parse([]) + if has_env("ALYA_CLI_TEST_ENV") == true + runner_assert_str_eq(r, ctx.get_option("token"), env("ALYA_CLI_TEST_ENV"), "the environment variable provides the option value") + else + runner_assert_str_eq(r, ctx.get_option("token"), "fallback", "the default is kept when the environment variable is unset") + end + runner_assert_eq(r, ctx.has("token"), false, "an environment provided value does not count as provided on the command line") + + let ctx2 = app.parse(["--token=explicit"]) + runner_assert_str_eq(r, ctx2.get_option("token"), "explicit", "the command line value wins over the environment") + runner_assert_eq(r, ctx2.has("token"), true, "a command line value counts as provided") +end + +@cfg(not(feature = "env")) +function assert_env_fallback(r) +end + function main() test_suite("CLI Core Tests") let r = runner_new() @@ -119,6 +148,11 @@ function main() runner_assert_str_eq(r, app5.author, "Alya Language Contributors", "the author is stored") runner_assert_str_eq(r, app5.version, "2.0.0", "the version is updated") + # ═══════════════════════════════════════════════════════════ + # 10. Environment-backed options + # ═══════════════════════════════════════════════════════════ + assert_env_fallback(r) + let failed = runner_summary(r) if failed > 0 exit(1) diff --git a/tests/test_strict.alya b/tests/test_strict.alya index 6962c8c..fefa246 100644 --- a/tests/test_strict.alya +++ b/tests/test_strict.alya @@ -59,6 +59,8 @@ function main() runner_assert_eq(r, ok_result.is_ok(), true, "the recovering parse reports success on valid input") runner_assert_eq(r, ok_result.is_error(), false, "the recovering parse does not report a failure") runner_assert_str_eq(r, ok_result.message(), "", "a successful result carries no diagnostic") + runner_assert_str_eq(r, ok_result.context().get_option("config"), "app.toml", "the result carries the parsed context") + runner_assert_eq(r, ok_result.context().has_errors(), false, "the carried context is free of diagnostics") let failed = runner_summary(r) if failed > 0