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
8 changes: 8 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
Upcoming (TBD)
==============

Bug Fixes
--------
* Omitted completion-candidate methods are no longer applied at all.


2.24.0 (2026/09/12)
==============

Expand Down
6 changes: 3 additions & 3 deletions mycli/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ show_warnings = False
# possible completions will be listed.
smart_completion = True

# Methods to find completion candidates, highest priority first. Omitted
# methods follow in the default order; empty uses the default. Filename
# and Polars completions retain their own dedicated ordering.
# Methods to find completion candidates, in priority order. Empty means the
# default list. Filename matching requires perfect and/or slash_words to be
# listed. Polars completions uses its own methods.
# * perfect - exact leading match
# * regex - ordered charater match with limited intervening spans ("rgx" matches "regex")
# * under_words - like regex but follows underscores ("uw" matches "under_words")
Expand Down
41 changes: 27 additions & 14 deletions mycli/sqlcompleter.py
Original file line number Diff line number Diff line change
Expand Up @@ -985,7 +985,7 @@ def __init__(
if len(set(order)) != len(order) or any(name not in default_order for name in order):
self.completion_config_errors.append('Invalid completion_match_order; using the default order.')
order = ()
self.completion_match_order = order + tuple(name for name in default_order if name not in order)
self.completion_match_order = order or default_order
self._match_priorities: dict[int, int] = {
Fuzziness[name.upper()]: priority for priority, name in enumerate(self.completion_match_order)
}
Expand Down Expand Up @@ -1337,12 +1337,12 @@ def word_parts_match(
def find_fuzzy_match(
self,
item: str,
pattern: re.Pattern[str],
pattern: re.Pattern[str] | None,
under_words_text: list[str],
case_words_text: list[str],
) -> int | None:
for name in self.completion_match_order:
if name == 'regex' and pattern.search(item.lower()):
if name == 'regex' and pattern is not None and pattern.search(item.lower()):
return Fuzziness.REGEX
if name == 'under_words':
under_words_item = [x for x in item.lower().split('_') if x]
Expand All @@ -1362,17 +1362,22 @@ def find_fuzzy_matches(
collection: Collection[Any],
) -> list[tuple[str, int]]:
completions: list[tuple[str, int]] = []
regex = f'.{{0,{self.regex_match_distance}}}?'.join(map(re.escape, text))
pattern = re.compile(f'({regex})')
under_words_text = [x for x in text.split('_') if x]
case_words_text = re.split(_CASE_CHANGE_PAT, last)
pattern = None
if 'regex' in self.completion_match_order:
regex = f'.{{0,{self.regex_match_distance}}}?'.join(map(re.escape, text))
pattern = re.compile(f'({regex})')
under_words_text = [x for x in text.split('_') if x] if 'under_words' in self.completion_match_order else []
case_words_text = re.split(_CASE_CHANGE_PAT, last) if 'camel_case' in self.completion_match_order else []

for item in collection:
fuzziness = self.find_fuzzy_match(item, pattern, under_words_text, case_words_text)
if 'perfect' in self.completion_match_order and self._matches_prefix(item, text):
if fuzziness is None or self._match_priorities[Fuzziness.PERFECT] < self._match_priorities[fuzziness]:
fuzziness = Fuzziness.PERFECT
if fuzziness is not None:
completions.append((item, fuzziness))

if len(text) >= self.rapidfuzz_min_length:
if 'rapidfuzz' in self.completion_match_order and len(text) >= self.rapidfuzz_min_length:
rapidfuzz_matches = rapidfuzz.process.extract(
text,
collection,
Expand All @@ -1397,17 +1402,22 @@ def find_fuzzy_matches(

return completions

def _matches_prefix(self, candidate: str, text: str) -> bool:
if not text.startswith('`'):
candidate = self._strip_backticks(candidate)
return candidate.lower().startswith(text)

def find_perfect_matches(
self,
text: str,
collection: Collection[Any],
start_only: bool,
) -> list[tuple[str, int]]:
if 'perfect' not in self.completion_match_order:
return []
completions: list[tuple[str, int]] = []
match_end_limit = len(text) if start_only else None
for item in collection:
match_point = item.lower().find(text, 0, match_end_limit)
if match_point >= 0:
if self._matches_prefix(item, text) if start_only else text in item.lower():
completions.append((item, Fuzziness.PERFECT))
return completions

Expand Down Expand Up @@ -1882,7 +1892,7 @@ def completion_sort_key(item: tuple[str, int, int], text_for_len: str) -> tuple[
tiebreaker = tiebreaker_key(candidate)
if not text_for_len:
return (0, rank, tiebreaker, 0)
elif candidate.lower().startswith(text_for_len):
elif self._matches_prefix(candidate, text_for_len):
# 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)
Expand Down Expand Up @@ -1929,10 +1939,13 @@ def find_files(self, word: str, *, sql_only: bool = True) -> Generator[tuple[str

"""
if '/' in word:
for path in suggest_path_by_prefix(word, sql_only=sql_only):
yield (path, Fuzziness.SLASH_WORDS)
if 'slash_words' in self.completion_match_order:
for path in suggest_path_by_prefix(word, sql_only=sql_only):
yield (path, Fuzziness.SLASH_WORDS)
return

if 'perfect' not in self.completion_match_order:
return
# todo position is ignored, but may need to be used
base_path, last_path, position = parse_path(word)
paths = suggest_path(word, sql_only=sql_only)
Expand Down
6 changes: 3 additions & 3 deletions test/myclirc
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ show_warnings = False
# possible completions will be listed.
smart_completion = True

# Methods to find completion candidates, highest priority first. Omitted
# methods follow in the default order; empty uses the default. Filename
# and Polars completions retain their own dedicated ordering.
# Methods to find completion candidates, in priority order. Empty means the
# default list. Filename matching requires perfect and/or slash_words to be
# listed. Polars completions uses its own methods.
# * perfect - exact leading match
# * regex - ordered charater match with limited intervening spans ("rgx" matches "regex")
# * under_words - like regex but follows underscores ("uw" matches "under_words")
Expand Down
2 changes: 1 addition & 1 deletion test/pytests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ def test_init_configures_completion_ranking(monkeypatch: pytest.MonkeyPatch, tmp

cli = MyCli(myclirc=myclirc)

assert cli.completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz')
assert cli.completer.completion_match_order == ('camel_case', 'under_words')


@pytest.mark.parametrize(
Expand Down
138 changes: 123 additions & 15 deletions test/pytests/test_sqlcompleter.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# type: ignore

from pathlib import Path
import re
from types import SimpleNamespace
from unittest.mock import Mock
Expand Down Expand Up @@ -153,7 +154,7 @@ def fail_extract(*args, **kwargs):
raise AssertionError('rapidfuzz should not be called')

monkeypatch.setattr(mycli.sqlcompleter.rapidfuzz.process, 'extract', fail_extract)
completer = SQLCompleter()
completer = SQLCompleter(completion_match_order=('rapidfuzz',))
matches = completer.find_fuzzy_matches('sel', 'sel', ['SELECT'])

assert matches == []
Expand Down Expand Up @@ -326,7 +327,7 @@ def test_find_matches_supports_substring_matching() -> None:
def test_find_matches_quotes_identifiers_when_text_starts_with_backtick() -> None:
matches = collect_matches('`us', ['users'])

assert matches == [('`users`', Fuzziness.REGEX)]
assert matches == [('`users`', Fuzziness.PERFECT)]


def test_find_matches_quotes_identifiers_when_cursor_is_inside_backticks() -> None:
Expand All @@ -336,7 +337,7 @@ def test_find_matches_quotes_identifiers_when_cursor_is_inside_backticks() -> No
text_before_cursor='select `uu',
)

assert matches == [('`uuid`', Fuzziness.REGEX)]
assert matches == [('`uuid`', Fuzziness.PERFECT)]


def test_find_matches_preserves_asterisk_inside_backticks() -> None:
Expand All @@ -346,14 +347,14 @@ def test_find_matches_preserves_asterisk_inside_backticks() -> None:
text_before_cursor='select `*',
)

assert matches == [('*', Fuzziness.REGEX)]
assert matches == [('*', Fuzziness.PERFECT)]


def test_find_matches_finds_regex_matches() -> None:
matches = collect_matches('sel', ['SELECT', 'foo_select_bar'])

assert matches == [
('SELECT', Fuzziness.REGEX),
('SELECT', Fuzziness.PERFECT),
('foo_select_bar', Fuzziness.REGEX),
]

Expand Down Expand Up @@ -386,7 +387,7 @@ def fail_extract(*args, **kwargs):

matches = collect_matches('sel', ['SELECT'])

assert matches == [('SELECT', Fuzziness.REGEX)]
assert matches == [('SELECT', Fuzziness.PERFECT)]


def test_find_matches_filters_short_rapidfuzz_candidates(monkeypatch) -> None:
Expand All @@ -404,10 +405,10 @@ def test_find_matches_filters_short_rapidfuzz_candidates(monkeypatch) -> None:
@pytest.mark.parametrize(
('orig_text', 'collection', 'casing', 'expected'),
[
('sel', ['SELECT'], 'auto', [('select', Fuzziness.REGEX)]),
('SEL', ['select'], 'auto', [('SELECT', Fuzziness.REGEX)]),
('sel', ['select'], 'upper', [('SELECT', Fuzziness.REGEX)]),
('SEL', ['SELECT'], 'lower', [('select', Fuzziness.REGEX)]),
('sel', ['SELECT'], 'auto', [('select', Fuzziness.PERFECT)]),
('SEL', ['select'], 'auto', [('SELECT', Fuzziness.PERFECT)]),
('sel', ['select'], 'upper', [('SELECT', Fuzziness.PERFECT)]),
('SEL', ['SELECT'], 'lower', [('select', Fuzziness.PERFECT)]),
],
)
def test_find_matches_applies_casing(
Expand Down Expand Up @@ -494,20 +495,127 @@ def test_completion_match_order_defaults_and_validation(order: tuple[str, ...])
def test_completion_match_order_normalizes_partial_list() -> None:
completer = SQLCompleter(completion_match_order=(' CAMEL_CASE ', 'under_words'))

assert completer.completion_match_order == ('camel_case', 'under_words', 'perfect', 'regex', 'slash_words', 'rapidfuzz')
assert completer.completion_match_order == ('camel_case', 'under_words')


@pytest.mark.parametrize(
('method', 'text', 'candidate'),
[
('perfect', 'sel', 'select'),
('regex', 'slt', 'select'),
('under_words', 'us_de_fu', 'user_defined_function'),
('camel_case', 'TiZoTrTy', 'TimeZoneTransitionType'),
('rapidfuzz', 'abcd', 'abce'),
],
)
def test_only_enabled_method_contributes_candidates(method: str, text: str, candidate: str) -> None:
enabled = SQLCompleter(completion_match_order=(method,))
disabled = SQLCompleter(completion_match_order=('slash_words',))

assert enabled.find_fuzzy_matches(text, text.lower(), [candidate]) == [(candidate, Fuzziness[method.upper()])]
assert disabled.find_fuzzy_matches(text, text.lower(), [candidate]) == []


def test_perfect_only_does_not_run_fuzzy_matchers(monkeypatch: pytest.MonkeyPatch) -> None:
completer = SQLCompleter(completion_match_order=('perfect',))
monkeypatch.setattr(mycli.sqlcompleter.re, 'compile', pytest.fail)
monkeypatch.setattr(mycli.sqlcompleter.re, 'split', pytest.fail)
monkeypatch.setattr(completer, 'word_parts_match', pytest.fail)
monkeypatch.setattr(mycli.sqlcompleter.rapidfuzz.process, 'extract', pytest.fail)

assert completer.find_fuzzy_matches('sele', 'sele', ['select', 'user_select']) == [('select', Fuzziness.PERFECT)]


@pytest.mark.parametrize('order', [('perfect', 'regex'), ('regex', 'perfect')])
def test_enabled_prefix_methods_follow_configured_priority(order: tuple[str, ...]) -> None:
completer = SQLCompleter(completion_match_order=order)

assert list(completer.find_matches('sel', ['select'])) == [('select', Fuzziness[order[0].upper()])]


@pytest.mark.parametrize('smart', [True, False])
@pytest.mark.parametrize(('order', 'expected'), [(('perfect',), ['select']), (('slash_words',), [])])
def test_sql_completion_with_restricted_methods(
monkeypatch: pytest.MonkeyPatch, smart: bool, order: tuple[str, ...], expected: list[str]
) -> None:
completer = make_completer(smart_completion=smart, completion_match_order=order)
completer.keywords = ['select', 'user_select']
completer.all_completions = {'select', 'user_select'}
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}])

assert [c.text for c in completer.get_completions(Document('sel'), None)] == expected


def test_nonfuzzy_completion_requires_perfect() -> None:
completer = SQLCompleter(completion_match_order=('regex',))

assert list(completer.find_matches('sel', ['select'], fuzzy=False)) == []


@pytest.mark.parametrize('smart', [True, False])
@pytest.mark.parametrize(('name', 'prefix'), [('order items', 'ord'), ('select', 'SEL')])
@pytest.mark.parametrize('quote', ['', '`'])
def test_perfect_only_completes_quoted_table_names(smart: bool, name: str, prefix: str, quote: str) -> None:
completer = SQLCompleter(smart_completion=smart, completion_match_order=('perfect',))
completer.extend_schemata('test')
completer.set_dbname('test')
completer.extend_relations([(name,)], kind='tables')
token = quote + prefix

matches = list(completer.get_completions(Document(f'SELECT * FROM {token}'), None))

assert any(c.text == f'`{name}`' and c.start_position == -len(token) for c in matches)


@pytest.mark.parametrize(('name', 'prefix'), [('order total', 'ord'), ('from', 'fro')])
def test_perfect_only_completes_quoted_column_names(name: str, prefix: str) -> None:
completer = SQLCompleter(completion_match_order=('perfect',))
completer.extend_schemata('test')
completer.set_dbname('test')
completer.extend_relations([('orders',)], kind='tables')
completer.extend_columns([('orders', name)], kind='tables')

matches = list(completer.get_completions(Document(f'SELECT * FROM orders WHERE {prefix}'), None))

assert any(c.text == f'`{name}`' and c.start_position == -len(prefix) for c in matches)


@pytest.mark.parametrize('fuzzy', [True, False])
def test_perfect_only_does_not_match_inside_quoted_names(fuzzy: bool) -> None:
completer = SQLCompleter(completion_match_order=('perfect',))

assert list(completer.find_matches('der', ['`order items`'], fuzzy=fuzzy, start_only=True)) == []


@pytest.mark.parametrize(('word', 'order'), [('./fi', ('perfect',)), ('fi', ('slash_words',))])
def test_disabled_file_methods_do_not_access_filesystem(monkeypatch: pytest.MonkeyPatch, word: str, order: tuple[str, ...]) -> None:
completer = SQLCompleter(completion_match_order=order)
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_path_by_prefix', pytest.fail)
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_path', pytest.fail)

assert list(completer.find_files(word)) == []


@pytest.mark.parametrize(('word', 'method'), [('./fi', 'slash_words'), ('fi', 'perfect')])
def test_enabled_file_method_completes_paths(monkeypatch: pytest.MonkeyPatch, tmp_path: Path, word: str, method: str) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / 'file.sql').touch()
completer = SQLCompleter(completion_match_order=(method,))

assert list(completer.find_files(word)) == [('./file.sql' if '/' in word else 'file.sql', Fuzziness[method.upper()])]


@pytest.mark.parametrize('preferred', ['regex', 'under_words', 'camel_case'])
def test_overlapping_matches_use_configured_priority(preferred: str) -> None:
completer = SQLCompleter(completion_match_order=(preferred,))
completer = SQLCompleter(completion_match_order=(preferred, 'perfect'))

matches = list(completer.find_matches('al', ['alphabet']))

assert matches == [('alphabet', Fuzziness[preferred.upper()])]


def test_rapidfuzz_can_replace_an_overlapping_category(monkeypatch: pytest.MonkeyPatch) -> None:
completer = SQLCompleter(completion_match_order=('rapidfuzz',))
completer = SQLCompleter(completion_match_order=('rapidfuzz', 'regex'))
monkeypatch.setattr(
mycli.sqlcompleter.rapidfuzz.process,
'extract',
Expand All @@ -520,7 +628,7 @@ def test_rapidfuzz_can_replace_an_overlapping_category(monkeypatch: pytest.Monke
@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
completion_match_order=('rapidfuzz', 'regex'), 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 @@ -529,7 +637,7 @@ def test_prefix_priority_precedes_frecency(monkeypatch: pytest.MonkeyPatch, tieb


def test_custom_match_priority_sorts_candidates(monkeypatch: pytest.MonkeyPatch) -> None:
completer = make_completer(completion_match_order=('under_words',))
completer = make_completer(completion_match_order=('under_words', 'regex'))
monkeypatch.setattr(mycli.sqlcompleter, 'suggest_type', lambda *args: [{'type': 'keyword'}])
monkeypatch.setattr(completer, 'find_matches', lambda *args, **kwargs: [('alpha', Fuzziness.REGEX), ('bravo', Fuzziness.UNDER_WORDS)])

Expand Down
Loading