1use std::ops::{Deref, DerefMut};
2
3use collections::{HashMap, HashSet};
4use gpui::ContextHandle;
5use language::{OffsetRangeExt, Point};
6use util::test::marked_text_offsets;
7
8use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
9use crate::state::Mode;
10
11pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[
12 ExemptionFeatures::DeletionOnEmptyLine,
13 ExemptionFeatures::OperatorAbortsOnFailedMotion,
14];
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 // Deletions on empty lines miss some newlines
22 DeletionOnEmptyLine,
23 // When a motion fails, it should should not apply linewise operations
24 OperatorAbortsOnFailedMotion,
25 // When an operator completes at the end of the file, an extra newline is left
26 OperatorLastNewlineRemains,
27 // Deleting a word on an empty line doesn't remove the newline
28 DeleteWordOnEmptyLine,
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 // Quote style surrounding text objects don't seek forward properly
44 QuotesSeekForward,
45 // Neovim freezes up for some reason with angle brackets
46 AngleBracketsFreezeNeovim,
47 // Sentence Doesn't backtrack when its at the end of the file
48 SentenceAfterPunctuationAtEndOfFile,
49}
50
51impl ExemptionFeatures {
52 pub fn supported(&self) -> bool {
53 SUPPORTED_FEATURES.contains(self)
54 }
55}
56
57pub struct NeovimBackedTestContext<'a> {
58 cx: VimTestContext<'a>,
59 // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
60 // bindings are exempted. If None, all bindings are ignored for that insertion text.
61 exemptions: HashMap<String, Option<HashSet<String>>>,
62 neovim: NeovimConnection,
63}
64
65impl<'a> NeovimBackedTestContext<'a> {
66 pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
67 let function_name = cx.function_name.clone();
68 let cx = VimTestContext::new(cx, true).await;
69 Self {
70 cx,
71 exemptions: Default::default(),
72 neovim: NeovimConnection::new(function_name).await,
73 }
74 }
75
76 pub fn add_initial_state_exemptions(
77 &mut self,
78 marked_positions: &str,
79 missing_feature: ExemptionFeatures, // Feature required to support this exempted test case
80 ) {
81 if !missing_feature.supported() {
82 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
83
84 for cursor_offset in cursor_offsets.iter() {
85 let mut marked_text = unmarked_text.clone();
86 marked_text.insert(*cursor_offset, 'ˇ');
87
88 // None represents all keybindings being exempted for that initial state
89 self.exemptions.insert(marked_text, None);
90 }
91 }
92 }
93
94 pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
95 self.neovim.send_keystroke(keystroke_text).await;
96 self.simulate_keystroke(keystroke_text)
97 }
98
99 pub async fn simulate_shared_keystrokes<const COUNT: usize>(
100 &mut self,
101 keystroke_texts: [&str; COUNT],
102 ) -> ContextHandle {
103 for keystroke_text in keystroke_texts.into_iter() {
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) -> ContextHandle {
110 let context_handle = self.set_state(marked_text, Mode::Normal);
111
112 let selection = self.editor(|editor, cx| editor.selections.newest::<Point>(cx));
113 let text = self.buffer_text();
114 self.neovim.set_state(selection, &text).await;
115
116 context_handle
117 }
118
119 pub async fn assert_state_matches(&mut self) {
120 assert_eq!(
121 self.neovim.text().await,
122 self.buffer_text(),
123 "{}",
124 self.assertion_context()
125 );
126
127 let mut neovim_selection = self.neovim.selection().await;
128 // Zed selections adjust themselves to make the end point visually make sense
129 if neovim_selection.start > neovim_selection.end {
130 neovim_selection.start.column += 1;
131 }
132 let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
133 self.assert_editor_selections(vec![neovim_selection]);
134
135 if let Some(neovim_mode) = self.neovim.mode().await {
136 assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
137 }
138 }
139
140 pub async fn assert_binding_matches<const COUNT: usize>(
141 &mut self,
142 keystrokes: [&str; COUNT],
143 initial_state: &str,
144 ) -> Option<(ContextHandle, ContextHandle)> {
145 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
146 match possible_exempted_keystrokes {
147 Some(exempted_keystrokes) => {
148 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
149 // This keystroke was exempted for this insertion text
150 return None;
151 }
152 }
153 None => {
154 // All keystrokes for this insertion text are exempted
155 return None;
156 }
157 }
158 }
159
160 let _state_context = self.set_shared_state(initial_state).await;
161 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
162 self.assert_state_matches().await;
163 Some((_state_context, _keystroke_context))
164 }
165
166 pub async fn assert_binding_matches_all<const COUNT: usize>(
167 &mut self,
168 keystrokes: [&str; COUNT],
169 marked_positions: &str,
170 ) {
171 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
172
173 for cursor_offset in cursor_offsets.iter() {
174 let mut marked_text = unmarked_text.clone();
175 marked_text.insert(*cursor_offset, 'ˇ');
176
177 self.assert_binding_matches(keystrokes, &marked_text).await;
178 }
179 }
180
181 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
182 &mut self,
183 keystrokes: [&str; COUNT],
184 marked_positions: &str,
185 feature: ExemptionFeatures,
186 ) {
187 if SUPPORTED_FEATURES.contains(&feature) {
188 self.assert_binding_matches_all(keystrokes, marked_positions)
189 .await
190 }
191 }
192
193 pub fn binding<const COUNT: usize>(
194 self,
195 keystrokes: [&'static str; COUNT],
196 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
197 NeovimBackedBindingTestContext::new(keystrokes, self)
198 }
199}
200
201impl<'a> Deref for NeovimBackedTestContext<'a> {
202 type Target = VimTestContext<'a>;
203
204 fn deref(&self) -> &Self::Target {
205 &self.cx
206 }
207}
208
209impl<'a> DerefMut for NeovimBackedTestContext<'a> {
210 fn deref_mut(&mut self) -> &mut Self::Target {
211 &mut self.cx
212 }
213}
214
215#[cfg(test)]
216mod test {
217 use gpui::TestAppContext;
218
219 use crate::test::NeovimBackedTestContext;
220
221 #[gpui::test]
222 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
223 let mut cx = NeovimBackedTestContext::new(cx).await;
224 cx.assert_state_matches().await;
225 cx.set_shared_state("This is a tesˇt").await;
226 cx.assert_state_matches().await;
227 }
228}