1#![allow(unused)]
2// todo!()
3
4use editor::{scroll::VERTICAL_SCROLL_MARGIN, test::editor_test_context::ContextHandle};
5use gpui::{point, px, rems, size, Context};
6use indoc::indoc;
7use settings::SettingsStore;
8use std::{
9 ops::{Deref, DerefMut},
10 panic, thread,
11};
12
13use collections::{HashMap, HashSet};
14use language::language_settings::{AllLanguageSettings, SoftWrap};
15use util::test::marked_text_offsets;
16
17use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
18use crate::state::Mode;
19
20pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[];
21
22/// Enum representing features we have tests for but which don't work, yet. Used
23/// to add exemptions and automatically
24#[derive(PartialEq, Eq)]
25pub enum ExemptionFeatures {
26 // MOTIONS
27 // When an operator completes at the end of the file, an extra newline is left
28 OperatorLastNewlineRemains,
29
30 // OBJECTS
31 // Resulting position after the operation is slightly incorrect for unintuitive reasons.
32 IncorrectLandingPosition,
33 // Operator around the text object at the end of the line doesn't remove whitespace.
34 AroundObjectLeavesWhitespaceAtEndOfLine,
35 // Sentence object on empty lines
36 SentenceOnEmptyLines,
37 // Whitespace isn't included with text objects at the start of the line
38 SentenceAtStartOfLineWithWhitespace,
39 // Whitespace around sentences is slightly incorrect when starting between sentences
40 AroundSentenceStartingBetweenIncludesWrongWhitespace,
41 // Non empty selection with text objects in visual mode
42 NonEmptyVisualTextObjects,
43 // Sentence Doesn't backtrack when its at the end of the file
44 SentenceAfterPunctuationAtEndOfFile,
45}
46
47impl ExemptionFeatures {
48 pub fn supported(&self) -> bool {
49 SUPPORTED_FEATURES.contains(self)
50 }
51}
52
53pub struct NeovimBackedTestContext<'a> {
54 cx: VimTestContext<'a>,
55 // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
56 // bindings are exempted. If None, all bindings are ignored for that insertion text.
57 exemptions: HashMap<String, Option<HashSet<String>>>,
58 neovim: NeovimConnection,
59
60 last_set_state: Option<String>,
61 recent_keystrokes: Vec<String>,
62
63 is_dirty: bool,
64}
65
66impl<'a> NeovimBackedTestContext<'a> {
67 pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
68 // rust stores the name of the test on the current thread.
69 // We use this to automatically name a file that will store
70 // the neovim connection's requests/responses so that we can
71 // run without neovim on CI.
72 let thread = thread::current();
73 let test_name = thread
74 .name()
75 .expect("thread is not named")
76 .split(":")
77 .last()
78 .unwrap()
79 .to_string();
80 Self {
81 cx: VimTestContext::new(cx, true).await,
82 exemptions: Default::default(),
83 neovim: NeovimConnection::new(test_name).await,
84
85 last_set_state: None,
86 recent_keystrokes: Default::default(),
87 is_dirty: false,
88 }
89 }
90
91 pub fn add_initial_state_exemptions(
92 &mut self,
93 marked_positions: &str,
94 missing_feature: ExemptionFeatures, // Feature required to support this exempted test case
95 ) {
96 if !missing_feature.supported() {
97 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
98
99 for cursor_offset in cursor_offsets.iter() {
100 let mut marked_text = unmarked_text.clone();
101 marked_text.insert(*cursor_offset, 'ˇ');
102
103 // None represents all key bindings being exempted for that initial state
104 self.exemptions.insert(marked_text, None);
105 }
106 }
107 }
108
109 pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
110 self.neovim.send_keystroke(keystroke_text).await;
111 self.simulate_keystroke(keystroke_text)
112 }
113
114 pub async fn simulate_shared_keystrokes<const COUNT: usize>(
115 &mut self,
116 keystroke_texts: [&str; COUNT],
117 ) {
118 for keystroke_text in keystroke_texts.into_iter() {
119 self.recent_keystrokes.push(keystroke_text.to_string());
120 self.neovim.send_keystroke(keystroke_text).await;
121 }
122 self.simulate_keystrokes(keystroke_texts);
123 }
124
125 pub async fn set_shared_state(&mut self, marked_text: &str) {
126 let mode = if marked_text.contains("»") {
127 Mode::Visual
128 } else {
129 Mode::Normal
130 };
131 self.set_state(marked_text, mode);
132 self.last_set_state = Some(marked_text.to_string());
133 self.recent_keystrokes = Vec::new();
134 self.neovim.set_state(marked_text).await;
135 self.is_dirty = true;
136 }
137
138 pub async fn set_shared_wrap(&mut self, columns: u32) {
139 if columns < 12 {
140 panic!("nvim doesn't support columns < 12")
141 }
142 self.neovim.set_option("wrap").await;
143 self.neovim
144 .set_option(&format!("columns={}", columns))
145 .await;
146
147 self.update(|cx| {
148 cx.update_global(|settings: &mut SettingsStore, cx| {
149 settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
150 settings.defaults.soft_wrap = Some(SoftWrap::PreferredLineLength);
151 settings.defaults.preferred_line_length = Some(columns);
152 });
153 })
154 })
155 }
156
157 pub async fn set_scroll_height(&mut self, rows: u32) {
158 // match Zed's scrolling behavior
159 self.neovim
160 .set_option(&format!("scrolloff={}", VERTICAL_SCROLL_MARGIN))
161 .await;
162 // +2 to account for the vim command UI at the bottom.
163 self.neovim.set_option(&format!("lines={}", rows + 2)).await;
164 let (line_height, visible_line_count) = self.editor(|editor, cx| {
165 (
166 editor
167 .style()
168 .unwrap()
169 .text
170 .line_height_in_pixels(cx.rem_size()),
171 editor.visible_line_count().unwrap(),
172 )
173 });
174
175 let window = self.window;
176 let margin = self
177 .update_window(window, |_, cx| {
178 cx.viewport_size().height - line_height * visible_line_count
179 })
180 .unwrap();
181
182 self.simulate_window_resize(
183 self.window,
184 size(px(1000.), margin + (rows as f32) * line_height),
185 );
186 }
187
188 pub async fn set_neovim_option(&mut self, option: &str) {
189 self.neovim.set_option(option).await;
190 }
191
192 pub async fn assert_shared_state(&mut self, marked_text: &str) {
193 self.is_dirty = false;
194 let marked_text = marked_text.replace("•", " ");
195 let neovim = self.neovim_state().await;
196 let editor = self.editor_state();
197 if neovim == marked_text && neovim == editor {
198 return;
199 }
200 let initial_state = self
201 .last_set_state
202 .as_ref()
203 .unwrap_or(&"N/A".to_string())
204 .clone();
205
206 let message = if neovim != marked_text {
207 "Test is incorrect (currently expected != neovim_state)"
208 } else {
209 "Editor does not match nvim behaviour"
210 };
211 panic!(
212 indoc! {"{}
213 # initial state:
214 {}
215 # keystrokes:
216 {}
217 # currently expected:
218 {}
219 # neovim state:
220 {}
221 # zed state:
222 {}"},
223 message,
224 initial_state,
225 self.recent_keystrokes.join(" "),
226 marked_text.replace(" \n", "•\n"),
227 neovim.replace(" \n", "•\n"),
228 editor.replace(" \n", "•\n")
229 )
230 }
231
232 pub async fn assert_shared_clipboard(&mut self, text: &str) {
233 let neovim = self.neovim.read_register('"').await;
234 let editor = self.read_from_clipboard().unwrap().text().clone();
235
236 if text == neovim && text == editor {
237 return;
238 }
239
240 let message = if neovim != text {
241 "Test is incorrect (currently expected != neovim)"
242 } else {
243 "Editor does not match nvim behaviour"
244 };
245
246 let initial_state = self
247 .last_set_state
248 .as_ref()
249 .unwrap_or(&"N/A".to_string())
250 .clone();
251
252 panic!(
253 indoc! {"{}
254 # initial state:
255 {}
256 # keystrokes:
257 {}
258 # currently expected:
259 {}
260 # neovim clipboard:
261 {}
262 # zed clipboard:
263 {}"},
264 message,
265 initial_state,
266 self.recent_keystrokes.join(" "),
267 text,
268 neovim,
269 editor
270 )
271 }
272
273 pub async fn neovim_state(&mut self) -> String {
274 self.neovim.marked_text().await
275 }
276
277 pub async fn neovim_mode(&mut self) -> Mode {
278 self.neovim.mode().await.unwrap()
279 }
280
281 pub async fn assert_state_matches(&mut self) {
282 self.is_dirty = false;
283 let neovim = self.neovim_state().await;
284 let editor = self.editor_state();
285 let initial_state = self
286 .last_set_state
287 .as_ref()
288 .unwrap_or(&"N/A".to_string())
289 .clone();
290
291 if neovim != editor {
292 panic!(
293 indoc! {"Test failed (zed does not match nvim behaviour)
294 # initial state:
295 {}
296 # keystrokes:
297 {}
298 # neovim state:
299 {}
300 # zed state:
301 {}"},
302 initial_state,
303 self.recent_keystrokes.join(" "),
304 neovim,
305 editor,
306 )
307 }
308 }
309
310 pub async fn assert_binding_matches<const COUNT: usize>(
311 &mut self,
312 keystrokes: [&str; COUNT],
313 initial_state: &str,
314 ) {
315 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
316 match possible_exempted_keystrokes {
317 Some(exempted_keystrokes) => {
318 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
319 // This keystroke was exempted for this insertion text
320 return;
321 }
322 }
323 None => {
324 // All keystrokes for this insertion text are exempted
325 return;
326 }
327 }
328 }
329
330 let _state_context = self.set_shared_state(initial_state).await;
331 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
332 self.assert_state_matches().await;
333 }
334
335 pub async fn assert_binding_matches_all<const COUNT: usize>(
336 &mut self,
337 keystrokes: [&str; COUNT],
338 marked_positions: &str,
339 ) {
340 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
341
342 for cursor_offset in cursor_offsets.iter() {
343 let mut marked_text = unmarked_text.clone();
344 marked_text.insert(*cursor_offset, 'ˇ');
345
346 self.assert_binding_matches(keystrokes, &marked_text).await;
347 }
348 }
349
350 pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
351 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
352 let mut ret = Vec::with_capacity(cursor_offsets.len());
353
354 for cursor_offset in cursor_offsets.iter() {
355 let mut marked_text = unmarked_text.clone();
356 marked_text.insert(*cursor_offset, 'ˇ');
357 ret.push(marked_text)
358 }
359
360 ret
361 }
362
363 pub async fn assert_neovim_compatible<const COUNT: usize>(
364 &mut self,
365 marked_positions: &str,
366 keystrokes: [&str; COUNT],
367 ) {
368 self.set_shared_state(&marked_positions).await;
369 self.simulate_shared_keystrokes(keystrokes).await;
370 self.assert_state_matches().await;
371 }
372
373 pub async fn assert_matches_neovim<const COUNT: usize>(
374 &mut self,
375 marked_positions: &str,
376 keystrokes: [&str; COUNT],
377 result: &str,
378 ) {
379 self.set_shared_state(marked_positions).await;
380 self.simulate_shared_keystrokes(keystrokes).await;
381 self.assert_shared_state(result).await;
382 }
383
384 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
385 &mut self,
386 keystrokes: [&str; COUNT],
387 marked_positions: &str,
388 feature: ExemptionFeatures,
389 ) {
390 if SUPPORTED_FEATURES.contains(&feature) {
391 self.assert_binding_matches_all(keystrokes, marked_positions)
392 .await
393 }
394 }
395
396 pub fn binding<const COUNT: usize>(
397 self,
398 keystrokes: [&'static str; COUNT],
399 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
400 NeovimBackedBindingTestContext::new(keystrokes, self)
401 }
402}
403
404impl<'a> Deref for NeovimBackedTestContext<'a> {
405 type Target = VimTestContext<'a>;
406
407 fn deref(&self) -> &Self::Target {
408 &self.cx
409 }
410}
411
412impl<'a> DerefMut for NeovimBackedTestContext<'a> {
413 fn deref_mut(&mut self) -> &mut Self::Target {
414 &mut self.cx
415 }
416}
417
418// a common mistake in tests is to call set_shared_state when
419// you mean asswert_shared_state. This notices that and lets
420// you know.
421impl<'a> Drop for NeovimBackedTestContext<'a> {
422 fn drop(&mut self) {
423 if self.is_dirty {
424 panic!("Test context was dropped after set_shared_state before assert_shared_state")
425 }
426 }
427}
428
429#[cfg(test)]
430mod test {
431 use gpui::TestAppContext;
432
433 use crate::test::NeovimBackedTestContext;
434
435 #[gpui::test]
436 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
437 let mut cx = NeovimBackedTestContext::new(cx).await;
438 cx.assert_state_matches().await;
439 cx.set_shared_state("This is a tesˇt").await;
440 cx.assert_state_matches().await;
441 }
442}