Skip to main content

typ_buffer/
brackets.rs

1//! Finding the partner of the bracket at a position.
2//!
3//! # Bounded on purpose
4//!
5//! The search takes a line budget and gives up rather than exceeding it. This
6//! runs on the render path โ€” the match is recomputed as the cursor moves โ€” and
7//! architecture ยง4 puts a 16 ms ceiling on a keystroke. An unmatched bracket on
8//! a 50k-line file is a far smaller cost than a scan of it, so the caller passes
9//! its viewport height plus a margin and the answer is "no match" beyond that.
10//!
11//! # Known limitation, until M2.5
12//!
13//! This is a character scan with no idea what a string or a comment is, so the
14//! `(` in `"a ( b"` counts, and a `)` inside a comment can be matched as a
15//! partner. Fixing that needs the syntax tree, which arrives with tree-sitter at
16//! M2.5 โ€” at which point this function takes a predicate for "is this position
17//! code" and the scan is otherwise unchanged. Recorded here rather than left to
18//! be filed as a bug later.
19
20use unicode_segmentation::UnicodeSegmentation;
21
22use crate::buffer::TextBuffer;
23use crate::position::Position;
24
25/// The pairs TYPE matches. Angle brackets are deliberately absent: `<` is a
26/// comparison far more often than a bracket, and highlighting it as one is
27/// wrong more often than it is right.
28const PAIRS: [(char, char); 3] = [('(', ')'), ('[', ']'), ('{', '}')];
29
30fn close_for(open: char) -> Option<char> {
31    PAIRS.iter().find(|(o, _)| *o == open).map(|(_, c)| *c)
32}
33
34fn open_for(close: char) -> Option<char> {
35    PAIRS.iter().find(|(_, c)| *c == close).map(|(o, _)| *o)
36}
37
38/// The character at a grapheme position, if there is one.
39fn char_at(buffer: &TextBuffer, at: Position) -> Option<char> {
40    buffer.with_line_str(at.line, |line| {
41        line.graphemes(true)
42            .nth(at.col)
43            .and_then(|g| g.chars().next())
44    })
45}
46
47/// The matching pair for the bracket at, or immediately before, `at`.
48///
49/// Returns `(open, close)` in document order regardless of which end was found
50/// first, so a caller highlights both without caring which way the scan ran.
51///
52/// Probing both sides of the cursor matters more than it looks: typing `)`
53/// leaves the caret *after* it, which is exactly the moment a user wants to see
54/// what it closed.
55pub fn match_at(
56    buffer: &TextBuffer,
57    at: Position,
58    max_lines: usize,
59) -> Option<(Position, Position)> {
60    let mut probes = Vec::with_capacity(2);
61    probes.push(at);
62    if at.col > 0 {
63        probes.push(Position {
64            line: at.line,
65            col: at.col - 1,
66        });
67    }
68
69    for probe in probes {
70        let Some(ch) = char_at(buffer, probe) else {
71            continue;
72        };
73        if let Some(close) = close_for(ch) {
74            if let Some(found) = scan_forward(buffer, probe, ch, close, max_lines) {
75                return Some((probe, found));
76            }
77        } else if let Some(open) = open_for(ch)
78            && let Some(found) = scan_backward(buffer, probe, open, ch, max_lines)
79        {
80            return Some((found, probe));
81        }
82    }
83    None
84}
85
86/// Walk one line's graphemes in a fixed direction, tracking nesting depth.
87///
88/// Returns the grapheme index of the partner if the depth reached zero on this
89/// line. `depth` is carried across lines by the callers.
90fn scan_line(
91    buffer: &TextBuffer,
92    line: usize,
93    range: impl Iterator<Item = usize>,
94    open: char,
95    close: char,
96    entering: char,
97    depth: &mut usize,
98) -> Option<usize> {
99    // One borrow of the line, then indexed walking. Collecting borrowed slices
100    // rather than owned strings keeps this to a single allocation per line, and
101    // the line budget keeps the number of lines small.
102    buffer.with_line_str(line, |text| {
103        let graphemes: Vec<&str> = text.graphemes(true).collect();
104        for i in range {
105            let Some(c) = graphemes.get(i).and_then(|g| g.chars().next()) else {
106                continue;
107            };
108            if c == entering {
109                *depth += 1;
110            } else if (c == open || c == close) && c != entering {
111                *depth -= 1;
112                if *depth == 0 {
113                    return Some(i);
114                }
115            }
116        }
117        None
118    })
119}
120
121fn scan_forward(
122    buffer: &TextBuffer,
123    from: Position,
124    open: char,
125    close: char,
126    max_lines: usize,
127) -> Option<Position> {
128    let last = (from.line + max_lines).min(buffer.line_count().saturating_sub(1));
129    let mut depth = 1usize;
130
131    for line in from.line..=last {
132        let count = buffer.line_grapheme_count(line);
133        let start = if line == from.line { from.col + 1 } else { 0 };
134        if start >= count {
135            continue;
136        }
137        if let Some(col) = scan_line(buffer, line, start..count, open, close, open, &mut depth) {
138            return Some(Position { line, col });
139        }
140    }
141    None
142}
143
144fn scan_backward(
145    buffer: &TextBuffer,
146    from: Position,
147    open: char,
148    close: char,
149    max_lines: usize,
150) -> Option<Position> {
151    let first = from.line.saturating_sub(max_lines);
152    let mut depth = 1usize;
153
154    for line in (first..=from.line).rev() {
155        let count = buffer.line_grapheme_count(line);
156        let end = if line == from.line { from.col } else { count };
157        if end == 0 {
158            continue;
159        }
160        // Reversed, so the nearest candidate is met first and nesting unwinds
161        // in the order a reader would follow it.
162        if let Some(col) = scan_line(buffer, line, (0..end).rev(), open, close, close, &mut depth) {
163            return Some(Position { line, col });
164        }
165    }
166    None
167}