diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index da287a5..0572c58 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -726,6 +726,8 @@ Input: ["demo", "yuhao", "./src", "--ling", "-i", "--max-depth", "3"] ├─ If args[i].startswith("-") and len > 1: │ ├─ IF _looks_like_number(token) AND (allow_negative_numbers OR no digit short opts): │ │ Treat as positional argument (negative number passthrough) + │ ├─ IF allow_negative_expressions AND first char after '-' is not a registered short: + │ │ Treat as positional argument (negative expression passthrough) │ └─ ELSE: │ ├─ Single char → _parse_short_single(key, raw_args, i, result) → new i │ └─ Multi char → _parse_short_merged(key, raw_args, i, result) → new i diff --git a/docs/changelog.md b/docs/changelog.md index fd70565..b905920 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -4,6 +4,8 @@ This document tracks all notable changes to ArgMojo, including new features, API ## 20260404 (v0.5.0) diff --git a/docs/declarative_api_planning.md b/docs/declarative_api_planning.md index 088b3b0..17dbd3b 100644 --- a/docs/declarative_api_planning.md +++ b/docs/declarative_api_planning.md @@ -296,6 +296,8 @@ struct Positional[ default: StringLiteral = "", required: Bool = False, choices: StringLiteral = "", + # ── Parsing behaviour ── + allow_hyphen: Bool = False, # allow hyphen-prefixed values # ── Display & help ── value_name: StringLiteral = "", group: StringLiteral = "", @@ -1289,21 +1291,22 @@ var (git_args, result) = MyGit.parse_full_from_command(cmd^) Some features are inherently imperative and don't fit neatly into struct declarations. I'm keeping these builder-only (accessible via `to_command()`): -| Feature | Reason | -| -------------------------- | ------------------------------------------------------------------ | -| `mutually_exclusive()` | Partially declarative via `conflicts_with` (§6.4); builder for N>2 | -| `required_together()` | Partially declarative via `depends_on` (§6.4); builder for N>2 | -| `one_required()` | Cross-field constraint on N args | -| `required_if()` | Cross-field conditional | -| `implies()` | Cross-field chain with cycle detection | -| `confirmation_option()` | Adds a synthetic `--yes` arg | -| `help_on_no_arguments()` | Command-level behavior | -| `add_tip()` | Help formatting | -| Color config | Command-level presentation | -| Completions config | Command-level behavior | -| Response file config | Command-level behavior | -| `allow_negative_numbers()` | Parser behavior flag | -| `add_parent()` | Cross-command inheritance | +| Feature | Reason | +| ------------------------------ | ------------------------------------------------------------------ | +| `mutually_exclusive()` | Partially declarative via `conflicts_with` (§6.4); builder for N>2 | +| `required_together()` | Partially declarative via `depends_on` (§6.4); builder for N>2 | +| `one_required()` | Cross-field constraint on N args | +| `required_if()` | Cross-field conditional | +| `implies()` | Cross-field chain with cycle detection | +| `confirmation_option()` | Adds a synthetic `--yes` arg | +| `help_on_no_arguments()` | Command-level behavior | +| `add_tip()` | Help formatting | +| Color config | Command-level presentation | +| Completions config | Command-level behavior | +| Response file config | Command-level behavior | +| `allow_negative_numbers()` | Parser behavior flag | +| `allow_negative_expressions()` | Parser behavior flag (superset of allow_negative_numbers) | +| `add_parent()` | Cross-command inheritance | I think this is the right call — these features describe *relationships between* arguments or *command-level* behavior, not individual argument metadata. Trying to force them into struct field attributes would create a confusing, non-composable API. diff --git a/docs/user_manual.md b/docs/user_manual.md index 26449ba..dda4f83 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -437,6 +437,7 @@ Argument("name", help="...") ╠══ Command-level configuration (called on Command) ════════════════════════════ ║ command.help_on_no_arguments() show help when invoked with no args ║ command.allow_negative_numbers() negative tokens treated as positionals +║ command.allow_negative_expressions() dash-prefixed expressions as positionals ║ command.allow_positional_with_subcommands() allow positionals + subcommands ║ command.add_tip("...") custom tip shown in help footer ║ command.command_aliases(["co"]) alternate names for this subcommand @@ -2627,20 +2628,78 @@ calc -3 # operand = "-3" (NOT the -3 flag!) --- +**Approach 4: `allow_negative_expressions()` (expressions and arbitrary tokens)** + +When your CLI accepts mathematical expressions that start with `-` (e.g. `-1/3*pi`, `-sin(2)`, `-e^2`), `allow_negative_numbers()` is not enough because those tokens are not pure numeric literals. Call `allow_negative_expressions()` to treat any single-hyphen token as a positional argument, provided it doesn't conflict with a registered short option. + +```mojo +var command = Command("calc", "Expression calculator") +command.allow_negative_expressions() +command.add_argument(Argument("precision", help="Decimal places").long["precision"]().short["p"]()) +command.add_argument(Argument("expr", help="Expression").positional().required()) +``` + +```shell +calc "-1/3*pi" -p 10 # expr = "-1/3*pi", precision = "10" +calc "-sin(2)" # expr = "-sin(2)" +calc -e # expr = "-e" (because -e is not a registered short option) +calc -p 10 hello # precision = "10", expr = "hello" (-p IS registered, so it's parsed as the -p short option taking 10 as its value) +``` + +Rules: + +- A single-hyphen token is treated as a positional **only when its first character after `-` does not match a registered short option**. + - Examples: `-1/3*pi`, `-sin(2)`, and `-e` are positional if `-1`, `-s`, and `-e` are not registered short options. + - If the first character **is** a registered short option, the token is parsed normally (merged shorts like `-vp` and attached values like `-p10` continue to work). +- Long options (`--foo`) are never affected — they always parse normally. + +> **Note:** `allow_negative_expressions()` is a superset of `allow_negative_numbers()`. You don't need to call both. + +--- + **When to use which approach** -| Scenario | Recommended approach | -| ------------------------------------------------------------------------- | ---------------------------------- | -| No digit short options registered | Auto-detect (nothing to configure) | -| You have digit short options (`-3`, `-5`, etc.) and need negative numbers | `allow_negative_numbers()` | -| You need to pass arbitrary dash-prefixed strings (not just numbers) | `--` separator | -| Legacy or defensive: works in all cases | `--` separator | +| Scenario | Recommended approach | +| ------------------------------------------------------------------------- | ---------------------------------------- | +| No digit short options registered | Auto-detect (nothing to configure) | +| You have digit short options (`-3`, `-5`, etc.) and need negative numbers | `allow_negative_numbers()` | +| You need to pass arbitrary dash-prefixed expressions (e.g. `-1/3*pi`) | `allow_negative_expressions()` | +| One specific argument needs to accept `-` (stdin) or dash-prefixed values | `allow_hyphen_values()` on that argument | +| You need to pass arbitrary dash-prefixed strings (not just numbers) | `--` separator | +| Legacy or defensive: works in all cases | `--` separator | + +--- + +**`allow_negative_expressions()` vs `allow_hyphen_values()` — how do they relate?** + +These two features partially overlap, especially when the command has a single positional argument. Here is a quick comparison: + +| Aspect | `allow_negative_expressions()` | `allow_hyphen_values()` | +| -------------------------------- | --------------------------------------------------------- | -------------------------------------------------------- | +| **Scope** | Per-command (one call covers all positionals) | Per-argument (must be set on each argument individually) | +| **Bare `-` (stdin)** | Accepted (bare `-` never enters short-option parsing) | Accepted | +| **Expressions (e.g. `-1/3*pi`)** | Accepted | Accepted | +| **Works on options** | No (positionals only) | Yes (also value-taking options like `--file`) | +| **Parsing behavior** | Enables dash-prefixed expression handling for positionals | Broadens one argument to accept hyphen-prefixed values | + +With a **single positional**, the two are nearly interchangeable for inputs like `-1/pi*sin(3)` — and a bare `-` is already treated as a positional token in both cases (it never enters short-option parsing). `allow_hyphen_values()` is still the better fit when one specific argument should accept arbitrary hyphen-prefixed values, especially for value-taking options. + +Choose `allow_negative_expressions()` when: + +- Your command has **multiple positionals** and you want a single switch for all of them. +- You want to signal intent: "this CLI handles math expressions." + +Choose `allow_hyphen_values()` when: + +- Only **one specific argument** should accept dash-prefixed values while others should not. +- You need the bare `-` (stdin/stdout convention). +- The argument is an **option** (`--file`), not a positional. --- **What is NOT a number** -Tokens like `-1abc`, `-e5`, or `-1-2` are not valid numeric patterns. They will still be parsed as short-option strings and may raise "Unknown option" errors if unregistered. +Tokens like `-1abc`, `-e5`, or `-1-2` are not valid numeric patterns. Without `allow_negative_expressions()`, they will still be parsed as short-option strings and may raise "Unknown option" errors if unregistered. With `allow_negative_expressions()`, they are consumed as positional arguments. ### Long Option Prefix Matching diff --git a/src/argmojo/argument_wrappers.mojo b/src/argmojo/argument_wrappers.mojo index 4534e20..f3d0405 100644 --- a/src/argmojo/argument_wrappers.mojo +++ b/src/argmojo/argument_wrappers.mojo @@ -465,6 +465,8 @@ struct Positional[ default: StringLiteral = "", required: Bool = False, choices: StringLiteral = "", + # -- Parsing behaviour -- + allow_hyphen: Bool = False, # -- Display & help -- value_name: StringLiteral = "", group: StringLiteral = "", @@ -482,6 +484,7 @@ struct Positional[ default: Default value as a string literal. required: If True, the positional must be provided. choices: Comma-separated allowed values. + allow_hyphen: Allow hyphen-prefixed values. value_name: Display name in help. group: Help group heading. @@ -558,6 +561,8 @@ struct Positional[ comptime if Self.choices != "": for c in String(Self.choices).split(","): arg._choice_values.append(String(c)) + comptime if Self.allow_hyphen: + arg._allow_hyphen_values = True comptime if Self.value_name != "": arg._value_name = String(Self.value_name) comptime if Self.group != "": diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 9a66a53..0d9b2f8 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -201,6 +201,14 @@ struct Command(Copyable, Movable, Writable): no registered short option uses a digit character (auto-detect). Enable explicitly via ``allow_negative_numbers()`` when you have a digit short option and still need negative-number literals to pass through.""" + var _allow_negative_expressions: Bool + """When True, single-hyphen tokens that are not known short options + are treated as positional arguments. This handles expressions like + ``-1/3*pi`` or ``-e`` (when ``-e`` is not a registered short flag). + For tokens with one or more characters after the hyphen, this applies + only when the first character after ``-`` does not match a registered + short option; otherwise the token continues to parse as a short option + sequence or short option with attached value.""" var _allow_positional_with_subcommands: Bool """When True, allows mixing positional arguments with subcommands. By default (False), registering a positional arg on a Command that already @@ -303,6 +311,7 @@ struct Command(Copyable, Movable, Writable): # ── Parser behavior ── self._help_on_no_arguments = False self._allow_negative_numbers = False + self._allow_negative_expressions = False self._allow_positional_with_subcommands = False self._response_file_prefix = String("") self._response_file_max_depth = 10 @@ -348,6 +357,7 @@ struct Command(Copyable, Movable, Writable): # ── Parser behavior ── self._help_on_no_arguments = take._help_on_no_arguments self._allow_negative_numbers = take._allow_negative_numbers + self._allow_negative_expressions = take._allow_negative_expressions self._allow_positional_with_subcommands = ( take._allow_positional_with_subcommands ) @@ -414,6 +424,7 @@ struct Command(Copyable, Movable, Writable): # ── Parser behavior ── self._help_on_no_arguments = copy._help_on_no_arguments self._allow_negative_numbers = copy._allow_negative_numbers + self._allow_negative_expressions = copy._allow_negative_expressions self._allow_positional_with_subcommands = ( copy._allow_positional_with_subcommands ) @@ -1272,6 +1283,38 @@ struct Command(Copyable, Movable, Writable): """ self._allow_negative_numbers = True + def allow_negative_expressions(mut self): + """Treats single-hyphen tokens as positional arguments when they + don't conflict with a known short option. + + This is a superset of ``allow_negative_numbers()`` — it also + handles mathematical expressions like ``-1/3*pi``, ``-sin(2)``, + or even arbitrary dash-prefixed strings like ``-e`` (when ``-e`` + is not a registered short option). + + Rules: + + - If the first character after the hyphen does **not** match a + registered short option, the token is consumed as a positional + (e.g. ``-1/3*pi`` when ``-1`` is not registered). + - If the first character **does** match a registered short option, + the token is parsed normally (merged shorts like ``-vp`` or + attached values like ``-p10`` continue to work). + + Examples: + + ```mojo + from argmojo import Command, Argument + var command = Command("calc", "Expression calculator") + command.allow_negative_expressions() + command.add_argument(Argument("expr", help="Expression").positional().required()) + command.add_argument(Argument("precision", help="Decimal places").long["precision"]().short["p"]()) + # Now: calc "-1/3*pi" -p 10 → expr = "-1/3*pi", precision = "10" + ``` + """ + self._allow_negative_expressions = True + self._allow_negative_numbers = True + # TODO: response_file_prefix[prefix: StringLiteral](mut self) for compile-time checks def response_file_prefix(mut self, prefix: String = "@"): """Enables response-file expansion for this command. @@ -2076,6 +2119,19 @@ struct Command(Copyable, Movable, Writable): i += 1 continue # ──────────────────────────────────────────────────────────── + # ── Negative-expression detection ─────────────────────────── + # A token like "-1/3*pi" is treated as a positional when + # allow_negative_expressions() was called. Reuse the + # shared _is_known_option() helper so this stays aligned + # with parse_known_arguments() and avoids duplicate scans. + if ( + self._allow_negative_expressions + and not self._is_known_option(arg) + ): + result._positionals.append(arg) + i += 1 + continue + # ──────────────────────────────────────────────────────────── var key = String(arg[byte=1:]) if len(key) == 1: i = self._parse_short_single(key, args_to_parse, i, result) @@ -2292,6 +2348,15 @@ struct Command(Copyable, Movable, Writable): result._positionals.append(arg) i += 1 continue + # ── Negative-expression detection (same as parse_arguments) ── + if ( + self._allow_negative_expressions + and not self._is_known_option(arg) + ): + result._positionals.append(arg) + i += 1 + continue + # ───────────────────────────────────────────────────────────── try: var key = String(arg[byte=1:]) if len(key) == 1: @@ -4273,38 +4338,45 @@ struct Command(Copyable, Movable, Writable): # Tip: show '--' separator hint when positional args are registered. # When negative numbers are already handled automatically (either via - # explicit allow_negative_numbers() or auto-detect: no digit short - # options), the example changes to a generic dash-prefixed value - # rather than '-10.18', since that case no longer needs '--'. + # explicit allow_negative_numbers(), allow_negative_expressions(), + # or auto-detect: no digit short options), the example changes to + # a generic dash-prefixed value rather than '-10.18'. var tip_lines = List[String]() if has_positional: - var neg_auto = self._allow_negative_numbers - if not neg_auto: - var has_digit_short = False - for _ti in range(len(self.args)): - var sc = self.args[_ti]._short_name - if ( - len(sc) == 1 - and sc[byte=0:1] >= "0" - and sc[byte=0:1] <= "9" - ): - has_digit_short = True - break - neg_auto = not has_digit_short - if neg_auto: + if self._allow_negative_expressions: tip_lines.append( - "Use '--' to pass values starting with '-' as" - " positionals: " + "Use '--' to force option-like tokens into positionals: " + self.name - + " -- -my-value" + + " -- -p" ) else: - tip_lines.append( - "Use '--' to pass values that start with '-' (e.g.," - " negative numbers): " - + self.name - + " -- -10.18" - ) + var neg_auto = self._allow_negative_numbers + if not neg_auto: + var has_digit_short = False + for _ti in range(len(self.args)): + var sc = self.args[_ti]._short_name + if ( + len(sc) == 1 + and sc[byte=0:1] >= "0" + and sc[byte=0:1] <= "9" + ): + has_digit_short = True + break + neg_auto = not has_digit_short + if neg_auto: + tip_lines.append( + "Use '--' to pass values starting with '-' as" + " positionals: " + + self.name + + " -- -my-value" + ) + else: + tip_lines.append( + "Use '--' to pass values that start with '-' (e.g.," + " negative numbers): " + + self.name + + " -- -10.18" + ) # User-defined tips — always shown when present. for _ti in range(len(self._tips)): diff --git a/tests/test_declarative.mojo b/tests/test_declarative.mojo index 8342fd5..6eed158 100644 --- a/tests/test_declarative.mojo +++ b/tests/test_declarative.mojo @@ -267,6 +267,32 @@ def test_choices_without_default() raises: assert_true(len(cmd.args[0]._choice_values) == 4, "4 choices") +struct HyphenPos(Parsable): + """Positional that accepts hyphen-prefixed values.""" + + var expr: Positional[ + String, help="Expression", required=True, allow_hyphen=True + ] + + @staticmethod + def description() -> String: + return String("Hyphen-positional test.") + + +def test_positional_allow_hyphen() raises: + """Test that Positional allow_hyphen=True sets _allow_hyphen_values.""" + var cmd = HyphenPos.to_command() + assert_true( + cmd.args[0]._allow_hyphen_values, "allow_hyphen_values on positional" + ) + + var args_list = List[String]() + args_list.append(String("hp")) + args_list.append(String("-1+2*sin(x)")) + var args = HyphenPos.parse_args(args_list) + assert_equal(args.expr.value, "-1+2*sin(x)") + + # ======================================================================= # Main # ======================================================================= diff --git a/tests/test_parse.mojo b/tests/test_parse.mojo index f0d8503..50c053b 100644 --- a/tests/test_parse.mojo +++ b/tests/test_parse.mojo @@ -1063,5 +1063,206 @@ def test_invalid_numeric_form_still_errors() raises: assert_true(raised, msg="'-1abc' is not a number and should raise an error") +# ── Negative expressions ───────────────────────────────────────────────────── + + +def test_negative_expression_multi_char() raises: + """A multi-character expression like -1/3*pi is treated as a positional + when allow_negative_expressions() is set.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-1/3*pi"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-1/3*pi") + + +def test_negative_expression_with_option() raises: + """Expressions coexist with registered options.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("precision", help="Decimal places") + .long["precision"]() + .short["p"]() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-1/3*pi*sin(10)", "-p", "10"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-1/3*pi*sin(10)") + assert_equal(result.get_string("precision"), "10") + + +def test_negative_expression_attached_short_value() raises: + """Attached short values like -p10 still parse as options when + allow_negative_expressions() is set.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("precision", help="Decimal places") + .long["precision"]() + .short["p"]() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-1/3*pi*sin(10)", "-p10"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-1/3*pi*sin(10)") + assert_equal(result.get_string("precision"), "10") + + +def test_negative_expression_merged_short_options() raises: + """Merged short forms like -vp still expand normally when + allow_negative_expressions() is set.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("verbose", help="Verbose output") + .long["verbose"]() + .short["v"]() + .flag() + ) + command.add_argument( + Argument("precision", help="Decimal places") + .long["precision"]() + .short["p"]() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-1/3*pi*sin(10)", "-vp", "10"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-1/3*pi*sin(10)") + assert_true(result.get_flag("verbose"), msg="-v should be True") + assert_equal(result.get_string("precision"), "10") + + +def test_negative_expression_single_char_no_conflict() raises: + """A single-character token like -e is treated as positional when -e is + not a registered short option and allow_negative_expressions() is set.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-e"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-e") + + +def test_negative_expression_single_char_conflict() raises: + """A single-character token like -p is parsed as a short flag when -p + IS a registered short option, even with allow_negative_expressions().""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("precision", help="Decimal places") + .long["precision"]() + .short["p"]() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "hello", "-p", "10"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "hello") + assert_equal(result.get_string("precision"), "10") + + +def test_negative_expression_complex_math() raises: + """Complex mathematical expressions with hyphen prefix are treated as + positionals: -sin(2), -3/2, -e^2, etc.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var exprs = List[String]() + exprs.append("-sin(2)") + exprs.append("-3/2") + exprs.append("-e^2") + exprs.append("-1+2i") + exprs.append("-abc") + for idx in range(len(exprs)): + var args: List[String] = ["test", exprs[idx]] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), exprs[idx]) + + +def test_negative_expression_also_handles_numbers() raises: + """Superset of allow_negative_numbers — negative numbers also work.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("triple", help="Triple mode") + .long["triple"]() + .short["3"]() + .flag() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + # Multi-char expression: always positional + var args: List[String] = ["test", "-3.14"] + var result = command.parse_arguments(args) + assert_equal(result.get_string("expr"), "-3.14") + + +def test_negative_expression_long_option_unaffected() raises: + """Long options (--foo) are never absorbed as positionals.""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("verbose", help="Verbose") + .long["verbose"]() + .short["v"]() + .flag() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "--verbose", "-1/3"] + var result = command.parse_arguments(args) + assert_true(result.get_flag("verbose")) + assert_equal(result.get_string("expr"), "-1/3") + + +def test_negative_expression_parse_known() raises: + """Dash-prefixed expressions are captured as positionals (not as unknown) + when allow_negative_expressions() is set via parse_known_arguments().""" + var command = Command("test", "Test app") + command.allow_negative_expressions() + command.add_argument( + Argument("precision", help="Decimal places") + .long["precision"]() + .short["p"]() + ) + command.add_argument( + Argument("expr", help="Expression").positional().required() + ) + + var args: List[String] = ["test", "-1/3*pi", "-p", "10", "--color"] + var result = command.parse_known_arguments(args) + assert_equal(result.get_string("expr"), "-1/3*pi") + assert_equal(result.get_string("precision"), "10") + var unknown = result.get_unknown_args() + assert_equal(len(unknown), 1) + assert_equal(unknown[0], "--color") + + def main() raises: TestSuite.discover_tests[__functions_in_module()]().run()