1use std::{cmp, ops::Range};
2
3use collections::HashMap;
4use futures::FutureExt;
5use futures::future::join_all;
6use gpui::{App, Context, HighlightStyle, Task};
7use itertools::Itertools as _;
8use language::language_settings::language_settings;
9use language::{Buffer, BufferSnapshot, OutlineItem};
10use multi_buffer::{Anchor, MultiBufferSnapshot};
11use text::{Bias, BufferId, OffsetRangeExt as _, ToOffset as _};
12use theme::{ActiveTheme as _, SyntaxTheme};
13
14use crate::display_map::DisplaySnapshot;
15use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT};
16
17impl Editor {
18 /// Returns all document outline items for a buffer, using LSP or
19 /// tree-sitter based on the `document_symbols` setting.
20 /// External consumers (outline modal, outline panel, breadcrumbs) should use this.
21 pub fn buffer_outline_items(
22 &self,
23 buffer_id: BufferId,
24 cx: &mut Context<Self>,
25 ) -> Task<Vec<OutlineItem<text::Anchor>>> {
26 let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
27 return Task::ready(Vec::new());
28 };
29
30 if lsp_symbols_enabled(buffer.read(cx), cx) {
31 let refresh_task = self.refresh_document_symbols_task.clone();
32 cx.spawn(async move |editor, cx| {
33 refresh_task.await;
34 editor
35 .read_with(cx, |editor, _| {
36 editor
37 .lsp_document_symbols
38 .get(&buffer_id)
39 .cloned()
40 .unwrap_or_default()
41 })
42 .ok()
43 .unwrap_or_default()
44 })
45 } else {
46 let buffer_snapshot = buffer.read(cx).snapshot();
47 let syntax = cx.theme().syntax().clone();
48 cx.background_executor()
49 .spawn(async move { buffer_snapshot.outline(Some(&syntax)).items })
50 }
51 }
52
53 /// Whether the buffer at `cursor` has LSP document symbols enabled.
54 pub(super) fn uses_lsp_document_symbols(
55 &self,
56 cursor: Anchor,
57 multi_buffer_snapshot: &MultiBufferSnapshot,
58 cx: &Context<Self>,
59 ) -> bool {
60 let Some(excerpt) = multi_buffer_snapshot.excerpt_containing(cursor..cursor) else {
61 return false;
62 };
63 let Some(buffer) = self.buffer.read(cx).buffer(excerpt.buffer_id()) else {
64 return false;
65 };
66 lsp_symbols_enabled(buffer.read(cx), cx)
67 }
68
69 /// Filters editor-local LSP document symbols to the ancestor chain
70 /// containing `cursor`. Never triggers an LSP request.
71 pub(super) fn lsp_symbols_at_cursor(
72 &self,
73 cursor: Anchor,
74 multi_buffer_snapshot: &MultiBufferSnapshot,
75 cx: &Context<Self>,
76 ) -> Option<(BufferId, Vec<OutlineItem<Anchor>>)> {
77 let excerpt = multi_buffer_snapshot.excerpt_containing(cursor..cursor)?;
78 let excerpt_id = excerpt.id();
79 let buffer_id = excerpt.buffer_id();
80 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 = buffer_snapshot.clip_offset(
296 selection_start_offset
297 .saturating_sub(name.len())
298 .max(range_start_offset),
299 Bias::Right,
300 );
301 let search_end = buffer_snapshot.clip_offset(
302 cmp::min(selection_start_offset + name.len() * 2, range_end_offset),
303 Bias::Left,
304 );
305
306 if search_start < search_end {
307 let buffer_text: String = buffer_snapshot
308 .text_for_range(search_start..search_end)
309 .collect();
310 if let Some(found_at) = buffer_text.find(name) {
311 let name_start_offset = search_start + found_at;
312 let name_end_offset = name_start_offset + name.len();
313 let result = highlights_for_buffer_range(
314 name_offset_in_text,
315 name_start_offset..name_end_offset,
316 buffer_id,
317 display_snapshot,
318 syntax_theme,
319 );
320 if result.is_some() {
321 return result;
322 }
323 }
324 }
325
326 // Fallback: match word-by-word. Split the name on whitespace and find
327 // each word sequentially in the buffer's symbol range.
328 let range_start_offset = buffer_snapshot.clip_offset(range_start_offset, Bias::Right);
329 let range_end_offset = buffer_snapshot.clip_offset(range_end_offset, Bias::Left);
330
331 let mut highlights = Vec::new();
332 let mut got_any = false;
333 let buffer_text: String = buffer_snapshot
334 .text_for_range(range_start_offset..range_end_offset)
335 .collect();
336 let mut buf_search_from = 0usize;
337 let mut name_search_from = 0usize;
338 for word in name.split_whitespace() {
339 let name_word_start = name[name_search_from..]
340 .find(word)
341 .map(|pos| name_search_from + pos)
342 .unwrap_or(name_search_from);
343 if let Some(found_in_buf) = buffer_text[buf_search_from..].find(word) {
344 let buf_word_start = range_start_offset + buf_search_from + found_in_buf;
345 let buf_word_end = buf_word_start + word.len();
346 let text_cursor = name_offset_in_text + name_word_start;
347 if let Some(mut word_highlights) = highlights_for_buffer_range(
348 text_cursor,
349 buf_word_start..buf_word_end,
350 buffer_id,
351 display_snapshot,
352 syntax_theme,
353 ) {
354 got_any = true;
355 highlights.append(&mut word_highlights);
356 }
357 buf_search_from = buf_search_from + found_in_buf + word.len();
358 }
359 name_search_from = name_word_start + word.len();
360 }
361
362 got_any.then_some(highlights)
363}
364
365/// Gets combined (tree-sitter + semantic token) highlights for a buffer byte
366/// range via the editor's display snapshot, then shifts the returned ranges
367/// so they start at `text_cursor_start` (the position in the outline item text).
368fn highlights_for_buffer_range(
369 text_cursor_start: usize,
370 buffer_range: Range<usize>,
371 buffer_id: BufferId,
372 display_snapshot: &DisplaySnapshot,
373 syntax_theme: &SyntaxTheme,
374) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
375 let raw = display_snapshot.combined_highlights(buffer_id, buffer_range, syntax_theme);
376 if raw.is_empty() {
377 return None;
378 }
379 Some(
380 raw.into_iter()
381 .map(|(range, style)| {
382 (
383 range.start + text_cursor_start..range.end + text_cursor_start,
384 style,
385 )
386 })
387 .collect(),
388 )
389}
390
391#[cfg(test)]
392mod tests {
393 use std::{
394 sync::{Arc, atomic},
395 time::Duration,
396 };
397
398 use futures::StreamExt as _;
399 use gpui::TestAppContext;
400 use settings::DocumentSymbols;
401 use util::path;
402 use zed_actions::editor::{MoveDown, MoveUp};
403
404 use crate::{
405 Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
406 editor_tests::{init_test, update_test_language_settings},
407 test::editor_lsp_test_context::EditorLspTestContext,
408 };
409
410 fn outline_symbol_names(editor: &Editor) -> Vec<&str> {
411 editor
412 .outline_symbols_at_cursor
413 .as_ref()
414 .expect("Should have outline symbols")
415 .1
416 .iter()
417 .map(|s| s.text.as_str())
418 .collect()
419 }
420
421 fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
422 lsp::Range {
423 start: lsp::Position::new(start_line, start_char),
424 end: lsp::Position::new(end_line, end_char),
425 }
426 }
427
428 fn nested_symbol(
429 name: &str,
430 kind: lsp::SymbolKind,
431 range: lsp::Range,
432 selection_range: lsp::Range,
433 children: Vec<lsp::DocumentSymbol>,
434 ) -> lsp::DocumentSymbol {
435 #[allow(deprecated)]
436 lsp::DocumentSymbol {
437 name: name.to_string(),
438 detail: None,
439 kind,
440 tags: None,
441 deprecated: None,
442 range,
443 selection_range,
444 children: if children.is_empty() {
445 None
446 } else {
447 Some(children)
448 },
449 }
450 }
451
452 #[gpui::test]
453 async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) {
454 init_test(cx, |_| {});
455
456 update_test_language_settings(cx, &|settings| {
457 settings.defaults.document_symbols = Some(DocumentSymbols::On);
458 });
459
460 let mut cx = EditorLspTestContext::new_rust(
461 lsp::ServerCapabilities {
462 document_symbol_provider: Some(lsp::OneOf::Left(true)),
463 ..lsp::ServerCapabilities::default()
464 },
465 cx,
466 )
467 .await;
468 let mut symbol_request = cx
469 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
470 move |_, _, _| async move {
471 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
472 nested_symbol(
473 "main",
474 lsp::SymbolKind::FUNCTION,
475 lsp_range(0, 0, 2, 1),
476 lsp_range(0, 3, 0, 7),
477 Vec::new(),
478 ),
479 ])))
480 },
481 );
482
483 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
484 assert!(symbol_request.next().await.is_some());
485 cx.run_until_parked();
486
487 cx.update_editor(|editor, _window, _cx| {
488 assert_eq!(outline_symbol_names(editor), vec!["fn main"]);
489 });
490 }
491
492 #[gpui::test]
493 async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) {
494 init_test(cx, |_| {});
495
496 update_test_language_settings(cx, &|settings| {
497 settings.defaults.document_symbols = Some(DocumentSymbols::On);
498 });
499
500 let mut cx = EditorLspTestContext::new_rust(
501 lsp::ServerCapabilities {
502 document_symbol_provider: Some(lsp::OneOf::Left(true)),
503 ..lsp::ServerCapabilities::default()
504 },
505 cx,
506 )
507 .await;
508 let mut symbol_request = cx
509 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
510 move |_, _, _| async move {
511 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
512 nested_symbol(
513 "Foo",
514 lsp::SymbolKind::STRUCT,
515 lsp_range(0, 0, 3, 1),
516 lsp_range(0, 7, 0, 10),
517 vec![
518 nested_symbol(
519 "bar",
520 lsp::SymbolKind::FIELD,
521 lsp_range(1, 4, 1, 13),
522 lsp_range(1, 4, 1, 7),
523 Vec::new(),
524 ),
525 nested_symbol(
526 "baz",
527 lsp::SymbolKind::FIELD,
528 lsp_range(2, 4, 2, 15),
529 lsp_range(2, 4, 2, 7),
530 Vec::new(),
531 ),
532 ],
533 ),
534 ])))
535 },
536 );
537
538 cx.set_state("struct Foo {\n baˇr: u32,\n baz: String,\n}\n");
539 assert!(symbol_request.next().await.is_some());
540 cx.run_until_parked();
541
542 cx.update_editor(|editor, _window, _cx| {
543 assert_eq!(
544 outline_symbol_names(editor),
545 vec!["struct Foo", "bar"],
546 "cursor is inside Foo > bar, so we expect the containing chain"
547 );
548 });
549 }
550
551 #[gpui::test]
552 async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) {
553 init_test(cx, |_| {});
554
555 // Start with tree-sitter (default)
556 let mut cx = EditorLspTestContext::new_rust(
557 lsp::ServerCapabilities {
558 document_symbol_provider: Some(lsp::OneOf::Left(true)),
559 ..lsp::ServerCapabilities::default()
560 },
561 cx,
562 )
563 .await;
564 let mut symbol_request = cx
565 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
566 move |_, _, _| async move {
567 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
568 nested_symbol(
569 "lsp_main_symbol",
570 lsp::SymbolKind::FUNCTION,
571 lsp_range(0, 0, 2, 1),
572 lsp_range(0, 3, 0, 7),
573 Vec::new(),
574 ),
575 ])))
576 },
577 );
578
579 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
580 cx.run_until_parked();
581
582 // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline
583 cx.update_editor(|editor, _window, _cx| {
584 assert_eq!(
585 outline_symbol_names(editor),
586 vec!["fn main"],
587 "Tree-sitter should produce 'fn main'"
588 );
589 });
590
591 // Step 2: Switch to LSP
592 update_test_language_settings(&mut cx.cx.cx, &|settings| {
593 settings.defaults.document_symbols = Some(DocumentSymbols::On);
594 });
595 assert!(symbol_request.next().await.is_some());
596 cx.run_until_parked();
597
598 cx.update_editor(|editor, _window, _cx| {
599 assert_eq!(
600 outline_symbol_names(editor),
601 vec!["lsp_main_symbol"],
602 "After switching to LSP, should see LSP symbols"
603 );
604 });
605
606 // Step 3: Switch back to tree-sitter
607 update_test_language_settings(&mut cx.cx.cx, &|settings| {
608 settings.defaults.document_symbols = Some(DocumentSymbols::Off);
609 });
610 cx.run_until_parked();
611
612 // Force another selection change
613 cx.update_editor(|editor, window, cx| {
614 editor.move_up(&MoveUp, window, cx);
615 });
616 cx.run_until_parked();
617
618 cx.update_editor(|editor, _window, _cx| {
619 assert_eq!(
620 outline_symbol_names(editor),
621 vec!["fn main"],
622 "After switching back to tree-sitter, should see tree-sitter symbols again"
623 );
624 });
625 }
626
627 #[gpui::test]
628 async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) {
629 init_test(cx, |_| {});
630
631 update_test_language_settings(cx, &|settings| {
632 settings.defaults.document_symbols = Some(DocumentSymbols::On);
633 });
634
635 let request_count = Arc::new(atomic::AtomicUsize::new(0));
636 let request_count_clone = request_count.clone();
637
638 let mut cx = EditorLspTestContext::new_rust(
639 lsp::ServerCapabilities {
640 document_symbol_provider: Some(lsp::OneOf::Left(true)),
641 ..lsp::ServerCapabilities::default()
642 },
643 cx,
644 )
645 .await;
646
647 let mut symbol_request = cx
648 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
649 request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
650 async move {
651 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
652 nested_symbol(
653 "main",
654 lsp::SymbolKind::FUNCTION,
655 lsp_range(0, 0, 2, 1),
656 lsp_range(0, 3, 0, 7),
657 Vec::new(),
658 ),
659 ])))
660 }
661 });
662
663 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
664 assert!(symbol_request.next().await.is_some());
665 cx.run_until_parked();
666
667 let first_count = request_count.load(atomic::Ordering::Acquire);
668 assert_eq!(first_count, 1, "Should have made exactly one request");
669
670 // Move cursor within the same buffer version — should use cache
671 cx.update_editor(|editor, window, cx| {
672 editor.move_down(&MoveDown, window, cx);
673 });
674 cx.background_executor
675 .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
676 cx.run_until_parked();
677
678 assert_eq!(
679 first_count,
680 request_count.load(atomic::Ordering::Acquire),
681 "Moving cursor without editing should use cached symbols"
682 );
683 }
684
685 #[gpui::test]
686 async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) {
687 init_test(cx, |_| {});
688
689 update_test_language_settings(cx, &|settings| {
690 settings.defaults.document_symbols = Some(DocumentSymbols::On);
691 });
692
693 let mut cx = EditorLspTestContext::new_rust(
694 lsp::ServerCapabilities {
695 document_symbol_provider: Some(lsp::OneOf::Left(true)),
696 ..lsp::ServerCapabilities::default()
697 },
698 cx,
699 )
700 .await;
701 let mut symbol_request = cx
702 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
703 move |_, _, _| async move {
704 #[allow(deprecated)]
705 Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![
706 lsp::SymbolInformation {
707 name: "main".to_string(),
708 kind: lsp::SymbolKind::FUNCTION,
709 tags: None,
710 deprecated: None,
711 location: lsp::Location {
712 uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
713 range: lsp_range(0, 0, 2, 1),
714 },
715 container_name: None,
716 },
717 ])))
718 },
719 );
720
721 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
722 assert!(symbol_request.next().await.is_some());
723 cx.run_until_parked();
724
725 cx.update_editor(|editor, _window, _cx| {
726 assert_eq!(outline_symbol_names(editor), vec!["main"]);
727 });
728 }
729
730 #[gpui::test]
731 async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) {
732 init_test(cx, |_| {});
733
734 update_test_language_settings(cx, &|settings| {
735 settings.defaults.document_symbols = Some(DocumentSymbols::On);
736 });
737
738 let mut cx = EditorLspTestContext::new_rust(
739 lsp::ServerCapabilities {
740 document_symbol_provider: Some(lsp::OneOf::Left(true)),
741 ..lsp::ServerCapabilities::default()
742 },
743 cx,
744 )
745 .await;
746 let mut symbol_request = cx
747 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
748 move |_, _, _| async move {
749 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
750 nested_symbol(
751 "MyModule",
752 lsp::SymbolKind::MODULE,
753 lsp_range(0, 0, 4, 1),
754 lsp_range(0, 4, 0, 12),
755 vec![nested_symbol(
756 "my_function",
757 lsp::SymbolKind::FUNCTION,
758 lsp_range(1, 4, 3, 5),
759 lsp_range(1, 7, 1, 18),
760 Vec::new(),
761 )],
762 ),
763 ])))
764 },
765 );
766
767 cx.set_state("mod MyModule {\n fn my_fuˇnction() {\n let x = 1;\n }\n}\n");
768 assert!(symbol_request.next().await.is_some());
769 cx.run_until_parked();
770
771 cx.update_editor(|editor, _window, _cx| {
772 assert_eq!(
773 outline_symbol_names(editor),
774 vec!["mod MyModule", "fn my_function"]
775 );
776 });
777 }
778
779 #[gpui::test]
780 async fn test_lsp_document_symbols_multibyte_highlights(cx: &mut TestAppContext) {
781 init_test(cx, |_| {});
782
783 update_test_language_settings(cx, &|settings| {
784 settings.defaults.document_symbols = Some(DocumentSymbols::On);
785 });
786
787 let mut cx = EditorLspTestContext::new_rust(
788 lsp::ServerCapabilities {
789 document_symbol_provider: Some(lsp::OneOf::Left(true)),
790 ..lsp::ServerCapabilities::default()
791 },
792 cx,
793 )
794 .await;
795 let mut symbol_request = cx
796 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
797 move |_, _, _| async move {
798 // Buffer: "/// αyzabc\nfn test() {}\n"
799 // Bytes 0-3: "/// ", bytes 4-5: α (2-byte UTF-8), bytes 6-11: "yzabc\n"
800 // Line 1 starts at byte 12: "fn test() {}"
801 //
802 // Symbol range includes doc comment (line 0-1).
803 // Selection points to "test" on line 1.
804 // enriched_symbol_text extracts "fn test" with source_range_for_text.start at byte 12.
805 // search_start = max(12 - 7, 0) = 5, which is INSIDE the 2-byte 'α' char.
806 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
807 nested_symbol(
808 "test",
809 lsp::SymbolKind::FUNCTION,
810 lsp_range(0, 0, 1, 13), // includes doc comment
811 lsp_range(1, 3, 1, 7), // "test"
812 Vec::new(),
813 ),
814 ])))
815 },
816 );
817
818 // "/// αyzabc\n" = 12 bytes, then "fn test() {}\n"
819 // search_start = 12 - 7 = 5, which is byte 5 = second byte of 'α' (not a char boundary)
820 cx.set_state("/// αyzabc\nfn teˇst() {}\n");
821 assert!(symbol_request.next().await.is_some());
822 cx.run_until_parked();
823
824 cx.update_editor(|editor, _window, _cx| {
825 let (_, symbols) = editor
826 .outline_symbols_at_cursor
827 .as_ref()
828 .expect("Should have outline symbols");
829 assert_eq!(symbols.len(), 1);
830
831 let symbol = &symbols[0];
832 assert_eq!(symbol.text, "fn test");
833
834 // Verify all highlight ranges are valid byte boundaries in the text
835 for (range, _style) in &symbol.highlight_ranges {
836 assert!(
837 symbol.text.is_char_boundary(range.start),
838 "highlight range start {} is not a char boundary in {:?}",
839 range.start,
840 symbol.text
841 );
842 assert!(
843 symbol.text.is_char_boundary(range.end),
844 "highlight range end {} is not a char boundary in {:?}",
845 range.end,
846 symbol.text
847 );
848 assert!(
849 range.end <= symbol.text.len(),
850 "highlight range end {} exceeds text length {} for {:?}",
851 range.end,
852 symbol.text.len(),
853 symbol.text
854 );
855 }
856 });
857 }
858
859 #[gpui::test]
860 async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) {
861 init_test(cx, |_| {});
862
863 update_test_language_settings(cx, &|settings| {
864 settings.defaults.document_symbols = Some(DocumentSymbols::On);
865 });
866
867 let mut cx = EditorLspTestContext::new_rust(
868 lsp::ServerCapabilities {
869 document_symbol_provider: Some(lsp::OneOf::Left(true)),
870 ..lsp::ServerCapabilities::default()
871 },
872 cx,
873 )
874 .await;
875 let mut symbol_request = cx
876 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
877 move |_, _, _| async move {
878 Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
879 },
880 );
881
882 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
883 assert!(symbol_request.next().await.is_some());
884 cx.run_until_parked();
885 cx.update_editor(|editor, _window, _cx| {
886 // With LSP enabled but empty response, outline_symbols_at_cursor should be None
887 // (no symbols to show in breadcrumbs)
888 assert!(
889 editor.outline_symbols_at_cursor.is_none(),
890 "Empty LSP response should result in no outline symbols"
891 );
892 });
893 }
894
895 #[gpui::test]
896 async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) {
897 init_test(cx, |_| {});
898
899 let request_count = Arc::new(atomic::AtomicUsize::new(0));
900 // Do NOT enable document_symbols — defaults to Off
901 let mut cx = EditorLspTestContext::new_rust(
902 lsp::ServerCapabilities {
903 document_symbol_provider: Some(lsp::OneOf::Left(true)),
904 ..lsp::ServerCapabilities::default()
905 },
906 cx,
907 )
908 .await;
909 let request_count_clone = request_count.clone();
910 let _symbol_request =
911 cx.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
912 request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
913 async move {
914 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
915 nested_symbol(
916 "should_not_appear",
917 lsp::SymbolKind::FUNCTION,
918 lsp_range(0, 0, 2, 1),
919 lsp_range(0, 3, 0, 7),
920 Vec::new(),
921 ),
922 ])))
923 }
924 });
925
926 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
927 cx.run_until_parked();
928
929 // Tree-sitter should be used instead
930 cx.update_editor(|editor, _window, _cx| {
931 assert_eq!(
932 outline_symbol_names(editor),
933 vec!["fn main"],
934 "With document_symbols off, should use tree-sitter"
935 );
936 });
937
938 assert_eq!(
939 request_count.load(atomic::Ordering::Acquire),
940 0,
941 "Should not have made any LSP document symbol requests when setting is off"
942 );
943 }
944}