lattice_lsp/sync.rs
1//! Document synchronisation: `didOpen` / `didChange` (incremental
2//! or full) / `didClose`. One [`DocSync`] is owned per actor;
3//! it shadows every buffer the server cares about with a string
4//! mirror so we can translate `Position { line, byte }` to the
5//! negotiated LSP encoding without re-querying the editor's rope.
6//!
7//! ## Pure state, separated I/O
8//!
9//! `DocSync` holds no `ServerHandle` and performs no I/O. Every
10//! mutating method either updates the mirror in place (no return
11//! value) or returns the LSP params the caller should ship over
12//! the wire. The owning actor sends the params via its writer
13//! task. This keeps the type:
14//!
15//! - **Standalone-testable.** Tests construct a `DocSync`, call
16//! `record_edit` / `take_flush_payload` etc., and assert on
17//! the returned params. No mock server needed.
18//! - **Lock-free at the call site.** The actor mutates its own
19//! `DocSync` without contending with the supervisor or the UI
20//! thread.
21//! - **Encoding-explicit.** `record_edit` and the flush helpers
22//! take `&Capabilities` so the converter knows whether to walk
23//! utf-16 columns. Pre-this-refactor it pulled this off a
24//! `ServerHandle` borrow; explicit parameter is cleaner.
25//!
26//! ## Lifecycle
27//!
28//! ```text
29//! editor DocSync actor (sends)
30//! ------ ------- -------------
31//! open file -----> open(uri, lang, text) --> didOpen
32//! apply_edit Ok -----> record_edit(caps, uri, edit)
33//! [mirror updated, change queued]
34//! ...50ms idle...
35//! flush(uri) -----> take_flush_payload(caps, uri) -> didChange
36//! bdelete -----> close(uri) --> didClose
37//! ```
38//!
39//! `record_edit` does not eagerly produce a flush payload. The
40//! editor batches edits between flushes; one keystroke commits
41//! one edit but generates one queued change event, sized down
42//! to the affected range. The flush cadence is the actor's
43//! choice; the per-actor select! loop sets ~50ms idle as the
44//! default (matching common LSP client conventions).
45//!
46//! ## Sync mode honour
47//!
48//! - `Incremental`: queued change events are sent verbatim.
49//! - `Full`: queued events are dropped; the entire post-edit
50//! mirror text is sent as one change. Most modern servers
51//! (rust-analyzer, pyright, gopls, clangd, tsserver) advertise
52//! Incremental; Full is the LSP 3.0 fallback.
53//! - `None`: didChange is a no-op. Some servers prefer pull-based
54//! diagnostics and don't want continuous text sync.
55//!
56//! ## Mirror cost
57//!
58//! One `String` per attached buffer per server. For a typical
59//! editor session with a handful of open files this is a few MB
60//! at worst. Not a `ropey::Rope` because the LSP layer doesn't
61//! benefit from `O(log n)` edits -- we only ever splice one
62//! contiguous region per edit, and indexing into a String by
63//! line is `O(n)` but rare (only the affected lines of the
64//! edit).
65
66use std::collections::HashMap;
67use std::str::FromStr;
68
69use lattice_protocol::edit::{Edit, EditKind};
70use lattice_protocol::position::{Position, Range};
71use lsp_types::Range as LspRange;
72use lsp_types::{
73 DidChangeTextDocumentParams, DidCloseTextDocumentParams, DidOpenTextDocumentParams,
74 PositionEncodingKind, TextDocumentContentChangeEvent, TextDocumentIdentifier, TextDocumentItem,
75 TextDocumentSyncKind, Uri, VersionedTextDocumentIdentifier,
76};
77
78use crate::capabilities::Capabilities;
79use crate::error::{LspError, LspResult};
80use crate::position::byte_to_lsp_character;
81
82/// What [`DocSync::close`] returns: an optional final `didChange`
83/// (any pending edits the actor should flush before announcing
84/// close) plus the `didClose` itself. The actor sends them in
85/// order so the server's last view of the doc matches the
86/// editor's.
87#[derive(Debug)]
88pub struct ClosePayloads {
89 pub final_changes: Option<DidChangeTextDocumentParams>,
90 pub close: DidCloseTextDocumentParams,
91}
92
93/// Per-document mirror state, keyed by URI inside [`DocSync`].
94///
95/// Holds a String mirror so the converter has access to BEFORE-
96/// state line text without recomputing from the editor's rope on
97/// every edit.
98struct DocState {
99 /// LSP `languageId` from `didOpen`. Held for re-emitting on a
100 /// language-server restart (4.1.b crash recovery hooks): we
101 /// re-issue didOpen with the same language id for every doc
102 /// the supervisor was tracking.
103 #[allow(dead_code)]
104 language_id: String,
105 /// LSP `version`. Bumped on every committed edit. LSP requires
106 /// monotonic increase; we start at 1 to match the convention
107 /// most servers expect (initial didOpen = 1).
108 version: i32,
109 /// Mirrors the editor's buffer text. Kept in sync by every
110 /// `record_edit`. For Full sync mode this becomes the full
111 /// payload of `didChange`.
112 text: String,
113 /// Queued change events. Cleared by `flush`.
114 pending: Vec<TextDocumentContentChangeEvent>,
115}
116
117/// Per-actor document-sync state. Pure state + pure methods --
118/// no I/O, no `ServerHandle` dependency. The owning actor sends
119/// the LSP params returned by `open` / `take_flush_payload` /
120/// `close` over the wire via its writer task.
121///
122/// Mutating methods either update the mirror in place or return
123/// the LSP params the caller must ship. `record_edit` and the
124/// flush helpers take `&Capabilities` so the position-encoding
125/// converter knows whether to walk utf-16 columns; pre-this-
126/// refactor it pulled the encoding off a `ServerHandle` borrow.
127pub struct DocSync {
128 docs: HashMap<Uri, DocState>,
129}
130
131impl Default for DocSync {
132 fn default() -> Self {
133 Self::new()
134 }
135}
136
137impl DocSync {
138 /// Empty state. Use [`Self::open`] to bring a buffer under
139 /// management.
140 pub fn new() -> Self {
141 Self {
142 docs: HashMap::new(),
143 }
144 }
145
146 /// Bring a buffer under management with `language_id` and
147 /// initial `text`. Returns the `didOpen` params the caller
148 /// ships over the wire. Pure: state mutation + payload
149 /// construction, no I/O.
150 pub fn open(
151 &mut self,
152 uri: Uri,
153 language_id: impl Into<String>,
154 text: impl Into<String>,
155 ) -> DidOpenTextDocumentParams {
156 let language_id = language_id.into();
157 let text = text.into();
158 let version: i32 = 1;
159 let params = DidOpenTextDocumentParams {
160 text_document: TextDocumentItem {
161 uri: uri.clone(),
162 language_id: language_id.clone(),
163 version,
164 text: text.clone(),
165 },
166 };
167 self.docs.insert(
168 uri,
169 DocState {
170 language_id,
171 version,
172 text,
173 pending: Vec::new(),
174 },
175 );
176 params
177 }
178
179 /// Record one committed edit. Updates the mirror, builds the
180 /// LSP change event for queued send, and bumps the version.
181 /// Does NOT produce a flush payload -- the caller drains via
182 /// [`Self::take_flush_payload`] when ready (debounced or
183 /// eager). Returns `Err` only when the URI isn't open or the
184 /// edit's range falls outside the mirror.
185 pub fn record_edit(
186 &mut self,
187 capabilities: &Capabilities,
188 uri: &Uri,
189 edit: &Edit,
190 ) -> LspResult<()> {
191 let encoding = capabilities.position_encoding.clone();
192 let state = self
193 .docs
194 .get_mut(uri)
195 .ok_or_else(|| LspError::HandshakeFailed(format!("doc not open: {}", uri.as_str())))?;
196
197 // Translate the edit's lattice range into LSP coordinates
198 // against the BEFORE-state mirror.
199 let lsp_range = lattice_range_to_lsp(&state.text, edit.range, &encoding);
200 let new_text = match &edit.kind {
201 EditKind::Replace { text } => text.as_str(),
202 };
203
204 // Apply to mirror. We need the byte offset of the range
205 // inside the FULL text (not just within the line). Walk
206 // line starts to find it.
207 let (start_byte, end_byte) = byte_range_in_full_text(&state.text, edit.range);
208 // Defensive bound: an edit whose range falls outside the
209 // mirror's current text would corrupt the mirror. Skip
210 // it and surface a clear error.
211 if start_byte > state.text.len() || end_byte > state.text.len() || start_byte > end_byte {
212 return Err(LspError::HandshakeFailed(format!(
213 "edit range {:?} out of mirror bounds (len {})",
214 edit.range,
215 state.text.len()
216 )));
217 }
218 state.text.replace_range(start_byte..end_byte, new_text);
219 state.version = state.version.saturating_add(1);
220
221 // Queue the change event.
222 state.pending.push(TextDocumentContentChangeEvent {
223 range: Some(lsp_range),
224 range_length: None,
225 text: new_text.to_string(),
226 });
227 Ok(())
228 }
229
230 /// Drain queued change events into a `didChange` payload.
231 /// Returns `None` when the queue is empty (no-op flush) or
232 /// the URI isn't open. Honours the negotiated sync mode:
233 ///
234 /// - `Incremental`: payload contains the queued events
235 /// verbatim.
236 /// - `Full`: payload is one synthetic change carrying the
237 /// entire post-edit mirror text (LSP convention for full
238 /// sync).
239 /// - `None`: pending is cleared; returns `None` (caller
240 /// skips the wire).
241 pub fn take_flush_payload(
242 &mut self,
243 capabilities: &Capabilities,
244 uri: &Uri,
245 ) -> Option<DidChangeTextDocumentParams> {
246 let kind = capabilities
247 .text_document_sync_kind()
248 .unwrap_or(TextDocumentSyncKind::FULL);
249 let state = self.docs.get_mut(uri)?;
250 if state.pending.is_empty() {
251 return None;
252 }
253
254 let changes: Vec<TextDocumentContentChangeEvent> = match kind {
255 TextDocumentSyncKind::INCREMENTAL => std::mem::take(&mut state.pending),
256 TextDocumentSyncKind::FULL => {
257 state.pending.clear();
258 vec![TextDocumentContentChangeEvent {
259 range: None,
260 range_length: None,
261 text: state.text.clone(),
262 }]
263 }
264 // None or unknown: clear pending, return nothing.
265 _ => {
266 state.pending.clear();
267 return None;
268 }
269 };
270
271 Some(DidChangeTextDocumentParams {
272 text_document: VersionedTextDocumentIdentifier {
273 uri: uri.clone(),
274 version: state.version,
275 },
276 content_changes: changes,
277 })
278 }
279
280 /// Drain every open doc's pending changes into per-URI
281 /// `didChange` payloads. Used by the actor's debounce timer +
282 /// by editor shutdown so the server sees a coherent final
283 /// state before `didClose`.
284 pub fn take_flush_all_payloads(
285 &mut self,
286 capabilities: &Capabilities,
287 ) -> Vec<(Uri, DidChangeTextDocumentParams)> {
288 let uris: Vec<Uri> = self.docs.keys().cloned().collect();
289 let mut out = Vec::new();
290 for uri in uris {
291 if let Some(params) = self.take_flush_payload(capabilities, &uri) {
292 out.push((uri, params));
293 }
294 }
295 out
296 }
297
298 /// Drop the mirror for `uri` and return the `didClose`
299 /// payload (paired with any final flush payload the actor
300 /// should send first). Returns `None` when the URI wasn't
301 /// open. The actor sends the optional `final_changes`
302 /// followed by the `close` notification so the server's
303 /// last view of the doc matches the editor's.
304 pub fn close(&mut self, capabilities: &Capabilities, uri: &Uri) -> Option<ClosePayloads> {
305 let final_changes = self.take_flush_payload(capabilities, uri);
306 if self.docs.remove(uri).is_some() {
307 Some(ClosePayloads {
308 final_changes,
309 close: DidCloseTextDocumentParams {
310 text_document: TextDocumentIdentifier { uri: uri.clone() },
311 },
312 })
313 } else {
314 None
315 }
316 }
317
318 /// True iff the URI is currently open under this DocSync.
319 pub fn is_open(&self, uri: &Uri) -> bool {
320 self.docs.contains_key(uri)
321 }
322
323 /// The version we'll attach to the next `didChange` for
324 /// `uri` if no further edits land before flush. Used by
325 /// tests + diagnostics gating (drop diagnostics older than
326 /// our version).
327 pub fn version(&self, uri: &Uri) -> Option<i32> {
328 self.docs.get(uri).map(|d| d.version)
329 }
330
331 /// True iff at least one queued change exists for `uri`.
332 pub fn has_pending(&self, uri: &Uri) -> bool {
333 self.docs
334 .get(uri)
335 .map(|d| !d.pending.is_empty())
336 .unwrap_or(false)
337 }
338}
339
340/// Convert a lattice `Position` to an LSP `Position`, given the
341/// line text from the BEFORE-state mirror.
342fn lattice_position_to_lsp(
343 line_text: &str,
344 pos: Position,
345 encoding: &PositionEncodingKind,
346) -> lsp_types::Position {
347 lsp_types::Position {
348 line: pos.line,
349 character: byte_to_lsp_character(line_text, pos.byte, encoding),
350 }
351}
352
353/// Convert a lattice `Range` to an LSP `Range`. Reads the start
354/// line and end line from the mirror to handle multi-byte
355/// characters when utf-16 is the negotiated encoding.
356fn lattice_range_to_lsp(
357 full_text: &str,
358 range: Range,
359 encoding: &PositionEncodingKind,
360) -> LspRange {
361 let start_line = nth_line(full_text, range.start.line);
362 let end_line = if range.start.line == range.end.line {
363 start_line
364 } else {
365 nth_line(full_text, range.end.line)
366 };
367 LspRange {
368 start: lattice_position_to_lsp(start_line, range.start, encoding),
369 end: lattice_position_to_lsp(end_line, range.end, encoding),
370 }
371}
372
373/// Borrow the `n`th line of `text` (0-based), without the
374/// trailing newline. Returns `""` if `n` is past the last line.
375fn nth_line(text: &str, n: u32) -> &str {
376 let mut current_line: u32 = 0;
377 let mut start = 0usize;
378 for (i, b) in text.bytes().enumerate() {
379 if current_line == n {
380 // Walk to the end of this line.
381 let mut end = i + 1;
382 while end <= text.len() {
383 if end == text.len() || text.as_bytes()[end - 1] == b'\n' {
384 break;
385 }
386 end += 1;
387 }
388 // start..end now spans this line including the
389 // trailing '\n' (if any). Strip it.
390 let mut line_end = end.min(text.len());
391 while line_end > start && (text.as_bytes()[line_end - 1] == b'\n') {
392 line_end -= 1;
393 }
394 // Strip trailing '\r' for CRLF lines.
395 while line_end > start && text.as_bytes()[line_end - 1] == b'\r' {
396 line_end -= 1;
397 }
398 return &text[start..line_end];
399 }
400 if b == b'\n' {
401 current_line += 1;
402 start = i + 1;
403 }
404 }
405 if current_line == n {
406 // Last line, no trailing newline.
407 let mut line_end = text.len();
408 while line_end > start && text.as_bytes()[line_end - 1] == b'\r' {
409 line_end -= 1;
410 }
411 return &text[start..line_end];
412 }
413 ""
414}
415
416/// Compute the (start_byte, end_byte) of `range` inside `text`,
417/// where range is in (line, byte_within_line) units.
418fn byte_range_in_full_text(text: &str, range: Range) -> (usize, usize) {
419 let start = line_byte_to_text_byte(text, range.start);
420 let end = line_byte_to_text_byte(text, range.end);
421 (start, end)
422}
423
424fn line_byte_to_text_byte(text: &str, pos: Position) -> usize {
425 let mut current_line: u32 = 0;
426 for (i, b) in text.bytes().enumerate() {
427 if current_line == pos.line {
428 return (i + pos.byte as usize).min(text.len());
429 }
430 if b == b'\n' {
431 current_line += 1;
432 }
433 }
434 if current_line == pos.line {
435 return (text.len()).min(text.len() + pos.byte as usize);
436 }
437 text.len()
438}
439
440/// Helper for callers: parse a filesystem path into an LSP `Uri`.
441/// Re-exported here so consumers don't need to import the
442/// internal `actor::uri_from_path` helper.
443pub fn uri_from_str(s: &str) -> LspResult<Uri> {
444 Uri::from_str(s).map_err(|e| LspError::HandshakeFailed(format!("invalid URI {s:?}: {e:?}")))
445}
446
447#[cfg(test)]
448mod tests {
449 use super::*;
450
451 #[test]
452 fn nth_line_returns_lf_separated_segments() {
453 let text = "line0\nline1\nline2";
454 assert_eq!(nth_line(text, 0), "line0");
455 assert_eq!(nth_line(text, 1), "line1");
456 assert_eq!(nth_line(text, 2), "line2");
457 assert_eq!(nth_line(text, 3), "");
458 }
459
460 #[test]
461 fn nth_line_strips_crlf() {
462 let text = "first\r\nsecond";
463 assert_eq!(nth_line(text, 0), "first");
464 assert_eq!(nth_line(text, 1), "second");
465 }
466
467 #[test]
468 fn nth_line_handles_trailing_newline() {
469 let text = "x\n";
470 assert_eq!(nth_line(text, 0), "x");
471 assert_eq!(nth_line(text, 1), "");
472 }
473
474 #[test]
475 fn byte_range_translates_simple_position() {
476 let text = "abc\ndef\nghi";
477 let r = Range::new(Position::new(1, 0), Position::new(1, 3));
478 // Line 1 starts at byte 4 ("def"), 0..3 = bytes 4..7
479 assert_eq!(byte_range_in_full_text(text, r), (4, 7));
480 }
481
482 #[test]
483 fn lattice_range_to_lsp_is_byte_for_byte_in_utf8_mode() {
484 let text = "abc\ndéf"; // line 1 has multi-byte 'é'
485 let r = Range::new(Position::new(1, 0), Position::new(1, 4));
486 let lsp = lattice_range_to_lsp(text, r, &PositionEncodingKind::UTF8);
487 assert_eq!(lsp.start.line, 1);
488 assert_eq!(lsp.start.character, 0);
489 assert_eq!(lsp.end.line, 1);
490 // utf-8: character == byte; "dé" is 3 bytes, 'd' + 2-byte 'é'.
491 assert_eq!(lsp.end.character, 4);
492 }
493
494 #[test]
495 fn lattice_range_to_lsp_converts_to_utf16_when_negotiated() {
496 let text = "abc\ndéf";
497 let r = Range::new(Position::new(1, 0), Position::new(1, 4));
498 let lsp = lattice_range_to_lsp(text, r, &PositionEncodingKind::UTF16);
499 // utf-16: "déf" is 3 code units (d=1, é=1, f=1).
500 // byte 4 = past 'f' = utf-16 column 3.
501 assert_eq!(lsp.end.character, 3);
502 }
503
504 #[test]
505 fn lattice_range_to_lsp_utf16_with_emoji() {
506 // 😀 is 4 utf-8 bytes, 2 utf-16 code units.
507 let text = "x😀y";
508 // byte 5 = past '😀'
509 let r = Range::new(Position::new(0, 0), Position::new(0, 5));
510 let lsp = lattice_range_to_lsp(text, r, &PositionEncodingKind::UTF16);
511 // utf-16: 'x' (1) + '😀' (2) = 3 code units.
512 assert_eq!(lsp.start.character, 0);
513 assert_eq!(lsp.end.character, 3);
514 }
515
516 #[test]
517 fn lattice_range_to_lsp_handles_cross_line_range() {
518 let text = "first\nsecond\nthird";
519 let r = Range::new(Position::new(0, 5), Position::new(2, 0));
520 let lsp = lattice_range_to_lsp(text, r, &PositionEncodingKind::UTF8);
521 assert_eq!(lsp.start.line, 0);
522 assert_eq!(lsp.start.character, 5);
523 assert_eq!(lsp.end.line, 2);
524 assert_eq!(lsp.end.character, 0);
525 }
526}