xonsh/xonsh/completer.py

417 lines
16 KiB
Python
Raw Normal View History

2015-03-01 22:59:44 -06:00
"""A (tab-)completer for xonsh."""
from __future__ import print_function, unicode_literals
import os
2015-03-02 23:01:43 -06:00
import re
2015-03-01 22:59:44 -06:00
import builtins
2015-05-25 12:51:07 +02:00
import pickle
2015-03-01 22:59:44 -06:00
import subprocess
2015-05-05 16:03:01 +02:00
import sys
2015-03-01 22:59:44 -06:00
2015-03-15 17:30:45 -05:00
from xonsh.built_ins import iglobpath
2015-04-06 08:14:53 -04:00
from xonsh.tools import subexpr_from_unbalanced
2015-03-15 17:30:45 -05:00
2015-03-26 01:33:08 -05:00
RE_DASHF = re.compile(r'-F\s+(\w+)')
2015-04-05 16:31:50 -05:00
RE_ATTR = re.compile(r'(\S+(\..+)*)\.(\w*)$')
2015-03-02 23:01:43 -06:00
2015-04-02 19:07:31 -04:00
XONSH_TOKENS = {
'and ', 'as ', 'assert ', 'break', 'class ', 'continue', 'def ', 'del ',
'elif ', 'else', 'except ', 'finally:', 'for ', 'from ', 'global ',
'import ', 'if ', 'in ', 'is ', 'lambda ', 'nonlocal ', 'not ', 'or ',
'pass', 'raise ', 'return ', 'try:', 'while ', 'with ', 'yield ', '+', '-',
'/', '//', '%', '**', '|', '&', '~', '^', '>>', '<<', '<', '<=', '>', '>=',
'==', '!=', '->', '=', '+=', '-=', '*=', '/=', '%=', '**=', '>>=', '<<=',
'&=', '^=', '|=', '//=', ',', ';', ':', '?', '??', '$(', '${', '$[', '..',
'...'
}
2015-03-01 22:59:44 -06:00
2015-03-03 00:52:33 -06:00
BASH_COMPLETE_SCRIPT = """source {filename}
COMP_WORDS=({line})
COMP_LINE="{line}"
COMP_POINT=${{#COMP_LINE}}
COMP_COUNT={end}
COMP_CWORD={n}
{func} {cmd} {prefix} {prev}
for ((i=0;i<${{#COMPREPLY[*]}};i++)) do echo ${{COMPREPLY[i]}}; done
"""
2015-08-03 20:46:41 -04:00
2015-07-30 19:40:19 -05:00
def startswithlow(x, start, startlow=None):
"""True if x starts with a string or its lowercase version. The lowercase
version may be optionally be provided.
"""
if startlow is None:
startlow = start.lower()
return x.startswith(start) or x.lower().startswith(startlow)
2015-04-02 19:07:31 -04:00
2015-08-02 16:14:56 -05:00
def startswithnorm(x, start, startlow=None):
2015-08-03 20:46:41 -04:00
"""True if x starts with a string s. Ignores its lowercase version, but
2015-08-02 16:14:56 -05:00
matches the API of startswithlow().
"""
return x.startswith(start)
def _normpath(p):
# Prevent normpath() from removing initial ./
here = os.curdir + os.sep
if p.startswith(here):
return os.path.join(os.curdir, os.path.normpath(p[len(here):]))
return os.path.normpath(p)
2015-03-01 22:59:44 -06:00
class Completer(object):
"""This provides a list of optional completions for the xonsh shell."""
2015-03-02 23:01:43 -06:00
def __init__(self):
2015-04-06 08:14:53 -04:00
# initialize command cache
self._path_checksum = None
self._alias_checksum = None
self._path_mtime = -1
2015-04-06 08:18:23 -04:00
self._cmds_cache = frozenset()
2015-05-25 12:51:07 +02:00
self._man_completer = ManCompleter()
2015-03-02 23:01:43 -06:00
try:
# FIXME this could be threaded for faster startup times
self._load_bash_complete_funcs()
# or we could make this lazy
self._load_bash_complete_files()
self.have_bash = True
except (subprocess.CalledProcessError, FileNotFoundError):
2015-03-02 23:01:43 -06:00
self.have_bash = False
2015-03-01 22:59:44 -06:00
def complete(self, prefix, line, begidx, endidx, ctx=None):
"""Complete the string s, given a possible execution context.
Parameters
----------
prefix : str
The string to match
line : str
The line that prefix appears on.
begidx : int
The index in line that prefix starts on.
endidx : int
The index in line that prefix ends on.
ctx : Iterable of str (ie dict, set, etc), optional
Names in the current execution context.
Returns
-------
rtn : list of str
Possible completions of prefix, sorted alphabetically.
"""
2015-03-02 19:18:16 -06:00
space = ' ' # intern some strings for faster appending
2015-03-02 18:36:07 -06:00
slash = '/'
2015-04-05 15:42:24 -05:00
dot = '.'
2015-04-03 18:25:44 -04:00
ctx = ctx or {}
2015-07-30 19:40:19 -05:00
prefixlow = prefix.lower()
2015-04-03 18:25:44 -04:00
cmd = line.split(' ', 1)[0]
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
2015-03-03 00:52:33 -06:00
if begidx == 0:
2015-04-03 18:25:44 -04:00
# the first thing we're typing; could be python or subprocess, so
# anything goes.
2015-04-06 08:14:53 -04:00
rtn = self.cmd_complete(prefix)
2015-04-03 18:25:44 -04:00
elif cmd in self.bash_complete_funcs:
2015-03-08 21:19:16 -05:00
rtn = set()
for s in self.bash_complete(prefix, line, begidx, endidx):
if os.path.isdir(s.rstrip()):
s = s.rstrip() + slash
rtn.add(s)
2015-03-15 17:55:42 -05:00
if len(rtn) == 0:
rtn = self.path_complete(prefix)
2015-03-08 21:19:16 -05:00
return sorted(rtn)
2015-04-06 08:14:53 -04:00
elif prefix.startswith('${') or prefix.startswith('@('):
# python mode explicitly
rtn = set()
2015-05-19 18:07:04 +02:00
elif prefix.startswith('-'):
2015-05-25 12:51:07 +02:00
return sorted(self._man_completer.option_complete(prefix, cmd))
2015-04-06 08:14:53 -04:00
elif cmd not in ctx:
2015-05-05 16:03:01 +02:00
if cmd == 'import' and begidx == len('import '):
# completing module to import
return sorted(self.module_complete(prefix))
2015-04-06 08:14:53 -04:00
if cmd in self._all_commands():
# subproc mode; do path completions
return sorted(self.path_complete(prefix, cdpath=True))
2015-04-06 08:14:53 -04:00
else:
# if we're here, could be anything
rtn = set()
2015-03-03 00:52:33 -06:00
else:
2015-04-05 20:43:25 -04:00
# if we're here, we're not a command, but could be anything else
2015-03-03 00:52:33 -06:00
rtn = set()
2015-08-02 16:14:56 -05:00
rtn |= {s for s in XONSH_TOKENS if startswither(s, prefix, prefixlow)}
2015-03-01 22:59:44 -06:00
if ctx is not None:
2015-04-05 15:42:24 -05:00
if dot in prefix:
rtn |= self.attr_complete(prefix, ctx)
else:
2015-08-02 16:14:56 -05:00
rtn |= {s for s in ctx if startswither(s, prefix, prefixlow)}
rtn |= {s for s in dir(builtins) if startswither(s, prefix, prefixlow)}
2015-07-30 19:40:19 -05:00
rtn |= {s + space for s in builtins.aliases
2015-08-02 16:14:56 -05:00
if startswither(s, prefix, prefixlow)}
2015-04-03 18:25:44 -04:00
rtn |= self.path_complete(prefix)
return sorted(rtn)
def _add_env(self, paths, prefix):
2015-03-01 22:59:44 -06:00
if prefix.startswith('$'):
2015-04-02 19:07:31 -04:00
env = builtins.__xonsh_env__
2015-08-02 16:14:56 -05:00
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
2015-03-01 22:59:44 -06:00
key = prefix[1:]
2015-07-30 19:40:19 -05:00
keylow = key.lower()
2015-08-02 16:14:56 -05:00
paths.update({'$' + k for k in env if startswither(k, key, keylow)})
2015-04-03 18:25:44 -04:00
def _add_dots(self, paths, prefix):
if prefix in {'', '.'}:
paths.update({'./', '../'})
if prefix == '..':
paths.add('../')
2015-03-02 23:01:43 -06:00
2015-05-28 02:48:11 +02:00
def _add_cdpaths(self, paths, prefix):
"""Completes current prefix using CDPATH"""
env = builtins.__xonsh_env__
2015-08-02 16:14:56 -05:00
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
2015-05-28 02:48:11 +02:00
for cdp in env.get("CDPATH", []):
2015-08-03 20:46:41 -04:00
test_glob = os.path.join(cdp, prefix) + '*'
for s in iglobpath(test_glob, ignore_case=(not csc)):
if os.path.isdir(s):
paths.add(os.path.basename(s))
2015-05-28 02:48:11 +02:00
2015-04-06 08:14:53 -04:00
def cmd_complete(self, cmd):
2015-03-03 00:52:33 -06:00
"""Completes a command name based on what is on the $PATH"""
space = ' '
2015-07-30 19:40:19 -05:00
cmdlow = cmd.lower()
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
2015-08-03 20:46:41 -04:00
return {s + space
for s in self._all_commands()
if startswither(s, cmd, cmdlow)}
2015-03-02 23:01:43 -06:00
2015-05-05 16:03:01 +02:00
def module_complete(self, prefix):
2015-05-19 18:07:04 +02:00
"""Completes a name of a module to import."""
2015-07-30 19:40:19 -05:00
prefixlow = prefix.lower()
2015-05-05 16:03:01 +02:00
modules = set(sys.modules.keys())
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
return {s for s in modules if startswither(s, prefix, prefixlow)}
2015-05-05 16:03:01 +02:00
def path_complete(self, prefix, cdpath=False):
2015-03-15 17:30:45 -05:00
"""Completes based on a path name."""
space = ' ' # intern some strings for faster appending
slash = '/'
2015-03-15 17:48:15 -05:00
tilde = '~'
paths = set()
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
if prefix.startswith("'") or prefix.startswith('"'):
prefix = prefix[1:]
2015-08-02 16:14:56 -05:00
for s in iglobpath(prefix + '*', ignore_case=(not csc)):
if space in s:
s = repr(s + (slash if os.path.isdir(s) else ''))
else:
s = s + (slash if os.path.isdir(s) else space)
paths.add(s)
2015-03-15 17:48:15 -05:00
if tilde in prefix:
home = os.path.expanduser(tilde)
paths = {s.replace(home, tilde) for s in paths}
2015-04-03 18:25:44 -04:00
self._add_env(paths, prefix)
self._add_dots(paths, prefix)
if cdpath:
self._add_cdpaths(paths, prefix)
return {_normpath(s) for s in paths}
2015-03-15 17:30:45 -05:00
2015-03-03 00:52:33 -06:00
def bash_complete(self, prefix, line, begidx, endidx):
"""Attempts BASH completion."""
splt = line.split()
cmd = splt[0]
func = self.bash_complete_funcs.get(cmd, None)
fnme = self.bash_complete_files.get(cmd, None)
if func is None or fnme is None:
return set()
2015-04-02 20:00:58 -05:00
idx = n = 0
2015-03-03 00:52:33 -06:00
for n, tok in enumerate(splt):
if tok == prefix:
idx = line.find(prefix, idx)
if idx >= begidx:
break
prev = tok
if len(prefix) == 0:
prefix = '""'
n += 1
2015-04-02 19:07:31 -04:00
script = BASH_COMPLETE_SCRIPT.format(filename=fnme,
line=line,
n=n,
func=func,
cmd=cmd,
end=endidx + 1,
prefix=prefix,
prev=prev)
out = subprocess.check_output(['bash'],
input=script,
universal_newlines=True,
2015-03-13 19:32:31 -05:00
stderr=subprocess.PIPE)
2015-03-03 00:52:33 -06:00
space = ' '
2015-04-02 19:07:31 -04:00
rtn = {s + space if s[-1:].isalnum() else s for s in out.splitlines()}
2015-03-03 00:52:33 -06:00
return rtn
def _source_completions(self):
srcs = []
for f in builtins.__xonsh_env__.get('BASH_COMPLETIONS', ()):
if os.path.isfile(f):
srcs.append('source ' + f)
return srcs
2015-03-02 23:01:43 -06:00
def _load_bash_complete_funcs(self):
self.bash_complete_funcs = bcf = {}
2015-04-02 20:00:58 -05:00
inp = self._source_completions()
if len(inp) == 0:
return
2015-04-02 20:00:58 -05:00
inp.append('complete -p\n')
2015-04-02 21:03:31 -05:00
out = subprocess.check_output(['bash'], input='\n'.join(inp),
universal_newlines=True)
2015-03-02 23:01:43 -06:00
for line in out.splitlines():
head, cmd = line.rsplit(' ', 1)
2015-03-07 16:36:31 -06:00
if len(cmd) == 0 or cmd == 'cd':
2015-03-02 23:01:43 -06:00
continue
m = RE_DASHF.search(head)
if m is None:
continue
bcf[cmd] = m.group(1)
def _load_bash_complete_files(self):
2015-04-02 20:00:58 -05:00
inp = self._source_completions()
if len(inp) == 0:
self.bash_complete_files = {}
return
if self.bash_complete_funcs:
inp.append('shopt -s extdebug')
2015-08-03 20:46:41 -04:00
bash_funcs = set(self.bash_complete_funcs.values())
inp.append('declare -F ' + ' '.join([f for f in bash_funcs]))
inp.append('shopt -u extdebug\n')
2015-04-02 20:00:58 -05:00
out = subprocess.check_output(['bash'], input='\n'.join(inp),
2015-03-02 23:01:43 -06:00
universal_newlines=True)
func_files = {}
for line in out.splitlines():
parts = line.split()
func_files[parts[0]] = parts[-1]
2015-04-02 19:07:31 -04:00
self.bash_complete_files = {
cmd: func_files[func]
for cmd, func in self.bash_complete_funcs.items()
if func in func_files
}
2015-04-05 16:31:50 -05:00
def attr_complete(self, prefix, ctx):
"""Complete attributes of an object."""
attrs = set()
m = RE_ATTR.match(prefix)
if m is None:
return attrs
expr, attr = m.group(1, 3)
expr = subexpr_from_unbalanced(expr, '(', ')')
expr = subexpr_from_unbalanced(expr, '[', ']')
expr = subexpr_from_unbalanced(expr, '{', '}')
try:
val = builtins.evalx(expr, glbs=ctx)
except: # pylint:disable=bare-except
try:
val = builtins.evalx(expr, glbs=builtins.__dict__)
except: # pylint:disable=bare-except
return attrs # anything could have gone wrong!
opts = dir(val)
if len(attr) == 0:
opts = [o for o in opts if not o.startswith('_')]
else:
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
2015-07-30 19:40:19 -05:00
attrlow = attr.lower()
2015-08-02 16:14:56 -05:00
opts = [o for o in opts if startswither(oattr, attrlow)]
2015-04-05 16:31:50 -05:00
prelen = len(prefix)
for opt in opts:
a = getattr(val, opt)
rpl = opt + '(' if callable(a) else opt
# note that prefix[:prelen-len(attr)] != prefix[:-len(attr)]
# when len(attr) == 0.
comp = prefix[:prelen - len(attr)] + rpl
2015-04-05 16:31:50 -05:00
attrs.add(comp)
return attrs
2015-04-06 08:14:53 -04:00
def _all_commands(self):
path = builtins.__xonsh_env__.get('PATH', [])
# did PATH change?
2015-04-06 08:14:53 -04:00
path_hash = hash(tuple(path))
cache_valid = path_hash == self._path_checksum
self._path_checksum = path_hash
# did aliases change?
al_hash = hash(tuple(sorted(builtins.aliases.keys())))
2015-04-06 08:14:53 -04:00
self._alias_checksum = al_hash
cache_valid = cache_valid and al_hash == self._alias_checksum
pm = self._path_mtime
# did the contents of any directory in PATH change?
for d in filter(os.path.isdir, path):
m = os.stat(d).st_mtime
if m > pm:
pm = m
cache_valid = False
self._path_mtime = pm
2015-04-06 08:14:53 -04:00
if cache_valid:
return self._cmds_cache
allcmds = set()
for d in filter(os.path.isdir, path):
allcmds |= set(os.listdir(d))
2015-04-06 08:14:53 -04:00
allcmds |= set(builtins.aliases.keys())
self._cmds_cache = frozenset(allcmds)
return self._cmds_cache
2015-05-25 12:51:07 +02:00
OPTIONS_PATH = os.path.expanduser('~') + "/.xonsh_man_completions"
SCRAPE_RE = re.compile(r'^(?:\s*(?:-\w|--[a-z0-9-]+)[\s,])+', re.M)
INNER_OPTIONS_RE = re.compile(r'-\w|--[a-z0-9-]+')
class ManCompleter(object):
"""Helper class that loads completions derived from man pages."""
def __init__(self):
self._load_cached_options()
def __del__(self):
self._save_cached_options()
def option_complete(self, prefix, cmd):
"""Completes an option name, basing on content of man page."""
2015-08-02 16:14:56 -05:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS', True)
startswither = startswithnorm if csc else startswithlow
2015-05-25 12:51:07 +02:00
if cmd not in self._options.keys():
2015-06-17 23:23:15 +02:00
try:
2015-08-03 20:46:41 -04:00
manpage = subprocess.Popen(["man", cmd],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
2015-06-17 23:23:15 +02:00
# This is a trick to get rid of reverse line feeds
2015-08-03 20:46:41 -04:00
text = subprocess.check_output(["col", "-b"],
stdin=manpage.stdout)
text = text.decode('utf-8')
2015-06-17 23:23:15 +02:00
scraped_text = ' '.join(SCRAPE_RE.findall(text))
matches = INNER_OPTIONS_RE.findall(scraped_text)
self._options[cmd] = matches
except:
return set()
2015-07-30 19:40:19 -05:00
prefixlow = prefix.lower()
2015-08-03 20:46:41 -04:00
return {s for s in self._options[cmd]
if startswither(s, prefix, prefixlow)}
2015-05-25 12:51:07 +02:00
def _load_cached_options(self):
"""Load options from file at startup."""
try:
with open(OPTIONS_PATH, 'rb') as f:
self._options = pickle.load(f)
except:
self._options = {}
def _save_cached_options(self):
"""Save completions to file."""
with open(OPTIONS_PATH, 'wb') as f:
pickle.dump(self._options, f)