use std::{ borrow::Cow, ops::{Deref, DerefMut, Range}, path::Path, sync::Arc, }; use anyhow::Result; use language::{markdown_lang, rust_lang}; use multi_buffer::MultiBufferOffset; use serde_json::json; use crate::{Editor, ToPoint}; use collections::HashSet; use futures::Future; use futures::stream::StreamExt; use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window}; use indoc::indoc; use language::{ BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries, point_to_lsp, }; use lsp::{notification, request}; use project::Project; use workspace::{AppState, MultiWorkspace, Workspace, WorkspaceHandle}; use super::editor_test_context::{AssertionContextManager, EditorTestContext}; pub struct EditorLspTestContext { pub cx: EditorTestContext, pub lsp: lsp::FakeLanguageServer, pub workspace: Entity, pub buffer_lsp_url: lsp::Uri, } #[cfg(test)] pub(crate) fn git_commit_lang() -> Arc { Arc::new(Language::new( LanguageConfig { name: "Git Commit".into(), line_comments: vec!["#".into()], ..Default::default() }, None, )) } impl EditorLspTestContext { pub async fn new( language: Language, capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { let app_state = cx.update(AppState::test); cx.update(|cx| { assets::Assets.load_test_fonts(cx); crate::init(cx); workspace::init(app_state.clone(), cx); }); let file_name = format!( "file.{}", language .path_suffixes() .first() .expect("language must have a path suffix for EditorLspTestContext") ); let project = Project::test(app_state.fs.clone(), [], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); let mut fake_servers = language_registry.register_fake_lsp( language.name(), FakeLspAdapter { capabilities, ..Default::default() }, ); language_registry.add(Arc::new(language)); let root = Self::root_path(); app_state .fs .as_fake() .insert_tree( root, json!({ ".git": {}, "dir": { file_name.clone(): "" } }), ) .await; let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); project .update(&mut cx, |project, cx| { project.find_or_create_worktree(root, true, cx) }) .await .unwrap(); cx.read(|cx| { workspace .read(cx) .workspace() .read(cx) .worktree_scans_complete(cx) }) .await; let file = cx.read(|cx| workspace.read(cx).workspace().file_project_paths(cx)[0].clone()); let item = workspace .update_in(&mut cx, |workspace, window, cx| { workspace.workspace().update(cx, |workspace, cx| { workspace.open_path(file, None, true, window, cx) }) }) .await .expect("Could not open test file"); let editor = cx.update(|_, cx| { item.act_as::(cx) .expect("Opened test file wasn't an editor") }); editor.update_in(&mut cx, |editor, window, cx| { let nav_history = workspace .read(cx) .workspace() .read(cx) .active_pane() .read(cx) .nav_history_for_item(&cx.entity()); editor.set_nav_history(Some(nav_history)); window.focus(&editor.focus_handle(cx), cx) }); let lsp = fake_servers.next().await.unwrap(); // Ensure the language server is fully registered with the buffer cx.executor().run_until_parked(); let workspace = cx.read(|cx| workspace.read(cx).workspace().clone()); Self { cx: EditorTestContext { cx, window: window.into(), editor, assertion_cx: AssertionContextManager::new(), }, lsp, workspace, buffer_lsp_url: lsp::Uri::from_file_path(root.join("dir").join(file_name)).unwrap(), } } pub async fn new_rust( capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await } pub async fn new_typescript( capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { let mut word_characters: HashSet = Default::default(); word_characters.insert('$'); word_characters.insert('#'); let language = Language::new( LanguageConfig { name: "Typescript".into(), matcher: (LanguageMatcher { path_suffixes: vec!["ts".to_string()], ..Default::default() }).into(), brackets: language::BracketPairConfig { pairs: vec![language::BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }], disabled_scopes_by_bracket_ix: Default::default(), }, word_characters, ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()), ) .with_queries(LanguageQueries { brackets: Some(Cow::from(indoc! {r#" ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) ("<" @open ">" @close) ("'" @open "'" @close) ("`" @open "`" @close) ("\"" @open "\"" @close)"#})), indents: Some(Cow::from(indoc! {r#" [ (call_expression) (assignment_expression) (member_expression) (lexical_declaration) (variable_declaration) (assignment_expression) (if_statement) (for_statement) ] @indent (_ "[" "]" @end) @indent (_ "<" ">" @end) @indent (_ "{" "}" @end) @indent (_ "(" ")" @end) @indent "#})), text_objects: Some(Cow::from(indoc! {r#" (function_declaration body: (_ "{" (_)* @function.inside "}")) @function.around (method_definition body: (_ "{" (_)* @function.inside "}")) @function.around ; Arrow function in variable declaration - capture the full declaration ([ (lexical_declaration (variable_declarator value: (arrow_function body: (statement_block "{" (_)* @function.inside "}")))) (variable_declaration (variable_declarator value: (arrow_function body: (statement_block "{" (_)* @function.inside "}")))) ]) @function.around ([ (lexical_declaration (variable_declarator value: (arrow_function))) (variable_declaration (variable_declarator value: (arrow_function))) ]) @function.around ; Catch-all for arrow functions in other contexts (callbacks, etc.) ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) "#})), ..Default::default() }) .expect("Could not parse queries"); Self::new(language, capabilities, cx).await } pub async fn new_tsx( capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { let mut word_characters: HashSet = Default::default(); word_characters.insert('$'); word_characters.insert('#'); let language = Language::new( LanguageConfig { name: "TSX".into(), matcher: (LanguageMatcher { path_suffixes: vec!["tsx".to_string()], ..Default::default() }).into(), brackets: language::BracketPairConfig { pairs: vec![language::BracketPair { start: "{".to_string(), end: "}".to_string(), close: true, surround: true, newline: true, }], disabled_scopes_by_bracket_ix: Default::default(), }, word_characters, ..Default::default() }, Some(tree_sitter_typescript::LANGUAGE_TSX.into()), ) .with_queries(LanguageQueries { brackets: Some(Cow::from(indoc! {r#" ("(" @open ")" @close) ("[" @open "]" @close) ("{" @open "}" @close) ("<" @open ">" @close) ("<" @open "/>" @close) ("" @close) ("\"" @open "\"" @close) ("'" @open "'" @close) ("`" @open "`" @close) ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})), indents: Some(Cow::from(indoc! {r#" [ (call_expression) (assignment_expression) (member_expression) (lexical_declaration) (variable_declaration) (assignment_expression) (if_statement) (for_statement) ] @indent (_ "[" "]" @end) @indent (_ "<" ">" @end) @indent (_ "{" "}" @end) @indent (_ "(" ")" @end) @indent (jsx_opening_element ">" @end) @indent (jsx_element (jsx_opening_element) @start (jsx_closing_element)? @end) @indent "#})), text_objects: Some(Cow::from(indoc! {r#" (function_declaration body: (_ "{" (_)* @function.inside "}")) @function.around (method_definition body: (_ "{" (_)* @function.inside "}")) @function.around ; Arrow function in variable declaration - capture the full declaration ([ (lexical_declaration (variable_declarator value: (arrow_function body: (statement_block "{" (_)* @function.inside "}")))) (variable_declaration (variable_declarator value: (arrow_function body: (statement_block "{" (_)* @function.inside "}")))) ]) @function.around ([ (lexical_declaration (variable_declarator value: (arrow_function))) (variable_declaration (variable_declarator value: (arrow_function))) ]) @function.around ; Catch-all for arrow functions in other contexts (callbacks, etc.) ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator)) "#})), ..Default::default() }) .expect("Could not parse queries"); Self::new(language, capabilities, cx).await } pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self { let language = Language::new( LanguageConfig { name: "HTML".into(), matcher: (LanguageMatcher { path_suffixes: vec!["html".into()], ..Default::default() }) .into(), block_comment: Some(BlockCommentConfig { start: "".into(), tab_size: 0, }), completion_query_characters: ['-'].into_iter().collect(), ..Default::default() }, Some(tree_sitter_html::LANGUAGE.into()), ) .with_queries(LanguageQueries { brackets: Some(Cow::from(indoc! {r#" ("<" @open "/>" @close) ("" @close) ("<" @open ">" @close) ("\"" @open "\"" @close)"#})), ..Default::default() }) .expect("Could not parse queries"); Self::new(language, Default::default(), cx).await } pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> Self { let context = Self::new( Arc::into_inner(markdown_lang()).unwrap(), Default::default(), cx, ) .await; let language_registry = context.workspace.read_with(cx, |workspace, cx| { workspace.project().read(cx).languages().clone() }); language_registry.add(rust_lang()); context } /// Constructs lsp range using a marked string with '[', ']' range delimiters #[track_caller] pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range { let ranges = self.ranges(marked_text); self.to_lsp_range(MultiBufferOffset(ranges[0].start)..MultiBufferOffset(ranges[0].end)) } #[expect(clippy::wrong_self_convention, reason = "This is test code")] pub fn to_lsp_range(&mut self, range: Range) -> lsp::Range { use language::ToPointUtf16; let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); let start_point = range.start.to_point(&snapshot.buffer_snapshot()); let end_point = range.end.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); let (start_buffer, start_offset) = buffer.point_to_buffer_offset(start_point, cx).unwrap(); let start = point_to_lsp(start_offset.to_point_utf16(&start_buffer.read(cx))); let (end_buffer, end_offset) = buffer.point_to_buffer_offset(end_point, cx).unwrap(); let end = point_to_lsp(end_offset.to_point_utf16(&end_buffer.read(cx))); lsp::Range { start, end } }) } #[expect(clippy::wrong_self_convention, reason = "This is test code")] pub fn to_lsp(&mut self, offset: MultiBufferOffset) -> lsp::Position { use language::ToPointUtf16; let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx)); let point = offset.to_point(&snapshot.buffer_snapshot()); self.editor(|editor, _, cx| { let buffer = editor.buffer().read(cx); let (buffer, offset) = buffer.point_to_buffer_offset(point, cx).unwrap(); point_to_lsp(offset.to_point_utf16(&buffer.read(cx))) }) } pub fn update_workspace(&mut self, update: F) -> T where F: FnOnce(&mut Workspace, &mut Window, &mut Context) -> T, { self.workspace.update_in(&mut self.cx.cx, update) } pub fn set_request_handler( &self, mut handler: F, ) -> futures::channel::mpsc::UnboundedReceiver<()> where T: 'static + request::Request, T::Params: 'static + Send, F: 'static + Send + FnMut(lsp::Uri, T::Params, gpui::AsyncApp) -> Fut, Fut: 'static + Future>, { let url = self.buffer_lsp_url.clone(); self.lsp.set_request_handler::(move |params, cx| { let url = url.clone(); handler(url, params, cx) }) } pub fn notify(&self, params: T::Params) { self.lsp.notify::(params); } #[cfg(target_os = "windows")] pub fn root_path() -> &'static Path { Path::new("C:\\root") } #[cfg(not(target_os = "windows"))] pub fn root_path() -> &'static Path { Path::new("/root") } } impl Deref for EditorLspTestContext { type Target = EditorTestContext; fn deref(&self) -> &Self::Target { &self.cx } } impl DerefMut for EditorLspTestContext { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.cx } }