diff --git a/cmd2/completion.py b/cmd2/completion.py index f770bb5a0..6733e56c9 100644 --- a/cmd2/completion.py +++ b/cmd2/completion.py @@ -31,6 +31,15 @@ from . import rich_utils as ru +class _UnsetStr(str): + """Internal sentinel to distinguish between an unset and an explicit empty string.""" + + __slots__ = () + + +_UNSET_STR = _UnsetStr("") + + @dataclass(frozen=True, slots=True, kw_only=True) class CompletionItem: """A single completion result.""" @@ -39,17 +48,20 @@ class CompletionItem: # control sequences (like ^J or ^I) in the completion menu. _CONTROL_WHITESPACE_RE = re.compile(r"\r\n|[\n\r\t\f\v]") - # The underlying object this completion represents (e.g., str, int, Path). - # This is used to support argparse choices validation. + # The core object this completion represents (e.g., str, int, Path). + # This serves as the default source for the completion string and is used + # to support object-based validation when used in argparse choices. value: Any = field(kw_only=False) - # The actual string that will be inserted into the command line. - # If not provided, it defaults to str(value). - text: str = "" + # The actual completion string. If not provided, defaults to str(value). + # This can be used to provide a human-friendly alias for complex objects in + # an argparse choices list (requires a matching 'type' converter for validation). + text: str = _UNSET_STR # Optional string for displaying the completion differently in the completion menu. # This can contain ANSI style sequences. A plain version is stored in display_plain. - display: str = "" + # If not provided, defaults to the (possibly computed) value of 'text'. + display: str = _UNSET_STR # Optional meta information about completion which displays in the completion menu. # This can contain ANSI style sequences. A plain version is stored in display_meta_plain. @@ -60,9 +72,8 @@ class CompletionItem: table_data: Sequence[Any] = field(default_factory=tuple) # Plain text versions of display fields (stripped of ANSI) for sorting/filtering. - # These are set in __post_init__(). - display_plain: str = field(init=False) - display_meta_plain: str = field(init=False) + display_plain: str = field(default="", init=False) + display_meta_plain: str = field(default="", init=False) @classmethod def _clean_display(cls, val: str) -> str: @@ -77,13 +88,21 @@ def _clean_display(cls, val: str) -> str: return cls._CONTROL_WHITESPACE_RE.sub(" ", val) def __post_init__(self) -> None: - """Finalize the object after initialization.""" - # Derive text from value if it wasn't explicitly provided - if not self.text: + """Finalize the object after initialization. + + By using the sentinel pattern to distinguish between a field that was never + set and one explicitly blanked out, this handles the two-stage lifecycle: + + 1. Initial creation (usually by a developer-provided choices_provider or completer). + 2. Post-processing by cmd2 via dataclasses.replace(), which may modify fields or + explicitly set them to empty strings. + """ + # If the completion string was not provided, derive it from value. + if isinstance(self.text, _UnsetStr): object.__setattr__(self, "text", str(self.value)) - # Ensure display is never blank. - if not self.display: + # If the display string was not provided, use text. + if isinstance(self.display, _UnsetStr): object.__setattr__(self, "display", self.text) # Clean display and display_meta @@ -163,7 +182,7 @@ class CompletionResultsBase: # True if every item in this collection has a numeric display string. # Used for sorting and alignment. - numeric_display: bool = field(init=False) + numeric_display: bool = field(default=False, init=False) def __post_init__(self) -> None: """Finalize the object after initialization.""" diff --git a/tests/test_completion.py b/tests/test_completion.py index f1c910759..ecc5bcd1a 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -4,6 +4,7 @@ file system paths, and shell commands. """ +import argparse import dataclasses import enum import os @@ -15,6 +16,7 @@ import cmd2 from cmd2 import ( + Choices, CompletionItem, Completions, utils, @@ -1300,3 +1302,140 @@ def test_subcommand_tab_completion_with_no_completer_scu(scu_app) -> None: completions = scu_app.complete(text, line, begidx, endidx) assert not completions + + +def test_set_completion_item_text() -> None: + """Test setting CompletionItem.text and how it affects CompletionItem.display.""" + value = 5 + + # Don't provide text + item = CompletionItem(value=value) + assert item.text == str(value) + + # Provide text + item = CompletionItem(value=value, text="my_text") + assert item.text == "my_text" + + # Provide blank text + item = CompletionItem(value=value, text="") + assert item.text == "" + + +def test_replace_completion_item_text() -> None: + """Test replacing the value of CompletionItem.text""" + value = 5 + + # Replace text value + item = CompletionItem(value=value, text="my_text") + updated_item = dataclasses.replace(item, text="new_text") + assert item.text == "my_text" + assert item.display == "my_text" + + # Text should be updated and display should be the same + assert updated_item.text == "new_text" + assert updated_item.display == "my_text" + + # Replace text value with blank + item = CompletionItem(value=value, text="my_text") + updated_item = dataclasses.replace(item, text="") + assert item.text == "my_text" + assert item.display == "my_text" + + # Text should be updated and display should be the same + assert updated_item.text == "" + assert updated_item.display == "my_text" + + +def test_set_completion_item_display() -> None: + """Test setting CompletionItem.display and how it is affected by CompletionItem.text.""" + value = 5 + + # Don't provide text or display + value = 5 + item = CompletionItem(value=value) + assert item.text == str(value) + assert item.display == item.text + + # Don't provide display but provide text + item = CompletionItem(value=value, text="my_text") + assert item.text == "my_text" + assert item.display == item.text + + # Provide display + item = CompletionItem(value=value, text="my_text", display="my_display") + assert item.text == "my_text" + assert item.display == "my_display" + + # Provide blank display + item = CompletionItem(value=value, text="my_text", display="") + assert item.text == "my_text" + assert item.display == "" + + +def test_replace_completion_item_display() -> None: + """Test replacing the value of CompletionItem.display""" + value = 5 + + # Replace display value + item = CompletionItem(value=value, display="my_display") + updated_item = dataclasses.replace(item, display="new_display") + + assert item.display == "my_display" + assert updated_item.display == "new_display" + + # Replace display value with blank + item = CompletionItem(value=value, display="my_display") + updated_item = dataclasses.replace(item, display="") + + assert item.display == "my_display" + assert updated_item.display == "" + + +def test_full_prefix_removal() -> None: + """Verify that Cmd._perform_completion() can clear item.text when + text_to_remove matches item.text exactly. This occurs when completing + a nested quoted string where the command line already contains the + full unquoted content of the completion match. + """ + + class TestApp(cmd2.Cmd): + def get_choices(self) -> Choices: + """Return choices.""" + choices = [ + "'This is a single-quoted item'", + '"This is a double-quoted item"', + ] + return cmd2.Choices.from_values(choices) + + parser = cmd2.Cmd2ArgumentParser() + parser.add_argument("arg", choices_provider=get_choices) + + @cmd2.with_argparser(parser) + def do_command(self, args: argparse.Namespace) -> None: + """Test stuff.""" + + # Test single-quoted item + text = "" + line = "command \"'This is a single-quoted item'" + endidx = len(line) + begidx = endidx + + app = TestApp() + completions = app.complete(text, line, begidx, endidx) + assert len(completions) == 1 + + item = completions[0] + assert item.text == "" + + # Test double-quoted item + text = "" + line = 'command \'"This is a double-quoted item"' + endidx = len(line) + begidx = endidx + + app = TestApp() + completions = app.complete(text, line, begidx, endidx) + assert len(completions) == 1 + + item = completions[0] + assert item.text == ""