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..e0580c8 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,162 @@ 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. + +### 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 -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 +317,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..370c89e 100644 --- a/examples/demo.alya +++ b/examples/demo.alya @@ -1,54 +1,109 @@ ## 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 rejected = cli::try_parse(app, ["build", "--target=wasm"]) + if rejected.is_error() + say "Rejected: " + rejected.message() + else + 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/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..3d91476 100644 --- a/src/core/formatter.alya +++ b/src/core/formatter.alya @@ -1,238 +1,287 @@ -## 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 + # 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 -## 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..ec75e97 100644 --- a/src/core/parser.alya +++ b/src/core/parser.alya @@ -1,987 +1,763 @@ -## 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 +## 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. -## 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 +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 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 - -## 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) -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 -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 -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 -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 -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 -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 -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 - -## 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 - -# --- Lookup Helpers (Internal) --- - -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 +# 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 + +# 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 and spec.short_name not in scope.provided + scope.flags[spec.short_name] = false + end + 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 and spec.short_name not in scope.provided + scope.multi_options[spec.short_name] = [] + end + 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 and spec.short_name not in scope.provided + scope.options[spec.short_name] = spec.default_value + end + if len(spec.long_name) > 0 and spec.long_name not in scope.provided + scope.options[spec.long_name] = spec.default_value + end end end - return null 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 +# 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 - return _cli_lookup_opt_in_list(app.app_options, name) + if len(long_name) > 0 and long_name not in names + names.push(long_name) + end + return names 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 - end - if target in c.cmd_aliases - return c - end +# 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 - 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 +# 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 - if opt.opt_is_multi == true - if len(s) > 0 - array_options[s] = [] - end - if len(l) > 0 - array_options[l] = [] - end - else - if len(s) > 0 - options[s] = def_val - end - if len(l) > 0 - options[l] = def_val - end - end + scope.options[key] = value end + scope.provided[key] = true end end -function _cli_classify_token(arg_str) -> int - let s = str(arg_str) - if _cli_starts_with(s, "--no-") == true - return ArgKind.InvertedFlag +# 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 - if _cli_starts_with(s, "--") == true - return ArgKind.LongOption + 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 - if _cli_starts_with(s, "-") == true and s != "-" - return ArgKind.ShortOption + 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 - return ArgKind.Positional end -# --- Parsing Engine --- - -## 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 - - # 1. Populate top-level defaults - _cli_populate_defaults(app.app_options, flags, options, array_options) - - let num_raw = len(raw_args) - let idx = 0 - let stop_options = false +# 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 - while idx < num_raw - let arg_str = str_from_ptr(raw_args[idx]) - let arg_len = len(arg_str) + 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 - if stop_options == true - positionals.push(arg_str) - idx += 1 - elif arg_str == "--" - stop_options = true - idx += 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 - 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 - end - end - end + let option: CliOption = lookup_options[option_index] + if option.kind == OptionKind.Flag + parser_store_flag(option, name, true, scope) + return index + 1 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) - end - for cmd_opt in active_cmd.cmd_options - pool.push(cmd_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 - opt_pool = pool + index += 1 + value = str(raw_args[index]) end - for opt in opt_pool - let s = str_from_ptr(opt.opt_short) - let l = str_from_ptr(opt.opt_long) - - # 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 - end - end - if has_val == false - errors.push(f"Missing required option: --{l}") - end - end + parser_store_value(option, name, value, scope) + return index + 1 +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 - end - 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 - # 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 + 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 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 - let num_defs = len(arg_defs) - let num_pos = len(positionals) - let ai = 0 - let pi = 0 - - while ai < num_defs - let adef = arg_defs[ai] - let aname = str_from_ptr(adef.arg_name) - if adef.arg_variadic == true - let remaining = [] - while pi < num_pos - remaining.push(positionals[pi]) - pi += 1 - end - arg_map[aname] = remaining - else - if pi < num_pos - arg_map[aname] = positionals[pi] - pi += 1 + 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 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) + 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 - ai += 1 + return 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 } + # Attached value such as `-oDist`. + parser_store_value(option, short_name, token[2..token_length], scope) + return index + 1 end -## Parses process arguments into a resolved execution context. -pub function app_parse_args(app: CliApp) -> CliContext - return app_parse(app, args()) +# 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 + return env(name) end -# --- Context Accessor Helpers --- +@cfg(not(feature = "env")) +function parser_env_value(name: string) -> string + return "" +end -## 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] +# 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 -## 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]) +# 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 - return default_val -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] + if len(option.short_name) > 0 + seen.push(option.short_name) end - return [] 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]) +# 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 - return default_val -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]) + 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 - 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 + # 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 + for key in parser_option_names(spec, display_name) + scope.options[key] = fallback + end + end + end -## Parses argument strings with this app. -pub function CliApp.parse(self: CliApp, raw_args: array) -> CliContext - return app_parse(self, raw_args) -end + if spec.required == true and len(current) == 0 + scope.errors.push(make_error(ErrorCode.MissingRequired, display_name, "Missing required option: --" + display_name)) + 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) + 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 - 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 +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Parsing engine +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -## 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 +## 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 -## 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 + let command_name = "" + let command_path = "" + let help_requested = false + let version_requested = false + let orphan_token = "" -## 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 + let positional = [] + let named = {} -## 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 + parser_seed_defaults(global_options, scope) -## 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 + let count = len(raw_args) + let index = 0 + let stop_options = false -## 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 + while index < count + let token = str(raw_args[index]) -## 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 + if stop_options == true + positional.push(token) + index += 1 + elif token == "--" + stop_options = true + 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 + 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 + 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 -## 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 + # โ”€โ”€ 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 + if parser_option_seen(seen, spec) == false + parser_validate_option(spec, scope) + end + parser_mark_seen(seen, spec) + 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) -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 -## 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 + # โ”€โ”€ Positional binding โ”€โ”€ + let definitions = scope_arguments + if scope_depth > 0 and len(scope_arguments) == 0 + definitions = app.arguments + 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 + let definition_count = len(definitions) + let positional_count = len(positional) + let definition_index = 0 + let value_index = 0 -# 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 + while definition_index < definition_count + let definition: CliArgument = definitions[definition_index] + let argument_name = definition.name + if definition.variadic == true + let remaining = [] + while value_index < positional_count + remaining.push(positional[value_index]) + value_index += 1 + end + named[argument_name] = remaining + else + if value_index < positional_count + named[argument_name] = positional[value_index] + value_index += 1 + else + 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 + definition_index += 1 + end -## Alias for get_flag on this context. -pub function CliContext.flag(self: CliContext, name) -> int - return ctx_get_flag(self, name) + 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 -## 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 +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +# Context accessors +# โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -## 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) +## 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 all values for a repeatable option. -pub function CliContext.get_all(self: CliContext, name) -> array - return ctx_get_all(self, name) +## 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_value end -## Alias for get_all on this context. -pub function CliContext.all(self: CliContext, name) -> array - return ctx_get_all(self, name) +## 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 from this context. -pub function CliContext.get_arg(self: CliContext, idx: int, default_val = "") -> string - return ctx_get_arg(self, idx, default_val) +## 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 ctx_get_flag(ctx, name) 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) +## 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 [] 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) +## 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 default_value 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..b97b3d9 100644 --- a/src/lib.alya +++ b/src/lib.alya @@ -1,131 +1,1089 @@ -## 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. +## +## ### Parameters +## - `app`: The application specification. +## - `spec`: Flag names, e.g. `"-v, --verbose"`. +## - `description`: Help text shown in usage screens. Defaults to `""`. ## -## Populates flags, options, positional arguments, subcommands, and collects validation errors. +## ### 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 + +## Registers a variadic positional argument on an application. +## +## The variadic argument captures every remaining positional value. +## +## ### Parameters +## - `app`: The application specification. +## - `name`: Argument name shown in usage screens. +## - `description`: Help text shown in usage screens. Defaults to `""`. +## +## ### Returns +## 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 -## Automatically reads and parses process arguments (`sys::args()` / `args`) into a context. +## Sets the version string of an application. ## ## ### Parameters -## - `app`: The target `CliApp` configuration model. +## - `app`: The application specification. +## - `version`: The version string. ## ## ### Returns -## A `CliContext` containing parsed command-line state. +## 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 print_help(app: CliApp, cmd = 0) - say cli_format_help(app, cmd) +pub function format_version(app: CliApp) -> string + return formatter::format_version(app) end -## Prints the application version string directly to standard output. +## Formats a list of parse diagnostics into a report. ## ## Requires the `help` feature (default-on). ## ## ### Parameters -## - `app`: The target `CliApp` instance. +## - `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) + say formatter::format_help(app) +end + +## Prints the usage screen of a single command. +## +## Requires the `help` feature (default-on). +## +## ### Parameters +## - `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 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) - say cli_format_version(app) + 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 + +## 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/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..af04504 100644 --- a/tests/test_basic.alya +++ b/tests/test_basic.alya @@ -1,68 +1,157 @@ -## 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" +@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 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") - - # 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") - - # 3. Parsing with long options and flags - let args1 = ["--verbose", "--output=build", "main.alya"] - let ctx1 = cli::parse(app, args1) - - 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. 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) - - 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") - - # 5. Double dash terminator -- - let args3 = ["-v", "--", "--not-a-flag", "-o", "file.txt"] - let ctx3 = cli::parse(app, args3) - - 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") - - # 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") - - 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") - - 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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. 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. 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 10. Environment-backed options + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + assert_env_fallback(r) let failed = runner_summary(r) if failed > 0 diff --git a/tests/test_commands.alya b/tests/test_commands.alya index 7054756..9494b1d 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,105 @@ 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) - - # 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") - - # 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") + let test_cmd = cli::new_command("test", "Run test suite", ["t"]) + test_cmd.flag("--watch", "Watch file changes") + app.command(test_cmd) + + # 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 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") - - # 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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 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..c576928 100644 --- a/tests/test_formatter.alya +++ b/tests/test_formatter.alya @@ -1,10 +1,14 @@ -## 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 "std/str" 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 +24,74 @@ 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) + + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") - 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) + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") - # 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") - # 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") - # 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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") - # 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") + # โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + # 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 diff --git a/tests/test_strict.alya b/tests/test_strict.alya new file mode 100644 index 0000000..fefa246 --- /dev/null +++ b/tests/test_strict.alya @@ -0,0 +1,71 @@ +## 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") + 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 + 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