Control Flow 2

In a previous chapter, we learned about basic control flow. In this chapter, we will discuss more advanced control flow actions: namely, range, while, and with actions.

Loop

Fundamentally, loops provide a way to perform repeated actions. There are two loop actions available in custom commands: range, which repeats an action for each entry in a data structure, and while, which repeats an action as long as a condition holds.

Range

The range action performs an action for each entry in a slice or map; we say that range iterates over the slice or map. If you have experience with other programming languages, range is roughly equivalent to a for-each loop.

Ranging over slices

We will explain how range works with a slice using an illustrative example. The program below iterates over a slice of snacks and generates a line of output for each one.

{{ $snacks := cslice
    (sdict "Name" "chips" "Calories" 540)
    (sdict "Name" "peanuts" "Calories" 580)
    (sdict "Name" "crackers" "Calories" 500)
}}
{{ range $snacks }}
    {{ .Name }} contain {{ .Calories }} calories.
{{ end }}

The loop body—that is, the code between the opening range $snacks and the closing end—is executed multiple times, with the dot . set to each element of the slice in succession.

For instance, in the first iteration, the . holds the first element of the slice: (sdict "Name" "chips" "Calories" 540). So

{{ .Name }} contain {{ .Calories }} calories.

evaluates to

chips contain 540 calories.

Likewise, the second iteration produces peanuts contain 580 calories, and the third produces crackers contain 500 calories. The complete output of the program is

chips contain 540 calories.

    peanuts contain 580 calories.

    crackers contain 500 calories.

Notice that this output contains some unwanted whitespace: ideally, we want each snack to appear on a separate line with no leading indentation. However, the extra whitespace is to be expected with our current program; the range block is indented, and YAGPDB is simply reproducing that indentation:

{{ range $snacks }}
    {{ .Name }} contain {{ .Calories }} calories.
^^^^
{{ end }}

To fix the excess whitespace in the output, then, one solution is to remove the corresponding whitespace in our source code:

{{ range $snacks }}{{ .Name }} contains {{ .Calories }} calories.
{{ end }}

However, though this version works, we have sacrificed readability in the process. Can we find a way to keep our source code indented while simultaneously hiding this indentation from the final output? It turns out that we can, by carefully adding trim markers.

{{ range $snacks }}
    {{- .Name }} contain {{ .Calories }} calories.
    ^^^
{{ end }}

{{- is a left trim marker that instructs YAGPDB to ignore all leading whitespace, so this new version is functionally equivalent to the previous solution. A corresponding right trim marker, -}}, also exists and trims all trailing whitespace.

Tip: Trim Markers

Use trim markers {{- and -}} to control the whitespace output by your program.

A mnemonic to help remember what {{- and -}} do is to view them as arrows that gobble up whitespace in the direction they point; for instance, {{- points left, and eats all whitespace to the left.

Ranging over maps

It is also possible to range over the (key, value) pairs of a map. To do so, assign two variables to the result of the range action, corresponding to the key and value respectively. (Note that the dot . is still overwritten when ranging with variables.)

For example, the following program displays the prices of various types of fruit, formatted nicely to 2 decimal places with the printf function.

{{ $fruitPrices := sdict "pineapple" 3.50 "apple" 1.50 "banana" 2.60 }}

{{ range $fruit, $price := $fruitPrices }}
    {{- $fruit }} costs ${{ printf "%.02f" $price }}.
{{ end }}

The names of the variables assigned to the key and value are arbitrary; instead of range $fruit, $price := $fruitPrices, we could also have written range $k, $v := $fruitPrices. However, if we use the names $k, $v, we must consistently refer to those in the loop body. That is, the following program is erroneous:

{{ range $k, $v := $fruitPrices }}
    {{- /* ERROR: $fruit and $price are undefined; must use $k and $v instead */}}
    {{- $fruit }} costs ${{ printf "%.02f" $price }}.
{{ end }}

Note

The two-variable form of range can also be used with a slice, in which case the first variable tracks the position of the element starting from 0.

Rarer forms of range

There are a few other, less common ways to invoke the range action.

  • Iterating n times. To iterate a fixed number of times, provide an integer to range:

    {{ range 5 }}
        {{/* executed 5 times */}}
    {{ end }}

    To iterate over an interval of integers (say, the integers between 5 and 10 exclusive), use the seq function to generate a slice of integers and then range over the result:

    {{ range seq 5 10 }}
        {{/* executed with the dot . set to 5, 6, 7, 8, 9 in succession */}}
    {{ end }}
  • Single-variable range. Instead of using the dot . to access the current element or value, one can also assign it to a variable:

    {{ $sports := cslice "tennis" "basketball" "soccer" }}
    {{ range $sport := $sports }}
        {{/* executed with $sport set to "tennis", "basketball", "soccer" in succession */}}
    {{ end }}

    Note that the dot . is still overwritten when using a variable.

  • Range with else branch. Similar to an if conditional, a range action may also have an else block, executed if the slice or map is empty.

    {{ $users := cslice }} {{/* imagine this data is dynamically generated */}}
    {{ range $user := $users }}
        {{/* do something with $user */}}
    {{ else }}
        no users
    {{ end }}

Accessing global context data in range

The following program illustrates a common error for first-time users of range.

{{ $nums := cslice 1 2 3 }}
{{ range $nums }}
    {{/* ... */}}
    {{ .User.Username }} {{/* ERROR: can't evaluate field User in type interface {} */}}
{{ end }}

The problem is that, inside the range block, the dot . is overwritten by successive elements of the slice 1, 2, 3. While this behavior is generally useful—we often want to refer to the current element in a range action—it is counterproductive here, as .User.Username tries to look up the field User on an integer (and fails to do so.) What we really want is to access the global context data as it was before the range loop. One solution is to save the original context data in a variable prior to the loop:

{{ $dot := . }}
{{ range ... }}
    {{ $dot.User.Username }}
{{ end }}

To make this pattern easier, before each custom command execution, YAGPDB predefines the variable $ as the initial context data for you.

Accessing Global Context Data

In a range block, the dot is overwritten by elements of the slice or map, so code such as .User.Username is likely to error. If you need to access the global context data, do so through the predefined $ variable instead.

{{ range ... }}
    {{ $.User.Username }} {{/* instead of .User.Username */}}
{{ end }}

While

while loops as long as the specified condition is truthy. Unlike the range action, the dot . is not affected.

For instance, the following code loops as long as $n is not 1. In each iteration, $n is updated to either n/2 or 3n+1.

{{ $n := 19 }}

{{ print $n " " -}}
{{ while ne $n 1 }}
    {{- if eq (mod $n 2) 0. }}
        {{- $n = div $n 2 }}
    {{- else }}
        {{- $n = mult $n 3 | add 1 }}
    {{- end -}}
    -> {{ print $n " " -}}
{{ end }}

As with range, it is also possible to attach a else branch to a while loop, executed if the condition is falsy initially.

Tip

Many while loops can be written as a more idiomatic range loop instead. In particular, to iterate a fixed number of times, use {{ range n }} as in {{ range 5 }} instead of maintaining your own counter variable with while.

Break and Continue

In custom commands, we provide two actions to control the flow of loops: {{ break }} and {{ continue }}. break exits the loop prematurely, whereas continue skips the remainder of the current iteration and jumps to the next one. These can prove very useful to optimize your code for size and readability, with similar benefits to guard clauses with {{ return }} introduced in earlier chapters.

With Blocks

Just like the if action, with runs a block of code if the given expression is truthy. The only difference is that with overwrites the dot . with the expression if it is truthy.

For instance, the following program

{{ $msg := "I <3 the YAGPDB documentation!" }}
{{ with reFind `\d+` $msg }}
    pattern found in text; the dot {{ printf "%q" . }} contains the match
{{ else }}
    pattern did not match
{{ end }}

outputs

pattern found in text; the dot "3" contains the match

Note that the dot . has been set to "3"—the result of reFind. See if you can change the text stored in $msg so that the program hits the else branch instead.

Warning

Be careful not to overuse with blocks, as they can make your code difficult to follow. In general, prefer using a normal if conditional and only use with if it improves readability; do not use it just to shorten code.