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