Skip to main content

reovim_server/session/
syntax_state.rs

1//! Syntax highlighting state per session.
2//!
3//! Core driver storage (`SyntaxSessionState`) lives in `reovim-driver-syntax`.
4//! This module provides:
5//! - Re-export of `SyntaxSessionState` for backward-compatible imports
6//! - `SyntaxStreamState` for token update streaming to gRPC clients
7//!
8//! # Architecture
9//!
10//! ```text
11//! Session ExtensionMap
12//!   ├─ SyntaxSessionState (from reovim-driver-syntax)
13//!   │    └─ HashMap<BufferId, Box<dyn SyntaxDriver>>
14//!   └─ SyntaxStreamState (this module)
15//!        └─ Vec<TokenSubscriber>
16//! ```
17
18// Re-export core syntax state from driver crate for backward compatibility.
19pub use reovim_driver_syntax::SyntaxSessionState;
20
21use {
22    reovim_driver_session::SessionExtension,
23    reovim_driver_syntax::SyntaxEdit,
24    reovim_kernel::api::v1::BufferId,
25    reovim_protocol::v2::{TokenSpan, TokenUpdate},
26    tokio::sync::mpsc,
27};
28
29/// Subscription handle for token update streams.
30pub type TokenSubscriber = mpsc::Sender<TokenUpdate>;
31
32/// Per-session token streaming state stored in `ExtensionMap`.
33///
34/// Manages subscriber channels for clients that want real-time token updates
35/// via the `StreamTokens` gRPC endpoint.
36///
37/// # Separation from `SyntaxSessionState`
38///
39/// Core driver storage lives in `reovim-driver-syntax` (accessible to modules).
40/// This type handles server-only streaming infrastructure that depends on
41/// `tokio` and `reovim-protocol` (not available to modules).
42#[derive(Default)]
43pub struct SyntaxStreamState {
44    /// Token update subscribers (streaming clients).
45    subscribers: Vec<TokenSubscriber>,
46}
47
48impl SessionExtension for SyntaxStreamState {
49    fn create() -> Self {
50        Self::default()
51    }
52}
53
54impl SyntaxStreamState {
55    /// Create a new empty stream state.
56    #[must_use]
57    pub fn new() -> Self {
58        Self::default()
59    }
60
61    /// Subscribe to token updates.
62    ///
63    /// Returns a receiver that will receive `TokenUpdate` messages when
64    /// buffers are modified and re-tokenized.
65    ///
66    /// # Channel Size
67    ///
68    /// The channel has a buffer of 16 messages. If a client falls behind,
69    /// older updates may be dropped.
70    #[must_use]
71    pub fn subscribe(&mut self) -> mpsc::Receiver<TokenUpdate> {
72        let (tx, rx) = mpsc::channel(16);
73        self.subscribers.push(tx);
74        rx
75    }
76
77    /// Get the number of active subscribers.
78    #[must_use]
79    pub const fn subscriber_count(&self) -> usize {
80        self.subscribers.len()
81    }
82
83    /// Check if there are no subscribers.
84    #[must_use]
85    pub const fn has_subscribers(&self) -> bool {
86        !self.subscribers.is_empty()
87    }
88
89    /// Broadcast a token update to all subscribers.
90    ///
91    /// Removes disconnected subscribers automatically.
92    pub fn broadcast(&mut self, update: &TokenUpdate) {
93        self.subscribers
94            .retain(|tx| tx.try_send(update.clone()).is_ok());
95    }
96
97    /// Notify subscribers of a buffer edit.
98    ///
99    /// This method:
100    /// 1. Updates the syntax driver incrementally via `driver.update()`
101    /// 2. Gets updated tokens for the affected region
102    /// 3. Broadcasts `TokenUpdate` to all subscribers
103    ///
104    /// # Arguments
105    ///
106    /// * `syntax` - The syntax session state containing drivers
107    /// * `buffer_id` - The buffer that was modified
108    /// * `content` - The full buffer content after the edit
109    /// * `edit` - The edit description for incremental parsing
110    /// * `start_line` - First line affected by the edit (for `TokenUpdate`)
111    /// * `end_line` - Last line affected by the edit (for `TokenUpdate`)
112    #[allow(clippy::cast_possible_truncation)]
113    pub fn notify_edit(
114        &mut self,
115        syntax: &mut SyntaxSessionState,
116        buffer_id: BufferId,
117        content: &str,
118        edit: &SyntaxEdit,
119        start_line: u64,
120        end_line: u64,
121    ) {
122        // Get the driver for this buffer
123        let Some(driver) = syntax.get_mut(buffer_id) else {
124            return; // No driver for this buffer
125        };
126
127        // Update driver incrementally
128        driver.update(content, edit);
129
130        // If no subscribers, skip token extraction
131        if self.subscribers.is_empty() {
132            return;
133        }
134
135        // Get tokens for the affected region (with some context)
136        // Use byte range from the edit, with padding for context
137        let start_byte = edit.start_byte.saturating_sub(100);
138        let end_byte = (edit.new_end_byte + 100).min(content.len());
139
140        // Re-acquire immutable reference after mutable borrow ended
141        let Some(driver) = syntax.get(buffer_id) else {
142            return;
143        };
144        let mut highlights = driver.highlights(start_byte..end_byte);
145        highlights.extend(driver.decorations(start_byte..end_byte));
146
147        // Convert to TokenSpan
148        let tokens: Vec<TokenSpan> = highlights
149            .into_iter()
150            .map(|span| TokenSpan {
151                start_byte: span.start_byte as u32,
152                end_byte: span.end_byte as u32,
153                category: span.category.to_string(),
154            })
155            .collect();
156
157        // Build the update message
158        let update = TokenUpdate {
159            buffer_id: buffer_id.as_usize() as u64,
160            tokens,
161            start_line,
162            end_line,
163            full_refresh: false,
164            layer: "syntax".into(),
165            priority: 0,
166        };
167
168        // Broadcast to subscribers (remove disconnected ones)
169        self.subscribers
170            .retain(|tx| tx.try_send(update.clone()).is_ok());
171    }
172
173    /// Send a full token refresh for a buffer.
174    ///
175    /// Call this when a new subscriber connects or when a buffer's language changes.
176    #[allow(clippy::cast_possible_truncation)]
177    pub fn send_full_refresh(
178        &mut self,
179        syntax: &SyntaxSessionState,
180        buffer_id: BufferId,
181        total_lines: u64,
182    ) {
183        let Some(driver) = syntax.get(buffer_id) else {
184            return;
185        };
186
187        if self.subscribers.is_empty() {
188            return;
189        }
190
191        // Get all highlights and decorations
192        let mut highlights = driver.highlights(0..usize::MAX);
193        highlights.extend(driver.decorations(0..usize::MAX));
194
195        // Convert to TokenSpan
196        let tokens: Vec<TokenSpan> = highlights
197            .into_iter()
198            .map(|span| TokenSpan {
199                start_byte: span.start_byte as u32,
200                end_byte: span.end_byte as u32,
201                category: span.category.to_string(),
202            })
203            .collect();
204
205        let update = TokenUpdate {
206            buffer_id: buffer_id.as_usize() as u64,
207            tokens,
208            start_line: 0,
209            end_line: total_lines.saturating_sub(1),
210            full_refresh: true,
211            layer: "syntax".into(),
212            priority: 0,
213        };
214
215        // Broadcast to subscribers
216        self.subscribers
217            .retain(|tx| tx.try_send(update.clone()).is_ok());
218    }
219}
220
221/// Compute the end position (row, col) after inserting text starting at (`start_row`, `start_col`).
222///
223/// Handles multi-line text by counting newlines and tracking the final line's column.
224#[must_use]
225pub fn compute_end_position(start_row: u32, start_col: u32, text: &str) -> (u32, u32) {
226    let mut row = start_row;
227    let mut col = start_col;
228    for ch in text.chars() {
229        if ch == '\n' {
230            row += 1;
231            col = 0;
232        } else {
233            col += 1;
234        }
235    }
236    (row, col)
237}
238
239/// Convert a kernel `Modification` to a syntax driver `SyntaxEdit`.
240///
241/// Returns `None` for `FullReplace` (requires full reparse, not incremental edit).
242///
243/// This function lives in the server layer because it bridges two crate boundaries:
244/// `Modification` from `reovim-kernel` and `SyntaxEdit` from `reovim-driver-syntax`.
245/// Placing it on `Modification` directly would violate kernel purity.
246#[must_use]
247pub fn modification_to_syntax_edit(
248    modification: &reovim_kernel::api::v1::events::kernel::Modification,
249) -> Option<SyntaxEdit> {
250    use reovim_kernel::api::v1::events::kernel::Modification;
251
252    match modification {
253        Modification::Insert {
254            start,
255            text,
256            start_byte,
257        } => {
258            let new_end_byte = start_byte + text.len();
259            let (new_end_row, new_end_col) = compute_end_position(start.0, start.1, text);
260            Some(SyntaxEdit::insert(
261                *start_byte,
262                start.0,
263                start.1,
264                new_end_byte,
265                new_end_row,
266                new_end_col,
267            ))
268        }
269        Modification::Delete {
270            start,
271            end,
272            text,
273            start_byte,
274        } => {
275            let old_end_byte = start_byte + text.len();
276            Some(SyntaxEdit::delete(*start_byte, start.0, start.1, old_end_byte, end.0, end.1))
277        }
278        Modification::Replace {
279            start,
280            end,
281            old_text,
282            new_text,
283            start_byte,
284        } => {
285            let old_end_byte = start_byte + old_text.len();
286            let new_end_byte = start_byte + new_text.len();
287            let (new_end_row, new_end_col) = compute_end_position(start.0, start.1, new_text);
288            Some(SyntaxEdit::new(
289                *start_byte,
290                old_end_byte,
291                new_end_byte,
292                start.0,
293                start.1,
294                end.0,
295                end.1,
296                new_end_row,
297                new_end_col,
298            ))
299        }
300        Modification::FullReplace => None,
301    }
302}
303
304/// Build a `TokenUpdate` from a syntax driver's current highlights.
305///
306/// This is a standalone function to avoid double-borrow issues when
307/// both `SyntaxSessionState` and `SyntaxStreamState` are in the same
308/// `ExtensionMap`. Call this after updating the driver, then pass the
309/// result to `SyntaxStreamState::broadcast()`.
310#[must_use]
311#[allow(clippy::cast_possible_truncation)]
312pub fn build_token_update(
313    syntax: &SyntaxSessionState,
314    buffer_id: BufferId,
315    total_lines: u64,
316    full_refresh: bool,
317) -> Option<TokenUpdate> {
318    let driver = syntax.get(buffer_id)?;
319    let mut highlights = driver.highlights(0..usize::MAX);
320    highlights.extend(driver.decorations(0..usize::MAX));
321
322    let tokens: Vec<TokenSpan> = highlights
323        .into_iter()
324        .map(|span| TokenSpan {
325            start_byte: span.start_byte as u32,
326            end_byte: span.end_byte as u32,
327            category: span.category.to_string(),
328        })
329        .collect();
330
331    Some(TokenUpdate {
332        buffer_id: buffer_id.as_usize() as u64,
333        tokens,
334        start_line: 0,
335        end_line: total_lines.saturating_sub(1),
336        full_refresh,
337        layer: "syntax".into(),
338        priority: 0,
339    })
340}
341
342impl std::fmt::Debug for SyntaxStreamState {
343    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
344        f.debug_struct("SyntaxStreamState")
345            .field("subscriber_count", &self.subscribers.len())
346            .finish()
347    }
348}
349
350#[cfg(test)]
351#[path = "syntax_state_tests.rs"]
352mod tests;