320 lines
11 KiB
Python
320 lines
11 KiB
Python
|
import os
|
||
|
import re
|
||
|
|
||
|
from parso import python_bytes_to_unicode
|
||
|
|
||
|
from jedi.debug import dbg
|
||
|
from jedi.file_io import KnownContentFileIO, FolderIO
|
||
|
from jedi.inference.names import SubModuleName
|
||
|
from jedi.inference.imports import load_module_from_path
|
||
|
from jedi.inference.filters import ParserTreeFilter
|
||
|
from jedi.inference.gradual.conversion import convert_names
|
||
|
|
||
|
_IGNORE_FOLDERS = ('.tox', '.venv', '.mypy_cache', 'venv', '__pycache__')
|
||
|
|
||
|
_OPENED_FILE_LIMIT = 2000
|
||
|
"""
|
||
|
Stats from a 2016 Lenovo Notebook running Linux:
|
||
|
With os.walk, it takes about 10s to scan 11'000 files (without filesystem
|
||
|
caching). Once cached it only takes 5s. So it is expected that reading all
|
||
|
those files might take a few seconds, but not a lot more.
|
||
|
"""
|
||
|
_PARSED_FILE_LIMIT = 30
|
||
|
"""
|
||
|
For now we keep the amount of parsed files really low, since parsing might take
|
||
|
easily 100ms for bigger files.
|
||
|
"""
|
||
|
|
||
|
|
||
|
def _resolve_names(definition_names, avoid_names=()):
|
||
|
for name in definition_names:
|
||
|
if name in avoid_names:
|
||
|
# Avoiding recursions here, because goto on a module name lands
|
||
|
# on the same module.
|
||
|
continue
|
||
|
|
||
|
if not isinstance(name, SubModuleName):
|
||
|
# SubModuleNames are not actually existing names but created
|
||
|
# names when importing something like `import foo.bar.baz`.
|
||
|
yield name
|
||
|
|
||
|
if name.api_type == 'module':
|
||
|
yield from _resolve_names(name.goto(), definition_names)
|
||
|
|
||
|
|
||
|
def _dictionarize(names):
|
||
|
return dict(
|
||
|
(n if n.tree_name is None else n.tree_name, n)
|
||
|
for n in names
|
||
|
)
|
||
|
|
||
|
|
||
|
def _find_defining_names(module_context, tree_name):
|
||
|
found_names = _find_names(module_context, tree_name)
|
||
|
|
||
|
for name in list(found_names):
|
||
|
# Convert from/to stubs, because those might also be usages.
|
||
|
found_names |= set(convert_names(
|
||
|
[name],
|
||
|
only_stubs=not name.get_root_context().is_stub(),
|
||
|
prefer_stub_to_compiled=False
|
||
|
))
|
||
|
|
||
|
found_names |= set(_find_global_variables(found_names, tree_name.value))
|
||
|
for name in list(found_names):
|
||
|
if name.api_type == 'param' or name.tree_name is None \
|
||
|
or name.tree_name.parent.type == 'trailer':
|
||
|
continue
|
||
|
found_names |= set(_add_names_in_same_context(name.parent_context, name.string_name))
|
||
|
return set(_resolve_names(found_names))
|
||
|
|
||
|
|
||
|
def _find_names(module_context, tree_name):
|
||
|
name = module_context.create_name(tree_name)
|
||
|
found_names = set(name.goto())
|
||
|
found_names.add(name)
|
||
|
|
||
|
return set(_resolve_names(found_names))
|
||
|
|
||
|
|
||
|
def _add_names_in_same_context(context, string_name):
|
||
|
if context.tree_node is None:
|
||
|
return
|
||
|
|
||
|
until_position = None
|
||
|
while True:
|
||
|
filter_ = ParserTreeFilter(
|
||
|
parent_context=context,
|
||
|
until_position=until_position,
|
||
|
)
|
||
|
names = set(filter_.get(string_name))
|
||
|
if not names:
|
||
|
break
|
||
|
yield from names
|
||
|
ordered = sorted(names, key=lambda x: x.start_pos)
|
||
|
until_position = ordered[0].start_pos
|
||
|
|
||
|
|
||
|
def _find_global_variables(names, search_name):
|
||
|
for name in names:
|
||
|
if name.tree_name is None:
|
||
|
continue
|
||
|
module_context = name.get_root_context()
|
||
|
try:
|
||
|
method = module_context.get_global_filter
|
||
|
except AttributeError:
|
||
|
continue
|
||
|
else:
|
||
|
for global_name in method().get(search_name):
|
||
|
yield global_name
|
||
|
c = module_context.create_context(global_name.tree_name)
|
||
|
yield from _add_names_in_same_context(c, global_name.string_name)
|
||
|
|
||
|
|
||
|
def find_references(module_context, tree_name, only_in_module=False):
|
||
|
inf = module_context.inference_state
|
||
|
search_name = tree_name.value
|
||
|
|
||
|
# We disable flow analysis, because if we have ifs that are only true in
|
||
|
# certain cases, we want both sides.
|
||
|
try:
|
||
|
inf.flow_analysis_enabled = False
|
||
|
found_names = _find_defining_names(module_context, tree_name)
|
||
|
finally:
|
||
|
inf.flow_analysis_enabled = True
|
||
|
|
||
|
found_names_dct = _dictionarize(found_names)
|
||
|
|
||
|
module_contexts = [module_context]
|
||
|
if not only_in_module:
|
||
|
for m in set(d.get_root_context() for d in found_names):
|
||
|
if m != module_context and m.tree_node is not None \
|
||
|
and inf.project.path in m.py__file__().parents:
|
||
|
module_contexts.append(m)
|
||
|
# For param no search for other modules is necessary.
|
||
|
if only_in_module or any(n.api_type == 'param' for n in found_names):
|
||
|
potential_modules = module_contexts
|
||
|
else:
|
||
|
potential_modules = get_module_contexts_containing_name(
|
||
|
inf,
|
||
|
module_contexts,
|
||
|
search_name,
|
||
|
)
|
||
|
|
||
|
non_matching_reference_maps = {}
|
||
|
for module_context in potential_modules:
|
||
|
for name_leaf in module_context.tree_node.get_used_names().get(search_name, []):
|
||
|
new = _dictionarize(_find_names(module_context, name_leaf))
|
||
|
if any(tree_name in found_names_dct for tree_name in new):
|
||
|
found_names_dct.update(new)
|
||
|
for tree_name in new:
|
||
|
for dct in non_matching_reference_maps.get(tree_name, []):
|
||
|
# A reference that was previously searched for matches
|
||
|
# with a now found name. Merge.
|
||
|
found_names_dct.update(dct)
|
||
|
try:
|
||
|
del non_matching_reference_maps[tree_name]
|
||
|
except KeyError:
|
||
|
pass
|
||
|
else:
|
||
|
for name in new:
|
||
|
non_matching_reference_maps.setdefault(name, []).append(new)
|
||
|
result = found_names_dct.values()
|
||
|
if only_in_module:
|
||
|
return [n for n in result if n.get_root_context() == module_context]
|
||
|
return result
|
||
|
|
||
|
|
||
|
def _check_fs(inference_state, file_io, regex):
|
||
|
try:
|
||
|
code = file_io.read()
|
||
|
except FileNotFoundError:
|
||
|
return None
|
||
|
code = python_bytes_to_unicode(code, errors='replace')
|
||
|
if not regex.search(code):
|
||
|
return None
|
||
|
new_file_io = KnownContentFileIO(file_io.path, code)
|
||
|
m = load_module_from_path(inference_state, new_file_io)
|
||
|
if m.is_compiled():
|
||
|
return None
|
||
|
return m.as_context()
|
||
|
|
||
|
|
||
|
def gitignored_paths(folder_io, file_io):
|
||
|
ignored_paths_abs = set()
|
||
|
ignored_paths_rel = set()
|
||
|
|
||
|
for l in file_io.read().splitlines():
|
||
|
if not l or l.startswith(b'#') or l.startswith(b'!') or b'*' in l:
|
||
|
continue
|
||
|
|
||
|
p = l.decode('utf-8', 'ignore').rstrip('/')
|
||
|
if '/' in p:
|
||
|
name = p.lstrip('/')
|
||
|
ignored_paths_abs.add(os.path.join(folder_io.path, name))
|
||
|
else:
|
||
|
name = p
|
||
|
ignored_paths_rel.add((folder_io.path, name))
|
||
|
|
||
|
return ignored_paths_abs, ignored_paths_rel
|
||
|
|
||
|
|
||
|
def expand_relative_ignore_paths(folder_io, relative_paths):
|
||
|
curr_path = folder_io.path
|
||
|
return {os.path.join(curr_path, p[1]) for p in relative_paths if curr_path.startswith(p[0])}
|
||
|
|
||
|
|
||
|
def recurse_find_python_folders_and_files(folder_io, except_paths=()):
|
||
|
except_paths = set(except_paths)
|
||
|
except_paths_relative = set()
|
||
|
|
||
|
for root_folder_io, folder_ios, file_ios in folder_io.walk():
|
||
|
# Delete folders that we don't want to iterate over.
|
||
|
for file_io in file_ios:
|
||
|
path = file_io.path
|
||
|
if path.suffix in ('.py', '.pyi'):
|
||
|
if path not in except_paths:
|
||
|
yield None, file_io
|
||
|
|
||
|
if path.name == '.gitignore':
|
||
|
ignored_paths_abs, ignored_paths_rel = gitignored_paths(
|
||
|
root_folder_io, file_io
|
||
|
)
|
||
|
except_paths |= ignored_paths_abs
|
||
|
except_paths_relative |= ignored_paths_rel
|
||
|
|
||
|
except_paths_relative_expanded = expand_relative_ignore_paths(
|
||
|
root_folder_io, except_paths_relative
|
||
|
)
|
||
|
|
||
|
folder_ios[:] = [
|
||
|
folder_io
|
||
|
for folder_io in folder_ios
|
||
|
if folder_io.path not in except_paths
|
||
|
and folder_io.path not in except_paths_relative_expanded
|
||
|
and folder_io.get_base_name() not in _IGNORE_FOLDERS
|
||
|
]
|
||
|
for folder_io in folder_ios:
|
||
|
yield folder_io, None
|
||
|
|
||
|
|
||
|
def recurse_find_python_files(folder_io, except_paths=()):
|
||
|
for folder_io, file_io in recurse_find_python_folders_and_files(folder_io, except_paths):
|
||
|
if file_io is not None:
|
||
|
yield file_io
|
||
|
|
||
|
|
||
|
def _find_python_files_in_sys_path(inference_state, module_contexts):
|
||
|
sys_path = inference_state.get_sys_path()
|
||
|
except_paths = set()
|
||
|
yielded_paths = [m.py__file__() for m in module_contexts]
|
||
|
for module_context in module_contexts:
|
||
|
file_io = module_context.get_value().file_io
|
||
|
if file_io is None:
|
||
|
continue
|
||
|
|
||
|
folder_io = file_io.get_parent_folder()
|
||
|
while True:
|
||
|
path = folder_io.path
|
||
|
if not any(path.startswith(p) for p in sys_path) or path in except_paths:
|
||
|
break
|
||
|
for file_io in recurse_find_python_files(folder_io, except_paths):
|
||
|
if file_io.path not in yielded_paths:
|
||
|
yield file_io
|
||
|
except_paths.add(path)
|
||
|
folder_io = folder_io.get_parent_folder()
|
||
|
|
||
|
|
||
|
def _find_project_modules(inference_state, module_contexts):
|
||
|
except_ = [m.py__file__() for m in module_contexts]
|
||
|
yield from recurse_find_python_files(FolderIO(inference_state.project.path), except_)
|
||
|
|
||
|
|
||
|
def get_module_contexts_containing_name(inference_state, module_contexts, name,
|
||
|
limit_reduction=1):
|
||
|
"""
|
||
|
Search a name in the directories of modules.
|
||
|
|
||
|
:param limit_reduction: Divides the limits on opening/parsing files by this
|
||
|
factor.
|
||
|
"""
|
||
|
# Skip non python modules
|
||
|
for module_context in module_contexts:
|
||
|
if module_context.is_compiled():
|
||
|
continue
|
||
|
yield module_context
|
||
|
|
||
|
# Very short names are not searched in other modules for now to avoid lots
|
||
|
# of file lookups.
|
||
|
if len(name) <= 2:
|
||
|
return
|
||
|
|
||
|
# Currently not used, because there's only `scope=project` and `scope=file`
|
||
|
# At the moment there is no such thing as `scope=sys.path`.
|
||
|
# file_io_iterator = _find_python_files_in_sys_path(inference_state, module_contexts)
|
||
|
file_io_iterator = _find_project_modules(inference_state, module_contexts)
|
||
|
yield from search_in_file_ios(inference_state, file_io_iterator, name,
|
||
|
limit_reduction=limit_reduction)
|
||
|
|
||
|
|
||
|
def search_in_file_ios(inference_state, file_io_iterator, name,
|
||
|
limit_reduction=1, complete=False):
|
||
|
parse_limit = _PARSED_FILE_LIMIT / limit_reduction
|
||
|
open_limit = _OPENED_FILE_LIMIT / limit_reduction
|
||
|
file_io_count = 0
|
||
|
parsed_file_count = 0
|
||
|
regex = re.compile(r'\b' + re.escape(name) + (r'' if complete else r'\b'))
|
||
|
for file_io in file_io_iterator:
|
||
|
file_io_count += 1
|
||
|
m = _check_fs(inference_state, file_io, regex)
|
||
|
if m is not None:
|
||
|
parsed_file_count += 1
|
||
|
yield m
|
||
|
if parsed_file_count >= parse_limit:
|
||
|
dbg('Hit limit of parsed files: %s', parse_limit)
|
||
|
break
|
||
|
|
||
|
if file_io_count >= open_limit:
|
||
|
dbg('Hit limit of opened files: %s', open_limit)
|
||
|
break
|