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 neovim = self.neovim_state().await;
157 let editor = self.editor_state();
158 if neovim == marked_text && neovim == editor {
159 return;
160 }
161 let initial_state = self
162 .last_set_state
163 .as_ref()
164 .unwrap_or(&"N/A".to_string())
165 .clone();
166
167 let message = if neovim != marked_text {
168 "Test is incorrect (currently expected != neovim_state)"
169 } else {
170 "Editor does not match nvim behaviour"
171 };
172 panic!(
173 indoc! {"{}
174 # initial state:
175 {}
176 # keystrokes:
177 {}
178 # currently expected:
179 {}
180 # neovim state:
181 {}
182 # zed state:
183 {}"},
184 message,
185 initial_state,
186 self.recent_keystrokes.join(" "),
187 marked_text,
188 neovim,
189 editor
190 )
191 }
192
193 pub async fn assert_shared_clipboard(&mut self, text: &str) {
194 let neovim = self.neovim.read_register('"').await;
195 let editor = self
196 .platform()
197 .read_from_clipboard()
198 .unwrap()
199 .text()
200 .clone();
201
202 if text == neovim && text == editor {
203 return;
204 }
205
206 let message = if neovim != text {
207 "Test is incorrect (currently expected != neovim)"
208 } else {
209 "Editor does not match nvim behaviour"
210 };
211
212 let initial_state = self
213 .last_set_state
214 .as_ref()
215 .unwrap_or(&"N/A".to_string())
216 .clone();
217
218 panic!(
219 indoc! {"{}
220 # initial state:
221 {}
222 # keystrokes:
223 {}
224 # currently expected:
225 {}
226 # neovim clipboard:
227 {}
228 # zed clipboard:
229 {}"},
230 message,
231 initial_state,
232 self.recent_keystrokes.join(" "),
233 text,
234 neovim,
235 editor
236 )
237 }
238
239 pub async fn neovim_state(&mut self) -> String {
240 generate_marked_text(
241 self.neovim.text().await.as_str(),
242 &self.neovim_selections().await[..],
243 true,
244 )
245 }
246
247 pub async fn neovim_mode(&mut self) -> Mode {
248 self.neovim.mode().await.unwrap()
249 }
250
251 async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
252 let neovim_selections = self.neovim.selections().await;
253 neovim_selections
254 .into_iter()
255 .map(|selection| selection.to_offset(&self.buffer_snapshot()))
256 .collect()
257 }
258
259 pub async fn assert_state_matches(&mut self) {
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#[cfg(test)]
386mod test {
387 use gpui::TestAppContext;
388
389 use crate::test::NeovimBackedTestContext;
390
391 #[gpui::test]
392 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
393 let mut cx = NeovimBackedTestContext::new(cx).await;
394 cx.assert_state_matches().await;
395 cx.set_shared_state("This is a tesˇt").await;
396 cx.assert_state_matches().await;
397 }
398}