diff --git a/jedi/api/__init__.py b/jedi/api/__init__.py index 94893a3d..0e505c9f 100644 --- a/jedi/api/__init__.py +++ b/jedi/api/__init__.py @@ -201,7 +201,7 @@ class Script(object): self._inference_state.environment, ) - def completions(self): + def completions(self, fuzzy=False): """ Return :class:`classes.Completion` objects. Those objects contain information about the completions, more than just names. @@ -214,7 +214,7 @@ class Script(object): self._inference_state, self._get_module_context(), self._code_lines, self._pos, self.call_signatures ) - return completion.completions() + return completion.completions(fuzzy) def goto_definitions(self, **kwargs): """ diff --git a/jedi/api/completion.py b/jedi/api/completion.py index 0876ac35..fc54b2c7 100644 --- a/jedi/api/completion.py +++ b/jedi/api/completion.py @@ -28,7 +28,7 @@ def get_call_signature_param_names(call_signatures): yield p._name -def filter_names(inference_state, completion_names, stack, like_name): +def filter_names(inference_state, completion_names, stack, like_name, fuzzy): comp_dct = {} if settings.case_insensitive_completion: like_name = like_name.lower() @@ -36,8 +36,11 @@ def filter_names(inference_state, completion_names, stack, like_name): string = name.string_name if settings.case_insensitive_completion: string = string.lower() - - if string.startswith(like_name): + if fuzzy: + match = helpers.fuzzy_match(string, like_name) + else: + match = helpers.start_match(string, like_name) + if match: new = classes.Completion( inference_state, name, @@ -70,7 +73,7 @@ def get_flow_scope_node(module_node, position): class Completion: def __init__(self, inference_state, module_context, code_lines, position, - call_signatures_callback): + call_signatures_callback, fuzzy=False): self._inference_state = inference_state self._module_context = module_context self._module_node = module_context.tree_node @@ -84,14 +87,20 @@ class Completion: self._position = position[0], position[1] - len(self._like_name) self._call_signatures_callback = call_signatures_callback - def completions(self): + self._fuzzy = fuzzy + + def completions(self, fuzzy=False, **kwargs): + return self._completions(fuzzy, **kwargs) + + def _completions(self, fuzzy): leaf = self._module_node.get_leaf_for_position(self._position, include_prefixes=True) string, start_leaf = _extract_string_while_in_string(leaf, self._position) if string is not None: completions = list(file_name_completions( self._inference_state, self._module_context, start_leaf, string, self._like_name, self._call_signatures_callback, - self._code_lines, self._original_position + self._code_lines, self._original_position, + fuzzy )) if completions: return completions @@ -99,7 +108,7 @@ class Completion: completion_names = self._get_value_completions(leaf) completions = filter_names(self._inference_state, completion_names, - self.stack, self._like_name) + self.stack, self._like_name, fuzzy) return sorted(completions, key=lambda x: (x.name.startswith('__'), x.name.startswith('_'), diff --git a/jedi/api/file_name.py b/jedi/api/file_name.py index 5c1985be..2cefc336 100644 --- a/jedi/api/file_name.py +++ b/jedi/api/file_name.py @@ -3,12 +3,13 @@ import os from jedi._compatibility import FileNotFoundError, force_unicode, scandir from jedi.inference.names import AbstractArbitraryName from jedi.api import classes +from jedi.api.helpers import fuzzy_match, start_match from jedi.inference.helpers import get_str_or_none from jedi.parser_utils import get_string_quote def file_name_completions(inference_state, module_context, start_leaf, string, - like_name, call_signatures_callback, code_lines, position): + like_name, call_signatures_callback, code_lines, position, fuzzy): # First we want to find out what can actually be changed as a name. like_name_length = len(os.path.basename(string) + like_name) @@ -32,13 +33,17 @@ def file_name_completions(inference_state, module_context, start_leaf, string, string = to_be_added + string base_path = os.path.join(inference_state.project._path, string) try: - listed = scandir(base_path) + listed = sorted(scandir(base_path), key=lambda e: e.name) # OSError: [Errno 36] File name too long: '...' except (FileNotFoundError, OSError): return for entry in listed: name = entry.name - if name.startswith(must_start_with): + if fuzzy: + match = fuzzy_match(name, must_start_with) + else: + match = start_match(name, must_start_with) + if match: if is_in_os_path_join or not entry.is_dir(): if start_leaf.type == 'string': quote = get_string_quote(start_leaf) diff --git a/jedi/api/helpers.py b/jedi/api/helpers.py index 9b2f2c07..ea797560 100644 --- a/jedi/api/helpers.py +++ b/jedi/api/helpers.py @@ -19,6 +19,19 @@ from jedi.cache import call_signature_time_cache CompletionParts = namedtuple('CompletionParts', ['path', 'has_dot', 'name']) +def start_match(string, like_name): + return string.startswith(like_name) + + +def fuzzy_match(string, like_name): + if len(like_name) <= 1: + return like_name in string + pos = string.find(like_name[0]) + if pos >= 0: + return fuzzy_match(string[pos + 1:], like_name[1:]) + return False + + def sorted_definitions(defs): # Note: `or ''` below is required because `module_path` could be return sorted(defs, key=lambda x: (x.module_path or '', x.line or 0, x.column or 0, x.name)) diff --git a/jedi/api/replstartup.py b/jedi/api/replstartup.py index 3ac84708..38aa8e6f 100644 --- a/jedi/api/replstartup.py +++ b/jedi/api/replstartup.py @@ -21,7 +21,7 @@ import jedi.utils from jedi import __version__ as __jedi_version__ print('REPL completion using Jedi %s' % __jedi_version__) -jedi.utils.setup_readline() +jedi.utils.setup_readline(fuzzy=False) del jedi diff --git a/jedi/utils.py b/jedi/utils.py index a5cf84b1..56b21d0c 100644 --- a/jedi/utils.py +++ b/jedi/utils.py @@ -17,7 +17,7 @@ from jedi import Interpreter READLINE_DEBUG = False -def setup_readline(namespace_module=__main__): +def setup_readline(namespace_module=__main__, fuzzy=False): """ Install Jedi completer to :mod:`readline`. @@ -83,7 +83,7 @@ def setup_readline(namespace_module=__main__): logging.debug("Start REPL completion: " + repr(text)) interpreter = Interpreter(text, [namespace_module.__dict__]) - completions = interpreter.completions() + completions = interpreter.completions(fuzzy=fuzzy) logging.debug("REPL completions: %s", completions) self.matches = [ diff --git a/test/test_api/test_api.py b/test/test_api/test_api.py index 587f0982..3dd857c5 100644 --- a/test/test_api/test_api.py +++ b/test/test_api/test_api.py @@ -317,3 +317,51 @@ def test_goto_follow_builtin_imports(Script): def test_docstrings_for_completions(Script): for c in Script('').completions(): assert isinstance(c.docstring(), (str, unicode)) + + +def test_fuzzy_completion(Script): + script = Script('string = "hello"\nstring.upper') + assert ['isupper', + 'upper'] == [comp.name for comp in script.completions(fuzzy=True)] + + +@pytest.mark.skipif(sys.version_info < (3, 3), + reason="requires python3.3 or higher") +def test_math_fuzzy_completion(Script): + script = Script('import math\nmath.og') + assert ['copysign', 'log', 'log10', 'log1p', + 'log2'] == [comp.name for comp in script.completions(fuzzy=True)] + +@pytest.mark.skipif(sys.version_info < (3, 3), + reason="requires python3.3 or higher") +def test_file_fuzzy_completion(Script, tmp_path): + folder0 = tmp_path / "inference" + folder0.mkdir() + file0_path0 = folder0 / "sys_path.py" + file0_path0.write_text('\n') + file0_path1 = folder0 / "syntax_tree.py" + file0_path1.write_text('\n') + script = Script('"{}/yt'.format(folder0)) + assert ['syntax_tree.py"', 'sys_path.py"'] \ + == [comp.name for comp in script.completions(fuzzy=True)] + + +@pytest.mark.skipif(sys.version_info > (2, 7), + reason="requires python3.3 or higher") +def test_math_fuzzy_completion(Script): + script = Script('import math\nmath.og') + assert ['copysign', 'log', 'log10', + 'log1p'] == [comp.name for comp in script.completions(fuzzy=True)] + +@pytest.mark.skipif(sys.version_info > (2, 7), + reason="requires python3.3 or higher") +def test_file_fuzzy_completion(Script, tmp_path): + folder0 = tmp_path / u"inference" + folder0.mkdir() + file0_path0 = folder0 / u"sys_path.py" + file0_path0.write_text('\n') + file0_path1 = folder0 / u"syntax_tree.py" + file0_path1.write_text('\n') + script = Script('"{}/yt'.format(folder0)) + assert ['syntax_tree.py"', 'sys_path.py"'] \ + == [comp.name for comp in script.completions(fuzzy=True)] diff --git a/test/test_api/test_completion.py b/test/test_api/test_completion.py index 2d35cb74..7c9f3ab2 100644 --- a/test/test_api/test_completion.py +++ b/test/test_api/test_completion.py @@ -271,3 +271,15 @@ def test_file_path_completions(Script, file, code, column, expected): assert len(comps) > 100 # This is basically global completions. else: assert [c.complete for c in comps] == expected + +from jedi.api.helpers import start_match, fuzzy_match + +def test_start_match(): + assert start_match('Condition', 'C') + +def test_fuzzy_match(): + assert fuzzy_match('Condition', 'i') + assert not fuzzy_match('Condition', 'p') + assert fuzzy_match('Condition', 'ii') + assert not fuzzy_match('Condition', 'Ciito') + assert fuzzy_match('Condition', 'Cdiio')