diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index b4b45aa6a..bd040a7a8 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -23,6 +23,7 @@ |<>|Whether to log javascript console messages. |<>|Whether to always save the open pages. |<>|The name of the session to save by default, or empty for the last loaded session. +|<>|The URL segments where `:navigate increment/decrement` will search for a number. |============== .Quick reference for section ``ui'' @@ -457,6 +458,19 @@ The name of the session to save by default, or empty for the last loaded session Default: empty +[[general-url-incdec-segments]] +=== url-incdec-segments +The URL segments where `:navigate increment/decrement` will search for a number. + +Valid values: + + * +host+ + * +path+ + * +query+ + * +anchor+ + +Default: +pass:[path,query]+ + == ui General options related to the user interface. diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 58df2bfd8..3ff648f9c 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -471,8 +471,13 @@ 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') + if segments is None: + segments = set() + else: + segments = set(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 acce633bf..1085866de 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 ec34c3009..86e500f1e 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -286,6 +286,58 @@ class List(BaseType): raise configexc.ValidationError(value, "items may not be empty!") +class FlagList(List): + + """Base class for a list setting that contains one or more flags. + + Lists with duplicate flags are invalid and each item is checked against + self.valid_values (if not empty). + """ + + combinable_values = None + + def validate(self, value): + self._basic_validation(value) + if not value: + return + + vals = self.transform(value) + if None in vals and not self.none_ok: + raise configexc.ValidationError( + value, "May not contain empty values!") + + # Check for duplicate values + if len(set(vals)) != len(vals): + raise configexc.ValidationError( + value, "List contains duplicate values!") + + # Check if each value is valid, ignores None values + set_vals = set(val for val in vals if val) + if (self.valid_values is not None and + not set_vals.issubset(set(self.valid_values))): + raise configexc.ValidationError( + value, "List contains invalid values!") + + def complete(self): + if self.valid_values is None: + return None + + out = [] + # Single value completions + for value in self.valid_values: + desc = self.valid_values.descriptions.get(value, "") + out.append((value, desc)) + + combinables = self.combinable_values + if combinables is None: + combinables = list(self.valid_values) + # Generate combinations of each possible value combination + for size in range(2, len(combinables) + 1): + for combination in itertools.combinations(combinables, size): + out.append((','.join(combination), '')) + return out + + class Bool(BaseType): """Base class for a boolean setting.""" @@ -1364,7 +1416,7 @@ class AcceptCookies(BaseType): ('never', "Don't accept cookies at all.")) -class ConfirmQuit(List): +class ConfirmQuit(FlagList): """Whether to display a confirmation when the window is closed.""" @@ -1379,18 +1431,11 @@ class ConfirmQuit(List): combinable_values = ('multiple-tabs', 'downloads') def validate(self, value): - self._basic_validation(value) + super().validate(value) if not value: return - values = [] - for v in self.transform(value): - if v: - values.append(v) - elif self.none_ok: - pass - else: - raise configexc.ValidationError(value, "May not contain empty " - "values!") + values = [x for x in self.transform(value) if x] + # Never can't be set with other options if 'never' in values and len(values) > 1: raise configexc.ValidationError( @@ -1399,30 +1444,6 @@ class ConfirmQuit(List): elif 'always' in values and len(values) > 1: raise configexc.ValidationError( value, "List cannot contain always!") - # Values have to be valid - elif not set(values).issubset(set(self.valid_values.values)): - raise configexc.ValidationError( - value, "List contains invalid values!") - # List can't have duplicates - elif len(set(values)) != len(values): - raise configexc.ValidationError( - value, "List contains duplicate values!") - - def complete(self): - combinations = [] - # Generate combinations of the options that can be combined - for size in range(2, len(self.combinable_values) + 1): - combinations += list( - itertools.combinations(self.combinable_values, size)) - out = [] - # Add valid single values - for val in self.valid_values: - out.append((val, self.valid_values.descriptions[val])) - # Add combinations to list of options - for val in combinations: - desc = '' - out.append((','.join(val), desc)) - return out class ForwardUnboundKeys(BaseType): @@ -1602,3 +1623,10 @@ class TabBarShow(BaseType): "is open."), ('switching', "Show the tab bar when switching " "tabs.")) + + +class URLSegmentList(FlagList): + + """A list of URL segments.""" + + valid_values = ValidValues('host', 'path', 'query', 'anchor') 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 fadcfe042..c10696e44 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -368,6 +368,81 @@ class TestList: assert klass().transform(val) == expected +class FlagListSubclass(configtypes.FlagList): + + """A subclass of FlagList which we use in tests. + + Valid values are 'foo', 'bar' and 'baz'. + """ + + valid_values = configtypes.ValidValues('foo', 'bar', 'baz') + combinable_values = ['foo', 'bar'] + + +class TestFlagList: + + """Test FlagList.""" + + @pytest.fixture + def klass(self): + return FlagListSubclass + + @pytest.fixture + def klass_valid_none(self): + """Return a FlagList with valid_values = None""" + return configtypes.FlagList + + @pytest.mark.parametrize('val', ['', 'foo', 'foo,bar', 'foo,']) + def test_validate_valid(self, klass, val): + klass(none_ok=True).validate(val) + + @pytest.mark.parametrize('val', ['qux', 'foo,qux', 'foo,foo']) + def test_validate_invalid(self, klass, val): + with pytest.raises(configexc.ValidationError): + klass(none_ok=True).validate(val) + + @pytest.mark.parametrize('val', ['', 'foo,', 'foo,,bar']) + def test_validate_empty_value_not_okay(self, klass, val): + with pytest.raises(configexc.ValidationError): + klass(none_ok=False).validate(val) + + @pytest.mark.parametrize('val, expected', [ + ('', None), + ('foo', ['foo']), + ('foo,bar', ['foo', 'bar']), + ]) + def test_transform(self, klass, val, expected): + assert klass().transform(val) == expected + + @pytest.mark.parametrize('val', ['spam', 'spam,eggs']) + def test_validate_values_none(self, klass_valid_none, val): + klass_valid_none().validate(val) + + def test_complete(self, klass): + """Test completing by doing some samples.""" + completions = [e[0] for e in klass().complete()] + assert 'foo' in completions + assert 'bar' in completions + assert 'baz' in completions + assert 'foo,bar' in completions + for val in completions: + assert 'baz,' not in val + assert ',baz' not in val + + def test_complete_all_valid_values(self, klass): + inst = klass() + inst.combinable_values = None + completions = [e[0] for e in inst.complete()] + assert 'foo' in completions + assert 'bar' in completions + assert 'baz' in completions + assert 'foo,bar' in completions + assert 'foo,baz' in completions + + def test_complete_no_valid_values(self, klass_valid_none): + assert klass_valid_none().complete() == None + + class TestBool: """Test Bool.""" diff --git a/tests/unit/utils/test_urlutils.py b/tests/unit/utils/test_urlutils.py index f216c9bbb..4a19df689 100644 --- a/tests/unit/utils/test_urlutils.py +++ b/tests/unit/utils/test_urlutils.py @@ -531,23 +531,46 @@ 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"), - - ("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"), + @pytest.mark.parametrize('incdec', ['increment', 'decrement']) + @pytest.mark.parametrize('value', [ + '{}foo', 'foo{}', 'foo{}bar', '42foo{}' ]) - def test_incdec_number(self, url, incdec, output): + @pytest.mark.parametrize('url', [ + 'http://example.com:80/v1/path/{}/test', + 'http://example.com:80/v1/query_test?value={}', + 'http://example.com:80/v1/anchor_test#{}', + 'http://host_{}_test.com:80', + 'http://m4ny.c0m:80/number5/3very?where=yes#{}' + ]) + def test_incdec_number(self, incdec, value, url): """Test incdec_number with valid URLs.""" - new_url = urlutils.incdec_number(QUrl(url), incdec) - assert new_url == QUrl(output) + # The integer used should not affect test output, as long as it's + # bigger than 1 + # 20 was chosen by dice roll, guaranteed to be random + base_value = value.format(20) + if incdec == 'increment': + expected_value = value.format(21) + else: + expected_value = value.format(19) + + base_url = QUrl(url.format(base_value)) + expected_url = QUrl(url.format(expected_value)) + new_url = urlutils.incdec_number( + base_url, incdec, segments={'host', 'path', 'query', 'anchor'}) + assert new_url == expected_url + + @pytest.mark.parametrize('url, segments, expected', [ + ('http://ex4mple.com/test_4?page=3#anchor2', {'host'}, + 'http://ex5mple.com/test_4?page=3#anchor2'), + ('http://ex4mple.com/test_4?page=3#anchor2', {'host', 'path'}, + 'http://ex4mple.com/test_5?page=3#anchor2'), + ('http://ex4mple.com/test_4?page=3#anchor5', {'host', 'path', 'query'}, + 'http://ex4mple.com/test_4?page=4#anchor5'), + ]) + def test_incdec_segment_ignored(self, url, segments, expected): + new_url = urlutils.incdec_number(QUrl(url), 'increment', + segments=segments) + assert new_url == QUrl(expected) @pytest.mark.parametrize('url', [ "http://example.com/long/path/but/no/number", @@ -582,6 +605,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"), ])