Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:38:34.151Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

document_symbols.rs

1157 lines · 44.2 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.