diff --git a/tests/rules/test_quoted_strings.py b/tests/rules/test_quoted_strings.py index 040ea793..f58cb13d 100644 --- a/tests/rules/test_quoted_strings.py +++ b/tests/rules/test_quoted_strings.py @@ -18,7 +18,7 @@ from yamllint import config -class QuotedTestCase(RuleTestCase): +class QuotedValuesTestCase(RuleTestCase): rule_id = 'quoted-strings' def test_disabled(self): @@ -595,3 +595,338 @@ def test_allow_quoted_quotes(self): "foo1: '[barbaz]'\n" "foo2: '[bar\"baz]'\n", conf) + + +class QuotedKeysTestCase(RuleTestCase): + rule_id = 'quoted-strings' + + def conf(self, options): + conf_with_options = (f"{self.rule_id}:\n" + " check-keys: true\n") + for option in options: + conf_with_options += f" {option}\n" + return conf_with_options + + key_strings = ('---\n' + 'true: 2\n' + '123: 3\n' + 'foo1: 4\n' + '"foo2": 5\n' + '"false": 6\n' + '"234": 7\n' + '\'bar\': 8\n' + '!!str generic_string: 9\n' + '!!str 456: 10\n' + '!!str "quoted_generic_string": 11\n' + '!!binary binstring: 12\n' + '!!int int_string: 13\n' + '!!bool bool_string: 14\n' + '!!bool "quoted_bool_string": 15\n' + # Sequences and mappings + '? - 16\n' + ' - 17\n' + ': 18\n' + '[119, 219]: 19\n' + '? a: 20\n' + ' "b": 21\n' + ': 22\n' + '{a: 123, "b": 223}: 23\n' + # Multiline strings + '? |\n' + ' line 1\n' + ' line 2\n' + ': 27\n' + '? >\n' + ' line 1\n' + ' line 2\n' + ': 31\n' + '?\n' + ' line 1\n' + ' line 2\n' + ': 35\n' + '?\n' + ' "line 1\\\n' + ' line 2"\n' + ': 39\n') + + def test_disabled(self): + conf_disabled = f"{self.rule_id}: {{}}" + self.check(self.key_strings, conf_disabled) + + def test_default(self): + # Default configuration, but with check-keys + conf_default = (f"{self.rule_id}:\n" + " check-keys: true\n") + self.check(self.key_strings, conf_default, problem1=(4, 1), + problem3=(20, 3), problem4=(23, 2), problem5=(33, 3)) + + def test_quote_type_any(self): + conf = self.conf(['quote-type: any']) + + self.check(self.key_strings, conf, + problem1=(4, 1), problem2=(20, 3), problem3=(23, 2), + problem4=(33, 3)) + + def test_quote_type_single(self): + conf = self.conf(['quote-type: single']) + + self.check(self.key_strings, conf, + problem1=(4, 1), problem2=(5, 1), problem3=(6, 1), + problem4=(7, 1), problem5=(20, 3), problem6=(21, 3), + problem7=(23, 2), problem8=(23, 10), problem9=(33, 3), + problem10=(37, 3)) + + def test_quote_type_double(self): + conf = self.conf(['quote-type: double']) + + self.check(self.key_strings, conf, + problem1=(4, 1), problem2=(8, 1), problem3=(20, 3), + problem4=(23, 2), problem5=(33, 3)) + + def test_any_quotes_not_required(self): + conf = self.conf(['quote-type: any', 'required: false']) + + self.check(self.key_strings, conf) + + def test_single_quotes_not_required(self): + conf = self.conf(['quote-type: single', 'required: false']) + + self.check(self.key_strings, conf, + problem1=(5, 1), problem2=(6, 1), problem3=(7, 1), + problem4=(21, 3), problem5=(23, 10), problem6=(37, 3)) + + def test_only_when_needed(self): + conf = self.conf(['required: only-when-needed']) + + self.check(self.key_strings, conf, + problem1=(5, 1), problem2=(8, 1), problem3=(21, 3), + problem4=(23, 10), problem5=(37, 3)) + + def test_only_when_needed_single_quotes(self): + conf = self.conf(['quote-type: single', 'required: only-when-needed']) + + self.check(self.key_strings, conf, + problem1=(5, 1), problem2=(6, 1), problem3=(7, 1), + problem4=(8, 1), problem5=(21, 3), problem6=(23, 10), + problem7=(37, 3)) + + def test_only_when_needed_corner_cases(self): + conf = self.conf(['required: only-when-needed']) + + self.check('---\n' + '"": 2\n' + '"- item": 3\n' + '"key: value": 4\n' + '"%H:%M:%S": 5\n' + '"%wheel ALL=(ALL) NOPASSWD: ALL": 6\n' + '\'"quoted"\': 7\n' + '"\'foo\' == \'bar\'": 8\n' + '"\'Mac\' in ansible_facts.product_name": 9\n' + '\'foo # bar\': 10\n', + conf) + self.check('---\n' + '"": 2\n' + '"- item": 3\n' + '"key: value": 4\n' + '"%H:%M:%S": 5\n' + '"%wheel ALL=(ALL) NOPASSWD: ALL": 6\n' + '\'"quoted"\': 7\n' + '"\'foo\' == \'bar\'": 8\n' + '"\'Mac\' in ansible_facts.product_name": 9\n', + conf) + + self.check('---\n' + '---: 2\n' + '"----": 3\n' # fails + '---------: 4\n' + '"----------": 5\n' # fails + ':wq: 6\n' + '":cw": 7\n', # fails + conf, problem1=(3, 1), problem2=(5, 1), problem3=(7, 1)) + + def test_only_when_needed_extras(self): + conf = self.conf(['required: true', 'extra-allowed: [^http://]']) + self.assertRaises(config.YamlLintConfigError, self.check, '', conf) + + conf = self.conf(['required: true', 'extra-required: [^http://]']) + self.assertRaises(config.YamlLintConfigError, self.check, '', conf) + + conf = self.conf(['required: false', 'extra-allowed: [^http://]']) + self.assertRaises(config.YamlLintConfigError, self.check, '', conf) + + conf = self.conf(['required: true']) + self.check('---\n' + '123: 2\n' + '"234": 3\n' + 'localhost: 4\n' # fails + '"host.local": 5\n' + 'http://localhost: 6\n' # fails + '"http://host.local": 7\n' + 'ftp://localhost: 8\n' # fails + '"ftp://host.local": 9\n', + conf, problem1=(4, 1), problem2=(6, 1), problem3=(8, 1)) + + conf = self.conf(['required: only-when-needed', + 'extra-allowed: [^ftp://]', + 'extra-required: [^http://]']) + self.check('---\n' + '123: 2\n' + '"234": 3\n' + 'localhost: 4\n' + '"host.local": 5\n' # fails + 'http://localhost: 6\n' # fails + '"http://host.local": 7\n' + 'ftp://localhost: 8\n' + '"ftp://host.local": 9\n', + conf, problem1=(5, 1), problem2=(6, 1)) + + conf = self.conf(['required: false', + 'extra-required: [^http://, ^ftp://]']) + self.check('---\n' + '123: 2\n' + '"234": 3\n' + 'localhost: 4\n' + '"host.local": 5\n' + 'http://localhost: 6\n' # fails + '"http://host.local": 7\n' + 'ftp://localhost: 8\n' # fails + '"ftp://host.local": 9\n', + conf, problem1=(6, 1), problem2=(8, 1)) + + conf = self.conf(['required: only-when-needed', + 'extra-allowed: [^ftp://, ";$", " "]']) + self.check('---\n' + 'localhost: 2\n' + '"host.local": 3\n' # fails + 'ftp://localhost: 4\n' + '"ftp://host.local": 5\n' + 'i=i+1: 6\n' + '"i=i+2": 7\n' # fails + 'i=i+3;: 8\n' + '"i=i+4;": 9\n' + 'foo1: 10\n' + '"foo2": 11\n' # fails + 'foo bar1: 12\n' + '"foo bar2": 13\n', + conf, problem1=(3, 1), problem2=(7, 1), problem3=(11, 1)) + + def test_octal_values(self): + conf = self.conf(['required: true']) + + self.check('---\n' + '100: 2\n' + '0100: 3\n' + '0o100: 4\n' + '777: 5\n' + '0777: 6\n' + '0o777: 7\n' + '800: 8\n' + '0800: 9\n' # fails + '0o800: 10\n' # fails + '"0900": 11\n' + '"0o900": 12\n', + conf, + problem1=(9, 1), problem2=(10, 1)) + + def test_allow_quoted_quotes(self): + conf = self.conf(['quote-type: single', + 'required: false', + 'allow-quoted-quotes: false']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: single', + 'required: false', + 'allow-quoted-quotes: true']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: single', + 'required: true', + 'allow-quoted-quotes: false']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: single', + 'required: true', + 'allow-quoted-quotes: true']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: single', + 'required: only-when-needed', + 'allow-quoted-quotes: false']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: single', + 'required: only-when-needed', + 'allow-quoted-quotes: true']) + self.check('---\n' + '"[barbaz]": 2\n' # fails + '"[bar\'baz]": 3\n', + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: double', + 'required: false', + 'allow-quoted-quotes: false']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: double', + 'required: false', + 'allow-quoted-quotes: true']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: double', + 'required: true', + 'allow-quoted-quotes: false']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: double', + 'required: true', + 'allow-quoted-quotes: true']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: double', + 'required: only-when-needed', + 'allow-quoted-quotes: false']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", # fails + conf, problem1=(2, 1), problem2=(3, 1)) + + conf = self.conf(['quote-type: double', + 'required: only-when-needed', + 'allow-quoted-quotes: true']) + self.check("---\n" + "'[barbaz]': 2\n" # fails + "'[bar\"baz]': 3\n", + conf, problem1=(2, 1)) + + conf = self.conf(['quote-type: any']) + self.check("---\n" + "'[barbaz]': 2\n" + "'[bar\"baz]': 3\n", + conf) diff --git a/yamllint/rules/common.py b/yamllint/rules/common.py index 06f560c0..395000e5 100644 --- a/yamllint/rules/common.py +++ b/yamllint/rules/common.py @@ -86,3 +86,7 @@ def is_explicit_key(token): # : v return (token.start_mark.pointer < token.end_mark.pointer and token.start_mark.buffer[token.start_mark.pointer] == '?') + + +def is_key(token): + return token and isinstance(token, yaml.KeyToken) diff --git a/yamllint/rules/quoted_strings.py b/yamllint/rules/quoted_strings.py index 86b825e0..77ada7ea 100644 --- a/yamllint/rules/quoted_strings.py +++ b/yamllint/rules/quoted_strings.py @@ -32,6 +32,9 @@ even if ``required: only-when-needed`` is set. * ``allow-quoted-quotes`` allows (``true``) using disallowed quotes for strings with allowed quotes inside. Default ``false``. +* ``check-keys`` defines whether to apply the rules to keys in mappings. By + default, ``quoted-strings`` rules apply only to values. Set this option to + ``true`` to apply the rules to keys as well. **Note**: Multi-line strings (with ``|`` or ``>``) will not be checked. @@ -46,6 +49,7 @@ extra-required: [] extra-allowed: [] allow-quoted-quotes: false + check-keys: false .. rubric:: Examples @@ -135,6 +139,19 @@ foo: 'bar"baz' +#. With ``quoted-strings: {required: only-when-needed, check-keys: true, + extra-required: ["[:]"]}`` + + the following code snippet would **FAIL**: + :: + + foo:bar: baz + + the following code snippet would **PASS**: + :: + + "foo:bar": baz + """ import re @@ -142,6 +159,7 @@ import yaml from yamllint.linter import LintProblem +from yamllint.rules.common import is_key ID = 'quoted-strings' TYPE = 'token' @@ -149,12 +167,14 @@ 'required': (True, False, 'only-when-needed'), 'extra-required': [str], 'extra-allowed': [str], - 'allow-quoted-quotes': bool} + 'allow-quoted-quotes': bool, + 'check-keys': bool} DEFAULT = {'quote-type': 'any', 'required': True, 'extra-required': [], 'extra-allowed': [], - 'allow-quoted-quotes': False} + 'allow-quoted-quotes': False, + 'check-keys': False} def VALIDATE(conf): @@ -226,10 +246,17 @@ def check(conf, token, prev, next, nextnext, context): if not (isinstance(token, yaml.tokens.ScalarToken) and isinstance(prev, (yaml.BlockEntryToken, yaml.FlowEntryToken, yaml.FlowSequenceStartToken, yaml.TagToken, - yaml.ValueToken))): + yaml.ValueToken, yaml.KeyToken))): return + if is_key(prev): + if not conf['check-keys']: + return + node = 'key' + else: + node = 'value' + # Ignore explicit types, e.g. !!str testtest or !!int 42 if (prev and isinstance(prev, yaml.tokens.TagToken) and prev.value[0] == '!!'): @@ -254,7 +281,7 @@ def check(conf, token, prev, next, nextnext, context): if (token.style is None or not (_quote_match(quote_type, token.style) or (conf['allow-quoted-quotes'] and _has_quoted_quotes(token)))): - msg = f"string value is not quoted with {quote_type} quotes" + msg = f"string {node} is not quoted with {quote_type} quotes" elif conf['required'] is False: @@ -263,13 +290,13 @@ def check(conf, token, prev, next, nextnext, context): not _quote_match(quote_type, token.style) and not (conf['allow-quoted-quotes'] and _has_quoted_quotes(token))): - msg = f"string value is not quoted with {quote_type} quotes" + msg = f"string {node} is not quoted with {quote_type} quotes" elif not token.style: is_extra_required = any(re.search(r, token.value) for r in conf['extra-required']) if is_extra_required: - msg = "string value is not quoted" + msg = f"string {node} is not quoted" elif conf['required'] == 'only-when-needed': @@ -282,14 +309,14 @@ def check(conf, token, prev, next, nextnext, context): is_extra_allowed = any(re.search(r, token.value) for r in conf['extra-allowed']) if not (is_extra_required or is_extra_allowed): - msg = f"string value is redundantly quoted with " \ + msg = f"string {node} is redundantly quoted with " \ f"{quote_type} quotes" # But when used need to match config elif (token.style and not _quote_match(quote_type, token.style) and not (conf['allow-quoted-quotes'] and _has_quoted_quotes(token))): - msg = f"string value is not quoted with {quote_type} quotes" + msg = f"string {node} is not quoted with {quote_type} quotes" elif not token.style: is_extra_required = len(conf['extra-required']) and any(