Docs

Language reference

The language itself, section by section. The other docs pages cover the memory model, the annotations shipped with the compiler and the standard library, the application home folder and migrations, the policy and permission model, and plugins.

Introduction

ADM is a statically-typed, multi-paradigm language for applications, services, and UI. Every program is one of four things - an application, a library, a plugin, or a module - and all four follow the same syntax and rules.

Three ideas carry through the rest of the language:

  • Structured. Structs, enums, unions, interfaces, and datatypes describe data. Functions and transactions describe behavior.
  • Expressive. Pattern matching, string interpolation, async/await, channels, and error handling (fail, onerror, expect/provide) are part of the core syntax.
  • Native. Applications, libraries, plugins, and modules all go through the same compilation pipeline.

Compiler

Everything goes through one CLI, adm.

ADM compiles for every platform and architecture its LLVM backend targets, not just the host machine. adm build --os currently accepts linux, windows, darwin, android, ios, and wasm; --arch recognizes amd64, 386, arm64, arm, riscv64, ppc64le, ppc64, s390x, and mips64 (defaulting to the host's own when left unset). Cross-compiling to one of these just picks the matching LLVM target triple.

--os wasm builds for the web: a page, a small JavaScript loader and a WebAssembly module that any web server serves, with the C library and the fonts it needs carried by the compiler. adm run --os wasm opens it in the browser and adm test --os wasm runs the suites in a headless one. Tasks run concurrently on the page's one thread; files live in the browser's storage; HTTP goes through fetch and WebSockets through the browser's; a UI program draws in the page's canvas through WebGL 2. The Browser service reaches what only a page has: the address and history, metadata, cookies, storage. Raw sockets, processes, plugins and devices are not there.

Every command and subcommand, with its flags, is on the Command line page; adm <command> --help prints the same for the installed version. In short: build, check, run, test, fmt and doc work on the source tree; app, audit, lib and list on the project and its manifest; get, install, publish, search and vendor on packages; doctor, targets, update and version on the installation itself.

Syntax Basics

Whitespace is insignificant except inside literals - no semicolons at the end of statements, no significant indentation. Source files are UTF-8; a byte order mark at the start of a file, as Windows editors write one, is ignored.

Comments

Line comments run to the end of the line; block comments nest arbitrarily, so commenting out a region that already contains a comment works:

comments.adm
// single line comment

/*
 * block comment
 * spanning lines
 */

/* outer /* inner */ still commented */

A run of // lines directly above a declaration is its doc comment. Start it with the name and say what the thing does, what it takes, what it returns and when it fails. adm doc, the hover card and the IDE's Documentation tab show it, and the IDE renders it in place with the gutter pencil to switch back to the raw lines.

A fenced block, three backticks on their own comment line before and after, is a code block shown in a grey box with highlighting. Tags at the end of the comment, one per line, are shown as rows: every @author joined under Author, each @see a link to that symbol under See Also, @since, @deprecated, and any other @word under its own name. A tag's text runs on until the next tag; tags come after the description, never inside it.

doc-comments.adm
// Groups the digits in threes: `1,234,567`; `-1,000`; `999` unchanged.
// `separator` replaces the comma.
//
// Example usage:
// ```
// comma(1234567) == "1,234,567"
// comma(-1234567, " ") == "-1 234 567"
// ```
// @author Ada Lovelace
// @see words
// @since 1.0
def comma(n int64, separator string = ",") string {

A block whose language is example is lifted out of the description instead, titled by the line above it, and listed under Examples by adm doc and the Documentation tab. A plain block is the better default.

Operators

OperatorPurpose
+ - * / % Arithmetic. + also concatenates strings and arrays, and merges maps (always into a new value).
**Exponentiation - a ** b raises a to the power b.
.*Element-wise matrix multiply (see Arrays & Slicing).
+= -= *= /= %= Compound assignment - lhs = lhs op rhs.
& | ^ ~ ! Bitwise AND / OR / XOR / NOT. Arrays reuse these as set operations.
<< >>Logical shifts; signedness is respected.
<<< >>> Bit rotations, with compound forms <<<= and >>>=.
&& || !Short-circuiting logical operators.
< <= > >= == != Comparisons - work across numeric primitives, strings, chars, and enums.
cond ?? (a, b) Conditional choice; only the taken branch is evaluated (see Conditionals).
:=Conditional junction - reuses the left operand across a chain (see Conditionals).
isType test, used for narrowing (see Type Narrowing).

A type can overload most of these for its own instances with an infix method - see Types โ†’ Methods.

Assignment

Several names can be assigned at once, which makes swapping values a one-liner - the right side is fully evaluated before anything is written:

multi_assign.adm
let a = 1
let b = 2

a, b = b, a  // a is 2, b is 1

++ and -- increment and decrement in place, and the usual unary operators apply to values:

unary.adm
let x int = 3
let y = -x   // negate
let w = ^x   // bitwise NOT

let ok bool = true
let nope = !ok

let counter = 0
counter++
counter--

Assignment is an expression, so it yields the value that was just written - handy inside a call or a condition. Compound assignment and slice assignment behave the same way:

assign_expr.adm
let x int = 0
println((x = 7))    // 7
println((x += 1))   // 8

let a = [1, 2]
println((a[0] = 9))    // 9
println((a[:] += 1))   // every element incremented

Program Structure

Every ADM source file uses the .adm extension. A single file can hold any number of applications, libraries, plugins, and modules - there's no one-file-one-thing rule.

Any of those four can be split across multiple files with the partial modifier, so one large application or module doesn't have to live in one large file. All the fragments for the same partial thing have to sit in the same directory; the compiler stitches them back together before type checking runs.

The directory a file lives in has nothing to do with its namespace, though. A module's name comes from its own declared identifier - module std.net.http is always std.net.http, regardless of which folder or filename it's written in. Folders are just how you organize files on disk.

Applications

An application block defines an executable target. Applications live at file scope, support annotations (@manifest, @allowDowngrade, etc.), and may be split with partial application as long as all fragments share the same identifier and directory.

main.adm
@annotation()
application MainApp {

	def new(args string[]) int {
		print("Hello, World")
		return 0
	}

	def dispose() {
		cleanup()
	}
}
  • The constructor def new(args string[]) int is the entry point; the integer return value becomes the process exit code.
  • def dispose() is optional and runs when the runtime shuts the application down.
  • Application bodies may declare properties and methods, like a regular type - but applications cannot have generics, where clauses, or infix methods, because they are never instantiated by the user.
  • Members cannot be marked internal; applications are always public compilation units.
  • Multiple application declarations in one workspace generate multiple binaries. An unnamed application inherits the filename and must be compiled explicitly - it cannot be spread via partial.
  • Application names must be unique across the workspace, and are a single identifier - no dots, unlike a module's name.

A project can define any number of applications. adm build with no arguments builds every application it finds; pass one or more names to build only those. A monorepo with a CLI, a daemon, and a worker can build any one of them, several, or all of them, in a single invocation.

An application's name is also the build output's filename, sanitized down to letters and digits (with .exe appended on Windows).

adm.toml Manifest

An application can carry an optional adm.toml file - a TOML manifest at the project root, keyed by the ADM application name. Run adm app init to generate one: it writes the manifest and also creates an Ed25519 signing keypair, putting the public key in the manifest and the private key in your config directory (or a path you choose with --private-key-out). The private key should never be committed.

None of it is required. Without a manifest the application still has an identity: __app_name__ and __app_id__ are the declared application name (the file name for an anonymous application { }) and __app_version__ is 0.1; @manifest(name = "myapp", version = "1.2.0") on the application block sets both without a file. The update constants resolve to empty strings. A adm.toml wins over all of that; when present, it sets:

  • Identity - the publisher's public key and a repo salt, combined with each app's slug (or its application name) to derive stable publisher_id/app_id values that survive renames.
  • Update metadata - a feed URL, channel, and artifact name, set globally or per app. These only reach your program as compiler constants (__update_feed__, __update_channel__, __update_artifact__) - nothing in the toolchain fetches or applies updates on its own.
  • Packaging - a version plus a list of files to bundle, consumed by adm package build to produce a release archive.

A family of commands works with the manifest once it exists, all on the Command line page:

  • adm app info / adm app id - print what the manifest configures and the identity it resolves to.
  • adm app set / adm app unset - write or remove one value, keeping the rest of the file; adm app bump major|minor|patch bumps a version.
  • adm app rekey - a new key pair and salt in place.
  • adm app sign / adm app verify - sign a built artifact with the publisher key, then verify it later.
  • adm app policy init|set|unset|get|log - the application's policy file and decision log.
  • adm package build / adm package install - build a release archive from the packaging rules and install it locally.

Every value the manifest can set has a matching compiler constant: __app_name__, __app_id__, __app_version__, __publisher_id__, __publisher_pubkey__, __plugin_policy__ (the [plugins] table: whose plugins load and what they may be given), plus the three update fields above. There's also an __build_id__ constant that's always set and changes on every build - don't use it as a stable identity.

Libraries & Plugins

Libraries and plugins share code without an entry point of their own - no def new, nothing runs them directly. The difference is how they reach a program:

  • library is shared as source: adm build packs its exported modules into a signed .admlib, published through the registry and compiled together with the program that uses it. See Libraries.
  • plugin is packaged: adm build produces one .admplugin file holding the plugin's metadata, its export index and a native shared object per platform, which a host loads at runtime through the plugin manager. See Plugins.

Both live at file scope, both can be partial, and neither can be marked internal - like applications, an unnamed one inherits its filename. The block name is the unit's identity and the module prefix it owns; what a package ships, what a consumer may use, and how shared code is split are covered under Libraries โ†’ What ships.

geometry.adm
library Geometry {
	use std.math

	def area(radius float) float {
		return math.Pi * radius * radius
	}
}

A plugin states its public surface with an export list, using the same selectors as use; everything else in it stays private. The compiler checks that every exported symbol exists, is not internal, and that its signature only mentions exported types. @manifest(version = ...) sets the plugin's version:

imagetools.adm
@manifest(version = "1.2.0")
plugin ImageTools {
	use (
		acme.imaging
		acme.imaging.filters
	)

	export (
		acme.imaging.filters              // every non-internal symbol of the module
		acme.imaging::(Image, decode)     // a subset
	)
}

adm build --platforms linux-amd64,linux-arm64,darwin-arm64,windows-amd64 builds the plugin for each platform listed and bundles them all into the one package; without the flag only the host platform is built. The Plugins page covers the package layout, adm doc --plugin and the shared runtime.

adm build --libs or --plugins narrows a build to just those targets, the same way naming an application does; adm lib manages libraries and dependencies once you have some.

Modules & Namespaces

Modules are source containers that group symbols under a qualified name. They never produce a binary or library on their own - their contents are pulled into whichever application, library, or plugin imports them.

module.adm
module my.project.name {
	// module code
}
  • Module names can be simple identifiers (module utils) or dotted namespaces (module std.net.http). A file may host multiple modules.
  • internal module hides the module outside its package; partial module lets you split it across files in the same directory.
  • Module bodies may contain use statements, constants, functions, types, structs, enums, unions, interfaces, datatypes, services, components, views, and styles.
  • Modules cannot declare other top-level units - application, library, plugin, or nested module declarations are forbidden inside a module block.

Modules are also how ADM talks to other languages. Annotate a module with an FFI attribute and every function declared without a body becomes an external symbol pulled in from that library instead of compiled ADM code. The standard library's OpenGL bindings do exactly this:

gl.adm
@link("GL")
module std.ui.renderer.opengl.gl {
	const GL_TRIANGLES uint32 = 0x00000004

	def glClear(mask uint32);
	def glViewport(x, y, width, height int);
}

Everything in this module is a real GL symbol - glClear and glViewport come straight from the system OpenGL library, not from ADM code. More on this under Interoperability.

use Imports

imports.adm
use std.net.http
use my.tools as mod
use some.lib::(foo, bar as Baz)
use some.lib::*
use (
	std.io,
	utils.math,
	graphics.engine::(Renderer as Ren)
)
  • A plain use std.net.http imports the module itself; you reference its exports through the module name - http.Response, not the full dotted path.
  • use my.tools as mod aliases the whole module to a different name.
  • use my.tools as _ links the module without a name. Nothing in the file can refer to it; it is there for what it registers, such as a renderer that reflection.attrs discovers by its annotation. The linter leaves these alone.
  • ::(foo, bar as Baz) imports specific symbols by name, optionally renaming any of them. A module imported this way can't itself be aliased - there's nothing to alias, only the symbols it exposes are in scope.
  • ::* (or ::(*)) imports every exported symbol from a module, including every overload of an imported function name.
  • Several imports can share one use ( ... ) block.

Module names aren't implicitly in scope - even after use std.net.http, you still write http.Response, never std.net.http.Response.

use is legal almost anywhere a statement is: inside modules, inside type bodies (so a method can pull in a helper without polluting the rest of the type), and inside function bodies when scoping needs to stay tight. Wherever it's written, the import is scoped to that block alone.

Data Types

Primitives

Values are declared with let, or const for ones that can't be reassigned. A type annotation is optional - leave it off and ADM infers it from the value:

primitives.adm
let x int = 2
let y = 2  // inferred as int

Strings (string) are UTF-8 and support interpolation and multi-line literals:

strings.adm
let a string = "Hello, ๐ŸŒ"

let b = "Hello {user}"  // interpolation

let c = """
This is a
   multi-line string
"""

let d = `{"name": "Ada", "path": "C:\data"}`  // raw: no escapes, no interpolation

A backtick string is raw: its text is exactly what stands between the backticks, over any number of lines, with no escapes and no {expr} interpolation. JSON, regexes and Windows paths go in as written. It cannot hold a backtick; a carriage return in it is dropped.

Inside a string, {expr} interpolates any expression: a variable, a call, an index. A lone { followed by an expression start (a letter, a digit, a quote, a bracket) opens interpolation, so a literal brace is written \{ and \}:

interpolation.adm
let name = "Ada"
let n = 3
println("{name} has {n} items ({n * 2} halves)")   // Ada has 3 items (6 halves)
println("template: \{name}")                        // template: {name}
println("json: \{\"ok\": true\}")                   // json: {"ok": true}

Chars (char) are single-quoted and hold one Unicode code point:

char.adm
let c char = 'a'

Booleans (bool) are true/false; none represents the absence of a value.

Integers come in a signed/unsigned family - int, int8/16/32/64/128, uint, uint8/16/32/64/128 - and can be written in decimal, hex, octal, or binary. Spaces (or underscores) can split up long digit runs for readability; the compiler ignores them:

integers.adm
let x = 1000000
let x = 1 000 000

let a = 0xDEAD BEEF
let a = 0o142
let a = 0b1010 1010

A number too large for int, like 6277101735386680762814942322444851025767571854389858533375, is simply written as a literal - ADM infers bigint.

Floats (float, float16/32/64/128) accept the same space separators, a leading-dot shorthand, and exponents:

floats.adm
let y = 1000.0000001
let y = 1 000.000 0001
let y = .3
let x = 1e-3

complex32/64/128 use an i suffix for the imaginary part, e.g. 123i.

Durations (duration) are a number plus an SI-style suffix:

duration.adm
let a = 1ns
let a = 1us
let a = 1ยตs
let a = 1ms
let a = 1s
let a = 1m
let a = 1h

Regexes (regex) are slash-delimited, with an optional run of flags after the closing slash:

regex.adm
let a = /./
let a = /[a-z]{2,}\d*/mg

Patterns use RE2 syntax: \d \s \w, Unicode classes such as \p{L} and \p{Greek}, named groups (?P<name>...), non-capturing groups (?:...), lazy repeats *? +? ??, counted repeats {n,m} and inline flags (?i). The flags are i (case-insensitive), m (^ and $ at line breaks), s (. matches \n), U (lazy and greedy swapped) and g (replace replaces every match). Matching is leftmost-first and runs in linear time, with no backtracking; backreferences and lookaround are not RE2 and make the pattern invalid. Two regexes are equal when their patterns and flags are. test also takes a byte[] of UTF-8 text, which, unlike a string, can hold U+0000.

A pattern that is only known at run time, such as one read from a file, becomes a regex with new regex(pattern, flags); one that does not compile matches nothing:

regex_runtime.adm
let rule = new regex("^.+@example\\.com$", "i")
rule.valid()                   // true: the pattern compiles
rule.test("Ana@Example.com")   // true
new regex("(a", "").valid()    // false: it matches nothing

SIMD's vec2/4/8/16/32/64/128 and mask2/4/8/16/32/64/128 types get their own section below, under Vectors (SIMD).

Converting between types uses the target type as a call - there are no implicit numeric conversions, so a narrowing or lossy conversion has to be written out:

casts.adm
let bytes = byte[]("test")   // string  -> byte[]
let str   = string(bytes)   // byte[]  -> string
let i     = int32(1.5)      // numeric -> numeric
let letter = string('Z')    // char    -> string
let held any = 7
let seven = int(held)       // any     -> the type it holds; another type panics

A type name is not reserved: a let or parameter named byte shadows it, and byte(x) then calls that value.

Primitives aren't bare values either - they carry methods, so common operations read as ordinary method calls:

primitive_methods.adm
let s = "  hello  "
s.trim()          // "hello"

let x = 3
x.max(5)          // 5

let xs = [1, 2, 3]
xs.indexOf(2)     // 1
xs.len()          // 3

Strings index and slice like arrays do, including negative indexes - and because strings are UTF-8, indexing works on characters, not bytes:

string_index.adm
let s = "abcd"

s[0]      // "a"
s[:1]     // "a"
s[1:3]    // "bc"
s[-2:]    // "cd"

Slicing mechanics are covered in full under Arrays & Slicing.

  • Unit. Every index and length is a Unicode code point: s.len() counts code points, s[i] is the code point at i, s[a:b] selects code points, for let (i, ch) in s yields code-point indexes. Bytes are reached through s.bytes(); grapheme clusters are not a unit the language knows.
  • Bounds. Negative indexes count from the end. An out-of-range s[i] yields '\0' rather than panicking; slice bounds are clamped and b <= a gives "". A string slice always copies.
  • Comparison. == and ordering are byte-wise (code-point order for valid UTF-8). No normalization, no case folding; upper/lower map ASCII only.
  • Ill-formed bytes. A string built from bytes decodes each ill-formed byte, overlong form, surrogate or out-of-range value as U+FFFD, counting one code point. A string cannot contain U+0000: bytes after an embedded zero are dropped.
  • Cost. len() is O(1) after the first call; s[i] is O(1) on ASCII strings and walks with a cursor otherwise, so sequential access stays linear.

Arrays & Slicing

Arrays are T[], dynamically sized, with a literal form - [1, 2, 3], or Type[1, 2, 3] with an explicit element type.

== and != on arrays compare identity: [1, 2] == [1, 2] is false, since the two literals are two arrays. a.equals(b) compares contents.

An untyped literal takes its element type from the items, or from where it is used: a declared any[], an assignment target or an any[] parameter push their element type into the literal, and items of unrelated types make it an any[] on their own, each item boxed:

array_literals.adm
let ints = [1, 2, 3]                 // int[]
let mixed = [4, "z", 5.5]            // any[]
let row any[] = [1, "two"]           // any[] by declaration
def log(fields any[]) { ... }
log([now(), "started", 200])         // the parameter type reaches the literal

The element type is part of a container's type and is never converted: an int[] is not an any[], a Dog[] is not an Animal[], a map<string, int> is not a map<string, any>, and passing one for the other is a compile error. Literals are the exception, since they have no type yet: a map literal argument takes the parameter's type the same way an array literal does.

element_types.adm
def save(fields map<string, any>) { ... }
save({"retries": 3})                  // built as map<string, any>

let counts = {"retries": 3}           // map<string, int>
save(counts)                          // error: not a map<string, any>
let fields map<string, any> = {"retries": 3}
save(fields)

Indexing supports negative indexes, which count from the end:

arrays.adm
let a = int[1, 2, 3, 4]
let first = a[0]   // 1
let last  = a[-1]  // 4
a[-2] = 10         // a is now [1, 2, 10, 4]

Slicing produces a view into the same backing storage - no copying:

slicing.adm
let whole   = a[:]    // full view
let prefix  = a[:2]   // first 2 elements
let suffix  = a[-2:]  // last 2 elements
let trimmed = a[:-1]  // drops the last element
  • Writes through a slice are visible through the original array (and any other slice) as long as they still share the same backing storage.
  • A slice has its own len, and its capacity is its length: pushing onto a slice copies it first, so it never writes over the elements of the array past its end.
  • If growing an array or slice needs more capacity, it detaches - allocates a new backing buffer and copies. Existing slices keep working but stop sharing storage with the grown one.
  • Use .clone() to force a copy instead of a view: let copy = a.clone().

Bulk operations treat arrays like sets, and apply arithmetic across every element at once:

bulk_ops.adm
let a = int[1, 2, 3, 4]
let b = int[3, 4, 5, 6]

let intersection = a & b  // [3, 4]
let joined       = a | b  // [1, 2, 3, 4, 5, 6]
let concat       = a + b  // [1, 2, 3, 4, 3, 4, 5, 6]
let diff         = a - b  // [1, 2]
let xor          = a ^ b  // [1, 2, 5, 6]

a[:] = 0     // mutate every element in place
a[:] /= 10   // divide every element in place

a += [7]     // append; type-checked at compile time

a = a + ["oops"] on an int[] is a compile-time error.

In an array literal ; separates rows: int[1, 2, 3; 4, 5, 6] is an int[][]. Arrays have no matrix arithmetic. Matrices are their own type, matrix<T>: a rectangular grid of numbers with the matrix product, element-wise operators and scalar multiply and divide. matrix<T, R, C> fixes the shape in the type, so a shape mismatch is a compile-time error:

matrix.adm
let m = matrix<float, 2, 3>[1, 2, 3; 4, 5, 6]     // two rows of three
let n matrix<float, 3, 2> = [7, 8; 9, 10; 11, 12]  // shape from the annotation

let product  = m * n   // matrix product: matrix<float, 2, 2>
let hadamard = m .* m  // element-wise
let x = m[1][2]        // 6.0: row 1, column 2

Maps

Maps are map<K, V>, with a literal syntax that mirrors structs. A key is a number, bool, char, string or enum (equal values are one key), a struct (equal when every field is, so its fields must be key types too), or a type. Two instances of a type are different keys even with equal fields, unless the type declares both def hash() uint64 and def equals(other T) bool: the map then hashes and compares through them, and instances that are equals must return the same hash (new KeyHasher().add(a).add(b).sum() builds one from the compared values). Declaring only one of the two is a compile error where the type is used as a key; functions, arrays, maps, tuples, optionals and interfaces cannot be keys. A key must not change while it is in a map. a + b on two maps merges b into a copy of a and returns the copy, like the array operators, neither operand changes; merge(other) is the in-place form.

maps.adm
let m = map<string, int>{
	"a": 1,
	"b": 2,
}

Reading and writing use the same bracket syntax as arrays:

access.adm
m["c"] = 3
let a int = m["a"]
let missing = m.get("missing")  // ?int - none if the key is absent

Other lookups and mutations are plain methods:

methods.adm
m.hasKey("a")  // true
m.delete("b")
m.len()          // 1

Iterating a map yields key/value pairs; keys() and values() return them as arrays on their own:

iterate.adm
for let (k, v) in m {
	println(k)
	println(v)
}

let ks = m.keys()    // string[]
let vs = m.values()  // int[]

Tuples

Tuples group a fixed set of values without declaring a type: (T1, T2, ...) is the type, and a parenthesized, comma-separated expression is the literal.

tuples.adm
let ingredients = ("Sugar", 25, true)
let t (int, int) = (1, 2)

A single parenthesized expression stays just that expression - it takes at least one comma to make a tuple.

Elements are read by position, counted from zero, with .0, .1 and so on. Each has its own type, positions chain through nested tuples, and an element takes whatever its type takes, a method call or a call of a function it holds:

elements.adm
let name = ingredients.0          // "Sugar"
let grams = ingredients.1 * 2     // 50

let nested = ((1, 2), ("a", [10, 20, 30]))
let last = nested.1.1[2]          // 30

let fns = (twice, 5)
let ten = fns.0(fns.1)            // calls twice(5)

The position is written right after the value, with no space before the dot; anywhere else .5 is a float. A position the tuple does not have is a compile error, and so is assigning to an element: a tuple keeps its values, make a new one to change them.

Destructuring pulls a tuple's values out positionally with let:

destructure_tuple.adm
let (name, amount, active) = ingredients
// name = "Sugar", amount = 25, active = true

Use _ to skip a position you don't need:

skip.adm
let (_, amount, _) = ingredients

The same let ( ... ) syntax also destructures a struct - but there it matches by field name instead of position, and any field can be renamed with as:

destructure_struct.adm
let (id as userId, address) = user

Tuples can also be unpacked with a match pattern - case (a, b): return a + b - covered under Match.

Vectors (SIMD)

vec2/4/8/16/32/64/128<T> are fixed-width SIMD vectors over a numeric lane type T - the number is the lane count, and operations run across all lanes at once when hardware SIMD is available. mask4/8/16/32/64/128<T> hold the per-lane result of a comparison.

A vector is built from a zero-valued instance, then loaded or splatted:

vectors.adm
let data = float[1.0, 2.0, 3.0, 4.0]

let v vec4<float>
let a = v.load(data, 0)  // 4 lanes starting at index 0

let w vec4<float>
let b = w.splat(2.0)     // 2.0 broadcast to every lane

Arithmetic works lane-wise through the usual operators:

arithmetic.adm
let sum  = a + b  // [3.0, 4.0, 5.0, 6.0]
let prod = a * b  // [2.0, 4.0, 6.0, 8.0]

sum.store(data, 0)   // writes the 4 lanes back into data

Comparisons return a mask instead of a bool, and a mask can pick lanes from either vector:

masks.adm
let hi = a.gt(b)          // mask4<float>: true where a's lane > b's lane
let picked = hi.blend(a, b)  // a's lane where hi is true, else b's

Structs

Structs are record-like: mutable fields, no methods or properties. Declare one with struct; annotations work both on the struct itself and on individual fields:

structs.adm
@annotation()
struct User {
	@json(omitempty=true)
	name string

	id int
	age, height int    // grouped: share one type
	userType = "user"  // default value, type inferred
	a, b = 0, true      // grouped defaults, types inferred

	address struct {     // anonymous inline struct
		street string
		city string
	}
}

Instantiating a struct uses a literal; the type name can be dropped once it's already known from context:

new_struct.adm
let u User = {id: 1, name: "MyName", address: {street: "street"}}

An anonymous struct is written where it is used, with no declaration: struct { x: 2, y: 3 } as a value, each field taking the type of its value, and struct { x int, y int } as a type. Two anonymous structs of a module with the same field names and types, in the same order, are one type:

anonymous_struct.adm
let p = struct { x: 2, y: 3 }
p.x = 10
p = struct { x: 30, y: 40 }             // same fields, same type

let rows = [struct { name: "a", n: 1 }, struct { name: "b", n: 2 }]

def sum(p struct { x int, y int }) int {
	return p.x + p.y
}

sum(p)
sum({x: 1, y: 2})                       // a literal without a type takes the parameter's

A field of the type form is a plain name type; defaults, annotations and modifiers need a declared struct. A value with no type of its own (none, an untyped {...}) cannot give a field its type. A different field order is a different type, a declared struct is never an anonymous one, and two modules that write the same fields have two types. The inline address struct { ... } field above is such a type: it is built with {street: "Main", city: "Cluj"} or with an anonymous literal of the same fields.

Embedding a struct - writing its type name as a bare field - copies that struct's fields onto the outer one:

embedding.adm
struct Account {
	User                    // embedding: id, name, age... become Account's own fields
	someModule.Properties   // embedded types can be qualified too

	balance float
}

Enums

Enums declare a fixed set of named constants. Members auto-increment from 0 unless given an explicit value, and a trailing comma is fine:

direction.adm
enum Direction {
	North, South, West, East,
}

Give members explicit values - including expressions that reference earlier members in the same enum:

flags.adm
internal enum Color {
	Red   = 0xFF0000,
	Green = 0x00FF00,
	Blue  = 0x0000FF,
}

enum Flags {
	None,
	Read,
	Write,
	ReadWrite = Read | Write,
}

internal enum keeps it visible only inside the declaring module, just like an internal module.

Access a member through the enum's name, and compare with == like any other value:

use_enum.adm
let f = Flags.ReadWrite
let same = f == Flags.ReadWrite  // true

Enums are declaration-only: they can't host methods, can't be partial, and can't declare a constructor or destructor. Only plain identifiers are allowed as member names.

Unions

A union is a raw memory type: every member shares the same storage. Writing to one member changes the bits for all of them - reading a different member reinterprets those same bytes as its type. Unions don't track which variant was last written.

A union whose members are numbers and bool can be a parameter, a result or a struct field of a C declaration: it is the C union of the same members.

union.adm
union Size {
    A int
    B float
}

Variants are constructed like calls, and read like fields:

use_union.adm
let s Size = Size.A(10)
let a = s.B  // 1.401298e-44 - the bits of 10 (int), reinterpreted as float

That reinterpretation is what a union is for. Since nothing tracks the active variant, reading the "wrong" member is legal and hands you back whatever bytes happen to be there.

Unions can be marked internal to be scope it to the declaring module. Unions can't be partial:

Interfaces & Datatypes

An interface declares behavior - a set of method signatures. Any type that has all of them automatically satisfies the interface, no implements clause needed - the same implicit satisfaction Go uses for its interfaces.

interface.adm
interface Animal {
	Walk() !int
	Eat<W>() ?bool
}

internal interface Dog {
	Animal
	someModule.SomeInterface

	Bark()
}

Interfaces support embedding - Dog pulls in every method Animal requires, plus its own Bark() - and generics in method signatures (Eat<W>).

== on two interface values compares what they hold: the same object held twice is equal, two different objects are not.

A method named on an interface value without a call (host.greet, with host Host) is a function value bound to that value, as a method named on an object is: apply(host.greet, "x") calls greet on the object the interface holds. Methods from embedded interfaces work the same way.

A datatype is the same idea applied to data instead of behavior: it describes a required shape - which fields must exist, and their types - rather than which methods must exist. Any struct or type exposing those fields satisfies it, implicitly, the same way:

datatype.adm
datatype UserLike {
	id int
	email string
}

internal datatype Account {
	UserLike
	someModule.SomeDatatype

	name string
}

Datatypes embed the same way interfaces do, and can be used anywhere a type constraint is expected - including generic bounds. & combines constraints (a type must satisfy all of them); | accepts any one of them:

bounds.adm
def save<T: UserLike & Serializable>(value T) {
	// UserLike ensures value exposes the right fields,
	// Serializable ensures it has the methods needed to persist it.
}

any

any holds a value of any type at all. It's the escape hatch for cases where the type genuinely isn't known ahead of time - and like a union, ADM won't let you use it as a concrete type until you narrow it with is or match:

any.adm
let x any = 123
x is int        // true

let p = Person{first: "Ada"}
let a any = p
a is Named     // true - interfaces work too

The value keeps its real type underneath, so std.reflection.typeName(x) reports what it actually is. Reach for a union type instead whenever the set of possibilities is known - it's checked at compile time, where any pushes the check to a narrowing site.

Units of measure

A number literal can carry a unit in parentheses: 100(km), 9.81(m/sยฒ), 36(km/h). The value's type is its dimension (Length, Speed, Force), declared with @dimension or derived with *, / and **. +, - and comparisons need the same dimension, and * and / combine dimensions, so adding a Length to a Time is a compile error. At run time a quantity is a float in SI base units; the dimension exists only in the type.

units.adm
use std.units::*

let distance = 420(km)
let t = distance / 110(km/h)          // Time
println(t.str(h, digits = 1))         // 3.8 h
println(t.to(min))                    // 229.090909, a float
let wrong = distance + t              // error: cannot add Length and Time

v.to(unit) gives the number in another unit, v.str(unit) the text, and quantity(x, unit) turns a variable into a quantity. When a duration is used with a quantity, it counts as a Time. Units of measure covers the unit notation, conversions, offset and logarithmic units, declaring your own units, and the full std.units catalogue.

Types

A type is ADM's stateful, instantiable building block - the closest thing to a class. Unlike a struct, it can have methods, properties, generics, and a constructor/destructor pair:

Declaring a Type

type_anatomy.adm
@controller()
partial type User<T> {
	use std.crypto.hash

	internal atomic id T = defaultValue()
	name string = "unknown"
	balance float

	where T is Account | Admin {
		permissions string[]
	}

	def new(val string) {
		name = val
	}

	def dispose() {
		audit("disposing {name}")
	}

	infix def + (delta float) User<T> {
		balance += delta
		return self
	}
}

Fields work like struct fields, but can take a default value (name string = "unknown"; it is evaluated before the instance exists, so it can't name another member or self), and the type itself can carry annotations and modifiers the same way a function can - this one is partial, so the rest of it can live in another file, as long as every fragment repeats the <T> generic parameter list. use inside a type is scoped to that type only, and a type body can also nest its own struct, enum, or union declarations. A const in a type body belongs to the type, not to an instance: its methods read it by name, other code through the type, Limits.Most.

internal type Cache { ... } hides the whole type outside its module - the usual way to keep a helper type from leaking into your public API.

A field marked weak (weak parent ?Node) refers to another object without owning it: the field must be optional, its target a struct, type, service or component, and it reads none once the target has been released. Use it for back references so parent and child form no reference cycle - see Memory model โ†’ Weak references.

A field marked @persist(key) is kept between runs of the program: @persist("editor.fontSize") fontSize int = 12. An object that is made starts with the value kept under the key, before new() runs, and an assignment that changes the field keeps the new value. It works on a field of a type, a service or a component, not of a struct; the rules are under @persist.

A constructor with one parameter marked @implicit makes the type accept that parameter's type directly: def send(to Secret<string>) is called as send("hunter2") and the compiler inserts the constructor, the way a value becomes a ?T. Inward only, one hop, never through an interface, and the constructor cannot fail; wrappers that validate use a factory returning !T instead. The language server shows such a parameter as Secret<string>/string.

A new may leave out what the code around it says. The type arguments of a generic type come from where the value goes when that is declared, otherwise from the constructor's arguments: padding Edges<Extent> = new Edges(12(px)), let free = new Edges(2.5). Where the whole type is declared, new(...) is enough: card.padding = new(9.0), return new(7.0).

A where T is Constraint { ... } block adds members that only exist when the generic parameter satisfies that constraint. Instantiate the type with a type argument that doesn't match, and those members simply aren't there:

generic_where.adm
type Admin {}
type Account {}

type User<T> {
	name string

	where T is Admin | Account {
		permissions string[]

		def tag() string { return "privileged" }
	}
}

let u1 User<Admin> = new(name="bob")
u1.tag()               // fine - T is Admin, satisfies the where clause

let u2 User<string> = new(name="alice")
// u2.tag() would not compile - string doesn't satisfy Admin | Account

Constructors & Destructors

def new(...) runs right after allocation; def dispose() runs when the runtime disposes the instance. Neither one declares a return type, and dispose takes no parameters: it is the destructor, so a method that takes arguments has another name. Construction accepts named arguments the same way any call does:

constructor.adm
type Pair {
	x int
	y int

	def new(x int, y int) {
		self.x = x
		self.y = y
	}

	def sum() int {
		return self.x + self.y
	}
}

let p = new Pair(y=2, x=3)

dispose is also a statement, for cleaning up before the runtime otherwise would:

destructor.adm
type Payload {
	id int

	def dispose() {
		// release whatever id owns
	}
}

let a = Payload{id: 1}
dispose a

Properties

A property bundles storage with its accessor logic in one block. Every property gets an implicit value storage slot, private to the block, that get/set read and write:

property.adm
type Box {
	count int {
		def get() int { return value }
		def set(v int) { value = v }
	}
}

let b = Box{}
b.count = 2
b.count += 1
print(b.count)  // 3

Both accessors are optional: drop get for a write-only property, drop set and it becomes computed (read-only) instead. Accessors are ordinary defs and can be internal, atomic, cuda, or sql - but never async or infix. A property itself can't be partial and can't declare its own where block; specialize the surrounding type instead.

Methods

A method is just a def inside a type, with an implicit self receiver. It can be generic on its own, even when the type isn't, and a call can pin the type argument explicitly if it can't be inferred:

generic_method.adm
type Holder {
	def mid<T>(x T) T { return x }
}

let h Holder = new()
h.mid(1)             // T inferred as int
h.mid<string>("ok")   // T pinned explicitly

infix turns a method into an operator overload - infix def + is what makes a + b work for a type's own instances. Only type methods can be infix; it can't be combined with async or internal:

infix.adm
type Bits {
	value int

	infix def <<< (count int) Bits {
		return self
	}

	infix def & (other Bits) Bits {
		return self
	}
}

let a = Bits{value: 1}
let b = Bits{value: 2}
a <<< 1
a & b

Everything from Params & Returns applies to methods too - modifiers, overloading, generics, annotations - plus infix, which only makes sense here.

Control Flow

Branching, looping, and pattern matching, and the same three work on primitives, structs, tuples and every other type.

Conditionals

if/else work as usual, and can carry an initializer before the condition, separated by ; - scoped to the whole chain:

if_else.adm
if let x = test(); x == 1 {
	x = 2
} else {
	x = 3
}

when and unless turn a single statement into its own guarded conditional - often more readable than a whole if block for a one-liner:

when_unless.adm
x = 1 when t
x = 3 unless t

callFunc() when ready
fail unless valid

The ?? choice operator picks between two expressions; only the taken branch is evaluated:

choice.adm
let cond bool = true
let x int = cond ?? (1, 2)  // 1

Conditional junctions let := reuse the left operand across a whole &&/|| chain, instead of repeating it:

junctions.adm
let color = "white"

if color := "white" || "black" || "blue" {
	// ...
}
// same as: if color == "white" || color == "black" || color == "blue" {}

planned The same trick works with regexes and && - each regex tests the same subject, which reads nicely for validating a password against several patterns at once:

junctions_regex.adm
let password = "secret123!"

if password := /:alpha/ && /:digit/ && /:punct/ {
	// ...
}

A junction beside another condition is written in parentheses: a == b || (c := d || f). Without them it is a compile error.

Loops

for takes a few different headers. The classic three-part form:

for_classic.adm
for let i = 0; i < 100; i++ {
	work(i)
}

Init and post can each carry more than one variable, sharing the clause:

for_multi.adm
for let i, j = 0, 0; i < b.len(); i, j = i + 4, j + 3 {
	b[i] = b[j]
}

Drop the init and post and only a condition is left:

for_cond.adm
for count < limit {
	count++
}

Drop the condition too and it loops forever, until something breaks it:

for_infinite.adm
for {
	if done() {
		break
	}
}

Four suffix blocks attach extra behavior to any for, in any combination:

  • every expr { ... } - runs after iterations matching expr (or after every iteration if expr is omitted).
  • break { ... } - runs if the loop exits via break.
  • empty { ... } - runs if the body never executes at all.
  • finish { ... } - runs after the loop completes normally.
for_suffixes.adm
for let i = 0; i < 3; i++ {
	sum += 1
    break when i == 1010
} every i % 2 == 0 {
	sum += 10
} break {
	sum += 100
} empty {
	sum += 1000
} finish {
	sum += 10000
}

A range produces every value from the start up to (but not including) the end:

for_range.adm
for let i in 0..10 {
	sum += i
}

The loop runs i from the first bound up to, not including, the second. a..b is only a loop header, of for and forall: a range is not a value, and writing one anywhere else is a compile-time error.

The same in form iterates an array - with the index if you ask for it - and a map, exactly like Maps showed earlier:

for_in_array.adm
let values = [10, 20, 30]
for let (idx, v) in values {
	println(idx)
	println(v)
}

A string iterates the same way, yielding each character with its index:

for_in_string.adm
let s = "ab"
for let (i, ch) in s {
	println(i)
	println(ch)
}

break exits a loop immediately; continue skips to the next iteration. Both work in every loop form, and break is what triggers the break { ... } suffix block:

break_continue.adm
for let i = 0; i < 3; i++ {
	if i == 1 {
		continue   // skip the rest of this iteration
	}
	sum = sum + 1
}

for let j = 0; j < 3; j++ {
	if j == 1 {
		break      // leave the loop entirely
	}
	sum = sum + 10
}

forall runs a range or array loop in parallel, spreading the work across as many CPU cores as are available. Order isn't guaranteed - each worker just picks up the next item as it finishes the last one:

forall.adm
forall let val in myArray {
	compute(val)
}

The suffix blocks still work under forall: every still runs, just out of order, and finish only runs once every worker has finished.

Any type can be iterated with for let v in value by implementing the builtin Iterator<T> interface, a single method def iterate() ?T. The loop calls it until it returns none; the type keeps its own cursor and resets it after exhaustion so the value can be walked again. An iterator, like a channel, has no index or key: with two names the first is _ (for let (_, v) in value), and two names never take an element apart, so an iterator of tuples is read with one name. An Iterator<T> is a real interface value, so it can be stored and passed around:

iterator.adm
type Countdown {
	from int
	next int

	def new(from int) {
		self.from = from
		next = from
	}

	def iterate() ?int {
		if next == 0 {
			next = from
			return none
		}
		next -= 1
		return next + 1
	}
}

for let n in new Countdown(3) {
	println(n)              // 3, 2, 1
}

let it Iterator<int> = new Countdown(2)
for let n in it { ... }

A generator writes the same thing as a function. A function or method that returns Iterator<T> and contains yield produces its values one at a time: calling it returns the iterator without running the body, and each iterate() runs the body up to the next yield. Locals, parameters and self keep their values in between. A bare return or the end of the body ends the sequence, and the iterator then starts again from the arguments of the call:

generator.adm
def fibonacci() Iterator<int> {
	let (a, b) = (0, 1)
	for {
		yield a
		(a, b) = (b, a + b)
	}
}

for let n in fibonacci() {
	break when n > 100
	println(n)              // 0, 1, 1, 2, 3, 5, ...
}

def countdown(from int) Iterator<int> {
	for from > 0 {
		yield from
		from--
	}
}

yield is a statement and takes when / unless guards. A generator is a plain function (not async, atomic, sql, cuda or meta); yield cannot stand inside forall or transaction. An anonymous function that returns Iterator<T> and yields is a generator too, keeping what it captures for as long as the iterator lives. defer runs once per sequence: when it ends, or when the iterator is dropped before that. A generator of Iterator<!T> may use try and fail: the error is the sequence's last value, and the next iterate() answers none. It costs one object.

Switch / Match

switch compares one value against several, with an optional initializer just like if. Cases can list more than one value, and fall through explicitly with continue instead of implicitly:

switch.adm
switch let x = classify(); x {
	case 1, 2:
		continue
	default:
		return 0
}

A condition-only switch, with no value to compare against, reads like a chain of if/else if:

switch_cond.adm
switch {
	case a == 1:
		// ...
	case a > 1:
		// ...
	default:
		// ...
}

match goes further: it can destructure the value right in the case, and each case can carry its own guard after a ;:

match.adm
def classify(x int) int {
	match x {
		case 0:
			return 0
		case (n); n > 0:
			return n
		default:
			return -1
	}
}

def sum(t (int, int)) int {
	match t {
		case (a, b):
			return a + b
		default:
			return 0
	}
}

def userId(u User) int {
	match u {
		case {id int, email string}; id > 0:
			return id
		default:
			return 0
	}
}

classify matches a literal and binds a guarded name; sum destructures a tuple positionally; userId destructures a struct's shape (with a guard) the same way destructuring let does.

A case can also be a bare type instead of a pattern. On a union-typed value, that narrows it: inside case string: the value really is a string; fall through to default and it's narrowed to whatever's left of the union. Multiple types can share one case, narrowing to that subset instead of just one. A match without default need not name every member: a value no case takes skips the match, and adm lint warns (non-exhaustive-match) naming the members left out:

match_union.adm
def run(v string | int) int {
	match v {
		case string:
			return 0
		default:
			return takesInt(v)  // v is narrowed to int here
	}
}

The same bare-type case works against an interface or datatype - including a generic parameter, which is how a function can behave differently depending on what constraint its type argument happens to satisfy:

match_interface.adm
interface Stringer {
	def str() string
}

def useMatch<T>(x T) string {
	match T {
		case Stringer:
			return x.str()
		default:
			return ""
	}
}

Matching a value against none or error works the same way, and is covered in more depth under Type Narrowing and Error Handling.

Type Narrowing

Some values can legitimately be more than one shape: a union type is one of several types, an optional is either a value or none. ADM won't let you use either as a single concrete type until you narrow it - with is, match, or a when/unless guard.

Union & Intersection Types

A union type, T1 | T2, accepts either shape, and is valid anywhere a type annotation can appear. is checks which one you have; match does the same thing case by case:

union_type.adm
let width string | int = 100

let pixels int
if width is string {
	pixels = parsePixels(width)
} else {
	pixels = width
}

// or by pattern matching
match width {
	case string:
		pixels = parsePixels(width)
	case int:
		pixels = width
}

Nothing lets you read width as a plain value without picking a branch first - unlike structurally typed languages such as TypeScript, there's no "anything goes" path around it.

A value of a union goes where another union is expected when that union takes each of its members. What is left of an A | B | C once is A was ruled out is a B | C, and it is still returned, assigned and passed as the union it came from. An optional or an errorable is unwrapped first.

== on two union values compares what they hold: 13(px) stored in two Extents is one value, two strings compare by their text, two objects only when they are the same object, and members of different types are never equal.

A member that is errorable or optional is written in parentheses, (!string) | User, since !string | User is the errorable of string | User. The wrapper does not stay in the value: a failed !string is its error, an empty optional is none, so match has case error or case none beside the member types.

An intersection type, T1 & T2, requires both at once. It shows up mostly in generic bounds, combining an interface and a datatype constraint the way UserLike & Serializable did earlier.

A type alias can name any type expression, including a union, and reads exactly like the type it points to:

alias.adm
type Numeric = int | float

let score Numeric = 9.5

Aliases are always a single expression - they can't be partial.

Optional Types

?T marks a value that might be none instead of holding a T. The same narrowing tools apply - is, match, and the when/unless guards from Conditionals:

optional.adm
struct Box { value int }

def read(b ?Box) int {
	return 0 when b is none
	return b.value
}

Converted to any, an optional does not stay one: an empty ?T becomes none and a set one becomes its T, so v is none and case T work on the any as they do on the optional.

An optional of an optional is that optional, since there is one none: where a type parameter T stands for ?string, ?T is ?string.

The same value narrows just as well with match:

optional_match.adm
let o ?int = none

match o {
	case none:
		// nothing to read
	case int:
		println(o)
}

Functions

A function declaration can carry modifiers, generics, parameter defaults and annotations, and - for a few special cases - a foreign-language body instead of an ADM one:

anatomy.adm
@annotation()
internal atomic async def funcName<A, B: Serializable>(a int, b string = "default") !?Result {
	let x, y = 1
	return try process(a, b) onerror recover fallback()
}

Params & Returns

A function can carry any of these modifiers:

  • internal - visible only inside the declaring module, same as everywhere else.
  • atomic - wraps the call in a per-instance lock, so only one caller runs it at a time; everyone else blocks until it's done. More under Concurrency.
  • async - the function returns a future instead of running immediately. Covered in depth under Async / Await / Futures.
  • infix - turns a method into an operator overload (a + b). Only type methods can be infix, so it's covered under Types โ†’ Methods.
  • meta - turns the function itself into a reusable annotation, the way @trim (below) is defined. Covered in depth under Attributes & Modifiers.
  • cuda, sql - the body is a foreign-language DSL instead of ADM statements (below).

Parameters can share a type across grouped names, take a default value, and a call can name any argument regardless of position - arguments after a named one just can't go back to being positional:

named_defaults.adm
def add(a int, b int = 5) int {
	return a + b
}

let s = add(a=2, b=3)
let t = add(a=2)  // b defaults to 5

A trailing ...T parameter collects any number of extra arguments into an array, and a matching array can be spread back out at the call site with the same ...:

variadic.adm
def sum(nums ...int) int {
	let total = 0
	for let (_, n) in nums {
		total = total + n
	}
	return total
}

let xs = [1, 2, 3]
let a = sum(...xs)
let b = sum(0, ...xs)

Parameters take annotations too, and an annotation can rewrite the value before the body ever sees it. @trim() does exactly that - it reads the argument, trims it, and writes the trimmed value back:

param_annotation.adm
def isAlpha(@trim() txt string) bool {
	// txt has already been trimmed by the time the body runs
	// ...
}

Annotation arguments can be named too, the same way call arguments can - @validate(min=-10, max=10) is the general shape. Annotations aren't limited to compile time either: they're inspectable at runtime through reflection, the same mechanism behind @flag, @json, and @controller.

Functions and methods can share a name as long as their full signatures - parameter types and count, plus the receiver type for methods - differ. Overload resolution happens entirely at compile time; a call must match exactly one candidate or it's a compile error, never a runtime guess. Used as a value, an overloaded name takes the overload the expected function type asks for (let f def (int) string = show); a let without a declared type cannot take one.

cuda and sql functions carry a foreign-language body instead of ADM statements. {name} inside a sql body names a parameter or a field of the type; it reaches the database as a bound parameter, never as text pasted into the statement:

foreign_bodies.adm
cuda def vectorAdd(a, b, out float[]) !none {
	int i = blockIdx.x * blockDim.x + threadIdx.x;
	if (i < out_len) out[i] = a[i] + b[i];
}

type Users {
	@connection()
	conn db.Queryable

	sql def find(email string) !?User {
		SELECT * FROM users WHERE email = {email}
	}

	sql def rename(id int, name string) !none {
		UPDATE users SET name = {name} WHERE id = {id}
	}

	sql def emails() !string[] {
		SELECT email FROM users
	}
}

A sql def is a method of a type that holds its connection: one property marked @connection (from std.data.db) holding a db.Queryable, a connection or a transaction. The result type says how the rows are read, and is always errorable:

  • !none runs the statement; !db.Result returns what it changed;
  • !User reads the first row into the struct or type, column by field name, and fails when there is none; !?User returns none then; !User[] reads every row;
  • !int, !string, !bool, !float, !byte[], sized numbers, dates and times read the first column; ? turns a missing row or a NULL into none, [] reads the column of every row;
  • a tuple such as !(int, ?string) reads one column per element.

An alias reads as the type it stands for: type Pair = (int, ?string) makes !Pair a tuple, type MaybeUser = ?User makes !MaybeUser an optional row. The statement is the body's text without the indentation its lines share, so adm fmt never changes it. Assigning the @connection property waits until the queries running on that instance finish.

A sql def outside a type, or in a type that marks no property, takes its connection from the Database service: the one @db("name") names, else the default connection. A missing connection fails the call, not the build.

The statement is the same text for every database: the compiler hands it to the driver with the driver's own placeholders (?, $1, :name). The connection comes from std.data.db (db.connect(url), db.pool(url)) with one of the driver libraries linked: adm.database.sqlite, adm.database.postgres, adm.database.mysql. Rows map onto a struct's fields by column name; @column on a field names another column or the form the value is stored in. The Databases example shows both.

A sql def can also be written where it is used, as an anonymous function: let count = sql def (least int) !int { SELECT COUNT(*) FROM stock WHERE quantity >= {least} }. It runs on the connection of the type whose method holds it, else on the service's, and its placeholders read its parameters and the variables around it.

Inside a transaction { } block the sql def calls run in a database transaction, with nothing to write at the call sites: the first call on a connection begins it, later calls on that connection reuse it (nested blocks and called functions included), the end of the block commits it and a failure in the block rolls it back. A block that touches two databases holds one transaction on each; their commits are separate, and when one fails after another committed the error is a TransactionCommitError whose committed names the databases that stay committed. A task started inside the block runs its queries outside the block's transactions. try db.isolate(db.Isolation.Serializable, readOnly = true) inside the block sets the isolation level and read-only mode of the transactions it begins after that call.

A body of several statements is a script: the statements run one by one, each with its own placeholders, and a failure names the statement (statement 4 of 9: ...). A ; inside quotes, comments or a trigger's BEGIN ... END does not end a statement. A script returns !none and does not begin or commit a transaction itself: it runs inside the caller's transaction { } block.

sql def createSchema(firstTeam string) !none {
	CREATE TABLE teams (id bigint PRIMARY KEY, name text NOT NULL);
	ALTER TABLE users ADD COLUMN team bigint;
	INSERT INTO teams (id, name) VALUES (1, {firstTeam});
}

A body written BEGIN ... END (or DECLARE ... BEGIN ... END) is a stored routine in the database's own language. It is created in the database under the function's name when its connection is added to the Database service, and again only when it changed; calling the function calls it. The result picks the kind: !none a procedure, a single value a function, rows a function returning a table (PostgreSQL) or a procedure that selects them (MySQL). SQLite has no stored routines.

@db("reports")
@db.routine(name = "top_users", schema = "analytics")   // optional: another name, a schema
sql def topUsers(n int) !User[] {
	BEGIN
		RETURN QUERY SELECT * FROM users ORDER BY score DESC LIMIT n;
	END
}

@db.sqlschema("schema.sql") on a type (or on one sql def) checks its statements against the schema's DDL when the program is built: a table or column the schema does not have, a placeholder whose value does not fit its column, or a result whose columns do not read as the declared type stops the build at the statement. The file is what pg_dump --schema-only or mysqldump --no-data writes.

The database's schema is set up by the application's own migration steps: @migration(from = "4.32", to = "4.33", db = "main") on a sql def (often a script) or on a function that calls several. Database.add runs the steps the database has not seen, each in a transaction, under a lock so instances starting together migrate once; a database with no version yet runs them all. The version is kept in the application's home folder, or with versions = "ops.adm_versions" in a table of the database, which several servers sharing it need.

Everything outside a {name} placeholder is plain text in the target language - the compiler doesn't try to understand it, just records the identifiers it needs to substitute before handing the body to the target toolchain.

A cuda def body is one CUDA kernel. Its ADM parameters are the kernel's parameters: a numeric scalar arrives as the matching C type, and an array T[] arrives as T* name plus int64_t name_len, copied to the device before the launch and back after it. The result is always !none: a launch can fail (no driver, rejected kernel), and values come back through the arrays. The grid is one thread per element of the first array or buffer parameter, in blocks of @launch(block = N) threads (default 256), so a body guards with i < name_len; @launch(x = "w", y = "h") names integer parameters holding the thread count per axis instead.

kernel.adm
use std.gpu

cuda def scale(values float32[], factor float32) !none {
	int i = blockIdx.x * blockDim.x + threadIdx.x;
	if (i < values_len) values[i] *= factor;
}

def new(args string[]) int {
	let samples = float32[1, 2, 3, 4]
	scale(samples, 2.5) onerror return 1
	println(samples)                        // [2.5, 5, 7.5, 10]
	println(gpu.available())                // true when a driver loaded
	return 0
}

The kernel is compiled at build time for every GPU architecture the toolkit knows (PTX plus SASS, one fatbin) and also as plain C, so the same program runs without a GPU, or with a driver that cannot run the images: the CPU path runs the blocks in parallel. ADM_CUDA=gpu or =cpu forces either, ADM_CUDA_DEVICE picks the device, and gpu.available() / gpu.deviceName() report the path in use. A body that needs device-only features (__shared__, __syncthreads, warp intrinsics) declares @launch(cpu = false) and fails without a GPU. A kernel that uses newer instructions names the lowest compute capability it needs, @launch(arch = 80) for TF32 tensor cores: its image targets that architecture and newer, and on an older device it takes the CPU path (or fails, when GPU-only). gpu.capability() gives the device's capability times ten, so a caller picks the kernel: if gpu.capability() >= 80 { tf32Matmul(...) } else { matmul(...) }.

Data that many launches share stays on the device in a std.gpu.DeviceBuffer<T>: gpu.upload(data) copies once, the kernel declares the parameter as a gpu.DeviceBuffer<T> and sees it like a T[], download and write move data explicitly, and dispose frees it. download waits for every queued launch; a buffer.readback() (start(), then wait(into)) waits only for those queued before its start; readback(overlap = true) also lets the launches queued after start run during the copy, provided they do not write the buffer. To send data without waiting, keep it in a gpu.hostBuffer<T>(count) (page-locked memory, filled with write(from, at)): buffer.write(from, start, count, at) queues the copy of a range behind the launches queued so far and returns at once; with overlap = true the copy runs beside the launches queued after it, which must leave the range alone until buffer.join(). async cuda def returns a future, so the scheduler keeps other tasks running while the driver works:

device_buffer.adm
use std.gpu

async cuda def step(cells gpu.DeviceBuffer<byte>, width int, height int) !none { ... }

def simulate(initial byte[], width int, height int, generations int) !byte[] {
	let cells = try gpu.upload(initial)
	for let g in 0..generations {
		try await step(cells, width, height)     // no copies between launches
	}
	let out = byte[]
	out.len(cells.len())
	try cells.download(out)
	cells.dispose()
	return out
}

Closures

A def block can be declared inline as a value - with the same parameter and return syntax as a top-level function - and called immediately, or stored and called later:

anonymous.adm
let value = def () int {
	let start = now()
	defer log("elapsed", now() - start)
	return compute()
}()

It closes over surrounding variables by reference, the same way a deferred call does - give it a name and it can even call itself, which is otherwise the only reason to name one:

anonymous_recursive.adm
let f = def fib(n int) int {
	return n when n < 2
	return fib(n-1) + fib(n-2)
}

An anonymous function can still have a sql or cuda body - but it can't be meta or infix, and doesn't accept internal or atomic; those only make sense on a named declaration.

A lambda is sugar over the same thing: param => expr expands to an anonymous function that returns expr. The parameter type can be dropped when it's already known from context, like a callback argument:

lambda.adm
let a = int[1, 2, 3]
let b = a.mapElem(v => v + 1)
let c = a.mapElem((v int) => v + 1)

param => { ... } takes a statement body. Its result is what the place asks for, with the parameter types written or not: handed to a def (int) int it returns an int, handed to a def (int) or kept in an untyped let it returns nothing, and a return with a value there is an error. A call of a function that returns nothing gives no value either: let x int = log("a") is warned of (W-TYPE-0002) and x holds its type's zero value, an untyped let y = log("a") holds none, and _ = log("a") discards the call without a warning.

Defer

defer schedules a call to run at the end of the current function's scope - typically cleanup, like closing a resource opened earlier in the same function:

defer.adm
def run() {
	let f File = new()
	defer f.close()
	defer print("second")
	print("body")
}

Multiple deferred calls run in reverse order - last deferred, first run - so the code above prints body, then second, then finally closes f.

Error Handling

Errors are values, not exceptions. A function that can fail says so in its return type, and a failure propagates up the call stack on its own until something tests for it, recovers it, or the program crashes.

Errorable Types

!T marks a value that might be a T or might be an error. If a function's body contains fail, its return type has to be errorable - the compiler checks this, not just a convention:

errorable_required.adm
// compile-time error: fail requires an errorable return type
def name(a int) int {
	if a == 0 {
		fail "invalid"
	}
	return a**a
}

// compiles: the ! marks the return type errorable
def name(a int) !int {
	if a == 0 {
		fail "invalid"
	}
	return a**a
}

An errorable value has to be dealt with before you can use it as a plain value - either test it, or use try to propagate the error to whoever called you instead of handling it here:

try.adm
// open returns !File - f might be a File, or an error
let f !File = open("file")

// try unwraps it to a plain File, or bails out of this function
// with the same error if open failed
let f File = try open("file")

fail / onerror

fail mirrors return, but returns an error instead of a result. Failing with a string is shorthand for building the builtin Error struct; failing with any struct that has the same shape works too, so you can attach extra fields. A handler tells them apart with is: err is MyCustomStruct is true for an error raised with that struct and reads its fields in the branch; err is Error is true for the plain error a string or an Error literal raises.

A return without a value ends a function that has no result, one declared none, and one declared !none (it returns without a failure). In a function with any other result it is an error.

fail.adm
fail "reason"

// same as
fail Error{message: "reason"}

// a custom error struct, as long as it has the fields of error
// (message, code, cause, stack)
fail MyCustomStruct{message: "reason", traceid: id}

onerror attaches a handler to a call. recover stops the error there and supplies a fallback - a value, another call, or a whole block if you need more logic - instead of letting the error keep propagating:

onerror.adm
def mightFail(flag bool) !int {
	if flag {
		fail "boom"
	}
	return 7
}

// recover with a plain value
let a = mightFail(false) onerror recover 99

// recover with access to the error itself
let b = mightFail(true) onerror (err error) recover 99

// bail out of the surrounding function instead of recovering a value here
let c = mightFail(false) onerror return 0

A block form works when recovering takes more than one expression:

onerror_block.adm
let h = open("file") onerror (err error) {
	log("Error opening file: {err}")
	recover try open("default") unless error.critical
}

Contracts

expects and provides wrap a function with assertions - preconditions checked before the body runs, postconditions checked after, no matter which return path was taken. Because a failed assertion is itself a failure, a function using either block has to return an errorable type:

contracts.adm
def guarded(x int) !int {
	expects {
		assert x > 0
	}

	let y int = x + 1
	return y

	provides {
		assert y > x
	}
}

assert also works on its own, outside expects/provides, anywhere a boolean check makes sense. Either way, a failed assertion can have its error message overridden with onerror:

assert_onerror.adm
def createUser(name, email string) !User {
	expects {
		assert name != "" onerror fail "Name is required"
		assert email != "" onerror fail "Email is required"
	} onerror (err error) fail ExpectationErrors{message: "Invalid input", cause: err}

	// save user to db and create a User instance
	return user

	provides {
		assert user != none
		assert user.name == name
	}
}

Transactions

begin and rollback blocks declare how a function saves and restores state, but they only actually run when that function is called from inside a transaction { ... } region - outside one, they're skipped entirely:

transaction.adm
let state int = 0

def someFunction(x int) {
	begin {
		state = state + x
	}

	rollback {
		state = state - x
	}

	state = state + 1
}

Inside a transaction block, any fail triggers every available rollback automatically; rollback can also be called directly to trigger it by hand. onerror on the transaction itself handles whatever error comes out the other side:

transaction_run.adm
def run() !none {
	transaction {
		someFunction(5)
		rollback
	} onerror (err error) {
		recover none
	}
	return none
}

A resource can also end with the block: anything with commitTx() !none and rollbackTx() !none, such as a database transaction. A library joins it to the running block on first use; when the body is done the block commits its resources in the order they joined, and a commit that fails rolls back the ones after it and arrives in onerror. When the body fails they are rolled back, newest first. A nested block joins the outermost one.

Concurrency

Three primitives cover concurrency: futures for a single background result, channels for a pipe between workers, and select for waiting on several channels at once.

Async / Await / Futures

An async function returns a future - async T - instead of a T directly. await unwraps it; using an async T without awaiting it (or propagating it onward as async) is a type error:

async_basic.adm
async def fetch() int { return 41 }

def new(args string[]) int {
	let a async int = fetch()
	let b int = await a
	print(b)
	return 0
}

Any call - async function or not - can run in the background just by prefixing it with async:

async_prefix.adm
def compute(x int) int {
	return x + 1
}

let f async int = async compute(41)
let v int = await f

await composes with &&/||, using the same short-circuit logic as booleans but over completion state instead. await (a || b) resumes on whichever finishes first and cancels the other (unless it called .keepAlive()); await (a && b) waits for both:

await_compose.adm
async def slowInt() int {
	// ... slow work ...
	return 7
}

async def fastString() string {
	return "ok"
}

let a = slowInt()
let b = fastString()

let v = await (a || b)
print("string") when v is string
print("int") when v is int

A future exposes .running()/.done()/.canceled() to check its state, .cancel(?reason) to request cancellation, and .timeout(duration) to set a deadline:

future_timeout.adm
let homepage async string = load("https://adm-lang.dev")
homepage.timeout(3s)

Cancelling is cooperative. A cancelled or timed-out future is finished at once, so await returns without waiting for the body and never panics: an async T gives the value an uninitialised let of T holds (0, "", an empty array, a default instance, nothing for any or a union), an async ?T gives none, and an async !T gives an error with code canceled or timeout whose message is the reason passed to cancel. .canceled() tells a cancelled result from a computed one, and so does the tag canceled: on the value itself when it is an object, on the variable an await initialises when it is a number, a bool or another scalar (let n = await job, then n.tags().has("canceled")). Cancelling an a || b or a && b cancels both operands, except those kept alive.

The task running the body is told, not stopped: its time.sleep returns early, an await inside it cancels the future it waits for (unless that future is .keepAlive()), and a socket operation it waits in fails with code canceled. A channel it waits on behaves, for that task only, as if it were closed and empty: a receive yields the zero value, a for over the channel ends, a select runs no case, and a send with no room does not send; the channel stays open for everyone else. Code that computes without waiting asks canceled(), which is true in a cancelled task:

canceled.adm
async def index(files string[]) int {
    let count = 0
    for let file in files {
        return count when canceled()
        count += indexOne(file)
    }
    return count
}

A future cancelled before its task first runs never runs its body. A task waiting for a lock or inside foreign code is not interrupted.

An AsyncScope handles futures as a group. async<group> call() starts a call as a member (the scope is a name, a field or a call), and a future that already exists joins with .scope(group); await group returns when no member is running and fails with the first member's error. A member that fails cancels the others (new AsyncScope(failFast=false) lets them finish), and a scope that is dropped cancels the members still running:

async_scope.adm
def load() !Page {
    let group = new AsyncScope()
    let profile = async<group> fetchProfile()
    let teams = async<group> fetchTeams()

    try await group
    return new Page(await profile, await teams)
}

group.cancel(?reason) cancels the members still running and those that join later, group.timeout(duration) gives the group one deadline, group.limit(count) lets at most that many members started with async<group> run at once, group.running(), group.done() and group.len() report its state, and group.errors() lists the errors of the members that failed. group.wait() is the method form of await group.

try and await compose in either order on an errorable future - try await f() and await try f() both work.

An onerror handler may follow an awaited expression, as it may follow a call: let page = await task onerror recover emptyPage(). It takes the failure of the awaited !T and leaves the T, for a future in a variable, an awaited call (await fetch(id) onerror ...) and an AsyncScope (await group onerror (err error) { ... recover none }).

A TaskLocal<T> holds one value per running task. set(value) stores it for the task that calls it (none clears) and get() reads it later in the same task as a ?T, however deep the calls between them. Declare it once, as a module let. A task started with async begins with the values of the task that started it, as they were at that moment; what either one sets afterwards the other does not see, and a task's values are dropped when it ends.

task_local.adm
let currentRequest = new TaskLocal<Request>()

def handle(request Request) {
    currentRequest.set(request)
    route()
}

def audit(action string) {
    let request = currentRequest.get()
    if request is Request {
        record(request.id, action)
    }
}

Channels

A channel is a typed pipe between workers - chan <- value sends, <-chan receives. An unbuffered channel (the default) blocks the sender until a receiver is ready; a buffered one has room for values to queue up:

channel_basic.adm
let ch channel<int> = new(1)  // buffered, room for 1
println(ch.len())
ch <- 7
let v = <-ch
println(v)

new channel<T>(size=?, mode=?) is the full constructor - size defaults to 0 (unbuffered), and mode (ChanMode.FIFO or ChanMode.LIFO) defaults to FIFO. A channel exposes a closed property, and the range form of for drains it until the producer closes it: for let value in channel { ... }.

ch.offer(value) sends only when that needs no waiting and returns whether it sent: a buffered channel must have room, a channel of size 0 a receiver waiting. It never waits and returns false on a closed channel. A wake-up signal several tasks may raise is signal.offer(none) on a channel<none>(1).

Select

select waits on several channels at once, running whichever case is ready first, or default if none are:

select_basic.adm
let ch channel<int> = new(1)
ch <- 42

select {
	case ch:
		let v = <-ch
		println(v)
	default:
		println(0)
}

There's no fall-through, so continue isn't allowed inside a select case - every case either handles its event or control leaves the select entirely.

Value Tags

Every reference value carries an optional set of string tags, reached through .tags(). Tags are metadata for tracing, ownership tracking and policy checks: a request marked as coming from the primary network, a string that has been signed, a record holding personal data. They are not part of the value's type.

Tagging Values

value.tags() returns the value's TagSet, creating an empty one on first use. The set offers add, remove, toggle, has, list, len, clear and set(spec), where the spec is whitespace-separated: +tag adds, -tag removes, a bare tag adds, and the empty string clears everything. Mutators return the set, so calls chain.

tags.adm
let request = new Request(7)
request.tags().add("Network/primary").add("pii")

let message = "hello"
message.tags().set("+signed")

if request.tags().has("pii") {
	redact(request)
}
request.tags().set("-pii +audited")   // remove one, add another
request.tags().set("")               // clear

Strings, arrays, maps, channels, errors, any, closures and instances of declared types carry their tags on the object itself - struct instances included, since they are heap-backed. Numbers, bool, char and enums have no identity, so their tags belong to the variable holding them: let x = 5; x.tags().add("trusted") tags x's storage, and the compiler moves the tags with the value through bindings, arguments and returns. A copy shares the set until its first mutation, then gets its own - tagging a copy never changes the original. Only named storage can be tagged: 5.tags() and (a + b).tags() are compile errors. Tags do not cross indirect calls, and scalar array elements or struct fields do not carry tags yet.

Propagation Rules

A tag set belongs to the value's identity. It follows the value wherever the value itself goes: assignments, function arguments and returns, container slots, channel sends, futures and errors. A callee can therefore check what it was handed:

propagation.adm
def send(message string) !none {
	fail "refusing an unsigned message" unless message.tags().has("signed")
	// ...
	return none
}

let items Request[]
items.push(request)
items[-1].tags().has("pii")   // true: the same object sits in the slot

let ch channel<Request> = new(size=1)
ch <- request
let got = <-ch                  // got.tags() is request.tags()

A copy is a new value and starts with an empty set: a + b on strings, clone(), slicing, map.values() and a freshly created error inherit nothing from their inputs. The set lives exactly as long as the value and is released with it.

Services

A service behaves like an OS service: the ADM service manager starts it before application.new runs and stops it on shutdown, and it owns exclusive access to one slice of the platform - networking, storage, the UI surface, whatever it's responsible for.

A service method is one of three kinds: @query() reads a snapshot of its state, @commit() mutates it and emits a typed Event<T> that other code can react to, and @emit() only exists to describe that event's shape - it can't be called directly. An event emitted inside a commit reaches its handlers after the outermost commit completes; @emit(immediate = true) runs them at the call.

Those annotated methods are the whole public surface. Fields are always private to the service, and a def without an annotation is internal: callable from the service's own methods, refused from anywhere else (method "helper" is internal to service Counter). The manager's members are the exception: the ServiceDescriptor fields (description, dependencies, lifecycle, permissions, status) and the ServiceHooks methods (config, start, stop, restart) stay reachable. @commit() may be async; @query() and @emit() may not.

service.adm
use std.services::(Dependencies, Lifecycle, RestartMode, StartMode)

service Network {
	description  = "Outgoing connections"
	dependencies = Dependencies{hard: [], soft: []}
	lifecycle    = Lifecycle{start: StartMode.Manual, restart: RestartMode.Never}
	permissions  = []

	connections Connection[]

	def config<T>(val T) !none {
		return none
	}

	async def start() !none {
		return none
	}

	async def stop() !none {
		return none
	}

	async def restart() !none {
		return none
	}

	@query()
	def connectionCount() int {
		return connections.len()
	}

	@commit()
	async def open(address string) !Connection {
		let con = try dial(address)
		connected(address)
		connections += [con]
		return connections[-1]
	}

	@emit()
	def connected(address string) string {
		return address
	}
}

@on(Network.connected)
def log(evt Event<string>) {
	println("Connected to {evt.result}")
}

Network.open("...") calls straight into the service like any other function. The handler subscribed with @on(Service.method, ?filter) takes exactly one argument - Event<T>, where T is that method's return type - and an optional filter to only react to some events. What the event carries, when handlers run, and how the manager starts, restarts and reports services is covered under Services โ†’ Events and observers.

When the program ends, the started services stop, each before the services it names as hard dependencies, so a stop() can still use them; services with no such relation stop in the reverse of the order they were registered in. A service's @persist(key) fields take their kept values when the program starts, before any service's start() runs.

Services can't be constructed with new and can't declare their own new/dispose - the manager owns that lifecycle entirely. @commit() methods are implicitly atomic; @query() and @emit() can never be async; and infix is rejected outright, since nothing ever operates on a service like a value. A service can still be internal or partial like anything else.

ADM ships a set of builtin services that cover the platform surface every application needs, each in its own module std.services.<name> (use std.services.cache::(Cache)). Every method signature and every annotation they provide is listed in the services reference.

ServiceDescription
ApplicationIdentity of the running application (PID, executable path, version, uptime), its home folder, shutdown and exit.
RuntimeThe ADM runtime: system and memory information, environment variables, the service list, starting programs.
StorageFiles and directories: open, read, write, list, memory maps.
NetworkSockets, addressing and name resolution; std.net is the usual way in.
CacheIn-memory key-value cache with TTL and idle expiry.
SchedulerCron-style scheduling of functions at intervals or times.
PolicyPermissions and policy decisions.
DiagnosticsMetric registry: counters, gauges, histograms.
NotificationsDesktop notifications.
ClipboardText copy and paste through the system clipboard.
DevicesCameras, microphones, GPUs, USB and other devices.
DebuggerCPU and memory profiles, call traces.
UIWindows, the renderer, UI events (std.ui).
Config plannedLayered configuration with profiles.
LoggingLeveled logging with structured fields, to console, file and network writers.
MessagingIn-memory queues by topic: consumer groups, priorities, retries and dead letters.
I18n plannedLocale selection and message catalogs.
IPCRemote interfaces between processes: @remote<I> serves an interface, IPC.connect<I> calls it, over TCP, TLS or Unix sockets.
TelemetrySends metrics, logs, errors, usage events and spans to a configured server (OpenTelemetry), with the user's consent.
VaultEncrypted key/value store for secrets, values and keys.

UI

ADM's UI is built from three pieces bound together into a component: a view (structure), a style (appearance), and a controller (behavior) - all written in plain ADM, not a separate template language.

The standard component set is std.ui.components: Button, Text, Row, TextField and the rest. A name in the samples below that is not in it (MessageCard) stands for a component you declare yourself.

UI components goes through the same system as a guide, with the windows and a picture of every component std ships.

Views & Styles

A view body is a regular ADM block. Components are instantiated like function calls with arguments passed by name (Button(label = "Logout", onClick = doLogout)), children go in a trailing { ... } block, and ordinary control flow - if/else, for, match, the when guard - works right inside it:

view.adm
view MyView {
	Button(label = "Logout", onClick = doLogout) when user.isLoggedIn

	if user.isLoggedIn {
		Text(text = "Welcome back, {user.name}")

		for let (_, item) in messages {
			MessageCard(item)
		} empty {
			Text(text = "You have no messages")
		}
	} else {
		Column(gap = 8(px)) {
			Heading(text = "Welcome")
			TextField(email, placeholder = "Email", kind = TextKind.Email)
			TextField(password, placeholder = "Password", kind = TextKind.Password, onSubmit = submit)
			Button(label = "Login", onClick = submit)
		}
	}
}

A style body is the same idea applied to appearance: a regular block, run in a scope that predeclares context variables - centered, box, on, children, plus layout references like root/parent/prev/next. Assigning a struct literal to one overrides the base style; let introduces a reusable snippet:

style.adm
style MyStyle {
	let width = 100

	centered = {
		align: root.center,
		size: width,
	}

	box = {
		padding: width / 10,
		backgroundColor: prev.backgroundColor,
		right: min(next.left - 10, root.right - 30),
	}

	if parent.width >= 100 {
		centered.box.backgroundColor = Colors.alert
	}

	// per-state overrides
	on = {
		hover: { cursor: "pointer" },
		pressed: { transform: scale(0.97) },
	}

	// style child components by name
	children = {
		Button: { ...loginButton },
		VStack: { spacing: 20 },
	}
}

Views and styles can't be partial or annotated - they're pure templates. Declare one standalone with a name, like MyView above, and a component can reference it by that name; or write it anonymously, inline inside the component itself.

Components

A component binds a view, a style, and a controller type together. The UI service asks for a window or surface and renders the component tree - the same component source targets a desktop window or a WASM/web canvas; the backend decides which.

Reference named view/style/type declarations, or write every piece anonymously inline - both are the same construct:

component_forms.adm
component myComponent {
	use lib

	view lib.MyView
	style lib.MyStyle
	type lib.MyType
}

component myComponent {
	view { // anonymous view
	}
	style { // anonymous style
	}
	type { // anonymous controller
	}
}

The controller type is where state and event handling live - a regular type, following every rule from Types (imports, annotations, internal members, infix methods, all of it):

component_controller.adm
use std.ui
use std.ui::(UI)

component SubmitButton {
	view {
	}
	style {
	}
	type {
		label   string = "Submit"
		loading bool   = false

		@on(UI.click)
		def onClick(evt Event<ui.ClickEvent>) {
			loading = true
			// do some work here
		}
	}
}

A component can be internal: it stays inside its module, a part that the module's own components place and that nothing outside can name, so a control split into parts exposes only the one that assembles them. A component can be partial, but its embedded view and style can't be - only the component wrapper splits across files. UI events are delivered through the UI service; a controller method opts in by annotating itself, the way onClick does above.

children() as a statement of a view is the place of what the caller wrote in the block after the call: view { Text(title); children(); Divider() } puts the block's components between the view's own two parts. It may stand under an if and in the block of a call of the view (Box() { children() }), which hands them on to that child. Their arguments are evaluated in the caller's view, and their parent() is the component whose view placed them. A block that reaches a component, on a call of a component whose view has no children(), is a compile error.

A slot is an input typed view. The caller fills it inside the block of the call with an assignment, and the component's view places it by calling it:

card.adm
component Card {
	type {
		@input
		title string = ""
		// A slot with a fallback, and an optional one.
		@input
		header view = view { Text(title) }
		@input
		footer ?view = none
	}
	view {
		header()
		children()
		if !(footer is none) {
			footer()
		}
	}
	style Plain
}

Card(title = "Profile") {
	header = view { Icon("user"); Text(user.name) }
	if canEdit {
		footer = view { Button("Edit") }
	}
	Text(user.bio)
}

view { ... } is an expression: its statements are a view's, written in the scope of whoever writes it, and it has no box of its own. Where it is placed its components become children of the component around the call; placed twice, it is mounted twice. A slot has a fallback, shown while nothing filled it, or is optional and placed under if !(name is none). Only slots are assigned in a block, and a slot whose name is also in scope where the block is written is a compile error: one of the two is renamed. A slot that receives something is a function returning a view (row ?(def(Item) view) = none, placed with row(item)), filled with row = (it Item) => Row(it): a component call written where a view is expected is the view of that call.

ref = name on a component call of a view names the component the call places and declares name as a member of the component, usable in its type, style and view. The member is a weak ?TextField for TextField(ref = named): the view sets it when the child is built, and it reads none while the child is not in the view (TextField(ref = named); Label(target = named)). No field is written for it; a field or method of that name is a duplicate identifier. A ref is given once per view and not inside a loop.

A component takes type parameters after its name, as a type does: component List<T> { ... }. The parameter is in scope in its type { } and its view (items T[], def first() ?T), and List<File> is a type (weak list ?List<File>, c is List<File>). A call writes the type arguments, List<File>(files), or leaves them to its arguments, List(files); a parameter no argument settles takes its default or is a compile error. Each instance is compiled for its own type arguments. A ref on such a call declares its member with the instance the call makes (List(files, ref = list) gives a ?List<File>). The component's inline style { } reads self, the refs and the type parameters in its values; only a let of that style cannot be typed with a type parameter.

A component may name its controller as a type declared apart and marked @controller(): component Counter { ...; type Counting }. The component has that type's fields, inputs and methods as if they stood in its own type { }, and inside the controller self is the component. Several components may name one controller, each with its own state; the controller may be a partial type split over files, and for a generic component it declares the component's type parameters (type Listed<T>). @emit and ref need the inline type { }.

A method of a component's type { } marked @emit() is an event of the component. A listener is a function or method marked @on that takes ComponentEvent<T> (source, name, result; T is none for an event without a result): @on(Button.clicked) hears every button, @on(save.clicked) the one the ref save holds, and @on(Button.clicked, ref("save")) those a selector matches. A listening method runs for each live instance of its type.

The unnamed argument of a component call (Text("hi")) sets the controller field marked @input(value = true); every other argument is passed by name. A field declared @input(bind = true) is an input its component may assign: the assignment reaches what the caller passed when that is a field marked @bindable (itself, or its whole struct or type) or a property with a setter, named alone or as the last member of a path such as model.email. Anything else the caller passes is a copy.

A field of a component's type { } marked @state is a state of the component: an assignment that changes its value emits UI.stateChange (in std.ui) with StateChange { source; state; value; previous }, the component, the field's name, the new value and the one it replaced. Listeners (@on(UI.stateChange), taking Event<StateChange>) run at the assignment, whoever makes it: a method, other code, the parent's view passing the input another value, a bound input assigned by the child. Any task may assign a state: a statement that reads or assigns one holds the program's state lock, which the UI loop holds for each pass and a task gives up while it waits. Numbers, strings and enums compare by value, objects, arrays and maps by identity; an equal value emits nothing, nor do the values a component is built with. A change in place of what the field holds counts as a change too (counts[key] = 1, items[i].done = true, files.push(f): an element or member stored through it, or a @mutates method of a value reached from it). A selector picks the changes a listener hears: @on(UI.stateChange, ref("mainTabs").state("selected")), or own().state("file") in a method of the component itself.

Every component has the members of the prelude's Component<T>: inputs such as enabled, the tree (childNodes, parent()) and the lifecycle (attached(), detached(), render(ctx), rerun() which runs its view again, unmount() which lets go of the children its view placed once it has left the tree, setChildren(given) which makes the components of given what the view places with children(), for a component made in code that holds others). A controller method with the same name and parameters replaces one of them for that component, except rerun() and unmount(), which the compiler writes from the component's view: declaring either is an error that asks for another name. The UI draws a component through render(ctx RenderContext) only, so a controller whose render methods all take something else (render(ctx DrawContext), render()) is an error too: none of them would ever run. A value typed component holds any component, and a call such as c.render(ctx) runs the method of the component it holds. c is Scroller tests whether that component's controller implements an interface and adds the interface's methods in the branch; c is ScrollView narrows to the component itself.

A view names its controller's fields, properties and methods directly in the arguments of the components it places (Text(text = title)). The view runs again in the next frame once its component changed: one of its fields (an input, a state or any other) took another value, it called redraw(), a component it names with ref changed, or the window, the theme or a state such as hover did. An argument whose value differs from what was passed the last time is then assigned to the child's input, which changes the child in turn; so is one that is, or comes from, a @state or @input field changed in place since the last run, though it is the same object. What a view reads through any other reference (a model object changed in place, a service, a global) shows once the component, or one above it, calls redraw(). An if, match or when in a view is evaluated again too, so components enter and leave with their branch, and a loop is iterated again, keeping its children by position. A view body must therefore be cheap and without effects. Literals and handlers (onClick = f) are bound when the component is built; a handler names a function, a method of the controller, or is a function literal closing over the controller and the view's locals (each child of a loop gets the values of its own pass). Optional and tuple arguments are compared member by member. new Name(...) on a component is the call Name(...): the view is mounted.

c.style of a value typed component is typed style, the kind of every style, with the members every style has (variant, geom). is with a datatype tests whether the style has the datatype's fields and narrows it in that branch, where the fields are read and written on the style itself:

style_view.adm
datatype Sized {
	width, height Sizing
	visible bool
}

def hide(c component) {
	let look = c.style
	if look is Sized {
		look.visible = false
	}
}

look is Card, with a style declaration, tests that the value is that style or a style that embeds it, and narrows it to a view of Card: its properties are read and written on the style itself. style.clone() returns a copy of a style with the same property values, which no component has as its style.

Enter and exit

A style body may assign enter and exit: the look a component appears from and the look it leaves to. Each lists properties of the style; a nested literal changes the fields it names of the value the property holds. A component whose style has Motion travels between that look and its own over its transition (style.transition = Transition{duration: 150ms} in the controller).

fade.adm
style Fade {
	Box
	Compositing
	Transforms
	Motion
	enter = {opacity: 0.0, transform: {translate: {y: 8(px)}}}
	exit = {opacity: 0.0}
}
  • style.enter and style.exit are functions, ?def(look style, owner component), none in a style that writes none: calling one assigns the listed properties on the style given (a clone(), when the component's own look must stay) for the component given.
  • A value may name what the module sees and the style's own lets. Embedding a style does not bring its enter and exit. A style body may also assign on, the looks of states: on = {hover: {opacity: 0.8}, [hover, selected]: {...}, disabled: {opacity: 0.4}}. A state is hover, pressed, focused, focusWithin, focusVisible, disabled, dragging or dropTarget, a @state bool field of the component, or a case of one of its @state enum fields; a list of states and an on inside an entry hold when all of them do. Entries that hold are applied in the order written to a copy of the style the component is drawn with; a name that is no state of the component is an error. A component's style is its resolved style: a property read through it (style.opacity, $ref.opacity) comes from that copy while a state holds, and an assignment is to the style itself. A style that embeds styles with an on has their entries, applied before its own. A style embedded twice, or reached through two styles that both embed it, is one copy of its properties; two different declarations of one property name are an error.
  • A child a view no longer places, whose style has an exit, leaves children for its parent's leaving and stays drawn until the exit has played; parent.finishLeaving(child) lets go of it (the UI runtime calls it). Its ref member reads none meanwhile.
  • A view that reaches the call again before that (the branch taken again, the same key in a loop, the same position in a loop without keys) places the same component, which travels back.
  • A component's own field may not have the name of a field every component has (children, leaving, enabled).

Attributes & Modifiers

Attributes (@name(...), or just @name when no arguments are passed) attach metadata to a declaration. Modifiers (internal, partial, ...) change how a declaration behaves. Both have appeared in earlier sections; this one is the reference for each. Every attribute the compiler and the standard library ship is catalogued on the Annotations page.

Attributes

An attribute's arguments can be positional or named, same as a regular call, and every attribute is preserved in compiled metadata, where tooling and runtime reflection can read it:

env.adm
@env("APP_REGION", defaultValue = "eu-west")
internal const region string

meta def is how an attribute like this gets defined in the first place - it's what @trim from Params & Returns actually is:

meta_def.adm
meta def trim<string>(target reflection.ParameterMetaContext) {
	let v = string(target.get())
	target.set(v.trim())
}

The context parameter's type is what limits where an attribute can be used - one that takes a ParameterMetaContext only makes sense on a parameter, one built around a service-specific context only makes sense on a service. Overload the same meta def with a different context type to let it target more than one kind of declaration.

A handful of attributes are built into the compiler itself. @cfg(...) conditionally compiles a declaration; an excluded copy is not even required to parse:

cfg.adm
@os("linux", "darwin")
const SEPARATOR = "/"

@os("windows")
const SEPARATOR = "\\"

@os, @arch, @build, @feature, and @version are sugar over the full @cfg(os=, arch=, build=, features=, version=) form; @os and @arch take several values, any one of which keeps the declaration. Keys within one @cfg all have to match (AND); stacking more than one @cfg on the same declaration means any one of them matching is enough (OR). os/arch come from ADM_OS/ADM_ARCH (or the host by default); build/features come from adm build flags like --release --features simd,fastmath.

A file can say the same with its name: platform_linux.adm belongs to Linux alone, as if every declaration in it carried @os("linux"). The names are the targets of adm build --os (linux, darwin, windows, android, ios, wasm), tests included (keys_wasm_test.adm). A build for another system never reads such a file, so web code costs a Linux build nothing.

@embed(path) is the other built-in: it bakes the file at path (relative to the source file) into a byte[] constant at compile time; @embed(path, gzip = true) stores it gzip-compressed, to inflate with std.data.compress.gzip.Reader.

Two more fill in a declaration's value from the outside world. @env(name, defaultValue) reads an environment variable, and @flag, from std.cli, fills a variable from the command line when the program first uses it; the variable's type says what the flag takes, its name is the flag's name, its declared value the default and its doc comment the help text --help prints:

env_flag.adm
module demo.globals {
	use (
		std.cli::(flag)
		std.os
	)

	@env("ADM_TEST", "fallback")
	const tmp string

	// Enable debug mode.
	@flag
	let debug bool = false      // --debug

	// Port to listen on.
	@flag(short = "p")
	let port int = 8080         // --port 9000, -p 9000

	// How long a request may take.
	@flag
	let wait duration = 30s     // --wait 1m30s

	// File read instead of standard input.
	@flag
	let input ?os.File          // --input ~/notes.txt
}

Modifiers

Every modifier in the language, and the section that covers it:

ModifierApplies toMeaning
internalmodules, members, services, unions, enums Limits visibility to the current package.
partialapplication/library/plugin/module/type/service/component Splits a declaration across multiple files in the same directory. See Program Structure.
atomicfields, functions, properties Locks per-instance around the call. See Params & Returns.
weakfields A non-owning optional reference (weak parent ?Node): never keeps its target alive, reads none once the target is gone. For back references, so parent and child form no cycle. See Memory โ†’ Weak references.
asyncfunctions, types Returns a future instead of running immediately. See Async / Await / Futures.
cuda, sql, metafunctions Body is a foreign DSL, or (for meta) defines a new attribute. See Params & Returns.
infixmethods Operator-style invocation (a + b). Only on type methods - see Types โ†’ Methods.

Interoperability

ADM calls into any language that exposes a C-compatible ABI directly, builds Zig, Rust and Go sources into the program, and reaches JavaScript, Lua, Python, Java, .NET and WebAssembly through libraries. Only a module body can host a foreign declaration - a function signature with no block body, ending in ;:

ffi.adm
module ffi.math {
	// a shared library
	@native("libc.so.6")
	def getpid() int32;

	// a C header
	@c("math.h")
	def cbrt(x float64) float64;

	// a C symbol under another name
	@c("math.h", symbol = "fabs")
	def absolute(x float64) float64;
}

A few rules: only module bodies can have a body-less def - applications, types, and services always need a real one. They can't be sql, cuda, meta, or infix, and every parameter/return type of a C declaration has to be representable in C - no channels, optionals, errorables, or ADM-only composites. atomic/async still work as wrappers around the call even though the body itself is foreign.

For binding a whole library at once instead of one function at a time, annotate the module itself - this is the same @link("GL") mechanism Modules & Namespaces already showed for the OpenGL bindings:

ffi_module.adm
@c("libm.so")
module Math {
	def sin(x float64) float64;
	def cos(x float64) float64;
}

Every def inside becomes an implicit FFI declaration, without repeating the annotation on each one.

Zig, Rust and Go sources

A module can name a source file of another compiled language. The build compiles it with that language's own compiler and links it into the program, so there is no library to build by hand and no @link line:

numbers.adm
@zig("numbers.zig", prefix = "numbers_")
module numbers {
	def hash(data byte[], n uint) uint;

	def primes(out uint32[], capacity uint) uint;

	// Zig calls the ADM function it is given.
	def collatz(start uint, visit def(uint)) int;
}
  • @zig("file.zig") builds a Zig file: each declaration is one of its export fns.
  • @rust("file.rs") builds a Rust file, and @rust("folder") a crate (the folder with Cargo.toml) through cargo: each declaration is a #[no_mangle] pub extern "C" fn. A single file takes no crate attributes (#![...]).
  • @go("folder") builds a Go package: each declaration is a function the package marks //export. The package is an ordinary one inside a Go module, not package main.
  • The path is relative to the source file. prefix is what the exported names start with (hash above is numbers_hash); the same annotation on a function with symbol = "..." names an export that does not follow it.
  • Types cross as they do for @c: numbers, bool, strings as C strings, arrays of numbers as a pointer to their elements, structs and unions by value, functions as function pointers.
  • The build needs the language's compiler on PATH (or named by ADM_ZIG, ADM_RUSTC, ADM_CARGO, ADM_GO) and downloads nothing. A panic in Rust or Go ends the program.
  • After a Zig or Rust source is built, each declaration is compared with the function it exports: a parameter or result of another width, sign or kind, another number of parameters, or a function the source does not export stops the build with both sides named. Go declarations are checked for existing only.

Scripting languages

A scripting language comes as a library that embeds its engine: the engine's C sources ship with the library and are compiled into the program, so nothing else has to be installed. Add one to a project with adm get admlang:adm.interop.js (or adm.interop.lua, adm.interop.python). Write the script as an ordinary module of its language, then declare its functions in ADM without a body, under the library's annotation:

billing.js
export function total(items) {
    return items.reduce((sum, item) => sum + item.price * item.quantity, 0)
}
billing.adm
module billing {
	use adm.interop.js::*

	struct Item {
		price    float
		quantity int
	}

	@js("billing.js")
	def total(items Item[]) !float;
}

let due = try billing.total(cart)
  • The file is named relative to the source file and is built into the program; nothing is read at run time, and changing the script rebuilds the module. Each declaration is the script's function of the same name.
  • Arguments and results convert by their declared types: numbers, bools, strings, byte[], arrays, structs (field by field, nested ones included), map<string, V> and optionals. A number that does not fit the declared type fails the call.
  • A script that throws fails an errorable declaration (!T) with a ScriptError; a declaration that cannot fail panics instead.
  • The library's Runtime type is the engine itself, for scripts that arrive at run time: eval, values, calls in both directions, and limits on memory and time for code the program does not trust.

Lua works the same way, with a file that returns a table of functions:

shop.adm
module shop {
	use adm.interop.lua::*

	struct Customer {
		name   string
		years  int
		orders int
	}

	@lua("rules.lua")
	def discount(customer Customer) !float;
}

Python works the same way through adm.interop.python, which embeds MicroPython: @python("rules.py") binds a declaration to the function of the same name in the file.

These annotations are ordinary meta defs, so a library can bind any engine the same way: a body-less declaration under a function meta is answered by the meta, which reads the arguments, takes the script as a file built into the program and supplies the result. See Writing your own.

Java and .NET

adm.interop.java and adm.interop.dotnet load the Java VM or the .NET runtime installed on the machine when the program first uses it; building the program needs neither. A declaration without a body is a static method of the class or type the annotation names:

pricing.adm
module pricing {
	use adm.interop.java::*

	@java("Pricing")
	def total(prices float[], quantities int32[], customer string) !float;

	// Java's null arrives as none.
	@java("Pricing")
	def coupon(code string) !?string;
}

try java.runtime().start(classPath = ["classes"])
let due = try pricing.total(prices, quantities, "ana@partner.example")
  • Numbers, bools, strings and their arrays cross each way, copied. @dotnet("Acme.Pricing") declares a .NET method the same way; a declaration total also binds Total.
  • An exception fails the call with a ScriptError that carries the stack trace and the exception itself as a value.
  • runtime() reaches everything else: load a class or type, construct objects, invoke methods, get and set fields and properties. An ADM function goes where Java takes an interface and .NET a delegate or an interface.
  • Running the program needs Java 8 or later (JAVA_HOME, or java on PATH), or .NET 8 or later (DOTNET_ROOT, or dotnet on PATH).

WebAssembly

adm.interop.wasm runs WebAssembly 2.0 modules, the 128-bit vector instructions included. It is written in ADM, so it adds no foreign code to the program:

run.adm
use adm.interop.wasm

let loaded = try wasm.decode(bytes)
let store = new wasm.Store()
store.fuel(1000000)
let instance = try store.instantiate(loaded)
let results = try instance.invoke("add", wasm.Value[
	wasm.Value{kind: wasm.ValueType.I32, bits: 2},
	wasm.Value{kind: wasm.ValueType.I32, bits: 3}
])
  • decode reads a binary into a Module; validate checks it without running anything.
  • A Store holds what instances share. Its function, global, table and memory make things a module can import, provide names them.
  • fuel limits what the code may run. A trap, running out of fuel included, is an ADM error of type Trap.
  • Typed calls, WASI and the text format are not there yet.
LayerLanguages
Native ABIC, C++, Rust, Zig, Go, Swift, Fortran, Nim, D, Pascal, Assembly
Sources built with the programZig (@zig), Rust (@rust), Go (@go)
Embedded enginesJavaScript (adm.interop.js, QuickJS-ng), Lua (adm.interop.lua, Lua 5.4), Python (adm.interop.python, MicroPython 1.29), WebAssembly (adm.interop.wasm, written in ADM)
Installed runtimesJava (adm.interop.java, Java 8 or later), .NET (adm.interop.dotnet, .NET 8 or later)
Embedded engines plannedCPython, Ruby, PHP, Perl, R, Scheme/Lisp variants, TCL
Bridging layers plannedKotlin, Scala, Clojure, Erlang/Elixir

Testing

check blocks keep tests next to the code they verify, with no separate test framework. They're parsed and type-checked with the production code, so a broken test surfaces immediately, but the compiler drops them from the final binary unless adm test is what's asking for them.

Unit Tests

A check "name" { ... } block is legal at file scope and inside modules, types, services, components, and applications - just not inside a function body. If it has no explicit @test() function, its top-level statements run as one synthetic test:

check_implicit.adm
check "luhnCheck" {
	use std.testing

	for let (_, t) in string["79927398713", "49927398716"] {
		assert luhnCheck(byte[](t))
	}
	for let (_, t) in string["123", ""] {
		assert !luhnCheck(byte[](t))
	}
}

Name individual cases with @test() instead, and each one runs - and reports - separately:

check_explicit.adm
check "Path" {
	@test()
	def TestExtension() {
		assert extension("/path/to/file.txt") == "txt"
		assert extension("file") == ""
	}

	@test()
	def TestBase() {
		assert base("/path/to/file.txt") == "file.txt"
	}
}

A suite can also declare use imports, constants, structs, and helper functions - all scoped to that suite, never exported - and can nest other check blocks to group related tests. Lifecycle hooks from std.testing are calls in the suite's body that take a function: beforeAll(handler) runs it once before the suite's first test, afterAll once after its last, beforeEach and afterEach around every test, those of nested suites included. A hook that fails fails the test it ran for. Files named _test.adm or .test.adm are discovered automatically; run everything with adm test.

Benchmarks

@benchmark() marks a suite function as a benchmark instead of a test - same check block, same scoping rules:

benchmark.adm
check "UUID" {
	use std.testing

	@benchmark()
	def newUUID() {
		let uuid = new UUID()
	}
}

Run benchmarks with adm test --bench . (the text selects benchmarks by name; . matches all). @skip() leaves a test out and @only() runs just the tests carrying it. @timeout(5s) fails a test that runs longer than the limit; on a check suite it is the time the suite's tests may take together. All three also go on a suite, and adm test --timeout 30s limits every test without its own. @fuzz() on a function with parameters makes it a fuzz target: adm test runs it on the empty input and on the files of testdata/fuzz/<target>/, and adm test --fuzz . generates inputs and saves one that fails there. @parallel() tests of a suite run at the same time, after its other tests. @golden("testdata/report.txt") compares the string or byte[] a test returns with that file, beside the test's source; adm test --update-golden writes it.

Magic Constants

A set of double-underscore constants is filled in by the compiler and folded into the binary at build time. They're usable anywhere an expression is, which makes them handy for logging, diagnostics, and build metadata:

constants.adm
def whereAmI() string {
	return "{__file_name__}:{__line__} in {__module__}"
}
ConstantDescription
__compiler_version__The compiler's semantic version string.
__target__Full target triple: CPU-Vendor-OS (e.g. "x86_64-unknown-linux-gnu").
__os__OS of the active compilation target ("linux", "windows", "darwin", "android", "wasm").
__arch__Architecture of the active target ("x86_64", "arm64", "wasm32").
__build_timestamp__ISO-8601 timestamp of when the build ran.
__app_name__The application's name: from adm.toml, else @manifest, else the declared application name.
__app_id__Stable identity derived from the manifest's publisher key and slug; the application name when there is no manifest.
__app_version__The application's version: from adm.toml, else @manifest, else "0.1". Drives the home folder migrations.
__endianness__"little" or "big", per the target architecture.
__ptr_width__Pointer width in bytes for the target.
__int_width__Size of the default integer type, in bytes.
__word_size__Native register width, in bytes.
__stack_alignment__Required stack alignment for the target.
__abi__Active ABI (e.g. "sysv", "msvc").
__debug__True when compiling in debug mode.
__release__True when compiling in release/optimized mode.
__in_test__True when compiling in test mode.
__labs__True in a build that has service labs: a test build (adm test) and a build the IDE runs.
__git_commit__Git commit hash of the build (empty outside a Git repository).
__git_branch__Git branch name of the build (empty outside a Git repository).
__git_tag__Git tag name of the build (empty outside a Git repository).
__file_name__Basename of the current source file.
__file_path__Absolute path to the current source file.
__dir__Absolute directory path of the current file.
__owner__Qualified name of the type, service, component or application the code stands in (shop.Till); empty in a function of a module.
__caller__The instance the current method runs on, as ?any: none in a function of a module. Inside a @trackCaller function, the instance of the caller.
__file_hash__Compiler-generated hash of the file contents (used for caching).
__line__Current line number, 1-based.
__module__Fully-qualified module of the current file.
__name__Current symbol name - function, type, method, and so on.

__build_id__ also exists and changes on every build - see adm.toml Manifest, which covers it alongside the identity constants a manifest fills in.