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