neovim_backed_test_context.rs

  1use gpui::{AppContext as _, UpdateGlobal, px, size};
  2use indoc::indoc;
  3use settings::SettingsStore;
  4use std::{
  5    ops::{Deref, DerefMut},
  6    panic, thread,
  7};
  8
  9use language::language_settings::SoftWrap;
 10use util::test::marked_text_offsets;
 11
 12use super::{VimTestContext, neovim_connection::NeovimConnection};
 13use crate::state::{Mode, VimGlobals};
 14
 15pub struct NeovimBackedTestContext {
 16    pub(crate) cx: VimTestContext,
 17    pub(crate) neovim: NeovimConnection,
 18
 19    last_set_state: Option<String>,
 20    recent_keystrokes: Vec<String>,
 21}
 22
 23#[derive(Default)]
 24pub struct SharedState {
 25    neovim: String,
 26    editor: String,
 27    initial: String,
 28    neovim_mode: Mode,
 29    editor_mode: Mode,
 30    recent_keystrokes: String,
 31}
 32
 33impl SharedState {
 34    /// Assert that both Zed and NeoVim have the same content and mode.
 35    #[track_caller]
 36    pub fn assert_matches(&self) {
 37        if self.neovim != self.editor || self.neovim_mode != self.editor_mode {
 38            panic!(
 39                indoc! {"Test failed (zed does not match nvim behavior)
 40                    # initial state:
 41                    {}
 42                    # keystrokes:
 43                    {}
 44                    # neovim ({}):
 45                    {}
 46                    # zed ({}):
 47                    {}"},
 48                self.initial,
 49                self.recent_keystrokes,
 50                self.neovim_mode,
 51                self.neovim,
 52                self.editor_mode,
 53                self.editor,
 54            )
 55        }
 56    }
 57
 58    #[track_caller]
 59    pub fn assert_eq(&mut self, marked_text: &str) {
 60        let marked_text = marked_text.replace('•', " ");
 61        if self.neovim == marked_text
 62            && self.neovim == self.editor
 63            && self.neovim_mode == self.editor_mode
 64        {
 65            return;
 66        }
 67
 68        let message = if self.neovim != marked_text {
 69            "Test is incorrect (currently expected != neovim_state)"
 70        } else {
 71            "Editor does not match nvim behavior"
 72        };
 73        panic!(
 74            indoc! {"{}
 75                # initial state:
 76                {}
 77                # keystrokes:
 78                {}
 79                # currently expected:
 80                {}
 81                # neovim ({}):
 82                {}
 83                # zed ({}):
 84                {}"},
 85            message,
 86            self.initial,
 87            self.recent_keystrokes,
 88            marked_text.replace(" \n", "\n"),
 89            self.neovim_mode,
 90            self.neovim.replace(" \n", "\n"),
 91            self.editor_mode,
 92            self.editor.replace(" \n", "\n"),
 93        )
 94    }
 95}
 96
 97pub struct SharedClipboard {
 98    register: char,
 99    neovim: String,
100    editor: String,
101    state: SharedState,
102}
103
104impl SharedClipboard {
105    #[track_caller]
106    pub fn assert_eq(&self, expected: &str) {
107        if expected == self.neovim && self.neovim == self.editor {
108            return;
109        }
110
111        let message = if expected != self.neovim {
112            "Test is incorrect (currently expected != neovim_state)"
113        } else {
114            "Editor does not match nvim behavior"
115        };
116
117        panic!(
118            indoc! {"{}
119                # initial state:
120                {}
121                # keystrokes:
122                {}
123                # currently expected: {:?}
124                # neovim register \"{}: {:?}
125                # zed register \"{}: {:?}"},
126            message,
127            self.state.initial,
128            self.state.recent_keystrokes,
129            expected,
130            self.register,
131            self.neovim,
132            self.register,
133            self.editor
134        )
135    }
136}
137
138impl NeovimBackedTestContext {
139    pub async fn new(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
140        #[cfg(feature = "neovim")]
141        cx.executor().allow_parking();
142        // rust stores the name of the test on the current thread.
143        // We use this to automatically name a file that will store
144        // the neovim connection's requests/responses so that we can
145        // run without neovim on CI.
146        let thread = thread::current();
147        let test_name = thread
148            .name()
149            .expect("thread is not named")
150            .split(':')
151            .next_back()
152            .unwrap()
153            .to_string();
154        Self {
155            cx: VimTestContext::new(cx, true).await,
156            neovim: NeovimConnection::new(test_name).await,
157
158            last_set_state: None,
159            recent_keystrokes: Default::default(),
160        }
161    }
162
163    pub async fn new_html(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
164        #[cfg(feature = "neovim")]
165        cx.executor().allow_parking();
166        // rust stores the name of the test on the current thread.
167        // We use this to automatically name a file that will store
168        // the neovim connection's requests/responses so that we can
169        // run without neovim on CI.
170        let thread = thread::current();
171        let test_name = thread
172            .name()
173            .expect("thread is not named")
174            .split(':')
175            .next_back()
176            .unwrap()
177            .to_string();
178        Self {
179            cx: VimTestContext::new_html(cx).await,
180            neovim: NeovimConnection::new(test_name).await,
181
182            last_set_state: None,
183            recent_keystrokes: Default::default(),
184        }
185    }
186
187    pub async fn new_markdown_with_rust(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
188        #[cfg(feature = "neovim")]
189        cx.executor().allow_parking();
190        let thread = thread::current();
191        let test_name = thread
192            .name()
193            .expect("thread is not named")
194            .split(':')
195            .next_back()
196            .unwrap()
197            .to_string();
198        Self {
199            cx: VimTestContext::new_markdown_with_rust(cx).await,
200            neovim: NeovimConnection::new(test_name).await,
201
202            last_set_state: None,
203            recent_keystrokes: Default::default(),
204        }
205    }
206
207    pub async fn new_typescript(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
208        #[cfg(feature = "neovim")]
209        cx.executor().allow_parking();
210        // rust stores the name of the test on the current thread.
211        // We use this to automatically name a file that will store
212        // the neovim connection's requests/responses so that we can
213        // run without neovim on CI.
214        let thread = thread::current();
215        let test_name = thread
216            .name()
217            .expect("thread is not named")
218            .split(':')
219            .next_back()
220            .unwrap()
221            .to_string();
222        Self {
223            cx: VimTestContext::new_typescript(cx).await,
224            neovim: NeovimConnection::new(test_name).await,
225
226            last_set_state: None,
227            recent_keystrokes: Default::default(),
228        }
229    }
230
231    pub async fn new_tsx(cx: &mut gpui::TestAppContext) -> NeovimBackedTestContext {
232        #[cfg(feature = "neovim")]
233        cx.executor().allow_parking();
234        let thread = thread::current();
235        let test_name = thread
236            .name()
237            .expect("thread is not named")
238            .split(':')
239            .next_back()
240            .unwrap()
241            .to_string();
242        Self {
243            cx: VimTestContext::new_tsx(cx).await,
244            neovim: NeovimConnection::new(test_name).await,
245
246            last_set_state: None,
247            recent_keystrokes: Default::default(),
248        }
249    }
250
251    pub async fn set_shared_state(&mut self, marked_text: &str) {
252        let mode = if marked_text.contains('»') {
253            Mode::Visual
254        } else {
255            Mode::Normal
256        };
257        self.set_state(marked_text, mode);
258        self.last_set_state = Some(marked_text.to_string());
259        self.recent_keystrokes = Vec::new();
260        self.neovim.set_state(marked_text).await;
261    }
262
263    pub async fn simulate_shared_keystrokes(&mut self, keystroke_texts: &str) {
264        for keystroke_text in keystroke_texts.split(' ') {
265            self.recent_keystrokes.push(keystroke_text.to_string());
266            self.neovim.send_keystroke(keystroke_text).await;
267        }
268        self.simulate_keystrokes(keystroke_texts);
269    }
270
271    #[must_use]
272    pub async fn simulate(&mut self, keystrokes: &str, initial_state: &str) -> SharedState {
273        self.set_shared_state(initial_state).await;
274        self.simulate_shared_keystrokes(keystrokes).await;
275        self.shared_state().await
276    }
277
278    pub async fn set_shared_wrap(&mut self, columns: u32) {
279        if columns < 12 {
280            panic!("nvim doesn't support columns < 12")
281        }
282        self.neovim.set_option("wrap").await;
283        self.neovim
284            .set_option(&format!("columns={}", columns))
285            .await;
286
287        self.update(|_, cx| {
288            SettingsStore::update_global(cx, |settings, cx| {
289                settings.update_user_settings(cx, |settings| {
290                    settings.project.all_languages.defaults.soft_wrap =
291                        Some(SoftWrap::PreferredLineLength);
292                    settings
293                        .project
294                        .all_languages
295                        .defaults
296                        .preferred_line_length = Some(columns);
297                });
298            })
299        })
300    }
301
302    pub async fn set_scroll_height(&mut self, rows: u32) {
303        // match Zed's scrolling behavior
304        self.neovim.set_option(&format!("scrolloff={}", 3)).await;
305        // +2 to account for the vim command UI at the bottom.
306        self.neovim.set_option(&format!("lines={}", rows + 2)).await;
307        let (line_height, visible_line_count) = self.editor(|editor, window, _cx| {
308            (
309                editor
310                    .style()
311                    .unwrap()
312                    .text
313                    .line_height_in_pixels(window.rem_size()),
314                editor.visible_line_count().unwrap(),
315            )
316        });
317
318        let window = self.window;
319        let margin = self
320            .update_window(window, |_, window, _cx| {
321                window.viewport_size().height - line_height * (visible_line_count as f32)
322            })
323            .unwrap();
324
325        self.simulate_window_resize(
326            self.window,
327            size(px(1000.), margin + (rows as f32) * line_height),
328        );
329    }
330
331    pub async fn set_neovim_option(&mut self, option: &str) {
332        self.neovim.set_option(option).await;
333    }
334
335    #[must_use]
336    pub async fn shared_clipboard(&mut self) -> SharedClipboard {
337        SharedClipboard {
338            register: '"',
339            state: self.shared_state().await,
340            neovim: self.neovim.read_register('"').await,
341            editor: self.read_from_clipboard().unwrap().text().unwrap(),
342        }
343    }
344
345    #[must_use]
346    pub async fn shared_register(&mut self, register: char) -> SharedClipboard {
347        SharedClipboard {
348            register,
349            state: self.shared_state().await,
350            neovim: self.neovim.read_register(register).await,
351            editor: self.update(|_, cx| {
352                cx.global::<VimGlobals>()
353                    .registers
354                    .get(&register)
355                    .cloned()
356                    .unwrap_or_default()
357                    .text
358                    .into()
359            }),
360        }
361    }
362
363    #[must_use]
364    pub async fn shared_state(&mut self) -> SharedState {
365        let (mode, marked_text) = self.neovim.state().await;
366        SharedState {
367            neovim: marked_text,
368            neovim_mode: mode,
369            editor: self.editor_state(),
370            editor_mode: self.mode(),
371            initial: self
372                .last_set_state
373                .as_ref()
374                .cloned()
375                .unwrap_or("N/A".to_string()),
376            recent_keystrokes: self.recent_keystrokes.join(" "),
377        }
378    }
379
380    #[must_use]
381    pub async fn simulate_at_each_offset(
382        &mut self,
383        keystrokes: &str,
384        marked_positions: &str,
385    ) -> SharedState {
386        let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
387
388        for cursor_offset in cursor_offsets.iter() {
389            let mut marked_text = unmarked_text.clone();
390            marked_text.insert(*cursor_offset, 'ˇ');
391
392            let state = self.simulate(keystrokes, &marked_text).await;
393            if state.neovim != state.editor || state.neovim_mode != state.editor_mode {
394                return state;
395            }
396        }
397
398        SharedState::default()
399    }
400}
401
402impl Deref for NeovimBackedTestContext {
403    type Target = VimTestContext;
404
405    fn deref(&self) -> &Self::Target {
406        &self.cx
407    }
408}
409
410impl DerefMut for NeovimBackedTestContext {
411    fn deref_mut(&mut self) -> &mut Self::Target {
412        &mut self.cx
413    }
414}
415
416#[cfg(test)]
417mod test {
418    use crate::test::NeovimBackedTestContext;
419    use gpui::TestAppContext;
420
421    #[gpui::test]
422    async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
423        let mut cx = NeovimBackedTestContext::new(cx).await;
424        cx.shared_state().await.assert_matches();
425        cx.set_shared_state("This is a tesˇt").await;
426        cx.shared_state().await.assert_matches();
427    }
428}