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