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 self.scroll_to_cursor(self.view_rows());
467 self.lsp_maybe_attach();
468 }
469
470 pub(crate) fn lsp_jump_from_picker(
473 &mut self,
474 path: PathBuf,
475 line: usize,
476 col: usize,
477 context: strop_lsp::ReplyContext,
478 ) {
479 self.jump_to_location(
480 strop_lsp::ServerLocation {
481 doc: ResourceLocation::local(path),
482 position: strop_lsp::ServerPosition {
483 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
484 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
485 },
486 },
487 context,
488 );
489 }
490
491 pub(crate) fn lsp_open_remote_hit(
497 &mut self,
498 endpoint: &strop_workspace::RemoteEndpoint,
499 path: &Path,
500 line: usize,
501 col: usize,
502 context: Option<strop_lsp::ReplyContext>,
503 ) {
504 if let Some(context) = context {
505 self.jump_to_location(
506 strop_lsp::ServerLocation {
507 doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
508 position: strop_lsp::ServerPosition {
509 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
510 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
511 },
512 },
513 context,
514 );
515 } else if let Some(seed) = self.remote_file_for(endpoint) {
516 self.push_jump();
519 match seed.with_path(path.to_owned()) {
520 Ok(file) => self.request_target(
521 crate::files::FileTarget::Remote(file.into()),
522 super::io::OpenIntent::Grep {
523 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
524 column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
525 },
526 ),
527 Err(error) => self.message = format!("lsp remote location: {error}"),
528 }
529 } else {
530 self.message = "lsp: the remote workspace for this hit was closed".into();
531 }
532 }
533 pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
534 self.lsp_request(strop_lsp::RequestKind::Locations(kind));
535 }
536 pub(crate) fn lsp_hover(&mut self) {
537 self.lsp_request(strop_lsp::RequestKind::Hover);
538 }
539 pub(crate) fn lsp_goto_definition(&mut self) {
540 self.lsp_request(strop_lsp::RequestKind::Goto);
541 }
542 pub(crate) fn lsp_switch_source_header(&mut self) {
543 self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
544 }
545 pub(crate) fn lsp_format(&mut self) {
547 self.lsp_change_request(strop_lsp::RequestKind::Format, None);
548 }
549 pub(crate) fn lsp_rename(&mut self, new_name: &str) {
551 self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
552 }
553 pub(crate) fn lsp_document_symbols(&mut self) {
555 self.lsp_request(strop_lsp::RequestKind::DocumentSymbols);
556 }
557 pub(crate) fn lsp_code_actions(&mut self) {
559 self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
560 }
561
562 pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
563 let Some(diags) = self
564 .diags_for(self.current())
565 .filter(|diags| !diags.is_empty())
566 else {
567 self.message = "no diagnostics".into();
568 return;
569 };
570 let cur = self.buf().line_of(self.head());
571 let col = self.buf().col_of(self.head());
572 let target = if forward {
573 diags
574 .iter()
575 .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
576 .or(diags.first())
577 } else {
578 diags
579 .iter()
580 .rev()
581 .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
582 .or(diags.last())
583 };
584 let Some(d) = target else {
585 return;
586 };
587 let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
588 let start = self
589 .buf()
590 .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
591 self.set_head(self.buf().clamp_boundary(start + col));
592 self.clamp_cursor();
593 self.scroll_to_cursor(self.view_rows());
594 self.message = msg;
595 }
596
597 pub(crate) fn open_diagnostics_picker(&mut self) {
598 use strop_picker::{Item, Kind, Payload};
599 let mut by_doc: Vec<_> = self
601 .diags
602 .keys()
603 .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
604 .collect();
605 by_doc.sort_by(|a, b| {
606 (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
607 });
608 let mut items: Vec<Item> = Vec::new();
609 for (doc, diags) in by_doc {
610 for d in diags {
611 let line = d.line.get() + 1;
612 let col = d.col.get() + 1;
613 match &doc.filesystem {
614 Filesystem::Local => items.push(Item {
615 text: format!(
616 "{}:{} {} {}",
617 doc.path.display(),
618 line,
619 d.severity_char(),
620 d.message
621 ),
622 payload: Payload::Grep {
623 path: doc.path.clone(),
624 line,
625 col,
626 match_len: 1,
627 line_text: d.message.clone(),
628 },
629 }),
630 Filesystem::Remote(endpoint) => items.push(Item {
634 text: format!(
635 "{}{}:{} {} {}",
636 endpoint,
637 doc.path.display(),
638 line,
639 d.severity_char(),
640 d.message
641 ),
642 payload: Payload::Remote {
643 endpoint: endpoint.clone(),
644 path: doc.path.clone(),
645 line,
646 col,
647 },
648 }),
649 Filesystem::Container(_) => {
653 trace::services::rejected(
654 "lsp",
655 "diagnostic in a container namespace (unwired)",
656 );
657 }
658 }
659 }
660 }
661 if items.is_empty() {
662 self.message = "no diagnostics".into();
663 return;
664 }
665 self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
666 Kind::Diagnostics,
667 items,
668 false,
669 )));
670 }
671
672 pub(crate) fn lsp_goto_definition_pub(&mut self) {
673 self.lsp_goto_definition();
674 }
675 pub(crate) fn lsp_switch_source_header_pub(&mut self) {
676 self.lsp_switch_source_header();
677 }
678 pub(crate) fn lsp_hover_pub(&mut self) {
679 self.lsp_hover();
680 }
681 pub fn lsp_code_actions_pub(&mut self) {
682 self.lsp_code_actions();
683 }
684 pub fn lsp_document_symbols_pub(&mut self) {
685 self.lsp_document_symbols();
686 }
687 pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
688 self.lsp_locations(kind);
689 }
690 pub fn jump_diagnostic_pub(&mut self, forward: bool) {
691 self.jump_diagnostic(forward);
692 }
693}
694
695pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
698 let ext = path.extension()?.to_str()?;
699 registry::language_for_extension_name(ext)
700}
701
702pub(crate) fn lang_id(path: &Path) -> &'static str {
704 match path.extension().and_then(|e| e.to_str()) {
705 Some("rs") => "rust",
706 Some("py") | Some("pyi") => "python",
707 Some("go") => "go",
708 Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
709 Some("ts") => "typescript",
710 Some("tsx") => "typescriptreact",
711 Some("json") => "json",
712 Some("sh") | Some("bash") => "shellscript",
713 Some("c") | Some("h") => "c",
714 Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
715 _ => "plaintext",
716 }
717}