aura-lang 0.3.0

Aura configuration language: deterministic, capability-secured, schema-validated configs — embeddable library + the `aura` CLI
Documentation

Aura

A configuration language that reads its own inputs — and never lets an import do the same.

Manifests compile to JSON, YAML or TOML, with schemas, enums, assertions that fail the build instead of the deploy, and a capability model in which env() and read_file() are granted per run — and the grant does not reach imported modules.

One binary, no runtime to install, 16 keywords, a 1.7 MB download.

CI crates.io docs.rs Rust 1.85+ License

Try it in your browser · Documentation · Русская версия

[!NOTE] Aura is a working preview (0.x). All six specification phases are implemented, but nothing has run in production anywhere yet. Until 1.0 a minor release may still change the language, and the syntax is frozen only at 1.0. The changelog lists every change that needs an edit.


Install

Nothing at all

Open the playground. The real compiler, compiled to WebAssembly. Nothing is installed and nothing leaves the page.

A binary

cargo install aura-lang

Or take a build from Releases: Linux (gnu and static musl), macOS (Intel and Apple silicon), Windows — each with a .sha256.

In CI

- uses: aura-config/setup-aura@v1
- run: aura check deploy.aura --strict

Resolves the version, verifies the checksum, puts aura on PATH.

In a Rust program

[dependencies]
aura-lang = { version = "0.1", default-features = false }

Without the default cli feature the library is 38 packages instead of 96, and no C dependencies at all.

[!TIP] On x86_64 Linux prefer the musl build — it is what setup-aura installs. The gnu build requires GLIBC_2.34 and will not start on Ubuntu 20.04, Debian 11, CentOS 8 or Amazon Linux 2. The static build has no such floor, and starts measurably faster.

Sixty seconds

# deploy.aura
base_port = 8000                                  # `=` computes, stays private
is_prod   = env("APP_ENV", "dev") == "production" # capability-gated

# `:` exports — everything under here ends up in the output
api:
  port:     base_port + 1
  replicas: cond
    is_prod -> 6
    else -> 1
  end
end

assert base_port > 1024, "ports below 1024 need root"
$ aura eval deploy.aura --allow-env=APP_ENV
{
  "api": {
    "port": 8001,
    "replicas": 1
  }
}

Note what is not in the output: base_port and is_prod. That is the whole rule — : exports, = does not.

Why Aura

Problem Aura's answer
YAML breaks from one stray space No significant indentation: structure comes from line breaks and an explicit end
Configs "work on my machine" Deterministic by construction: without explicit flags a manifest has no access to files or environment variables
A module pulled from the internet reads /etc/passwd Imported modules are isolated from I/O, and cannot borrow the caller's rights
"Why is prod on a different port?" Values are immutable; shadowing requires the explicit shadow keyword
Dependency version drift in CI Versioned imports and aura.lock with a token-stream integrity hash, plus --frozen
Dead config fragments live for years Static analysis finds unused variables and imports; --strict makes them fail CI
A generated config.json nobody can trace The manifest is the source, and --dry-run reports what it read and would write

The language

Aura's central rule — the same idea as locals versus outputs in Terraform:

tmp = base * 2 #  =  a private variable: does NOT end up in JSON
port: tmp + 1  #  :  a property: ends up in JSON

This is what makes dead-code analysis precise: an unused = variable is always genuine cruft (W0501), never "maybe someone needs this output".

path = "/etc/global.config"

domain "prod"
  path        = "/var/log" # E0302: shadowing requires the keyword
  shadow path = "/var/log" # OK - the intent is explicit
end

Reassigning a name within the same scope is always an error (E0301).

enum Tier
  "frontend"
  "backend"
  "cache"
end

type Service
  name: String
  tier: Tier
  port: Int = 8080 # optional: omit it and the default applies
end

api: new Service
  name: "api"
  tier: "backand" # E0514: did you mean "backend"? members: ...
end

A missing field is E0511, a type mismatch E0512, an extra field E0513 under --strict. Int and Float are separate types, so byte limits and 64-bit IDs never lose precision. An enum member stays an ordinary string — the JSON output does not change, only what is accepted.

Optionality never introduces a null: a default is a value, not an absence.

The schema that validates the manifest can also type the program reading its JSON, so there are no hand-written structs to keep in sync:

aura types config.aura --lang rust   # or ts | go
export type Scheme = "https" | "http";

export interface Endpoint {
  host: string;
  port: number;
  scheme: Scheme;
}

Rust gets a serde struct plus an enum with #[serde(rename)]; Go gets a struct with json: tags and typed constants. Parsing only — the manifest is never evaluated, so no capabilities are involved, and the output is already canonical for rustfmt, gofmt and prettier.

def labels(app) # a def body is an object
  app: app
end

up = (s) -> s.upper() end # a lambda

xs.compact().uniq().map (item, index) ->
  "#{index}: #{item}"
end

Sixty methods across String, Int, Float, Bool, List and Object — the full list is in the method reference. range(n) produces [0 … n-1], which is how you generate N shards or replicas without listing them by hand.

cond takes the first true arm, and the else is mandatory — no branch yields nothing:

tier = cond
  region == "eu" -> "frankfurt"
  region == "us" -> "virginia"
  else -> "singapore"
end

A text … end block is an ordinary multi-line string. Interpolation works; braces inside do not, which is what makes generating nginx configs practical:

entrypoint: text
  #!/bin/sh
  echo "starting #{app_name}"
  exec ./server --port #{port}
end

now() does not exist and never will — an unreproducible config cannot be written. Durations and dates are first-class:

ttl = "1h30m".parse_duration()       # -> 5400 seconds
refresh: (ttl / 3).format_duration() # -> "30m"

If a build timestamp is needed the host supplies it as an input: env("BUILD_TIME", …).

Dot for fields, brackets only for list indices — one operator per operation:

loaded = read_file("./data.json").parse_json()

version:  loaded.package.version          # ordinary keys
port:     loaded.servers."eu west".port   # a key with a space
first:    loaded.apps[0].name             # list index (out of bounds is E0317)
optional: loaded.get("maybe", "fallback") # safe access

A typo in a key is E0308 with a position, not a silent null.

import github/actions/rust-cache@v1.2 as rust # a version is mandatory
import "templates/k8s_defaults.aura" as defaults

Cyclic imports are reported with the full chain. Every module is loaded, parsed and evaluated exactly once. Exact versions and integrity hashes live in aura.lock, and --frozen makes CI refuse anything else.

aura add is the only place Aura touches the network. eval always runs offline, so a result never depends on what a registry served today.

The language reads TOML, JSON and YAML and writes all three, so conversion is one line:

config: read_file("./legacy.toml").parse_toml()
aura eval convert.aura --allow-read=. --format yaml

Unlike yq or jq you can validate against a schema, merge several sources and add assert checks along the way — conversion with guarantees.

Security model

By default a manifest can do nothing: no files, no environment variables. Rights are granted per run, and they do not propagate.

Flag What it allows
--allow-read=<dir> read_file() inside that directory (repeatable; paths are canonicalised, .. cannot escape)
--allow-env[=A,B] env() for the listed variables — with no list, all of them
--allow-imports-io extends the root's rights to imported modules
--hermetic the opposite: no I/O at all, E0505 everywhere; excludes the --allow-* flags

[!IMPORTANT] Grants belong to the root manifest. A module you import cannot call env() or read_file() even when the root holds those rights, and an exported function runs with the capabilities of the module it came from — so isolation cannot be borrowed by persuading you to call something innocent-looking.

A call without a grant is E0310; a grant that does not cover the path is E0311. An effectful call inside an imported module is caught statically as W0512, before anything runs.

--hermetic inverts the question. Rather than granting rights it requires that none are needed — and because E0505 is an analysis error, this is settled without evaluating anything, including for branches a given run would not take:

$ aura check --hermetic deploy.aura
[E0505] Error: env() is not allowed in hermetic mode

Command line

aura eval <file.aura>  [--strict] [--dry-run] [--frozen] [--hermetic]
                       [--allow-read=<dir>] [--allow-env[=A,B]] [--allow-imports-io]
                       [--max-steps=N] [--max-memory=MB] [--stats]
                       [--format json|json-flat|yaml|toml] [-o out.json] [--registry-dir=<dir>]
aura check <file.aura> [--strict] [--hermetic]
aura fmt <files...>    [--check]
aura types <file.aura> --lang rust|ts|go [--out <file>]
aura docs --agent      [-o <file>]
aura add <path>@vX.Y.Z [--from <file>] [--registry-dir=<dir>]
Mode Behaviour
--strict analysis warnings become errors; extra schema fields are forbidden
--dry-run a full evaluation, but nothing is written — you get a report of what it read and would write
--frozen dependencies resolve strictly via aura.lock; a mismatch is an error and the lock is never rewritten
--hermetic settles, statically, that the manifest performs no I/O

Exit codes: 0 success, 1 diagnostics, 2 an I/O or argument error.

Working with an AI assistant

aura docs --agent prints the complete language reference — syntax, standard library, every diagnostic code — assembled from the compiler's own definitions, at roughly four thousand tokens. Small enough to hand over whole, and it always describes the binary that produced it. One line in your repository's AGENTS.md is enough:

Before writing or editing .aura files, run `aura docs --agent` for the complete
language reference.

The same text is published at /llms.txt.

Using Aura from other languages

The CLI contract is the API, in the tradition of terraform, jq and pandoc: JSON on stdout, stable E0xxx codes on stderr, exit codes 0/1/2.

import json, subprocess
r = subprocess.run(["aura", "eval", "app.aura", "--frozen"], capture_output=True, text=True)
if r.returncode != 0:
    raise RuntimeError(r.stderr)
config = json.loads(r.stdout)
const { execFileSync } = require("node:child_process");
const config = JSON.parse(execFileSync("aura", ["eval", "app.aura", "--frozen"]));
out, err := exec.Command("aura", "eval", "app.aura", "--frozen").Output()
if err != nil { log.Fatal(err) }
var config map[string]any
json.Unmarshal(out, &config)

For production: --frozen with a committed lock file, capabilities only explicit, and --format yaml|toml if the consumer prefers another shape.

use aura_lang::facade::{eval_file, EvalOptions};

let opts = EvalOptions { strict: true, ..Default::default() };
let out = eval_file("config/app.aura".as_ref(), &opts)?;
let cfg: MyConfig = serde_json::from_value(out.json)?;

There is no intermediate file: the application reads the manifest itself.

A manifest's pub def is also a callable value, so rules can live in a .aura file and the application can call them per request, changing behaviour without a rebuild:

use aura_lang::Script;

let rules = Script::load("rules.aura")?;            // hermetic: proves it reads nothing
let route: Route = rules.call("route", (path, method))?;

Every call has a budget in steps and bytes, memory per call is constant, and one Script serves every thread. See the embedding guide and cargo run --example embedding.

Mobile applications consume the result: a server or CI evaluates the manifest, the client reads the JSON. A WebAssembly build exists and is CI-tested — it is what the playground runs on — and npm, PyO3 and a C ABI are on demand.

Diagnostics

Errors carry a file, line and column, highlight the code, and suggest a fix:

[E0302] Error: 'global_file_path' shadows an outer variable
    ╭─[ production_deploy.aura:24:3 ]
 24 │   global_file_path = "/var/log/aura.log"
    │   ─────────┬────────
    │            ╰── add `shadow`
    │
    │   Help: write `shadow global_file_path = ...` to make the shadowing explicit
────╯

Every code is stable and documented in the catalogue — a test keeps that list and the compiler in step, in both directions.

Architecture

flowchart LR
    S["source<br/>&'a str"] --> L["lexer<br/>Vec&lt;Token&gt;"]
    L --> P["parser<br/>AST"]
    P --> A["analysis<br/>Vec&lt;Diagnostic&gt;"]
    A --> E["evaluation<br/>Value"]
    E --> O["JSON · YAML · TOML"]

Tokens and the AST borrow the source's memory — nothing is copied along the way.

crates/
├── aura-lang        # the library and the `aura` CLI, one publishable crate
│   ├── lexer/       # zero-copy DFA, newline normalisation
│   ├── parser/      # recursive descent + Pratt expressions
│   ├── analysis/    # dead code, undefined names, shadow rules
│   ├── eval/        # tree-walking interpreter, environments, method registry
│   ├── vfs/         # resolvers, cycle detection, aura.lock
│   └── serialize/   # Value -> JSON/YAML/TOML, Int without precision loss
└── aura-lsp         # the language server, built on aura-lang

Invariants. Zero-copy: neither lexer nor parser copies a string. Determinism: JSON key order is declaration order, and two runs are byte-identical. Immutability: containers live in Arc, so cloning a value is O(1).

Performance

cargo bench -p aura-lang, on the reference manifest:

Stage Result
Lexer 258 MiB/s
Lexer + parser 178 MiB/s
Lexer + parser + resolver 71 MiB/s
Full pipeline (lex + parse + evaluate) 33 µs per manifest

Measured 2026-07-29 on x86_64 Linux. Numbers from one machine are worth what they cost — treat them as an order of magnitude, and re-run them on yours.

How this is verified

Tests the whole suite, on Linux, macOS and Windows
Conformance every example in examples/ driven through the real binary, output diffed against a pinned expectation
End to end packaging/e2e.sh asserts 26 documented claims — exit codes, capability refusals, hermetic mode, output formats, and byte-identical output across two runs
Containers on a tag, those artefacts run on debian:12, ubuntu:22.04, alpine, ubuntu:20.04, and aarch64 under emulation
Miri the source arena, under both Stacked Borrows and Tree Borrows
Fuzzing a cargo-fuzz target per stage: lexer, parser, pipeline, formatter, codegen, resolver, and the JSON/YAML/TOML readers. ClusterFuzzLite runs them nightly with a corpus kept between runs, and on every pull request that touches the compiler
Cross-platform cargo check for freebsd, aarch64-linux, musl and wasm32
Documentation snippets must be canonical aura fmt and name nothing the language lacks; the diagnostic catalogue, the method reference and the decision log must match the compiler, in both languages

The recursive-descent parser is DoS-hardened: deeply nested input yields E0208 rather than a stack overflow, and that is checked in release builds, where there is no thread with a large stack to hide behind.

cargo test --workspace     # units, conformance, property tests, snapshots
cargo bench -p aura-lang   # lexer, parser, resolver, full pipeline

The formal specification, including the numbered design decisions the code refers to by name, is SPEC.md.

How Aura is built

My name is Ilya Brin. I came up with Aura and I develop it, working on the language together with Claude, an AI model by Anthropic.

The idea is an old one. It goes back to when I was writing my first language, in Go. That was when I came across Starlark and thought that computed configuration is a genuinely useful thing. For Go, though, it was overkill: everything compiles fast anyway, and a YAML file or a few constants in the code are enough. With Rust it became clear that I would have to write such a language myself. I have several growing projects and production services that need it. I trusted Claude with writing the code, and I read everything it writes. That way I am also learning Rust from real code rather than from textbook examples.

We have different responsibilities. I am responsible for what the Aura language is: why it exists, what must never be in it, and which rules it keeps. There are only a few:

  • no magic;
  • the same thing always behaves the same way;
  • code reads one way only;
  • the developer's experience matters more than anything else.

Claude:

  • writes the code;
  • tests ideas in practice;
  • looks for places where the documentation has drifted from what the language actually does.

The decisions are mine, and no change reaches the project without my agreement.

Along the way we settled into habits that have proven themselves:

  • One task, one change. Each comes with an explanation: not only what changed, but why we chose this path.
  • We don't take text at its word, we check. If the documentation says the language can do something, we run it and see. That is how we found things you don't notice by reading: the reference was missing half the methods, one example did not compile, and three decisions were mentioned everywhere but written down nowhere.
  • Every bug we find gets a test. And we make sure that without the fix the test really fails. Otherwise it only looks like protection.
  • Hints in error messages are checked too. If the compiler suggests how to fix your code, a test runs that suggestion and makes sure it works.

Commits written by Claude carry a Co-Authored-By trailer, starting with the one that added this section. Earlier ones don't: back then Aura was an internal tool for my own projects.

Status

All six specification phases are implemented. The syntax is still allowed to change, and is frozen at 1.0.

The language and its tooling

  • Zero-copy lexer and Pratt parser
  • Runtime with a capability model, schemas and enum
  • Modules, cycle detection, aura.lock with a token-stream hash
  • Static analysis, --strict, --dry-run, --hermetic
  • JSON, flattened JSON, YAML and TOML output
  • aura fmt — canonical formatting with a token-stream guarantee
  • aura types — Rust, TypeScript and Go from a manifest's schemas
  • aura docs --agent — the whole language for a coding assistant
  • Deterministic time; now() forbidden by construction
  • Language server: completion, hover, go-to-definition, references, symbols, rename, signature help, format-on-save

Ecosystem and distribution

Towards 1.0. Three criteria remain: a frozen syntax, a promise of backward compatibility, and real users whose configs must not break.

Contributing

Issues and pull requests are welcome. Before opening one:

cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
cargo build -p aura-lang && find examples -name '*.aura' -print0 | xargs -0 ./target/debug/aura fmt --check

CI runs exactly those four. The last is the language formatting its own examples. Code comments and commit messages are in English. AGENTS.md records where each fact has its single home — worth reading before adding a second copy of one.

License

MIT (LICENSE-MIT) or Apache 2.0 (LICENSE-APACHE), at your option.

Unless you state otherwise, any contribution you intentionally submit for inclusion shall be licensed as above, with no additional terms.