Skip to content

Commit d1d5d11

Browse files
amjithjonathanslenders
authored andcommitted
Add a FuzzyWordCompleter.
Commit modified by: Jonathan Slenders.
1 parent 7c913f2 commit d1d5d11

4 files changed

Lines changed: 146 additions & 1 deletion

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/usr/bin/env python
2+
"""
3+
Autocompletion example.
4+
5+
Press [Tab] to complete the current word.
6+
- The first Tab press fills in the common part of all completions
7+
and shows all the completions. (In the menu)
8+
- Any following tab press cycles through all the possible completions.
9+
"""
10+
from __future__ import unicode_literals
11+
12+
from prompt_toolkit.completion import FuzzyWordCompleter
13+
from prompt_toolkit.shortcuts import prompt
14+
15+
16+
animal_completer = FuzzyWordCompleter([
17+
'alligator', 'ant', 'ape', 'bat', 'bear', 'beaver', 'bee', 'bison',
18+
'butterfly', 'cat', 'chicken', 'crocodile', 'dinosaur', 'dog', 'dolphin',
19+
'dove', 'duck', 'eagle', 'elephant', 'fish', 'goat', 'gorilla', 'kangaroo',
20+
'leopard', 'lion', 'mouse', 'rabbit', 'rat', 'snake', 'spider', 'turkey',
21+
'turtle',
22+
])
23+
24+
25+
def main():
26+
text = prompt('Give some animals: ', completer=animal_completer,
27+
complete_while_typing=True)
28+
print('You said: %s' % text)
29+
30+
31+
if __name__ == '__main__':
32+
main()

prompt_toolkit/completion/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +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
56

67
__all__ = [
78
# Base.
@@ -20,4 +21,5 @@
2021

2122
# Word completer.
2223
'WordCompleter',
24+
'FuzzyWordCompleter',
2325
]
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from __future__ import unicode_literals
2+
import re
3+
4+
from collections import namedtuple
5+
from six import string_types
6+
7+
from prompt_toolkit.completion import Completer, Completion
8+
9+
__all__ = [
10+
'FuzzyWordCompleter',
11+
]
12+
13+
14+
class FuzzyWordCompleter(Completer):
15+
"""
16+
Fuzzy completion on a list of words.
17+
18+
If the list of words is: ["leopard" , "gorilla", "dinosaur", "cat", "bee"]
19+
Then trying to complete "oar" would yield "leopard" and "dinosaur", but not
20+
the others, because they match the regular expression 'o.*a.*r'.
21+
22+
The results are sorted by relevance, which is defined as the start position
23+
of the match and then the proportion of the word span that is covered. As a
24+
user, if you want to get leopard, it's better to type 'ld' (first + last
25+
letter) because this covers 100% of the word.
26+
27+
See: https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
28+
29+
:param words: List of words or callable that returns a list of words.
30+
:param meta_dict: Optional dict mapping words to their meta-information.
31+
:param WORD: When True, use WORD characters.
32+
:param sort_results: Boolean to determine whether to sort the results (default: True).
33+
34+
Fuzzy algorithm is based on this post: https://blog.amjith.com/fuzzyfinder-in-10-lines-of-python
35+
"""
36+
def __init__(self, words, meta_dict=None, WORD=False, sort_results=True):
37+
assert callable(words) or all(isinstance(w, string_types) for w in words)
38+
39+
self.words = words
40+
self.meta_dict = meta_dict or {}
41+
self.sort_results = sort_results
42+
self.WORD = WORD
43+
44+
def get_completions(self, document, complete_event):
45+
# Get list of words.
46+
words = self.words
47+
if callable(words):
48+
words = words()
49+
50+
word_before_cursor = document.get_word_before_cursor(WORD=self.WORD)
51+
52+
fuzzy_matches = []
53+
pat = '.*?'.join(map(re.escape, word_before_cursor))
54+
pat = '(?=({0}))'.format(pat) # lookahead regex to manage overlapping matches
55+
regex = re.compile(pat, re.IGNORECASE)
56+
for word in words:
57+
matches = list(regex.finditer(word))
58+
if matches:
59+
best = min(matches, key=lambda x: len(x.group(1))) # find shortest match
60+
fuzzy_matches.append(_FuzzyMatch(len(best.group(1)), best.start(), word))
61+
62+
def sort_key(fuzzy_match):
63+
" Sort by start position, then by proportion of word that is covered. "
64+
return (
65+
fuzzy_match.start_pos,
66+
float(fuzzy_match.match_length) / len(fuzzy_match.word)
67+
)
68+
69+
fuzzy_matches = sorted(fuzzy_matches, key=sort_key)
70+
71+
for match in fuzzy_matches:
72+
display_meta = self.meta_dict.get(match.word, '')
73+
74+
yield Completion(
75+
match.word,
76+
-len(word_before_cursor),
77+
display_meta=display_meta)
78+
79+
80+
_FuzzyMatch = namedtuple('_FuzzyMatch', 'match_length start_pos word')

tests/test_completion.py

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from contextlib import contextmanager
88
from six import text_type
99

10-
from prompt_toolkit.completion import CompleteEvent, PathCompleter, WordCompleter
10+
from prompt_toolkit.completion import CompleteEvent, PathCompleter, WordCompleter, FuzzyWordCompleter
1111
from prompt_toolkit.document import Document
1212

1313

@@ -314,3 +314,34 @@ def get_words():
314314
completions = completer.get_completions(Document('a'), CompleteEvent())
315315
assert [c.text for c in completions] == ['abc', 'aaa']
316316
assert called[0] == 2
317+
318+
def test_fuzzy_completer():
319+
collection = [
320+
'migrations.py',
321+
'django_migrations.py',
322+
'django_admin_log.py',
323+
'api_user.doc',
324+
'user_group.doc',
325+
'users.txt',
326+
'accounts.txt',
327+
'123.py',
328+
'test123test.py'
329+
]
330+
completer = FuzzyWordCompleter(collection)
331+
completions = completer.get_completions(Document('txt'), CompleteEvent())
332+
assert [c.text for c in completions] == ['users.txt', 'accounts.txt']
333+
334+
completions = completer.get_completions(Document('djmi'), CompleteEvent())
335+
assert [c.text for c in completions] == ['django_migrations.py', 'django_admin_log.py']
336+
337+
completions = completer.get_completions(Document('mi'), CompleteEvent())
338+
assert [c.text for c in completions] == ['migrations.py', 'django_migrations.py', 'django_admin_log.py']
339+
340+
completions = completer.get_completions(Document('user'), CompleteEvent())
341+
assert [c.text for c in completions] == ['user_group.doc', 'users.txt', 'api_user.doc']
342+
343+
completions = completer.get_completions(Document('123'), CompleteEvent())
344+
assert [c.text for c in completions] == ['123.py', 'test123test.py']
345+
346+
completions = completer.get_completions(Document('miGr'), CompleteEvent())
347+
assert [c.text for c in completions] == ['migrations.py', 'django_migrations.py',]

0 commit comments

Comments
 (0)