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