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