editor_lsp_test_context.rs

  1use std::{
  2    borrow::Cow,
  3    ops::{Deref, DerefMut, Range},
  4    path::Path,
  5    sync::Arc,
  6};
  7
  8use anyhow::Result;
  9use language::rust_lang;
 10use serde_json::json;
 11
 12use crate::{Editor, ToPoint};
 13use collections::HashSet;
 14use futures::Future;
 15use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window};
 16use indoc::indoc;
 17use language::{
 18    BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries,
 19    point_to_lsp,
 20};
 21use lsp::{notification, request};
 22use project::Project;
 23use smol::stream::StreamExt;
 24use workspace::{AppState, Workspace, WorkspaceHandle};
 25
 26use super::editor_test_context::{AssertionContextManager, EditorTestContext};
 27
 28pub struct EditorLspTestContext {
 29    pub cx: EditorTestContext,
 30    pub lsp: lsp::FakeLanguageServer,
 31    pub workspace: Entity<Workspace>,
 32    pub buffer_lsp_url: lsp::Uri,
 33}
 34
 35#[cfg(test)]
 36pub(crate) fn git_commit_lang() -> Arc<Language> {
 37    Arc::new(Language::new(
 38        LanguageConfig {
 39            name: "Git Commit".into(),
 40            line_comments: vec!["#".into()],
 41            ..Default::default()
 42        },
 43        None,
 44    ))
 45}
 46
 47impl EditorLspTestContext {
 48    pub async fn new(
 49        language: Language,
 50        capabilities: lsp::ServerCapabilities,
 51        cx: &mut gpui::TestAppContext,
 52    ) -> EditorLspTestContext {
 53        let app_state = cx.update(AppState::test);
 54
 55        cx.update(|cx| {
 56            assets::Assets.load_test_fonts(cx);
 57            language::init(cx);
 58            crate::init(cx);
 59            workspace::init(app_state.clone(), cx);
 60            Project::init_settings(cx);
 61        });
 62
 63        let file_name = format!(
 64            "file.{}",
 65            language
 66                .path_suffixes()
 67                .first()
 68                .expect("language must have a path suffix for EditorLspTestContext")
 69        );
 70
 71        let project = Project::test(app_state.fs.clone(), [], cx).await;
 72
 73        let language_registry = project.read_with(cx, |project, _| project.languages().clone());
 74        let mut fake_servers = language_registry.register_fake_lsp(
 75            language.name(),
 76            FakeLspAdapter {
 77                capabilities,
 78                ..Default::default()
 79            },
 80        );
 81        language_registry.add(Arc::new(language));
 82
 83        let root = Self::root_path();
 84
 85        app_state
 86            .fs
 87            .as_fake()
 88            .insert_tree(
 89                root,
 90                json!({
 91                    ".git": {},
 92                    "dir": {
 93                        file_name.clone(): ""
 94                    }
 95                }),
 96            )
 97            .await;
 98
 99        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
100
101        let workspace = window.root(cx).unwrap();
102
103        let mut cx = VisualTestContext::from_window(*window.deref(), cx);
104        project
105            .update(&mut cx, |project, cx| {
106                project.find_or_create_worktree(root, true, cx)
107            })
108            .await
109            .unwrap();
110        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
111            .await;
112        let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
113        let item = workspace
114            .update_in(&mut cx, |workspace, window, cx| {
115                workspace.open_path(file, None, true, window, cx)
116            })
117            .await
118            .expect("Could not open test file");
119        let editor = cx.update(|_, cx| {
120            item.act_as::<Editor>(cx)
121                .expect("Opened test file wasn't an editor")
122        });
123        editor.update_in(&mut cx, |editor, window, cx| {
124            let nav_history = workspace
125                .read(cx)
126                .active_pane()
127                .read(cx)
128                .nav_history_for_item(&cx.entity());
129            editor.set_nav_history(Some(nav_history));
130            window.focus(&editor.focus_handle(cx))
131        });
132
133        let lsp = fake_servers.next().await.unwrap();
134        Self {
135            cx: EditorTestContext {
136                cx,
137                window: window.into(),
138                editor,
139                assertion_cx: AssertionContextManager::new(),
140            },
141            lsp,
142            workspace,
143            buffer_lsp_url: lsp::Uri::from_file_path(root.join("dir").join(file_name)).unwrap(),
144        }
145    }
146
147    pub async fn new_rust(
148        capabilities: lsp::ServerCapabilities,
149        cx: &mut gpui::TestAppContext,
150    ) -> EditorLspTestContext {
151        Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await
152    }
153
154    pub async fn new_typescript(
155        capabilities: lsp::ServerCapabilities,
156        cx: &mut gpui::TestAppContext,
157    ) -> EditorLspTestContext {
158        let mut word_characters: HashSet<char> = Default::default();
159        word_characters.insert('$');
160        word_characters.insert('#');
161        let language = Language::new(
162            LanguageConfig {
163                name: "Typescript".into(),
164                matcher: LanguageMatcher {
165                    path_suffixes: vec!["ts".to_string()],
166                    ..Default::default()
167                },
168                brackets: language::BracketPairConfig {
169                    pairs: vec![language::BracketPair {
170                        start: "{".to_string(),
171                        end: "}".to_string(),
172                        close: true,
173                        surround: true,
174                        newline: true,
175                    }],
176                    disabled_scopes_by_bracket_ix: Default::default(),
177                },
178                word_characters,
179                ..Default::default()
180            },
181            Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
182        )
183        .with_queries(LanguageQueries {
184            brackets: Some(Cow::from(indoc! {r#"
185                ("(" @open ")" @close)
186                ("[" @open "]" @close)
187                ("{" @open "}" @close)
188                ("<" @open ">" @close)
189                ("'" @open "'" @close)
190                ("`" @open "`" @close)
191                ("\"" @open "\"" @close)"#})),
192            indents: Some(Cow::from(indoc! {r#"
193                [
194                    (call_expression)
195                    (assignment_expression)
196                    (member_expression)
197                    (lexical_declaration)
198                    (variable_declaration)
199                    (assignment_expression)
200                    (if_statement)
201                    (for_statement)
202                ] @indent
203
204                (_ "[" "]" @end) @indent
205                (_ "<" ">" @end) @indent
206                (_ "{" "}" @end) @indent
207                (_ "(" ")" @end) @indent
208                "#})),
209            ..Default::default()
210        })
211        .expect("Could not parse queries");
212
213        Self::new(language, capabilities, cx).await
214    }
215
216    pub async fn new_tsx(
217        capabilities: lsp::ServerCapabilities,
218        cx: &mut gpui::TestAppContext,
219    ) -> EditorLspTestContext {
220        let mut word_characters: HashSet<char> = Default::default();
221        word_characters.insert('$');
222        word_characters.insert('#');
223        let language = Language::new(
224            LanguageConfig {
225                name: "TSX".into(),
226                matcher: LanguageMatcher {
227                    path_suffixes: vec!["tsx".to_string()],
228                    ..Default::default()
229                },
230                brackets: language::BracketPairConfig {
231                    pairs: vec![language::BracketPair {
232                        start: "{".to_string(),
233                        end: "}".to_string(),
234                        close: true,
235                        surround: true,
236                        newline: true,
237                    }],
238                    disabled_scopes_by_bracket_ix: Default::default(),
239                },
240                word_characters,
241                ..Default::default()
242            },
243            Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
244        )
245        .with_queries(LanguageQueries {
246            brackets: Some(Cow::from(indoc! {r#"
247                ("(" @open ")" @close)
248                ("[" @open "]" @close)
249                ("{" @open "}" @close)
250                ("<" @open ">" @close)
251                ("<" @open "/>" @close)
252                ("</" @open ">" @close)
253                ("\"" @open "\"" @close)
254                ("'" @open "'" @close)
255                ("`" @open "`" @close)
256                ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})),
257            indents: Some(Cow::from(indoc! {r#"
258                [
259                    (call_expression)
260                    (assignment_expression)
261                    (member_expression)
262                    (lexical_declaration)
263                    (variable_declaration)
264                    (assignment_expression)
265                    (if_statement)
266                    (for_statement)
267                ] @indent
268
269                (_ "[" "]" @end) @indent
270                (_ "<" ">" @end) @indent
271                (_ "{" "}" @end) @indent
272                (_ "(" ")" @end) @indent
273
274                (jsx_opening_element ">" @end) @indent
275
276                (jsx_element
277                  (jsx_opening_element) @start
278                  (jsx_closing_element)? @end) @indent
279                "#})),
280            ..Default::default()
281        })
282        .expect("Could not parse queries");
283
284        Self::new(language, capabilities, cx).await
285    }
286
287    pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self {
288        let language = Language::new(
289            LanguageConfig {
290                name: "HTML".into(),
291                matcher: LanguageMatcher {
292                    path_suffixes: vec!["html".into()],
293                    ..Default::default()
294                },
295                block_comment: Some(BlockCommentConfig {
296                    start: "<!--".into(),
297                    prefix: "".into(),
298                    end: "-->".into(),
299                    tab_size: 0,
300                }),
301                completion_query_characters: ['-'].into_iter().collect(),
302                ..Default::default()
303            },
304            Some(tree_sitter_html::LANGUAGE.into()),
305        )
306        .with_queries(LanguageQueries {
307            brackets: Some(Cow::from(indoc! {r#"
308                ("<" @open "/>" @close)
309                ("</" @open ">" @close)
310                ("<" @open ">" @close)
311                ("\"" @open "\"" @close)"#})),
312            ..Default::default()
313        })
314        .expect("Could not parse queries");
315        Self::new(language, Default::default(), cx).await
316    }
317
318    /// Constructs lsp range using a marked string with '[', ']' range delimiters
319    #[track_caller]
320    pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
321        let ranges = self.ranges(marked_text);
322        self.to_lsp_range(ranges[0].clone())
323    }
324
325    #[expect(clippy::wrong_self_convention, reason = "This is test code")]
326    pub fn to_lsp_range(&mut self, range: Range<usize>) -> lsp::Range {
327        let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
328        let start_point = range.start.to_point(&snapshot.buffer_snapshot());
329        let end_point = range.end.to_point(&snapshot.buffer_snapshot());
330
331        self.editor(|editor, _, cx| {
332            let buffer = editor.buffer().read(cx);
333            let start = point_to_lsp(
334                buffer
335                    .point_to_buffer_offset(start_point, cx)
336                    .unwrap()
337                    .1
338                    .to_point_utf16(&buffer.read(cx)),
339            );
340            let end = point_to_lsp(
341                buffer
342                    .point_to_buffer_offset(end_point, cx)
343                    .unwrap()
344                    .1
345                    .to_point_utf16(&buffer.read(cx)),
346            );
347
348            lsp::Range { start, end }
349        })
350    }
351
352    #[expect(clippy::wrong_self_convention, reason = "This is test code")]
353    pub fn to_lsp(&mut self, offset: usize) -> lsp::Position {
354        let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
355        let point = offset.to_point(&snapshot.buffer_snapshot());
356
357        self.editor(|editor, _, cx| {
358            let buffer = editor.buffer().read(cx);
359            point_to_lsp(
360                buffer
361                    .point_to_buffer_offset(point, cx)
362                    .unwrap()
363                    .1
364                    .to_point_utf16(&buffer.read(cx)),
365            )
366        })
367    }
368
369    pub fn update_workspace<F, T>(&mut self, update: F) -> T
370    where
371        F: FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) -> T,
372    {
373        self.workspace.update_in(&mut self.cx.cx, update)
374    }
375
376    pub fn set_request_handler<T, F, Fut>(
377        &self,
378        mut handler: F,
379    ) -> futures::channel::mpsc::UnboundedReceiver<()>
380    where
381        T: 'static + request::Request,
382        T::Params: 'static + Send,
383        F: 'static + Send + FnMut(lsp::Uri, T::Params, gpui::AsyncApp) -> Fut,
384        Fut: 'static + Future<Output = Result<T::Result>>,
385    {
386        let url = self.buffer_lsp_url.clone();
387        self.lsp.set_request_handler::<T, _, _>(move |params, cx| {
388            let url = url.clone();
389            handler(url, params, cx)
390        })
391    }
392
393    pub fn notify<T: notification::Notification>(&self, params: T::Params) {
394        self.lsp.notify::<T>(params);
395    }
396
397    #[cfg(target_os = "windows")]
398    fn root_path() -> &'static Path {
399        Path::new("C:\\root")
400    }
401
402    #[cfg(not(target_os = "windows"))]
403    fn root_path() -> &'static Path {
404        Path::new("/root")
405    }
406}
407
408impl Deref for EditorLspTestContext {
409    type Target = EditorTestContext;
410
411    fn deref(&self) -> &Self::Target {
412        &self.cx
413    }
414}
415
416impl DerefMut for EditorLspTestContext {
417    fn deref_mut(&mut self) -> &mut Self::Target {
418        &mut self.cx
419    }
420}