Skip to content

Commit 1a6542c

Browse files
committed
quoted-strings: Fix only-when-needed on multiline with backslash
On double-quoted multiline strings, quotes aren't needed if lines are broken on spaces, e.g.: multiline: "this is a sentence cut into words" But quotes are needed when at least one line ends with a backslash character (`\`), meaning that the next spaces should be removed: multiline: "https://example.com/a/very/very\ /very/very/long/URL" This commit fixes that. Fixes #275
1 parent 2d10aaa commit 1a6542c

File tree

2 files changed

+102
-26
lines changed

2 files changed

+102
-26
lines changed

tests/rules/test_quoted_strings.py

Lines changed: 84 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@ def test_quote_type_any(self):
7777
' word 1\n' # fails
7878
' word 2\n'
7979
'multiline string 4:\n'
80+
' "word 1\n'
81+
' word 2"\n'
82+
'multiline string 5:\n'
8083
' "word 1\\\n'
8184
' word 2"\n',
8285
conf, problem1=(9, 3))
@@ -127,9 +130,12 @@ def test_quote_type_single(self):
127130
' word 1\n' # fails
128131
' word 2\n'
129132
'multiline string 4:\n'
130-
' "word 1\\\n'
133+
' "word 1\n' # fails
134+
' word 2"\n'
135+
'multiline string 5:\n'
136+
' "word 1\\\n' # fails
131137
' word 2"\n',
132-
conf, problem1=(9, 3), problem2=(12, 3))
138+
conf, problem1=(9, 3), problem2=(12, 3), problem3=(15, 3))
133139

134140
def test_quote_type_double(self):
135141
conf = 'quoted-strings: {quote-type: double}\n'
@@ -173,6 +179,9 @@ def test_quote_type_double(self):
173179
' word 1\n' # fails
174180
' word 2\n'
175181
'multiline string 4:\n'
182+
' "word 1\n'
183+
' word 2"\n'
184+
'multiline string 5:\n'
176185
' "word 1\\\n'
177186
' word 2"\n',
178187
conf, problem1=(9, 3))
@@ -216,6 +225,9 @@ def test_any_quotes_not_required(self):
216225
' word 1\n'
217226
' word 2\n'
218227
'multiline string 4:\n'
228+
' "word 1\n'
229+
' word 2"\n'
230+
'multiline string 5:\n'
219231
' "word 1\\\n'
220232
' word 2"\n',
221233
conf)
@@ -263,9 +275,12 @@ def test_single_quotes_not_required(self):
263275
' word 1\n'
264276
' word 2\n'
265277
'multiline string 4:\n'
278+
' "word 1\n' # fails
279+
' word 2"\n'
280+
'multiline string 5:\n'
266281
' "word 1\\\n' # fails
267282
' word 2"\n',
268-
conf, problem1=(12, 3))
283+
conf, problem1=(12, 3), problem2=(15, 3))
269284

270285
def test_only_when_needed(self):
271286
conf = 'quoted-strings: {required: only-when-needed}\n'
@@ -307,7 +322,10 @@ def test_only_when_needed(self):
307322
' word 1\n'
308323
' word 2\n'
309324
'multiline string 4:\n'
310-
' "word 1\\\n' # fails
325+
' "word 1\n' # fails
326+
' word 2"\n'
327+
'multiline string 5:\n'
328+
' "word 1\\\n'
311329
' word 2"\n',
312330
conf, problem1=(12, 3))
313331

@@ -354,9 +372,12 @@ def test_only_when_needed_single_quotes(self):
354372
' word 1\n'
355373
' word 2\n'
356374
'multiline string 4:\n'
375+
' "word 1\n'
376+
' word 2"\n'
377+
'multiline string 5:\n'
357378
' "word 1\\\n' # fails
358379
' word 2"\n',
359-
conf, problem1=(12, 3))
380+
conf, problem1=(12, 3), problem2=(15, 3))
360381

361382
def test_only_when_needed_corner_cases(self):
362383
conf = 'quoted-strings: {required: only-when-needed}\n'
@@ -626,7 +647,8 @@ class QuotedKeysTestCase(RuleTestCase):
626647
rule_id = 'quoted-strings'
627648

628649
def test_disabled(self):
629-
conf_disabled = "quoted-strings: {}"
650+
conf_disabled = ('quoted-strings: {}\n'
651+
'key-duplicates: disable\n')
630652
key_strings = ('---\n'
631653
'true: 2\n'
632654
'123: 3\n'
@@ -665,6 +687,10 @@ def test_disabled(self):
665687
' line 2\n'
666688
': 35\n'
667689
'?\n'
690+
' "line 1\n'
691+
' line 2"\n'
692+
': 37\n'
693+
'?\n'
668694
' "line 1\\\n'
669695
' line 2"\n'
670696
': 39\n')
@@ -673,7 +699,8 @@ def test_disabled(self):
673699
def test_default(self):
674700
# Default configuration, but with check-keys
675701
conf_default = ("quoted-strings:\n"
676-
" check-keys: true\n")
702+
" check-keys: true\n"
703+
"key-duplicates: disable\n")
677704
key_strings = ('---\n'
678705
'true: 2\n'
679706
'123: 3\n'
@@ -712,6 +739,10 @@ def test_default(self):
712739
' line 2\n'
713740
': 35\n'
714741
'?\n'
742+
' "line 1\n'
743+
' line 2"\n'
744+
': 37\n'
745+
'?\n'
715746
' "line 1\\\n'
716747
' line 2"\n'
717748
': 39\n')
@@ -721,7 +752,8 @@ def test_default(self):
721752
def test_quote_type_any(self):
722753
conf = ('quoted-strings:\n'
723754
' check-keys: true\n'
724-
' quote-type: any\n')
755+
' quote-type: any\n'
756+
'key-duplicates: disable\n')
725757

726758
key_strings = ('---\n'
727759
'true: 2\n'
@@ -761,6 +793,10 @@ def test_quote_type_any(self):
761793
' line 2\n'
762794
': 35\n'
763795
'?\n'
796+
' "line 1\n'
797+
' line 2"\n'
798+
': 37\n'
799+
'?\n'
764800
' "line 1\\\n'
765801
' line 2"\n'
766802
': 39\n')
@@ -771,7 +807,8 @@ def test_quote_type_any(self):
771807
def test_quote_type_single(self):
772808
conf = ('quoted-strings:\n'
773809
' check-keys: true\n'
774-
' quote-type: single\n')
810+
' quote-type: single\n'
811+
'key-duplicates: disable\n')
775812

776813
key_strings = ('---\n'
777814
'true: 2\n'
@@ -811,19 +848,24 @@ def test_quote_type_single(self):
811848
' line 2\n'
812849
': 35\n'
813850
'?\n'
851+
' "line 1\n'
852+
' line 2"\n'
853+
': 37\n'
854+
'?\n'
814855
' "line 1\\\n'
815856
' line 2"\n'
816857
': 39\n')
817858
self.check(key_strings, conf,
818859
problem1=(4, 1), problem2=(5, 1), problem3=(6, 1),
819860
problem4=(7, 1), problem5=(20, 3), problem6=(21, 3),
820861
problem7=(23, 2), problem8=(23, 10), problem9=(33, 3),
821-
problem10=(37, 3))
862+
problem10=(37, 3), problem11=(41, 3))
822863

823864
def test_quote_type_double(self):
824865
conf = ('quoted-strings:\n'
825866
' check-keys: true\n'
826-
' quote-type: double\n')
867+
' quote-type: double\n'
868+
'key-duplicates: disable\n')
827869

828870
key_strings = ('---\n'
829871
'true: 2\n'
@@ -863,6 +905,10 @@ def test_quote_type_double(self):
863905
' line 2\n'
864906
': 35\n'
865907
'?\n'
908+
' "line 1\n'
909+
' line 2"\n'
910+
': 37\n'
911+
'?\n'
866912
' "line 1\\\n'
867913
' line 2"\n'
868914
': 39\n')
@@ -874,7 +920,8 @@ def test_any_quotes_not_required(self):
874920
conf = ('quoted-strings:\n'
875921
' check-keys: true\n'
876922
' quote-type: any\n'
877-
' required: false\n')
923+
' required: false\n'
924+
'key-duplicates: disable\n')
878925

879926
key_strings = ('---\n'
880927
'true: 2\n'
@@ -914,6 +961,10 @@ def test_any_quotes_not_required(self):
914961
' line 2\n'
915962
': 35\n'
916963
'?\n'
964+
' "line 1\n'
965+
' line 2"\n'
966+
': 37\n'
967+
'?\n'
917968
' "line 1\\\n'
918969
' line 2"\n'
919970
': 39\n')
@@ -923,7 +974,8 @@ def test_single_quotes_not_required(self):
923974
conf = ('quoted-strings:\n'
924975
' check-keys: true\n'
925976
' quote-type: single\n'
926-
' required: false\n')
977+
' required: false\n'
978+
'key-duplicates: disable\n')
927979

928980
key_strings = ('---\n'
929981
'true: 2\n'
@@ -963,17 +1015,23 @@ def test_single_quotes_not_required(self):
9631015
' line 2\n'
9641016
': 35\n'
9651017
'?\n'
1018+
' "line 1\n'
1019+
' line 2"\n'
1020+
': 37\n'
1021+
'?\n'
9661022
' "line 1\\\n'
9671023
' line 2"\n'
9681024
': 39\n')
9691025
self.check(key_strings, conf,
9701026
problem1=(5, 1), problem2=(6, 1), problem3=(7, 1),
971-
problem4=(21, 3), problem5=(23, 10), problem6=(37, 3))
1027+
problem4=(21, 3), problem5=(23, 10), problem6=(37, 3),
1028+
problem7=(41, 3))
9721029

9731030
def test_only_when_needed(self):
9741031
conf = ('quoted-strings:\n'
9751032
' check-keys: true\n'
976-
' required: only-when-needed\n')
1033+
' required: only-when-needed\n'
1034+
'key-duplicates: disable\n')
9771035

9781036
key_strings = ('---\n'
9791037
'true: 2\n'
@@ -1013,6 +1071,10 @@ def test_only_when_needed(self):
10131071
' line 2\n'
10141072
': 35\n'
10151073
'?\n'
1074+
' "line 1\n'
1075+
' line 2"\n'
1076+
': 37\n'
1077+
'?\n'
10161078
' "line 1\\\n'
10171079
' line 2"\n'
10181080
': 39\n')
@@ -1024,7 +1086,8 @@ def test_only_when_needed_single_quotes(self):
10241086
conf = ('quoted-strings:\n'
10251087
' check-keys: true\n'
10261088
' quote-type: single\n'
1027-
' required: only-when-needed\n')
1089+
' required: only-when-needed\n'
1090+
'key-duplicates: disable\n')
10281091

10291092
key_strings = ('---\n'
10301093
'true: 2\n'
@@ -1064,13 +1127,17 @@ def test_only_when_needed_single_quotes(self):
10641127
' line 2\n'
10651128
': 35\n'
10661129
'?\n'
1130+
' "line 1\n'
1131+
' line 2"\n'
1132+
': 37\n'
1133+
'?\n'
10671134
' "line 1\\\n'
10681135
' line 2"\n'
10691136
': 39\n')
10701137
self.check(key_strings, conf,
10711138
problem1=(5, 1), problem2=(6, 1), problem3=(7, 1),
10721139
problem4=(8, 1), problem5=(21, 3), problem6=(23, 10),
1073-
problem7=(37, 3))
1140+
problem7=(37, 3), problem8=(41, 3))
10741141

10751142
def test_only_when_needed_corner_cases(self):
10761143
conf = ('quoted-strings:\n'

yamllint/rules/quoted_strings.py

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -204,20 +204,23 @@ def _quote_match(quote_type, token_style):
204204
(quote_type == 'double' and token_style == '"'))
205205

206206

207-
def _quotes_are_needed(string, style, is_inside_a_flow):
207+
def _quotes_are_needed(token, is_inside_a_flow):
208208
# Quotes needed on strings containing flow tokens
209-
if is_inside_a_flow and set(string) & {',', '[', ']', '{', '}'}:
209+
if is_inside_a_flow and set(token.value) & {',', '[', ']', '{', '}'}:
210210
return True
211211

212-
if style == '"':
212+
if token.style == '"':
213213
try:
214-
yaml.reader.Reader('').check_printable('key: ' + string)
214+
yaml.reader.Reader('').check_printable('key: ' + token.value)
215215
except yaml.reader.ReaderError:
216216
# Special characters in a double-quoted string are assumed to have
217217
# been backslash-escaped
218218
return True
219219

220-
loader = yaml.BaseLoader('key: ' + string)
220+
if _has_backslash_on_at_least_one_line_ending(token):
221+
return True
222+
223+
loader = yaml.BaseLoader('key: ' + token.value)
221224
# Remove the 5 first tokens corresponding to 'key: ' (StreamStartToken,
222225
# BlockMappingStartToken, KeyToken, ScalarToken(value=key), ValueToken)
223226
for _ in range(5):
@@ -228,7 +231,7 @@ def _quotes_are_needed(string, style, is_inside_a_flow):
228231
return True
229232
else:
230233
if (isinstance(a, yaml.ScalarToken) and a.style is None and
231-
isinstance(b, yaml.BlockEndToken) and a.value == string):
234+
isinstance(b, yaml.BlockEndToken) and a.value == token.value):
232235
return False
233236
return True
234237

@@ -239,6 +242,14 @@ def _has_quoted_quotes(token):
239242
(token.style == '"' and "'" in token.value)))
240243

241244

245+
def _has_backslash_on_at_least_one_line_ending(token):
246+
if token.start_mark.line == token.end_mark.line:
247+
return False
248+
buffer = token.start_mark.buffer[
249+
token.start_mark.index + 1:token.end_mark.index - 1]
250+
return '\\\n' in buffer or '\\\r\n' in buffer
251+
252+
242253
def check(conf, token, prev, next, nextnext, context):
243254
if 'flow_nest_count' not in context:
244255
context['flow_nest_count'] = 0
@@ -306,9 +317,7 @@ def check(conf, token, prev, next, nextnext, context):
306317

307318
# Quotes are not strictly needed here
308319
if (token.style and tag == DEFAULT_SCALAR_TAG and token.value and
309-
not _quotes_are_needed(token.value,
310-
token.style,
311-
context['flow_nest_count'] > 0)):
320+
not _quotes_are_needed(token, context['flow_nest_count'] > 0)):
312321
is_extra_required = any(re.search(r, token.value)
313322
for r in conf['extra-required'])
314323
is_extra_allowed = any(re.search(r, token.value)

0 commit comments

Comments
 (0)