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