From bbc1bb4a61382745bc89cf2785388f1335bd76f0 Mon Sep 17 00:00:00 2001 From: Roland Walker Date: Sat, 12 Sep 2026 09:52:50 -0400 Subject: [PATCH] configurable completion-candidate sort tiebreaker The default is frecency, keeping the behavior unchanged, but the user may also choose lexicographic and length sort tiebreakers. This perhaps should have been offered when the frecency feature was added. --- changelog.md | 1 + mycli/client.py | 1 + mycli/client_query.py | 1 + mycli/myclirc | 6 +++ mycli/sqlcompleter.py | 38 +++++++++++++------ test/myclirc | 6 +++ test/pytests/test_client.py | 15 ++++++++ test/pytests/test_client_query.py | 4 ++ test/pytests/test_sqlcompleter.py | 63 +++++++++++++++++++++++++++---- 9 files changed, 115 insertions(+), 20 deletions(-) diff --git a/changelog.md b/changelog.md index e523a61c..e7d0cdc6 100644 --- a/changelog.md +++ b/changelog.md @@ -7,6 +7,7 @@ Features * Make completion candidate match order configurable. * Make approximate-matching completion thresholds configurable. * Make regex matching completion thresholds configurable. +* Make completion-candidate sorting tiebreaker configurable. 2.23.0 (2026/09/09) diff --git a/mycli/client.py b/mycli/client.py index 5b65aa28..6062d301 100644 --- a/mycli/client.py +++ b/mycli/client.py @@ -213,6 +213,7 @@ def __init__( indexed_column_suffix=indexed_column_suffix, config_property_names=get_config_property_names(self.config), completion_match_order=c['main'].as_list('completion_match_order') if 'completion_match_order' in c['main'] else (), + completion_tiebreaker=c['main'].get('completion_tiebreaker', 'frecency') or 'frecency', rapidfuzz_min_length=c['main'].as_int('rapidfuzz_min_length') if c['main'].get('rapidfuzz_min_length') else 4, regex_match_distance=c['main'].as_int('regex_match_distance') if c['main'].get('regex_match_distance') else 3, rapidfuzz_score_cutoff=c['main'].as_float('rapidfuzz_score_cutoff') if c['main'].get('rapidfuzz_score_cutoff') else 75.0, diff --git a/mycli/client_query.py b/mycli/client_query.py index fdfce5ba..ca6bc929 100644 --- a/mycli/client_query.py +++ b/mycli/client_query.py @@ -61,6 +61,7 @@ def refresh_completions(self, reset: bool = False) -> list[SQLResult]: "config_property_names": self.completer.config_property_names, 'frecency_provider': self.completer.frecency_provider, 'completion_match_order': self.completer.completion_match_order, + 'completion_tiebreaker': self.completer.completion_tiebreaker, 'rapidfuzz_min_length': self.completer.rapidfuzz_min_length, 'rapidfuzz_length_coverage': self.completer.rapidfuzz_length_coverage, 'rapidfuzz_score_cutoff': self.completer.rapidfuzz_score_cutoff, diff --git a/mycli/myclirc b/mycli/myclirc index 22acb567..16818269 100644 --- a/mycli/myclirc +++ b/mycli/myclirc @@ -20,6 +20,12 @@ smart_completion = True # * rapidfuzz - true approximate matching, _ie_ autcorrect completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz +# Break completion candiate sorting ties by +# * frecency - history frequency combined with recency, the default +# * length - shortest first +# * lexicographic - alphabetical, ignoring case +completion_tiebreaker = frecency + # Maximum intervening span length between input characters for regex completion # candidate matches. Empty uses the default of 3. Higher is more liberal, but # carries a perforance penalty. diff --git a/mycli/sqlcompleter.py b/mycli/sqlcompleter.py index 752211b8..a9619988 100644 --- a/mycli/sqlcompleter.py +++ b/mycli/sqlcompleter.py @@ -960,6 +960,7 @@ def __init__( rapidfuzz_length_coverage: float = 0.67, rapidfuzz_score_cutoff: float = 75.0, regex_match_distance: int = 3, + completion_tiebreaker: str = 'frecency', ) -> None: super(self.__class__, self).__init__() self.smart_completion = smart_completion @@ -971,6 +972,14 @@ def __init__( self.rapidfuzz_score_cutoff = max(0.0, min(100.0, rapidfuzz_score_cutoff)) self.regex_match_distance = max(0, regex_match_distance) self.completion_config_errors: list[str] = [] + self.completion_tiebreaker: Literal['frecency', 'length', 'lexicographic'] = 'frecency' + tiebreaker = completion_tiebreaker.strip().lower() + if tiebreaker == 'length': + self.completion_tiebreaker = 'length' + elif tiebreaker == 'lexicographic': + self.completion_tiebreaker = 'lexicographic' + elif tiebreaker not in ('', 'frecency'): + self.completion_config_errors.append('Invalid completion_tiebreaker; using frecency.') default_order = tuple(category.name.lower() for category in Fuzziness) order = tuple(name.strip().lower() for name in completion_match_order if name.strip()) if len(set(order)) != len(order) or any(name not in default_order for name in order): @@ -1483,7 +1492,14 @@ def get_completions( last_for_len = last_word(word_before_cursor, include="most_punctuations") text_for_len = last_for_len.lower() path_for_len = word_before_cursor - frecency = self.frecency_provider() if self.frecency_provider is not None else {} + frecency = self.frecency_provider() if self.completion_tiebreaker == 'frecency' and self.frecency_provider is not None else {} + + def tiebreaker_key(candidate: str) -> tuple[float, str]: + if self.completion_tiebreaker == 'length': + return (len(candidate), '') + if self.completion_tiebreaker == 'lexicographic': + return (0, candidate.casefold()) + return (-frecency_score(candidate, frecency) if frecency else 0.0, '') if smart_completion is None: smart_completion = self.smart_completion @@ -1498,8 +1514,8 @@ def get_completions( fuzzy=False, text_before_cursor=document.text_before_cursor, ) - if frecency: - matches = sorted(matches, key=lambda item: -frecency_score(item[0], frecency)) + if frecency or self.completion_tiebreaker != 'frecency': + matches = sorted(matches, key=lambda item: tiebreaker_key(item[0])) return (Completion(x[0], -len(text_for_len)) for x in matches) completions: list[tuple[str, int, int]] = [] @@ -1861,18 +1877,16 @@ def get_completions( ] break - def completion_sort_key(item: tuple[str, int, int], text_for_len: str) -> tuple[int, int, float, int]: + def completion_sort_key(item: tuple[str, int, int], text_for_len: str) -> tuple[int, int, tuple[float, str], int]: candidate, fuzziness, rank = item - candidate_frecency = frecency_score(candidate, frecency) if frecency else 0.0 + tiebreaker = tiebreaker_key(candidate) if not text_for_len: - # Sort by the rank (the order of the completion type), then frecency. - return (0, rank, -candidate_frecency, 0) + return (0, rank, tiebreaker, 0) elif candidate.lower().startswith(text_for_len): - # Direct prefix matches are equally relevant; prefer frecency before length. - return (0, 0, -candidate_frecency, -1000 + len(candidate)) - # Sort by fuzziness, rank, and frecency. - # todo add alpha here, or original order? - return (self._match_priorities[fuzziness], rank, -candidate_frecency, 0) + # Preserve the shorter-prefix fallback for equal frecency scores. + length = -1000 + len(candidate) if self.completion_tiebreaker == 'frecency' else 0 + return (0, 0, tiebreaker, length) + return (self._match_priorities[fuzziness], rank, tiebreaker, 0) if rigid_sort: uniq_completions_str = dict.fromkeys(x[0] for x in completions) diff --git a/test/myclirc b/test/myclirc index cb66041f..6d7808fe 100644 --- a/test/myclirc +++ b/test/myclirc @@ -20,6 +20,12 @@ smart_completion = True # * rapidfuzz - true approximate matching, _ie_ autcorrect completion_match_order = perfect, regex, under_words, slash_words, camel_case, rapidfuzz +# Break completion candiate sorting ties by +# * frecency - history frequency combined with recency, the default +# * length - shortest first +# * lexicographic - alphabetical, ignoring case +completion_tiebreaker = frecency + # Maximum intervening span length between input characters for regex completion # candidate matches. Empty uses the default of 3. Higher is more liberal, but # carries a perforance penalty. diff --git a/test/pytests/test_client.py b/test/pytests/test_client.py index f8dc38a8..6373c686 100644 --- a/test/pytests/test_client.py +++ b/test/pytests/test_client.py @@ -36,6 +36,21 @@ def test_init_configures_completion_ranking(monkeypatch: pytest.MonkeyPatch, tmp assert cli.completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz') +@pytest.mark.parametrize( + ('value', 'expected'), + [(None, 'frecency'), ('', 'frecency'), ('length', 'length'), ('LEXICOGRAPHIC', 'lexicographic'), ('invalid', 'frecency')], +) +def test_init_configures_completion_tiebreaker(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: str) -> None: + patch_constructor_side_effects(monkeypatch) + messages: list[str] = [] + monkeypatch.setattr(MyCli, 'echo', lambda self, message, **kwargs: messages.append(message)) + setting = f'completion_tiebreaker = {value}\n' if value is not None else '' + cli = MyCli(myclirc=write_myclirc(tmp_path, f'[main]\n{setting}')) + + assert cli.completer.completion_tiebreaker == expected + assert messages == (['Invalid completion_tiebreaker; using frecency.'] if value == 'invalid' else []) + + @pytest.mark.parametrize(('value', 'expected'), [(None, 3), ('', 3), ('5', 5), ('0', 0), ('-1', 0)]) def test_init_configures_regex_match_distance(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, value: str | None, expected: int) -> None: patch_constructor_side_effects(monkeypatch) diff --git a/test/pytests/test_client_query.py b/test/pytests/test_client_query.py index 1fe3cb57..37b23ac1 100644 --- a/test/pytests/test_client_query.py +++ b/test/pytests/test_client_query.py @@ -29,6 +29,7 @@ def make_refresh_cli() -> tuple[Any, dict[str, Any]]: config_property_names=('main.show_warnings',), frecency_provider=lambda: {'select': 1.0}, completion_match_order=('under_words', 'regex'), + completion_tiebreaker='length', rapidfuzz_min_length=2, rapidfuzz_length_coverage=0.5, rapidfuzz_score_cutoff=82.5, @@ -80,6 +81,7 @@ def test_refresh_completions_passes_options_to_refresher() -> None: 'config_property_names': ('main.show_warnings',), 'frecency_provider': cli.completer.frecency_provider, 'completion_match_order': ('under_words', 'regex'), + 'completion_tiebreaker': 'length', 'rapidfuzz_min_length': 2, 'rapidfuzz_length_coverage': 0.5, 'rapidfuzz_score_cutoff': 82.5, @@ -115,6 +117,7 @@ def test_refresh_completions_updates_dbname_when_reset() -> None: config_property_names=(), frecency_provider=None, completion_match_order=(), + completion_tiebreaker='frecency', rapidfuzz_min_length=4, rapidfuzz_length_coverage=0.67, rapidfuzz_score_cutoff=75.0, @@ -141,6 +144,7 @@ def test_refresh_completions_uses_lock_when_reset() -> None: config_property_names=(), frecency_provider=None, completion_match_order=(), + completion_tiebreaker='frecency', rapidfuzz_min_length=4, rapidfuzz_length_coverage=0.67, rapidfuzz_score_cutoff=75.0, diff --git a/test/pytests/test_sqlcompleter.py b/test/pytests/test_sqlcompleter.py index d30b93f8..77c4fe03 100644 --- a/test/pytests/test_sqlcompleter.py +++ b/test/pytests/test_sqlcompleter.py @@ -442,6 +442,47 @@ def provider() -> dict[str, float]: assert completer.frecency_provider is provider +@pytest.mark.parametrize('smart', [True, False]) +@pytest.mark.parametrize('text', ['', 'a']) +@pytest.mark.parametrize( + ('tiebreaker', 'expected'), + [('frecency', ['azure', 'a', 'Alpha']), ('length', ['a', 'azure', 'Alpha']), ('lexicographic', ['a', 'Alpha', 'azure'])], +) +def test_completion_tiebreaker_orders_candidates( + monkeypatch: pytest.MonkeyPatch, smart: bool, text: str, tiebreaker: str, expected: list[str] +) -> None: + def history() -> dict[str, float]: + if tiebreaker != 'frecency': + pytest.fail('Alternative tie-breakers must not read history.') + return {'azure': 20.0} + + completer = make_completer(smart_completion=smart, completion_tiebreaker=tiebreaker, frecency_provider=history) + monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}]) + monkeypatch.setattr( + completer, 'find_matches', lambda *args, **kwargs: [('azure', Fuzziness.REGEX), ('a', Fuzziness.REGEX), ('Alpha', Fuzziness.REGEX)] + ) + + assert [c.text for c in completer.get_completions(Document(text), None)] == expected + + +@pytest.mark.parametrize('smart', [True, False]) +@pytest.mark.parametrize('tiebreaker', ['length', 'lexicographic']) +def test_equal_tiebreaker_keys_preserve_order(monkeypatch: pytest.MonkeyPatch, smart: bool, tiebreaker: str) -> None: + completer = make_completer(smart_completion=smart, completion_tiebreaker=tiebreaker) + monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}]) + monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('foo', Fuzziness.REGEX), ('FOO', Fuzziness.REGEX)]) + + assert [c.text for c in completer.get_completions(Document('f'), None)] == ['foo', 'FOO'] + + +def test_frecency_without_history_preserves_shorter_prefix_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + completer = make_completer() + monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}]) + monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('alphabet', Fuzziness.REGEX), ('ant', Fuzziness.REGEX)]) + + assert [c.text for c in completer.get_completions(Document('a'), None)] == ['ant', 'alphabet'] + + @pytest.mark.parametrize('order', [(), ('',), ('invalid',), ('regex', 'REGEX')]) def test_completion_match_order_defaults_and_validation(order: tuple[str, ...]) -> None: completer = SQLCompleter(completion_match_order=order) @@ -476,8 +517,11 @@ def test_rapidfuzz_can_replace_an_overlapping_category(monkeypatch: pytest.Monke assert list(completer.find_matches('alph', ['alphabet'])) == [('alphabet', Fuzziness.RAPIDFUZZ)] -def test_prefix_priority_precedes_frecency(monkeypatch: pytest.MonkeyPatch) -> None: - completer = make_completer(completion_match_order=('rapidfuzz',), frecency_provider=lambda: {'alpha': 100.0}) +@pytest.mark.parametrize('tiebreaker', ['frecency', 'length', 'lexicographic']) +def test_prefix_priority_precedes_frecency(monkeypatch: pytest.MonkeyPatch, tiebreaker: str) -> None: + completer = make_completer( + completion_match_order=('rapidfuzz',), frecency_provider=lambda: {'alpha': 100.0}, completion_tiebreaker=tiebreaker + ) monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}]) monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('alpha', Fuzziness.RAPIDFUZZ), ('prefix', Fuzziness.REGEX)]) @@ -492,8 +536,9 @@ def test_custom_match_priority_sorts_candidates(monkeypatch: pytest.MonkeyPatch) assert [c.text for c in completer.get_completions(Document('x'), None)] == ['bravo', 'alpha'] -def test_completion_type_precedes_frecency_for_empty_input(monkeypatch: pytest.MonkeyPatch) -> None: - completer = make_completer(frecency_provider=lambda: {'popular': 10.0}) +@pytest.mark.parametrize('tiebreaker', ['frecency', 'length', 'lexicographic']) +def test_completion_type_precedes_frecency_for_empty_input(monkeypatch: pytest.MonkeyPatch, tiebreaker: str) -> None: + completer = make_completer(frecency_provider=lambda: {'popular': 10.0}, completion_tiebreaker=tiebreaker) completer.keywords = ['popular'] completer.functions = ['other'] monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'function', 'schema': None}, {'type': 'keyword'}]) @@ -539,8 +584,9 @@ def test_get_completions_uses_frecency_before_prefix_length(monkeypatch) -> None assert result == ['alphabet', 'ant'] -def test_get_completions_preserves_stronger_fuzzy_match(monkeypatch) -> None: - completer = make_completer(frecency_provider=lambda: {'far': 100.0}) +@pytest.mark.parametrize('tiebreaker', ['frecency', 'length', 'lexicographic']) +def test_get_completions_preserves_stronger_fuzzy_match(monkeypatch, tiebreaker: str) -> None: + completer = make_completer(frecency_provider=lambda: {'far': 100.0}, completion_tiebreaker=tiebreaker) monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda text, before: [{'type': 'column', 'tables': []}]) monkeypatch.setattr(completer, 'populate_scoped_cols', lambda tables: ['foo', 'far']) monkeypatch.setattr(completer, 'populate_scoped_indexed_columns', lambda tables: []) @@ -568,8 +614,9 @@ def test_naive_completions_use_live_frecency_provider() -> None: assert second == ['alpha', 'bravo'] -def test_file_completions_preserve_rigid_ordering(monkeypatch) -> None: - completer = make_completer(frecency_provider=lambda: {'alpha': 100.0}) +@pytest.mark.parametrize('tiebreaker', ['frecency', 'length', 'lexicographic']) +def test_file_completions_preserve_rigid_ordering(monkeypatch, tiebreaker: str) -> None: + completer = make_completer(frecency_provider=lambda: {'alpha': 100.0}, completion_tiebreaker=tiebreaker) monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda text, before: [{'type': 'file_name'}]) monkeypatch.setattr(completer, 'find_files', lambda word: iter([('zeta', 0), ('alpha', 0)]))