Standard Library¶
Onion's standard library consists of built-in modules and interfaces for common functionality.
Modules at a glance¶
| Area | Modules |
|---|---|
| I/O & system | IO (console), Files (files + paths), FileResource (the file"…" literal), Resources (backs the file"…"/http"…"/re"…" literals as bare functions), System, Proc (subprocesses), Args (CLI), Cli (auto-CLI runtime) |
| Network | Http (HTTP client), HttpResource (the http"…" literal), Net (TCP sockets), Server (HTTP server) |
| Data stores | Db (SQL over JDBC) |
| Archives | Archive (zip, gzip) |
| Concurrency | Future, Concurrent (pools, counters, locks, channels) |
| Collections | Colls (lists: map/filter/fold, chunked/windowed, sumBy/maxBy), Iterables, Maps, Sets, Range (the a..b/a..<b literal type) |
| Text | Strings (case, split, pad, parse), Text (wrap/indent/table), Regex |
| Numbers | Math, OnionMath (hyperbolic trig, clamp, hypot, bounded randomInt), Stats (sum/average/median/stddev), Format (grouping, bytes, durations) |
| Data formats | Json, Yaml, Csv, Config (dot-notation config access) |
| Encoding | Codec (base64/hex/url), Hash (md5/sha256/…) |
| Functional | Option, Result, Future, Outcome + Defect (reading external data) |
| Positions | Origin (where a value came from, in the text it was read out of) |
| Boundaries | Shape + Shapes (text <-> typed value), Scalars, Lossless + Residue (round-trip-preserving lens over a lossless shape) |
| Date & random | DateTime, Rand (choice/shuffle/sample/uuid) |
| Testing & timing | Assert, Timing |
Most helpers are also usable as method chains, not only as static Module:: calls —
collections (list.filter { ... }.map { ... }, m.mapValues { ... }), strings
("s".capitalize()), hashing/encoding ("pw".sha256(), "x".base64Encode()), text
layout (text.wrap(40)), numeric aggregation (nums.sum(), nums.average()), and number
formatting ((1536L).bytes(), (21L).ordinal()).
IO Module¶
Console input and output operations.
Standard input is decoded as UTF-8 when it is a pipe or a file, on every JDK; a Windows console is decoded in its own code page (see Console encoding).
IO::println¶
Print a line to standard output:
IO::print¶
Print without newline:
IO::readln¶
Read a line of input from the user:
IO::input(prompt) is the same operation callable directly by that name --
readln(prompt) is implemented in terms of it:
IO::readLine¶
Read a line from standard input, or null at end of input. IO::readln() (no
prompt) is an alias for this:
IO::readAll¶
Read all remaining standard input as a single string:
Formatted Output¶
Error Output (stderr)¶
IO::eprint("warning: ")
IO::eprintln("disk almost full")
IO::eprintf("failed after %d retries\n", 3)
Type-Safe Input¶
Read and parse a line as a specific type, throwing on invalid input; each has an overload that prints a prompt first:
val age: Int = IO::readInt("Age: ")
val price: Long = IO::readLong("Price: ")
val ratio: Double = IO::readDouble("Ratio: ")
val ok: Boolean = IO::readBoolean("Continue? ") // accepts true/yes/1, false/no/0
Safe Input¶
Like the type-safe readers above, but return null instead of throwing on
invalid input or end of stream:
val n: Int? = IO::tryReadInt("N: ")
val d: Double? = IO::tryReadDouble("D: ")
val l: Long? = IO::tryReadLong("L: ")
Line-Oriented I/O¶
val lines: List[String] = IO::readLines() // reads until end of input
IO::eachLine { line -> IO::println(line) } // applies a callback to each remaining line
IO::printLines(["a", "b", "c"]) // one item per line
IO::printAll("a", "b", "c") // varargs form of printLines
Utility¶
IO::flush() // flushes standard output
IO::newline() // prints a blank line
IO::clear() // clears the terminal screen (ANSI escape codes)
System Module¶
Access to system-level operations via Java's System class.
System::out¶
Standard output stream:
System::in¶
Standard input stream:
import {
java.io.BufferedReader;
java.io.InputStreamReader;
}
val reader: BufferedReader = new BufferedReader(
new InputStreamReader(System::in)
)
System::currentTimeMillis¶
Get current time in milliseconds:
System::getProperty¶
Get system properties:
val os: String = System::getProperty("os.name")
val user: String = System::getProperty("user.name")
val home: String = System::getProperty("user.home")
System::exit¶
Exit the program:
Math Module¶
Mathematical operations via Java's Math class.
Math::random¶
Generate random number between 0.0 and 1.0:
Math::sqrt¶
Square root:
Math::pow¶
Exponentiation:
Math::abs¶
Absolute value:
Math::max / Math::min¶
Maximum and minimum:
Math::floor / Math::ceil / Math::round¶
Rounding functions:
val floor: Double = Math::floor(3.7) // 3.0
val ceil: Double = Math::ceil(3.2) // 4.0
val round: Long = Math::round(3.5) // 4
Math::sin / Math::cos / Math::tan¶
Trigonometric functions (radians):
val sine: Double = Math::sin(Math::PI / 2) // 1.0
val cosine: Double = Math::cos(0.0) // 1.0
val tangent: Double = Math::tan(Math::PI / 4) // 1.0
Math Constants¶
OnionMath Module¶
An onion.* numeric module, distinct from the JDK's Math, covering hyperbolic
trig, safe rounding/clamping, and a bounded random integer. It is default-imported
like the rest of the standard library, so no explicit import is needed.
OnionMath::sin / OnionMath::cos / OnionMath::tan / OnionMath::asin / OnionMath::acos / OnionMath::atan / OnionMath::atan2¶
Trigonometric and inverse trigonometric functions (radians):
val sine: Double = OnionMath::sin(OnionMath::PI / 2) // 1.0
val angle: Double = OnionMath::atan2(1.0, 1.0) // pi/4
OnionMath::sinh / OnionMath::cosh / OnionMath::tanh¶
Hyperbolic trigonometric functions:
OnionMath::exp / OnionMath::log / OnionMath::log10¶
Exponential and logarithms:
val e2: Double = OnionMath::exp(1.0) // e
val l: Double = OnionMath::log(OnionMath::E) // 1.0
val l10: Double = OnionMath::log10(100.0) // 2.0
OnionMath::pow / OnionMath::sqrt / OnionMath::cbrt¶
Powers and roots:
val cube: Double = OnionMath::pow(2.0, 3.0) // 8.0
val root: Double = OnionMath::sqrt(16.0) // 4.0
val croot: Double = OnionMath::cbrt(27.0) // 3.0
OnionMath::abs / OnionMath::absFloat / OnionMath::absInt / OnionMath::absLong¶
Absolute value, by primitive type:
val a1: Double = OnionMath::abs(-3.14)
val a2: Int = OnionMath::absInt(-10) // 10
val a3: Long = OnionMath::absLong(-10L) // 10
OnionMath::min / OnionMath::minInt / OnionMath::minLong / OnionMath::max / OnionMath::maxInt / OnionMath::maxLong¶
Minimum and maximum, by primitive type:
OnionMath::floor / OnionMath::ceil / OnionMath::round / OnionMath::roundFloat¶
Rounding functions:
val f: Double = OnionMath::floor(3.7) // 3.0
val c: Double = OnionMath::ceil(3.2) // 4.0
val r: Long = OnionMath::round(3.5) // 4
val rf: Int = OnionMath::roundFloat(3.5f) // 4
OnionMath::random / OnionMath::randomInt¶
Random number generation. Unlike Math::random, randomInt takes bounds directly
and is tracked by the effect checker as a Rand effect:
val r: Double = OnionMath::random() // [0.0, 1.0)
val n: Int = OnionMath::randomInt(1, 10) // [1, 10], inclusive
OnionMath::signum / OnionMath::signumFloat¶
Sign of a number (-1.0, 0.0, or 1.0):
OnionMath::toRadians / OnionMath::toDegrees¶
Angle unit conversion:
val rad: Double = OnionMath::toRadians(180.0) // pi
val deg: Double = OnionMath::toDegrees(OnionMath::PI) // 180.0
OnionMath::clamp / OnionMath::clampInt¶
Constrain a value to a range:
val c1: Double = OnionMath::clamp(15.0, 0.0, 10.0) // 10.0
val c2: Int = OnionMath::clampInt(-5, 0, 10) // 0
OnionMath::hypot¶
Hypotenuse without intermediate overflow/underflow:
OnionMath Constants¶
Origin¶
Where a value came from, in the text it was read out of — the runtime counterpart to the
compiler's own source locations. A parser that knows it failed on line 12 can say so,
instead of returning a bare null.
source is free-form: a file path, a URL, "<stdin>", "<literal>". Line and column are
1-based. A column of 0 means the position is known only to the line, which is what a
line-oriented parser can honestly report.
import { onion.Origin; }
val o = Origin::at("access.log", 12, 5)
println(o.describe()) // access.log:12:5
val lineOnly = Origin::atLine("data.json", 4)
println(lineOnly.describe()) // data.json:4
println(lineOnly.hasColumn()) // false
Origin::at / Origin::atLine / Origin::spanning¶
at(source, line, column) spans a single character; atLine(source, line) records a line
with no column; spanning(source, line, column, span) covers span characters.
origin.onLine / origin.inSource¶
Parsing a document line by line means each sub-parse reports positions relative to its own
line. onLine lifts one back into the whole document; inSource retargets it.
origin.describe¶
file:line:column, or file:line when only the line is known — the form every compiler
and editor already knows how to parse. toString returns the same.
Outcome and Defect¶
The result of reading external data: either a value, or every reason it could not be
read. Defect is one thing that was wrong; Outcome[T] is a value or a list of them.
isOk()/isBad() say which case it is.
A Defect answers three questions a caller actually has — where in the text (origin,
which may be absent), where in the value (path), and what was expected against what was
found. hasOrigin() says whether a position is known at all, before origin() is read.
import { onion.Outcome; onion.Defect; onion.Origin; }
val d = Defect::at(Origin::atLine("config.json", 4), "port", "Int", "\"http\"")
println(d.describe()) // config.json:4: port: expected Int, found "http"
println(d.hasOrigin()) // true -- it knows where in config.json
val missing = Defect::of("name", "String", "absent")
println(missing.describe()) // name: expected String, found absent
println(missing.hasOrigin()) // false -- a missing key has no position to point at
Why not Result?¶
Because of zip. Result is monadic: bind short-circuits, so the first bad field hides
the rest. A record with three malformed fields should report three defects in one pass.
Ok(f) zip Ok(x) = Ok(f(x))
Bad(d1) zip Ok(_) = Bad(d1)
Ok(_) zip Bad(d2) = Bad(d2)
Bad(d1) zip Bad(d2) = Bad(d1 ++ d2) <- the reason this type exists
val a: Outcome[JInteger] = Outcome::bad(Defect::of("x", "Int", "p"))
val b: Outcome[JInteger] = Outcome::bad(Defect::of("y", "Int", "q"))
println(a.zip(b) { p, q -> p + q }.defects().size) // 2, not 1
bind still short-circuits, because it must — the second computation may depend on the
first's value. Both are available, and do[Outcome] uses bind.
Reading many values¶
all is all-or-nothing and accumulates every defect. When a partial result is still worth
having — a log file where the good lines matter — values and defects keep both.
val os: List[Outcome[JInteger]] =
[Outcome::ok(1), Outcome::bad(Defect::of("a", "Int", "x")), Outcome::ok(3)]
println(Outcome::values(os).size) // 2
println(Outcome::defects(os).size) // 1
println(Outcome::all(os).isOk()) // false
Positioning a nested or per-line read¶
under prefixes every defect's path; onLine lifts positions reported relative to one
line back into the whole document.
o.under("address") // "city" becomes "address.city"
o.onLine(40) // a defect at line 1 of a fragment becomes line 40 of the file
Shape¶
A partial, potentially bidirectional correspondence between external text and a typed
value. Shape[T] reads text into a T and — when the correspondence is invertible —
renders one back.
import { onion.Shape; onion.Shapes; onion.Outcome; }
val r = pointShape.parse("3,4")
if r.isOk() { println(r.get()) }
println(pointShape.print(pt))
Two laws, deliberately kept apart¶
L1 round-trip parse(print(v)) == Ok(v) guaranteed wherever print exists
L2 normalization print(parse(t)) == t false in general
L2 fails for ordinary reasons — "007" is a perfectly good Int that prints back as
"7". A shape satisfying L2 as well is lossless, which is rare and is what a lens
needs. Most shapes are L1-only, and saying which is the difference between a reversible
language and one that claims to be.
canPrint¶
Not every shape can render. A regex with a \s+ separator has no unique rendering, so
the shape is read-only and canPrint() says so before print is called — rather than
the method silently not existing.
printable¶
printable(v) says whether a value is one the shape can print and read back. The round-trip L1
holds on this domain, not on every value of T: a fixed-width shape cannot print a name longer
than its column. The default admits every value; override it to state a narrower domain. The
build samples values and checks parse(print(v)) == Ok(v) for each one printable admits; a
value that does not come back is E0114.
describe¶
describe() names what a shape reads, for use in diagnostics — re"\d+-\d+" for a
regex shape, for instance. It is what an UnsupportedOperationException from print
or printLossless quotes to say which shape refused, since "this shape is not
invertible" is useless on its own.
Component failures accumulate¶
Reading two Int components out of "abc,def" reports two defects, not the first
one. That is what Outcome's accumulating zip is for.
Lossless shapes and lenses¶
A shape that also satisfies L2 is lossless — isLossless() says so, and
parseLossless(text[, origin]) reads a Lossless[T] instead of a plain T: the value
plus the Residue of everything around it (comments, spacing, key order, original
value spellings). printLossless(value, residue) renders back through that residue —
unchanged parts reproduce byte for byte, and only deliberately changed values re-render.
Residue is opaque; hand it back only to the shape that produced it.
Lossless[T] is the lens itself: value()/residue() read the pair, withValue(v)
swaps the value while keeping the residue, and edit { v -> ... } focuses an update.
render() reassembles the text:
val r = configShape.parseLossless(file"app.conf".text()).get()
val out = r.edit { v -> v.copy(port = 9090) }.render()
// diff app.conf out -> one changed line
Shapes::regex and Shapes::json build the shapes behind shape name = re"..." /
shape name = json, and Shapes::config and Shapes::yaml build the lossless shapes
behind shape name = config / shape name = yaml -- all four for when you want the
Shape[T] value directly instead of the sugar.
The config format is line-based: each line is key = value (the first = splits,
spacing around it is free), a line whose trimmed text starts with # is a whole-line
comment, and a blank line is ignored. There is no inline-comment syntax -- a # after
a value on the same line is read as part of that value, not stripped (host =
example.com # primary parses host as "example.com # primary"). Put a comment on
its own line instead. A String component's value is trimmed like any other scalar,
unless it is in double quotes: name = " padded " reads as padded. print
quotes a String value when it is padded or starts with ", and writes every other
value unquoted. Inside the quotes only \" and \\ are escapes, so \n stays two
characters. A quoted value with no closing ", or a bare " inside, is a Defect.
Structured JSON and JSON Schema¶
A json shape's components may also be List[S] of a scalar, a nested record that
declares a json shape, List[R] of such records, and T? of any of these (the key may be
absent or null); a defect inside a structure carries its path (actions[2].owner).
Shapes::json(names, tags, nested, build, explode) is the overload behind that: the tags
take List[K], Nested and a trailing ?, and nested holds a Function0 returning
the nested shape for each Nested component (null for the others).
hasJsonSchema() answers whether a shape can describe what it reads as a JSON Schema —
true only for a json shape. jsonSchema() returns that schema as JSON text: an object
with one property per component, required listing every non-nullable one, and
additionalProperties: false, recursing into arrays and nested objects — the form an
LLM's structured-output mode takes:
Any other shape's jsonSchema() throws UnsupportedOperationException, as does a
recursive record's, which has no finite inline schema.
describe(component, text) returns a shape whose JSON Schema carries text as
component's "description"; parsing, printing and every other component are unchanged,
and calls chain (s.describe("owner", "...").describe("task", "...")). It throws the same
UnsupportedOperationException as jsonSchema() when hasJsonSchema() is false, and
IllegalArgumentException for a component the shape does not have (RFC #1960, option 3).
Combinators¶
eachLine(text[, origin])— oneOutcome[T]per line, keeping both the lines that read and the defects of the ones that didn't (Outcome::values/Outcome::defectssplit them apart). Use this overlines()when a partial result is meaningful, as in a log file where most lines parse.lines()— aShape[List[T]]reading one value per line, all or nothing.sepBy(separator)— aShape[List[T]]split on a literal separator, all or nothing.xmap(forward, backward)— transports a shape along an isomorphism; both directions are required soprintisn't silently destroyed.orElse(other)— this shape, orotherwhen it doesn't read; reports both shapes' defects when neither does. Prints with this shape.
Scalars Module¶
Strict scalar parsing for boundary derivations, used by record ... from re"..."/shape
generated code (and callable directly) wherever the JDK's own parser is too lenient to
serve as one.
Why not Boolean::parseBoolean?¶
Every java.lang.X.parseX rejects malformed input by throwing — except
Boolean::parseBoolean, which maps everything that isn't "true" to false. That is
the one failure mode a parser must never have: "maybe", "yes" and "1" would all
silently become false with nothing to indicate the data was wrong.
Scalars::toBoolean("TRUE") // true
Scalars::toBoolean("false") // false
Scalars::toBoolean("yes") // throws IllegalArgumentException
Scalars::isBoolean("yes") // false -- check before you call toBoolean
toBoolean throws IllegalArgumentException, the supertype of the
NumberFormatException the numeric parsers throw, so a derivation catches both the
same way.
Scalars::read¶
Reads text as the scalar kind named by tag (one of String, Int, Long,
Double, Float, Boolean, Short, Byte), reporting a positioned Defect rather
than throwing.
import { onion.Scalars; onion.Outcome; }
val port: Outcome[Object] = Scalars::read("Int", "8080", null, "port")
println(port.get()) // 8080
val bad: Outcome[Object] = Scalars::read("Int", "http", null, "port")
println(bad.defects().get(0).describe()) // port: expected Int, found http
origin (an Origin or null) positions the defect in the source text; path names
where in the value being built this field belongs.
Scalars::coerce¶
Coerces an already-parsed document value (from Json/Yaml/...) to the scalar kind
named by tag. Unlike read, the value arrives typed — a JSON number is already a
Number, so this narrows rather than parses; a value of the wrong shape entirely (a
string where an Int was required) is a defect, not a silent null.
Scalars::coerce("Int", 8080, null, "port") // Outcome::ok(8080)
Scalars::coerce("Int", "8080", null, "port") // Outcome::ok(8080) -- numeric string still parses
Scalars::coerce("Int", [1, 2], null, "port") // a defect: expected Int, found an array
Both read and coerce speak the same tag vocabulary as the compiler's own scalar
conversion table, so a shape/from re"..." derivation and hand-written code using
Scalars directly report defects the same way.
Function Interfaces¶
Built-in function types for lambdas and closures. You can call them with f(args) as a shorthand for f.call(args).
Function0¶
Function with no parameters:
Function1¶
Function with one parameter:
Function2¶
Function with two parameters:
val add: Function2[Int, Int, Int] = (x: Int, y: Int) -> { return x + y; }
val result: Int = add(3, 7)
Function3 through Function10¶
Functions with 3 to 10 parameters follow the same pattern.
Wrapper Classes¶
Java wrapper classes for primitives (accessed with J prefix in some contexts).
JInteger¶
Integer operations:
val i: Int = JInteger::parseInt("42")
val s: String = JInteger::toString(42)
val max: Int = JInteger::MAX_VALUE
val min: Int = JInteger::MIN_VALUE
JLong¶
Long operations:
JDouble¶
Double operations:
JBoolean¶
Boolean operations:
Common Java Classes¶
Frequently used Java standard library classes.
String¶
String operations (automatically available):
val text: String = "Hello, World!"
val upper: String = text.toUpperCase()
val lower: String = text.toLowerCase()
val length: Int = text.length()
val sub: String = text.substring(0, 5)
val contains: Boolean = text.contains("World")
val starts: Boolean = text.startsWith("Hello")
val ends: Boolean = text.endsWith("!")
StringBuilder¶
Efficient string building:
import { java.lang.StringBuilder; }
val builder: StringBuilder = new StringBuilder()
builder.append("Hello")
builder.append(" ")
builder.append("World")
val result: String = builder.toString()
ArrayList¶
Dynamic arrays:
import { java.util.ArrayList; }
val list: ArrayList[String] = new ArrayList[String]
list.add("First")
list << "Second" // Using << operator
val size: Int = list.size()
val item: Object = list.get(0)
list.remove(0)
val empty: Boolean = list.isEmpty()
HashMap¶
Key-value maps:
import { java.util.HashMap; }
val map: HashMap[String, String] = new HashMap[String, String]
map.put("key1", "value1")
map.put("key2", "value2")
val value: Object = map.get("key1")
val has: Boolean = map.containsKey("key1")
val size: Int = map.size()
File¶
File operations:
import { java.io.File; }
val file: File = new File("data.txt")
val exists: Boolean = file.exists()
val isFile: Boolean = file.isFile()
val isDir: Boolean = file.isDirectory()
val name: String = file.getName()
val path: String = file.getPath()
val length: Long = file.length()
BufferedReader¶
Reading text:
import {
java.io.BufferedReader;
java.io.FileReader;
}
val reader: BufferedReader = new BufferedReader(
new FileReader("file.txt")
)
var line: String = null
while (line = reader.readLine()) != null {
IO::println(line)
}
reader.close()
BufferedWriter¶
Writing text:
import {
java.io.BufferedWriter;
java.io.FileWriter;
}
val writer: BufferedWriter = new BufferedWriter(
new FileWriter("output.txt")
)
writer.write("Hello, World!")
writer.newLine()
writer.close()
Range¶
The runtime type behind the range literals a..b (inclusive) and a..<b (exclusive),
onion.Range. It is Iterable[Int], so it works directly in foreach and with the
Iterables/extension pipeline methods, but it also exposes its own small set of members:
val r = 2..5 // Range(2..<6), inclusive endpoint folded in
r.start() // 2, the first value
r.endExclusive() // 6, one past the last value (works the same for a..<b)
r.isEmpty() // false
r.size() // 4
r.contains(3) // true
r.contains(9) // false
r.toString() // "Range(2..<6)"; empty ranges print "Range(empty)"
foreach i: Int in r { IO::println(i) } // 2 3 4 5
val evens = (1..10).filter { x -> x % 2 == 0 } // a List[Int]: [2, 4, 6, 8, 10]
evens.size // 5
Iterables Module¶
Provided via onion.Iterables (Java interface).
Access iteration utilities for collections and arrays:
Iterables::map(list|iterable|set, f)- aListfor a list or any otherIterable(a range,map.values()), aSetfor a setIterables::mapMap(map, f)- maps eachMap.Entrythroughf, returning a newMapin the original insertion orderIterables::toList(iterable)- materializes anyIterable(ranges included) into aListIterables::filter(list|iterable, predicate)- always aListIterables::foldl(iterable, init, f)Iterables::reduce(list, initial, reducer)Iterables::exists(iterable, predicate)Iterables::forAll(iterable, predicate)Iterables::listOf(elements...)Iterables::newList(size)- a new emptyListpre-sized forsizeelementsIterables::first(list)/Iterables::last(list)-nullif the list is emptyIterables::reverse(list)Iterables::take(list, n)/Iterables::drop(list, n)Iterables::sort(list, comparator)/Iterables::sort(list)- the second overload requiresComparableelements
Every method above (listOf and newList excepted -- they build a List
rather than operate on one) is also a builtin extension method, callable as
a chain on its first argument:
xs.map { x -> x * 2 } // also Set/Iterable receivers
m.mapMap((e) -> Colls::entry(e.getKey(), e.getValue() * 2))
(1..5).toList() // ranges included
xs.filter { x -> x > 0 }
xs.foldl(0, (acc, x) -> acc + x)
xs.reduce(0, (acc, x) -> acc + x)
xs.exists { x -> x > 2 }
xs.forAll { x -> x > 0 }
xs.first() / xs.last()
xs.reverse()
xs.take(2) / xs.drop(1)
xs.sort() / xs.sort(comparator)
map/filter/take/drop/reverse are exceptions: onion.Colls also
declares map(List, Function1)/filter(List, Function1)/take(List, int)/
drop(List, int)/reverse(List) extensions with the same erased signatures,
and it is registered ahead of onion.Iterables, so xs.map(f), xs.filter(p),
xs.take(n), xs.drop(n) and xs.reverse() always reach onion.Colls's
versions, never onion.Iterables's. The two disagree on edge cases:
xs.map(f)returns an unmodifiable list;Iterables::mapreturns a plain mutable copyxs.filter(p)callspand auto-unboxes itsBooleanresult, throwingNullPointerExceptionifpreturnsnull;Iterables::filtertreats anullresult as "not kept" instead of throwing- a negative
n-- both clamp (takegives an empty list,dropleaves the list unchanged);xs.drop(-1)returns the same reference,Iterables::drop(xs, -1)a copy nat or past the list's size --xs.take(n)/xs.drop(n)return the very same list reference, not a copy;Iterables::take/Iterables::dropalways copyxs.reverse()returns an unmodifiable list;Iterables::reversereturns a plain mutable copy
Use the Iterables::map/Iterables::filter/Iterables::take/
Iterables::drop/Iterables::reverse static-call form when you need
mutable-copy or null-tolerant, size-safe semantics instead:
val xs: List[Int] = [1, 2, 3]
xs.map { x -> x * 2 }.add(8) // throws UnsupportedOperationException (onion.Colls::map)
Iterables::map(xs, (x) -> x * 2).add(8) // ok, mutable copy (onion.Iterables::map)
xs.filter { x -> null } // throws NullPointerException (onion.Colls::filter)
Iterables::filter(xs, (x) -> null) // [] (onion.Iterables::filter, null is not-kept)
xs.take(-1) // [] (onion.Colls::take, clamps instead of throwing)
xs.take(xs.size() + 1) === xs // true (onion.Colls::take, same list reference)
xs.reverse().add(4) // throws UnsupportedOperationException (onion.Colls::reverse)
Iterables::take(xs, -1) // [] (onion.Iterables::take, clamps too)
reduce is shadowed too, but at compile time, not just runtime: onion.Colls
also declares a three-arg reduce(List, Object, Function2) extension with the
same erased signature as onion.Iterables's, so xs.reduce(initial, f) always
reaches onion.Colls's implementation -- onion.Iterables's three-arg
reduce is never reachable by extension-call syntax at all. Unlike
map/filter/take/drop/reverse above, the two don't just disagree on
runtime edge cases: Colls::reduce's declared signature types initial and
the return value as the actual generic U, so the accumulator gets a concrete
inferred type (e.g. Int); Iterables::reduce's declared signature erases
both to plain Object. So xs.reduce(0, (acc, x) -> acc + x) above compiles
and sums to an Int because it silently reaches onion.Colls::reduce, not
onion.Iterables::reduce as its placement in this list suggests -- calling
Iterables::reduce explicitly fails to compile instead:
val xs: List[Int] = [1, 2, 3]
xs.reduce(0, (acc, x) -> acc + x) // 6 (onion.Colls::reduce, acc: Int)
Iterables::reduce(xs, 0, (acc, x) -> acc + x) // [E0001] operator + is not applicable for type Object, Int
fold/reduce/foldl warn on their own most idiomatic use: Colls::fold
and Colls::reduce's 3-arg form (likewise Iterables::foldl) are Java
generic methods (<T, R> R fold(List<T>, R, Function2<R, T, R>)); their
declared return type is the type variable R itself -- a boxed reference
with no Onion-tracked nullability -- regardless of what the call site's own
arguments pin R to. So xs.fold(0) { a, b -> a + b } / xs.reduce(0, (acc,
x) -> acc + x) on a List[Int], with a literal zero and an Int-returning
combiner, still unboxes the result through [W0015] when assigned to a
non-null Int -- the same warning Json::getInt on a possibly-missing key
gets, even though nothing here can actually be null. There is no
Onion-native, warning-free alternative for a non-Double running total
(Stats::sum/sumBy return Double); suppress the warning with --Wno
platform-unboxing if the call is intentional.
first/last are shadowed too, but harmlessly: onion.Colls also
declares first(List)/last(List) extensions with the same erased
signature as onion.Iterables's, so xs.first()/xs.last() always reach
onion.Colls's versions -- onion.Iterables's first/last are never
reachable by extension-call syntax at all. Unlike every other name in this
section, the two containers' implementations are identical
(list.isEmpty() ? null : list.get(0), and the equivalent for the last
index), so the shadowing has no observable effect: xs.first() always
equals Iterables::first(xs), and likewise for last.
sort's two-arg form is shadowed too, harder than any of the above:
java.util.List already declares an instance method sort(Comparator) (a
default method since Java 8), and an instance method always wins over an
extension method of the same name -- so xs.sort(comparator) never reaches
onion.Iterables::sort(List, Comparator) at all. This isn't just a runtime
edge case: native List.sort mutates the receiver in place and returns
void, while Iterables::sort leaves the receiver untouched and returns a
new sorted List. So xs.sort(comparator) compiles fine as a bare,
unused statement (it destructively sorts xs), but fails to compile the
moment its result is used as a value:
val xs: List[Int] = [3, 1, 2]
xs.sort((a, b) -> a - b) // xs is now [1, 2, 3] -- native List.sort, in place
val ys = xs.sort((a, b) -> a - b) // [E0000] type Object is expected, but type void is used
Iterables::sort(xs, (a, b) -> a - b) // ok -- new sorted copy, xs untouched
The no-arg form is unaffected: List declares no no-arg sort(), so
xs.sort() always reaches Iterables::sort(List) and returns a new sorted
copy, exactly as shown above.
Option Module¶
Provided via onion.Option.
Option::some(value)/Option::none()/Option::of(value)opt.isDefined()/opt.isEmpty()/opt.get()—get()throwsNoSuchElementExceptiononNoneopt.getOrElse(defaultValue)/opt.orElseGet(() -> default)/opt.orNull()opt.orElseThrow()/opt.orElseThrow(() -> customException)opt.orElse(otherOption)opt.map(f)/opt.flatMap(f)/opt.filter(predicate)/opt.forEach(action)opt.contains(value)/opt.exists(predicate)opt.fold(() -> ifEmpty, v -> ifPresent)— collapse to a single valueopt.toList()— zero- or one-element list
Result Module¶
Provided via onion.Result.
Result::ok(value)/Result::err(error)Result::ofNullable(value, errorIfNull)/Result::trying(operation)res.isOk()/res.isErr()/res.get()/res.getError()—get()throws onErr,getError()throws onOkres.map(f)/res.mapError(f)/res.flatMap(f)/res.toOption()res.getOrElse(default)/res.orElseGet(() -> default)/res.orNull()res.getOrThrow()/res.getOrThrow(e -> customException)— throws the error (wrapped if not aThrowable) or a mapped exceptionres.forEach(action)/res.forEachError(action)res.fold(e -> ifErr, v -> ifOk)— collapse to a single valueres.recover(e -> value)/res.recoverWith(e -> otherResult)— rescue anErrres.exists(predicate)/res.toList()- Match by case:
case o is Result.Ok/case e is Result.Err, theno.value()/e.error()at the scrutinee's type arguments; aselectwith both cases is exhaustive (see Matching Nested Java Types).Option.Some/Option.NoneandOutcome.Ok/Outcome.Badmatch the same way.
Future Module¶
Provided via onion.Future. Represents asynchronous computations.
Creating Futures¶
// Already completed with a value
val done: Future[Int] = Future::successful(42)
// Already failed
val fail: Future[Int] = Future::failed(new RuntimeException("error"))
// Run async on background thread
val async: Future[String] = Future::async { compute() }
// Async with exception handling
val safe: Future[Int] = Future::asyncThrowing {
riskyOperation()
}
// Delay
val delayed: Future[Void] = Future::delay(1000L) // 1 second
A zero or negative delay completes at once, so Future::delay(deadline - Timing::millis()) is safe once the deadline has passed.
Transformation Methods¶
val f: Future[Int] = Future::successful(10)
// Transform the value
f.map((x: Int) -> { return x * 2; }) // Future[Int] = 20
// Chain async operations
f.flatMap((x: Int) -> { return Future::successful(x + 1); })
// Filter (fails if predicate false)
f.filter((x: Int) -> { return x > 0; })
// Alias for flatMap (used by do notation)
f.bind((x: Int) -> { return Future::successful(x); })
Error Handling¶
val f: Future[Int] = Future::failed(new RuntimeException("oops"))
// Recover with value
f.recover((e: Throwable) -> { return 0; })
// Recover with another Future
f.recoverWith((e: Throwable) -> { return Future::successful(42); })
// Transform error
f.mapError((e: Throwable) -> { return new CustomException(e); })
Callbacks¶
val f: Future[String] = Future::async { "result" }
f.onSuccess((value: String) -> { IO::println(value); })
f.onFailure((error: Throwable) -> { IO::println(error); })
f.onComplete(
(value: String) -> { IO::println("ok: " + value); },
(error: Throwable) -> { IO::println("err: " + error); }
)
Blocking Operations¶
val f: Future[Int] = Future::successful(42)
f.await() // Block and get result (throws on failure)
f.awaitTimeout(5000L) // Block with timeout in ms
f.getOrElse(0) // Get result or default on failure
Status Queries¶
f.isCompleted() // true if done (success or failure)
f.isSuccess() // true if completed successfully
f.isFailure() // true if completed with error
These are non-blocking — they report the future's current state, so a future
that is still running reports both isSuccess() and isFailure() as false. To wait
for the outcome, use await()/getOrElse() (or onSuccess/onFailure/recover)
rather than polling isFailure().
Combining Futures¶
val f1: Future[Int] = Future::successful(1)
val f2: Future[Int] = Future::successful(2)
// Zip into tuple-like array
f1.zip(f2) // Future[List[Object]] = [1, 2]
// Race: first to complete wins
f1.race(f2)
// Wait for all
Future::all(f1, f2, f3) // Future[List[Int]] = [1, 2, 3]
Future::all([f1, f2, f3]) // same, but takes a List[Future[T]] built at runtime
// First to complete
Future::first(f1, f2, f3)
Conversions¶
val f: Future[Int] = Future::successful(42)
f.toOption() // Option[Int] - Some(42) or None (blocks)
f.toResult() // Result[Int, Throwable] (blocks)
f.underlying() // Java CompletableFuture for interop
// The reverse direction: wrap a Java CompletableFuture as a Future
val cf: java.util.concurrent.CompletableFuture[Int] = someJavaApi()
val wrapped: Future[Int] = Future::fromCompletableFuture(cf)
Do Notation Support¶
Future works with do notation for sequential async composition:
val result: Future[Int] = do[Future] {
x <- Future::async { fetchA() }
y <- Future::async { fetchB(x) }
yield x + y
}
Rand Module¶
Random number generation utilities via onion.Rand.
Rand::nextInt / nextLong / nextDouble / nextBoolean¶
Generate random numbers:
val randomInt: Int = Rand::nextInt() // Random Int
val randomLong: Long = Rand::nextLong() // Random Long
val randomDouble: Double = Rand::nextDouble() // 0.0 to 1.0
val randomBool: Boolean = Rand::nextBoolean() // Random Boolean
Rand::nextInt (bounded)¶
Generate a random integer in a range:
val dice: Int = Rand::nextInt(6) + 1 // 1 to 6
val percent: Int = Rand::nextInt(100) // 0 to 99
val d20: Int = Rand::nextInt(1, 21) // 1 to 20 (min, exclusive max)
Rand::nextLong (bounded)¶
Generate a random long in a range:
Rand::nextDouble (bounded)¶
val small: Double = Rand::nextDouble(10.0) // 0.0 to 10.0
val ranged: Double = Rand::nextDouble(1.0, 2.0) // 1.0 to 2.0
Rand::choice¶
Pick one random element from a list:
Rand::shuffle¶
Shuffle an array, returning a shuffled list:
Rand::sample¶
Pick n distinct random elements from a list, without replacement:
val deck: List[String] = ["A", "B", "C", "D", "E"]
val hand: List[String] = Rand::sample(deck, 3) // 3 distinct cards
Rand::uuid¶
Generate a random UUID string:
Assert Module¶
Testing assertions via onion.Assert. Throws AssertionError on failure.
Basic Assertions¶
Assert::isTrue(x > 0)
Assert::isFalse(list.isEmpty())
Assert::equals(expected, actual)
Assert::notEquals(a, b)
Each of these also has an overload taking a custom failure message as the last argument:
Assert::isTrue(x > 0, "x must be positive")
Assert::isFalse(hasErrors, "must not have errors")
Assert::equals(expected, actual, "expected and actual should match")
Assert::notEquals(a, b, "a and b should differ")
Null Assertions¶
These also accept a custom failure message:
Assert::notNull(result, "result must not be null")
Assert::isNull(errorMessage, "errorMessage should be null")
Explicit Failure¶
Timing Module¶
Time measurement utilities via onion.Timing.
Getting Current Time¶
val startNanos: Long = Timing::nanos() // High-precision (System.nanoTime)
val startMillis: Long = Timing::millis() // Wall clock (System.currentTimeMillis)
Measuring Elapsed Time¶
val start: Long = Timing::nanos()
// ... some operation ...
val elapsedNs: Long = Timing::elapsedNanos(start) // Elapsed in nanoseconds
val elapsedMs: Double = Timing::elapsedMs(start) // Elapsed in milliseconds (double, sub-ms precision)
val elapsedMillis: Long = Timing::elapsedMillis(start) // Elapsed in milliseconds since a Timing::millis() start
Formatting Time¶
val nanos: Long = 1234567890L
val formatted: String = Timing::formatNanos(nanos) // "1.23s"
// Output formats: "123ns", "45.67us", "12.34ms", "1.23s"
val millis: Long = 125000L
val formattedMs: String = Timing::formatMillis(millis) // "2m5s"
// Output formats: "500ms", "1.23s", "2m30s"
Sleep¶
Timing::sleep(1000L) // Sleep for 1000 milliseconds
Timing::sleepNanos(500000L) // Sleep for 500,000 nanoseconds
A zero or negative duration returns immediately, so Timing::sleep(deadline - Timing::millis()) is safe once the deadline has passed.
Measuring Function Execution¶
// Measure and print execution time, return result
val result: Int = Timing::measure { expensiveOperation() }
// Prints: "Elapsed: 123.45ms"
val result2: Int = Timing::measure("task") { expensiveOperation() }
// Prints: "task: 123.45ms"
// Same, but for a function that returns nothing
Timing::measureVoid { expensiveOperation() }
// Prints: "Elapsed: 123.45ms"
Timing::measureVoid("task") { expensiveOperation() }
// Prints: "task: 123.45ms"
// Get execution time in nanoseconds without printing
val timeNanos: Long = Timing::time { expensiveOperation() }
Strings Module¶
String utilities (onion.Strings, auto-imported):
Strings::split("a,b,c", ",") // List[String] ["a","b","c"]
Strings::splitRegex("a1b2c", "[0-9]") // List[String] ["a","b","c"]
Strings::join(parts, "-") // arrays or Lists
Strings::upper(s) / Strings::lower(s) / Strings::trim(s)
Strings::replace(s, "a", "b") / Strings::replaceRegex(s, "[0-9]+", "#")
Strings::startsWith(s, p) / Strings::endsWith(s, p) / Strings::contains(s, sub)
Strings::padLeft(s, 8, '0') / Strings::padRight(s, 8, ' ') / Strings::repeat(s, 3)
Case and inspection helpers:
Strings::capitalize("hello") // "Hello"
Strings::decapitalize("Hello") // "hello"
Strings::capitalizeWords("a b c") // "A B C"
Strings::equalsIgnoreCase(a, b) / Strings::containsIgnoreCase(s, sub)
Strings::count("banana", "a") // 3
Strings::isEmpty("") / Strings::isBlank(" ") // true / true
Strings::reverse("abc") // "cba"
Strings::lines("a\nb\r\nc") // List[String] ["a","b","c"]
Shaping and decomposition:
Strings::removePrefix("unhappy", "un") // "happy"
Strings::removeSuffix("running", "ing") // "runn"
Strings::truncate("hello world", 8, "...") // "hello..."
Strings::center("hi", 6, '*') // "**hi**"
Strings::ifBlank(" ", "default") // "default"
Strings::words(" a b c ") // List[String] ["a","b","c"]
Strings::chars("abc") // List ["a","b","c"]
Strings::substring("hello", 1) / Strings::substring("hello", 1, 3) // "ello" / "el"
Strings::indexOf("hello", "l") / Strings::lastIndexOf("hello", "l") // 2 / 3
Parsing that throws NumberFormatException ("not an integer: \"abc\"") on bad input, by the same rules as the null-safe forms below; also callable as "42".toInt():
Null-safe parsing (return null/fallback instead of throwing):
Strings::toIntOrNull("42") // 42, or null if not an int
Strings::toLongOrNull("100") / Strings::toDoubleOrNull("3.14")
Strings::toIntOr("nope", 0) // 0
toDoubleOrNull accepts only a plain decimal or scientific-notation number (3.14, -2.5e10); Java literal forms such as 1d, 0x1p3, NaN and Infinity give null.
Most Strings methods (upper, lower, capitalize, reverse, ...) also
work as extension-call method chains (s.upper(), s.reverse(), ...) with
identical behavior to the static form. split, substring, lines,
chars and repeat are the exception: java.lang.String already defines
methods with these same names, and an instance method always wins over an
extension method of the same name, so s.split(","), s.substring(1),
s.lines(), s.chars() and s.repeat(3) silently call the native JDK
method instead of onion.Strings's. That means s.split(",") returns a
String[] (not a List), s.substring(10) throws on an out-of-range start
instead of returning "", s.lines() / s.chars() return a JDK
Stream/IntStream (not a List), and s.repeat(-1) throws instead of
returning "". Use the Strings:: static-call form (e.g. Strings::split(...),
Strings::substring(...)) for these five methods to get onion.Strings's
List-returning, exception-safe behavior.
join is shadowed too, but by onion.Colls, not the JDK: String has
no native join instance method, but onion.Colls also declares a
join(List, String) extension (an alias for mkString, see the Colls
Module section below) with the same erased signature, and Colls is
registered ahead of Strings in the builtin extension container list, so
parts.join(sep) always reaches onion.Colls's version --
onion.Strings's join is never reachable by extension-call syntax at
all. The two disagree on a null element: Colls::join appends the
literal "null", while Strings::join throws NullPointerException. Use
the Strings::join(...) static-call form to get the throwing behavior (or
just rely on Colls's xs.join(sep) / xs.mkString(sep), which never
throws on a null element).
contains and isEmpty are shadowed too, but only observably for a
platform-typed null: java.lang.String already defines contains
and isEmpty instance methods, so s.contains(x) and s.isEmpty() also
silently reach the native JDK method instead of onion.Strings's, same
as the five methods above. For a non-null String the two agree (both
end up running the same JDK logic), so this is invisible in ordinary
code. It becomes observable for a value read back from unparameterized
Java interop -- a platform type carries no compile-time nullability
tracking, so Onion's null-safety checking does not force a null check
before the call -- and that value happens to be null at runtime:
s.isEmpty() / s.contains(x) then throw NullPointerException from
the native method, while onion.Strings's versions are null-safe
(Strings::isEmpty(null) == true, Strings::contains(null, x) ==
false). Use the Strings::contains(...) / Strings::isEmpty(...)
static-call form when the receiver may be an unchecked platform null.
trim, startsWith, endsWith, indexOf and replace are shadowed the
same way: java.lang.String already defines trim(),
startsWith(String), endsWith(String), indexOf(String) and
replace(CharSequence, CharSequence) instance methods, so s.trim(),
s.startsWith(x), s.endsWith(x), s.indexOf(x) and s.replace(a, b)
also silently reach the native JDK method instead of onion.Strings's.
As with contains/isEmpty above, this is invisible for a non-null
String and only becomes observable for a platform-typed null receiver,
where the native methods throw NullPointerException while
onion.Strings's versions are null-safe (Strings::trim(null) == "",
Strings::startsWith(null, x) == false, Strings::endsWith(null, x) ==
false, Strings::indexOf(null, x) == -1, Strings::replace(null, a, b) ==
""). Use the Strings::trim(...) / Strings::startsWith(...) /
Strings::endsWith(...) / Strings::indexOf(...) / Strings::replace(...)
static-call form when the receiver may be an unchecked platform null.
isBlank is shadowed too, and observably so even on an ordinary non-null
String: java.lang.String has defined an isBlank() instance method
since Java 11, so s.isBlank() also silently reaches the native JDK
method instead of onion.Strings's. Unlike contains/isEmpty above, the
two disagree without needing a platform-typed null: onion.Strings::isBlank
is implemented as str.trim().isEmpty(), and String::trim only strips
characters <= U+0020, while native String::isBlank treats every
character satisfying Character.isWhitespace as blank -- including Unicode
space separators like EM SPACE (U+2003) that trim() does not strip. So a
string consisting only of an EM SPACE is blank under s.isBlank()
(extension-call syntax) but not blank under Strings::isBlank(s)
(static-call syntax). Use the Strings::isBlank(...) static-call form for
trim()-based, ASCII-whitespace semantics.
equalsIgnoreCase and lastIndexOf are shadowed the same way as
trim/startsWith/endsWith/indexOf/replace above:
java.lang.String already defines equalsIgnoreCase(String) and
lastIndexOf(String) instance methods, so s.equalsIgnoreCase(x) and
s.lastIndexOf(x) also silently reach the native JDK method instead of
onion.Strings's. As above, this is invisible for a non-null String and
only becomes observable for a platform-typed null receiver, where the
native methods throw NullPointerException while onion.Strings's
versions are null-safe (Strings::equalsIgnoreCase(null, x) == false,
Strings::lastIndexOf(null, x) == -1). Use the
Strings::equalsIgnoreCase(...) / Strings::lastIndexOf(...) static-call
form when the receiver may be an unchecked platform null.
Files Module¶
File I/O (onion.Files). Text is read and written as UTF-8 on every JDK and platform;
Files::readText(path, charset) and Files::writeText(path, content, charset) take a
java.nio.charset.Charset for another encoding:
Files::readText("path.txt") // whole file as String
Files::readLines("path.txt") // List[String]
Files::writeText("out.txt", content)
Files::writeLines("out.txt", lines) // List[String] -> one line per entry
Files::appendText("out.txt", content) // appends, creating the file if needed
Files::readBytes(path) / Files::writeBytes(path, bytes)
Files::list("dir") // List of entry names
Files::listFiles("dir") // List of java.io.File entries
Files::glob("dir", "*.on") // glob-matched names
Files::walk("dir") // every file under dir, recursively: relative paths, sorted
Files::delete(path) / Files::exists(path)
Files::isFile(path) / Files::isDirectory(path)
Files::mkdirs(path) // creates dir + missing parents
Files::size(path) // Long, size in bytes (0 if missing)
Files::copy(src, dst) // replaces dst if it exists
Files::move(src, dst) // rename; replaces dst if it exists
Files::copyDir(src, dst) // recursive directory copy; rejects dst inside/equal to src
Path helpers — file names, parents, joining, and extensions:
Files::getFileName("a/b/c.txt") // "c.txt"
Files::getParent("a/b/c.txt") // "a/b"
Files::getAbsolutePath("a/b/c.txt") // absolute path resolved against the cwd
Files::joinPath("a/b", "c.txt") // "a/b/c.txt"
Files::ext("report.txt") // "txt" (extension, keyword-safe name)
Files::stem("report.txt") // "report"
Files::withExtension("report.txt", "md") // "report.md"
Resources Module¶
The entry points behind Onion's scheme-prefixed string literals (onion.Resources), on
the default static import list — so all three factory functions resolve unqualified,
exactly like the literal sugar they back:
Resources::file(path) // same as file"path" / file(path), a FileResource
Resources::http(url) // same as http"url" / http(url), an HttpResource
Resources::re(pattern) // same as re"pattern" / re(pattern), a compiled Pattern
file"x" desugars to the unqualified call file("x"); a dynamic path uses the function
form directly (file(pathVariable)) — the literal and the function are exactly
equivalent. Same for http"…" / http(url) and re"…" / re(pattern).
FileResource¶
The object behind the file"…" literal (dynamic form: file(path)), returned by
onion.Resources::file. It bundles a path with the read/write operations for that one
file, so the parse step is chosen by which getter you call:
val f = file"data.csv"
f.path() // the underlying path string, "data.csv"
f.exists() // Boolean
f.text() // whole file as String (UTF-8)
f.lines() // List[String]
f.json() // parsed JSON value (see Json::parse)
f.csv() // List of List of String (see Csv::parse)
f.csvRows() // List of Map, header -> value (see Csv::parseWithHeader)
f.write("new content") // replaces the file's contents
f.append("more\n") // appends, creating the file if needed
The fixed getters above close the set of things a file can be read as. read(shape)
opens it — the parse step comes from the Shape[T] you pass, and a read failure carries
this file's path into the resulting Defect (so it says which file):
readLossless(shape) is the lossless counterpart — the read half of a config lens,
pairing with Lossless::edit/render to rewrite one value in place while preserving
comments, spacing and key order:
eachLine(shape) reads one value per line, keeping both the lines that parsed and the
Defects for the ones that didn't — each positioned on its own line of the file:
Json Module¶
JSON parsing and serialization (onion.Json). The intermediate representation is
plain Java Map/List/scalars (String/Long/Double/Boolean/null):
val obj = Json::parse("{\"name\": \"kota\"}")
Json::getString(obj, "name") // typed accessors: getInt/getDouble/getBoolean
Json::stringify(obj) / Json::stringifyPretty(obj)
// Building a value to stringify
val m = Json::object() // empty Map
m.put("x", 1)
Json::stringify(m) // {"x":1}
val a = Json::array() // empty List, for JSON array values
// A non-finite Double/Float (NaN, +/-Infinity) stringifies as the JSON literal `null` --
// JSON has no token for one, and Json::parse can never produce one either
Json::stringify(0.0d / 0.0d) // "null"
// A record serializes as a JSON object of its components, in declaration order,
// recursing into nested records/Lists/Maps the same way a Map's values already do.
record Point(x: Int, y: Int)
Json::stringify(new Point(1, 2)) // {"x":1,"y":2}
// Navigable wrapper: index with [] and convert with as-methods
val v = Json::value(jsonText)
v["users"][0]["name"].asString()
The plain getString/getInt/getLong/getDouble/getFloat/getBoolean/getShort/getByte
return a boxed value that is null when the key is missing or has the wrong type — assigning
that straight into a non-null primitive throws NullPointerException. getStringOr/getIntOr/
getLongOr/getDoubleOr/getFloatOr/getBooleanOr(obj, key, default) return a primitive with
an explicit fallback instead:
val obj = Json::parse("{}")
Json::getIntOr(obj, "missing", 42) // 42, no NPE
Json::getStringOr(obj, "name", "anon") // "anon"
A missing key or out-of-range index on the Json::value wrapper yields a null-holding
Value instead of throwing, so a chain like v["users"][99]["name"] stays safe until you
convert it — asString()/asInt()/etc. return null/0/false at the end of the chain.
Value also has isNull() (was the underlying value null?), size() (element count for
an array/object Value, 0 otherwise), and raw() (the underlying Map/List/scalar/null).
Json::parseOrNull(json) behaves like Json::parse(json) but returns null on malformed
input instead of throwing Json.JsonParseException — useful when a parse failure is just
another "absent" case rather than an error to handle separately:
When you do want to handle a malformed-input failure, Json.JsonParseException carries
getPosition() — the character offset into the input where parsing gave up — in addition
to the usual message():
try {
Json::parse("{bad json")
} catch e: Json.JsonParseException {
IO::println(e.message() + " at offset " + e.getPosition())
}
Json::asObject(obj) and Json::asArray(obj) are type-safe casts on the plain
Map/List representation: each returns its argument cast to Map/List when the
runtime type matches, or null otherwise. They're handy after Json::get, Json::parse,
or Json::parseOrNull return Object and you need the Map/List view back to iterate:
val obj = Json::parse("{\"tags\": [\"a\", \"b\"]}")
val tags = Json::asArray(Json::get(obj, "tags")) // List, or null if "tags" wasn't an array
Yaml Module¶
YAML serialization and parsing for flat block-mapping documents, and a top-level
block sequence of them (onion.Yaml). Shares the same intermediate representation
as Json — scalars map to the same Java types — so derive!(Yaml) builds on
exactly the same toMap / fromMap core as derive!(Json).
Scope: a flat block mapping, or a top-level block sequence of flat mappings (no
nested maps/sequences within an element, no anchors, no tags, no flow collections). A
construct outside that scope raises an error rather than silently losing data:
Yaml::stringify throws IllegalArgumentException on a nested Map/List value,
and Yaml::parse throws Yaml.YamlParseException on:
- a nested mapping: a line indented deeper than its siblings, at the top level or inside a sequence element
- a nested sequence: a
-item under a key, or at a different column from the first item - a flow collection (
[...]/{...}), as a value or as a whole line - an anchor/alias (
&name/*name) value - a tag (
!!str 5,!name 5) value - a block scalar (
|/>)
A uniformly indented document is fine. Yaml::stringify quotes any string that
starts with one of these indicators, or with - or ?, so a flat Map, or a
List of non-empty flat Maps, always parses back.
Yaml::parse¶
Parse a YAML flat block-mapping string into a LinkedHashMap:
val data = Yaml::parse("name: Alice\nage: 30\n")
// data is a LinkedHashMap; scalars follow the same type inference as Json::parse
A top-level block sequence (- key: value items) parses into an ArrayList of
LinkedHashMaps instead — which format a text is detected from its first
non-blank line, a - item marker or a key: pair:
val rows = Yaml::parse("- name: Alice\n age: 30\n- name: Bob\n age: 25\n")
// rows is an ArrayList of two LinkedHashMaps
Scalar type inference rules (identical to Json, plus two YAML-only cases):
- "", null, or ~ → null
- true / false → Boolean
- Bare integer (matches -?\d+) → Long
- Floating-point pattern or number containing ./e/E → Double
- Double-quoted "..." → String (backslash-unescaped, no further coercion)
- Single-quoted '...' → String ('' is a literal ', no backslash escapes, no further coercion)
- Anything else → String
A trailing #... comment (a # at the start of the value, or preceded by whitespace) is
stripped before this inference runs, unless it is inside a "..." or '...' quoted value:
a: 1 # note parses a as the Long 1, and b: 'q # no' keeps the # as part of the
string q # no.
A key may be quoted the same two ways, and is unquoted by the same rules: 'k': 1 and
"k": 1 both give the key k, and 'a: b': 1 gives the key a: b.
Throws Yaml.YamlParseException on malformed input; derive!(Yaml)'s
fromYaml catches this and returns null instead.
Yaml::parseOrNull(text) behaves like Yaml::parse(text) but returns null on
malformed input instead of throwing Yaml.YamlParseException — the same
"absent" convenience Json::parseOrNull gives JSON, and what fromYamlList
(below) uses internally.
When you do want to handle a malformed-input failure, Yaml.YamlParseException carries
getLine() — the 1-based line number where parsing gave up — in addition to the usual
message():
try {
Yaml::parse("no colon here")
} catch e: Yaml.YamlParseException {
IO::println(e.message() + " at line " + e.getLine())
}
Yaml::stringify¶
Serialize a Map (or scalar) to a YAML flat block-mapping string:
A top-level List of Maps serializes to a block sequence instead, mirroring
Yaml::parse's own format detection:
val rows: List[Object] = Json::array()
rows.add(m)
Yaml::stringify(rows)
// "- name: Alice\n age: 30\n"
String values that would be misread on parse-back (those containing :,
#, newlines, or that look like numbers or booleans) are automatically
double-quoted. Numbers and booleans are rendered verbatim. Map keys are
quoted under the same rule — a key containing : or leading/trailing
whitespace is double-quoted so it doesn't collide with the key: value
separator on parse-back.
Round-trip guarantee¶
For any Map produced by Yaml::parse, Yaml::parse(Yaml::stringify(m))
returns an equal map (and likewise for a top-level sequence List).
Equivalently, for any record annotated with derive!(Yaml),
fromYaml(toYaml(v)) == v holds for all scalar-component values.
Usage with derive!(Yaml)¶
derive!(Yaml) synthesizes fromYaml and toYaml on any scalar-component
record, plus fromYamlList(s: String): List[R] reading a top-level YAML
sequence the same way derive!(Json)'s fromJsonList reads a JSON array —
skipping (not failing on) any element that isn't a mapping or doesn't convert,
with malformed input or a non-sequence top level yielding an empty list; see
Records — derive! for the
full contract.
record ServerConfig(host: String, port: Int, debug: Boolean) derive!(Yaml)
val cfg = new ServerConfig("localhost", 8080, false)
val yaml = ServerConfig::toYaml(cfg)
// "host: localhost\nport: 8080\ndebug: false\n"
val cfg2 = ServerConfig::fromYaml(yaml) // ServerConfig? — null on parse/convert failure
derive!(Json, Yaml) is also valid; both formats share the internal
toMap / fromMap core, so there is no duplication:
record User(name: String, age: Int) derive!(Json, Yaml)
val u = new User("ko", 3)
val viaJson = User::fromJson(User::toJson(u)) // == u
val viaYaml = User::fromYaml(User::toYaml(u)) // == u
Config Module¶
Configuration loading and dot-notation access over parsed JSON (onion.Config). Builds on
Json::parse, so the same object/array/scalar shape applies; nothing here is YAML- or
.env-aware — it's JSON plus dotted-path lookups and environment-variable overrides.
val config = Config::loadJson("config.json") // reads + parses a JSON file
val config2 = Config::parseJson("{\"port\": 8080}") // parses a JSON string directly
Config::get(config, "database.host") // raw value, or null if not found
Config::getString(config, "database.host", "localhost")
Config::getInt(config, "database.port", 5432) // the default if the number is a fraction or out of Int range
Config::getLong(config, "database.maxConnections", 10L)
Config::getDouble(config, "database.timeout", 30.0)
Config::getBoolean(config, "database.ssl", false)
Paths are dot-separated and walk both objects and arrays — a numeric segment indexes into an array:
val config = Config::parseJson("{\"users\": [{\"name\": \"Alice\"}, {\"name\": \"Bob\"}]}")
Config::getString(config, "users.0.name", "unknown") // "Alice"
The config can be any Map and List, not only parsed JSON. A map built with
Colls::mapOf or a Java HashMap works the same way.
A missing key, an out-of-range array index, or a value that can't convert to the requested
type all fall back to the supplied default instead of throwing; the numeric getters accept
the stored value as either a JSON number or a numeric string. hasPath checks presence
without needing a default:
Environment variables round out configuration — getEnv reads one directly, and
getWithEnvOverride reads a config path but lets an environment variable take precedence
when set, which is useful for overriding a checked-in config value at deploy time:
Config::getEnv("PORT", "3000")
Config::getWithEnvOverride(config, "database.host", "DB_HOST", "localhost")
Csv Module¶
Self-contained RFC 4180 CSV parsing and serialization (onion.Csv,
auto-imported) — quoted fields, embedded commas/newlines, and doubled quotes
are handled. An unterminated quoted field throws IllegalArgumentException
naming the line where the quote was opened, rather than swallowing the rest of the input.
val rows = Csv::parse(text) // List of List of String
val recs = Csv::parseWithHeader(text) // List of Map (header -> value)
Csv::column(rows, 0) // one positional column
Csv::columnByName(recs, "age") // one header-named column
val out = Csv::stringify(rows) // rows -> CSV text
val out2 = Csv::stringifyWithHeader(recs) // records -> CSV (inverse of parseWithHeader)
Hash Module¶
Cryptographic and checksum digests (onion.Hash). Each hashes a string's UTF-8
bytes and returns a lowercase hex digest.
Hash::sha256("password") // 64-char hex
Hash::sha512(text) // 128-char hex
Hash::md5(text) / Hash::sha1(text) // checksums / interop (not collision-safe)
Each is also a builtin extension method on String, so it can be written as a
method chain instead of a static call:
"password".sha256() // same as Hash::sha256("password")
"x".base64Encode().sha256().substring(0, 8) // chains with Codec below
Every parameter is String, not String?: a value from a nullable boundary (a Map
lookup, an Option) needs Hash::md5(maybe ?: "") before hashing, even though the
underlying Java code already treats null as "" internally — Onion has no mechanism
yet for a Java-declared parameter that is null-safe while every other one stays non-null
by default (tracked in #2180 and
#2213).
Codec Module¶
Text encoding and decoding (onion.Codec): Base64, hex, and URL/percent form.
val enc = Codec::base64Encode("Hello") // "SGVsbG8="
Codec::base64Decode(enc) // "Hello"
Codec::hexEncode("Hi") / Codec::hexDecode("4869")
Codec::urlEncode("a b&c") / Codec::urlDecode(s)
base64Decodeskips whitespace, so a file's trailing newline orbase64's 76-column lines decode as is. A byte order mark at the start (a file saved as "UTF-8 with BOM") is skipped too.- Bad input to a decoder throws
IllegalArgumentExceptionnaming the problem, for exampleinvalid Base64 character '!' at index 10(an invisible or non-ASCII character shows asU+00A0) orinvalid % escape at index 3 in "100%zz".
These are also builtin extension methods on String:
"Hello".base64Encode().base64Decode() // "Hello"
"Hi".hexEncode() / "4869".hexDecode()
"a b&c".urlEncode() / s.urlDecode()
Stats Module¶
Numeric aggregation over a list of numbers (onion.Stats). The generic
aggregates accept List[Int], List[Long] or List[Double] and work in double
precision; sumInt / sumLong keep integer precision.
val xs: List[Int] = [10, 20, 30, 40]
Stats::sum(xs) // 100.0 Stats::sumInt(xs) // 100
Stats::average(xs) // 25.0 Stats::median(xs) // 25.0
Stats::min(xs) / Stats::max(xs) // 10.0 / 40.0
Stats::variance(xs) / Stats::stddev(xs)
val ys: List[Long] = [10L, 20L, 30L, 40L]
Stats::sumLong(ys) // 100L (Long, exact precision)
These are also reachable as method calls, which is the form most code reaches
for. The method form has the same double precision, so a list of Int
sums to a Double — use Stats::sumInt when you want an Int back:
val xs: List[Int] = [10, 20, 30, 40]
xs.sum() // 100.0 (Double — the generic aggregate)
Stats::sumInt(xs) // 100 (Int)
min/max are the exception: onion.Colls also declares a
min(List)/max(List) extension with the same erased signature, and it is
registered ahead of onion.Stats, so xs.min() and xs.max() always reach
onion.Colls's versions, never onion.Stats's. That means xs.min()
returns the list's exact element type (an Int for List[Int], not a lossy
Double) and throws NoSuchElementException on an empty list instead of
returning 0.0. Use the Stats::min/Stats::max static-call form when you
need the Double result and the empty-list-safe 0.0 fallback:
val xs: List[Int] = [10, 20, 30, 40]
xs.min() // 10 (Int — onion.Colls::min, not onion.Stats::min)
Stats::min(xs) // 10.0 (Double, and 0.0 rather than a throw for [])
Type erasure is the reason there is no Int-returning sum() overload: the
element type is gone at runtime, so sum(List[Int]) and sum(List[Double])
would be the same JVM signature.
Format Module¶
Locale-independent human-readable formatting (onion.Format) — commas, decimals,
sizes and durations. For a thousands separator (digit grouping) on a whole number, use
Format::integer; Format::number groups a Double and rounds it to fixed decimals.
Format::integer(1234567) // "1,234,567" (thousands separator / digit grouping)
Format::number(1234.5678, 2) // "1,234.57"
Format::fixed(3.14159, 2) // "3.14"
Format::percent(0.756, 1) // "75.6%"
Format::bytes(1536) // "1.5 KB" (1024-based)
Format::duration(3661) // "1h 1m 1s"
Format::ordinal(21) // "21st"
Each is also a builtin extension method on its numeric receiver (Long for
integer/bytes/duration/ordinal, Double for number/fixed/percent):
(1536L).bytes() // "1.5 KB"
(3661L).duration() // "1h 1m 1s"
(21L).ordinal() // "21st"
(0.756).percent(1) // "75.6%"
(3.14159).fixed(2) // "3.14"
Text Module¶
Console text layout (onion.Text): word wrapping, indenting, and aligned tables.
Text::wrap("a long sentence ...", 40) // List of wrapped lines
Text::indent("a\nb", "> ") // "> a\n> b"
Text::dedent(" a\n b") // "a\nb"
Text::table([["Name", "Dept"], ["Alice", "Eng"], ["Bob", "Sales"]])
// Name Dept
// Alice Eng
// Bob Sales
Each is also a builtin extension method on its receiver (String for
wrap/indent/dedent, List for table):
"a long sentence ...".wrap(40) // List of wrapped lines
"a\nb".indent("> ") // "> a\n> b"
[["Name", "Dept"], ["Alice", "Eng"]].table()
Proc Module¶
Process execution for scripting (onion.Proc):
val r = Proc::capture("git", "status") // r.status() / r.stdout() / r.stderr() / r.succeeded() / r.failed()
Proc::run("ls", "-la") // stdout as String (throws on failure)
Proc::exec("make", "build") // exit code, output passes through
Proc::captureIn("/tmp", "ls") // ...In variants set the working directory
Proc::runIn("/tmp", "ls") // like run, but in the given working directory
Proc::execIn("/tmp", "make", "build") // like exec, but in the given working directory
Each command runs directly, with no shell: a single string containing pipes, redirection
or && is passed to the OS literally, not interpreted. For shell syntax, run a shell
explicitly: Proc::capture("sh", "-c", "ls | wc -l").
Args Module¶
Command-line argument parsing (onion.Args):
val parsed = Args::parse(args)
parsed.flag("verbose") // --verbose
parsed.option("out", "a.out") // --out path (with default)
parsed.option("out") // --out path, or null if absent
parsed.intOption("level", 3)
parsed.positional() // List of non-option arguments
Cli Module¶
onion.Cli is the lower-level runtime that the compiler's auto-CLI
feature (a top-level def main(...) or a tool declaration — see
Tools, capabilities and effects) generates calls to: it
turns a comma-separated spec string into parsed values, converts each flag to
its declared scalar type, and prints a usage line derived from the spec. You
normally never call it yourself — Args (above) is the module meant for
hand-written argument parsing — but it is public and importable when a script
wants the exact same spec-string parsing the generated code uses:
import { onion.Cli; }
// spec entries: "name" (positional), "name=" (--name VALUE), "name?" (--name switch)
val args: String[] = Cli::parse(rawArgs, "path,top=,verbose?")
Cli::parseInt("count", "5") // typed conversion; exits with a usage
Cli::parseLong("size", "100") // message (not an exception) on a bad value
Cli::parseDouble("ratio", "3.14")
Cli::parseBoolean("loud", "true") // accepts true/false only, unlike Boolean::parseBoolean
Cli::rest(rawArgs, 2) // trailing positionals from index 2 on, for a String[] rest param
Cli::requireArgs(rawArgs, 1, "<name> [more...]") // usage-and-exit if fewer than 1 argument given
Cli::kebab("makeSample") // "make-sample": the flag spelling usage shows
A flag entry matches both its own name and its kebab-case spelling, so
"makeSample?" accepts --make-sample and --makeSample.
Cli::tryParse(args, specString) is the non-exiting counterpart to parse:
it returns an Outcome[String[]] instead of printing to stderr and calling
System::exit, for callers that want to handle a parse failure themselves.
Colls Module¶
Collection factories and pipelines (onion.Colls):
Colls::listOf("a", "b", "c") // immutable List
Colls::mutableListOf(1, 2, 3) // ArrayList
Colls::range(0, 5) // List [0,1,2,3,4]
Colls::rangeWithStep(0, 10, 2) // List [0,2,4,6,8]
Colls::sortedBy(people) { p -> p.age() }
// map/filter/reduce/fold pipelines are extension methods on
// List/Iterable/arrays: xs.map { x -> x * 2 }.filter { x -> x > 0 }
More factories: sets, maps, and empty collections¶
Colls::setOf("a", "b", "c") // immutable Set (iteration order unspecified)
Colls::mutableSetOf(1, 2, 3) // HashSet
Colls::entry("name", "Alice") // a Map.Entry, for mapOf/mutableMapOf
Colls::mapOf(Colls::entry("name", "Alice"), Colls::entry("age", "30")) // immutable Map, insertion order preserved
Colls::mutableMapOf(Colls::entry("x", 1)) // HashMap
Colls::emptyList() // []
Colls::emptySet() // empty Set
Colls::emptyMap() // empty Map
List, set, and map utilities¶
Also usable as extension methods on their first (list/map) argument, chaining
into a pipeline like the rest of Colls:
xs.concat(ys) // elements of xs followed by elements of ys
xs + ys // same as concat (also xs.plus(ys), xs += ys); a new List
xs.associateBy { x -> key(x) } // Map key -> element; a later element wins a shared key
xs.indexOfFirst { x -> x > 6 } // index of the first match, or -1
xs.maxOrNull() / xs.minOrNull() // like max()/min(), but null for an empty list
foreach (i, x) in xs.withIndex() { ... } // withIndex(): ordered Map index -> element
xs.mapIndexed { i, x -> x + i } // map with each element's index
xs.forEachIndexed { i, x -> ... } // forEach with each element's index
[[1, 2], [3, 4]].flatten() // [1, 2, 3, 4] - one level of nesting removed
xs.flatMap { x -> [x, x] } // maps each element to a list, then flattens one level
// (bind is an alias, used by do[List] { x <- xs; ... })
xs.partition { x -> x > 1 } // [matching, nonMatching] - two Lists
xs.toSet() // Set built from xs's elements
xs.distinct() // duplicates removed, first-seen order preserved
xs.slice(0, 2) // sublist [0, 2), clamped into range
xs.sorted() // new List, ascending (elements must be Comparable)
xs.sortedByDescending { x -> x } // like sortedBy, but descending
xs.head() // first element, or null if empty (alias for first)
xs.tail() // all but the first element (throws on an empty list)
xs.takeWhile { x -> x < 3 } // longest leading run matching the predicate
xs.dropWhile { x -> x < 3 } // xs with that leading run removed
xs.zip(ys) // [[x0, y0], [x1, y1], ...] - pairs, truncated to the shorter list
xs.groupBy { x -> x % 2 } // Map from key to the List of elements with that key
xs.mkString(", ") // "1, 2, 3" - joins elements into a String (join is an alias)
Colls::isNotEmpty(xs) // true - the negation of isEmpty
m.filterMap { k, v -> k == "name" } // Map with only the matching entries
xs.any { x -> x > 1 } // true if some element matches
xs.all { x -> x > 0 } // true if every element matches
xs.none { x -> x > 5 } // true if no element matches
xs.find { x -> x > 1 } // first matching element, or null
xs.forEach { x -> println(x) } // runs an action per element, returns nothing
xs.count { x -> x > 1 } // how many elements match
xs.reverse() // new List, elements in reverse order
xs.contains(2) // true if some element equals 2
xs.isEmpty() // true if xs has no elements
xs.size() // element count
m.get("key") // value for "key", or null if absent
m.containsKey("key") // true if the map has a "key" entry
Colls::toList(args) // a Java array (e.g. main's String[]) as a List
contains, isEmpty, size, get and containsKey are also plain instance methods on
List/Set/Map, which always win over an extension method of the same name --
so these five calls never actually reach onion.Colls's versions, only the
native ones. That is not observable here: Colls's implementations are
one-line pass-throughs to the same native method.
forEach shadows the same way, for the same underlying reason as
Maps::forEach/Sets::forEach documented above: List (via Iterable)
already declares a matching one-arg instance forEach(Consumer) (a default
method since Java 8), so xs.forEach(action) reaches the native method,
never onion.Colls::forEach. Unlike Maps/Sets, though, that shadowing
hides no null-safety gap: onion.Colls::forEach performs no null check at
all, so calling it directly on a null platform List also throws
NullPointerException -- there is no null-safe form to fall back on here.
Batching, windowing, and selector aggregation¶
Also available as Colls:: static calls and, like the rest of Colls, as
List extensions that chain into a pipeline:
xs.chunked(3) // [[1,2,3],[4,5,6],[7]] - batches of at most 3, last may be smaller
xs.windowed(3) // [[1,2,3],[2,3,4],[3,4,5]] - sliding windows, one step at a time
ps.sumBy((p) -> p.age()) // Double - sum of the selector over every element
ps.averageBy((p) -> p.age()) // Double - average of the selector, 0.0 if empty
ps.maxBy((p) -> p.age()) // the element with the greatest selector value, null if empty
ps.minBy((p) -> p.age()) // the element with the smallest selector value, null if empty
xs.chunked(2).map { b -> (b as List).size() } // chains like any other pipeline stage
Http¶
HTTP client utilities (uses Java 11+ HttpClient).
GET Requests¶
POST Requests¶
Http::post(url, body): String
Http::postJson(url, jsonBody): String // Sets Content-Type: application/json
Http::post(url, body, headers): String // headers: as for get
Response Object¶
Http::getResponse(url): Response // status/body/headers, instead of just the body
Http::getResponse(url, headers): Response // headers: as for get
Http::postResponse(url, body): Response
Http::postResponse(url, body, headers): Response
Response has status(): Int, body(): String and headers(): List accessors
(r.status with no parentheses calls the same method), plus isOk(): Boolean
(2xx) and isError(): Boolean (4xx/5xx) helpers, and
header(name): String? (the first value of that header, any case) — use
these when the status code or headers matter, not just the body.
Request Builder¶
For any method, custom headers, a body and a timeout, build the request and send() it.
send() returns a Result[Http.Response, Http.HttpFailure] (see the Result Module above),
which keeps two outcomes apart:
Ok(response): the server answered, whatever the status. A 404 or a 500 is anOktoo; checkstatus,isOk()orisError()on theResponse.Err(failure): no response arrived. TheHttp.HttpFailuresays why.
val r = Http::request("POST", "https://api.example.com/v1/messages")
.header("content-type", "application/json")
.header("x-api-key", key)
.body(json)
.timeoutSeconds(120)
.send() // Result[Http.Response, Http.HttpFailure]
if r.isOk() {
val res = r.get()
if res.isError() {
IO::println("the API said no: #{res.status} #{res.body}")
} else {
IO::println(res.body)
}
} else {
val f = r.getError()
select f.kind() {
case "timeout": IO::println("gave up after 120 s")
case "connect": IO::println("could not reach #{f.url()}: #{f.message()}")
else: IO::println("connection broke: #{f.message()}")
}
}
The two outcomes can also be matched by case. The bindings are typed from the Result's type
arguments, so ok.value() is an Http.Response and no.error() an Http.HttpFailure, and the
select needs no else:
select Http::request("GET", url).send() {
case ok is Result.Ok: IO::println("HTTP #{ok.value().status}")
case no is Result.Err: IO::println("no response (#{no.error().kind()})")
}
The usual Result combinators apply. fold collapses both outcomes, map reaches into the
response, and do[Result] chains requests and stops at the first one that got no response:
val line = r.fold((f) -> "no response: #{f.kind()}", (res) -> "HTTP #{res.status}")
val body = r.map { res -> res.body }.getOrElse("")
val both = do[Result] {
a <- Http::request("GET", urlA).send()
b <- Http::request("GET", urlB).send()
yield a.status + b.status
}
For a script where "no response" should simply stop the program, use sendOrThrow(). It returns
the Response and rethrows the original exception: java.net.http.HttpTimeoutException, or
another java.io.IOException. send().getOrThrow() would instead wrap the failure's text in a
RuntimeException.
Http::request(method, url): Http.Request // method sent as written: "GET", "PUT", "PATCH", ...
.header(name, value) // adds a header (a repeated name is sent twice)
.headers(pairs) // adds ["Name1", "Value1", ...]
.body(text) // UTF-8 body; without one, no body is sent
.timeoutSeconds(n) / .timeoutMillis(n) // per-request timeout (default: none)
.send(): Result[Http.Response, Http.HttpFailure]
.sendOrThrow(): Http.Response // throws on no response
Http.HttpFailure // a value, never thrown
.kind(): String // "timeout" | "connect" | "io"
.isTimeout() / .isConnect(): Boolean
.method() / .url() / .message(): String
.cause(): Throwable // the underlying exception
kindis one of:"timeout": the per-request timeout elapsed."connect": no connection was made. That covers refused, unknown host, unreachable, the 30-second connect timeout, and a failed TLS handshake."io": the connection broke while sending or receiving.- Connecting is bounded at 30 seconds with or without a per-request timeout.
- A
Requestis immutable: every step returns a new one, so a base request with authentication headers can be shared and extended. - Programming errors are not outcomes, so they still throw
IllegalArgumentExceptionat the step that names them, not atsend(). That covers: - a malformed URL or method
- a null header name or value
- a header the JDK client manages itself (
Host,Content-Length, ...) - a non-positive timeout
- Effects: building is pure, and only
send()/sendOrThrow()arenet.--planfollows the builder steps back to a literal URL and shows its host (net api.example.com).
Other Methods¶
URL Utilities¶
Http::encodeUrl(str): String
Http::decodeUrl(str): String // same as Codec::urlDecode
Http::buildQuery(params): String // params: alternating keys and values
Http::buildUrl(baseUrl, params): String // appends "?"/"&" + buildQuery(params)
Example¶
val response: String = Http::get("https://api.example.com/data");
val data: Object = Json::parse(response);
val postResponse: String = Http::postJson(
"https://api.example.com/users",
"{\"name\": \"Bob\"}"
);
HttpResource¶
The object behind the http"…" literal (dynamic form: http(url)), returned by
onion.Resources::http. It bundles a URL with the request methods for that one
endpoint, so a fetch-and-parse pipeline is one expression:
val h = http"https://api.example.com/users"
h.url() // the underlying URL string
h.get() // GET, response body as String
h.get(["Authorization", "Bearer t"]) // GET with headers (alternating names/values)
h.getJson() // GET, body parsed as JSON (see Json::parse)
h.post("body text") // POST, response body as String
h.postJson("{\"name\": \"Bob\"}") // POST with Content-Type: application/json
h.put("body text") // PUT, response body as String
h.delete() // DELETE, response body as String
read(shape) GETs the endpoint and parses the body through the Shape[T] you pass —
the parse step comes from the shape, and a failure (transport or parse) carries this
URL into the resulting Defect (so it says which endpoint):
val o: Outcome[Config] = http"https://api.example.com/config".read(shape) // Outcome[T], not an exception
eachLine(shape) GETs the endpoint and reads one value per line of the response body,
keeping both the lines that parsed and the Defects for the ones that didn't — each
positioned on its own line of the response. A transport failure is a single defect, not
an exception:
request(method) escalates to Http::request's full builder, seeded with this resource's
own URL, for anything the fixed verb methods above don't cover — a header, a timeout, a body
on any method, or the response status:
val res = http"https://api.example.com/x".request("GET")
.header("Authorization", "Bearer t")
.timeoutSeconds(5)
.send() // Result[Http.Response, Http.HttpFailure]
DateTime¶
Date and time utilities using epoch milliseconds.
Current Time¶
DateTime::now(): Long // Current epoch milliseconds
DateTime::nowString(): String // ISO format (local timezone)
DateTime::nowString(pattern): String
Parsing¶
parse(isoString) accepts 2026-11-11, 2026-11-11T10:00[:00] (local zone), and a value with Z or an offset (2026-11-11T10:00:00+09:00), which is an exact instant.
Formatting¶
Components¶
DateTime::year(epochMillis): Int
DateTime::month(epochMillis): Int // 1-12
DateTime::day(epochMillis): Int // 1-31
DateTime::hour(epochMillis): Int // 0-23
DateTime::minute(epochMillis): Int // 0-59
DateTime::second(epochMillis): Int // 0-59
DateTime::dayOfWeek(epochMillis): Int // 1=Monday, 7=Sunday
DateTime::dayOfYear(epochMillis): Int // 1-366
Arithmetic¶
DateTime::addDays(epochMillis, days): Long
DateTime::addHours(epochMillis, hours): Long
DateTime::addMinutes(epochMillis, minutes): Long
DateTime::addSeconds(epochMillis, seconds): Long
DateTime::addMonths(epochMillis, months): Long
DateTime::addYears(epochMillis, years): Long
addDays, addMonths and addYears move the calendar date and keep the local time, even across a daylight-saving change. addHours, addMinutes and addSeconds add elapsed time.
Comparison¶
DateTime::diff(time1, time2): Long // Difference in milliseconds
DateTime::diffDays(time1, time2): Int
DateTime::diffHours(time1, time2): Long // whole hours / minutes / seconds
DateTime::diffMinutes(time1, time2): Long
DateTime::diffSeconds(time1, time2): Long
DateTime::isBefore(time1, time2): Boolean
DateTime::isAfter(time1, time2): Boolean
DateTime::dayName(epochMillis): String // "Friday" (English, locale-independent)
DateTime::monthName(epochMillis): String // "March"
Factory¶
DateTime::of(year, month, day): Long
DateTime::of(year, month, day, hour, minute, second): Long
DateTime::today(): Long // = startOfDay(now())
DateTime::startOfDay(epochMillis): Long
DateTime::endOfDay(epochMillis): Long
Example¶
val now: Long = DateTime::now();
IO::println("Today: " + DateTime::format(now, "yyyy-MM-dd"));
val tomorrow: Long = DateTime::addDays(now, 1);
IO::println("Tomorrow: " + DateTime::format(tomorrow));
val birthday: Long = DateTime::of(1990, 5, 15);
val age: Int = DateTime::diffDays(now, birthday) / 365;
Net¶
TCP sockets. Http makes requests; this speaks any protocol, and accepts connections.
Net::connect¶
val conn = Net::connect("example.com", 80)
conn.writeLine("GET / HTTP/1.0")
conn.writeLine("Host: example.com")
conn.writeLine("")
IO::println(conn.readAll())
conn.close()
Net::connect(host, port, timeoutMillis) gives up rather than waiting for the OS default,
which on a dropped packet can be a minute or more.
A connection reads with readLine() (null at end of stream), readAll() (UTF-8, until the
peer closes) and readBytes(); it writes with write(text), writeLine(text) (appends
CRLF, which is what line-oriented protocols expect) and writeBytes(bytes). Every write
flushes, so nothing sits in a buffer unsent. timeout(millis) bounds a blocking read,
closeWrite() half-closes to signal EOF while still reading, close() is idempotent, and
conn.isClosed() reports whether it already has been.
Net::listen¶
val listener = Net::listen("localhost", 0, 4) // 0 asks the OS for a free port
IO::println("listening on " + listener.port())
val peer = listener.accept()
peer.writeLine("hello " + peer.remoteAddress())
peer.close()
listener.close()
Port 0 asks the OS for a free port, and port() reports the one it chose — that is what
makes a server testable without picking a number and hoping. Binding "localhost" keeps it
off the network; passing null as the host binds every local address. Closing the listener
is how to unblock a thread parked in accept(), and listener.isClosed() reports whether
that has happened.
Net::listen(port) is the shorthand for that last case: it binds every local address with
a default backlog of 50, equivalent to Net::listen(null, port, 50).
Failures carry the address that failed, so a catch in Onion names the host rather than
just saying "connection refused".
Server¶
An HTTP server, on the JDK's own implementation — no dependency.
Server::start¶
val server = Server::start("localhost", 8080)
server.handle("/hello", (req) -> Server::text("hi " + req.method()))
server.await()
Server::start(port) binds every local address; Server::start(host, port) binds one.
Port 0 asks the OS for a free port, which port() then reports. await() blocks until the
process ends; stop() stops accepting and waits a second for handlers in flight.
Routing¶
handle(path, handler) matches one exact path. handleAll(handler) catches everything
else, which is where routing in Onion belongs:
server.handleAll((req) -> select req.path() {
case re"/users/(\d+)" (id): Server::json("{\"id\":" + id + "}")
case "/health": Server::text("ok")
else: Server::notFound()
})
A handler that throws produces a 500 rather than taking the server down or leaving the client waiting on a socket that never answers.
Request¶
method(), path() (without the query string), query() (the raw one, "" when absent),
body() (read in full before the handler runs), header(name), headers() and params()
— the last two return Maps, in the order written.
Response¶
Server::text, Server::json and Server::html are 200s with the matching content type;
Server::notFound() is a 404 and Server::status(code, body) is anything else. Responses
are immutable, so withStatus and withHeader hand back a new one:
Building a response touches no socket, which is what lets a handler be tested on its own.
Archive¶
Zip and gzip. Tar is not here: it needs a dependency, and these two are what the JDK can do on its own.
Archive::zip("out.zip", ["a.txt", "b.txt"])
Archive::zipDir("site.zip", "site") // keeps paths relative to "site"
val names = Archive::entries("out.zip") // without extracting
val written = Archive::unzip("out.zip", "extracted")
Archive::gzipFile("big.log", "big.log.gz") // streams, rather than reading it all in
Archive::gunzipFile("big.log.gz", "big.log") // and the reverse
val bytes = Archive::gunzip(Archive::gzip(text.getBytes()))
Extraction refuses to write outside the target directory. An entry named
../../.ssh/authorized_keys is the standard "zip slip" attack, and an extractor that
resolves entry names naively writes exactly where it is told; this throws instead, naming
the entry.
unzip always returns absolute paths, even when targetDir was given as a relative
path: it is resolved against the current directory and normalized before any entry is
written.
Entries are written with a fixed timestamp, so zipping the same inputs twice produces the same bytes — an artefact that differs run to run cannot be checksummed or cached.
Concurrent¶
Threads, and the pieces needed to use them safely. Future could already run one thing off
the current thread, but there was no way to bound how many run at once, to share a counter
between them, to hold a lock, or to hand work from one to another.
Virtual threads are deliberately absent: they need Java 21, and Onion targets 17.
Pool¶
val pool = Concurrent::pool(4) // or Concurrent::pool() for one per CPU
val bodies = pool.mapAll(urls, (u) -> Http::get(u))
pool.close()
mapAll returns results in the input's order, not in the order they finished — output
that depends on timing is output you cannot test. A failing element is reported once every
task has settled, so one bad input cannot leave workers running behind a caller that has
already given up. submit(f) returns a Future for a single piece of work.
Pool threads are daemons, so a pool someone forgot to close cannot keep the JVM alive after
main returns. close() is still the right thing to call; awaitClose(millis) waits for
work in flight.
Full API:
Concurrent::cpus()- processors available on this machine (whatConcurrent::pool(), with no argument, sizes itself to)pool.size()- how many threads this pool haspool.submit(f)- runsfon a worker, returns aFuturepool.mapAll(items, f)- see abovepool.close()- stops accepting work and interrupts what is running; idempotentpool.awaitClose(timeoutMillis)- stops accepting work and waits for what is running; returns whether everything finished within the timeout
Counter, Lock, Channel¶
val hits = Concurrent::counter()
hits.increment()
val lock = Concurrent::lock()
lock.withLock { /* … */ } // releases even if the body throws
val chan = Concurrent::channel(16) // bounded on purpose
chan.send("work")
val item = chan.receiveTimeout(1000) // null rather than blocking forever
Prefer withLock to acquire/release: a body that throws between a manual pair leaks
the lock and every other thread waits forever. A channel is bounded because an unbounded
one hides a producer outrunning its consumer until memory runs out, and it refuses null,
which would be indistinguishable from an empty receive.
Full API:
Concurrent::counter()/Concurrent::counter(initial)counter.get()/counter.increment()/counter.decrement()/counter.add(delta)/counter.set(next)counter.compareAndSet(expected, next)- sets the value only if it still equalsexpectedConcurrent::lock()lock.withLock(body)- see abovelock.acquire()/lock.release()- the manual pairwithLockexists to avoidlock.tryAcquire()- takes the lock only if it is free; returns whether it was takenlock.isHeld()- whether the lock is currently heldConcurrent::channel(capacity)chan.send(item)/chan.trySend(item)- blocks while full vs. returns false insteadchan.receive()/chan.receiveTimeout(timeoutMillis)- blocks until something arrives (null once the channel is closed and drained) vs. returns null after the timeoutchan.size()/chan.isEmpty()chan.close()/chan.isClosed()- refuses further sends; what is already queued can still be received, thenreceive()returns nullchan.drain()- everything queued right now, leaving the channel empty
Db¶
SQL over JDBC. The driver is not bundled — it is whatever the project declares:
val db = Db::connect("jdbc:postgresql://localhost/app", "user", "secret")
val rows = db.query("SELECT id, name FROM users WHERE age > ?", 18)
val one = db.queryOne("SELECT * FROM users WHERE id = ?", 7) // null when nothing matches
val n = db.queryValue("SELECT COUNT(*) FROM users") // first column of first row
db.update("INSERT INTO users VALUES (?, ?)", 8, "ada")
db.transaction((conn) -> {
conn.update("UPDATE accounts SET balance = balance - ? WHERE id = ?", 100, 1)
conn.update("UPDATE accounts SET balance = balance + ? WHERE id = ?", 100, 2)
})
db.isClosed() // false until db.close() is called
db.close()
Db::connect(url) also has a credential-less, one-argument form for databases that need
none (SQLite, H2): Db::connect("jdbc:sqlite:local.db") is Db::connect(url, null, null).
Values are always bound, never pasted into the SQL, so WHERE name = ? is safe with
any name and there is no way to build the string by accident.
A transaction commits when the body returns and rolls back when it throws, then rethrows.
There is no begin/commit pair to forget: a body that throws between a manual pair
leaves the connection holding an open transaction, and the next unrelated statement joins
it.
A row is a Map from column label to value, in the order selected — the label, so
SELECT x AS y gives y. Two columns sharing a label is refused rather than silently
losing one; alias one with AS.
Regex¶
Regular expression utilities.
Matching¶
Regex::matches(input, pattern): Boolean // Entire string matches
Regex::find(input, pattern): Boolean // Pattern found anywhere
Extraction¶
Regex::findAll(input, pattern): List[String]
Regex::findFirst(input, pattern): String
Regex::groups(input, pattern): List[String] // First match groups
Regex::groupsAll(input, pattern): List[List[String]] // All matches groups
Replacement¶
Regex::replace(input, pattern, replacement): String
Regex::replaceFirst(input, pattern, replacement): String
replacement follows java.util.regex.Matcher's replacement syntax, not plain
text: $1, $2, ... insert capture groups, and a literal $ must reach Java as
\$. In an Onion string literal that backslash itself needs escaping, so write
\\$. (A bare "\$" is a compile error: \$ is not a supported escape.)
Regex::replace("2024-05", "(\\d+)-(\\d+)", "$2/$1") // "05/2024"
Regex::replace("a.b", ".", "\\$") // "$$$" -- literal dollar signs
Splitting¶
Utility¶
Extension-call shadowing¶
matches, replace, replaceFirst and split also work as extension-call
method chains (s.matches(p), s.replace(p, r), ...), but java.lang.String
already declares instance methods with these exact names and arities, and an
instance method always wins over an extension method of the same name -- the
same hazard already documented for Strings/Colls/Iterables/Maps/Sets.
find, findAll, findFirst, groups, groupsAll, quote, isValid and
matchGroups have no such collision and are not shadowed.
Two different traps hide behind that one shadowing mechanism:
-
replacesilently changes meaning, not just null-safety.String.replace(CharSequence, CharSequence)is a literal substring replacement, whileonion.Regex::replacetreats its second argument as a regex. Sos.replace(pattern, replacement)reaches the literal native method and gives the wrong answer even for an ordinary non-nulls:Use"a1b22c333".replace("\\d+", "-") // "a1b22c333" -- no literal "\d+" substring, unchanged Regex::replace("a1b22c333", "\\d+", "-") // "a-b-c-" -- \d+ matched as a regexRegex::replace(...)explicitly whenever the pattern is a regex, nots.replace(...). -
matches/replaceFirst/splitreach a native method that already uses regex semantics, so results agree on a non-null receiver -- the divergence only shows up on anullplatform-typed receiver (a value from unparameterized Java interop, per CLAUDE.md), where the native method extension-call syntax reaches throwsNullPointerExceptioninstead of the null-safe resultonion.Regexwould give (falseformatches,""forreplaceFirst,[]forsplit).splithas a second gap even for a non-null receiver:s.split(pattern)returns a rawString[]array, not theList[String]every other stdlib collection-returning method promises.
Anchored match¶
Matches only if the whole input matches pattern (anchored, unlike find/
findAll, which match anywhere); returns null otherwise. On a match, returns the
capture groups (index 0 is group 1); a group that did not participate in the match
yields "" rather than null. This is the primitive behind the case re"..." (a, b):
select pattern (see "Regex literals" in CLAUDE.md) — the compiler desugars an anchored
regex pattern into a matchGroups call plus a null check.
The same anchored match, for a single 1-based group, without the "" substitution:
returns null both when the whole match fails and when group did not participate in
it, so it distinguishes "absent" from "present but empty" — unlike matchGroups, whose
non-null bindings can't. This is the primitive behind a nullable (T?) component in
record ... from re"..." (see "Shape-First Scripting" in CLAUDE.md): an unmatched
optional group becomes a real null there, while a non-nullable component keeps "".
Pattern literal overloads¶
A re"..." literal compiles to a java.util.regex.Pattern, not a String.
Every matching/extraction/replacement/splitting method above also has an
overload that takes a compiled Pattern directly, so a re"..." literal can
be passed straight in without going through a String pattern:
Regex::matches(input, pattern: Pattern): Boolean
Regex::find(input, pattern: Pattern): Boolean
Regex::findAll(input, pattern: Pattern): List[String]
Regex::findFirst(input, pattern: Pattern): String
Regex::groups(input, pattern: Pattern): List[String]
Regex::groupsAll(input, pattern: Pattern): List[List[String]]
Regex::replace(input, pattern: Pattern, replacement): String
Regex::replaceFirst(input, pattern: Pattern, replacement): String
Regex::split(input, pattern: Pattern): List[String]
Regex::split(input, pattern: Pattern, limit): List[String]
Example¶
val text: String = "Email: alice@example.com, bob@test.org";
val emails: List[String] = Regex::findAll(text, "[\\w.]+@[\\w.]+");
// ["alice@example.com", "bob@test.org"]
val masked: String = Regex::replace(text, "@[\\w.]+", "@***");
// "Email: alice@***, bob@***"
if (Regex::matches("hello123", "[a-z]+\\d+")) {
IO::println("Pattern matched!");
}
Maps Module¶
Map utility functions.
Construction¶
Access¶
Result maps preserve insertion order (LinkedHashMap).
Access¶
Maps::getOrElse(m, "x", () -> compute()) // lazy default when absent
Maps::keys(m) // List of keys, in order
Maps::values(m) // List of values, in order
getOrDefault, keys and values are also usable as extension methods on the
map itself, chaining into a pipeline like the rest of Maps:
m.getOrDefault("x", 0) // same as Maps::getOrDefault(m, "x", 0)
m.keys() // NOT the same as Maps::keys(m) -- see caveat below
m.values() // NOT onion.Maps's or onion.Colls's values() -- see caveat below
Extension-call shadowing: onion.Colls also declares keys(Map)/
values(Map)/mapValues(Map, Function1) extension methods with the same
erased signatures as onion.Maps's, and Colls is registered ahead of
Maps in the builtin extension container list -- but that registration
order only decides keys() and mapValues(). m.keys() and
m.mapValues(...) reach onion.Colls's versions (onion.Maps's
versions of these two are never reachable by extension-call syntax at
all); Colls's results are unmodifiable (ks.add(...) throws
UnsupportedOperationException).
m.values() is different: it never reaches either extension container.
Map[K, V] is backed by java.util.Map, which already declares an
instance values() method taking no arguments, and an applicable
instance method on the receiver's own type is always tried before any
extension fallback -- Colls included. So m.values() reaches the
native java.util.Map.values(), which returns a live view backed
by the map, not a snapshot: removing through the view's iterator removes
the entry from m itself, and mutating m afterward is visible through
a values() view obtained earlier. Calling .add(...) on that view
throws UnsupportedOperationException too (views don't support
insertion), which happens to match Colls's unmodifiable behavior on
that one call but for an unrelated reason -- the live-view aliasing has
no equivalent in either Colls's unmodifiable snapshot or
Maps::values's mutable ArrayList snapshot. Maps::keys/values/
mapValues called directly return a plain mutable
ArrayList/LinkedHashMap. getOrDefault shadows the same way as
values() -- java.util.Map also declares a matching two-arg
getOrDefault, so m.getOrDefault(k, d) resolves to the native method
rather than either extension too. For a non-null receiver that is
invisible, since the native method and both extensions agree. It becomes
observable for a receiver that is null at runtime but was never checked
at compile time -- a "platform type" (per CLAUDE.md) read back from
unparameterized Java interop carries no compile-time nullability
tracking, so Onion's null-safety typechecking does not force a null check
first. onion.Maps::getOrDefault is null-safe (a null map returns the
default), but m.getOrDefault(k, d) on that same null map reaches the
native method and throws NullPointerException instead -- call
Maps::getOrDefault(...) directly to get the null-safe behavior on a
value that might be null. forEach shadows the same way too: java.util.Map
also declares a matching default instance method forEach(BiConsumer)
(since Java 8), and a two-arg Onion lambda SAM-converts to BiConsumer the
same way it converts to onion.Maps::forEach's Function2, so
m.forEach(action) reaches the native java.util.Map.forEach rather
than onion.Maps::forEach. For a non-null receiver that is invisible, since
both visit every (key, value) pair in iteration order. It becomes observable
for a receiver that is null at runtime but was never checked at compile
time (the same "platform type" hazard as above): onion.Maps::forEach is
null-safe (a null map is a no-op), but m.forEach(action) on that same
null map reaches the native method and throws NullPointerException
instead -- call Maps::forEach(...) directly to get the null-safe behavior.
Transformation¶
Maps::mapValues(m, (v: Int) -> v * 2) // extension call m.mapValues(...) reaches onion.Colls's -- see caveat above
Maps::mapKeys(m, (k: String) -> k.toUpperCase())
Maps::filterValues(m, (v: Int) -> v > 0)
Maps::filterKeys(m, (k: String) -> k.startsWith("a"))
Maps::filter(m, (k: String, v: Int) -> v > 0) // key+value predicate
Maps::invert(m) // swap keys and values
Maps::map(m, (k: String, v: Int) -> k + "=" + v) // entries -> List; m.map { k, v -> ... }
Maps::toList(m, (k: String, v: Int) -> k + "=" + v) // entries -> List (same as map)
Maps::forEach(m, (k: String, v: Int) -> println(k))
Querying¶
Maps::count(m, (k: String, v: Int) -> v > 0)
Maps::anyEntry(m, (k: String, v: Int) -> v < 0)
Maps::allEntries(m, (k: String, v: Int) -> v >= 0)
Building from lists¶
Maps::groupBy(items, (x: Item) -> x.category()) // Map[K, List[Item]]
Maps::countBy(items, (x: Item) -> x.category()) // Map[K, Integer] frequency
Extension-call shadowing: onion.Colls also declares a groupBy(List,
Function1) extension method with the same erased signature as
onion.Maps's, and Colls is registered ahead of Maps in the builtin
extension container list, so xs.groupBy(f) always reaches onion.Colls's
groupBy -- onion.Maps's is never reachable by extension-call syntax at
all. The two disagree on null and on mutability: Colls::groupBy throws
NullPointerException on a null list and returns an unmodifiable
Map of unmodifiable inner Lists, while Maps::groupBy returns an empty
mutable Map for a null list and mutable ArrayList buckets otherwise.
Call Maps::groupBy(...) directly (not xs.groupBy(...)) to get
onion.Maps's mutable result.
Combination¶
val merged = Maps::merge(a, b) // b wins on collisions
Maps::mergeWith(a, b, (x: Int, y: Int) -> x + y) // combine on collision
Maps::update(m, "a", (v: Int) -> v + 1) // functional update
Sets Module¶
Set utility functions. Result sets preserve insertion order (LinkedHashSet),
and the set-algebra operations are null-safe.
Construction¶
val a = Sets::of(1, 2, 3)
val b = Sets::newSet[Int]()
val c = Sets::fromList([1, 1, 2, 3]) // distinct, first-seen order
Sets::toList(a) // back to a List
a.toList() on a Set is onion.Colls's toList(Set): it and
onion.Iterables's toList(Iterable) both accept a Set, and the more
specific receiver wins (before this, the call failed with E0006 as
ambiguous). onion.Sets's toList(Set) has the same erased signature as
Colls's and is shadowed by it. The two static forms differ:
Colls::toList wraps its copy in Collections.unmodifiableList and throws
NullPointerException for a null set; Sets::toList returns a plain
mutable ArrayList and treats null as empty. Call Sets::toList(a) when
you need the mutable, null-safe copy.
a.toList() // = Colls::toList(a): unmodifiable
Sets::toList(a) // mutable; null-safe (empty list for null)
Colls::toList(a) // unmodifiable; throws NullPointerException for null
Set algebra¶
Every method below is also a builtin extension method on Set, callable as
a.union(b) instead of Sets::union(a, b) -- except union/intersection/
difference: onion.Colls also declares extension methods of those three
names with the same erased (Set, Set) signature, and Colls is registered
ahead of Sets in the builtin extension container list, so a.union(b),
a.intersection(b) and a.difference(b) always reach onion.Colls's
versions -- onion.Sets's versions of these three are never reachable by
extension-call syntax at all. Colls's results are unmodifiable
(Collections.unmodifiableSet); Sets::union/intersection/difference
called directly return a plain mutable LinkedHashSet. Use the Sets::
form when a mutable result is required.
Sets::union(a, b) // mutable; NOT reachable as a.union(b)
Sets::intersection(a, b) // mutable; NOT reachable as a.intersection(b)
Sets::difference(a, b) // mutable; NOT reachable as a.difference(b)
a.union(b) // onion.Colls's version instead -- unmodifiable
a.intersection(b) // onion.Colls's version instead -- unmodifiable
a.difference(b) // onion.Colls's version instead -- unmodifiable
Sets::symmetricDifference(a, b) // a.symmetricDifference(b) -- in exactly one of the two
Sets::containsAll(a, b) // NOT a.containsAll(b) -- see the shadowing note below
Sets::isSubsetOf(a, b) // a.isSubsetOf(b) -- every element of a is in b
Sets::isSupersetOf(a, b) // a.isSupersetOf(b)
Sets::isDisjoint(a, b) // a.isDisjoint(b) -- share no elements
Extension-call shadowing: unlike every other method in this list, a.containsAll(b)
does not reach onion.Sets::containsAll at all -- java.util.Set (via
java.util.Collection) already declares an instance method containsAll(Collection),
and an instance method always wins over an extension method before the extension
fallback path is even consulted. The two disagree on null: onion.Sets::containsAll
is null-safe (a null subset means "contains all," a null container means "contains
none"), while native Set.containsAll throws NullPointerException for a null
argument. Call Sets::containsAll(...) directly (not a.containsAll(...)) to get
onion.Sets's null-safe result.
a.containsAll(b) // java.util.Set's native method -- throws NullPointerException on null
Sets::containsAll(a, null) // true -- onion.Sets's null-safe result
Sets::containsAll(null, b) // false -- onion.Sets's null-safe result
Functional operations¶
These are also builtin extension methods on Set (a.filter(...),
a.count(...), ...) -- except map and forEach, see the shadowing
notes below:
Sets::map(a, (x: Int) -> x * 2) // NOT reachable as a.map(...) -- see below
Sets::filter(a, (x: Int) -> x > 1) // a.filter((x: Int) -> x > 1)
Sets::forEach(a, (x: Int) -> println(x)) // NOT reachable as a.forEach(...) -- see below
Sets::count(a, (x: Int) -> x > 1) // a.count((x: Int) -> x > 1)
Sets::any(a, (x: Int) -> x > 2) // a.any((x: Int) -> x > 2)
Sets::all(a, (x: Int) -> x > 0) // a.all((x: Int) -> x > 0)
Sets::find(a, (x: Int) -> x > 2) // a.find((x: Int) -> x > 2) -- matching element or null
Extension-call shadowing: onion.Iterables also declares a map(Set,
Function1) extension method with the same erased signature as
onion.Sets's, and Iterables is registered ahead of Sets in the builtin
extension container list, so a.map(f) always reaches onion.Iterables's
map -- onion.Sets's map is never reachable by extension-call syntax at
all. Both collect into a LinkedHashSet (insertion order preserved, as
promised above), so they only disagree on null: Iterables::map throws
NullPointerException for a null set, while Sets::map returns an empty
set. Call Sets::map(...) directly (not a.map(...)) to get onion.Sets's
null-safe result.
forEach is shadowed too, but by the JDK, not another onion module:
Set conforms to java.lang.Iterable, which already declares a default
instance method forEach(Consumer), and an instance method always wins
over an extension method before the extension fallback path is even
consulted -- the same shadowing pattern already documented for
Maps::forEach above. A one-arg Onion lambda SAM-converts to Consumer
the same way it converts to onion.Sets::forEach's Function1, so
a.forEach(action) always reaches the native Iterable.forEach,
never onion.Sets::forEach. For a non-null receiver that is invisible,
since both visit every element in iteration order. It becomes observable
for a receiver that is null at runtime but was never checked at compile
time (a "platform type"): onion.Sets::forEach is null-safe (a null set
is a no-op), but a.forEach(action) on that same null set reaches the
native method and throws NullPointerException instead -- call
Sets::forEach(...) directly to get the null-safe behavior on a value
that might be null.
a.forEach((x: Int) -> println(x)) // native Iterable.forEach -- throws NullPointerException on a null Set
Sets::forEach(a, (x: Int) -> println(x)) // onion.Sets's null-safe result
Next Steps¶
- Language Specification - Formal language spec
- Compiler Architecture - Compiler internals
- Java Interoperability - Using Java libraries