Repository: qutebrowser/qutebrowser

Issue:
:bind should error for mode/command mismatch
`bind --mode=caret <Ctrl-k> rl-unix-filename-rubout` should error rather than letting you create a binding for a command that isn't supported in the selected mode.



Discussion:

Resolving patch:
diff --git a/qutebrowser/commands/command.py b/qutebrowser/commands/command.py
--- a/qutebrowser/commands/command.py
+++ b/qutebrowser/commands/command.py
@@ -82,7 +82,6 @@ class Command:
         no_replace_variables: Don't replace variables like {url}
         _qute_args: The saved data from @cmdutils.argument
         _modes: The modes the command can be executed in.
-        _not_modes: The modes the command can not be executed in.
         _count: The count set for the command.
         _instance: The object to bind 'self' to.
         _scope: The scope to get _instance for in the object registry.
@@ -101,10 +100,14 @@ def __init__(self, *, handler, name, instance=None, maxsplit=None,
             for m in modes:
                 if not isinstance(m, usertypes.KeyMode):
                     raise TypeError("Mode {} is no KeyMode member!".format(m))
-        if not_modes is not None:
+            self._modes = set(modes)
+        elif not_modes is not None:
             for m in not_modes:
                 if not isinstance(m, usertypes.KeyMode):
                     raise TypeError("Mode {} is no KeyMode member!".format(m))
+            self._modes = set(usertypes.KeyMode).difference(not_modes)
+        else:
+            self._modes = set(usertypes.KeyMode)
         if scope != 'global' and instance is None:
             raise ValueError("Setting scope without setting instance makes "
                              "no sense!")
@@ -114,8 +117,6 @@ def __init__(self, *, handler, name, instance=None, maxsplit=None,
         self.hide = hide
         self.deprecated = deprecated
         self._instance = instance
-        self._modes = modes
-        self._not_modes = not_modes
         self._scope = scope
         self._star_args_optional = star_args_optional
         self.debug = debug
@@ -155,17 +156,7 @@ def _check_prerequisites(self, win_id):
         """
         mode_manager = objreg.get('mode-manager', scope='window',
                                   window=win_id)
-        curmode = mode_manager.mode
-        if self._modes is not None and curmode not in self._modes:
-            mode_names = '/'.join(mode.name for mode in self._modes)
-            raise cmdexc.PrerequisitesError(
-                "{}: This command is only allowed in {} mode.".format(
-                    self.name, mode_names))
-        elif self._not_modes is not None and curmode in self._not_modes:
-            mode_names = '/'.join(mode.name for mode in self._not_modes)
-            raise cmdexc.PrerequisitesError(
-                "{}: This command is not allowed in {} mode.".format(
-                    self.name, mode_names))
+        self.validate_mode(mode_manager.mode)
 
         used_backend = usertypes.arg2backend[objreg.get('args').backend]
         if self.backend is not None and used_backend != self.backend:
@@ -525,3 +516,15 @@ def run(self, win_id, args=None, count=None):
         log.commands.debug('Calling {}'.format(
             debug_utils.format_call(self.handler, posargs, kwargs)))
         self.handler(*posargs, **kwargs)
+
+    def validate_mode(self, mode):
+        """Raise cmdexc.PrerequisitesError unless allowed in the given mode.
+
+        Args:
+            mode: The usertypes.KeyMode to check.
+        """
+        if mode not in self._modes:
+            mode_names = '/'.join(sorted(m.name for m in self._modes))
+            raise cmdexc.PrerequisitesError(
+                "{}: This command is only allowed in {} mode, not {}.".format(
+                    self.name, mode_names, mode.name))
diff --git a/qutebrowser/config/parsers/keyconf.py b/qutebrowser/config/parsers/keyconf.py
--- a/qutebrowser/config/parsers/keyconf.py
+++ b/qutebrowser/config/parsers/keyconf.py
@@ -177,12 +177,13 @@ def bind(self, key, command=None, *, mode='normal', force=False):
                                                                      mode))
             return
 
-        mode = self._normalize_sectname(mode)
-        for m in mode.split(','):
+        modenames = self._normalize_sectname(mode).split(',')
+        for m in modenames:
             if m not in configdata.KEY_DATA:
                 raise cmdexc.CommandError("Invalid mode {}!".format(m))
         try:
-            self._validate_command(command)
+            modes = [usertypes.KeyMode[m] for m in modenames]
+            self._validate_command(command, modes)
         except KeyConfigError as e:
             raise cmdexc.CommandError(str(e))
         try:
@@ -192,7 +193,7 @@ def bind(self, key, command=None, *, mode='normal', force=False):
                                       "override!".format(str(e.keychain)))
         except KeyConfigError as e:
             raise cmdexc.CommandError(e)
-        for m in mode.split(','):
+        for m in modenames:
             self.changed.emit(m)
             self._mark_config_dirty()
 
@@ -331,8 +332,13 @@ def _mark_config_dirty(self):
         self.is_dirty = True
         self.config_dirty.emit()
 
-    def _validate_command(self, line):
-        """Check if a given command is valid."""
+    def _validate_command(self, line, modes=None):
+        """Check if a given command is valid.
+
+        Args:
+            line: The commandline to validate.
+            modes: A list of modes to validate the commands for, or None.
+        """
         from qutebrowser.config import config
         if line == self.UNBOUND_COMMAND:
             return
@@ -352,8 +358,15 @@ def _validate_command(self, line):
         commands = [c.split(maxsplit=1)[0].strip() for c in commands]
         for cmd in commands:
             aliases = config.section('aliases')
-            if cmd not in cmdutils.cmd_dict and cmd not in aliases:
+            if cmd in cmdutils.cmd_dict:
+                cmdname = cmd
+            elif cmd in aliases:
+                cmdname = aliases[cmd].split(maxsplit=1)[0].strip()
+            else:
                 raise KeyConfigError("Invalid command '{}'!".format(cmd))
+            cmd_obj = cmdutils.cmd_dict[cmdname]
+            for m in modes or []:
+                cmd_obj.validate_mode(m)
 
     def _read_command(self, line):
         """Read a command from a line."""


Test patch:
diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature
--- a/tests/end2end/features/hints.feature
+++ b/tests/end2end/features/hints.feature
@@ -2,7 +2,7 @@ Feature: Using hints
 
     Scenario: Using :follow-hint outside of hint mode (issue 1105)
         When I run :follow-hint
-        Then the error "follow-hint: This command is only allowed in hint mode." should be shown
+        Then the error "follow-hint: This command is only allowed in hint mode, not normal." should be shown
 
     Scenario: Using :follow-hint with an invalid index.
         When I open data/hints/html/simple.html
diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature
--- a/tests/end2end/features/keyinput.feature
+++ b/tests/end2end/features/keyinput.feature
@@ -73,6 +73,10 @@ Feature: Keyboard input
         And I run :bind <test26>
         Then the message "<test26> is bound to 'mib' in normal mode" should be shown
 
+    Scenario: Binding with an unsupported mode
+        When I run :bind --mode=caret test27 rl-unix-filename-rubout
+        Then the error "rl-unix-filename-rubout: This command is only allowed in command/prompt mode, not caret." should be shown
+
     # :unbind
 
     Scenario: Binding and unbinding a keychain
diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature
--- a/tests/end2end/features/misc.feature
+++ b/tests/end2end/features/misc.feature
@@ -589,7 +589,7 @@ Feature: Various utility commands.
         And I run :repeat-command with count 2
         And I wait until the scroll position changed to 0/0
         Then the page should not be scrolled
-        And the error "prompt-accept: This command is only allowed in prompt/yesno mode." should be shown
+        And the error "prompt-accept: This command is only allowed in prompt/yesno mode, not normal." should be shown
 
     @qtwebengine_createWindow
     Scenario: :repeat-command with mode-switching command
diff --git a/tests/end2end/test_insert_mode.py b/tests/end2end/test_insert_mode.py
--- a/tests/end2end/test_insert_mode.py
+++ b/tests/end2end/test_insert_mode.py
@@ -93,7 +93,7 @@ def test_auto_leave_insert_mode(quteproc):
     quteproc.send_cmd(':paste-primary')
 
     expected_message = ('paste-primary: This command is only allowed in '
-                        'insert mode.')
+                        'insert mode, not caret.')
     quteproc.mark_expected(category='message',
                            loglevel=logging.ERROR,
                            message=expected_message)