neovim_backed_test_context.rs

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