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