123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213 |
- from __future__ import annotations
- import re
- from typing import Callable, Iterable, NamedTuple
- 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())
- )
- # Get completions
- 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 == "":
- # If word before the cursor is an empty string, consider all
- # completions, without filtering everything with an empty regex
- # pattern.
- fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions]
- else:
- pat = ".*?".join(map(re.escape, word_before_cursor))
- pat = f"(?=({pat}))" # lookahead regex to manage overlapping matches
- regex = re.compile(pat, re.IGNORECASE)
- for compl in inner_completions:
- matches = list(regex.finditer(compl.text))
- if matches:
- # Prefer the match, closest to the left, then shortest.
- 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:
- # Include these completions, but set the correct `display`
- # attribute and `start_position`.
- yield Completion(
- text=match.completion.text,
- start_position=match.completion.start_position
- - len(word_before_cursor),
- # We access to private `_display_meta` attribute, because that one is lazy.
- 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:
- # No highlighting when we have zero length matches (no input text).
- # In this case, use the original display text (which can include
- # additional styling or characters).
- return m.completion.display
- result: StyleAndTextTuples = []
- # Text before match.
- result.append(("class:fuzzymatch.outside", word[: m.start_pos]))
- # The match itself.
- 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))
- # Text after match.
- 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: list[str] | Callable[[], list[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
|