Skip to content

Commit 4de3fd8

Browse files
committed
Reintroduce escaped trailing newlines in heredocs in Macro.to_string/2
Closes #15354.
1 parent 9412f48 commit 4de3fd8

2 files changed

Lines changed: 106 additions & 17 deletions

File tree

lib/elixir/lib/code/normalizer.ex

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ defmodule Code.Normalizer do
6868

6969
# Bit containers
7070
defp do_normalize({:<<>>, _, args} = quoted, state) when is_list(args) do
71-
normalize_bitstring(quoted, state)
71+
normalize_bitstring(quoted, state, false)
7272
end
7373

7474
# Atoms with interpolations
@@ -89,13 +89,7 @@ defmodule Code.Normalizer do
8989
normalize_literal(:utf8, [], state)
9090
end
9191

92-
string =
93-
if state.escape do
94-
normalize_bitstring(string, state, true)
95-
else
96-
normalize_bitstring(string, state)
97-
end
98-
92+
string = normalize_bitstring(string, state, state.escape)
9993
{{:., dot_meta, [:erlang, :binary_to_atom]}, call_meta, [string, utf8]}
10094
end
10195

@@ -118,6 +112,7 @@ defmodule Code.Normalizer do
118112
end
119113
end)
120114

115+
parts = maybe_add_trailing_newline(call_meta, parts, state)
121116
{{:., dot_meta, [List, :to_charlist]}, call_meta, [parts]}
122117
else
123118
normalize_call(quoted, state)
@@ -405,7 +400,8 @@ defmodule Code.Normalizer do
405400
defp allow_keyword?(:{}, _), do: false
406401
defp allow_keyword?(op, arity), do: not is_atom(op) or not Macro.operator?(op, arity)
407402

408-
defp normalize_bitstring({:<<>>, meta, parts}, state, escape_interpolation \\ false) do
403+
defp normalize_bitstring({:<<>>, meta, parts}, state, escape_interpolation) do
404+
parts = maybe_add_trailing_newline(meta, parts, state)
409405
meta = patch_meta_line(meta, state.parent_meta)
410406

411407
parts =
@@ -427,6 +423,17 @@ defmodule Code.Normalizer do
427423
{:<<>>, meta, parts}
428424
end
429425

426+
defp maybe_add_trailing_newline(meta, parts, state) do
427+
with true <- state.escape and Keyword.get(meta, :delimiter) in ~w(""" '''),
428+
last = List.last(parts),
429+
true <- is_binary(last) and not String.ends_with?(last, "\n") do
430+
[_last | rest] = Enum.reverse(parts)
431+
Enum.reverse([last <> "\n" | rest])
432+
else
433+
_ -> parts
434+
end
435+
end
436+
430437
defp normalize_interpolation_parts(parts, state, escape_interpolation) do
431438
Enum.map(parts, fn
432439
{:"::", interpolation_meta,

lib/elixir/test/elixir/code_normalizer/formatted_ast_test.exs

Lines changed: 90 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ defmodule Code.Normalizer.FormatterASTTest do
3030

3131
{quoted, comments} = Code.string_to_quoted_with_comments!(good, to_quoted_opts)
3232

33-
to_algebra_opts = [comments: comments, escape: false] ++ opts
33+
to_algebra_opts = Keyword.merge([comments: comments, escape: false], opts)
3434

3535
quoted
3636
|> Code.quoted_to_algebra(to_algebra_opts)
@@ -289,13 +289,53 @@ defmodule Code.Normalizer.FormatterASTTest do
289289
end
290290

291291
test "with escaped new lines" do
292-
assert_same ~S'''
293-
"""
294-
one\
295-
#{"two"}\
296-
three\
297-
"""
298-
'''
292+
source =
293+
String.trim(~S'''
294+
"""
295+
one\
296+
#{"two"}\
297+
three\
298+
"""
299+
''')
300+
301+
assert_same source
302+
303+
# This is the same default as assert_same
304+
assert string_to_string(source, unescape: false, escape: false) == source
305+
306+
# If we unescape, we cannot keep the original representation
307+
# but we must keep it as a valid heredoc
308+
assert string_to_string(source, unescape: true, escape: true) ==
309+
String.trim(~S'''
310+
"""
311+
one#{"two"}three
312+
"""
313+
''')
314+
end
315+
316+
test "with empty escaped new lines" do
317+
source =
318+
String.trim(~S'''
319+
"""
320+
one\
321+
#{"two"}\
322+
three\
323+
"""
324+
''')
325+
326+
assert_same source
327+
328+
# This is the same default as assert_same
329+
assert string_to_string(source, unescape: false, escape: false) == source
330+
331+
# If we unescape, we cannot keep the original representation
332+
# but we must keep it as a valid heredoc
333+
assert string_to_string(source, unescape: true, escape: true) ==
334+
String.trim(~S'''
335+
"""
336+
one#{"two"}three
337+
"""
338+
''')
299339
end
300340
end
301341

@@ -341,6 +381,31 @@ defmodule Code.Normalizer.FormatterASTTest do
341381
'''
342382
"""
343383
end
384+
385+
test "with empty escaped new lines" do
386+
source =
387+
String.trim(~S"""
388+
'''
389+
one\
390+
#{"two"}\
391+
three\
392+
'''
393+
""")
394+
395+
assert_same source
396+
397+
# This is the same default as assert_same
398+
assert string_to_string(source, unescape: false, escape: false) == source
399+
400+
# If we unescape, we cannot keep the original representation
401+
# but we must keep it as a valid heredoc
402+
assert string_to_string(source, unescape: true, escape: true) ==
403+
String.trim(~S"""
404+
'''
405+
one#{"two"}three
406+
'''
407+
""")
408+
end
344409
end
345410

346411
describe "keyword list" do
@@ -467,6 +532,23 @@ defmodule Code.Normalizer.FormatterASTTest do
467532
'''rsa
468533
"""
469534
end
535+
536+
test "with empty escaped new lines" do
537+
source =
538+
String.trim(~S"""
539+
~c'''
540+
one\
541+
#{"two"}\
542+
three\
543+
'''
544+
""")
545+
546+
assert_same source
547+
548+
# Heredoc in sigils always preserves newlines regardless of escape/unescape
549+
assert string_to_string(source, unescape: false, escape: false) == source
550+
assert string_to_string(source, unescape: true, escape: true) == source
551+
end
470552
end
471553

472554
describe "preserves comments formatting" do

0 commit comments

Comments
 (0)