1use indoc::indoc;
2use std::ops::{Deref, DerefMut, Range};
3
4use collections::{HashMap, HashSet};
5use gpui::ContextHandle;
6use language::OffsetRangeExt;
7use util::test::{generate_marked_text, marked_text_offsets};
8
9use super::{neovim_connection::NeovimConnection, NeovimBackedBindingTestContext, VimTestContext};
10use crate::state::Mode;
11
12pub const SUPPORTED_FEATURES: &[ExemptionFeatures] = &[
13 ExemptionFeatures::DeletionOnEmptyLine,
14 ExemptionFeatures::OperatorAbortsOnFailedMotion,
15];
16
17/// Enum representing features we have tests for but which don't work, yet. Used
18/// to add exemptions and automatically
19#[derive(PartialEq, Eq)]
20pub enum ExemptionFeatures {
21 // MOTIONS
22 // Deletions on empty lines miss some newlines
23 DeletionOnEmptyLine,
24 // When a motion fails, it should should not apply linewise operations
25 OperatorAbortsOnFailedMotion,
26 // When an operator completes at the end of the file, an extra newline is left
27 OperatorLastNewlineRemains,
28 // Deleting a word on an empty line doesn't remove the newline
29 DeleteWordOnEmptyLine,
30
31 // OBJECTS
32 // Resulting position after the operation is slightly incorrect for unintuitive reasons.
33 IncorrectLandingPosition,
34 // Operator around the text object at the end of the line doesn't remove whitespace.
35 AroundObjectLeavesWhitespaceAtEndOfLine,
36 // Sentence object on empty lines
37 SentenceOnEmptyLines,
38 // Whitespace isn't included with text objects at the start of the line
39 SentenceAtStartOfLineWithWhitespace,
40 // Whitespace around sentences is slightly incorrect when starting between sentences
41 AroundSentenceStartingBetweenIncludesWrongWhitespace,
42 // Non empty selection with text objects in visual mode
43 NonEmptyVisualTextObjects,
44 // Quote style surrounding text objects don't seek forward properly
45 QuotesSeekForward,
46 // Neovim freezes up for some reason with angle brackets
47 AngleBracketsFreezeNeovim,
48 // Sentence Doesn't backtrack when its at the end of the file
49 SentenceAfterPunctuationAtEndOfFile,
50}
51
52impl ExemptionFeatures {
53 pub fn supported(&self) -> bool {
54 SUPPORTED_FEATURES.contains(self)
55 }
56}
57
58pub struct NeovimBackedTestContext<'a> {
59 cx: VimTestContext<'a>,
60 // Lookup for exempted assertions. Keyed by the insertion text, and with a value indicating which
61 // bindings are exempted. If None, all bindings are ignored for that insertion text.
62 exemptions: HashMap<String, Option<HashSet<String>>>,
63 neovim: NeovimConnection,
64}
65
66impl<'a> NeovimBackedTestContext<'a> {
67 pub async fn new(cx: &'a mut gpui::TestAppContext) -> NeovimBackedTestContext<'a> {
68 let function_name = cx.function_name.clone();
69 let cx = VimTestContext::new(cx, true).await;
70 Self {
71 cx,
72 exemptions: Default::default(),
73 neovim: NeovimConnection::new(function_name).await,
74 }
75 }
76
77 pub fn add_initial_state_exemptions(
78 &mut self,
79 marked_positions: &str,
80 missing_feature: ExemptionFeatures, // Feature required to support this exempted test case
81 ) {
82 if !missing_feature.supported() {
83 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
84
85 for cursor_offset in cursor_offsets.iter() {
86 let mut marked_text = unmarked_text.clone();
87 marked_text.insert(*cursor_offset, 'ˇ');
88
89 // None represents all key bindings being exempted for that initial state
90 self.exemptions.insert(marked_text, None);
91 }
92 }
93 }
94
95 pub async fn simulate_shared_keystroke(&mut self, keystroke_text: &str) -> ContextHandle {
96 self.neovim.send_keystroke(keystroke_text).await;
97 self.simulate_keystroke(keystroke_text)
98 }
99
100 pub async fn simulate_shared_keystrokes<const COUNT: usize>(
101 &mut self,
102 keystroke_texts: [&str; COUNT],
103 ) -> ContextHandle {
104 for keystroke_text in keystroke_texts.into_iter() {
105 self.neovim.send_keystroke(keystroke_text).await;
106 }
107 self.simulate_keystrokes(keystroke_texts)
108 }
109
110 pub async fn set_shared_state(&mut self, marked_text: &str) -> ContextHandle {
111 let context_handle = self.set_state(marked_text, Mode::Normal);
112 self.neovim.set_state(marked_text).await;
113 context_handle
114 }
115
116 pub async fn assert_shared_state(&mut self, marked_text: &str) {
117 let neovim = self.neovim_state().await;
118 if neovim != marked_text {
119 panic!(
120 indoc! {"Test is incorrect (currently expected != neovim state)
121
122 # currently expected:
123 {}
124 # neovim state:
125 {}
126 # zed state:
127 {}"},
128 marked_text,
129 neovim,
130 self.editor_state(),
131 )
132 }
133 self.assert_editor_state(marked_text)
134 }
135
136 pub async fn neovim_state(&mut self) -> String {
137 generate_marked_text(
138 self.neovim.text().await.as_str(),
139 &vec![self.neovim_selection().await],
140 true,
141 )
142 }
143
144 async fn neovim_selection(&mut self) -> Range<usize> {
145 let mut neovim_selection = self.neovim.selection().await;
146 // Zed selections adjust themselves to make the end point visually make sense
147 if neovim_selection.start > neovim_selection.end {
148 neovim_selection.start.column += 1;
149 }
150 neovim_selection.to_offset(&self.buffer_snapshot())
151 }
152
153 pub async fn assert_state_matches(&mut self) {
154 assert_eq!(
155 self.neovim.text().await,
156 self.buffer_text(),
157 "{}",
158 self.assertion_context()
159 );
160
161 let selections = vec![self.neovim_selection().await];
162 self.assert_editor_selections(selections);
163
164 if let Some(neovim_mode) = self.neovim.mode().await {
165 assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
166 }
167 }
168
169 pub async fn assert_binding_matches<const COUNT: usize>(
170 &mut self,
171 keystrokes: [&str; COUNT],
172 initial_state: &str,
173 ) -> Option<(ContextHandle, ContextHandle)> {
174 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
175 match possible_exempted_keystrokes {
176 Some(exempted_keystrokes) => {
177 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
178 // This keystroke was exempted for this insertion text
179 return None;
180 }
181 }
182 None => {
183 // All keystrokes for this insertion text are exempted
184 return None;
185 }
186 }
187 }
188
189 let _state_context = self.set_shared_state(initial_state).await;
190 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
191 self.assert_state_matches().await;
192 Some((_state_context, _keystroke_context))
193 }
194
195 pub async fn assert_binding_matches_all<const COUNT: usize>(
196 &mut self,
197 keystrokes: [&str; COUNT],
198 marked_positions: &str,
199 ) {
200 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
201
202 for cursor_offset in cursor_offsets.iter() {
203 let mut marked_text = unmarked_text.clone();
204 marked_text.insert(*cursor_offset, 'ˇ');
205
206 self.assert_binding_matches(keystrokes, &marked_text).await;
207 }
208 }
209
210 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
211 &mut self,
212 keystrokes: [&str; COUNT],
213 marked_positions: &str,
214 feature: ExemptionFeatures,
215 ) {
216 if SUPPORTED_FEATURES.contains(&feature) {
217 self.assert_binding_matches_all(keystrokes, marked_positions)
218 .await
219 }
220 }
221
222 pub fn binding<const COUNT: usize>(
223 self,
224 keystrokes: [&'static str; COUNT],
225 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
226 NeovimBackedBindingTestContext::new(keystrokes, self)
227 }
228}
229
230impl<'a> Deref for NeovimBackedTestContext<'a> {
231 type Target = VimTestContext<'a>;
232
233 fn deref(&self) -> &Self::Target {
234 &self.cx
235 }
236}
237
238impl<'a> DerefMut for NeovimBackedTestContext<'a> {
239 fn deref_mut(&mut self) -> &mut Self::Target {
240 &mut self.cx
241 }
242}
243
244#[cfg(test)]
245mod test {
246 use gpui::TestAppContext;
247
248 use crate::test::NeovimBackedTestContext;
249
250 #[gpui::test]
251 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
252 let mut cx = NeovimBackedTestContext::new(cx).await;
253 cx.assert_state_matches().await;
254 cx.set_shared_state("This is a tesˇt").await;
255 cx.assert_state_matches().await;
256 }
257}