Skip to content

Writing Scripts

A script is a file you can run end-to-end. Anything you type into the shell — let-bindings, fn declarations, pipelines, control flow — also works in a .lash file. The benefit of putting it in a file is that you can re-run it with the same inputs, share it with someone else, or call it from another script.

The simplest possible lash script is just a single command in a file:

echo "hello, scripts"

Save that as hello.lash and run it:

Terminal window
lash hello.lash

That works, but a real script needs three things on top of “code in a file”:

  1. A way to declare what arguments it takes — so the user knows how to call it without reading the source.
  2. A way to surface help text — lash hello.lash --help should print something useful.
  3. A way to test the logic — without touching the filesystem or having to run the script end-to-end.

lash gives you all three through one feature: a top-level fn main.

We’ll take the disk-hog finder from the Pipes and Chains tutorial and turn it into a proper command-line script with typed arguments, auto-generated help, and inline unit tests.

Terminal window
lash --test disk-hogs.lash # runs the unittests only
lash disk-hogs.lash --help # auto-generated help

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


Start small. Drop the pipeline into disk-hogs.lash:

#!/usr/bin/env lash
`find . -type f -size +10M -printf "%s\t%p\n" 2>/dev/null`
.sortNumeric()
.reverse()
.take(15)
.each(x => echo $x)

The #!/usr/bin/env lash shebang is optional — without it the script runs the same way; with it, you can chmod +x disk-hogs.lash and run it as ./disk-hogs.lash.

This works for personal use, but the path, file-size threshold, and the top-N count are all hardcoded. Anyone who runs it has to edit the script to change them. Time to declare arguments.


Step 2: Move it into fn main with typed arguments

Section titled “Step 2: Move it into fn main with typed arguments”

Wrap the body in a fn main and add typed parameters:

#!/usr/bin/env lash
/// Find the largest files under a directory.
fn main(path: string = ".", top: int = 15, min_mb: int = 10) {
`find $path -type f -size +${min_mb}M -printf "%s\t%p\n" 2>/dev/null`
.sortNumeric()
.reverse()
.take(top)
.each(x => echo $x)
}

Two new things:

fn main(...) — this function is special. When the script runs from the command line, lash binds the CLI arguments to the parameter list, coercing each one to its declared type. Anything you’d otherwise put at the top level of the script goes inside this function instead.

path: string = "." — a typed parameter with a default value. If the user runs lash disk-hogs.lash with no args, path is ".". With lash disk-hogs.lash /home, path is "/home".

The doc-comment immediately above fn main plus the parameter list become the script’s --help text automatically:

Terminal window
lash disk-hogs.lash --help
disk-hogs.lash — Find the largest files under a directory.
Arguments:
path (string) default: "."
top (int) default: 15
min_mb (int) default: 10

You didn’t write any of that text — lash derived it from the function signature.

TypeCoerced fromExample
stringthe raw arg"hello"
intparsed integer42
floatparsed floating point3.14
bool"true" / "false"true
semversemver string"1.2.3"

If the user passes --top abc, lash refuses to run and prints 'abc' is not a valid int.


Step 3: Add inline tests for the format helper

Section titled “Step 3: Add inline tests for the format helper”

The pipeline body is glue around find; it doesn’t need a unittest. But formatting numbers as MB is a pure function with edge cases — write the test first:

/// format_size renders a byte count as an MB label
unittest {
format_size(1048576).must.equal("1 MB")
format_size(1572864).must.equal("1.5 MB")
format_size(0).must.equal("0 MB")
}

The middle row is the one to think about: 1.5 MB shouldn’t silently become “1 MB” or “2 MB” — the test pins down that we want the fractional value through. Run lash --test disk-hogs.lash — it fails because format_size doesn’t exist. Now write it:

fn format_size(bytes) {
let mb = bytes / 1024 / 1024
return "$mb MB"
}

Re-run; passes. lash’s / returns a float when the result isn’t an integer, so the fractional case Just Works.

The comment-above-the-block convention is what gives each test its name in the runner output:

Terminal window
lash --test disk-hogs.lash
[PASS] format_size renders a byte count as a whole-MB label
1 test: 1 passed, 0 failed
AssertionChecks
.must.equal(x)Value equality
.must.notEqual(x)Value inequality
.must.beGreaterThan(x)Numeric greater-than
.must.beLessThan(x)Numeric less-than
.must.contain(x)String or list contains
.must.startWith(x)String starts with
.must.endWith(x)String ends with

Override the failure message with .because("reason"):

result.must.equal(42).because("size calculation should return 42")

Add a format parameter so the script can emit JSON when piped into something else. Test the data shape first:

/// to_entries parses size+path lines into structured records
unittest {
let line = "1048576\t/tmp/big.bin"
let entry = to_entry(line)
entry["size_bytes"].must.equal(1048576)
entry["path"].must.equal("/tmp/big.bin")
}

Now the helper:

fn to_entry(line) {
let parts = line.split("\t")
return { size_bytes: parts[0].toNumber(), path: parts[1] }
}

And the branch in fn main:

let results = `find $path -type f -size +${min_mb}M -printf "%s\t%p\n" 2>/dev/null`
.sortNumeric()
.reverse()
.take(top)
.map(to_entry)
if format == "json" {
results.writeln(stdout)
} else {
for entry in results {
echo "${format_size(entry["size_bytes"])}\t${entry["path"]}"
}
}

#!/usr/bin/env lash
/// Find the largest files under a directory.
fn main(path: string = ".", top: int = 15, min_mb: int = 10, format: string = "text") {
let results = `find $path -type f -size +${min_mb}M -printf "%s\t%p\n" 2>/dev/null`
.sortNumeric()
.reverse()
.take(top)
.map(to_entry)
if format == "json" {
results.writeln(stdout)
} else {
for entry in results {
echo "${format_size(entry["size_bytes"])}\t${entry["path"]}"
}
}
}
fn format_size(bytes) {
let mb = bytes / 1024 / 1024
return "$mb MB"
}
fn to_entry(line) {
let parts = line.split("\t")
return { size_bytes: parts[0].toNumber(), path: parts[1] }
}
/// format_size renders a byte count as a whole-MB label
unittest {
format_size(1048576).must.equal("1 MB")
format_size(1572864).must.equal("1 MB")
format_size(0).must.equal("0 MB")
}
/// to_entries parses size+path lines into structured records
unittest {
let entry = to_entry("1048576\t/tmp/big.bin")
entry["size_bytes"].must.equal(1048576)
entry["path"].must.equal("/tmp/big.bin")
}

Run it:

Terminal window
lash --test disk-hogs.lash # the unittests
lash disk-hogs.lash --help # auto-generated help
lash disk-hogs.lash # uses defaults
lash disk-hogs.lash /home/alice # search a specific path
lash disk-hogs.lash /home/alice 5 100 # top 5, at least 100 MB
lash disk-hogs.lash . 15 10 json # emit JSON

Constraining argument values: For inputs more nuanced than string/int/bool, declare a user-defined type. For instance, an output-format argument that must be text or json:

type OutputFormat {
values(query) {
return ["text", "json"]
}
}
fn main(format: OutputFormat = "text") { ... }

Now lash disk-hogs.lash --format yaml is rejected with 'yaml' is not a valid OutputFormat, and tab-completion offers the two valid values for free. See user-defined types for the full feature.

Verbose test output: when an assertion fails, the runner prints just the diff. To see the full assertion call site, the actual and expected values, and a stack frame, use --log-verbose:

Terminal window
lash --test --log-verbose disk-hogs.lash

Script-self path: inside any script, $0 resolves to the script’s own filename. The other shell positionals ($1, $@, $#) were removed in favour of fn main’s named parameters.