Skip to content

Variables and Types

A variable is a name you give to a value so you can refer back to it later. Instead of writing "1.4.2" everywhere in your script, you write raw once and reuse the name.

In lash you create one with let:

let greeting = "hello"
  • let introduces an immutable binding — once greeting is set, you can’t reassign it. If you need to change the value later, use mut (more on that below).
  • greeting is the name. From here on it stands for "hello".
  • Everything to the right of = is the value.

Variables have types. lash figures the type out from the value, so you don’t write it down. The six core types you’ll use most:

TypeExample
stringlet greeting = "hello"
intlet count = 42
floatlet ratio = 3.14
boollet verbose = true
listlet files = ["a.txt", "b.txt"]
objectlet config = { editor: "vim", tabs: 4 }

We’ll use most of these in the script we’re about to build.

Build a script that reads a VERSION file like 1.4.2, increments the patch number, and writes the result back.

We’ll work test-first — at every step we write a unittest { } block before the code, run it (it fails), then make it pass. The reason isn’t ceremony: pinning the behaviour down in a test catches the cases your intuition misses. You’ll see two of those below.

Terminal window
lash --test bump.lash

The --test flag runs only the unittest { } blocks; the rest of the script (the fn main body, free statements) is skipped.


Step 1: Parse the parts of a version string

Section titled “Step 1: Parse the parts of a version string”

The first thing the script does is split "1.4.2" into three pieces. Write down what we expect:

/// version_parts splits a semver string into [major, minor, patch]
unittest {
version_parts("1.4.2").must.equal(["1", "4", "2"])
version_parts("0.0.1").must.equal(["0", "0", "1"])
}

Run lash --test bump.lash — it fails (no version_parts yet). Now write the function:

fn version_parts(raw) {
return raw.split(".")
}

raw.split(".") returns a list, an ordered sequence of values. Lists hold any types, but here every element is a string. You can index into one with []:

let parts = version_parts("1.4.2")
parts[0] # "1"
parts[1] # "4"
parts[2] # "2"

Lists also support range syntax for integer sequences:

let digits = 1..5 # [1, 2, 3, 4, 5]

Re-run the suite. Both assertions pass.


The patch is currently "2" — a string. To increment it we need to convert it to an int, add one, and convert back. Test the contract first:

/// bump_patch turns "X.Y.Z" into "X.Y.(Z+1)"
unittest {
bump_patch("1.4.2").must.equal("1.4.3")
bump_patch("0.0.0").must.equal("0.0.1")
bump_patch("9.9.9").must.equal("9.9.10")
}

Three rows, three things we care about:

  • typical case,
  • zero base,
  • carry into a two-digit patch.

The third row is why we test before writing code. Without it, you might write bump_patch as “concatenate the patch with '1'” — which works for "2" → "21" if you don’t think about it, and obviously breaks the moment you try "9" → "10". The test forces you to actually do the arithmetic.

Now the implementation:

fn bump_patch(raw) {
let parts = version_parts(raw)
let major = parts[0]
let minor = parts[1]
let patch = parts[2].toNumber() + 1
return "$major.$minor.$patch"
}

Two new things here:

parts[2].toNumber() + 1 — converts the string "2" to a number, then adds. Arithmetic notes:

let a = 10 / 3 # 3.333... (lash's `/` is real division)
let b = 10 % 3 # 1 (modulo)

If you need integer division, take the floor explicitly with (a / b).toNumber() after rounding via awk/bash, or guard your inputs so the division is exact.

"$major.$minor.$patch"string interpolation. Inside a double-quoted string, $name substitutes the variable’s value. lash supports several forms:

let user = "alice"
let count = 3
echo "hello $user" # hello alice
echo "${user}_home" # alice_home
echo "total: ${count * 10}" # total: 30

You can index lists and objects directly inside the string:

let colors = ["red", "green", "blue"]
echo "first: $colors[0]" # first: red

Single quotes opt out of interpolation entirely:

echo 'no $interpolation here' # no $interpolation here

Re-run the suite. All three rows pass.


A bad input like "1.4" should fail loudly, not silently produce nonsense. Test that intent first:

/// version_parts on a malformed string returns the wrong shape — we
/// detect that with parts.length, not by trusting the caller.
unittest {
version_parts("1.4").length.must.equal(2)
version_parts("1.4.2").length.must.equal(3)
}

This test doesn’t change version_parts — it documents that the script should check the length and reject anything that isn’t 3. We’ll do that check in fn main below.


The runtime glue takes a filename, reads it, calls bump_patch, writes the result back. We don’t unittest main — it touches the filesystem and we’d be testing cat and >, not our logic. The pure functions above already have tests.

fn main(file: string = "VERSION") {
let raw = `cat $file`.first.trim()
if version_parts(raw).length != 3 {
exit "unexpected version format: $raw (expected X.Y.Z)"
}
let next = bump_patch(raw)
echo "$raw -> $next" > /dev/stderr
write to $file as f { next.writeln(f) }
}

Two notes:

fn main(file: string = "VERSION") — the = "VERSION" is a default value. If the user runs lash bump.lash with no arg, file is "VERSION". With an arg, file is whatever they pass.

Progress text goes to /dev/stderr so it doesn’t contaminate stdout if another script captures your output. The actual file write uses the write to block so the path expansion is unambiguous.


Two more variable forms to know about — both come up in the script we just built.

mut lets you change a value:

mut counter = 0
counter = counter + 1 # valid

Reassigning a let is an error:

raw = "something else"
# Error: 'raw' is immutable. Declare with 'mut' if you need to reassign.

Redeclaring a name in the same scope is also an error — use drop to remove the binding first:

let x = 5
drop x
let x = 10 # OK

#!/usr/bin/env lash
/// Increment the patch version in a VERSION file.
fn main(file: string = "VERSION") {
let raw = `cat $file`.first.trim()
if version_parts(raw).length != 3 {
exit "unexpected version format: $raw (expected X.Y.Z)"
}
let next = bump_patch(raw)
echo "$raw -> $next" > /dev/stderr
write to $file as f { next.writeln(f) }
}
fn version_parts(raw) {
return raw.split(".")
}
fn bump_patch(raw) {
let parts = version_parts(raw)
let major = parts[0]
let minor = parts[1]
let patch = parts[2].toNumber() + 1
return "$major.$minor.$patch"
}
/// version_parts splits a semver string into [major, minor, patch]
unittest {
version_parts("1.4.2").must.equal(["1", "4", "2"])
version_parts("0.0.1").must.equal(["0", "0", "1"])
}
/// bump_patch turns "X.Y.Z" into "X.Y.(Z+1)"
unittest {
bump_patch("1.4.2").must.equal("1.4.3")
bump_patch("0.0.0").must.equal("0.0.1")
bump_patch("9.9.9").must.equal("9.9.10")
}

Run it:

Terminal window
lash --test bump.lash # the unittests only
lash bump.lash # uses the default VERSION file
lash bump.lash custom.txt # uses a custom path
# 1.4.2 -> 1.4.3

The doc-comment above fn main plus the typed parameters become the script’s --help text automatically:

Terminal window
lash bump.lash --help
bump.lash — Increment the patch version in a VERSION file.
Arguments:
file (string) default: "VERSION"

Variable scoping: Variables are lexically scoped to the block where they are declared. Inner scopes shadow outer ones without modifying them:

let x = "outer"
if true {
let x = "inner"
echo $x # inner
}
echo $x # outer

Environment variables: All let and mut variables are exported to child processes. Use the env namespace for explicit access to system variables:

echo $HOME
let editor = env.EDITOR

Objects: Key-value data with dot or bracket access:

let server = { host: "localhost", port: 8080, tls: false }
server.host # "localhost"
server["port"] # 8080

Universal properties: Every value has .isSuccess and .isFailure:

let result = `grep -r "TODO" src/`
if result.isSuccess {
echo "found TODOs"
}

Escape sequences: Double-quoted strings support standard escapes — \n, \t, \\, \", \$. Single-quoted strings treat all of these as literal characters.