Skip to content

Commit 5c4f5ee

Browse files
committed
break sqlcompleter.py find_matches() into units
and add test coverage. This also changes find_matches() into an instance method, but we could consider changing find_matches() and many others into static methods. Motivation: smaller units make the code more testable and more amenable to agentic coding.
1 parent 94bb91a commit 5c4f5ee

3 files changed

Lines changed: 478 additions & 86 deletions

File tree

changelog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ Features
66
* Continue to expand TIPS.
77

88

9+
Internal
10+
---------
11+
* Refactor `find_matches()` into smaller logical units.
12+
13+
914
1.67.1 (2026/03/28)
1015
==============
1116

mycli/sqlcompleter.py

Lines changed: 135 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
from mycli.packages.special.main import COMMANDS as SPECIAL_COMMANDS
2020

2121
_logger = logging.getLogger(__name__)
22+
_CASE_CHANGE_PAT = re.compile('(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])')
2223

2324

2425
class Fuzziness(IntEnum):
@@ -1173,8 +1174,135 @@ def reset_completions(self) -> None:
11731174
}
11741175
self.all_completions = set(self.keywords + self.functions)
11751176

1176-
@staticmethod
1177+
def maybe_quote_identifier(self, item: str) -> str:
1178+
if item.startswith('`'):
1179+
return item
1180+
if item == '*':
1181+
return item
1182+
return '`' + item + '`'
1183+
1184+
def quote_collection_if_needed(
1185+
self,
1186+
text: str,
1187+
collection: Collection[Any],
1188+
text_before_cursor: str,
1189+
) -> Collection[Any]:
1190+
# checking text.startswith() first is an optimization; is_inside_quotes() covers more cases
1191+
if text.startswith('`') or is_inside_quotes(text_before_cursor, len(text_before_cursor)) == 'backtick':
1192+
return [self.maybe_quote_identifier(x) if isinstance(x, str) else x for x in collection]
1193+
return collection
1194+
1195+
def word_parts_match(
1196+
self,
1197+
text_parts: list[str],
1198+
item_parts: list[str],
1199+
) -> bool:
1200+
occurrences = 0
1201+
for text_part in text_parts:
1202+
for item_part in item_parts:
1203+
if item_part.startswith(text_part):
1204+
occurrences += 1
1205+
break
1206+
return occurrences >= len(text_parts)
1207+
1208+
def find_fuzzy_match(
1209+
self,
1210+
item: str,
1211+
pattern: re.Pattern[str],
1212+
under_words_text: list[str],
1213+
case_words_text: list[str],
1214+
) -> int | None:
1215+
if pattern.search(item.lower()):
1216+
return Fuzziness.REGEX
1217+
1218+
under_words_item = [x for x in item.lower().split('_') if x]
1219+
if self.word_parts_match(under_words_text, under_words_item):
1220+
return Fuzziness.UNDER_WORDS
1221+
1222+
case_words_item = re.split(_CASE_CHANGE_PAT, item)
1223+
if self.word_parts_match(case_words_text, case_words_item):
1224+
return Fuzziness.CAMEL_CASE
1225+
1226+
return None
1227+
1228+
def find_fuzzy_matches(
1229+
self,
1230+
last: str,
1231+
text: str,
1232+
collection: Collection[Any],
1233+
) -> list[tuple[str, int]]:
1234+
completions: list[tuple[str, int]] = []
1235+
regex = '.{0,3}?'.join(map(re.escape, text))
1236+
pattern = re.compile(f'({regex})')
1237+
under_words_text = [x for x in text.split('_') if x]
1238+
case_words_text = re.split(_CASE_CHANGE_PAT, last)
1239+
1240+
for item in collection:
1241+
fuzziness = self.find_fuzzy_match(item, pattern, under_words_text, case_words_text)
1242+
if fuzziness is not None:
1243+
completions.append((item, fuzziness))
1244+
1245+
if len(text) >= 4:
1246+
rapidfuzz_matches = rapidfuzz.process.extract(
1247+
text,
1248+
collection,
1249+
scorer=rapidfuzz.fuzz.WRatio,
1250+
# todo: maybe make our own processor which only does case-folding
1251+
# because underscores are valuable info
1252+
processor=rapidfuzz.utils.default_process,
1253+
limit=20,
1254+
score_cutoff=75,
1255+
)
1256+
for item, _score, _type in rapidfuzz_matches:
1257+
if len(item) < len(text) / 1.5:
1258+
continue
1259+
if item in completions:
1260+
continue
1261+
completions.append((item, Fuzziness.RAPIDFUZZ))
1262+
1263+
return completions
1264+
1265+
def find_perfect_matches(
1266+
self,
1267+
text: str,
1268+
collection: Collection[Any],
1269+
start_only: bool,
1270+
) -> list[tuple[str, int]]:
1271+
completions: list[tuple[str, int]] = []
1272+
match_end_limit = len(text) if start_only else None
1273+
for item in collection:
1274+
match_point = item.lower().find(text, 0, match_end_limit)
1275+
if match_point >= 0:
1276+
completions.append((item, Fuzziness.PERFECT))
1277+
return completions
1278+
1279+
def resolve_casing(
1280+
self,
1281+
casing: str | None,
1282+
last: str,
1283+
) -> str | None:
1284+
if casing != 'auto':
1285+
return casing
1286+
return 'lower' if last and (last[0].islower() or last[-1].islower()) else 'upper'
1287+
1288+
def apply_casing(
1289+
self,
1290+
completions: list[tuple[str, int]],
1291+
casing: str | None,
1292+
) -> Generator[tuple[str, int], None, None]:
1293+
if casing is None:
1294+
return (completion for completion in completions)
1295+
1296+
def apply_case(tup: tuple[str, int]) -> tuple[str, int]:
1297+
kw, fuzziness = tup
1298+
if casing == 'upper':
1299+
return (kw.upper(), fuzziness)
1300+
return (kw.lower(), fuzziness)
1301+
1302+
return (apply_case(completion) for completion in completions)
1303+
11771304
def find_matches(
1305+
self,
11781306
orig_text: str,
11791307
collection: Collection,
11801308
start_only: bool = False,
@@ -1195,96 +1323,17 @@ def find_matches(
11951323
yields prompt_toolkit Completion instances for any matches found
11961324
in the collection of available completions.
11971325
"""
1198-
last = last_word(orig_text, include="most_punctuations")
1326+
last = last_word(orig_text, include='most_punctuations')
11991327
text = last.lower()
1200-
# unicode support not possible without adding the regex dependency
1201-
case_change_pat = re.compile("(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
1202-
1203-
completions: list[tuple[str, int]] = []
1204-
1205-
def maybe_quote_identifier(item: str) -> str:
1206-
if item.startswith('`'):
1207-
return item
1208-
if item == '*':
1209-
return item
1210-
return '`' + item + '`'
1211-
1212-
# checking text.startswith() first is an optimization; is_inside_quotes() covers more cases
1213-
if text.startswith('`') or is_inside_quotes(text_before_cursor, len(text_before_cursor)) == 'backtick':
1214-
quoted_collection: Collection[Any] = [maybe_quote_identifier(x) if isinstance(x, str) else x for x in collection]
1215-
else:
1216-
quoted_collection = collection
1328+
quoted_collection = self.quote_collection_if_needed(text, collection, text_before_cursor)
12171329

12181330
if fuzzy:
1219-
regex = ".{0,3}?".join(map(re.escape, text))
1220-
pat = re.compile(f'({regex})')
1221-
under_words_text = [x for x in text.split('_') if x]
1222-
case_words_text = re.split(case_change_pat, last)
1223-
1224-
for item in quoted_collection:
1225-
r = pat.search(item.lower())
1226-
if r:
1227-
completions.append((item, Fuzziness.REGEX))
1228-
continue
1229-
1230-
under_words_item = [x for x in item.lower().split('_') if x]
1231-
occurrences = 0
1232-
for elt_word in under_words_text:
1233-
for elt_item in under_words_item:
1234-
if elt_item.startswith(elt_word):
1235-
occurrences += 1
1236-
break
1237-
if occurrences >= len(under_words_text):
1238-
completions.append((item, Fuzziness.UNDER_WORDS))
1239-
continue
1240-
1241-
case_words_item = re.split(case_change_pat, item)
1242-
occurrences = 0
1243-
for elt_word in case_words_text:
1244-
for elt_item in case_words_item:
1245-
if elt_item.startswith(elt_word):
1246-
occurrences += 1
1247-
break
1248-
if occurrences >= len(case_words_text):
1249-
completions.append((item, Fuzziness.CAMEL_CASE))
1250-
continue
1251-
1252-
if len(text) >= 4:
1253-
rapidfuzz_matches = rapidfuzz.process.extract(
1254-
text,
1255-
quoted_collection,
1256-
scorer=rapidfuzz.fuzz.WRatio,
1257-
# todo: maybe make our own processor which only does case-folding
1258-
# because underscores are valuable info
1259-
processor=rapidfuzz.utils.default_process,
1260-
limit=20,
1261-
score_cutoff=75,
1262-
)
1263-
for elt in rapidfuzz_matches:
1264-
item, _score, _type = elt
1265-
if len(item) < len(text) / 1.5:
1266-
continue
1267-
if item in completions:
1268-
continue
1269-
completions.append((item, Fuzziness.RAPIDFUZZ))
1270-
1331+
completions = self.find_fuzzy_matches(last, text, quoted_collection)
12711332
else:
1272-
match_end_limit = len(text) if start_only else None
1273-
for item in quoted_collection:
1274-
match_point = item.lower().find(text, 0, match_end_limit)
1275-
if match_point >= 0:
1276-
completions.append((item, Fuzziness.PERFECT))
1277-
1278-
if casing == "auto":
1279-
casing = "lower" if last and (last[0].islower() or last[-1].islower()) else "upper"
1280-
1281-
def apply_case(tup: tuple[str, int]) -> tuple[str, int]:
1282-
kw, fuzziness = tup
1283-
if casing == "upper":
1284-
return (kw.upper(), fuzziness)
1285-
return (kw.lower(), fuzziness)
1333+
completions = self.find_perfect_matches(text, quoted_collection, start_only)
12861334

1287-
return (x if casing is None else apply_case(x) for x in completions)
1335+
casing = self.resolve_casing(casing, last)
1336+
return self.apply_casing(completions, casing)
12881337

12891338
def get_completions(
12901339
self,

0 commit comments

Comments
 (0)