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