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