add_ed/macros.rs
1use std::borrow::Cow;
2
3use crate::{Result, EdError};
4
5// TODO, enable this later
6///// How to handle undo/redo snapshotting during macro execution
7//pub enum MacroSnapshottingMode {
8// /// The default mode, same behaviour as the 'g' command
9// ///
10// /// Will squash modifications into the invocation itself *AND* remove that
11// /// snapshot if it isn't changed from the previous.
12// Default,
13// /// Any modifications to the buffer are rollbacked after execution
14// RevertMutation,
15// /// Any modifications are shown as caused by the macro invocation
16// SquashModifications,
17// /// Any modifications are shown as caused by the modifying command in the
18// /// macro
19// ExposeModifications,
20//}
21
22/// Small enum describing argument nr constraints
23///
24/// (We use serde's default, externally tagged)
25#[derive(Debug)]
26#[cfg_attr(feature="serde", derive(serde::Serialize, serde::Deserialize))]
27#[cfg_attr(feature="serde", serde(rename_all="lowercase"))]
28pub enum NrArguments {
29 Any,
30 None,
31 Exactly(usize),
32 Between{incl_min: usize, incl_max: usize},
33}
34
35/// A struct representing a runnable macro
36///
37/// It is intended to add more/change the variables, but the constructor and any
38/// thereafter applied modification should produces instances with the same
39/// behaviour through any changes.
40///
41/// If the `serde` feature is enabled, serialization will produce the most
42/// backwards compatible representation while still ensuring the same behaviour.
43/// Deserialization should produce identically behaving macros when valid for
44/// the version of `add-ed` being used, if newer features are used in the macro
45/// than the deserializing version of `add-ed` has access to an unknown field
46/// error will be raised.
47#[derive(Debug)]
48#[cfg_attr(feature="serde", derive(serde::Serialize, serde::Deserialize))]
49#[cfg_attr(feature="serde", serde(deny_unknown_fields))]
50#[non_exhaustive]
51pub struct Macro {
52 /// Input to simulate
53 ///
54 /// Should be a string of newline separated commands. Execution is equivalent
55 /// to if this input was given on STDIN while the editor is running.
56 pub input: Cow<'static, str>,
57 /// The number of arguments the macro accepts
58 ///
59 /// `Any` performs no validation, `Exactly` verifies that it is exactly that
60 /// nr of arguments given, and if `None` is set no argument substitution is
61 /// run on the macro (which means '$'s don't need to be doubled in the macro).
62 pub nr_arguments: NrArguments,
63 // TODO, enable this later
64 // /// How the macro execution interacts with undo/redo snapshotting
65 // snapshotting_mode: MacroSnapshottingMode,
66}
67impl Macro {
68 /// Construct a macro
69 ///
70 /// Creates a macro with the given text as command input and all other options
71 /// default. Use the builder pattern operators below or modify the public
72 /// member variables to configure the rest.
73 pub fn new<T: Into<Cow<'static, str>>>(
74 input: T,
75 ) -> Self {
76 Self{
77 input: input.into(),
78 nr_arguments: NrArguments::Any,
79 }
80 }
81 /// Configure required nr of arguments for the macro
82 pub fn nr_arguments(mut self, nr: NrArguments) -> Self {
83 self.nr_arguments = nr;
84 self
85 }
86}
87
88/// Trait over different ways to get macros by name
89///
90/// The intent is to allow for different methods of storing macros without
91/// requiring loading them in at editor startup. For example reading macros from
92/// filepaths based on their names, or perhaps from environment variables.
93///
94/// A ready implementation exists for HashMap, if you prefer to load in at
95/// startup for infallible macro getting during execution. A very good option if
96/// if you embedd your macro declarations in your editor's main config file.
97pub trait MacroGetter {
98 fn get_macro(&self, name: &str) -> Result<Option<&Macro>>;
99}
100
101impl MacroGetter for std::collections::HashMap<&str, Macro> {
102 fn get_macro(&self, name: &str) -> Result<Option<&Macro>> {
103 Ok(self.get(name))
104 }
105}
106
107/// Parse the macro and its arguments into a command string
108///
109/// Will error if the Macro expects another number of arguments than the given.
110/// Will not error on malformed Macro, instead ignoring non-inserting '$'
111/// instances and insert nothing for non existing arguments.
112pub fn apply_arguments<
113 S: std::ops::Deref<Target = str>,
114>(
115 mac: &Macro,
116 args: &[S],
117) -> Result<String> {
118 // Verify arguments
119 use NrArguments as NA;
120 match mac.nr_arguments {
121 NA::None => {
122 if !args.is_empty() { return Err(EdError::ArgumentsWrongNr{
123 expected: "absolutely no".into(),
124 received: args.len(),
125 }); }
126 // For this case we skip arguments substitution completely
127 return Ok(mac.input.to_string());
128 },
129 NA::Exactly(x) => {
130 if args.len() != x { return Err(EdError::ArgumentsWrongNr{
131 expected: format!("{}",x).into(),
132 received: args.len(),
133 }); }
134 },
135 NA::Between{incl_min, incl_max} => {
136 if args.len() > incl_max || args.len() < incl_min {
137 return Err(EdError::ArgumentsWrongNr{
138 expected: format!("between {} and {}", incl_min, incl_max).into(),
139 received: args.len(),
140 });
141 }
142 },
143 NA::Any => {},
144 }
145 // Iterate over every character in the macro to find "$<char>", replace with the
146 // matching argument (or $ in the case of $$)
147 // We construct a small state machine
148 let mut active_dollar_index = None;
149 let mut output = String::new();
150 let mut partial_number = String::new();
151 for (i,c) in mac.input.char_indices() {
152 match (c, active_dollar_index) {
153 // A first dollar sign, start of substitution sequence
154 ('$', None) => {
155 active_dollar_index = Some(i);
156 },
157 // Means we got "$$", which should be "$" in output
158 ('$', Some(j)) if j+1 == i => {
159 output.push('$');
160 active_dollar_index = None;
161 },
162 // We are receiving the digits for the integer, so we aggregate the digits
163 // until we reach the end of them and parse them.
164 (x, Some(_)) if x.is_ascii_digit() => {
165 partial_number.push(x);
166 },
167 // Means we received a bad/empty escape, a dollar sign followed by
168 // something else than another dollar sign or an integer.
169 // Handled by not substituting, just forwarding the input we got.
170 (x, Some(j)) if j+1 == i => {
171 output.push('$');
172 output.push(x);
173 active_dollar_index = None;
174 },
175 // Means we have reached end of a chain of at least one digit and should
176 // substitute in the corresponding argument
177 // (implicit `if j > i+1 && !x.is_ascii_digit()` due to if clauses in
178 // above matches)
179 (x, Some(_)) => {
180 // Safe to unwrap as we give it at least one digit and only ascii digits
181 let index = partial_number.parse::<usize>().unwrap();
182 partial_number.clear();
183 if index == 0 {
184 // Insert all the arguments space separated
185 for (i, arg) in args.iter().enumerate() {
186 if i != 0 { output.push(' '); } // Add spaces only between args
187 output.push_str(&arg);
188 }
189 }
190 else {
191 // Insert the argument (default to none if not given)
192 output.push_str(args
193 .get(index-1)
194 .map(|x|->&str {&x})
195 .unwrap_or("")
196 );
197 }
198 // If we are now on another dollar we note that, else put in the
199 // current character into output
200 match x {
201 '$' => {
202 active_dollar_index = Some(i);
203 },
204 x => {
205 active_dollar_index = None;
206 output.push(x);
207 },
208 }
209 },
210 // The normal case, just write in the char into the output
211 (x, None) => {
212 output.push(x);
213 },
214 }
215 }
216 // After looping, check that all variables were handled
217 if let Some(_) = active_dollar_index {
218 if !partial_number.is_empty() {
219 // Parse out a substitution that clearly was at the end of the macro
220 // (basically a copy of end of chain handling above)
221 let index = partial_number.parse::<usize>().unwrap();
222 partial_number.clear();
223 if index == 0 {
224 // Insert all the arguments space separated
225 for (i, arg) in args.iter().enumerate() {
226 if i != 0 { output.push(' '); } // Add spaces only between args
227 output.push_str(&arg);
228 }
229 }
230 else {
231 // Insert the argument (default to none if not given)
232 output.push_str(args
233 .get(index-1)
234 .map(|x|->&str {&x})
235 .unwrap_or("")
236 );
237 }
238 }
239 else {
240 // Insert lonely '$' that was clearly at the end of the macro
241 output.push('$');
242 }
243 }
244 // Finally, return the constructed output
245 Ok(output)
246}
247
248#[cfg(test)]
249mod test {
250 use super::*;
251
252 #[test]
253 fn argument_substitution() {
254 let mac = Macro::new("$1 world. Test$2")
255 .nr_arguments(NrArguments::Exactly(2))
256 ;
257 let args = ["Hello", "ing"];
258 let output = apply_arguments(&mac, &args).unwrap();
259 assert_eq!(
260 &output,
261 "Hello world. Testing",
262 "$<integer> should be replaced with argument at index <integer> - 1."
263 );
264 }
265
266 #[test]
267 fn dollar_escaping() {
268 let mac = Macro::new("$$1 and $1.");
269 let args = ["one dollar"];
270 let output = apply_arguments(&mac, &args).unwrap();
271 assert_eq!(
272 &output,
273 "$1 and one dollar.",
274 "$$ in macro should be replaced with $, to enable escaping $ characters."
275 );
276 }
277
278 #[test]
279 fn ignore_bad_escape() {
280 let mac = Macro::new("$a $");
281 let args = ["shouldn't appear"];
282 let output = apply_arguments(&mac, &args).unwrap();
283 assert_eq!(
284 &output,
285 "$a $",
286 "Invalid argument references ($ not followed by integer) should be left as is."
287 );
288 }
289
290 #[test]
291 fn all_arguments() {
292 let mac = Macro::new("hi $0");
293 let args = ["alice,","bob,","carol"];
294 let output = apply_arguments(&mac, &args).unwrap();
295 assert_eq!(
296 &output,
297 "hi alice, bob, carol",
298 "$0 should be replaced with all arguments (space separated)."
299 );
300 }
301
302 // Verify that no substitution is done if arguments none specified
303 #[test]
304 fn no_arguments() {
305 let mac = Macro::new("test $$ test")
306 .nr_arguments(NrArguments::None)
307 ;
308 let args: &[&str] = &[];
309 let output = apply_arguments(&mac, &args).unwrap();
310 assert_eq!(
311 &output,
312 "test $$ test",
313 "When no arguments are allowed no substitution should be done."
314 );
315 }
316}