xonsh/xonsh/completer.py

554 lines
20 KiB
Python
Raw Normal View History

# -*- coding: utf-8 -*-
2015-03-01 22:59:44 -06:00
"""A (tab-)completer for xonsh."""
import os
2015-03-02 23:01:43 -06:00
import re
import ast
import sys
import shlex
import pickle
import builtins
2015-03-01 22:59:44 -06:00
import subprocess
2015-03-15 17:30:45 -05:00
from xonsh.built_ins import iglobpath
2015-12-17 09:51:17 -05:00
from xonsh.tools import subexpr_from_unbalanced, get_sep, check_for_partial_string, RE_STRING_START
from xonsh.tools import ON_WINDOWS
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*)$')
RE_WIN_DRIVE = re.compile(r'^([a-zA-Z]):\\')
2015-03-02 23:01:43 -06:00
def _path_from_partial_string(inp, pos=None):
if pos is None:
pos = len(inp)
partial = inp[:pos]
2015-12-17 09:51:17 -05:00
startix, endix, quote = check_for_partial_string(partial)
if startix is None:
return None
2015-12-17 09:51:17 -05:00
elif endix is None:
string = partial[startix:]
else:
2015-12-17 09:51:17 -05:00
if endix != pos:
return None
2015-12-17 09:51:17 -05:00
string = partial[startix:endix]
end = re.sub(RE_STRING_START,'',quote)
_string = string
if not _string.endswith(end):
_string = _string + end
try:
2015-12-17 09:51:17 -05:00
val = ast.literal_eval(_string)
except SyntaxError:
return None
if isinstance(val, bytes):
val = val.decode()
2015-12-17 09:51:17 -05:00
return string, val, quote, end
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
COMPLETION_SKIP_TOKENS = {'sudo', 'time', 'man'}
COMPLETION_WRAP_TOKENS = {' ',',','[',']','(',')','{','}'}
2015-03-03 00:52:33 -06:00
BASH_COMPLETE_SCRIPT = """source {filename}
COMP_WORDS=({line})
COMP_LINE={comp_line}
2015-03-03 00:52:33 -06:00
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-09-30 01:30:03 -04:00
WS = set(' \t\r\n')
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):
""" Wraps os.normpath() to avoid removing './' at the beginning
2015-08-19 10:47:10 +02:00
and '/' at the end. On windows it does the same with backslases
"""
initial_dotslash = p.startswith(os.curdir + os.sep)
initial_dotslash |= (ON_WINDOWS and p.startswith(os.curdir + os.altsep))
p = p.rstrip()
trailing_slash = p.endswith(os.sep)
trailing_slash |= (ON_WINDOWS and p.endswith(os.altsep))
p = os.path.normpath(p)
if initial_dotslash and p != '.':
p = os.path.join(os.curdir, p)
if trailing_slash:
p = os.path.join(p, '')
2015-10-10 16:45:02 -04:00
if ON_WINDOWS and builtins.__xonsh_env__.get('FORCE_POSIX_PATHS'):
p = p.replace(os.sep, os.altsep)
return p
def completionwrap(s):
""" Returns the repr of input string s if that string contains
a 'problem' token that will confuse the xonsh parser
"""
space = ' '
slash = get_sep()
return (_normpath(repr(s + (slash if os.path.isdir(s) else '')))
if COMPLETION_WRAP_TOKENS.intersection(s) else
s + space
if s[-1:].isalnum() else
s)
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):
2015-09-30 01:30:03 -04:00
"""Complete the string, given a possible execution context.
2015-03-01 22:59:44 -06:00
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()
_line = line
line = builtins.aliases.expand_alias(line)
# string stuff for automatic quoting
path_str_start = ''
path_str_end = ''
p = _path_from_partial_string(_line, endidx)
lprefix = len(prefix)
if p is not None:
lprefix = len(p[0])
prefix = p[1]
path_str_start = p[2]
path_str_end = p[3]
2015-04-03 18:25:44 -04:00
cmd = line.split(' ', 1)[0]
while cmd in COMPLETION_SKIP_TOKENS:
begidx -= len(cmd)+1
endidx -= len(cmd)+1
cmd = line.split(' ', 2)[1]
line = line.split(' ', 1)[1]
2015-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
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, path_str_start, path_str_end)
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('-'):
return lprefix, 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 lprefix, 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 lprefix, sorted(self.path_complete(prefix, path_str_start, path_str_end, 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)}
rtn |= self.path_complete(prefix, path_str_start, path_str_end)
return lprefix, sorted(rtn)
2015-04-03 18:25:44 -04:00
2015-09-30 01:30:03 -04:00
def find_and_complete(self, line, idx, ctx=None):
"""Finds the completions given only the full code line and a current cursor
position. This represents an easier alternative to the complete() method.
Parameters
----------
line : str
The line that prefix appears on.
idx : int
The current position in the line.
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.
begidx : int
The index in line that prefix starts on.
endidx : int
The index in line that prefix ends on.
"""
if idx < 0:
raise ValueError('index must be non-negative!')
n = len(line)
begidx = endidx = (idx - 1 if idx == n else idx)
while 0 < begidx and line[begidx] not in WS:
begidx -= 1
begidx = begidx + 1 if line[begidx] in WS else begidx
while endidx < n - 1 and line[endidx] not in WS:
endidx += 1
endidx = endidx - 1 if line[endidx] in WS else endidx
prefix = line[begidx:endidx+1]
return self.complete(prefix, line, begidx, endidx, ctx=ctx), begidx, endidx
2015-04-03 18:25:44 -04:00
def _add_env(self, paths, prefix):
2015-03-01 22:59:44 -06:00
if prefix.startswith('$'):
2015-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
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()
paths.update({'$' + k for k in builtins.__xonsh_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"""
2015-10-10 16:45:02 -04:00
env = builtins.__xonsh_env__
csc = env.get('CASE_SENSITIVE_COMPLETIONS')
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-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
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-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
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, start, end, cdpath=False):
2015-03-15 17:30:45 -05:00
"""Completes based on a path name."""
space = ' ' # intern some strings for faster appending
slash = get_sep()
2015-03-15 17:48:15 -05:00
tilde = '~'
paths = set()
2015-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
# look for being inside a string
2015-08-02 16:14:56 -05:00
for s in iglobpath(prefix + '*', ignore_case=(not csc)):
2015-12-17 10:36:03 -05:00
if (space in s or "\\" in s) and start == '':
start = "'"
end = "'"
if os.path.isdir(s):
_tail = slash
elif end == '':
_tail = space
else:
2015-12-17 10:36:03 -05:00
_tail = ''
s = start + s + _tail + end
if "r" not in start.lower():
s = s.replace('\\','\\\\')
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
else:
prefix = shlex.quote(prefix)
2015-04-02 19:07:31 -04:00
script = BASH_COMPLETE_SCRIPT.format(filename=fnme,
line=' '.join(shlex.quote(p) for p in splt),
comp_line=shlex.quote(line),
2015-04-02 19:07:31 -04:00
n=n,
func=func,
cmd=cmd,
end=endidx + 1,
prefix=prefix,
prev=shlex.quote(prev))
try:
out = subprocess.check_output(['bash'],
input=script,
universal_newlines=True,
stderr=subprocess.PIPE)
except subprocess.CalledProcessError:
out = ''
rtn = set(map(completionwrap, out.splitlines()))
2015-03-03 00:52:33 -06:00
return rtn
def _source_completions(self):
srcs = []
2015-10-10 16:45:02 -04:00
for f in builtins.__xonsh_env__.get('BASH_COMPLETIONS'):
if os.path.isfile(f):
2015-10-10 16:45:02 -04:00
# We need to "Unixify" Windows paths for Bash to understand
if ON_WINDOWS:
f = RE_WIN_DRIVE.sub(lambda m: '/{0}/'.format(m.group(1).lower()), f).replace('\\', '/')
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, '{', '}')
_ctx = None
2015-04-05 16:31:50 -05:00
try:
val = eval(expr, ctx)
_ctx = ctx
2015-04-05 16:31:50 -05:00
except: # pylint:disable=bare-except
try:
val = eval(expr, builtins.__dict__)
_ctx = builtins.__dict__
2015-04-05 16:31:50 -05:00
except: # pylint:disable=bare-except
return attrs # anything could have gone wrong!
_opts = dir(val)
# check whether these options actually work (e.g., disallow 7.imag)
opts = []
for i in _opts:
try:
2015-11-29 13:13:08 -05:00
v = eval('{0}.{1}'.format(expr, i), _ctx)
opts.append(i)
2015-11-29 13:13:08 -05:00
except: # pylint:disable=bare-except
continue
2015-11-29 10:16:28 -05:00
if len(attr) == 0:
2015-04-05 16:31:50 -05:00
opts = [o for o in opts if not o.startswith('_')]
else:
2015-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
startswither = startswithnorm if csc else startswithlow
2015-07-30 19:40:19 -05:00
attrlow = attr.lower()
2015-10-16 10:50:18 -04:00
opts = [o for o in opts if startswither(o, attr, 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
cache_valid = cache_valid and al_hash == self._alias_checksum
self._alias_checksum = al_hash
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):
2015-08-23 16:50:54 -04:00
try:
self._save_cached_options()
except Exception:
pass
2015-05-25 12:51:07 +02:00
def option_complete(self, prefix, cmd):
"""Completes an option name, basing on content of man page."""
2015-10-10 16:45:02 -04:00
csc = builtins.__xonsh_env__.get('CASE_SENSITIVE_COMPLETIONS')
2015-08-02 16:14:56 -05:00
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)