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)]))