From bb59771d9962dcd69c29b008031fd4ae9002915d Mon Sep 17 00:00:00 2001 From: mathieui Date: Thu, 1 Aug 2013 20:17:12 +0200 Subject: Fix #2049 (get the current completed argument) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A command argument can now be completed even if it isn’t the last one in the input. - Add a new method Input.new_completion Almost like the old auto_completion method, except taht it takes another argument: argument_position, which is the argument to be completed. - Methods using the old completion method still work - All completion methods in poezio now use the new one if necessary - Further details can be found in the docstring of new_completion --- doc/source/dev/overview.rst | 15 ++- src/common.py | 96 ++++++++++++-- src/core.py | 226 ++++++++++++++++----------------- src/decorators.py | 13 +- src/plugin_manager.py | 4 +- src/shlex.py | 303 ++++++++++++++++++++++++++++++++++++++++++++ src/tabs.py | 201 ++++++++++++++--------------- src/windows.py | 116 +++++++++++++++++ 8 files changed, 729 insertions(+), 245 deletions(-) create mode 100644 src/shlex.py diff --git a/doc/source/dev/overview.rst b/doc/source/dev/overview.rst index 452938e6..f0eef18a 100644 --- a/doc/source/dev/overview.rst +++ b/doc/source/dev/overview.rst @@ -91,24 +91,27 @@ Completions are a bit tricky, but it’s easy once you get used to it: They take an **Input** (a _windows_ class) as a parameter, named the_input everywhere in the sources. To effectively have a completion, you have to call -**the_input.auto_completion()** at the end of the function. +**the_input.auto_completion()** or **the_input.new_completion()** at the end +of the function. .. code-block:: python class Input(Win): # … - def auto_completion(completion_list, after='', quote=True): + def auto_completion(completion_list, after='', quotify=True): + # … + + def new_completion(completion_list, argument_position, after='', quotify=True): # … Set the input to iterate over _completion_list_ when the user hits tab, insert **after** after the completed item, and surround the item with double quotes or not. -There is no method to find the current argument in the input (although the -feature is planned), so you have to assume the current argument is the last, -and guess it by splitting the string an checking for end-space. +To find the current completed argument, use the **input.get_argument_position()** +method. You can then use new_completion() to select the argument to be completed. You can look for examples in the sources, all the possible cases are covered (single-argument, complex arguments with spaces, several arguments, -etc…) +etc…). diff --git a/src/common.py b/src/common.py index d861fea4..25950987 100644 --- a/src/common.py +++ b/src/common.py @@ -182,7 +182,7 @@ def datetime_tuple(timestamp): :return: The date. :rtype: :py:class:`datetime.datetime` - >>> common.datetime_tuple('20130226T06:23:12') + >>> datetime_tuple('20130226T06:23:12') datetime.datetime(2013, 2, 26, 8, 23, 12) """ timestamp = timestamp.split('.')[0] @@ -233,19 +233,82 @@ def shell_split(st): >>> shell_split('"sdf 1" "toto 2"') ['sdf 1', 'toto 2'] """ - sh = shlex.shlex(st, posix=True) - sh.commenters = '' - sh.whitespace_split = True - sh.quotes = '"' - ret = list() - try: + sh = shlex.shlex(st) + ret = [] + w = sh.get_token() + while w and w[2] is not None: + ret.append(w[2]) w = sh.get_token() - while w is not None: - ret.append(w) - w = sh.get_token() - return ret - except ValueError: - return st.split(" ") + return ret + +def find_argument(pos, text, quoted=True): + """ + Split an input into a list of arguments, return the number of the + argument selected by pos. + + If the position searched is outside the string, or in a space between words, + then it will return the position of an hypothetical new argument. + + See the doctests of the two methods for example behaviors. + + :param int pos: The position to search. + :param str text: The text to analyze. + :param quoted: Whether to take quotes into account or not. + :rtype: int + """ + if quoted: + return find_argument_quoted(pos, text) + else: + return find_argument_unquoted(pos, text) + +def find_argument_quoted(pos, text): + """ + >>> find_argument_quoted(4, 'toto titi tata') + 3 + >>> find_argument_quoted(4, '"toto titi" tata') + 0 + >>> find_argument_quoted(8, '"toto" "titi tata"') + 1 + >>> find_argument_quoted(8, '"toto" "titi tata') + 1 + >>> find_argument_quoted(18, '"toto" "titi tata" ') + 2 + """ + sh = shlex.shlex(text) + count = -1 + w = sh.get_token() + while w and w[2] is not None: + count += 1 + if w[0] <= pos < w[1]: + return count + w = sh.get_token() + + return count + 1 + +def find_argument_unquoted(pos, text): + """ + >>> find_argument_unquoted(2, 'toto titi tata') + 0 + >>> find_argument_unquoted(3, 'toto titi tata') + 0 + >>> find_argument_unquoted(6, 'toto titi tata') + 1 + >>> find_argument_unquoted(4, 'toto titi tata') + 3 + >>> find_argument_unquoted(25, 'toto titi tata') + 3 + """ + ret = text.split() + search = 0 + argnum = 0 + for i, elem in enumerate(ret): + elem_start = text.find(elem, search) + elem_end = elem_start + len(elem) + search = elem_end + if elem_start <= pos < elem_end: + return i + argnum = i + return argnum + 1 def parse_str_to_secs(duration=''): """ @@ -286,7 +349,7 @@ def parse_secs_to_str(duration=0): :rtype: :py:class:`str` >>> parse_secs_to_str(3601) - 1h1s + '1h1s' """ secs, mins, hours, days = 0, 0, 0, 0 result = '' @@ -370,3 +433,8 @@ def safeJID(*args, **kwargs): return JID(*args, **kwargs) except InvalidJID: return JID('') + + +if __name__ == "__main__": + import doctest + doctest.testmod() diff --git a/src/core.py b/src/core.py index 8edbd8dc..9589f8e4 100644 --- a/src/core.py +++ b/src/core.py @@ -1482,8 +1482,8 @@ class Core(object): def completion_help(self, the_input): """Completion for /help.""" - commands = list(self.commands.keys()) + list(self.current_tab().commands.keys()) - return the_input.auto_completion(commands, ' ', quotify=False) + commands = sorted(self.commands.keys()) + sorted(self.current_tab().commands.keys()) + return the_input.new_completion(commands, 1, quotify=False) def command_runkey(self, arg): """ @@ -1509,7 +1509,7 @@ class Core(object): list_ = [] list_.extend(self.key_func.keys()) list_.extend(self.current_tab().key_func.keys()) - return the_input.auto_completion(list_, '', quotify=False) + return the_input.new_completion(list_, 1, quotify=False) def command_status(self, arg): """ @@ -1548,7 +1548,8 @@ class Core(object): """ Completion of /status """ - return the_input.auto_completion([status for status in possible_show], ' ', quotify=False) + if the_input.get_argument_position() == 1: + return the_input.new_completion([status for status in possible_show], 1, ' ', quotify=False) def command_presence(self, arg): """ @@ -1595,15 +1596,11 @@ class Core(object): """ Completion of /presence """ - text = the_input.get_text() - args = text.split() - n = len(args) - if text.endswith(' '): - n += 1 - if n == 2: - return the_input.auto_completion([jid for jid in roster.jids()], '') - elif n == 3: - return the_input.auto_completion([status for status in possible_show], '') + arg = the_input.get_argument_position() + if arg == 1: + return the_input.auto_completion([jid for jid in roster.jids()], '', quotify=True) + elif arg == 2: + return the_input.auto_completion([status for status in possible_show], '', quotify=True) def command_theme(self, arg=''): """/theme """ @@ -1631,7 +1628,7 @@ class Core(object): theme_files = [name[:-3] for name in names if name.endswith('.py')] if not 'default' in theme_files: theme_files.append('default') - return the_input.auto_completion(theme_files, '', quotify=False) + return the_input.new_completion(theme_files, 1, '', quotify=False) def command_win(self, arg): """ @@ -1675,7 +1672,7 @@ class Core(object): for tab in self.tabs: l.extend(tab.matching_names()) l = [i[1] for i in l] - return the_input.auto_completion(l, ' ', quotify=False) + return the_input.new_completion(l, 1, '', quotify=False) def command_move_tab(self, arg): """ @@ -1692,7 +1689,7 @@ class Core(object): except ValueError: old_tab = None for tab in self.tabs: - if not old_tab and value in safeJID(tab.get_name()).user: + if not old_tab and value == tab.get_name(): old_tab = tab if not old_tab: self.information("Tab %s does not exist" % args[0], "Error") @@ -1712,8 +1709,11 @@ class Core(object): def completion_move_tab(self, the_input): """Completion for /move_tab""" - nodes = [safeJID(tab.get_name()).user for tab in self.tabs] - return the_input.auto_completion(nodes, ' ', quotify=True) + n = the_input.get_argument_position(quoted=True) + if n == 1: + nodes = [tab.get_name() for tab in self.tabs if tab] + nodes.remove('Roster') + return the_input.new_completion(nodes, 1, ' ', quotify=True) def command_list(self, arg): """ @@ -1741,7 +1741,7 @@ class Core(object): tab.get_name() not in muc_serv_list: muc_serv_list.append(safeJID(tab.get_name()).server) if muc_serv_list: - return the_input.auto_completion(muc_serv_list, ' ', quotify=False) + return the_input.new_completion(muc_serv_list, 1, quotify=False) def command_version(self, arg): """ @@ -1770,12 +1770,11 @@ class Core(object): def completion_version(self, the_input): """Completion for /version""" - n = len(the_input.get_text().split()) - if n > 2 or (n == 2 and the_input.get_text().endswith(' ')): + n = the_input.get_argument_position(quoted=True) + if n >= 2: return - comp = reduce(lambda x, y: x + [i for i in y], (jid.resources for jid in roster if len(jid)), []) - comp = (str(res.jid) for res in comp) - return the_input.auto_completion(sorted(comp), '', quotify=False) + comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), []) + return the_input.new_completion(sorted(comp), 1, '', quotify=True) def command_join(self, arg, histo_length=None): """ @@ -1968,18 +1967,15 @@ class Core(object): def completion_bookmark_local(self, the_input): """Completion for /bookmark_local""" - txt = the_input.get_text() - args = common.shell_split(txt) - n = len(args) - if txt.endswith(' '): - n += 1 + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) - if len(args) == 1: - jid = safeJID('') - else: - jid = safeJID(args[1]) - if len(args) > 2: + if n >= 2: return + if len(args) == 1: + args.append('') + jid = safeJID(args[1]) + if jid.server and (jid.resource or jid.full.endswith('/')): tab = self.get_tab_by_name(jid.bare, tabs.MucTab) nicks = [tab.own_nick] if tab else [] @@ -1992,10 +1988,10 @@ class Core(object): if not nick in nicks: nicks.append(nick) jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] - return the_input.auto_completion(jids_list, '') + return the_input.new_completion(jids_list, 1, quotify=True) muc_list = [tab.get_name() for tab in self.tabs if isinstance(tab, tabs.MucTab)] muc_list.append('*') - return the_input.auto_completion(muc_list, '') + return the_input.new_completion(muc_list, 1, quotify=True) def command_bookmark(self, arg=''): """ @@ -2070,22 +2066,18 @@ class Core(object): def completion_bookmark(self, the_input): """Completion for /bookmark""" - txt = the_input.get_text() - args = common.shell_split(txt) - n = len(args) - if txt.endswith(' '): - n += 1 - - if len(args) == 1: - jid = safeJID('') - else: - jid = safeJID(args[1]) + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) - if len(args) == 2: - return the_input.auto_completion(['true', 'false'], '') - if len(args) == 3: + if n == 2: + return the_input.new_completion(['true', 'false'], 2, quotify=True) + if n >= 3: return + if len(args) == 1: + args.append('') + jid = safeJID(args[1]) + if jid.server and (jid.resource or jid.full.endswith('/')): tab = self.get_tab_by_name(jid.bare, tabs.MucTab) nicks = [tab.own_nick] if tab else [] @@ -2098,10 +2090,11 @@ class Core(object): if not nick in nicks: nicks.append(nick) jids_list = ['%s/%s' % (jid.bare, nick) for nick in nicks] - return the_input.auto_completion(jids_list, '') + return the_input.new_completion(jids_list, 1, quotify=True) muc_list = [tab.get_name() for tab in self.tabs if isinstance(tab, tabs.MucTab)] + muc_list.sort() muc_list.append('*') - return the_input.auto_completion(muc_list, '') + return the_input.new_completion(muc_list, 1, quotify=True) def command_bookmarks(self, arg=''): """/bookmarks""" @@ -2133,7 +2126,7 @@ class Core(object): def completion_remove_bookmark(self, the_input): """Completion for /remove_bookmark""" - return the_input.auto_completion([bm.jid for bm in bookmark.bookmarks], '') + return the_input.new_completion([bm.jid for bm in bookmark.bookmarks], 1, quotify=False) def command_set(self, arg): """ @@ -2170,27 +2163,24 @@ class Core(object): def completion_set(self, the_input): """Completion for /set""" - text = the_input.get_text() - args = common.shell_split(text) - n = len(args) - empty = False - if text.endswith(' '): - n += 1 - empty = True - if n == 2: - if not empty and '|' in args[1]: + args = common.shell_split(the_input.text) + n = the_input.get_argument_position(quoted=True) + if n >= len(args): + args.append('') + if n == 1: + if '|' in args[1]: plugin_name, section = args[1].split('|')[:2] if not plugin_name in self.plugin_manager.plugins: - return the_input.auto_completion([],'') + return the_input.new_completion([], n, quotify=True) plugin = self.plugin_manager.plugins[plugin_name] end_list = ['%s|%s' % (plugin_name, section) for section in plugin.config.sections()] else: end_list = config.options('Poezio') - elif n == 3: + elif n == 2: if '|' in args[1]: plugin_name, section = args[1].split('|')[:2] if not plugin_name in self.plugin_manager.plugins: - return the_input.auto_completion([''],'') + return the_input.auto_completion([''], n, quotify=True) plugin = self.plugin_manager.plugins[plugin_name] end_list = plugin.config.options(section or plugin_name) elif not config.has_option('Poezio', args[1]): @@ -2201,11 +2191,11 @@ class Core(object): end_list = [] else: end_list = [config.get(args[1], ''), ''] - elif n == 4: + elif n == 3: if '|' in args[1]: plugin_name, section = args[1].split('|')[:2] if not plugin_name in self.plugin_manager.plugins: - return the_input.auto_completion([],'') + return the_input.auto_completion([''], n, quotify=True) plugin = self.plugin_manager.plugins[plugin_name] end_list = [plugin.config.get(args[2], '', section or plugin_name), ''] else: @@ -2213,7 +2203,9 @@ class Core(object): end_list = [''] else: end_list = [config.get(args[2], '', args[1]), ''] - return the_input.auto_completion(end_list, '') + else: + return + return the_input.new_completion(end_list, n, quotify=True) def command_server_cycle(self, arg=''): """ @@ -2241,18 +2233,12 @@ class Core(object): def completion_server_cycle(self, the_input): """Completion for /server_cycle""" - txt = the_input.get_text() - args = txt.split() - n = len(args) - if txt.endswith(' '): - n += 1 - if n == 2: - serv_list = set() - for tab in self.tabs: - if isinstance(tab, tabs.MucTab): - serv = safeJID(tab.get_name()).server - serv_list.add(serv) - return the_input.auto_completion(list(serv_list), ' ') + serv_list = set() + for tab in self.tabs: + if isinstance(tab, tabs.MucTab): + serv = safeJID(tab.get_name()).server + serv_list.add(serv) + return the_input.new_completion(sorted(serv_list), 1, ' ') def command_last_activity(self, arg): """ @@ -2284,7 +2270,7 @@ class Core(object): self.xmpp.plugin['xep_0012'].get_last_activity(jid, block=False, callback=callback) def completion_last_activity(self, the_input): - return the_input.auto_completion([jid for jid in roster.jids()], '', quotify=False) + return the_input.new_completion([jid for jid in roster.jids()], 1, quotify=False) def command_mood(self, arg): """ @@ -2304,7 +2290,9 @@ class Core(object): def completion_mood(self, the_input): """Completion for /mood""" - return the_input.auto_completion(list(pep.MOODS.keys()), '', quotify=False) + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.new_completion(sorted(pep.MOODS.keys()), 1, quotify=True) def command_activity(self, arg): """ @@ -2347,18 +2335,16 @@ class Core(object): def completion_activity(self, the_input): """Completion for /activity""" - txt = the_input.get_text() - args = common.shell_split(txt) - n = len(args) - if txt.endswith(' '): - n += 1 - if n == 2: - return the_input.auto_completion(list(pep.ACTIVITIES.keys()), '', quotify=False) - elif n == 3: + n = the_input.get_argument_position(quoted=True) + args = common.shell_split(the_input.text) + if n == 1: + return the_input.new_completion(sorted(pep.ACTIVITIES.keys()), n, quotify=True) + elif n == 2: if args[1] in pep.ACTIVITIES: l = list(pep.ACTIVITIES[args[1]]) l.remove('category') - return the_input.auto_completion(l, '', quotify=False) + l.sort() + return the_input.new_completion(l, n, quotify=True) def command_invite(self, arg): """/invite [reason]""" @@ -2372,19 +2358,16 @@ class Core(object): def completion_invite(self, the_input): """Completion for /invite""" - txt = the_input.get_text() - args = common.shell_split(txt) - n = len(args) - if txt.endswith(' '): - n += 1 - if n == 2: - return the_input.auto_completion([jid for jid in roster.jids()], '') - elif n == 3: + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.new_completion(sorted(jid for jid in roster.jids()), n, quotify=True) + elif n == 2: rooms = [] for tab in self.tabs: if isinstance(tab, tabs.MucTab) and tab.joined: rooms.append(tab.get_name()) - return the_input.auto_completion(rooms, '') + rooms.sort() + return the_input.new_completion(rooms, n, '', quotify=True) def command_decline(self, arg): """/decline [reason]""" @@ -2400,13 +2383,9 @@ class Core(object): def completion_decline(self, the_input): """Completion for /decline""" - txt = the_input.get_text() - args = common.shell_split(txt) - n = len(args) - if txt.endswith(' '): - n += 1 - if n == 2: - return the_input.auto_completion(list(self.pending_invites.keys()), '') + n = the_input.get_argument_position(quoted=True) + if n == 1: + return the_input.auto_completion(sorted(self.pending_invites.keys()), 1, '', quotify=True) ### Commands without a completion in this class ### @@ -2442,6 +2421,20 @@ class Core(object): self.reset_curses() sys.exit() + def completion_bind(self, the_input): + n = the_input.get_argument_position() + if n == 1: + args = [key for key in self.key_func if not key.startswith('_')] + elif n == 2: + args = [key for key in self.key_func] + else: + return + + return the_input.new_completion(args, n, '', quotify=False) + + + return the_input + def command_bind(self, arg): """ Bind a key. @@ -2537,15 +2530,15 @@ class Core(object): def completion_message(self, the_input): """Completion for /message""" - n = len(the_input.get_text().split()) - if n > 2 or (n == 2 and the_input.get_text().endswith(' ')): + n = the_input.get_argument_position(quoted=True) + if n >= 2: return - comp = reduce(lambda x, y: x + [i for i in y], (jid.resources for jid in roster if len(jid)), []) - comp = sorted((str(res.jid) for res in comp)) - bares = sorted(contact.bare_jid for contact in roster if len(contact)) - off = sorted(contact.bare_jid for contact in roster if not len(contact)) + comp = reduce(lambda x, y: x + [i.jid for i in y], (roster[jid].resources for jid in roster.jids() if len(roster[jid])), []) + comp = sorted(comp) + bares = sorted(roster[contact].bare_jid for contact in roster.jids() if len(roster[contact])) + off = sorted(jid for jid in roster.jids() if jid not in bares) comp = bares + comp + off - return the_input.auto_completion(comp, '', quotify=True) + return the_input.new_completion(comp, 1, '', quotify=True) def command_xml_tab(self, arg=''): """/xml_tab""" @@ -2652,6 +2645,7 @@ class Core(object): self.register_command('bind', self.command_bind, usage=_(' '), desc=_('Bind a key to another key or to a “command”. For example "/bind ^H KEY_UP" makes Control + h do the same same as the Up key.'), + completion=self.completion_bind, shortdesc=_('Bind a key to another key.')) self.register_command('load', self.command_load, usage=_(''), @@ -3253,12 +3247,14 @@ class Core(object): """subscribe received""" jid = presence['from'].bare contact = roster[jid] - if contact.subscription in ('from', 'both'): + if contact and contact.subscription in ('from', 'both'): return - elif contact.subscription == 'to': + elif contact and contact.subscription == 'to': self.xmpp.sendPresence(pto=jid, ptype='subscribed') self.xmpp.sendPresence(pto=jid) else: + if not contact: + contact = roster.get_and_set(jid) roster.update_contact_groups(contact) contact.pending_in = True self.information('%s wants to subscribe to your presence' % jid, 'Roster') diff --git a/src/decorators.py b/src/decorators.py index ab987701..3cb967b3 100644 --- a/src/decorators.py +++ b/src/decorators.py @@ -1,7 +1,9 @@ """ -Module containing the decorators +Module containing various decorators """ +from functools import partial + class RefreshWrapper(object): def __init__(self): self.core = None @@ -40,5 +42,14 @@ class RefreshWrapper(object): return ret return wrap +def __completion(quoted, func): + class Completion(object): + quoted = quoted + def __new__(cls, *args, **kwargs): + return func(*args, **kwargs) + return Completion + +completion_quotes = partial(__completion, True) +completion_raw = partial(__completion, False) refresh_wrapper = RefreshWrapper() diff --git a/src/plugin_manager.py b/src/plugin_manager.py index 9e253f97..5931d798 100644 --- a/src/plugin_manager.py +++ b/src/plugin_manager.py @@ -314,13 +314,13 @@ class PluginManager(object): plugins_files = [name[:-3] for name in names if name.endswith('.py') and name != '__init__.py' and not name.startswith('.')] plugins_files.sort() - return the_input.auto_completion(plugins_files, '', quotify=False) + return the_input.new_completion(plugins_files, 1, '', quotify=False) def completion_unload(self, the_input): """ completion function that completes the name of the plugins that are loaded """ - return the_input.auto_completion(list(self.plugins.keys()), '', quotify=False) + return the_input.new_completion(sorted(self.plugins.keys()), 1, '', quotify=False) def on_plugins_dir_change(self, new_value): global plugins_dir diff --git a/src/shlex.py b/src/shlex.py new file mode 100644 index 00000000..87a241ed --- /dev/null +++ b/src/shlex.py @@ -0,0 +1,303 @@ +""" +A lexical analyzer class for simple shell-like syntaxes. + +Tweaked for the specific needs of parsing poezio input. + +""" + +# Module and documentation by Eric S. Raymond, 21 Dec 1998 +# Input stacking and error message cleanup added by ESR, March 2000 +# push_source() and pop_source() made explicit by ESR, January 2001. +# Posix compliance, split(), string arguments, and +# iterator interface by Gustavo Niemeyer, April 2003. + +import os +import re +import sys +from collections import deque + +from io import StringIO + +__all__ = ["shlex", "split", "quote"] + +class shlex: + """ + A custom version of the shlex in the stdlib to yield more information + """ + def __init__(self, instream=None, infile=None, posix=True): + if isinstance(instream, str): + instream = StringIO(instream) + if instream is not None: + self.instream = instream + self.infile = infile + else: + self.instream = sys.stdin + self.infile = None + self.posix = posix + self.eof = '' + self.commenters = '' + self.wordchars = ('abcdfeghijklmnopqrstuvwxyz' + 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_') + if self.posix: + self.wordchars += ('ßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ' + 'ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞ') + self.whitespace = ' \t\r\n' + self.whitespace_split = True + self.quotes = '"' + self.escape = '\\' + self.escapedquotes = '"' + self.state = ' ' + self.pushback = deque() + self.lineno = 1 + self.debug = 0 + self.token = '' + self.filestack = deque() + self.source = None + if self.debug: + print('shlex: reading from %s, line %d' \ + % (self.instream, self.lineno)) + + def push_token(self, tok): + "Push a token onto the stack popped by the get_token method" + if self.debug >= 1: + print("shlex: pushing token " + repr(tok)) + self.pushback.appendleft(tok) + + def push_source(self, newstream, newfile=None): + "Push an input source onto the lexer's input source stack." + if isinstance(newstream, str): + newstream = StringIO(newstream) + self.filestack.appendleft((self.infile, self.instream, self.lineno)) + self.infile = newfile + self.instream = newstream + self.lineno = 1 + if self.debug: + if newfile is not None: + print('shlex: pushing to file %s' % (self.infile,)) + else: + print('shlex: pushing to stream %s' % (self.instream,)) + + def pop_source(self): + "Pop the input source stack." + self.instream.close() + (self.infile, self.instream, self.lineno) = self.filestack.popleft() + if self.debug: + print('shlex: popping to %s, line %d' \ + % (self.instream, self.lineno)) + self.state = ' ' + + def get_token(self): + "Get a token from the input stream (or from stack if it's nonempty)" + if self.pushback: + tok = self.pushback.popleft() + if self.debug >= 1: + print("shlex: popping token " + repr(tok)) + return tok + # No pushback. Get a token. + start, end, raw = self.read_token() + # Handle inclusions + # Maybe we got EOF instead? + while raw == self.eof: + if not self.filestack: + return self.eof + else: + self.pop_source() + start, end, raw = self.get_token() + # Neither inclusion nor EOF + if self.debug >= 1: + if raw != self.eof: + print("shlex: token=" + repr(raw)) + else: + print("shlex: token=EOF") + return start, end, raw + + def read_token(self): + quoted = False + escapedstate = ' ' + token_start = 0 + token_end = -1 + while True: + nextchar = self.instream.read(1) + if nextchar == '\n': + self.lineno = self.lineno + 1 + if self.debug >= 3: + print("shlex: in state", repr(self.state), \ + "I see character:", repr(nextchar)) + if self.state is None: + self.token = '' # past end of file + token_end = self.instream.tell() + break + elif self.state == ' ': + if not nextchar: + self.state = None # end of file + token_end = self.instream.tell() + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in whitespace state") + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif nextchar in self.commenters: + self.instream.readline() + self.lineno = self.lineno + 1 + elif self.posix and nextchar in self.escape: + token_start = self.instream.tell() - 1 + escapedstate = 'a' + self.state = nextchar + elif nextchar in self.wordchars: + token_start = self.instream.tell() - 1 + self.token = nextchar + self.state = 'a' + elif nextchar in self.quotes: + token_start = self.instream.tell() - 1 + self.state = nextchar + elif self.whitespace_split: + token_start = self.instream.tell() - 1 + self.token = nextchar + self.state = 'a' + else: + token_start = self.instream.tell() - 1 + self.token = nextchar + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif self.state in self.quotes: + quoted = True + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in quotes state") + # XXX what error should be raised here? + token_end = self.instream.tell() + break + if nextchar == self.state: + if not self.posix: + self.token = self.token + nextchar + self.state = ' ' + token_end = self.instream.tell() + break + else: + self.state = 'a' + elif self.posix and nextchar in self.escape and \ + self.state in self.escapedquotes: + escapedstate = self.state + self.state = nextchar + else: + self.token = self.token + nextchar + elif self.state in self.escape: + if not nextchar: # end of file + if self.debug >= 2: + print("shlex: I see EOF in escape state") + # XXX what error should be raised here? + token_end = self.instream.tell() + break + # In posix shells, only the quote itself or the escape + # character may be escaped within quotes. + if escapedstate in self.quotes and \ + nextchar != self.state and nextchar != escapedstate: + self.token = self.token + self.state + self.token = self.token + nextchar + self.state = escapedstate + elif self.state == 'a': + if not nextchar: + self.state = None # end of file + token_end = self.instream.tell() + break + elif nextchar in self.whitespace: + if self.debug >= 2: + print("shlex: I see whitespace in word state") + self.state = ' ' + if self.token or (self.posix and quoted): + token_end = self.instream.tell() - 1 + break # emit current token + else: + continue + elif self.posix and nextchar in self.quotes: + self.state = nextchar + elif self.posix and nextchar in self.escape: + escapedstate = 'a' + self.state = nextchar + elif nextchar in self.wordchars or nextchar in self.quotes \ + or self.whitespace_split: + self.token = self.token + nextchar + else: + self.pushback.appendleft(nextchar) + if self.debug >= 2: + print("shlex: I see punctuation in word state") + self.state = ' ' + if self.token: + token_end = self.instream.tell() + break # emit current token + else: + continue + result = self.token + self.token = '' + if self.posix and not quoted and result == '': + result = None + if self.debug > 1: + if result: + print("shlex: raw token=" + repr(result)) + else: + print("shlex: raw token=EOF") + return (token_start, token_end, result) + + def sourcehook(self, newfile): + "Hook called on a filename to be sourced." + if newfile[0] == '"': + newfile = newfile[1:-1] + # This implements cpp-like semantics for relative-path inclusion. + if isinstance(self.infile, str) and not os.path.isabs(newfile): + newfile = os.path.join(os.path.dirname(self.infile), newfile) + return (newfile, open(newfile, "r")) + + def error_leader(self, infile=None, lineno=None): + "Emit a C-compiler-like, Emacs-friendly error-message leader." + if infile is None: + infile = self.infile + if lineno is None: + lineno = self.lineno + return "\"%s\", line %d: " % (infile, lineno) + + def __iter__(self): + return self + + def __next__(self): + token = self.get_token() + if token and token[0] == self.eof: + raise StopIteration + return token + +def split(s, comments=False, posix=True): + lex = shlex(s, posix=posix) + lex.whitespace_split = True + if not comments: + lex.commenters = '' + return list(lex) + + +_find_unsafe = re.compile(r'[^\w@%+=:,./-]', re.ASCII).search + +def quote(s): + """Return a shell-escaped version of the string *s*.""" + if not s: + return "''" + if _find_unsafe(s) is None: + return s + + # use single quotes, and put single quotes into double quotes + # the string $'b is then quoted as '$'"'"'b' + return "'" + s.replace("'", "'\"'\"'") + "'" + + +if __name__ == '__main__': + lexer = shlex(instream=sys.argv[1]) + while 1: + tt = lexer.get_token() + if tt: + print("Token: " + repr(tt)) + else: + break diff --git a/src/tabs.py b/src/tabs.py index ceb69278..6a97e476 100644 --- a/src/tabs.py +++ b/src/tabs.py @@ -215,8 +215,29 @@ class Tab(object): txt = the_input.get_text() # check if this is a command if txt.startswith('/') and not txt.startswith('//'): + position = the_input.get_argument_position(quoted=False) + if position == 0: + words = ['/%s'% (name) for name in sorted(self.core.commands)] +\ + ['/%s' % (name) for name in sorted(self.commands)] + the_input.new_completion(words, 0) + # Do not try to cycle command completion if there was only + # one possibily. The next tab will complete the argument. + # Otherwise we would need to add a useless space before being + # able to complete the arguments. + hit_copy = set(the_input.hit_list) + while not hit_copy: + whitespace = the_input.text.find(' ') + if whitespace == -1: + whitespace = len(the_input.text) + the_input.text = the_input.text[:whitespace-1] + the_input.text[whitespace:] + the_input.new_completion(words, 0) + hit_copy = set(the_input.hit_list) + if len(hit_copy) == 1: + the_input.do_command(' ') + the_input.reset_completion() + return True # check if we are in the middle of the command name - if len(txt.split()) > 1 or\ + elif len(txt.split()) > 1 or\ (txt.endswith(' ') and not the_input.last_completion): command_name = txt.split()[0][1:] if command_name in self.commands: @@ -229,22 +250,6 @@ class Tab(object): return False # There's no completion function else: return command[2](the_input) - else: - # complete the command's name - words = ['/%s'% (name) for name in self.core.commands] +\ - ['/%s' % (name) for name in self.commands] - the_input.auto_completion(words, '', quotify=False) - # Do not try to cycle command completion if there was only - # one possibily. The next tab will complete the argument. - # Otherwise we would need to add a useless space before being - # able to complete the arguments. - hit_copy = set(the_input.hit_list) - while not hit_copy: - the_input.key_backspace() - the_input.auto_completion(words, '', quotify=False) - hit_copy = set(the_input.hit_list) - if len(hit_copy) == 1: - the_input.do_command(' ') return True return False @@ -637,7 +642,7 @@ class ChatTab(Tab): self.command_say(line, correct=True) def completion_correct(self, the_input): - if self.last_sent_message: + if self.last_sent_message and the_input.get_argument_position() == 1: return the_input.auto_completion([self.last_sent_message['body']], '', quotify=False) @property @@ -834,15 +839,13 @@ class MucTab(ChatTab): compare_users = lambda x: x.last_talked userlist = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)\ if user.nick != self.own_nick] - contact_list = [jid for jid in roster.jids()] - userlist.extend(contact_list) - return the_input.auto_completion(userlist, '', quotify=False) + return the_input.auto_completion(userlist, quotify=False) def completion_info(self, the_input): """Completion for /info""" compare_users = lambda x: x.last_talked userlist = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)] - return the_input.auto_completion(userlist, '', quotify=False) + return the_input.auto_completion(userlist, quotify=False) def completion_nick(self, the_input): """Completion for /nick""" @@ -851,7 +854,9 @@ class MucTab(ChatTab): return the_input.auto_completion(nicks, '', quotify=False) def completion_recolor(self, the_input): - return the_input.auto_completion(['random'], '', quotify=False) + if the_input.get_argument_position() == 1: + return the_input.new_completion(['random'], 1, '', quotify=False) + return True def completion_ignore(self, the_input): """Completion for /ignore""" @@ -859,32 +864,24 @@ class MucTab(ChatTab): if self.own_nick in userlist: userlist.remove(self.own_nick) userlist.sort() - return the_input.auto_completion(userlist, '', quotify=False) + return the_input.auto_completion(userlist, quotify=False) def completion_role(self, the_input): """Completion for /role""" - text = the_input.get_text() - args = common.shell_split(text) - n = len(args) - if text.endswith(' '): - n += 1 - if n == 2: + n = the_input.get_argument_position(quoted=True) + if n == 1: userlist = [user.nick for user in self.users] if self.own_nick in userlist: userlist.remove(self.own_nick) - return the_input.auto_completion(userlist, '') - elif n == 3: + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: possible_roles = ['none', 'visitor', 'participant', 'moderator'] - return the_input.auto_completion(possible_roles, '') + return the_input.new_completion(possible_roles, 2, '', quotify=True) def completion_affiliation(self, the_input): """Completion for /affiliation""" - text = the_input.get_text() - args = common.shell_split(text) - n = len(args) - if text.endswith(' '): - n += 1 - if n == 2: + n = the_input.get_argument_position(quoted=True) + if n == 1: userlist = [user.nick for user in self.users] if self.own_nick in userlist: userlist.remove(self.own_nick) @@ -892,10 +889,10 @@ class MucTab(ChatTab): if self.core.xmpp.boundjid.bare in jidlist: jidlist.remove(self.core.xmpp.boundjid.bare) userlist.extend(jidlist) - return the_input.auto_completion(userlist, '') - elif n == 3: + return the_input.new_completion(userlist, 1, '', quotify=True) + elif n == 2: possible_affiliations = ['none', 'member', 'admin', 'owner', 'outcast'] - return the_input.auto_completion(possible_affiliations, '') + return the_input.new_completion(possible_affiliations, 2, '', quotify=True) def scroll_user_list_up(self): self.user_win.scroll_up() @@ -1130,15 +1127,16 @@ class MucTab(ChatTab): self.input.refresh() def completion_topic(self, the_input): - current_topic = self.topic - return the_input.auto_completion([current_topic], '', quotify=False) + if the_input.get_argument_position() == 1: + return the_input.auto_completion([self.topic], '', quotify=False) def completion_quoted(self, the_input): """Nick completion, but with quotes""" - compare_users = lambda x: x.last_talked - word_list = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)\ - if user.nick != self.own_nick] - return the_input.auto_completion(word_list, '', quotify=True) + if the_input.get_argument_position(quoted=True) == 1: + compare_users = lambda x: x.last_talked + word_list = [user.nick for user in sorted(self.users, key=compare_users, reverse=True)\ + if user.nick != self.own_nick] + return the_input.new_completion(word_list, 1, quotify=True) def command_kick(self, arg): """ @@ -1290,7 +1288,8 @@ class MucTab(ChatTab): self.core.information(_('%s is now unignored') % nick) def completion_unignore(self, the_input): - return the_input.auto_completion([user.nick for user in self.ignores], '', quotify=False) + if the_input.get_argument_position() == 1: + return the_input.new_completion([user.nick for user in self.ignores], 1, '', quotify=False) def resize(self): """ @@ -2270,8 +2269,9 @@ class RosterInfoTab(Tab): """ Completion for /block """ - jids = roster.jids() - return the_input.auto_completion(jids, '', quotify=False) + if the_input.get_argument_position() == 1: + jids = roster.jids() + return the_input.new_completion(jids, 1, '', quotify=False) def command_unblock(self, arg): """ @@ -2296,15 +2296,16 @@ class RosterInfoTab(Tab): """ Completion for /unblock """ - try: - iq = self.core.xmpp.plugin['xep_0191'].get_blocked(block=True) - except Exception as e: - iq = e.iq - finally: - if iq['type'] == 'error': - return - l = [str(item) for item in iq['blocklist']['items']] - return the_input.auto_completion(l, quotify=False) + if the_input.get_argument_position(): + try: + iq = self.core.xmpp.plugin['xep_0191'].get_blocked(block=True) + except Exception as e: + iq = e.iq + finally: + if iq['type'] == 'error': + return + l = sorted(str(item) for item in iq['blocklist']['items']) + return the_input.new_completion(l, 1, quotify=False) def command_list_blocks(self, arg=None): """ @@ -2673,73 +2674,58 @@ class RosterInfoTab(Tab): return the_input.auto_completion(jids, '', quotify=False) def completion_name(self, the_input): - text = the_input.get_text() - n = len(common.shell_split(text)) - if text.endswith(' '): - n += 1 - - if n == 2: + """Completion for /name""" + n = the_input.get_argument_position() + if n == 1: jids = [jid for jid in roster.jids()] - return the_input.auto_completion(jids, '') + return the_input.new_completion(jids, n, quotify=True) return False def completion_groupadd(self, the_input): - text = the_input.get_text() - n = len(common.shell_split(text)) - if text.endswith(' '): - n += 1 - - if n == 2: - jids = [jid for jid in roster.jids()] - return the_input.auto_completion(jids, '') - elif n == 3: - groups = [group for group in roster.groups if group != 'none'] - return the_input.auto_completion(groups, '') + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: + groups = sorted(group for group in roster.groups if group != 'none') + return the_input.new_completion(groups, n, '', quotify=True) return False def completion_groupmove(self, the_input): - text = the_input.get_text() - args = common.shell_split(text) - n = len(args) - if text.endswith(' '): - n += 1 - - if n == 2: - jids = [jid for jid in roster.jids()] - return the_input.auto_completion(jids, '') - elif n == 3: + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: contact = roster[args[1]] if not contact: return False groups = list(contact.groups) if 'none' in groups: groups.remove('none') - return the_input.auto_completion(groups, '') - elif n == 4: - groups = [group for group in roster.groups] - return the_input.auto_completion(groups, '') + return the_input.new_completion(groups, n, '', quotify=True) + elif n == 3: + groups = sorted(group for group in roster.groups) + return the_input.new_completion(groups, n, '', quotify=True) return False def completion_groupremove(self, the_input): - text = the_input.get_text() - args = common.shell_split(text) - n = len(args) - if text.endswith(' '): - n += 1 - - if n == 2: - jids = [jid for jid in roster.jids()] - return the_input.auto_completion(jids, '') - elif n == 3: + args = common.shell_split(the_input.text) + n = the_input.get_argument_position() + if n == 1: + jids = sorted(jid for jid in roster.jids()) + return the_input.new_completion(jids, n, '', quotify=True) + elif n == 2: contact = roster[args[1]] if contact is None: return False - groups = list(contact.groups) + groups = sorted(contact.groups) try: groups.remove('none') except ValueError: pass - return the_input.auto_completion(groups, '') + return the_input.new_completion(groups, n, '', quotify=True) return False def completion_deny(self, the_input): @@ -2747,9 +2733,9 @@ class RosterInfoTab(Tab): Complete the first argument from the list of the contact with ask=='subscribe' """ - jids = [str(contact.bare_jid) for contact in roster.contacts.values()\ - if contact.pending_in] - return the_input.auto_completion(jids, '', quotify=False) + jids = sorted(str(contact.bare_jid) for contact in roster.contacts.values() + if contact.pending_in) + return the_input.new_completion(jids, 1, '', quotify=False) def command_accept(self, arg): """ @@ -2772,6 +2758,7 @@ class RosterInfoTab(Tab): contact = roster[jid] if contact is None: return + contact.pending_in = False roster.modified() self.core.xmpp.send_presence(pto=jid, ptype='subscribed') self.core.xmpp.client_roster.send_last_presence() diff --git a/src/windows.py b/src/windows.py index 6ee9fbfb..b35ad4ba 100644 --- a/src/windows.py +++ b/src/windows.py @@ -1383,6 +1383,122 @@ class Input(Win): self.normal_completion(word_list, add_after) return True + def new_completion(self, word_list, argument_position=-1, add_after='', quotify=True): + """ + Complete the argument at position ``argument_postion`` in the input. + If ``quotify`` is ``True``, then the completion will operate on block of words + (e.g. "toto titi") whereas if it is ``False``, it will operate on words (e.g + "toto", "titi"). + + The completions may modify other parts of the input when completing an argument, + for example removing useless double quotes around single-words, or setting the + space between each argument to only one space. + + The case where we complete the first argument is special, because we complete + the command, and we do not want to modify anything else in the input. + + This method is the one that should be used if the command being completed + has several arguments. + """ + if argument_position == 0: + self._new_completion_first(word_list) + else: + self._new_completion_args(word_list, argument_position, add_after, quotify) + self.rewrite_text() + return True + + def _new_completion_args(self, word_list, argument_position=-1, add_after='', quoted=True): + """ + Case for completing arguments with position ≠ 0 + """ + if quoted: + words = common.shell_split(self.text) + else: + words = self.text.split() + if argument_position >= len(words): + current = '' + else: + current = words[argument_position] + + if quoted: + split_words = words[1:] + words = [words[0]] + for word in split_words: + if ' ' in word: + words.append('"' + word + '"') + else: + words.append(word) + current_l = current.lower() + if self.last_completion is not None: + self.hit_list.append(self.hit_list.pop(0)) + else: + hit_list = [] + for word in word_list: + if word.lower().startswith(current_l): + hit_list.append(word) + + if not hit_list: + return + self.hit_list = hit_list + + if argument_position >= len(words): + if quoted and ' ' in self.hit_list[0]: + words.append('"'+self.hit_list[0]+'"') + else: + words.append(self.hit_list[0]) + else: + if quoted and ' ' in self.hit_list[0]: + words[argument_position] = '"'+self.hit_list[0]+'"' + else: + words[argument_position] = self.hit_list[0] + + new_pos = -1 + for i, word in enumerate(words): + if argument_position >= i: + new_pos += len(word) + 1 + + self.last_completion = self.hit_list[0] + self.text = words[0] + ' ' + ' '.join(words[1:]) + self.pos = new_pos + + def _new_completion_first(self, word_list): + """ + Special case of completing the command itself: + we don’t want to change anything to the input doing that + """ + space_pos = self.text.find(' ') + if space_pos != -1: + current, follow = self.text[:space_pos], self.text[space_pos:] + else: + current, follow = self.text, '' + + if self.last_completion: + self.hit_list.append(self.hit_list.pop(0)) + else: + hit_list = [] + for word in word_list: + if word.lower().startswith(current): + hit_list.append(word) + if not hit_list: + return + self.hit_list = hit_list + + self.last_completion = self.hit_list[0] + self.text = self.hit_list[0] + follow + self.pos = len(self.hit_list[0]) + + def get_argument_position(self, quoted=True): + """ + Get the argument number at the current position + """ + command_stop = self.text.find(' ') + if command_stop == -1 or self.pos <= command_stop: + return 0 + text = self.text[command_stop+1:] + pos = self.pos + self.line_pos - len(self.text) + len(text) - 1 + val = common.find_argument(pos, text, quoted=quoted) + 1 + return val + def reset_completion(self): """ Reset the completion list (called on ALL keys except tab) -- cgit v1.2.3