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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions mycli/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions mycli/client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
38 changes: 26 additions & 12 deletions mycli/sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand All @@ -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]] = []
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions test/pytests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions test/pytests/test_client_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
63 changes: 55 additions & 8 deletions test/pytests/test_sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)])

Expand All @@ -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'}])
Expand Down Expand Up @@ -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: [])
Expand Down Expand Up @@ -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)]))

Expand Down
Loading