diff --git a/Cython/Compiler/Code.pxd b/Cython/Compiler/Code.pxd index ad44048409b..14400e86d0a 100644 --- a/Cython/Compiler/Code.pxd +++ b/Cython/Compiler/Code.pxd @@ -138,6 +138,3 @@ cdef class PyxCodeWriter: cdef Py_ssize_t level cdef Py_ssize_t original_level cdef dict _insertion_points - - @cython.final - cdef int _putln(self, line) except -1 diff --git a/Cython/Compiler/Code.py b/Cython/Compiler/Code.py index d34bda3bc55..bc2ad3813bb 100644 --- a/Cython/Compiler/Code.py +++ b/Cython/Compiler/Code.py @@ -3086,22 +3086,23 @@ def getvalue(self): return result def putln(self, line, context=None): - context = context or self.context - if context: + if context is None: + if self.context is not None: + context = self.context + if context is not None: line = sub_tempita(line, context) - self._putln(line) - - def _putln(self, line): - self.buffer.write(f"{self.level * ' '}{line}\n") + # Avoid indenting empty lines. + self.buffer.write(f"{self.level * ' '}{line}\n" if line else "\n") def put_chunk(self, chunk, context=None): - context = context or self.context - if context: + if context is None: + if self.context is not None: + context = self.context + if context is not None: chunk = sub_tempita(chunk, context) - chunk = textwrap.dedent(chunk) - for line in chunk.splitlines(): - self._putln(line) + chunk = _indent_chunk(chunk, self.level * 4) + self.buffer.write(chunk) def insertion_point(self): return type(self)(self.buffer.insertion_point(), self.level, self.context) @@ -3119,6 +3120,66 @@ def __getitem__(self, name): return self._insertion_points[name] +@cython.final +@cython.ccall +def _indent_chunk(chunk: str, indentation_length: cython.int) -> str: + """Normalise leading space to the intended indentation and strip empty lines. + """ + assert '\t' not in chunk + lines = chunk.splitlines(keepends=True) + if not lines: + return chunk + last_line = lines[-1].rstrip(' ') + if last_line: + lines[-1] = last_line + else: + del lines[-1] + if not lines: + return '\n' + + # Count minimal (non-empty) indentation and strip empty lines. + min_indentation: cython.int = len(chunk) + 1 + line_indentation: cython.int + line: str + i: cython.int + for i, line in enumerate(lines): + line_indentation = _count_indentation(line) + if line_indentation + 1 == len(line): + lines[i] = '\n' + elif line_indentation < min_indentation: + min_indentation = line_indentation + + if min_indentation > len(chunk): + # All empty lines. + min_indentation = 0 + + if min_indentation < indentation_length: + add_indent = ' ' * (indentation_length - min_indentation) + lines = [ + add_indent + line if line != '\n' else '\n' + for line in lines + ] + elif min_indentation > indentation_length: + start: cython.int = min_indentation - indentation_length + lines = [ + line[start:] if line != '\n' else '\n' + for line in lines + ] + + return ''.join(lines) + + +@cython.exceptval(-1) +@cython.cfunc +def _count_indentation(s: str) -> cython.int: + i: cython.int = 0 + ch: cython.Py_UCS4 + for i, ch in enumerate(s): + if ch != ' ': + break + return i + + class ClosureTempAllocator: def __init__(self, klass): self.klass = klass diff --git a/Cython/Compiler/Tests/TestCode.py b/Cython/Compiler/Tests/TestCode.py new file mode 100644 index 00000000000..decfbf3946c --- /dev/null +++ b/Cython/Compiler/Tests/TestCode.py @@ -0,0 +1,86 @@ +import textwrap +from unittest import TestCase + +from ..Code import _indent_chunk + +class TestIndent(TestCase): + def _test_indentations(self, chunk, expected): + for indentation in range(16): + expected_indented = textwrap.indent(expected, ' ' * indentation) + for line in expected_indented.splitlines(): + # Validate before the comparison that empty lines got stripped also by textwrap.indent(). + self.assertTrue(line == '' or line.strip(), repr(line)) + + with self.subTest(indentation=indentation): + result = _indent_chunk(chunk, indentation_length=indentation) + self.assertEqual(expected_indented, result) + + def test_indent_empty(self): + self._test_indentations('', '') + + def test_indent_empty_lines(self): + self._test_indentations('\n', '\n') + self._test_indentations('\n'*2, '\n'*2) + self._test_indentations('\n'*3, '\n'*3) + self._test_indentations(' \n'*2, '\n'*2) + self._test_indentations('\n \n \n \n', '\n'*4) + + def test_indent_one_line(self): + self._test_indentations('abc', 'abc') + + def test_indent_chunk(self): + chunk = """ + x = 1 + if x == 2: + print("False") + else: + print("True") + """ + expected = """ +x = 1 +if x == 2: + print("False") +else: + print("True") +""" + self._test_indentations(chunk, expected) + + def test_indent_empty_line(self): + chunk = """ + x = 1 + + if x == 2: + print("False") + else: + print("True") + """ + expected = """ +x = 1 + +if x == 2: + print("False") +else: + print("True") +""" + self._test_indentations(chunk, expected) + + def test_indent_empty_line_unclean(self): + lines = """ + x = 1 + + if x == 2: + print("False") + else: + print("True") + """.splitlines(keepends=True) + lines[2] = ' \n' + chunk = ''.join(lines) + expected = """ +x = 1 + +if x == 2: + print("False") +else: + print("True") +""" + self._test_indentations(chunk, expected)