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