Writing Scripts
What’s a script?
Section titled “What’s a script?”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:
lash hello.lashThat works, but a real script needs three things on top of “code in a file”:
- A way to declare what arguments it takes — so the user knows how to call it without reading the source.
- A way to surface help text —
lash hello.lash --helpshould print something useful. - 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.
lash --test disk-hogs.lash # runs the unittests onlylash disk-hogs.lash --help # auto-generated helpThe --test flag runs only the unittest { } blocks; the rest of the
script (the fn main body, free statements) is skipped.
Step 1: Just the code in a file
Section titled “Step 1: Just the code in a file”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:
lash disk-hogs.lash --helpdisk-hogs.lash — Find the largest files under a directory.
Arguments: path (string) default: "." top (int) default: 15 min_mb (int) default: 10You didn’t write any of that text — lash derived it from the function signature.
Argument types you can use
Section titled “Argument types you can use”| Type | Coerced from | Example |
|---|---|---|
string | the raw arg | "hello" |
int | parsed integer | 42 |
float | parsed floating point | 3.14 |
bool | "true" / "false" | true |
semver | semver 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 labelunittest { 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:
lash --test disk-hogs.lash[PASS] format_size renders a byte count as a whole-MB label
1 test: 1 passed, 0 failedAssertion methods
Section titled “Assertion methods”| Assertion | Checks |
|---|---|
.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")Step 4: Branch on output format
Section titled “Step 4: Branch on output format”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 recordsunittest { 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"]}" }}Complete script
Section titled “Complete script”#!/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 labelunittest { 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 recordsunittest { let entry = to_entry("1048576\t/tmp/big.bin") entry["size_bytes"].must.equal(1048576) entry["path"].must.equal("/tmp/big.bin")}Run it:
lash --test disk-hogs.lash # the unittestslash disk-hogs.lash --help # auto-generated helplash disk-hogs.lash # uses defaultslash disk-hogs.lash /home/alice # search a specific pathlash disk-hogs.lash /home/alice 5 100 # top 5, at least 100 MBlash disk-hogs.lash . 15 10 json # emit JSONFurther reading
Section titled “Further reading”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:
lash --test --log-verbose disk-hogs.lashScript-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.