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