Skip to main content

reovim_tui_mod_pair/
lib.rs

1#![cfg_attr(coverage_nightly, allow(unused_features))]
2#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
3//! Bracket pair highlighting module.
4//!
5//! Receives bracket state from the server's pair bridge and renders:
6//! - Rainbow bracket coloring (6-color depth cycling)
7//! - Matched-pair highlighting (bold + underline)
8//! - Unmatched bracket warning (red + underline)
9//!
10//! Migrated from `PairExtension` (`TuiExtension`) to native
11//! `ClientModule` as part of M6 (#637).
12
13use std::collections::HashMap;
14
15use reovim_client_driver::{
16    Attributes, ClientModule, ClientModuleError, InlineDecoration, ModuleContext, ProbeResult,
17    Style, Version,
18};
19
20use {reovim_arch::Color, serde::Deserialize};
21
22/// Rainbow color palette (6 colors, cycling via `depth % 6`).
23const RAINBOW_COLORS: [Color; 6] = [
24    Color::Rgb {
25        r: 255,
26        g: 215,
27        b: 0,
28    }, // Gold
29    Color::Rgb {
30        r: 218,
31        g: 112,
32        b: 214,
33    }, // Orchid
34    Color::Rgb {
35        r: 0,
36        g: 191,
37        b: 255,
38    }, // Deep sky blue
39    Color::Rgb {
40        r: 50,
41        g: 205,
42        b: 50,
43    }, // Lime green
44    Color::Rgb {
45        r: 255,
46        g: 127,
47        b: 80,
48    }, // Coral
49    Color::Rgb {
50        r: 147,
51        g: 112,
52        b: 219,
53    }, // Medium purple
54];
55
56/// Color for unmatched brackets.
57const UNMATCHED_COLOR: Color = Color::Rgb {
58    r: 255,
59    g: 80,
60    b: 80,
61};
62
63/// Number of rainbow colors.
64const RAINBOW_COUNT: usize = RAINBOW_COLORS.len();
65
66#[derive(Debug, Deserialize)]
67struct BracketEntry {
68    line: u32,
69    col: u32,
70    depth: u64,
71    #[allow(dead_code)]
72    char: String,
73    unmatched: bool,
74}
75
76#[derive(Debug, Deserialize)]
77struct MatchedEntry {
78    open: PosEntry,
79    close: PosEntry,
80}
81
82#[derive(Debug, Deserialize)]
83struct PosEntry {
84    line: u32,
85    col: u32,
86}
87
88#[derive(Deserialize)]
89struct PairNotification {
90    #[serde(default)]
91    active: bool,
92    #[serde(default = "default_true")]
93    rainbow: bool,
94    #[serde(default = "default_true")]
95    matchpair: bool,
96    #[serde(default)]
97    brackets: Vec<BracketEntry>,
98    #[serde(default)]
99    matched: Option<MatchedEntry>,
100}
101
102const fn default_true() -> bool {
103    true
104}
105
106/// Bracket pair highlighting module.
107///
108/// Renders rainbow-colored brackets and matched-pair highlighting
109/// via inline decorations. Style-only overlays — no text replacement.
110///
111/// Kind: `"pair"` (matches server's pair bridge).
112pub struct PairModule {
113    active: bool,
114    rainbow_enabled: bool,
115    matchpair_enabled: bool,
116    brackets: Vec<BracketEntry>,
117    matched: Option<MatchedEntry>,
118    /// Cached inline decorations grouped by line.
119    decorations_by_line: HashMap<usize, Vec<InlineDecoration>>,
120}
121
122impl PairModule {
123    #[must_use]
124    pub fn new() -> Self {
125        Self {
126            active: false,
127            rainbow_enabled: true,
128            matchpair_enabled: true,
129            brackets: Vec::new(),
130            matched: None,
131            decorations_by_line: HashMap::new(),
132        }
133    }
134
135    /// Rebuild the cached inline decorations from current state.
136    #[allow(clippy::cast_possible_truncation)]
137    fn rebuild_decorations(&mut self) {
138        self.decorations_by_line.clear();
139
140        if !self.active {
141            return;
142        }
143
144        // Rainbow brackets
145        if self.rainbow_enabled {
146            for bracket in &self.brackets {
147                let style = if bracket.unmatched {
148                    let mut attrs = Attributes::new();
149                    attrs.set(Attributes::UNDERLINE);
150                    Style {
151                        fg: Some(UNMATCHED_COLOR),
152                        attributes: attrs,
153                        ..Style::default()
154                    }
155                } else {
156                    let color_idx = (bracket.depth as usize) % RAINBOW_COUNT;
157                    Style {
158                        fg: Some(RAINBOW_COLORS[color_idx]),
159                        ..Style::default()
160                    }
161                };
162
163                self.decorations_by_line
164                    .entry(bracket.line as usize)
165                    .or_default()
166                    .push(InlineDecoration {
167                        col_start: bracket.col as u16,
168                        col_end: bracket.col as u16 + 1,
169                        style,
170                    });
171            }
172        }
173
174        // Matched pair highlight
175        if self.matchpair_enabled
176            && let Some(ref matched) = self.matched
177        {
178            let mut attrs = Attributes::new();
179            attrs.set(Attributes::BOLD | Attributes::UNDERLINE);
180            let style = Style {
181                attributes: attrs,
182                ..Style::default()
183            };
184
185            self.decorations_by_line
186                .entry(matched.open.line as usize)
187                .or_default()
188                .push(InlineDecoration {
189                    col_start: matched.open.col as u16,
190                    col_end: matched.open.col as u16 + 1,
191                    style: style.clone(),
192                });
193
194            self.decorations_by_line
195                .entry(matched.close.line as usize)
196                .or_default()
197                .push(InlineDecoration {
198                    col_start: matched.close.col as u16,
199                    col_end: matched.close.col as u16 + 1,
200                    style,
201                });
202        }
203    }
204}
205
206impl Default for PairModule {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212#[cfg_attr(coverage_nightly, coverage(off))]
213impl ClientModule for PairModule {
214    fn id(&self) -> &'static str {
215        "pair"
216    }
217
218    fn kind(&self) -> &'static str {
219        "pair"
220    }
221
222    fn name(&self) -> &'static str {
223        "Bracket Pair"
224    }
225
226    fn version(&self) -> Version {
227        Version::new(0, 1, 0)
228    }
229
230    fn init(&mut self, _ctx: &ModuleContext) -> ProbeResult {
231        ProbeResult::Success
232    }
233
234    fn exit(&mut self) -> Result<(), ClientModuleError> {
235        Ok(())
236    }
237
238    fn has_buffer_contrib(&self) -> bool {
239        self.active
240    }
241
242    fn on_notification(&mut self, data: &str) {
243        let Ok(notification) = serde_json::from_str::<PairNotification>(data) else {
244            return;
245        };
246
247        self.active = notification.active;
248        self.rainbow_enabled = notification.rainbow;
249        self.matchpair_enabled = notification.matchpair;
250        self.brackets = notification.brackets;
251        self.matched = notification.matched;
252
253        self.rebuild_decorations();
254    }
255
256    fn inline_decorations(&self, line: usize) -> &[InlineDecoration] {
257        self.decorations_by_line
258            .get(&line)
259            .map_or(&[], Vec::as_slice)
260    }
261}
262
263#[cfg(test)]
264mod tests;