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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
## 3.1.3 (TBD)

- Bug Fixes
- Fixed issue where `delimiter_complete()` could cause more matches than display matches

## 3.1.2 (January 26, 2026)

- Bug Fixes
Expand Down
52 changes: 33 additions & 19 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1757,31 +1757,45 @@ def delimiter_complete(
:return: a list of possible tab completions
"""
matches = self.basic_complete(text, line, begidx, endidx, match_against)
if not matches:
return []

# Display only the portion of the match that's being completed based on delimiter
if matches:
# Set this to True for proper quoting of matches with spaces
self.matches_delimited = True
# Set this to True for proper quoting of matches with spaces
self.matches_delimited = True

# Get the common beginning for the matches
common_prefix = os.path.commonprefix(matches)
prefix_tokens = common_prefix.split(delimiter)
# Get the common beginning for the matches
common_prefix = os.path.commonprefix(matches)
prefix_tokens = common_prefix.split(delimiter)

# Calculate what portion of the match we are completing
display_token_index = 0
if prefix_tokens:
display_token_index = len(prefix_tokens) - 1
# Calculate what portion of the match we are completing
display_token_index = 0
if prefix_tokens:
display_token_index = len(prefix_tokens) - 1

# Get this portion for each match and store them in self.display_matches
for cur_match in matches:
match_tokens = cur_match.split(delimiter)
display_token = match_tokens[display_token_index]
# Remove from each match everything after where the user is completing.
# This approach can result in duplicates so we will filter those out.
unique_results: dict[str, str] = {}

if not display_token:
display_token = delimiter
self.display_matches.append(display_token)
for cur_match in matches:
match_tokens = cur_match.split(delimiter)

return matches
filtered_match = delimiter.join(match_tokens[: display_token_index + 1])
display_match = match_tokens[display_token_index]

# If there are more tokens, then we aren't done completing a full item
if len(match_tokens) > display_token_index + 1:
filtered_match += delimiter
display_match += delimiter
self.allow_appended_space = False
self.allow_closing_quote = False

if filtered_match not in unique_results:
unique_results[filtered_match] = display_match

filtered_matches = list(unique_results.keys())
self.display_matches = list(unique_results.values())

return filtered_matches

def flag_based_complete(
self,
Expand Down
6 changes: 1 addition & 5 deletions cmd2/utils.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""Shared utility functions."""

import argparse
import collections
import contextlib
import functools
import glob
Expand Down Expand Up @@ -192,10 +191,7 @@ def remove_duplicates(list_to_prune: list[_T]) -> list[_T]:
:param list_to_prune: the list being pruned of duplicates
:return: The pruned list
"""
temp_dict: collections.OrderedDict[_T, Any] = collections.OrderedDict()
for item in list_to_prune:
temp_dict[item] = None

temp_dict = dict.fromkeys(list_to_prune)
return list(temp_dict.keys())


Expand Down
64 changes: 51 additions & 13 deletions tests/test_completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -716,19 +716,53 @@ def test_basic_completion_nomatch(cmd2_app) -> None:
assert cmd2_app.basic_complete(text, line, begidx, endidx, food_item_strs) == []


def test_delimiter_completion(cmd2_app) -> None:
def test_delimiter_completion_partial(cmd2_app) -> None:
"""Test that a delimiter is added when an item has not been fully completed"""
text = '/home/'
line = f'run_script {text}'
line = f'command {text}'
endidx = len(line)
begidx = endidx - len(text)

matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')

# All matches end with the delimiter
matches.sort(key=cmd2_app.default_sort_key)
expected_matches = sorted(["/home/other user/", "/home/user/"], key=cmd2_app.default_sort_key)

cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key)
expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key)

assert matches == expected_matches
assert cmd2_app.display_matches == expected_display


def test_delimiter_completion_full(cmd2_app) -> None:
"""Test that no delimiter is added when an item has been fully completed"""
text = '/home/other user/'
line = f'command {text}'
endidx = len(line)
begidx = endidx - len(text)

cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')
matches = cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/')

# No matches end with the delimiter
matches.sort(key=cmd2_app.default_sort_key)
expected_matches = sorted(["/home/other user/maps", "/home/other user/tests"], key=cmd2_app.default_sort_key)

# Remove duplicates from display_matches and sort it. This is typically done in complete().
display_list = utils.remove_duplicates(cmd2_app.display_matches)
display_list = utils.alphabetical_sort(display_list)
cmd2_app.display_matches.sort(key=cmd2_app.default_sort_key)
expected_display = sorted(["maps", "tests"], key=cmd2_app.default_sort_key)

assert display_list == ['other user', 'user']
assert matches == expected_matches
assert cmd2_app.display_matches == expected_display


def test_delimiter_completion_nomatch(cmd2_app) -> None:
text = '/nothing_to_see'
line = f'command {text}'
endidx = len(line)
begidx = endidx - len(text)

assert cmd2_app.delimiter_complete(text, line, begidx, endidx, delimited_strs, '/') == []


def test_flag_based_completion_single(cmd2_app) -> None:
Expand Down Expand Up @@ -964,20 +998,24 @@ def test_add_opening_quote_delimited_no_text(cmd2_app) -> None:
endidx = len(line)
begidx = endidx - len(text)

# The whole list will be returned with no opening quotes added
# Matches returned with no opening quote
expected_matches = sorted(["/home/other user/", "/home/user/"], key=cmd2_app.default_sort_key)
expected_display = sorted(["other user/", "user/"], key=cmd2_app.default_sort_key)

first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
assert first_match is not None
assert cmd2_app.completion_matches == sorted(delimited_strs, key=cmd2_app.default_sort_key)
assert cmd2_app.completion_matches == expected_matches
assert cmd2_app.display_matches == expected_display


def test_add_opening_quote_delimited_nothing_added(cmd2_app) -> None:
text = '/ho'
text = '/home/'
line = f'test_delimited {text}'
endidx = len(line)
begidx = endidx - len(text)

expected_matches = sorted(delimited_strs, key=cmd2_app.default_sort_key)
expected_display = sorted(['other user', 'user'], key=cmd2_app.default_sort_key)
expected_matches = sorted(['/home/other user/', '/home/user/'], key=cmd2_app.default_sort_key)
expected_display = sorted(['other user/', 'user/'], key=cmd2_app.default_sort_key)

first_match = complete_tester(text, line, begidx, endidx, cmd2_app)
assert first_match is not None
Expand Down Expand Up @@ -1017,7 +1055,7 @@ def test_add_opening_quote_delimited_text_is_common_prefix(cmd2_app) -> None:


def test_add_opening_quote_delimited_space_in_prefix(cmd2_app) -> None:
# This test when a space appears before the part of the string that is the display match
# This tests when a space appears before the part of the string that is the display match
text = '/home/oth'
line = f'test_delimited {text}'
endidx = len(line)
Expand Down
Loading