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 if !self.finish_lsp_reply(&context) {
160 trace::services::rejected(
161 "lsp",
162 "navigation request/server/document/revision changed",
163 );
164 return;
165 }
166 self.message = text;
167 }
168 LspEvent::Edits { context, edits } => {
169 if !self.finish_lsp_reply(&context) {
170 trace::services::rejected("lsp", "edit request owner/revision changed");
171 return;
172 }
173 if edits.is_empty() {
174 self.message = "already formatted".into();
175 return;
176 }
177 let Some(location) =
178 self.lsp_state
179 .bindings
180 .get(&context.stamp.document)
181 .map(|binding| ResourceLocation {
182 filesystem: binding.target.clone(),
183 path: binding.path.clone(),
184 })
185 else {
186 trace::services::rejected("lsp", "edits for an unbound document");
187 return;
188 };
189 let plan = self.build_change_plan(
190 super::changes::ChangeProducer::Format,
191 vec![(location, edits)],
192 context.encoding,
193 );
194 self.apply_change_plan(plan);
195 }
196 LspEvent::WorkspaceEdits { context, edits } => {
197 if !self.finish_lsp_reply(&context) {
198 trace::services::rejected("lsp", "workspace-edit owner/revision changed");
199 return;
200 }
201 if edits.is_empty() {
202 self.message = format!("lsp: {} made no edits", context.kind.label());
203 return;
204 }
205 let producer = match context.kind {
206 strop_lsp::RequestKind::Rename => super::changes::ChangeProducer::Rename,
207 _ => super::changes::ChangeProducer::CodeAction,
208 };
209 let plan = self.build_change_plan(producer, edits, context.encoding);
210 self.apply_change_plan(plan);
211 }
212 LspEvent::Symbols { context, symbols } => {
213 if !self.finish_lsp_reply(&context) {
214 trace::services::rejected("lsp", "symbol owner/revision changed");
215 return;
216 }
217 if symbols.is_empty() {
218 self.message = "no symbols in this document".into();
219 return;
220 }
221 use strop_picker::{Item, Payload};
222 let items = symbols
223 .into_iter()
224 .filter_map(|symbol| {
225 let line = symbol.location.position.line.get() + 1;
226 let col = symbol.location.position.column.get() + 1;
227 let path = symbol.location.doc.path.clone();
228 let payload = match symbol.location.doc.filesystem {
229 strop_workspace::Filesystem::Local => Payload::Grep {
230 path,
231 line,
232 col,
233 match_len: 1,
234 line_text: String::new(),
235 },
236 strop_workspace::Filesystem::Remote(endpoint) => Payload::Remote {
237 endpoint,
238 path,
239 line,
240 col,
241 },
242 strop_workspace::Filesystem::Container(_) => {
245 trace::services::rejected("lsp", "container symbol dropped");
246 return None;
247 }
248 };
249 let text = if symbol.container.is_empty() {
250 format!("{} · {} · :{}", symbol.name, symbol.kind, line)
251 } else {
252 format!(
253 "{} {} · {} · :{}",
254 symbol.name, symbol.container, symbol.kind, line
255 )
256 };
257 Some(Item { text, payload })
258 })
259 .collect();
260 self.open_picker(strop_picker::Kind::Symbols);
261 if let Some(glue) = self.picker.as_mut() {
262 glue.picker.append(items);
263 }
264 self.request_picker_ranking();
267 }
268 LspEvent::ActionList { context, actions } => {
269 if !self.finish_lsp_reply(&context) {
270 trace::services::rejected("lsp", "code-action owner/revision changed");
271 return;
272 }
273 if actions.is_empty() {
274 self.message = "no code actions here".into();
275 return;
276 }
277 let items = actions
278 .iter()
279 .enumerate()
280 .map(|(index, action)| strop_picker::Item {
281 text: action.title.clone(),
282 payload: strop_picker::Payload::CodeAction(index),
283 })
284 .collect();
285 self.changes.pending_actions = actions;
286 self.open_picker(strop_picker::Kind::CodeActions);
287 self.changes.pending_encoding = context.encoding;
288 if let Some(glue) = self.picker.as_mut() {
289 glue.picker.append(items);
290 }
291 self.request_picker_ranking();
294 }
295 LspEvent::GotoLocation { context, location } => {
296 if !self.finish_lsp_reply(&context) {
297 trace::services::rejected(
298 "lsp",
299 "navigation request/server/document/revision changed",
300 );
301 return;
302 }
303 self.jump_to_location(location, context);
304 }
305 LspEvent::Locations {
306 context,
307 kind,
308 items,
309 } => {
310 if !self.finish_lsp_reply(&context) {
311 trace::services::rejected("lsp", "location-list owner changed");
312 return;
313 }
314 match items.len() {
315 0 => {
316 self.message = format!("no {}", kind.label());
317 }
318 1 => {
319 if let Some(location) = items.into_iter().next() {
320 self.jump_to_location(location, context);
321 }
322 }
323 count => {
324 use strop_picker::{Item, Kind, Payload};
325 let items = items
326 .into_iter()
327 .filter_map(|location| {
328 let line = location.position.line.get() + 1;
329 let col = location.position.column.get() + 1;
330 let text = format!("{}:{}:{}", location.doc.label(), line, col);
331 let payload = match location.doc.filesystem {
332 Filesystem::Local => Payload::Grep {
333 path: location.doc.path,
334 line,
335 col,
336 match_len: 1,
337 line_text: String::new(),
338 },
339 Filesystem::Remote(endpoint) => Payload::Remote {
340 endpoint,
341 path: location.doc.path,
342 line,
343 col,
344 },
345 Filesystem::Container(_) => {
350 trace::services::rejected(
351 "lsp",
352 "location in a container namespace (unwired)",
353 );
354 return None;
355 }
356 };
357 Some(Item { text, payload })
358 })
359 .collect();
360 let mut glue = super::PickerGlue::diagnostics(strop_picker::Picker::new(
361 Kind::Locations,
362 items,
363 false,
364 ));
365 glue.lsp_context = Some(context);
366 self.set_picker(glue);
367 self.message = format!("{count} {}", kind.label());
368 }
369 }
370 }
371 }
372 }
373
374 pub(crate) fn jump_to_location(
375 &mut self,
376 location: strop_lsp::ServerLocation,
377 context: strop_lsp::ReplyContext,
378 ) {
379 if !self.lsp_context_fresh(&context) {
380 return;
381 }
382 let intent = super::io::OpenIntent::LspLocation {
383 context,
384 position: location.position,
385 };
386 match location.doc.filesystem {
387 Filesystem::Local => self.request_open(location.doc.path, intent),
388 Filesystem::Remote(endpoint) => {
389 match self.remote_file_for(&endpoint) {
394 Some(seed) => match seed.with_path(location.doc.path.clone()) {
395 Ok(file) => self
396 .request_target(crate::files::FileTarget::Remote(file.into()), intent),
397 Err(error) => {
398 trace::services::rejected("lsp", "remote navigation target invalid");
399 self.message = format!("lsp: remote target invalid: {error}");
400 }
401 },
402 None => {
403 trace::services::rejected("lsp", "remote navigation endpoint lost");
404 self.message =
405 "lsp: the remote workspace for this target was closed".into();
406 }
407 }
408 }
409 Filesystem::Container(_) => {
410 trace::services::rejected("lsp", "navigation into a container namespace (unwired)");
411 self.message = "lsp: container locations are not navigable yet".into();
412 }
413 }
414 }
415
416 pub(crate) fn finish_lsp_jump(
417 &mut self,
418 target: strop_core::id::DocumentId,
419 position: strop_lsp::ServerPosition,
420 context: strop_lsp::ReplyContext,
421 ) {
422 if !self.lsp_context_fresh(&context) {
423 trace::services::rejected("lsp", "navigation changed while target was loading");
424 return;
425 }
426 let Some(target_doc) = self.docs.get(target) else {
427 return;
428 };
429 let Some(binding) = self.lsp_state.bindings.get(&context.stamp.document) else {
430 return;
431 };
432 let outside = match &target_doc.source {
433 crate::editor::document::DocumentSource::Remote(file) => {
437 !file.file.path().starts_with(&binding.root)
438 }
439 _ => target_doc
440 .buf
441 .path
442 .as_ref()
443 .is_some_and(|path| !self.cwd.join(path).starts_with(&binding.root)),
444 };
445 let line = position
446 .line
447 .get()
448 .min(target_doc.buf.len_lines().saturating_sub(1));
449 let text = target_doc
450 .buf
451 .text()
452 .byte_slice(target_doc.buf.line_start(line)..target_doc.buf.line_end(line));
453 let col = strop_lsp::to_byte_col_slice(text, position.column, context.encoding).get();
454 let head = target_doc
455 .buf
456 .clamp_boundary(target_doc.buf.line_start(line).saturating_add(col));
457 self.push_jump();
458 self.lsp_state.navigation = None;
459 self.switch_to(target);
460 if outside && !self.buf().readonly {
461 self.buf_mut().readonly = true;
462 self.message = "readonly — outside workspace (:set noro to edit)".into();
463 }
464 self.set_head(head);
465 self.clamp_cursor();
466 let origin = self
471 .lsp_state
472 .bindings
473 .get(&context.stamp.document)
474 .map(|b| {
475 (
476 b.server,
477 b.root.clone(),
478 b.target.clone(),
479 b.language.clone(),
480 )
481 });
482 if let Some((server, root, origin_target, language)) = origin {
483 let target_bound = self.lsp_state.bindings.contains_key(&target);
484 let doc = self.lsp_doc_path(target);
485 let language_compatible = doc
488 .as_ref()
489 .and_then(|doc| lsp_language(&doc.path))
490 .is_none_or(|known| {
491 known == language
492 || (matches!(known, "c" | "cpp")
493 && matches!(language.as_str(), "c" | "cpp"))
494 });
495 let context_free = !self.lsp_state.jump_contexts.contains_key(&target);
496 if let (false, true, Some(doc), true) =
497 (target_bound, context_free, doc, language_compatible)
498 {
499 if doc.filesystem == origin_target {
500 self.lsp_state.jump_contexts.insert(
503 target,
504 state::JumpContext {
505 server,
506 root,
507 language,
508 target: origin_target,
509 },
510 );
511 }
512 }
513 }
514 self.scroll_to_cursor(self.view_rows());
515 self.lsp_maybe_attach();
516 }
517
518 pub(crate) fn lsp_jump_from_picker(
521 &mut self,
522 path: PathBuf,
523 line: usize,
524 col: usize,
525 context: strop_lsp::ReplyContext,
526 ) {
527 self.jump_to_location(
528 strop_lsp::ServerLocation {
529 doc: ResourceLocation::local(path),
530 position: strop_lsp::ServerPosition {
531 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
532 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
533 },
534 },
535 context,
536 );
537 }
538
539 pub(crate) fn lsp_open_remote_hit(
545 &mut self,
546 endpoint: &strop_workspace::RemoteEndpoint,
547 path: &Path,
548 line: usize,
549 col: usize,
550 context: Option<strop_lsp::ReplyContext>,
551 ) {
552 if let Some(context) = context {
553 self.jump_to_location(
554 strop_lsp::ServerLocation {
555 doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
556 position: strop_lsp::ServerPosition {
557 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
558 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
559 },
560 },
561 context,
562 );
563 } else if let Some(seed) = self.remote_file_for(endpoint) {
564 self.push_jump();
567 match seed.with_path(path.to_owned()) {
568 Ok(file) => self.request_target(
569 crate::files::FileTarget::Remote(file.into()),
570 super::io::OpenIntent::Grep {
571 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
572 column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
573 },
574 ),
575 Err(error) => self.message = format!("lsp remote location: {error}"),
576 }
577 } else {
578 self.message = "lsp: the remote workspace for this hit was closed".into();
579 }
580 }
581 pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
582 self.lsp_request(strop_lsp::RequestKind::Locations(kind));
583 }
584 pub(crate) fn lsp_hover(&mut self) {
585 self.lsp_request(strop_lsp::RequestKind::Hover);
586 }
587 pub(crate) fn lsp_goto_definition(&mut self) {
588 self.lsp_request(strop_lsp::RequestKind::Goto);
589 }
590 pub(crate) fn lsp_switch_source_header(&mut self) {
591 self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
592 }
593 pub(crate) fn lsp_format(&mut self) {
595 self.lsp_change_request(strop_lsp::RequestKind::Format, None);
596 }
597 pub(crate) fn lsp_rename(&mut self, new_name: &str) {
599 self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
600 }
601 pub(crate) fn lsp_document_symbols(&mut self) {
603 self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
604 }
605 pub(crate) fn lsp_code_actions(&mut self) {
607 self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
608 }
609
610 pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
611 let Some(diags) = self
612 .diags_for(self.current())
613 .filter(|diags| !diags.is_empty())
614 else {
615 self.message = "no diagnostics".into();
616 return;
617 };
618 let cur = self.buf().line_of(self.head());
619 let col = self.buf().col_of(self.head());
620 let target = if forward {
621 diags
622 .iter()
623 .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
624 .or(diags.first())
625 } else {
626 diags
627 .iter()
628 .rev()
629 .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
630 .or(diags.last())
631 };
632 let Some(d) = target else {
633 return;
634 };
635 let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
636 let start = self
637 .buf()
638 .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
639 self.set_head(self.buf().clamp_boundary(start + col));
640 self.clamp_cursor();
641 self.scroll_to_cursor(self.view_rows());
642 self.message = msg;
643 }
644
645 pub(crate) fn open_diagnostics_picker(&mut self) {
646 use strop_picker::{Item, Kind, Payload};
647 let mut by_doc: Vec<_> = self
649 .diags
650 .keys()
651 .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
652 .collect();
653 by_doc.sort_by(|a, b| {
654 (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
655 });
656 let mut items: Vec<Item> = Vec::new();
657 for (doc, diags) in by_doc {
658 for d in diags {
659 let line = d.line.get() + 1;
660 let col = d.col.get() + 1;
661 match &doc.filesystem {
662 Filesystem::Local => items.push(Item {
663 text: format!(
664 "{}:{} {} {}",
665 doc.path.display(),
666 line,
667 d.severity_char(),
668 d.message
669 ),
670 payload: Payload::Grep {
671 path: doc.path.clone(),
672 line,
673 col,
674 match_len: 1,
675 line_text: d.message.clone(),
676 },
677 }),
678 Filesystem::Remote(endpoint) => items.push(Item {
682 text: format!(
683 "{}{}:{} {} {}",
684 endpoint,
685 doc.path.display(),
686 line,
687 d.severity_char(),
688 d.message
689 ),
690 payload: Payload::Remote {
691 endpoint: endpoint.clone(),
692 path: doc.path.clone(),
693 line,
694 col,
695 },
696 }),
697 Filesystem::Container(_) => {
701 trace::services::rejected(
702 "lsp",
703 "diagnostic in a container namespace (unwired)",
704 );
705 }
706 }
707 }
708 }
709 if items.is_empty() {
710 self.message = "no diagnostics".into();
711 return;
712 }
713 self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
714 Kind::Diagnostics,
715 items,
716 false,
717 )));
718 }
719
720 pub(crate) fn lsp_goto_definition_pub(&mut self) {
721 self.lsp_goto_definition();
722 }
723 pub(crate) fn lsp_switch_source_header_pub(&mut self) {
724 self.lsp_switch_source_header();
725 }
726 pub(crate) fn lsp_hover_pub(&mut self) {
727 self.lsp_hover();
728 }
729 pub fn lsp_code_actions_pub(&mut self) {
730 self.lsp_code_actions();
731 }
732 pub fn lsp_document_symbols_pub(&mut self) {
733 self.lsp_document_symbols();
734 }
735 pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
736 self.lsp_locations(kind);
737 }
738 pub fn jump_diagnostic_pub(&mut self, forward: bool) {
739 self.jump_diagnostic(forward);
740 }
741}
742
743pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
746 let ext = path.extension()?.to_str()?;
747 registry::language_for_extension_name(ext)
748}
749
750pub(crate) fn lang_id(path: &Path) -> &'static str {
752 match path.extension().and_then(|e| e.to_str()) {
753 Some("rs") => "rust",
754 Some("py") | Some("pyi") => "python",
755 Some("go") => "go",
756 Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
757 Some("ts") => "typescript",
758 Some("tsx") => "typescriptreact",
759 Some("json") => "json",
760 Some("sh") | Some("bash") => "shellscript",
761 Some("c") | Some("h") => "c",
762 Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
763 _ => "plaintext",
764 }
765}