Skip to main content

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}