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