boop Tutorial
A complete walkthrough of boop from first principles. By the end you will have written real classes, used the standard library, wired up signals, queried JSON, and tested everything with the built-in test runner. No prior OOP-in-bash experience required.
Table of Contents
- What boop actually is — and what it is not
- Setup and first source
- Your first class
- Returning values — the
into=system - Constructors and properties
- Instance methods
- Inheritance and
_Super - Mixins
- The standard library
- Error handling
- Signals
- Testing with TestSuite
- CLI tools
- Complete example: a task list app
1. What boop actually is
boop is an OOP layer for bash. It is a single sourceable file (~2 500 lines) that, when sourced, gives you:
- Real classes.
boopClassregisters a class with properties, method visibility, and a parent class (optional). - Real objects.
ClassName [key=val ...]creates an instance and returns its object ID. - Dispatch wrappers.
$obj.method arg…dispatches with_Selfand_Classset correctly — no subshells, no forks. - A return system.
boop.pass value ${into:-}writes a return value into a named variable instead of stdout. This keeps the hot path free of$()subshells. - Inheritance + MRO. Single parent, cached method resolution.
- Mixins.
boopMixinlets you share behaviour across unrelated classes without forcing inheritance. - A class loader.
. boop ClassNamefinds and sources the right file automatically.
Everything is pure bash. Nothing compiles. There are no C extensions, no subprocesses in the dispatch path, and no package manager requirements.
Why OOP in bash at all?
The primitives OOP gives you — encapsulation, inheritance, object identity — aren’t just aesthetics. They solve real bash pain:
- Data isolation. A bash script’s default state is one giant pile of globals. Objects let you attach state to a named thing and pass that thing around, rather than threading prefixed variables through every function call.
- Code reuse. Inheritance and mixins let you define behaviour once and graft it onto multiple classes. Without this you copy-paste, or invent yet another naming convention and hope everyone follows it.
- Boundaries. A method that operates on
_Selfcan only reach what its class gives it. That constraint makes large scripts easier to reason about and refactor.
None of this is magic. It is convention — carefully maintained, enforced by the framework rather than by discipline alone.
What boop is not
Be honest with yourself before you commit:
It is not fast. Every method call goes through dispatch wrappers
and associative-array lookups. It avoids $() subshells in the hot
path, which helps, but it is still bash. If you need to process
millions of records per second, reach for a compiled tool. boop is
for the logic that orchestrates — not the loop that burns CPU.
It is not for binary data. Bash variables cannot hold NUL bytes. If your data contains embedded NULs (binary blobs, certain compressed formats) it will be silently truncated. See GOTCHAS.md.
It is not a showcase of exemplary bash. It is a thought experiment
that turned out to be useful — a deliberate answer to the question
“what would OOP in pure bash look like?” The internals use
namerefs, associative arrays, eval, and dynamic dispatch in ways
that would get a normal script rejected in code review. The framework
earns those tricks; your code using the framework does not need to.
It is not a general replacement for a real language. If you have Python, Ruby, or Go available and time is not the constraint, use them. boop earns its keep in the niche where you need structured logic in bash specifically — deployment hooks, system configuration, scripts that live next to the tools they orchestrate and cannot take on a runtime dependency.
If you are still here: good. You found the niche.
2. Setup and first source
Install
git clone <repo-url> ~/boop
export PATH="$PATH:$HOME/boop" # makes `. boop` work from anywhere
Nothing else is required. The PATH addition lets . boop (and
. boop ClassName) find the framework file by name.
Source the framework
In any bash script:
#!/usr/bin/env bash
. boop # load core framework only
Or load the core plus one or more library classes in a single line:
. boop Args List Config
Each name after boop is a class to load. You can also load
additional classes later with the same syntax — boop’s framework
initialization is guarded and won’t re-run, but the import step
runs every time:
. boop # load framework
# ... later ...
. boop Foo Bar # framework init skipped; Foo and Bar are loaded now
Each class file has its own idempotence guard (boop.init ClassName),
so sourcing an already-loaded class is a no-op.
Other ways to load classes
. boop ClassName is the idiomatic entry point for scripts, but there
are two lower-level loaders you will encounter inside class files:
_Require ClassName — fatal hard dependency. Crashes with a clear
error if the class cannot be found or fails to source. This is what
. boop ClassName uses internally, and it is the right choice when
your code cannot function without the dependency.
_Require Config # crash if Config unavailable
_Require Math Config List # crash if any are unavailable
_Load ClassName — non-fatal soft dependency. Returns 1 if the
class is unavailable; never crashes. Use it when you want to try for
a dependency and gracefully degrade if it is missing.
_Load Math || { printf 'precision math unavailable\n'; return 1; }
if _Load Prometheus; then Prometheus.record "$metric" "$value"; fi
Both functions are idempotent — calling them for an already-loaded class is a cheap no-op (registry check, no file I/O).
Class resolution and BOOPPATH
When boop looks for a class file it searches in order: the current
directory, entries in BOOPPATH (colon-separated, like PATH), and
finally entries from PATH itself. You can register an explicit path
for a class using boop.classPath:
boop.classPath set MyClass /opt/lib/myproject/MyClass
boop.classPath list # show all registered overrides
boop.classPath get MyClass # show path for one class
boop.classPath remove MyClass
For most use cases — running from the repo root or with PATH set as
shown above — the defaults work without any configuration.
Verify it loaded
. boop
printf 'boop %s\n' "$__boop_version" # prints: boop 1.0.0
3. Your first class
A class is: one or more bash functions following the ClassName.method
naming convention, closed with a boopClass declaration.
The simplest classes can be declared inline and used with only the defaults.
#!/bin/bash
. boop
boopClass Hero 'has:team,name,wealth'
into=a Hero team=Avengers name='Iron Man' wealth=999999999
_EOL=$' is here!\n' $a.name
To add your own functionality, just write the methods and declare them public.
You will have to name them according to the convention, which prefixes the
class name at the front of the method name for dispatch.
#!/usr/bin/env bash
. boop # load the framework
Counter.increment() {
local _Class="${_Class:-Counter}" _Self="${_Self:-}" # boilerplate for dispatch context
local __Counter_increment_val # triple-prefix naming avoids nameref collisions
into=__Counter_increment_val $_Self.count # read via autogenerated accessor
(( ++__Counter_increment_val )) # prefix ++ always nonzero, safe under set -e
$_Self.count "$__Counter_increment_val" # write back via accessor (one arg = setter)
}
boopClass Counter 'has:count public:increment'
Using it:
. boop Counter # if in $BOOPPATH, or paste it inline
into=a Counter count=0 && echo "a=$a"
into=b Counter count=0 && echo "b=$b"
o=( "$a" "$b" ) # object names into an array
$a.increment # a.count = 1
for i in loop three times; do $b.increment; done # b.count = 3
for obj in "${o[@]}"; do _EOL=" on $obj"$'\n' $obj.count; done
The ouput:
a=__obj_01
b=__obj_02
1 on __obj_01
3 on __obj_02
What each line does
local _Class="${_Class:-Counter}" _Self="${_Self:-}"
Every method generally needs this boilerplate at the top. _Class is
the class name (used by _Super and _Cast). _Self is the object ID
(used by property access and dispatch). boop sets them via inline env vars
on every call — these locals capture and protect them from leaking.
into=__Counter_increment_val $_Self.count
Reads the count property into a local variable via the autogenerated
accessor. $_Self expands to the object ID (e.g. __obj_01), and
bash appends .count, making __obj_01.count — the baked wrapper
method for this object and property. into= routes the return value
into the variable rather than stdout.
$_Self.count "$__Counter_increment_val"
Stores the new property value. The same accessor called with one argument
acts as a setter. Note the $ before _Self — without it, bash would
look for a function literally named _Self.count, not the object’s
baked wrapper.
boopClass Counter 'has:count public:increment'
Registers the class. has:count tells boop that count is a
property (getter and setter auto-generated, no method needed).
public: names the author-written methods that become dispatchable.
Object IDs are always safe unquoted. They are generated as
__obj_ followed by lowercase hex digits — no spaces, no glob
characters, no special characters. You don’t have to quote $a,
$_Self, or ${!obj} for word-splitting or globbing safety.
4. Returning values — the into= system
boop avoids $() subshells for performance. Instead of:
result="$(some_function)" # forks a subshell — expensive
boop uses:
into=result some_function # no fork — writes directly into 'result'
This is the return-target prefix pattern. The called function reads
${into:-} inside boop.pass to decide where to write.
Inline prefix vs. export
into=x $obj.method # CORRECT: scoped inline prefix
export into=x # don't; there are better ways to do this
Always use the inline (no-export) form. Treat into like a keyword.
When to use stdout
If you genuinely want stdout (piping to another tool, printing to the
user), just don’t use into=:
$obj.describe # prints to stdout
$obj.describe | head # pipes correctly
This works for the usual subshell version of assignment, though it’s much less efficient.
val=$($o.method) # will work, just pointlessly slower, and a bad habit
What will not work in a subshell is any change you make. This is still bash. The subshell forks a copy, and any changes made there silently vanish with the subshell’s internal environment when it exits.
_EOL= — custom line ending
_EOL is a sibling of into=. It controls what appends after the value when
writing to stdout (default: newline). Override inline:
_EOL=' (done)\n' $obj.label # prints: SomeLabel (done)
_EOL='' $obj.label # prints without trailing newline
You can set a different default globally as well, with either simple assignment or argument syntax.
_EOL=$'\r\n' # just assign it,
_EOL $'\n\n' # or pass as an argument
This changes the global default, but you can still override it inline for any given command.
5. Constructors and properties
The default constructor is provided by boop. You do not need to write one unless you need custom initialization logic.
Default constructor: has: properties
boopClass Person 'has:name,age public:greet'
This generates:
- a constructor that accepts
name=andage=keyword arguments - getter methods
$obj.nameand$obj.age - setter syntax
$obj.name "value"and$obj.age "value"
into=p Person name="Alice" age=30
into=n $p.name # n = "Alice"
into=a $p.age # a = "30"
$p.name "Bob" # update name
Custom constructor
Override ClassName.new to add initialization logic:
Rectangle.new() {
local _Class="${_Class:-Rectangle}" _Self="${_Self:-}"
# Let the default constructor handle keyword args (width=, height=)
__boop.new "$@"
# Post-construction validation
local __Rectangle_new_w __Rectangle_new_h
into=__Rectangle_new_w $_Self.width
into=__Rectangle_new_h $_Self.height
if (( __Rectangle_new_w <= 0 || __Rectangle_new_h <= 0 )); then
_Error "Rectangle: width and height must be positive"; return 1
fi
}
Rectangle.area() {
local _Class="${_Class:-Rectangle}" _Self="${_Self:-}"
local __Rectangle_area_w __Rectangle_area_h
into=__Rectangle_area_w $_Self.width
into=__Rectangle_area_h $_Self.height
boop.pass "$(( __Rectangle_area_w * __Rectangle_area_h ))" "${into:-}"
}
boopClass Rectangle 'has:width,height public:area'
into=r Rectangle width=4 height=5
into=a $r.area
printf 'Area: %s\n' "$a" # Area: 20
_Self shortcuts
Inside any method, $_Self expands to the object ID and .propname
is appended to form the baked accessor function name.
Use into=var $_Self.prop to read and $_Self.prop "value" to write.
6. Instance methods
Instance methods receive _Self (the object ID) and can call other
methods on it, call _Super, or dispatch to other objects.
Calling methods on _Self
Widget.render() {
local _Class="${_Class:-Widget}" _Self="${_Self:-}"
local __Widget_render_label __Widget_render_size
into=__Widget_render_label $_Self.label
into=__Widget_render_size $_Self.size
printf '[%s:%s]' "$__Widget_render_label" "$__Widget_render_size"
boop.pass "" "${into:-}" # no meaningful return value — side effect only
}
Calling methods on other objects
Dashboard.show() {
local _Class="${_Class:-Dashboard}" _Self="${_Self:-}"
local __Dashboard_show_w
into=__Dashboard_show_w $_Self.widget # properties CAN be other objects!
$__Dashboard_show_w.render # dispatch on it
}
Class-level (static) methods
Methods called directly on the class name (not an instance) still
receive _Class and _Self — _Self equals _Class for static calls.
MathHelper.square() {
local _Class="${_Class:-MathHelper}" _Self="${_Self:-}"
local __MathHelper_square_n="${1:-0}"
boop.pass "$(( __MathHelper_square_n * __MathHelper_square_n ))" "${into:-}"
}
boopClass MathHelper 'public:square'
into=r MathHelper.square 7 # r = "49"
_Delegate
For plain cross-object calls like the Dashboard example above, the
baked wrapper takes care of everything — _Self and _Class are set
explicitly as inline env vars on every dispatch, so the calling
context’s _Class is irrelevant.
_Delegate is for a different situation: forwarding into= through
the call so a return value flows all the way back to the outer caller’s
target variable.
Wrapper.process() {
local _Class="${_Class:-Wrapper}" _Self="${_Self:-}"
local __Wrapper_process_inner
into=__Wrapper_process_inner $_Self.inner
_Delegate $__Wrapper_process_inner.doWork "$@"
# Without _Delegate, doWork's return value goes nowhere — into= is
# an inline prefix and does not survive the function boundary on its own.
}
7. Inheritance and _Super
Single inheritance
boopClass Animal 'has:name public:speak'
boopClass Dog isa:Animal 'public:speak,fetch'
isa:Animal makes Dog inherit from Animal. The MRO is: Dog →
Animal → boop (root).
Overriding a method
Animal.speak() {
local _Class="${_Class:-Animal}" _Self="${_Self:-}"
boop.pass "..." "${into:-}"
}
Dog.speak() {
local _Class="${_Class:-Dog}" _Self="${_Self:-}"
boop.pass "Woof!" "${into:-}"
}
Calling the parent with _Super
_Super method args… calls the nearest ancestor implementation of
method. _Self stays bound to the current object.
GoldenRetriever.speak() {
local _Class="${_Class:-GoldenRetriever}" _Self="${_Self:-}"
local __GR_speak_parent
into=__GR_speak_parent _Super speak # calls Dog.speak → "Woof!"
boop.pass "(very happy) *${__GR_speak_parent}!*" "${into:-}"
isa checking
into=d Dog name="Rex"
$d.isa Dog # returns 0 (true)
$d.isa Animal # returns 0 (true — ancestor)
$d.isa boop # returns 0 (true — root)
$d.isa Cat # returns 1 (false)
_Cast
_Cast ClassName $obj methodName args… calls a specific class’s
implementation on the given object, bypassing the MRO.
You might wonder: why not just call Animal.speak directly? You can,
if you know Animal.speak never calls _Super. But if it does,
calling it directly is a bug: _Class in the calling method is still
"Dog", so when Animal.speak captures local _Class="${_Class:-Animal}",
it gets "Dog" — and any _Super call inside it walks from Dog’s
parent (Animal) rather than Animal’s parent, causing wrong dispatch or
infinite recursion.
_Cast sets _Class to the target class as an inline env var on the
call, so _Super inside the called method chains correctly upward from
there.
# Inside a Dog method — call Animal.speak on the current object,
# with _Class correctly set to Animal for any _Super calls within it:
_Cast Animal $_Self speak
8. Mixins
A mixin is a bundle of methods that can be grafted onto any class
without changing its inheritance chain. Declare with boopMixin:
# Define the mixin
boopMixin Serializable 'public:serialize,deserialize'
Serializable.serialize() {
local _Class="${_Class:-}" _Self="${_Self:-}"
# ... implementation ...
}
# Apply to a class
boopClass Config isa:Map has:name '
public:get,set
with:Serializable
'
The with:Serializable applies the mixin. Every Config instance now
has .serialize and .deserialize without Config needing to inherit
from Serializable.
Mixin method resolution
Mixin methods are tried after the class’s own methods but before the
parent class. If both the mixin and the parent define the same method,
the mixin wins for classes that declare with:MixinName.
9. The standard library
boop ships a library of tested classes. Load any of them with:
. boop ClassName # from boopRoot
Args — argument parsing
Parse a rich DSL that describes your CLI:
#!/usr/bin/env bash
. boop Args
Args.parse '
[Use]
myscript [options] FILE...
[Options]
verbose | v # enable verbose output
output | o = # output file
count | n = 1 # number of iterations (default 1)
' "$@"
(( _verbose )) && printf 'verbose mode on\n'
printf 'output: %s\n' "${_output:-stdout}"
printf 'count: %s\n' "$_count"
Boolean flags become _flagname (0 or 1). Options with = become
_optname (string). Remaining positional args go into _ArgsRemaining.
See Args for the full Args reference.
Config — key/value store
. boop Config
into=cfg Config
$cfg.set database.host "localhost"
$cfg.set database.port "5432"
into=host $cfg.get database.host
printf 'host: %s\n' "$host"
Config supports nested keys (dot-separated), defaults, and iteration.
See Config for the full Config reference.
List — ordered collection
. boop List
into=lst List
$lst.push "alpha"
$lst.push "beta"
$lst.push "gamma"
into=n $lst.length # n = 3
into=v $lst.get 1 # v = "beta" (0-indexed)
$lst.each my_callback_function # calls my_callback_function item index
# Pipeline (functional-style chaining):
$lst.do | filter 'alpha' | count
See List for the full List reference.
Map — key/value object store
. boop Collection::Map
into=m Map
$m.set name "Alice"
$m.set score "42"
into=n $m.get name # n = "Alice"
$m.has score # returns 0 (true)
$m.delete score
$m.has score # returns 1 (false)
$m.each my_callback # calls my_callback key value
See Map for the full Map reference.
Map.Fast — high-performance flat map
For hot paths where you need key/value storage without object overhead:
. boop Collection::Map::Fast
declare -A mymap
Map.Fast.set mymap key "value"
Map.Fast.get mymap key # returns via boop.pass / into=
See Map.Fast for the full Map.Fast reference.
Stack and Queue
. boop Collection::Stack Collection::Queue
into=stk Stack
$stk.push "first"
$stk.push "second"
into=top $stk.pop # top = "second" (LIFO)
into=q Queue
$q.enqueue "first"
$q.enqueue "second"
into=front $q.dequeue # front = "first" (FIFO)
See Stack and Queue for the full references.
Set
. boop Collection::Set
into=s Set
$s.add "apple"
$s.add "banana"
$s.add "apple" # duplicate — silently ignored
into=n $s.size # n = 2
$s.has "apple" # returns 0 (true)
$s.has "cherry" # returns 1 (false)
See Set for the full Set reference.
SemVer — version handling
. boop SemVer
into=v SemVer "2.4.1"
into=major $v.major # 2
into=minor $v.minor # 4
into=patch $v.patch # 1
into=v2 SemVer "3.0.0"
$v.lessThan $v2 # returns 0 (true — 2.4.1 < 3.0.0)
# Version constraint guard (fails load if constraint not satisfied)
require: ">=1.0.0" # in a class file, guards against old boop
See SemVer for the full SemVer reference.
Math — arbitrary-precision arithmetic
. boop Math
into=r Math.add 1.23 4.56 # r = "5.79"
into=r Math.mul 3.14 2 # r = "6.28"
into=r Math.div 22 7 scale=10 # r = "3.1428571428"
into=r Math.pow 2 64 # r = "18446744073709551616"
# Infix expression with precedence and parens
into=r Math.DO '(3 + 4) * 2' # r = "14"
Math is pure bash — no bc, no awk, no subshells. It handles
integers and decimals to arbitrary precision.
See Math for the full Math reference.
Text.String — string manipulation
. boop Text::String
into=s Text.String " Hello, World! "
$s.trim # edits in place → "Hello, World!"
into=u $s.upper # u = "HELLO, WORLD!" (original unchanged)
$s.replace "World" "boop"
into=result $s.value # result = "Hello, boop!"
# Pipeline
into=s Text.String "foo bar baz"
$s.do | upper | replace 'BAR' 'BAZ' | trimmed
See Text.String for the full Text.String reference.
Data.JSON — JSON parsing
. boop Data::JSON
input='{"name":"Alice","scores":[10,20,30]}'
into=doc Data.JSON.parse "$input"
into=name $doc.get name # name = "Alice"
into=score $doc.get 'scores[1]' # score = "20"
See Data.JSON for the full Data.JSON reference.
DateTime — date/time handling
. boop DateTime
into=now DateTime # current time
into=ts $now.timestamp # Unix epoch
into=fmt $now.format "%Y-%m-%d %H:%M:%S"
into=d DateTime "2026-01-01"
into=tomorrow $d.addDays 1
into=result $tomorrow.format "%Y-%m-%d" # result = "2026-01-02"
See DateTime for the full DateTime reference.
Stream — line-oriented file/stdin processing
. boop Stream
into=s Stream.new -P "data.csv" -f ',' # file, comma-delimited fields
while $s.next; do
into=n $s.fieldCount
for (( i=0; i<n; i++ )); do
into=field $s.field $i
printf 'col %d: %s\n' "$i" "$field"
done
done
Stream handles large files without reading them all into memory. It supports custom record delimiters, field delimiters, paragraph mode, array mode, and more.
See Stream for the full Stream reference.
10. Error handling
boop has two error levels:
| Function | Behaviour |
|---|---|
_Error "message" |
Prints the message to stderr, returns 1 |
_Crash "message" |
Prints the message to stderr, kills the process |
Use _Error + return 1 for everything recoverable. Only use
_Crash for security violations, framework corruption, or situations
where the process genuinely cannot continue.
Propagating errors
MyClass.doThing() {
local _Class="${_Class:-MyClass}" _Self="${_Self:-}"
# Call a method that might fail
$other.validate || { _Error "MyClass.doThing: validation failed"; return 1; }
# ... rest of method ...
}
Strict vs lenient: _FatalLevel
By default, _Error prints and returns 1. You can escalate to process
exit by setting _FatalLevel:
_FatalLevel=error # _Error now kills the process
_FatalLevel=warn # _Warn also kills
_FatalLevel=crash # only _Crash kills (default)
This lets callers decide how fatal errors should be for their context.
Log levels
Six levels, from loudest to quietest threshold:
_Warn "this is suspicious" # level 2 — default threshold
_Info "this is informational" # level 3
_Debug "verbose operational detail" # level 4
_Trace "framework-level tracing" # level 5 — used by boop internals
Control verbosity with __boop.setLogLevel:
__boop.setLogLevel warn # show warn and above (default)
__boop.setLogLevel info # show info and above
__boop.setLogLevel debug # show debug and above
__boop.setLogLevel trace # show everything, including framework internals
__boop.setLogLevel silent # suppress all output
_Trace is what boop itself uses for dispatch, loader, and destroy
internals. It is rarely useful in application code, but invaluable
when debugging the framework.
11. Signals
The Signal class layers a LIFO callback stack on top of bash’s
single-slot trap. Multiple components can register handlers without
stomping each other.
. boop Signal
# Register a cleanup handler for EXIT
cleanup_handler() {
local sig="$1"
printf 'Cleaning up on %s...\n' "$sig"
rm -f /tmp/myapp_*
}
Signal.on EXIT cleanup_handler
# Register a second EXIT handler (will fire BEFORE cleanup_handler)
log_exit() {
printf 'Exiting.\n'
}
Signal.on EXIT log_exit
# Signal.dispatch fires all handlers in LIFO order:
# log_exit fires first, then cleanup_handler
Available methods
Signal.on SIGNAME callback # push callback (fires first)
Signal.off SIGNAME callback # remove first occurrence
Signal.pop SIGNAME # remove and return last-pushed
Signal.clear SIGNAME # remove all, uninstall trap
into=list Signal.list SIGNAME # newline-joined list of callbacks
Signal.dispatch SIGNAME [args] # fire all callbacks manually
Restrictions
Signal refuses to manage KILL, STOP, DEBUG, and RETURN:
KILL/STOP: unblockable by the OS — handlers never fire.DEBUG/RETURN: bash pseudo-signals that fire per-command/per-return. Incompatible with the callback-stack model.
See Signal for the full Signal reference.
12. Testing with TestSuite
boop’s TestSuite class provides a structured test runner with
sections, assertions, and summary reporting.
Basic test structure
#!/usr/bin/env bash
. boop TestSuite
into=t TestSuite name="My Tests"
# ── First section ──────────────────────────────────────────────────
$t.section "Basic math"
into=r Math.add 2 2
$t.assert_eq "2+2=4" "$r" "4"
into=r Math.mul 3 4
$t.assert_eq "3*4=12" "$r" "12"
# ── Failure testing ────────────────────────────────────────────────
$t.section "Error handling"
$t.assert_fail "negative sqrt fails" Math.sqrt -1
$t.assert_ok "positive sqrt works" Math.sqrt 4
# ── Summary ────────────────────────────────────────────────────────
$t.results
Assertion methods
| Method | What it checks |
|---|---|
$t.assert_eq "label" got want |
string equality |
$t.assert_ne "label" got want |
string inequality |
$t.assert_ok "label" cmd… |
command exits 0 |
$t.assert_fail "label" cmd… |
command exits non-zero |
$t.assert_match "label" str pattern |
glob match |
$t.assert_contains "label" haystack needle |
substring |
Sections
$t.section "label" groups assertions visually and in the report.
Exit code
$t.results exits 0 if all assertions passed, 1 otherwise. Pipe your
test runner output through tests/test_all or call suites directly:
bash tests/unit/test_myclass_ts
Verbose mode
Set TESTSUITE_VERBOSE=1 in the environment to print every PASS line.
Default is to print only failures.
TESTSUITE_VERBOSE=1 bash tests/unit/test_signal_ts
See TestSuite for the full TestSuite reference.
13. CLI tools
The tools live in bin/. Run them from the repo root:
./bin/lens --help
./bin/boson --help
./bin/probe --help
./bin/collider --help
lens — text stream inspection
lens is a composable replacement for head, tail, grep, cut,
and wc. One axis per invocation; axes compose naturally in a pipeline.
# First 10 lines
cat file | ./bin/lens --first 10
# Last 5 lines that match a pattern
cat file | ./bin/lens --last 5 --match 'ERROR'
# Fields 1 and 3 from a colon-delimited file
cat /etc/passwd | ./bin/lens --fields 1,3 -f :
# Count matching lines
cat file | ./bin/lens --match 'WARN' --count
# Lines from the 20th to the 30th
cat file | ./bin/lens --from 20 --to 30
# With line numbers
cat file | ./bin/lens --first 5 --number
boson — JSON query
boson queries JSON with a jq-style path syntax, backed by
Data.JSON. No external dependencies.
echo '{"name":"Alice","age":30}' | ./bin/boson '.name'
# Alice
echo '{"users":[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]}' \
| ./bin/boson '.users[].name'
# Alice
# Bob
# Raw output (no quoting)
echo '{"x":42}' | ./bin/boson -r '.x'
# 42
probe — HTTP client
probe makes plaintext HTTP requests (no TLS — use curl for HTTPS).
Useful for testing local services and APIs.
./bin/probe http://localhost:8080/api/status
./bin/probe -X POST -d '{"key":"val"}' http://localhost:3000/data
./bin/probe -i http://localhost:8080/ # include response headers
./bin/probe -s http://localhost:8080/ # status line only
collider — single-file bundler
collider bundles a tool and all its boop dependencies into one
portable executable. The output runs on any machine with bash 4.3+
and no boop installation.
./bin/collider bin/lens -o lens.bundle
chmod +x lens.bundle
./lens.bundle --help # no boop installation needed
14. Complete example: a task list app
This example brings together classes, inheritance, properties, the return system, Config, List, and a basic test suite.
tasks.sh
#!/usr/bin/env bash
set -euo pipefail
. boop List Config Args
# ── Task class ────────────────────────────────────────────────────────
Task.describe() {
local _Class="${_Class:-Task}" _Self="${_Self:-}"
local __Task_describe_id __Task_describe_title __Task_describe_done
into=__Task_describe_id $_Self.id
into=__Task_describe_title $_Self.title
into=__Task_describe_done $_Self.done
local __Task_describe_mark="[ ]"
[[ "$__Task_describe_done" == "1" ]] && __Task_describe_mark="[x]"
boop.pass "${__Task_describe_mark} #${__Task_describe_id}: ${__Task_describe_title}" "${into:-}"
}
Task.complete() {
local _Class="${_Class:-Task}" _Self="${_Self:-}"
$_Self.done "1"
}
boopClass Task 'has:id,title,done public:describe,complete'
# ── TaskList class ────────────────────────────────────────────────────
TaskList.new() {
local _Class="${_Class:-TaskList}" _Self="${_Self:-}"
__boop.new "$@"
into=__TaskList_new_lst List
$_Self.tasks "$__TaskList_new_lst"
$_Self.nextId "1"
}
TaskList.add() {
local _Class="${_Class:-TaskList}" _Self="${_Self:-}"
local __TaskList_add_title="${1:-}" __TaskList_add_id __TaskList_add_lst
[[ -n "$__TaskList_add_title" ]] || { _Error "TaskList.add: title required"; return 1; }
into=__TaskList_add_id $_Self.nextId
into=__TaskList_add_lst $_Self.tasks
into=task Task id="$__TaskList_add_id" title="$__TaskList_add_title" done="0"
$__TaskList_add_lst.push "$task"
$_Self.nextId "$(( __TaskList_add_id + 1 ))"
boop.pass "$task" "${into:-}"
}
TaskList.show() {
local _Class="${_Class:-TaskList}" _Self="${_Self:-}"
local __TaskList_show_lst __TaskList_show_n __TaskList_show_i
local __TaskList_show_task __TaskList_show_desc
into=__TaskList_show_lst $_Self.tasks
into=__TaskList_show_n $__TaskList_show_lst.length
for (( __TaskList_show_i=0; __TaskList_show_i<__TaskList_show_n; __TaskList_show_i++ )); do
into=__TaskList_show_task $__TaskList_show_lst.get "$__TaskList_show_i"
into=__TaskList_show_desc $__TaskList_show_task.describe
printf '%s\n' "$__TaskList_show_desc"
done
}
TaskList.pending() {
local _Class="${_Class:-TaskList}" _Self="${_Self:-}"
local __TaskList_pending_lst __TaskList_pending_n
local __TaskList_pending_i __TaskList_pending_task __TaskList_pending_done
local -i __TaskList_pending_count=0
into=__TaskList_pending_lst $_Self.tasks
into=__TaskList_pending_n $__TaskList_pending_lst.length
for (( __TaskList_pending_i=0;
__TaskList_pending_i<__TaskList_pending_n;
__TaskList_pending_i++ )); do
into=__TaskList_pending_task $__TaskList_pending_lst.get "$__TaskList_pending_i"
into=__TaskList_pending_done $__TaskList_pending_task.done
[[ "$__TaskList_pending_done" == "0" ]] && (( __TaskList_pending_count++ )) || true
done
boop.pass "$__TaskList_pending_count" "${into:-}"
}
boopClass TaskList 'public:add,show,pending'
# ── Main ──────────────────────────────────────────────────────────────
into=tl TaskList
into=t1 $tl.add "Write the tutorial"
into=t2 $tl.add "Add tests"
into=t3 $tl.add "Ship it"
$t1.complete
printf '=== Tasks ===\n'
$tl.show
into=remaining $tl.pending
printf '\n%s task(s) remaining.\n' "$remaining"
Run it:
bash tasks.sh
Expected output:
=== Tasks ===
[x] #1: Write the tutorial
[ ] #2: Add tests
[ ] #3: Ship it
2 task(s) remaining.
Adding a test suite
#!/usr/bin/env bash
set -euo pipefail
. boop TestSuite
# source the app (class definitions only — no main block if you guard with
# [[ "${BASH_SOURCE[0]}" == "$0" ]] around your main logic)
. tasks.sh
into=t TestSuite name="TaskList Tests"
# ── Creation ────────────────────────────────────────────────────────
$t.section "TaskList creation"
into=tl TaskList
into=n $tl.pending
$t.assert_eq "new list has 0 pending" "$n" "0"
# ── Adding tasks ─────────────────────────────────────────────────────
$t.section "Adding tasks"
into=task1 $tl.add "First task"
$t.assert_ok "add returns object ID" test -n "$task1"
into=desc $task1.describe
$t.assert_contains "describe includes title" "$desc" "First task"
$t.assert_contains "describe shows pending" "$desc" "[ ]"
into=n $tl.pending
$t.assert_eq "one pending after add" "$n" "1"
# ── Completing tasks ──────────────────────────────────────────────────
$t.section "Completing tasks"
$task1.complete
into=desc $task1.describe
$t.assert_contains "complete marks done" "$desc" "[x]"
into=n $tl.pending
$t.assert_eq "zero pending after complete" "$n" "0"
# ── Error handling ────────────────────────────────────────────────────
$t.section "Error handling"
$t.assert_fail "add without title fails" $tl.add ""
$t.results
Next steps
- Read docs/boop.md — full dispatch, return system, naming rules, every public function.
- Read docs/STANDARDS.md — coding conventions, shell option requirements, error handling contract.
- Browse docs/GOTCHAS.md — surprising bash behaviours that affect boop code.
- Explore the library classes in the source tree — every class is a working example of boop conventions.
- Run the included test suites:
bash tests/test_all - Try
./bin/boopShellfor an interactive boop playground.