diff --git a/Lib/_pyrepl/readline.py b/Lib/_pyrepl/readline.py index 23b8fa6b9c7625..b8c16748307449 100644 --- a/Lib/_pyrepl/readline.py +++ b/Lib/_pyrepl/readline.py @@ -256,20 +256,52 @@ def _should_auto_indent(buffer: list[str], pos: int) -> bool: # check if last character before "pos" is a colon, ignoring # whitespaces and comments. last_char = None - while pos > 0: - pos -= 1 - if last_char is None: - if buffer[pos] not in " \t\n#": # ignore whitespaces and comments - last_char = buffer[pos] - else: - # even if we found a non-whitespace character before - # original pos, we keep going back until newline is reached - # to make sure we ignore comments - if buffer[pos] == "\n": - break - if buffer[pos] == "#": - last_char = None - return last_char == ":" + # A stack to keep track of string delimiters. Push a quote when entering a + # string, pop it when the string ends. If the stack is empty, we're not + # inside a string. When see a '#', it's a comment start if we're not inside + # a string; otherwise, it's just a '#' character within a string. + str_delims: list[str] = [] + in_comment = False + char_line_indent_start = None + char_line_indent = 0 + lastchar_line_indent = 0 + cursor_line_indent = 0 + + i = -1 + while i < pos - 1: + i += 1 + char = buffer[i] + + # update last_char + if char == "#": + if str_delims: + last_char = char # '#' inside a string is just a character + else: + in_comment = True + elif char == "\n": + # newline ends a comment + in_comment = False + if i < pos - 1 and buffer[i + 1] in " \t": + char_line_indent_start = i + 1 + else: + char_line_indent_start = None # clear last line's line_indent_start + char_line_indent = 0 + elif char not in " \t": + if char_line_indent_start is not None: + char_line_indent = i - char_line_indent_start + if not in_comment and not str_delims: + # update last_char with non-whitespace chars outside comments and strings + last_char = char + lastchar_line_indent = char_line_indent + + # update stack + if char in "\"'" and (i == 0 or buffer[i - 1] != "\\"): + if str_delims and str_delims[-1] == char: + str_delims.pop() + else: + str_delims.append(char) + cursor_line_indent = char_line_indent + return last_char == ":" and cursor_line_indent <= lastchar_line_indent class maybe_accept(commands.Command): diff --git a/Lib/test/test_pyrepl/test_pyrepl.py b/Lib/test/test_pyrepl/test_pyrepl.py index e298b2add52c3e..2a135aa08f5f43 100644 --- a/Lib/test/test_pyrepl/test_pyrepl.py +++ b/Lib/test/test_pyrepl/test_pyrepl.py @@ -593,6 +593,46 @@ def test_auto_indent_with_comment(self): output = multiline_input(reader) self.assertEqual(output, output_code) + # fmt: off + events = code_to_events( + "def f():\n" + "# foo\n" + "pass\n\n" + ) + + output_code = ( + "def f():\n" + " # foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + + # fmt: off + events = itertools.chain( + code_to_events("def f():\n"), + [ + Event(evt="key", data="backspace", raw=b"\x08"), + ], + code_to_events("# foo\npass\n\n") + ) + + output_code = ( + "def f():\n" + "# foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + def test_auto_indent_with_multicomment(self): # fmt: off events = code_to_events( @@ -626,6 +666,63 @@ def test_auto_indent_ignore_comments(self): output = multiline_input(reader) self.assertEqual(output, output_code) + def test_dont_indent_hashtag(self): + # fmt: off + events = code_to_events( + "if ' ' == '#':\n" + "pass\n\n" + ) + + output_code = ( + "if ' ' == '#':\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + + def test_dont_indent_in_multiline_string(self): + # fmt: off + events = code_to_events( + "s = '''\n" + "Note:\n" + "'''\n\n" + ) + + output_code = ( + "s = '''\n" + "Note:\n" + "'''" + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + + def test_dont_indent_already_indented(self): + # fmt: off + events = code_to_events( + "def f():\n" + "# foo\n" + "pass\n\n" + ) + + output_code = ( + "def f():\n" + " # foo\n" + " pass\n" + " " + ) + # fmt: on + + reader = self.prepare_reader(events) + output = multiline_input(reader) + self.assertEqual(output, output_code) + class TestPyReplOutput(ScreenEqualMixin, TestCase): def prepare_reader(self, events): diff --git a/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst new file mode 100644 index 00000000000000..fd2bb3f0d98df6 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-10-28-23-14-16.gh-issue-133710.BzRnmu.rst @@ -0,0 +1 @@ +Enhance auto-indent in :mod:`!_pyrepl`.