1use std::ops::Range;
2
3use gpui::{impl_internal_actions, MutableAppContext, Task, ViewContext};
4use language::{Bias, ToOffset};
5use project::LocationLink;
6use settings::Settings;
7use util::TryFutureExt;
8use workspace::Workspace;
9
10use crate::{Anchor, DisplayPoint, Editor, EditorSnapshot, GoToDefinition, Select, SelectPhase};
11
12#[derive(Clone, PartialEq)]
13pub struct UpdateGoToDefinitionLink {
14 pub point: Option<DisplayPoint>,
15 pub cmd_held: bool,
16}
17
18#[derive(Clone, PartialEq)]
19pub struct CmdChanged {
20 pub cmd_down: bool,
21}
22
23#[derive(Clone, PartialEq)]
24pub struct GoToFetchedDefinition {
25 pub point: DisplayPoint,
26}
27
28impl_internal_actions!(
29 editor,
30 [UpdateGoToDefinitionLink, CmdChanged, GoToFetchedDefinition]
31);
32
33pub fn init(cx: &mut MutableAppContext) {
34 cx.add_action(update_go_to_definition_link);
35 cx.add_action(cmd_changed);
36 cx.add_action(go_to_fetched_definition);
37}
38
39#[derive(Default)]
40pub struct LinkGoToDefinitionState {
41 pub last_mouse_location: Option<Anchor>,
42 pub symbol_range: Option<Range<Anchor>>,
43 pub definitions: Vec<LocationLink>,
44 pub task: Option<Task<Option<()>>>,
45}
46
47pub fn update_go_to_definition_link(
48 editor: &mut Editor,
49 &UpdateGoToDefinitionLink { point, cmd_held }: &UpdateGoToDefinitionLink,
50 cx: &mut ViewContext<Editor>,
51) {
52 // Store new mouse point as an anchor
53 let snapshot = editor.snapshot(cx);
54 let point = point.map(|point| {
55 snapshot
56 .buffer_snapshot
57 .anchor_before(point.to_offset(&snapshot.display_snapshot, Bias::Left))
58 });
59
60 // If the new point is the same as the previously stored one, return early
61 if let (Some(a), Some(b)) = (
62 &point,
63 &editor.link_go_to_definition_state.last_mouse_location,
64 ) {
65 if a.cmp(&b, &snapshot.buffer_snapshot).is_eq() {
66 return;
67 }
68 }
69
70 editor.link_go_to_definition_state.last_mouse_location = point.clone();
71 if cmd_held {
72 if let Some(point) = point {
73 show_link_definition(editor, point, snapshot, cx);
74 return;
75 }
76 }
77
78 hide_link_definition(editor, cx);
79}
80
81pub fn cmd_changed(
82 editor: &mut Editor,
83 &CmdChanged { cmd_down }: &CmdChanged,
84 cx: &mut ViewContext<Editor>,
85) {
86 if let Some(point) = editor
87 .link_go_to_definition_state
88 .last_mouse_location
89 .clone()
90 {
91 if cmd_down {
92 let snapshot = editor.snapshot(cx);
93 show_link_definition(editor, point.clone(), snapshot, cx);
94 } else {
95 hide_link_definition(editor, cx)
96 }
97 }
98}
99
100pub fn show_link_definition(
101 editor: &mut Editor,
102 trigger_point: Anchor,
103 snapshot: EditorSnapshot,
104 cx: &mut ViewContext<Editor>,
105) {
106 if editor.pending_rename.is_some() {
107 return;
108 }
109
110 let (buffer, buffer_position) = if let Some(output) = editor
111 .buffer
112 .read(cx)
113 .text_anchor_for_position(trigger_point.clone(), cx)
114 {
115 output
116 } else {
117 return;
118 };
119
120 let excerpt_id = if let Some((excerpt_id, _, _)) = editor
121 .buffer()
122 .read(cx)
123 .excerpt_containing(trigger_point.clone(), cx)
124 {
125 excerpt_id
126 } else {
127 return;
128 };
129
130 let project = if let Some(project) = editor.project.clone() {
131 project
132 } else {
133 return;
134 };
135
136 // Don't request again if the location is within the symbol region of a previous request
137 if let Some(symbol_range) = &editor.link_go_to_definition_state.symbol_range {
138 if symbol_range
139 .start
140 .cmp(&trigger_point, &snapshot.buffer_snapshot)
141 .is_le()
142 && symbol_range
143 .end
144 .cmp(&trigger_point, &snapshot.buffer_snapshot)
145 .is_ge()
146 {
147 return;
148 }
149 }
150
151 let task = cx.spawn_weak(|this, mut cx| {
152 async move {
153 // query the LSP for definition info
154 let definition_request = cx.update(|cx| {
155 project.update(cx, |project, cx| {
156 project.definition(&buffer, buffer_position.clone(), cx)
157 })
158 });
159
160 let result = definition_request.await.ok().map(|definition_result| {
161 (
162 definition_result.iter().find_map(|link| {
163 link.origin.as_ref().map(|origin| {
164 let start = snapshot
165 .buffer_snapshot
166 .anchor_in_excerpt(excerpt_id.clone(), origin.range.start);
167 let end = snapshot
168 .buffer_snapshot
169 .anchor_in_excerpt(excerpt_id.clone(), origin.range.end);
170
171 start..end
172 })
173 }),
174 definition_result,
175 )
176 });
177
178 if let Some(this) = this.upgrade(&cx) {
179 this.update(&mut cx, |this, cx| {
180 // Clear any existing highlights
181 this.clear_text_highlights::<LinkGoToDefinitionState>(cx);
182 this.link_go_to_definition_state.symbol_range = result
183 .as_ref()
184 .and_then(|(symbol_range, _)| symbol_range.clone());
185
186 if let Some((symbol_range, definitions)) = result {
187 this.link_go_to_definition_state.definitions = definitions.clone();
188
189 let buffer_snapshot = buffer.read(cx).snapshot();
190 // Only show highlight if there exists a definition to jump to that doesn't contain
191 // the current location.
192 if definitions.iter().any(|definition| {
193 let target = &definition.target;
194 if target.buffer == buffer {
195 let range = &target.range;
196 // Expand range by one character as lsp definition ranges include positions adjacent
197 // but not contained by the symbol range
198 let start = buffer_snapshot.clip_offset(
199 range.start.to_offset(&buffer_snapshot).saturating_sub(1),
200 Bias::Left,
201 );
202 let end = buffer_snapshot.clip_offset(
203 range.end.to_offset(&buffer_snapshot) + 1,
204 Bias::Right,
205 );
206 let offset = buffer_position.to_offset(&buffer_snapshot);
207 !(start <= offset && end >= offset)
208 } else {
209 true
210 }
211 }) {
212 // If no symbol range returned from language server, use the surrounding word.
213 let highlight_range = symbol_range.unwrap_or_else(|| {
214 let snapshot = &snapshot.buffer_snapshot;
215 let (offset_range, _) = snapshot.surrounding_word(trigger_point);
216
217 snapshot.anchor_before(offset_range.start)
218 ..snapshot.anchor_after(offset_range.end)
219 });
220
221 // Highlight symbol using theme link definition highlight style
222 let style = cx.global::<Settings>().theme.editor.link_definition;
223 this.highlight_text::<LinkGoToDefinitionState>(
224 vec![highlight_range],
225 style,
226 cx,
227 )
228 } else {
229 hide_link_definition(this, cx);
230 }
231 }
232 })
233 }
234
235 Ok::<_, anyhow::Error>(())
236 }
237 .log_err()
238 });
239
240 editor.link_go_to_definition_state.task = Some(task);
241}
242
243pub fn hide_link_definition(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
244 if editor.link_go_to_definition_state.symbol_range.is_some()
245 || !editor.link_go_to_definition_state.definitions.is_empty()
246 {
247 editor.link_go_to_definition_state.symbol_range.take();
248 editor.link_go_to_definition_state.definitions.clear();
249 cx.notify();
250 }
251
252 editor.link_go_to_definition_state.task = None;
253
254 editor.clear_text_highlights::<LinkGoToDefinitionState>(cx);
255}
256
257pub fn go_to_fetched_definition(
258 workspace: &mut Workspace,
259 GoToFetchedDefinition { point }: &GoToFetchedDefinition,
260 cx: &mut ViewContext<Workspace>,
261) {
262 let active_item = workspace.active_item(cx);
263 let editor_handle = if let Some(editor) = active_item
264 .as_ref()
265 .and_then(|item| item.act_as::<Editor>(cx))
266 {
267 editor
268 } else {
269 return;
270 };
271
272 let definitions = editor_handle.update(cx, |editor, cx| {
273 let definitions = editor.link_go_to_definition_state.definitions.clone();
274 hide_link_definition(editor, cx);
275 definitions
276 });
277
278 if !definitions.is_empty() {
279 Editor::navigate_to_definitions(workspace, editor_handle, definitions, cx);
280 } else {
281 editor_handle.update(cx, |editor, cx| {
282 editor.select(
283 &Select(SelectPhase::Begin {
284 position: point.clone(),
285 add: false,
286 click_count: 1,
287 }),
288 cx,
289 );
290 });
291
292 Editor::go_to_definition(workspace, &GoToDefinition, cx);
293 }
294}
295
296#[cfg(test)]
297mod tests {
298 use futures::StreamExt;
299 use indoc::indoc;
300
301 use crate::test::EditorLspTestContext;
302
303 use super::*;
304
305 #[gpui::test]
306 async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) {
307 let mut cx = EditorLspTestContext::new_rust(
308 lsp::ServerCapabilities {
309 hover_provider: Some(lsp::HoverProviderCapability::Simple(true)),
310 ..Default::default()
311 },
312 cx,
313 )
314 .await;
315
316 cx.set_state(indoc! {"
317 fn |test()
318 do_work();
319
320 fn do_work()
321 test();"});
322
323 // Basic hold cmd, expect highlight in region if response contains definition
324 let hover_point = cx.display_point(indoc! {"
325 fn test()
326 do_w|ork();
327
328 fn do_work()
329 test();"});
330
331 let symbol_range = cx.lsp_range(indoc! {"
332 fn test()
333 [do_work]();
334
335 fn do_work()
336 test();"});
337 let target_range = cx.lsp_range(indoc! {"
338 fn test()
339 do_work();
340
341 fn [do_work]()
342 test();"});
343
344 let mut requests =
345 cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
346 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
347 lsp::LocationLink {
348 origin_selection_range: Some(symbol_range),
349 target_uri: url.clone(),
350 target_range,
351 target_selection_range: target_range,
352 },
353 ])))
354 });
355 cx.update_editor(|editor, cx| {
356 update_go_to_definition_link(
357 editor,
358 &UpdateGoToDefinitionLink {
359 point: Some(hover_point),
360 cmd_held: true,
361 },
362 cx,
363 );
364 });
365 requests.next().await;
366 cx.foreground().run_until_parked();
367 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
368 fn test()
369 [do_work]();
370
371 fn do_work()
372 test();"});
373
374 // Unpress cmd causes highlight to go away
375 cx.update_editor(|editor, cx| {
376 cmd_changed(editor, &CmdChanged { cmd_down: false }, cx);
377 });
378 // Assert no link highlights
379 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
380 fn test()
381 do_work();
382
383 fn do_work()
384 test();"});
385
386 // Response without source range still highlights word
387 cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_mouse_location = None);
388 let mut requests =
389 cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
390 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
391 lsp::LocationLink {
392 // No origin range
393 origin_selection_range: None,
394 target_uri: url.clone(),
395 target_range,
396 target_selection_range: target_range,
397 },
398 ])))
399 });
400 cx.update_editor(|editor, cx| {
401 update_go_to_definition_link(
402 editor,
403 &UpdateGoToDefinitionLink {
404 point: Some(hover_point),
405 cmd_held: true,
406 },
407 cx,
408 );
409 });
410 requests.next().await;
411 cx.foreground().run_until_parked();
412
413 println!("tag");
414 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
415 fn test()
416 [do_work]();
417
418 fn do_work()
419 test();"});
420
421 // Moving mouse to location with no response dismisses highlight
422 let hover_point = cx.display_point(indoc! {"
423 f|n test()
424 do_work();
425
426 fn do_work()
427 test();"});
428 let mut requests =
429 cx.lsp
430 .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
431 // No definitions returned
432 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
433 });
434 cx.update_editor(|editor, cx| {
435 update_go_to_definition_link(
436 editor,
437 &UpdateGoToDefinitionLink {
438 point: Some(hover_point),
439 cmd_held: true,
440 },
441 cx,
442 );
443 });
444 requests.next().await;
445 cx.foreground().run_until_parked();
446
447 // Assert no link highlights
448 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
449 fn test()
450 do_work();
451
452 fn do_work()
453 test();"});
454
455 // Move mouse without cmd and then pressing cmd triggers highlight
456 let hover_point = cx.display_point(indoc! {"
457 fn test()
458 do_work();
459
460 fn do_work()
461 te|st();"});
462 cx.update_editor(|editor, cx| {
463 update_go_to_definition_link(
464 editor,
465 &UpdateGoToDefinitionLink {
466 point: Some(hover_point),
467 cmd_held: false,
468 },
469 cx,
470 );
471 });
472 cx.foreground().run_until_parked();
473
474 // Assert no link highlights
475 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
476 fn test()
477 do_work();
478
479 fn do_work()
480 test();"});
481
482 let symbol_range = cx.lsp_range(indoc! {"
483 fn test()
484 do_work();
485
486 fn do_work()
487 [test]();"});
488 let target_range = cx.lsp_range(indoc! {"
489 fn [test]()
490 do_work();
491
492 fn do_work()
493 test();"});
494
495 let mut requests =
496 cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
497 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
498 lsp::LocationLink {
499 origin_selection_range: Some(symbol_range),
500 target_uri: url,
501 target_range,
502 target_selection_range: target_range,
503 },
504 ])))
505 });
506 cx.update_editor(|editor, cx| {
507 cmd_changed(editor, &CmdChanged { cmd_down: true }, cx);
508 });
509 requests.next().await;
510 cx.foreground().run_until_parked();
511
512 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
513 fn test()
514 do_work();
515
516 fn do_work()
517 [test]();"});
518
519 // Moving within symbol range doesn't re-request
520 let hover_point = cx.display_point(indoc! {"
521 fn test()
522 do_work();
523
524 fn do_work()
525 tes|t();"});
526 cx.update_editor(|editor, cx| {
527 update_go_to_definition_link(
528 editor,
529 &UpdateGoToDefinitionLink {
530 point: Some(hover_point),
531 cmd_held: true,
532 },
533 cx,
534 );
535 });
536 cx.foreground().run_until_parked();
537 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
538 fn test()
539 do_work();
540
541 fn do_work()
542 [test]();"});
543
544 // Cmd click with existing definition doesn't re-request and dismisses highlight
545 cx.update_workspace(|workspace, cx| {
546 go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
547 });
548 // Assert selection moved to to definition
549 cx.lsp
550 .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
551 // Empty definition response to make sure we aren't hitting the lsp and using
552 // the cached location instead
553 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
554 });
555 cx.assert_editor_state(indoc! {"
556 fn [test}()
557 do_work();
558
559 fn do_work()
560 test();"});
561 // Assert no link highlights after jump
562 cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
563 fn test()
564 do_work();
565
566 fn do_work()
567 test();"});
568
569 // Cmd click without existing definition requests and jumps
570 let hover_point = cx.display_point(indoc! {"
571 fn test()
572 do_w|ork();
573
574 fn do_work()
575 test();"});
576 let target_range = cx.lsp_range(indoc! {"
577 fn test()
578 do_work();
579
580 fn [do_work]()
581 test();"});
582
583 let mut requests =
584 cx.handle_request::<lsp::request::GotoDefinition, _, _>(move |url, _, _| async move {
585 Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
586 lsp::LocationLink {
587 origin_selection_range: None,
588 target_uri: url,
589 target_range,
590 target_selection_range: target_range,
591 },
592 ])))
593 });
594 cx.update_workspace(|workspace, cx| {
595 go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
596 });
597 requests.next().await;
598 cx.foreground().run_until_parked();
599
600 cx.assert_editor_state(indoc! {"
601 fn test()
602 do_work();
603
604 fn [do_work}()
605 test();"});
606 }
607}