1use indoc::indoc;
2use std::ops::{Deref, DerefMut, Range};
3
4use collections::{HashMap, HashSet};
5use gpui::ContextHandle;
6use language::OffsetRangeExt;
7use util::test::{generate_marked_text, marked_text_offsets};
8
9use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
10use crate::state::Mode;
11
12pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[
13 ExemptionFeatures::DeletionOnEmptyLine,
14 ExemptionFeatures::OperatorAbortsOnFailedMotion,
15];
16
17/// Enum representing features we have tests for but which don't work, yet. Used
18/// to add exemptions and automatically
19#[derive(PartialEq, Eq)]
20pub enum ExemptionFeatures {
21 // MOTIONS
22 // Deletions on empty lines miss some newlines
23 DeletionOnEmptyLine,
24 // When a motion fails, it should should not apply linewise operations
25 OperatorAbortsOnFailedMotion,
26 // When an operator completes at the end of the file, an extra newline is left
27 OperatorLastNewlineRemains,
28 // Deleting a word on an empty line doesn't remove the newline
29 DeleteWordOnEmptyLine,
30
31 // OBJECTS
32 // Resulting position after the operation is slightly incorrect for unintuitive reasons.
33 IncorrectLandingPosition,
34 // Operator around the text object at the end of the line doesn't remove whitespace.
35 AroundObjectLeavesWhitespaceAtEndOfLine,
36 // Sentence object on empty lines
37 SentenceOnEmptyLines,
38 // Whitespace isn't included with text objects at the start of the line
39 SentenceAtStartOfLineWithWhitespace,
40 // Whitespace around sentences is slightly incorrect when starting between sentences
41 AroundSentenceStartingBetweenIncludesWrongWhitespace,
42 // Non empty selection with text objects in visual mode
43 NonEmptyVisualTextObjects,
44 // Quote style surrounding text objects don't seek forward properly
45 QuotesSeekForward,
46 // Neovim freezes up for some reason with angle brackets
47 AngleBracketsFreezeNeovim,
48 // Sentence Doesn't backtrack when its at the end of the file
49 SentenceAfterPunctuationAtEndOfFile,
50}
51
52impl ExemptionFeatures {
53 pub fn supported(&self) -> bool {
54 SUPPORTED_FEATURES.contains(self)
55 }
56}
57
58pub struct NeovimBackedTestContext<'a> {
59 cx: VimTestContext<'a>,
60 // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
61 // bindings are exempted. If None, all bindings are ignored for that insertion text.
62 exemptions: HashMap<String, Option<HashSet<String>>>,
63 neovim: NeovimConnection,
64
65 last_set_state: Option<String>,
66 recent_keystrokes: Vec<String>,
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 }
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 context_handle
128 }
129
130 pub async fn assert_shared_state(&mut self, marked_text: &str) {
131 let neovim = self.neovim_state().await;
132 let editor = self.editor_state();
133 if neovim == marked_text && neovim == editor {
134 return;
135 }
136 let initial_state = self
137 .last_set_state
138 .as_ref()
139 .unwrap_or(&"N/A".to_string())
140 .clone();
141
142 let message = if neovim != marked_text {
143 "Test is incorrect (currently expected != neovim_state)"
144 } else {
145 "Editor does not match nvim behaviour"
146 };
147 panic!(
148 indoc! {"{}
149 # initial state:
150 {}
151 # keystrokes:
152 {}
153 # currently expected:
154 {}
155 # neovim state:
156 {}
157 # zed state:
158 {}"},
159 message,
160 initial_state,
161 self.recent_keystrokes.join(" "),
162 marked_text,
163 neovim,
164 editor
165 )
166 }
167
168 pub async fn assert_shared_clipboard(&mut self, text: &str) {
169 let neovim = self.neovim.read_register('"').await;
170 let editor = self
171 .platform()
172 .read_from_clipboard()
173 .unwrap()
174 .text()
175 .clone();
176
177 if text == neovim && text == editor {
178 return;
179 }
180
181 let message = if neovim != text {
182 "Test is incorrect (currently expected != neovim)"
183 } else {
184 "Editor does not match nvim behaviour"
185 };
186
187 let initial_state = self
188 .last_set_state
189 .as_ref()
190 .unwrap_or(&"N/A".to_string())
191 .clone();
192
193 panic!(
194 indoc! {"{}
195 # initial state:
196 {}
197 # keystrokes:
198 {}
199 # currently expected:
200 {}
201 # neovim clipboard:
202 {}
203 # zed clipboard:
204 {}"},
205 message,
206 initial_state,
207 self.recent_keystrokes.join(" "),
208 text,
209 neovim,
210 editor
211 )
212 }
213
214 pub async fn neovim_state(&mut self) -> String {
215 generate_marked_text(
216 self.neovim.text().await.as_str(),
217 &self.neovim_selections().await[..],
218 true,
219 )
220 }
221
222 pub async fn neovim_mode(&mut self) -> Mode {
223 self.neovim.mode().await.unwrap()
224 }
225
226 async fn neovim_selections(&mut self) -> Vec<Range<usize>> {
227 let neovim_selections = self.neovim.selections().await;
228 neovim_selections
229 .into_iter()
230 .map(|selection| selection.to_offset(&self.buffer_snapshot()))
231 .collect()
232 }
233
234 pub async fn assert_state_matches(&mut self) {
235 let neovim = self.neovim_state().await;
236 let editor = self.editor_state();
237 let initial_state = self
238 .last_set_state
239 .as_ref()
240 .unwrap_or(&"N/A".to_string())
241 .clone();
242
243 if neovim != editor {
244 panic!(
245 indoc! {"Test failed (zed does not match nvim behaviour)
246 # initial state:
247 {}
248 # keystrokes:
249 {}
250 # neovim state:
251 {}
252 # zed state:
253 {}"},
254 initial_state,
255 self.recent_keystrokes.join(" "),
256 neovim,
257 editor,
258 )
259 }
260 }
261
262 pub async fn assert_binding_matches<const COUNT: usize>(
263 &mut self,
264 keystrokes: [&str; COUNT],
265 initial_state: &str,
266 ) -> Option<(ContextHandle, ContextHandle)> {
267 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
268 match possible_exempted_keystrokes {
269 Some(exempted_keystrokes) => {
270 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
271 // This keystroke was exempted for this insertion text
272 return None;
273 }
274 }
275 None => {
276 // All keystrokes for this insertion text are exempted
277 return None;
278 }
279 }
280 }
281
282 let _state_context = self.set_shared_state(initial_state).await;
283 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
284 self.assert_state_matches().await;
285 Some((_state_context, _keystroke_context))
286 }
287
288 pub async fn assert_binding_matches_all<const COUNT: usize>(
289 &mut self,
290 keystrokes: [&str; COUNT],
291 marked_positions: &str,
292 ) {
293 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
294
295 for cursor_offset in cursor_offsets.iter() {
296 let mut marked_text = unmarked_text.clone();
297 marked_text.insert(*cursor_offset, 'ˇ');
298
299 self.assert_binding_matches(keystrokes, &marked_text).await;
300 }
301 }
302
303 pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
304 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
305 let mut ret = Vec::with_capacity(cursor_offsets.len());
306
307 for cursor_offset in cursor_offsets.iter() {
308 let mut marked_text = unmarked_text.clone();
309 marked_text.insert(*cursor_offset, 'ˇ');
310 ret.push(marked_text)
311 }
312
313 ret
314 }
315
316 pub async fn assert_neovim_compatible<const COUNT: usize>(
317 &mut self,
318 marked_positions: &str,
319 keystrokes: [&str; COUNT],
320 ) {
321 self.set_shared_state(&marked_positions).await;
322 self.simulate_shared_keystrokes(keystrokes).await;
323 self.assert_state_matches().await;
324 }
325
326 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
327 &mut self,
328 keystrokes: [&str; COUNT],
329 marked_positions: &str,
330 feature: ExemptionFeatures,
331 ) {
332 if SUPPORTED_FEATURES.contains(&feature) {
333 self.assert_binding_matches_all(keystrokes, marked_positions)
334 .await
335 }
336 }
337
338 pub fn binding<const COUNT: usize>(
339 self,
340 keystrokes: [&'static str; COUNT],
341 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
342 NeovimBackedBindingTestContext::new(keystrokes, self)
343 }
344}
345
346impl<'a> Deref for NeovimBackedTestContext<'a> {
347 type Target = VimTestContext<'a>;
348
349 fn deref(&self) -> &Self::Target {
350 &self.cx
351 }
352}
353
354impl<'a> DerefMut for NeovimBackedTestContext<'a> {
355 fn deref_mut(&mut self) -> &mut Self::Target {
356 &mut self.cx
357 }
358}
359
360#[cfg(test)]
361mod test {
362 use gpui::TestAppContext;
363
364 use crate::test::NeovimBackedTestContext;
365
366 #[gpui::test]
367 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
368 let mut cx = NeovimBackedTestContext::new(cx).await;
369 cx.assert_state_matches().await;
370 cx.set_shared_state("This is a tesˇt").await;
371 cx.assert_state_matches().await;
372 }
373}