Skip to main content

strop_engine/editor/lsp/
state.rs

1//! Live document bindings and original request ownership. Lifecycle
2//! calls run through the replay tape (R11): model owners update
3//! identically live and replayed; only the native wire work is gated.
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7use strop_core::id::{BufferRevision, ByteColumn, DocumentId, LineIndex};
8use strop_lsp::{
9    Client, ReplyContext, RequestInput, RequestKind, RequestRefusal, RequestStamp, ServerId,
10};
11
12use super::super::Editor;
13use super::attach::AttachState;
14
15pub(crate) struct Binding {
16    pub server: ServerId,
17    pub path: PathBuf,
18    pub root: PathBuf,
19    /// Which filesystem `path` names — remote bindings never alias
20    /// same-bytes local paths (0036 RW8).
21    pub target: strop_workspace::Filesystem,
22    pub revision: BufferRevision,
23}
24
25/// Tape arguments for open/change — identity only; document content
26/// never enters the trace (metadata exports drop content-bearing
27/// fields, and R6 forbids per-change full-text materialization).
28#[derive(Debug, serde::Serialize)]
29pub(crate) struct SyncArgs {
30    pub server: ServerId,
31    pub document: DocumentId,
32    pub revision: BufferRevision,
33    #[serde(with = "strop_core::path_serde")]
34    pub path: PathBuf,
35    pub bytes: usize,
36}
37
38#[derive(Debug, serde::Serialize)]
39pub(crate) struct CloseArgs {
40    pub server: ServerId,
41    pub document: DocumentId,
42    #[serde(with = "strop_core::path_serde")]
43    pub path: PathBuf,
44}
45
46pub(crate) struct LspState {
47    pub bindings: HashMap<DocumentId, Binding>,
48    pub hover: Option<RequestStamp>,
49    pub navigation: Option<RequestStamp>,
50    pub attach: AttachState,
51}
52
53impl Default for LspState {
54    fn default() -> Self {
55        Self {
56            bindings: HashMap::new(),
57            hover: None,
58            navigation: None,
59            attach: AttachState::new(),
60        }
61    }
62}
63
64impl Editor {
65    fn lsp_live_client(&self, server: ServerId) -> Option<Client> {
66        self.lsp_servers
67            .iter()
68            .find(|s| s.id == server)
69            .and_then(|s| s.client.clone())
70    }
71
72    pub(super) fn lsp_did_open_current(&mut self) {
73        let document = self.current();
74        let Some(doc) = self.lsp_current_doc_path() else {
75            return;
76        };
77        let Some(language) = super::lsp_language(&doc.path) else {
78            return;
79        };
80        let Some((server, root)) = self.lsp_server_for(&doc.path, language, &doc.filesystem) else {
81            return;
82        };
83        if let Some(binding) = self.lsp_state.bindings.get(&document) {
84            if binding.server == server
85                && binding.path == doc.path
86                && binding.target == doc.filesystem
87            {
88                return;
89            }
90            self.lsp_close_document(document);
91        }
92        let revision = self.buf().revision();
93        let text = self.buf().snapshot();
94        let args = SyncArgs {
95            server,
96            document,
97            revision,
98            path: doc.path.clone(),
99            bytes: text.len_bytes(),
100        };
101        // Replay reproduces the recorded admission result; the binding
102        // updates identically so injected replies pass freshness.
103        let opened = self.tape.call("lsp.open", &args, || {
104            self.lsp_live_client(server).map(|client| {
105                client.did_open(
106                    document,
107                    revision,
108                    &doc.path,
109                    super::lang_id(&doc.path),
110                    text,
111                )
112            })
113        });
114        match opened {
115            Ok(Some(true)) => {
116                self.lsp_state.bindings.insert(
117                    document,
118                    Binding {
119                        server,
120                        path: doc.path,
121                        root,
122                        target: doc.filesystem,
123                        revision,
124                    },
125                );
126            }
127            // A replayed refusal or a vanished connection: no binding.
128            Ok(_) => {}
129            Err(error) => self.message = format!("lsp open diverged from trace: {error}"),
130        }
131    }
132
133    pub fn lsp_sync_changed(&mut self) {
134        // Journal consumers can edit a non-current document; sync every
135        // live binding in a deterministic order.
136        let mut changed: Vec<_> = self
137            .lsp_state
138            .bindings
139            .iter()
140            .filter_map(|(&id, binding)| {
141                let doc = self.docs.get(id)?;
142                let revision = doc.buf.revision();
143                (revision != binding.revision).then(|| {
144                    (
145                        id,
146                        binding.server,
147                        binding.path.clone(),
148                        revision,
149                        doc.buf.snapshot(),
150                    )
151                })
152            })
153            .collect();
154        changed.sort_by_key(|(id, _, _, _, _)| *id);
155        for (document, server, path, revision, text) in changed {
156            let args = SyncArgs {
157                server,
158                document,
159                revision,
160                path: path.clone(),
161                bytes: text.len_bytes(),
162            };
163            match self.tape.call("lsp.change", &args, || {
164                self.lsp_live_client(server)
165                    .map(|client| client.did_change(document, revision, &path, text))
166            }) {
167                Ok(Some(true)) => {
168                    if let Some(binding) = self.lsp_state.bindings.get_mut(&document) {
169                        binding.revision = revision;
170                    }
171                }
172                Ok(_) => self.message = "lsp: document change refused".into(),
173                Err(error) => self.message = error.to_string(),
174            }
175        }
176    }
177
178    pub(crate) fn lsp_close_document(&mut self, document: DocumentId) {
179        self.diags.remove(&document);
180        if !self.docs.is_empty() && document == self.current() {
181            self.hover_card = None;
182        }
183        // Model owner removal happens in both modes; only the native
184        // didClose notification is gated.
185        if let Some(binding) = self.lsp_state.bindings.remove(&document) {
186            let args = CloseArgs {
187                server: binding.server,
188                document,
189                path: binding.path.clone(),
190            };
191            match self.tape.request("lsp.close", &args) {
192                Ok(true) => {
193                    if let Some(client) = self.lsp_live_client(binding.server) {
194                        client.did_close(document, &binding.path);
195                    }
196                }
197                Ok(false) => {}
198                Err(error) => self.message = format!("lsp close diverged from trace: {error}"),
199            }
200        }
201        if self.lsp_state.hover.is_some_and(|r| r.document == document) {
202            self.lsp_state.hover = None;
203            self.hover_card = None;
204        }
205        if self
206            .lsp_state
207            .navigation
208            .is_some_and(|r| r.document == document)
209        {
210            self.lsp_state.navigation = None;
211        }
212        if self
213            .picker
214            .as_ref()
215            .and_then(|p| p.lsp_context)
216            .is_some_and(|c| c.stamp.document == document)
217        {
218            self.close_picker();
219        }
220        // Closing the last owning remote workspace retires its server.
221        self.lsp_retire_remote_servers();
222    }
223
224    pub(crate) fn lsp_reply_fresh(&self, context: &ReplyContext) -> bool {
225        let stamp = context.stamp;
226        let expected = if context.kind == RequestKind::Hover {
227            self.lsp_state.hover
228        } else {
229            self.lsp_state.navigation
230        };
231        expected == Some(stamp) && self.lsp_context_fresh(context)
232    }
233
234    /// An accepted result may transfer to a picker or I/O ticket after its
235    /// server request is terminal. The original document/server/revision still
236    /// has to be current; the new subsystem owns cancellation after transfer.
237    pub(crate) fn lsp_context_fresh(&self, context: &ReplyContext) -> bool {
238        let stamp = context.stamp;
239        let newer = if context.kind == RequestKind::Hover {
240            self.lsp_state.hover
241        } else {
242            self.lsp_state.navigation
243        };
244        !self.docs.is_empty()
245            && stamp.document == self.current()
246            && newer.is_none_or(|owner| owner == stamp)
247            && self
248                .docs
249                .get(stamp.document)
250                .is_some_and(|d| d.buf.revision() == stamp.revision)
251            && self
252                .lsp_state
253                .bindings
254                .get(&stamp.document)
255                .is_some_and(|b| b.server == stamp.server && b.revision == stamp.revision)
256    }
257
258    pub(super) fn finish_lsp_reply(&mut self, context: &ReplyContext) -> bool {
259        let fresh = self.lsp_reply_fresh(context);
260        let slot = if context.kind == RequestKind::Hover {
261            &mut self.lsp_state.hover
262        } else {
263            &mut self.lsp_state.navigation
264        };
265        if *slot == Some(context.stamp) {
266            *slot = None;
267        }
268        fresh
269    }
270
271    pub(super) fn lsp_request(&mut self, kind: RequestKind) {
272        self.lsp_request_with(kind, None);
273    }
274
275    /// Change-producing requests (0043): rename carries its new name on
276    /// the admitted input so the tape relaunches the identical payload;
277    /// format is document-wide and records the configured tab width on
278    /// the pending request for the same reason.
279    pub(super) fn lsp_change_request(&mut self, kind: RequestKind, rename_to: Option<String>) {
280        self.lsp_request_with(kind, rename_to);
281    }
282
283    fn lsp_request_with(&mut self, kind: RequestKind, rename_to: Option<String>) {
284        let hover = kind == RequestKind::Hover;
285        if hover {
286            self.lsp_state.hover = None;
287        } else {
288            self.lsp_state.navigation = None;
289            self.cancel_open(strop_core::worker::CancelReason::Superseded);
290        }
291        let Some(doc) = self.lsp_current_doc_path() else {
292            self.message =
293                "language services require a complete file buffer, not a partial/follow view"
294                    .into();
295            return;
296        };
297        let Some(language) = super::lsp_language(&doc.path) else {
298            self.message = "no language server for this file type".into();
299            return;
300        };
301        let Some((server, _)) = self.lsp_server_for(&doc.path, language, &doc.filesystem) else {
302            match doc.filesystem {
303                strop_workspace::Filesystem::Local => {
304                    self.message = "no language server — install it or fix languages.toml".into()
305                }
306                strop_workspace::Filesystem::Remote(endpoint) => {
307                    self.message = format!(
308                        "no language server on {endpoint} — install it there or fix languages.toml"
309                    )
310                }
311                strop_workspace::Filesystem::Container(_) => {
312                    self.message = "language services in containers are not wired yet".into()
313                }
314            }
315            return;
316        };
317        self.lsp_did_open_current();
318        self.lsp_sync_changed();
319        let Some(doc) = self.lsp_current_doc_path() else {
320            return;
321        };
322        let line = self.buf().line_of(self.head());
323        let input = RequestInput {
324            document: self.current(),
325            revision: self.buf().revision(),
326            path: doc.path.clone(),
327            line: LineIndex::new(line),
328            byte_col: ByteColumn::new(self.buf().col_of(self.head())),
329            line_text: strop_lsp::FrozenLine::from_slice(
330                self.buf()
331                    .text()
332                    .byte_slice(self.buf().line_start(line)..self.buf().line_end(line)),
333            ),
334            kind,
335            rename_to,
336        };
337        let native_input = input.clone();
338        let prepared = self.tape.call("lsp.prepare", &input, || {
339            let client = self
340                .lsp_live_client(server)
341                .ok_or(RequestRefusal::NotOpen)?;
342            client.prepare_request(native_input)
343        });
344        match prepared {
345            Ok(Ok(mut prepared)) => {
346                if kind == RequestKind::Format {
347                    // Rides the admitted record so replay relaunches the
348                    // identical payload (tab width included).
349                    prepared.tab_width = Some(self.config.tab_size);
350                }
351                // Register the owner stamp before launching; replayed
352                // replies validate against exactly this stamp.
353                if hover {
354                    self.lsp_state.hover = Some(prepared.stamp);
355                } else {
356                    self.lsp_state.navigation = Some(prepared.stamp);
357                }
358                if matches!(
359                    kind,
360                    RequestKind::Locations(_)
361                        | RequestKind::Format
362                        | RequestKind::Rename
363                        | RequestKind::CodeAction
364                ) {
365                    let label = match kind {
366                        RequestKind::Locations(k) => k.label(),
367                        other => other.label(),
368                    };
369                    self.message = format!("lsp: {label} …");
370                }
371                match self.tape.request("lsp.launch", &prepared) {
372                    Ok(true) => {
373                        if let Some(client) = self.lsp_live_client(server) {
374                            client.launch_request(prepared);
375                        }
376                    }
377                    Ok(false) => {}
378                    Err(error) => {
379                        if hover {
380                            self.lsp_state.hover = None;
381                        } else {
382                            self.lsp_state.navigation = None;
383                        }
384                        self.message = format!("lsp request diverged from trace: {error}");
385                    }
386                }
387            }
388            Ok(Err(refusal)) => {
389                self.message = match refusal {
390                    RequestRefusal::NotOpen => {
391                        "lsp: the document is not open on this server".into()
392                    }
393                    RequestRefusal::StaleRevision => format!(
394                        "lsp: buffer changed while syncing — repeat {}",
395                        kind.label()
396                    ),
397                    RequestRefusal::Unsupported => {
398                        format!("lsp: {} is not supported by this server", kind.label())
399                    }
400                    RequestRefusal::IdentityExhausted => "lsp: request identities exhausted".into(),
401                };
402            }
403            Err(error) => self.message = format!("lsp prepare diverged from trace: {error}"),
404        }
405    }
406
407    pub(super) fn lsp_failed(&mut self, server: ServerId) {
408        let mut docs: Vec<_> = self
409            .lsp_state
410            .bindings
411            .iter()
412            .filter_map(|(&id, b)| (b.server == server).then_some(id))
413            .collect();
414        docs.sort();
415        for document in docs {
416            self.lsp_close_document(document);
417        }
418        self.lsp_state
419            .attach
420            .attached
421            .retain(|a| a.server != server);
422        if let Some(index) = self.lsp_servers.iter().position(|s| s.id == server) {
423            let connection = self.lsp_servers.remove(index);
424            if let Some(client) = connection.client {
425                // Joining a dead/failed server never blocks the input thread.
426                std::thread::spawn(move || {
427                    client.shutdown();
428                    client.wait(std::time::Duration::from_secs(2));
429                });
430            }
431        }
432    }
433}