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