Functions
What’s a function?
Section titled “What’s a function?”A function is a named piece of code you can call by name to get work
done. Instead of repeating the same five lines every time you want to
extract a status code from a log line, you write those lines once, give
them a name (say, extract_status), and then call extract_status(line)
wherever you need it.
In lash, the simplest function looks like this:
fn double(x) { return x * 2}
double(5) # 10fnintroduces the function.doubleis the name; you call it later with that name.(x)is the parameter list — the things the function expects to receive. Inside the body,xholds whatever you pass in.returnsays what value to give back.
You can call functions exactly like a built-in command: by name, with arguments. They’re values in the language — you can pass them around, store them in variables, return them from other functions. We’ll use all of that below.
A function should usually do one thing and return a result. Functions that “do one thing” are easy to test, easy to reuse, and easy to compose into pipelines.
Parse a web server access log and summarize error status codes. The log has thousands of lines; we want to count how often each 4xx and 5xx code appears without scanning the whole file twice.
The first thing we’ll do at every step is write a unittest { } block —
before any function body. Pinning down the behaviour in a test first stops
us from writing code that “looks right” but quietly does the wrong thing.
You can run the suite at any time:
lash --test log-errors.lashThe --test flag runs the unittest { } blocks only — the script’s
fn main body is skipped, so the tests don’t need a live log file. Add
--log-verbose to see assertion-failure detail.
Step 1: Extract the status code from a log line
Section titled “Step 1: Extract the status code from a log line”A common log format line looks like this:
10.0.0.1 - alice [15/Jan/2026:10:00:01 +0000] "GET /api/users HTTP/1.1" 200 1234The status code is the ninth space-separated field — index 8.
Start with the test:
/// extracts the status code from a common-log lineunittest { extract_status(`10.0.0.1 - alice [15/Jan/2026:10:00:01 +0000] "GET /api/users HTTP/1.1" 200 1234`) .must.equal("200")}Run lash --test log-errors.lash and it fails — extract_status doesn’t
exist yet. That’s good: the test is now driving us to write exactly the
function we need, no more.
Now the implementation. fn declares a function with named parameters, and
return specifies the value:
fn extract_status(line) { return line.split(" ")[8]}Re-run the suite. It passes. Lock in a second case before moving on — a 4xx line, just to be sure the field index isn’t a coincidence:
/// extracts a 4xx status codeunittest { extract_status(`1.2.3.4 - - [01/Jan:00:00:00 +0000] "GET / HTTP/1.1" 404 0`) .must.equal("404")}Already green; the existing implementation handles it. The two unittests together are the function’s contract — anything that breaks either one will be caught immediately.
Step 2: Decide if a status code is an error
Section titled “Step 2: Decide if a status code is an error”The four cases we care about — 4xx error, 5xx error, 2xx success, 3xx redirect — go straight into a single test:
/// is_error: true for 4xx and 5xx codes, false otherwiseunittest { is_error("404").must.equal(true) is_error("500").must.equal(true) is_error("200").must.equal(false) is_error("301").must.equal(false)}Why all four in one block? Because the function’s behaviour is “yes for
4/5, no for everything else”, and that statement only makes sense when
both sides are exercised. A single-assertion test for is_error("404") == true would let fn is_error(_) { return true } pass, which is
obviously wrong.
Now the function:
fn is_error(status) { return status.startsWith("4") || status.startsWith("5")}Functions are immutable bindings — they live in the command namespace and cannot be reassigned.
Step 3: Wire them together with a chain
Section titled “Step 3: Wire them together with a chain”The next step is gluing the two functions together. We don’t write a unittest for the chain itself — both halves already have tests, and the chain is just transport. Smoke-test it manually with a small fixture:
let errors = `cat access.log` .map(x => extract_status(x)) .filter(x => is_error(x)) .wordcount() .take(10)
for entry in errors { echo "status ${entry["word"]}: ${entry["count"]} occurrences"}.map() and .filter() take lambda expressions — anonymous functions
written inline with =>:
`ls`.filter(x => x.endsWith(".log"))Named functions and lambdas are interchangeable when passed to chain methods:
# These are equivalent:`cat access.log`.map(x => extract_status(x))`cat access.log`.map(extract_status)The rule of thumb: tests pin down pure functions; chains are wiring and get a hand-eye check on real input.
Step 4: Make the column configurable with a closure
Section titled “Step 4: Make the column configurable with a closure”Different deployments may put the status code in a different column. Test it both ways before committing to an implementation — the second binding proves the closure isn’t sharing state with the first:
/// status_extractor binds the column index in a closureunittest { let pick9 = status_extractor(8) pick9(`a b c d e f g h 200 i`).must.equal("200")
let pick3 = status_extractor(2) pick3(`a b 404 d e`).must.equal("404")}Now write the factory:
fn status_extractor(column) { line => line.split(" ")[column]}The inner lambda line => line.split(" ")[column] closes over column
from the outer scope. The unittest above is what makes that real — without
the second binding, you couldn’t tell whether column was captured per
call or shared across all calls.
Step 5: Sum errors with reduce
Section titled “Step 5: Sum errors with reduce”The chain we already have (map → filter) plus a .length is the
simplest way to count errors. Test that:
/// counts how many of the items satisfy is_errorunittest { let codes = ["200", "404", "500", "201"] codes.filter(x => is_error(x)).length.must.equal(2)}Already green. We didn’t have to write any new code — once is_error
exists, the count emerges from the chain.
If you’d rather express it as a fold:
fn count_errors(acc, code) { if is_error(code) { return acc + 1 } else { return acc }}
["200", "404", "500", "201"].reduce(0, count_errors) # 2.reduce(initial, fn) accumulates a single value across all elements,
starting with initial. The function receives the current accumulator and
the current element.
Complete script
Section titled “Complete script”#!/usr/bin/env lash
/// Summarize error status codes from a web server access log.fn main(logfile: string, top: int = 10, column: int = 8) { let check = `test -f $logfile`.capture if check.isFailure { exit "file not found: $logfile" }
let extract = status_extractor(column)
let errors = `cat $logfile` .map(extract) .filter(x => is_error(x)) .wordcount() .take(top)
if errors.length == 0 { echo "No errors found in $logfile" } else { echo "Top $top error status codes in $logfile:" for entry in errors { echo " ${entry["word"]}: ${entry["count"]} occurrences" } }}
fn status_extractor(col) { line => line.split(" ")[col]}
fn is_error(status) { return status.startsWith("4") || status.startsWith("5")}
fn extract_status(line) { return line.split(" ")[8]}
/// extracts the status code from a common-log lineunittest { extract_status(`10.0.0.1 - alice [15/Jan:10:00:01 +0000] "GET /a HTTP/1.1" 200 0`) .must.equal("200")}
/// is_error: true for 4xx and 5xx codes, false otherwiseunittest { is_error("404").must.equal(true) is_error("500").must.equal(true) is_error("200").must.equal(false) is_error("301").must.equal(false)}
/// status_extractor binds the column index in a closureunittest { let pick9 = status_extractor(8) pick9(`a b c d e f g h 200 i`).must.equal("200")
let pick3 = status_extractor(2) pick3(`a b 404 d e`).must.equal("404")}Run it:
lash --test log-errors.lash # only the unittest blockslash log-errors.lash /var/log/nginx/access.log # production runlash log-errors.lash /var/log/nginx/access.log 5 # top 5 onlyThe doc-comment on fn main plus its typed parameters become the
script’s --help text automatically:
lash log-errors.lash --helplog-errors.lash — Summarize error status codes from a web server access log.
Arguments: logfile (string) top (int) default: 10 column (int) default: 8Top 10 error status codes in access.log: 404: 2105 occurrences 500: 212 occurrences 403: 87 occurrencesFurther reading
Section titled “Further reading”Recursion: Functions can call themselves. With recursion the base case is the easy thing to forget, so it goes in the test first:
/// factorial(0) is 1unittest { factorial(0).must.equal(1) }
/// factorial(6) is 720unittest { factorial(6).must.equal(720) }
fn factorial(n) { if n <= 1 { return 1 } return n * factorial(n - 1)}Higher-order functions: Functions that take or return other functions
are first-class. status_extractor above is one — the unittest pattern
is the same: bind the closure, call it, assert.