diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 58df2bfd8..15d648c70 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -471,8 +471,9 @@ class CommandDispatcher: background: Open the link in a new background tab. window: Open the link in a new window. """ + segments = config.get('general', 'url-incdec-segments') try: - new_url = urlutils.incdec_number(url, incdec) + new_url = urlutils.incdec_number(url, incdec, segments=segments) except urlutils.IncDecError as error: raise cmdexc.CommandError(error.msg) self._open(new_url, tab, background, window) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 5f04ed9f9..06e6c23c0 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -225,6 +225,11 @@ def data(readonly=False): "The name of the session to save by default, or empty for the " "last loaded session."), + ('url-incdec-segments', + SettingValue(typ.URLSegmentList(none_ok=True), 'path,query'), + "The URL segments where `:navigate increment/decrement` will " + "search for a number."), + readonly=readonly )), diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 75e05360c..e600b82d3 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -1578,3 +1578,37 @@ class TabBarShow(BaseType): "is open."), ('switching', "Show the tab bar when switching " "tabs.")) + + +class URLSegmentList(List): + + """A list of URL segments.""" + + valid_values = ValidValues('host', 'path', 'query', 'anchor') + + def transform(self, value): + values = super().transform(value) + if values is None: + return set() + return set(values) + + def validate(self, value): + self._basic_validation(value) + segments = super().transform(value) + if segments is None: + # Basic validation already checked if none_ok is True + return + faulty_segments = set(segments) - set(self.valid_values.values) + # faulty_segments is a set of options that are given but not valid + if None in faulty_segments: + raise configexc.ValidationError(value, + "Empty list item is not allowed") + if faulty_segments: + error_str = ("List contains invalid segments: {}" + .format(', '.join(faulty_segments))) + raise configexc.ValidationError(value, error_str) + + # Make sure there are no duplicates + if len(set(segments)) != len(segments): + raise configexc.ValidationError(value, + "List contains duplicate segments") diff --git a/qutebrowser/utils/urlutils.py b/qutebrowser/utils/urlutils.py index 16acbb9aa..e372dc65f 100644 --- a/qutebrowser/utils/urlutils.py +++ b/qutebrowser/utils/urlutils.py @@ -456,12 +456,15 @@ class IncDecError(Exception): return '{}: {}'.format(self.msg, self.url.toString()) -def incdec_number(url, incdec): +def incdec_number(url, incdec, segments=None): """Find a number in the url and increment or decrement it. Args: url: The current url incdec: Either 'increment' or 'decrement' + segments: A set of URL segments to search. Valid segments are: + 'host', 'path', 'query', 'anchor'. + Default: {'path', 'query'} Return: The new url with the number incremented/decremented. @@ -471,24 +474,46 @@ def incdec_number(url, incdec): if not url.isValid(): raise InvalidUrlError(url) - path = url.path() - # Get the last number in a string - match = re.match(r'(.*\D|^)(\d+)(.*)', path) - if not match: - raise IncDecError("No number found in URL!", url) - pre, number, post = match.groups() - # This should always succeed because we match \d+ - val = int(number) - if incdec == 'decrement': - if val <= 0: - raise IncDecError("Can't decrement {}!".format(val), url) - val -= 1 - elif incdec == 'increment': - val += 1 - else: - raise ValueError("Invalid value {} for indec!".format(incdec)) - new_path = ''.join([pre, str(val), post]) + if segments is None: + segments = {'path', 'query'} + valid_segments = {'host', 'path', 'query', 'anchor'} + if segments - valid_segments: + extra_elements = segments - valid_segments + raise IncDecError("Invalid segments: {}".format( + ', '.join(extra_elements)), url) + # Make a copy of the QUrl so we don't modify the original - new_url = QUrl(url) - new_url.setPath(new_path) - return new_url + url = QUrl(url) + # Order as they appear in a URL + segment_modifiers = [ + ('host', url.host, url.setHost), + ('path', url.path, url.setPath), + ('query', url.query, url.setQuery), + ('anchor', url.fragment, url.setFragment), + ] + # We're searching the last number so we walk the url segments backwards + for segment, getter, setter in reversed(segment_modifiers): + if segment not in segments: + continue + + # Get the last number in a string + match = re.match(r'(.*\D|^)(\d+)(.*)', getter()) + if not match: + continue + + pre, number, post = match.groups() + # This should always succeed because we match \d+ + val = int(number) + if incdec == 'decrement': + if val <= 0: + raise IncDecError("Can't decrement {}!".format(val), url) + val -= 1 + elif incdec == 'increment': + val += 1 + else: + raise ValueError("Invalid value {} for indec!".format(incdec)) + new_value = ''.join([pre, str(val), post]) + setter(new_value) + return url + + raise IncDecError("No number found in URL!", url) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index d6ba61d2a..360bb2ed9 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -1902,6 +1902,43 @@ class TestUserAgent: """Simple smoke test for completion.""" klass().complete() +class TestURLSegmentList: + + """Test URLSegmentList.""" + + @pytest.fixture + def klass(self): + return configtypes.URLSegmentList + + @pytest.mark.parametrize('val', [ + '', 'host', 'path', 'query', 'anchor', 'host,path,anchor', + ]) + def test_validate_valid(self, klass, val): + klass(none_ok=True).validate(val) + + def test_validate_empty(self, klass): + with pytest.raises(configexc.ValidationError): + klass(none_ok=False).validate('') + + @pytest.mark.parametrize('val', [ + 'foo', 'bar', 'foo,bar', 'host,path,foo', 'host,host' + ]) + def test_validate_invalid(self, klass, val): + with pytest.raises(configexc.ValidationError): + klass(none_ok=True).validate(val) + + @pytest.mark.parametrize('val, expected', [ + ('', set()), + ('path', {'path'}), + ('path,query', {'path', 'query'}), + ]) + def test_transform(self, klass, val, expected): + assert klass().transform(val) == expected + + def test_complete(self, klass): + """Simple smoke test for completion.""" + klass().complete() + @pytest.mark.parametrize('first, second, equal', [ (re.compile('foo'), RegexEq('foo'), True), diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index f3d8e13d2..b8ecbd6b8 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -531,22 +531,36 @@ class TestIncDecNumber: """Tests for urlutils.incdec_number().""" - @pytest.mark.parametrize('url, incdec, output', [ - ("http://example.com/index1.html", "increment", "http://example.com/index2.html"), - ("http://foo.bar/folder_1/image_2", "increment", "http://foo.bar/folder_1/image_3"), - ("http://bbc.c0.uk:80/story_1", "increment", "http://bbc.c0.uk:80/story_2"), - ("http://mydomain.tld/1_%C3%A4", "increment", "http://mydomain.tld/2_%C3%A4"), - ("http://example.com/site/5#5", "increment", "http://example.com/site/6#5"), + @pytest.mark.parametrize('url, incdec, output, segments', [ + ("http://example.com/index1.html", "increment", + "http://example.com/index2.html", {'path'}), + ("http://foo.bar/folder_1/image_2", "increment", + "http://foo.bar/folder_1/image_3", {'path'}), + ("http://bbc.c0.uk:80/story_1", "increment", + "http://bbc.c0.uk:80/story_2", {'path'}), + ("http://mydomain.tld/1_%C3%A4", "increment", + "http://mydomain.tld/2_%C3%A4", {'path'}), + ("http://example.com/site/5#5", "increment", + "http://example.com/site/6#5", {'path'}), + ("http://example.com/site/1#1", "increment", + "http://example.com/site/1#2", {'path', 'anchor'}), + ("http://example.com/site/1?page=2#3", "increment", + "http://example.com/site/1?page=3#3", {'path', 'query'}), - ("http://example.com/index10.html", "decrement", "http://example.com/index9.html"), - ("http://foo.bar/folder_1/image_3", "decrement", "http://foo.bar/folder_1/image_2"), - ("http://bbc.c0.uk:80/story_1", "decrement", "http://bbc.c0.uk:80/story_0"), - ("http://mydomain.tld/2_%C3%A4", "decrement", "http://mydomain.tld/1_%C3%A4"), - ("http://example.com/site/5#5", "decrement", "http://example.com/site/4#5"), + ("http://example.com/index10.html", "decrement", + "http://example.com/index9.html", {'path'}), + ("http://foo.bar/folder_1/image_3", "decrement", + "http://foo.bar/folder_1/image_2", {'path'}), + ("http://bbc.c0.uk:80/story_1", "decrement", + "http://bbc.c0.uk:80/story_0", {'path'}), + ("http://mydomain.tld/2_%C3%A4", "decrement", + "http://mydomain.tld/1_%C3%A4", {'path'}), + ("http://example.com/site/5#5", "decrement", + "http://example.com/site/4#5", {'path'}), ]) - def test_incdec_number(self, url, incdec, output): + def test_incdec_number(self, url, incdec, output, segments): """Test incdec_number with valid URLs.""" - new_url = urlutils.incdec_number(QUrl(url), incdec) + new_url = urlutils.incdec_number(QUrl(url), incdec, segments=segments) assert new_url == QUrl(output) @pytest.mark.parametrize('url', [ @@ -582,6 +596,12 @@ class TestIncDecNumber: with pytest.raises(ValueError): urlutils.incdec_number(valid_url, "foobar") + def test_wrong_segment(self): + """Test if incdec_number rejects a wrong segment""" + with pytest.raises(urlutils.IncDecError): + urlutils.incdec_number(QUrl('http://example.com'), + 'increment', segments={'foobar'}) + @pytest.mark.parametrize("url, msg, expected_str", [ ("http://example.com", "Invalid", "Invalid: http://example.com"), ])