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.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}