Repository: jtroo/kanata (Rust)

Issue:
Feature request: iif for templates
### Is your feature request related to a problem? Please describe.

now that we have templates I tried to use them to simplify my config and came to the point where I have a set of lengthy but almost identical templates.

I prefer to have just one template and a parameter to select the current version.

```
(defvar
  version1 a
  version2 b
)
(deftemplate with-version (version)(
   (equal $version $version1 (
        c 
     )
   )
)
```

so ` (template-expand with-version $version1)` would be `c`




### Describe the solution you'd like.

In the example above I called the `iif` part `equals` maybe not the best

Like `key-history` we could implement a new switch type `(switch ((var-equal $version $version1)) c break)` (this syntax would even work outside of templates, but I don't see a use case)


### Describe alternatives you've considered.

another syntax option would be
` (template-expand-if compare1 compare2 mytemplatename ...)`    

### Additional context

_No response_


Discussion:
Template parsing and expansion currently happens before any defvar variables are read.

Is the use case still satisfied by the proposal if variables are not expanded?
My original idea was to use a string compare to decide about equallty.
The reason I used variables was to give a version a human readable name (and bc there is no string type in kanata) .

I tried passing a var to a template, and it worked.
```
(defvar
  hb c
)
(deftemplate echo-me (ab) $ab )
...
(template-expand echo-me $hb)
```
this means a if-equal must evaluate at runtime, an compare two expressions? ... Am I still on the right track?
> this means a if-equal must evaluate at runtime, an compare two expressions? ... Am I still on the right track?

It is evaluated at parse time. My comment was intending to explain that `(if-equal $version $version1 ...)` with $version1 passed in will be `(if-equal $version1 $version1 ...)` which does match. To give another example, if within the template it is `(if-equal $version v1 ...)`, the expansion would become `(if-equal $version1 a ...)` which does not match because $version1 is not replaced by the defvar value.
Seems like the implementation should fit the use case, but leaving this open for a bit for clarification/feedback
git is not giving me the main branch with `(if-equal)` ... is parser a submodule?

anyway we need `if-not-equal` because that way we can use a single template parameter to expand the if an carry information

`(if-not-equal $output XX $output)` 
it is not the fault of git I have the latest parser/src/cfg/deftemplate.rs but I still get `Unknown action type: if-equal` 
Hm perhaps you are nesting `if-equal` within another `if-equal`. That's a use case that wasn't implemented yet.
Oh turns out I also forgot to add recursion into lists
```
 250 │         (switch
 251 │             () (if-equal $comboout $comboout w) break
     ·                 ────┬───
     ·                     ╰── Error here

  help: Unknown action type: if-equal
```
I got fould be **Unknown action type** kanata knows `if-equal` but the parser does not like it within `switch`

I used another syntax, and now get another error message :(

```
 250 │         (switch
 251 │             (if-equal $comboout $comboout () w break)
     ·              ────┬───
     ·                  ╰── Error here

  help: invalid key name
```
Fixed recursion and nested if-equal in https://github.com/jtroo/kanata/commit/09ba37cbaf87050fa6a8f76fdc65498876e1f76e

if-not-equal makes sense to add

I'm also thinking if perhaps we should allow one of the parameters to be a list, which would mean: if the non-list equals any item in the list, then it evaluates to true. Or perhaps use different syntax like `if-matches` and `if-not-matches`.
great it works.
regarding lists I'd prefer destinct list operators like `if-contains`, `if-in-list`, `if-ismember` (`if-matches` hints me toward regex)  
Implemented `if-not-equal`, `if-in-list`, `if-not-in-list` in 498f067a93af305a559f45657f67229fee304cee.
after using `if-...` I see why the list versions are needed, but what ist missing is a test for the empty list `()` like `if-list-is-empty`.
but one could use a workaround like testing for `($off)`
```
(deftemplate combo combokeylist comboout 
...
(switch
		    (if-not-in-list $off ($combokeylist) $combokeylist $comboout break)  

...
 (template-expand combo ($off) XX)
``` 
I was surprised that I had to use ` ($combokeylist)` for `if-not-in-list` to accept it as list where as `switch` accepts `combokeylist` as list ( without extra paranthesis)

also I defined $off as `(defvar off a)` wich works as long as I compara $off with $off. but comparing `$off` to `a` fails.  
This seems like intended behaviour, which is clarified in https://github.com/jtroo/kanata/commit/ae6aa25929c9b7e670ef07dff0ec818e955f7329.

I believe only one of the following makes sense to do:
- Parse `defvar` first and substitute `defvar` values for use in templates. Forbid `defvar` within `deftemplate`.
- Parse `deftemplate` first and allow templates to be used inside of a `defvar`. As a result `defvar` substitutions do not take effect in templates.

The code currently goes with the second choice, but perhaps it makes more sense to use the first choice? Probably worth a new ticket and discussion.



Patch:
diff --git a/cfg_samples/kanata.kbd b/cfg_samples/kanata.kbd
index f0d904ddb..4235695e7 100644
--- a/cfg_samples/kanata.kbd
+++ b/cfg_samples/kanata.kbd
@@ -1,5 +1,9 @@
 #|
 This is a sample configuration file that showcases every feature in kanata.
+A more detailed and less terse configuration guide is found here:
+
+    https://github.com/jtroo/kanata/blob/main/docs/config.adoc
+
 If anything is confusing or hard to discover, please file an issue or
 contribute a pull request to help improve the document.
 
@@ -11,9 +15,6 @@ three following keys together at the same time will cause kanata to exit:
 This is for the physical key input rather than after any remappings
 that are done by kanata.
 
-This mechanism works on the key input*before any remappings done by kanata.
-
-https://github.com/jtroo/kanata/blob/main/docs/config.adoc
 |#
 
 ;; Single-line comments are prefixed by double-semicolon. A single semicolon
@@ -1094,4 +1095,14 @@ If you need help, please feel welcome to ask in the GitHub discussions.
   _    @dva @dvo @dve @dvu _    _    _    _    _    _    _    _
   _    _    _    _    _    _    _    _    _    _    _    _
   _    _    _              _              _    _    _
+)
+
+;; Within a deftemplate you can use if-equal to conditionally insert content
+;; into the template.
+
+(deftemplate home-row (version)
+  a s d f g h 
+  (if-equal $version v1 j)
+  (if-equal $version v2 (tap-hold 200 200 j lctl))
+   k l ; '
 )
\ No newline at end of file
diff --git a/docs/config.adoc b/docs/config.adoc
index 6a21705c0..dd94b66a9 100644
--- a/docs/config.adoc
+++ b/docs/config.adoc
@@ -2646,6 +2646,45 @@ Its parameters in order are:
 )
 ----
 
+==== if-equal
+
+Within a template you can use the list item `if-equal`
+to have condiditionally-used items within a template.
+
+It accepts a minimum of 2 parameters.
+The first two parameters must be strings and are compared
+against each other.
+If they match, the following parameters are inserted into
+the template in place of the `if-equal` list.
+Otherwise if the strings do not match
+then the whole `if-equal` list is removed from the template.
+
+.Example:
+----
+(deftemplate home-row (version)
+  a s d f g h 
+  (if-equal $version v1 j)
+  (if-equal $version v2 (tap-hold 200 200 j lctl))
+   k l ; '
+)
+
+(defsrc
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row v1)                            ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+  
+(deflayer base
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row v2)                            ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+----
+
 [[custom-tap-hold-behaviour]]
 === Custom tap-hold behaviour
 <<table-of-contents,Back to ToC>>
diff --git a/parser/src/cfg/deftemplate.rs b/parser/src/cfg/deftemplate.rs
index 93e890d67..a2846b9c9 100644
--- a/parser/src/cfg/deftemplate.rs
+++ b/parser/src/cfg/deftemplate.rs
@@ -9,10 +9,10 @@
 //!   Instead the code could pre-compute the paths to access every variable
 //!   that needs substition. (perf_1)
 //!
-//! - Replacing the `template-expand` items with the expanded template
-//!   recreates the Vec for every replacement that happens at that layer.
-//!   Instead the code could do a single pass
-//!   and intelligently insert SExprs at the proper places. (perf_2)
+//! - Replacing the `template-expand|if-equal` items with the appropriate values
+//!   recreates the Vec for every replacement that happens at that recursion depth.
+//!   Instead the code could do recreate the vec only once
+//!   and insert SExprs at the proper places. (perf_2)
 
 use crate::anyhow_expr;
 use crate::anyhow_span;
@@ -175,15 +175,6 @@ fn visit_validate_all_atoms(
     Ok(())
 }
 
-fn visit_mut_all_atoms(exprs: &mut [SExpr], visit: &mut dyn FnMut(&mut SExpr)) {
-    for expr in exprs {
-        match expr {
-            SExpr::Atom(_) => visit(expr),
-            SExpr::List(l) => visit_mut_all_atoms(&mut l.t, visit),
-        }
-    }
-}
-
 struct Replacement {
     exprs: Vec<SExpr>,
     insert_index: usize,
@@ -230,10 +221,13 @@ fn expand(exprs: &mut Vec<SExpr>, templates: &[Template]) -> Result<()> {
 
                 let var_substitutions = l.t.iter().skip(2);
                 let mut expanded_template = template.content.clone();
+                // Substitute variables.
                 // perf_1 : could store substitution knowledge instead of iterating and searching
                 // every time
                 visit_mut_all_atoms(&mut expanded_template, &mut |expr: &mut SExpr| {
                     *expr = match expr {
+                        // Below should not be reached because only atoms should be visited
+                        SExpr::List(_) => unreachable!(),
                         SExpr::Atom(a) => {
                             match template
                                 .vars_substitute_names
@@ -249,8 +243,6 @@ fn expand(exprs: &mut Vec<SExpr>, templates: &[Template]) -> Result<()> {
                                     .clone(),
                             }
                         }
-                        // Below should not be reached because only atoms should be visited
-                        SExpr::List(_) => unreachable!(),
                     }
                 });
 
@@ -278,5 +270,91 @@ fn expand(exprs: &mut Vec<SExpr>, templates: &[Template]) -> Result<()> {
         *exprs = new_vec;
     }
 
+    expand_if_equal(exprs)?;
+
+    Ok(())
+}
+
+fn visit_mut_all_atoms(exprs: &mut [SExpr], visit: &mut dyn FnMut(&mut SExpr)) {
+    for expr in exprs {
+        match expr {
+            SExpr::Atom(_) => visit(expr),
+            SExpr::List(l) => visit_mut_all_atoms(&mut l.t, visit),
+        }
+    }
+}
+
+fn expand_if_equal(exprs: &mut Vec<SExpr>) -> Result<()> {
+    let mut replacements: Vec<Replacement> = vec![];
+    for (index, expr) in exprs.iter_mut().enumerate() {
+        if matches!(expr, SExpr::Atom(_)) {
+            continue;
+        }
+        // expr must be a list, visit it
+        if let Some(exprs) = if_equal_replacement(expr)? {
+            replacements.push(Replacement {
+                exprs,
+                insert_index: index,
+            })
+        }
+    }
+    // Ensure replacements are sorted. They probably are, but may as well make sure.
+    replacements.sort_by_key(|r| r.insert_index);
+    // Must replace last-first to keep unreplaced insertion points stable.
+    // perf_2 : could construct vec in one pass.
+    for replacement in replacements.iter().rev() {
+        let (before, after) = exprs.split_at(replacement.insert_index);
+        let after = after.iter().skip(1); // first element is `(if-equal ...)`
+        let new_vec = before
+            .iter()
+            .cloned()
+            .chain(replacement.exprs.iter().cloned())
+            .chain(after.cloned())
+            .collect();
+        *exprs = new_vec;
+    }
     Ok(())
 }
+
+fn if_equal_replacement(expr: &SExpr) -> Result<Option<Vec<SExpr>>> {
+    match expr {
+        // Below should not be reached because only lists should be visited
+        SExpr::Atom(_) => unreachable!(),
+        SExpr::List(l) => Ok(match l.t.first() {
+            Some(SExpr::Atom(Spanned { t, .. })) if t.as_str() == "if-equal" => {
+                let first =
+                    l.t.get(1)
+                        .ok_or_else(|| {
+                            anyhow_expr!(
+                                &expr,
+                                "if-equal expects a string comparand as the first parameter"
+                            )
+                        })
+                        .and_then(|expr| {
+                            expr.atom(None).ok_or_else(|| {
+                                anyhow_expr!(&expr, "comparands within if-equal must be strings")
+                            })
+                        })?;
+                let second =
+                    l.t.get(2)
+                        .ok_or_else(|| {
+                            anyhow_expr!(
+                                &expr,
+                                "if-equal expects a string comparand as the second parameter"
+                            )
+                        })
+                        .and_then(|expr| {
+                            expr.atom(None).ok_or_else(|| {
+                                anyhow_expr!(&expr, "comparands within if-equal must be strings")
+                            })
+                        })?;
+                if first == second {
+                    Some(l.t.iter().skip(3).cloned().collect())
+                } else {
+                    Some(vec![])
+                }
+            }
+            _ => None,
+        }),
+    }
+}


Test patch:
diff --git a/parser/src/cfg/tests.rs b/parser/src/cfg/tests.rs
index 3be2d4d1e..a4b521522 100644
--- a/parser/src/cfg/tests.rs
+++ b/parser/src/cfg/tests.rs
@@ -1591,3 +1591,148 @@ fn parse_defvar_concat_err2() {
     )
     .expect_err("fails");
 }
+
+#[test]
+fn parse_template_1() {
+    let _lk = match CFG_PARSE_LOCK.lock() {
+        Ok(guard) => guard,
+        Err(poisoned) => poisoned.into_inner(),
+    };
+
+    let source = r#"
+(deftemplate home-row (j-behaviour)
+  a s d f g h $j-behaviour k l ; '
+)
+
+(defsrc
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row j)                            ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+
+(deflayer base
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row (tap-hold 200 200 j lctl))    ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+"#;
+    let mut s = ParsedState::default();
+    parse_cfg_raw_string(
+        source,
+        &mut s,
+        &PathBuf::from("test"),
+        &mut FileContentProvider {
+            get_file_content_fn: &mut |_| unimplemented!(),
+        },
+        DEF_LOCAL_KEYS,
+    )
+    .map_err(|e| {
+        eprintln!("{:?}", miette::Error::from(e));
+    })
+    .expect("parses");
+}
+
+#[test]
+fn parse_template_2() {
+    let _lk = match CFG_PARSE_LOCK.lock() {
+        Ok(guard) => guard,
+        Err(poisoned) => poisoned.into_inner(),
+    };
+
+    let source = r#"
+(defvar chord-timeout 200)
+(defcfg process-unmapped-keys yes)
+
+;; This template defines a chord group and aliases that use the chord group.
+;; The purpose is to easily define the same chord position behaviour
+;; for multiple layers that have different underlying keys.
+(deftemplate left-hand-chords (chordgroupname k1 k2 k3 k4 alias1 alias2 alias3 alias4)
+  (defalias
+    $alias1 (chord $chordgroupname $k1)
+    $alias2 (chord $chordgroupname $k2)
+    $alias3 (chord $chordgroupname $k3)
+    $alias4 (chord $chordgroupname $k4)
+  )
+  (defchords $chordgroupname $chord-timeout
+    ($k1) $k1
+    ($k2) $k2
+    ($k3) $k3
+    ($k4) $k4
+    ($k1 $k2) lctl
+    ($k3 $k4) lsft
+  )
+)
+
+(template-expand left-hand-chords qwerty a s d f qwa qws qwd qwf)
+(template-expand left-hand-chords dvorak a o e u dva dvo dve dvu)
+
+(defsrc a s d f)
+(deflayer dvorak @dva @dvo @dve @dvu)
+(deflayer qwerty @qwa @qws @qwd @qwf)
+"#;
+    let mut s = ParsedState::default();
+    parse_cfg_raw_string(
+        source,
+        &mut s,
+        &PathBuf::from("test"),
+        &mut FileContentProvider {
+            get_file_content_fn: &mut |_| unimplemented!(),
+        },
+        DEF_LOCAL_KEYS,
+    )
+    .map_err(|e| {
+        eprintln!("{:?}", miette::Error::from(e));
+    })
+    .expect("parses");
+}
+
+#[test]
+fn parse_template_3() {
+    let _lk = match CFG_PARSE_LOCK.lock() {
+        Ok(guard) => guard,
+        Err(poisoned) => poisoned.into_inner(),
+    };
+
+    let source = r#"
+(deftemplate home-row (version)
+  a s d f g h 
+  (if-equal $version v1 j)
+  (if-equal $version v2 (tap-hold 200 200 j lctl))
+   k l ; '
+)
+
+(defsrc
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row v1)                            ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+  
+(deflayer base
+  grv  1    2    3    4    5    6    7    8    9    0    -    =    bspc
+  tab  q    w    e    r    t    y    u    i    o    p    [    ]    \
+  caps (template-expand home-row v2)                            ret
+  lsft z    x    c    v    b    n    m    ,    .    /    rsft
+  lctl lmet lalt           spc            ralt rmet rctl
+)
+"#;
+    let mut s = ParsedState::default();
+    parse_cfg_raw_string(
+        source,
+        &mut s,
+        &PathBuf::from("test"),
+        &mut FileContentProvider {
+            get_file_content_fn: &mut |_| unimplemented!(),
+        },
+        DEF_LOCAL_KEYS,
+    )
+    .map_err(|e| {
+        eprintln!("{:?}", miette::Error::from(e));
+    })
+    .expect("parses");
+}