document_symbols.rs

  1use std::{cmp, 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::{Bias, 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 = buffer_snapshot.clip_offset(
296        selection_start_offset
297            .saturating_sub(name.len())
298            .max(range_start_offset),
299        Bias::Right,
300    );
301    let search_end = buffer_snapshot.clip_offset(
302        cmp::min(selection_start_offset + name.len() * 2, range_end_offset),
303        Bias::Left,
304    );
305
306    if search_start < search_end {
307        let buffer_text: String = buffer_snapshot
308            .text_for_range(search_start..search_end)
309            .collect();
310        if let Some(found_at) = buffer_text.find(name) {
311            let name_start_offset = search_start + found_at;
312            let name_end_offset = name_start_offset + name.len();
313            let result = highlights_for_buffer_range(
314                name_offset_in_text,
315                name_start_offset..name_end_offset,
316                buffer_id,
317                display_snapshot,
318                syntax_theme,
319            );
320            if result.is_some() {
321                return result;
322            }
323        }
324    }
325
326    // Fallback: match word-by-word. Split the name on whitespace and find
327    // each word sequentially in the buffer's symbol range.
328    let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right);
329    let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left);
330
331    let mut highlights = Vec::new();
332    let mut got_any = false;
333    let buffer_text: String = buffer_snapshot
334        .text_for_range(range_start_offset..range_end_offset)
335        .collect();
336    let mut buf_search_from = 0usize;
337    let mut name_search_from = 0usize;
338    for word in name.split_whitespace() {
339        let name_word_start = name[name_search_from..]
340            .find(word)
341            .map(|pos| name_search_from + pos)
342            .unwrap_or(name_search_from);
343        if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) {
344            let buf_word_start = range_start_offset + buf_search_from + found_in_buf;
345            let buf_word_end = buf_word_start + word.len();
346            let text_cursor = name_offset_in_text + name_word_start;
347            if let Some(mut word_highlights) = highlights_for_buffer_range(
348                text_cursor,
349                buf_word_start..buf_word_end,
350                buffer_id,
351                display_snapshot,
352                syntax_theme,
353            ) {
354                got_any = true;
355                highlights.append(&mut word_highlights);
356            }
357            buf_search_from = buf_search_from + found_in_buf + word.len();
358        }
359        name_search_from = name_word_start + word.len();
360    }
361
362    got_any.then_some(highlights)
363}
364
365/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte
366/// range via the editor's display snapshot, then shifts the returned ranges
367/// so they start at `text_cursor_start` (the position in the outline item text).
368fn highlights_for_buffer_range(
369    text_cursor_start: usize,
370    buffer_range: Range<usize>,
371    buffer_id: BufferId,
372    display_snapshot: &DisplaySnapshot,
373    syntax_theme: &SyntaxTheme,
374) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
375    let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme);
376    if raw.is_empty() {
377        return None;
378    }
379    Some(
380        raw.into_iter()
381            .map(|(range, style)| {
382                (
383                    range.start + text_cursor_start..range.end + text_cursor_start,
384                    style,
385                )
386            })
387            .collect(),
388    )
389}
390
391#[cfg(test)]
392mod tests {
393    use std::{
394        sync::{Arc, atomic},
395        time::Duration,
396    };
397
398    use futures::StreamExt as _;
399    use gpui::TestAppContext;
400    use settings::DocumentSymbols;
401    use util::path;
402    use zed_actions::editor::{MoveDown, MoveUp};
403
404    use crate::{
405        Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
406        editor_tests::{init_test, update_test_language_settings},
407        test::editor_lsp_test_context::EditorLspTestContext,
408    };
409
410    fn outline_symbol_names(editor: &Editor) -> Vec<&str> {
411        editor
412            .outline_symbols_at_cursor
413            .as_ref()
414            .expect("Should have outline symbols")
415            .1
416            .iter()
417            .map(|s| s.text.as_str())
418            .collect()
419    }
420
421    fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
422        lsp::Range {
423            start: lsp::Position::new(start_line, start_char),
424            end: lsp::Position::new(end_line, end_char),
425        }
426    }
427
428    fn nested_symbol(
429        name: &str,
430        kind: lsp::SymbolKind,
431        range: lsp::Range,
432        selection_range: lsp::Range,
433        children: Vec<lsp::DocumentSymbol>,
434    ) -> lsp::DocumentSymbol {
435        #[allow(deprecated)]
436        lsp::DocumentSymbol {
437            name: name.to_string(),
438            detail: None,
439            kind,
440            tags: None,
441            deprecated: None,
442            range,
443            selection_range,
444            children: if children.is_empty() {
445                None
446            } else {
447                Some(children)
448            },
449        }
450    }
451
452    #[gpui::test]
453    async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) {
454        init_test(cx, |_| {});
455
456        update_test_language_settings(cx, &|settings| {
457            settings.defaults.document_symbols = Some(DocumentSymbols::On);
458        });
459
460        let mut cx = EditorLspTestContext::new_rust(
461            lsp::ServerCapabilities {
462                document_symbol_provider: Some(lsp::OneOf::Left(true)),
463                ..lsp::ServerCapabilities::default()
464            },
465            cx,
466        )
467        .await;
468        let mut symbol_request = cx
469            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
470                move |_, _, _| async move {
471                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
472                        nested_symbol(
473                            "main",
474                            lsp::SymbolKind::FUNCTION,
475                            lsp_range(0, 0, 2, 1),
476                            lsp_range(0, 3, 0, 7),
477                            Vec::new(),
478                        ),
479                    ])))
480                },
481            );
482
483        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
484        assert!(symbol_request.next().await.is_some());
485        cx.run_until_parked();
486
487        cx.update_editor(|editor, _window, _cx| {
488            assert_eq!(outline_symbol_names(editor), vec!["fn main"]);
489        });
490    }
491
492    #[gpui::test]
493    async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) {
494        init_test(cx, |_| {});
495
496        update_test_language_settings(cx, &|settings| {
497            settings.defaults.document_symbols = Some(DocumentSymbols::On);
498        });
499
500        let mut cx = EditorLspTestContext::new_rust(
501            lsp::ServerCapabilities {
502                document_symbol_provider: Some(lsp::OneOf::Left(true)),
503                ..lsp::ServerCapabilities::default()
504            },
505            cx,
506        )
507        .await;
508        let mut symbol_request = cx
509            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
510                move |_, _, _| async move {
511                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
512                        nested_symbol(
513                            "Foo",
514                            lsp::SymbolKind::STRUCT,
515                            lsp_range(0, 0, 3, 1),
516                            lsp_range(0, 7, 0, 10),
517                            vec![
518                                nested_symbol(
519                                    "bar",
520                                    lsp::SymbolKind::FIELD,
521                                    lsp_range(1, 4, 1, 13),
522                                    lsp_range(1, 4, 1, 7),
523                                    Vec::new(),
524                                ),
525                                nested_symbol(
526                                    "baz",
527                                    lsp::SymbolKind::FIELD,
528                                    lsp_range(2, 4, 2, 15),
529                                    lsp_range(2, 4, 2, 7),
530                                    Vec::new(),
531                                ),
532                            ],
533                        ),
534                    ])))
535                },
536            );
537
538        cx.set_state("struct Foo {\n    baˇr: u32,\n    baz: String,\n}\n");
539        assert!(symbol_request.next().await.is_some());
540        cx.run_until_parked();
541
542        cx.update_editor(|editor, _window, _cx| {
543            assert_eq!(
544                outline_symbol_names(editor),
545                vec!["struct Foo", "bar"],
546                "cursor is inside Foo > bar, so we expect the containing chain"
547            );
548        });
549    }
550
551    #[gpui::test]
552    async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) {
553        init_test(cx, |_| {});
554
555        // Start with tree-sitter (default)
556        let mut cx = EditorLspTestContext::new_rust(
557            lsp::ServerCapabilities {
558                document_symbol_provider: Some(lsp::OneOf::Left(true)),
559                ..lsp::ServerCapabilities::default()
560            },
561            cx,
562        )
563        .await;
564        let mut symbol_request = cx
565            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
566                move |_, _, _| async move {
567                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
568                        nested_symbol(
569                            "lsp_main_symbol",
570                            lsp::SymbolKind::FUNCTION,
571                            lsp_range(0, 0, 2, 1),
572                            lsp_range(0, 3, 0, 7),
573                            Vec::new(),
574                        ),
575                    ])))
576                },
577            );
578
579        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
580        cx.run_until_parked();
581
582        // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline
583        cx.update_editor(|editor, _window, _cx| {
584            assert_eq!(
585                outline_symbol_names(editor),
586                vec!["fn main"],
587                "Tree-sitter should produce 'fn main'"
588            );
589        });
590
591        // Step 2: Switch to LSP
592        update_test_language_settings(&mut cx.cx.cx, &|settings| {
593            settings.defaults.document_symbols = Some(DocumentSymbols::On);
594        });
595        assert!(symbol_request.next().await.is_some());
596        cx.run_until_parked();
597
598        cx.update_editor(|editor, _window, _cx| {
599            assert_eq!(
600                outline_symbol_names(editor),
601                vec!["lsp_main_symbol"],
602                "After switching to LSP, should see LSP symbols"
603            );
604        });
605
606        // Step 3: Switch back to tree-sitter
607        update_test_language_settings(&mut cx.cx.cx, &|settings| {
608            settings.defaults.document_symbols = Some(DocumentSymbols::Off);
609        });
610        cx.run_until_parked();
611
612        // Force another selection change
613        cx.update_editor(|editor, window, cx| {
614            editor.move_up(&MoveUp, window, cx);
615        });
616        cx.run_until_parked();
617
618        cx.update_editor(|editor, _window, _cx| {
619            assert_eq!(
620                outline_symbol_names(editor),
621                vec!["fn main"],
622                "After switching back to tree-sitter, should see tree-sitter symbols again"
623            );
624        });
625    }
626
627    #[gpui::test]
628    async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) {
629        init_test(cx, |_| {});
630
631        update_test_language_settings(cx, &|settings| {
632            settings.defaults.document_symbols = Some(DocumentSymbols::On);
633        });
634
635        let request_count = Arc::new(atomic::AtomicUsize::new(0));
636        let request_count_clone = request_count.clone();
637
638        let mut cx = EditorLspTestContext::new_rust(
639            lsp::ServerCapabilities {
640                document_symbol_provider: Some(lsp::OneOf::Left(true)),
641                ..lsp::ServerCapabilities::default()
642            },
643            cx,
644        )
645        .await;
646
647        let mut symbol_request = cx
648            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
649                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
650                async move {
651                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
652                        nested_symbol(
653                            "main",
654                            lsp::SymbolKind::FUNCTION,
655                            lsp_range(0, 0, 2, 1),
656                            lsp_range(0, 3, 0, 7),
657                            Vec::new(),
658                        ),
659                    ])))
660                }
661            });
662
663        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
664        assert!(symbol_request.next().await.is_some());
665        cx.run_until_parked();
666
667        let first_count = request_count.load(atomic::Ordering::Acquire);
668        assert_eq!(first_count, 1, "Should have made exactly one request");
669
670        // Move cursor within the same buffer version — should use cache
671        cx.update_editor(|editor, window, cx| {
672            editor.move_down(&MoveDown, window, cx);
673        });
674        cx.background_executor
675            .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
676        cx.run_until_parked();
677
678        assert_eq!(
679            first_count,
680            request_count.load(atomic::Ordering::Acquire),
681            "Moving cursor without editing should use cached symbols"
682        );
683    }
684
685    #[gpui::test]
686    async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) {
687        init_test(cx, |_| {});
688
689        update_test_language_settings(cx, &|settings| {
690            settings.defaults.document_symbols = Some(DocumentSymbols::On);
691        });
692
693        let mut cx = EditorLspTestContext::new_rust(
694            lsp::ServerCapabilities {
695                document_symbol_provider: Some(lsp::OneOf::Left(true)),
696                ..lsp::ServerCapabilities::default()
697            },
698            cx,
699        )
700        .await;
701        let mut symbol_request = cx
702            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
703                move |_, _, _| async move {
704                    #[allow(deprecated)]
705                    Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![
706                        lsp::SymbolInformation {
707                            name: "main".to_string(),
708                            kind: lsp::SymbolKind::FUNCTION,
709                            tags: None,
710                            deprecated: None,
711                            location: lsp::Location {
712                                uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
713                                range: lsp_range(0, 0, 2, 1),
714                            },
715                            container_name: None,
716                        },
717                    ])))
718                },
719            );
720
721        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
722        assert!(symbol_request.next().await.is_some());
723        cx.run_until_parked();
724
725        cx.update_editor(|editor, _window, _cx| {
726            assert_eq!(outline_symbol_names(editor), vec!["main"]);
727        });
728    }
729
730    #[gpui::test]
731    async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) {
732        init_test(cx, |_| {});
733
734        update_test_language_settings(cx, &|settings| {
735            settings.defaults.document_symbols = Some(DocumentSymbols::On);
736        });
737
738        let mut cx = EditorLspTestContext::new_rust(
739            lsp::ServerCapabilities {
740                document_symbol_provider: Some(lsp::OneOf::Left(true)),
741                ..lsp::ServerCapabilities::default()
742            },
743            cx,
744        )
745        .await;
746        let mut symbol_request = cx
747            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
748                move |_, _, _| async move {
749                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
750                        nested_symbol(
751                            "MyModule",
752                            lsp::SymbolKind::MODULE,
753                            lsp_range(0, 0, 4, 1),
754                            lsp_range(0, 4, 0, 12),
755                            vec![nested_symbol(
756                                "my_function",
757                                lsp::SymbolKind::FUNCTION,
758                                lsp_range(1, 4, 3, 5),
759                                lsp_range(1, 7, 1, 18),
760                                Vec::new(),
761                            )],
762                        ),
763                    ])))
764                },
765            );
766
767        cx.set_state("mod MyModule {\n    fn my_fuˇnction() {\n        let x = 1;\n    }\n}\n");
768        assert!(symbol_request.next().await.is_some());
769        cx.run_until_parked();
770
771        cx.update_editor(|editor, _window, _cx| {
772            assert_eq!(
773                outline_symbol_names(editor),
774                vec!["mod MyModule", "fn my_function"]
775            );
776        });
777    }
778
779    #[gpui::test]
780    async fn test_lsp_document_symbols_multibyte_highlights(cx: &mut TestAppContext) {
781        init_test(cx, |_| {});
782
783        update_test_language_settings(cx, &|settings| {
784            settings.defaults.document_symbols = Some(DocumentSymbols::On);
785        });
786
787        let mut cx = EditorLspTestContext::new_rust(
788            lsp::ServerCapabilities {
789                document_symbol_provider: Some(lsp::OneOf::Left(true)),
790                ..lsp::ServerCapabilities::default()
791            },
792            cx,
793        )
794        .await;
795        let mut symbol_request = cx
796            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
797                move |_, _, _| async move {
798                    // Buffer: "/// αyzabc\nfn test() {}\n"
799                    // Bytes 0-3: "/// ", bytes 4-5: α (2-byte UTF-8), bytes 6-11: "yzabc\n"
800                    // Line 1 starts at byte 12: "fn test() {}"
801                    //
802                    // Symbol range includes doc comment (line 0-1).
803                    // Selection points to "test" on line 1.
804                    // enriched_symbol_text extracts "fn test" with source_range_for_text.start at byte 12.
805                    // search_start = max(12 - 7, 0) = 5, which is INSIDE the 2-byte 'α' char.
806                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
807                        nested_symbol(
808                            "test",
809                            lsp::SymbolKind::FUNCTION,
810                            lsp_range(0, 0, 1, 13), // includes doc comment
811                            lsp_range(1, 3, 1, 7),  // "test"
812                            Vec::new(),
813                        ),
814                    ])))
815                },
816            );
817
818        // "/// αyzabc\n" = 12 bytes, then "fn test() {}\n"
819        // search_start = 12 - 7 = 5, which is byte 5 = second byte of 'α' (not a char boundary)
820        cx.set_state("/// αyzabc\nfn teˇst() {}\n");
821        assert!(symbol_request.next().await.is_some());
822        cx.run_until_parked();
823
824        cx.update_editor(|editor, _window, _cx| {
825            let (_, symbols) = editor
826                .outline_symbols_at_cursor
827                .as_ref()
828                .expect("Should have outline symbols");
829            assert_eq!(symbols.len(), 1);
830
831            let symbol = &symbols[0];
832            assert_eq!(symbol.text, "fn test");
833
834            // Verify all highlight ranges are valid byte boundaries in the text
835            for (range, _style) in &symbol.highlight_ranges {
836                assert!(
837                    symbol.text.is_char_boundary(range.start),
838                    "highlight range start {} is not a char boundary in {:?}",
839                    range.start,
840                    symbol.text
841                );
842                assert!(
843                    symbol.text.is_char_boundary(range.end),
844                    "highlight range end {} is not a char boundary in {:?}",
845                    range.end,
846                    symbol.text
847                );
848                assert!(
849                    range.end <= symbol.text.len(),
850                    "highlight range end {} exceeds text length {} for {:?}",
851                    range.end,
852                    symbol.text.len(),
853                    symbol.text
854                );
855            }
856        });
857    }
858
859    #[gpui::test]
860    async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) {
861        init_test(cx, |_| {});
862
863        update_test_language_settings(cx, &|settings| {
864            settings.defaults.document_symbols = Some(DocumentSymbols::On);
865        });
866
867        let mut cx = EditorLspTestContext::new_rust(
868            lsp::ServerCapabilities {
869                document_symbol_provider: Some(lsp::OneOf::Left(true)),
870                ..lsp::ServerCapabilities::default()
871            },
872            cx,
873        )
874        .await;
875        let mut symbol_request = cx
876            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
877                move |_, _, _| async move {
878                    Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
879                },
880            );
881
882        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
883        assert!(symbol_request.next().await.is_some());
884        cx.run_until_parked();
885        cx.update_editor(|editor, _window, _cx| {
886            // With LSP enabled but empty response, outline_symbols_at_cursor should be None
887            // (no symbols to show in breadcrumbs)
888            assert!(
889                editor.outline_symbols_at_cursor.is_none(),
890                "Empty LSP response should result in no outline symbols"
891            );
892        });
893    }
894
895    #[gpui::test]
896    async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) {
897        init_test(cx, |_| {});
898
899        let request_count = Arc::new(atomic::AtomicUsize::new(0));
900        // Do NOT enable document_symbols — defaults to Off
901        let mut cx = EditorLspTestContext::new_rust(
902            lsp::ServerCapabilities {
903                document_symbol_provider: Some(lsp::OneOf::Left(true)),
904                ..lsp::ServerCapabilities::default()
905            },
906            cx,
907        )
908        .await;
909        let request_count_clone = request_count.clone();
910        let _symbol_request =
911            cx.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
912                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
913                async move {
914                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
915                        nested_symbol(
916                            "should_not_appear",
917                            lsp::SymbolKind::FUNCTION,
918                            lsp_range(0, 0, 2, 1),
919                            lsp_range(0, 3, 0, 7),
920                            Vec::new(),
921                        ),
922                    ])))
923                }
924            });
925
926        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
927        cx.run_until_parked();
928
929        // Tree-sitter should be used instead
930        cx.update_editor(|editor, _window, _cx| {
931            assert_eq!(
932                outline_symbol_names(editor),
933                vec!["fn main"],
934                "With document_symbols off, should use tree-sitter"
935            );
936        });
937
938        assert_eq!(
939            request_count.load(atomic::Ordering::Acquire),
940            0,
941            "Should not have made any LSP document symbol requests when setting is off"
942        );
943    }
944}