neovim_backed_test_context.rs

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