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 .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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub(crate) fn lsp_document_symbols(&mut self) {
628 self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
629 }
630 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 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 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 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
768pub(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
775pub(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}