Repository: qutebrowser/qutebrowser

Issue:
Qutebrowser crashes when pasting commands 
<!-- If this is a bug report, please remember to mention your version info from
the `qute:version` page or `qutebrowser --version` -->

Steps to reproduce : 
1. Copy the following text into your clipboard : `:bind`
2. Open the command prompt with `:`
3. Paste `:bind` into the command prompt. 

Qutebrowser crashes.
# qute:version :

```
Version info

qutebrowser v0.8.1
Git commit: v0.8.0-1332-g5ce1f0b-dirty (2016-09-29 08:48:04 +0200)

CPython: 3.5.1
Qt: 5.6.1
PyQt: 5.6

sip: 4.18
colorama: 0.3.7
pypeg2: 2.15
jinja2: 2.8
pygments: 2.1.3
yaml: 3.11
cssutils: 1.0.1 $Id$
typing: yes
PyQt5.QtWebEngineWidgets: yes
pdf.js: no
Webkit: 538.1
SSL: OpenSSL 1.0.2j-fips  26 Sep 2016

Style: QFusionStyle
Platform: Linux-4.7.4-200.fc24.x86_64-x86_64-with-fedora-24-Twenty_Four, 64bit
Frozen: False
Imported from /home/edgar/Programs/qutebrowser/qutebrowser
OS Version: 

--- /etc/redhat-release ---
Fedora release 24 (Twenty Four)

--- /etc/fedora-release ---
Fedora release 24 (Twenty Four)

--- /etc/system-release ---
Fedora release 24 (Twenty Four)

--- /etc/os-release ---
NAME=Fedora
VERSION="24 (Workstation Edition)"
ID=fedora
VERSION_ID=24
PRETTY_NAME="Fedora 24 (Workstation Edition)"
CPE_NAME="cpe:/o:fedoraproject:fedora:24"
REDHAT_BUGZILLA_PRODUCT="Fedora"
REDHAT_BUGZILLA_PRODUCT_VERSION=24
REDHAT_SUPPORT_PRODUCT="Fedora"
REDHAT_SUPPORT_PRODUCT_VERSION=24
PRIVACY_POLICY_URL=https://fedoraproject.org/wiki/Legal:PrivacyPolicy
VARIANT="Workstation Edition"
VARIANT_ID=workstation

Paths:
download: /home/edgar/Downloads
runtime: /run/user/1000/qutebrowser
config: /home/edgar/.config/qutebrowser
system_data: /home/edgar/.local/share/qutebrowser
data: /home/edgar/.local/share/qutebrowser
cache: /home/edgar/.cache/qutebrowser
Copyright info

Copyright 2014-2016 Florian Bruhin (The Compiler)

This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.

This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.

You should have received a copy of the GNU General Public License along with this program. If not, see http://www.gnu.org/licenses/ or open qute://gpl.
```



Discussion:
Entering `::bind` also triggers it. The exception looks the same as in #1969:

```
Traceback (most recent call last):
  File "/home/florian/proj/qutebrowser/git/qutebrowser/completion/completer.py", line 245, in _update_completion
    before_cursor, pattern, after_cursor = self._partition()
TypeError: 'NoneType' object is not iterable
```

so I'll let @rcorre take a look :wink:

Argh, this is because `CommandRunner.parse` behaves differently for known vs unknown commands, so `::bin` is fine but as soon as it sees `::bind` it strips an extra `:`.

Sorry if you just got some double-post email notifications. I was messing with my user-agent and it causes the Github UI not to respond >.<


Resolving patch:
diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py
--- a/qutebrowser/commands/runners.py
+++ b/qutebrowser/commands/runners.py
@@ -31,8 +31,7 @@
 from qutebrowser.misc import split
 
 
-ParseResult = collections.namedtuple('ParseResult', ['cmd', 'args', 'cmdline',
-                                                     'count'])
+ParseResult = collections.namedtuple('ParseResult', ['cmd', 'args', 'cmdline'])
 last_command = {}
 
 
@@ -154,26 +153,6 @@ def parse_all(self, text, aliases=True, *args, **kwargs):
         for sub in sub_texts:
             yield self.parse(sub, *args, **kwargs)
 
-    def _parse_count(self, cmdstr):
-        """Split a count prefix off from a command for parse().
-
-        Args:
-            cmdstr: The command/args including the count.
-
-        Return:
-            A (count, cmdstr) tuple, with count being None or int.
-        """
-        if ':' not in cmdstr:
-            return (None, cmdstr)
-
-        count, cmdstr = cmdstr.split(':', maxsplit=1)
-        try:
-            count = int(count)
-        except ValueError:
-            # We just ignore invalid prefixes
-            count = None
-        return (count, cmdstr)
-
     def parse(self, text, *, fallback=False, keep=False):
         """Split the commandline text into command and arguments.
 
@@ -187,7 +166,6 @@ def parse(self, text, *, fallback=False, keep=False):
             A ParseResult tuple.
         """
         cmdstr, sep, argstr = text.partition(' ')
-        count, cmdstr = self._parse_count(cmdstr)
 
         if not cmdstr and not fallback:
             raise cmdexc.NoSuchCommandError("No command given")
@@ -202,8 +180,7 @@ def parse(self, text, *, fallback=False, keep=False):
                 raise cmdexc.NoSuchCommandError(
                     '{}: no such command'.format(cmdstr))
             cmdline = split.split(text, keep=keep)
-            return ParseResult(cmd=None, args=None, cmdline=cmdline,
-                               count=count)
+            return ParseResult(cmd=None, args=None, cmdline=cmdline)
 
         args = self._split_args(cmd, argstr, keep)
         if keep and args:
@@ -213,7 +190,7 @@ def parse(self, text, *, fallback=False, keep=False):
         else:
             cmdline = [cmdstr] + args[:]
 
-        return ParseResult(cmd=cmd, args=args, cmdline=cmdline, count=count)
+        return ParseResult(cmd=cmd, args=args, cmdline=cmdline)
 
     def _completion_match(self, cmdstr):
         """Replace cmdstr with a matching completion if there's only one match.
@@ -291,20 +268,10 @@ def run(self, text, count=None):
                 args = result.args
             else:
                 args = replace_variables(self._win_id, result.args)
-            if count is not None:
-                if result.count is not None:
-                    raise cmdexc.CommandMetaError("Got count via command and "
-                                                  "prefix!")
-                result.cmd.run(self._win_id, args, count=count)
-            elif result.count is not None:
-                result.cmd.run(self._win_id, args, count=result.count)
-            else:
-                result.cmd.run(self._win_id, args)
+            result.cmd.run(self._win_id, args, count=count)
 
             if result.cmdline[0] != 'repeat-command':
-                last_command[cur_mode] = (
-                    self._parse_count(text)[1],
-                    count if count is not None else result.count)
+                last_command[cur_mode] = (text, count)
 
     @pyqtSlot(str, int)
     @pyqtSlot(str)
diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py
--- a/qutebrowser/misc/utilcmds.py
+++ b/qutebrowser/misc/utilcmds.py
@@ -86,6 +86,23 @@ def repeat(times: int, command, win_id):
         commandrunner.run_safely(command)
 
 
+@cmdutils.register(maxsplit=1, hide=True, no_cmd_split=True,
+                   no_replace_variables=True)
+@cmdutils.argument('win_id', win_id=True)
+@cmdutils.argument('count', count=True)
+def run_with_count(count_arg: int, command, win_id, count=1):
+    """Run a command with the given count.
+
+    If run_with_count itself is run with a count, it multiplies count_arg.
+
+    Args:
+        count_arg: The count to pass to the command.
+        command: The command to run, with optional args.
+        count: The count that run_with_count itself received.
+    """
+    runners.CommandRunner(win_id).run(command, count_arg * count)
+
+
 @cmdutils.register(hide=True)
 def message_error(text):
     """Show an error message in the statusbar.


Test patch:
diff --git a/tests/end2end/features/caret.feature b/tests/end2end/features/caret.feature
--- a/tests/end2end/features/caret.feature
+++ b/tests/end2end/features/caret.feature
@@ -3,7 +3,7 @@ Feature: Caret mode
 
     Background:
         Given I open data/caret.html
-        And I run :tab-only ;; :enter-mode caret
+        And I run :tab-only ;; enter-mode caret
 
     # document
 
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
@@ -499,7 +499,7 @@ Feature: Various utility commands.
     Scenario: Using :debug-log-capacity
         When I run :debug-log-capacity 100
         And I run :message-info oldstuff
-        And I run :repeat 20 :message-info otherstuff
+        And I run :repeat 20 message-info otherstuff
         And I run :message-info newstuff
         And I open qute:log
         Then the page should contain the plaintext "newstuff"
@@ -723,3 +723,13 @@ Feature: Various utility commands.
         And I run :command-accept
         And I set general -> private-browsing to false
         Then the message "blah" should be shown
+
+    ## :run-with-count
+
+    Scenario: :run-with-count
+        When I run :run-with-count 2 scroll down
+        Then "command called: scroll ['down'] (count=2)" should be logged
+
+    Scenario: :run-with-count with count
+        When I run :run-with-count 2 scroll down with count 3
+        Then "command called: scroll ['down'] (count=6)" should be logged
diff --git a/tests/end2end/features/tabs.feature b/tests/end2end/features/tabs.feature
--- a/tests/end2end/features/tabs.feature
+++ b/tests/end2end/features/tabs.feature
@@ -987,13 +987,13 @@ Feature: Tab management
     Scenario: Using :tab-next after closing last tab (#1448)
         When I set tabs -> last-close to close
         And I run :tab-only
-        And I run :tab-close ;; :tab-next
+        And I run :tab-close ;; tab-next
         Then qutebrowser should quit
         And no crash should happen
 
     Scenario: Using :tab-prev after closing last tab (#1448)
         When I set tabs -> last-close to close
         And I run :tab-only
-        And I run :tab-close ;; :tab-prev
+        And I run :tab-close ;; tab-prev
         Then qutebrowser should quit
         And no crash should happen
diff --git a/tests/end2end/fixtures/quteprocess.py b/tests/end2end/fixtures/quteprocess.py
--- a/tests/end2end/fixtures/quteprocess.py
+++ b/tests/end2end/fixtures/quteprocess.py
@@ -437,7 +437,8 @@ def send_cmd(self, command, count=None, invalid=False, *, escape=True):
             command = command.replace('\\', r'\\')
 
         if count is not None:
-            command = ':{}:{}'.format(count, command.lstrip(':'))
+            command = ':run-with-count {} {}'.format(count,
+                                                     command.lstrip(':'))
 
         self.send_ipc([command])
         if not invalid:
diff --git a/tests/unit/commands/test_runners.py b/tests/unit/commands/test_runners.py
--- a/tests/unit/commands/test_runners.py
+++ b/tests/unit/commands/test_runners.py
@@ -64,15 +64,6 @@ def test_parse_empty_with_alias(self, command):
         with pytest.raises(cmdexc.NoSuchCommandError):
             list(cr.parse_all(command))
 
-    def test_parse_with_count(self):
-        """Test parsing of commands with a count."""
-        cr = runners.CommandRunner(0)
-        result = cr.parse('20:scroll down')
-        assert result.cmd.name == 'scroll'
-        assert result.count == 20
-        assert result.args == ['down']
-        assert result.cmdline == ['scroll', 'down']
-
     def test_partial_parsing(self):
         """Test partial parsing with a runner where it's enabled.
 
diff --git a/tests/unit/completion/test_completer.py b/tests/unit/completion/test_completer.py
--- a/tests/unit/completion/test_completer.py
+++ b/tests/unit/completion/test_completer.py
@@ -181,6 +181,7 @@ def _set_cmd_prompt(cmd, txt):
     (':open -- |', None, ''),
     (':gibberish nonesense |', None, ''),
     ('/:help|', None, ''),
+    ('::bind|', usertypes.Completion.command, ':bind'),
 ])
 def test_update_completion(txt, kind, pattern, status_command_stub,
                            completer_obj, completion_widget_stub):