Hello and welcome to the last instalment in the series where we build a parser for a domain specific langauge in Rust. Please go through the previous articles since this article assumes you are aware of such contextual details.

Let’s start with the bugfixes.

Eagerly removing unbinds

While going through the tests, I figured that the prior parser eagerly parses unbinds and removes said keystroke combinations from our binding set. Unlike the previous iteration, our iteration had unbinds as a separate set which deferred the task of the removing the set intersection to the upstream crate instead.

To fix this, we follow the good old adage, “fix it in post”. With the import functionality taking care of duplicate imports, all imports are parsed using the private SwhkdParser::as_import function, passing in the respective inputs as well as a state struct to keep track of imports we’ve already seen. The only exception to this rule is for the root of all the imports. For the root config, we have a from function that accepts a single input (raw text or path) and repeatedly uses the as_import function on all subsequent inputs.

Since we know that the upstream crate will only be able to use the public from function, we can add the fix right after every import has been parsed. We add the following loop to remove any binding in our binding list as long as it also exists in the unbinds list.

for def in root.unbinds.iter() {
    if let Some(i) = root.bindings.iter().position(|b| b.definition.eq(def)) {
        root.bindings.remove(i);
    }
}

Overwriting bindings that are redefined

I had a talk with my GSoC mentor last week where we discussed whether bindings from imports that get redefined in the root config should be overwritten. After some back and forth, we decided to stick with the older behavior of overwriting.

To implement this, instead of blindly extending the list of bindings with what has been parsed, we check if a binding with the same definition exists. If so, we replace the binding’s command with the new command.

for binding in binding_parser(decl)? {
    if let Some(b) = bindings
        .iter_mut()
        .find(|b| b.definition == binding.definition)
    {
        b.command = binding.command;
        b.mode_instructions = binding.mode_instructions;
    } else {
        bindings.push(binding);
    }
}

Unescaping commands in shorthands

This one’s a fairly straightforward one but I probably would have missed it if it were not for the tests. The commands, just like keys, must be unescaped when present in shorthands. This is so that we can distinguish a comma separating two shorthand elements or a dash representing a range from a literal comma or a dash.

Solution? Simply reuse the unescape function we used in for the keys.

// ...
Rule::command_component => {
    command_variants.push(unescape(component.as_str()).to_string())
}
// ...

Removing trailing double ampersands from commands

When defining commands for bindings, swhkd allows us to chain commands with double ampersands (&&). Not only that, we can also invoke modes with special syntax. If the && is followed by a @enter and a modename, we enter a mode whereas a @escape allows us to exit a mode.

In a previous article where we built a way to extract these modes during a single pass iteration, we extract the mode instruction to our list of mode instructions and if the last component was not a &&, we keep the &&.

This idea was somewhat flawed since and expression like the following keeps an extra trailing &&.

echo hi && ls && @enter mymode

Clearly, the last && has no problem staying beside the ls while the @enter mode instruction was happily extracted away. The result echo hi && ls && isn’t a valid command though.

To fix this, we add a small snippet of code to pop off the last element if it happens to be just one &&.

if comm
    .last()
    .is_some_and(|last| last.len() == 1 && last[0] == "&&")
{
    comm.pop();
}

Wrapping up

So yeah, those were the small bugs that needed to be squashed and with that all the previous tests as well as new tests are passing. This also marks the end of the development phase on my end. Perhaps in a next post, I’ll talk about how I actually use SWHKD in my daily workflows. Stay tuned!