Variables and Types
What’s a variable?
Section titled “What’s a variable?”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"letintroduces an immutable binding — oncegreetingis set, you can’t reassign it. If you need to change the value later, usemut(more on that below).greetingis 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:
| Type | Example |
|---|---|
string | let greeting = "hello" |
int | let count = 42 |
float | let ratio = 3.14 |
bool | let verbose = true |
list | let files = ["a.txt", "b.txt"] |
object | let 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.
lash --test bump.lashThe --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.
Step 2: Increment the patch number
Section titled “Step 2: Increment the patch number”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 aliceecho "${user}_home" # alice_homeecho "total: ${count * 10}" # total: 30You can index lists and objects directly inside the string:
let colors = ["red", "green", "blue"]echo "first: $colors[0]" # first: redSingle quotes opt out of interpolation entirely:
echo 'no $interpolation here' # no $interpolation hereRe-run the suite. All three rows pass.
Step 3: Validate the input
Section titled “Step 3: Validate the input”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.
Step 4: Wire it into a main
Section titled “Step 4: Wire it into a main”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.
Variables you didn’t write
Section titled “Variables you didn’t write”Two more variable forms to know about — both come up in the script we just built.
mut lets you change a value:
mut counter = 0counter = counter + 1 # validReassigning 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 = 5drop xlet x = 10 # OKComplete script
Section titled “Complete script”#!/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:
lash --test bump.lash # the unittests onlylash bump.lash # uses the default VERSION filelash bump.lash custom.txt # uses a custom path# 1.4.2 -> 1.4.3The doc-comment above fn main plus the typed parameters become the
script’s --help text automatically:
lash bump.lash --helpbump.lash — Increment the patch version in a VERSION file.
Arguments: file (string) default: "VERSION"Further reading
Section titled “Further reading”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 # outerEnvironment variables: All let and mut variables are exported to
child processes. Use the env namespace for explicit access to system
variables:
echo $HOMElet editor = env.EDITORObjects: Key-value data with dot or bracket access:
let server = { host: "localhost", port: 8080, tls: false }server.host # "localhost"server["port"] # 8080Universal 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.