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