Skip to content

Project CLI (onion new/build/run/test/clean)

The onion command also understands a small, convention-over-configuration project workflow. It sits alongside the script runner and REPL: new, build, run, test, and clean are reserved only when they are the exact first argument, so every existing onion [options] file.on [args...] and onion repl invocation keeps working unchanged.

Quick Start

onion new hello
cd hello
onion run
onion test

onion new hello creates:

hello/
├── onion.toml
├── src/
│   └── main.on
└── tests/
    └── main_test.on

Generated output lives under hello/target/, which onion clean removes. No Git repository is initialized and no existing path is ever overwritten.

The generated manifest:

[package]
name = "hello"
version = "0.1.0"

The generated program:

def main(): void {
  println("Hello, hello!")
}

The generated test:

Assert::equals(4, 2 + 2)

Commands

onion new <name>
onion build [--verbose]
onion run [--verbose] [-- <arguments...>]
onion test [--verbose] [--report-xml <path>]
onion clean
onion doc [-d <dir>] [<source.on>...]
onion fmt [--check] [<path>...]

onion test --report-xml

Writes the run as JUnit XML, which is what CI reads to annotate a build with the tests that failed rather than only that something did:

onion test --report-xml target/test-reports/junit.xml

The summary line and the exit code are unchanged. A report that cannot be written is an error in its own right — it does not silently turn a failing run into a passing one, and it does not hide a genuine test failure behind an I/O problem.

onion fmt

Normalises spacing. With no arguments inside a project it formats everything under src/ and tests/:

onion fmt

A path may be a file or a directory, and a directory is walked for .on files, so it also works outside a project:

onion fmt scripts/

--check writes nothing, lists the files that would change, and exits 1 if there are any — the shape a CI step needs:

onion fmt --check

What it changes is deliberately small, and each boundary was measured rather than guessed. It tightens the punctuation that binds to what it touches — f(a , b) to f(a, b), "=" .rep(60) to "=".rep(60), f(- 1) to f(-1) — and adds a missing newline at the end of a file. Everything else is reproduced byte for byte, tabs included.

It does not reindent. Onion's continuations are not bracketed: an expression body on the line after =, a method chain led by .filter, an expression carried on by a trailing +, a from re"…" clause under a record. Nothing lexical separates those from a misindented line, so a formatter that computes indentation from brace depth reindents correct code — measured against the sample programs, that design rewrote 36% of their lines, almost all of it wrongly.

It does not move a line break. A line break can end a statement in Onion, so moving one would be a semantic change rather than a cosmetic one.

It does not touch a bracket, in either direction, and the sample programs are why. Closing f( a ) up to f(a) reads like an improvement until you meet new Employee( 1, …) sitting above new Employee(10, …), where the space is a right-aligned ID column. Closing f (1) up reads like an improvement until you meet an enum whose cases are padded so their argument lists line up — with five spaces on one line and a single space on the next, so not even "collapse a lone space" separates the typo from the intent. Across the 182 sample programs those two rules fired dozens of times and every firing was damage.

The result is a small tool. Run over the whole sample corpus it changes four lines. That is the honest size of the problem a formatter working from tokens alone can see here; the rest needs a parser.

Before writing any file, the formatted text is re-lexed and compared token for token, comments included, against the original. If they differ the file is left untouched and the command reports it as a bug.

onion doc

Generates API documentation as HTML. With no arguments inside a project it documents the production sources under src/ into target/doc:

onion doc

-d <dir> writes somewhere else, and naming source files explicitly makes it work outside a project entirely:

onion doc -d api src/main.on src/util.on

Doc comments (/** … */) on classes, interfaces, records, enums and methods are carried across, not just the signatures.

build, run, test, doc, fmt, and clean start at the current directory and walk upward until they find onion.toml, so any of them also work from src/, tests/, or another nested subdirectory of the project — there is no need to cd back to the project root first.

Exit codes are stable across every project command:

  • 0 — the command completed successfully.
  • 1 — a project, manifest, build, test, or runtime failure.
  • 2 — invalid command-line usage.

Manifest

The manifest requires [package], with exactly two required string keys: name (matching [A-Za-z][A-Za-z0-9_-]*) and version (a valid SemVer 2.0 version). Unknown keys or tables, duplicate keys, malformed TOML, and invalid names or versions are all reported as errors, with a line and column when the TOML parser can supply one. No source-root overrides, entrypoint overrides, compiler flags, or scripts are accepted.

[dependencies]

Maven coordinates, one per line, as "group:artifact" = "version":

[package]
name = "report"
version = "0.1.0"

[dependencies]
"org.postgresql:postgresql" = "42.7.3"
"com.fasterxml.jackson.core:jackson-databind" = "2.17.0"

The key has to be quoted, because a Maven coordinate contains a colon. Versions are exact — no ranges, no latest. The manifest rejects, naming the dependency and its line, a Maven range ([1.0,2.0), (,1.0], or anything else with [, ], (, ) or ,), a dynamic revision ending in + (1.+), and the moving aliases LATEST, RELEASE and latest.* (latest.release, latest.integration). A + inside SemVer build metadata (1.0.0+build.5) and a -SNAPSHOT version are accepted. A script's //> using dep directive applies the same rule. Transitive dependencies are resolved and land on the classpath for build, run and test alike, so a library you compile against is also there when the program runs.

The resolved set — transitives included — is part of the build fingerprint, not just the manifest text. A transitive moving underneath you invalidates the cache and forces a recompile, rather than serving classes compiled against the old classpath as current.

A coordinate that cannot be resolved fails the build with coursier's message, which names the coordinate it could not find.

A single-file script declares the same thing with //> using dep "group:artifact:version" lines at its top (see Script Runner). A script has no onion.lock, so its transitive versions are not pinned; when that matters, make it a project.

[[repositories]]

Extra Maven repositories, searched before Maven Central and in the order written:

[[repositories]]
url = "https://nexus.example.com/repository/maven-public"

Maven Central stays available; these are added to it rather than replacing it. Only absolute http, https and file URLs are accepted — a relative or misspelled entry is rejected here rather than surfacing later as a resolution failure blamed on a dependency.

This is an array of tables rather than a plain repositories = [...] array on purpose. A bare key-value pair belongs to whatever table header precedes it, so written after [package] it would quietly become package.repositories; a table header can go anywhere in the file.

Not yet supported

There is no offline mode. Resolution runs whenever a build cannot reuse the classpath it recorded for the current onion.lock (see below), reading coursier's cache (~/.cache/coursier) for anything already fetched, so a warm build does not hit the network — but it is not prevented from doing so. Onion embeds coursier's Java API, which does not expose a cache policy, so an --offline flag would have to be faked; it is left out rather than made to look like a guarantee it is not.

onion.lock

A build writes onion.lock next to onion.toml, and later builds resolve from it. Commit it.

[dependencies] names direct dependencies at a version each, which is not enough to reproduce a build: a transitive dependency's version is chosen at resolution time, so two builds of a byte-identical onion.toml can compile against different jars the moment a transitive publishes a release. That failure appears on one machine, cannot be reproduced on another, and nothing in the project changed.

The lock records the whole transitive coordinate set, and a later build resolves [dependencies] with every one of those versions forced, rather than re-deriving them. They are forced onto the manifest's own graph, not resolved as a flat list of roots: flattening takes a transitive out from under the POM that manages its version (<dependencyManagement>, a parent BOM) and can pick a different one. The lock also records a SHA-256 for each artifact, compared before anything is compiled. Different bytes for a version that was already published stops the build:

error: Resolved dependencies do not match onion.lock:
different bytes for the same file:
  postgresql-42.7.3.jar
    locked 8f3a...
    found  1c90...
A published version's bytes should never change. Check the repository, or delete onion.lock
to accept what it is serving now.

A different set of artifacts is a resolution difference rather than changed bytes, and the error says so, listing the versions that moved instead of blaming the repository:

error: Resolved dependencies do not match onion.lock:
not in the lock: lib-2.0.0.jar
missing: lib-1.0.0.jar
resolved versions differ from the lock:
  com.example:lib  locked 1.0.0  resolved 2.0.0
The artifacts resolved differently from the ones the lock records; no file's bytes changed.
Delete onion.lock to resolve again and record the new answer.

Once a build has resolved and verified a lock, it records the resulting classpath in target/.onion/dependency-classpath.json, keyed by the lock's contents. The next build/run/test with the same lock takes that classpath without asking coursier at all, provided every recorded jar still exists and its SHA-256 still matches the lock (a jar is re-hashed when its size or modification time has changed). Anything else — a different lock, a missing or changed jar, no record — falls back to resolving. The record holds absolute paths into this machine's cache, which is why it lives under target/ and is not committed; onion clean removes it. The language server reads the same record, read-only.

Change a version in onion.toml, or add a repository, and the lock no longer describes what you are asking for -- it is discarded and rewritten rather than enforced against a different question. Reordering [dependencies] is not a change and does not discard it. onion clean does not remove it: it is an input to the next build, not an output of this one.

It is not an offline mode. coursier's embedding API exposes a cache location, a thread pool and a logger, and no cache policy at all, so there is no honest way to promise a build that never reaches the network. The lock gives the same answer every time, not the absence of the question; the recorded classpath only means the question is usually not asked twice.

Source Layout

Production sources are every .on file under src/; tests are every _test.on file under tests/. Both are discovered recursively, sorted by their project-relative path, and never follow symbolic links. An empty production source set is a build error; a missing or empty tests/ directory is a successful test run with a zero-test summary.

Build Cache

build compiles once and caches the result under target/. A second build (or the build that run and test perform first) reuses that output when a SHA-256 fingerprint over the compiler's identity, the Java version, the exact manifest bytes, and every source path and its bytes still matches:

$ onion build
Built hello (1 classes)
$ onion build
Built hello (cached)

Any source addition, removal, rename, or content change invalidates the cache and triggers a full rebuild.

The compiler's identity is its version plus a digest of the jar (or class directory) it was loaded from, so a new compiler rebuilds the project even when it reports the same version: an upgraded or locally rebuilt onion.jar, or a changed compiler resource such as the effect table. New diagnostics and effect checks then run on the next build, with no onion clean needed. Hashing the jar is not repeated on every build: its digest is cached under the user cache directory (-Donion.cache.dir, $ONION_CACHE_DIR, or the platform default), keyed by the jar's path, size and modification time.

A failed rebuild never destroys the previous successful target/classes or build-state file — the new output is staged and only promoted after every artifact is written successfully.

Entrypoint Convention

Because the compiler always emits a JVM main for every top-level source, a class file alone can't tell a real entry point from a helper source. Project builds instead look at the parsed source:

  1. src/main.on is a candidate whenever it has at least one top-level statement (including a bare expression or variable declaration).
  2. Any production source that declares a top-level function named main is a candidate.
  3. Any production source that declares a top-level tool, and has neither a main nor a top-level statement, is a candidate. That is exactly when the compiler synthesizes the source's tool CLI main, the same one a script gets, so run dispatches through it: --help, --contract and --plan all work.
  4. Every other compiler-generated main is ignored.

run requires exactly one candidate. Zero candidates fail with a hint to add executable code to src/main.on, define a top-level main, or declare a top-level tool; more than one candidate fails and lists every candidate's source and location so the ambiguity can be resolved.

Arguments after -- go to the program, so a tool project is driven like the script it would otherwise be:

$ onion run -- --help
usage: meeting-summary <notes> <out>
  ...
$ onion run -- samples/notes.md out.json --plan
plan: `summarize` would
  read    derived from notes = samples/notes.md
  write   derived from out = out.json
  ...

The usage line names the project's package ([package].name) where a script's names its file.

Testing

test builds production sources once, then compiles and runs each discovered test file in sorted order, sequentially:

$ onion test
test tests/main_test.on ... ok

1 tests, 1 passed, 0 failed

A test's own stdout/stderr are shown only when it fails, unless --verbose is given. One test's compile failure, assertion failure, runtime exception, or nonzero numeric result is reported against that test alone — every remaining test still runs, and a project with no tests at all succeeds with a 0 tests, 0 passed, 0 failed summary.

A failed assertion or runtime exception is reported at the line that raised it — for an Assert:: call, the line of that call, in the test or in a project source it reached — followed by that line:

$ onion test
test tests/summary_test.on ... FAILED

1 tests, 0 passed, 1 failed
error: tests/summary_test.on:4: AssertionError: Expected true but was false
  4 | Assert::isTrue(words.size == 4)

onion script.on reports a failed assertion the same way.

Clean

clean removes exactly the project's canonical target/ directory:

$ onion clean
Cleaned target

It requires a valid, located project first, succeeds (printing the same message) whether target/ exists or not, and refuses to touch a target that is itself a symbolic link rather than following it.

Script and REPL Compatibility

new, build, run, test, and clean are reserved only as the exact first argument. Anything else — including a file that happens to be named build.on, or -- prefixed options — is passed through unchanged to the existing script runner, and onion repl [options...] still starts the interactive shell.

Deferred

The following are intentionally out of scope for this first version, to keep onion.toml from becoming another large build language before real usage justifies it: publishing, offline resolution, multiple modules or workspaces, configurable source/test/output/entrypoint paths, incremental (per-file) or parallel compilation, a project watch mode, test annotations or a new test framework, package/archive commands, linter integration, lifecycle hooks or manifest scripts, terminal color, and an onion init for adopting an existing directory.

Next Steps