This article is part of the Beginner’s Guide to Elvish series:
-
Variables and loops
1. Using variables
In Your first Elvish commands, we saw an example of how to use a series of commands to download Elvish. Let’s focus on the initial two commands, which download the archive and show the SHA256 checksum respectively:
Terminal - elvish
~> curl -s -o elvish-HEAD.tar.gz https://dl.elv.sh/linux-amd64/elvish-HEAD.tar.gz ~> curl -s https://dl.elv.sh/linux-amd64/elvish-HEAD.tar.gz.sha256sum 93b206f7a5b7f807f6b2b2b99dd4074ed678620541f6e9742148fede0a5fefdb elvish-HEAD.tar.gz
This example comes with a catch – it only works as long as the linux-amd64
part actually matches your platform, namely Linux on a
x86-64 CPU. To fix that, instead
of hardcoding this string, we need a way to construct it dynamically to
actually match your platform.
Turns out that Elvish already has all the information we need, stored inside two variables:
Terminal - elvish
~> use platform ~> echo $platform:os darwin ~> echo $platform:arch arm64
(We’ll learn what use platform and the colons are about in
Organizing and reusing code).
The $ character starts a variable, and tells Elvish to evaluate it to the
value stored inside it. In this case, the $platform:os variable stores a
string identifying the OS
(darwin in the
example output), and the $platform:arch variable stores a string identifying
the CPU architecture (arm64 in the
example output).
Your output may differ, but at least in the example output, it turns out our
platform doesn’t match linux-amd64 after all. Let’s now fix our command by
making use of these variables:
Terminal - elvish
~> curl -s -o elvish-HEAD.tar.gz https://dl.elv.sh/$platform:os'-'$platform:arch/elvish-HEAD.tar.gz ~> curl -s https://dl.elv.sh/$platform:os'-'$platform:arch/elvish-HEAD.tar.gz.sha256sum f1b2e7c149f5104c191bc7c9cd922b87ac73d810ba71c186636d1807e2a5ce95 elvish-HEAD.tar.gz
And now our commands work regardless of which platform we are on! In more fancy terms, our commands are now portable across platforms.
Let’s recap what is going on:
-
Elvish sees
$platform:osand$platform:archand evaluates them to their respective values – in our environment,darwinandarm64respectively. -
Elvish concatenates them to the neighboring strings to form the overall argument. The argument for the first
curlcommand is https://dl.elv.sh/darwin-arm64/elvish-HEAD.tar.gz; similarly for the secondcurlcommand, with an extra.sha256sumsuffix. -
The
curlcommand then runs with the arguments we have constructed.
(There is still a catch: this example still doesn’t work for Windows, because
the archive files for Windows end in .zip instead of .tar.gz. Once we have
learned conditionals in Value types, you can come back here
to make this code fully portable.)
1.1. Quoting and syntax highlighting
Notice how we quoted - between $platform:os and $platform:arch. This is
because variable names in Elvish can include -, so if we omit it, Elvish will
try to find the variable $platform:os-:
Terminal - elvish
~> curl -s -o elvish-HEAD.tar.gz https://dl.elv.sh/$platform:os-$platform:arch/elvish-HEAD.tar.gz Exception: variable $platform:os- not found
This introduces us to another reason for quoting strings: when concatenating literal strings with variables, quoting the literal part can stop Elvish from treating it as part of the variable name.
Elvish also gives you hints using by highlighting different parts of the code. Let’s zoom in on the part around our variables:
Terminal - elvish
~> echo $platform:os'-'$platform:arch darwin-arm64 ~> echo $platform:os-$platform:arch Exception: variable $platform:os- not found
In the first correct command, the quoted '-' has a distinct color, clearly
standing out from the variables around it. In the second incorrect command, the
unquoted - is colored the same as variables, meaning that Elvish will treat it
as part of the variable name.
2. Defining new variables
Our commands for downloading Elvish and showing the checksum still has some room for improvement. Notice how similar the two commands are, in particular the last argument:
Terminal - elvish
~> curl -s -o elvish-HEAD.tar.gz https://dl.elv.sh/$platform:os'-'$platform:arch/elvish-HEAD.tar.gz ~> curl -s https://dl.elv.sh/$platform:os'-'$platform:arch/elvish-HEAD.tar.gz.sha256sum f1b2e7c149f5104c191bc7c9cd922b87ac73d810ba71c186636d1807e2a5ce95 elvish-HEAD.tar.gz
To fix that, we can store the common part in a new variable:
Terminal - elvish
~> var archive-url = https://dl.elv.sh/$platform:os'-'$platform:arch/elvish-HEAD.tar.gz ~> curl -s -o elvish-HEAD.tar.gz $archive-url ~> curl -s $archive-url.sha256sum f1b2e7c149f5104c191bc7c9cd922b87ac73d810ba71c186636d1807e2a5ce95 elvish-HEAD.tar.gz
The var command defines a new variable called
archive-url and gives it an initial value. After that, we can use it like
$archive-url.
Notice how we don’t use the $ prefix when defining a variable. This is because
$ instructs Elvish to evaluate a variable, and we are not doing that when
defining it. However, we may still say that we “define $archive-url“ as a
shorthand of “define the archive-url variable”.
3. For loops and lists
The ability to use and define variables gives us the flexibility in how we do one thing, but often we find ourselves repeating similar but not entirely identical tasks.
For example, let’s say we have a few .jpg files that we would like to convert
into the more efficient AVIF format. (If
you’d like to follow this example but don’t have spare .jpg files lying
around, download some from
Wikimedia Commons.)
With the gm command provided by
GraphicsMagick, we can convert them one by one:
Terminal - elvish
~> gm convert banana.jpg banana.avif ~> gm convert unicorn.jpg unicorn.avif ~> # and so on...
There is a better way to do it, though. Like many other programming languages, Elvish provides loops to perform repetitive work:
Terminal - elvish
~> use str # ① ~> for jpg [banana.jpg unicorn.jpg] { # ② var avif = (str:trim-suffix $jpg .jpg).avif # ③ gm convert $jpg $avif # ④ }
This is a more complex example, so let’s go through it line by line:
-
use strimports thestrmodule. We’ll learn about modules in Organizing and reusing code; for now, it suffices to know that this is needed to be able to usestr:trim-suffixbelow. -
The
forcommand introduces a for loop.Let’s first focus on
[banana.jpg unicorn.jpg]: the[and]delimits a list, a type of value that consists of multiple elements. Here, the elements arebanana.jpgandunicorn.jpg, separated by spaces – just like how the arguments to a command are separated by spaces.The for loop works as follows: for each element of the list, it defines the
jpgvariable to be equal to that element, and runs the code inside{and}(the body of the for loop).Now for the body itself…
-
Since the name of the input JPG file is no longer hardcoded, we can no longer hardcode the name of the output AVIF file either. Instead, we use some string manipulation to derive the output name from the input name - the
str:trim-suffixcommands removes a fixed suffix from a string. You can see it in action like this:Terminal - elvish
~> str:trim-suffix banana.jpg .jpg ▶ bananaWe then concatenate the result with
.avifto form the output filename, in this casebanana.avif, and store it in the$avifvariable. -
Finally, we use the
gmcommand to perform the conversion.
As we can see, the for loop will run the body twice, once with $foo equal to
banana.jpg, and once with $foo equal to unicorn.jpg, so this achieves the
same effect as two “manual” invocations gm that we set out to improve.
3.1. The strength of loops
In this particular case, we haven’t really achieved any improvement – our new
code is longer and more complex than the two separate gm invocations. In fact,
when you only need to repeat a simple task twice or three times, just repeating
it “manually” – probably with the help of Elvish’s command history – is a
totally valid approach.
The real strength of for loops is when there are many elements, maybe even an
unknown number of them. Let’s say we’d like to convert all the .jpg files to
.avif files. With the manual approach you’d have to write as many gm
commands as there are files, but with a for loop, just a simple modification is
needed:
Terminal - elvish
~> use str ~> for jpg [*.jpg] { # ① var avif = (str:trim-suffix $jpg .jpg).avif gm convert $jpg $avif }
Here, we have changed the element of the list to be *.jpg – this doesn’t
represent a single file named *.jpg, but is a stand-in for all the filenames
ending in .jpg. Here, our for loop is able to handle the conversion
comfortably, whether it’s just one file or thousands of files.
3.2. Wildcards
The *.jpg we have just seen is an example of wildcard patterns. Here, *
is a wildcard character that can match any number of characters, so *.jpg
matches banana.jpg, unicorn.jpg, or even .jpg if there happens to be such
a file. The wildcard expansion
section of the language reference describes wildcards in more details, but *
is perhaps what you will use most of the time.
4. Multiple values
Something worth remarking with the behavior of *.jpg is that it evaluates to
multiple values. This means that it becomes multiple elements in a list,
which is what’s happening here, but it also becomes multiple arguments when used
in commands. We can see this most clearly with
the put command, which writes each of its argument
as a value output:
Terminal - elvish
~> put *.jpg ▶ banana.jpg ▶ unicorn.jpg
4.1. Output capture redux
Previously, we have captured the outputs of commands to use as arguments to other commands, like this:
Terminal - elvish
~> * (+ 2 10) 3 ▶ (num 36)
Here, (+ 2 10) outputs a single value, which then gets used as a single
argument.
Some commands in Elvish can output multiple values, and capturing their output
gives us multiple values too. For example, the
str:split command splits a string around a
separator, outputting one value for each split results:
Terminal - elvish
~> str:split , friends,Romands,countrymen ▶ friends ▶ Romands ▶ countrymen
We can use these multiple values in the same way we used the multiple values
generated *.jpg. For example, we can put them in a list and use that in a for
loop:
Terminal - elvish
~> for who [(str:split , friends,Romans,countrymen)] { echo 'Hello, '$who'!' } Hello, friends! Hello, Romans! Hello, countrymen!
Both + and str:split output values, but what about commands that output
bytes? When we capture their output, each line becomes a value. As an example,
https://dl.elv.sh/INDEX is a file listing all the files available on the
https://dl.elv.sh site. We can use curl to request this file and capture the
output:
Terminal - elvish
~> for url [(curl -s https://dl.elv.sh/INDEX)] { echo 'URL: '$url } URL: https://dl.elv.sh/darwin-amd64/elvish-HEAD.tar.gz URL: https://dl.elv.sh/darwin-amd64/elvish-HEAD.tar.gz.sha256sum ...
For the purpose of examining values, we don’t have to put them in a list and use
a for loop. Remember the put command, which turns each argument into a value
in its output:
Terminal - elvish
~> put (curl -s https://dl.elv.sh/INDEX) ▶ https://dl.elv.sh/darwin-amd64/elvish-HEAD.tar.gz ▶ https://dl.elv.sh/darwin-amd64/elvish-HEAD.tar.gz.sha256sum ...
4.2. Lists vs multiple values
A list in Elvish stores multiple values, but it’s always one value itself. In some shells and other programming languages, lists can implicitly “become” multiple values – that never happens in Elvish.
We have seen how you can turn multiple values into a list simply by wrapping
them inside a pair of [ and ]. Conversely, when you have a list and would
like to get all its elements as separate values, you can use the
all command, which does exactly that:
Terminal - elvish
~> all [foo bar] ▶ foo ▶ bar
If the list happens to be stored inside a variable $list, you can also use the
shorthand $@list:
Terminal - elvish
~> var list = [foo bar] ~> put $@list ▶ foo ▶ bar
5. Conclusion
Variables, lists and loops are basic but important abstraction mechanisms in programming, and shell scripting is no exception.
In this part, we’ve learned how to use variables to go beyond simple hardcoded commands and adapt them to the context they operate in. We’ve also used loops, lists and wildcards to repeat operations without even knowing in advance how many times to repeat them for, and dived into how to make use of multiple values.
We are now ready for the next part, Pipelines and IO.