Repository: adrienverge/yamllint

Issue:
quoted-strings only-when-needed doesn't work properly for multiline strings
Example YAML file `input.yml`:

```yaml
---
multiline_url_with_quotes: "https://github.com/cloudposse/\
  github-authorized-keys/releases/download/1.0.3/\
  github-authorized-keys_linux_amd64"

multiline_url_without_quotes: https://github.com/cloudposse/\
  github-authorized-keys/releases/download/1.0.3/\
  github-authorized-keys_linux_amd64

```

Yamllint 1.23.0 reports:
```
$ yamllint -d '{extends: default, rules: {quoted-strings: {required: only-when-needed}}}' input.yml
input.yml
  2:28      error    string value is redundantly quoted with any quotes  (quoted-strings)

```

However, the quotes are actually not redundant:

```python
>>> import yaml
>>> with open("input.yml") as f:
...     data = yaml.safe_load(f)
... 
>>> data['multiline_url_with_quotes']
'https://github.com/cloudposse/github-authorized-keys/releases/download/1.0.3/github-authorized-keys_linux_amd64'
>>> data['multiline_url_without_quotes']
'https://github.com/cloudposse/\\ github-authorized-keys/releases/download/1.0.3/\\ github-authorized-keys_linux_amd64'
>>>
```

Discussion:
@adrienverge: Thanks for the report! Contributions to fix this are welcome.

Solution patch:
diff --git a/yamllint/rules/quoted_strings.py b/yamllint/rules/quoted_strings.py
index 7cb88ff..587d2c9 100644
--- a/yamllint/rules/quoted_strings.py
+++ b/yamllint/rules/quoted_strings.py
@@ -204,20 +204,23 @@ def _quote_match(quote_type, token_style):
             (quote_type == 'double' and token_style == '"'))
 
 
-def _quotes_are_needed(string, style, is_inside_a_flow):
+def _quotes_are_needed(token, is_inside_a_flow):
     # Quotes needed on strings containing flow tokens
-    if is_inside_a_flow and set(string) & {',', '[', ']', '{', '}'}:
+    if is_inside_a_flow and set(token.value) & {',', '[', ']', '{', '}'}:
         return True
 
-    if style == '"':
+    if token.style == '"':
         try:
-            yaml.reader.Reader('').check_printable('key: ' + string)
+            yaml.reader.Reader('').check_printable('key: ' + token.value)
         except yaml.reader.ReaderError:
             # Special characters in a double-quoted string are assumed to have
             # been backslash-escaped
             return True
 
-    loader = yaml.BaseLoader('key: ' + string)
+        if _has_backslash_on_at_least_one_line_ending(token):
+            return True
+
+    loader = yaml.BaseLoader('key: ' + token.value)
     # Remove the 5 first tokens corresponding to 'key: ' (StreamStartToken,
     # BlockMappingStartToken, KeyToken, ScalarToken(value=key), ValueToken)
     for _ in range(5):
@@ -228,7 +231,7 @@ def _quotes_are_needed(string, style, is_inside_a_flow):
         return True
     else:
         if (isinstance(a, yaml.ScalarToken) and a.style is None and
-                isinstance(b, yaml.BlockEndToken) and a.value == string):
+                isinstance(b, yaml.BlockEndToken) and a.value == token.value):
             return False
         return True
 
@@ -239,6 +242,14 @@ def _has_quoted_quotes(token):
              (token.style == '"' and "'" in token.value)))
 
 
+def _has_backslash_on_at_least_one_line_ending(token):
+    if token.start_mark.line == token.end_mark.line:
+        return False
+    buffer = token.start_mark.buffer[
+        token.start_mark.index + 1:token.end_mark.index - 1]
+    return '\\\n' in buffer or '\\\r\n' in buffer
+
+
 def check(conf, token, prev, next, nextnext, context):
     if 'flow_nest_count' not in context:
         context['flow_nest_count'] = 0
@@ -306,9 +317,7 @@ def check(conf, token, prev, next, nextnext, context):
 
         # Quotes are not strictly needed here
         if (token.style and tag == DEFAULT_SCALAR_TAG and token.value and
-                not _quotes_are_needed(token.value,
-                                       token.style,
-                                       context['flow_nest_count'] > 0)):
+                not _quotes_are_needed(token, context['flow_nest_count'] > 0)):
             is_extra_required = any(re.search(r, token.value)
                                     for r in conf['extra-required'])
             is_extra_allowed = any(re.search(r, token.value)


Test patch:
diff --git a/tests/rules/test_quoted_strings.py b/tests/rules/test_quoted_strings.py
index 31b392a..593b36e 100644
--- a/tests/rules/test_quoted_strings.py
+++ b/tests/rules/test_quoted_strings.py
@@ -77,6 +77,9 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'               # fails
                    '  word 2\n'
                    'multiline string 4:\n'
+                   '  "word 1\n'
+                   '   word 2"\n'
+                   'multiline string 5:\n'
                    '  "word 1\\\n'
                    '   word 2"\n',
                    conf, problem1=(9, 3))
@@ -127,9 +130,12 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'               # fails
                    '  word 2\n'
                    'multiline string 4:\n'
-                   '  "word 1\\\n'
+                   '  "word 1\n'              # fails
+                   '   word 2"\n'
+                   'multiline string 5:\n'
+                   '  "word 1\\\n'            # fails
                    '   word 2"\n',
-                   conf, problem1=(9, 3), problem2=(12, 3))
+                   conf, problem1=(9, 3), problem2=(12, 3), problem3=(15, 3))
 
     def test_quote_type_double(self):
         conf = 'quoted-strings: {quote-type: double}\n'
@@ -173,6 +179,9 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'               # fails
                    '  word 2\n'
                    'multiline string 4:\n'
+                   '  "word 1\n'
+                   '   word 2"\n'
+                   'multiline string 5:\n'
                    '  "word 1\\\n'
                    '   word 2"\n',
                    conf, problem1=(9, 3))
@@ -216,6 +225,9 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'
                    '  word 2\n'
                    'multiline string 4:\n'
+                   '  "word 1\n'
+                   '   word 2"\n'
+                   'multiline string 5:\n'
                    '  "word 1\\\n'
                    '   word 2"\n',
                    conf)
@@ -263,9 +275,12 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'
                    '  word 2\n'
                    'multiline string 4:\n'
+                   '  "word 1\n'              # fails
+                   '   word 2"\n'
+                   'multiline string 5:\n'
                    '  "word 1\\\n'            # fails
                    '   word 2"\n',
-                   conf, problem1=(12, 3))
+                   conf, problem1=(12, 3), problem2=(15, 3))
 
     def test_only_when_needed(self):
         conf = 'quoted-strings: {required: only-when-needed}\n'
@@ -307,7 +322,10 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'
                    '  word 2\n'
                    'multiline string 4:\n'
-                   '  "word 1\\\n'            # fails
+                   '  "word 1\n'              # fails
+                   '   word 2"\n'
+                   'multiline string 5:\n'
+                   '  "word 1\\\n'
                    '   word 2"\n',
                    conf, problem1=(12, 3))
 
@@ -354,9 +372,12 @@ class QuotedValuesTestCase(RuleTestCase):
                    '  word 1\n'
                    '  word 2\n'
                    'multiline string 4:\n'
+                   '  "word 1\n'
+                   '   word 2"\n'
+                   'multiline string 5:\n'
                    '  "word 1\\\n'            # fails
                    '   word 2"\n',
-                   conf, problem1=(12, 3))
+                   conf, problem1=(12, 3), problem2=(15, 3))
 
     def test_only_when_needed_corner_cases(self):
         conf = 'quoted-strings: {required: only-when-needed}\n'
@@ -626,7 +647,8 @@ class QuotedKeysTestCase(RuleTestCase):
     rule_id = 'quoted-strings'
 
     def test_disabled(self):
-        conf_disabled = "quoted-strings: {}"
+        conf_disabled = ('quoted-strings: {}\n'
+                         'key-duplicates: disable\n')
         key_strings = ('---\n'
                        'true: 2\n'
                        '123: 3\n'
@@ -665,6 +687,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -673,7 +699,8 @@ class QuotedKeysTestCase(RuleTestCase):
     def test_default(self):
         # Default configuration, but with check-keys
         conf_default = ("quoted-strings:\n"
-                        "  check-keys: true\n")
+                        "  check-keys: true\n"
+                        "key-duplicates: disable\n")
         key_strings = ('---\n'
                        'true: 2\n'
                        '123: 3\n'
@@ -712,6 +739,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -721,7 +752,8 @@ class QuotedKeysTestCase(RuleTestCase):
     def test_quote_type_any(self):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
-                '  quote-type: any\n')
+                '  quote-type: any\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -761,6 +793,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -771,7 +807,8 @@ class QuotedKeysTestCase(RuleTestCase):
     def test_quote_type_single(self):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
-                '  quote-type: single\n')
+                '  quote-type: single\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -811,6 +848,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -818,12 +859,13 @@ class QuotedKeysTestCase(RuleTestCase):
                    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))
+                   problem10=(37, 3), problem11=(41, 3))
 
     def test_quote_type_double(self):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
-                '  quote-type: double\n')
+                '  quote-type: double\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -863,6 +905,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -874,7 +920,8 @@ class QuotedKeysTestCase(RuleTestCase):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
                 '  quote-type: any\n'
-                '  required: false\n')
+                '  required: false\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -914,6 +961,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -923,7 +974,8 @@ class QuotedKeysTestCase(RuleTestCase):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
                 '  quote-type: single\n'
-                '  required: false\n')
+                '  required: false\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -963,17 +1015,23 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
         self.check(key_strings, conf,
                    problem1=(5, 1), problem2=(6, 1), problem3=(7, 1),
-                   problem4=(21, 3), problem5=(23, 10), problem6=(37, 3))
+                   problem4=(21, 3), problem5=(23, 10), problem6=(37, 3),
+                   problem7=(41, 3))
 
     def test_only_when_needed(self):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
-                '  required: only-when-needed\n')
+                '  required: only-when-needed\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -1013,6 +1071,10 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
@@ -1024,7 +1086,8 @@ class QuotedKeysTestCase(RuleTestCase):
         conf = ('quoted-strings:\n'
                 '  check-keys: true\n'
                 '  quote-type: single\n'
-                '  required: only-when-needed\n')
+                '  required: only-when-needed\n'
+                'key-duplicates: disable\n')
 
         key_strings = ('---\n'
                        'true: 2\n'
@@ -1064,13 +1127,17 @@ class QuotedKeysTestCase(RuleTestCase):
                        '  line 2\n'
                        ': 35\n'
                        '?\n'
+                       '  "line 1\n'
+                       '  line 2"\n'
+                       ': 37\n'
+                       '?\n'
                        '  "line 1\\\n'
                        '   line 2"\n'
                        ': 39\n')
         self.check(key_strings, conf,
                    problem1=(5, 1), problem2=(6, 1), problem3=(7, 1),
                    problem4=(8, 1), problem5=(21, 3), problem6=(23, 10),
-                   problem7=(37, 3))
+                   problem7=(37, 3), problem8=(41, 3))
 
     def test_only_when_needed_corner_cases(self):
         conf = ('quoted-strings:\n'