Skip to content

Control Flow

Control flow is how a program decides what to do next. So far the scripts in earlier tutorials have been mostly straight-line: read a file, transform it, write it back. Control flow lets you branch (“only do this if the file is too big”), repeat (“do it for every image in the folder”), and dispatch (“based on the extension, pick the right tool”).

lash has four building blocks:

  • if / else if / else — choose one of several blocks based on a condition.
  • for — iterate over a list, range, or command output.
  • while — repeat as long as a condition holds.
  • match — pick one branch based on which pattern fits a value.

You’ll use all four in the script we’re about to build.

Build a script that finds every image in a folder wider than a set limit and resizes them in place. We’ll start with the “is the tool installed” check, then loop, then add a dry-run flag, then dispatch on file extension. The pure helpers get unittests; the I/O parts get a manual smoke check.

Terminal window
lash --test resize.lash # the unittests only
lash resize.lash --help # auto-generated help

The --test flag runs only the unittest { } blocks; the fn main body is skipped — so the tests don’t need an actual convert binary or any image files.


Step 1: Conditionals — guard against a missing tool

Section titled “Step 1: Conditionals — guard against a missing tool”

Before doing any work, verify ImageMagick is installed. This is a classic if:

let check = `which convert`.capture
if check.isFailure {
exit "ImageMagick 'convert' is required but not installed"
}

if blocks use braces — no then/fi. The full syntax:

if count > 10 {
echo "many"
} else if count > 0 {
echo "some"
} else {
echo "none"
}

Conditions can use &&, ||, !, and the comparison operators (==, !=, <, <=, >, >=):

if age >= 18 && country == "NL" {
echo "allowed"
}

This is straight wiring around an external check — there’s nothing pure to test here yet.


Step 2: Decide whether a file needs resizing

Section titled “Step 2: Decide whether a file needs resizing”

The decision “should this file be resized” is a pure function of width and the limit. Pin it down first:

/// needs_resize is true iff the actual width exceeds the target
unittest {
needs_resize(2400, 1920).must.equal(true) // wider — resize
needs_resize(1920, 1920).must.equal(false) // exactly at limit — skip
needs_resize(800, 1920).must.equal(false) // narrower — skip
}

The middle row matters: if the image is exactly at the limit, we don’t want to bounce it through convert and lose quality for nothing. The implementation:

fn needs_resize(width, limit) {
return width > limit
}

A two-line function with three test rows isn’t overkill — it’s the record of what we decided. If a future contributor changes the condition to >=, that test stops them.


Now the loop. for iterates over a list, a range, or the output of a command:

for file in `find $dir -type f \( -name "*.jpg" -o -name "*.png" \)` {
echo $file
}

Other shapes:

# list literal
for name in ["alice", "bob", "carol"] {
echo "hello $name"
}
# range
for i in 1..10 {
echo "item $i"
}

For our resizer, we read each file’s width and skip the small ones with continue:

mut resized = 0
mut skipped = 0
for file in `find $dir -type f \( -name "*.jpg" -o -name "*.png" \)` {
let width = `identify -format "%w" $file`.trim().toNumber()
if !needs_resize(width, max_width) {
skipped = skipped + 1
continue # jump to the next file
}
# ... resize logic next ...
}

continue moves to the next iteration. break exits the loop entirely:

for line in `cat big.log` {
if line.contains("FATAL") {
echo "Fatal error found: $line"
break
}
}

The script supports both .jpg and .png — and we want a pretty label in the output. That’s a multi-way dispatch — exactly what match is for. Test it first:

/// label_for picks a friendly label per file extension
unittest {
label_for("jpg").must.equal("JPEG")
label_for("jpeg").must.equal("JPEG")
label_for("png").must.equal("PNG")
label_for("gif").must.equal("GIF")
label_for("xyz").must.equal("unknown")
}

Five rows because there are five behaviours we care about: each named extension gives its own label, the jpg/jpeg aliases collapse, and unknown extensions get a sentinel rather than crashing.

fn label_for(ext) {
return match ext {
"jpg" | "jpeg" => "JPEG"
"png" => "PNG"
"gif" => "GIF"
_ => "unknown"
}
}

match checks patterns from top to bottom and stops at the first hit. | combines patterns. _ is the wildcard that catches anything not matched above. Every arm must produce a value (the last expression in the arm body), because match is an expression — it returns the value of the matching arm.


The runtime glue takes a directory, a width limit, and a dry-run flag. We don’t unittest main — it touches the filesystem and shells out to convert. The pure helpers (needs_resize, label_for) already have tests.

fn main(dir: string, max_width: int = 1920, dry_run: bool = false) {
let check = `which convert`.capture
if check.isFailure {
exit "ImageMagick 'convert' is required but not installed"
}
mut resized = 0
mut skipped = 0
for file in `find $dir -type f \( -name "*.jpg" -o -name "*.png" \)` {
let width = `identify -format "%w" $file`.trim().toNumber()
if !needs_resize(width, max_width) {
skipped = skipped + 1
continue
}
let ext = file.split(".").last
let kind = label_for(ext)
if dry_run {
echo " would resize ($kind): $file ($width px -> $max_width px)"
} else {
convert $file -resize $max_width $file
echo " resized ($kind): $file"
}
resized = resized + 1
}
echo ""
echo "$resized resized, $skipped already within limit"
}

mut declares a mutable bindinglet would refuse the later reassignment. We need it here because the counters change across loop iterations.


#!/usr/bin/env lash
/// Resize images larger than a given width.
fn main(dir: string, max_width: int = 1920, dry_run: bool = false) {
let check = `which convert`.capture
if check.isFailure {
exit "ImageMagick 'convert' is required but not installed"
}
mut resized = 0
mut skipped = 0
for file in `find $dir -type f \( -name "*.jpg" -o -name "*.png" \)` {
let width = `identify -format "%w" $file`.trim().toNumber()
if !needs_resize(width, max_width) {
skipped = skipped + 1
continue
}
let ext = file.split(".").last
let kind = label_for(ext)
if dry_run {
echo " would resize ($kind): $file ($width px -> $max_width px)"
} else {
convert $file -resize $max_width $file
echo " resized ($kind): $file"
}
resized = resized + 1
}
echo ""
echo "$resized resized, $skipped already within limit"
}
fn needs_resize(width, limit) {
return width > limit
}
fn label_for(ext) {
return match ext {
"jpg" | "jpeg" => "JPEG"
"png" => "PNG"
"gif" => "GIF"
_ => "unknown"
}
}
/// needs_resize is true iff the actual width exceeds the target
unittest {
needs_resize(2400, 1920).must.equal(true)
needs_resize(1920, 1920).must.equal(false)
needs_resize(800, 1920).must.equal(false)
}
/// label_for picks a friendly label per file extension
unittest {
label_for("jpg").must.equal("JPEG")
label_for("jpeg").must.equal("JPEG")
label_for("png").must.equal("PNG")
label_for("gif").must.equal("GIF")
label_for("xyz").must.equal("unknown")
}

Run it:

Terminal window
lash --test resize.lash # the unittests
lash resize.lash --help # auto-generated help
lash resize.lash ~/Photos # use the default 1920 limit
lash resize.lash ~/Photos 2560 true # 2560 px, dry run

The doc-comment plus typed parameters become the script’s --help:

Terminal window
lash resize.lash --help
resize.lash — Resize images larger than a given width.
Arguments:
dir (string)
max_width (int) default: 1920
dry_run (bool) default: false

While loops: repeat as long as a condition holds:

mut n = 1
while n <= 5 {
echo $n
n = n + 1
}

A while loop usually pairs with a mut counter so the loop has a chance to terminate.

Chains with filters: for many “loop, skip, do” patterns, a chain is more concise than a for + continue:

`find $dir -name "*.jpg"`
.filter(x => needs_resize(`identify -format "%w" $x`.trim().toNumber(), max_width))
.each(x => { convert $x -resize $max_width $x })

filter replaces the if … continue; each replaces the loop body.

Job control: background a long-running job with &:

make -j8 &
CommandDescription
jobsList all running and stopped background jobs
fg %nBring job n to the foreground
bg %nResume a stopped job in the background
Ctrl+ZSuspend the foreground process (SIGTSTP)
Ctrl+CInterrupt the foreground process (SIGINT)