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