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}