Repository: espanso/espanso (language: rust)

Issue:
Weird Interaction with AltTab on MacOS
### Problem:
I use AltTab for switching windows, with hotkey bound to `cmd-tab`. Using `cmd-tab` disables triggering of matches, unless I hit `cmd` again. Won't even trigger within 20 seconds of writing match. This happens every time I use the hotkey. However, if I switch the hotkey to `option-Tab`, then the replacement will eventually trigger after ~6 seconds.

### Expected behavior:
Immediately trigger replacement when match is written, even after switching windows/apps

### Background:
- OS: MacOS 12.0.1 (MacOS Monterey)
-  espanso: 2.0.5~alpha
- AltTab: 6.26
- `toggle_key: "RIGHT_ALT"`

### Workarounds
- Manually clicking between apps 
- Disable AltTab
- (partial workaround) Switch AltTab hotkey to `option-Tab`

### What I've noticed:
- After `cmd-tab`, persists even when I click on other apps
- `cmd-tab` doesn’t fix it (doesn’t reverse the problem)
- Doesn’t matter how long I wait in between one `cmd-tab` and another
- Doesn’t matter how long I wait in between holding `cmd` and hitting `tab`
- Only `cmd` allows the triggers again (not `Esc`, `control`, or `option`)
- If I switch the AltTab hotkey to option-Tab, then the `cmd-tab` problem still persists, but now WILL trigger after a short delay (about 6 seconds); 
- Hitting `tab-cmd` doesn’t cause problems
- Hitting `control-cmd` doesn’t cause problems
- Hitting `option-cmd` doesn’t cause problems

### Other github problems referenced and tried solutions:
- #815 suggests that alt-tab disables espanso because of hitting activation key twice in a row; don’t think this is the case because mine is set to right alt (i.e. right option)
- #646 suggests an alt-key + modifier issue; workaround was swapping activation key to right alt, but this doesn’t help my situation.


Discussion / hints:

Fix patch:
diff --git a/Cargo.lock b/Cargo.lock
index 9572b17a..244170ae 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -572,7 +572,7 @@ dependencies = [
 
 [[package]]
 name = "espanso"
-version = "2.0.5-alpha"
+version = "2.1.0-alpha"
 dependencies = [
  "anyhow",
  "caps",
diff --git a/espanso-config/src/legacy/mod.rs b/espanso-config/src/legacy/mod.rs
index 674b08a9..8347c8ea 100644
--- a/espanso-config/src/legacy/mod.rs
+++ b/espanso-config/src/legacy/mod.rs
@@ -89,7 +89,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup)
     .iter()
     .filter_map(|var| {
       let var: YAMLVariable = serde_yaml::from_value(var.clone()).ok()?;
-      let (var, warnings) = try_convert_into_variable(var).ok()?;
+      let (var, warnings) = try_convert_into_variable(var, true).ok()?;
       warnings.into_iter().for_each(|warning| {
         warn!("{}", warning);
       });
@@ -102,7 +102,7 @@ fn split_config(config: LegacyConfig) -> (LegacyInteropConfig, LegacyMatchGroup)
     .iter()
     .filter_map(|var| {
       let m: YAMLMatch = serde_yaml::from_value(var.clone()).ok()?;
-      let (m, warnings) = try_convert_into_match(m).ok()?;
+      let (m, warnings) = try_convert_into_match(m, true).ok()?;
       warnings.into_iter().for_each(|warning| {
         warn!("{}", warning);
       });
diff --git a/espanso-config/src/matches/group/loader/yaml/mod.rs b/espanso-config/src/matches/group/loader/yaml/mod.rs
index 1d3a61c3..1559fda4 100644
--- a/espanso-config/src/matches/group/loader/yaml/mod.rs
+++ b/espanso-config/src/matches/group/loader/yaml/mod.rs
@@ -43,6 +43,8 @@ mod util;
 
 lazy_static! {
   static ref VAR_REGEX: Regex = Regex::new("\\{\\{\\s*(\\w+)(\\.\\w+)?\\s*\\}\\}").unwrap();
+  static ref FORM_CONTROL_REGEX: Regex =
+    Regex::new("\\[\\[\\s*(\\w+)(\\.\\w+)?\\s*\\]\\]").unwrap();
 }
 
 // Create an alias to make the meaning more explicit
@@ -72,7 +74,7 @@ impl Importer for YAMLImporter {
 
     let mut global_vars = Vec::new();
     for yaml_global_var in yaml_group.global_vars.as_ref().cloned().unwrap_or_default() {
-      match try_convert_into_variable(yaml_global_var) {
+      match try_convert_into_variable(yaml_global_var, false) {
         Ok((var, warnings)) => {
           global_vars.push(var);
           non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
@@ -85,7 +87,7 @@ impl Importer for YAMLImporter {
 
     let mut matches = Vec::new();
     for yaml_match in yaml_group.matches.as_ref().cloned().unwrap_or_default() {
-      match try_convert_into_match(yaml_match) {
+      match try_convert_into_match(yaml_match, false) {
         Ok((m, warnings)) => {
           matches.push(m);
           non_fatal_errors.extend(warnings.into_iter().map(ErrorRecord::warn));
@@ -119,7 +121,10 @@ impl Importer for YAMLImporter {
   }
 }
 
-pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warning>)> {
+pub fn try_convert_into_match(
+  yaml_match: YAMLMatch,
+  use_compatibility_mode: bool,
+) -> Result<(Match, Vec<Warning>)> {
   let mut warnings = Vec::new();
 
   if yaml_match.uppercase_style.is_some() && yaml_match.propagate_case.is_none() {
@@ -203,8 +208,9 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
 
       let mut vars: Vec<Variable> = Vec::new();
       for yaml_var in yaml_match.vars.unwrap_or_default() {
-        let (var, var_warnings) = try_convert_into_variable(yaml_var.clone())
-          .with_context(|| format!("failed to load variable: {:?}", yaml_var))?;
+        let (var, var_warnings) =
+          try_convert_into_variable(yaml_var.clone(), use_compatibility_mode)
+            .with_context(|| format!("failed to load variable: {:?}", yaml_var))?;
         warnings.extend(var_warnings);
         vars.push(var);
       }
@@ -216,21 +222,45 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
         force_mode,
       })
     } else if let Some(form_layout) = yaml_match.form {
-      // TODO: test form case
       // Replace all the form fields with actual variables
-      let resolved_layout = VAR_REGEX
-        .replace_all(&form_layout, |caps: &Captures| {
-          let var_name = caps.get(1).unwrap().as_str();
-          format!("{{{{form1.{}}}}}", var_name)
-        })
-        .to_string();
+
+      // In v2.1.0-alpha the form control syntax was replaced with [[control]]
+      // instead of {{control}}, so we check if compatibility mode is being used.
+      // TODO: remove once compatibility mode is removed
+
+      let (resolved_replace, resolved_layout) = if use_compatibility_mode {
+        (
+          VAR_REGEX
+            .replace_all(&form_layout, |caps: &Captures| {
+              let var_name = caps.get(1).unwrap().as_str();
+              format!("{{{{form1.{}}}}}", var_name)
+            })
+            .to_string(),
+          VAR_REGEX
+            .replace_all(&form_layout, |caps: &Captures| {
+              let var_name = caps.get(1).unwrap().as_str();
+              format!("[[{}]]", var_name)
+            })
+            .to_string(),
+        )
+      } else {
+        (
+          FORM_CONTROL_REGEX
+            .replace_all(&form_layout, |caps: &Captures| {
+              let var_name = caps.get(1).unwrap().as_str();
+              format!("{{{{form1.{}}}}}", var_name)
+            })
+            .to_string(),
+          form_layout,
+        )
+      };
 
       // Convert escaped brakets in forms
-      let resolved_layout = resolved_layout.replace("\\{", "{ ").replace("\\}", " }");
+      let resolved_replace = resolved_replace.replace("\\{", "{ ").replace("\\}", " }");
 
       // Convert the form data to valid variables
       let mut params = Params::new();
-      params.insert("layout".to_string(), Value::String(form_layout));
+      params.insert("layout".to_string(), Value::String(resolved_layout));
 
       if let Some(fields) = yaml_match.form_fields {
         params.insert("fields".to_string(), Value::Object(convert_params(fields)?));
@@ -241,10 +271,11 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
         name: "form1".to_owned(),
         var_type: "form".to_owned(),
         params,
+        ..Default::default()
       }];
 
       MatchEffect::Text(TextEffect {
-        replace: resolved_layout,
+        replace: resolved_replace,
         vars,
         format: TextFormat::Plain,
         force_mode,
@@ -274,13 +305,18 @@ pub fn try_convert_into_match(yaml_match: YAMLMatch) -> Result<(Match, Vec<Warni
   ))
 }
 
-pub fn try_convert_into_variable(yaml_var: YAMLVariable) -> Result<(Variable, Vec<Warning>)> {
+pub fn try_convert_into_variable(
+  yaml_var: YAMLVariable,
+  use_compatibility_mode: bool,
+) -> Result<(Variable, Vec<Warning>)> {
   Ok((
     Variable {
       name: yaml_var.name,
       var_type: yaml_var.var_type,
       params: convert_params(yaml_var.params)?,
       id: next_id(),
+      inject_vars: !use_compatibility_mode && yaml_var.inject_vars.unwrap_or(true),
+      depends_on: yaml_var.depends_on,
     },
     Vec::new(),
   ))
@@ -295,9 +331,12 @@ mod tests {
   };
   use std::fs::create_dir_all;
 
-  fn create_match_with_warnings(yaml: &str) -> Result<(Match, Vec<Warning>)> {
+  fn create_match_with_warnings(
+    yaml: &str,
+    use_compatibility_mode: bool,
+  ) -> Result<(Match, Vec<Warning>)> {
     let yaml_match: YAMLMatch = serde_yaml::from_str(yaml)?;
-    let (mut m, warnings) = try_convert_into_match(yaml_match)?;
+    let (mut m, warnings) = try_convert_into_match(yaml_match, use_compatibility_mode)?;
 
     // Reset the IDs to correctly compare them
     m.id = 0;
@@ -309,7 +348,7 @@ mod tests {
   }
 
   fn create_match(yaml: &str) -> Result<Match> {
-    let (m, warnings) = create_match_with_warnings(yaml)?;
+    let (m, warnings) = create_match_with_warnings(yaml, false)?;
     if !warnings.is_empty() {
       panic!("warnings were detected but not handled: {:?}", warnings);
     }
@@ -529,6 +568,7 @@ mod tests {
         replace: "world"
         uppercase_style: "capitalize"
         "#,
+      false,
     )
     .unwrap();
     assert_eq!(
@@ -545,6 +585,7 @@ mod tests {
         uppercase_style: "invalid"
         propagate_case: true
         "#,
+      false,
     )
     .unwrap();
     assert_eq!(
@@ -554,6 +595,119 @@ mod tests {
     assert_eq!(warnings.len(), 1);
   }
 
+  #[test]
+  fn form_maps_correctly() {
+    let mut params = Params::new();
+    params.insert(
+      "layout".to_string(),
+      Value::String("Hi [[name]]!".to_string()),
+    );
+
+    assert_eq!(
+      create_match(
+        r#"
+        trigger: "Hello"
+        form: "Hi [[name]]!"
+        "#
+      )
+      .unwrap(),
+      Match {
+        cause: MatchCause::Trigger(TriggerCause {
+          triggers: vec!["Hello".to_string()],
+          ..Default::default()
+        }),
+        effect: MatchEffect::Text(TextEffect {
+          replace: "Hi {{form1.name}}!".to_string(),
+          vars: vec![Variable {
+            id: 0,
+            name: "form1".to_string(),
+            var_type: "form".to_string(),
+            params,
+            ..Default::default()
+          }],
+          ..Default::default()
+        }),
+        ..Default::default()
+      }
+    )
+  }
+
+  #[test]
+  fn form_maps_correctly_with_variable_injection() {
+    let mut params = Params::new();
+    params.insert(
+      "layout".to_string(),
+      Value::String("Hi [[name]]! {{signature}}".to_string()),
+    );
+
+    assert_eq!(
+      create_match(
+        r#"
+        trigger: "Hello"
+        form: "Hi [[name]]! {{signature}}"
+        "#
+      )
+      .unwrap(),
+      Match {
+        cause: MatchCause::Trigger(TriggerCause {
+          triggers: vec!["Hello".to_string()],
+          ..Default::default()
+        }),
+        effect: MatchEffect::Text(TextEffect {
+          replace: "Hi {{form1.name}}! {{signature}}".to_string(),
+          vars: vec![Variable {
+            id: 0,
+            name: "form1".to_string(),
+            var_type: "form".to_string(),
+            params,
+            ..Default::default()
+          }],
+          ..Default::default()
+        }),
+        ..Default::default()
+      }
+    )
+  }
+
+  #[test]
+  fn form_maps_correctly_legacy_format() {
+    let mut params = Params::new();
+    params.insert(
+      "layout".to_string(),
+      Value::String("Hi [[name]]!".to_string()),
+    );
+
+    assert_eq!(
+      create_match_with_warnings(
+        r#"
+        trigger: "Hello"
+        form: "Hi {{name}}!"
+        "#,
+        true
+      )
+      .unwrap()
+      .0,
+      Match {
+        cause: MatchCause::Trigger(TriggerCause {
+          triggers: vec!["Hello".to_string()],
+          ..Default::default()
+        }),
+        effect: MatchEffect::Text(TextEffect {
+          replace: "Hi {{form1.name}}!".to_string(),
+          vars: vec![Variable {
+            id: 0,
+            name: "form1".to_string(),
+            var_type: "form".to_string(),
+            params,
+            ..Default::default()
+          }],
+          ..Default::default()
+        }),
+        ..Default::default()
+      }
+    )
+  }
+
   #[test]
   fn vars_maps_correctly() {
     let mut params = Params::new();
@@ -592,6 +746,52 @@ mod tests {
     )
   }
 
+  #[test]
+  fn vars_inject_vars_and_depends_on() {
+    let vars = vec![
+      Variable {
+        name: "var1".to_string(),
+        var_type: "test".to_string(),
+        depends_on: vec!["test".to_owned()],
+        ..Default::default()
+      },
+      Variable {
+        name: "var2".to_string(),
+        var_type: "test".to_string(),
+        inject_vars: false,
+        ..Default::default()
+      },
+    ];
+    assert_eq!(
+      create_match(
+        r#"
+        trigger: "Hello"
+        replace: "world"
+        vars:
+          - name: var1
+            type: test
+            depends_on: ["test"]
+          - name: var2
+            type: "test"
+            inject_vars: false
+        "#
+      )
+      .unwrap(),
+      Match {
+        cause: MatchCause::Trigger(TriggerCause {
+          triggers: vec!["Hello".to_string()],
+          ..Default::default()
+        }),
+        effect: MatchEffect::Text(TextEffect {
+          replace: "world".to_string(),
+          vars,
+          ..Default::default()
+        }),
+        ..Default::default()
+      }
+    )
+  }
+
   #[test]
   fn vars_no_params_maps_correctly() {
     let vars = vec![Variable {
diff --git a/espanso-config/src/matches/group/loader/yaml/parse.rs b/espanso-config/src/matches/group/loader/yaml/parse.rs
index 39a18fa8..50c14636 100644
--- a/espanso-config/src/matches/group/loader/yaml/parse.rs
+++ b/espanso-config/src/matches/group/loader/yaml/parse.rs
@@ -125,6 +125,12 @@ pub struct YAMLVariable {
 
   #[serde(default = "default_params")]
   pub params: Mapping,
+
+  #[serde(default)]
+  pub inject_vars: Option<bool>,
+
+  #[serde(default)]
+  pub depends_on: Vec<String>,
 }
 
 fn default_params() -> Mapping {
diff --git a/espanso-config/src/matches/mod.rs b/espanso-config/src/matches/mod.rs
index 7e3fb13f..8d2a5bbd 100644
--- a/espanso-config/src/matches/mod.rs
+++ b/espanso-config/src/matches/mod.rs
@@ -205,6 +205,8 @@ pub struct Variable {
   pub name: String,
   pub var_type: String,
   pub params: Params,
+  pub inject_vars: bool,
+  pub depends_on: Vec<String>,
 }
 
 impl Default for Variable {
@@ -214,6 +216,8 @@ impl Default for Variable {
       name: String::new(),
       var_type: String::new(),
       params: Params::new(),
+      inject_vars: true,
+      depends_on: Vec::new(),
     }
   }
 }
diff --git a/espanso-detect/src/mac/mod.rs b/espanso-detect/src/mac/mod.rs
index 5b93558e..5f49ea01 100644
--- a/espanso-detect/src/mac/mod.rs
+++ b/espanso-detect/src/mac/mod.rs
@@ -32,7 +32,7 @@ use log::{error, trace, warn};
 use anyhow::Result;
 use thiserror::Error;
 
-use crate::event::{HotKeyEvent, InputEvent, Key, KeyboardEvent, Variant};
+use crate::event::{HotKeyEvent, InputEvent, Key, KeyboardEvent, Status, Variant};
 use crate::event::{Key::*, MouseButton, MouseEvent};
 use crate::{event::Status::*, Source, SourceCallback};
 use crate::{event::Variant::*, hotkey::HotKey};
@@ -50,6 +50,7 @@ const INPUT_MOUSE_MIDDLE_BUTTON: i32 = 3;
 
 // Take a look at the native.h header file for an explanation of the fields
 #[repr(C)]
+#[derive(Debug)]
 pub struct RawInputEvent {
   pub event_type: i32,
 
@@ -58,6 +59,12 @@ pub struct RawInputEvent {
 
   pub key_code: i32,
   pub status: i32,
+
+  pub is_caps_lock_pressed: i32,
+  pub is_shift_pressed: i32,
+  pub is_control_pressed: i32,
+  pub is_option_pressed: i32,
+  pub is_command_pressed: i32,
 }
 
 #[repr(C)]
@@ -82,15 +89,97 @@ extern "C" {
   );
 }
 
+#[derive(Debug, Default)]
+struct ModifierState {
+  is_ctrl_down: bool,
+  is_shift_down: bool,
+  is_command_down: bool,
+  is_option_down: bool,
+}
+
 lazy_static! {
   static ref CURRENT_SENDER: Arc<Mutex<Option<Sender<InputEvent>>>> = Arc::new(Mutex::new(None));
+  static ref MODIFIER_STATE: Arc<Mutex<ModifierState>> =
+    Arc::new(Mutex::new(ModifierState::default()));
 }
 
 extern "C" fn native_callback(raw_event: RawInputEvent) {
   let lock = CURRENT_SENDER
     .lock()
     .expect("unable to acquire CocoaSource sender lock");
+
+  // Most of the times, when pressing a modifier key (such as Alt, Ctrl, Shift, Cmd),
+  // we get both a Pressed and Released event. This is important to keep Espanso's
+  // internal representation of modifiers in sync.
+  // Unfortunately, there are times when the corresponding "release" event is not sent,
+  // and this causes Espanso to mistakenly think that the modifier is still pressed.
+  // This can happen for various reasons, such as when using external bluetooth keyboards
+  // or certain keyboard shortcuts.
+  // Luckily, most key events include the "modifiers flag" information, that tells us which
+  // modifier keys were currently pressed at that time.
+  // We use this modifier flag information to detect "inconsistent" states to send the corresponding
+  // modifier release events, keeping espanso's state in sync.
+  // For more info, see:
+  // https://github.com/federico-terzi/espanso/issues/825
+  // https://github.com/federico-terzi/espanso/issues/858
+  let mut compensating_events = Vec::new();
+  if raw_event.event_type == INPUT_EVENT_TYPE_KEYBOARD {
+    let (key_code, _) = key_code_to_key(raw_event.key_code);
+    let mut current_mod_state = MODIFIER_STATE
+      .lock()
+      .expect("unable to acquire modifier state in cocoa detector");
+
+    if let Key::Alt = &key_code {
+      current_mod_state.is_option_down = raw_event.status == INPUT_STATUS_PRESSED;
+    } else if let Key::Meta = &key_code {
+      current_mod_state.is_command_down = raw_event.status == INPUT_STATUS_PRESSED;
+    } else if let Key::Shift = &key_code {
+      current_mod_state.is_shift_down = raw_event.status == INPUT_STATUS_PRESSED;
+    } else if let Key::Control = &key_code {
+      current_mod_state.is_ctrl_down = raw_event.status == INPUT_STATUS_PRESSED;
+    } else {
+      if current_mod_state.is_command_down && raw_event.is_command_pressed == 0 {
+        compensating_events.push((Key::Meta, 0x37));
+        current_mod_state.is_command_down = false;
+      }
+      if current_mod_state.is_ctrl_down && raw_event.is_control_pressed == 0 {
+        compensating_events.push((Key::Control, 0x3B));
+        current_mod_state.is_ctrl_down = false;
+      }
+      if current_mod_state.is_shift_down && raw_event.is_shift_pressed == 0 {
+        compensating_events.push((Key::Shift, 0x38));
+        current_mod_state.is_shift_down = false;
+      }
+      if current_mod_state.is_option_down && raw_event.is_option_pressed == 0 {
+        compensating_events.push((Key::Alt, 0x3A));
+        current_mod_state.is_option_down = false;
+      }
+    }
+  }
+
+  if !compensating_events.is_empty() {
+    warn!(
+      "detected inconsistent modifier state for keys {:?}, sending compensating events...",
+      compensating_events
+    );
+  }
+
   if let Some(sender) = lock.as_ref() {
+    for (key, code) in compensating_events {
+      if let Err(error) = sender.send(InputEvent::Keyboard(KeyboardEvent {
+        key,
+        value: None,
+        status: Status::Released,
+        variant: None,
+        code,
+      })) {
+        error!(
+          "Unable to send compensating event to Cocoa Sender: {}",
+          error
+        );
+      }
+    }
+
     let event: Option<InputEvent> = raw_event.into();
     if let Some(event) = event {
       if let Err(error) = sender.send(event) {
@@ -386,6 +475,11 @@ mod tests {
       buffer_len: 0,
       key_code: 0,
       status: INPUT_STATUS_PRESSED,
+      is_caps_lock_pressed: 0,
+      is_shift_pressed: 0,
+      is_control_pressed: 0,
+      is_option_pressed: 0,
+      is_command_pressed: 0,
     }
   }
 
diff --git a/espanso-detect/src/mac/native.h b/espanso-detect/src/mac/native.h
index a86d9432..50836e63 100644
--- a/espanso-detect/src/mac/native.h
+++ b/espanso-detect/src/mac/native.h
@@ -52,6 +52,16 @@ typedef struct {
   
   // Pressed or Released status
   int32_t status;
+
+  // Modifier keys status, this is needed to "correct" missing modifier release events.
+  // For more info, see the following issues:
+  // https://github.com/federico-terzi/espanso/issues/825
+  // https://github.com/federico-terzi/espanso/issues/858
+  int32_t is_caps_lock_pressed;
+  int32_t is_shift_pressed;
+  int32_t is_control_pressed;
+  int32_t is_option_pressed;
+  int32_t is_command_pressed;
 } InputEvent;
 
 typedef void (*EventCallback)(InputEvent data);
diff --git a/espanso-detect/src/mac/native.mm b/espanso-detect/src/mac/native.mm
index 6eb5e9a2..b96d7ac3 100644
--- a/espanso-detect/src/mac/native.mm
+++ b/espanso-detect/src/mac/native.mm
@@ -76,6 +76,19 @@
           strncpy(inputEvent.buffer, chars, 23);
           inputEvent.buffer_len = event.characters.length;
 
+          // We also send the modifier key status to "correct" missing modifier release events
+          if (([event modifierFlags] & NSEventModifierFlagShift) != 0) {
+            inputEvent.is_shift_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagControl) != 0) {
+            inputEvent.is_control_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagCapsLock) != 0) {
+            inputEvent.is_caps_lock_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagOption) != 0) {
+            inputEvent.is_option_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) {
+            inputEvent.is_command_pressed = 1;
+          }
+
           callback(inputEvent);
         }else if (event.type == NSEventTypeLeftMouseDown || event.type == NSEventTypeRightMouseDown || event.type == NSEventTypeOtherMouseDown ||
                   event.type == NSEventTypeLeftMouseUp || event.type == NSEventTypeRightMouseUp || event.type == NSEventTypeOtherMouseUp) {
@@ -106,6 +119,20 @@
           } else if (event.keyCode == kVK_Option || event.keyCode == kVK_RightOption) {
             inputEvent.status = (([event modifierFlags] & NSEventModifierFlagOption) == 0) ? INPUT_STATUS_RELEASED : INPUT_STATUS_PRESSED;
           }
+
+          // We also send the modifier key status to "correct" missing modifier release events
+          if (([event modifierFlags] & NSEventModifierFlagShift) != 0) {
+            inputEvent.is_shift_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagControl) != 0) {
+            inputEvent.is_control_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagCapsLock) != 0) {
+            inputEvent.is_caps_lock_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagOption) != 0) {
+            inputEvent.is_option_pressed = 1;
+          } else if (([event modifierFlags] & NSEventModifierFlagCommand) != 0) {
+            inputEvent.is_command_pressed = 1;
+          }
+
           callback(inputEvent);
         }
     }];
diff --git a/espanso-migrate/src/convert.rs b/espanso-migrate/src/convert.rs
index 9fd86131..4e9b4c5a 100644
--- a/espanso-migrate/src/convert.rs
+++ b/espanso-migrate/src/convert.rs
@@ -17,6 +17,7 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+use regex::{Captures, Regex};
 use std::{cmp::Ordering, collections::HashMap, path::PathBuf};
 use yaml_rust::{yaml::Hash, Yaml};
 
@@ -69,24 +70,32 @@ pub fn convert(input_files: HashMap<String, Hash>) -> HashMap<String, ConvertedF
         });
 
       if let Some(global_vars) = yaml_global_vars {
+        let mut patched_global_vars: Vec<Yaml> = global_vars.clone();
+        patched_global_vars
+          .iter_mut()
+          .for_each(apply_form_syntax_patch_to_variable);
+
         let output_global_vars = output_yaml
           .content
           .entry(Yaml::String("global_vars".to_string()))
           .or_insert(Yaml::Array(Vec::new()));
         if let Yaml::Array(out_global_vars) = output_global_vars {
-          out_global_vars.extend(global_vars.clone());
+          out_global_vars.extend(patched_global_vars);
         } else {
           eprintln!("unable to transform global_vars for file: {}", input_path);
         }
       }
 
       if let Some(matches) = yaml_matches {
+        let mut patched_matches = matches.clone();
+        apply_form_syntax_patch(&mut patched_matches);
+
         let output_matches = output_yaml
           .content
           .entry(Yaml::String("matches".to_string()))
           .or_insert(Yaml::Array(Vec::new()));
         if let Yaml::Array(out_matches) = output_matches {
-          out_matches.extend(matches.clone());
+          out_matches.extend(patched_matches);
         } else {
           eprintln!("unable to transform matches for file: {}", input_path);
         }
@@ -324,3 +333,57 @@ fn map_field_if_present(
     }
   }
 }
+
+// This is needed to convert the old form's {{control}} syntax to the new [[control]] one.
+fn apply_form_syntax_patch(matches: &mut Vec<Yaml>) {
+  matches.iter_mut().for_each(|m| {
+    if let Yaml::Hash(fields) = m {
+      if let Some(Yaml::String(form_option)) = fields.get_mut(&Yaml::String("form".to_string())) {
+        let converted = replace_legacy_form_syntax_with_new_one(form_option);
+        if &converted != form_option {
+          form_option.clear();
+          form_option.push_str(&converted);
+        }
+      }
+
+      if let Some(Yaml::Array(vars)) = fields.get_mut(&Yaml::String("vars".to_string())) {
+        vars
+          .iter_mut()
+          .for_each(apply_form_syntax_patch_to_variable)
+      }
+    }
+  })
+}
+
+fn apply_form_syntax_patch_to_variable(variable: &mut Yaml) {
+  if let Yaml::Hash(fields) = variable {
+    if let Some(Yaml::String(var_type)) = fields.get(&Yaml::String("type".to_string())) {
+      if var_type != "form" {
+        return;
+      }
+    }
+
+    if let Some(Yaml::Hash(params)) = fields.get_mut(&Yaml::String("params".to_string())) {
+      if let Some(Yaml::String(layout)) = params.get_mut(&Yaml::String("layout".to_string())) {
+        let converted = replace_legacy_form_syntax_with_new_one(layout);
+        if &converted != layout {
+          layout.clear();
+          layout.push_str(&converted);
+        }
+      }
+    }
+  }
+}
+
+lazy_static! {
+  static ref LEGACY_FIELD_REGEX: Regex = Regex::new(r"\{\{(?P<name>.*?)\}\}").unwrap();
+}
+
+fn replace_legacy_form_syntax_with_new_one(layout: &str) -> String {
+  LEGACY_FIELD_REGEX
+    .replace_all(layout, |caps: &Captures| {
+      let field_name = caps.name("name").unwrap().as_str();
+      format!("[[{}]]", field_name)
+    })
+    .to_string()
+}
diff --git a/espanso-migrate/src/lib.rs b/espanso-migrate/src/lib.rs
index 1208053d..d3c97106 100644
--- a/espanso-migrate/src/lib.rs
+++ b/espanso-migrate/src/lib.rs
@@ -200,12 +200,14 @@ mod tests {
   static BASE_CASE: Dir = include_dir!("test/base");
   static ALL_PARAMS_CASE: Dir = include_dir!("test/all_params");
   static OTHER_DIRS_CASE: Dir = include_dir!("test/other_dirs");
+  static FORM_SYNTAX: Dir = include_dir!("test/form_syntax");
 
   #[allow(clippy::unused_unit)]
   #[test_case(&SIMPLE_CASE; "simple case")]
   #[test_case(&BASE_CASE; "base case")]
   #[test_case(&ALL_PARAMS_CASE; "all config parameters case")]
   #[test_case(&OTHER_DIRS_CASE; "other directories case")]
+  #[test_case(&FORM_SYNTAX; "form syntax")]
   fn test_migration(test_data: &Dir) {
     run_with_temp_dir(test_data, |legacy, expected| {
       let tmp_out_dir = TempDir::new("espanso-migrate-out").unwrap();
diff --git a/espanso-modulo/src/form/parser/layout.rs b/espanso-modulo/src/form/parser/layout.rs
index 0f77587b..4336b1e5 100644
--- a/espanso-modulo/src/form/parser/layout.rs
+++ b/espanso-modulo/src/form/parser/layout.rs
@@ -21,7 +21,8 @@ use super::split::*;
 use regex::Regex;
 
 lazy_static! {
-  static ref FIELD_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}").unwrap();
+  // We need to match for both the new [[name]] syntax and the legacy {{name}} one
+  static ref FIELD_REGEX: Regex = Regex::new(r"\{\{(.*?)\}\}|\[\[(.*?)\]\]").unwrap();
 }
 
 #[derive(Debug, Clone, PartialEq)]
@@ -57,6 +58,9 @@ pub fn parse_layout(layout: &str) -> Vec<Vec<Token>> {
           if let Some(name) = caps.get(1) {
             let name = name.as_str().to_owned();
             row.push(Token::Field(name));
+          } else if let Some(name) = caps.get(2) {
+            let name = name.as_str().to_owned();
+            row.push(Token::Field(name));
           }
         }
       }
@@ -74,7 +78,7 @@ mod tests {
 
   #[test]
   fn test_parse_layout() {
-    let layout = "Hey {{name}},\nHow are you?\n  \nCheers";
+    let layout = "Hey [[name]],\nHow are you?\n  \nCheers";
     let result = parse_layout(layout);
     assert_eq!(
       result,
@@ -92,7 +96,7 @@ mod tests {
 
   #[test]
   fn test_parse_layout_2() {
-    let layout = "Hey {{name}} {{surname}},";
+    let layout = "Hey [[name]] [[surname]],";
     let result = parse_layout(layout);
     assert_eq!(
       result,
@@ -105,4 +109,22 @@ mod tests {
       ],]
     );
   }
+
+  #[test]
+  fn test_parse_layout_legacy_syntax() {
+    let layout = "Hey {{name}},\nHow are you?\n  \nCheers";
+    let result = parse_layout(layout);
+    assert_eq!(
+      result,
+      vec![
+        vec![
+          Token::Text("Hey ".to_owned()),
+          Token::Field("name".to_owned()),
+          Token::Text(",".to_owned())
+        ],
+        vec![Token::Text("How are you?".to_owned())],
+        vec![Token::Text("Cheers".to_owned())],
+      ]
+    );
+  }
 }
diff --git a/espanso-render/src/extension/form.rs b/espanso-render/src/extension/form.rs
index c75c75d6..623b4825 100644
--- a/espanso-render/src/extension/form.rs
+++ b/espanso-render/src/extension/form.rs
@@ -17,14 +17,11 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use crate::renderer::VAR_REGEX;
 use log::error;
 use std::collections::HashMap;
 use thiserror::Error;
 
-use crate::{
-  renderer::render_variables, Extension, ExtensionOutput, ExtensionResult, Params, Value,
-};
+use crate::{Extension, ExtensionOutput, ExtensionResult, Params, Value};
 
 lazy_static! {
   static ref EMPTY_PARAMS: Params = Params::new();
@@ -59,7 +56,7 @@ impl<'a> Extension for FormExtension<'a> {
   fn calculate(
     &self,
     _: &crate::Context,
-    scope: &crate::Scope,
+    _: &crate::Scope,
     params: &Params,
   ) -> crate::ExtensionResult {
     let layout = if let Some(Value::String(layout)) = params.get("layout") {
@@ -68,15 +65,12 @@ impl<'a> Extension for FormExtension<'a> {
       return crate::ExtensionResult::Error(FormExtensionError::MissingLayout.into());
     };
 
-    let mut fields = if let Some(Value::Object(fields)) = params.get("fields") {
+    let fields = if let Some(Value::Object(fields)) = params.get("fields") {
       fields.clone()
     } else {
       Params::new()
     };
 
-    // Inject scope variables into fields (if needed)
-    inject_scope(&mut fields, scope);
-
     match self.provider.show(layout, &fields, &EMPTY_PARAMS) {
       FormProviderResult::Success(values) => {
         ExtensionResult::Success(ExtensionOutput::Multiple(values))
@@ -87,25 +81,6 @@ impl<'a> Extension for FormExtension<'a> {
   }
 }
 
-// TODO: test
-fn inject_scope(fields: &mut HashMap<String, Value>, scope: &HashMap<&str, ExtensionOutput>) {
-  for value in fields.values_mut() {
-    if let Value::Object(field_options) = value {
-      if let Some(Value::String(default_value)) = field_options.get_mut("default") {
-        if VAR_REGEX.is_match(default_value) {
-          match render_variables(default_value, scope) {
-            Ok(rendered) => *default_value = rendered,
-            Err(err) => error!(
-              "error while injecting variable in form default value: {}",
-              err
-            ),
-          }
-        }
-      }
-    }
-  }
-}
-
 #[derive(Error, Debug)]
 pub enum FormExtensionError {
   #[error("missing layout parameter")]
diff --git a/espanso-render/src/lib.rs b/espanso-render/src/lib.rs
index b9932da6..0517f9f5 100644
--- a/espanso-render/src/lib.rs
+++ b/espanso-render/src/lib.rs
@@ -98,7 +98,10 @@ impl Default for Template {
 pub struct Variable {
   pub name: String,
   pub var_type: String,
+  pub inject_vars: bool,
   pub params: Params,
+  // Name of the variables this variable depends on
+  pub depends_on: Vec<String>,
 }
 
 impl Default for Variable {
@@ -106,7 +109,9 @@ impl Default for Variable {
     Self {
       name: "".to_string(),
       var_type: "".to_string(),
+      inject_vars: true,
       params: Params::new(),
+      depends_on: Vec::new(),
     }
   }
 }
diff --git a/espanso-render/src/renderer/mod.rs b/espanso-render/src/renderer/mod.rs
index 917d9211..92b38e6a 100644
--- a/espanso-render/src/renderer/mod.rs
+++ b/espanso-render/src/renderer/mod.rs
@@ -1,5 +1,5 @@
 /*
- * This file is part of esp name: (), var_type: (), params: ()anso.
+ * This file is part of espanso.
  *
  * Copyright (C) 2019-2021 Federico Terzi
  *
@@ -17,18 +17,19 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
-use std::collections::{HashMap, HashSet};
+use std::{borrow::Cow, collections::HashMap};
 
 use crate::{
   CasingStyle, Context, Extension, ExtensionOutput, ExtensionResult, RenderOptions, RenderResult,
   Renderer, Scope, Template, Value, Variable,
 };
-use anyhow::Result;
 use log::{error, warn};
 use regex::{Captures, Regex};
 use thiserror::Error;
-use util::get_body_variable_names;
 
+use self::util::{inject_variables_into_params, render_variables};
+
+mod resolve;
 mod util;
 
 lazy_static! {
@@ -59,23 +60,6 @@ impl<'a> Renderer for DefaultRenderer<'a> {
     options: &RenderOptions,
   ) -> RenderResult {
     let body = if VAR_REGEX.is_match(&template.body) {
-      // In order to define a variable evaluation order, we first need to find
-      // the global variables that are being used but for which an explicit order
-      // is not defined.
-      let body_variable_names = get_body_variable_names(&template.body);
-      let local_variable_names: HashSet<&str> =
-        template.vars.iter().map(|var| var.name.as_str()).collect();
-      let missing_global_variable_names: HashSet<&str> = body_variable_names
-        .difference(&local_variable_names)
-        .copied()
-        .collect();
-      let missing_global_variables: Vec<&Variable> = context
-        .global_vars
-        .iter()
-        .copied()
-        .filter(|global_var| missing_global_variable_names.contains(&*global_var.name))
-        .collect();
-
       // Convert "global" variable type aliases when needed
       let local_variables: Vec<&Variable> =
         if template.vars.iter().any(|var| var.var_type == "global") {
@@ -99,10 +83,16 @@ impl<'a> Renderer for DefaultRenderer<'a> {
           template.vars.iter().collect()
         };
 
-      // The implicit global variables will be evaluated first, followed by the local vars
-      let mut variables: Vec<&Variable> = Vec::new();
-      variables.extend(missing_global_variables);
-      variables.extend(local_variables.iter());
+      // Here we execute a graph dependency resolution algorithm to determine a valid
+      // evaluation order for variables.
+      let variables = match resolve::resolve_evaluation_order(
+        &template.body,
+        &local_variables,
+        &context.global_vars,
+      ) {
+        Ok(variables) => variables,
+        Err(err) => return RenderResult::Error(err),
+      };
 
       // Compute the variable outputs
       let mut scope = Scope::new();
@@ -123,7 +113,31 @@ impl<'a> Renderer for DefaultRenderer<'a> {
             return RenderResult::Error(RendererError::MissingSubMatch.into());
           }
         } else if let Some(extension) = self.extensions.get(&variable.var_type) {
-          match extension.calculate(context, &scope, &variable.params) {
+          let variable_params = if variable.inject_vars {
+            match inject_variables_into_params(&variable.params, &scope) {
+              Ok(augmented_params) => Cow::Owned(augmented_params),
+              Err(err) => {
+                error!(
+                  "unable to inject variables into params of variable '{}': {}",
+                  variable.name, err
+                );
+
+                if variable.var_type == "form" {
+                  if let Some(RendererError::MissingVariable(_)) =
+                    err.downcast_ref::<RendererError>()
+                  {
+                    log_new_form_syntax_tip();
+                  }
+                }
+
+                return RenderResult::Error(err);
+              }
+            }
+          } else {
+            Cow::Borrowed(&variable.params)
+          };
+
+          match extension.calculate(context, &scope, &variable_params) {
             ExtensionResult::Success(output) => {
               scope.insert(&variable.name, output);
             }
@@ -161,6 +175,8 @@ impl<'a> Renderer for DefaultRenderer<'a> {
       template.body.clone()
     };
 
+    let body = util::unescape_variable_inections(&body);
+
     // Process the casing style
     let body_with_casing = match options.casing_style {
       CasingStyle::None => body,
@@ -192,52 +208,6 @@ impl<'a> Renderer for DefaultRenderer<'a> {
   }
 }
 
-// TODO: test
-pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result<String> {
-  let mut replacing_error = None;
-  let output = VAR_REGEX
-    .replace_all(body, |caps: &Captures| {
-      let var_name = caps.name("name").unwrap().as_str();
-      let var_subname = caps.name("subname");
-      match scope.get(var_name) {
-        Some(output) => match output {
-          ExtensionOutput::Single(output) => output,
-          ExtensionOutput::Multiple(results) => match var_subname {
-            Some(var_subname) => {
-              let var_subname = var_subname.as_str();
-              results.get(var_subname).map_or("", |value| &*value)
-            }
-            None => {
-              error!(
-                "nested name missing from multi-value variable: {}",
-                var_name
-              );
-              replacing_error = Some(RendererError::MissingVariable(format!(
-                "nested name missing from multi-value variable: {}",
-                var_name
-              )));
-              ""
-            }
-          },
-        },
-        None => {
-          replacing_error = Some(RendererError::MissingVariable(format!(
-            "variable '{}' is missing",
-            var_name
-          )));
-          ""
-        }
-      }
-    })
-    .to_string();
-
-  if let Some(error) = replacing_error {
-    return Err(error.into());
-  }
-
-  Ok(output)
-}
-
 fn get_matching_template<'a>(
   variable: &Variable,
   templates: &'a [&Template],
@@ -254,6 +224,24 @@ fn get_matching_template<'a>(
   }
 }
 
+fn log_new_form_syntax_tip() {
+  error!("");
+  error!("TIP: This error might be happening because since version 2.1.0-alpha, Espanso changed");
+  error!("the syntax to define form controls. Instead of `{{{{control}}}}` you need to use");
+  error!("[[control]] (using square brackets instead of curly brackets).");
+  error!("");
+  error!("For example, if you have a form defined like the following:");
+  error!("  - trigger: test");
+  error!("    form: |");
+  error!("      Hi {{{{name}}}}!");
+  error!("");
+  error!("You'll need to replace it with:");
+  error!("  - trigger: test");
+  error!("    form: |");
+  error!("      Hi [[name]]!");
+  error!("");
+}
+
 #[derive(Error, Debug)]
 pub enum RendererError {
   #[error("missing variable: `{0}`")]
@@ -261,6 +249,9 @@ pub enum RendererError {
 
   #[error("missing sub match")]
   MissingSubMatch,
+
+  #[error("circular dependency: `{0}` -> `{1}`")]
+  CircularDependency(String, String),
 }
 
 #[cfg(test)]
@@ -285,6 +276,13 @@ mod tests {
       if let Some(Value::String(string)) = params.get("echo") {
         return ExtensionResult::Success(ExtensionOutput::Single(string.clone()));
       }
+      if let (Some(Value::String(name)), Some(Value::String(value))) =
+        (params.get("name"), params.get("value"))
+      {
+        let mut map = HashMap::new();
+        map.insert(name.to_string(), value.to_string());
+        return ExtensionResult::Success(ExtensionOutput::Multiple(map));
+      }
       // If the "read" param is present, echo the value of the corresponding result in the scope
       if let Some(Value::String(string)) = params.get("read") {
         if let Some(ExtensionOutput::Single(value)) = scope.get(string.as_str()) {
@@ -324,6 +322,7 @@ mod tests {
         params: vec![("echo".to_string(), Value::String((*value).to_string()))]
           .into_iter()
           .collect::<Params>(),
+        ..Default::default()
       })
       .collect();
     Template {
@@ -393,6 +392,28 @@ mod tests {
     assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
   }
 
+  #[test]
+  fn dict_variable_variable() {
+    let renderer = get_renderer();
+    let template = Template {
+      body: "hello {{var.nested}}".to_string(),
+      vars: vec![Variable {
+        name: "var".to_string(),
+        var_type: "mock".to_string(),
+        params: vec![
+          ("name".to_string(), Value::String("nested".to_string())),
+          ("value".to_string(), Value::String("dict".to_string())),
+        ]
+        .into_iter()
+        .collect::<Params>(),
+        ..Default::default()
+      }],
+      ..Default::default()
+    };
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello dict"));
+  }
+
   #[test]
   fn missing_variable() {
     let renderer = get_renderer();
@@ -415,6 +436,7 @@ mod tests {
             "echo".to_string(),
             Value::String("world".to_string()),
           )]),
+          ..Default::default()
         }],
         ..Default::default()
       },
@@ -423,6 +445,31 @@ mod tests {
     assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
   }
 
+  #[test]
+  fn global_dict_variable() {
+    let renderer = get_renderer();
+    let template = template("hello {{var.nested}}", &[]);
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![&Variable {
+          name: "var".to_string(),
+          var_type: "mock".to_string(),
+          params: vec![
+            ("name".to_string(), Value::String("nested".to_string())),
+            ("value".to_string(), Value::String("dict".to_string())),
+          ]
+          .into_iter()
+          .collect::<Params>(),
+          ..Default::default()
+        }],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello dict"));
+  }
+
   #[test]
   fn global_variable_explicit_ordering() {
     let renderer = get_renderer();
@@ -435,6 +482,7 @@ mod tests {
           params: vec![("echo".to_string(), Value::String("Bob".to_string()))]
             .into_iter()
             .collect::<Params>(),
+          ..Default::default()
         },
         Variable {
           name: "var".to_string(),
@@ -454,6 +502,7 @@ mod tests {
             "read".to_string(),
             Value::String("local".to_string()),
           )]),
+          ..Default::default()
         }],
         ..Default::default()
       },
@@ -462,6 +511,147 @@ mod tests {
     assert!(matches!(res, RenderResult::Success(str) if str == "hello Bob Bob"));
   }
 
+  #[test]
+  fn nested_global_variable() {
+    let renderer = get_renderer();
+    let template = template("hello {{var2}}", &[]);
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![
+          &Variable {
+            name: "var".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("world".to_string()),
+            )]),
+            ..Default::default()
+          },
+          &Variable {
+            name: "var2".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("{{var}}".to_string()),
+            )]),
+            ..Default::default()
+          },
+        ],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello world"));
+  }
+
+  #[test]
+  fn nested_global_variable_circular_dependency_should_fail() {
+    let renderer = get_renderer();
+    let template = template("hello {{var}}", &[]);
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![
+          &Variable {
+            name: "var".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("{{var2}}".to_string()),
+            )]),
+            ..Default::default()
+          },
+          &Variable {
+            name: "var2".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("{{var3}}".to_string()),
+            )]),
+            ..Default::default()
+          },
+          &Variable {
+            name: "var3".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("{{var}}".to_string()),
+            )]),
+            ..Default::default()
+          },
+        ],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Error(_)));
+  }
+
+  #[test]
+  fn global_variable_depends_on() {
+    let renderer = get_renderer();
+    let template = template("hello {{var}}", &[]);
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![
+          &Variable {
+            name: "var".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![(
+              "echo".to_string(),
+              Value::String("world".to_string()),
+            )]),
+            depends_on: vec!["var2".to_string()],
+            ..Default::default()
+          },
+          &Variable {
+            name: "var2".to_string(),
+            var_type: "mock".to_string(),
+            params: Params::from_iter(vec![("abort".to_string(), Value::Null)]),
+            ..Default::default()
+          },
+        ],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Aborted));
+  }
+
+  #[test]
+  fn local_variable_explicit_ordering() {
+    let renderer = get_renderer();
+    let template = Template {
+      body: "hello {{var}}".to_string(),
+      vars: vec![Variable {
+        name: "var".to_string(),
+        var_type: "mock".to_string(),
+        params: vec![("echo".to_string(), Value::String("something".to_string()))]
+          .into_iter()
+          .collect::<Params>(),
+        depends_on: vec!["global".to_string()],
+        ..Default::default()
+      }],
+      ..Default::default()
+    };
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![&Variable {
+          name: "global".to_string(),
+          var_type: "mock".to_string(),
+          params: Params::from_iter(vec![("abort".to_string(), Value::Null)]),
+          ..Default::default()
+        }],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Aborted));
+  }
+
   #[test]
   fn nested_match() {
     let renderer = get_renderer();
@@ -473,6 +663,7 @@ mod tests {
         params: vec![("trigger".to_string(), Value::String("nested".to_string()))]
           .into_iter()
           .collect::<Params>(),
+        ..Default::default()
       }],
       ..Default::default()
     };
@@ -503,6 +694,7 @@ mod tests {
         params: vec![("trigger".to_string(), Value::String("nested".to_string()))]
           .into_iter()
           .collect::<Params>(),
+        ..Default::default()
       }],
       ..Default::default()
     };
@@ -527,6 +719,7 @@ mod tests {
         params: vec![("abort".to_string(), Value::Null)]
           .into_iter()
           .collect::<Params>(),
+        ..Default::default()
       }],
       ..Default::default()
     };
@@ -545,10 +738,213 @@ mod tests {
         params: vec![("error".to_string(), Value::Null)]
           .into_iter()
           .collect::<Params>(),
+        ..Default::default()
       }],
       ..Default::default()
     };
     let res = renderer.render(&template, &Default::default(), &Default::default());
     assert!(matches!(res, RenderResult::Error(_)));
   }
+
+  #[test]
+  fn variable_injection() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{fullname}}");
+    template.vars = vec![
+      Variable {
+        name: "firstname".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("John".to_string()),
+        )]),
+        ..Default::default()
+      },
+      Variable {
+        name: "lastname".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("Snow".to_string()),
+        )]),
+        ..Default::default()
+      },
+      Variable {
+        name: "fullname".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("{{firstname}} {{lastname}}".to_string()),
+        )]),
+        inject_vars: true,
+        ..Default::default()
+      },
+    ];
+
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello John Snow"));
+  }
+
+  #[test]
+  fn disable_variable_injection() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{second}}");
+    template.vars = vec![
+      Variable {
+        name: "first".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![("echo".to_string(), Value::String("one".to_string()))]),
+        ..Default::default()
+      },
+      Variable {
+        name: "second".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("{{first}} two".to_string()),
+        )]),
+        inject_vars: false,
+        ..Default::default()
+      },
+    ];
+
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello {{first}} two"));
+  }
+
+  #[test]
+  fn escaped_variable_injection() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{second}}");
+    template.vars = vec![
+      Variable {
+        name: "first".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![("echo".to_string(), Value::String("one".to_string()))]),
+        ..Default::default()
+      },
+      Variable {
+        name: "second".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("\\{\\{first\\}\\} two".to_string()),
+        )]),
+        ..Default::default()
+      },
+    ];
+
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello {{first}} two"));
+  }
+
+  #[test]
+  fn variable_injection_missing_var() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{second}}");
+    template.vars = vec![Variable {
+      name: "second".to_string(),
+      var_type: "mock".to_string(),
+      params: Params::from_iter(vec![(
+        "echo".to_string(),
+        Value::String("the next is {{missing}}".to_string()),
+      )]),
+      ..Default::default()
+    }];
+
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Error(_)));
+  }
+
+  #[test]
+  fn variable_injection_with_global_variable() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{output}}");
+    template.vars = vec![
+      Variable {
+        name: "var".to_string(),
+        var_type: "global".to_string(),
+        ..Default::default()
+      },
+      Variable {
+        name: "output".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("{{var}}".to_string()),
+        )]),
+        ..Default::default()
+      },
+    ];
+
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![&Variable {
+          name: "var".to_string(),
+          var_type: "mock".to_string(),
+          params: Params::from_iter(vec![(
+            "echo".to_string(),
+            Value::String("global".to_string()),
+          )]),
+          ..Default::default()
+        }],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello global"));
+  }
+
+  #[test]
+  fn variable_injection_local_var_takes_precedence_over_global() {
+    let renderer = get_renderer();
+    let mut template = template_for_str("hello {{output}}");
+    template.vars = vec![
+      Variable {
+        name: "var".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("local".to_string()),
+        )]),
+        ..Default::default()
+      },
+      Variable {
+        name: "output".to_string(),
+        var_type: "mock".to_string(),
+        params: Params::from_iter(vec![(
+          "echo".to_string(),
+          Value::String("{{var}}".to_string()),
+        )]),
+        ..Default::default()
+      },
+    ];
+
+    let res = renderer.render(
+      &template,
+      &Context {
+        global_vars: vec![&Variable {
+          name: "var".to_string(),
+          var_type: "mock".to_string(),
+          params: Params::from_iter(vec![(
+            "echo".to_string(),
+            Value::String("global".to_string()),
+          )]),
+          ..Default::default()
+        }],
+        ..Default::default()
+      },
+      &Default::default(),
+    );
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello local"));
+  }
+
+  #[test]
+  fn variable_escape() {
+    let renderer = get_renderer();
+    let template = template("hello \\{\\{var\\}\\}", &[("var", "world")]);
+    let res = renderer.render(&template, &Default::default(), &Default::default());
+    assert!(matches!(res, RenderResult::Success(str) if str == "hello {{var}}"));
+  }
 }
diff --git a/espanso-render/src/renderer/resolve.rs b/espanso-render/src/renderer/resolve.rs
new file mode 100644
index 00000000..c1534080
--- /dev/null
+++ b/espanso-render/src/renderer/resolve.rs
@@ -0,0 +1,204 @@
+/*
+ * This file is part of espanso.
+ *
+ * Copyright (C) 2019-2021 Federico Terzi
+ *
+ * espanso is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * espanso is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
+ */
+
+use std::{
+  cell::RefCell,
+  collections::{HashMap, HashSet},
+};
+
+use anyhow::{anyhow, Result};
+use log::error;
+
+use crate::Variable;
+
+use super::RendererError;
+
+struct Node<'a> {
+  name: &'a str,
+  variable: Option<&'a Variable>,
+  dependencies: Option<HashSet<&'a str>>,
+}
+
+pub(crate) fn resolve_evaluation_order<'a>(
+  body: &'a str,
+  local_vars: &'a [&'a Variable],
+  global_vars: &'a [&'a Variable],
+) -> Result<Vec<&'a Variable>> {
+  let node_map = generate_nodes(body, local_vars, global_vars);
+
+  let body_node = node_map
+    .get("__match_body")
+    .ok_or_else(|| anyhow!("missing body node"))?;
+
+  let eval_order = RefCell::new(Vec::new());
+  let resolved = RefCell::new(HashSet::new());
+  let seen = RefCell::new(HashSet::new());
+  {
+    resolve_dependencies(body_node, &node_map, &eval_order, &resolved, &seen)?;
+  }
+
+  let eval_order_ref = eval_order.borrow();
+
+  let mut ordered_variables = Vec::new();
+  for var_name in (*eval_order_ref).iter() {
+    let node = node_map
+      .get(var_name)
+      .ok_or_else(|| anyhow!("could not find dependency node for variable: {}", var_name))?;
+    if let Some(var) = node.variable {
+      ordered_variables.push(var);
+    }
+  }
+
+  Ok(ordered_variables)
+}
+
+fn generate_nodes<'a>(
+  body: &'a str,
+  local_vars: &'a [&'a Variable],
+  global_vars: &'a [&'a Variable],
+) -> HashMap<&'a str, Node<'a>> {
+  let mut local_vars_nodes = Vec::new();
+  for (index, var) in local_vars.iter().enumerate() {
+    let mut dependencies = HashSet::new();
+    if var.inject_vars {
+      dependencies.extend(super::util::get_params_variable_names(&var.params));
+    }
+    dependencies.extend(var.depends_on.iter().map(|v| v.as_str()));
+
+    // Every local variable depends on the one before it.
+    // Needed to guarantee execution order within local vars.
+    if index > 0 {
+      let previous_var = local_vars.get(index - 1);
+      if let Some(previous_var) = previous_var {
+        dependencies.insert(&previous_var.name);
+      }
+    }
+
+    local_vars_nodes.push(Node {
+      name: &var.name,
+      variable: Some(var),
+      dependencies: Some(dependencies),
+    });
+  }
+
+  let global_vars_nodes = global_vars.iter().map(|var| create_node_from_var(*var));
+
+  // The body depends on all local variables + the variables read inside it (which might be global)
+  let mut body_dependencies: HashSet<&str> =
+    local_vars_nodes.iter().map(|node| node.name).collect();
+  body_dependencies.extend(super::util::get_body_variable_names(body));
+
+  let body_node = Node {
+    name: "__match_body",
+    variable: None,
+    dependencies: Some(body_dependencies),
+  };
+
+  let mut node_map = HashMap::new();
+
+  node_map.insert(body_node.name, body_node);
+  global_vars_nodes.into_iter().for_each(|node| {
+    node_map.insert(node.name, node);
+  });
+  local_vars_nodes.into_iter().for_each(|node| {
+    node_map.insert(node.name, node);
+  });
+
+  node_map
+}
+
+fn create_node_from_var(var: &Variable) -> Node {
+  let dependencies = if var.inject_vars || !var.depends_on.is_empty() {
+    let mut vars = HashSet::new();
+
+    if var.inject_vars {
+      vars.extend(super::util::get_params_variable_names(&var.params))
+    }
+
+    vars.extend(var.depends_on.iter().map(|s| s.as_str()));
+
+    Some(vars)
+  } else {
+    None
+  };
+
+  Node {
+    name: &var.name,
+    variable: Some(var),
+    dependencies,
+  }
+}
+
+fn resolve_dependencies<'a>(
+  node: &'a Node,
+  node_map: &'a HashMap<&'a str, Node<'a>>,
+  eval_order: &'a RefCell<Vec<&'a str>>,
+  resolved: &'a RefCell<HashSet<&'a str>>,
+  seen: &'a RefCell<HashSet<&'a str>>,
+) -> Result<()> {
+  {
+    let mut seen_ref = seen.borrow_mut();
+    seen_ref.insert(node.name);
+  }
+
+  if let Some(dependencies) = &node.dependencies {
+    for dependency in dependencies.iter() {
+      let has_been_resolved = {
+        let resolved_ref = resolved.borrow();
+        resolved_ref.contains(dependency)
+      };
+      let has_been_seen = {
+        let seen_ref = seen.borrow();
+        seen_ref.contains(dependency)
+      };
+
+      if !has_been_resolved {
+        if has_been_seen {
+          return Err(
+            RendererError::CircularDependency(node.name.to_string(), dependency.to_string()).into(),
+          );
+        }
+
+        match node_map.get(dependency) {
+          Some(dependency_node) => {
+            resolve_dependencies(dependency_node, node_map, eval_order, resolved, seen)?
+          }
+          None => {
+            error!("could not resolve variable {:?}", dependency);
+            if let Some(variable) = &node.variable {
+              if variable.var_type == "form" {
+                super::log_new_form_syntax_tip();
+              }
+            }
+            return Err(RendererError::MissingVariable(dependency.to_string()).into());
+          }
+        }
+      }
+    }
+  }
+
+  {
+    let mut eval_order_ref = eval_order.borrow_mut();
+    eval_order_ref.push(node.name);
+    let mut resolved_ref = resolved.borrow_mut();
+    resolved_ref.insert(node.name);
+  }
+
+  Ok(())
+}
diff --git a/espanso-render/src/renderer/util.rs b/espanso-render/src/renderer/util.rs
index e053cf79..299e164d 100644
--- a/espanso-render/src/renderer/util.rs
+++ b/espanso-render/src/renderer/util.rs
@@ -17,6 +17,11 @@
  * along with espanso.  If not, see <https://www.gnu.org/licenses/>.
  */
 
+use crate::{renderer::RendererError, ExtensionOutput, Params, Scope, Value};
+use anyhow::Result;
+use log::error;
+use regex::Captures;
+
 use super::VAR_REGEX;
 use std::collections::HashSet;
 
@@ -29,10 +34,128 @@ pub(crate) fn get_body_variable_names(body: &str) -> HashSet<&str> {
   variables
 }
 
+pub(crate) fn get_params_variable_names(params: &Params) -> HashSet<&str> {
+  let mut names = HashSet::new();
+
+  for (_, value) in params.iter() {
+    let local_names = get_value_variable_names_recursively(value);
+    names.extend(local_names);
+  }
+
+  names
+}
+
+fn get_value_variable_names_recursively(value: &Value) -> HashSet<&str> {
+  match value {
+    Value::String(s_value) => get_body_variable_names(s_value),
+    Value::Array(values) => {
+      let mut local_names: HashSet<&str> = HashSet::new();
+      for value in values {
+        local_names.extend(get_value_variable_names_recursively(value));
+      }
+      local_names
+    }
+    Value::Object(fields) => {
+      let mut local_names: HashSet<&str> = HashSet::new();
+      for value in fields.values() {
+        local_names.extend(get_value_variable_names_recursively(value));
+      }
+      local_names
+    }
+    _ => HashSet::new(),
+  }
+}
+
+pub(crate) fn render_variables(body: &str, scope: &Scope) -> Result<String> {
+  let mut replacing_error = None;
+  let output = VAR_REGEX
+    .replace_all(body, |caps: &Captures| {
+      let var_name = caps.name("name").unwrap().as_str();
+      let var_subname = caps.name("subname");
+      match scope.get(var_name) {
+        Some(output) => match output {
+          ExtensionOutput::Single(output) => output,
+          ExtensionOutput::Multiple(results) => match var_subname {
+            Some(var_subname) => {
+              let var_subname = var_subname.as_str();
+              results.get(var_subname).map_or("", |value| &*value)
+            }
+            None => {
+              error!(
+                "nested name missing from multi-value variable: {}",
+                var_name
+              );
+              replacing_error = Some(RendererError::MissingVariable(format!(
+                "nested name missing from multi-value variable: {}",
+                var_name
+              )));
+              ""
+            }
+          },
+        },
+        None => {
+          replacing_error = Some(RendererError::MissingVariable(format!(
+            "variable '{}' is missing",
+            var_name
+          )));
+          ""
+        }
+      }
+    })
+    .to_string();
+
+  if let Some(error) = replacing_error {
+    return Err(error.into());
+  }
+
+  let unescaped_output = unescape_variable_inections(&output);
+  Ok(unescaped_output)
+}
+
+pub(crate) fn unescape_variable_inections(body: &str) -> String {
+  body.replace("\\{\\{", "{{").replace("\\}\\}", "}}")
+}
+
+pub(crate) fn inject_variables_into_params(params: &Params, scope: &Scope) -> Result<Params> {
+  let mut params = params.clone();
+
+  for (_, value) in params.iter_mut() {
+    inject_variables_into_value(value, scope)?;
+  }
+
+  Ok(params)
+}
+
+fn inject_variables_into_value(value: &mut Value, scope: &Scope) -> Result<()> {
+  match value {
+    Value::String(s_value) => {
+      let new_value = render_variables(s_value, scope)?;
+
+      if &new_value != s_value {
+        s_value.clear();
+        s_value.push_str(&new_value);
+      }
+    }
+    Value::Array(values) => {
+      for value in values {
+        inject_variables_into_value(value, scope)?;
+      }
+    }
+    Value::Object(fields) => {
+      for value in fields.values_mut() {
+        inject_variables_into_value(value, scope)?;
+      }
+    }
+    _ => {}
+  }
+
+  Ok(())
+}
+
 #[cfg(test)]
 mod tests {
   use super::*;
-  use std::iter::FromIterator;
+  use std::{collections::HashMap, iter::FromIterator};
 
   #[test]
   fn get_body_variable_names_no_vars() {
@@ -49,4 +172,44 @@ mod tests {
       HashSet::from_iter(vec!["world", "greet"]),
     );
   }
+
+  #[test]
+  fn test_inject_variables_into_params() {
+    let mut params = Params::new();
+    params.insert(
+      "field1".to_string(),
+      Value::String("this contains {{first}}".to_string()),
+    );
+    params.insert("field2".to_string(), Value::Bool(true));
+    params.insert(
+      "field3".to_string(),
+      Value::Array(vec![Value::String("this contains {{first}}".to_string())]),
+    );
+
+    let mut nested = HashMap::new();
+    nested.insert(
+      "subfield1".to_string(),
+      Value::String("also contains {{first}}".to_string()),
+    );
+    params.insert("field4".to_string(), Value::Object(nested));
+
+    let mut scope = Scope::new();
+    scope.insert("first", ExtensionOutput::Single("one".to_string()));
+
+    let result = inject_variables_into_params(&params, &scope).unwrap();
+
+    assert_eq!(result.len(), 4);
+    assert_eq!(
+      result.get("field1").unwrap(),
+      &Value::String("this contains one".to_string())
+    );
+    assert_eq!(result.get("field2").unwrap(), &Value::Bool(true));
+    assert_eq!(
+      result.get("field3").unwrap(),
+      &Value::Array(vec![Value::String("this contains one".to_string())])
+    );
+    assert!(
+      matches!(result.get("field4").unwrap(), Value::Object(fields) if fields.get("subfield1").unwrap() == &Value::String("also contains one".to_string()))
+    );
+  }
 }
diff --git a/espanso/Cargo.toml b/espanso/Cargo.toml
index a80bffe1..72484845 100644
--- a/espanso/Cargo.toml
+++ b/espanso/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "espanso"
-version = "2.0.5-alpha"
+version = "2.1.0-alpha"
 authors = ["Federico Terzi <federicoterzi96@gmail.com>"]
 license = "GPL-3.0"
 description = "Cross-platform Text Expander written in Rust"
diff --git a/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs b/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs
index fb0510b4..16da24f0 100644
--- a/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs
+++ b/espanso/src/cli/worker/engine/process/middleware/render/extension/form.rs
@@ -63,14 +63,7 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
             .cloned(),
           values: params
             .get("values")
-            .and_then(|val| val.as_array())
-            .map(|arr| {
-              arr
-                .iter()
-                .flat_map(|choice| choice.as_string())
-                .cloned()
-                .collect()
-            })
+            .and_then(|v| extract_values(v, params.get("trim_string_values")))
             .unwrap_or_default(),
         }),
         Some(Value::String(field_type)) if field_type == "list" => Some(FormField::List {
@@ -80,14 +73,7 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
             .cloned(),
           values: params
             .get("values")
-            .and_then(|val| val.as_array())
-            .map(|arr| {
-              arr
-                .iter()
-                .flat_map(|choice| choice.as_string())
-                .cloned()
-                .collect()
-            })
+            .and_then(|v| extract_values(v, params.get("trim_string_values")))
             .unwrap_or_default(),
         }),
         // By default, it's considered type 'text'
@@ -113,3 +99,38 @@ fn convert_fields(fields: &Params) -> HashMap<String, FormField> {
   }
   out
 }
+
+fn extract_values(value: &Value, trim_string_values: Option<&Value>) -> Option<Vec<String>> {
+  let trim_string_values = *trim_string_values
+    .and_then(|v| v.as_bool())
+    .unwrap_or(&true);
+
+  match value {
+    Value::Array(values) => Some(
+      values
+        .iter()
+        .flat_map(|choice| choice.as_string())
+        .cloned()
+        .collect(),
+    ),
+    Value::String(values) => Some(
+      values
+        .lines()
+        .filter_map(|line| {
+          if trim_string_values {
+            let trimmed_line = line.trim();
+            if !trimmed_line.is_empty() {
+              Some(trimmed_line)
+            } else {
+              None
+            }
+          } else {
+            Some(line)
+          }
+        })
+        .map(String::from)
+        .collect(),
+    ),
+    _ => None,
+  }
+}
diff --git a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs
index e2f73ea6..9b5d7ae3 100644
--- a/espanso/src/cli/worker/engine/process/middleware/render/mod.rs
+++ b/espanso/src/cli/worker/engine/process/middleware/render/mod.rs
@@ -151,6 +151,8 @@ fn convert_var(var: espanso_config::matches::Variable) -> espanso_render::Variab
     name: var.name,
     var_type: var.var_type,
     params: convert_params(var.params),
+    inject_vars: var.inject_vars,
+    depends_on: var.depends_on,
   }
 }
 
@@ -225,6 +227,8 @@ impl<'a> Renderer<'a> for RendererAdapter<'a> {
               name,
               var_type: "echo".to_string(),
               params,
+              inject_vars: false,
+              ..Default::default()
             },
           )
         }
diff --git a/snapcraft.yaml b/snapcraft.yaml
index 4141d80d..ad623ef8 100644
--- a/snapcraft.yaml
+++ b/snapcraft.yaml
@@ -1,5 +1,5 @@
 name: espanso
-version: 2.0.5-alpha
+version: 2.1.0-alpha
 summary: A Cross-platform Text Expander written in Rust
 description: |
   espanso is a Cross-platform, Text Expander written in Rust.


Test patch:
diff --git a/espanso-migrate/test/form_syntax/expected/config/default.yml b/espanso-migrate/test/form_syntax/expected/config/default.yml
new file mode 100644
index 00000000..e69de29b
diff --git a/espanso-migrate/test/form_syntax/expected/match/base.yml b/espanso-migrate/test/form_syntax/expected/match/base.yml
new file mode 100644
index 00000000..474aecce
--- /dev/null
+++ b/espanso-migrate/test/form_syntax/expected/match/base.yml
@@ -0,0 +1,25 @@
+global_vars:
+  - name: global_form
+    type: form
+    params:
+      layout: |
+        Reverse [[name]]
+
+matches:
+  - trigger: ":greet"
+    form: |
+      Hey [[name]],
+      Happy Birthday!
+  
+  - trigger: ":rev"
+    replace: "{{reversed}}"
+    vars:
+    - name: form1
+      type: form
+      params:
+        layout: |
+          Reverse [[name]]
+    - name: reversed
+      type: shell
+      params:
+       cmd: "echo $ESPANSO_FORM1_NAME | rev"
\ No newline at end of file
diff --git a/espanso-migrate/test/form_syntax/legacy/default.yml b/espanso-migrate/test/form_syntax/legacy/default.yml
new file mode 100644
index 00000000..22324a1e
--- /dev/null
+++ b/espanso-migrate/test/form_syntax/legacy/default.yml
@@ -0,0 +1,25 @@
+global_vars:
+  - name: global_form
+    type: form
+    params:
+      layout: |
+        Reverse {{name}}
+
+matches:
+  - trigger: ":greet"
+    form: |
+      Hey {{name}},
+      Happy Birthday!
+  
+  - trigger: ":rev"
+    replace: "{{reversed}}"
+    vars:
+    - name: form1
+      type: form
+      params:
+        layout: |
+          Reverse {{name}}
+    - name: reversed
+      type: shell
+      params:
+       cmd: "echo $ESPANSO_FORM1_NAME | rev"
\ No newline at end of file