From d0a897264908489d0fbbbcc2bc6ed297a73dc88a Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 13 Apr 2026 00:00:25 +0200 Subject: [PATCH 1/7] word warp for help message --- examples/yu.mojo | 9 +- src/argmojo/command.mojo | 248 +++++++++++++++++++++++-------------- src/argmojo/utils.mojo | 172 +++++++++++++++++++++++++ tests/test_completion.mojo | 106 ++++++++-------- tests/test_help.mojo | 12 +- tests/test_options.mojo | 12 +- 6 files changed, 404 insertions(+), 155 deletions(-) diff --git a/examples/yu.mojo b/examples/yu.mojo index 111b81d..813b2e7 100644 --- a/examples/yu.mojo +++ b/examples/yu.mojo @@ -142,13 +142,16 @@ def main() raises: ) app.add_argument( - Argument("漢字", help="要查詢的漢字(可以輸入多個漢字)").positional().required() + Argument("漢字", help="要查詢的漢字\n(可以輸入多個漢字)").positional().required() ) app.add_argument( - Argument("joy", help="使用卿雲編碼(預設為靈明)").long["joy"]().short["j"]().flag() + Argument("joy", help="使用卿雲編碼\n(預設為靈明)") + .long["joy"]() + .short["j"]() + .flag() ) app.add_argument( - Argument("star", help="使用星陳編碼(預設為靈明)") + Argument("star", help="使用星陳編碼\n(預設為靈明)") .long["star"]() .short["s"]() .flag() diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index a8a8f65..a9fb0e7 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -13,17 +13,26 @@ from .utils import ( _DEFAULT_ARG_COLOR, _DEFAULT_WARN_COLOR, _DEFAULT_ERROR_COLOR, + _HELP_LINE_WIDTH, + _HELP_INDENT, + _HELP_OPT_WIDTH, + _HELP_GAP, + _HELP_TRAIL, _correct_cjk_punctuation, _disable_echo, _display_width, + _format_two_column_line, _read_password_asterisk, _restore_echo, _fullwidth_to_halfwidth, _has_fullwidth_chars, _looks_like_number, _resolve_color, + _section_desc_layout, _split_on_fullwidth_spaces, _suggest_similar, + _wrap_description, + _wrap_text_at, ) @@ -337,7 +346,7 @@ struct Command(Copyable, Movable, Writable): self._tips = List[String]() # ── Shell completions ── self._completions_enabled = True - self._completions_name = String("completions") + self._completions_name = String("comp") self._completions_is_subcommand = False # ── Auto-dispatch ── self._run_function = None @@ -4004,7 +4013,7 @@ struct Command(Copyable, Movable, Writable): """ var s = String("") if self.description: - s += self.description + "\n\n" + s += _wrap_text_at(self.description, _HELP_LINE_WIDTH) + "\n\n" # Usage line. if self._custom_usage: @@ -4014,6 +4023,10 @@ struct Command(Copyable, Movable, Writable): s += header_color + "Usage:" + reset_code + " " s += arg_color + self.name + reset_code + # Collect usage tokens (plain and coloured) for wrapping. + var usage_tokens_plain = List[String]() + var usage_tokens_colored = List[String]() + # Show positional args in usage line. for i in range(len(self.args)): if self.args[i]._is_positional and not self.args[i]._is_hidden: @@ -4021,9 +4034,15 @@ struct Command(Copyable, Movable, Writable): if self.args[i]._is_remainder: display += "..." if self.args[i]._is_required: - s += " " + arg_color + "<" + display + ">" + reset_code + usage_tokens_plain.append("<" + display + ">") + usage_tokens_colored.append( + arg_color + "<" + display + ">" + reset_code + ) else: - s += " " + arg_color + "[" + display + "]" + reset_code + usage_tokens_plain.append("[" + display + "]") + usage_tokens_colored.append( + arg_color + "[" + display + "]" + reset_code + ) # Show placeholder when subcommands are registered. var has_subcommands = False @@ -4035,9 +4054,47 @@ struct Command(Copyable, Movable, Writable): has_subcommands = True break if has_subcommands: - s += " " + arg_color + "" + reset_code + usage_tokens_plain.append("") + usage_tokens_colored.append(arg_color + "" + reset_code) + + usage_tokens_plain.append("[OPTIONS]") + usage_tokens_colored.append(arg_color + "[OPTIONS]" + reset_code) + + # Build the usage line with wrapping at _HELP_LINE_WIDTH. + var prefix_plain = "Usage: " + self.name + var prefix_width = _display_width(prefix_plain) + var indent = String("") + for _ in range(prefix_width): + indent += " " + + # Check if everything fits on one line. + var total_plain = prefix_plain + for ti in range(len(usage_tokens_plain)): + total_plain += " " + usage_tokens_plain[ti] + + if _display_width(total_plain) <= _HELP_LINE_WIDTH: + # Single line — append coloured tokens directly. + for ti in range(len(usage_tokens_colored)): + s += " " + usage_tokens_colored[ti] + else: + # Multi-line: wrap tokens with " \" continuation. + var line_width = prefix_width # current line width (plain) + for ti in range(len(usage_tokens_plain)): + var tw = _display_width(usage_tokens_plain[ti]) + # +1 for the preceding space, +2 for " \" if not last token. + var is_last = ti == len(usage_tokens_plain) - 1 + var trail = 0 if is_last else 2 # reserve for " \" + if ( + line_width + 1 + tw + trail > _HELP_LINE_WIDTH + and line_width > prefix_width + ): + # Wrap: end current line, start new indented line. + s += " \\\n" + indent + line_width = prefix_width + s += " " + usage_tokens_colored[ti] + line_width += 1 + tw - s += " " + arg_color + "[OPTIONS]" + reset_code + "\n\n" + s += "\n\n" return s def _help_positionals_section( @@ -4085,23 +4142,24 @@ struct Command(Copyable, Movable, Writable): pos_colors.append(colored) pos_helps.append(self.args[i].help_text) - var pos_max: Int = 0 - for k in range(len(pos_plains)): - var w = _display_width(pos_plains[k]) - if w > pos_max: - pos_max = w - var pos_pad = pos_max + 4 - var s = header_color + "Arguments:" + reset_code + "\n" + var pos_indices = List[Int]() for k in range(len(pos_plains)): - var line = pos_colors[k] - if pos_helps[k]: - # Pad based on plain-text width. - var padding = pos_pad - _display_width(pos_plains[k]) - for _p in range(padding): - line += " " - line += pos_helps[k] - s += line + "\n" + pos_indices.append(k) + var pos_layout = _section_desc_layout(pos_plains, pos_indices) + var pos_ds = pos_layout[0] + var pos_dw = pos_layout[1] + for k in range(len(pos_plains)): + s += ( + _format_two_column_line( + pos_colors[k], + pos_plains[k], + pos_helps[k], + pos_ds, + pos_dw, + ) + + "\n" + ) s += "\n" return s @@ -4309,24 +4367,26 @@ struct Command(Copyable, Movable, Writable): opt_helps.append(String("Show version")) if self._completions_enabled and not self._completions_is_subcommand: var comp_plain = String( - " --" + self._completions_name + " {bash,zsh,fish}" + " --" + self._completions_name + " " ) var comp_colored = ( - " " + " " + arg_color + "--" + self._completions_name + reset_code + " " + arg_color - + "{bash,zsh,fish}" + + "" + reset_code ) opt_plains.append(comp_plain) opt_colors.append(comp_colored) opt_persistent.append(False) opt_groups.append(String("")) - opt_helps.append(String("Generate shell completion script")) + opt_helps.append( + String("Generate shell completion (bash, zsh, fish)") + ) # Check if there are any persistent (global) options. var has_global = False @@ -4347,76 +4407,76 @@ struct Command(Copyable, Movable, Writable): if not found: group_names.append(opt_groups[k]) - # --- Helper: compute max display width for a subset of options --- - def _section_pad( - plains: List[String], - persistent: List[Bool], - groups: List[String], - want_persistent: Bool, - want_group: String, - ) -> Int: - var mx: Int = 0 - for idx in range(len(plains)): - if persistent[idx] != want_persistent: - continue - if groups[idx] != want_group: - continue - var w = _display_width(plains[idx]) - if w > mx: - mx = w - return mx + 4 + # --- Per-section dynamic padding (capped at _HELP_OPT_WIDTH) --- # --- Ungrouped local options (Options:) --- - var ungrouped_pad = _section_pad( - opt_plains, opt_persistent, opt_groups, False, String("") - ) - var s = header_color + "Options:" + reset_code + "\n" + var ungrouped_idx = List[Int]() for k in range(len(opt_plains)): if not opt_persistent[k] and not opt_groups[k]: - var line = opt_colors[k] - if opt_helps[k]: - var padding = ungrouped_pad - _display_width(opt_plains[k]) - for _p in range(padding): - line += " " - line += opt_helps[k] - s += line + "\n" + ungrouped_idx.append(k) + var ug_layout = _section_desc_layout(opt_plains, ungrouped_idx) + var ug_ds = ug_layout[0] + var ug_dw = ug_layout[1] + var s = header_color + "Options:" + reset_code + "\n" + for ki in range(len(ungrouped_idx)): + var k = ungrouped_idx[ki] + s += ( + _format_two_column_line( + opt_colors[k], + opt_plains[k], + opt_helps[k], + ug_ds, + ug_dw, + ) + + "\n" + ) # --- Grouped local options (one section per group) --- for g in range(len(group_names)): var gname = group_names[g] - var gpad = _section_pad( - opt_plains, opt_persistent, opt_groups, False, gname - ) - s += "\n" + header_color + gname + ":" + reset_code + "\n" + var grp_idx = List[Int]() for k in range(len(opt_plains)): if not opt_persistent[k] and opt_groups[k] == gname: - var line = opt_colors[k] - if opt_helps[k]: - var padding = gpad - _display_width(opt_plains[k]) - for _p in range(padding): - line += " " - line += opt_helps[k] - s += line + "\n" + grp_idx.append(k) + var g_layout = _section_desc_layout(opt_plains, grp_idx) + var g_ds = g_layout[0] + var g_dw = g_layout[1] + s += "\n" + header_color + gname + ":" + reset_code + "\n" + for ki in range(len(grp_idx)): + var k = grp_idx[ki] + s += ( + _format_two_column_line( + opt_colors[k], + opt_plains[k], + opt_helps[k], + g_ds, + g_dw, + ) + + "\n" + ) # Global (persistent) options — shown under a separate heading. if has_global: - var global_max: Int = 0 + var gl_idx = List[Int]() for k in range(len(opt_plains)): if opt_persistent[k]: - var w = _display_width(opt_plains[k]) - if w > global_max: - global_max = w - var global_pad = global_max + 4 + gl_idx.append(k) + var gl_layout = _section_desc_layout(opt_plains, gl_idx) + var gl_ds = gl_layout[0] + var gl_dw = gl_layout[1] s += "\n" + header_color + "Global Options:" + reset_code + "\n" - for k in range(len(opt_plains)): - if opt_persistent[k]: - var line = opt_colors[k] - if opt_helps[k]: - var padding = global_pad - _display_width(opt_plains[k]) - for _p in range(padding): - line += " " - line += opt_helps[k] - s += line + "\n" + for ki in range(len(gl_idx)): + var k = gl_idx[ki] + s += ( + _format_two_column_line( + opt_colors[k], + opt_plains[k], + opt_helps[k], + gl_ds, + gl_dw, + ) + + "\n" + ) return s @@ -4476,24 +4536,26 @@ struct Command(Copyable, Movable, Writable): cmd_plains.append(plain) cmd_colors.append(colored) cmd_helps.append( - String("Generate shell completion script (bash, zsh, fish)") + String("Generate shell completion (bash, zsh, fish)") ) - # Compute padding. - var cmd_max: Int = 0 + var cmd_indices = List[Int]() for k in range(len(cmd_plains)): - var w = _display_width(cmd_plains[k]) - if w > cmd_max: - cmd_max = w - var cmd_pad = cmd_max + 4 + cmd_indices.append(k) + var cmd_layout = _section_desc_layout(cmd_plains, cmd_indices) + var cmd_ds = cmd_layout[0] + var cmd_dw = cmd_layout[1] var s = "\n" + header_color + "Commands:" + reset_code + "\n" for k in range(len(cmd_plains)): - var line = cmd_colors[k] - if cmd_helps[k]: - var padding = cmd_pad - _display_width(cmd_plains[k]) - for _p in range(padding): - line += " " - line += cmd_helps[k] - s += line + "\n" + s += ( + _format_two_column_line( + cmd_colors[k], + cmd_plains[k], + cmd_helps[k], + cmd_ds, + cmd_dw, + ) + + "\n" + ) return s diff --git a/src/argmojo/utils.mojo b/src/argmojo/utils.mojo index a63af90..bcfeff2 100644 --- a/src/argmojo/utils.mojo +++ b/src/argmojo/utils.mojo @@ -22,6 +22,15 @@ comptime _DEFAULT_ARG_COLOR = _MAGENTA comptime _DEFAULT_WARN_COLOR = _ORANGE comptime _DEFAULT_ERROR_COLOR = _RED +# ── Help formatting layout constants ───────────────────────────────────────── +# This design is based on my personal aesthetic taste and the typical terminal width of 80 columns. +# Per-section layout: INDENT + min(longest, OPT_WIDTH) + GAP + desc + TRAIL = LINE_WIDTH +comptime _HELP_LINE_WIDTH: Int = 80 +comptime _HELP_INDENT: Int = 2 +comptime _HELP_OPT_WIDTH: Int = 32 +comptime _HELP_GAP: Int = 2 +comptime _HELP_TRAIL: Int = 2 + # ── Utility functions ──────────────────────────────────────────────────────── @@ -190,6 +199,169 @@ def _display_width(s: String) -> Int: return width +def _wrap_text_at(text: String, max_width: Int) -> String: + """Word-wrap plain text so no line exceeds *max_width* columns. + + Splits on whitespace; preserves explicit ``\\n`` line breaks. + + Args: + text: The text to wrap. + max_width: Maximum terminal columns per line. + + Returns: + The wrapped text string. + """ + if not text: + return "" + var result = String("") + var paragraphs = text.split("\n") + for pi in range(len(paragraphs)): + if pi > 0: + result += "\n" + var paragraph = String(paragraphs[pi]) + var words = paragraph.split(" ") + var current_width = 0 + var first_word = True + for wi in range(len(words)): + var word = String(words[wi]) + if not word: + continue + var ww = _display_width(word) + if first_word: + result += word + current_width = ww + first_word = False + elif current_width + 1 + ww <= max_width: + result += " " + word + current_width += 1 + ww + else: + result += "\n" + word + current_width = ww + return result + + +def _wrap_description(text: String, desc_width: Int, align_col: Int) -> String: + """Word-wrap a description for the two-column help layout. + + Handles explicit ``\\n`` in *text* as forced line breaks. Each + continuation line is indented to *align_col* columns. + + Args: + text: The description text (may contain newlines). + desc_width: Maximum width for description text. + align_col: Column position where description starts. + + Returns: + The wrapped description. The first segment has **no** leading + indent (the caller positions it); continuation lines begin with + *align_col* spaces. + """ + if not text: + return "" + var align = String("") + for _ in range(align_col): + align += " " + var result = String("") + var paragraphs = text.split("\n") + for pi in range(len(paragraphs)): + if pi > 0: + result += "\n" + align + var paragraph = String(paragraphs[pi]) + var words = paragraph.split(" ") + var current_width = 0 + var first_word = True + for wi in range(len(words)): + var word = String(words[wi]) + if not word: + continue + var ww = _display_width(word) + if first_word: + result += word + current_width = ww + first_word = False + elif current_width + 1 + ww <= desc_width: + result += " " + word + current_width += 1 + ww + else: + result += "\n" + align + word + current_width = ww + return result + + +def _section_desc_layout( + plains: List[String], + indices: List[Int], +) -> Tuple[Int, Int]: + """Compute description start column and width for a help section. + + Finds the longest option in the section (by display width, minus + indent). The option column is capped at ``_HELP_OPT_WIDTH``. + + Args: + plains: All plain-text option strings in the parent list. + indices: Indices into *plains* that belong to this section. + + Returns: + A tuple ``(desc_start, desc_width)``. + """ + var mx: Int = 0 + for i in range(len(indices)): + var w = _display_width(plains[indices[i]]) - _HELP_INDENT + if w > mx: + mx = w + var capped = mx if mx <= _HELP_OPT_WIDTH else _HELP_OPT_WIDTH + var desc_start = _HELP_INDENT + capped + _HELP_GAP + var desc_width = _HELP_LINE_WIDTH - desc_start - _HELP_TRAIL + return (desc_start, desc_width) + + +def _format_two_column_line( + colored: String, + plain: String, + help_text: String, + desc_start: Int, + desc_width: Int, +) -> String: + """Format a single two-column help line. + + If the option text (after indent) exceeds the section's effective + option column, the description is placed on the next line, aligned + at *desc_start*. + + Args: + colored: The option text with ANSI colour codes. + plain: The option text without colour (for width measurement). + help_text: The description text. + desc_start: Column where descriptions begin. + desc_width: Maximum width for the description. + + Returns: + One or more lines of formatted text (no trailing newline). + """ + var line = colored + + if not help_text: + return line + + var wrapped_desc = _wrap_description(help_text, desc_width, desc_start) + var plain_width = _display_width(plain) + + if desc_start - plain_width < _HELP_GAP: + # Option overflows — description on next line. + var align = String("") + for _ in range(desc_start): + align += " " + line += "\n" + align + wrapped_desc + else: + # Pad to description start position. + var padding = desc_start - plain_width + for _ in range(padding): + line += " " + line += wrapped_desc + + return line + + def _looks_like_number(token: String) -> Bool: """Returns True if *token* is a negative-number literal. diff --git a/tests/test_completion.mojo b/tests/test_completion.mojo index 25ff6b4..de98d7a 100644 --- a/tests/test_completion.mojo +++ b/tests/test_completion.mojo @@ -633,12 +633,12 @@ def test_generated_by_comment() raises: def test_fish_builtin_completions() raises: - """Tests that Fish script includes the built-in --completions option.""" + """Tests that Fish script includes the built-in --comp option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["fish"]() assert_true( - "-l completions" in script, - msg="Fish script should include -l completions", + "-l comp" in script, + msg="Fish script should include -l comp", ) assert_true( "bash zsh fish" in script, @@ -647,12 +647,12 @@ def test_fish_builtin_completions() raises: def test_zsh_builtin_completions() raises: - """Tests that Zsh script includes the built-in --completions option.""" + """Tests that Zsh script includes the built-in --comp option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["zsh"]() assert_true( - "--completions" in script, - msg="Zsh script should include --completions", + "--comp" in script, + msg="Zsh script should include --comp", ) assert_true( "(bash zsh fish)" in script, @@ -661,12 +661,12 @@ def test_zsh_builtin_completions() raises: def test_bash_builtin_completions() raises: - """Tests that Bash script includes the built-in --completions option.""" + """Tests that Bash script includes the built-in --comp option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["bash"]() assert_true( - "--completions" in script, - msg="Bash script should include --completions", + "--comp" in script, + msg="Bash script should include --comp", ) assert_true( "bash zsh fish" in script, @@ -675,7 +675,7 @@ def test_bash_builtin_completions() raises: def test_disable_default_completions_not_in_script() raises: - """Tests that disable_default_completions() removes --completions from all scripts. + """Tests that disable_default_completions() removes --comp from all scripts. """ var command = Command("myapp", "A test app") command.disable_default_completions() @@ -683,55 +683,55 @@ def test_disable_default_completions_not_in_script() raises: var zsh = command.generate_completion["zsh"]() var bash = command.generate_completion["bash"]() assert_false( - "-l completions" in fish, + "-l comp" in fish, msg=( - "Fish script should NOT include -l completions after" + "Fish script should NOT include -l comp after" " disable_default_completions()" ), ) assert_false( - "--completions" in zsh, + "--comp" in zsh, msg=( - "Zsh script should NOT include --completions after" + "Zsh script should NOT include --comp after" " disable_default_completions()" ), ) assert_false( - "--completions" in bash, + "--comp" in bash, msg=( - "Bash script should NOT include --completions after" + "Bash script should NOT include --comp after" " disable_default_completions()" ), ) def test_disable_default_completions_not_in_help() raises: - """Tests that disable_default_completions() hides --completions from help. + """Tests that disable_default_completions() hides --comp from help. """ var command = Command("myapp", "A test app") command.disable_default_completions() var help_text = command._generate_help(color=False) assert_false( - "--completions" in help_text, + "--comp" in help_text, msg=( - "Help text should NOT include --completions after" + "Help text should NOT include --comp after" " disable_default_completions()" ), ) def test_completions_in_help_by_default() raises: - """Tests that --completions appears in the Options section of help by default. + """Tests that --comp appears in the Options section of help by default. """ var command = Command("myapp", "A test app") var help_text = command._generate_help(color=False) assert_true( - "--completions" in help_text, - msg="Help text should include --completions by default", + "--comp" in help_text, + msg="Help text should include --comp by default", ) assert_true( - "bash,zsh,fish" in help_text or "{bash,zsh,fish}" in help_text, - msg="Help text should show shell choices for --completions", + "" in help_text, + msg="Help text should show placeholder for --comp", ) @@ -752,8 +752,8 @@ def test_completions_custom_name_in_scripts() raises: msg="Fish script should use '-l autocomp' after completions_name()", ) assert_false( - "-l completions" in fish, - msg="Fish script should NOT have '-l completions' after rename", + "-l comp" in fish, + msg="Fish script should NOT have '-l comp' after rename", ) assert_true( "--autocomp[" in zsh, @@ -764,8 +764,8 @@ def test_completions_custom_name_in_scripts() raises: msg="Bash script should use '--autocomp' after completions_name()", ) assert_false( - "--completions" in bash, - msg="Bash script should NOT have '--completions' after rename", + "--comp" in bash, + msg="Bash script should NOT have '--comp' after rename", ) @@ -779,8 +779,8 @@ def test_completions_custom_name_in_help() raises: msg="Help text should show '--autocomp' after completions_name()", ) assert_false( - "--completions" in help_text, - msg="Help text should NOT show '--completions' after rename", + "--comp" in help_text, + msg="Help text should NOT show '--comp' after rename", ) @@ -794,8 +794,8 @@ def test_completions_custom_name_in_bash_prev() raises: msg="Bash prev-case should use '--gen-comp)' after rename", ) assert_false( - "--completions)" in bash, - msg="Bash prev-case should NOT have '--completions)' after rename", + "--comp)" in bash, + msg="Bash prev-case should NOT have '--comp)' after rename", ) @@ -812,19 +812,19 @@ def test_completions_as_subcommand_in_help() raises: command.add_subcommand(sub^) command.completions_as_subcommand() var help_text = command._generate_help(color=False) - # Should NOT appear in Options section as --completions. + # Should NOT appear in Options section as --comp. assert_false( - "--completions" in help_text, + "--comp" in help_text, msg=( - "Help text should NOT show '--completions' in Options" + "Help text should NOT show '--comp' in Options" " when using subcommand mode" ), ) # Should appear in Commands section. assert_true( - "completions" in help_text, + "comp" in help_text, msg=( - "Help text should show 'completions' in Commands section" + "Help text should show 'comp' in Commands section" " when using subcommand mode" ), ) @@ -839,17 +839,17 @@ def test_completions_as_subcommand_in_fish() raises: var fish = command.generate_completion["fish"]() # Should NOT appear as an option. assert_false( - "-l completions" in fish, + "-l comp" in fish, msg=( - "Fish script should NOT have '-l completions' option" + "Fish script should NOT have '-l comp' option" " in subcommand mode" ), ) # Should appear as a subcommand candidate. assert_true( - "-a 'completions'" in fish, + "-a 'comp'" in fish, msg=( - "Fish script should include '-a completions' as subcommand" + "Fish script should include '-a comp' as subcommand" " in subcommand mode" ), ) @@ -864,18 +864,18 @@ def test_completions_as_subcommand_in_zsh() raises: var zsh = command.generate_completion["zsh"]() # Should appear in commands array. assert_true( - "'completions:" in zsh, - msg="Zsh script should include completions in commands array", + "'comp:" in zsh, + msg="Zsh script should include comp in commands array", ) # Should NOT appear as an option. assert_false( - "'--completions[" in zsh, - msg="Zsh script should NOT have '--completions[' option in sub mode", + "'--comp[" in zsh, + msg="Zsh script should NOT have '--comp[' option in sub mode", ) # Should have a subcommand handler. assert_true( - "completions)" in zsh, - msg="Zsh script should have completions) case handler", + "comp)" in zsh, + msg="Zsh script should have comp) case handler", ) @@ -886,19 +886,19 @@ def test_completions_as_subcommand_in_bash() raises: command.add_subcommand(sub^) command.completions_as_subcommand() var bash = command.generate_completion["bash"]() - # Should NOT appear as --completions option. + # Should NOT appear as --comp option. assert_false( - " --completions" in bash, + " --comp" in bash, msg=( - "Bash script should NOT list '--completions' option" + "Bash script should NOT list '--comp' option" " in subcommand mode" ), ) - # Subcommand names should include completions. + # Subcommand names should include comp. assert_true( - "completions" in bash, + "comp" in bash, msg=( - "Bash script should include 'completions' in subcommand" + "Bash script should include 'comp' in subcommand" " names in subcommand mode" ), ) diff --git a/tests/test_help.mojo b/tests/test_help.mojo index c2938d1..b1c8fc4 100644 --- a/tests/test_help.mojo +++ b/tests/test_help.mojo @@ -195,7 +195,8 @@ def test_dynamic_padding_short_options() raises: def test_dynamic_padding_long_options() raises: - """Tests that padding grows when a very long option is present.""" + """Tests that a very long option overflows and its description wraps + to the next line, aligned at the fixed description column.""" var command = Command("test", "Test app") command.add_argument( Argument("very-long-option-name", help="Description").long[ @@ -208,15 +209,18 @@ def test_dynamic_padding_long_options() raises: var help = command._generate_help(color=False) # The longest user arg is "--very-long-option-name " - # The help descriptions should still be aligned. + # which overflows the 32-char option column. Its description should + # appear on the next line, aligned with other descriptions at column 38. var desc_col_long: Int = -1 var desc_col_short: Int = -1 var lines = help.splitlines() for idx in range(len(lines)): if "--very-long-option-name" in lines[idx]: - desc_col_long = lines[idx].find("Description") + # Description overflows to next line. + if idx + 1 < len(lines): + desc_col_long = String(lines[idx + 1]).find("Description") if "-s, --short" in lines[idx]: - desc_col_short = lines[idx].find("Short one") + desc_col_short = String(lines[idx]).find("Short one") assert_true( desc_col_long > 0, msg="Description should appear in long option line", diff --git a/tests/test_options.mojo b/tests/test_options.mojo index 339c6a8..c454952 100644 --- a/tests/test_options.mojo +++ b/tests/test_options.mojo @@ -1109,8 +1109,16 @@ def test_help_deprecated_tag() raises: var help = command._generate_help(color=False) assert_true( - "[deprecated: Use --new instead]" in help, - msg="help should contain deprecated tag", + "[deprecated:" in help, + msg="help should contain deprecated tag opening", + ) + assert_true( + "--new" in help, + msg="help should reference replacement option", + ) + assert_true( + "instead]" in help, + msg="help should contain deprecated message closing", ) From 76fd2e0e9d931abc980d8077a8440aa90e880abd Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 13 Apr 2026 00:00:41 +0200 Subject: [PATCH 2/7] Format --- tests/test_completion.mojo | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/tests/test_completion.mojo b/tests/test_completion.mojo index de98d7a..d8c7ce8 100644 --- a/tests/test_completion.mojo +++ b/tests/test_completion.mojo @@ -706,8 +706,7 @@ def test_disable_default_completions_not_in_script() raises: def test_disable_default_completions_not_in_help() raises: - """Tests that disable_default_completions() hides --comp from help. - """ + """Tests that disable_default_completions() hides --comp from help.""" var command = Command("myapp", "A test app") command.disable_default_completions() var help_text = command._generate_help(color=False) @@ -721,8 +720,7 @@ def test_disable_default_completions_not_in_help() raises: def test_completions_in_help_by_default() raises: - """Tests that --comp appears in the Options section of help by default. - """ + """Tests that --comp appears in the Options section of help by default.""" var command = Command("myapp", "A test app") var help_text = command._generate_help(color=False) assert_true( @@ -840,10 +838,7 @@ def test_completions_as_subcommand_in_fish() raises: # Should NOT appear as an option. assert_false( "-l comp" in fish, - msg=( - "Fish script should NOT have '-l comp' option" - " in subcommand mode" - ), + msg="Fish script should NOT have '-l comp' option in subcommand mode", ) # Should appear as a subcommand candidate. assert_true( @@ -889,10 +884,7 @@ def test_completions_as_subcommand_in_bash() raises: # Should NOT appear as --comp option. assert_false( " --comp" in bash, - msg=( - "Bash script should NOT list '--comp' option" - " in subcommand mode" - ), + msg="Bash script should NOT list '--comp' option in subcommand mode", ) # Subcommand names should include comp. assert_true( From d104f8eb9b761d25ac9479c7d907a58113d72b3e Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Mon, 13 Apr 2026 10:20:19 +0200 Subject: [PATCH 3/7] Fix zsh and fish problems with regard to CJK description --- src/argmojo/command.mojo | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index a9fb0e7..910a9d5 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -4900,8 +4900,7 @@ struct Command(Copyable, Movable, Writable): The escaped text with ``'`` replaced by ``\\'``. """ var result = String("") - for i in range(len(text)): - var ch = text[byte = i : i + 1] + for ch in text.codepoint_slices(): if ch == "'": result += "\\'" else: @@ -5138,16 +5137,15 @@ struct Command(Copyable, Movable, Writable): The escaped text. """ var result = String("") - for i in range(len(text)): - var ch = text[byte = i : i + 1] + for ch in text.codepoint_slices(): if ch == "[" or ch == "]": - result += "\\" + ch + result += "\\" + String(ch) elif ch == "'": result += "'\"'\"'" elif ch == ":": result += "\\:" else: - result += ch + result += String(ch) return result def _completion_bash(self) -> String: From a18c7fdec8e31d801e3b6154a59f496ba726b06d Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 14 Apr 2026 15:42:24 +0200 Subject: [PATCH 4/7] Update roadmap --- docs/argmojo_overall_planning.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index ba64f5b..342d10b 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -535,6 +535,10 @@ if result.subcommand == "search": Some features shipped in v0.3.0, others completed in the unreleased update branch. Remaining items may be deferred to v0.4+. +Note that this phase is long-term polish and enhancement. If I find any other important or interesting features during development, I may add them here as well. + +On contrary, Phase 6 and Phase 7 are more about specific topics (e.g., CJK, declarative API, auto dispatch...) that require some dedicated design and implementation work. There might be standalone planning documents for these topics. + #### Pre-requisite refactor Before adding Phase 5 features, further decompose `parse_arguments()` for readability and maintainability: @@ -582,6 +586,7 @@ Before adding Phase 5 features, further decompose `parse_arguments()` for readab - [x] **Value-name wrapping control** — `.value_name[wrapped: Bool = True]("NAME")` displays custom value names in `` by default (matching clap/cargo/pixi/git convention); pass `False` for bare display (PR #17) - [ ] **Extend `implies()`** - support value-taking options with a default value, e.g., `cmd.implies("debug", "output", "debug.log")` — when `--debug` is set, auto-set `--output` to `"debug.log"`. Currently `implies()` only supports flag/count targets (same as cobra in Go). Revisit when there is a concrete use case. - [ ] **80-character help formatting** — wrap help descriptions at 80 columns with proper indentation (no major library does this by default; users typically pipe through `less` or rely on terminal wrapping) +- [ ] **Comptime string concatenation** — 將 String 的拼接 comptime 化,避免在運行時進行多次拼接(例如錯誤消息、幫助文本等),提升性能。 #### Explicitly Out of Scope in This Phase From 85c74275a7b1e014509c5ed1beecd676d2886e5d Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 14 Apr 2026 18:54:47 +0200 Subject: [PATCH 5/7] Update roadmap --- docs/argmojo_overall_planning.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index 342d10b..85ff7fb 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -585,7 +585,7 @@ Before adding Phase 5 features, further decompose `parse_arguments()` for readab - [x] **`NO_COLOR` env variable** — honour the [no-color.org](https://no-color.org/) standard: if env `NO_COLOR` is set (any value, including empty), suppress all ANSI colour output; lower priority than explicit `.color(False)` API call (PR #9) - [x] **Value-name wrapping control** — `.value_name[wrapped: Bool = True]("NAME")` displays custom value names in `` by default (matching clap/cargo/pixi/git convention); pass `False` for bare display (PR #17) - [ ] **Extend `implies()`** - support value-taking options with a default value, e.g., `cmd.implies("debug", "output", "debug.log")` — when `--debug` is set, auto-set `--output` to `"debug.log"`. Currently `implies()` only supports flag/count targets (same as cobra in Go). Revisit when there is a concrete use case. -- [ ] **80-character help formatting** — wrap help descriptions at 80 columns with proper indentation (no major library does this by default; users typically pipe through `less` or rely on terminal wrapping) +- [x] **80-character help formatting** — wrap help descriptions at 80 columns with proper indentation (no major library does this by default; users typically pipe through `less` or rely on terminal wrapping) - [ ] **Comptime string concatenation** — 將 String 的拼接 comptime 化,避免在運行時進行多次拼接(例如錯誤消息、幫助文本等),提升性能。 #### Explicitly Out of Scope in This Phase From d76640ef48d05573a49f3259f55631320f83907d Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 14 Apr 2026 19:38:59 +0200 Subject: [PATCH 6/7] Address comments --- docs/argmojo_overall_planning.md | 14 +++- src/argmojo/command.mojo | 3 +- src/argmojo/utils.mojo | 3 +- tests/test_completion.mojo | 112 +++++++++++++++++-------------- 4 files changed, 76 insertions(+), 56 deletions(-) diff --git a/docs/argmojo_overall_planning.md b/docs/argmojo_overall_planning.md index 85ff7fb..36c545b 100644 --- a/docs/argmojo_overall_planning.md +++ b/docs/argmojo_overall_planning.md @@ -537,7 +537,7 @@ Some features shipped in v0.3.0, others completed in the unreleased update branc Note that this phase is long-term polish and enhancement. If I find any other important or interesting features during development, I may add them here as well. -On contrary, Phase 6 and Phase 7 are more about specific topics (e.g., CJK, declarative API, auto dispatch...) that require some dedicated design and implementation work. There might be standalone planning documents for these topics. +On the contrary, Phase 6 and Phase 7 are more about specific topics (e.g., CJK, declarative API, auto dispatch...) that require some dedicated design and implementation work. There might be standalone planning documents for these topics. #### Pre-requisite refactor @@ -623,6 +623,18 @@ ArgMojo's differentiating features — no other CLI library addresses CJK-specif **References:** POSIX `wcwidth(3)`, Python `unicodedata.east_asian_width()`, Rust `unicode-width` crate. +##### CJK-aware word wrapping (TODO) + +The current `_wrap_text_at` and `_wrap_description` helpers only split on ASCII spaces. This means: + +- CJK text without spaces (e.g. continuous Chinese/Japanese) will never be broken and may exceed the 80-column width. +- Unicode whitespace characters (e.g. `U+3000` ideographic space, `U+2003` em space) are not recognized as split points. + +Improvements needed: + +- [ ] Split on all Unicode whitespace (not just ASCII space) +- [ ] Add fallback to break long CJK tokens by codepoint/display-width when a single token exceeds the target width + #### 6.2 Full-width → half-width auto-correction ✓ **Problem:** CJK users frequently forget to switch input methods, typing full-width ASCII: diff --git a/src/argmojo/command.mojo b/src/argmojo/command.mojo index 910a9d5..8fb834d 100644 --- a/src/argmojo/command.mojo +++ b/src/argmojo/command.mojo @@ -31,7 +31,6 @@ from .utils import ( _section_desc_layout, _split_on_fullwidth_spaces, _suggest_similar, - _wrap_description, _wrap_text_at, ) @@ -346,7 +345,7 @@ struct Command(Copyable, Movable, Writable): self._tips = List[String]() # ── Shell completions ── self._completions_enabled = True - self._completions_name = String("comp") + self._completions_name = String("completions") self._completions_is_subcommand = False # ── Auto-dispatch ── self._run_function = None diff --git a/src/argmojo/utils.mojo b/src/argmojo/utils.mojo index bcfeff2..7ba4833 100644 --- a/src/argmojo/utils.mojo +++ b/src/argmojo/utils.mojo @@ -23,7 +23,8 @@ comptime _DEFAULT_WARN_COLOR = _ORANGE comptime _DEFAULT_ERROR_COLOR = _RED # ── Help formatting layout constants ───────────────────────────────────────── -# This design is based on my personal aesthetic taste and the typical terminal width of 80 columns. +# Fixed layout values for the two-column help formatter, optimized for +# readable output on standard 80-column terminals. # Per-section layout: INDENT + min(longest, OPT_WIDTH) + GAP + desc + TRAIL = LINE_WIDTH comptime _HELP_LINE_WIDTH: Int = 80 comptime _HELP_INDENT: Int = 2 diff --git a/tests/test_completion.mojo b/tests/test_completion.mojo index d8c7ce8..011cac2 100644 --- a/tests/test_completion.mojo +++ b/tests/test_completion.mojo @@ -633,12 +633,12 @@ def test_generated_by_comment() raises: def test_fish_builtin_completions() raises: - """Tests that Fish script includes the built-in --comp option.""" + """Tests that Fish script includes the built-in --completions option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["fish"]() assert_true( - "-l comp" in script, - msg="Fish script should include -l comp", + "-l completions" in script, + msg="Fish script should include -l completions", ) assert_true( "bash zsh fish" in script, @@ -647,12 +647,12 @@ def test_fish_builtin_completions() raises: def test_zsh_builtin_completions() raises: - """Tests that Zsh script includes the built-in --comp option.""" + """Tests that Zsh script includes the built-in --completions option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["zsh"]() assert_true( - "--comp" in script, - msg="Zsh script should include --comp", + "--completions" in script, + msg="Zsh script should include --completions", ) assert_true( "(bash zsh fish)" in script, @@ -661,12 +661,12 @@ def test_zsh_builtin_completions() raises: def test_bash_builtin_completions() raises: - """Tests that Bash script includes the built-in --comp option.""" + """Tests that Bash script includes the built-in --completions option.""" var command = Command("myapp", "A test app") var script = command.generate_completion["bash"]() assert_true( - "--comp" in script, - msg="Bash script should include --comp", + "--completions" in script, + msg="Bash script should include --completions", ) assert_true( "bash zsh fish" in script, @@ -675,7 +675,7 @@ def test_bash_builtin_completions() raises: def test_disable_default_completions_not_in_script() raises: - """Tests that disable_default_completions() removes --comp from all scripts. + """Tests that disable_default_completions() removes --completions from all scripts. """ var command = Command("myapp", "A test app") command.disable_default_completions() @@ -683,53 +683,55 @@ def test_disable_default_completions_not_in_script() raises: var zsh = command.generate_completion["zsh"]() var bash = command.generate_completion["bash"]() assert_false( - "-l comp" in fish, + "-l completions" in fish, msg=( - "Fish script should NOT include -l comp after" + "Fish script should NOT include -l completions after" " disable_default_completions()" ), ) assert_false( - "--comp" in zsh, + "--completions" in zsh, msg=( - "Zsh script should NOT include --comp after" + "Zsh script should NOT include --completions after" " disable_default_completions()" ), ) assert_false( - "--comp" in bash, + "--completions" in bash, msg=( - "Bash script should NOT include --comp after" + "Bash script should NOT include --completions after" " disable_default_completions()" ), ) def test_disable_default_completions_not_in_help() raises: - """Tests that disable_default_completions() hides --comp from help.""" + """Tests that disable_default_completions() hides --completions from help. + """ var command = Command("myapp", "A test app") command.disable_default_completions() var help_text = command._generate_help(color=False) assert_false( - "--comp" in help_text, + "--completions" in help_text, msg=( - "Help text should NOT include --comp after" + "Help text should NOT include --completions after" " disable_default_completions()" ), ) def test_completions_in_help_by_default() raises: - """Tests that --comp appears in the Options section of help by default.""" + """Tests that --completions appears in the Options section of help by default. + """ var command = Command("myapp", "A test app") var help_text = command._generate_help(color=False) assert_true( - "--comp" in help_text, - msg="Help text should include --comp by default", + "--completions" in help_text, + msg="Help text should include --completions by default", ) assert_true( "" in help_text, - msg="Help text should show placeholder for --comp", + msg="Help text should show placeholder for --completions", ) @@ -750,8 +752,8 @@ def test_completions_custom_name_in_scripts() raises: msg="Fish script should use '-l autocomp' after completions_name()", ) assert_false( - "-l comp" in fish, - msg="Fish script should NOT have '-l comp' after rename", + "-l completions" in fish, + msg="Fish script should NOT have '-l completions' after rename", ) assert_true( "--autocomp[" in zsh, @@ -762,8 +764,8 @@ def test_completions_custom_name_in_scripts() raises: msg="Bash script should use '--autocomp' after completions_name()", ) assert_false( - "--comp" in bash, - msg="Bash script should NOT have '--comp' after rename", + "--completions" in bash, + msg="Bash script should NOT have '--completions' after rename", ) @@ -777,8 +779,8 @@ def test_completions_custom_name_in_help() raises: msg="Help text should show '--autocomp' after completions_name()", ) assert_false( - "--comp" in help_text, - msg="Help text should NOT show '--comp' after rename", + "--completions" in help_text, + msg="Help text should NOT show '--completions' after rename", ) @@ -792,8 +794,8 @@ def test_completions_custom_name_in_bash_prev() raises: msg="Bash prev-case should use '--gen-comp)' after rename", ) assert_false( - "--comp)" in bash, - msg="Bash prev-case should NOT have '--comp)' after rename", + "--completions)" in bash, + msg="Bash prev-case should NOT have '--completions)' after rename", ) @@ -810,19 +812,19 @@ def test_completions_as_subcommand_in_help() raises: command.add_subcommand(sub^) command.completions_as_subcommand() var help_text = command._generate_help(color=False) - # Should NOT appear in Options section as --comp. + # Should NOT appear in Options section as --completions. assert_false( - "--comp" in help_text, + "--completions" in help_text, msg=( - "Help text should NOT show '--comp' in Options" + "Help text should NOT show '--completions' in Options" " when using subcommand mode" ), ) # Should appear in Commands section. assert_true( - "comp" in help_text, + "completions" in help_text, msg=( - "Help text should show 'comp' in Commands section" + "Help text should show 'completions' in Commands section" " when using subcommand mode" ), ) @@ -837,14 +839,17 @@ def test_completions_as_subcommand_in_fish() raises: var fish = command.generate_completion["fish"]() # Should NOT appear as an option. assert_false( - "-l comp" in fish, - msg="Fish script should NOT have '-l comp' option in subcommand mode", + "-l completions" in fish, + msg=( + "Fish script should NOT have '-l completions' option in subcommand" + " mode" + ), ) # Should appear as a subcommand candidate. assert_true( - "-a 'comp'" in fish, + "-a 'completions'" in fish, msg=( - "Fish script should include '-a comp' as subcommand" + "Fish script should include '-a completions' as subcommand" " in subcommand mode" ), ) @@ -859,18 +864,18 @@ def test_completions_as_subcommand_in_zsh() raises: var zsh = command.generate_completion["zsh"]() # Should appear in commands array. assert_true( - "'comp:" in zsh, - msg="Zsh script should include comp in commands array", + "'completions:" in zsh, + msg="Zsh script should include completions in commands array", ) # Should NOT appear as an option. assert_false( - "'--comp[" in zsh, - msg="Zsh script should NOT have '--comp[' option in sub mode", + "'--completions[" in zsh, + msg="Zsh script should NOT have '--completions[' option in sub mode", ) # Should have a subcommand handler. assert_true( - "comp)" in zsh, - msg="Zsh script should have comp) case handler", + "completions)" in zsh, + msg="Zsh script should have completions) case handler", ) @@ -881,16 +886,19 @@ def test_completions_as_subcommand_in_bash() raises: command.add_subcommand(sub^) command.completions_as_subcommand() var bash = command.generate_completion["bash"]() - # Should NOT appear as --comp option. + # Should NOT appear as --completions option. assert_false( - " --comp" in bash, - msg="Bash script should NOT list '--comp' option in subcommand mode", + " --completions" in bash, + msg=( + "Bash script should NOT list '--completions' option in subcommand" + " mode" + ), ) - # Subcommand names should include comp. + # Subcommand names should include completions. assert_true( - "comp" in bash, + "completions" in bash, msg=( - "Bash script should include 'comp' in subcommand" + "Bash script should include 'completions' in subcommand" " names in subcommand mode" ), ) From 8461a07e0944b84578afef07f7f5b7e2b324ca17 Mon Sep 17 00:00:00 2001 From: ZHU Yuhao Date: Tue, 14 Apr 2026 19:58:03 +0200 Subject: [PATCH 7/7] Update workflow --- .github/workflows/run_tests.yaml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/run_tests.yaml b/.github/workflows/run_tests.yaml index 57ff7d3..6d68c63 100644 --- a/.github/workflows/run_tests.yaml +++ b/.github/workflows/run_tests.yaml @@ -144,18 +144,18 @@ jobs: steps: - uses: actions/checkout@v4 - uses: ./.github/actions/setup-mojo - - name: Run example binaries (with retry) + - name: Build example binaries (with retry) run: | for attempt in 1 2 3; do - echo "=== debug attempt $attempt ===" - if pixi run debug; then - echo "=== debug passed on attempt $attempt ===" + echo "=== build attempt $attempt ===" + if pixi run build; then + echo "=== build passed on attempt $attempt ===" break fi if [ "$attempt" -eq 3 ]; then - echo "=== debug failed after 3 attempts ===" + echo "=== build failed after 3 attempts ===" exit 1 fi - echo "=== debug run crashed, retrying in 5s... ===" + echo "=== build run crashed, retrying in 5s... ===" sleep 5 done