From cc50e8682efb47a9cd0ac372f6e4e651e0495adf Mon Sep 17 00:00:00 2001 From: johnslavik Date: Wed, 21 Oct 2026 07:45:21 -0700 Subject: [PATCH] Highlight matching delimiters in the REPL --- Doc/tutorial/appendix.rst | 6 +- Lib/_colorize.py | 1 + Lib/_pyrepl/reader.py | 65 ++++- Lib/_pyrepl/simple_interact.py | 1 + Lib/_pyrepl/utils.py | 88 +++++- Lib/test/test_pyrepl/test_interact.py | 45 ++- Lib/test/test_pyrepl/test_reader.py | 274 +++++++++++++++++- Lib/test/test_pyrepl/test_utils.py | 222 +++++++++++++- ...0-00.gh-issue-153401.matching-brackets.rst | 3 + 9 files changed, 689 insertions(+), 16 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-20-21-00-00.gh-issue-153401.matching-brackets.rst diff --git a/Doc/tutorial/appendix.rst b/Doc/tutorial/appendix.rst index 2539edb51ba3a09..0630bdc28d18832 100644 --- a/Doc/tutorial/appendix.rst +++ b/Doc/tutorial/appendix.rst @@ -17,7 +17,11 @@ control capabilities. Since Python 3.13, a new interactive shell is used by default. This one supports color, multiline editing, history browsing, and paste mode. To disable color, see :ref:`using-on-controlling-color` for -details. Function keys provide some additional functionality. +details. When the cursor touches a parenthesis, bracket, brace, or string +quote, its matching delimiter is highlighted. This includes triple quotes +and expressions in formatted and template strings. Delimiters within +string contents and comments are ignored. Function keys provide some +additional functionality. :kbd:`F1` enters the interactive help browser :mod:`pydoc`. :kbd:`F2` allows for browsing command-line history with neither output nor the :term:`>>>` and :term:`...` prompts. :kbd:`F3` enters "paste mode", which diff --git a/Lib/_colorize.py b/Lib/_colorize.py index cfea60310ad55bc..f2f244860e0a1b8 100644 --- a/Lib/_colorize.py +++ b/Lib/_colorize.py @@ -412,6 +412,7 @@ class Syntax(ThemeSection): number: str = ANSIColors.YELLOW op: str = ANSIColors.RESET definition: str = ANSIColors.BOLD + matching_delimiter: str = ANSIColors.INTENSE_BACKGROUND_BLACK soft_keyword: str = ANSIColors.BOLD_BLUE reset: str = ANSIColors.RESET diff --git a/Lib/_pyrepl/reader.py b/Lib/_pyrepl/reader.py index d552214f322d76d..556e5dd01596253 100644 --- a/Lib/_pyrepl/reader.py +++ b/Lib/_pyrepl/reader.py @@ -37,7 +37,10 @@ from .layout import LayoutMap, LayoutResult, LayoutRow, WrappedRow, layout_content_lines from .layout import wrapped_row_end from .render import RenderCell, RenderLine, RenderedScreen, ScreenOverlay -from .utils import ANSI_ESCAPE_SEQUENCE, ColorSpan, THEME, StyleRef, gen_colors +from .utils import ( + ANSI_ESCAPE_SEQUENCE, ColorSpan, THEME, StyleRef, + find_matching_delimiter, gen_colors, +) from .trace import trace @@ -304,6 +307,7 @@ class Reader: cxy: CursorXY = field(init=False) lxy: CursorXY = field(init=False) scheduled_commands: list[CommandName] = field(default_factory=list) + _matching_delimiter: tuple[int, int] | None = field(init=False) can_colorize: bool = False gen_colors: Callable[[str], Iterator[ColorSpan]] = gen_colors threading_hook: Callback | None = None @@ -386,6 +390,7 @@ def __post_init__(self) -> None: self.input_trans = input.KeymapTranslator( self.keymap, invalid_cls="invalid-key", character_cls="self-insert" ) + self._matching_delimiter = None self.layout = LayoutMap.empty() self.cxy = self.pos2xy() self.lxy = (self.pos, 0) @@ -429,9 +434,8 @@ def calc_screen(self) -> RenderedScreen: self, reuse_full=True, ) - assert not self.last_refresh_cache.line_end_offsets or ( - self.last_refresh_cache.line_end_offsets[-1] >= len(self.buffer) - ), "Buffer modified without invalidate_buffer() call" + # A short screen can cache only a prefix of the buffer. + # get_cached_location() then falls back to a full rebuild. else: offset, num_common_lines = self.last_refresh_cache.get_cached_location( self, @@ -602,8 +606,51 @@ def _render_message_lines(self) -> tuple[RenderLine, ...]: def get_screen_overlays(self) -> tuple[ScreenOverlay, ...]: return () + def _get_matching_delimiter(self) -> tuple[int, int] | None: + if not self.can_colorize or self.finished: + return None + if not any( + 0 <= i < len(self.buffer) and self.buffer[i] in "()[]{}\"'" + for i in (self.pos, self.pos - 1) + ): + return None + return find_matching_delimiter(self.get_unicode(), self.pos) + + def _matching_delimiter_overlays( + self, base_screen: RenderedScreen + ) -> tuple[ScreenOverlay, ...]: + if ( + self._matching_delimiter is None + or not self.last_refresh_cache.line_end_offsets + ): + return () + + changed_rows: dict[int, list[RenderCell]] = {} + start, end = self._matching_delimiter + for offset in range(start, end): + # pos_to_xy() clamps positions beyond the cached layout. + if offset >= self.last_refresh_cache.line_end_offsets[-1]: + break + # The delimiter's end disambiguates a wrap boundary. + x, y = self.layout.pos_to_xy(offset + 1) + cells = changed_rows.setdefault(y, list(base_screen.lines[y].cells)) + column = 0 + for i, cell in enumerate(cells): + if column == x - 1 and cell.width: + cells[i] = replace( + cell, style=StyleRef.from_tag("matching_delimiter") + ) + break + column += cell.width + return tuple( + ScreenOverlay(y, (RenderLine.from_cells(cells),)) + for y, cells in changed_rows.items() + ) + def compose_rendered_screen(self, base_screen: RenderedScreen) -> RenderedScreen: - overlays = list(self.get_screen_overlays()) + self._matching_delimiter = self._get_matching_delimiter() + overlays = list(self._matching_delimiter_overlays(base_screen)) + overlays.extend(self.get_screen_overlays()) message_lines = self._render_message_lines() if message_lines: overlays.append(ScreenOverlay(len(base_screen.lines), message_lines)) @@ -826,6 +873,7 @@ def prepare(self) -> None: self.finished = False del self.buffer[:] self.pos = 0 + self._matching_delimiter = None self.layout = LayoutMap.empty() self.cxy = self.pos2xy() self.lxy = (self.pos, 0) @@ -892,6 +940,11 @@ def error(self, msg: str = "none") -> None: self.invalidate_message() def update_screen(self) -> None: + if ( + not self.invalidation.needs_screen_refresh + and self._get_matching_delimiter() != self._matching_delimiter + ): + self.invalidate_overlay() if self.invalidation.is_cursor_only: self.update_cursor() self.clear_invalidation() @@ -939,12 +992,12 @@ def do_cmd(self, cmd: CommandInput) -> None: and not self.invalidation.is_cursor_only ): self.invalidate_cursor() + self.finished = bool(command.finish) self.update_screen() if command_type is not commands.digit_arg: self.last_command = command_type - self.finished = bool(command.finish) if self.finished: self.console.finish() self.finish() diff --git a/Lib/_pyrepl/simple_interact.py b/Lib/_pyrepl/simple_interact.py index 5a7c8c20202640b..4e90c533c75c0f6 100644 --- a/Lib/_pyrepl/simple_interact.py +++ b/Lib/_pyrepl/simple_interact.py @@ -159,6 +159,7 @@ def maybe_run_command(statement: str) -> bool: r.cmpltn_reset() if r.input_trans is r.isearch_trans: r.do_cmd(("isearch-end", [""])) + r.finished = True r.pos = len(r.get_unicode()) r.invalidate_full() r.refresh() diff --git a/Lib/_pyrepl/utils.py b/Lib/_pyrepl/utils.py index e77001f6a1d10bb..4327c3fcf0cf376 100644 --- a/Lib/_pyrepl/utils.py +++ b/Lib/_pyrepl/utils.py @@ -116,12 +116,7 @@ def unbracket(s: str, including_content: bool = False) -> str: return s.translate(ZERO_WIDTH_TRANS) -def gen_colors(buffer: str) -> Iterator[ColorSpan]: - """Returns a list of index spans to color using the given color tag. - - The input `buffer` should be a valid start of a Python code block, i.e. - it cannot be a block starting in the middle of a multiline string. - """ +def _get_token_stream(buffer: str) -> tuple[Iterator[TI], list[int]]: sio = StringIO(buffer) line_lengths = [0] + [len(line) for line in sio.readlines()] # make line_lengths cumulative @@ -130,6 +125,87 @@ def gen_colors(buffer: str) -> Iterator[ColorSpan]: sio.seek(0) gen = tokenize.generate_tokens(sio.readline) + return gen, line_lengths + + +def find_matching_delimiter(buffer: str, pos: int) -> tuple[int, int] | None: + """Return the half-open span of the partner touching the cursor.""" + # A delimiter under the cursor takes precedence, even inside literal data. + candidate = next(( + i for i in (pos, pos - 1) + if 0 <= i < len(buffer) and buffer[i] in "()[]{}\"'" + ), None) + if candidate is None: + return None + + gen, line_lengths = _get_token_stream(buffer) + try: + return _find_matching_delimiter_from_token_stream( + gen, line_lengths, candidate + ) + except SyntaxError, tokenize.TokenError: + # Incomplete input can still contain complete pairs. + return None + + +def _find_matching_delimiter_from_token_stream( + token_generator: Iterator[TI], + line_lengths: list[int], + candidate: int, +) -> tuple[int, int] | None: + brackets: list[tuple[str, int]] = [] + strings: list[tuple[int, int]] = [] + closing = {")": "(", "]": "[", "}": "{"} + for token in token_generator: + if token.type == T.ERRORTOKEN and token.string in {"\"", "'"}: + # An unfinished short string can expose apparent operators. + break + offset = line_lengths[token.start[0] - 1] + token.start[1] + pair = None + match token.type: + case T.STRING | T.FSTRING_START | T.TSTRING_START: + # Token boundaries already account for prefixes and escapes. + quote_index = next( + i for i, c in enumerate(token.string) if c in "\"'" + ) + delimiter = token.string[quote_index] + width = 3 if token.string.startswith(delimiter * 3, quote_index) else 1 + opening = (offset + quote_index, offset + quote_index + width) + if token.type == T.STRING: + end = line_lengths[token.end[0] - 1] + token.end[1] + pair = (opening, (end - width, end)) + else: + strings.append(opening) + case T.FSTRING_END | T.TSTRING_END: + opening = strings.pop() + pair = (opening, (offset, offset + len(token.string))) + case T.OP if token.string in "()[]{}": + if token.string in "([{": + brackets.append((token.string, offset)) + elif brackets and brackets[-1][0] == closing[token.string]: + _, opening_offset = brackets.pop() + pair = ( + (opening_offset, opening_offset + 1), (offset, offset + 1) + ) + else: + # Do not pair across an incorrectly nested delimiter. + brackets.clear() + if pair is not None: + opening, ending = pair + if opening[0] <= candidate < opening[1]: + return ending + if ending[0] <= candidate < ending[1]: + return opening + return None + + +def gen_colors(buffer: str) -> Iterator[ColorSpan]: + """Returns a list of index spans to color using the given color tag. + + The input `buffer` should be a valid start of a Python code block, i.e. + it cannot be a block starting in the middle of a multiline string. + """ + gen, line_lengths = _get_token_stream(buffer) last_emitted: ColorSpan | None = None try: for color in gen_colors_from_token_stream(gen, line_lengths): diff --git a/Lib/test/test_pyrepl/test_interact.py b/Lib/test/test_pyrepl/test_interact.py index 673827608abc477..cb439c0a84ad25f 100644 --- a/Lib/test/test_pyrepl/test_interact.py +++ b/Lib/test/test_pyrepl/test_interact.py @@ -8,9 +8,52 @@ from test.support import force_not_colorized from _pyrepl.console import InteractiveColoredConsole -from _pyrepl.simple_interact import _more_lines +from _pyrepl.simple_interact import _more_lines, run_multiline_interactive_console +from .support import FakeConsole, prepare_reader class TestSimpleInteract(unittest.TestCase): + def test_keyboard_interrupt_clears_matching_delimiter_overlay(self): + backend = FakeConsole(()) + reader = prepare_reader(backend) + reader.can_colorize = True + reader.buffer = list("(x)") + reader.pos = 0 + reader.refresh() + + matching_cells = [ + cell + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual([cell.text for cell in matching_cells], [")"]) + + console = InteractiveColoredConsole() + reader.push_input_trans(reader.isearch_trans) + with ( + patch("_pyrepl.readline._setup"), + patch("_pyrepl.simple_interact._get_reader", return_value=reader), + patch( + "_pyrepl.simple_interact.multiline_input", + side_effect=[KeyboardInterrupt, EOFError], + ), + patch.object(console, "runsource", wraps=console.runsource) as runsource, + contextlib.redirect_stderr(io.StringIO()) as stderr, + ): + run_multiline_interactive_console(console) + + self.assertEqual("".join(reader.buffer), "(x)") + self.assertFalse( + any( + cell.style.tag == "matching_delimiter" + for line in reader.rendered_screen.composed_lines + for cell in line.cells + ) + ) + self.assertIn("KeyboardInterrupt", stderr.getvalue()) + runsource.assert_not_called() + def test_multiple_statements(self): namespace = {} code = dedent("""\ diff --git a/Lib/test/test_pyrepl/test_reader.py b/Lib/test/test_pyrepl/test_reader.py index fd9900d9a32b3d2..6b5215369bc856e 100644 --- a/Lib/test/test_pyrepl/test_reader.py +++ b/Lib/test/test_pyrepl/test_reader.py @@ -35,6 +35,218 @@ def prepare_reader_multiline_prompt(*args, **kwargs): @force_not_colorized_test_class class TestReader(ScreenEqualMixin, TestCase): + def test_buffer_replacement_and_cursor_moves(self): + reader = prepare_reader(prepare_console((), width=80)) + reader.can_colorize = True + reader.buffer = list("()[]") + reader.refresh() + self.assertEqual(reader._matching_delimiter, (1, 2)) + reader.invalidate_full() + reader.refresh() + reader.do_cmd(("right", [])) + reader.do_cmd(("right", [])) + self.assertEqual(reader._matching_delimiter, (3, 4)) + + # Replacing the list with different text of the same length must + # update delimiter matching without relying on edit commands. + reader.buffer = list("[{}]") + reader.invalidate_full() + reader.refresh() + self.assertEqual(reader._matching_delimiter, (1, 2)) + + def test_custom_colorizer_receives_original_source(self): + reader = prepare_reader(prepare_console((), width=80)) + reader.buffer = list("(value)") + reader.gen_colors = MagicMock(return_value=iter(())) + reader.can_colorize = False + reader.refresh() + reader.gen_colors.assert_not_called() + reader.can_colorize = True + reader.invalidate_full() + reader.refresh() + reader.gen_colors.assert_called_once_with("(value)") + self.assertEqual(reader._matching_delimiter, (6, 7)) + + def test_matching_delimiter_overlay_and_finished_state(self): + console = prepare_console((), width=80) + reader = prepare_reader(console) + reader.can_colorize = True + reader.buffer = list("(value)") + reader.pos = 0 + + composed = reader.compose_rendered_screen(reader.calc_screen()) + matching_cells = [ + cell + for overlay in composed.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual([cell.text for cell in matching_cells], [")"]) + + reader.finished = True + composed = reader.compose_rendered_screen(reader.calc_screen()) + self.assertFalse(composed.overlays) + + def test_matching_delimiter_overlay_tracks_cursor_and_accept(self): + console = prepare_console((), width=80) + reader = prepare_reader(console) + reader.can_colorize = True + reader.buffer = list("()[]") + reader.refresh() + self.assertEqual(reader._matching_delimiter, (1, 2)) + + reader.do_cmd(("right", [])) + self.assertEqual(reader._matching_delimiter, (0, 1)) + reader.do_cmd(("right", [])) + self.assertEqual(reader._matching_delimiter, (3, 4)) + reader.do_cmd(("accept", [])) + self.assertIsNone(reader._matching_delimiter) + self.assertFalse(reader.rendered_screen.overlays) + + def test_matching_delimiter_overlay_disappears_after_moving_inside(self): + console = prepare_console((), width=80) + reader = prepare_reader(console) + reader.can_colorize = True + reader.buffer = list("(value)") + reader.refresh() + self.assertEqual(reader._matching_delimiter, (6, 7)) + + reader.do_cmd(("right", [])) + self.assertEqual(reader._matching_delimiter, (6, 7)) + reader.do_cmd(("right", [])) + self.assertIsNone(reader._matching_delimiter) + self.assertFalse(reader.rendered_screen.overlays) + + def test_matching_delimiter_overlay_respects_color_and_wrapping(self): + console = prepare_console((), width=4) + reader = prepare_reader(console) + reader.can_colorize = True + reader.buffer = list("(樂)") + reader.pos = 0 + reader.refresh() + matching_cells = [ + cell + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual([cell.text for cell in matching_cells], [")"]) + + reader.can_colorize = False + reader.refresh() + self.assertFalse(reader.rendered_screen.overlays) + + def test_matching_delimiter_overlay_highlights_entire_triple_quote(self): + console = prepare_console((), width=2) + reader = prepare_reader(console) + reader.can_colorize = True + reader.buffer = list('"""x"""') + original_buffer = reader.buffer.copy() + reader.pos = 0 + reader.refresh() + + matching_cells = [ + cell + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual([cell.text for cell in matching_cells], ['"', '"', '"']) + self.assertEqual( + len({overlay.y for overlay in reader.rendered_screen.overlays}), 3 + ) + self.assertEqual(reader.buffer, original_buffer) + + def test_matching_delimiter_overlay_cursor_moves_on_short_screen(self): + console = prepare_console((), width=80) + console.height = 3 + reader = prepare_reader(console) + reader.can_colorize = True + buffer = list("(x)\n" + "x\n" * 8) + reader.buffer = buffer.copy() + reader.pos = 0 + + reader.refresh() + def matching_texts(): + return [ + cell.text + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + + self.assertEqual(matching_texts(), [")"]) + reader.do_cmd(("right", [])) + self.assertEqual(matching_texts(), [")"]) + reader.do_cmd(("right", [])) + self.assertEqual(matching_texts(), ["("]) + reader.do_cmd(("right", [])) + self.assertEqual(matching_texts(), ["("]) + reader.do_cmd(("right", [])) + self.assertFalse(matching_texts()) + + self.assertEqual(reader.buffer, buffer) + + def test_matching_delimiter_overlay_ignores_unrendered_partner(self): + console = prepare_console((), width=80) + console.height = 3 + reader = prepare_reader(console) + reader.can_colorize = True + buffer = list("(\n" + "x\n" * 8 + ")") + reader.buffer = buffer.copy() + reader.pos = 0 + + reader.refresh() + matching_cells = [ + cell + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertFalse(matching_cells) + + console.height = 20 + reader.refresh() + matching_cells = [ + cell + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual([cell.text for cell in matching_cells], [")"]) + self.assertEqual(reader.buffer, buffer) + + def test_matching_delimiter_triple_quote_beyond_cached_layout(self): + console = prepare_console((), width=80) + console.height = 3 + reader = prepare_reader(console) + reader.can_colorize = True + source = '"""\n' + 'text\n' * 8 + '"""' + reader.buffer = list(source) + reader.pos = 0 + reader.refresh() + self.assertFalse(reader.rendered_screen.overlays) + self.assertLess(reader.last_refresh_cache.line_end_offsets[-1], + len(source)) + + console.height = 20 + reader.refresh() + matching = [ + cell.text + for overlay in reader.rendered_screen.overlays + for line in overlay.lines + for cell in line.cells + if cell.style.tag == "matching_delimiter" + ] + self.assertEqual(matching, ['"'] * 3) + self.assertEqual("".join(reader.buffer), source) + def assert_multiline_prompt_screen(self, code, expected_screen, expected_cxy): reader, _ = handle_all_events( code_to_events(code), @@ -562,6 +774,62 @@ def test_no_completion_matches_message(self): @force_colorized_test_class class TestReaderInColor(ScreenEqualMixin, TestCase): + def test_matching_delimiter_emits_theme_style(self): + cases = [ + # The opener is the last body cell before the wrap marker. + ("ab(cd)", 5, 4, "(", (2, 0)), + # The closer is the first cell on the following row. + ("(ab)tail", 0, 4, ")", (0, 1)), + ("(e\N{COMBINING ACUTE ACCENT})", 0, 80, ")", (2, 0)), + ] + for code, pos, width, target, expected_xy in cases: + with self.subTest(code=code, width=width): + console = prepare_console((), width=width) + reader = prepare_reader(console) + reader.buffer = list(code) + reader.pos = pos + reader.refresh() + rendered = "\n".join(reader.rendered_screen.screen_lines) + self.assertIn( + default_theme.syntax["matching_delimiter"] + target, rendered + ) + matching_overlay = next( + overlay + for overlay in reader.rendered_screen.overlays + if any( + cell.style.tag == "matching_delimiter" + for line in overlay.lines + for cell in line.cells + ) + ) + matching_line = matching_overlay.lines[0] + matching_index = next( + i + for i, cell in enumerate(matching_line.cells) + if cell.style.tag == "matching_delimiter" + ) + matching_column = sum( + cell.width for cell in matching_line.cells[:matching_index] + ) + self.assertEqual( + (matching_column, matching_overlay.y), expected_xy + ) + + def test_matching_delimiter_with_multiline_prompt(self): + console = prepare_console((), width=80) + reader = prepare_reader(console) + del reader.get_prompt + reader.ps1 = reader.ps2 = "Python 3.16\n>>> " + reader.ps3 = reader.ps4 = "... " + reader.buffer = list("(value)") + reader.pos = 0 + reader.refresh() + + self.assertIn( + default_theme.syntax["matching_delimiter"] + ")", + "\n".join(reader.rendered_screen.screen_lines), + ) + def test_syntax_highlighting_basic(self): code = dedent( """\ @@ -759,4 +1027,8 @@ def test_control_characters(self): events = code_to_events(code) reader, _ = handle_all_events(events) self.assert_screen_equal(reader, 'flag = "🏳️\\u200d🌈"', clean=True) - self.assert_screen_equal(reader, 'flag {o}={z} {s}"🏳️\\u200d🌈"{z}'.format(**colors)) + expected = 'flag {o}={z} {matching_delimiter}"{z}{s}🏳️\\u200d🌈"{z}'.format( + **colors, + matching_delimiter=default_theme.syntax["matching_delimiter"], + ) + self.assert_screen_equal(reader, expected) diff --git a/Lib/test/test_pyrepl/test_utils.py b/Lib/test/test_pyrepl/test_utils.py index e87d14304c82257..34d98dcaabd0703 100644 --- a/Lib/test/test_pyrepl/test_utils.py +++ b/Lib/test/test_pyrepl/test_utils.py @@ -1,9 +1,229 @@ from unittest import TestCase -from _pyrepl.utils import str_width, wlen, prev_next_window, gen_colors +from _pyrepl.utils import ( + str_width, wlen, prev_next_window, gen_colors, + find_matching_delimiter, +) class TestUtils(TestCase): + def test_incomplete_input_colors_and_delimiters(self): + cases = [ + ('"unfinished', [(0, 10, "string")]), + ('f"unfinished', [(0, 1, "string"), (2, 11, "string")]), + ('t"unfinished', [(0, 1, "string"), (2, 11, "string")]), + ('"""unfinished', [(0, 12, "string")]), + ('f"""unfinished', [(0, 3, "string"), (4, 13, "string")]), + ('t"""unfinished', [(0, 3, "string"), (4, 13, "string")]), + ] + for buffer, expected_colors in cases: + with self.subTest(buffer=buffer): + self.assertEqual( + [(c.span.start, c.span.end, c.tag) + for c in gen_colors(buffer)], + expected_colors, + ) + quote = buffer.index('"') + self.assertIsNone(find_matching_delimiter(buffer, quote)) + + buffer = '(value) """unfinished' + self.assertEqual( + [(c.span.start, c.span.end, c.tag) + for c in gen_colors(buffer)], + [(0, 0, "op"), (6, 6, "op"), (8, 20, "string")], + ) + self.assertEqual(find_matching_delimiter(buffer, 0), (6, 7)) + quote = buffer.index('"') + self.assertIsNone(find_matching_delimiter(buffer, quote)) + + def test_find_matching_delimiter(self): + cases = [ + ("(value)", 0, (6, 7)), + ("(value)", 6, (0, 1)), + ("(樂)", 0, (2, 3)), + ("(樂)", 2, (0, 1)), + ("[({value})]", 2, (8, 9)), + ("[({value})]", 8, (2, 3)), + ("call(\n value\n)", 4, (16, 17)), + # The cursor may be just after a bracket. + ("(value)", 7, (0, 1)), + # Prefer a bracket at the cursor over one immediately before it. + ("()[]", 1, (0, 1)), + ("()[]", 2, (3, 4)), + # An unmatched bracket at the cursor takes precedence over one + # immediately before it. + ("()(", 2, None), + # A later lexical error or mismatched bracket does not discard an + # earlier complete pair. + ("(value) [", 0, (6, 7)), + ("(value) ([)]", 0, (6, 7)), + ] + for buffer, pos, expected in cases: + with self.subTest(buffer=buffer, pos=pos): + self.assertEqual(find_matching_delimiter(buffer, pos), expected) + + def test_find_matching_delimiter_ignores_strings_and_comments(self): + cases = [ + ('"( text )"', 1, None), + ("# ( comment )", 3, None), + ('"a \\\" (still string)"', 8, None), + ("(\"[\" + value)", 0, (12, 13)), + ] + for buffer, pos, expected in cases: + with self.subTest(buffer=buffer, pos=pos): + self.assertEqual(find_matching_delimiter(buffer, pos), expected) + + def test_find_matching_delimiter_f_string_expression(self): + buffer = 'f"{mapping[\'key\']}"' + self.assertEqual(find_matching_delimiter(buffer, 16), (10, 11)) + + # Python 3.12+ permits a string with the same quote style inside an + # f-string expression; the inner pair and outer pair stay distinct. + buffer = 'f"{mapping["key"]}"' + inner_open = buffer.index('"', 2) + inner_close = buffer.index('"', inner_open + 1) + outer_close = buffer.rindex('"') + self.assertEqual(find_matching_delimiter(buffer, inner_open), + (inner_close, inner_close + 1)) + self.assertEqual(find_matching_delimiter(buffer, 1), + (outer_close, outer_close + 1)) + + def test_find_matching_delimiter_f_and_t_string_braces(self): + for buffer in ('f"{{literal}}"', 't"{{literal}}"'): + with self.subTest(buffer=buffer): + self.assertIsNone(find_matching_delimiter(buffer, 2)) + + buffer = 't"{mapping[1]}"' + self.assertEqual( + find_matching_delimiter(buffer, buffer.index("[")), + (buffer.index("]"), buffer.index("]") + 1), + ) + + def test_find_matching_delimiter_t_string_quotes(self): + buffer = 't"{mapping[1]}"' + opening = buffer.index('"') + closing = buffer.rindex('"') + self.assertEqual(find_matching_delimiter(buffer, opening), + (closing, closing + 1)) + self.assertEqual(find_matching_delimiter(buffer, closing), + (opening, opening + 1)) + + def test_find_matching_delimiter_formatted_string_quotes(self): + for prefix in ("f", "t", "fr", "rt"): + for quote in ("'", '"', "'" * 3, '"' * 3): + buffer = prefix + quote + "value" + quote + width = len(quote) + opening = len(prefix) + closing = len(buffer) - width + with self.subTest(buffer=buffer): + for pos in range(opening, opening + width + 1): + self.assertEqual(find_matching_delimiter(buffer, pos), + (closing, closing + width)) + for pos in range(closing, closing + width + 1): + self.assertEqual(find_matching_delimiter(buffer, pos), + (opening, opening + width)) + + for prefix in ("f", "t"): + buffer = prefix + '"{' + prefix + '"value"}"' + with self.subTest(buffer=buffer): + self.assertEqual(find_matching_delimiter(buffer, 1), + (len(buffer) - 1, len(buffer))) + self.assertEqual(find_matching_delimiter(buffer, 4), (10, 11)) + self.assertEqual(find_matching_delimiter(buffer, 10), (4, 5)) + + def test_find_matching_delimiter_complete_quote_before_error(self): + for buffer in ('"value" "unfinished', 'f"value" "unfinished'): + opening = buffer.index('"') + closing = buffer.index('"', opening + 1) + with self.subTest(buffer=buffer): + self.assertEqual(find_matching_delimiter(buffer, opening), + (closing, closing + 1)) + + def test_find_matching_delimiter_ignores_unterminated_strings(self): + cases = [ + ('"(fake)', 2), + ('"""(fake)', 3), + ("'''[fake]", 3), + ] + for buffer, pos in cases: + with self.subTest(buffer=buffer): + self.assertIsNone(find_matching_delimiter(buffer, pos)) + + def test_find_matching_delimiter_string_vertical_tab_and_form_feed(self): + for control in ("\v", "\f"): + buffer = f'"before{control}after" + (value)' + opening = buffer.index("(") + with self.subTest(control=repr(control)): + self.assertEqual( + find_matching_delimiter(buffer, opening), + (buffer.index(")"), buffer.index(")") + 1), + ) + + def test_find_matching_delimiter_unmatched_or_mismatched(self): + for buffer, pos in [("(", 0), ("([)]", 0), ("x)", 1), ("()(", 2)]: + with self.subTest(buffer=buffer, pos=pos): + self.assertIsNone(find_matching_delimiter(buffer, pos)) + + def test_find_matching_delimiter_string_quotes(self): + for quote in ("'", '"', "'''", '"""'): + buffer = quote + "value" + quote + width = len(quote) + opening = 0 + closing = len(buffer) - width + with self.subTest(buffer=buffer): + for pos in range(opening, opening + width): + self.assertEqual(find_matching_delimiter(buffer, pos), + (closing, closing + width)) + self.assertEqual(find_matching_delimiter(buffer, opening + width), + (closing, closing + width)) + for pos in range(closing, closing + width): + self.assertEqual(find_matching_delimiter(buffer, pos), + (opening, opening + width)) + self.assertEqual(find_matching_delimiter(buffer, closing + width), + (opening, opening + width)) + + def test_find_matching_delimiter_complete_string_tokens(self): + for token in ( + 'r"raw value"', 'b"bytes"', 'u"unicode"', + 'br"raw bytes"', 'Rb"raw bytes"', '"a"', + ): + with self.subTest(token=token): + # Prefixes do not change the quoted token's delimiter span. + opening = token.index('"') + closing = token.rindex('"') + for pos in range(opening, opening + 2): + self.assertEqual(find_matching_delimiter(token, pos), + (closing, closing + 1)) + for pos in range(closing, closing + 2): + self.assertEqual(find_matching_delimiter(token, pos), + (opening, opening + 1)) + + for token in ('""', "''"): + with self.subTest(token=token): + self.assertEqual(find_matching_delimiter(token, 0), (1, 2)) + self.assertEqual(find_matching_delimiter(token, 2), (0, 1)) + + adjacent = '"first" "second"' + with self.subTest(token=adjacent): + self.assertEqual(find_matching_delimiter(adjacent, 0), (6, 7)) + self.assertEqual(find_matching_delimiter(adjacent, 8), (15, 16)) + + def test_find_matching_delimiter_raw_candidate_wins_previous(self): + # The raw delimiter under the cursor takes precedence even when it is + # unmatched or belongs to a string literal. + self.assertIsNone(find_matching_delimiter('()"', 2)) + self.assertEqual(find_matching_delimiter('("x"', 1), (3, 4)) + + def test_find_matching_delimiter_unfinished_or_escaped_quotes(self): + for buffer in ('"unfinished', "'unfinished", '"escaped \\\" quote'): + with self.subTest(buffer=buffer): + self.assertIsNone(find_matching_delimiter(buffer, 0)) + + buffer = '"escaped \\\" quote"' + escaped_quote = buffer.index('"', 1) + with self.subTest(buffer=buffer, pos=escaped_quote): + self.assertIsNone(find_matching_delimiter(buffer, escaped_quote)) + def test_str_width(self): characters = [ 'a', diff --git a/Misc/NEWS.d/next/Library/2026-10-20-21-00-00.gh-issue-153401.matching-brackets.rst b/Misc/NEWS.d/next/Library/2026-10-20-21-00-00.gh-issue-153401.matching-brackets.rst new file mode 100644 index 000000000000000..a6dbcc212791e3a --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-20-21-00-00.gh-issue-153401.matching-brackets.rst @@ -0,0 +1,3 @@ +The interactive shell now highlights the matching parenthesis, bracket, +brace, or string quote when the cursor touches a delimiter, including +triple quotes. Patch by Bartosz Sławecki.