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::Symbols { context, symbols } => {
213                if !self.finish_lsp_reply(&context) {
214                    trace::services::rejected("lsp", "symbol owner/revision changed");
215                    return;
216                }
217                if symbols.is_empty() {
218                    self.message = "no symbols in this document".into();
219                    return;
220                }
221                use strop_picker::{Item, Payload};
222                let items = symbols
223                    .into_iter()
224                    .filter_map(|symbol| {
225                        let line = symbol.location.position.line.get() + 1;
226                        let col = symbol.location.position.column.get() + 1;
227                        let path = symbol.location.doc.path.clone();
228                        let payload = match symbol.location.doc.filesystem {
229                            strop_workspace::Filesystem::Local => Payload::Grep {
230                                path,
231                                line,
232                                col,
233                                match_len: 1,
234                                line_text: String::new(),
235                            },
236                            strop_workspace::Filesystem::Remote(endpoint) => Payload::Remote {
237                                endpoint,
238                                path,
239                                line,
240                                col,
241                            },
242                            // No container LSP is wired (DC1a); drop with a
243                            // trace rather than aliasing a local path.
244                            strop_workspace::Filesystem::Container(_) => {
245                                trace::services::rejected("lsp", "container symbol dropped");
246                                return None;
247                            }
248                        };
249                        let text = if symbol.container.is_empty() {
250                            format!("{}  · {} · :{}", symbol.name, symbol.kind, line)
251                        } else {
252                            format!(
253                                "{}  {} · {} · :{}",
254                                symbol.name, symbol.container, symbol.kind, line
255                            )
256                        };
257                        Some(Item { text, payload })
258                    })
259                    .collect();
260                self.open_picker(strop_picker::Kind::Symbols);
261                if let Some(glue) = self.picker.as_mut() {
262                    glue.picker.append(items);
263                }
264                // Items landed after the initial (empty-catalog)
265                // ranking: re-rank or the list renders empty.
266                self.request_picker_ranking();
267            }
268            LspEvent::ActionList { context, actions } => {
269                if !self.finish_lsp_reply(&context) {
270                    trace::services::rejected("lsp", "code-action owner/revision changed");
271                    return;
272                }
273                if actions.is_empty() {
274                    self.message = "no code actions here".into();
275                    return;
276                }
277                let items = actions
278                    .iter()
279                    .enumerate()
280                    .map(|(index, action)| strop_picker::Item {
281                        text: action.title.clone(),
282                        payload: strop_picker::Payload::CodeAction(index),
283                    })
284                    .collect();
285                self.changes.pending_actions = actions;
286                self.open_picker(strop_picker::Kind::CodeActions);
287                self.changes.pending_encoding = context.encoding;
288                if let Some(glue) = self.picker.as_mut() {
289                    glue.picker.append(items);
290                }
291                // Same post-append re-rank as the symbols arm above:
292                // the initial ranking ran over an empty catalog.
293                self.request_picker_ranking();
294            }
295            LspEvent::GotoLocation { context, location } => {
296                if !self.finish_lsp_reply(&context) {
297                    trace::services::rejected(
298                        "lsp",
299                        "navigation request/server/document/revision changed",
300                    );
301                    return;
302                }
303                self.jump_to_location(location, context);
304            }
305            LspEvent::Locations {
306                context,
307                kind,
308                items,
309            } => {
310                if !self.finish_lsp_reply(&context) {
311                    trace::services::rejected("lsp", "location-list owner changed");
312                    return;
313                }
314                match items.len() {
315                    0 => {
316                        self.message = format!("no {}", kind.label());
317                    }
318                    1 => {
319                        if let Some(location) = items.into_iter().next() {
320                            self.jump_to_location(location, context);
321                        }
322                    }
323                    count => {
324                        use strop_picker::{Item, Kind, Payload};
325                        let items = items
326                            .into_iter()
327                            .filter_map(|location| {
328                                let line = location.position.line.get() + 1;
329                                let col = location.position.column.get() + 1;
330                                let text = format!("{}:{}:{}", location.doc.label(), line, col);
331                                let payload = match location.doc.filesystem {
332                                    Filesystem::Local => Payload::Grep {
333                                        path: location.doc.path,
334                                        line,
335                                        col,
336                                        match_len: 1,
337                                        line_text: String::new(),
338                                    },
339                                    Filesystem::Remote(endpoint) => Payload::Remote {
340                                        endpoint,
341                                        path: location.doc.path,
342                                        line,
343                                        col,
344                                    },
345                                    // DC1a wires no container LSP, so a container
346                                    // location cannot arrive; if one ever does, it
347                                    // is dropped with a trace, never aliased to a
348                                    // local path.
349                                    Filesystem::Container(_) => {
350                                        trace::services::rejected(
351                                            "lsp",
352                                            "location in a container namespace (unwired)",
353                                        );
354                                        return None;
355                                    }
356                                };
357                                Some(Item { text, payload })
358                            })
359                            .collect();
360                        let mut glue = super::PickerGlue::diagnostics(strop_picker::Picker::new(
361                            Kind::Locations,
362                            items,
363                            false,
364                        ));
365                        glue.lsp_context = Some(context);
366                        self.set_picker(glue);
367                        self.message = format!("{count} {}", kind.label());
368                    }
369                }
370            }
371        }
372    }
373
374    pub(crate) fn jump_to_location(
375        &mut self,
376        location: strop_lsp::ServerLocation,
377        context: strop_lsp::ReplyContext,
378    ) {
379        if !self.lsp_context_fresh(&context) {
380            return;
381        }
382        let intent = super::io::OpenIntent::LspLocation {
383            context,
384            position: location.position,
385        };
386        match location.doc.filesystem {
387            Filesystem::Local => self.request_open(location.doc.path, intent),
388            Filesystem::Remote(endpoint) => {
389                // The target is a file on the replying server's host:
390                // resolve it through an open document's canonical seed
391                // (`with_path` keeps endpoint + native bytes) — the
392                // analogous local path is never opened or probed.
393                match self.remote_file_for(&endpoint) {
394                    Some(seed) => match seed.with_path(location.doc.path.clone()) {
395                        Ok(file) => self
396                            .request_target(crate::files::FileTarget::Remote(file.into()), intent),
397                        Err(error) => {
398                            trace::services::rejected("lsp", "remote navigation target invalid");
399                            self.message = format!("lsp: remote target invalid: {error}");
400                        }
401                    },
402                    None => {
403                        trace::services::rejected("lsp", "remote navigation endpoint lost");
404                        self.message =
405                            "lsp: the remote workspace for this target was closed".into();
406                    }
407                }
408            }
409            Filesystem::Container(_) => {
410                trace::services::rejected("lsp", "navigation into a container namespace (unwired)");
411                self.message = "lsp: container locations are not navigable yet".into();
412            }
413        }
414    }
415
416    pub(crate) fn finish_lsp_jump(
417        &mut self,
418        target: strop_core::id::DocumentId,
419        position: strop_lsp::ServerPosition,
420        context: strop_lsp::ReplyContext,
421    ) {
422        if !self.lsp_context_fresh(&context) {
423            trace::services::rejected("lsp", "navigation changed while target was loading");
424            return;
425        }
426        let Some(target_doc) = self.docs.get(target) else {
427            return;
428        };
429        let Some(binding) = self.lsp_state.bindings.get(&context.stamp.document) else {
430            return;
431        };
432        let outside = match &target_doc.source {
433            // A remote target is outside the workspace when its remote
434            // path leaves the binding's remote root — never by
435            // comparing against local paths.
436            crate::editor::document::DocumentSource::Remote(file) => {
437                !file.file.path().starts_with(&binding.root)
438            }
439            _ => target_doc
440                .buf
441                .path
442                .as_ref()
443                .is_some_and(|path| !self.cwd.join(path).starts_with(&binding.root)),
444        };
445        let line = position
446            .line
447            .get()
448            .min(target_doc.buf.len_lines().saturating_sub(1));
449        let text = target_doc
450            .buf
451            .text()
452            .byte_slice(target_doc.buf.line_start(line)..target_doc.buf.line_end(line));
453        let col = strop_lsp::to_byte_col_slice(text, position.column, context.encoding).get();
454        let head = target_doc
455            .buf
456            .clamp_boundary(target_doc.buf.line_start(line).saturating_add(col));
457        self.push_jump();
458        self.lsp_state.navigation = None;
459        self.switch_to(target);
460        if outside && !self.buf().readonly {
461            self.buf_mut().readonly = true;
462            self.message = "readonly — outside workspace (:set noro to edit)".into();
463        }
464        self.set_head(head);
465        self.clamp_cursor();
466        // A server-originated jump carries its language-service context
467        // (0049 §4.1): the replying server keeps answering inside the
468        // target. A live binding another navigation established is never
469        // switched (0049 §4.5), and namespaces never cross (0049 §4.7).
470        let origin = self
471            .lsp_state
472            .bindings
473            .get(&context.stamp.document)
474            .map(|b| {
475                (
476                    b.server,
477                    b.root.clone(),
478                    b.target.clone(),
479                    b.language.clone(),
480                )
481            });
482        if let Some((server, root, origin_target, language)) = origin {
483            let target_bound = self.lsp_state.bindings.contains_key(&target);
484            let doc = self.lsp_doc_path(target);
485            // C and C++ headers are interchangeable for the server that
486            // serves both (0049 §4.4: an ambiguous `.h` inherits).
487            let language_compatible = doc
488                .as_ref()
489                .and_then(|doc| lsp_language(&doc.path))
490                .is_none_or(|known| {
491                    known == language
492                        || (matches!(known, "c" | "cpp")
493                            && matches!(language.as_str(), "c" | "cpp"))
494                });
495            let context_free = !self.lsp_state.jump_contexts.contains_key(&target);
496            if let (false, true, Some(doc), true) =
497                (target_bound, context_free, doc, language_compatible)
498            {
499                if doc.filesystem == origin_target {
500                    // A routing hint, not open state: didOpen follows in
501                    // lsp_maybe_attach and becomes the real binding.
502                    self.lsp_state.jump_contexts.insert(
503                        target,
504                        state::JumpContext {
505                            server,
506                            root,
507                            language,
508                            target: origin_target,
509                        },
510                    );
511                }
512            }
513        }
514        self.scroll_to_cursor(self.view_rows());
515        self.lsp_maybe_attach();
516    }
517
518    /// A local picker hit with a live LSP request context: the server
519    /// that produced the list owns the target's filesystem.
520    pub(crate) fn lsp_jump_from_picker(
521        &mut self,
522        path: PathBuf,
523        line: usize,
524        col: usize,
525        context: strop_lsp::ReplyContext,
526    ) {
527        self.jump_to_location(
528            strop_lsp::ServerLocation {
529                doc: ResourceLocation::local(path),
530                position: strop_lsp::ServerPosition {
531                    line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
532                    column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
533                },
534            },
535            context,
536        );
537    }
538
539    /// A remote picker hit (locations or diagnostics): re-parse the
540    /// endpoint and route through the endpoint's file identity — with
541    /// a live request context through the freshness-checked navigation
542    /// path (server columns), without one as a direct remote open at a
543    /// byte column. The analogous local path is never touched.
544    pub(crate) fn lsp_open_remote_hit(
545        &mut self,
546        endpoint: &strop_workspace::RemoteEndpoint,
547        path: &Path,
548        line: usize,
549        col: usize,
550        context: Option<strop_lsp::ReplyContext>,
551    ) {
552        if let Some(context) = context {
553            self.jump_to_location(
554                strop_lsp::ServerLocation {
555                    doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
556                    position: strop_lsp::ServerPosition {
557                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
558                        column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
559                    },
560                },
561                context,
562            );
563        } else if let Some(seed) = self.remote_file_for(endpoint) {
564            // Context-free remote hits (symbol rows) record too —
565            // ctrl-o after the jump returns (0047 §1).
566            self.push_jump();
567            match seed.with_path(path.to_owned()) {
568                Ok(file) => self.request_target(
569                    crate::files::FileTarget::Remote(file.into()),
570                    super::io::OpenIntent::Grep {
571                        line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
572                        column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
573                    },
574                ),
575                Err(error) => self.message = format!("lsp remote location: {error}"),
576            }
577        } else {
578            self.message = "lsp: the remote workspace for this hit was closed".into();
579        }
580    }
581    pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
582        self.lsp_request(strop_lsp::RequestKind::Locations(kind));
583    }
584    pub(crate) fn lsp_hover(&mut self) {
585        self.lsp_request(strop_lsp::RequestKind::Hover);
586    }
587    pub(crate) fn lsp_goto_definition(&mut self) {
588        self.lsp_request(strop_lsp::RequestKind::Goto);
589    }
590    pub(crate) fn lsp_switch_source_header(&mut self) {
591        self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
592    }
593    /// `:format` — server formatting through a change plan (0043).
594    pub(crate) fn lsp_format(&mut self) {
595        self.lsp_change_request(strop_lsp::RequestKind::Format, None);
596    }
597    /// `:rename <new>` — workspace rename through a change plan.
598    pub(crate) fn lsp_rename(&mut self, new_name: &str) {
599        self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
600    }
601    /// `Space s` — the current document's symbols as a picker (0047 §1).
602    pub(crate) fn lsp_document_symbols(&mut self) {
603        self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
604    }
605    /// `Space a` — code actions at the cursor, offered as a picker.
606    pub(crate) fn lsp_code_actions(&mut self) {
607        self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
608    }
609
610    pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
611        let Some(diags) = self
612            .diags_for(self.current())
613            .filter(|diags| !diags.is_empty())
614        else {
615            self.message = "no diagnostics".into();
616            return;
617        };
618        let cur = self.buf().line_of(self.head());
619        let col = self.buf().col_of(self.head());
620        let target = if forward {
621            diags
622                .iter()
623                .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
624                .or(diags.first())
625        } else {
626            diags
627                .iter()
628                .rev()
629                .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
630                .or(diags.last())
631        };
632        let Some(d) = target else {
633            return;
634        };
635        let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
636        let start = self
637            .buf()
638            .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
639        self.set_head(self.buf().clamp_boundary(start + col));
640        self.clamp_cursor();
641        self.scroll_to_cursor(self.view_rows());
642        self.message = msg;
643    }
644
645    pub(crate) fn open_diagnostics_picker(&mut self) {
646        use strop_picker::{Item, Kind, Payload};
647        // Deterministic row order across hash seeds (R11).
648        let mut by_doc: Vec<_> = self
649            .diags
650            .keys()
651            .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
652            .collect();
653        by_doc.sort_by(|a, b| {
654            (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
655        });
656        let mut items: Vec<Item> = Vec::new();
657        for (doc, diags) in by_doc {
658            for d in diags {
659                let line = d.line.get() + 1;
660                let col = d.col.get() + 1;
661                match &doc.filesystem {
662                    Filesystem::Local => items.push(Item {
663                        text: format!(
664                            "{}:{} {} {}",
665                            doc.path.display(),
666                            line,
667                            d.severity_char(),
668                            d.message
669                        ),
670                        payload: Payload::Grep {
671                            path: doc.path.clone(),
672                            line,
673                            col,
674                            match_len: 1,
675                            line_text: d.message.clone(),
676                        },
677                    }),
678                    // Remote diagnostics carry their endpoint: the
679                    // preview stays local-clean and acceptance opens
680                    // the remote target (0036).
681                    Filesystem::Remote(endpoint) => items.push(Item {
682                        text: format!(
683                            "{}{}:{} {} {}",
684                            endpoint,
685                            doc.path.display(),
686                            line,
687                            d.severity_char(),
688                            d.message
689                        ),
690                        payload: Payload::Remote {
691                            endpoint: endpoint.clone(),
692                            path: doc.path.clone(),
693                            line,
694                            col,
695                        },
696                    }),
697                    // Unreachable in DC1a (no container bindings); if one
698                    // ever arrives it is dropped with a trace, never
699                    // aliased to a local path.
700                    Filesystem::Container(_) => {
701                        trace::services::rejected(
702                            "lsp",
703                            "diagnostic in a container namespace (unwired)",
704                        );
705                    }
706                }
707            }
708        }
709        if items.is_empty() {
710            self.message = "no diagnostics".into();
711            return;
712        }
713        self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
714            Kind::Diagnostics,
715            items,
716            false,
717        )));
718    }
719
720    pub(crate) fn lsp_goto_definition_pub(&mut self) {
721        self.lsp_goto_definition();
722    }
723    pub(crate) fn lsp_switch_source_header_pub(&mut self) {
724        self.lsp_switch_source_header();
725    }
726    pub(crate) fn lsp_hover_pub(&mut self) {
727        self.lsp_hover();
728    }
729    pub fn lsp_code_actions_pub(&mut self) {
730        self.lsp_code_actions();
731    }
732    pub fn lsp_document_symbols_pub(&mut self) {
733        self.lsp_document_symbols();
734    }
735    pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
736        self.lsp_locations(kind);
737    }
738    pub fn jump_diagnostic_pub(&mut self, forward: bool) {
739        self.jump_diagnostic(forward);
740    }
741}
742
743/// The LSP language for a path, from the embedded extension table —
744/// pure, in-memory, safe on every keystroke.
745pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
746    let ext = path.extension()?.to_str()?;
747    registry::language_for_extension_name(ext)
748}
749
750/// The didOpen languageId sent to servers.
751pub(crate) fn lang_id(path: &Path) -> &'static str {
752    match path.extension().and_then(|e| e.to_str()) {
753        Some("rs") => "rust",
754        Some("py") | Some("pyi") => "python",
755        Some("go") => "go",
756        Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
757        Some("ts") => "typescript",
758        Some("tsx") => "typescriptreact",
759        Some("json") => "json",
760        Some("sh") | Some("bash") => "shellscript",
761        Some("c") | Some("h") => "c",
762        Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
763        _ => "plaintext",
764    }
765}