|
from __future__ import annotations |
|
|
|
import re |
|
from typing import Callable, Iterable, NamedTuple, Sequence |
|
|
|
from prompt_toolkit.document import Document |
|
from prompt_toolkit.filters import FilterOrBool, to_filter |
|
from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples |
|
|
|
from .base import CompleteEvent, Completer, Completion |
|
from .word_completer import WordCompleter |
|
|
|
__all__ = [ |
|
"FuzzyCompleter", |
|
"FuzzyWordCompleter", |
|
] |
|
|
|
|
|
class FuzzyCompleter(Completer): |
|
""" |
|
Fuzzy completion. |
|
This wraps any other completer and turns it into a fuzzy completer. |
|
|
|
If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"] |
|
Then trying to complete "oar" would yield "leopard" and "dinosaur", but not |
|
the others, because they match the regular expression 'o.*a.*r'. |
|
Similar, in another application "djm" could expand to "django_migrations". |
|
|
|
The results are sorted by relevance, which is defined as the start position |
|
and the length of the match. |
|
|
|
Notice that this is not really a tool to work around spelling mistakes, |
|
like what would be possible with difflib. The purpose is rather to have a |
|
quicker or more intuitive way to filter the given completions, especially |
|
when many completions have a common prefix. |
|
|
|
Fuzzy algorithm is based on this post: |
|
https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python |
|
|
|
:param completer: A :class:`~.Completer` instance. |
|
:param WORD: When True, use WORD characters. |
|
:param pattern: Regex pattern which selects the characters before the |
|
cursor that are considered for the fuzzy matching. |
|
:param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For |
|
easily turning fuzzyness on or off according to a certain condition. |
|
""" |
|
|
|
def __init__( |
|
self, |
|
completer: Completer, |
|
WORD: bool = False, |
|
pattern: str | None = None, |
|
enable_fuzzy: FilterOrBool = True, |
|
) -> None: |
|
assert pattern is None or pattern.startswith("^") |
|
|
|
self.completer = completer |
|
self.pattern = pattern |
|
self.WORD = WORD |
|
self.pattern = pattern |
|
self.enable_fuzzy = to_filter(enable_fuzzy) |
|
|
|
def get_completions( |
|
self, document: Document, complete_event: CompleteEvent |
|
) -> Iterable[Completion]: |
|
if self.enable_fuzzy(): |
|
return self._get_fuzzy_completions(document, complete_event) |
|
else: |
|
return self.completer.get_completions(document, complete_event) |
|
|
|
def _get_pattern(self) -> str: |
|
if self.pattern: |
|
return self.pattern |
|
if self.WORD: |
|
return r"[^\s]+" |
|
return "^[a-zA-Z0-9_]*" |
|
|
|
def _get_fuzzy_completions( |
|
self, document: Document, complete_event: CompleteEvent |
|
) -> Iterable[Completion]: |
|
word_before_cursor = document.get_word_before_cursor( |
|
pattern=re.compile(self._get_pattern()) |
|
) |
|
|
|
|
|
document2 = Document( |
|
text=document.text[: document.cursor_position - len(word_before_cursor)], |
|
cursor_position=document.cursor_position - len(word_before_cursor), |
|
) |
|
|
|
inner_completions = list( |
|
self.completer.get_completions(document2, complete_event) |
|
) |
|
|
|
fuzzy_matches: list[_FuzzyMatch] = [] |
|
|
|
if word_before_cursor == "": |
|
|
|
|
|
|
|
fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions] |
|
else: |
|
pat = ".*?".join(map(re.escape, word_before_cursor)) |
|
pat = f"(?=({pat}))" |
|
regex = re.compile(pat, re.IGNORECASE) |
|
for compl in inner_completions: |
|
matches = list(regex.finditer(compl.text)) |
|
if matches: |
|
|
|
best = min(matches, key=lambda m: (m.start(), len(m.group(1)))) |
|
fuzzy_matches.append( |
|
_FuzzyMatch(len(best.group(1)), best.start(), compl) |
|
) |
|
|
|
def sort_key(fuzzy_match: _FuzzyMatch) -> tuple[int, int]: |
|
"Sort by start position, then by the length of the match." |
|
return fuzzy_match.start_pos, fuzzy_match.match_length |
|
|
|
fuzzy_matches = sorted(fuzzy_matches, key=sort_key) |
|
|
|
for match in fuzzy_matches: |
|
|
|
|
|
yield Completion( |
|
text=match.completion.text, |
|
start_position=match.completion.start_position |
|
- len(word_before_cursor), |
|
|
|
display_meta=match.completion._display_meta, |
|
display=self._get_display(match, word_before_cursor), |
|
style=match.completion.style, |
|
) |
|
|
|
def _get_display( |
|
self, fuzzy_match: _FuzzyMatch, word_before_cursor: str |
|
) -> AnyFormattedText: |
|
""" |
|
Generate formatted text for the display label. |
|
""" |
|
|
|
def get_display() -> AnyFormattedText: |
|
m = fuzzy_match |
|
word = m.completion.text |
|
|
|
if m.match_length == 0: |
|
|
|
|
|
|
|
return m.completion.display |
|
|
|
result: StyleAndTextTuples = [] |
|
|
|
|
|
result.append(("class:fuzzymatch.outside", word[: m.start_pos])) |
|
|
|
|
|
characters = list(word_before_cursor) |
|
|
|
for c in word[m.start_pos : m.start_pos + m.match_length]: |
|
classname = "class:fuzzymatch.inside" |
|
if characters and c.lower() == characters[0].lower(): |
|
classname += ".character" |
|
del characters[0] |
|
|
|
result.append((classname, c)) |
|
|
|
|
|
result.append( |
|
("class:fuzzymatch.outside", word[m.start_pos + m.match_length :]) |
|
) |
|
|
|
return result |
|
|
|
return get_display() |
|
|
|
|
|
class FuzzyWordCompleter(Completer): |
|
""" |
|
Fuzzy completion on a list of words. |
|
|
|
(This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.) |
|
|
|
:param words: List of words or callable that returns a list of words. |
|
:param meta_dict: Optional dict mapping words to their meta-information. |
|
:param WORD: When True, use WORD characters. |
|
""" |
|
|
|
def __init__( |
|
self, |
|
words: Sequence[str] | Callable[[], Sequence[str]], |
|
meta_dict: dict[str, str] | None = None, |
|
WORD: bool = False, |
|
) -> None: |
|
self.words = words |
|
self.meta_dict = meta_dict or {} |
|
self.WORD = WORD |
|
|
|
self.word_completer = WordCompleter( |
|
words=self.words, WORD=self.WORD, meta_dict=self.meta_dict |
|
) |
|
|
|
self.fuzzy_completer = FuzzyCompleter(self.word_completer, WORD=self.WORD) |
|
|
|
def get_completions( |
|
self, document: Document, complete_event: CompleteEvent |
|
) -> Iterable[Completion]: |
|
return self.fuzzy_completer.get_completions(document, complete_event) |
|
|
|
|
|
class _FuzzyMatch(NamedTuple): |
|
match_length: int |
|
start_pos: int |
|
completion: Completion |
|
|