44from collections import namedtuple
55from 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' )
0 commit comments