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.diags.remove(&document);
228        if !self.docs.is_empty() && document == self.current() {
229            self.hover_card = None;
230        }
231        // Model owner removal happens in both modes; only the native
232        // didClose notification is gated.
233        if let Some(binding) = self.lsp_state.bindings.remove(&document) {
234            let args = CloseArgs {
235                server: binding.server,
236                document,
237                path: binding.path.clone(),
238            };
239            match self.tape.request("lsp.close", &args) {
240                Ok(true) => {
241                    if let Some(client) = self.lsp_live_client(binding.server) {
242                        client.did_close(document, &binding.path);
243                    }
244                }
245                Ok(false) => {}
246                Err(error) => self.message = format!("lsp close diverged from trace: {error}"),
247            }
248        }
249        if self.lsp_state.hover.is_some_and(|r| r.document == document) {
250            self.lsp_state.hover = None;
251            self.hover_card = None;
252        }
253        if self
254            .lsp_state
255            .navigation
256            .is_some_and(|r| r.document == document)
257        {
258            self.lsp_state.navigation = None;
259        }
260        if self
261            .picker
262            .as_ref()
263            .and_then(|p| p.lsp_context)
264            .is_some_and(|c| c.stamp.document == document)
265        {
266            self.close_picker();
267        }
268        // Closing the last owning remote workspace retires its server.
269        self.lsp_retire_remote_servers();
270    }
271
272    pub(crate) fn lsp_reply_fresh(&self, context: &ReplyContext) -> bool {
273        let stamp = context.stamp;
274        let expected = if context.kind == RequestKind::Hover {
275            self.lsp_state.hover
276        } else {
277            self.lsp_state.navigation
278        };
279        expected == Some(stamp) && self.lsp_context_fresh(context)
280    }
281
282    /// An accepted result may transfer to a picker or I/O ticket after its
283    /// server request is terminal. The original document/server/revision still
284    /// has to be current; the new subsystem owns cancellation after transfer.
285    pub(crate) fn lsp_context_fresh(&self, context: &ReplyContext) -> bool {
286        let stamp = context.stamp;
287        let newer = if context.kind == RequestKind::Hover {
288            self.lsp_state.hover
289        } else {
290            self.lsp_state.navigation
291        };
292        !self.docs.is_empty()
293            && stamp.document == self.current()
294            && newer.is_none_or(|owner| owner == stamp)
295            && self
296                .docs
297                .get(stamp.document)
298                .is_some_and(|d| d.buf.revision() == stamp.revision)
299            && self
300                .lsp_state
301                .bindings
302                .get(&stamp.document)
303                .is_some_and(|b| b.server == stamp.server && b.revision == stamp.revision)
304    }
305
306    pub(super) fn finish_lsp_reply(&mut self, context: &ReplyContext) -> bool {
307        let fresh = self.lsp_reply_fresh(context);
308        let slot = if context.kind == RequestKind::Hover {
309            &mut self.lsp_state.hover
310        } else {
311            &mut self.lsp_state.navigation
312        };
313        if *slot == Some(context.stamp) {
314            *slot = None;
315        }
316        fresh
317    }
318
319    pub(super) fn lsp_request(&mut self, kind: RequestKind) {
320        self.lsp_request_with(kind, None);
321    }
322
323    /// Change-producing requests (0043): rename carries its new name on
324    /// the admitted input so the tape relaunches the identical payload;
325    /// format is document-wide and records the configured tab width on
326    /// the pending request for the same reason.
327    pub(super) fn lsp_change_request(&mut self, kind: RequestKind, rename_to: Option<String>) {
328        self.lsp_request_with(kind, rename_to);
329    }
330
331    fn lsp_request_with(&mut self, kind: RequestKind, rename_to: Option<String>) {
332        let hover = kind == RequestKind::Hover;
333        if hover {
334            self.lsp_state.hover = None;
335        } else {
336            self.lsp_state.navigation = None;
337            self.cancel_open(strop_core::worker::CancelReason::Superseded);
338        }
339        let Some(doc) = self.lsp_current_doc_path() else {
340            self.message =
341                "language services require a complete file buffer, not a partial/follow view"
342                    .into();
343            return;
344        };
345        let Some(language) = self.lsp_doc_language(self.current(), &doc.path) else {
346            self.message = "no language server for this file type".into();
347            return;
348        };
349        let Some((server, _)) =
350            self.lsp_server_for(self.current(), &doc.path, &language, &doc.filesystem)
351        else {
352            self.message = match doc.filesystem {
353                strop_workspace::Filesystem::Local => {
354                    // 0049 §4.6: a server that simply doesn't cover this
355                    // path is a different story from one that isn't
356                    // installed — name the way in, honestly.
357                    let covered_language = self
358                        .lsp_state
359                        .attach
360                        .attached
361                        .iter()
362                        .any(|a| a.language == language);
363                    if covered_language {
364                        "no language context for this file — reach it via gd from a                          served file, or add its root to languages.toml"
365                            .into()
366                    } else {
367                        "no language server — install it or fix languages.toml".into()
368                    }
369                }
370                strop_workspace::Filesystem::Remote(endpoint) => {
371                    format!(
372                        "no language server on {endpoint} — install it there or fix languages.toml"
373                    )
374                }
375                strop_workspace::Filesystem::Container(_) => {
376                    "language services in containers are not wired yet".into()
377                }
378            };
379            return;
380        };
381        self.lsp_did_open_current();
382        self.lsp_sync_changed();
383        let Some(doc) = self.lsp_current_doc_path() else {
384            return;
385        };
386        let line = self.buf().line_of(self.head());
387        let input = RequestInput {
388            document: self.current(),
389            revision: self.buf().revision(),
390            path: doc.path.clone(),
391            line: LineIndex::new(line),
392            byte_col: ByteColumn::new(self.buf().col_of(self.head())),
393            line_text: strop_lsp::FrozenLine::from_slice(
394                self.buf()
395                    .text()
396                    .byte_slice(self.buf().line_start(line)..self.buf().line_end(line)),
397            ),
398            kind,
399            rename_to,
400        };
401        let native_input = input.clone();
402        let prepared = self.tape.call("lsp.prepare", &input, || {
403            let client = self
404                .lsp_live_client(server)
405                .ok_or(RequestRefusal::NotOpen)?;
406            client.prepare_request(native_input)
407        });
408        match prepared {
409            Ok(Ok(mut prepared)) => {
410                if kind == RequestKind::Format {
411                    // Rides the admitted record so replay relaunches the
412                    // identical payload (tab width included).
413                    prepared.tab_width = Some(self.cur_indent().width);
414                }
415                // Register the owner stamp before launching; replayed
416                // replies validate against exactly this stamp.
417                if hover {
418                    self.lsp_state.hover = Some(prepared.stamp);
419                } else {
420                    self.lsp_state.navigation = Some(prepared.stamp);
421                }
422                if matches!(
423                    kind,
424                    RequestKind::Locations(_)
425                        | RequestKind::Format
426                        | RequestKind::Rename
427                        | RequestKind::CodeAction
428                ) {
429                    let label = match kind {
430                        RequestKind::Locations(k) => k.label(),
431                        other => other.label(),
432                    };
433                    self.message = format!("lsp: {label} …");
434                }
435                match self.tape.request("lsp.launch", &prepared) {
436                    Ok(true) => {
437                        if let Some(client) = self.lsp_live_client(server) {
438                            client.launch_request(prepared);
439                        }
440                    }
441                    Ok(false) => {}
442                    Err(error) => {
443                        if hover {
444                            self.lsp_state.hover = None;
445                        } else {
446                            self.lsp_state.navigation = None;
447                        }
448                        self.message = format!("lsp request diverged from trace: {error}");
449                    }
450                }
451            }
452            Ok(Err(refusal)) => {
453                self.message = match refusal {
454                    RequestRefusal::NotOpen => {
455                        "lsp: the document is not open on this server".into()
456                    }
457                    RequestRefusal::StaleRevision => format!(
458                        "lsp: buffer changed while syncing — repeat {}",
459                        kind.label()
460                    ),
461                    RequestRefusal::Unsupported => {
462                        format!("lsp: {} is not supported by this server", kind.label())
463                    }
464                    RequestRefusal::IdentityExhausted => "lsp: request identities exhausted".into(),
465                };
466            }
467            Err(error) => self.message = format!("lsp prepare diverged from trace: {error}"),
468        }
469    }
470
471    pub(super) fn lsp_failed(&mut self, server: ServerId) {
472        let mut docs: Vec<_> = self
473            .lsp_state
474            .bindings
475            .iter()
476            .filter_map(|(&id, b)| (b.server == server).then_some(id))
477            .collect();
478        docs.sort();
479        for document in docs {
480            self.lsp_close_document(document);
481        }
482        self.lsp_state
483            .attach
484            .attached
485            .retain(|a| a.server != server);
486        self.lsp_state
487            .jump_contexts
488            .retain(|_, context| context.server != server);
489        if let Some(index) = self.lsp_servers.iter().position(|s| s.id == server) {
490            let connection = self.lsp_servers.remove(index);
491            if let Some(client) = connection.client {
492                // Joining a dead/failed server never blocks the input thread.
493                std::thread::spawn(move || {
494                    client.shutdown();
495                    client.wait(std::time::Duration::from_secs(2));
496                });
497            }
498        }
499    }
500}