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 ) {
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) {
118 let mode = if marked_text.contains("»") {
119 Mode::Visual
120 } else {
121 Mode::Normal
122 };
123 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 }
129
130 pub async fn set_shared_wrap(&mut self, columns: u32) {
131 if columns < 12 {
132 panic!("nvim doesn't support columns < 12")
133 }
134 self.neovim.set_option("wrap").await;
135 self.neovim.set_option("columns=12").await;
136
137 self.update(|cx| {
138 cx.update_global(|settings: &mut SettingsStore, cx| {
139 settings.update_user_settings::<AllLanguageSettings>(cx, |settings| {
140 settings.defaults.soft_wrap = Some(SoftWrap::PreferredLineLength);
141 settings.defaults.preferred_line_length = Some(columns);
142 });
143 })
144 })
145 }
146
147 pub async fn set_neovim_option(&mut self, option: &str) {
148 self.neovim.set_option(option).await;
149 }
150
151 pub async fn assert_shared_state(&mut self, marked_text: &str) {
152 self.is_dirty = false;
153 let marked_text = marked_text.replace("•", " ");
154 let neovim = self.neovim_state().await;
155 let editor = self.editor_state();
156 if neovim == marked_text && neovim == editor {
157 return;
158 }
159 let initial_state = self
160 .last_set_state
161 .as_ref()
162 .unwrap_or(&"N/A".to_string())
163 .clone();
164
165 let message = if neovim != marked_text {
166 "Test is incorrect (currently expected != neovim_state)"
167 } else {
168 "Editor does not match nvim behaviour"
169 };
170 panic!(
171 indoc! {"{}
172 # initial state:
173 {}
174 # keystrokes:
175 {}
176 # currently expected:
177 {}
178 # neovim state:
179 {}
180 # zed state:
181 {}"},
182 message,
183 initial_state,
184 self.recent_keystrokes.join(" "),
185 marked_text.replace(" \n", "•\n"),
186 neovim.replace(" \n", "•\n"),
187 editor.replace(" \n", "•\n")
188 )
189 }
190
191 pub async fn assert_shared_clipboard(&mut self, text: &str) {
192 let neovim = self.neovim.read_register('"').await;
193 let editor = self
194 .platform()
195 .read_from_clipboard()
196 .unwrap()
197 .text()
198 .clone();
199
200 if text == neovim && text == editor {
201 return;
202 }
203
204 let message = if neovim != text {
205 "Test is incorrect (currently expected != neovim)"
206 } else {
207 "Editor does not match nvim behaviour"
208 };
209
210 let initial_state = self
211 .last_set_state
212 .as_ref()
213 .unwrap_or(&"N/A".to_string())
214 .clone();
215
216 panic!(
217 indoc! {"{}
218 # initial state:
219 {}
220 # keystrokes:
221 {}
222 # currently expected:
223 {}
224 # neovim clipboard:
225 {}
226 # zed clipboard:
227 {}"},
228 message,
229 initial_state,
230 self.recent_keystrokes.join(" "),
231 text,
232 neovim,
233 editor
234 )
235 }
236
237 pub async fn neovim_state(&mut self) -> String {
238 generate_marked_text(
239 self.neovim.text().await.as_str(),
240 &self.neovim_selections().await[..],
241 true,
242 )
243 }
244
245 pub async fn neovim_mode(&mut self) -> Mode {
246 self.neovim.mode().await.unwrap()
247 }
248
249 async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
250 let neovim_selections = self.neovim.selections().await;
251 neovim_selections
252 .into_iter()
253 .map(|selection| selection.to_offset(&self.buffer_snapshot()))
254 .collect()
255 }
256
257 pub async fn assert_state_matches(&mut self) {
258 self.is_dirty = false;
259 let neovim = self.neovim_state().await;
260 let editor = self.editor_state();
261 let initial_state = self
262 .last_set_state
263 .as_ref()
264 .unwrap_or(&"N/A".to_string())
265 .clone();
266
267 if neovim != editor {
268 panic!(
269 indoc! {"Test failed (zed does not match nvim behaviour)
270 # initial state:
271 {}
272 # keystrokes:
273 {}
274 # neovim state:
275 {}
276 # zed state:
277 {}"},
278 initial_state,
279 self.recent_keystrokes.join(" "),
280 neovim,
281 editor,
282 )
283 }
284 }
285
286 pub async fn assert_binding_matches<const COUNT: usize>(
287 &mut self,
288 keystrokes: [&str; COUNT],
289 initial_state: &str,
290 ) {
291 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
292 match possible_exempted_keystrokes {
293 Some(exempted_keystrokes) => {
294 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
295 // This keystroke was exempted for this insertion text
296 return;
297 }
298 }
299 None => {
300 // All keystrokes for this insertion text are exempted
301 return;
302 }
303 }
304 }
305
306 let _state_context = self.set_shared_state(initial_state).await;
307 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
308 self.assert_state_matches().await;
309 }
310
311 pub async fn assert_binding_matches_all<const COUNT: usize>(
312 &mut self,
313 keystrokes: [&str; COUNT],
314 marked_positions: &str,
315 ) {
316 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
317
318 for cursor_offset in cursor_offsets.iter() {
319 let mut marked_text = unmarked_text.clone();
320 marked_text.insert(*cursor_offset, 'ˇ');
321
322 self.assert_binding_matches(keystrokes, &marked_text).await;
323 }
324 }
325
326 pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
327 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
328 let mut ret = Vec::with_capacity(cursor_offsets.len());
329
330 for cursor_offset in cursor_offsets.iter() {
331 let mut marked_text = unmarked_text.clone();
332 marked_text.insert(*cursor_offset, 'ˇ');
333 ret.push(marked_text)
334 }
335
336 ret
337 }
338
339 pub async fn assert_neovim_compatible<const COUNT: usize>(
340 &mut self,
341 marked_positions: &str,
342 keystrokes: [&str; COUNT],
343 ) {
344 self.set_shared_state(&marked_positions).await;
345 self.simulate_shared_keystrokes(keystrokes).await;
346 self.assert_state_matches().await;
347 }
348
349 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
350 &mut self,
351 keystrokes: [&str; COUNT],
352 marked_positions: &str,
353 feature: ExemptionFeatures,
354 ) {
355 if SUPPORTED_FEATURES.contains(&feature) {
356 self.assert_binding_matches_all(keystrokes, marked_positions)
357 .await
358 }
359 }
360
361 pub fn binding<const COUNT: usize>(
362 self,
363 keystrokes: [&'static str; COUNT],
364 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
365 NeovimBackedBindingTestContext::new(keystrokes, self)
366 }
367}
368
369impl<'a> Deref for NeovimBackedTestContext<'a> {
370 type Target = VimTestContext<'a>;
371
372 fn deref(&self) -> &Self::Target {
373 &self.cx
374 }
375}
376
377impl<'a> DerefMut for NeovimBackedTestContext<'a> {
378 fn deref_mut(&mut self) -> &mut Self::Target {
379 &mut self.cx
380 }
381}
382
383// a common mistake in tests is to call set_shared_state when
384// you mean asswert_shared_state. This notices that and lets
385// you know.
386impl<'a> Drop for NeovimBackedTestContext<'a> {
387 fn drop(&mut self) {
388 if self.is_dirty {
389 panic!("Test context was dropped after set_shared_state before assert_shared_state")
390 }
391 }
392}
393
394#[cfg(test)]
395mod test {
396 use gpui::TestAppContext;
397
398 use crate::test::NeovimBackedTestContext;
399
400 #[gpui::test]
401 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
402 let mut cx = NeovimBackedTestContext::new(cx).await;
403 cx.assert_state_matches().await;
404 cx.set_shared_state("This is a tesˇt").await;
405 cx.assert_state_matches().await;
406 }
407}