1use std::{
2 borrow::Cow,
3 ops::{Deref, DerefMut, Range},
4 path::Path,
5 sync::Arc,
6};
7
8use anyhow::Result;
9use language::{markdown_lang, rust_lang};
10use multi_buffer::MultiBufferOffset;
11use serde_json::json;
12
13use crate::{Editor, ToPoint};
14use collections::HashSet;
15use futures::Future;
16use gpui::{Context, Entity, Focusable as _, VisualTestContext, Window};
17use indoc::indoc;
18use language::{
19 BlockCommentConfig, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LanguageQueries,
20 point_to_lsp,
21};
22use lsp::{notification, request};
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 crate::init(cx);
59 workspace::init(app_state.clone(), cx);
60 });
61
62 let file_name = format!(
63 "file.{}",
64 language
65 .path_suffixes()
66 .first()
67 .expect("language must have a path suffix for EditorLspTestContext")
68 );
69
70 let project = Project::test(app_state.fs.clone(), [], cx).await;
71
72 let language_registry = project.read_with(cx, |project, _| project.languages().clone());
73 let mut fake_servers = language_registry.register_fake_lsp(
74 language.name(),
75 FakeLspAdapter {
76 capabilities,
77 ..Default::default()
78 },
79 );
80 language_registry.add(Arc::new(language));
81
82 let root = Self::root_path();
83
84 app_state
85 .fs
86 .as_fake()
87 .insert_tree(
88 root,
89 json!({
90 ".git": {},
91 "dir": {
92 file_name.clone(): ""
93 }
94 }),
95 )
96 .await;
97
98 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
99
100 let workspace = window.root(cx).unwrap();
101
102 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
103 project
104 .update(&mut cx, |project, cx| {
105 project.find_or_create_worktree(root, true, cx)
106 })
107 .await
108 .unwrap();
109 cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
110 .await;
111 let file = cx.read(|cx| workspace.file_project_paths(cx)[0].clone());
112 let item = workspace
113 .update_in(&mut cx, |workspace, window, cx| {
114 workspace.open_path(file, None, true, window, cx)
115 })
116 .await
117 .expect("Could not open test file");
118 let editor = cx.update(|_, cx| {
119 item.act_as::<Editor>(cx)
120 .expect("Opened test file wasn't an editor")
121 });
122 editor.update_in(&mut cx, |editor, window, cx| {
123 let nav_history = workspace
124 .read(cx)
125 .active_pane()
126 .read(cx)
127 .nav_history_for_item(&cx.entity());
128 editor.set_nav_history(Some(nav_history));
129 window.focus(&editor.focus_handle(cx), cx)
130 });
131
132 let lsp = fake_servers.next().await.unwrap();
133
134 // Ensure the language server is fully registered with the buffer
135 cx.executor().run_until_parked();
136
137 Self {
138 cx: EditorTestContext {
139 cx,
140 window: window.into(),
141 editor,
142 assertion_cx: AssertionContextManager::new(),
143 },
144 lsp,
145 workspace,
146 buffer_lsp_url: lsp::Uri::from_file_path(root.join("dir").join(file_name)).unwrap(),
147 }
148 }
149
150 pub async fn new_rust(
151 capabilities: lsp::ServerCapabilities,
152 cx: &mut gpui::TestAppContext,
153 ) -> EditorLspTestContext {
154 Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await
155 }
156
157 pub async fn new_typescript(
158 capabilities: lsp::ServerCapabilities,
159 cx: &mut gpui::TestAppContext,
160 ) -> EditorLspTestContext {
161 let mut word_characters: HashSet<char> = Default::default();
162 word_characters.insert('$');
163 word_characters.insert('#');
164 let language = Language::new(
165 LanguageConfig {
166 name: "Typescript".into(),
167 matcher: LanguageMatcher {
168 path_suffixes: vec!["ts".to_string()],
169 ..Default::default()
170 },
171 brackets: language::BracketPairConfig {
172 pairs: vec![language::BracketPair {
173 start: "{".to_string(),
174 end: "}".to_string(),
175 close: true,
176 surround: true,
177 newline: true,
178 }],
179 disabled_scopes_by_bracket_ix: Default::default(),
180 },
181 word_characters,
182 ..Default::default()
183 },
184 Some(tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()),
185 )
186 .with_queries(LanguageQueries {
187 brackets: Some(Cow::from(indoc! {r#"
188 ("(" @open ")" @close)
189 ("[" @open "]" @close)
190 ("{" @open "}" @close)
191 ("<" @open ">" @close)
192 ("'" @open "'" @close)
193 ("`" @open "`" @close)
194 ("\"" @open "\"" @close)"#})),
195 indents: Some(Cow::from(indoc! {r#"
196 [
197 (call_expression)
198 (assignment_expression)
199 (member_expression)
200 (lexical_declaration)
201 (variable_declaration)
202 (assignment_expression)
203 (if_statement)
204 (for_statement)
205 ] @indent
206
207 (_ "[" "]" @end) @indent
208 (_ "<" ">" @end) @indent
209 (_ "{" "}" @end) @indent
210 (_ "(" ")" @end) @indent
211 "#})),
212 text_objects: Some(Cow::from(indoc! {r#"
213 (function_declaration
214 body: (_
215 "{"
216 (_)* @function.inside
217 "}")) @function.around
218
219 (method_definition
220 body: (_
221 "{"
222 (_)* @function.inside
223 "}")) @function.around
224
225 ; Arrow function in variable declaration - capture the full declaration
226 ([
227 (lexical_declaration
228 (variable_declarator
229 value: (arrow_function
230 body: (statement_block
231 "{"
232 (_)* @function.inside
233 "}"))))
234 (variable_declaration
235 (variable_declarator
236 value: (arrow_function
237 body: (statement_block
238 "{"
239 (_)* @function.inside
240 "}"))))
241 ]) @function.around
242
243 ([
244 (lexical_declaration
245 (variable_declarator
246 value: (arrow_function)))
247 (variable_declaration
248 (variable_declarator
249 value: (arrow_function)))
250 ]) @function.around
251
252 ; Catch-all for arrow functions in other contexts (callbacks, etc.)
253 ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
254 "#})),
255 ..Default::default()
256 })
257 .expect("Could not parse queries");
258
259 Self::new(language, capabilities, cx).await
260 }
261
262 pub async fn new_tsx(
263 capabilities: lsp::ServerCapabilities,
264 cx: &mut gpui::TestAppContext,
265 ) -> EditorLspTestContext {
266 let mut word_characters: HashSet<char> = Default::default();
267 word_characters.insert('$');
268 word_characters.insert('#');
269 let language = Language::new(
270 LanguageConfig {
271 name: "TSX".into(),
272 matcher: LanguageMatcher {
273 path_suffixes: vec!["tsx".to_string()],
274 ..Default::default()
275 },
276 brackets: language::BracketPairConfig {
277 pairs: vec![language::BracketPair {
278 start: "{".to_string(),
279 end: "}".to_string(),
280 close: true,
281 surround: true,
282 newline: true,
283 }],
284 disabled_scopes_by_bracket_ix: Default::default(),
285 },
286 word_characters,
287 ..Default::default()
288 },
289 Some(tree_sitter_typescript::LANGUAGE_TSX.into()),
290 )
291 .with_queries(LanguageQueries {
292 brackets: Some(Cow::from(indoc! {r#"
293 ("(" @open ")" @close)
294 ("[" @open "]" @close)
295 ("{" @open "}" @close)
296 ("<" @open ">" @close)
297 ("<" @open "/>" @close)
298 ("</" @open ">" @close)
299 ("\"" @open "\"" @close)
300 ("'" @open "'" @close)
301 ("`" @open "`" @close)
302 ((jsx_element (jsx_opening_element) @open (jsx_closing_element) @close) (#set! newline.only))"#})),
303 indents: Some(Cow::from(indoc! {r#"
304 [
305 (call_expression)
306 (assignment_expression)
307 (member_expression)
308 (lexical_declaration)
309 (variable_declaration)
310 (assignment_expression)
311 (if_statement)
312 (for_statement)
313 ] @indent
314
315 (_ "[" "]" @end) @indent
316 (_ "<" ">" @end) @indent
317 (_ "{" "}" @end) @indent
318 (_ "(" ")" @end) @indent
319
320 (jsx_opening_element ">" @end) @indent
321
322 (jsx_element
323 (jsx_opening_element) @start
324 (jsx_closing_element)? @end) @indent
325 "#})),
326 text_objects: Some(Cow::from(indoc! {r#"
327 (function_declaration
328 body: (_
329 "{"
330 (_)* @function.inside
331 "}")) @function.around
332
333 (method_definition
334 body: (_
335 "{"
336 (_)* @function.inside
337 "}")) @function.around
338
339 ; Arrow function in variable declaration - capture the full declaration
340 ([
341 (lexical_declaration
342 (variable_declarator
343 value: (arrow_function
344 body: (statement_block
345 "{"
346 (_)* @function.inside
347 "}"))))
348 (variable_declaration
349 (variable_declarator
350 value: (arrow_function
351 body: (statement_block
352 "{"
353 (_)* @function.inside
354 "}"))))
355 ]) @function.around
356
357 ([
358 (lexical_declaration
359 (variable_declarator
360 value: (arrow_function)))
361 (variable_declaration
362 (variable_declarator
363 value: (arrow_function)))
364 ]) @function.around
365
366 ; Catch-all for arrow functions in other contexts (callbacks, etc.)
367 ((arrow_function) @function.around (#not-has-parent? @function.around variable_declarator))
368 "#})),
369 ..Default::default()
370 })
371 .expect("Could not parse queries");
372
373 Self::new(language, capabilities, cx).await
374 }
375
376 pub async fn new_html(cx: &mut gpui::TestAppContext) -> Self {
377 let language = Language::new(
378 LanguageConfig {
379 name: "HTML".into(),
380 matcher: LanguageMatcher {
381 path_suffixes: vec!["html".into()],
382 ..Default::default()
383 },
384 block_comment: Some(BlockCommentConfig {
385 start: "<!--".into(),
386 prefix: "".into(),
387 end: "-->".into(),
388 tab_size: 0,
389 }),
390 completion_query_characters: ['-'].into_iter().collect(),
391 ..Default::default()
392 },
393 Some(tree_sitter_html::LANGUAGE.into()),
394 )
395 .with_queries(LanguageQueries {
396 brackets: Some(Cow::from(indoc! {r#"
397 ("<" @open "/>" @close)
398 ("</" @open ">" @close)
399 ("<" @open ">" @close)
400 ("\"" @open "\"" @close)"#})),
401 ..Default::default()
402 })
403 .expect("Could not parse queries");
404 Self::new(language, Default::default(), cx).await
405 }
406
407 pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> Self {
408 let context = Self::new(
409 Arc::into_inner(markdown_lang()).unwrap(),
410 Default::default(),
411 cx,
412 )
413 .await;
414
415 let language_registry = context.workspace.read_with(cx, |workspace, cx| {
416 workspace.project().read(cx).languages().clone()
417 });
418 language_registry.add(rust_lang());
419
420 context
421 }
422
423 /// Constructs lsp range using a marked string with '[', ']' range delimiters
424 #[track_caller]
425 pub fn lsp_range(&mut self, marked_text: &str) -> lsp::Range {
426 let ranges = self.ranges(marked_text);
427 self.to_lsp_range(MultiBufferOffset(ranges[0].start)..MultiBufferOffset(ranges[0].end))
428 }
429
430 #[expect(clippy::wrong_self_convention, reason = "This is test code")]
431 pub fn to_lsp_range(&mut self, range: Range<MultiBufferOffset>) -> lsp::Range {
432 use language::ToPointUtf16;
433 let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
434 let start_point = range.start.to_point(&snapshot.buffer_snapshot());
435 let end_point = range.end.to_point(&snapshot.buffer_snapshot());
436
437 self.editor(|editor, _, cx| {
438 let buffer = editor.buffer().read(cx);
439 let (start_buffer, start_offset) =
440 buffer.point_to_buffer_offset(start_point, cx).unwrap();
441 let start = point_to_lsp(start_offset.to_point_utf16(&start_buffer.read(cx)));
442 let (end_buffer, end_offset) = buffer.point_to_buffer_offset(end_point, cx).unwrap();
443 let end = point_to_lsp(end_offset.to_point_utf16(&end_buffer.read(cx)));
444 lsp::Range { start, end }
445 })
446 }
447
448 #[expect(clippy::wrong_self_convention, reason = "This is test code")]
449 pub fn to_lsp(&mut self, offset: MultiBufferOffset) -> lsp::Position {
450 use language::ToPointUtf16;
451
452 let snapshot = self.update_editor(|editor, window, cx| editor.snapshot(window, cx));
453 let point = offset.to_point(&snapshot.buffer_snapshot());
454
455 self.editor(|editor, _, cx| {
456 let buffer = editor.buffer().read(cx);
457 let (buffer, offset) = buffer.point_to_buffer_offset(point, cx).unwrap();
458 point_to_lsp(offset.to_point_utf16(&buffer.read(cx)))
459 })
460 }
461
462 pub fn update_workspace<F, T>(&mut self, update: F) -> T
463 where
464 F: FnOnce(&mut Workspace, &mut Window, &mut Context<Workspace>) -> T,
465 {
466 self.workspace.update_in(&mut self.cx.cx, update)
467 }
468
469 pub fn set_request_handler<T, F, Fut>(
470 &self,
471 mut handler: F,
472 ) -> futures::channel::mpsc::UnboundedReceiver<()>
473 where
474 T: 'static + request::Request,
475 T::Params: 'static + Send,
476 F: 'static + Send + FnMut(lsp::Uri, T::Params, gpui::AsyncApp) -> Fut,
477 Fut: 'static + Future<Output = Result<T::Result>>,
478 {
479 let url = self.buffer_lsp_url.clone();
480 self.lsp.set_request_handler::<T, _, _>(move |params, cx| {
481 let url = url.clone();
482 handler(url, params, cx)
483 })
484 }
485
486 pub fn notify<T: notification::Notification>(&self, params: T::Params) {
487 self.lsp.notify::<T>(params);
488 }
489
490 #[cfg(target_os = "windows")]
491 fn root_path() -> &'static Path {
492 Path::new("C:\\root")
493 }
494
495 #[cfg(not(target_os = "windows"))]
496 fn root_path() -> &'static Path {
497 Path::new("/root")
498 }
499}
500
501impl Deref for EditorLspTestContext {
502 type Target = EditorTestContext;
503
504 fn deref(&self) -> &Self::Target {
505 &self.cx
506 }
507}
508
509impl DerefMut for EditorLspTestContext {
510 fn deref_mut(&mut self) -> &mut Self::Target {
511 &mut self.cx
512 }
513}