From f59091e2a376526ea5f80eeabc6e2deab8a9a703 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Fri, 10 Apr 2026 22:35:10 +0200 Subject: [PATCH 1/5] Add allow_negative_expressions --- docs/argmojo_overall_planning.md | 2 + docs/changelog.md | 2 + docs/declarative_api_planning.md | 31 +++---- docs/user_manual.md | 32 ++++++- src/argmojo/command.mojo | 141 +++++++++++++++++++++++++------ tests/test_parse.mojo | 131 ++++++++++++++++++++++++++++ 6 files changed, 297 insertions(+), 42 deletions(-) diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index da287a5..46b8c42 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 token is not a known short option: + │ │ 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..513acc6 100644 --- a/docs/declarative_api_planning.md +++ b/docs/declarative_api_planning.md @@ -1289,21 +1289,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..8e3f057 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,12 +2628,41 @@ 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 a flag) +``` + +Rules: + +- Token with **2+ characters** after the hyphen (e.g. `-1/3*pi`, `-abc`): always treated as a positional. +- Token with **exactly 1 character** after the hyphen (e.g. `-e`): treated as a positional only if the character is not a registered short option. +- 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 expressions (e.g. `-1/3*pi`) | `allow_negative_expressions()` | | You need to pass arbitrary dash-prefixed strings (not just numbers) | `--` separator | | Legacy or defensive: works in all cases | `--` separator | @@ -2640,7 +2670,7 @@ calc -3 # operand = "-3" (NOT the -3 flag!) **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/command.mojo b/src/argmojo/command.mojo index 9a66a53..592b802 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -201,6 +201,13 @@ 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). + Tokens with more than one character after the hyphen are always + consumed as positionals; single-character tokens (``-x``) are only + consumed if ``x`` does not match a registered short option.""" 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 +310,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 +356,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 +423,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 +1282,37 @@ 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 token has **more than one** character after the hyphen + (e.g. ``-1/3*pi``, ``-abc``), it is always consumed as a + positional argument. + - If the token has **exactly one** character after the hyphen + (e.g. ``-e``), it is consumed as a positional only when that + character does not match any registered short option. + + 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 + # 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 +2117,29 @@ 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. Multi-character + # payloads are always consumed; single-character payloads + # are consumed only when they don't match a registered short. + if self._allow_negative_expressions: + var _ne_key = String(arg[byte=1:]) + if len(_ne_key) > 1: + result._positionals.append(arg) + i += 1 + continue + else: + # Single character — only consume if not a short flag + var _ne_is_short = False + for _nei in range(len(self.args)): + if self.args[_nei]._short_name == _ne_key: + _ne_is_short = True + break + if not _ne_is_short: + 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 +2356,24 @@ struct Command(Copyable, Movable, Writable): result._positionals.append(arg) i += 1 continue + # ── Negative-expression detection (same as parse_arguments) ── + if self._allow_negative_expressions: + var _ne_key = String(arg[byte=1:]) + if len(_ne_key) > 1: + result._positionals.append(arg) + i += 1 + continue + else: + var _ne_is_short = False + for _nei in range(len(self.args)): + if self.args[_nei]._short_name == _ne_key: + _ne_is_short = True + break + if not _ne_is_short: + result._positionals.append(arg) + i += 1 + continue + # ───────────────────────────────────────────────────────────── try: var key = String(arg[byte=1:]) if len(key) == 1: @@ -4273,38 +4355,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 pass long options as positionals: " + self.name - + " -- -my-value" + + " -- --my-value" ) 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_parse.mojo b/tests/test_parse.mojo index f0d8503..0a96949 100644 --- a/tests/test_parse.mojo +++ b/tests/test_parse.mojo @@ -1063,5 +1063,136 @@ 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_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 main() raises: TestSuite.discover_tests[__functions_in_module()]().run() From b61ee37c5125ba170039c44979db3afc356feb39 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Fri, 10 Apr 2026 22:50:07 +0200 Subject: [PATCH 2/5] Update --- docs/user_manual.md | 42 +++++++++++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/docs/user_manual.md b/docs/user_manual.md index 8e3f057..365e250 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -2658,13 +2658,41 @@ Rules: **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 expressions (e.g. `-1/3*pi`) | `allow_negative_expressions()` | -| 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)** | Not accepted (requires 2+ chars) | Accepted | +| **Expressions (e.g. `-1/3*pi`)** | Accepted | Accepted | +| **Works on options** | No (positionals only) | Yes (also value-taking options like `--file`) | +| **Parsing priority** | Fires after long-option and negative-number checks | Fires first — before all other dash-token checks | + +With a **single positional** and no need for bare `-`, the two are nearly interchangeable. For example, `decimo "-1/pi*sin(3)" -p 10` works identically with either approach. The bare `-` (which has no mathematical meaning) is rejected by `allow_negative_expressions()` but accepted by `allow_hyphen_values()`. + +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. --- From 00dbac6bd71732df021fe3642a382e66f74e193a Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Fri, 10 Apr 2026 23:27:33 +0200 Subject: [PATCH 3/5] Fix comments --- docs/argmojo_overall_planning.md | 2 +- docs/user_manual.md | 5 +-- src/argmojo/command.mojo | 62 +++++++++++++++----------------- tests/test_parse.mojo | 47 ++++++++++++++++++++++++ 4 files changed, 79 insertions(+), 37 deletions(-) diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index 46b8c42..0572c58 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -726,7 +726,7 @@ 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 token is not a known short option: + │ ├─ 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 diff --git a/docs/user_manual.md b/docs/user_manual.md index 365e250..d4f42cf 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -2648,8 +2648,9 @@ calc -p 10 hello # precision = "10", expr = "hello" (-p IS registered, Rules: -- Token with **2+ characters** after the hyphen (e.g. `-1/3*pi`, `-abc`): always treated as a positional. -- Token with **exactly 1 character** after the hyphen (e.g. `-e`): treated as a positional only if the character is not a registered short option. +- 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. diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 592b802..56db348 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -1293,12 +1293,12 @@ struct Command(Copyable, Movable, Writable): Rules: - - If the token has **more than one** character after the hyphen - (e.g. ``-1/3*pi``, ``-abc``), it is always consumed as a - positional argument. - - If the token has **exactly one** character after the hyphen - (e.g. ``-e``), it is consumed as a positional only when that - character does not match any registered short option. + - 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: @@ -1312,6 +1312,7 @@ struct Command(Copyable, Movable, Writable): ``` """ 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 = "@"): @@ -2119,26 +2120,22 @@ struct Command(Copyable, Movable, Writable): # ──────────────────────────────────────────────────────────── # ── Negative-expression detection ─────────────────────────── # A token like "-1/3*pi" is treated as a positional when - # allow_negative_expressions() was called. Multi-character - # payloads are always consumed; single-character payloads - # are consumed only when they don't match a registered short. + # allow_negative_expressions() was called, but only if + # the first character does not match a registered short + # option. This preserves merged shorts (-vp) and + # attached values (-p10) that start with a known flag. if self._allow_negative_expressions: var _ne_key = String(arg[byte=1:]) - if len(_ne_key) > 1: + var _ne_first = String(_ne_key[byte=:1]) + var _ne_short_conflict = False + for _nei in range(len(self.args)): + if self.args[_nei]._short_name == _ne_first: + _ne_short_conflict = True + break + if not _ne_short_conflict: result._positionals.append(arg) i += 1 continue - else: - # Single character — only consume if not a short flag - var _ne_is_short = False - for _nei in range(len(self.args)): - if self.args[_nei]._short_name == _ne_key: - _ne_is_short = True - break - if not _ne_is_short: - result._positionals.append(arg) - i += 1 - continue # ──────────────────────────────────────────────────────────── var key = String(arg[byte=1:]) if len(key) == 1: @@ -2359,20 +2356,16 @@ struct Command(Copyable, Movable, Writable): # ── Negative-expression detection (same as parse_arguments) ── if self._allow_negative_expressions: var _ne_key = String(arg[byte=1:]) - if len(_ne_key) > 1: + var _ne_first = String(_ne_key[byte=:1]) + var _ne_short_conflict = False + for _nei in range(len(self.args)): + if self.args[_nei]._short_name == _ne_first: + _ne_short_conflict = True + break + if not _ne_short_conflict: result._positionals.append(arg) i += 1 continue - else: - var _ne_is_short = False - for _nei in range(len(self.args)): - if self.args[_nei]._short_name == _ne_key: - _ne_is_short = True - break - if not _ne_is_short: - result._positionals.append(arg) - i += 1 - continue # ───────────────────────────────────────────────────────────── try: var key = String(arg[byte=1:]) @@ -4362,9 +4355,10 @@ struct Command(Copyable, Movable, Writable): if has_positional: if self._allow_negative_expressions: tip_lines.append( - "Use '--' to pass long options as positionals: " + "Use '--' to force option-like tokens into" + " positionals: " + self.name - + " -- --my-value" + + " -- -p" ) else: var neg_auto = self._allow_negative_numbers diff --git a/tests/test_parse.mojo b/tests/test_parse.mojo index 0a96949..2836e39 100644 --- a/tests/test_parse.mojo +++ b/tests/test_parse.mojo @@ -1099,6 +1099,53 @@ def test_negative_expression_with_option() raises: 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.""" From 6db6cc281800ac83f3cf91ac3df65ab38b5ba40c Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Fri, 10 Apr 2026 23:28:38 +0200 Subject: [PATCH 4/5] Format --- src/argmojo/command.mojo | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 56db348..08fe7eb 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -4355,8 +4355,7 @@ struct Command(Copyable, Movable, Writable): if has_positional: if self._allow_negative_expressions: tip_lines.append( - "Use '--' to force option-like tokens into" - " positionals: " + "Use '--' to force option-like tokens into positionals: " + self.name + " -- -p" ) From c501d79cf1bc5d239d703f0c9e4fa0ad99632c96 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Sat, 11 Apr 2026 00:00:14 +0200 Subject: [PATCH 5/5] Add allow hyphen into decl api for positional --- docs/declarative_api_planning.md | 2 ++ docs/user_manual.md | 18 +++++------ src/argmojo/argument_wrappers.mojo | 5 +++ src/argmojo/command.mojo | 52 ++++++++++++------------------ tests/test_declarative.mojo | 26 +++++++++++++++ tests/test_parse.mojo | 23 +++++++++++++ 6 files changed, 86 insertions(+), 40 deletions(-) diff --git a/docs/declarative_api_planning.md b/docs/declarative_api_planning.md index 513acc6..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 = "", diff --git a/docs/user_manual.md b/docs/user_manual.md index d4f42cf..dda4f83 100644 --- a/docs/user_manual.md +++ b/docs/user_manual.md @@ -2643,7 +2643,7 @@ command.add_argument(Argument("expr", help="Expression").positional().required() 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 a flag) +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: @@ -2674,15 +2674,15 @@ Rules: 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)** | Not accepted (requires 2+ chars) | Accepted | -| **Expressions (e.g. `-1/3*pi`)** | Accepted | Accepted | -| **Works on options** | No (positionals only) | Yes (also value-taking options like `--file`) | -| **Parsing priority** | Fires after long-option and negative-number checks | Fires first — before all other dash-token checks | +| 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** and no need for bare `-`, the two are nearly interchangeable. For example, `decimo "-1/pi*sin(3)" -p 10` works identically with either approach. The bare `-` (which has no mathematical meaning) is rejected by `allow_negative_expressions()` but accepted by `allow_hyphen_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: 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 08fe7eb..0d9b2f8 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -205,9 +205,10 @@ struct Command(Copyable, Movable, Writable): """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). - Tokens with more than one character after the hyphen are always - consumed as positionals; single-character tokens (``-x``) are only - consumed if ``x`` does not match a registered short option.""" + 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 @@ -2120,22 +2121,16 @@ struct Command(Copyable, Movable, Writable): # ──────────────────────────────────────────────────────────── # ── Negative-expression detection ─────────────────────────── # A token like "-1/3*pi" is treated as a positional when - # allow_negative_expressions() was called, but only if - # the first character does not match a registered short - # option. This preserves merged shorts (-vp) and - # attached values (-p10) that start with a known flag. - if self._allow_negative_expressions: - var _ne_key = String(arg[byte=1:]) - var _ne_first = String(_ne_key[byte=:1]) - var _ne_short_conflict = False - for _nei in range(len(self.args)): - if self.args[_nei]._short_name == _ne_first: - _ne_short_conflict = True - break - if not _ne_short_conflict: - result._positionals.append(arg) - i += 1 - continue + # 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: @@ -2354,18 +2349,13 @@ struct Command(Copyable, Movable, Writable): i += 1 continue # ── Negative-expression detection (same as parse_arguments) ── - if self._allow_negative_expressions: - var _ne_key = String(arg[byte=1:]) - var _ne_first = String(_ne_key[byte=:1]) - var _ne_short_conflict = False - for _nei in range(len(self.args)): - if self.args[_nei]._short_name == _ne_first: - _ne_short_conflict = True - break - if not _ne_short_conflict: - result._positionals.append(arg) - i += 1 - continue + 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:]) 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 2836e39..50c053b 100644 --- a/tests/test_parse.mojo +++ b/tests/test_parse.mojo @@ -1241,5 +1241,28 @@ def test_negative_expression_long_option_unaffected() raises: 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()