Repository: hamdanal/rich-argparse

Issue:
Using `%(default)s` inside square brackets crashes with ValueError
This program crashes with `ValueError: substring not found`

```python
import argparse
from rich_argparse._argparse import RichHelpFormatter

parser = argparse.ArgumentParser(prog="PROG", formatter_class=RichHelpFormatter)
parser.add_argument("--hello", default="world", help="say hello to [default: %(default)s]")
parser.print_help()
```

Using square brackets here is wrong since it is considered markup but it shouldn't crash.

Hints from issue comments:

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e9f900f..14229ee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,9 @@
   https://github.com/hamdanal/rich-argparse#additional-formatters
 
 ### Fixes
+- [GH-152](https://github.com/hamdanal/rich-argparse/issues/152),
+  [PR-153](https://github.com/hamdanal/rich-argparse/pull/153)
+  Fix `ValueError` when using `%(default)s` inside square brackets and `help_markup` is enabled.
 - [GH-141](https://github.com/hamdanal/rich-argparse/issues/141),
   [PR-142](https://github.com/hamdanal/rich-argparse/pull/142)
   Do not highlight --options inside backticks.
diff --git a/rich_argparse/_argparse.py b/rich_argparse/_argparse.py
index 4625d05..fd9aef3 100644
--- a/rich_argparse/_argparse.py
+++ b/rich_argparse/_argparse.py
@@ -422,7 +422,8 @@ class RichHelpFormatter(argparse.HelpFormatter):
         help_string % params  # pyright: ignore[reportUnusedExpression]
         parts = []
         defaults: list[str] = []
-        default_repl = "rich-argparse-f3ae8b55df34d5d83a8189d2e4766e68-argparse-rich"
+        default_sub_template = "rich-argparse-f3ae8b55df34d5d83a8189d2e4766e68-{}-argparse-rich"
+        default_n = 0
         last = 0
         for m in self._printf_style_pattern.finditer(help_string):
             start, end = m.span()
@@ -430,7 +431,8 @@ class RichHelpFormatter(argparse.HelpFormatter):
             sub = help_string[start:end] % params
             if m.group("mapping") == "default":
                 defaults.append(sub)
-                sub = default_repl
+                sub = default_sub_template.format(default_n)
+                default_n += 1
             else:
                 sub = r.escape(sub)
             parts.append(sub)
@@ -441,13 +443,33 @@ class RichHelpFormatter(argparse.HelpFormatter):
             if self.help_markup
             else r.Text("".join(parts), style="argparse.help")
         )
-        default_repl_len = len(default_repl)
-        for default in reversed(defaults):
-            default_index = rich_help.plain.rindex(default_repl)
+        for i, default in reversed(list(enumerate(defaults))):
+            default_sub = default_sub_template.format(i)
+            try:
+                start = rich_help.plain.rindex(default_sub)
+            except ValueError:
+                # This could happen in cases like `[default: %(default)s]` with markup activated
+                import warnings
+
+                action_id = next(iter(action.option_strings), action.dest)
+                md = re.search(
+                    rf"\[([^\]]*{self._printf_style_pattern.pattern}[^\]]*)\]", help_string, re.X
+                )
+                repl = (
+                    repr(md.group(1))[1:-1]
+                    if md and md.group("mapping") == "default"
+                    else "default: %(default)s"
+                )
+                msg = (
+                    f"Failed to process default value in help string of argument {action_id!r}."
+                    f"\nHint: try disabling rich markup: `RichHelpFormatter.help_markup = False`"
+                    f"\n      or replace brackets by parenthesis: `[{repl}]` -> `({repl})`"
+                )
+                warnings.warn(msg, UserWarning, stacklevel=4)
+                continue
+            end = start + len(default_sub)
             rich_help = (
-                rich_help[:default_index]
-                .append(default, style="argparse.default")
-                .append(rich_help[default_index + default_repl_len :])
+                rich_help[:start].append(default, style="argparse.default").append(rich_help[end:])
             )
         for highlight in self.highlights:
             rich_help.highlight_regex(highlight, style_prefix="argparse.")


Test patch:
diff --git a/tests/test_argparse.py b/tests/test_argparse.py
index ec3fd02..0f70ec4 100644
--- a/tests/test_argparse.py
+++ b/tests/test_argparse.py
@@ -1,6 +1,7 @@
 from __future__ import annotations
 
 import argparse
+import re
 import string
 import sys
 import textwrap
@@ -1021,6 +1022,35 @@ def test_arg_default_spans():
     assert help_text == clean_argparse(expected_help_text)
 
 
+def test_arg_default_in_markup():
+    parser = ArgumentParser(prog="PROG", formatter_class=RichHelpFormatter)
+    parser.add_argument(
+        "--foo",
+        default="def",
+        help=(
+            "(default: %(default)r)[default wrong: %(default)r] text [default wrong: %(default)s]"
+            "(default: %(default)s) [link default wrong %(default)s] %(default)s"
+        ),
+    )
+    expected_help_text = """\
+    Usage: PROG [-h] [--foo FOO]
+
+    Optional Arguments:
+      -h, --help  show this help message and exit
+      --foo FOO   (default: 'def') text (default: def) def
+    """
+    with pytest.warns(
+        UserWarning,
+        match=re.escape(
+            "Failed to process default value in help string of argument '--foo'.\n"
+            "Hint: try disabling rich markup: `RichHelpFormatter.help_markup = False`\n"
+            "      or replace brackets by parenthesis: `[default wrong: %(default)r]` -> `(default wrong: %(default)r)`"
+        ),
+    ):
+        help_text = parser.format_help()
+    assert help_text == clean_argparse(expected_help_text)
+
+
 @pytest.mark.usefixtures("force_color")
 def test_metavar_spans():
     # tests exotic metavars (tuples, wrapped, different nargs, etc.) in usage and help text