1from __future__ import annotations
2
3import re
4from collections.abc import Callable, Iterable, Sequence
5from typing import NamedTuple
6
7from prompt_toolkit.document import Document
8from prompt_toolkit.filters import FilterOrBool, to_filter
9from prompt_toolkit.formatted_text import AnyFormattedText, StyleAndTextTuples
10
11from .base import CompleteEvent, Completer, Completion
12from .word_completer import WordCompleter
13
14__all__ = [
15 "FuzzyCompleter",
16 "FuzzyWordCompleter",
17]
18
19
20class FuzzyCompleter(Completer):
21 """
22 Fuzzy completion.
23 This wraps any other completer and turns it into a fuzzy completer.
24
25 If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"]
26 Then trying to complete "oar" would yield "leopard" and "dinosaur", but not
27 the others, because they match the regular expression 'o.*a.*r'.
28 Similar, in another application "djm" could expand to "django_migrations".
29
30 The results are sorted by relevance, which is defined as the start position
31 and the length of the match.
32
33 Notice that this is not really a tool to work around spelling mistakes,
34 like what would be possible with difflib. The purpose is rather to have a
35 quicker or more intuitive way to filter the given completions, especially
36 when many completions have a common prefix.
37
38 Fuzzy algorithm is based on this post:
39 https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
40
41 :param completer: A :class:`~.Completer` instance.
42 :param WORD: When True, use WORD characters.
43 :param pattern: Regex pattern which selects the characters before the
44 cursor that are considered for the fuzzy matching.
45 :param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For
46 easily turning fuzzyness on or off according to a certain condition.
47 """
48
49 def __init__(
50 self,
51 completer: Completer,
52 WORD: bool = False,
53 pattern: str | None = None,
54 enable_fuzzy: FilterOrBool = True,
55 ) -> None:
56 assert pattern is None or pattern.startswith("^")
57
58 self.completer = completer
59 self.pattern = pattern
60 self.WORD = WORD
61 self.pattern = pattern
62 self.enable_fuzzy = to_filter(enable_fuzzy)
63
64 def get_completions(
65 self, document: Document, complete_event: CompleteEvent
66 ) -> Iterable[Completion]:
67 if self.enable_fuzzy():
68 return self._get_fuzzy_completions(document, complete_event)
69 else:
70 return self.completer.get_completions(document, complete_event)
71
72 def _get_pattern(self) -> str:
73 if self.pattern:
74 return self.pattern
75 if self.WORD:
76 return r"[^\s]+"
77 return "^[a-zA-Z0-9_]*"
78
79 def _get_fuzzy_completions(
80 self, document: Document, complete_event: CompleteEvent
81 ) -> Iterable[Completion]:
82 word_before_cursor = document.get_word_before_cursor(
83 pattern=re.compile(self._get_pattern())
84 )
85
86 # Get completions
87 document2 = Document(
88 text=document.text[: document.cursor_position - len(word_before_cursor)],
89 cursor_position=document.cursor_position - len(word_before_cursor),
90 )
91
92 inner_completions = list(
93 self.completer.get_completions(document2, complete_event)
94 )
95
96 fuzzy_matches: list[_FuzzyMatch] = []
97
98 if word_before_cursor == "":
99 # If word before the cursor is an empty string, consider all
100 # completions, without filtering everything with an empty regex
101 # pattern.
102 fuzzy_matches = [_FuzzyMatch(0, 0, compl) for compl in inner_completions]
103 else:
104 pat = ".*?".join(map(re.escape, word_before_cursor))
105 pat = f"(?=({pat}))" # lookahead regex to manage overlapping matches
106 regex = re.compile(pat, re.IGNORECASE)
107 for compl in inner_completions:
108 matches = list(regex.finditer(compl.text))
109 if matches:
110 # Prefer the match, closest to the left, then shortest.
111 best = min(matches, key=lambda m: (m.start(), len(m.group(1))))
112 fuzzy_matches.append(
113 _FuzzyMatch(len(best.group(1)), best.start(), compl)
114 )
115
116 def sort_key(fuzzy_match: _FuzzyMatch) -> tuple[int, int]:
117 "Sort by start position, then by the length of the match."
118 return fuzzy_match.start_pos, fuzzy_match.match_length
119
120 fuzzy_matches = sorted(fuzzy_matches, key=sort_key)
121
122 for match in fuzzy_matches:
123 # Include these completions, but set the correct `display`
124 # attribute and `start_position`.
125 yield Completion(
126 text=match.completion.text,
127 start_position=match.completion.start_position
128 - len(word_before_cursor),
129 # We access to private `_display_meta` attribute, because that one is lazy.
130 display_meta=match.completion._display_meta,
131 display=self._get_display(match, word_before_cursor),
132 style=match.completion.style,
133 )
134
135 def _get_display(
136 self, fuzzy_match: _FuzzyMatch, word_before_cursor: str
137 ) -> AnyFormattedText:
138 """
139 Generate formatted text for the display label.
140 """
141
142 def get_display() -> AnyFormattedText:
143 m = fuzzy_match
144 word = m.completion.text
145
146 if m.match_length == 0:
147 # No highlighting when we have zero length matches (no input text).
148 # In this case, use the original display text (which can include
149 # additional styling or characters).
150 return m.completion.display
151
152 result: StyleAndTextTuples = []
153
154 # Text before match.
155 result.append(("class:fuzzymatch.outside", word[: m.start_pos]))
156
157 # The match itself.
158 characters = list(word_before_cursor)
159
160 for c in word[m.start_pos : m.start_pos + m.match_length]:
161 classname = "class:fuzzymatch.inside"
162 if characters and c.lower() == characters[0].lower():
163 classname += ".character"
164 del characters[0]
165
166 result.append((classname, c))
167
168 # Text after match.
169 result.append(
170 ("class:fuzzymatch.outside", word[m.start_pos + m.match_length :])
171 )
172
173 return result
174
175 return get_display()
176
177
178class FuzzyWordCompleter(Completer):
179 """
180 Fuzzy completion on a list of words.
181
182 (This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.)
183
184 :param words: List of words or callable that returns a list of words.
185 :param meta_dict: Optional dict mapping words to their meta-information.
186 :param WORD: When True, use WORD characters.
187 """
188
189 def __init__(
190 self,
191 words: Sequence[str] | Callable[[], Sequence[str]],
192 meta_dict: dict[str, str] | None = None,
193 WORD: bool = False,
194 ) -> None:
195 self.words = words
196 self.meta_dict = meta_dict or {}
197 self.WORD = WORD
198
199 self.word_completer = WordCompleter(
200 words=self.words, WORD=self.WORD, meta_dict=self.meta_dict
201 )
202
203 self.fuzzy_completer = FuzzyCompleter(self.word_completer, WORD=self.WORD)
204
205 def get_completions(
206 self, document: Document, complete_event: CompleteEvent
207 ) -> Iterable[Completion]:
208 return self.fuzzy_completer.get_completions(document, complete_event)
209
210
211class _FuzzyMatch(NamedTuple):
212 match_length: int
213 start_pos: int
214 completion: Completion