From 795acd4a9e450d44b28f6cc0d79e6c260bcccd30 Mon Sep 17 00:00:00 2001 From: Tim Hatch Date: Wed, 25 Mar 2026 10:22:56 -0700 Subject: [PATCH] libs/textwrap/no_line_continuations Finds cases like textwrap.dedent(""" x \ y """) which can't be safely reindented without changing their final value. --- libs/textwrap/ick.toml | 7 ++ libs/textwrap/no_line_continuations.py | 86 +++++++++++++++++++ .../basic/input/example.py | 6 ++ .../basic/output/output.txt | 1 + .../from_import/input/example.py | 6 ++ .../from_import/output/output.txt | 1 + .../start_ok/input/example.py | 6 ++ .../start_ok/output/output.txt | 0 8 files changed, 113 insertions(+) create mode 100644 libs/textwrap/no_line_continuations.py create mode 100644 libs/textwrap/tests/no_line_continuations/basic/input/example.py create mode 100644 libs/textwrap/tests/no_line_continuations/basic/output/output.txt create mode 100644 libs/textwrap/tests/no_line_continuations/from_import/input/example.py create mode 100644 libs/textwrap/tests/no_line_continuations/from_import/output/output.txt create mode 100644 libs/textwrap/tests/no_line_continuations/start_ok/input/example.py create mode 100644 libs/textwrap/tests/no_line_continuations/start_ok/output/output.txt diff --git a/libs/textwrap/ick.toml b/libs/textwrap/ick.toml index 65f8636..dbe9649 100644 --- a/libs/textwrap/ick.toml +++ b/libs/textwrap/ick.toml @@ -1,3 +1,10 @@ +[[rule]] +name = "no_line_continuations" +impl = "python" +scope = "file" +inputs = ["*.py"] +deps = ["libcst"] + [[rule]] name = "use_textwrap_in_tests" impl = "python" diff --git a/libs/textwrap/no_line_continuations.py b/libs/textwrap/no_line_continuations.py new file mode 100644 index 0000000..948af24 --- /dev/null +++ b/libs/textwrap/no_line_continuations.py @@ -0,0 +1,86 @@ +"""Ick rule: flag line-continuation backslashes inside textwrap.dedent strings.""" +import re +import sys +from pathlib import Path + +import libcst as cst +from libcst.metadata import PositionProvider, QualifiedNameProvider + + +_LINE_CONT_RE = re.compile(r'(\\+)\n') + + +def _has_line_continuation(raw_value: str) -> bool: + """Return True if the raw string source has a line-continuation backslash after the first line. + + The pattern ``\"\"\"\\`` (continuation right after the opening triple-quote) is + explicitly allowed; it's only continuations on subsequent lines that break dedent. + """ + prefix_len = len(raw_value) - len(raw_value.lstrip("rRbBfFuU")) + content_start = prefix_len + 3 # skip prefix + opening triple-quote + + for m in _LINE_CONT_RE.finditer(raw_value): + if len(m.group(1)) % 2 == 0: + continue + if m.start() == content_start: + continue # """\ at the very start is fine + return True + return False + + +class LineContinuationChecker(cst.CSTVisitor): + METADATA_DEPENDENCIES = (QualifiedNameProvider, PositionProvider) + + def __init__(self) -> None: + self.issues: list[int] = [] + + def visit_Call(self, node: cst.Call) -> None: + names = self.get_metadata(QualifiedNameProvider, node.func, set()) + if not any(qn.name == "textwrap.dedent" for qn in names): + return + if not node.args: + return + arg = node.args[0] + if arg.keyword is not None: + return + str_node = arg.value + if not isinstance(str_node, cst.SimpleString): + return + value = str_node.value + prefix = value[:len(value) - len(value.lstrip("rRbBfFuU"))] + prefix_lower = prefix.lower() + if "r" in prefix_lower: + return # raw strings: backslash is literal, not a line continuation + if "b" in prefix_lower: + return # bytes strings: textwrap.dedent doesn't accept bytes + stripped = value[len(prefix):] + if not (stripped.startswith('"""') or stripped.startswith("'''")): + return + if _has_line_continuation(value): + pos = self.get_metadata(PositionProvider, node) + self.issues.append(pos.start.line) + + +def main() -> None: + exit_status = 0 + for path_str in sys.argv[1:]: + path = Path(path_str) + src = path.read_bytes() + try: + module = cst.parse_module(src) + except cst.ParserSyntaxError: + continue + + checker = LineContinuationChecker() + wrapper = cst.metadata.MetadataWrapper(module) + wrapper.visit(checker) + + for lineno in checker.issues: + print(f"{path_str}:{lineno}: line-continuation backslash in textwrap.dedent string") + exit_status = 99 + + sys.exit(exit_status) + + +if __name__ == "__main__": + main() diff --git a/libs/textwrap/tests/no_line_continuations/basic/input/example.py b/libs/textwrap/tests/no_line_continuations/basic/input/example.py new file mode 100644 index 0000000..6b5415a --- /dev/null +++ b/libs/textwrap/tests/no_line_continuations/basic/input/example.py @@ -0,0 +1,6 @@ +import textwrap + +x = textwrap.dedent(""" + first line \ + second line +""") diff --git a/libs/textwrap/tests/no_line_continuations/basic/output/output.txt b/libs/textwrap/tests/no_line_continuations/basic/output/output.txt new file mode 100644 index 0000000..5ee4ac1 --- /dev/null +++ b/libs/textwrap/tests/no_line_continuations/basic/output/output.txt @@ -0,0 +1 @@ +example.py:3: line-continuation backslash in textwrap.dedent string diff --git a/libs/textwrap/tests/no_line_continuations/from_import/input/example.py b/libs/textwrap/tests/no_line_continuations/from_import/input/example.py new file mode 100644 index 0000000..326e182 --- /dev/null +++ b/libs/textwrap/tests/no_line_continuations/from_import/input/example.py @@ -0,0 +1,6 @@ +from textwrap import dedent as tw_dedent + +x = tw_dedent(""" + first line \ + second line +""") diff --git a/libs/textwrap/tests/no_line_continuations/from_import/output/output.txt b/libs/textwrap/tests/no_line_continuations/from_import/output/output.txt new file mode 100644 index 0000000..5ee4ac1 --- /dev/null +++ b/libs/textwrap/tests/no_line_continuations/from_import/output/output.txt @@ -0,0 +1 @@ +example.py:3: line-continuation backslash in textwrap.dedent string diff --git a/libs/textwrap/tests/no_line_continuations/start_ok/input/example.py b/libs/textwrap/tests/no_line_continuations/start_ok/input/example.py new file mode 100644 index 0000000..60833e1 --- /dev/null +++ b/libs/textwrap/tests/no_line_continuations/start_ok/input/example.py @@ -0,0 +1,6 @@ +import textwrap + +x = textwrap.dedent("""\ + hello + world +""") diff --git a/libs/textwrap/tests/no_line_continuations/start_ok/output/output.txt b/libs/textwrap/tests/no_line_continuations/start_ok/output/output.txt new file mode 100644 index 0000000..e69de29