Merge remote-tracking branch 'origin/pr/5967' into dev

This commit is contained in:
Florian Bruhin 2021-03-03 18:19:42 +01:00
commit aefcb31da5
8 changed files with 324 additions and 225 deletions

View File

@ -0,0 +1,210 @@
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
# Copyright 2021 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
#
# This file is part of qutebrowser.
#
# qutebrowser 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.
#
# qutebrowser 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 qutebrowser. If not, see <http://www.gnu.org/licenses/>.
"""Module for parsing commands entered into the browser."""
import attr
from qutebrowser.commands import cmdexc, command
from qutebrowser.misc import split, objects
@dataclasses.dataclass
class ParseResult:
"""The result of parsing a commandline."""
cmd: Optional[command.Command]
args: Optional[List[str]]
cmdline: List[str]
class CommandParser:
"""Parse qutebrowser commandline commands.
Attributes:
_partial_match: Whether to allow partial command matches.
"""
def __init__(self, partial_match=False):
self._partial_match = partial_match
def _get_alias(self, text, aliases, default=None):
"""Get an alias from the config.
Args:
text: The text to parse.
aliases: A map of aliases to commands.
default : Default value to return when alias was not found.
Return:
The new command string if an alias was found. Default value
otherwise.
"""
parts = text.strip().split(maxsplit=1)
if parts[0] not in aliases:
return default
alias = aliases[parts[0]]
try:
new_cmd = '{} {}'.format(alias, parts[1])
except IndexError:
new_cmd = alias
if text.endswith(' '):
new_cmd += ' '
return new_cmd
def _parse_all_gen(self, text, *args, aliases=None, **kwargs):
"""Split a command on ;; and parse all parts.
If the first command in the commandline is a non-split one, it only
returns that.
Args:
text: Text to parse.
aliases: A map of aliases to commands.
*args/**kwargs: Passed to parse().
Yields:
ParseResult tuples.
"""
text = text.strip().lstrip(':').strip()
if not text:
raise cmdexc.NoSuchCommandError("No command given")
if aliases:
text = self._get_alias(text, aliases, text)
if ';;' in text:
# Get the first command and check if it doesn't want to have ;;
# split.
first = text.split(';;')[0]
result = self.parse(first, *args, **kwargs)
if result.cmd.no_cmd_split:
sub_texts = [text]
else:
sub_texts = [e.strip() for e in text.split(';;')]
else:
sub_texts = [text]
for sub in sub_texts:
yield self.parse(sub, *args, **kwargs)
def parse_all(self, *args, **kwargs):
"""Wrapper over _parse_all_gen."""
return list(self._parse_all_gen(*args, **kwargs))
def parse(self, text, *, fallback=False, keep=False, best_match=False):
"""Split the commandline text into command and arguments.
Args:
text: Text to parse.
fallback: Whether to do a fallback splitting when the command was
unknown.
keep: Whether to keep special chars and whitespace
Return:
A ParseResult tuple.
"""
cmdstr, sep, argstr = text.partition(' ')
if not cmdstr and not fallback:
raise cmdexc.NoSuchCommandError("No command given")
if self._partial_match:
cmdstr = self._completion_match(cmdstr, best_match)
try:
cmd = objects.commands[cmdstr]
except KeyError:
if not fallback:
raise cmdexc.NoSuchCommandError(
'{}: no such command'.format(cmdstr))
cmdline = split.split(text, keep=keep)
return ParseResult(cmd=None, args=None, cmdline=cmdline)
args = self._split_args(cmd, argstr, keep)
if keep and args:
cmdline = [cmdstr, sep + args[0]] + args[1:]
elif keep:
cmdline = [cmdstr, sep]
else:
cmdline = [cmdstr] + args[:]
return ParseResult(cmd=cmd, args=args, cmdline=cmdline)
def _completion_match(self, cmdstr, best):
"""Replace cmdstr with a matching completion if there's only one match.
Args:
cmdstr: The string representing the entered command so far
Return:
cmdstr modified to the matching completion or unmodified
"""
matches = [cmd for cmd in sorted(objects.commands, key=len)
if cmdstr in cmd]
if len(matches) == 1:
cmdstr = matches[0]
elif len(matches) > 1 and best:
cmdstr = matches[0]
return cmdstr
def _split_args(self, cmd, argstr, keep):
"""Split the arguments from an arg string.
Args:
cmd: The command we're currently handling.
argstr: An argument string.
keep: Whether to keep special chars and whitespace
Return:
A list containing the split strings.
"""
if not argstr:
return []
elif cmd.maxsplit is None:
return split.split(argstr, keep=keep)
else:
# If split=False, we still want to split the flags, but not
# everything after that.
# We first split the arg string and check the index of the first
# non-flag args, then we re-split again properly.
# example:
#
# input: "--foo -v bar baz"
# first split: ['--foo', '-v', 'bar', 'baz']
# 0 1 2 3
# second split: ['--foo', '-v', 'bar baz']
# (maxsplit=2)
split_args = split.simple_split(argstr, keep=keep)
flag_arg_count = 0
for i, arg in enumerate(split_args):
arg = arg.strip()
if arg.startswith('-'):
if arg in cmd.flags_with_args:
flag_arg_count += 1
else:
maxsplit = i + cmd.maxsplit + flag_arg_count
return split.simple_split(argstr, keep=keep,
maxsplit=maxsplit)
# If there are only flags, we got it right on the first try
# already.
return split_args

View File

@ -30,9 +30,8 @@ from PyQt5.QtCore import pyqtSlot, QUrl, QObject
from qutebrowser.api import cmdutils
from qutebrowser.config import config
from qutebrowser.commands import cmdexc, command
from qutebrowser.commands import cmdexc, parser
from qutebrowser.utils import message, objreg, qtutils, usertypes, utils
from qutebrowser.misc import split, objects
from qutebrowser.keyinput import macros, modeman
if TYPE_CHECKING:
@ -43,16 +42,6 @@ _ReplacementFunction = Callable[['tabbedbrowser.TabbedBrowser'], str]
last_command = {}
@dataclasses.dataclass
class ParseResult:
"""The result of parsing a commandline."""
cmd: Optional[command.Command]
args: Optional[List[str]]
cmdline: List[str]
def _url(tabbed_browser):
"""Convenience method to get the current url."""
try:
@ -130,181 +119,6 @@ def replace_variables(win_id, arglist):
return args
class CommandParser:
"""Parse qutebrowser commandline commands.
Attributes:
_partial_match: Whether to allow partial command matches.
"""
def __init__(self, partial_match=False):
self._partial_match = partial_match
def _get_alias(self, text, default=None):
"""Get an alias from the config.
Args:
text: The text to parse.
default : Default value to return when alias was not found.
Return:
The new command string if an alias was found. Default value
otherwise.
"""
parts = text.strip().split(maxsplit=1)
aliases = config.cache['aliases']
if parts[0] not in aliases:
return default
alias = aliases[parts[0]]
try:
new_cmd = '{} {}'.format(alias, parts[1])
except IndexError:
new_cmd = alias
if text.endswith(' '):
new_cmd += ' '
return new_cmd
def _parse_all_gen(self, text, *args, aliases=True, **kwargs):
"""Split a command on ;; and parse all parts.
If the first command in the commandline is a non-split one, it only
returns that.
Args:
text: Text to parse.
aliases: Whether to handle aliases.
*args/**kwargs: Passed to parse().
Yields:
ParseResult tuples.
"""
text = text.strip().lstrip(':').strip()
if not text:
raise cmdexc.NoSuchCommandError("No command given")
if aliases:
text = self._get_alias(text, text)
if ';;' in text:
# Get the first command and check if it doesn't want to have ;;
# split.
first = text.split(';;')[0]
result = self.parse(first, *args, **kwargs)
if result.cmd.no_cmd_split:
sub_texts = [text]
else:
sub_texts = [e.strip() for e in text.split(';;')]
else:
sub_texts = [text]
for sub in sub_texts:
yield self.parse(sub, *args, **kwargs)
def parse_all(self, *args, **kwargs):
"""Wrapper over _parse_all_gen."""
return list(self._parse_all_gen(*args, **kwargs))
def parse(self, text, *, fallback=False, keep=False):
"""Split the commandline text into command and arguments.
Args:
text: Text to parse.
fallback: Whether to do a fallback splitting when the command was
unknown.
keep: Whether to keep special chars and whitespace
Return:
A ParseResult tuple.
"""
cmdstr, sep, argstr = text.partition(' ')
if not cmdstr and not fallback:
raise cmdexc.NoSuchCommandError("No command given")
if self._partial_match:
cmdstr = self._completion_match(cmdstr)
try:
cmd = objects.commands[cmdstr]
except KeyError:
if not fallback:
raise cmdexc.NoSuchCommandError(
'{}: no such command'.format(cmdstr))
cmdline = split.split(text, keep=keep)
return ParseResult(cmd=None, args=None, cmdline=cmdline)
args = self._split_args(cmd, argstr, keep)
if keep and args:
cmdline = [cmdstr, sep + args[0]] + args[1:]
elif keep:
cmdline = [cmdstr, sep]
else:
cmdline = [cmdstr] + args[:]
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.
Args:
cmdstr: The string representing the entered command so far
Return:
cmdstr modified to the matching completion or unmodified
"""
matches = [cmd for cmd in sorted(objects.commands, key=len)
if cmdstr in cmd]
if len(matches) == 1:
cmdstr = matches[0]
elif len(matches) > 1 and config.val.completion.use_best_match:
cmdstr = matches[0]
return cmdstr
def _split_args(self, cmd, argstr, keep):
"""Split the arguments from an arg string.
Args:
cmd: The command we're currently handling.
argstr: An argument string.
keep: Whether to keep special chars and whitespace
Return:
A list containing the split strings.
"""
if not argstr:
return []
elif cmd.maxsplit is None:
return split.split(argstr, keep=keep)
else:
# If split=False, we still want to split the flags, but not
# everything after that.
# We first split the arg string and check the index of the first
# non-flag args, then we re-split again properly.
# example:
#
# input: "--foo -v bar baz"
# first split: ['--foo', '-v', 'bar', 'baz']
# 0 1 2 3
# second split: ['--foo', '-v', 'bar baz']
# (maxsplit=2)
split_args = split.simple_split(argstr, keep=keep)
flag_arg_count = 0
for i, arg in enumerate(split_args):
arg = arg.strip()
if arg.startswith('-'):
if arg in cmd.flags_with_args:
flag_arg_count += 1
else:
maxsplit = i + cmd.maxsplit + flag_arg_count
return split.simple_split(argstr, keep=keep,
maxsplit=maxsplit)
# If there are only flags, we got it right on the first try
# already.
return split_args
class AbstractCommandRunner(QObject):
"""Abstract base class for CommandRunner."""
@ -329,7 +143,7 @@ class CommandRunner(AbstractCommandRunner):
def __init__(self, win_id, partial_match=False, parent=None):
super().__init__(parent)
self._parser = CommandParser(partial_match=partial_match)
self._parser = parser.CommandParser(partial_match=partial_match)
self._win_id = win_id
@contextlib.contextmanager
@ -359,7 +173,8 @@ class CommandRunner(AbstractCommandRunner):
parsed = None
with self._handle_error(safely):
parsed = self._parser.parse_all(text)
parsed = self._parser.parse_all(
text, best_match=config.val.completion.use_best_match)
if parsed is None:
return

View File

@ -25,7 +25,7 @@ from typing import TYPE_CHECKING
from PyQt5.QtCore import pyqtSlot, QObject, QTimer
from qutebrowser.config import config
from qutebrowser.commands import runners
from qutebrowser.commands import parser
from qutebrowser.misc import objects
from qutebrowser.utils import log, utils, debug, objreg
from qutebrowser.completion.models import miscmodels
@ -139,8 +139,7 @@ class Completer(QObject):
if not text or not text.strip():
# Only ":", empty part under the cursor with nothing before/after
return [], '', []
parser = runners.CommandParser()
result = parser.parse(text, fallback=True, keep=True)
result = parser.CommandParser().parse(text, fallback=True, keep=True)
parts = [x for x in result.cmdline if x]
pos = self._cmd.cursorPosition() - len(self._cmd.prefix())
pos = min(pos, len(text)) # Qt treats 2-byte UTF-16 chars as 2 chars

View File

@ -21,7 +21,7 @@
from qutebrowser.config import configdata, configexc
from qutebrowser.completion.models import completionmodel, listcategory, util
from qutebrowser.commands import runners, cmdexc
from qutebrowser.commands import parser, cmdexc
from qutebrowser.keyinput import keyutils
@ -117,9 +117,8 @@ def _bind_current_default(key, info):
cmd_text = info.keyconf.get_command(seq, 'normal')
if cmd_text:
parser = runners.CommandParser()
try:
cmd = parser.parse(cmd_text).cmd
cmd = parser.CommandParser().parse(cmd_text).cmd
except cmdexc.NoSuchCommandError:
data.append((cmd_text, '(Current) Invalid command!', key))
else:
@ -127,8 +126,7 @@ def _bind_current_default(key, info):
cmd_text = info.keyconf.get_command(seq, 'normal', default=True)
if cmd_text:
parser = runners.CommandParser()
cmd = parser.parse(cmd_text).cmd
cmd = parser.CommandParser().parse(cmd_text).cmd
data.append((cmd_text, '(Default) {}'.format(cmd.desc), key))
return data

View File

@ -27,6 +27,7 @@ from typing import (TYPE_CHECKING, Any, Callable, Dict, Iterator, List, Mapping,
from PyQt5.QtCore import pyqtSignal, QObject, QUrl
from qutebrowser.commands import cmdexc, parser
from qutebrowser.config import configdata, configexc, configutils
from qutebrowser.utils import utils, log, urlmatch
from qutebrowser.misc import objects
@ -162,13 +163,39 @@ class KeyConfig:
bindings[key] = binding
return bindings
def _implied_cmd(self, cmdline: str) -> Optional[str]:
"""Return cmdline, or the implied cmd if cmdline is a set-cmd-text."""
try:
results = parser.CommandParser().parse_all(
cmdline, aliases=cache['aliases'])
except cmdexc.NoSuchCommandError:
return None
result = results[0]
if result.cmd.name != "set-cmd-text":
return cmdline
*flags, cmd = result.args
if "-a" in flags or "--append" in flags or not cmd.startswith(":"):
return None # doesn't look like this sets a command
return cmd.lstrip(":")
def get_reverse_bindings_for(self, mode: str) -> '_ReverseBindings':
"""Get a dict of commands to a list of bindings for the mode."""
"""Get a dict of commands to a list of bindings for the mode.
This is intented for user-facing display of keybindings.
As such, bindings for 'set-cmd-text [flags] :<cmd> ...' are translated
to '<cmd> ...', as from the user's perspective these keys behave like
bindings for '<cmd>' (that allow for further input before running).
See #5942.
"""
cmd_to_keys: KeyConfig._ReverseBindings = {}
bindings = self.get_bindings_for(mode)
for seq, full_cmd in sorted(bindings.items()):
for cmd in full_cmd.split(';;'):
cmd = cmd.strip()
for cmdtext in full_cmd.split(';;'):
cmd = self._implied_cmd(cmdtext.strip())
if not cmd:
continue
cmd_to_keys.setdefault(cmd, [])
# Put bindings involving modifiers last
if any(info.modifiers for info in seq):

View File

@ -86,6 +86,8 @@ class NormalKeyParser(CommandKeyParser):
_partial_timer: Timer to clear partial keypresses.
"""
_sequence: keyutils.KeySequence
def __init__(self, *, win_id: int,
commandrunner: 'runners.CommandRunner',
parent: QObject = None) -> None:
@ -154,6 +156,8 @@ class HintKeyParser(basekeyparser.BaseKeyParser):
_last_press: The nature of the last keypress, a LastPress member.
"""
_sequence: keyutils.KeySequence
def __init__(self, *, win_id: int,
commandrunner: 'runners.CommandRunner',
hintmanager: hints.HintManager,

View File

@ -17,12 +17,12 @@
# You should have received a copy of the GNU General Public License
# along with qutebrowser. If not, see <https://www.gnu.org/licenses/>.
"""Tests for qutebrowser.commands.runners."""
"""Tests for qutebrowser.commands.parser."""
import pytest
from qutebrowser.misc import objects
from qutebrowser.commands import runners, cmdexc
from qutebrowser.commands import parser, cmdexc
class TestCommandParser:
@ -35,26 +35,26 @@ class TestCommandParser:
Args:
cmdline_test: A pytest fixture which provides testcases.
"""
parser = runners.CommandParser()
p = parser.CommandParser()
if cmdline_test.valid:
parser.parse_all(cmdline_test.cmd, aliases=False)
p.parse_all(cmdline_test.cmd)
else:
with pytest.raises(cmdexc.NoSuchCommandError):
parser.parse_all(cmdline_test.cmd, aliases=False)
p.parse_all(cmdline_test.cmd)
def test_parse_all_with_alias(self, cmdline_test, monkeypatch,
config_stub):
if not cmdline_test.cmd:
pytest.skip("Empty command")
config_stub.val.aliases = {'alias_name': cmdline_test.cmd}
aliases = {'alias_name': cmdline_test.cmd}
parser = runners.CommandParser()
p = parser.CommandParser()
if cmdline_test.valid:
assert len(parser.parse_all("alias_name")) > 0
assert len(p.parse_all("alias_name", aliases=aliases)) > 0
else:
with pytest.raises(cmdexc.NoSuchCommandError):
parser.parse_all("alias_name")
p.parse_all("alias_name", aliases=aliases)
@pytest.mark.parametrize('command', ['', ' '])
def test_parse_empty_with_alias(self, command):
@ -63,9 +63,33 @@ class TestCommandParser:
See https://github.com/qutebrowser/qutebrowser/issues/1690
and https://github.com/qutebrowser/qutebrowser/issues/1773
"""
parser = runners.CommandParser()
p = parser.CommandParser()
with pytest.raises(cmdexc.NoSuchCommandError):
parser.parse_all(command)
p.parse_all(command, aliases={"foo": "bar"})
@pytest.mark.parametrize('command, name, args', [
("set-cmd-text -s :open", "set-cmd-text", ["-s", ":open"]),
("set-cmd-text :open {url:pretty}", "set-cmd-text",
[":open {url:pretty}"]),
("set-cmd-text -s :open -t", "set-cmd-text", ["-s", ":open -t"]),
("set-cmd-text :open -t -r {url:pretty}", "set-cmd-text",
[":open -t -r {url:pretty}"]),
("set-cmd-text -s :open -b", "set-cmd-text", ["-s", ":open -b"]),
("set-cmd-text :open -b -r {url:pretty}", "set-cmd-text",
[":open -b -r {url:pretty}"]),
("set-cmd-text -s :open -w", "set-cmd-text",
["-s", ":open -w"]),
("set-cmd-text :open -w {url:pretty}", "set-cmd-text",
[":open -w {url:pretty}"]),
("set-cmd-text /", "set-cmd-text", ["/"]),
("set-cmd-text ?", "set-cmd-text", ["?"]),
("set-cmd-text :", "set-cmd-text", [":"]),
])
def test_parse_result(self, command, name, args):
p = parser.CommandParser()
result = p.parse_all(command)[0]
assert result.cmd.name == name
assert result.args == args
class TestCompletions:
@ -86,8 +110,8 @@ class TestCompletions:
The same with it being disabled is tested by test_parse_all.
"""
parser = runners.CommandParser(partial_match=True)
result = parser.parse('on')
p = parser.CommandParser(partial_match=True)
result = p.parse('on')
assert result.cmd.name == 'one'
def test_dont_use_best_match(self, config_stub):
@ -96,10 +120,10 @@ class TestCompletions:
Should raise NoSuchCommandError
"""
config_stub.val.completion.use_best_match = False
parser = runners.CommandParser(partial_match=True)
p = parser.CommandParser(partial_match=True)
with pytest.raises(cmdexc.NoSuchCommandError):
parser.parse('tw')
p.parse('tw')
def test_use_best_match(self, config_stub):
"""Test multiple completion options with use_best_match set to true.
@ -107,7 +131,7 @@ class TestCompletions:
The resulting command should be the best match
"""
config_stub.val.completion.use_best_match = True
parser = runners.CommandParser(partial_match=True)
p = parser.CommandParser(partial_match=True)
result = parser.parse('tw')
result = p.parse('tw', best_match=True)
assert result.cmd.name == 'two'

View File

@ -187,17 +187,39 @@ class TestKeyConfig:
@pytest.mark.parametrize('bindings, expected', [
# Simple
({'a': 'message-info foo', 'b': 'message-info bar'},
{'message-info foo': ['a'], 'message-info bar': ['b']}),
({'a': 'open foo', 'b': 'open bar'},
{'open foo': ['a'], 'open bar': ['b']}),
# Multiple bindings
({'a': 'message-info foo', 'b': 'message-info foo'},
{'message-info foo': ['b', 'a']}),
({'a': 'open foo', 'b': 'open foo'},
{'open foo': ['b', 'a']}),
# With modifier keys (should be listed last and normalized)
({'a': 'message-info foo', '<ctrl-a>': 'message-info foo'},
{'message-info foo': ['a', '<Ctrl+a>']}),
({'a': 'open foo', '<ctrl-a>': 'open foo'},
{'open foo': ['a', '<Ctrl+a>']}),
# Chained command
({'a': 'message-info foo ;; message-info bar'},
{'message-info foo': ['a'], 'message-info bar': ['a']}),
({'a': 'open foo ;; open bar'},
{'open foo': ['a'], 'open bar': ['a']}),
# Command using set-cmd-text (#5942)
(
{
"o": "set-cmd-text -s :open",
"O": "set-cmd-text -s :open -t",
"go": "set-cmd-text :open {url:pretty}",
# all of these should be ignored
"/": "set-cmd-text /",
"?": "set-cmd-text ?",
":": "set-cmd-text :",
"a": "set-cmd-text no_leading_colon",
"b": "set-cmd-text -s -a :skip_cuz_append",
"c": "set-cmd-text --append :skip_cuz_append",
},
{
"open": ["o"],
"open -t": ["O"],
"open {url:pretty}": ["go"],
}
),
# Empty/unknown commands
({"a": "", "b": "notreal"}, {}),
])
def test_get_reverse_bindings_for(self, key_config_stub, config_stub,
no_bindings, bindings, expected):