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