From a44319f7935db454dc7eda2b0a31638e6e94b92e Mon Sep 17 00:00:00 2001 From: Kevin Van Brunt Date: Tue, 3 Feb 2026 11:49:12 -0500 Subject: [PATCH] Fixed issue where delimiter_complete() could cause more matches than display matches --- CHANGELOG.md | 5 ++++ cmd2/cmd2.py | 52 ++++++++++++++++++++------------ cmd2/utils.py | 6 +--- tests/test_completion.py | 64 ++++++++++++++++++++++++++++++++-------- 4 files changed, 90 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 172967281..b6cc4080b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py index 9000d7411..20c00cc2f 100644 --- a/cmd2/cmd2.py +++ b/cmd2/cmd2.py @@ -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, diff --git a/cmd2/utils.py b/cmd2/utils.py index b0a03f9b1..367debd7a 100644 --- a/cmd2/utils.py +++ b/cmd2/utils.py @@ -1,7 +1,6 @@ """Shared utility functions.""" import argparse -import collections import contextlib import functools import glob @@ -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()) diff --git a/tests/test_completion.py b/tests/test_completion.py index bd31bd3fa..1b4986f83 100644 --- a/tests/test_completion.py +++ b/tests/test_completion.py @@ -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: @@ -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 @@ -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)