From b65883028ffa171b8f35a001993d177d16a04598 Mon Sep 17 00:00:00 2001 From: Giorgio Maria Federico Birnthaler <129273127+Dodothereal@users.noreply.github.com> Date: Thu, 29 Oct 2026 18:09:07 +0100 Subject: [PATCH] fix: preserve escaped newlines inside ordinary strings --- src/docformatter/format.py | 12 ++-- .../test_continued_string_preservation.py | 58 +++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/formatter/test_continued_string_preservation.py diff --git a/src/docformatter/format.py b/src/docformatter/format.py index 3634f65..0ead8a5 100644 --- a/src/docformatter/format.py +++ b/src/docformatter/format.py @@ -200,12 +200,14 @@ def _do_update_token_indices( is_same_line = tokens[i].line == tokens[i - 1].line is_same_position = tokens[i].start[0] == _prev_end_row _prev_end_row = tokens[i].end[0] - # A backslash continuation joins two physical lines without an NL - # token between them, so the current token always starts on the row - # after the previous one, even though the rows may look the same once - # the previous token has been shifted. + # A backslash continuation between tokens joins two physical lines without + # an NL token. A multiline string can start on the previous token's row + # and contain that backslash itself, so compare the original row positions + # before treating it as a continuation between tokens. prev_line = tokens[i - 1].line.rstrip("\r\n") - is_backslash_continuation = not is_same_line and prev_line.endswith("\\") + is_backslash_continuation = ( + not is_same_position and not is_same_line and prev_line.endswith("\\") + ) if not is_backslash_continuation and ( is_multiline diff --git a/tests/formatter/test_continued_string_preservation.py b/tests/formatter/test_continued_string_preservation.py new file mode 100644 index 0000000..14e2e16 --- /dev/null +++ b/tests/formatter/test_continued_string_preservation.py @@ -0,0 +1,58 @@ +"""Preserve escaped physical newlines inside ordinary string tokens.""" + +import ast +import sys + +import pytest + +from docformatter import Formatter + + +@pytest.mark.integration +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +@pytest.mark.parametrize( + "source", + [ + 'value = "first\\\nsecond"\n', + "value = 'first\\\nsecond'\n", + 'value = r"first\\\nsecond"\n', + 'value = b"first\\\nsecond"\n', + 'value = u"first\\\nsecond"\n', + 'print("first\\\nsecond")\n', + 'def value():\n return "first\\\nsecond"\n', + 'value = "first\\\nsecond\\\nthird"\n', + 'value = "first\\\nsecond" "suffix"\n', + 'value = "first\\\nsecond" + "suffix"\n', + 'value = "first\\\nsecond"', + 'value = (\n "first\\\nsecond"\n)\n', + ], +) +def test_continued_string_is_preserved(source, newline, test_args, args): + """A continuation within a string must not duplicate surrounding Python code.""" + source = source.replace("\n", newline) + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + + formatted = formatter._do_format_code(source) + + assert formatted == source + assert ast.dump(ast.parse(formatted)) == ast.dump(ast.parse(source)) + assert formatter._do_format_code(formatted) == formatted + + +@pytest.mark.integration +@pytest.mark.parametrize("args", [[""], ["--black", ""]]) +@pytest.mark.parametrize("newline", ["\n", "\r\n"]) +def test_continued_string_after_docstring_rewrite(newline, test_args, args): + """Preserve string-token positions when docstring formatting shifts later rows.""" + source = 'class Example:\n """Summary."""\n value = "first\\\nsecond"\n' + expected = source.replace('"""Summary."""\n', '"""Summary."""\n\n') + source = source.replace("\n", newline) + expected = expected.replace("\n", newline) + formatter = Formatter(test_args, sys.stderr, sys.stdin, sys.stdout) + + formatted = formatter._do_format_code(source) + + assert formatted == expected + assert ast.dump(ast.parse(formatted)) == ast.dump(ast.parse(source)) + assert formatter._do_format_code(formatted) == formatted