Skip to content

Scripting

Onion is built to replace shell scripts when they outgrow bash. This page tours the scripting toolkit end to end.

Running Scripts

onion script.on arg1 arg2     # compile in memory and run
onion --watch script.on       # rerun on every save
onion repl                    # interactive REPL

Scripts can use a shebang:

#!/usr/bin/env onion
println("hello")

Top-level statements run in order; args holds the command line. Compile errors exit with a non-zero status.

Command-Line Arguments

val opts = Args::parse(args)

if opts.flag("verbose") { ... }          // --verbose or -v
val out = opts.option("output", "a.txt") // --output=x or --output x
val n = opts.intOption("count", 1)       // numeric with default
val files = opts.positional()            // everything else

Running External Commands

val branch = Proc::run("git", "branch", "--show-current")  // capture stdout, throw on failure

val r = Proc::capture("sh", "-c", "ls missing")            // never throws
if r.failed() { println(r.stderr()) }

val code = Proc::exec("make", "build")                     // inherit console, exit status

Proc::runIn(dir, ...) variants take a working directory.

Files and Globs

val text = Files::readText("config.txt")
Files::writeText("out.txt", text.toUpperCase())

foreach f: String in Files::glob(".", "*.on") {
  println(f)
}
Files::glob("src", "**/*.java")    // recursive

JSON and HTTP

val body = Http::get("https://api.github.com/repos/onion-lang/onion")
val v = Json::value(body)
println(v["name"].asString() + " stars=" + v["stargazers_count"].asInt())

Json::value returns a navigable value: index into objects and arrays, then convert with asString / asInt / asDouble / asBoolean; isNull() reports missing paths instead of throwing.

Scheme-Prefixed Literals

re"..." / file"..." / http"..." are RAW string literals (backslashes pass through — no \\d escaping) that desugar to the calls re(...), file(...), http(...):

val p    = re"\d+-\d+"                        // compiled Pattern
val rows = file"data.csv".csvRows()           // read + RFC 4180 parse, header-mapped
val text = file"notes.txt".text()             // also: lines() / json() / write() / append()
val body = http"https://api.example.com".get() // also: getJson() / post() / postJson()

The literal and the function form are equivalent, so dynamic values just use file(path).

Pattern-Attached Records

Attach a regex to a record and a typed parser is derived from the shape — capture groups convert to the component types:

record Access(time: String, method: String, path: String, status: Int)
  from re"(\S+) (\w+) (\S+) (\d+)"

val hits = Access::parseAll(file"access.log".text())   // List[Access], bad lines skipped
val one  = Access::parse(line)                          // Access? (null on no match)

Deriving Serialization with derive!

derive!(Json) / derive!(Yaml) synthesize both directions of a format from the record's shape — no hand-written serializer. derive! is a macro (the ! marks expansion at the use site), not a type class:

record User(name: String, age: Int) derive!(Json, Yaml)

User::toJson(u)              // {"name":"ko","age":3}
val a = User::fromJson(s)    // User?  (null on bad input)
User::toYaml(u)              // name: ko\nage: 3
val b = User::fromYaml(s)    // User?

Every format shares one toMap / fromMap core, so adding a format is just an stdlib type with parse / stringify over the shared Map — the macro is unchanged. derive!(...) and a from re"..." clause can coexist on one record. Supported component types are the scalars String / Int / Long / Double / Float / Boolean / Short / Byte; anything else is a compile error (E0062). An unknown marker is E0063.

Compile-Time Laws and Examples

A record can carry law and example clauses that the compiler runs at build time — the specification lives in the language, not a separate test suite:

record Pt(x: Int, y: Int) from re"(-?\d+),(-?\d+)"
  law roundtrip(p: Pt) { Pt::parse(Pt::format(p)) == p }
  example { Pt::parse("3,4") == new Pt(3, 4) }

example { e } must evaluate to true. law name(p: T) { e } is property-checked: the compiler generates sample values for p and requires e to hold for all of them. A false example fails compilation (E0065); a falsified law fails with a printed counterexample (E0064). This turns parse ∘ format == id into a machine-checked guarantee rather than a hope. Laws whose parameter types aren't generatable are skipped; the check is on by default.

Pipeline Operator

e |> f calls f(e); e |> f(a) injects e as the first argument. A newline before |> continues the pipeline:

file"access.log".lines()
  .map { l => classify(l) }
  .groupBy { c => c }
  .mapValues { xs => xs.size }
  |> println

Auto-CLI

A top-level main with typed parameters derives its whole command-line interface: required parameters are positional, defaulted parameters become --name flags (Boolean defaults become switches), and a usage line is generated on error:

def main(name: String, count: Int = 3, loud: Boolean = false): void {
  var msg = "hello " + name + " x" + count
  if loud { msg = msg.toUpperCase() }
  println(msg)
}
$ onion greet.on world --count 5 --loud
HELLO WORLD X5
$ onion greet.on world --count=5 --loud     # --name=value form works too
HELLO WORLD X5
$ onion greet.on --help                      # prints usage and exits 0
usage: <script> <name> [--count VALUE] [--loud] [--help]
$ onion greet.on
error: missing argument: name
usage: <script> <name> [--count VALUE] [--loud] [--help]

Flags accept both --name value and --name=value; --help (or -h) prints the generated usage and exits.

Putting It Together

#!/usr/bin/env onion
record Hit(ip: String, method: String, path: String, status: Int)
  from re"(\S+) (\w+) (\S+) (\d+)"

def main(log: String, minStatus: Int = 500): void {
  Hit::parseAll(file(log).text())
    .filter { h => h.status() >= minStatus }
    .groupBy { h => h.path() }
    .mapValues { xs => xs.size }
    |> println
}