document_symbols.rs

  1use std::ops::Range;
  2
  3use collections::HashMap;
  4use futures::FutureExt;
  5use futures::future::join_all;
  6use gpui::{App, Context, HighlightStyle, Task};
  7use itertools::Itertools as _;
  8use language::language_settings::language_settings;
  9use language::{Buffer, BufferSnapshot, OutlineItem};
 10use multi_buffer::{Anchor, MultiBufferSnapshot};
 11use text::{BufferId, OffsetRangeExt as _, ToOffset as _};
 12use theme::{ActiveTheme as _, SyntaxTheme};
 13
 14use crate::display_map::DisplaySnapshot;
 15use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT};
 16
 17impl Editor {
 18    /// Returns all document outline items for a buffer, using LSP or
 19    /// tree-sitter based on the `document_symbols` setting.
 20    /// External consumers (outline modal, outline panel, breadcrumbs) should use this.
 21    pub fn buffer_outline_items(
 22        &self,
 23        buffer_id: BufferId,
 24        cx: &mut Context<Self>,
 25    ) -> Task<Vec<OutlineItem<text::Anchor>>> {
 26        let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
 27            return Task::ready(Vec::new());
 28        };
 29
 30        if lsp_symbols_enabled(buffer.read(cx), cx) {
 31            let refresh_task = self.refresh_document_symbols_task.clone();
 32            cx.spawn(async move |editor, cx| {
 33                refresh_task.await;
 34                editor
 35                    .read_with(cx, |editor, _| {
 36                        editor
 37                            .lsp_document_symbols
 38                            .get(&buffer_id)
 39                            .cloned()
 40                            .unwrap_or_default()
 41                    })
 42                    .ok()
 43                    .unwrap_or_default()
 44            })
 45        } else {
 46            let buffer_snapshot = buffer.read(cx).snapshot();
 47            let syntax = cx.theme().syntax().clone();
 48            cx.background_executor()
 49                .spawn(async move { buffer_snapshot.outline(Some(&syntax)).items })
 50        }
 51    }
 52
 53    /// Whether the buffer at `cursor` has LSP document symbols enabled.
 54    pub(super) fn uses_lsp_document_symbols(
 55        &self,
 56        cursor: Anchor,
 57        multi_buffer_snapshot: &MultiBufferSnapshot,
 58        cx: &Context<Self>,
 59    ) -> bool {
 60        let Some(excerpt) = multi_buffer_snapshot.excerpt_containing(cursor..cursor) else {
 61            return false;
 62        };
 63        let Some(buffer) = self.buffer.read(cx).buffer(excerpt.buffer_id()) else {
 64            return false;
 65        };
 66        lsp_symbols_enabled(buffer.read(cx), cx)
 67    }
 68
 69    /// Filters editor-local LSP document symbols to the ancestor chain
 70    /// containing `cursor`. Never triggers an LSP request.
 71    pub(super) fn lsp_symbols_at_cursor(
 72        &self,
 73        cursor: Anchor,
 74        multi_buffer_snapshot: &MultiBufferSnapshot,
 75        cx: &Context<Self>,
 76    ) -> Option<(BufferId, Vec<OutlineItem<Anchor>>)> {
 77        let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?;
 78        let excerpt_id = excerpt.id();
 79        let buffer_id = excerpt.buffer_id();
 80        let buffer = self.buffer.read(cx).buffer(buffer_id)?;
 81        let buffer_snapshot = buffer.read(cx).snapshot();
 82        let cursor_text_anchor = cursor.text_anchor;
 83
 84        let all_items = self.lsp_document_symbols.get(&buffer_id)?;
 85        if all_items.is_empty() {
 86            return None;
 87        }
 88
 89        let mut symbols = all_items
 90            .iter()
 91            .filter(|item| {
 92                item.range
 93                    .start
 94                    .cmp(&cursor_text_anchor, &buffer_snapshot)
 95                    .is_le()
 96                    && item
 97                        .range
 98                        .end
 99                        .cmp(&cursor_text_anchor, &buffer_snapshot)
100                        .is_ge()
101            })
102            .map(|item| OutlineItem {
103                depth: item.depth,
104                range: Anchor::range_in_buffer(excerpt_id, item.range.clone()),
105                source_range_for_text: Anchor::range_in_buffer(
106                    excerpt_id,
107                    item.source_range_for_text.clone(),
108                ),
109                text: item.text.clone(),
110                highlight_ranges: item.highlight_ranges.clone(),
111                name_ranges: item.name_ranges.clone(),
112                body_range: item
113                    .body_range
114                    .as_ref()
115                    .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())),
116                annotation_range: item
117                    .annotation_range
118                    .as_ref()
119                    .map(|r| Anchor::range_in_buffer(excerpt_id, r.clone())),
120            })
121            .collect::<Vec<_>>();
122
123        let mut prev_depth = None;
124        symbols.retain(|item| {
125            let retain = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth);
126            prev_depth = Some(item.depth);
127            retain
128        });
129
130        Some((buffer_id, symbols))
131    }
132
133    /// Fetches document symbols from the LSP for buffers that have the setting
134    /// enabled. Called from `update_lsp_data` on edits, server events, etc.
135    /// When the fetch completes, stores results in `self.lsp_document_symbols`
136    /// and triggers `refresh_outline_symbols_at_cursor` so breadcrumbs pick up the new data.
137    pub(super) fn refresh_document_symbols(
138        &mut self,
139        for_buffer: Option<BufferId>,
140        cx: &mut Context<Self>,
141    ) {
142        if !self.mode().is_full() {
143            return;
144        }
145        let Some(project) = self.project.clone() else {
146            return;
147        };
148
149        let buffers_to_query = self
150            .visible_excerpts(true, cx)
151            .into_iter()
152            .filter_map(|(_, (buffer, _, _))| {
153                let id = buffer.read(cx).remote_id();
154                if for_buffer.is_none_or(|target| target == id)
155                    && lsp_symbols_enabled(buffer.read(cx), cx)
156                {
157                    Some(buffer)
158                } else {
159                    None
160                }
161            })
162            .unique_by(|buffer| buffer.read(cx).remote_id())
163            .collect::<Vec<_>>();
164
165        let mut symbols_altered = false;
166        let multi_buffer = self.buffer().clone();
167        self.lsp_document_symbols.retain(|buffer_id, _| {
168            let Some(buffer) = multi_buffer.read(cx).buffer(*buffer_id) else {
169                symbols_altered = true;
170                return false;
171            };
172            let retain = lsp_symbols_enabled(buffer.read(cx), cx);
173            symbols_altered |= !retain;
174            retain
175        });
176        if symbols_altered {
177            self.refresh_outline_symbols_at_cursor(cx);
178        }
179
180        if buffers_to_query.is_empty() {
181            return;
182        }
183
184        self.refresh_document_symbols_task = cx
185            .spawn(async move |editor, cx| {
186                cx.background_executor()
187                    .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
188                    .await;
189
190                let Some(tasks) = editor
191                    .update(cx, |_, cx| {
192                        project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
193                            buffers_to_query
194                                .into_iter()
195                                .map(|buffer| {
196                                    let buffer_id = buffer.read(cx).remote_id();
197                                    let task = lsp_store.fetch_document_symbols(&buffer, cx);
198                                    async move { (buffer_id, task.await) }
199                                })
200                                .collect::<Vec<_>>()
201                        })
202                    })
203                    .ok()
204                else {
205                    return;
206                };
207
208                let results = join_all(tasks).await.into_iter().collect::<HashMap<_, _>>();
209                editor
210                    .update(cx, |editor, cx| {
211                        let syntax = cx.theme().syntax().clone();
212                        let display_snapshot =
213                            editor.display_map.update(cx, |map, cx| map.snapshot(cx));
214                        let mut highlighted_results = results;
215                        for (buffer_id, items) in &mut highlighted_results {
216                            if let Some(buffer) = editor.buffer.read(cx).buffer(*buffer_id) {
217                                let snapshot = buffer.read(cx).snapshot();
218                                apply_highlights(
219                                    items,
220                                    *buffer_id,
221                                    &snapshot,
222                                    &display_snapshot,
223                                    &syntax,
224                                );
225                            }
226                        }
227                        editor.lsp_document_symbols.extend(highlighted_results);
228                        editor.refresh_outline_symbols_at_cursor(cx);
229                    })
230                    .ok();
231            })
232            .shared();
233    }
234}
235
236fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool {
237    language_settings(buffer.language().map(|l| l.name()), buffer.file(), cx)
238        .document_symbols
239        .lsp_enabled()
240}
241
242/// Applies combined syntax + semantic token highlights to LSP document symbol
243/// outline items that were built without highlights by the project layer.
244fn apply_highlights(
245    items: &mut [OutlineItem<text::Anchor>],
246    buffer_id: BufferId,
247    buffer_snapshot: &BufferSnapshot,
248    display_snapshot: &DisplaySnapshot,
249    syntax_theme: &SyntaxTheme,
250) {
251    for item in items {
252        let symbol_range = item.range.to_offset(buffer_snapshot);
253        let selection_start = item.source_range_for_text.start.to_offset(buffer_snapshot);
254
255        if let Some(highlights) = highlights_from_buffer(
256            &item.text,
257            0,
258            buffer_id,
259            buffer_snapshot,
260            display_snapshot,
261            symbol_range,
262            selection_start,
263            syntax_theme,
264        ) {
265            item.highlight_ranges = highlights;
266        }
267    }
268}
269
270/// Finds where the symbol name appears in the buffer and returns combined
271/// (tree-sitter + semantic token) highlights for those positions.
272///
273/// First tries to find the name verbatim near the selection range so that
274/// complex names (`impl Trait for Type`) get full highlighting. Falls back
275/// to word-by-word matching for cases like `impl<T> Trait<T> for Type`
276/// where the LSP name doesn't appear verbatim in the buffer.
277fn highlights_from_buffer(
278    name: &str,
279    name_offset_in_text: usize,
280    buffer_id: BufferId,
281    buffer_snapshot: &BufferSnapshot,
282    display_snapshot: &DisplaySnapshot,
283    symbol_range: Range<usize>,
284    selection_start_offset: usize,
285    syntax_theme: &SyntaxTheme,
286) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
287    if name.is_empty() {
288        return None;
289    }
290
291    let range_start_offset = symbol_range.start;
292    let range_end_offset = symbol_range.end;
293
294    // Try to find the name verbatim in the buffer near the selection range.
295    let search_start = selection_start_offset
296        .saturating_sub(name.len())
297        .max(range_start_offset);
298    let search_end = (selection_start_offset + name.len() * 2).min(range_end_offset);
299
300    if search_start < search_end {
301        let buffer_text: String = buffer_snapshot
302            .text_for_range(search_start..search_end)
303            .collect();
304        if let Some(found_at) = buffer_text.find(name) {
305            let name_start_offset = search_start + found_at;
306            let name_end_offset = name_start_offset + name.len();
307            let result = highlights_for_buffer_range(
308                name_offset_in_text,
309                name_start_offset..name_end_offset,
310                buffer_id,
311                display_snapshot,
312                syntax_theme,
313            );
314            if result.is_some() {
315                return result;
316            }
317        }
318    }
319
320    // Fallback: match word-by-word. Split the name on whitespace and find
321    // each word sequentially in the buffer's symbol range.
322    let mut highlights = Vec::new();
323    let mut got_any = false;
324    let buffer_text: String = buffer_snapshot
325        .text_for_range(range_start_offset..range_end_offset)
326        .collect();
327    let mut buf_search_from = 0usize;
328    let mut name_search_from = 0usize;
329    for word in name.split_whitespace() {
330        let name_word_start = name[name_search_from..]
331            .find(word)
332            .map(|pos| name_search_from + pos)
333            .unwrap_or(name_search_from);
334        if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) {
335            let buf_word_start = range_start_offset + buf_search_from + found_in_buf;
336            let buf_word_end = buf_word_start + word.len();
337            let text_cursor = name_offset_in_text + name_word_start;
338            if let Some(mut word_highlights) = highlights_for_buffer_range(
339                text_cursor,
340                buf_word_start..buf_word_end,
341                buffer_id,
342                display_snapshot,
343                syntax_theme,
344            ) {
345                got_any = true;
346                highlights.append(&mut word_highlights);
347            }
348            buf_search_from = buf_search_from + found_in_buf + word.len();
349        }
350        name_search_from = name_word_start + word.len();
351    }
352
353    got_any.then_some(highlights)
354}
355
356/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte
357/// range via the editor's display snapshot, then shifts the returned ranges
358/// so they start at `text_cursor_start` (the position in the outline item text).
359fn highlights_for_buffer_range(
360    text_cursor_start: usize,
361    buffer_range: Range<usize>,
362    buffer_id: BufferId,
363    display_snapshot: &DisplaySnapshot,
364    syntax_theme: &SyntaxTheme,
365) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
366    let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme);
367    if raw.is_empty() {
368        return None;
369    }
370    Some(
371        raw.into_iter()
372            .map(|(range, style)| {
373                (
374                    range.start + text_cursor_start..range.end + text_cursor_start,
375                    style,
376                )
377            })
378            .collect(),
379    )
380}
381
382#[cfg(test)]
383mod tests {
384    use std::{
385        sync::{Arc, atomic},
386        time::Duration,
387    };
388
389    use futures::StreamExt as _;
390    use gpui::TestAppContext;
391    use settings::DocumentSymbols;
392    use util::path;
393    use zed_actions::editor::{MoveDown, MoveUp};
394
395    use crate::{
396        Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
397        editor_tests::{init_test, update_test_language_settings},
398        test::editor_lsp_test_context::EditorLspTestContext,
399    };
400
401    fn outline_symbol_names(editor: &Editor) -> Vec<&str> {
402        editor
403            .outline_symbols_at_cursor
404            .as_ref()
405            .expect("Should have outline symbols")
406            .1
407            .iter()
408            .map(|s| s.text.as_str())
409            .collect()
410    }
411
412    fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
413        lsp::Range {
414            start: lsp::Position::new(start_line, start_char),
415            end: lsp::Position::new(end_line, end_char),
416        }
417    }
418
419    fn nested_symbol(
420        name: &str,
421        kind: lsp::SymbolKind,
422        range: lsp::Range,
423        selection_range: lsp::Range,
424        children: Vec<lsp::DocumentSymbol>,
425    ) -> lsp::DocumentSymbol {
426        #[allow(deprecated)]
427        lsp::DocumentSymbol {
428            name: name.to_string(),
429            detail: None,
430            kind,
431            tags: None,
432            deprecated: None,
433            range,
434            selection_range,
435            children: if children.is_empty() {
436                None
437            } else {
438                Some(children)
439            },
440        }
441    }
442
443    #[gpui::test]
444    async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) {
445        init_test(cx, |_| {});
446
447        update_test_language_settings(cx, |settings| {
448            settings.defaults.document_symbols = Some(DocumentSymbols::On);
449        });
450
451        let mut cx = EditorLspTestContext::new_rust(
452            lsp::ServerCapabilities {
453                document_symbol_provider: Some(lsp::OneOf::Left(true)),
454                ..lsp::ServerCapabilities::default()
455            },
456            cx,
457        )
458        .await;
459        let mut symbol_request = cx
460            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
461                move |_, _, _| async move {
462                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
463                        nested_symbol(
464                            "main",
465                            lsp::SymbolKind::FUNCTION,
466                            lsp_range(0, 0, 2, 1),
467                            lsp_range(0, 3, 0, 7),
468                            Vec::new(),
469                        ),
470                    ])))
471                },
472            );
473
474        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
475        assert!(symbol_request.next().await.is_some());
476        cx.run_until_parked();
477
478        cx.update_editor(|editor, _window, _cx| {
479            assert_eq!(outline_symbol_names(editor), vec!["fn main"]);
480        });
481    }
482
483    #[gpui::test]
484    async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) {
485        init_test(cx, |_| {});
486
487        update_test_language_settings(cx, |settings| {
488            settings.defaults.document_symbols = Some(DocumentSymbols::On);
489        });
490
491        let mut cx = EditorLspTestContext::new_rust(
492            lsp::ServerCapabilities {
493                document_symbol_provider: Some(lsp::OneOf::Left(true)),
494                ..lsp::ServerCapabilities::default()
495            },
496            cx,
497        )
498        .await;
499        let mut symbol_request = cx
500            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
501                move |_, _, _| async move {
502                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
503                        nested_symbol(
504                            "Foo",
505                            lsp::SymbolKind::STRUCT,
506                            lsp_range(0, 0, 3, 1),
507                            lsp_range(0, 7, 0, 10),
508                            vec![
509                                nested_symbol(
510                                    "bar",
511                                    lsp::SymbolKind::FIELD,
512                                    lsp_range(1, 4, 1, 13),
513                                    lsp_range(1, 4, 1, 7),
514                                    Vec::new(),
515                                ),
516                                nested_symbol(
517                                    "baz",
518                                    lsp::SymbolKind::FIELD,
519                                    lsp_range(2, 4, 2, 15),
520                                    lsp_range(2, 4, 2, 7),
521                                    Vec::new(),
522                                ),
523                            ],
524                        ),
525                    ])))
526                },
527            );
528
529        cx.set_state("struct Foo {\n    baˇr: u32,\n    baz: String,\n}\n");
530        assert!(symbol_request.next().await.is_some());
531        cx.run_until_parked();
532
533        cx.update_editor(|editor, _window, _cx| {
534            assert_eq!(
535                outline_symbol_names(editor),
536                vec!["struct Foo", "bar"],
537                "cursor is inside Foo > bar, so we expect the containing chain"
538            );
539        });
540    }
541
542    #[gpui::test]
543    async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) {
544        init_test(cx, |_| {});
545
546        // Start with tree-sitter (default)
547        let mut cx = EditorLspTestContext::new_rust(
548            lsp::ServerCapabilities {
549                document_symbol_provider: Some(lsp::OneOf::Left(true)),
550                ..lsp::ServerCapabilities::default()
551            },
552            cx,
553        )
554        .await;
555        let mut symbol_request = cx
556            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
557                move |_, _, _| async move {
558                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
559                        nested_symbol(
560                            "lsp_main_symbol",
561                            lsp::SymbolKind::FUNCTION,
562                            lsp_range(0, 0, 2, 1),
563                            lsp_range(0, 3, 0, 7),
564                            Vec::new(),
565                        ),
566                    ])))
567                },
568            );
569
570        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
571        cx.run_until_parked();
572
573        // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline
574        cx.update_editor(|editor, _window, _cx| {
575            assert_eq!(
576                outline_symbol_names(editor),
577                vec!["fn main"],
578                "Tree-sitter should produce 'fn main'"
579            );
580        });
581
582        // Step 2: Switch to LSP
583        update_test_language_settings(&mut cx.cx.cx, |settings| {
584            settings.defaults.document_symbols = Some(DocumentSymbols::On);
585        });
586        assert!(symbol_request.next().await.is_some());
587        cx.run_until_parked();
588
589        cx.update_editor(|editor, _window, _cx| {
590            assert_eq!(
591                outline_symbol_names(editor),
592                vec!["lsp_main_symbol"],
593                "After switching to LSP, should see LSP symbols"
594            );
595        });
596
597        // Step 3: Switch back to tree-sitter
598        update_test_language_settings(&mut cx.cx.cx, |settings| {
599            settings.defaults.document_symbols = Some(DocumentSymbols::Off);
600        });
601        cx.run_until_parked();
602
603        // Force another selection change
604        cx.update_editor(|editor, window, cx| {
605            editor.move_up(&MoveUp, window, cx);
606        });
607        cx.run_until_parked();
608
609        cx.update_editor(|editor, _window, _cx| {
610            assert_eq!(
611                outline_symbol_names(editor),
612                vec!["fn main"],
613                "After switching back to tree-sitter, should see tree-sitter symbols again"
614            );
615        });
616    }
617
618    #[gpui::test]
619    async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) {
620        init_test(cx, |_| {});
621
622        update_test_language_settings(cx, |settings| {
623            settings.defaults.document_symbols = Some(DocumentSymbols::On);
624        });
625
626        let request_count = Arc::new(atomic::AtomicUsize::new(0));
627        let request_count_clone = request_count.clone();
628
629        let mut cx = EditorLspTestContext::new_rust(
630            lsp::ServerCapabilities {
631                document_symbol_provider: Some(lsp::OneOf::Left(true)),
632                ..lsp::ServerCapabilities::default()
633            },
634            cx,
635        )
636        .await;
637
638        let mut symbol_request = cx
639            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
640                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
641                async move {
642                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
643                        nested_symbol(
644                            "main",
645                            lsp::SymbolKind::FUNCTION,
646                            lsp_range(0, 0, 2, 1),
647                            lsp_range(0, 3, 0, 7),
648                            Vec::new(),
649                        ),
650                    ])))
651                }
652            });
653
654        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
655        assert!(symbol_request.next().await.is_some());
656        cx.run_until_parked();
657
658        let first_count = request_count.load(atomic::Ordering::Acquire);
659        assert_eq!(first_count, 1, "Should have made exactly one request");
660
661        // Move cursor within the same buffer version — should use cache
662        cx.update_editor(|editor, window, cx| {
663            editor.move_down(&MoveDown, window, cx);
664        });
665        cx.background_executor
666            .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
667        cx.run_until_parked();
668
669        assert_eq!(
670            first_count,
671            request_count.load(atomic::Ordering::Acquire),
672            "Moving cursor without editing should use cached symbols"
673        );
674    }
675
676    #[gpui::test]
677    async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) {
678        init_test(cx, |_| {});
679
680        update_test_language_settings(cx, |settings| {
681            settings.defaults.document_symbols = Some(DocumentSymbols::On);
682        });
683
684        let mut cx = EditorLspTestContext::new_rust(
685            lsp::ServerCapabilities {
686                document_symbol_provider: Some(lsp::OneOf::Left(true)),
687                ..lsp::ServerCapabilities::default()
688            },
689            cx,
690        )
691        .await;
692        let mut symbol_request = cx
693            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
694                move |_, _, _| async move {
695                    #[allow(deprecated)]
696                    Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![
697                        lsp::SymbolInformation {
698                            name: "main".to_string(),
699                            kind: lsp::SymbolKind::FUNCTION,
700                            tags: None,
701                            deprecated: None,
702                            location: lsp::Location {
703                                uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
704                                range: lsp_range(0, 0, 2, 1),
705                            },
706                            container_name: None,
707                        },
708                    ])))
709                },
710            );
711
712        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
713        assert!(symbol_request.next().await.is_some());
714        cx.run_until_parked();
715
716        cx.update_editor(|editor, _window, _cx| {
717            assert_eq!(outline_symbol_names(editor), vec!["main"]);
718        });
719    }
720
721    #[gpui::test]
722    async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) {
723        init_test(cx, |_| {});
724
725        update_test_language_settings(cx, |settings| {
726            settings.defaults.document_symbols = Some(DocumentSymbols::On);
727        });
728
729        let mut cx = EditorLspTestContext::new_rust(
730            lsp::ServerCapabilities {
731                document_symbol_provider: Some(lsp::OneOf::Left(true)),
732                ..lsp::ServerCapabilities::default()
733            },
734            cx,
735        )
736        .await;
737        let mut symbol_request = cx
738            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
739                move |_, _, _| async move {
740                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
741                        nested_symbol(
742                            "MyModule",
743                            lsp::SymbolKind::MODULE,
744                            lsp_range(0, 0, 4, 1),
745                            lsp_range(0, 4, 0, 12),
746                            vec![nested_symbol(
747                                "my_function",
748                                lsp::SymbolKind::FUNCTION,
749                                lsp_range(1, 4, 3, 5),
750                                lsp_range(1, 7, 1, 18),
751                                Vec::new(),
752                            )],
753                        ),
754                    ])))
755                },
756            );
757
758        cx.set_state("mod MyModule {\n    fn my_fuˇnction() {\n        let x = 1;\n    }\n}\n");
759        assert!(symbol_request.next().await.is_some());
760        cx.run_until_parked();
761
762        cx.update_editor(|editor, _window, _cx| {
763            assert_eq!(
764                outline_symbol_names(editor),
765                vec!["mod MyModule", "fn my_function"]
766            );
767        });
768    }
769
770    #[gpui::test]
771    async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) {
772        init_test(cx, |_| {});
773
774        update_test_language_settings(cx, |settings| {
775            settings.defaults.document_symbols = Some(DocumentSymbols::On);
776        });
777
778        let mut cx = EditorLspTestContext::new_rust(
779            lsp::ServerCapabilities {
780                document_symbol_provider: Some(lsp::OneOf::Left(true)),
781                ..lsp::ServerCapabilities::default()
782            },
783            cx,
784        )
785        .await;
786        let mut symbol_request = cx
787            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
788                move |_, _, _| async move {
789                    Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
790                },
791            );
792
793        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
794        assert!(symbol_request.next().await.is_some());
795        cx.run_until_parked();
796        cx.update_editor(|editor, _window, _cx| {
797            // With LSP enabled but empty response, outline_symbols_at_cursor should be None
798            // (no symbols to show in breadcrumbs)
799            assert!(
800                editor.outline_symbols_at_cursor.is_none(),
801                "Empty LSP response should result in no outline symbols"
802            );
803        });
804    }
805
806    #[gpui::test]
807    async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) {
808        init_test(cx, |_| {});
809
810        let request_count = Arc::new(atomic::AtomicUsize::new(0));
811        // Do NOT enable document_symbols — defaults to Off
812        let mut cx = EditorLspTestContext::new_rust(
813            lsp::ServerCapabilities {
814                document_symbol_provider: Some(lsp::OneOf::Left(true)),
815                ..lsp::ServerCapabilities::default()
816            },
817            cx,
818        )
819        .await;
820        let request_count_clone = request_count.clone();
821        let _symbol_request =
822            cx.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
823                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
824                async move {
825                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
826                        nested_symbol(
827                            "should_not_appear",
828                            lsp::SymbolKind::FUNCTION,
829                            lsp_range(0, 0, 2, 1),
830                            lsp_range(0, 3, 0, 7),
831                            Vec::new(),
832                        ),
833                    ])))
834                }
835            });
836
837        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
838        cx.run_until_parked();
839
840        // Tree-sitter should be used instead
841        cx.update_editor(|editor, _window, _cx| {
842            assert_eq!(
843                outline_symbol_names(editor),
844                vec!["fn main"],
845                "With document_symbols off, should use tree-sitter"
846            );
847        });
848
849        assert_eq!(
850            request_count.load(atomic::Ordering::Acquire),
851            0,
852            "Should not have made any LSP document symbol requests when setting is off"
853        );
854    }
855}