# Validating Custom Keyboard Layouts on NixOS

9 min read

If you're in the rare intersection of people that use NixOS and custom keyboard layouts, this post is for you. And for those of you who don't use custom layouts, you might learn something useful about build-time assertions and laziness.

All three of you still reading, here we go.

So, one day I decided that I wanted to use a custom keyboard layout on my NixOS machine. Should be simple enough. If you find the right bit of documentation, you'll find out that you can easily and declaratively add custom XKB layouts to NixOS using the services.xserver.xkb.extraLayouts key. This presumes that you understand how to write your own XKB symbol files, a nontrivial assumption given how arcane that format is1.

Suppose you have the following simple XKB symbol file2:

xkb_symbols "test"
{
include "us(basic)"
key <CAPS> {[ Escape ]};
};

This file defines a new layout based on the regular QWERTY layout, mapping the capslock key to escape.

Adding this to your NixOS setup is easy enough:

services.xserver.xkb.extraLayouts = {
test = {
description = "Test";
languages = ["eng"];
symbolsFile = ./test.xkb;
};
};

Each entry under extraLayouts points to one XKB symbols file along with some metadata. If you add this to your NixOS config, this will build and successfully add the new layout to the system.

But then I kept on reading the documentation and saw the following warning:

... a broken XKB file can lead to the X session crashing at login. Therefore, you're strongly advised to test your layout before applying it...

And then they show you some cryptic bash commands to actually perform the testing. This decidedly doesn't feel like the Nix way. What's the point of having this amazing build machinery if I have to run some manual commands every time I decide to update my keyboard layouts3?

No, what I want is to validate my layouts during the Nix build, and stop the build if the XKB file is invalid.

After some digging around, I found this somewhat dated documentation on the Wiki:

let
# 1
compiledLayout = pkgs.runCommand "keyboard-layout" {} ''
${pkgs.xorg.xkbcomp}/bin/xkbcomp ${./path/to/layout.xkb} $out
'';
in
# 2
services.xserver.displayManager.sessionCommands = "${pkgs.xorg.xkbcomp}/bin/xkbcomp ${compiledLayout} $DISPLAY";

This uses xkbcomp to compile the layout file (1), which should fail if the file is invalid. It then passes on the result of compilation as part of the sessionCommands for XServer (2). But using sessionCommands is outdated, we want to use the newer extraLayouts way of doing things, which feels more high-level.

So let's combine the two approaches: compile only to validate the layout, and if compilation succeeded, use the extraLayouts setting like we did before.

To make sure this actually work for us, let's check the relevant commands in the terminal:

Terminal window
xkbcomp ./test.xkb

This won't have any output if the file is valid. But suppose you make some error in the file, then you'll get something like this:

Terminal window
xkbcomp ./test.xkb
syntax error: line 5 of ./test.xkb
last scanned symbol is: CAPS
Errors encountered in ./test.xkb; not compiled.

This is exactly what we need, a check we can run during the build. The only wrinkle with xkbcomp is that it makes what I find to be a strange usage of exit codes. Whether or not compilation succeeds, the exit code is always 1. No worries though, it's easy enough to accommodate.

With this tool in hand, here's my naive first attempt to write a Nix function that compiles the layout before using it:

buildLayout = { # 1
name,
description,
lang,
symbols,
}: let
# 2
xkbcomp = pkgs.lib.getExe pkgs.xorg.xkbcomp;
# 3
compilationOutputFile = pkgs.runCommand "${name}-compiled-keyboard-layout" {} ''
(${xkbcomp} ${symbols} 2> $out) || true
'';
# 4
compilationOutput = builtins.readFile compilationOutputFile;
# 5
compilationSuccess =
(compilationOutput == "") || abort "Failure compiling layout [${name}]: ${compilationOutput}";
in {
# 6
${name} = {
description = description;
languages = [lang];
symbolsFile = symbols;
};
};

This is a bit long, so step by step:

  • In (1) we define a new function buildLayout that takes all the data that we need to construct a new layout, symbols refers to the XKB symbols file
  • We find the executable for xkbcomp (2)
  • And use it to validate the symbols file (3)
    • We want to run the command and get its output to be available within the build
    • For that we use runCommand which will run the given command and let us access its output
    • This is done by running xkbcomp and redirecting the error output to $out
    • Due to the quirk with the exit code of xkbcomp described above, we ignore it completely and always succeed with || true
    • After running this, compilationOutputFile points to a file that should either be empty if the layout is valid, or contain the error message that xkbcomp produced
  • We then read the file back into the variable compilationOutput (4)
  • And finally the check (5), if compilationOutput is empty nothing happens, but otherwise we abort with a friendly message that contains the output from xkbcomp
  • After all that, we can construct the new layout definition (6), safe in the knowledge that if we got this far, the layout is definitely valid

With buildLayout in hand, we can set our now verified layout:

services.xserver.xkb.extraLayouts = buildLayout {
name = "test";
description = "Test";
lang = "eng";
symbols = ./test.xkb;
};

Done, the layout building and verification is fully automated. We can try building this new module with the following command4:

Terminal window
nix build --impure --expr 'let pkgs = import <nixpkgs> {}; in (import ./with-validation1.nix { inherit pkgs; }).services.xserver.xkb.extraLayouts'

Unfortunately, this never fails. Whether or not the XKB file is valid or not doesn't change a thing. The build always passes. Can you see the issue?

It took me a bit to spot it, but Nix is a lazy language. So unless an expression is explicitly used for anything, it's never evaluated5.

In the new flow I haphazardly mixed and matched the validation logic from one place with layout setting from another. In the outdated documentation the result of compilation was actually referenced in an expression that is used by NixOS, transitively forcing the evaluation of the compilation command6.

Notice that in the command I'm running above, I explicitly reference extraLayouts, this will force the evaluation of the buildLayout call. Within buildLayout itself, the compilationSuccess value is defined in a let but it's never actually used in the output of the let expression. That is to say, it is not referenced in the final output that we produce. As a result, under lazy evaluation, we don't have to evaluate compilationSuccess to compute the output of buildLayout. In effect, we never actually run the check we so lovingly crafted.

I have no qualms with laziness in the Nix language in general, can't really imagine the flexibility of nixpkgs without it, but I really do want that check to be executed. So we need to chain it to the final output.

Like so:

let
# ... same as before
compilationSuccess = compilationOutput == ""; # 1
in {
${name} =
if compilationSuccess # 2
then {
description = description;
languages = [lang];
symbolsFile = symbols;
}
# 3
else abort "Failure compiling layout [${name}]: ${compilationOutput}";
};

Most of the code remains as is, but we now modify compilationSuccess to only check the condition without aborting (1). We then branch on the result of compilationSuccess (2), and abort if it's false (3). As a result compilationSuccess and transitively the xkbcomp call, is chained to the final output.

And this works as expected, if we build it with a broken layout:

Terminal window
nix build --impure --expr 'let pkgs = import <nixpkgs> {}; in (import ./with-validation2.nix { inherit pkgs; }).services.xserver.xkb.extraLayouts'
error:
… while evaluating the attribute 'test'
at with-validation2.nix:22:5:
21| in {
22| ${name} =
| ^
23| if compilationSuccess
… while calling the 'abort' builtin
at with-validation2.nix:29:12:
28| }
29| else abort "Failure compiling layout [${name}]: ${compilationOutput}";
| ^
30| };
error: evaluation aborted with the following error message: 'Failure compiling layout [test]: syntax error: line 5 of /nix/store/ilwgm7nihyzy30avldk7nr2wim9vwj9d-test.xkb
last scanned symbol is: CAPS
Errors encountered in /nix/store/ilwgm7nihyzy30avldk7nr2wim9vwj9d-test.xkb; not compiled.
'

A bit verbose, but the build fails as required, and the compilation error from xkbcomp is printed along the way.

Since doing an if/else and abort every time I want to validate something feels a bit noisy, we can use utility functions to prettify it. There are a few ways to do this, like the builtin assert special form, maybe paired with the slightly more friendly assertMsg. But I think that the syntax for throwIfNot is a bit cleaner. Here's the final version7:

let
# ... same as before
compilationSuccess = compilationOutput == "";
# 1
errorMessage = "Failure compiling layout [${name}]: ${compilationOutput}";
# 2
ifCompilationSuccess = lib.throwIfNot compilationSuccess errorMessage;
in {
# 3
${name} = ifCompilationSuccess {
description = description;
languages = [lang];
symbolsFile = symbols;
};
};

To prettify things we extracted the error message into a variable (1), now laziness is on our side, it won't be evaluated unless actually used. We then create a new function called ifCompilationSuccess (2). It uses throwIfNot to check the compilationSuccess value and will throw the errorMessage if it's false. throwIfNot returns the identity function if the condition is true, otherwise it throws an error. Lastly, we apply ifCompilationSuccess to the result of buildLayout (3), and by this we chained the compilation check into the flow, and kept the noise to a minimum.

This works as before, although the error message looks a bit different due to the difference in formatting between abort and throw.

We are now done. We can easily create and modify XKB layouts without worrying about breaking our system. Any errors will be caught at build time.

If you enjoyed this, reach out for a workshop on Functional Programming, where I teach how to apply Functional Programming to improve code quality in any language.

Subscribe to the newsletter
Get new posts by email:

Footnotes

  1. If you're feeling particularly masochistic, you can read an in depth tutorial on the topic.

  2. The example code can be found on GitHub.

  3. I'll silently ignore the question of how often I actually modify keyboard layouts.

  4. The full file can be found here.

  5. Shame on me, having spent a semester TAing on a Haskell course, I shouldn't be surprised by laziness.

  6. Actually, in the old documentation it's not clear to me how it could ever work in the first place. Due to the aforementioned exit code quirk of xkbcomp any build that calls it without error capturing should fail. Maybe it's more recent behavior.

  7. The full file.


Comments

More Posts
Previous