Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 34 additions & 15 deletions cmd2/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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.
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
139 changes: 139 additions & 0 deletions tests/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
file system paths, and shell commands.
"""

import argparse
import dataclasses
import enum
import os
Expand All @@ -15,6 +16,7 @@

import cmd2
from cmd2 import (
Choices,
CompletionItem,
Completions,
utils,
Expand Down Expand Up @@ -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 == ""
Loading