Skip to content

Commit

Permalink
Merge branch 'dict', fixes #951
Browse files Browse the repository at this point in the history
  • Loading branch information
davidhalter committed Dec 31, 2019
2 parents eff6706 + cf26ede commit 469ddc2
Show file tree
Hide file tree
Showing 14 changed files with 415 additions and 79 deletions.
125 changes: 90 additions & 35 deletions jedi/api/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from jedi.api import classes
from jedi.api import helpers
from jedi.api import keywords
from jedi.api.strings import complete_dict
from jedi.api.file_name import complete_file_name
from jedi.inference import imports
from jedi.inference.base_value import ValueSet
Expand Down Expand Up @@ -59,6 +60,11 @@ def filter_names(inference_state, completion_names, stack, like_name, fuzzy):
yield new


def _remove_duplicates(completions, other_completions):
names = {d.name for d in other_completions}
return [c for c in completions if c.name not in names]


def get_user_context(module_context, position):
"""
Returns the scope in which the user resides. This includes flows.
Expand Down Expand Up @@ -95,36 +101,52 @@ def __init__(self, inference_state, module_context, code_lines, position,
# The actual cursor position is not what we need to calculate
# everything. We want the start of the name we're on.
self._original_position = position
self._position = position[0], position[1] - len(self._like_name)
self._signatures_callback = signatures_callback

self._fuzzy = fuzzy

def complete(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(complete_file_name(
leaf = self._module_node.get_leaf_for_position(
self._original_position,
include_prefixes=True
)
string, start_leaf, quote = _extract_string_while_in_string(leaf, self._original_position)

prefixed_completions = complete_dict(
self._module_context,
self._code_lines,
start_leaf or leaf,
self._original_position,
None if string is None else quote + string,
fuzzy=fuzzy,
)

if string is not None and not prefixed_completions:
prefixed_completions = list(complete_file_name(
self._inference_state, self._module_context, start_leaf, string,
self._like_name, self._signatures_callback,
self._code_lines, self._original_position,
fuzzy
))
if completions:
return completions
if string is not None:
return prefixed_completions

completion_names = self._complete_python(leaf)

completions = filter_names(self._inference_state, completion_names,
self.stack, self._like_name, fuzzy)
completions = list(filter_names(self._inference_state, completion_names,
self.stack, self._like_name, fuzzy))

return sorted(completions, key=lambda x: (x.name.startswith('__'),
x.name.startswith('_'),
x.name.lower()))
return (
# Removing duplicates mostly to remove False/True/None duplicates.
_remove_duplicates(prefixed_completions, completions)
+ sorted(completions, key=lambda x: (x.name.startswith('__'),
x.name.startswith('_'),
x.name.lower()))
)

def _complete_python(self, leaf):
"""
Analyzes the value that a completion is made in and decides what to
Analyzes the current context of a completion and decides what to
return.
Technically this works by generating a parser stack and analysing the
Expand All @@ -139,6 +161,10 @@ def _complete_python(self, leaf):

grammar = self._inference_state.grammar
self.stack = stack = None
self._position = (
self._original_position[0],
self._original_position[1] - len(self._like_name)
)

try:
self.stack = stack = helpers.get_stack_at_position(
Expand Down Expand Up @@ -191,23 +217,20 @@ def _complete_python(self, leaf):

completion_names = []
current_line = self._code_lines[self._position[0] - 1][:self._position[1]]
if not current_line or current_line[-1] in ' \t.;' \
and current_line[-3:] != '...':
completion_names += self._complete_keywords(allowed_transitions)

completion_names += self._complete_keywords(
allowed_transitions,
only_values=not (not current_line or current_line[-1] in ' \t.;'
and current_line[-3:] != '...')
)

if any(t in allowed_transitions for t in (PythonTokenTypes.NAME,
PythonTokenTypes.INDENT)):
# This means that we actually have to do type inference.

nonterminals = [stack_node.nonterminal for stack_node in stack]

nodes = []
for stack_node in stack:
if stack_node.dfa.from_rule == 'small_stmt':
nodes = []
else:
nodes += stack_node.nodes

nodes = _gather_nodes(stack)
if nodes and nodes[-1] in ('as', 'def', 'class'):
# No completions for ``with x as foo`` and ``import x as foo``.
# Also true for defining names as a class or function.
Expand Down Expand Up @@ -279,10 +302,11 @@ def _complete_params(self, leaf):
return complete_param_names(context, function_name.value, decorators)
return []

def _complete_keywords(self, allowed_transitions):
def _complete_keywords(self, allowed_transitions, only_values):
for k in allowed_transitions:
if isinstance(k, str) and k.isalpha():
yield keywords.KeywordName(self._inference_state, k)
if not only_values or k in ('True', 'False', 'None'):
yield keywords.KeywordName(self._inference_state, k)

def _complete_global_scope(self):
context = get_user_context(self._module_context, self._position)
Expand Down Expand Up @@ -411,23 +435,54 @@ def _complete_inherited(self, is_function=True):
yield name


def _gather_nodes(stack):
nodes = []
for stack_node in stack:
if stack_node.dfa.from_rule == 'small_stmt':
nodes = []
else:
nodes += stack_node.nodes
return nodes


_string_start = re.compile(r'^\w*(\'{3}|"{3}|\'|")')


def _extract_string_while_in_string(leaf, position):
def return_part_of_leaf(leaf):
kwargs = {}
if leaf.line == position[0]:
kwargs['endpos'] = position[1] - leaf.column
match = _string_start.match(leaf.value, **kwargs)
start = match.group(0)
if leaf.line == position[0] and position[1] < leaf.column + match.end():
return None, None, None
return cut_value_at_position(leaf, position)[match.end():], leaf, start

if position < leaf.start_pos:
return None, None
return None, None, None

if leaf.type == 'string':
match = re.match(r'^\w*(\'{3}|"{3}|\'|")', leaf.value)
quote = match.group(1)
if leaf.line == position[0] and position[1] < leaf.column + match.end():
return None, None
if leaf.end_pos[0] == position[0] and position[1] > leaf.end_pos[1] - len(quote):
return None, None
return cut_value_at_position(leaf, position)[match.end():], leaf
return return_part_of_leaf(leaf)

leaves = []
while leaf is not None and leaf.line == position[0]:
if leaf.type == 'error_leaf' and ('"' in leaf.value or "'" in leaf.value):
return ''.join(l.get_code() for l in leaves), leaf
if len(leaf.value) > 1:
return return_part_of_leaf(leaf)
prefix_leaf = None
if not leaf.prefix:
prefix_leaf = leaf.get_previous_leaf()
if prefix_leaf is None or prefix_leaf.type != 'name' \
or not all(c in 'rubf' for c in prefix_leaf.value.lower()):
prefix_leaf = None

return (
''.join(cut_value_at_position(l, position) for l in leaves),
prefix_leaf or leaf,
('' if prefix_leaf is None else prefix_leaf.value)
+ cut_value_at_position(leaf, position),
)
leaves.insert(0, leaf)
leaf = leaf.get_previous_leaf()
return None, None
return None, None, None
29 changes: 9 additions & 20 deletions jedi/api/file_name.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import os

from jedi._compatibility import FileNotFoundError, force_unicode, scandir
from jedi.inference.names import AbstractArbitraryName
from jedi.api import classes
from jedi.api.strings import StringName, get_quote_ending
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


class PathName(StringName):
api_type = u'path'


def complete_file_name(inference_state, module_context, start_leaf, string,
like_name, 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)
like_name_length = len(os.path.basename(string))

addition = _get_string_additions(module_context, start_leaf)
if addition is None:
Expand All @@ -20,7 +23,7 @@ def complete_file_name(inference_state, module_context, start_leaf, string,

# Here we use basename again, because if strings are added like
# `'foo' + 'bar`, it should complete to `foobar/`.
must_start_with = os.path.basename(string) + like_name
must_start_with = os.path.basename(string)
string = os.path.dirname(string)

sigs = signatures_callback(*position)
Expand All @@ -45,22 +48,13 @@ def complete_file_name(inference_state, module_context, start_leaf, string,
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)
else:
assert start_leaf.type == 'error_leaf'
quote = start_leaf.value
potential_other_quote = \
code_lines[position[0] - 1][position[1]:position[1] + len(quote)]
# Add a quote if it's not already there.
if quote != potential_other_quote:
name += quote
name += get_quote_ending(start_leaf.value, code_lines, position)
else:
name += os.path.sep

yield classes.Completion(
inference_state,
FileName(inference_state, name[len(must_start_with) - like_name_length:]),
PathName(inference_state, name[len(must_start_with) - like_name_length:]),
stack=None,
like_name_length=like_name_length,
is_fuzzy=fuzzy,
Expand Down Expand Up @@ -106,11 +100,6 @@ def _add_strings(context, nodes, add_slash=False):
return string


class FileName(AbstractArbitraryName):
api_type = u'path'
is_value_name = False


def _add_os_path_join(module_context, start_leaf, bracket_start):
def check(maybe_bracket, nodes):
if maybe_bracket.start_pos != bracket_start:
Expand Down
110 changes: 110 additions & 0 deletions jedi/api/strings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
"""
This module is here for string completions. This means mostly stuff where
strings are returned, like `foo = dict(bar=3); foo["ba` would complete to
`"bar"]`.
It however does the same for numbers. The difference between string completions
and other completions is mostly that this module doesn't return defined
names in a module, but pretty much an arbitrary string.
"""
import re

from jedi._compatibility import unicode
from jedi.inference.names import AbstractArbitraryName
from jedi.inference.helpers import infer_call_of_leaf
from jedi.api.classes import Completion
from jedi.parser_utils import cut_value_at_position

_sentinel = object()


class StringName(AbstractArbitraryName):
api_type = u'string'
is_value_name = False


def complete_dict(module_context, code_lines, leaf, position, string, fuzzy):
bracket_leaf = leaf
if bracket_leaf != '[':
bracket_leaf = leaf.get_previous_leaf()

cut_end_quote = ''
if string:
cut_end_quote = get_quote_ending(string, code_lines, position, invert_result=True)

if bracket_leaf == '[':
if string is None and leaf is not bracket_leaf:
string = cut_value_at_position(leaf, position)

context = module_context.create_context(bracket_leaf)
before_bracket_leaf = bracket_leaf.get_previous_leaf()
if before_bracket_leaf.type in ('atom', 'trailer', 'name'):
values = infer_call_of_leaf(context, before_bracket_leaf)
return list(_completions_for_dicts(
module_context.inference_state,
values,
'' if string is None else string,
cut_end_quote,
fuzzy=fuzzy,
))
return []


def _completions_for_dicts(inference_state, dicts, literal_string, cut_end_quote, fuzzy):
for dict_key in sorted(_get_python_keys(dicts), key=lambda x: repr(x)):
dict_key_str = _create_repr_string(literal_string, dict_key)
if dict_key_str.startswith(literal_string):
name = StringName(inference_state, dict_key_str[:-len(cut_end_quote) or None])
yield Completion(
inference_state,
name,
stack=None,
like_name_length=len(literal_string),
is_fuzzy=fuzzy
)


def _create_repr_string(literal_string, dict_key):
if not isinstance(dict_key, (unicode, bytes)) or not literal_string:
return repr(dict_key)

r = repr(dict_key)
prefix, quote = _get_string_prefix_and_quote(literal_string)
if quote is None:
return r
if quote == r[0]:
return prefix + r
return prefix + quote + r[1:-1] + quote


def _get_python_keys(dicts):
for dct in dicts:
if dct.array_type == 'dict':
for key in dct.get_key_values():
dict_key = key.get_safe_value(default=_sentinel)
if dict_key is not _sentinel:
yield dict_key


def _get_string_prefix_and_quote(string):
match = re.match(r'(\w*)("""|\'{3}|"|\')', string)
if match is None:
return None, None
return match.group(1), match.group(2)


def _get_string_quote(string):
return _get_string_prefix_and_quote(string)[1]


def _matches_quote_at_position(code_lines, quote, position):
string = code_lines[position[0] - 1][position[1]:position[1] + len(quote)]
return string == quote


def get_quote_ending(string, code_lines, position, invert_result=False):
quote = _get_string_quote(string)
# Add a quote only if it's not already there.
if _matches_quote_at_position(code_lines, quote, position) != invert_result:
return ''
return quote
Loading

0 comments on commit 469ddc2

Please sign in to comment.