Skip to main content

strop_engine/editor/
lsp.rs

1//! Editor-side LSP event handling and asynchronous navigation. Local
2//! and remote documents share the request/server/incarnation/revision
3//! ownership; a remote workspace adds the endpoint to every identity —
4//! diagnostics, bindings and navigation never alias a remote path onto
5//! the local disk (0036 RW8).
6
7use super::{trace, Editor};
8use std::path::{Path, PathBuf};
9use std::sync::mpsc::Receiver;
10
11use strop_lsp::protocol::ResolvedDiag;
12use strop_lsp::registry;
13use strop_lsp::{LspEvent, ServerId};
14use strop_workspace::{Filesystem, ResourceLocation};
15
16pub(crate) mod attach;
17mod lifecycle;
18pub(crate) mod remote;
19pub(crate) mod state;
20#[cfg(test)]
21mod tests;
22
23pub struct LspServer {
24    pub id: ServerId,
25    /// None for replayed servers: identity and replies come from the
26    /// injected record/event stream — never a fake client.
27    pub client: Option<strop_lsp::Client>,
28    pub rx: Receiver<LspEvent>,
29    pub ready: bool,
30}
31
32impl Editor {
33    /// The current document's path identity: a local absolute path, or
34    /// the canonical remote file's endpoint-scoped path. Remote
35    /// windows must be complete for language services (0036 RW8) —
36    /// partial/follow windows refuse, they never pretend.
37    pub(super) fn lsp_current_doc_path(&self) -> Option<ResourceLocation> {
38        if self.cur().remote_metadata().is_some() && !self.remote_window_complete() {
39            return None;
40        }
41        self.lsp_doc_path(self.current())
42    }
43
44    fn lsp_doc_path(&self, document: strop_core::id::DocumentId) -> Option<ResourceLocation> {
45        let document = self.docs.get(document)?;
46        match &document.source {
47            crate::editor::document::DocumentSource::Remote(file) => {
48                Some(ResourceLocation::remote(
49                    file.file.endpoint().clone(),
50                    file.file.path().to_path_buf(),
51                ))
52            }
53            crate::editor::document::DocumentSource::Container { container, path } => {
54                Some(ResourceLocation {
55                    filesystem: strop_workspace::Filesystem::Container(container.clone()),
56                    path: path.clone(),
57                })
58            }
59            _ => document
60                .buf
61                .path
62                .as_ref()
63                .map(|path| ResourceLocation::local(self.cwd.join(path))),
64        }
65    }
66
67    /// A canonical remote file on `endpoint`, when any open document
68    /// still owns one — the `with_path` seed for remote navigation.
69    pub(super) fn remote_file_for(
70        &self,
71        endpoint: &strop_workspace::RemoteEndpoint,
72    ) -> Option<strop_workspace::RemoteFile> {
73        self.docs.iter().find_map(|(_, document)| {
74            match &document.source {
75                crate::editor::document::DocumentSource::Remote(source) => Some(&source.file),
76                _ => None,
77            }
78            .filter(|file| file.endpoint() == endpoint)
79            .cloned()
80        })
81    }
82
83    pub(crate) fn handle_lsp_event(&mut self, event: LspEvent) {
84        trace::services::lsp(&event);
85        match event {
86            LspEvent::Ready { server, name } => {
87                if let Some(owner) = self.lsp_servers.iter_mut().find(|owner| owner.id == server) {
88                    owner.ready = true;
89                    // Success must not erase a configuration warning
90                    // (0033 §2): readiness is reported alongside it.
91                    self.message = match self.layer_warning() {
92                        Some(warning) => format!("lsp: {name} ready — {warning}"),
93                        None => format!("lsp: {name} ready"),
94                    };
95                }
96            }
97            LspEvent::Failed { server, name, hint } => {
98                if self.lsp_servers.iter().any(|s| s.id == server) {
99                    self.lsp_failed(server);
100                    self.message = format!("lsp: {name} failed — {hint}");
101                } else {
102                    trace::services::rejected("lsp", "failure for an unowned server");
103                }
104            }
105            LspEvent::ServerMessage { server, name, text } => {
106                if self.lsp_servers.iter().any(|s| s.id == server) {
107                    self.message = format!("lsp: {name}: {text}");
108                } else {
109                    trace::services::rejected("lsp", "message for an unowned server");
110                }
111            }
112            LspEvent::Diagnostics {
113                context,
114                doc,
115                diags,
116            } => {
117                let valid = self
118                    .lsp_state
119                    .bindings
120                    .get(&context.document)
121                    .is_some_and(|b| {
122                        b.server == context.server
123                            && b.path == doc.path
124                            && b.target == doc.filesystem
125                            && b.revision == context.revision
126                    });
127                let Some(doc_buffer) = self
128                    .docs
129                    .get(context.document)
130                    .filter(|d| valid && d.buf.revision() == context.revision)
131                else {
132                    trace::services::rejected("lsp", "diagnostic owner/revision changed");
133                    return;
134                };
135                let buffer = &doc_buffer.buf;
136                let resolved: Vec<ResolvedDiag> = diags
137                    .into_iter()
138                    .map(|d| d.resolve(context.encoding, buffer))
139                    .collect();
140                self.diags.insert(
141                    context.document,
142                    super::diagnostics::DocumentDiagnostics {
143                        revision: context.revision,
144                        items: resolved,
145                    },
146                );
147            }
148            LspEvent::HoverText { context, text } => {
149                if !self.finish_lsp_reply(&context) {
150                    trace::services::rejected(
151                        "lsp",
152                        "hover request/server/document/revision changed",
153                    );
154                    return;
155                }
156                self.hover_card = Some(text);
157            }
158            LspEvent::Note { context, text } => {
159                if !self.finish_lsp_reply(&context) {
160                    trace::services::rejected(
161                        "lsp",
162                        "navigation request/server/document/revision changed",
163                    );
164                    return;
165                }
166                self.message = text;
167            }
168            LspEvent::Edits { context, edits } => {
169                if !self.finish_lsp_reply(&context) {
170                    trace::services::rejected("lsp", "edit request owner/revision changed");
171                    return;
172                }
173                if edits.is_empty() {
174                    self.message = "already formatted".into();
175                    return;
176                }
177                let Some(location) =
178                    self.lsp_state
179                        .bindings
180                        .get(&context.stamp.document)
181                        .map(|binding| ResourceLocation {
182                            filesystem: binding.target.clone(),
183                            path: binding.path.clone(),
184                        })
185                else {
186                    trace::services::rejected("lsp", "edits for an unbound document");
187                    return;
188                };
189                let plan = self.build_change_plan(
190                    super::changes::ChangeProducer::Format,
191                    vec![(location, edits)],
192                    context.encoding,
193                );
194                self.apply_change_plan(plan);
195            }
196            LspEvent::WorkspaceEdits { context, edits } => {
197                if !self.finish_lsp_reply(&context) {
198                    trace::services::rejected("lsp", "workspace-edit owner/revision changed");
199                    return;
200                }
201                if edits.is_empty() {
202                    self.message = format!("lsp: {} made no edits", context.kind.label());
203                    return;
204                }
205                let producer = match context.kind {
206                    strop_lsp::RequestKind::Rename => super::changes::ChangeProducer::Rename,
207                    _ => super::changes::ChangeProducer::CodeAction,
208                };
209                let plan = self.build_change_plan(producer, edits, context.encoding);
210                self.apply_change_plan(plan);
211            }
212            LspEvent::ActionList { context, actions } => {
213                if !self.finish_lsp_reply(&context) {
214                    trace::services::rejected("lsp", "code-action owner/revision changed");
215                    return;
216                }
217                if actions.is_empty() {
218                    self.message = "no code actions here".into();
219                    return;
220                }
221                let items = actions
222                    .iter()
223                    .enumerate()
224                    .map(|(index, action)| strop_picker::Item {
225                        text: action.title.clone(),
226                        payload: strop_picker::Payload::CodeAction(index),
227                    })
228                    .collect();
229                self.changes.pending_actions = actions;
230                self.open_picker(strop_picker::Kind::CodeActions);
231                self.changes.pending_encoding = context.encoding;
232                if let Some(glue) = self.picker.as_mut() {
233                    glue.picker.append(items);
234                }
235            }
236            LspEvent::GotoLocation { context, location } => {
237                if !self.finish_lsp_reply(&context) {
238                    trace::services::rejected(
239                        "lsp",
240                        "navigation request/server/document/revision changed",
241                    );
242                    return;
243                }
244                self.jump_to_location(location, context);
245            }
246            LspEvent::Locations {
247                context,
248                kind,
249                items,
250            } => {
251                if !self.finish_lsp_reply(&context) {
252                    trace::services::rejected("lsp", "location-list owner changed");
253                    return;
254                }
255                match items.len() {
256                    0 => {
257                        self.message = format!("no {}", kind.label());
258                    }
259                    1 => {
260                        if let Some(location) = items.into_iter().next() {
261                            self.jump_to_location(location, context);
262                        }
263                    }
264                    count => {
265                        use strop_picker::{Item, Kind, Payload};
266                        let items = items
267                            .into_iter()
268                            .filter_map(|location| {
269                                let line = location.position.line.get() + 1;
270                                let col = location.position.column.get() + 1;
271                                let text = format!("{}:{}:{}", location.doc.label(), line, col);
272                                let payload = match location.doc.filesystem {
273                                    Filesystem::Local => Payload::Grep {
274                                        path: location.doc.path,
275                                        line,
276                                        col,
277                                        match_len: 1,
278                                        line_text: String::new(),
279                                    },
280                                    Filesystem::Remote(endpoint) => Payload::Remote {
281                                        endpoint,
282                                        path: location.doc.path,
283                                        line,
284                                        col,
285                                    },
286                                    // DC1a wires no container LSP, so a container
287                                    // location cannot arrive; if one ever does, it
288                                    // is dropped with a trace, never aliased to a
289                                    // local path.
290                                    Filesystem::Container(_) => {
291                                        trace::services::rejected(
292                                            "lsp",
293                                            "location in a container namespace (unwired)",
294                                        );
295                                        return None;
296                                    }
297                                };
298                                Some(Item { text, payload })
299                            })
300                            .collect();
301                        let mut glue = super::PickerGlue::diagnostics(strop_picker::Picker::new(
302                            Kind::Locations,
303                            items,
304                            false,
305                        ));
306                        glue.lsp_context = Some(context);
307                        self.set_picker(glue);
308                        self.message = format!("{count} {}", kind.label());
309                    }
310                }
311            }
312        }
313    }
314
315    pub(crate) fn jump_to_location(
316        &mut self,
317        location: strop_lsp::ServerLocation,
318        context: strop_lsp::ReplyContext,
319    ) {
320        if !self.lsp_context_fresh(&context) {
321            return;
322        }
323        let intent = super::io::OpenIntent::LspLocation {
324            context,
325            position: location.position,
326        };
327        match location.doc.filesystem {
328            Filesystem::Local => self.request_open(location.doc.path, intent),
329            Filesystem::Remote(endpoint) => {
330                // The target is a file on the replying server's host:
331                // resolve it through an open document's canonical seed
332                // (`with_path` keeps endpoint + native bytes) — the
333                // analogous local path is never opened or probed.
334                match self.remote_file_for(&endpoint) {
335                    Some(seed) => match seed.with_path(location.doc.path.clone()) {
336                        Ok(file) => self
337                            .request_target(crate::files::FileTarget::Remote(file.into()), intent),
338                        Err(error) => {
339                            trace::services::rejected("lsp", "remote navigation target invalid");
340                            self.message = format!("lsp: remote target invalid: {error}");
341                        }
342                    },
343                    None => {
344                        trace::services::rejected("lsp", "remote navigation endpoint lost");
345                        self.message =
346                            "lsp: the remote workspace for this target was closed".into();
347                    }
348                }
349            }
350            Filesystem::Container(_) => {
351                trace::services::rejected("lsp", "navigation into a container namespace (unwired)");
352                self.message = "lsp: container locations are not navigable yet".into();
353            }
354        }
355    }
356
357    pub(crate) fn finish_lsp_jump(
358        &mut self,
359        target: strop_core::id::DocumentId,
360        position: strop_lsp::ServerPosition,
361        context: strop_lsp::ReplyContext,
362    ) {
363        if !self.lsp_context_fresh(&context) {
364            trace::services::rejected("lsp", "navigation changed while target was loading");
365            return;
366        }
367        let Some(target_doc) = self.docs.get(target) else {
368            return;
369        };
370        let Some(binding) = self.lsp_state.bindings.get(&context.stamp.document) else {
371            return;
372        };
373        let outside = match &target_doc.source {
374            // A remote target is outside the workspace when its remote
375            // path leaves the binding's remote root — never by
376            // comparing against local paths.
377            crate::editor::document::DocumentSource::Remote(file) => {
378                !file.file.path().starts_with(&binding.root)
379            }
380            _ => target_doc
381                .buf
382                .path
383                .as_ref()
384                .is_some_and(|path| !self.cwd.join(path).starts_with(&binding.root)),
385        };
386        let line = position
387            .line
388            .get()
389            .min(target_doc.buf.len_lines().saturating_sub(1));
390        let text = target_doc
391            .buf
392            .text()
393            .byte_slice(target_doc.buf.line_start(line)..target_doc.buf.line_end(line));
394        let col = strop_lsp::to_byte_col_slice(text, position.column, context.encoding).get();
395        let head = target_doc
396            .buf
397            .clamp_boundary(target_doc.buf.line_start(line).saturating_add(col));
398        self.push_jump();
399        self.lsp_state.navigation = None;
400        self.switch_to(target);
401        if outside && !self.buf().readonly {
402            self.buf_mut().readonly = true;
403            self.message = "readonly — outside workspace (:set noro to edit)".into();
404        }
405        self.set_head(head);
406        self.clamp_cursor();
407        self.scroll_to_cursor(self.view_rows());
408        self.lsp_maybe_attach();
409    }
410
411    /// A local picker hit with a live LSP request context: the server
412    /// that produced the list owns the target's filesystem.
413    pub(crate) fn lsp_jump_from_picker(
414        &mut self,
415        path: PathBuf,
416        line: usize,
417        col: usize,
418        context: strop_lsp::ReplyContext,
419    ) {
420        self.jump_to_location(
421            strop_lsp::ServerLocation {
422                doc: ResourceLocation::local(path),
423                position: strop_lsp::ServerPosition {
424                    line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
425                    column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
426                },
427            },
428            context,
429        );
430    }
431
432    /// A remote picker hit (locations or diagnostics): re-parse the
433    /// endpoint and route through the endpoint's file identity — with
434    /// a live request context through the freshness-checked navigation
435    /// path (server columns), without one as a direct remote open at a
436    /// byte column. The analogous local path is never touched.
437    pub(crate) fn lsp_open_remote_hit(
438        &mut self,
439        endpoint: &strop_workspace::RemoteEndpoint,
440        path: &Path,
441        line: usize,
442        col: usize,
443        context: Option<strop_lsp::ReplyContext>,
444    ) {
445        if let Some(context) = context {
446            self.jump_to_location(
447                strop_lsp::ServerLocation {
448                    doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
449                    position: strop_lsp::ServerPosition {
450                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
451                        column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
452                    },
453                },
454                context,
455            );
456        } else if let Some(seed) = self.remote_file_for(endpoint) {
457            match seed.with_path(path.to_owned()) {
458                Ok(file) => self.request_target(
459                    crate::files::FileTarget::Remote(file.into()),
460                    super::io::OpenIntent::Grep {
461                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
462                        column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
463                    },
464                ),
465                Err(error) => self.message = format!("lsp remote location: {error}"),
466            }
467        } else {
468            self.message = "lsp: the remote workspace for this hit was closed".into();
469        }
470    }
471    pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
472        self.lsp_request(strop_lsp::RequestKind::Locations(kind));
473    }
474    pub(crate) fn lsp_hover(&mut self) {
475        self.lsp_request(strop_lsp::RequestKind::Hover);
476    }
477    pub(crate) fn lsp_goto_definition(&mut self) {
478        self.lsp_request(strop_lsp::RequestKind::Goto);
479    }
480    pub(crate) fn lsp_switch_source_header(&mut self) {
481        self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
482    }
483    /// `:format` — server formatting through a change plan (0043).
484    pub(crate) fn lsp_format(&mut self) {
485        self.lsp_change_request(strop_lsp::RequestKind::Format, None);
486    }
487    /// `:rename <new>` — workspace rename through a change plan.
488    pub(crate) fn lsp_rename(&mut self, new_name: &str) {
489        self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
490    }
491    /// `Space a` — code actions at the cursor, offered as a picker.
492    pub(crate) fn lsp_code_actions(&mut self) {
493        self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
494    }
495
496    pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
497        let Some(diags) = self
498            .diags_for(self.current())
499            .filter(|diags| !diags.is_empty())
500        else {
501            self.message = "no diagnostics".into();
502            return;
503        };
504        let cur = self.buf().line_of(self.head());
505        let col = self.buf().col_of(self.head());
506        let target = if forward {
507            diags
508                .iter()
509                .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
510                .or(diags.first())
511        } else {
512            diags
513                .iter()
514                .rev()
515                .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
516                .or(diags.last())
517        };
518        let Some(d) = target else {
519            return;
520        };
521        let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
522        let start = self
523            .buf()
524            .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
525        self.set_head(self.buf().clamp_boundary(start + col));
526        self.clamp_cursor();
527        self.scroll_to_cursor(self.view_rows());
528        self.message = msg;
529    }
530
531    pub(crate) fn open_diagnostics_picker(&mut self) {
532        use strop_picker::{Item, Kind, Payload};
533        // Deterministic row order across hash seeds (R11).
534        let mut by_doc: Vec<_> = self
535            .diags
536            .keys()
537            .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
538            .collect();
539        by_doc.sort_by(|a, b| {
540            (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
541        });
542        let mut items: Vec<Item> = Vec::new();
543        for (doc, diags) in by_doc {
544            for d in diags {
545                let line = d.line.get() + 1;
546                let col = d.col.get() + 1;
547                match &doc.filesystem {
548                    Filesystem::Local => items.push(Item {
549                        text: format!(
550                            "{}:{} {} {}",
551                            doc.path.display(),
552                            line,
553                            d.severity_char(),
554                            d.message
555                        ),
556                        payload: Payload::Grep {
557                            path: doc.path.clone(),
558                            line,
559                            col,
560                            match_len: 1,
561                            line_text: d.message.clone(),
562                        },
563                    }),
564                    // Remote diagnostics carry their endpoint: the
565                    // preview stays local-clean and acceptance opens
566                    // the remote target (0036).
567                    Filesystem::Remote(endpoint) => items.push(Item {
568                        text: format!(
569                            "{}{}:{} {} {}",
570                            endpoint,
571                            doc.path.display(),
572                            line,
573                            d.severity_char(),
574                            d.message
575                        ),
576                        payload: Payload::Remote {
577                            endpoint: endpoint.clone(),
578                            path: doc.path.clone(),
579                            line,
580                            col,
581                        },
582                    }),
583                    // Unreachable in DC1a (no container bindings); if one
584                    // ever arrives it is dropped with a trace, never
585                    // aliased to a local path.
586                    Filesystem::Container(_) => {
587                        trace::services::rejected(
588                            "lsp",
589                            "diagnostic in a container namespace (unwired)",
590                        );
591                    }
592                }
593            }
594        }
595        if items.is_empty() {
596            self.message = "no diagnostics".into();
597            return;
598        }
599        self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
600            Kind::Diagnostics,
601            items,
602            false,
603        )));
604    }
605
606    pub(crate) fn lsp_goto_definition_pub(&mut self) {
607        self.lsp_goto_definition();
608    }
609    pub(crate) fn lsp_switch_source_header_pub(&mut self) {
610        self.lsp_switch_source_header();
611    }
612    pub(crate) fn lsp_hover_pub(&mut self) {
613        self.lsp_hover();
614    }
615    pub fn lsp_code_actions_pub(&mut self) {
616        self.lsp_code_actions();
617    }
618    pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
619        self.lsp_locations(kind);
620    }
621    pub fn jump_diagnostic_pub(&mut self, forward: bool) {
622        self.jump_diagnostic(forward);
623    }
624}
625
626/// The LSP language for a path, from the embedded extension table —
627/// pure, in-memory, safe on every keystroke.
628pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
629    let ext = path.extension()?.to_str()?;
630    registry::language_for_extension_name(ext)
631}
632
633/// The didOpen languageId sent to servers.
634pub(crate) fn lang_id(path: &Path) -> &'static str {
635    match path.extension().and_then(|e| e.to_str()) {
636        Some("rs") => "rust",
637        Some("py") | Some("pyi") => "python",
638        Some("go") => "go",
639        Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
640        Some("ts") => "typescript",
641        Some("tsx") => "typescriptreact",
642        Some("json") => "json",
643        Some("sh") | Some("bash") => "shellscript",
644        Some("c") | Some("h") => "c",
645        Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
646        _ => "plaintext",
647    }
648}