Skip to repository content1157 lines · 44.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:38:34.151Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
document_symbols.rs
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 selection_range: multi_buffer_snapshot
107 .anchor_in_buffer(item.selection_range.start)?
108 ..multi_buffer_snapshot.anchor_in_buffer(item.selection_range.end)?,
109 source_range_for_text: source_range_for_text_start..source_range_for_text_end,
110 text: item.text.clone(),
111 highlight_ranges: item.highlight_ranges.clone(),
112 name_ranges: item.name_ranges.clone(),
113 body_range: item.body_range.as_ref().and_then(|r| {
114 Some(
115 multi_buffer_snapshot.anchor_in_buffer(r.start)?
116 ..multi_buffer_snapshot.anchor_in_buffer(r.end)?,
117 )
118 }),
119 annotation_range: item.annotation_range.as_ref().and_then(|r| {
120 Some(
121 multi_buffer_snapshot.anchor_in_buffer(r.start)?
122 ..multi_buffer_snapshot.anchor_in_buffer(r.end)?,
123 )
124 }),
125 })
126 })
127 .collect::<Vec<_>>();
128
129 let mut prev_depth = None;
130 symbols.retain(|item| {
131 let retain = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth);
132 prev_depth = Some(item.depth);
133 retain
134 });
135
136 Some((buffer.remote_id(), symbols))
137 }
138
139 /// Fetches document symbols from the LSP for buffers that have the setting
140 /// enabled. Called from `update_lsp_data` on edits, server events, etc.
141 /// When the fetch completes, stores results in `self.lsp_document_symbols`
142 /// and triggers `refresh_outline_symbols_at_cursor` so breadcrumbs pick up the new data.
143 pub(super) fn refresh_document_symbols(
144 &mut self,
145 for_buffer: Option<BufferId>,
146 cx: &mut Context<Self>,
147 ) {
148 if !self.lsp_data_enabled() {
149 return;
150 }
151 let Some(project) = self.project.as_ref().map(|p| p.downgrade()) else {
152 return;
153 };
154
155 let buffers_to_query = self
156 .visible_buffers(cx)
157 .into_iter()
158 .filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx))
159 .filter_map(|buffer| {
160 let id = buffer.read(cx).remote_id();
161 if for_buffer.is_none_or(|target| target == id)
162 && lsp_symbols_enabled(buffer.read(cx), cx)
163 {
164 Some(buffer)
165 } else {
166 None
167 }
168 })
169 .unique_by(|buffer| buffer.read(cx).remote_id())
170 .collect::<Vec<_>>();
171
172 let mut symbols_altered = false;
173 let multi_buffer = self.buffer().clone();
174 self.lsp_document_symbols.retain(|buffer_id, _| {
175 let Some(buffer) = multi_buffer.read(cx).buffer(*buffer_id) else {
176 symbols_altered = true;
177 return false;
178 };
179 let retain = lsp_symbols_enabled(buffer.read(cx), cx);
180 symbols_altered |= !retain;
181 retain
182 });
183 if symbols_altered {
184 self.refresh_outline_symbols_at_cursor(cx);
185 }
186
187 if buffers_to_query.is_empty() {
188 return;
189 }
190
191 self.refresh_document_symbols_task = cx
192 .spawn(async move |editor, cx| {
193 cx.background_executor()
194 .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
195 .await;
196
197 let Some(tasks) = project
198 .update(cx, |project, cx| {
199 project.lsp_store().update(cx, |lsp_store, cx| {
200 buffers_to_query
201 .into_iter()
202 .map(|buffer| {
203 let buffer_id = buffer.read(cx).remote_id();
204 let task = lsp_store.fetch_document_symbols(&buffer, cx);
205 async move { (buffer_id, task.await) }
206 })
207 .collect::<Vec<_>>()
208 })
209 })
210 .ok()
211 else {
212 return;
213 };
214
215 let results = join_all(tasks).await.into_iter().collect::<HashMap<_, _>>();
216 editor
217 .update(cx, |editor, cx| {
218 let syntax = cx.theme().syntax().clone();
219 let display_snapshot =
220 editor.display_map.update(cx, |map, cx| map.snapshot(cx));
221 let mut highlighted_results = results;
222 for items in highlighted_results.values_mut() {
223 for item in items {
224 if let Some(highlights) =
225 highlights_from_buffer(&display_snapshot, &item, &syntax)
226 {
227 item.highlight_ranges = highlights;
228 }
229 }
230 }
231 editor.lsp_document_symbols.extend(highlighted_results);
232 editor.refresh_outline_symbols_at_cursor(cx);
233 })
234 .ok();
235 })
236 .shared();
237 }
238}
239
240fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool {
241 LanguageSettings::for_buffer(buffer, cx)
242 .document_symbols
243 .lsp_enabled()
244}
245
246/// Finds where the symbol name appears in the buffer and returns combined
247/// (tree-sitter + semantic token) highlights for those positions.
248///
249/// First tries to find the name verbatim near the selection range so that
250/// complex names (`impl Trait for Type`) get full highlighting. Falls back
251/// to word-by-word matching for cases like `impl<T> Trait<T> for Type`
252/// where the LSP name doesn't appear verbatim in the buffer.
253fn highlights_from_buffer(
254 display_snapshot: &DisplaySnapshot,
255 item: &OutlineItem<text::Anchor>,
256 syntax_theme: &SyntaxTheme,
257) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
258 let outline_text = &item.text;
259 if outline_text.is_empty() {
260 return None;
261 }
262
263 let multi_buffer_snapshot = display_snapshot.buffer();
264 let multi_buffer_source_range_anchors =
265 multi_buffer_snapshot.text_anchors_to_visible_anchors([
266 item.source_range_for_text.start,
267 item.source_range_for_text.end,
268 ]);
269 let Some(anchor_range) = maybe!({
270 Some(
271 (*multi_buffer_source_range_anchors.get(0)?)?
272 ..(*multi_buffer_source_range_anchors.get(1)?)?,
273 )
274 }) else {
275 return None;
276 };
277
278 let selection_point_range = anchor_range.to_point(multi_buffer_snapshot);
279 let mut search_start = selection_point_range.start;
280 search_start.column = 0;
281 let search_start_offset = search_start.to_offset(&multi_buffer_snapshot);
282 let mut search_end = selection_point_range.end;
283 search_end.column = multi_buffer_snapshot.line_len(MultiBufferRow(search_end.row));
284
285 let search_text = multi_buffer_snapshot
286 .text_for_range(search_start..search_end)
287 .collect::<String>();
288
289 let mut outline_text_highlights = Vec::new();
290 match search_text.find(outline_text.as_str()) {
291 Some(start_index) => {
292 let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
293 let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len());
294 outline_text_highlights.extend(
295 display_snapshot
296 .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme),
297 );
298 }
299 None => {
300 for (outline_text_word_start, outline_word) in outline_text.split_word_bound_indices() {
301 if let Some(start_index) = search_text.find(outline_word) {
302 let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
303 let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_word.len());
304 outline_text_highlights.extend(
305 display_snapshot
306 .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme)
307 .into_iter()
308 .map(|(range_in_word, style)| {
309 (
310 outline_text_word_start + range_in_word.start
311 ..outline_text_word_start + range_in_word.end,
312 style,
313 )
314 }),
315 );
316 }
317 }
318 }
319 }
320
321 if outline_text_highlights.is_empty() {
322 None
323 } else {
324 Some(outline_text_highlights)
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use std::{
331 sync::{Arc, atomic},
332 time::Duration,
333 };
334
335 use futures::StreamExt as _;
336 use gpui::{App, TestAppContext};
337 use multi_buffer::ToPoint;
338 use settings::{DocumentSymbols, SettingsStore};
339 use text::Point;
340 use util::path;
341 use workspace::item::{Item, ItemEvent};
342 use zed_actions::editor::MoveDown;
343
344 use crate::{
345 Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
346 editor_tests::{init_test, update_test_language_settings},
347 test::editor_lsp_test_context::EditorLspTestContext,
348 };
349
350 fn outline_symbol_names(editor: &Editor) -> Vec<&str> {
351 editor
352 .outline_symbols_at_cursor
353 .as_ref()
354 .expect("Should have outline symbols")
355 .1
356 .iter()
357 .map(|s| s.text.as_str())
358 .collect()
359 }
360
361 fn breadcrumb_texts(editor: &Editor, cx: &App) -> Vec<String> {
362 editor
363 .breadcrumbs(cx)
364 .expect("Should have breadcrumbs")
365 .0
366 .into_iter()
367 .map(|segment| segment.text.to_string())
368 .collect()
369 }
370
371 fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
372 lsp::Range {
373 start: lsp::Position::new(start_line, start_char),
374 end: lsp::Position::new(end_line, end_char),
375 }
376 }
377
378 fn nested_symbol(
379 name: &str,
380 kind: lsp::SymbolKind,
381 range: lsp::Range,
382 selection_range: lsp::Range,
383 children: Vec<lsp::DocumentSymbol>,
384 ) -> lsp::DocumentSymbol {
385 #[allow(deprecated)]
386 lsp::DocumentSymbol {
387 name: name.to_string(),
388 detail: None,
389 kind,
390 tags: None,
391 deprecated: None,
392 range,
393 selection_range,
394 children: if children.is_empty() {
395 None
396 } else {
397 Some(children)
398 },
399 }
400 }
401
402 #[gpui::test]
403 async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) {
404 init_test(cx, |_| {});
405
406 update_test_language_settings(cx, &|settings| {
407 settings.defaults.document_symbols = Some(DocumentSymbols::On);
408 });
409
410 let mut cx = EditorLspTestContext::new_rust(
411 lsp::ServerCapabilities {
412 document_symbol_provider: Some(lsp::OneOf::Left(true)),
413 ..lsp::ServerCapabilities::default()
414 },
415 cx,
416 )
417 .await;
418 let mut symbol_request = cx
419 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
420 move |_, _, _| async move {
421 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
422 nested_symbol(
423 "main",
424 lsp::SymbolKind::FUNCTION,
425 lsp_range(0, 0, 2, 1),
426 lsp_range(0, 3, 0, 7),
427 Vec::new(),
428 ),
429 ])))
430 },
431 );
432
433 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
434 assert!(symbol_request.next().await.is_some());
435 cx.run_until_parked();
436
437 cx.update_editor(|editor, _window, _cx| {
438 assert_eq!(outline_symbol_names(editor), vec!["fn main"]);
439 });
440 }
441
442 #[gpui::test]
443 async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) {
444 init_test(cx, |_| {});
445
446 update_test_language_settings(cx, &|settings| {
447 settings.defaults.document_symbols = Some(DocumentSymbols::On);
448 });
449
450 let mut cx = EditorLspTestContext::new_rust(
451 lsp::ServerCapabilities {
452 document_symbol_provider: Some(lsp::OneOf::Left(true)),
453 ..lsp::ServerCapabilities::default()
454 },
455 cx,
456 )
457 .await;
458 let mut symbol_request = cx
459 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
460 move |_, _, _| async move {
461 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
462 nested_symbol(
463 "Foo",
464 lsp::SymbolKind::STRUCT,
465 lsp_range(0, 0, 3, 1),
466 lsp_range(0, 7, 0, 10),
467 vec![
468 nested_symbol(
469 "bar",
470 lsp::SymbolKind::FIELD,
471 lsp_range(1, 4, 1, 13),
472 lsp_range(1, 4, 1, 7),
473 Vec::new(),
474 ),
475 nested_symbol(
476 "baz",
477 lsp::SymbolKind::FIELD,
478 lsp_range(2, 4, 2, 15),
479 lsp_range(2, 4, 2, 7),
480 Vec::new(),
481 ),
482 ],
483 ),
484 ])))
485 },
486 );
487
488 cx.set_state("struct Foo {\n baˇr: u32,\n baz: String,\n}\n");
489 assert!(symbol_request.next().await.is_some());
490 cx.run_until_parked();
491
492 cx.update_editor(|editor, _window, _cx| {
493 assert_eq!(
494 outline_symbol_names(editor),
495 vec!["struct Foo", "bar"],
496 "cursor is inside Foo > bar, so we expect the containing chain"
497 );
498 });
499 }
500
501 #[gpui::test]
502 async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) {
503 init_test(cx, |_| {});
504
505 // Start with tree-sitter (default)
506 let mut cx = EditorLspTestContext::new_rust(
507 lsp::ServerCapabilities {
508 document_symbol_provider: Some(lsp::OneOf::Left(true)),
509 ..lsp::ServerCapabilities::default()
510 },
511 cx,
512 )
513 .await;
514 let mut symbol_request = cx
515 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
516 move |_, _, _| async move {
517 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
518 nested_symbol(
519 "lsp_main_symbol",
520 lsp::SymbolKind::FUNCTION,
521 lsp_range(0, 0, 2, 1),
522 lsp_range(0, 3, 0, 7),
523 Vec::new(),
524 ),
525 ])))
526 },
527 );
528
529 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
530 cx.run_until_parked();
531
532 // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline
533 cx.update_editor(|editor, _window, _cx| {
534 assert_eq!(
535 outline_symbol_names(editor),
536 vec!["fn main"],
537 "Tree-sitter should produce 'fn main'"
538 );
539 });
540
541 // Step 2: Switch to LSP
542 update_test_language_settings(&mut cx.cx.cx, &|settings| {
543 settings.defaults.document_symbols = Some(DocumentSymbols::On);
544 });
545 assert!(symbol_request.next().await.is_some());
546 cx.run_until_parked();
547
548 cx.update_editor(|editor, _window, _cx| {
549 assert_eq!(
550 outline_symbol_names(editor),
551 vec!["lsp_main_symbol"],
552 "After switching to LSP, should see LSP symbols"
553 );
554 });
555
556 // Step 3: Switch back to tree-sitter, the symbols should refresh
557 // without any extra selection changes
558 update_test_language_settings(&mut cx.cx.cx, &|settings| {
559 settings.defaults.document_symbols = Some(DocumentSymbols::Off);
560 });
561 cx.run_until_parked();
562
563 cx.update_editor(|editor, _window, _cx| {
564 assert_eq!(
565 outline_symbol_names(editor),
566 vec!["fn main"],
567 "After switching back to tree-sitter, should see tree-sitter symbols again"
568 );
569 });
570 }
571
572 #[gpui::test]
573 async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) {
574 init_test(cx, |_| {});
575
576 update_test_language_settings(cx, &|settings| {
577 settings.defaults.document_symbols = Some(DocumentSymbols::On);
578 });
579
580 let request_count = Arc::new(atomic::AtomicUsize::new(0));
581 let request_count_clone = request_count.clone();
582
583 let mut cx = EditorLspTestContext::new_rust(
584 lsp::ServerCapabilities {
585 document_symbol_provider: Some(lsp::OneOf::Left(true)),
586 ..lsp::ServerCapabilities::default()
587 },
588 cx,
589 )
590 .await;
591
592 let mut symbol_request = cx
593 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
594 request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
595 async move {
596 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
597 nested_symbol(
598 "main",
599 lsp::SymbolKind::FUNCTION,
600 lsp_range(0, 0, 2, 1),
601 lsp_range(0, 3, 0, 7),
602 Vec::new(),
603 ),
604 ])))
605 }
606 });
607
608 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
609 assert!(symbol_request.next().await.is_some());
610 cx.run_until_parked();
611
612 let first_count = request_count.load(atomic::Ordering::Acquire);
613 assert_eq!(first_count, 1, "Should have made exactly one request");
614
615 // Move cursor within the same buffer version — should use cache
616 cx.update_editor(|editor, window, cx| {
617 editor.move_down(&MoveDown, window, cx);
618 });
619 cx.background_executor
620 .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
621 cx.run_until_parked();
622
623 assert_eq!(
624 first_count,
625 request_count.load(atomic::Ordering::Acquire),
626 "Moving cursor without editing should use cached symbols"
627 );
628 }
629
630 #[gpui::test]
631 async fn test_lsp_document_symbols_flat_response(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 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 let mut symbol_request = cx
647 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
648 move |_, _, _| async move {
649 #[allow(deprecated)]
650 Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![
651 lsp::SymbolInformation {
652 name: "main".to_string(),
653 kind: lsp::SymbolKind::FUNCTION,
654 tags: None,
655 deprecated: None,
656 location: lsp::Location {
657 uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
658 range: lsp_range(0, 0, 2, 1),
659 },
660 container_name: None,
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 cx.update_editor(|editor, _window, _cx| {
671 assert_eq!(outline_symbol_names(editor), vec!["main"]);
672 });
673 }
674
675 #[gpui::test]
676 async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) {
677 init_test(cx, |_| {});
678
679 update_test_language_settings(cx, &|settings| {
680 settings.defaults.document_symbols = Some(DocumentSymbols::On);
681 });
682
683 let mut cx = EditorLspTestContext::new_rust(
684 lsp::ServerCapabilities {
685 document_symbol_provider: Some(lsp::OneOf::Left(true)),
686 ..lsp::ServerCapabilities::default()
687 },
688 cx,
689 )
690 .await;
691 let mut symbol_request = cx
692 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
693 move |_, _, _| async move {
694 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
695 nested_symbol(
696 "MyModule",
697 lsp::SymbolKind::MODULE,
698 lsp_range(0, 0, 4, 1),
699 lsp_range(0, 4, 0, 12),
700 vec![nested_symbol(
701 "my_function",
702 lsp::SymbolKind::FUNCTION,
703 lsp_range(1, 4, 3, 5),
704 lsp_range(1, 7, 1, 18),
705 Vec::new(),
706 )],
707 ),
708 ])))
709 },
710 );
711
712 cx.set_state("mod MyModule {\n fn my_fuˇnction() {\n let x = 1;\n }\n}\n");
713 assert!(symbol_request.next().await.is_some());
714 cx.run_until_parked();
715
716 cx.update_editor(|editor, _window, _cx| {
717 assert_eq!(
718 outline_symbol_names(editor),
719 vec!["mod MyModule", "fn my_function"]
720 );
721 });
722 }
723
724 #[gpui::test]
725 async fn test_lsp_document_symbols_multibyte_highlights(cx: &mut TestAppContext) {
726 init_test(cx, |_| {});
727
728 update_test_language_settings(cx, &|settings| {
729 settings.defaults.document_symbols = Some(DocumentSymbols::On);
730 });
731
732 let mut cx = EditorLspTestContext::new_rust(
733 lsp::ServerCapabilities {
734 document_symbol_provider: Some(lsp::OneOf::Left(true)),
735 ..lsp::ServerCapabilities::default()
736 },
737 cx,
738 )
739 .await;
740 let mut symbol_request = cx
741 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
742 move |_, _, _| async move {
743 // Buffer: "/// αyzabc\nfn test() {}\n"
744 // Bytes 0-3: "/// ", bytes 4-5: α (2-byte UTF-8), bytes 6-11: "yzabc\n"
745 // Line 1 starts at byte 12: "fn test() {}"
746 //
747 // Symbol range includes doc comment (line 0-1).
748 // Selection points to "test" on line 1.
749 // enriched_symbol_text extracts "fn test" with source_range_for_text.start at byte 12.
750 // search_start = max(12 - 7, 0) = 5, which is INSIDE the 2-byte 'α' char.
751 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
752 nested_symbol(
753 "test",
754 lsp::SymbolKind::FUNCTION,
755 lsp_range(0, 0, 1, 13), // includes doc comment
756 lsp_range(1, 3, 1, 7), // "test"
757 Vec::new(),
758 ),
759 ])))
760 },
761 );
762
763 // "/// αyzabc\n" = 12 bytes, then "fn test() {}\n"
764 // search_start = 12 - 7 = 5, which is byte 5 = second byte of 'α' (not a char boundary)
765 cx.set_state("/// αyzabc\nfn teˇst() {}\n");
766 assert!(symbol_request.next().await.is_some());
767 cx.run_until_parked();
768
769 cx.update_editor(|editor, _window, cx| {
770 let (_, symbols) = editor
771 .outline_symbols_at_cursor
772 .as_ref()
773 .expect("Should have outline symbols");
774 assert_eq!(symbols.len(), 1);
775
776 let symbol = &symbols[0];
777 let multi_buffer_snapshot = editor.buffer.read(cx).snapshot(cx);
778 assert_eq!(symbol.text, "fn test");
779 assert_eq!(
780 symbol
781 .selection_range
782 .start
783 .to_point(&multi_buffer_snapshot),
784 Point::new(1, 3)
785 );
786 assert_eq!(
787 symbol.selection_range.end.to_point(&multi_buffer_snapshot),
788 Point::new(1, 7)
789 );
790
791 // Verify all highlight ranges are valid byte boundaries in the text
792 for (range, _style) in &symbol.highlight_ranges {
793 assert!(
794 symbol.text.is_char_boundary(range.start),
795 "highlight range start {} is not a char boundary in {:?}",
796 range.start,
797 symbol.text
798 );
799 assert!(
800 symbol.text.is_char_boundary(range.end),
801 "highlight range end {} is not a char boundary in {:?}",
802 range.end,
803 symbol.text
804 );
805 assert!(
806 range.end <= symbol.text.len(),
807 "highlight range end {} exceeds text length {} for {:?}",
808 range.end,
809 symbol.text.len(),
810 symbol.text
811 );
812 }
813 });
814 }
815
816 #[gpui::test]
817 async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) {
818 init_test(cx, |_| {});
819
820 update_test_language_settings(cx, &|settings| {
821 settings.defaults.document_symbols = Some(DocumentSymbols::On);
822 });
823
824 let mut cx = EditorLspTestContext::new_rust(
825 lsp::ServerCapabilities {
826 document_symbol_provider: Some(lsp::OneOf::Left(true)),
827 ..lsp::ServerCapabilities::default()
828 },
829 cx,
830 )
831 .await;
832 let mut symbol_request = cx
833 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
834 move |_, _, _| async move {
835 Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
836 },
837 );
838
839 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
840 assert!(symbol_request.next().await.is_some());
841 cx.run_until_parked();
842 cx.update_editor(|editor, _window, _cx| {
843 // With LSP enabled but empty response, outline_symbols_at_cursor should be None
844 // (no symbols to show in breadcrumbs)
845 assert!(
846 editor.outline_symbols_at_cursor.is_none(),
847 "Empty LSP response should result in no outline symbols"
848 );
849 });
850 }
851
852 #[gpui::test]
853 async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) {
854 init_test(cx, |_| {});
855
856 let request_count = Arc::new(atomic::AtomicUsize::new(0));
857 // Do NOT enable document_symbols — defaults to Off
858 let mut cx = EditorLspTestContext::new_rust(
859 lsp::ServerCapabilities {
860 document_symbol_provider: Some(lsp::OneOf::Left(true)),
861 ..lsp::ServerCapabilities::default()
862 },
863 cx,
864 )
865 .await;
866 let request_count_clone = request_count.clone();
867 let _symbol_request =
868 cx.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
869 request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
870 async move {
871 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
872 nested_symbol(
873 "should_not_appear",
874 lsp::SymbolKind::FUNCTION,
875 lsp_range(0, 0, 2, 1),
876 lsp_range(0, 3, 0, 7),
877 Vec::new(),
878 ),
879 ])))
880 }
881 });
882
883 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
884 cx.run_until_parked();
885
886 // Tree-sitter should be used instead
887 cx.update_editor(|editor, _window, _cx| {
888 assert_eq!(
889 outline_symbol_names(editor),
890 vec!["fn main"],
891 "With document_symbols off, should use tree-sitter"
892 );
893 });
894
895 assert_eq!(
896 request_count.load(atomic::Ordering::Acquire),
897 0,
898 "Should not have made any LSP document symbol requests when setting is off"
899 );
900 }
901
902 #[gpui::test]
903 async fn test_breadcrumb_highlights_update_on_theme_change(cx: &mut TestAppContext) {
904 use collections::IndexMap;
905 use gpui::{Hsla, Rgba, UpdateGlobal as _};
906 use theme_settings::{HighlightStyleContent, ThemeStyleContent};
907 use ui::ActiveTheme as _;
908
909 init_test(cx, |_| {});
910
911 let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;
912
913 // Set the initial theme with a red keyword color and sync it to the
914 // language registry so tree-sitter highlight maps are up to date.
915 let red_color: Hsla = Rgba {
916 r: 1.0,
917 g: 0.0,
918 b: 0.0,
919 a: 1.0,
920 }
921 .into();
922 cx.update(|_, cx| {
923 SettingsStore::update_global(cx, |store, cx| {
924 store.update_user_settings(cx, |settings| {
925 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
926 syntax: IndexMap::from_iter([(
927 "keyword".to_string(),
928 HighlightStyleContent {
929 color: Some("#ff0000".to_string()),
930 background_color: None,
931 font_style: None,
932 font_weight: None,
933 },
934 )]),
935 ..ThemeStyleContent::default()
936 });
937 });
938 });
939 });
940 cx.update_editor(|editor, _window, cx| {
941 editor
942 .project
943 .as_ref()
944 .expect("editor should have a project")
945 .read(cx)
946 .languages()
947 .set_theme(cx.theme().clone());
948 });
949 cx.set_state("fn maˇin() {}");
950 cx.run_until_parked();
951
952 cx.update_editor(|editor, _window, cx| {
953 let breadcrumbs = editor
954 .breadcrumbs_inner(cx)
955 .expect("Should have breadcrumbs");
956 let symbol_segment = breadcrumbs
957 .iter()
958 .find(|b| b.text.as_ref() == "fn main")
959 .expect("Should have 'fn main' breadcrumb");
960 let keyword_highlight = symbol_segment
961 .highlights
962 .iter()
963 .find(|(range, _)| &symbol_segment.text[range.clone()] == "fn")
964 .expect("Should have a highlight for the 'fn' keyword");
965 assert_eq!(
966 keyword_highlight.1.color,
967 Some(red_color),
968 "The 'fn' keyword should have red color"
969 );
970 });
971
972 // Change the theme to use a blue keyword color. This simulates a user
973 // switching themes. The language registry set_theme call mirrors what
974 // the application does in main.rs on theme change.
975 let blue_color: Hsla = Rgba {
976 r: 0.0,
977 g: 0.0,
978 b: 1.0,
979 a: 1.0,
980 }
981 .into();
982 cx.update(|_, cx| {
983 SettingsStore::update_global(cx, |store, cx| {
984 store.update_user_settings(cx, |settings| {
985 settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
986 syntax: IndexMap::from_iter([(
987 "keyword".to_string(),
988 HighlightStyleContent {
989 color: Some("#0000ff".to_string()),
990 background_color: None,
991 font_style: None,
992 font_weight: None,
993 },
994 )]),
995 ..ThemeStyleContent::default()
996 });
997 });
998 });
999 });
1000 cx.update_editor(|editor, _window, cx| {
1001 editor
1002 .project
1003 .as_ref()
1004 .expect("editor should have a project")
1005 .read(cx)
1006 .languages()
1007 .set_theme(cx.theme().clone());
1008 });
1009 cx.run_until_parked();
1010
1011 cx.update_editor(|editor, _window, cx| {
1012 let breadcrumbs = editor
1013 .breadcrumbs_inner(cx)
1014 .expect("Should have breadcrumbs after theme change");
1015 let symbol_segment = breadcrumbs
1016 .iter()
1017 .find(|b| b.text.as_ref() == "fn main")
1018 .expect("Should have 'fn main' breadcrumb after theme change");
1019 let keyword_highlight = symbol_segment
1020 .highlights
1021 .iter()
1022 .find(|(range, _)| &symbol_segment.text[range.clone()] == "fn")
1023 .expect("Should have a highlight for the 'fn' keyword after theme change");
1024 assert_eq!(
1025 keyword_highlight.1.color,
1026 Some(blue_color),
1027 "The 'fn' keyword should have blue color after theme change"
1028 );
1029 });
1030 }
1031
1032 #[gpui::test]
1033 async fn test_breadcrumbs_keep_file_name_without_lsp_symbols(cx: &mut TestAppContext) {
1034 init_test(cx, |_| {});
1035
1036 update_test_language_settings(cx, &|settings| {
1037 settings.defaults.document_symbols = Some(DocumentSymbols::On);
1038 });
1039
1040 let mut cx = EditorLspTestContext::new_rust(
1041 lsp::ServerCapabilities {
1042 document_symbol_provider: Some(lsp::OneOf::Left(true)),
1043 ..lsp::ServerCapabilities::default()
1044 },
1045 cx,
1046 )
1047 .await;
1048 let mut symbol_request = cx
1049 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
1050 move |_, _, _| async move {
1051 Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
1052 },
1053 );
1054
1055 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
1056 assert!(symbol_request.next().await.is_some());
1057 cx.run_until_parked();
1058
1059 cx.update_editor(|editor, _window, cx| {
1060 assert_eq!(
1061 breadcrumb_texts(editor, cx),
1062 vec![path!("dir/file.rs").to_string()],
1063 "Breadcrumbs should fall back to the file name when the language server returns no symbols"
1064 );
1065
1066 editor.set_breadcrumb_header("Last 1000 lines in the log".to_string());
1067 assert_eq!(
1068 breadcrumb_texts(editor, cx),
1069 vec!["Last 1000 lines in the log".to_string()],
1070 "A custom breadcrumb header should never disappear"
1071 );
1072 });
1073 }
1074
1075 #[gpui::test]
1076 async fn test_breadcrumbs_refresh_on_document_symbols_setting_change(cx: &mut TestAppContext) {
1077 init_test(cx, |_| {});
1078
1079 let mut cx = EditorLspTestContext::new_rust(
1080 lsp::ServerCapabilities {
1081 document_symbol_provider: Some(lsp::OneOf::Left(true)),
1082 ..lsp::ServerCapabilities::default()
1083 },
1084 cx,
1085 )
1086 .await;
1087 let mut symbol_request = cx
1088 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
1089 move |_, _, _| async move {
1090 Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
1091 },
1092 );
1093
1094 cx.set_state("fn maˇin() {\n let x = 1;\n}\n");
1095 cx.run_until_parked();
1096
1097 cx.update_editor(|editor, _window, cx| {
1098 assert_eq!(
1099 breadcrumb_texts(editor, cx),
1100 vec![path!("dir/file.rs").to_string(), "fn main".to_string()],
1101 "With tree-sitter symbols, breadcrumbs should show the file name and the symbol"
1102 );
1103 });
1104
1105 let breadcrumb_updates = Arc::new(atomic::AtomicUsize::new(0));
1106 let editor = cx.editor.clone();
1107 let _subscription = cx.update(|_, cx| {
1108 cx.subscribe(&editor, {
1109 let breadcrumb_updates = breadcrumb_updates.clone();
1110 move |_, event, _| {
1111 Editor::to_item_events(event, &mut |item_event| {
1112 if item_event == ItemEvent::UpdateBreadcrumbs {
1113 breadcrumb_updates.fetch_add(1, atomic::Ordering::AcqRel);
1114 }
1115 });
1116 }
1117 })
1118 });
1119
1120 update_test_language_settings(&mut cx.cx.cx, &|settings| {
1121 settings.defaults.document_symbols = Some(DocumentSymbols::On);
1122 });
1123 assert!(symbol_request.next().await.is_some());
1124 cx.run_until_parked();
1125
1126 cx.update_editor(|editor, _window, cx| {
1127 assert_eq!(
1128 breadcrumb_texts(editor, cx),
1129 vec![path!("dir/file.rs").to_string()],
1130 "After enabling LSP symbols that return nothing, breadcrumbs should keep the file name"
1131 );
1132 });
1133 assert!(
1134 breadcrumb_updates.load(atomic::Ordering::Acquire) > 0,
1135 "Breadcrumbs should refresh on the setting change, without extra selection changes"
1136 );
1137
1138 breadcrumb_updates.store(0, atomic::Ordering::Release);
1139 update_test_language_settings(&mut cx.cx.cx, &|settings| {
1140 settings.defaults.document_symbols = Some(DocumentSymbols::Off);
1141 });
1142 cx.run_until_parked();
1143
1144 cx.update_editor(|editor, _window, cx| {
1145 assert_eq!(
1146 breadcrumb_texts(editor, cx),
1147 vec![path!("dir/file.rs").to_string(), "fn main".to_string()],
1148 "After disabling LSP symbols, tree-sitter breadcrumbs should return"
1149 );
1150 });
1151 assert!(
1152 breadcrumb_updates.load(atomic::Ordering::Acquire) > 0,
1153 "Breadcrumbs should refresh on the setting change, without extra selection changes"
1154 );
1155 }
1156}
1157