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::ActionList { context, actions } => {
213 if !self.finish_lsp_reply(&context) {
214 trace::services::rejected("lsp", "code-action owner/revision changed");
215 return;
216 }
217 if actions.is_empty() {
218 self.message = "no code actions here".into();
219 return;
220 }
221 let items = actions
222 .iter()
223 .enumerate()
224 .map(|(index, action)| strop_picker::Item {
225 text: action.title.clone(),
226 payload: strop_picker::Payload::CodeAction(index),
227 })
228 .collect();
229 self.changes.pending_actions = actions;
230 self.open_picker(strop_picker::Kind::CodeActions);
231 self.changes.pending_encoding = context.encoding;
232 if let Some(glue) = self.picker.as_mut() {
233 glue.picker.append(items);
234 }
235 }
236 LspEvent::GotoLocation { context, location } => {
237 if !self.finish_lsp_reply(&context) {
238 trace::services::rejected(
239 "lsp",
240 "navigation request/server/document/revision changed",
241 );
242 return;
243 }
244 self.jump_to_location(location, context);
245 }
246 LspEvent::Locations {
247 context,
248 kind,
249 items,
250 } => {
251 if !self.finish_lsp_reply(&context) {
252 trace::services::rejected("lsp", "location-list owner changed");
253 return;
254 }
255 match items.len() {
256 0 => {
257 self.message = format!("no {}", kind.label());
258 }
259 1 => {
260 if let Some(location) = items.into_iter().next() {
261 self.jump_to_location(location, context);
262 }
263 }
264 count => {
265 use strop_picker::{Item, Kind, Payload};
266 let items = items
267 .into_iter()
268 .filter_map(|location| {
269 let line = location.position.line.get() + 1;
270 let col = location.position.column.get() + 1;
271 let text = format!("{}:{}:{}", location.doc.label(), line, col);
272 let payload = match location.doc.filesystem {
273 Filesystem::Local => Payload::Grep {
274 path: location.doc.path,
275 line,
276 col,
277 match_len: 1,
278 line_text: String::new(),
279 },
280 Filesystem::Remote(endpoint) => Payload::Remote {
281 endpoint,
282 path: location.doc.path,
283 line,
284 col,
285 },
286 Filesystem::Container(_) => {
291 trace::services::rejected(
292 "lsp",
293 "location in a container namespace (unwired)",
294 );
295 return None;
296 }
297 };
298 Some(Item { text, payload })
299 })
300 .collect();
301 let mut glue = super::PickerGlue::diagnostics(strop_picker::Picker::new(
302 Kind::Locations,
303 items,
304 false,
305 ));
306 glue.lsp_context = Some(context);
307 self.set_picker(glue);
308 self.message = format!("{count} {}", kind.label());
309 }
310 }
311 }
312 }
313 }
314
315 pub(crate) fn jump_to_location(
316 &mut self,
317 location: strop_lsp::ServerLocation,
318 context: strop_lsp::ReplyContext,
319 ) {
320 if !self.lsp_context_fresh(&context) {
321 return;
322 }
323 let intent = super::io::OpenIntent::LspLocation {
324 context,
325 position: location.position,
326 };
327 match location.doc.filesystem {
328 Filesystem::Local => self.request_open(location.doc.path, intent),
329 Filesystem::Remote(endpoint) => {
330 match self.remote_file_for(&endpoint) {
335 Some(seed) => match seed.with_path(location.doc.path.clone()) {
336 Ok(file) => self
337 .request_target(crate::files::FileTarget::Remote(file.into()), intent),
338 Err(error) => {
339 trace::services::rejected("lsp", "remote navigation target invalid");
340 self.message = format!("lsp: remote target invalid: {error}");
341 }
342 },
343 None => {
344 trace::services::rejected("lsp", "remote navigation endpoint lost");
345 self.message =
346 "lsp: the remote workspace for this target was closed".into();
347 }
348 }
349 }
350 Filesystem::Container(_) => {
351 trace::services::rejected("lsp", "navigation into a container namespace (unwired)");
352 self.message = "lsp: container locations are not navigable yet".into();
353 }
354 }
355 }
356
357 pub(crate) fn finish_lsp_jump(
358 &mut self,
359 target: strop_core::id::DocumentId,
360 position: strop_lsp::ServerPosition,
361 context: strop_lsp::ReplyContext,
362 ) {
363 if !self.lsp_context_fresh(&context) {
364 trace::services::rejected("lsp", "navigation changed while target was loading");
365 return;
366 }
367 let Some(target_doc) = self.docs.get(target) else {
368 return;
369 };
370 let Some(binding) = self.lsp_state.bindings.get(&context.stamp.document) else {
371 return;
372 };
373 let outside = match &target_doc.source {
374 crate::editor::document::DocumentSource::Remote(file) => {
378 !file.file.path().starts_with(&binding.root)
379 }
380 _ => target_doc
381 .buf
382 .path
383 .as_ref()
384 .is_some_and(|path| !self.cwd.join(path).starts_with(&binding.root)),
385 };
386 let line = position
387 .line
388 .get()
389 .min(target_doc.buf.len_lines().saturating_sub(1));
390 let text = target_doc
391 .buf
392 .text()
393 .byte_slice(target_doc.buf.line_start(line)..target_doc.buf.line_end(line));
394 let col = strop_lsp::to_byte_col_slice(text, position.column, context.encoding).get();
395 let head = target_doc
396 .buf
397 .clamp_boundary(target_doc.buf.line_start(line).saturating_add(col));
398 self.push_jump();
399 self.lsp_state.navigation = None;
400 self.switch_to(target);
401 if outside && !self.buf().readonly {
402 self.buf_mut().readonly = true;
403 self.message = "readonly — outside workspace (:set noro to edit)".into();
404 }
405 self.set_head(head);
406 self.clamp_cursor();
407 self.scroll_to_cursor(self.view_rows());
408 self.lsp_maybe_attach();
409 }
410
411 pub(crate) fn lsp_jump_from_picker(
414 &mut self,
415 path: PathBuf,
416 line: usize,
417 col: usize,
418 context: strop_lsp::ReplyContext,
419 ) {
420 self.jump_to_location(
421 strop_lsp::ServerLocation {
422 doc: ResourceLocation::local(path),
423 position: strop_lsp::ServerPosition {
424 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
425 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
426 },
427 },
428 context,
429 );
430 }
431
432 pub(crate) fn lsp_open_remote_hit(
438 &mut self,
439 endpoint: &strop_workspace::RemoteEndpoint,
440 path: &Path,
441 line: usize,
442 col: usize,
443 context: Option<strop_lsp::ReplyContext>,
444 ) {
445 if let Some(context) = context {
446 self.jump_to_location(
447 strop_lsp::ServerLocation {
448 doc: ResourceLocation::remote(endpoint.clone(), path.to_owned()),
449 position: strop_lsp::ServerPosition {
450 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
451 column: strop_lsp::ServerColumn::new(col.saturating_sub(1)),
452 },
453 },
454 context,
455 );
456 } else if let Some(seed) = self.remote_file_for(endpoint) {
457 match seed.with_path(path.to_owned()) {
458 Ok(file) => self.request_target(
459 crate::files::FileTarget::Remote(file.into()),
460 super::io::OpenIntent::Grep {
461 line: strop_core::id::LineIndex::new(line.saturating_sub(1)),
462 column: strop_core::id::ByteColumn::new(col.saturating_sub(1)),
463 },
464 ),
465 Err(error) => self.message = format!("lsp remote location: {error}"),
466 }
467 } else {
468 self.message = "lsp: the remote workspace for this hit was closed".into();
469 }
470 }
471 pub(crate) fn lsp_locations(&mut self, kind: strop_lsp::LocKind) {
472 self.lsp_request(strop_lsp::RequestKind::Locations(kind));
473 }
474 pub(crate) fn lsp_hover(&mut self) {
475 self.lsp_request(strop_lsp::RequestKind::Hover);
476 }
477 pub(crate) fn lsp_goto_definition(&mut self) {
478 self.lsp_request(strop_lsp::RequestKind::Goto);
479 }
480 pub(crate) fn lsp_switch_source_header(&mut self) {
481 self.lsp_request(strop_lsp::RequestKind::SwitchHeader);
482 }
483 pub(crate) fn lsp_format(&mut self) {
485 self.lsp_change_request(strop_lsp::RequestKind::Format, None);
486 }
487 pub(crate) fn lsp_rename(&mut self, new_name: &str) {
489 self.lsp_change_request(strop_lsp::RequestKind::Rename, Some(new_name.to_string()));
490 }
491 pub(crate) fn lsp_code_actions(&mut self) {
493 self.lsp_change_request(strop_lsp::RequestKind::CodeAction, None);
494 }
495
496 pub(crate) fn jump_diagnostic(&mut self, forward: bool) {
497 let Some(diags) = self
498 .diags_for(self.current())
499 .filter(|diags| !diags.is_empty())
500 else {
501 self.message = "no diagnostics".into();
502 return;
503 };
504 let cur = self.buf().line_of(self.head());
505 let col = self.buf().col_of(self.head());
506 let target = if forward {
507 diags
508 .iter()
509 .find(|d| d.line.get() > cur || (d.line.get() == cur && d.col.get() > col))
510 .or(diags.first())
511 } else {
512 diags
513 .iter()
514 .rev()
515 .find(|d| d.line.get() < cur || (d.line.get() == cur && d.col.get() < col))
516 .or(diags.last())
517 };
518 let Some(d) = target else {
519 return;
520 };
521 let (line, col, msg) = (d.line.get(), d.col.get(), d.message.clone());
522 let start = self
523 .buf()
524 .line_start(line.min(self.buf().len_lines().saturating_sub(1)));
525 self.set_head(self.buf().clamp_boundary(start + col));
526 self.clamp_cursor();
527 self.scroll_to_cursor(self.view_rows());
528 self.message = msg;
529 }
530
531 pub(crate) fn open_diagnostics_picker(&mut self) {
532 use strop_picker::{Item, Kind, Payload};
533 let mut by_doc: Vec<_> = self
535 .diags
536 .keys()
537 .filter_map(|&id| Some((self.lsp_doc_path(id)?, self.diags_for(id)?)))
538 .collect();
539 by_doc.sort_by(|a, b| {
540 (a.0.filesystem.label(), &a.0.path).cmp(&(b.0.filesystem.label(), &b.0.path))
541 });
542 let mut items: Vec<Item> = Vec::new();
543 for (doc, diags) in by_doc {
544 for d in diags {
545 let line = d.line.get() + 1;
546 let col = d.col.get() + 1;
547 match &doc.filesystem {
548 Filesystem::Local => items.push(Item {
549 text: format!(
550 "{}:{} {} {}",
551 doc.path.display(),
552 line,
553 d.severity_char(),
554 d.message
555 ),
556 payload: Payload::Grep {
557 path: doc.path.clone(),
558 line,
559 col,
560 match_len: 1,
561 line_text: d.message.clone(),
562 },
563 }),
564 Filesystem::Remote(endpoint) => items.push(Item {
568 text: format!(
569 "{}{}:{} {} {}",
570 endpoint,
571 doc.path.display(),
572 line,
573 d.severity_char(),
574 d.message
575 ),
576 payload: Payload::Remote {
577 endpoint: endpoint.clone(),
578 path: doc.path.clone(),
579 line,
580 col,
581 },
582 }),
583 Filesystem::Container(_) => {
587 trace::services::rejected(
588 "lsp",
589 "diagnostic in a container namespace (unwired)",
590 );
591 }
592 }
593 }
594 }
595 if items.is_empty() {
596 self.message = "no diagnostics".into();
597 return;
598 }
599 self.set_picker(super::PickerGlue::diagnostics(strop_picker::Picker::new(
600 Kind::Diagnostics,
601 items,
602 false,
603 )));
604 }
605
606 pub(crate) fn lsp_goto_definition_pub(&mut self) {
607 self.lsp_goto_definition();
608 }
609 pub(crate) fn lsp_switch_source_header_pub(&mut self) {
610 self.lsp_switch_source_header();
611 }
612 pub(crate) fn lsp_hover_pub(&mut self) {
613 self.lsp_hover();
614 }
615 pub fn lsp_code_actions_pub(&mut self) {
616 self.lsp_code_actions();
617 }
618 pub fn lsp_locations_pub(&mut self, kind: strop_lsp::LocKind) {
619 self.lsp_locations(kind);
620 }
621 pub fn jump_diagnostic_pub(&mut self, forward: bool) {
622 self.jump_diagnostic(forward);
623 }
624}
625
626pub(crate) fn lsp_language(path: &Path) -> Option<&'static str> {
629 let ext = path.extension()?.to_str()?;
630 registry::language_for_extension_name(ext)
631}
632
633pub(crate) fn lang_id(path: &Path) -> &'static str {
635 match path.extension().and_then(|e| e.to_str()) {
636 Some("rs") => "rust",
637 Some("py") | Some("pyi") => "python",
638 Some("go") => "go",
639 Some("js") | Some("jsx") | Some("mjs") | Some("cjs") => "javascript",
640 Some("ts") => "typescript",
641 Some("tsx") => "typescriptreact",
642 Some("json") => "json",
643 Some("sh") | Some("bash") => "shellscript",
644 Some("c") | Some("h") => "c",
645 Some("cpp") | Some("cc") | Some("cxx") | Some("hpp") | Some("hh") => "cpp",
646 _ => "plaintext",
647 }
648}