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