(rontolisp) docs

Compile to JVM Bytecode

Give rontolisp an output path ending in .class with -o, and it compiles the source straight to JVM bytecode instead of interpreting it -- no ASM or other library, the bytecode is emitted by hand. The output extension is what selects the backend (.class or .jar for JVM, .wasm for WASM).

echo '(print (+ 1 2))' > hello.lisp
rontolisp hello.lisp -o Hello.class
java Hello

The generated class is named after the output file, so the name you pass to java is the file's stem: -o Hello.class produces a class Hello you run with java Hello. A directory in the path becomes the class's Java package: -o com/example/Kernels.class produces com.example.Kernels, which you run with java -cp . com.example.Kernels from the directory the path started in (the missing directories are created). The program's top-level forms become the class's entry point and run in order when you launch it.

-o out.jar writes a jar rather than a bare class: the class, the runtime classes that have to travel with it, a manifest, and -- with --maven-coordinates -- the Maven metadata that lets a consumer install it with no flags at all. The jar is executable, so it needs nothing else to run:

rontolisp hello.lisp -o hello.jar
java -jar hello.jar

A jar path names no class, so the class inside takes its name from the file's stem in CamelCase (hello.jar -> Hello, my-app-1.0.0.jar -> MyApp100) and the manifest's Main-Class points at it. That name only matters if you call the class directly: --class-name sets it, and it is REQUIRED for a --no-main library jar, whose class is the artifact's Java API rather than an entry point. It works for .class output too, where it replaces the name the path would give.

A class can also be a library Java code calls directly: rontolisp:jvm-export declares a typed, Java-callable static method for a defun, and --no-main drops the main entry point entirely. See Export a JVM library.

Example (hello.lisp):

3

Optimize (Dead-Code Elimination)

Compilation drops every method unreachable from main, along with any static field only they referenced, and compacts the constant pool accordingly. You get that without asking:

echo '(defun fact (n) (if (<= n 1) 1 (* n (fact (- n 1)))))
(print (fact 10))' > fact.lisp
rontolisp fact.lisp -o Fact.class
java Fact

For a small program like fact the class is ~6.5 KB. Pass --optimize=off and the class instead embeds the entire runtime (printer, numeric, reader and eval helper methods, plus a first-class wrapper for every built-in) regardless of what the program actually uses, which for the same fact is ~190 KB. The elimination is behavior-preserving: reachability follows the actual invoke instructions in the bytecode, so anything a first-class function value, funcall, or an embedded eval/load can dispatch to is kept, and the java: interop bridge's reflective entry point survives as an explicit root. Every rontolisp:jvm-export typed method is an explicit root too — its caller is Java code the bytecode cannot show — which is what lets a compiled library keep the default size. The same levels also tree-shake the WASM output.

The dispatch methods funcall goes through list only the functions your program can actually obtain as a value — #'name, a quoted 'name designator, a lambda, or (while the program holds a symbol builder such as intern or find-symbol) a string or keyword constant spelling the name — so everything else becomes ordinary dead code the shaker removes. That listing switches off, and every function stays reachable, only when the program can name a function out of data this compile never sees: any use of eval, read, read-from-string, a runtime load or a ~/name/ format directive — including one inside a library you loaded — as does --dynamic. Compile with -Drontolisp.debug.dispatchgate=true to have the compiler name the operator responsible.

One carve-out follows from that: a designator assembled at run time out of computed pieces — (funcall (intern (concatenate 'string "gre" suffix))) — is no constant the compiler can read, so the call signals the ordinary "undefined function" error. --dynamic is the way back. --optimize=off is not: the listing is not part of what the level switches, so declining the optimizer does not bring such a name back.

--optimize takes an optional level, shared with the WASM backend. --optimize and --optimize=default both spell what an absent flag already selects — everything above — for a build script that wants it written down. --optimize=off declines it, and emits what a build before the flag was on by default emitted. --optimize=size asks for the smallest output a backend can give. On this backend it declines the two emissions that spend bytes on speed. One is the typed numeric loop: a dotimes whose body reads and writes packed single/double-float arrays through fixnum index math, let temporaries, + - * /, the unary math functions and if/when/unless tests compiles by default to a primitive long/double loop over raw float[]/double[] accesses, behind a check at the loop's entry that falls back to the ordinary emission whenever the variables are not what the typing assumed -- the same values either way, several times faster, and a larger class because the body is emitted more than once. The other is integer expression-tree fusion: a nested + - * mod rem logand logior logxor lognot ash tree compiles by default into a shared method that runs the whole tree as raw long arithmetic and boxes only the result, with the generic per-operation chain kept alongside as the fallback for anything that is not a machine-word integer at run time -- again the same values, and a class that carries each tree twice. --optimize=size keeps only the ordinary emissions; a program with neither shape compiles to the same class at both levels, and the same program's JVM bytecode is about a third the size of its WASM to begin with. So one build script can pass --optimize=size for every target.

--optimize=off exists for two jobs, and neither of them is making a program work: comparing an artifact against one built before a compiler change, and bisecting a suspected shaker bug by asking whether the unshaken class behaves differently. A program whose functions are reached only through a name the compiler cannot read needs --dynamic, as above.

Independently of the level, compilation always tree-shakes the libraries it splices in: the bundled Lisp-source ones (linalg:, vec:, JSON, URL, equalp/string<) and every system loaded with asdf:load-system / ql:quickload. A function, variable or constant your program never mentions -- by name anywhere in the source, including quoted symbols and string literals -- is not compiled in. Your own code is never pruned, and neither is anything a load/require splices in: only a library that came from a system is subject to it.

Classes, generic functions, methods, conditions and structures are pruned by the same rule: a class nothing references leaves together with its methods, and a method on a generic your program does call is still dropped when no reachable code can create an instance of the class it specializes on. Methods on the standard protocol names (initialize-instance, print-object, close, ...) follow their class alone, since those calls are implicit.

The one consequence: a library function whose name is only assembled at runtime from computed strings and called through eval/apply signals the usual "undefined function" error. Compile with --no-prune (or --dynamic) to keep every library definition in that case.

The generated .class file targets Java 17 (class version 61), so running it requires a Java 17 or newer JRE. Beyond java.lang and java.io, the emitted runtime helpers reference java.math (BigInteger/BigDecimal/MathContext, for the overflow-promoting integer and exact ratio arithmetic) and java.util (ArrayList/Arrays, and HashMap for hash tables); a program that calls rontolisp:fetch additionally references java.net/java.net.http, and rontolisp:await / rontolisp:futurep represent futures as java.util.concurrent futures -- all of which are part of Java 17, so none of these raise the requirement. The one exception is a program that uses the java: interop package: the compiler embeds a reflection bridge (compiled with the project's own Java release) into the class, so it needs a JRE at least as new as the one rontolisp was built with.