This guide is for Java developers who want to add Aussom scripting to
a host JVM application. It walks through the standard javax.script
API end-to-end as the Aussom engine implements it: discovery, eval,
bindings, compilation, invocation, I/O routing, threading, and the
gotchas that come from Aussom's syntax differing from JavaScript or
Groovy.
You do not need to know the Aussom interpreter's internals to use it through JSR 223. Everything below is API-level.
JSR 223 is the standard Java specification for "scripting for the
Java platform." It defines a small set of interfaces in
javax.script:
| Interface / Class | Role |
|---|---|
ScriptEngineManager |
Entry point. Discovers engines on the classpath. |
ScriptEngineFactory |
Engine metadata, mints engine instances. |
ScriptEngine |
Per-instance script runner. eval, put, get. |
Bindings |
A Map<String,Object> representing variable scope. |
ScriptContext |
Holds engine-scope and global-scope bindings + I/O. |
Compilable (optional) |
Pre-parse a script for repeated execution. |
Invocable (optional) |
Call named functions and methods after eval. |
ScriptException |
Checked exception for any script-side failure. |
Aussom's engine implements ScriptEngine, Compilable, and
Invocable, so the surface area you have to learn is small.
The big advantage of going through JSR 223 (rather than calling
Aussom's Engine class directly) is that your host code stays
language-neutral. The same code path that runs an Aussom script can
run a Groovy or Kotlin script if you swap which jar is on the
classpath.
aussom-base is published to Maven Central. Add it to your pom.xml:
<dependency>
<groupId>io.github.rsv-code</groupId>
<artifactId>aussom.base</artifactId>
<version>1.2.3</version>
</dependency>
Or in Gradle:
implementation 'io.github.rsv-code:aussom.base:1.2.3'
The jar contains a
META-INF/services/javax.script.ScriptEngineFactory descriptor, so
the standard Java service loader will discover the engine
automatically. You do not have to register anything by hand.
The engine targets Java 8 and uses only the javax.script API that
ships with the JDK. There are no extra runtime dependencies you need
to add.
import javax.script.*;
public class HelloAussom {
public static void main(String[] args) throws ScriptException {
ScriptEngineManager mgr = new ScriptEngineManager();
ScriptEngine eng = mgr.getEngineByName("aussom");
Object out = eng.eval("return \"hello \" + \"world\";");
System.out.println(out); // -> hello world
}
}
That's the whole loop. Anything below this section is detail.
The standard discovery methods all work:
ScriptEngineManager mgr = new ScriptEngineManager();
ScriptEngine byName = mgr.getEngineByName("aussom");
// also: "aus", "Aussom"
ScriptEngine byExt = mgr.getEngineByExtension("aus");
ScriptEngine byMime = mgr.getEngineByMimeType("application/x-aussom");
// also: "text/x-aussom"
Each call to getEngineByName returns a new ScriptEngine
instance. Engines are cheap to construct, so this is fine for most
hosts. Reuse one engine across many evals when you can — see the
threading section for the rules.
To enumerate all engines available on the classpath (for example to check that Aussom is actually loaded):
for (ScriptEngineFactory f : mgr.getEngineFactories()) {
System.out.println(f.getEngineName() + " " + f.getEngineVersion());
}
The ScriptEngine interface gives you six eval overloads. They all
ultimately delegate to eval(String, ScriptContext) or
eval(Reader, ScriptContext):
eng.eval("return 1 + 2;"); // String, default context
eng.eval(new FileReader("script.aus")); // Reader, default context
eng.eval("return x + 1;", aBindings); // String + ad-hoc bindings
eng.eval(reader, aBindings); // Reader + ad-hoc bindings
eng.eval("return 1;", aScriptContext); // String + custom context
eng.eval(reader, aScriptContext); // Reader + custom context
The Aussom engine returns the value from the script's return
statement, marshalled into a Java type (see Section 6). If the script
does not return anything, the engine returns null.
Object n = eng.eval("return 42;"); // -> Long(42)
Object s = eng.eval("return \"hi\";"); // -> String("hi")
Object xs = eng.eval("return [1, 2, 3];"); // -> ArrayList<Object>
Object u = eng.eval("x = 1;"); // -> null (no return)
Aussom is a class-based language: a normal program declares a class
with a main(args) method. The engine is friendly to both shapes.
Snippets. If your source does not declare a top-level class,
the engine wraps it in a synthetic class with a main for you:
eng.eval("return 7 * 6;"); // wrapped automatically
eng.eval("c.log(\"hello\");"); // also wrapped
Full programs. If your source already declares one or more
classes, the engine treats it as a full program. If any of those
classes has a main, the engine runs it. If none does, the engine
just registers the class definitions for later use:
// No main -> just registers Calc; the eval returns null.
eng.eval("class Calc { public add(a, b) { return a + b; } }");
// Has main -> runs main, returns its value.
Object r = eng.eval("class App { public main(args) { return 99; } }");
// r == 99L
This pattern (define classes once with eval, then call methods on
them with Invocable) is the recommended way to expose Aussom-side
business logic to your host. See Section 8.
Inside a snippet, a value flows back to your host via an explicit
return statement. Aussom does not have JavaScript's "last
expression wins" rule, so this:
eng.eval("1 + 2;"); // returns null, NOT 3
eng.eval("return 1 + 2;"); // returns Long(3)
If you need an Aussom expression's value, write return ...;.
A Bindings is just a Map<String,Object>. The engine has two
scopes per the JSR 223 spec:
engine.put(k, v)
or engine.getContext().setBindings(b, ScriptContext.ENGINE_SCOPE).engine.getContext().setBindings(b, ScriptContext.GLOBAL_SCOPE).Engine scope shadows global scope; that is, if both define the same key, the script sees the engine-scope value.
The engine exposes the merged bindings to the script as a single
class member named bindings, of Aussom type map. Read it from
inside main (or any method on the synthetic class) with the
idiomatic this. prefix that Aussom requires for instance members:
eng.put("name", "Ada");
Object r = eng.eval("return \"hello \" + this.bindings[\"name\"] + \"!\";");
// r == "hello Ada!"
This is the most common gotcha to be aware of: it is
this.bindings["name"], not bindings["name"] and not just
name. Aussom requires this. to access an instance member from
inside a method body, and we expose bindings as one such member.
Anything the script writes to this.bindings["..."] is reflected
back into the host's engine scope after the eval completes:
eng.put("counter", 10L);
eng.eval("this.bindings[\"counter\"] = this.bindings[\"counter\"] + 5;");
System.out.println(eng.get("counter")); // -> 15
The script can also create new bindings:
eng.eval("this.bindings[\"created\"] = \"yes\";");
System.out.println(eng.get("created")); // -> yes
If you want bindings that only apply to one eval (without touching
the engine's default state), use the two-argument eval:
Bindings b = new SimpleBindings();
b.put("x", 5L);
b.put("y", 7L);
Object r = eng.eval(
"return this.bindings[\"x\"] + this.bindings[\"y\"];", b);
// r == 12L
The engine converts between Java host types and Aussom runtime types in both directions. You usually do not have to think about it -- pass plain Java values in, get plain Java values back -- but here are the rules.
| Host type | Aussom type |
|---|---|
null |
cnull |
Boolean |
bool |
Byte, Short, Integer, Long |
int |
Float, Double |
double |
Character, String, CharSequence |
string |
java.util.List<?> |
list |
java.util.Map<String,?> |
map |
Java array (e.g. Object[], long[]) |
list |
Any other Object |
opaque object wrap |
Maps with non-String keys have their keys stringified.
| Aussom type | Host type |
|---|---|
cnull |
null |
bool |
Boolean |
int |
Long |
double |
Double |
string |
String |
list |
java.util.ArrayList<Object> |
map |
java.util.LinkedHashMap<String,Object> (preserves insertion order) |
object |
the original externObject if set, else the AussomObject itself |
exception |
translated to a thrown ScriptException |
Note that Aussom integers are 64-bit, so int always comes back as
Long -- never Integer. Cast accordingly:
long sum = (Long) eng.eval("return 2 + 3;"); // 5L
double d = (Double) eng.eval("return 1.5 + 2.5;"); // 4.0
com.aussom.stdlib.console (c) is Aussom's standard output sink.
By default, c.log("..."), c.info(...), c.warn(...), and friends
write to the JVM's System.out. c.err(...) writes to System.err.
You can redirect both by setting writers on the engine's
ScriptContext:
StringWriter out = new StringWriter();
StringWriter err = new StringWriter();
eng.getContext().setWriter(out);
eng.getContext().setErrorWriter(err);
eng.eval("c.log(\"normal message\"); c.err(\"problem\");");
System.out.println(out); // -> normal message
System.out.println(err); // -> problem
Replacing the writer between calls takes effect immediately on the
next eval -- there is no need to rebuild the engine. The same is true
of replacing the whole context with setContext(...). The engine
resolves the current context every time a script writes, so it always
uses the writers you have in place at that moment.
The output destination is a property of the ScriptEngine. It is not
a property of an individual eval call.
This matters for one overload. When you call
eval(script, myContext), the engine uses myContext for
bindings, but script output still goes to the writers on the
engine's own context:
StringWriter mine = new StringWriter();
SimpleScriptContext myCtx = new SimpleScriptContext();
myCtx.setWriter(mine);
eng.eval("c.log(\"hello\");", myCtx);
// mine is EMPTY. The line went to eng.getContext()'s writer.
If you want a caller's output kept separate from another caller's,
give each caller its own ScriptEngine:
ScriptEngine a = mgr.getEngineByName("aussom");
ScriptEngine b = mgr.getEngineByName("aussom");
a.getContext().setWriter(writerForA);
b.getContext().setWriter(writerForB);
That is the right boundary for a second reason: class definitions
registered by eval persist on the engine that ran them (Section
4.2), so two callers sharing one ScriptEngine already share a class
table. One engine per caller keeps both output and definitions
separate. Engines are cheap to construct.
Implementing Compilable lets you save the cost of parsing on
repeated invocations. Cast the engine to Compilable and call
compile:
Compilable comp = (Compilable) eng;
CompiledScript cs = comp.compile(
"return this.bindings[\"x\"] * this.bindings[\"x\"];");
eng.put("x", 7L);
System.out.println(cs.eval()); // 49
eng.put("x", 9L);
System.out.println(cs.eval()); // 81
CompiledScript.eval accepts the same overloads as
ScriptEngine.eval: with a default context, with a Bindings, or
with a custom ScriptContext.
The compiled form is tied to its owning engine. If the host needs
several CompiledScript objects that share state, build them all
from the same ScriptEngine instance.
Invocable is the bridge that lets a host call into Aussom-defined
code by name after a script has been loaded.
eng.eval("class Calc { public add(int a, int b) { return a + b; } }");
Invocable inv = (Invocable) eng;
Object sum = inv.invokeFunction("add", 2L, 3L); // -> 5L
invokeFunction looks for a method named add with a matching
arity on any class registered in the engine. The first matching
class wins. If no class has such a method, NoSuchMethodException
is thrown (per JSR 223, this is not a ScriptException).
If you have a handle to a particular Aussom object, dispatch on it directly:
eng.eval("class Greeter { public hi(string name) { return \"hi \" + name; } }");
Object g = eng.eval("return new Greeter();");
Invocable inv = (Invocable) eng;
String s = (String) inv.invokeMethod(g, "hi", "alice"); // -> "hi alice"
The receiver must be an AussomObject produced by an earlier eval
or invokeFunction. Passing a plain Java object will throw
IllegalArgumentException.
If your host already has an interface like java.lang.Runnable or
java.util.function.Function, you can ask the engine for an
implementation backed by Aussom code:
eng.eval("class Worker { public run() { c.log(\"working\"); return null; } }");
Invocable inv = (Invocable) eng;
Runnable r = inv.getInterface(Runnable.class);
new Thread(r).start();
getInterface(Class) walks every interface method and checks that
some Aussom class declares a method with the same name. If even one
is missing it returns null. The proxy it returns dispatches each
Java call through invokeFunction, so the same arity / name rules
apply.
There is also getInterface(Object thiz, Class) which proxies onto
a specific Aussom receiver (analogous to invokeMethod).
This is convenient for callbacks, but a Java -> Proxy -> Aussom hop is not free; do not put one inside a tight inner loop.
All script-side failures arrive as javax.script.ScriptException (a
checked exception). The engine maps the underlying cause as follows:
| Source | Resulting exception |
|---|---|
| Parse failure | ScriptException with file/line |
| Runtime error (bad call, type mismatch, ...) | ScriptException with message |
Invocable lookup miss |
NoSuchMethodException (per JSR 223) |
getInterface non-interface argument |
IllegalArgumentException |
The engine internally subclasses ScriptException as
com.aussom.script.AussomScriptException when the failure came from
the Aussom runtime. If you need to introspect the Aussom-side stack,
cast and call getAussomException(). For most hosts, the message
text is enough.
A failed parse leaves the engine in a clean state. The next eval
on the same engine starts fresh -- you do not need to rebuild it.
ScriptEngineFactory.getParameter("THREADING") returns
"MULTITHREADED". In JSR 223 terms that means: a single
ScriptEngine is safe to call from multiple threads
concurrently, as long as the host does not modify shared scopes
without synchronizing.
The engine's threading guarantees in detail:
compile or into eval(source-string, ...) (which compiles
under the hood) take a per-engine lock automatically. You do not
need to wrap them.eval, invokeFunction,
or invokeMethod allocates a fresh stack frame and locals, so
running scripts on different threads do not see each other's
variables.ScriptEngine write to that engine's context writers. Routing is
not per thread and not per call, so a ScriptContext you pass to
eval supplies bindings but not output (Section 7.1). Concurrent
evals interleave their output into the same writer; no lines are
lost or delivered to another engine's writer.eval
stay on that engine and are visible to later evals on any thread.
This is what makes the define-once, invoke-many pattern work, and
it also means one ScriptEngine is one trust boundary. Give each
caller its own engine if they should not see each other's classes
or output.static classes -- including the stdlib ones such as c, sys and
math -- get one instance per engine, not one per JVM. Every
thread running on that engine sees the same instance, so if a script
mutates a static class field from several threads it is the
script's job to coordinate; the engine does not synchronize
individual field writes. A second ScriptEngine has its own
instances and cannot see those fields at all.What we do not advertise:
THREAD-ISOLATED: would require per-thread Bindings views.
Hosts that need thread-isolated bindings should pass a fresh
ScriptContext to each eval (see Section 5.3).STATELESS: incompatible with Aussom's static-class model.Two patterns hosts commonly use safely:
// Pattern A: single engine, many threads, per-call bindings.
// Each call gets its own inputs; all output goes to eng's writers.
ScriptEngine eng = new ScriptEngineManager().getEngineByName("aussom");
Runnable task = () -> {
SimpleScriptContext ctx = new SimpleScriptContext();
Bindings b = new SimpleBindings();
b.put("n", Thread.currentThread().getId());
ctx.setBindings(b, ScriptContext.ENGINE_SCOPE);
Object r = eng.eval("return this.bindings[\"n\"] * 2;", ctx);
// ...
};
// Pattern B: one engine per thread or per caller. Use this when
// callers must not share output or class definitions.
ThreadLocal<ScriptEngine> per = ThreadLocal.withInitial(
() -> new ScriptEngineManager().getEngineByName("aussom"));
Both work, and the choice is about isolation more than performance.
Pattern A uses less memory simply because there is one engine: each engine parses its own private copy of the standard library, costing about 0.81 MB of heap and 7 ms to build. Nothing is shared between engines -- not the parsed standard library, not static class fields, not output. Pattern A is the right shape when the callers are parts of one application and may see each other's classes.
Pattern B is the right shape when callers should not see each other's output or class definitions -- separate tenants, separate users, untrusted scripts. It also avoids contention on the parse lock if you compile a lot. The per-engine cost is small enough that a few hundred engines in one JVM is unremarkable; a fleet test held 20,000 idle engines in 16 GB. What it does cost is startup time, which does not improve much with more threads, so build engines lazily rather than all at once.
javax.script has no vocabulary for "stop this script" or "how much
memory is that script holding", so these live on the Aussom Engine
underneath. Reach it with getAussomEngine():
import com.aussom.Engine;
import com.aussom.script.AussomScriptEngine;
ScriptEngine se = new ScriptEngineManager().getEngineByName("aussom");
Engine eng = ((AussomScriptEngine) se).getAussomEngine();
Everything on that handle works the same way it does for a direct embedder, and the standard embedding guide covers it in full detail in its Section 13. The short version:
Bounding a script before it runs. Four numeric properties on your security manager, each with a sensible default:
| Property | Default | What it bounds |
|---|---|---|
aussom.limit.call.depth |
1000 | How deep Aussom calls may nest |
aussom.limit.regex.steps |
0 (off) | Characters of subject one regex may read |
aussom.limit.sleep.slice |
50 (ms) | How long a sleeping script runs between control checks |
aussom.limit.source.bytes |
0 (off) | Largest source file the engine will parse |
Setting these through JSR 223 takes one extra step, because a
ScriptEngineManager gives you an engine built with
DefaultSecurityManagerImpl and no way to pass your own. Subclass the
factory and override getScriptEngine():
public class HostAussomFactory extends AussomScriptEngineFactory {
@Override
public ScriptEngine getScriptEngine() {
try {
Engine eng = new Engine(new HostSecurityManager());
eng.addResourceIncludePath("/com/aussom/stdlib/aus/");
return new AussomScriptEngine(this, eng);
} catch (Exception e) {
throw new RuntimeException("failed to build engine", e);
}
}
}
AussomScriptEngine's constructor is package-private, so a factory
subclass has to live in com.aussom.script. If you would rather not put
a class there, use the direct Engine API instead -- at this point you
are past what JSR 223 was designed to express.
The call depth limit is worth setting for scripts you did not write.
Runaway recursion always ends as an Aussom exception rather than a raw
Java error, but there are two endings: CALL_DEPTH_EXCEEDED, which the
script can catch, and STACK_OVERFLOW, which it cannot because the
engine converts it as the program unwinds. Which one you get is a race
between the limit and the JVM stack. Measured on a simple recursive
method, limits up to 300 gave the catchable one and 500 lost to the
stack, so set it in the low hundreds if the recoverable failure is what
you want.
Stopping a script. eval blocks, so cancellation has to come from
another thread:
Future<?> f = pool.submit(() -> se.eval(userScript));
try {
f.get(30, TimeUnit.SECONDS);
} catch (TimeoutException te) {
eng.cancel(); // unwinds the script promptly
f.get(); // now returns; the script threw
eng.clearCancel(); // sticky: clear before reusing this engine
}
The script cannot swallow the cancellation in a try/catch, and the
engine notices at every loop back edge, every function call, every
sleep, and every batch of characters a regex reads. What it cannot do is
interrupt your own Java code: if the script is blocked inside one of your
extern methods on a socket read, the cancel takes effect when that method
returns. Note also that Future.cancel(true) on its own does nothing
here -- the engine deliberately does not treat a thread interrupt as a
reason to stop -- so pair it with eng.cancel().
Measuring. eng.getCpuNanos() and eng.getAllocatedBytes() are
cumulative totals across every thread that has run this engine's code,
including compilation; eng.resetAccounting() turns them into
per-request numbers. eng.measureRetainedFootprint() estimates what the
script is holding right now, and returns -1 unless the engine is
between scripts or fully paused -- pause with eng.pause() and
eng.awaitPaused(...), read, then eng.resume().
One caution specific to this layer: a Compilable engine accumulates
class definitions as you compile, and a long-lived engine holds all of
them. If you compile a lot of one-off scripts, prefer a fresh engine per
tenant (Pattern B in Section 11) and let the old one be collected.
A list of things that bite host developers most often. Read this once and keep it as a reference.
this.bindings["x"], not bindings["x"].
Aussom requires the this. prefix to access an instance member
from inside a method body. The engine exposes host bindings as
one such member.
Local variables in Aussom are untyped.
int x = 1; is not valid as a local declaration. Write
x = 1; -- the type is inferred from the value. (Typed
declarations are allowed for class members and method parameters,
not for locals.)
for (item : list), not foreach.
Aussom's iteration syntax uses a colon:
for (v : this.bindings["xs"]) { sum = sum + v; }
There is no foreach keyword.
You must return an expression to get its value back.
Aussom does not auto-return the last expression. Without an
explicit return ...;, eval gives you null.
Aussom integers are 64-bit.
They marshal to Long, never Integer. Cast accordingly.
Aussom maps are keyed on String.
When you pass a Map<Integer,X> from Java, the keys are
stringified.
Strings are double-quoted. Single quotes are for character literals.
c.log is the print function.
print, println, info, dbg, warn, and err are all
available on c (the console static class). err is the only
one that goes to the error writer; the rest go to the regular
writer.
Class definitions persist across evals on the same engine.
eng.eval("class Calc { public add(a,b) { return a + b; } }");
// Calc is now available for any future eval / invokeFunction
// call on this engine. To start clean, build a new engine.
Synthetic eval classes accumulate per engine.
Every eval of a snippet adds one synthetic class to the engine's
class registry. Long-lived hosts that evaluate many distinct
snippets per second should compile via Compilable.compile
(which reuses one class per compiled script) or recreate the
engine periodically. The total memory footprint is small per
class, but it does grow.
Cold-path class instantiation.
This layer instantiates every class a parse just added, once, before
returning -- not only classes containing main -- so eval and
compile already cover anything they registered. The reason is to
avoid racing on the setup of inherited members the first time a
class is instantiated. A stress test on current code could not
reproduce a failure, so treat a manual warm-up as cheap insurance
rather than a required step. If you build classes some other way and
intend to hit them concurrently, invoke any method on the class once
on a single thread first.
Compilable does not freeze the engine's class list.
A CompiledScript holds a reference to a synthetic class name
in the underlying engine. If you call
Compilable.compile(badSource) after compiling a good one, the
good one keeps working -- but a new bad parse on the same
engine still throws. Errors in one compile do not invalidate
the others.
A practical pattern: load a set of Aussom rules at startup, then
call into them from a hot path with invokeFunction. This is what
JSR 223 was designed for.
import javax.script.*;
import java.io.*;
import java.util.*;
public class RulesDemo {
public static void main(String[] args) throws Exception {
ScriptEngineManager mgr = new ScriptEngineManager();
ScriptEngine eng = mgr.getEngineByName("aussom");
// Load the rules definitions once at startup.
eng.eval(new FileReader("rules.aus"));
Invocable inv = (Invocable) eng;
// Apply the rules to a stream of events.
List<Map<String,Object>> events = new ArrayList<>();
events.add(Map.of("kind", "login", "user", "alice"));
events.add(Map.of("kind", "purchase", "amount", 99L));
for (Map<String,Object> ev : events) {
Object verdict = inv.invokeFunction("evaluate", ev);
System.out.println(ev + " -> " + verdict);
}
}
}
rules.aus might look like:
class RuleSet {
public evaluate(map event) {
if (event["kind"] == "purchase" && event["amount"] > 100) {
return "review";
}
return "allow";
}
}
The host code knows nothing about Aussom internals. It just calls the standard JSR 223 API, and the engine takes care of parse, dispatch, and marshalling.
design/aussom-jsr-223.md in this repository.