1use 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 pub client: Option<strop_lsp::Client>,
28 pub rx: Receiver<LspEvent>,
29 pub ready: bool,
30}
31
32impl Editor {
33 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 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 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 strop_workspace::Filesystem::Container(_) => {
251 trace::services::rejected("lsp", "container symbol dropped");
252 return None;
253 }
254 };
255 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(crate) fn lsp_document_symbols(&mut self) {
637 self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
638 }
639 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 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 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 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
779pub(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
786pub(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
803fn 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}