use std::ops::Range;

use collections::HashMap;
use futures::FutureExt;
use futures::future::join_all;
use gpui::{App, Context, HighlightStyle, Task};
use itertools::Itertools as _;
use language::language_settings::LanguageSettings;
use language::{Buffer, OutlineItem};
use multi_buffer::{
    Anchor, AnchorRangeExt as _, MultiBufferOffset, MultiBufferRow, MultiBufferSnapshot,
    ToOffset as _,
};
use text::BufferId;
use theme::{ActiveTheme as _, SyntaxTheme};
use unicode_segmentation::UnicodeSegmentation as _;
use util::maybe;

use crate::display_map::DisplaySnapshot;
use crate::{Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT};

impl Editor {
    /// Returns all document outline items for a buffer, using LSP or
    /// tree-sitter based on the `document_symbols` setting.
    /// External consumers (outline modal, outline panel, breadcrumbs) should use this.
    pub fn buffer_outline_items(
        &self,
        buffer_id: BufferId,
        cx: &mut Context<Self>,
    ) -> Task<Vec<OutlineItem<text::Anchor>>> {
        let Some(buffer) = self.buffer.read(cx).buffer(buffer_id) else {
            return Task::ready(Vec::new());
        };

        if lsp_symbols_enabled(buffer.read(cx), cx) {
            let refresh_task = self.refresh_document_symbols_task.clone();
            cx.spawn(async move |editor, cx| {
                refresh_task.await;
                editor
                    .read_with(cx, |editor, _| {
                        editor
                            .lsp_document_symbols
                            .get(&buffer_id)
                            .cloned()
                            .unwrap_or_default()
                    })
                    .ok()
                    .unwrap_or_default()
            })
        } else {
            let buffer_snapshot = buffer.read(cx).snapshot();
            let syntax = cx.theme().syntax().clone();
            cx.background_executor()
                .spawn(async move { buffer_snapshot.outline(Some(&syntax)).items })
        }
    }

    /// Whether the buffer at `cursor` has LSP document symbols enabled.
    pub(super) fn uses_lsp_document_symbols(
        &self,
        cursor: Anchor,
        multi_buffer_snapshot: &MultiBufferSnapshot,
        cx: &Context<Self>,
    ) -> bool {
        let Some((anchor, _)) = multi_buffer_snapshot.anchor_to_buffer_anchor(cursor) else {
            return false;
        };
        let Some(buffer) = self.buffer.read(cx).buffer(anchor.buffer_id) else {
            return false;
        };
        lsp_symbols_enabled(buffer.read(cx), cx)
    }

    /// Filters editor-local LSP document symbols to the ancestor chain
    /// containing `cursor`. Never triggers an LSP request.
    pub(super) fn lsp_symbols_at_cursor(
        &self,
        cursor: Anchor,
        multi_buffer_snapshot: &MultiBufferSnapshot,
        _cx: &Context<Self>,
    ) -> Option<(BufferId, Vec<OutlineItem<Anchor>>)> {
        let (cursor_text_anchor, buffer) = multi_buffer_snapshot.anchor_to_buffer_anchor(cursor)?;
        let all_items = self
            .lsp_document_symbols
            .get(&cursor_text_anchor.buffer_id)?;
        if all_items.is_empty() {
            return None;
        }

        let mut symbols = all_items
            .iter()
            .filter(|item| {
                item.range.start.cmp(&cursor_text_anchor, buffer).is_le()
                    && item.range.end.cmp(&cursor_text_anchor, buffer).is_ge()
            })
            .filter_map(|item| {
                let range_start = multi_buffer_snapshot.anchor_in_buffer(item.range.start)?;
                let range_end = multi_buffer_snapshot.anchor_in_buffer(item.range.end)?;
                let source_range_for_text_start =
                    multi_buffer_snapshot.anchor_in_buffer(item.source_range_for_text.start)?;
                let source_range_for_text_end =
                    multi_buffer_snapshot.anchor_in_buffer(item.source_range_for_text.end)?;
                Some(OutlineItem {
                    depth: item.depth,
                    range: range_start..range_end,
                    source_range_for_text: source_range_for_text_start..source_range_for_text_end,
                    text: item.text.clone(),
                    highlight_ranges: item.highlight_ranges.clone(),
                    name_ranges: item.name_ranges.clone(),
                    body_range: item.body_range.as_ref().and_then(|r| {
                        Some(
                            multi_buffer_snapshot.anchor_in_buffer(r.start)?
                                ..multi_buffer_snapshot.anchor_in_buffer(r.end)?,
                        )
                    }),
                    annotation_range: item.annotation_range.as_ref().and_then(|r| {
                        Some(
                            multi_buffer_snapshot.anchor_in_buffer(r.start)?
                                ..multi_buffer_snapshot.anchor_in_buffer(r.end)?,
                        )
                    }),
                })
            })
            .collect::<Vec<_>>();

        let mut prev_depth = None;
        symbols.retain(|item| {
            let retain = prev_depth.is_none_or(|prev_depth| item.depth > prev_depth);
            prev_depth = Some(item.depth);
            retain
        });

        Some((buffer.remote_id(), symbols))
    }

    /// Fetches document symbols from the LSP for buffers that have the setting
    /// enabled. Called from `update_lsp_data` on edits, server events, etc.
    /// When the fetch completes, stores results in `self.lsp_document_symbols`
    /// and triggers `refresh_outline_symbols_at_cursor` so breadcrumbs pick up the new data.
    pub(super) fn refresh_document_symbols(
        &mut self,
        for_buffer: Option<BufferId>,
        cx: &mut Context<Self>,
    ) {
        if !self.lsp_data_enabled() {
            return;
        }
        let Some(project) = self.project.clone() else {
            return;
        };

        let buffers_to_query = self
            .visible_buffers(cx)
            .into_iter()
            .filter(|buffer| self.is_lsp_relevant(buffer.read(cx).file(), cx))
            .filter_map(|buffer| {
                let id = buffer.read(cx).remote_id();
                if for_buffer.is_none_or(|target| target == id)
                    && lsp_symbols_enabled(buffer.read(cx), cx)
                {
                    Some(buffer)
                } else {
                    None
                }
            })
            .unique_by(|buffer| buffer.read(cx).remote_id())
            .collect::<Vec<_>>();

        let mut symbols_altered = false;
        let multi_buffer = self.buffer().clone();
        self.lsp_document_symbols.retain(|buffer_id, _| {
            let Some(buffer) = multi_buffer.read(cx).buffer(*buffer_id) else {
                symbols_altered = true;
                return false;
            };
            let retain = lsp_symbols_enabled(buffer.read(cx), cx);
            symbols_altered |= !retain;
            retain
        });
        if symbols_altered {
            self.refresh_outline_symbols_at_cursor(cx);
        }

        if buffers_to_query.is_empty() {
            return;
        }

        self.refresh_document_symbols_task = cx
            .spawn(async move |editor, cx| {
                cx.background_executor()
                    .timer(LSP_REQUEST_DEBOUNCE_TIMEOUT)
                    .await;

                let Some(tasks) = editor
                    .update(cx, |_, cx| {
                        project.read(cx).lsp_store().update(cx, |lsp_store, cx| {
                            buffers_to_query
                                .into_iter()
                                .map(|buffer| {
                                    let buffer_id = buffer.read(cx).remote_id();
                                    let task = lsp_store.fetch_document_symbols(&buffer, cx);
                                    async move { (buffer_id, task.await) }
                                })
                                .collect::<Vec<_>>()
                        })
                    })
                    .ok()
                else {
                    return;
                };

                let results = join_all(tasks).await.into_iter().collect::<HashMap<_, _>>();
                editor
                    .update(cx, |editor, cx| {
                        let syntax = cx.theme().syntax().clone();
                        let display_snapshot =
                            editor.display_map.update(cx, |map, cx| map.snapshot(cx));
                        let mut highlighted_results = results;
                        for items in highlighted_results.values_mut() {
                            for item in items {
                                if let Some(highlights) =
                                    highlights_from_buffer(&display_snapshot, &item, &syntax)
                                {
                                    item.highlight_ranges = highlights;
                                }
                            }
                        }
                        editor.lsp_document_symbols.extend(highlighted_results);
                        editor.refresh_outline_symbols_at_cursor(cx);
                    })
                    .ok();
            })
            .shared();
    }
}

fn lsp_symbols_enabled(buffer: &Buffer, cx: &App) -> bool {
    LanguageSettings::for_buffer(buffer, cx)
        .document_symbols
        .lsp_enabled()
}

/// Finds where the symbol name appears in the buffer and returns combined
/// (tree-sitter + semantic token) highlights for those positions.
///
/// First tries to find the name verbatim near the selection range so that
/// complex names (`impl Trait for Type`) get full highlighting. Falls back
/// to word-by-word matching for cases like `impl<T> Trait<T> for Type`
/// where the LSP name doesn't appear verbatim in the buffer.
fn highlights_from_buffer(
    display_snapshot: &DisplaySnapshot,
    item: &OutlineItem<text::Anchor>,
    syntax_theme: &SyntaxTheme,
) -> Option<Vec<(Range<usize>, HighlightStyle)>> {
    let outline_text = &item.text;
    if outline_text.is_empty() {
        return None;
    }

    let multi_buffer_snapshot = display_snapshot.buffer();
    let multi_buffer_source_range_anchors =
        multi_buffer_snapshot.text_anchors_to_visible_anchors([
            item.source_range_for_text.start,
            item.source_range_for_text.end,
        ]);
    let Some(anchor_range) = maybe!({
        Some(
            (*multi_buffer_source_range_anchors.get(0)?)?
                ..(*multi_buffer_source_range_anchors.get(1)?)?,
        )
    }) else {
        return None;
    };

    let selection_point_range = anchor_range.to_point(multi_buffer_snapshot);
    let mut search_start = selection_point_range.start;
    search_start.column = 0;
    let search_start_offset = search_start.to_offset(&multi_buffer_snapshot);
    let mut search_end = selection_point_range.end;
    search_end.column = multi_buffer_snapshot.line_len(MultiBufferRow(search_end.row));

    let search_text = multi_buffer_snapshot
        .text_for_range(search_start..search_end)
        .collect::<String>();

    let mut outline_text_highlights = Vec::new();
    match search_text.find(outline_text) {
        Some(start_index) => {
            let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
            let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_text.len());
            outline_text_highlights.extend(
                display_snapshot
                    .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme),
            );
        }
        None => {
            for (outline_text_word_start, outline_word) in outline_text.split_word_bound_indices() {
                if let Some(start_index) = search_text.find(outline_word) {
                    let multibuffer_start = search_start_offset + MultiBufferOffset(start_index);
                    let multibuffer_end = multibuffer_start + MultiBufferOffset(outline_word.len());
                    outline_text_highlights.extend(
                        display_snapshot
                            .combined_highlights(multibuffer_start..multibuffer_end, syntax_theme)
                            .into_iter()
                            .map(|(range_in_word, style)| {
                                (
                                    outline_text_word_start + range_in_word.start
                                        ..outline_text_word_start + range_in_word.end,
                                    style,
                                )
                            }),
                    );
                }
            }
        }
    }

    if outline_text_highlights.is_empty() {
        None
    } else {
        Some(outline_text_highlights)
    }
}

#[cfg(test)]
mod tests {
    use std::{
        sync::{Arc, atomic},
        time::Duration,
    };

    use futures::StreamExt as _;
    use gpui::TestAppContext;
    use settings::{DocumentSymbols, SettingsStore};
    use util::path;
    use zed_actions::editor::{MoveDown, MoveUp};

    use crate::{
        Editor, LSP_REQUEST_DEBOUNCE_TIMEOUT,
        editor_tests::{init_test, update_test_language_settings},
        test::editor_lsp_test_context::EditorLspTestContext,
    };

    fn outline_symbol_names(editor: &Editor) -> Vec<&str> {
        editor
            .outline_symbols_at_cursor
            .as_ref()
            .expect("Should have outline symbols")
            .1
            .iter()
            .map(|s| s.text.as_str())
            .collect()
    }

    fn lsp_range(start_line: u32, start_char: u32, end_line: u32, end_char: u32) -> lsp::Range {
        lsp::Range {
            start: lsp::Position::new(start_line, start_char),
            end: lsp::Position::new(end_line, end_char),
        }
    }

    fn nested_symbol(
        name: &str,
        kind: lsp::SymbolKind,
        range: lsp::Range,
        selection_range: lsp::Range,
        children: Vec<lsp::DocumentSymbol>,
    ) -> lsp::DocumentSymbol {
        #[allow(deprecated)]
        lsp::DocumentSymbol {
            name: name.to_string(),
            detail: None,
            kind,
            tags: None,
            deprecated: None,
            range,
            selection_range,
            children: if children.is_empty() {
                None
            } else {
                Some(children)
            },
        }
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_fetches_when_enabled(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "main",
                            lsp::SymbolKind::FUNCTION,
                            lsp_range(0, 0, 2, 1),
                            lsp_range(0, 3, 0, 7),
                            Vec::new(),
                        ),
                    ])))
                },
            );

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(outline_symbol_names(editor), vec!["fn main"]);
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_nested(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "Foo",
                            lsp::SymbolKind::STRUCT,
                            lsp_range(0, 0, 3, 1),
                            lsp_range(0, 7, 0, 10),
                            vec![
                                nested_symbol(
                                    "bar",
                                    lsp::SymbolKind::FIELD,
                                    lsp_range(1, 4, 1, 13),
                                    lsp_range(1, 4, 1, 7),
                                    Vec::new(),
                                ),
                                nested_symbol(
                                    "baz",
                                    lsp::SymbolKind::FIELD,
                                    lsp_range(2, 4, 2, 15),
                                    lsp_range(2, 4, 2, 7),
                                    Vec::new(),
                                ),
                            ],
                        ),
                    ])))
                },
            );

        cx.set_state("struct Foo {\n    baˇr: u32,\n    baz: String,\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["struct Foo", "bar"],
                "cursor is inside Foo > bar, so we expect the containing chain"
            );
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_switch_tree_sitter_to_lsp_and_back(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        // Start with tree-sitter (default)
        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "lsp_main_symbol",
                            lsp::SymbolKind::FUNCTION,
                            lsp_range(0, 0, 2, 1),
                            lsp_range(0, 3, 0, 7),
                            Vec::new(),
                        ),
                    ])))
                },
            );

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        cx.run_until_parked();

        // Step 1: With tree-sitter (default), breadcrumbs use tree-sitter outline
        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["fn main"],
                "Tree-sitter should produce 'fn main'"
            );
        });

        // Step 2: Switch to LSP
        update_test_language_settings(&mut cx.cx.cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["lsp_main_symbol"],
                "After switching to LSP, should see LSP symbols"
            );
        });

        // Step 3: Switch back to tree-sitter
        update_test_language_settings(&mut cx.cx.cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::Off);
        });
        cx.run_until_parked();

        // Force another selection change
        cx.update_editor(|editor, window, cx| {
            editor.move_up(&MoveUp, window, cx);
        });
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["fn main"],
                "After switching back to tree-sitter, should see tree-sitter symbols again"
            );
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_caches_results(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let request_count = Arc::new(atomic::AtomicUsize::new(0));
        let request_count_clone = request_count.clone();

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;

        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
                async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "main",
                            lsp::SymbolKind::FUNCTION,
                            lsp_range(0, 0, 2, 1),
                            lsp_range(0, 3, 0, 7),
                            Vec::new(),
                        ),
                    ])))
                }
            });

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        let first_count = request_count.load(atomic::Ordering::Acquire);
        assert_eq!(first_count, 1, "Should have made exactly one request");

        // Move cursor within the same buffer version — should use cache
        cx.update_editor(|editor, window, cx| {
            editor.move_down(&MoveDown, window, cx);
        });
        cx.background_executor
            .advance_clock(LSP_REQUEST_DEBOUNCE_TIMEOUT + Duration::from_millis(100));
        cx.run_until_parked();

        assert_eq!(
            first_count,
            request_count.load(atomic::Ordering::Acquire),
            "Moving cursor without editing should use cached symbols"
        );
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_flat_response(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    #[allow(deprecated)]
                    Ok(Some(lsp::DocumentSymbolResponse::Flat(vec![
                        lsp::SymbolInformation {
                            name: "main".to_string(),
                            kind: lsp::SymbolKind::FUNCTION,
                            tags: None,
                            deprecated: None,
                            location: lsp::Location {
                                uri: lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap(),
                                range: lsp_range(0, 0, 2, 1),
                            },
                            container_name: None,
                        },
                    ])))
                },
            );

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(outline_symbol_names(editor), vec!["main"]);
        });
    }

    #[gpui::test]
    async fn test_breadcrumbs_use_lsp_symbols(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "MyModule",
                            lsp::SymbolKind::MODULE,
                            lsp_range(0, 0, 4, 1),
                            lsp_range(0, 4, 0, 12),
                            vec![nested_symbol(
                                "my_function",
                                lsp::SymbolKind::FUNCTION,
                                lsp_range(1, 4, 3, 5),
                                lsp_range(1, 7, 1, 18),
                                Vec::new(),
                            )],
                        ),
                    ])))
                },
            );

        cx.set_state("mod MyModule {\n    fn my_fuˇnction() {\n        let x = 1;\n    }\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["mod MyModule", "fn my_function"]
            );
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_multibyte_highlights(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    // Buffer: "/// αyzabc\nfn test() {}\n"
                    // Bytes 0-3: "/// ", bytes 4-5: α (2-byte UTF-8), bytes 6-11: "yzabc\n"
                    // Line 1 starts at byte 12: "fn test() {}"
                    //
                    // Symbol range includes doc comment (line 0-1).
                    // Selection points to "test" on line 1.
                    // enriched_symbol_text extracts "fn test" with source_range_for_text.start at byte 12.
                    // search_start = max(12 - 7, 0) = 5, which is INSIDE the 2-byte 'α' char.
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "test",
                            lsp::SymbolKind::FUNCTION,
                            lsp_range(0, 0, 1, 13), // includes doc comment
                            lsp_range(1, 3, 1, 7),  // "test"
                            Vec::new(),
                        ),
                    ])))
                },
            );

        // "/// αyzabc\n" = 12 bytes, then "fn test() {}\n"
        // search_start = 12 - 7 = 5, which is byte 5 = second byte of 'α' (not a char boundary)
        cx.set_state("/// αyzabc\nfn teˇst() {}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();

        cx.update_editor(|editor, _window, _cx| {
            let (_, symbols) = editor
                .outline_symbols_at_cursor
                .as_ref()
                .expect("Should have outline symbols");
            assert_eq!(symbols.len(), 1);

            let symbol = &symbols[0];
            assert_eq!(symbol.text, "fn test");

            // Verify all highlight ranges are valid byte boundaries in the text
            for (range, _style) in &symbol.highlight_ranges {
                assert!(
                    symbol.text.is_char_boundary(range.start),
                    "highlight range start {} is not a char boundary in {:?}",
                    range.start,
                    symbol.text
                );
                assert!(
                    symbol.text.is_char_boundary(range.end),
                    "highlight range end {} is not a char boundary in {:?}",
                    range.end,
                    symbol.text
                );
                assert!(
                    range.end <= symbol.text.len(),
                    "highlight range end {} exceeds text length {} for {:?}",
                    range.end,
                    symbol.text.len(),
                    symbol.text
                );
            }
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_empty_response(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        update_test_language_settings(cx, &|settings| {
            settings.defaults.document_symbols = Some(DocumentSymbols::On);
        });

        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let mut symbol_request = cx
            .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
                move |_, _, _| async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(Vec::new())))
                },
            );

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        assert!(symbol_request.next().await.is_some());
        cx.run_until_parked();
        cx.update_editor(|editor, _window, _cx| {
            // With LSP enabled but empty response, outline_symbols_at_cursor should be None
            // (no symbols to show in breadcrumbs)
            assert!(
                editor.outline_symbols_at_cursor.is_none(),
                "Empty LSP response should result in no outline symbols"
            );
        });
    }

    #[gpui::test]
    async fn test_lsp_document_symbols_disabled_by_default(cx: &mut TestAppContext) {
        init_test(cx, |_| {});

        let request_count = Arc::new(atomic::AtomicUsize::new(0));
        // Do NOT enable document_symbols — defaults to Off
        let mut cx = EditorLspTestContext::new_rust(
            lsp::ServerCapabilities {
                document_symbol_provider: Some(lsp::OneOf::Left(true)),
                ..lsp::ServerCapabilities::default()
            },
            cx,
        )
        .await;
        let request_count_clone = request_count.clone();
        let _symbol_request =
            cx.set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(move |_, _, _| {
                request_count_clone.fetch_add(1, atomic::Ordering::AcqRel);
                async move {
                    Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
                        nested_symbol(
                            "should_not_appear",
                            lsp::SymbolKind::FUNCTION,
                            lsp_range(0, 0, 2, 1),
                            lsp_range(0, 3, 0, 7),
                            Vec::new(),
                        ),
                    ])))
                }
            });

        cx.set_state("fn maˇin() {\n    let x = 1;\n}\n");
        cx.run_until_parked();

        // Tree-sitter should be used instead
        cx.update_editor(|editor, _window, _cx| {
            assert_eq!(
                outline_symbol_names(editor),
                vec!["fn main"],
                "With document_symbols off, should use tree-sitter"
            );
        });

        assert_eq!(
            request_count.load(atomic::Ordering::Acquire),
            0,
            "Should not have made any LSP document symbol requests when setting is off"
        );
    }

    #[gpui::test]
    async fn test_breadcrumb_highlights_update_on_theme_change(cx: &mut TestAppContext) {
        use collections::IndexMap;
        use gpui::{Hsla, Rgba, UpdateGlobal as _};
        use theme_settings::{HighlightStyleContent, ThemeStyleContent};
        use ui::ActiveTheme as _;

        init_test(cx, |_| {});

        let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await;

        // Set the initial theme with a red keyword color and sync it to the
        // language registry so tree-sitter highlight maps are up to date.
        let red_color: Hsla = Rgba {
            r: 1.0,
            g: 0.0,
            b: 0.0,
            a: 1.0,
        }
        .into();
        cx.update(|_, cx| {
            SettingsStore::update_global(cx, |store, cx| {
                store.update_user_settings(cx, |settings| {
                    settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
                        syntax: IndexMap::from_iter([(
                            "keyword".to_string(),
                            HighlightStyleContent {
                                color: Some("#ff0000".to_string()),
                                background_color: None,
                                font_style: None,
                                font_weight: None,
                            },
                        )]),
                        ..ThemeStyleContent::default()
                    });
                });
            });
        });
        cx.update_editor(|editor, _window, cx| {
            editor
                .project
                .as_ref()
                .expect("editor should have a project")
                .read(cx)
                .languages()
                .set_theme(cx.theme().clone());
        });
        cx.set_state("fn maˇin() {}");
        cx.run_until_parked();

        cx.update_editor(|editor, _window, cx| {
            let breadcrumbs = editor
                .breadcrumbs_inner(cx)
                .expect("Should have breadcrumbs");
            let symbol_segment = breadcrumbs
                .iter()
                .find(|b| b.text.as_ref() == "fn main")
                .expect("Should have 'fn main' breadcrumb");
            let keyword_highlight = symbol_segment
                .highlights
                .iter()
                .find(|(range, _)| &symbol_segment.text[range.clone()] == "fn")
                .expect("Should have a highlight for the 'fn' keyword");
            assert_eq!(
                keyword_highlight.1.color,
                Some(red_color),
                "The 'fn' keyword should have red color"
            );
        });

        // Change the theme to use a blue keyword color. This simulates a user
        // switching themes. The language registry set_theme call mirrors what
        // the application does in main.rs on theme change.
        let blue_color: Hsla = Rgba {
            r: 0.0,
            g: 0.0,
            b: 1.0,
            a: 1.0,
        }
        .into();
        cx.update(|_, cx| {
            SettingsStore::update_global(cx, |store, cx| {
                store.update_user_settings(cx, |settings| {
                    settings.theme.experimental_theme_overrides = Some(ThemeStyleContent {
                        syntax: IndexMap::from_iter([(
                            "keyword".to_string(),
                            HighlightStyleContent {
                                color: Some("#0000ff".to_string()),
                                background_color: None,
                                font_style: None,
                                font_weight: None,
                            },
                        )]),
                        ..ThemeStyleContent::default()
                    });
                });
            });
        });
        cx.update_editor(|editor, _window, cx| {
            editor
                .project
                .as_ref()
                .expect("editor should have a project")
                .read(cx)
                .languages()
                .set_theme(cx.theme().clone());
        });
        cx.run_until_parked();

        cx.update_editor(|editor, _window, cx| {
            let breadcrumbs = editor
                .breadcrumbs_inner(cx)
                .expect("Should have breadcrumbs after theme change");
            let symbol_segment = breadcrumbs
                .iter()
                .find(|b| b.text.as_ref() == "fn main")
                .expect("Should have 'fn main' breadcrumb after theme change");
            let keyword_highlight = symbol_segment
                .highlights
                .iter()
                .find(|(range, _)| &symbol_segment.text[range.clone()] == "fn")
                .expect("Should have a highlight for the 'fn' keyword after theme change");
            assert_eq!(
                keyword_highlight.1.color,
                Some(blue_color),
                "The 'fn' keyword should have blue color after theme change"
            );
        });
    }
}
