Skip to content

Commit bf86bd0

Browse files
authored
Fix rendering of control whitespace in completion items (#1622)
1 parent d404476 commit bf86bd0

File tree

3 files changed

+77
-35
lines changed

3 files changed

+77
-35
lines changed

cmd2/argparse_completer.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,6 @@
3333
from .completion import (
3434
CompletionItem,
3535
Completions,
36-
all_display_numeric,
3736
)
3837
from .constants import INFINITY
3938
from .exceptions import CompletionError
@@ -647,12 +646,15 @@ def _build_completion_table(self, arg_state: _ArgumentState, completions: Comple
647646
# the 3rd or more argument here.
648647
destination = destination[min(len(destination) - 1, arg_state.count)]
649648

650-
# Determine if all display values are numeric so we can right-align them
651-
all_nums = all_display_numeric(completions.items)
652-
653649
# Build header row
654650
rich_columns: list[Column] = []
655-
rich_columns.append(Column(destination.upper(), justify="right" if all_nums else "left", no_wrap=True))
651+
rich_columns.append(
652+
Column(
653+
destination.upper(),
654+
justify="right" if completions.numeric_display else "left",
655+
no_wrap=True,
656+
)
657+
)
656658
rich_columns.extend(
657659
column if isinstance(column, Column) else Column(column, overflow="fold") for column in table_columns
658660
)

cmd2/completion.py

Lines changed: 46 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import re
44
import sys
55
from collections.abc import (
6-
Collection,
76
Iterable,
87
Iterator,
98
Sequence,
@@ -31,26 +30,15 @@
3130

3231
from . import rich_utils as ru
3332

34-
# Regular expression to identify strings which we should sort numerically
35-
NUMERIC_RE = re.compile(
36-
r"""
37-
^ # Start of string
38-
[-+]? # Optional sign
39-
(?: # Start of non-capturing group
40-
\d+\.?\d* # Matches 123 or 123. or 123.45
41-
| # OR
42-
\.\d+ # Matches .45
43-
) # End of group
44-
$ # End of string
45-
""",
46-
re.VERBOSE,
47-
)
48-
4933

5034
@dataclass(frozen=True, slots=True, kw_only=True)
5135
class CompletionItem:
5236
"""A single completion result."""
5337

38+
# Regular expression to identify whitespace characters that are rendered as
39+
# control sequences (like ^J or ^I) in the completion menu.
40+
_CONTROL_WHITESPACE_RE = re.compile(r'\r\n|[\n\r\t\f\v]')
41+
5442
# The underlying object this completion represents (e.g., str, int, Path).
5543
# This is used to support argparse choices validation.
5644
value: Any = field(kw_only=False)
@@ -76,6 +64,18 @@ class CompletionItem:
7664
display_plain: str = field(init=False)
7765
display_meta_plain: str = field(init=False)
7866

67+
@classmethod
68+
def _clean_display(cls, val: str) -> str:
69+
"""Clean a string for display in the completion menu.
70+
71+
This replaces whitespace characters that are rendered as
72+
control sequences (like ^J or ^I) with spaces.
73+
74+
:param val: string to be cleaned
75+
:return: the cleaned string
76+
"""
77+
return cls._CONTROL_WHITESPACE_RE.sub(' ', val)
78+
7979
def __post_init__(self) -> None:
8080
"""Finalize the object after initialization."""
8181
# Derive text from value if it wasn't explicitly provided
@@ -86,7 +86,11 @@ def __post_init__(self) -> None:
8686
if not self.display:
8787
object.__setattr__(self, "display", self.text)
8888

89-
# Pre-calculate plain text versions by stripping ANSI sequences.
89+
# Clean display and display_meta
90+
object.__setattr__(self, "display", self._clean_display(self.display))
91+
object.__setattr__(self, "display_meta", self._clean_display(self.display_meta))
92+
93+
# Create plain text versions by stripping ANSI sequences.
9094
# These are stored as attributes for fast access during sorting/filtering.
9195
object.__setattr__(self, "display_plain", su.strip_style(self.display))
9296
object.__setattr__(self, "display_meta_plain", su.strip_style(self.display_meta))
@@ -135,20 +139,44 @@ def __hash__(self) -> int:
135139
class CompletionResultsBase:
136140
"""Base class for results containing a collection of CompletionItems."""
137141

142+
# Regular expression to identify strings that we should sort numerically
143+
_NUMERIC_RE = re.compile(
144+
r"""
145+
^ # Start of string
146+
[-+]? # Optional sign
147+
(?: # Start of non-capturing group
148+
\d+\.?\d* # Matches 123 or 123. or 123.45
149+
| # OR
150+
\.\d+ # Matches .45
151+
) # End of group
152+
$ # End of string
153+
""",
154+
re.VERBOSE,
155+
)
156+
138157
# The collection of CompletionItems. This is stored internally as a tuple.
139158
items: Sequence[CompletionItem] = field(default_factory=tuple, kw_only=False)
140159

141160
# If True, indicates the items are already provided in the desired display order.
142161
# If False, items will be sorted by their display value during initialization.
143162
is_sorted: bool = False
144163

164+
# True if every item in this collection has a numeric display string.
165+
# Used for sorting and alignment.
166+
numeric_display: bool = field(init=False)
167+
145168
def __post_init__(self) -> None:
146169
"""Finalize the object after initialization."""
147170
from . import utils
148171

149172
unique_items = utils.remove_duplicates(self.items)
173+
174+
# Determine if all items have numeric display strings
175+
numeric_display = bool(unique_items) and all(self._NUMERIC_RE.match(i.display_plain) for i in unique_items)
176+
object.__setattr__(self, "numeric_display", numeric_display)
177+
150178
if not self.is_sorted:
151-
if all_display_numeric(unique_items):
179+
if self.numeric_display:
152180
# Sort numerically
153181
unique_items.sort(key=lambda item: float(item.display_plain))
154182
else:
@@ -249,8 +277,3 @@ class Completions(CompletionResultsBase):
249277

250278
# The quote character to use if adding an opening or closing quote to the matches.
251279
_quote_char: str = ""
252-
253-
254-
def all_display_numeric(items: Collection[CompletionItem]) -> bool:
255-
"""Return True if items is non-empty and every item.display_plain value is a numeric string."""
256-
return bool(items) and all(NUMERIC_RE.match(item.display_plain) for item in items)

tests/test_completion.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
Completions,
2020
utils,
2121
)
22-
from cmd2.completion import all_display_numeric
2322

2423
from .conftest import (
2524
normalize,
@@ -877,7 +876,7 @@ def test_is_sorted() -> None:
877876

878877

879878
@pytest.mark.parametrize(
880-
('values', 'all_nums'),
879+
('values', 'numeric_display'),
881880
[
882881
([2, 3], True),
883882
([2, 3.7], True),
@@ -889,11 +888,10 @@ def test_is_sorted() -> None:
889888
(["\x1b[31mNOT_STRING\x1b[0m", "\x1b[32m9.2\x1b[0m"], False),
890889
],
891890
)
892-
def test_all_display_numeric(values: list[int | float | str], all_nums: bool) -> None:
893-
"""Test that all_display_numeric() evaluates the display_plain field."""
894-
895-
items = [CompletionItem(v) for v in values]
896-
assert all_display_numeric(items) == all_nums
891+
def test_numeric_display(values: list[int | float | str], numeric_display: bool) -> None:
892+
"""Test setting of the Completions.numeric_display field."""
893+
completions = Completions.from_values(values)
894+
assert completions.numeric_display == numeric_display
897895

898896

899897
def test_remove_duplicates() -> None:
@@ -932,6 +930,25 @@ def test_plain_fields() -> None:
932930
assert completion_item.display_meta_plain == "A tasty apple"
933931

934932

933+
def test_clean_display() -> None:
934+
"""Test display string cleaning in CompletionItem."""
935+
# Test all problematic characters being replaced by a single space.
936+
# Also verify that \r\n is replaced by a single space.
937+
display = "str1\r\nstr2\nstr3\rstr4\tstr5\fstr6\vstr7"
938+
expected = "str1 str2 str3 str4 str5 str6 str7"
939+
940+
# Since display defaults to text if not provided, we test both text and display fields
941+
completion_item = CompletionItem("item", display=display, display_meta=display)
942+
assert completion_item.display == expected
943+
assert completion_item.display_meta == expected
944+
945+
# Verify that text derived display is also sanitized
946+
text = "item\nwith\nnewlines"
947+
expected_text_display = "item with newlines"
948+
completion_item = CompletionItem(text)
949+
assert completion_item.display == expected_text_display
950+
951+
935952
def test_styled_completion_sort() -> None:
936953
"""Test that sorting is done with the display_plain field."""
937954

0 commit comments

Comments
 (0)