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}