Menu

Embedding Aussom in a Java Application via JSR 223

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.


1. What is JSR 223?

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.


2. Quick start

2.1 Add the dependency

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.

2.2 Hello world

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.


3. Discovering the engine

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());
}

4. Running scripts

4.1 The eval overloads

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)

4.2 Snippets vs. full programs

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.

4.3 Returning values

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 ...;.


5. Bindings (passing values to and from a script)

A Bindings is just a Map<String,Object>. The engine has two scopes per the JSR 223 spec:

  • Engine scope -- per-engine variables. Set with engine.put(k, v) or engine.getContext().setBindings(b, ScriptContext.ENGINE_SCOPE).
  • Global scope -- shared bindings. Set with 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.

5.1 Reading bindings inside Aussom

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.

5.2 Writing bindings from inside Aussom

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

5.3 Per-eval bindings

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

6. Type marshalling

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.

6.1 Java -> Aussom (input)

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.

6.2 Aussom -> Java (output)

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

7. Routing script output

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.

7.1 Output belongs to the engine, not to a single eval

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.


8. Compilable: parse once, run many

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.


9. Invocable: calling Aussom functions and methods from Java

Invocable is the bridge that lets a host call into Aussom-defined code by name after a script has been loaded.

9.1 invokeFunction: call a method on any class

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).

9.2 invokeMethod: call on a specific receiver

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.

9.3 getInterface: bind a Java interface to Aussom code

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.


10. Errors

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.


11. Threading

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:

  1. Parsing is serialized internally. Concurrent calls into compile or into eval(source-string, ...) (which compiles under the hood) take a per-engine lock automatically. You do not need to wrap them.
  2. Run paths use per-call state. Each eval, invokeFunction, or invokeMethod allocates a fresh stack frame and locals, so running scripts on different threads do not see each other's variables.
  3. Output routing is per engine. All threads running on one 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.
  4. Class definitions are shared. Classes registered by 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.
  5. Static class fields are shared within one engine. Aussom's 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.


12. Limits, stopping a script, and measuring it

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.


13. Common gotchas

A list of things that bite host developers most often. Read this once and keep it as a reference.

  1. 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.

  2. 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.)

  3. 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.

  4. 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.

  5. Aussom integers are 64-bit. They marshal to Long, never Integer. Cast accordingly.

  6. Aussom maps are keyed on String. When you pass a Map<Integer,X> from Java, the keys are stringified.

  7. Strings are double-quoted. Single quotes are for character literals.

  8. 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.

  9. 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.
    
  10. 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.

  11. 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.

  12. 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.


14. End-to-end example: an Aussom-driven rules engine

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.


15. Where to go next

  • JSR 223 reference: https://docs.oracle.com/javase/8/docs/technotes/guides/scripting/prog_guide/api.html
  • Aussom language overview: Language Overview
  • Aussom style guide: Style Guide
  • Engine design notes (for anyone curious about the internals, threading audit, cold-path mitigations): see design/aussom-jsr-223.md in this repository.