diff options
author | Patrick Totzke <patricktotzke@gmail.com> | 2019-08-17 10:00:30 +0100 |
---|---|---|
committer | Patrick Totzke <patricktotzke@gmail.com> | 2019-08-17 11:10:37 +0100 |
commit | d8d1429ec3daf8f2a67ffccbc2aa1d54eb3639c6 (patch) | |
tree | 3f13fd449bacd97432e18ba4fb0fb4b19ac0b5ed /alot | |
parent | b5e612c69b625271424b626da24d941ddbe39391 (diff) |
refactor prompt completion
This just splits the file completion.py into several files, one for each
Completer subclass.
Diffstat (limited to 'alot')
-rw-r--r-- | alot/completion.py | 622 | ||||
-rw-r--r-- | alot/completion/__init__.py | 17 | ||||
-rw-r--r-- | alot/completion/abooks.py | 42 | ||||
-rw-r--r-- | alot/completion/accounts.py | 19 | ||||
-rw-r--r-- | alot/completion/argparse.py | 41 | ||||
-rw-r--r-- | alot/completion/command.py | 221 | ||||
-rw-r--r-- | alot/completion/commandline.py | 54 | ||||
-rw-r--r-- | alot/completion/commandname.py | 26 | ||||
-rw-r--r-- | alot/completion/completer.py | 37 | ||||
-rw-r--r-- | alot/completion/contacts.py | 21 | ||||
-rw-r--r-- | alot/completion/cryptokey.py | 24 | ||||
-rw-r--r-- | alot/completion/multipleselection.py | 48 | ||||
-rw-r--r-- | alot/completion/namedquery.py | 18 | ||||
-rw-r--r-- | alot/completion/path.py | 43 | ||||
-rw-r--r-- | alot/completion/query.py | 57 | ||||
-rw-r--r-- | alot/completion/stringlist.py | 32 | ||||
-rw-r--r-- | alot/completion/tag.py | 17 | ||||
-rw-r--r-- | alot/completion/tags.py | 18 |
18 files changed, 735 insertions, 622 deletions
diff --git a/alot/completion.py b/alot/completion.py deleted file mode 100644 index dcf1383d..00000000 --- a/alot/completion.py +++ /dev/null @@ -1,622 +0,0 @@ -# Copyright (C) 2011-2012 Patrick Totzke <patricktotzke@gmail.com> -# This file is released under the GNU GPL, version 3 or a later revision. -# For further details see the COPYING file -import abc -import argparse -import email.utils -import glob -import logging -import os -import re - -from . import crypto -from . import commands -from .buffers import EnvelopeBuffer -from .settings.const import settings -from .utils import argparse as cargparse -from .db.utils import formataddr -from .helper import split_commandline -from .addressbook import AddressbookError -from .errors import CompletionError -from .utils.cached_property import cached_property - - -class Completer: - """base class for completers""" - - __metaclass__ = abc.ABCMeta - - @abc.abstractmethod - def complete(self, original, pos): - """returns a list of completions and cursor positions for the - string original from position pos on. - - :param original: the string to complete - :type original: str - :param pos: starting position to complete from - :type pos: int - :returns: pairs of completed string and cursor position in the - new string - :rtype: list of (str, int) - :raises: :exc:`CompletionError` - """ - pass - - def relevant_part(self, original, pos, sep=' '): - """ - calculates the subword in a `sep`-splitted list of substrings of - `original` that `pos` is ia.n - """ - start = original.rfind(sep, 0, pos) + 1 - end = original.find(sep, pos - 1) - if end == -1: - end = len(original) - return original[start:end], start, end, pos - start - - -class StringlistCompleter(Completer): - """completer for a fixed list of strings""" - - def __init__(self, resultlist, ignorecase=True, match_anywhere=False): - """ - :param resultlist: strings used for completion - :type resultlist: list of str - :param liberal: match case insensitive and not prefix-only - :type liberal: bool - """ - self.resultlist = resultlist - self.flags = re.IGNORECASE if ignorecase else 0 - self.match_anywhere = match_anywhere - - def complete(self, original, pos): - pref = original[:pos] - - re_prefix = '.*' if self.match_anywhere else '' - - def match(s, m): - r = '{}{}.*'.format(re_prefix, re.escape(m)) - return re.match(r, s, flags=self.flags) is not None - - return [(a, len(a)) for a in self.resultlist if match(a, pref)] - - -class MultipleSelectionCompleter(Completer): - """ - Meta-Completer that turns any Completer into one that deals with a list of - completion strings using the wrapped Completer. - This allows for example to easily construct a completer for comma separated - recipient-lists using a :class:`ContactsCompleter`. - """ - - def __init__(self, completer, separator=', '): - """ - :param completer: completer to use for individual substrings - :type completer: Completer - :param separator: separator used to split the completion string into - substrings to be fed to `completer`. - :type separator: str - """ - self._completer = completer - self._separator = separator - - def relevant_part(self, original, pos): - """ - calculates the subword of `original` that `pos` is in - """ - start = original.rfind(self._separator, 0, pos) - if start == -1: - start = 0 - else: - start = start + len(self._separator) - end = original.find(self._separator, pos - 1) - if end == -1: - end = len(original) - return original[start:end], start, end, pos - start - - def complete(self, original, pos): - mypart, start, end, mypos = self.relevant_part(original, pos) - res = [] - for c, _ in self._completer.complete(mypart, mypos): - newprefix = original[:start] + c - if not original[end:].startswith(self._separator): - newprefix += self._separator - res.append((newprefix + original[end:], len(newprefix))) - return res - - -class NamedQueryCompleter(StringlistCompleter): - """complete the name of a named query string""" - - def __init__(self, dbman): - """ - :param dbman: used to look up named query strings in the DB - :type dbman: :class:`~alot.db.DBManager` - """ - # mapping of alias to query string (dict str -> str) - nqueries = dbman.get_named_queries() - StringlistCompleter.__init__(self, list(nqueries)) - - -class QueryCompleter(Completer): - """completion for a notmuch query string""" - def __init__(self, dbman): - """ - :param dbman: used to look up available tagstrings - :type dbman: :class:`~alot.db.DBManager` - """ - self.dbman = dbman - abooks = settings.get_addressbooks() - self._abookscompleter = AbooksCompleter(abooks, addressesonly=True) - self._tagcompleter = TagCompleter(dbman) - self._nquerycompleter = NamedQueryCompleter(dbman) - self.keywords = ['tag', 'from', 'to', 'subject', 'attachment', - 'is', 'id', 'thread', 'folder', 'query'] - - def complete(self, original, pos): - mypart, start, end, mypos = self.relevant_part(original, pos) - myprefix = mypart[:mypos] - m = re.search(r'(tag|is|to|from|query):(\w*)', myprefix) - if m: - cmd, _ = m.groups() - cmdlen = len(cmd) + 1 # length of the keyword part including colon - if cmd in ['to', 'from']: - localres = self._abookscompleter.complete(mypart[cmdlen:], - mypos - cmdlen) - elif cmd in ['query']: - localres = self._nquerycompleter.complete(mypart[cmdlen:], - mypos - cmdlen) - else: - localres = self._tagcompleter.complete(mypart[cmdlen:], - mypos - cmdlen) - resultlist = [] - for ltxt, lpos in localres: - newtext = original[:start] + cmd + ':' + ltxt + original[end:] - newpos = start + len(cmd) + 1 + lpos - resultlist.append((newtext, newpos)) - return resultlist - else: - matched = (t for t in self.keywords if t.startswith(myprefix)) - resultlist = [] - for keyword in matched: - newprefix = original[:start] + keyword + ':' - resultlist.append((newprefix + original[end:], len(newprefix))) - return resultlist - - -class TagCompleter(StringlistCompleter): - """complete a tagstring""" - - def __init__(self, dbman): - """ - :param dbman: used to look up available tagstrings - :type dbman: :class:`~alot.db.DBManager` - """ - resultlist = dbman.get_all_tags() - StringlistCompleter.__init__(self, resultlist) - - -class TagsCompleter(MultipleSelectionCompleter): - """completion for a comma separated list of tagstrings""" - - def __init__(self, dbman): - """ - :param dbman: used to look up available tagstrings - :type dbman: :class:`~alot.db.DBManager` - """ - self._completer = TagCompleter(dbman) - self._separator = ',' - - -class ContactsCompleter(MultipleSelectionCompleter): - """completes contacts from given address books""" - def __init__(self, abooks, addressesonly=False): - """ - :param abooks: used to look up email addresses - :type abooks: list of :class:`~alot.account.AddresBook` - :param addressesonly: only insert address, not the realname of the - contact - :type addressesonly: bool - """ - self._completer = AbooksCompleter(abooks, addressesonly=addressesonly) - self._separator = ', ' - - -class AbooksCompleter(Completer): - """completes a contact from given address books""" - def __init__(self, abooks, addressesonly=False): - """ - :param abooks: used to look up email addresses - :type abooks: list of :class:`~alot.account.AddresBook` - :param addressesonly: only insert address, not the realname of the - contact - :type addressesonly: bool - """ - self.abooks = abooks - self.addressesonly = addressesonly - - def complete(self, original, pos): - if not self.abooks: - return [] - prefix = original[:pos] - res = [] - for abook in self.abooks: - try: - res = res + abook.lookup(prefix) - except AddressbookError as e: - raise CompletionError(e) - if self.addressesonly: - returnlist = [(addr, len(addr)) for (name, addr) in res] - else: - returnlist = [] - for name, addr in res: - newtext = formataddr((name, addr)) - returnlist.append((newtext, len(newtext))) - return returnlist - - -class ArgparseOptionCompleter(Completer): - """completes option parameters for a given argparse.Parser""" - def __init__(self, parser): - """ - :param parser: the option parser we look up parameter and choices from - :type parser: `argparse.ArgumentParser` - """ - self.parser = parser - self.actions = parser._optionals._actions - - def complete(self, original, pos): - pref = original[:pos] - - res = [] - for act in self.actions: - if '=' in pref: - optionstring = pref[:pref.rfind('=') + 1] - # get choices - if 'choices' in act.__dict__: - # TODO: respect prefix - choices = act.choices or [] - res = res + [optionstring + a for a in choices] - else: - for optionstring in act.option_strings: - if optionstring.startswith(pref): - # append '=' for options that await a string value - if isinstance(act, (argparse._StoreAction, - cargparse.BooleanAction)): - optionstring += '=' - res.append(optionstring) - - return [(a, len(a)) for a in res] - - -class AccountCompleter(StringlistCompleter): - """completes users' own mailaddresses""" - - def __init__(self, **kwargs): - accounts = settings.get_accounts() - resultlist = [email.utils.formataddr((a.realname, str(a.address))) - for a in accounts] - StringlistCompleter.__init__(self, resultlist, match_anywhere=True, - **kwargs) - - -class CommandNameCompleter(Completer): - """completes command names""" - - def __init__(self, mode): - """ - :param mode: mode identifier - :type mode: str - """ - self.mode = mode - - def complete(self, original, pos): - # TODO refine <tab> should get current querystring - commandprefix = original[:pos] - logging.debug('original="%s" prefix="%s"', original, commandprefix) - cmdlist = commands.COMMANDS['global'].copy() - cmdlist.update(commands.COMMANDS[self.mode]) - matching = [t for t in cmdlist if t.startswith(commandprefix)] - return [(t, len(t)) for t in matching] - - -class CommandCompleter(Completer): - """completes one command consisting of command name and parameters""" - - def __init__(self, dbman, mode, currentbuffer=None): - """ - :param dbman: used to look up available tagstrings - :type dbman: :class:`~alot.db.DBManager` - :param mode: mode identifier - :type mode: str - :param currentbuffer: currently active buffer. If defined, this will be - used to dynamically extract possible completion - strings - :type currentbuffer: :class:`~alot.buffers.Buffer` - """ - self.dbman = dbman - self.mode = mode - self.currentbuffer = currentbuffer - self._commandnamecompleter = CommandNameCompleter(mode) - - @cached_property - def _querycompleter(self): - return QueryCompleter(self.dbman) - - @cached_property - def _tagcompleter(self): - return TagCompleter(self.dbman) - - @cached_property - def _contactscompleter(self): - abooks = settings.get_addressbooks() - return ContactsCompleter(abooks) - - @cached_property - def _pathcompleter(self): - return PathCompleter() - - @cached_property - def _accountscompleter(self): - return AccountCompleter() - - @cached_property - def _secretkeyscompleter(self): - return CryptoKeyCompleter(private=True) - - @cached_property - def _publickeyscompleter(self): - return CryptoKeyCompleter(private=False) - - def complete(self, line, pos): - # remember how many preceding space characters we see until the command - # string starts. We'll continue to complete from there on and will add - # these whitespaces again at the very end - whitespaceoffset = len(line) - len(line.lstrip()) - line = line[whitespaceoffset:] - pos = pos - whitespaceoffset - - words = line.split(' ', 1) - - res = [] - if pos <= len(words[0]): # we complete commands - for cmd, cpos in self._commandnamecompleter.complete(line, pos): - newtext = ('%s %s' % (cmd, ' '.join(words[1:]))) - res.append((newtext, cpos + 1)) - else: - cmd, params = words - localpos = pos - (len(cmd) + 1) - parser = commands.lookup_parser(cmd, self.mode) - if parser is not None: - # set 'res' - the result set of matching completionstrings - # depending on the current mode and command - - # detect if we are completing optional parameter - arguments_until_now = params[:localpos].split(' ') - all_optionals = True - logging.debug(str(arguments_until_now)) - for a in arguments_until_now: - logging.debug(a) - if a and not a.startswith('-'): - all_optionals = False - # complete optional parameter if - # 1. all arguments prior to current position are optional - # 2. the parameter starts with '-' or we are at its beginning - if all_optionals: - myarg = arguments_until_now[-1] - start_myarg = params.rindex(myarg) - beforeme = params[:start_myarg] - # set up local stringlist completer - # and let it complete for given list of options - localcompleter = ArgparseOptionCompleter(parser) - localres = localcompleter.complete(myarg, len(myarg)) - res = [( - beforeme + c, p + start_myarg) for (c, p) in localres] - - # global - elif cmd == 'search': - res = self._querycompleter.complete(params, localpos) - elif cmd == 'help': - res = self._commandnamecompleter.complete(params, localpos) - elif cmd in ['compose']: - res = self._contactscompleter.complete(params, localpos) - # search - elif self.mode == 'search' and cmd == 'refine': - res = self._querycompleter.complete(params, localpos) - elif self.mode == 'search' and cmd in ['tag', 'retag', 'untag', - 'toggletags']: - localcomp = MultipleSelectionCompleter(self._tagcompleter, - separator=',') - res = localcomp.complete(params, localpos) - elif self.mode == 'search' and cmd == 'toggletag': - localcomp = MultipleSelectionCompleter(self._tagcompleter, - separator=' ') - res = localcomp.complete(params, localpos) - # envelope - elif self.mode == 'envelope' and cmd == 'set': - plist = params.split(' ', 1) - if len(plist) == 1: # complete from header keys - localprefix = params - headers = ['Subject', 'To', 'Cc', 'Bcc', 'In-Reply-To', - 'From'] - localcompleter = StringlistCompleter(headers) - localres = localcompleter.complete( - localprefix, localpos) - res = [(c, p + 6) for (c, p) in localres] - else: # must have 2 elements - header, params = plist - localpos = localpos - (len(header) + 1) - if header.lower() in ['to', 'cc', 'bcc']: - res = self._contactscompleter.complete(params, - localpos) - elif header.lower() == 'from': - res = self._accountscompleter.complete(params, - localpos) - - # prepend 'set ' + header and correct position - def f(completed, pos): - return ('%s %s' % (header, completed), - pos + len(header) + 1) - res = [f(c, p) for c, p in res] - logging.debug(res) - - elif self.mode == 'envelope' and cmd == 'unset': - plist = params.split(' ', 1) - if len(plist) == 1: # complete from header keys - localprefix = params - buf = self.currentbuffer - if buf: - if isinstance(buf, EnvelopeBuffer): - available = buf.envelope.headers.keys() - localcompleter = StringlistCompleter(available) - localres = localcompleter.complete(localprefix, - localpos) - res = [(c, p + 6) for (c, p) in localres] - - elif self.mode == 'envelope' and cmd == 'attach': - res = self._pathcompleter.complete(params, localpos) - elif self.mode == 'envelope' and cmd in ['sign', 'togglesign']: - res = self._secretkeyscompleter.complete(params, localpos) - elif self.mode == 'envelope' and cmd in ['encrypt', - 'rmencrypt', - 'toggleencrypt']: - res = self._publickeyscompleter.complete(params, localpos) - elif self.mode == 'envelope' and cmd in ['tag', 'toggletags', - 'untag', 'retag']: - localcomp = MultipleSelectionCompleter(self._tagcompleter, - separator=',') - res = localcomp.complete(params, localpos) - # thread - elif self.mode == 'thread' and cmd == 'save': - res = self._pathcompleter.complete(params, localpos) - elif self.mode == 'thread' and cmd in ['fold', 'unfold', - 'togglesource', - 'toggleheaders']: - res = self._querycompleter.complete(params, localpos) - elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag', - 'toggletags']: - localcomp = MultipleSelectionCompleter(self._tagcompleter, - separator=',') - res = localcomp.complete(params, localpos) - elif cmd == 'move': - directions = ['up', 'down', 'page up', 'page down', - 'halfpage up', 'halfpage down', 'first', - 'last'] - if self.mode == 'thread': - directions += ['parent', 'first reply', 'last reply', - 'next sibling', 'previous sibling', - 'next', 'previous', 'next unfolded', - 'previous unfolded'] - localcompleter = StringlistCompleter(directions) - res = localcompleter.complete(params, localpos) - - # prepend cmd and correct position - res = [('%s %s' % (cmd, t), p + len(cmd) + - 1) for (t, p) in res] - - # re-insert whitespaces and correct position - wso = whitespaceoffset - res = [(' ' * wso + cmdstr, p + wso) for cmdstr, p in res] - return res - - -class CommandLineCompleter(Completer): - """completes command lines: semicolon separated command strings""" - - def __init__(self, dbman, mode, currentbuffer=None): - """ - :param dbman: used to look up available tagstrings - :type dbman: :class:`~alot.db.DBManager` - :param mode: mode identifier - :type mode: str - :param currentbuffer: currently active buffer. If defined, this will be - used to dynamically extract possible completion - strings - :type currentbuffer: :class:`~alot.buffers.Buffer` - """ - self._commandcompleter = CommandCompleter(dbman, mode, currentbuffer) - - @staticmethod - def get_context(line, pos): - """ - computes start and end position of substring of line that is the - command string under given position - """ - commands = split_commandline(line) + [''] - i = 0 - start = 0 - end = len(commands[i]) - while pos > end: - i += 1 - start = end + 1 - end += 1 + len(commands[i]) - return start, end - - def complete(self, line, pos): - cstart, cend = self.get_context(line, pos) - before = line[:cstart] - after = line[cend:] - cmdstring = line[cstart:cend] - cpos = pos - cstart - - res = [] - for ccmd, ccpos in self._commandcompleter.complete(cmdstring, cpos): - newtext = before + ccmd + after - newpos = pos + (ccpos - cpos) - res.append((newtext, newpos)) - return res - - -class PathCompleter(Completer): - - """completion for paths""" - - def complete(self, original, pos): - if not original: - return [('~/', 2)] - prefix = os.path.expanduser(original[:pos]) - - def escape(path): - """Escape all backslashes and spaces in given path with a - backslash. - - :param path: the path to escape - :type path: str - :returns: the escaped path - :rtype: str - """ - return path.replace('\\', '\\\\').replace(' ', r'\ ') - - def deescape(escaped_path): - """Remove escaping backslashes in front of spaces and backslashes. - - :param escaped_path: a path potentially with escaped spaces and - backslashs - :type escaped_path: str - :returns: the actual path - :rtype: str - """ - return escaped_path.replace('\\ ', ' ').replace('\\\\', '\\') - - def prep(path): - escaped_path = escape(path) - return escaped_path, len(escaped_path) - - return [prep(g) for g in glob.glob(deescape(prefix) + '*')] - - -class CryptoKeyCompleter(StringlistCompleter): - """completion for gpg keys""" - - def __init__(self, private=False): - """ - :param private: return private keys - :type private: bool - """ - keys = crypto.list_keys(private=private) - resultlist = [] - for k in keys: - for s in k.subkeys: - resultlist.append(s.keyid) - for u in k.uids: - resultlist.append(u.email) - StringlistCompleter.__init__(self, resultlist, match_anywhere=True) diff --git a/alot/completion/__init__.py b/alot/completion/__init__.py new file mode 100644 index 00000000..86e5d93c --- /dev/null +++ b/alot/completion/__init__.py @@ -0,0 +1,17 @@ +from .completer import Completer + +from .abooks import AbooksCompleter +from .accounts import AccountCompleter +from .argparse import ArgparseOptionCompleter +from .command import CommandCompleter +from .commandline import CommandLineCompleter +from .commandname import CommandNameCompleter +from .contacts import ContactsCompleter +from .cryptokey import CryptoKeyCompleter +from .multipleselection import MultipleSelectionCompleter +from .namedquery import NamedQueryCompleter +from .path import PathCompleter +from .query import QueryCompleter +from .stringlist import StringlistCompleter +from .tag import TagCompleter +from .tags import TagsCompleter diff --git a/alot/completion/abooks.py b/alot/completion/abooks.py new file mode 100644 index 00000000..bc2fa20b --- /dev/null +++ b/alot/completion/abooks.py @@ -0,0 +1,42 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .completer import Completer +from ..addressbook import AddressbookError +from ..db.utils import formataddr +from ..errors import CompletionError + + +class AbooksCompleter(Completer): + """Complete a contact from given address books.""" + + def __init__(self, abooks, addressesonly=False): + """ + :param abooks: used to look up email addresses + :type abooks: list of :class:`~alot.account.AddresBook` + :param addressesonly: only insert address, not the realname of the + contact + :type addressesonly: bool + """ + self.abooks = abooks + self.addressesonly = addressesonly + + def complete(self, original, pos): + if not self.abooks: + return [] + prefix = original[:pos] + res = [] + for abook in self.abooks: + try: + res = res + abook.lookup(prefix) + except AddressbookError as e: + raise CompletionError(e) + if self.addressesonly: + returnlist = [(addr, len(addr)) for (name, addr) in res] + else: + returnlist = [] + for name, addr in res: + newtext = formataddr((name, addr)) + returnlist.append((newtext, len(newtext))) + return returnlist diff --git a/alot/completion/accounts.py b/alot/completion/accounts.py new file mode 100644 index 00000000..c83ce09c --- /dev/null +++ b/alot/completion/accounts.py @@ -0,0 +1,19 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import email.utils + +from .stringlist import StringlistCompleter +from ..settings.const import settings + + +class AccountCompleter(StringlistCompleter): + """Completes users' own mailaddresses.""" + + def __init__(self, **kwargs): + accounts = settings.get_accounts() + resultlist = [email.utils.formataddr((a.realname, str(a.address))) + for a in accounts] + StringlistCompleter.__init__(self, resultlist, match_anywhere=True, + **kwargs) diff --git a/alot/completion/argparse.py b/alot/completion/argparse.py new file mode 100644 index 00000000..ac15b2c6 --- /dev/null +++ b/alot/completion/argparse.py @@ -0,0 +1,41 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import argparse +from .completer import Completer +from ..utils import argparse as cargparse + + +class ArgparseOptionCompleter(Completer): + """completes option parameters for a given argparse.Parser""" + def __init__(self, parser): + """ + :param parser: the option parser we look up parameter and choices from + :type parser: `argparse.ArgumentParser` + """ + self.parser = parser + self.actions = parser._optionals._actions + + def complete(self, original, pos): + pref = original[:pos] + + res = [] + for act in self.actions: + if '=' in pref: + optionstring = pref[:pref.rfind('=') + 1] + # get choices + if 'choices' in act.__dict__: + # TODO: respect prefix + choices = act.choices or [] + res = res + [optionstring + a for a in choices] + else: + for optionstring in act.option_strings: + if optionstring.startswith(pref): + # append '=' for options that await a string value + if isinstance(act, (argparse._StoreAction, + cargparse.BooleanAction)): + optionstring += '=' + res.append(optionstring) + + return [(a, len(a)) for a in res] diff --git a/alot/completion/command.py b/alot/completion/command.py new file mode 100644 index 00000000..305e445f --- /dev/null +++ b/alot/completion/command.py @@ -0,0 +1,221 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import logging + +from alot import commands +from alot.buffers import EnvelopeBuffer +from alot.settings.const import settings +from alot.utils.cached_property import cached_property +from .completer import Completer +from .commandname import CommandNameCompleter +from .tag import TagCompleter +from .query import QueryCompleter +from .contacts import ContactsCompleter +from .accounts import AccountCompleter +from .path import PathCompleter +from .stringlist import StringlistCompleter +from .multipleselection import MultipleSelectionCompleter +from .cryptokey import CryptoKeyCompleter +from .argparse import ArgparseOptionCompleter + + +class CommandCompleter(Completer): + """completes one command consisting of command name and parameters""" + + def __init__(self, dbman, mode, currentbuffer=None): + """ + :param dbman: used to look up available tagstrings + :type dbman: :class:`~alot.db.DBManager` + :param mode: mode identifier + :type mode: str + :param currentbuffer: currently active buffer. If defined, this will be + used to dynamically extract possible completion + strings + :type currentbuffer: :class:`~alot.buffers.Buffer` + """ + self.dbman = dbman + self.mode = mode + self.currentbuffer = currentbuffer + self._commandnamecompleter = CommandNameCompleter(mode) + + @cached_property + def _querycompleter(self): + return QueryCompleter(self.dbman) + + @cached_property + def _tagcompleter(self): + return TagCompleter(self.dbman) + + @cached_property + def _contactscompleter(self): + abooks = settings.get_addressbooks() + return ContactsCompleter(abooks) + + @cached_property + def _pathcompleter(self): + return PathCompleter() + + @cached_property + def _accountscompleter(self): + return AccountCompleter() + + @cached_property + def _secretkeyscompleter(self): + return CryptoKeyCompleter(private=True) + + @cached_property + def _publickeyscompleter(self): + return CryptoKeyCompleter(private=False) + + def complete(self, line, pos): + # remember how many preceding space characters we see until the command + # string starts. We'll continue to complete from there on and will add + # these whitespaces again at the very end + whitespaceoffset = len(line) - len(line.lstrip()) + line = line[whitespaceoffset:] + pos = pos - whitespaceoffset + + words = line.split(' ', 1) + + res = [] + if pos <= len(words[0]): # we complete commands + for cmd, cpos in self._commandnamecompleter.complete(line, pos): + newtext = ('%s %s' % (cmd, ' '.join(words[1:]))) + res.append((newtext, cpos + 1)) + else: + cmd, params = words + localpos = pos - (len(cmd) + 1) + parser = commands.lookup_parser(cmd, self.mode) + if parser is not None: + # set 'res' - the result set of matching completionstrings + # depending on the current mode and command + + # detect if we are completing optional parameter + arguments_until_now = params[:localpos].split(' ') + all_optionals = True + logging.debug(str(arguments_until_now)) + for a in arguments_until_now: + logging.debug(a) + if a and not a.startswith('-'): + all_optionals = False + # complete optional parameter if + # 1. all arguments prior to current position are optional + # 2. the parameter starts with '-' or we are at its beginning + if all_optionals: + myarg = arguments_until_now[-1] + start_myarg = params.rindex(myarg) + beforeme = params[:start_myarg] + # set up local stringlist completer + # and let it complete for given list of options + localcompleter = ArgparseOptionCompleter(parser) + localres = localcompleter.complete(myarg, len(myarg)) + res = [( + beforeme + c, p + start_myarg) for (c, p) in localres] + + # global + elif cmd == 'search': + res = self._querycompleter.complete(params, localpos) + elif cmd == 'help': + res = self._commandnamecompleter.complete(params, localpos) + elif cmd in ['compose']: + res = self._contactscompleter.complete(params, localpos) + # search + elif self.mode == 'search' and cmd == 'refine': + res = self._querycompleter.complete(params, localpos) + elif self.mode == 'search' and cmd in ['tag', 'retag', 'untag', + 'toggletags']: + localcomp = MultipleSelectionCompleter(self._tagcompleter, + separator=',') + res = localcomp.complete(params, localpos) + elif self.mode == 'search' and cmd == 'toggletag': + localcomp = MultipleSelectionCompleter(self._tagcompleter, + separator=' ') + res = localcomp.complete(params, localpos) + # envelope + elif self.mode == 'envelope' and cmd == 'set': + plist = params.split(' ', 1) + if len(plist) == 1: # complete from header keys + localprefix = params + headers = ['Subject', 'To', 'Cc', 'Bcc', 'In-Reply-To', + 'From'] + localcompleter = StringlistCompleter(headers) + localres = localcompleter.complete( + localprefix, localpos) + res = [(c, p + 6) for (c, p) in localres] + else: # must have 2 elements + header, params = plist + localpos = localpos - (len(header) + 1) + if header.lower() in ['to', 'cc', 'bcc']: + res = self._contactscompleter.complete(params, + localpos) + elif header.lower() == 'from': + res = self._accountscompleter.complete(params, + localpos) + + # prepend 'set ' + header and correct position + def f(completed, pos): + return ('%s %s' % (header, completed), + pos + len(header) + 1) + res = [f(c, p) for c, p in res] + logging.debug(res) + + elif self.mode == 'envelope' and cmd == 'unset': + plist = params.split(' ', 1) + if len(plist) == 1: # complete from header keys + localprefix = params + buf = self.currentbuffer + if buf: + if isinstance(buf, EnvelopeBuffer): + available = buf.envelope.headers.keys() + localcompleter = StringlistCompleter(available) + localres = localcompleter.complete(localprefix, + localpos) + res = [(c, p + 6) for (c, p) in localres] + + elif self.mode == 'envelope' and cmd == 'attach': + res = self._pathcompleter.complete(params, localpos) + elif self.mode == 'envelope' and cmd in ['sign', 'togglesign']: + res = self._secretkeyscompleter.complete(params, localpos) + elif self.mode == 'envelope' and cmd in ['encrypt', + 'rmencrypt', + 'toggleencrypt']: + res = self._publickeyscompleter.complete(params, localpos) + elif self.mode == 'envelope' and cmd in ['tag', 'toggletags', + 'untag', 'retag']: + localcomp = MultipleSelectionCompleter(self._tagcompleter, + separator=',') + res = localcomp.complete(params, localpos) + # thread + elif self.mode == 'thread' and cmd == 'save': + res = self._pathcompleter.complete(params, localpos) + elif self.mode == 'thread' and cmd in ['fold', 'unfold', + 'togglesource', + 'toggleheaders']: + res = self._querycompleter.complete(params, localpos) + elif self.mode == 'thread' and cmd in ['tag', 'retag', 'untag', + 'toggletags']: + localcomp = MultipleSelectionCompleter(self._tagcompleter, + separator=',') + res = localcomp.complete(params, localpos) + elif cmd == 'move': + directions = ['up', 'down', 'page up', 'page down', + 'halfpage up', 'halfpage down', 'first', + 'last'] + if self.mode == 'thread': + directions += ['parent', 'first reply', 'last reply', + 'next sibling', 'previous sibling', + 'next', 'previous', 'next unfolded', + 'previous unfolded'] + localcompleter = StringlistCompleter(directions) + res = localcompleter.complete(params, localpos) + + # prepend cmd and correct position + res = [('%s %s' % (cmd, t), p + len(cmd) + 1) + for (t, p) in res] + + # re-insert whitespaces and correct position + wso = whitespaceoffset + res = [(' ' * wso + cmdstr, p + wso) for cmdstr, p in res] + return res diff --git a/alot/completion/commandline.py b/alot/completion/commandline.py new file mode 100644 index 00000000..f5739f38 --- /dev/null +++ b/alot/completion/commandline.py @@ -0,0 +1,54 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .completer import Completer +from .command import CommandCompleter +from ..helper import split_commandline + + +class CommandLineCompleter(Completer): + """completes command lines: semicolon separated command strings""" + + def __init__(self, dbman, mode, currentbuffer=None): + """ + :param dbman: used to look up available tagstrings + :type dbman: :class:`~alot.db.DBManager` + :param mode: mode identifier + :type mode: str + :param currentbuffer: currently active buffer. If defined, this will be + used to dynamically extract possible completion + strings + :type currentbuffer: :class:`~alot.buffers.Buffer` + """ + self._commandcompleter = CommandCompleter(dbman, mode, currentbuffer) + + @staticmethod + def get_context(line, pos): + """ + computes start and end position of substring of line that is the + command string under given position + """ + commands = split_commandline(line) + [''] + i = 0 + start = 0 + end = len(commands[i]) + while pos > end: + i += 1 + start = end + 1 + end += 1 + len(commands[i]) + return start, end + + def complete(self, line, pos): + cstart, cend = self.get_context(line, pos) + before = line[:cstart] + after = line[cend:] + cmdstring = line[cstart:cend] + cpos = pos - cstart + + res = [] + for ccmd, ccpos in self._commandcompleter.complete(cmdstring, cpos): + newtext = before + ccmd + after + newpos = pos + (ccpos - cpos) + res.append((newtext, newpos)) + return res diff --git a/alot/completion/commandname.py b/alot/completion/commandname.py new file mode 100644 index 00000000..5c946eb6 --- /dev/null +++ b/alot/completion/commandname.py @@ -0,0 +1,26 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import logging +from alot import commands +from .completer import Completer + + +class CommandNameCompleter(Completer): + """Completes command names.""" + + def __init__(self, mode): + """ + :param mode: mode identifier + :type mode: str + """ + self.mode = mode + + def complete(self, original, pos): + commandprefix = original[:pos] + logging.debug('original="%s" prefix="%s"', original, commandprefix) + cmdlist = commands.COMMANDS['global'].copy() + cmdlist.update(commands.COMMANDS[self.mode]) + matching = [t for t in cmdlist if t.startswith(commandprefix)] + return [(t, len(t)) for t in matching] diff --git a/alot/completion/completer.py b/alot/completion/completer.py new file mode 100644 index 00000000..de15d312 --- /dev/null +++ b/alot/completion/completer.py @@ -0,0 +1,37 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import abc + + +class Completer: + """base class for completers""" + + __metaclass__ = abc.ABCMeta + + @abc.abstractmethod + def complete(self, original, pos): + """returns a list of completions and cursor positions for the + string original from position pos on. + + :param original: the string to complete + :type original: str + :param pos: starting position to complete from + :type pos: int + :returns: pairs of completed string and cursor position in the + new string + :rtype: list of (str, int) + :raises: :exc:`CompletionError` + """ + pass + + def relevant_part(self, original, pos, sep=' '): + """ + calculates the subword in a `sep`-splitted list of substrings of + `original` that `pos` is ia.n + """ + start = original.rfind(sep, 0, pos) + 1 + end = original.find(sep, pos - 1) + if end == -1: + end = len(original) + return original[start:end], start, end, pos - start diff --git a/alot/completion/contacts.py b/alot/completion/contacts.py new file mode 100644 index 00000000..ae4e71cd --- /dev/null +++ b/alot/completion/contacts.py @@ -0,0 +1,21 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .multipleselection import MultipleSelectionCompleter +from .abooks import AbooksCompleter + + +class ContactsCompleter(MultipleSelectionCompleter): + """completes contacts from given address books""" + + def __init__(self, abooks, addressesonly=False): + """ + :param abooks: used to look up email addresses + :type abooks: list of :class:`~alot.account.AddresBook` + :param addressesonly: only insert address, not the realname of the + contact + :type addressesonly: bool + """ + self._completer = AbooksCompleter(abooks, addressesonly=addressesonly) + self._separator = ', ' diff --git a/alot/completion/cryptokey.py b/alot/completion/cryptokey.py new file mode 100644 index 00000000..0631eee3 --- /dev/null +++ b/alot/completion/cryptokey.py @@ -0,0 +1,24 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from alot import crypto +from .stringlist import StringlistCompleter + + +class CryptoKeyCompleter(StringlistCompleter): + """completion for gpg keys""" + + def __init__(self, private=False): + """ + :param private: return private keys + :type private: bool + """ + keys = crypto.list_keys(private=private) + resultlist = [] + for k in keys: + for s in k.subkeys: + resultlist.append(s.keyid) + for u in k.uids: + resultlist.append(u.email) + StringlistCompleter.__init__(self, resultlist, match_anywhere=True) diff --git a/alot/completion/multipleselection.py b/alot/completion/multipleselection.py new file mode 100644 index 00000000..45de5a28 --- /dev/null +++ b/alot/completion/multipleselection.py @@ -0,0 +1,48 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + + +from .completer import Completer + + +class MultipleSelectionCompleter(Completer): + """ + Meta-Completer that turns any Completer into one that deals with a list of + completion strings using the wrapped Completer. + This allows for example to easily construct a completer for comma separated + recipient-lists using a :class:`ContactsCompleter`. + """ + + def __init__(self, completer, separator=', '): + """ + :param completer: completer to use for individual substrings + :type completer: Completer + :param separator: separator used to split the completion string into + substrings to be fed to `completer`. + :type separator: str + """ + self._completer = completer + self._separator = separator + + def relevant_part(self, original, pos): + """Calculate the subword of `original` that `pos` is in.""" + start = original.rfind(self._separator, 0, pos) + if start == -1: + start = 0 + else: + start = start + len(self._separator) + end = original.find(self._separator, pos - 1) + if end == -1: + end = len(original) + return original[start:end], start, end, pos - start + + def complete(self, original, pos): + mypart, start, end, mypos = self.relevant_part(original, pos) + res = [] + for c, _ in self._completer.complete(mypart, mypos): + newprefix = original[:start] + c + if not original[end:].startswith(self._separator): + newprefix += self._separator + res.append((newprefix + original[end:], len(newprefix))) + return res diff --git a/alot/completion/namedquery.py b/alot/completion/namedquery.py new file mode 100644 index 00000000..bcad5d2c --- /dev/null +++ b/alot/completion/namedquery.py @@ -0,0 +1,18 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .stringlist import StringlistCompleter + + +class NamedQueryCompleter(StringlistCompleter): + """Complete the name of a named query string.""" + + def __init__(self, dbman): + """ + :param dbman: used to look up named query strings in the DB + :type dbman: :class:`~alot.db.DBManager` + """ + # mapping of alias to query string (dict str -> str) + nqueries = dbman.get_named_queries() + StringlistCompleter.__init__(self, list(nqueries)) diff --git a/alot/completion/path.py b/alot/completion/path.py new file mode 100644 index 00000000..c05f42df --- /dev/null +++ b/alot/completion/path.py @@ -0,0 +1,43 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import glob +import os +from .completer import Completer + + +class PathCompleter(Completer): + """Completes for file system paths.""" + + def complete(self, original, pos): + if not original: + return [('~/', 2)] + prefix = os.path.expanduser(original[:pos]) + + def escape(path): + """Escape all backslashes and spaces in path with a backslash. + + :param path: the path to escape + :type path: str + :returns: the escaped path + :rtype: str + """ + return path.replace('\\', '\\\\').replace(' ', r'\ ') + + def deescape(escaped_path): + """Remove escaping backslashes in front of spaces and backslashes. + + :param escaped_path: a path potentially with escaped spaces and + backslashs + :type escaped_path: str + :returns: the actual path + :rtype: str + """ + return escaped_path.replace('\\ ', ' ').replace('\\\\', '\\') + + def prep(path): + escaped_path = escape(path) + return escaped_path, len(escaped_path) + + return [prep(g) for g in glob.glob(deescape(prefix) + '*')] diff --git a/alot/completion/query.py b/alot/completion/query.py new file mode 100644 index 00000000..42ea64a0 --- /dev/null +++ b/alot/completion/query.py @@ -0,0 +1,57 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +import re + +from alot.settings.const import settings +from .completer import Completer +from .abooks import AbooksCompleter +from .tag import TagCompleter +from .namedquery import NamedQueryCompleter + + +class QueryCompleter(Completer): + """completion for a notmuch query string""" + def __init__(self, dbman): + """ + :param dbman: used to look up available tagstrings + :type dbman: :class:`~alot.db.DBManager` + """ + self.dbman = dbman + abooks = settings.get_addressbooks() + self._abookscompleter = AbooksCompleter(abooks, addressesonly=True) + self._tagcompleter = TagCompleter(dbman) + self._nquerycompleter = NamedQueryCompleter(dbman) + self.keywords = ['tag', 'from', 'to', 'subject', 'attachment', + 'is', 'id', 'thread', 'folder', 'query'] + + def complete(self, original, pos): + mypart, start, end, mypos = self.relevant_part(original, pos) + myprefix = mypart[:mypos] + m = re.search(r'(tag|is|to|from|query):(\w*)', myprefix) + if m: + cmd, _ = m.groups() + cmdlen = len(cmd) + 1 # length of the keyword part including colon + if cmd in ['to', 'from']: + localres = self._abookscompleter.complete(mypart[cmdlen:], + mypos - cmdlen) + elif cmd in ['query']: + localres = self._nquerycompleter.complete(mypart[cmdlen:], + mypos - cmdlen) + else: + localres = self._tagcompleter.complete(mypart[cmdlen:], + mypos - cmdlen) + resultlist = [] + for ltxt, lpos in localres: + newtext = original[:start] + cmd + ':' + ltxt + original[end:] + newpos = start + len(cmd) + 1 + lpos + resultlist.append((newtext, newpos)) + return resultlist + else: + matched = (t for t in self.keywords if t.startswith(myprefix)) + resultlist = [] + for keyword in matched: + newprefix = original[:start] + keyword + ':' + resultlist.append((newprefix + original[end:], len(newprefix))) + return resultlist diff --git a/alot/completion/stringlist.py b/alot/completion/stringlist.py new file mode 100644 index 00000000..9d7fa3d3 --- /dev/null +++ b/alot/completion/stringlist.py @@ -0,0 +1,32 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file +import re + +from .completer import Completer + + +class StringlistCompleter(Completer): + """Completer for a fixed list of strings.""" + + def __init__(self, resultlist, ignorecase=True, match_anywhere=False): + """ + :param resultlist: strings used for completion + :type resultlist: list of str + :param liberal: match case insensitive and not prefix-only + :type liberal: bool + """ + self.resultlist = resultlist + self.flags = re.IGNORECASE if ignorecase else 0 + self.match_anywhere = match_anywhere + + def complete(self, original, pos): + pref = original[:pos] + + re_prefix = '.*' if self.match_anywhere else '' + + def match(s, m): + r = '{}{}.*'.format(re_prefix, re.escape(m)) + return re.match(r, s, flags=self.flags) is not None + + return [(a, len(a)) for a in self.resultlist if match(a, pref)] diff --git a/alot/completion/tag.py b/alot/completion/tag.py new file mode 100644 index 00000000..fd6ed119 --- /dev/null +++ b/alot/completion/tag.py @@ -0,0 +1,17 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .stringlist import StringlistCompleter + + +class TagCompleter(StringlistCompleter): + """Complete a tagstring.""" + + def __init__(self, dbman): + """ + :param dbman: used to look up available tagstrings + :type dbman: :class:`~alot.db.DBManager` + """ + resultlist = dbman.get_all_tags() + StringlistCompleter.__init__(self, resultlist) diff --git a/alot/completion/tags.py b/alot/completion/tags.py new file mode 100644 index 00000000..b151e6f5 --- /dev/null +++ b/alot/completion/tags.py @@ -0,0 +1,18 @@ +# Copyright (C) 2011-2019 Patrick Totzke <patricktotzke@gmail.com> +# This file is released under the GNU GPL, version 3 or a later revision. +# For further details see the COPYING file + +from .multipleselection import MultipleSelectionCompleter +from .tag import TagCompleter + + +class TagsCompleter(MultipleSelectionCompleter): + """Complete a comma separated list of tagstrings.""" + + def __init__(self, dbman): + """ + :param dbman: used to look up available tagstrings + :type dbman: :class:`~alot.db.DBManager` + """ + self._completer = TagCompleter(dbman) + self._separator = ',' |