Skip to content

Functions

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) # 10
  • fn introduces the function.
  • double is the name; you call it later with that name.
  • (x) is the parameter list — the things the function expects to receive. Inside the body, x holds whatever you pass in.
  • return says 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:

Terminal window
lash --test log-errors.lash

The --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 1234

The status code is the ninth space-separated field — index 8.

Start with the test:

/// extracts the status code from a common-log line
unittest {
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 code
unittest {
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 otherwise
unittest {
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.


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 closure
unittest {
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.


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_error
unittest {
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.


#!/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 line
unittest {
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 otherwise
unittest {
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 closure
unittest {
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:

Terminal window
lash --test log-errors.lash # only the unittest blocks
lash log-errors.lash /var/log/nginx/access.log # production run
lash log-errors.lash /var/log/nginx/access.log 5 # top 5 only

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

Terminal window
lash log-errors.lash --help
log-errors.lash — Summarize error status codes from a web server access log.
Arguments:
logfile (string)
top (int) default: 10
column (int) default: 8
Top 10 error status codes in access.log:
404: 2105 occurrences
500: 212 occurrences
403: 87 occurrences

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 1
unittest { factorial(0).must.equal(1) }
/// factorial(6) is 720
unittest { 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.