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::registry;
12use strop_lsp::{LspEvent, ServerId};
13use strop_workspace::{Filesystem, ResourceLocation};
14
15pub(crate) mod attach;
16mod lifecycle;
17mod navigation;
18pub(crate) mod remote;
19mod routing;
20pub(crate) mod state;
21#[cfg(test)]
22mod tests;
23
24pub struct LspServer {
25    pub id: ServerId,
26    /// None for replayed servers: identity and replies come from the
27    /// injected record/event stream — never a fake client.
28    pub client: Option<strop_lsp::Client>,
29    pub rx: Receiver<LspEvent>,
30    pub ready: bool,
31}
32
33impl Editor {
34    pub(crate) fn open_hover_document(&mut self) {
35        let Some(text) = self.hover_card.take() else {
36            return;
37        };
38        self.push_jump();
39        let mut document = super::Document::documentation(strop_core::Buffer::from_text(&text));
40        document.set_return_point(self.jump_record());
41        let id = self.docs.insert(document);
42        self.switch_to(id);
43        self.set_head(0);
44        self.view_mut().view_top = 0;
45        self.message = "documentation: search/scroll normally; Ctrl-O returns".into();
46    }
47}
48
49impl Editor {
50    /// The current document's path identity: a local absolute path, or
51    /// the canonical remote file's endpoint-scoped path. Remote
52    /// windows must be complete for language services (0036 RW8) —
53    /// partial/follow windows refuse, they never pretend.
54    pub(super) fn lsp_current_doc_path(&self) -> Option<ResourceLocation> {
55        if self.cur().remote_metadata().is_some() && !self.remote_window_complete() {
56            return None;
57        }
58        self.lsp_doc_path(self.current())
59    }
60
61    fn lsp_doc_path(&self, document: strop_core::id::DocumentId) -> Option<ResourceLocation> {
62        let document = self.docs.get(document)?;
63        match &document.source {
64            crate::editor::document::DocumentSource::Remote(file) => {
65                Some(ResourceLocation::remote(
66                    file.file.endpoint().clone(),
67                    file.file.path().to_path_buf(),
68                ))
69            }
70            crate::editor::document::DocumentSource::Container { container, path } => {
71                Some(ResourceLocation {
72                    filesystem: strop_workspace::Filesystem::Container(container.clone()),
73                    path: path.clone(),
74                })
75            }
76            _ => document
77                .buf
78                .path
79                .as_ref()
80                .map(|path| ResourceLocation::local(self.cwd.join(path))),
81        }
82    }
83
84    /// A canonical remote file on `endpoint`, when any open document
85    /// still owns one — the `with_path` seed for remote navigation.
86    pub(super) fn remote_file_for(
87        &self,
88        endpoint: &strop_workspace::RemoteEndpoint,
89    ) -> Option<strop_workspace::RemoteFile> {
90        self.docs.iter().find_map(|(_, document)| {
91            match &document.source {
92                crate::editor::document::DocumentSource::Remote(source) => Some(&source.file),
93                _ => None,
94            }
95            .filter(|file| file.endpoint() == endpoint)
96            .cloned()
97        })
98    }
99
100    pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
101        self.lsp_request(strop_lsp::RequestKind::Locations(kind));
102    }
103    pub(crate) fn lsp_hover(&mut self) {
104        self.lsp_request(strop_lsp::RequestKind::Hover);
105    }
106    pub(crate) fn lsp_goto_definition(&mut self) {
107        self.lsp_request(strop_lsp::RequestKind::Goto);
108    }
109    pub(crate) fn lsp_switch_source_header(&mut self) {
110        self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
111    }
112    /// `:format` — server formatting through a change plan (0043).
113    /// auto_format admission: a binding with a live, formatting-capable
114    /// client. Anything less saves without formatting.
115    pub(crate) fn lsp_format_available(&self) -> bool {
116        let Some(binding) = self.lsp_state.bindings.get(&self.current()) else {
117            return false;
118        };
119        self.lsp_live_client(binding.server)
120            .is_some_and(|client| client.caps().formatting())
121    }
122
123    /// The format reply concluded: run the save that was waiting on it
124    /// (config auto_format). Never fires twice — the slot is taken.
125    pub(crate) fn continue_after_format(
126        &mut self,
127        context: &strop_lsp::ReplyContext,
128        warning: Option<String>,
129    ) {
130        if context.kind != strop_lsp::RequestKind::Format
131            || !matches!(
132                self.lsp_state.after_format.as_ref(),
133                Some(state::AfterFormat::Save { request, .. }) if *request == context.stamp
134            )
135        {
136            return;
137        }
138        let Some(state::AfterFormat::Save {
139            document,
140            close,
141            force,
142            request,
143        }) = self.lsp_state.after_format.take()
144        else {
145            return;
146        };
147        if warning.is_some()
148            && self
149                .docs
150                .get(document)
151                .is_none_or(|source| source.buf.revision() != request.revision)
152        {
153            self.message = "write not started: source changed while formatting — repeat :w to save newer edits".into();
154            return;
155        }
156        let admitted = self.request_save_document(document, None, force, close);
157        if let Some(warning) = warning {
158            if admitted {
159                self.io.format_warnings.insert(document, warning);
160            } else {
161                self.message
162                    .push_str(&format!(" — format warning: {warning}"));
163            }
164        }
165    }
166
167    pub(crate) fn lsp_format(&mut self) {
168        self.lsp_change_request(strop_lsp::RequestKind::Format, None);
169    }
170    /// `:rename <new>` — workspace rename through a change plan.
171    pub(crate) fn lsp_rename(&mut self, new_name: &str) {
172        self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
173    }
174    /// `Space s` — the current document's symbols as a picker (0047 §1).
175    pub(crate) fn lsp_document_symbols(&mut self) {
176        self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
177    }
178    /// `Space a` — code actions at the cursor, offered as a picker.
179    pub(crate) fn lsp_code_actions(&mut self) {
180        self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
181    }
182
183    pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
184        let Some(diags) = self
185            .diags_for(self.current())
186            .filter(|diags| !diags.is_empty())
187        else {
188            self.message = "no diagnostics".into();
189            return;
190        };
191        let cur = self.buf().line_of(self.head());
192        let col = self.buf().col_of(self.head());
193        let target = if forward {
194            diags
195                .iter()
196                .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
197                .or(diags.first())
198        } else {
199            diags
200                .iter()
201                .rev()
202                .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
203                .or(diags.last())
204        };
205        let Some(d) = target else {
206            return;
207        };
208        let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
209        let start = self
210            .buf()
211            .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
212        self.set_head(self.buf().clamp_boundary(start + col));
213        self.clamp_cursor();
214        self.scroll_to_cursor(self.view_rows());
215        self.message = msg;
216    }
217
218    pub(crate) fn open_diagnostics_picker(&mut self) {
219        use strop_picker::{Item, Kind, Payload};
220        // Deterministic row order across hash seeds (R11).
221        let mut by_doc: Vec<_> = self
222            .diags
223            .keys()
224            .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
225            .collect();
226        by_doc.sort_by(|a, b| {
227            (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
228        });
229        let mut items: Vec<Item> = Vec::new();
230        for (doc, diags) in by_doc {
231            for d in diags {
232                let line = d.line.get() + 1;
233                let col = d.col.get() + 1;
234                match &doc.filesystem {
235                    Filesystem::Local => items.push(Item {
236                        badge: None,
237                        text: format!(
238                            "{}:{} {} {}",
239                            doc.path.display(),
240                            line,
241                            d.severity_char(),
242                            d.message
243                        ),
244                        payload: Payload::Grep {
245                            path: doc.path.clone(),
246                            line,
247                            col,
248                            match_len: 1,
249                            line_text: d.message.clone().into(),
250                        },
251                    }),
252                    // Remote diagnostics carry their endpoint: the
253                    // preview stays local-clean and acceptance opens
254                    // the remote target (0036).
255                    Filesystem::Remote(endpoint) => items.push(Item {
256                        badge: None,
257                        text: format!(
258                            "{}{}:{} {} {}",
259                            endpoint,
260                            doc.path.display(),
261                            line,
262                            d.severity_char(),
263                            d.message
264                        ),
265                        payload: Payload::Remote {
266                            endpoint: endpoint.clone(),
267                            path: doc.path.clone(),
268                            line,
269                            col,
270                        },
271                    }),
272                    // Unreachable in DC1a (no container bindings); if one
273                    // ever arrives it is dropped with a trace, never
274                    // aliased to a local path.
275                    Filesystem::Container(_) => {
276                        trace::services::rejected(
277                            "lsp",
278                            "diagnostic in a container namespace (unwired)",
279                        );
280                    }
281                }
282            }
283        }
284        if items.is_empty() {
285            self.message = "no diagnostics".into();
286            return;
287        }
288        self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
289            Kind::Diagnostics,
290            items,
291            false,
292        )));
293    }
294
295    pub(crate) fn lsp_goto_definition_pub(&mut self) {
296        self.lsp_goto_definition();
297    }
298    pub(crate) fn lsp_switch_source_header_pub(&mut self) {
299        self.lsp_switch_source_header();
300    }
301    pub(crate) fn lsp_hover_pub(&mut self) {
302        self.lsp_hover();
303    }
304    pub fn lsp_code_actions_pub(&mut self) {
305        self.lsp_code_actions();
306    }
307    pub fn lsp_document_symbols_pub(&mut self) {
308        self.lsp_document_symbols();
309    }
310    pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
311        self.lsp_locations(kind);
312    }
313    pub fn jump_diagnostic_pub(&mut self, forward: bool) {
314        self.jump_diagnostic(forward);
315    }
316}
317
318/// The LSP language for a path, from the embedded extension table —
319/// pure, in-memory, safe on every keystroke.
320pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
321    let ext = path.extension()?.to_str()?;
322    registry::language_for_extension_name(ext)
323}
324
325/// The didOpen languageId sent to servers.
326pub(crate) fn lang_id(path: &Path) -> &'static str {
327    match path.extension().and_then(|e| e.to_str()) {
328        Some("rs") => "rust",
329        Some("py") | Some("pyi") => "python",
330        Some("go") => "go",
331        Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
332        Some("ts") => "typescript",
333        Some("tsx") => "typescriptreact",
334        Some("json") => "json",
335        Some("sh") | Some("bash") => "shellscript",
336        Some("c") | Some("h") => "c",
337        Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
338        _ => "plaintext",
339    }
340}