A statically typed scripting language, embedded in Rust. Scripts and
templates share one compiler: types are inferred from use, state the host
keeps is named as @context values, and functions the host registers from
Rust are called like any other. The compiler lowers to an SSA form,
optimizes, and validates the result before a register machine runs it.
cargo run -p acvus-cli -- run script.acvus [name=literal]... [--space S] [--parallel] [--opt none|full] [--time] [--stack MiB]
cargo run -p acvus-cli -- check script.acvus [--json] [--opt none|full] [--time] [--stack MiB]
cargo run -p acvus-cli -- mir script.acvus [--json] [--opt none|full] [--time] [--stack MiB]
cargo run -p acvus-cli -- ops script.acvus [--json] [--opt none|full] [--time] [--stack MiB]A .acvus file is a script, a .acvt file a template; both go through the
same stages. name=literal binds the input $name to a value written in
the script's own syntax: the whole graph is compiled with that value, the
code it decides against is removed, and a $ still unbound stops run
with the binding to give. check lists the $ inputs a source still
requires, each with its type.
A script that names an @context runs in a space, which acvus ctl keeps:
the space holds its scripts, one init per context, and the contexts
themselves. A context's type is what the space's scripts and its init solve
it to; its first value is its init, run when a run fetches the context and
the space lacks it.
acvus ctl use work # a ctl context: names spaces
acvus ctl space add notes dir:notes # map a space to a directory
acvus ctl space add-script notes turn.acvus # the space keeps the source
acvus ctl space init notes log -e 'deque()' # the init of @log
acvus run turn --space notes # fills @log from its init, runs, commits
acvus ctl space fill notes # runs every init the space lacks
acvus ctl space ls notes # scripts, inits and contexts--space names a space, or acvus ctl space mark <space> names it for
every command under the working directory. acvus ctl with no arguments
lists its commands.
Each command stops where its job stops. check and mir run parse,
typecheck, lowering, optimization and validation: check prints nothing,
mir prints the optimized program. ops and run add the interpreter's
prepare: ops prints the operations it prepares, main and every closure
body, block by block, and run executes them. A program check admits
reaches the machine: a field no path stores,
a result that is or holds a reference, and a type the solve leaves open are
all refused by check, with a diagnostic.
--opt picks how hard the compiler works. full, the default, runs every
pass; none runs only what a program needs to reach the machine at all, so
mir --opt none prints the program the source wrote -- a loop's invariants
still inside its body. Which programs are refused does not move with the
level: the move, borrow and exhaustiveness checks and the validator run at
both, and acvus-interpreter-test/tests/differential.rs runs the corpus at
both levels and compares the values.
A diagnostic is one error: <message> line, then the file, line and column,
the source line and a caret under the span. A refusal whose story needs a
second place carries a label there: that line too, with --- under the
span and the label's words after it, in source order, the lines between
elided with .... A label with no place of its own is a = help: line.
--json puts them on stdout instead, as an array of {severity, message, path, line, col, span, labels}, each label {line, col, span, text} — the
span is [start, end] in bytes, and line, col and span are null
where the failing stage has no span to give. Under --json every byte on
stdout is JSON: check and mir print the array alone, ops prints its
listing where it has one.
--time adds, after everything else the command printed, one line per
stage it ran:
time: compile 1.284 ms at opt full (parse 0.112, typeck 0.731, lower 0.201, optimize 0.240)
time: prepare 0.318 ms
time: run 42.907 ms
check and mir report compile alone, ops adds prepare, run all
three. compile is the whole of check, of which parse, typechecking,
lowering and optimization are the named parts; run is the machine and
nothing around it, so a script's own print is inside it, and so are the
loads, stores and inits its fetches and commits make; the space's commit
after the run is not. The lines go to stderr; under --json
they are a trailing {"time": …} object on stdout instead, the same
milliseconds as numbers. Without the flag no clock is read.
acvus compiles and runs on a thread of 64 MiB, and every thread of its
runtime has the same stack; --stack <MiB> sets them all. A recursion
deeper than its thread's stack admits traps with call depth past the machine's bound, so a larger stack admits a deeper one.
A run that fails prints one error: <message> line: a trap names the
operation in Rust's words (attempt to divide by zero), and a context the
space can neither give nor fill names the command that adds its init. A
line that reads error: acvus panicked: … is a fault in acvus itself, not
in the script.
Exit status: 0 success, 1 a diagnostic, 2 a refusal at run time, 64
a usage error.
let count = 0;
let it = as_iter(&@items);
while let Some(item) = next(&mut it) {
if item.active {
count = count + 1;
};
}
let label = if count > 10 { "many" } else { "few" };
label
-
let x = expr;binds;x = expr;assigns an existing binding. A block ends with the value of its last expression. -
if,if let,while,while let, andmatch:match shape { Shape::Circle(r) => { 3.14 * r * r }, Shape::Square(s) => { s * s }, }A
matchmust be exhaustive;_is the catch-all. Variants need no declaration — writingShape::Circle(r)introduces it. -
OptionandResult:Some(x)/None,Ok(x)/Err(e), and?to return early onNoneorErr. -
Containers:
vec([]),push,pop,len,v[i](au64index),as_slice. Iterators:as_iter,next,map,filter,fold,sum,collect, and the pipe form below. -
expr as Tconverts between numbers — the eight integer widths andf64— with Rust'sasvalues:i as f64,n as u8,x as i64. -
A literal can say its own type:
10u64at a width,'c'achar(one Unicode scalar value),b"GET"anArray<u8, 3>,b'G'au8. -
References:
&xand&mut xborrow; a value passed by value moves. Use after move is a compile error. -
//comments to the end of the line, in a script and inside a{{ }}tag. There is no block comment. -
print(s)writes one line to stdout while the script runs; the result line follows everything it printed. -
acvus runregisters the standard library, regular expressions (regex,is_match,find,captures,named,replace_all), dates (parse_date,format_date,timestamp,add_days),print, and HTTP (get_text, aClient, requests and responses). Each module is described underdocs/std/.
@items | filter(|x| -> x.active) | map(|x| -> x.name) | join(", ")
a | f(b) is f(a, b). Lambdas are |args| -> expr. The same
expression language runs inside a template's {{ }}.
A template is a script with one extra rule (RFC-0071): a line that does not
begin with % is text, appended to the result as written. A line whose
first non-blank character is % is one statement of the script grammar,
without its ; and without block braces, and % end closes what it opened.
Hello, {{ &@name }}!
% if @language == "korean"
한국어로 답변합니다.
% else
Responding in English.
% end
% for item in &@items
- {{ &item.name }}: {{ item.value.to_string() }}
% end
A text line carries its newline; one ending in \ does not, and one
beginning with %% writes a single %. {{ expr }} inside a text line is
the format string a script already writes, and // inside a tag comments
out the rest of its line. Inline branching is the expression grammar's:
{{ if c { "a" } else { "b" } }}.
Every value has a static type; none is written in a script. @items's
type is solved from every script and init that reads or stores it, a
lambda's from its use, a literal's from itself. Objects are structural —
{ name: String, age: i64 } is a type, and any value with those fields
has it. Effects are part of a
function's type: a call that reaches outside the program (IO, an LLM,
a heavy computation) is known to the compiler, and independent such
calls run concurrently without the script saying so.
A Rust function becomes a script function with one attribute; a registry groups them under a namespace.
use acvus_extern::{extern_fn, extern_registry, Registry};
use acvus_interpreter::AcvusRuntime;
#[extern_fn(effect = pure)]
fn add(a: i64, b: i64) -> i64 {
a + b
}
let registry: Registry<AcvusRuntime> = extern_registry! {
ns: "math",
fns: [add],
};The script calls add(10, 32) or math::add(10, 32). effect states
what the function does (pure, or an effect the compiler must order);
heavy marks a computation to run on a worker thread; async fn marks
IO. A #[state] parameter carries Rust state into the function.
A Rust program runs scripts through acvus_interpreter::Host. It names
each entry with the Rust types of its $ inputs and its result, gives
each context its init, and compiles them all as one graph, so a context
has one type in every entry.
use acvus_extern::TyArg;
use acvus_interpreter::{
AcvusRuntime, Host, HostError, InputShape, Inputs, MemoryStorage, SequentialExecutor, Source,
};
#[derive(TyArg)]
pub struct Visit {
who: String,
}
async fn run() -> Result<(), HostError> {
let program = Host::new(acvus_ext::std_registries::<AcvusRuntime>())
.init("seen", Source::Script("vec([])"))
.entry::<Visit, String>(
"visit",
Source::Script(r#"@seen.push($who.clone()); "hello, {{ &$who }}""#),
)
.entry::<(), u64>("visits", Source::Script("@seen.len()"))
.entry_shaped::<i64>(
"plus",
InputShape::new().field::<i64>("a").field::<i64>("b"),
Source::Script("$a + $b"),
)
.compile(SequentialExecutor)?;
program
.scope(async |scope| {
let mut storage = MemoryStorage::new();
let mut page = scope.open(&mut storage);
let visit = scope.entry::<Visit, String>("visit")?;
let greeting = visit.run(&mut page, Visit { who: "ann".to_string() }).await?;
assert_eq!(greeting.with(|s: &str| s.to_owned())?, "hello, ann");
let visits = scope.entry::<(), u64>("visits")?;
assert_eq!(visits.run(&mut page, ()).await?.with(|n: &u64| *n)?, 1);
let plus = scope.entry_shaped::<i64>("plus")?;
let sum = plus.run(&mut page, Inputs::new().set("a", 1i64).set("b", 2i64)).await?;
assert_eq!(sum.with(|n: &i64| *n)?, 3);
page.commit().await
})
.await
}- An entry's inputs are a shape of named fields: a
#[derive(TyArg)]struct (entry::<I, R>),()for none, or a shape built from data (entry_shaped::<R>with anInputShape, run withInputs). A$the entry reads that its shape lacks is refused at compile, andInputsis checked field by field before any value crosses. - No one declares a context's type: the graph solves it from every body
that reads or stores it. Its first value is its init, a source (
init) or a Rust function (init_with), run at the first load that finds the storage without it. - A result or a context is read only by lending it to a closure whose
parameter is typed as an extern handler's is:
Output::with, andPage::withandwith_mutfor a context.Page::insertstores a Rust value at the context's solved type. A type that differs is aHostErrorbefore any value is touched, and so are a script's trap and a storage's failure. - A page reads and writes its storage exactly where a run fetches and
commits.
MemoryStoragekeeps holders in memory andSpaceStoragein a space;Host::async_accesscompiles for anAsyncStoragewhose access can wait. - An entry's name differs from every function a script calls by a bare
name: the registries'
countandaddmake an entry of either name a refusal. bind(name, literal)fixes$namefor the whole graph, which is whatacvus run'sname=literaldoes.- Natively a recursion traps before the stack of the thread it runs on runs out, so how deep a script may recurse is set by the threads the embedder compiles and runs it on.
- The host reads no clock, so it compiles on
wasm32-unknown-unknownas it does natively;acvus-interpreter/examples/clockless_host.rsruns one under node.
acvus-utils the interner and the shared small types
acvus-ast parser: template, script, expression
acvus-mir types, inference, SSA lowering, optimization, validation
acvus-interpreter the register machine, and `Host`, the embedding surface
acvus-extern the Rust-side ABI: values, ownership, registries
acvus-extern-macro `#[extern_fn]`, `extern_registry!` and the derives
acvus-ext the standard library
acvus-ext-net HTTP
acvus-lsp language server
acvus-cli `acvus run | check | mir | ops | ctl | lsp`
acvus-mir knows nothing about the interpreter; acvus-interpreter
depends on it, never the reverse.
Each directory under examples/ holds main.acvus, the
init of each context it reads under inits/, and the expected.txt it
prints:
acvus ctl space add collatz dir:collatz-space
acvus ctl space add-script collatz examples/collatz/main.acvus
acvus ctl space init collatz start -f examples/collatz/inits/start.acvus
acvus run main --space collatzcollatz the Collatz walk of a context number: `while`, and `if` as a value
grades objects in a context array: `for s in &@students`, and `map | filter | fold`
word-count `split_whitespace`, `lower`, counts in a `HashMap`, the top three by count then bytes
log-parse log lines cut by one regex's named groups: a tally per level, and the span in seconds between the first and last timestamp
shapes a structural enum, an exhaustive `match`, a `Result` per entry carried out by `?`
ledger money as whole cents: a running balance and the largest debit, no float
queue a `deque` as a work queue: `push_back`, `pop_front`, and the order out
acvus-cli/tests/examples.rs runs each of them and compares stdout to
expected.txt byte for byte.
Design decisions are RFCs under docs/rfcs/.
Free to use, copy, modify and distribute; not to be sold as a product or service. See LICENSE.