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 { line: false }
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 if neovim != marked_text {
133 let initial_state = self
134 .last_set_state
135 .as_ref()
136 .unwrap_or(&"N/A".to_string())
137 .clone();
138 panic!(
139 indoc! {"Test is incorrect (currently expected != neovim state)
140 # initial state:
141 {}
142 # keystrokes:
143 {}
144 # currently expected:
145 {}
146 # neovim state:
147 {}
148 # zed state:
149 {}"},
150 initial_state,
151 self.recent_keystrokes.join(" "),
152 marked_text,
153 neovim,
154 self.editor_state(),
155 )
156 }
157 self.assert_editor_state(marked_text)
158 }
159
160 pub async fn neovim_state(&mut self) -> String {
161 generate_marked_text(
162 self.neovim.text().await.as_str(),
163 &vec![self.neovim_selection().await],
164 true,
165 )
166 }
167
168 pub async fn neovim_mode(&mut self) -> Mode {
169 self.neovim.mode().await.unwrap()
170 }
171
172 async fn neovim_selection(&mut self) -> Range<usize> {
173 let neovim_selection = self.neovim.selection().await;
174 neovim_selection.to_offset(&self.buffer_snapshot())
175 }
176
177 pub async fn assert_state_matches(&mut self) {
178 let neovim = self.neovim_state().await;
179 let editor = self.editor_state();
180 let initial_state = self
181 .last_set_state
182 .as_ref()
183 .unwrap_or(&"N/A".to_string())
184 .clone();
185
186 if neovim != editor {
187 panic!(
188 indoc! {"Test failed (zed does not match nvim behaviour)
189 # initial state:
190 {}
191 # keystrokes:
192 {}
193 # neovim state:
194 {}
195 # zed state:
196 {}"},
197 initial_state,
198 self.recent_keystrokes.join(" "),
199 neovim,
200 editor,
201 )
202 }
203 }
204
205 pub async fn assert_binding_matches<const COUNT: usize>(
206 &mut self,
207 keystrokes: [&str; COUNT],
208 initial_state: &str,
209 ) -> Option<(ContextHandle, ContextHandle)> {
210 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
211 match possible_exempted_keystrokes {
212 Some(exempted_keystrokes) => {
213 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
214 // This keystroke was exempted for this insertion text
215 return None;
216 }
217 }
218 None => {
219 // All keystrokes for this insertion text are exempted
220 return None;
221 }
222 }
223 }
224
225 let _state_context = self.set_shared_state(initial_state).await;
226 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
227 self.assert_state_matches().await;
228 Some((_state_context, _keystroke_context))
229 }
230
231 pub async fn assert_binding_matches_all<const COUNT: usize>(
232 &mut self,
233 keystrokes: [&str; COUNT],
234 marked_positions: &str,
235 ) {
236 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
237
238 for cursor_offset in cursor_offsets.iter() {
239 let mut marked_text = unmarked_text.clone();
240 marked_text.insert(*cursor_offset, 'ˇ');
241
242 self.assert_binding_matches(keystrokes, &marked_text).await;
243 }
244 }
245
246 pub fn each_marked_position(&self, marked_positions: &str) -> Vec<String> {
247 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
248 let mut ret = Vec::with_capacity(cursor_offsets.len());
249
250 for cursor_offset in cursor_offsets.iter() {
251 let mut marked_text = unmarked_text.clone();
252 marked_text.insert(*cursor_offset, 'ˇ');
253 ret.push(marked_text)
254 }
255
256 ret
257 }
258
259 pub async fn assert_neovim_compatible<const COUNT: usize>(
260 &mut self,
261 marked_positions: &str,
262 keystrokes: [&str; COUNT],
263 ) {
264 self.set_shared_state(&marked_positions).await;
265 self.simulate_shared_keystrokes(keystrokes).await;
266 self.assert_state_matches().await;
267 }
268
269 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
270 &mut self,
271 keystrokes: [&str; COUNT],
272 marked_positions: &str,
273 feature: ExemptionFeatures,
274 ) {
275 if SUPPORTED_FEATURES.contains(&feature) {
276 self.assert_binding_matches_all(keystrokes, marked_positions)
277 .await
278 }
279 }
280
281 pub fn binding<const COUNT: usize>(
282 self,
283 keystrokes: [&'static str; COUNT],
284 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
285 NeovimBackedBindingTestContext::new(keystrokes, self)
286 }
287}
288
289impl<'a> Deref for NeovimBackedTestContext<'a> {
290 type Target = VimTestContext<'a>;
291
292 fn deref(&self) -> &Self::Target {
293 &self.cx
294 }
295}
296
297impl<'a> DerefMut for NeovimBackedTestContext<'a> {
298 fn deref_mut(&mut self) -> &mut Self::Target {
299 &mut self.cx
300 }
301}
302
303#[cfg(test)]
304mod test {
305 use gpui::TestAppContext;
306
307 use crate::test::NeovimBackedTestContext;
308
309 #[gpui::test]
310 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
311 let mut cx = NeovimBackedTestContext::new(cx).await;
312 cx.assert_state_matches().await;
313 cx.set_shared_state("This is a tesˇt").await;
314 cx.assert_state_matches().await;
315 }
316}