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