PBS 124 of X: Templates (Chezmoi)
At this stage in our Chezmoi journey we’ve learned how to manage and version our dot files. We’ve learned that Chezmoi stores its version history in Git, and hence, that we can use a remote Git repository as a backup of our dot files.
We made a start on exploring the power to templating within Chezmoi — we learned how to define data, and how include that data in templates using the text/template syntax from the Go language. Specifically, we learned how to use the dot notation to include single values, and the range
action to iterate over arrays and objects.
To keep things simple, I avoided using text/template jargon in the previous instalment, but we need to remedy that before we can move on to explore the powerful text/template features we’ll need to master to enable us to use Chezmoi across multiple machines effectively.
Specifically, we’ll be looking at:
- comment actions
- conditional actions
- arguments & functions
- more on arrays
- variables
- pipelines
Matching Podcast Episode
Listen along to this instalment on episode 698 of the Chit Chat Across the Pond Podcast.
You can also Download the MP3
Instalment Resources
- The instalment ZIP file — pbs124.zip.
Chezmoi Templating Recap
Let’s take a quick moment to remind ourselves of what we learned so far about Chezmoi templates.
Firstly — the data for use within our templates comes from Chezmoi itself, the optional data
section in the Chezmoi config file, and an optional Chezmoi data file.
Secondly, because Chezmoi is written in Go, it uses Go’s text/template templating system.
Finally, we learned how to include data fields, iterate over arrays, and iterate over objects:
The value of the data field .chezmoi.os is {{ .chezmoi.os }}.
The elements of an array in data field .languages:
{{ range .languages -}}
* {{ . }}
{{ end }}
The keys and values of an object in the data field .socialMedia:
{{ range $site, $uname := .socialMedia -}}
* key={{ $site }} & value={{$uname}}
{{ end }}
Chezmoi Templates Without the Training Wheels
First things first, in text/template it’s all about actions — that’s the correct jargon for the thing that goes inside the pairs of curly braces.
So far we’ve seen two actions, one specific type of pipeline for outputting single values (the dot notation), and the range
action.
Comment Actions
Before we get into the more complex stuff, let’s start nice and simple — you can add comments into a templates, and you can even combine the comment action with the white-space trim syntax:
{{/* This is a comment with no white-space trimming */}}
{{- /* This is a comment with preceding white-space trimmed */ }}
This is a lot more useful than it might at first appear, because it lets you include comments in places you usually can’t, like JSON files.
Conditional Actions
The next important action type the conditional. There are four actions that can be combined to address various scenarios — {{ if CONDITION }}
, {{ else if CONDITION }}
, {{ else }}
, and {{ end }}
.
Note that the examples below make use of the eq
function. We’ll be looking at functions in more detail later, but for now, it’s sufficient to know that eq
evaluates to true if the two values following it are equal to each other.
The simplest scenario is optional content:
{{ if eq .chezmoi.os "darwin" }}
This text will only be included on Macs (Darwin is Apple's port of BSD that powers macOS).
{{ end }}
The next simplest scenario is two alternative pieces of content:
{{ if eq .chezmoi.os "darwin" }}
This text will only be included on Macs.
{{ else }}
This text will be included everywhere but Macs.
{{ end }}
And finally, we can have multiple possible alternative pieces of content:
{{ if eq .chezmoi.os "darwin" }}
This text will only be included on Macs.
{{ else if eq .chezmoi.os "linux" }}
This text will only be included on Linux machines.
{{ else }}
This text will only be included on machines that are neither Macs nor running Linux.
{{ end }}
Functions & Arguments
The definition of argument in the text/template jargon is a little odd — it basically means a value, and arguments can be one of:
- A literal value — a string like
"boogers"
, a number like42
or3.14
, or a boolean (true
orfalse
). - A variable name (more on those later). You can recognise variables names because they must start with the
$
symbol. - A data field. You can recognise these because they start with a dot (
.
) character.
Functions are named actions that take zero or more arguments, and produce one output.
Functions are called by using their name, without a prefix, followed by zero or more space-delimited arguments. When you come from C-style languages like JavaScript this is a little strange, but the way to think about it is that the JS code someFunction(anArg, anotherArg)
becomes {{ someFunction anArg anotherArg }}
in a template.
The standard functions provide both comparison functions and boolean functions.
Comparison Functions
We’ve already see the most commonly used of these functions in our previous examples — the eq
function. This function expects two or more arguments, and returns true of all the arguments have the same value, and false otherwise. Here’s the full list of comparison functions:
Function | Description |
---|---|
eq |
is Equal, equivalent to == in JavaScript. |
ne |
is Not Equal, equivalent to != in JavaScript. |
lt |
is Less Than, equivalent to < in JavaScript. |
le |
is Less Than or Equal, equivalent to <= in JavaScript. |
gt |
is Greater Than, equivalent to > in JavaScript. |
ge |
is Greater Than or Equal, equivalent to >= in JavaScript. |
Boolean Functions
Beyond facilitating making comparisons, the default functions also implement basic boolean logic:
Function | Description |
---|---|
not |
Inverts its argument, equivalent to ! in JavaScript. |
and |
A boolean and, equivalent to && in JavaScript. |
or |
A boolean or, equivalent to || in JavaScript. |
Nested Function Calls and Complex Logic
This is a good opportunity to illustrate text/template’s support for nesting function calls within the arguments to function calls. Without this ability, the boolean functions would be quite useless!
The key to nesting is wrapping the nested function calls in brackets.
As an example, we can nest two calls to the eq
function within a call to the and
function within an if
action like so:
{{ if (and (eq .chezmoi.os "darwin") (eq .chezmoi.version.builtBy "HomeBrew")) }}
You're one of the smart Mac users, you use homebrew 🙂
{{ end }}
Notice the pre-fix-style notation — this is alien to most of a younger generation, but those from an older generation may recognise it as Polish notation, and those who loved HP calculators may find it an annoying mirror-image of their preferred reverse polish notation.
The sprig
Utility Functions
Chezmoi not only provides access to the standard functions provided by text/template, but augments them with the sprig collection of templating utility functions. There are too many to list here, but the documentation is actually quite good. The main page of the documentation lists the categories the functions are grouped into, and a few example functions from each group are included right on the main page. To see all the functions for the group, click into it.
Defining Default Values
From a dot-file POV the most useful of the sprig functions is probably default
. This allows you to specify a default value when a data field or variable you include has no value. The default
function expects two arguments, the default value, and a possible non-default value that will be tested for emptiness. If the second argument is empty (an object with no keys, an array with no elements, an empty string, or the number zero), the first argument will be returned, otherwise, the second argument will be returned.
As a practical example, I like to export an environment variable named CURRENT_WORK_DIR
that I use in various shell scripts and aliases. For various reasons I need to use different paths on different machines, so I need to define the value I want in the data
section of my Chezmoi config file, but on computers where I don’t define it, I need it to have a sane default, I can do that with:
export CURRENT_WORK_DIR={{ default "~/Documents" .currentWorkDir }}
As useful as default
is, sprig provides an even more powerful alternative, coalesce
. This function takes arbitrarily many arguments, and returns the value of the first non-empty argument. This allows for a hierarchy of defaults. This function is especially powerful when combined with sprig’s env
function for reading environment variables.
For example, the following will figure out the best editor to use by first trying the VISUAL
environment variable, then the EDITOR
environment variable, and finally defaulting to /usr/bin/nano
if no editor has been found yet:
{{ coalesce (env "VISUAL") (env "EDITOR") "/usr/bin/nano" }}
Note the use of brackets to nest the function calls.
Arrays Revisited
In the previous instalment we learned how to loop over an entire array with the range
action, but we didn’t learn how to access a single element in the array, or to determine it’s length. This is because you need to understand functions to do either of those things!
Firstly, the standard index
function allows you to access specific array elements, it expects one argument, the array index to return, numbered from zero.
Secondly, the standard len
function returns the length of an array, taking the array as the only argument.
Finally, we can combine these two functions with sprig’s randInt
function to access a random element within an array. The randInt
function expects two arguments, and inclusive lower-bound, and an exclusive upper-bound. This makes the function ideal for use with arrays since they are zero-indexed. Assuming we have an array named .languages
, let’s build it up piece by piece to find a random element from the array.
if .languages is an array with English, Nederlands, Vlaams, Gaeilge, Français:
{{ index .languages 2 }}
Would return Vlaams.
{{ len .languages }}
Would return 5.
{{ randInt 0 5 }}
Would return 0, 1, 2, 3, 4 (but not 5).
And finally putting it all together:
{{ index .languages (randInt 0 (len .languages)) }}
Would randomly return one of the five languages.
Variables
Variables can be defined and re-assigned pretty much anywhere an argument can be used. Like in JavaScript, variables need to be defined before they can be used, but unlike in JavaScript, the syntax is different between declaring a variable with an initial value, and assigning a new value to an existing variable.
To assign an initial value to a new variable the syntax is $variableName := 'some value'
, while the syntax to assign a new value to an existing variable is $variableName = 'some new value'
.
Once a variable is declared its value can be accessed simply by using its name (always pre-fixed with the $
) symbol.
Note that you can include actions in your templates that only declare a variable and do nothing else.
As a practical example, I hate having to remember that Chezmoi sees the Mac as "darwin"
, filling my templates with {{ if eq .chezmoi.os "darwin" }}
results in code that doesn’t make sense unless you happen to know Apple named their BSD Unix port Darwin. To get around this, I include the following variable definition at the top of any templates where I need to do Mac-only things:
{{ $isMac := eq .chezmoi.os "darwin" }}
I can then use the variable in future if
actions:
{{ if $isMac }}
I wrote this on my Mac 🙂
{{ end }}
Pipelines
Pipelines provide a very convenient and much easier to read alternative to nested function calls. Instead of ever deeper nested brackets, the function calls are written one after the other separated by the pipe (|
) symbol. The output of the function to the left of the pipe is passed as the last argument to the next function. Pipelines can be started with arguments rather than function calls when needed.
For example, assuming a data field named .favouriteQuote
exists, and contains a quotation that’s potentially spread over multiple lines. You want to print it out onto the CLI so that it’s indented by 4 characters and re-flowed to a maximum of 80 characters wide.
The sprig library provides us all the functions we need to do this:
replace
takes a string to be replaced as the first argument, a replacement as the second, and the string to perform the replacements on as the final argument.wrap
will reflow text to a given number of columns. The number of columns (characters) should be passed as the first argument, and the text to reflow as the last.indent
indents every line of text in a string. The amount of characters to indent by is passed as the first argument, and the string to indent as the last.
To transform our quotation we must first replace all the newlines in the original string with spaces, then re-flow to 76 characters wide, then indent by four characters.
We could do that with nested function calls like so:
{{ indent 4 (wrap 76 (replace "\n" " " .favouriteQuote)) }}
I don’t find that easy to read or understand, but we can re-write it as a pipeline like so:
{{ .favouriteQuote | replace "\n" " " | wrap 76 | indent 4 }}
A Worked Example — An Updated .plan
File
Let’s put what we’ve learned into practice, and extend the .plan
file we were working in on the previous instalment with some additional content.
Getting Caught Up
If you didn’t follow along last time (and assuming you have Chezmoi installed and working), you can use the following instructions to get caught up.
First, make sure you have the following data defined in either the data section of the Chezmoi config file (chezmoi edit-config
), or, in the Chezmoi data (a file named .chezmoidata.json
in the Chezmoi source directory (chezmoi source-path
)):
{
"email": "opensource@bartificer.net",
"languages": [
"English",
"Nederlands",
"Vlaams",
"Gaeilge",
"Français"
],
"name": "Bart Busschots",
"organisation": "Bartificer Creations",
"socialMedia": {
"flickr:": "bbusschots",
"twitter": "bbusschots"
},
"url": {
"consulting": "https://bartificer.net/",
"personal": "https://bartb.ie/",
"podcasting": "https://lets-talk.ie/"
}
}
Create an empty ~/.plan
if none exists:
[[ -f ~/.plan ]] || echo ' ' >> ~/.plan
Add the empty plan to Chezmoi as a template:
chezmoi add --template ~/.plan
Edit the template (with chezmoi edit ~/.plan
) so it contains:
Hi there, I'm {{ .name }} from {{ .organisation }}.
I speak:
{{ range .languages -}}
* {{ . }}
{{ end }}
You'll find me on:
{{ range $site, $uname := .socialMedia -}}
* on {{ $site }} as {{$uname}}
{{ end }}
Note that there’s a copy of this file in the instalment’s ZIP as 1-initial-dot_plan.tmpl
.
Add an OS-Specific Footer
To illustrate use of variables and conditionals, let’s capture a human-friendly version of the OS and save it in a variable named $os
, then use that variable in some text.
Let’s add the logic to initialise the variable to the top of the templates:
{{- /* Capture a human-friendly version of the OS */ -}}
{{- $os := "some weird OS" -}}{{- /* initialise the variable with a default value */ -}}
{{- if eq .chezmoi.os "linux" -}}
{{- $os = "some Linux Distro" -}}
{{- else if eq .chezmoi.os "windows" -}}
{{- $os = "Windows" -}}
{{- else if eq .chezmoi.os "darwin" -}}
{{- $os = "macOS" -}}
{{- end -}}
Note that I’m going out of my way to strip absolutely all white space by adding -
symbols on all sides of all action delimiters, and note the use of comment actions. More importantly though, notice that I used :=
to declare the variable with an initial value, and =
to change its value later in the template if appropriate.
We can now use this variable anywhere in the script. Let’s add a little footer to the end of the template:
(Generated on {{ $os }})
For me, when I test my template with chezmoi cat ~/.plan
I now get:
Hi there, I'm Bart Busschots from Bartificer Creations.
I speak:
* English
* Nederlands
* Vlaams
* Gaeilge
* Français
You'll find me on:
* on flickr: as bbusschots* on twitter as bbusschots
(Generated on macOS)
Note that the variable definitions at the top did not introduce any white space, and that the variable has the expected value in the new footer.
Add the Date to the Footer
To practice using a function from sprig, let’s add the current date into the footer using the very appropriately named function now
and date
functions. You’ll find documentation on all sprig’s date-related functions on the Date Functions page of their docs. If you want to change the date format you’ll unfortunately need to read up on the unique (moronic) way the Go language handles date formatting on the time page in the official Go docs.
Let’s update the footer to:
(This plan was generated on {{ $os }} at {{ now | date "15:04 on Jan 2 2006"}})
As I write these show notes, this generates the following output:
(This plan was generated on macOS at 15:53 on Sep 4 2021)
Notice the use of pipelining, the date
function requires two arguments, first a (moronic) format string, and last, a date object. The now
function outputs a date object, and the pipeline passes that date object as the last argument to the date
function.
Improve the Languages Section
To practice working with arrays, let’s improve the languages section by:
- Adding a count of the languages
- Sorting the languages
- Calling out a primary language (first in the original list)
We can easily add the count with the len
function by updating the heading to:
I speak {{len .languages }} language(s):
This works, but, we can do better! sprig provides a very useful function for handling pluralisation in templates. The function is named plural
and expects three arguments, the singular form of the string, the plural form of the string, and a number. Using pipelining we can be sure to always get the correct plural by updating our template like so:
I speak {{len .languages }} {{ len .languages | plural "language" "languages" }}:
Now let’s sort the languages using sprig’s sortAlpha
function, simply update the range
action to pipe the array through this function:
{{ range .languages | sortAlpha -}}
* {{ . }}
{{ end }}
Lastly, let’s add a line calling out the first language in the list as the primary. The following would work using the built-in index
function:
(My first language is {{ index .languages 0 }})
But sprig provides a clearer option with its first
function:
(My first language is {{ .languages | first }})
Add a Randomly Picked Witticism in a Banner
Finally, to really show off the power of pipelining, let’s add a randomly picked witticism re-formatted as a fancy banner.
First, add the following to either the data
section of your Chezmoi config file, or to your dedicated Chezmoi data file:
"witticisms": [
"The problems that exist in the world today cannot be solved by the level of thinking that created them - Albert Einstein",
"In war it does not matter who is right, but who is left - Winston Churchill",
"You have the right to remain silent. Anything you say will be misquoted then used against you."
]
The generally accepted default width for terminal output is 80 characters. So, what we want to do is add a line of 80 dashes before and after the randomly chosen witticism, then re-flow the witticism so it’s a maximum of 76 characters per line, then indent it by two spaces, giving a guaranteed indent of two on each side.
We can do all this with function we’ve met already, and the following three additional sprig string functions:
Function | Description |
---|---|
indent |
Takes a number as the first argument, and a string to indent as the last argument. The appropriate number of spaces are pre-pended to the front of the string. |
repeat |
Takes a number as the first argument and a string as the last. It outputs the string repeated the specified number of times. |
wrap |
Takes a number of characters as the first argument, and a string as the last argument, it then re-flows the string to the specified maximum line length. |
This is my final code:
{{ repeat 80 "-" }}
{{ len .witticisms | randInt 0 | index .witticisms | wrap 76 | indent 2 }}
{{ repeat 80 "-" }}
The repeats should be obvious enough, but the middle line is worth examining a little more closely.
This very long pipeline starts by getting the number of witticisms, then generating a random in between zero and that length, then fetching the witticism at that random index, then re-flowing that witticism to 76 characters, and finally indenting each line in the re-flowed witticism by two spaces!
Putting it all together, this is the final version of my template:
{{- /* Capture a human-friendly version of the OS */ -}}
{{- $os := "some weird OS" -}}{{- /* initialise the variable with a default value */ -}}
{{- if eq .chezmoi.os "linux" -}}
{{- $os = "some Linux Distro" -}}
{{- else if eq .chezmoi.os "windows" -}}
{{- $os = "Windows" -}}
{{- else if eq .chezmoi.os "darwin" -}}
{{- $os = "macOS" -}}
{{- end -}}
Hi there, I'm {{ .name }} from {{ .organisation }}.
I speak {{len .languages }} {{ len .languages | plural "language" "languages" }}:
{{ range .languages | sortAlpha -}}
* {{ . }}
{{ end -}}
(My first language is {{ .languages | first }})
You'll find me on:
{{ range $site, $uname := .socialMedia -}}
* on {{ $site }} as {{$uname}}
{{- end }}
{{ repeat 80 "-" }}
{{ len .witticisms | randInt 0 | index .witticisms | wrap 76 | indent 2 }}
{{ repeat 80 "-" }}
(This plan was generated on {{ $os }} at {{ now | date "15:04 on Jan 2 2006"}})
Note that there’s a copy of this file in the instalment’s ZIP as 2-final-dot_plan.tmpl
.
Final Thoughts
It’s taken a while, but we now know enough about Chezmoi templates to start using it across multiple machines in an effective way. The next instalment will be our final one in this series-within-a-series on Chezmoi, and will focus entirely on syncing dot files between multiple computers, and managing the inevitably need for some differences from machine to machine. As you’ve probably guessed, templates are key to this.