neovim_backed_test_context.rs

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