Repository: adrienverge/yamllint (Python)

Issue:
Feature request: quoted-strings: quote-type: consistent
`quote-type` allows for `single`, `double` or `any`. Adding `consistent` would be nice, allowing for single or double but not both mixed in the same document.


Discussion:
Hello Ryan, thanks for this proposal. Such an addition would make sense 👍 



Patch:
diff --git a/yamllint/rules/quoted_strings.py b/yamllint/rules/quoted_strings.py
index 2e4f750e..d98dcc8d 100644
--- a/yamllint/rules/quoted_strings.py
+++ b/yamllint/rules/quoted_strings.py
@@ -139,6 +139,20 @@
 
     foo: 'bar"baz'
 
+#. With ``quoted-strings: {quote-type: consistent}``
+
+   the following code snippet would **PASS**:
+   ::
+
+    foo: 'bar'
+    baz: 'quux'
+
+   the following code snippet would **FAIL**:
+   ::
+
+    foo: 'bar'
+    baz: "quux"
+
 #. With ``quoted-strings: {required: only-when-needed, check-keys: true,
    extra-required: ["[:]"]}``
 
@@ -161,7 +175,7 @@
 
 ID = 'quoted-strings'
 TYPE = 'token'
-CONF = {'quote-type': ('any', 'single', 'double'),
+CONF = {'quote-type': ('any', 'single', 'double', 'consistent'),
         'required': (True, False, 'only-when-needed'),
         'extra-required': [str],
         'extra-allowed': [str],
@@ -198,7 +212,14 @@ def VALIDATE(conf):
     list('-+0123456789'))
 
 
-def _quote_match(quote_type, token_style):
+def _quote_match(quote_type, token_style, context):
+    if quote_type == 'consistent' and token_style is not None:
+        # The canonical token style in a document is assumed to be the first
+        # one found for the purpose of 'consistent'
+        if 'quoted_strings_consistent_token_style' not in context:
+            context['quoted_strings_consistent_token_style'] = token_style
+        return context['quoted_strings_consistent_token_style'] == token_style
+
     return ((quote_type == 'any') or
             (quote_type == 'single' and token_style == "'") or
             (quote_type == 'double' and token_style == '"'))
@@ -294,7 +315,7 @@ def check(conf, token, prev, next, nextnext, context):
 
         # Quotes are mandatory and need to match config
         if (token.style is None or
-            not (_quote_match(quote_type, token.style) or
+            not (_quote_match(quote_type, token.style, context) or
                  (conf['allow-quoted-quotes'] and _has_quoted_quotes(token)))):
             msg = f"string {node} is not quoted with {quote_type} quotes"
 
@@ -302,7 +323,7 @@ def check(conf, token, prev, next, nextnext, context):
 
         # Quotes are not mandatory but when used need to match config
         if (token.style and
-                not _quote_match(quote_type, token.style) and
+                not _quote_match(quote_type, token.style, context) and
                 not (conf['allow-quoted-quotes'] and
                      _has_quoted_quotes(token))):
             msg = f"string {node} is not quoted with {quote_type} quotes"
@@ -328,7 +349,7 @@ def check(conf, token, prev, next, nextnext, context):
 
         # But when used need to match config
         elif (token.style and
-              not _quote_match(quote_type, token.style) and
+              not _quote_match(quote_type, token.style, context) and
               not (conf['allow-quoted-quotes'] and _has_quoted_quotes(token))):
             msg = f"string {node} is not quoted with {quote_type} quotes"
 


Test patch:
diff --git a/tests/rules/test_quoted_strings.py b/tests/rules/test_quoted_strings.py
index 593b36e6..c3b90b51 100644
--- a/tests/rules/test_quoted_strings.py
+++ b/tests/rules/test_quoted_strings.py
@@ -186,6 +186,47 @@ def test_quote_type_double(self):
                    '   word 2"\n',
                    conf, problem1=(9, 3))
 
+    def test_quote_type_consistent(self):
+        conf = 'quoted-strings: {quote-type: consistent}'
+        self.check('---\n'
+                   'string1: "foo"\n'
+                   'string2: "bar"\n'
+                   'string3: \'baz\'\n'              # fails
+                   'string4: "quux"\n',
+                   conf, problem1=(4, 10))
+
+        conf = ('quoted-strings:\n'
+                '  quote-type: consistent\n'
+                '  check-keys: true\n')
+        self.check('---\n'
+                   '"string1": "foo"\n'
+                   'string2: "bar"\n'                # fails
+                   '\'string3\': "baz"\n'            # fails
+                   '"string4": {"key": "val"}\n'
+                   '"string5": {\'key\': "val"}\n',  # fails
+                   conf, problem1=(3, 1), problem2=(4, 1), problem3=(6, 13))
+
+        conf = ('quoted-strings:\n'
+                '  quote-type: consistent\n'
+                '  check-keys: true\n'
+                '  required: false\n')
+        self.check('---\n'
+                   'string1: \'foo\'\n'
+                   'string2: "bar"\n'               # fails
+                   'string3: \'baz\'\n'
+                   'string4: {\'key\': "val"}\n'    # fails
+                   'string5: {"key": \'val\'}\n'    # fails
+                   'string6:\n'
+                   '  \'key\': "val"\n'             # fails
+                   'string7:\n'
+                   '  "key": \'val\'\n'             # fails
+                   'string8:\n'
+                   '  "string"\n'                   # fails
+                   'string9: >\n'
+                   '  "string"\n',
+                   conf, problem1=(3, 10), problem2=(5, 18), problem3=(6, 11),
+                   problem4=(8, 10), problem5=(10, 3), problem6=(12, 3))
+
     def test_any_quotes_not_required(self):
         conf = 'quoted-strings: {quote-type: any, required: false}\n'