link_go_to_definition.rs

  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.lsp
346                .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
347                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
348                        lsp::LocationLink {
349                            origin_selection_range: Some(symbol_range),
350                            target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
351                            target_range,
352                            target_selection_range: target_range,
353                        },
354                    ])))
355                });
356        cx.update_editor(|editor, cx| {
357            update_go_to_definition_link(
358                editor,
359                &UpdateGoToDefinitionLink {
360                    point: Some(hover_point),
361                    cmd_held: true,
362                },
363                cx,
364            );
365        });
366        requests.next().await;
367        cx.foreground().run_until_parked();
368        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
369            fn test()
370                [do_work]();
371            
372            fn do_work()
373                test();"});
374
375        // Unpress cmd causes highlight to go away
376        cx.update_editor(|editor, cx| {
377            cmd_changed(editor, &CmdChanged { cmd_down: false }, cx);
378        });
379        // Assert no link highlights
380        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
381            fn test()
382                do_work();
383            
384            fn do_work()
385                test();"});
386
387        // Response without source range still highlights word
388        cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_mouse_location = None);
389        let mut requests =
390            cx.lsp
391                .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
392                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
393                        lsp::LocationLink {
394                            // No origin range
395                            origin_selection_range: None,
396                            target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
397                            target_range,
398                            target_selection_range: target_range,
399                        },
400                    ])))
401                });
402        cx.update_editor(|editor, cx| {
403            update_go_to_definition_link(
404                editor,
405                &UpdateGoToDefinitionLink {
406                    point: Some(hover_point),
407                    cmd_held: true,
408                },
409                cx,
410            );
411        });
412        requests.next().await;
413        cx.foreground().run_until_parked();
414
415        println!("tag");
416        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
417            fn test()
418                [do_work]();
419            
420            fn do_work()
421                test();"});
422
423        // Moving mouse to location with no response dismisses highlight
424        let hover_point = cx.display_point(indoc! {"
425            f|n test()
426                do_work();
427            
428            fn do_work()
429                test();"});
430        let mut requests =
431            cx.lsp
432                .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
433                    // No definitions returned
434                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
435                });
436        cx.update_editor(|editor, cx| {
437            update_go_to_definition_link(
438                editor,
439                &UpdateGoToDefinitionLink {
440                    point: Some(hover_point),
441                    cmd_held: true,
442                },
443                cx,
444            );
445        });
446        requests.next().await;
447        cx.foreground().run_until_parked();
448
449        // Assert no link highlights
450        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
451            fn test()
452                do_work();
453            
454            fn do_work()
455                test();"});
456
457        // Move mouse without cmd and then pressing cmd triggers highlight
458        let hover_point = cx.display_point(indoc! {"
459            fn test()
460                do_work();
461            
462            fn do_work()
463                te|st();"});
464        cx.update_editor(|editor, cx| {
465            update_go_to_definition_link(
466                editor,
467                &UpdateGoToDefinitionLink {
468                    point: Some(hover_point),
469                    cmd_held: false,
470                },
471                cx,
472            );
473        });
474        cx.foreground().run_until_parked();
475
476        // Assert no link highlights
477        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
478            fn test()
479                do_work();
480            
481            fn do_work()
482                test();"});
483
484        let symbol_range = cx.lsp_range(indoc! {"
485            fn test()
486                do_work();
487            
488            fn do_work()
489                [test]();"});
490        let target_range = cx.lsp_range(indoc! {"
491            fn [test]()
492                do_work();
493            
494            fn do_work()
495                test();"});
496
497        let mut requests =
498            cx.lsp
499                .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
500                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
501                        lsp::LocationLink {
502                            origin_selection_range: Some(symbol_range),
503                            target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
504                            target_range,
505                            target_selection_range: target_range,
506                        },
507                    ])))
508                });
509        cx.update_editor(|editor, cx| {
510            cmd_changed(editor, &CmdChanged { cmd_down: true }, cx);
511        });
512        requests.next().await;
513        cx.foreground().run_until_parked();
514
515        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
516            fn test()
517                do_work();
518            
519            fn do_work()
520                [test]();"});
521
522        // Moving within symbol range doesn't re-request
523        let hover_point = cx.display_point(indoc! {"
524            fn test()
525                do_work();
526            
527            fn do_work()
528                tes|t();"});
529        cx.update_editor(|editor, cx| {
530            update_go_to_definition_link(
531                editor,
532                &UpdateGoToDefinitionLink {
533                    point: Some(hover_point),
534                    cmd_held: true,
535                },
536                cx,
537            );
538        });
539        cx.foreground().run_until_parked();
540        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
541            fn test()
542                do_work();
543            
544            fn do_work()
545                [test]();"});
546
547        // Cmd click with existing definition doesn't re-request and dismisses highlight
548        cx.update_workspace(|workspace, cx| {
549            go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
550        });
551        // Assert selection moved to to definition
552        cx.lsp
553            .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
554                // Empty definition response to make sure we aren't hitting the lsp and using
555                // the cached location instead
556                Ok(Some(lsp::GotoDefinitionResponse::Link(vec![])))
557            });
558        cx.assert_editor_state(indoc! {"
559            fn [test}()
560                do_work();
561            
562            fn do_work()
563                test();"});
564        // Assert no link highlights after jump
565        cx.assert_editor_text_highlights::<LinkGoToDefinitionState>(indoc! {"
566            fn test()
567                do_work();
568            
569            fn do_work()
570                test();"});
571
572        // Cmd click without existing definition requests and jumps
573        let hover_point = cx.display_point(indoc! {"
574            fn test()
575                do_w|ork();
576            
577            fn do_work()
578                test();"});
579        let target_range = cx.lsp_range(indoc! {"
580            fn test()
581                do_work();
582            
583            fn [do_work]()
584                test();"});
585
586        let mut requests =
587            cx.lsp
588                .handle_request::<lsp::request::GotoDefinition, _, _>(move |_, _| async move {
589                    Ok(Some(lsp::GotoDefinitionResponse::Link(vec![
590                        lsp::LocationLink {
591                            origin_selection_range: None,
592                            target_uri: lsp::Url::from_file_path("/root/dir/file.rs").unwrap(),
593                            target_range,
594                            target_selection_range: target_range,
595                        },
596                    ])))
597                });
598        cx.update_workspace(|workspace, cx| {
599            go_to_fetched_definition(workspace, &GoToFetchedDefinition { point: hover_point }, cx);
600        });
601        requests.next().await;
602        cx.foreground().run_until_parked();
603
604        cx.assert_editor_state(indoc! {"
605            fn test()
606                do_work();
607            
608            fn [do_work}()
609                test();"});
610    }
611}