1use std::ops::{Deref, DerefMut};
2
3use collections::{HashMap, HashSet};
4use gpui::ContextHandle;
5use language::OffsetRangeExt;
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 key bindings 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 self.neovim.set_state(marked_text).await;
112 context_handle
113 }
114
115 pub async fn assert_state_matches(&mut self) {
116 assert_eq!(
117 self.neovim.text().await,
118 self.buffer_text(),
119 "{}",
120 self.assertion_context()
121 );
122
123 let mut neovim_selection = self.neovim.selection().await;
124 // Zed selections adjust themselves to make the end point visually make sense
125 if neovim_selection.start > neovim_selection.end {
126 neovim_selection.start.column += 1;
127 }
128 let neovim_selection = neovim_selection.to_offset(&self.buffer_snapshot());
129 self.assert_editor_selections(vec![neovim_selection]);
130
131 if let Some(neovim_mode) = self.neovim.mode().await {
132 assert_eq!(neovim_mode, self.mode(), "{}", self.assertion_context(),);
133 }
134 }
135
136 pub async fn assert_binding_matches<const COUNT: usize>(
137 &mut self,
138 keystrokes: [&str; COUNT],
139 initial_state: &str,
140 ) -> Option<(ContextHandle, ContextHandle)> {
141 if let Some(possible_exempted_keystrokes) = self.exemptions.get(initial_state) {
142 match possible_exempted_keystrokes {
143 Some(exempted_keystrokes) => {
144 if exempted_keystrokes.contains(&format!("{keystrokes:?}")) {
145 // This keystroke was exempted for this insertion text
146 return None;
147 }
148 }
149 None => {
150 // All keystrokes for this insertion text are exempted
151 return None;
152 }
153 }
154 }
155
156 let _state_context = self.set_shared_state(initial_state).await;
157 let _keystroke_context = self.simulate_shared_keystrokes(keystrokes).await;
158 self.assert_state_matches().await;
159 Some((_state_context, _keystroke_context))
160 }
161
162 pub async fn assert_binding_matches_all<const COUNT: usize>(
163 &mut self,
164 keystrokes: [&str; COUNT],
165 marked_positions: &str,
166 ) {
167 let (unmarked_text, cursor_offsets) = marked_text_offsets(marked_positions);
168
169 for cursor_offset in cursor_offsets.iter() {
170 let mut marked_text = unmarked_text.clone();
171 marked_text.insert(*cursor_offset, 'ˇ');
172
173 self.assert_binding_matches(keystrokes, &marked_text).await;
174 }
175 }
176
177 pub async fn assert_binding_matches_all_exempted<const COUNT: usize>(
178 &mut self,
179 keystrokes: [&str; COUNT],
180 marked_positions: &str,
181 feature: ExemptionFeatures,
182 ) {
183 if SUPPORTED_FEATURES.contains(&feature) {
184 self.assert_binding_matches_all(keystrokes, marked_positions)
185 .await
186 }
187 }
188
189 pub fn binding<const COUNT: usize>(
190 self,
191 keystrokes: [&'static str; COUNT],
192 ) -> NeovimBackedBindingTestContext<'a, COUNT> {
193 NeovimBackedBindingTestContext::new(keystrokes, self)
194 }
195}
196
197impl<'a> Deref for NeovimBackedTestContext<'a> {
198 type Target = VimTestContext<'a>;
199
200 fn deref(&self) -> &Self::Target {
201 &self.cx
202 }
203}
204
205impl<'a> DerefMut for NeovimBackedTestContext<'a> {
206 fn deref_mut(&mut self) -> &mut Self::Target {
207 &mut self.cx
208 }
209}
210
211#[cfg(test)]
212mod test {
213 use gpui::TestAppContext;
214
215 use crate::test::NeovimBackedTestContext;
216
217 #[gpui::test]
218 async fn neovim_backed_test_context_works(cx: &mut TestAppContext) {
219 let mut cx = NeovimBackedTestContext::new(cx).await;
220 cx.assert_state_matches().await;
221 cx.set_shared_state("This is a tesˇt").await;
222 cx.assert_state_matches().await;
223 }
224}