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