Skip to content

Commit 81c3015

Browse files
Implemented a generic FuzzyCompleter that wraps around any other Completer.
1 parent bcfef22 commit 81c3015

File tree

6 files changed

+167
-42
lines changed

6 files changed

+167
-42
lines changed
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
#!/usr/bin/env python
2+
"""
3+
Demonstration of a custom completer wrapped in a `FuzzyCompleter` for fuzzy
4+
matching.
5+
"""
6+
from __future__ import unicode_literals
7+
from prompt_toolkit.completion import Completion, Completer, FuzzyCompleter
8+
from prompt_toolkit.output.color_depth import ColorDepth
9+
from prompt_toolkit.shortcuts import prompt, CompleteStyle
10+
11+
12+
colors = ['red', 'blue', 'green', 'orange', 'purple', 'yellow', 'cyan',
13+
'magenta', 'pink']
14+
15+
16+
class ColorCompleter(Completer):
17+
def get_completions(self, document, complete_event):
18+
word = document.get_word_before_cursor()
19+
for color in colors:
20+
if color.startswith(word):
21+
yield Completion(
22+
color,
23+
start_position=-len(word),
24+
style='fg:' + color,
25+
selected_style='fg:white bg:' + color)
26+
27+
28+
def main():
29+
# Simple completion menu.
30+
print('(The completion menu displays colors.)')
31+
prompt('Type a color: ', completer=FuzzyCompleter(ColorCompleter()))
32+
33+
# Multi-column menu.
34+
prompt('Type a color: ', completer=FuzzyCompleter(ColorCompleter()),
35+
complete_style=CompleteStyle.MULTI_COLUMN)
36+
37+
# Readline-like
38+
prompt('Type a color: ', completer=FuzzyCompleter(ColorCompleter()),
39+
complete_style=CompleteStyle.READLINE_LIKE)
40+
41+
42+
if __name__ == '__main__':
43+
main()

prompt_toolkit/completion/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from .base import Completion, Completer, ThreadedCompleter, DummyCompleter, DynamicCompleter, CompleteEvent, merge_completers, get_common_complete_suffix
33
from .filesystem import PathCompleter, ExecutableCompleter
44
from .word_completer import WordCompleter
5-
from .fuzzy_completer import FuzzyWordCompleter
5+
from .fuzzy_completer import FuzzyCompleter, FuzzyWordCompleter
66

77
__all__ = [
88
# Base.
@@ -21,5 +21,6 @@
2121

2222
# Word completer.
2323
'WordCompleter',
24+
'FuzzyCompleter',
2425
'FuzzyWordCompleter',
2526
]

prompt_toolkit/completion/fuzzy_completer.py

Lines changed: 96 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -4,59 +4,90 @@
44
from collections import namedtuple
55
from six import string_types
66

7-
from prompt_toolkit.completion import Completer, Completion
7+
from prompt_toolkit.document import Document
8+
from prompt_toolkit.filters import to_filter
9+
10+
from .base import Completer, Completion
11+
from .word_completer import WordCompleter
812

913
__all__ = [
14+
'FuzzyCompleter',
1015
'FuzzyWordCompleter',
1116
]
1217

1318

14-
class FuzzyWordCompleter(Completer):
19+
class FuzzyCompleter(Completer):
1520
"""
16-
Fuzzy completion on a list of words.
21+
Fuzzy completion.
22+
This wraps any other completer and turns it into a fuzzy completer.
1723
1824
If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"]
1925
Then trying to complete "oar" would yield "leopard" and "dinosaur", but not
2026
the others, because they match the regular expression 'o.*a.*r'.
27+
Similar, in another application "djm" could expand to "django_migrations".
2128
2229
The results are sorted by relevance, which is defined as the start position
2330
and the length of the match.
2431
25-
See: https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
32+
Notice that this is not really a tool to work around spelling mistakes,
33+
like what would be possible with difflib. The purpose is rather to have a
34+
quicker or more intuitive way to filter the given completions, especially
35+
when many completions have a common prefix.
2636
27-
:param words: List of words or callable that returns a list of words.
28-
:param meta_dict: Optional dict mapping words to their meta-information.
29-
:param WORD: When True, use WORD characters.
30-
:param sort_results: Boolean to determine whether to sort the results (default: True).
37+
Fuzzy algorithm is based on this post:
38+
https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
3139
32-
Fuzzy algorithm is based on this post: https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
40+
:param completer: A :class:`~.Completer` instance.
41+
:param WORD: When True, use WORD characters.
42+
:param pattern: Regex pattern which selects the characters before the
43+
cursor that are considered for the fuzzy matching.
44+
:param enable_fuzzy: (bool or `Filter`) Enabled the fuzzy behavior. For
45+
easily turning fuzzyness on or off according to a certain condition.
3346
"""
34-
def __init__(self, words, meta_dict=None, WORD=False, sort_results=True):
35-
assert callable(words) or all(isinstance(w, string_types) for w in words)
47+
def __init__(self, completer, WORD=False, pattern=None, enable_fuzzy=True):
48+
assert isinstance(completer, Completer)
49+
assert pattern is None or pattern.startswith('^')
3650

37-
self.words = words
38-
self.meta_dict = meta_dict or {}
39-
self.sort_results = sort_results
51+
self.completer = completer
52+
self.pattern = pattern
4053
self.WORD = WORD
54+
self.pattern = pattern
55+
self.enable_fuzzy = to_filter(enable_fuzzy)
4156

4257
def get_completions(self, document, complete_event):
43-
# Get list of words.
44-
words = self.words
45-
if callable(words):
46-
words = words()
58+
if self.enable_fuzzy():
59+
return self._get_fuzzy_completions(document, complete_event)
60+
else:
61+
return self.completer.get_completions(document, complete_event)
62+
63+
def _get_pattern(self):
64+
if self.pattern:
65+
return self.pattern
66+
if self.WORD:
67+
return r'[^\s]+'
68+
return '^[a-zA-Z0-9_]*'
69+
70+
def _get_fuzzy_completions(self, document, complete_event):
71+
word_before_cursor = document.get_word_before_cursor(
72+
pattern=re.compile(self._get_pattern()))
73+
74+
# Get completions
75+
document2 = Document(
76+
text=document.text[:document.cursor_position - len(word_before_cursor)],
77+
cursor_position=document.cursor_position - len(word_before_cursor))
4778

48-
word_before_cursor = document.get_word_before_cursor(WORD=self.WORD)
79+
completions = list(self.completer.get_completions(document2, complete_event))
4980

5081
fuzzy_matches = []
5182
pat = '.*?'.join(map(re.escape, word_before_cursor))
5283
pat = '(?=({0}))'.format(pat) # lookahead regex to manage overlapping matches
5384
regex = re.compile(pat, re.IGNORECASE)
54-
for word in words:
55-
matches = list(regex.finditer(word))
85+
for compl in completions:
86+
matches = list(regex.finditer(compl.text))
5687
if matches:
5788
# Prefer the match, closest to the left, then shortest.
5889
best = min(matches, key=lambda m: (m.start(), len(m.group(1))))
59-
fuzzy_matches.append(_FuzzyMatch(len(best.group(1)), best.start(), word))
90+
fuzzy_matches.append(_FuzzyMatch(len(best.group(1)), best.start(), compl))
6091

6192
def sort_key(fuzzy_match):
6293
" Sort by start position, then by the length of the match. "
@@ -65,45 +96,76 @@ def sort_key(fuzzy_match):
6596
fuzzy_matches = sorted(fuzzy_matches, key=sort_key)
6697

6798
for match in fuzzy_matches:
68-
display_meta = self.meta_dict.get(match.word, '')
69-
99+
# Include these completions, but set the correct `display`
100+
# attribute and `start_position`.
70101
yield Completion(
71-
match.word,
72-
-len(word_before_cursor),
73-
display_meta=display_meta,
74-
display=self._get_display(match, word_before_cursor))
102+
match.completion.text,
103+
start_position=match.completion.start_position - len(word_before_cursor),
104+
display_meta=match.completion.display_meta,
105+
display=self._get_display(match, word_before_cursor),
106+
style=match.completion.style)
75107

76108
def _get_display(self, fuzzy_match, word_before_cursor):
77109
"""
78110
Generate formatted text for the display label.
79111
"""
80112
m = fuzzy_match
113+
word = m.completion.text
81114

82115
if m.match_length == 0:
83116
# No highlighting when we have zero length matches (no input text).
84-
return m.word
117+
return word
85118

86119
result = []
87120

88121
# Text before match.
89-
result.append(('class:fuzzymatch.outside', m.word[:m.start_pos]))
122+
result.append(('class:fuzzymatch.outside', word[:m.start_pos]))
90123

91124
# The match itself.
92125
characters = list(word_before_cursor)
93126

94-
for c in m.word[m.start_pos:m.start_pos + m.match_length]:
127+
for c in word[m.start_pos:m.start_pos + m.match_length]:
95128
classname = 'class:fuzzymatch.inside'
96-
if characters and c == characters[0]:
129+
if characters and c.lower() == characters[0].lower():
97130
classname += '.character'
98131
del characters[0]
99132

100133
result.append((classname, c))
101134

102135
# Text after match.
103136
result.append(
104-
('class:fuzzymatch.outside', m.word[m.start_pos + m.match_length:]))
137+
('class:fuzzymatch.outside', word[m.start_pos + m.match_length:]))
105138

106139
return result
107140

108141

109-
_FuzzyMatch = namedtuple('_FuzzyMatch', 'match_length start_pos word')
142+
class FuzzyWordCompleter(Completer):
143+
"""
144+
Fuzzy completion on a list of words.
145+
146+
(This is basically a `WordCompleter` wrapped in a `FuzzyCompleter`.)
147+
148+
:param words: List of words or callable that returns a list of words.
149+
:param meta_dict: Optional dict mapping words to their meta-information.
150+
:param WORD: When True, use WORD characters.
151+
"""
152+
def __init__(self, words, meta_dict=None, WORD=False):
153+
assert callable(words) or all(isinstance(w, string_types) for w in words)
154+
155+
self.words = words
156+
self.meta_dict = meta_dict or {}
157+
self.WORD = WORD
158+
159+
self.word_completer = WordCompleter(
160+
words=lambda: self.words,
161+
WORD=self.WORD)
162+
163+
self.fuzzy_completer = FuzzyCompleter(
164+
self.word_completer,
165+
WORD=self.WORD)
166+
167+
def get_completions(self, document, complete_event):
168+
return self.fuzzy_completer.get_completions(document, complete_event)
169+
170+
171+
_FuzzyMatch = namedtuple('_FuzzyMatch', 'match_length start_pos completion')

prompt_toolkit/document.py

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -407,32 +407,50 @@ def find_backwards(self, sub, in_current_line=False, ignore_case=False, count=1)
407407
except StopIteration:
408408
pass
409409

410-
def get_word_before_cursor(self, WORD=False):
410+
def get_word_before_cursor(self, WORD=False, pattern=None):
411411
"""
412412
Give the word before the cursor.
413413
If we have whitespace before the cursor this returns an empty string.
414+
415+
:param pattern: (None or compiled regex). When given, use this regex
416+
pattern.
414417
"""
415-
if self.text_before_cursor[-1:].isspace():
418+
text_before_cursor = self.text_before_cursor
419+
start = self.find_start_of_previous_word(WORD=WORD, pattern=pattern)
420+
421+
if start is None:
422+
# Space before the cursor or no text before cursor.
416423
return ''
417-
else:
418-
return self.text_before_cursor[self.find_start_of_previous_word(WORD=WORD):]
419424

420-
def find_start_of_previous_word(self, count=1, WORD=False):
425+
return text_before_cursor[len(text_before_cursor) + start:]
426+
427+
def find_start_of_previous_word(self, count=1, WORD=False, pattern=None):
421428
"""
422429
Return an index relative to the cursor position pointing to the start
423430
of the previous word. Return `None` if nothing was found.
431+
432+
:param pattern: (None or compiled regex). When given, use this regex
433+
pattern.
424434
"""
435+
assert not (WORD and pattern)
436+
425437
# Reverse the text before the cursor, in order to do an efficient
426438
# backwards search.
427439
text_before_cursor = self.text_before_cursor[::-1]
428440

429-
regex = _FIND_BIG_WORD_RE if WORD else _FIND_WORD_RE
441+
if pattern:
442+
regex = pattern
443+
elif WORD:
444+
regex = _FIND_BIG_WORD_RE
445+
else:
446+
regex = _FIND_WORD_RE
447+
430448
iterator = regex.finditer(text_before_cursor)
431449

432450
try:
433451
for i, match in enumerate(iterator):
434452
if i + 1 == count:
435-
return - match.end(1)
453+
return - match.end(0)
436454
except StopIteration:
437455
pass
438456

tests/test_completion.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,7 @@ def get_words():
315315
assert [c.text for c in completions] == ['abc', 'aaa']
316316
assert called[0] == 2
317317

318+
318319
def test_fuzzy_completer():
319320
collection = [
320321
'migrations.py',

0 commit comments

Comments
 (0)