# Validating Custom Keyboard Layouts on NixOS
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:
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:
➜ xkbcomp ./test.xkbsyntax error: line 5 of ./test.xkblast scanned symbol is: CAPSErrors 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 thatxkbcomp
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 weabort
with a friendly message that contains the output fromxkbcomp
- 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:
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 == ""; # 1in { ${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:
➜ 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.
Footnotes
-
If you're feeling particularly masochistic, you can read an in depth tutorial on the topic. ↩
-
I'll silently ignore the question of how often I actually modify keyboard layouts. ↩
-
Shame on me, having spent a semester TAing on a Haskell course, I shouldn't be surprised by laziness. ↩
-
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. ↩