Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

DETLEF is a typed, embeddable scripting language. Its syntax consists of literals, identifiers, calls, and blocks. Variable declarations, arithmetic, comparisons, and control flow are provided through calls rather than keywords or operators.

declare(x, integer, 0)
while(less(x, 3), {
    print(x)
    set(x, add(x, 1))
})

This program uses the standard library, which the CLI installs before running a script. An embedding application decides which operations to provide.

The language chapters describe syntax, runtime types, evaluation, and lexical scope. The stdlib reference describes the operations available when the standard library is installed.

Functions and Controls

A Function receives values after its arguments have been evaluated from left to right. A Control receives unevaluated expressions and decides which to evaluate, in what order, and how often. This lets if select a branch and while repeat a body without dedicated parser syntax.

Both use the same call syntax. See evaluation for argument evaluation, overload selection, block results, and errors.

Embedding and extensions

Use the C SDK to embed DETLEF in a native application or write packages for native and Wasm hosts. Use the JavaScript SDK to embed DETLEF in Node.js or a browser and load compiled Wasm packages.

The SDK guides cover setup and API details. The language chapters explain the evaluation rules, types, and scoping shared by all hosts.

Syntax

DETLEF has a deliberately small syntax. A program consists of a sequence of expressions, and every expression is one of a small number of forms.

Features such as variable declarations, arithmetic, conditionals, and loops do not have dedicated syntax in the core language. They can instead be provided through calls.

Grammar

The core grammar of DETLEF can be summarized as follows:

program     := expression*
expression  := literal | identifier | call | block
call        := expression "(" arguments? ")"
arguments   := expression ("," expression)*
block       := "{" expression* "}"
literal     := integer | float | string

In this notation, * means zero or more occurrences and ? marks an optional element. Text in quotation marks represents literal punctuation.

Literals

Literals directly represent values in source code.

DETLEF currently supports integer, floating-point, and string literals.

Integer Literals

Integer literals represent integer values.

0
42
1000

Negative numbers do not require dedicated literal syntax in the core language. Negation can instead be provided as an operation by the host environment or standard library.

Floating-Point Literals

Floating-point literals represent floating-point values.

0.5
3.14
100.0

Integers and floating-point numbers are distinct types.

String Literals

String literals are enclosed in double quotes.

"hello"
"DETLEF"

Identifiers

Identifiers name entities that can be resolved during evaluation.

x
integer
print
some_name

DETLEF does not require dedicated syntax for names such as types or boolean values. For example, integer, true, and false are syntactically ordinary identifiers.

Their meaning is determined during evaluation.

Calls

A call consists of an expression followed by a parenthesized argument list.

print("hello")
add(1, 2)
foo()

Arguments are themselves arbitrary expressions:

add(multiply(2, 3), 4)

The grammar permits an arbitrary expression in the callee position:

expression(arguments)

Whether that expression is callable is determined during evaluation. The current runtime requires callable names to be identifiers.

Calls do not imply a particular evaluation strategy. The distinction between Functions and Controls, including how their arguments are evaluated, is described in Evaluation.

Blocks

A block is a sequence of expressions enclosed in braces.

{
    print("hello")
    print("world")
}

Blocks may contain any number of expressions, including none:

{}

A block is itself an expression. Its evaluation and scoping behavior are described in Evaluation and Scoping.

Programs

A DETLEF program is a sequence of expressions.

print("one")
print("two")
print("three")

There is no syntactic distinction between statements and expressions, and expressions do not require semicolons.

Whitespace separates tokens where necessary but otherwise does not determine program structure.

Punctuation

The core syntax uses only a small amount of punctuation:

PunctuationPurpose
( )Call arguments
{ }Blocks
,Separates arguments
"String literals

There is no operator syntax in the core language. Expressions such as addition or comparison can be represented through calls:

add(1, 2)
greater(x, 10)

Keywords

DETLEF currently has no reserved keyword set.

Names that may appear special in a particular environment are still ordinary identifiers at the syntactic level. This allows constructs to be added without extending the core grammar.

String Escapes

Double-quoted strings decode these backslash escapes:

EscapeCharacter
\nNewline
\rCarriage return
\tTab
\bBackspace
\fForm feed
\0NUL
\"Double quote
\\Backslash
\/Forward slash
\u{HEX}Unicode scalar value, using one to six hexadecimal digits
"first line\nsecond line"
"He said \"hello\"."
"C:\\Users\\example"
"An owl: \u{1F989}"

Unknown and incomplete escapes are syntax errors. Unicode escapes reject surrogate code points and values above 10FFFF. Literal Unicode characters and literal line breaks remain supported. To represent a literal backslash, write \\; "\\n" contains a backslash and the letter n, not a newline.

Comments

A slash (/) outside a string starts a line comment. It continues to the next line ending or the end of the source:

/ A line comment
operation(value) / An inline comment

Square brackets enclose block comments, which may span lines and nest:

[
A block comment.
[ A nested block comment. ]
]

Comments act as whitespace and can appear between tokens, including call arguments. They do not supply missing commas or join separate identifiers. Inside strings, slashes and brackets are literal characters. Inside block comments, only brackets have special meaning; quotes and slashes are ordinary comment text. An unclosed block comment or a stray closing bracket outside a string or comment is a syntax error.

Values and Types

DETLEF values represent data produced and consumed during program evaluation. Every value has a runtime type.

Primitive Values

DETLEF currently provides four primitive value types:

  • integer
  • float
  • string
  • boolean

Examples of values include:

42
3.14
"hello"

Boolean values have no dedicated literal syntax. Names such as true and false can instead be provided as ordinary bindings by the runtime or standard library.

This means that:

true

is syntactically an identifier rather than a special boolean literal.

Types

Types participate in evaluation without requiring special syntax.

For example:

integer

is syntactically an ordinary identifier. An environment may resolve it to a description of the integer type.

This makes it possible for constructs to receive types through the same expression syntax used elsewhere:

declare(x, integer)

Here, declare and integer do not require special treatment by the parser. The meaning of both names is established during evaluation.

Type descriptions are not ordinary runtime values. They provide information used by the evaluator and by constructs that operate on types.

Typed Variables

Variables in DETLEF have a type.

Conceptually, a variable contains:

name
type
optional value

For example, a variable x may have type integer and contain the value 42.

A variable may also be declared without an initial value. In that state it still has a type, but reading it cannot produce a value until it has been initialized.

The exact operations used to declare and modify variables are not part of the core language syntax. An environment may, for example, provide:

declare(x, integer)
declare(x, integer, 42)
set(x, 10)

Compound Values

The runtime can also represent compound objects such as arrays and records.

An array contains a dynamic sequence of values of a particular element type.

A record contains values associated with a type-defined collection of fields.

Compound objects have reference semantics. Multiple values referring to the same object refer to the same underlying object rather than independent copies.

The source-level operations used to construct and access compound values are provided outside the core syntax. See the stdlib references for arrays and records.

Absence Values

The runtime provides an explicit absence value, none, with the singleton runtime type NoneType. The name none is an ordinary runtime binding, not special literal syntax.

Absence differs from two other situations:

  • an uninitialized variable has a declared type but no assigned value;
  • a void expression performs an operation without producing a value.

Reading an uninitialized variable fails. Reading a variable containing none produces a valid value. Unsetting a variable returns it to its uninitialized state rather than assigning none.

See expressions without values. The stdlib provides absence checks and fallbacks.

Type Identity

Runtime values have concrete types. Integer and floating-point values, for example, remain distinct:

42
42.0

Functions may use these runtime types when selecting between different signatures.

DETLEF does not implicitly make two distinct runtime types equivalent merely because their values could represent similar information.

Optional Types

An optional type accepts either its inner type or the absence value. Values retain their actual runtime types: an integer stored in an optional integer variable is still an integer, and an absent value still has type NoneType. Nested optional types normalize to a single optional type.

This compatibility rule applies to variables, record fields, array elements, and function parameters. Optional fields retain their positions in a record. Arrays remain invariant: an array of integers is not an array of optional integers.

Function overload selection prefers exact argument types over optional matches. A signature wins only when its matches are no worse for every argument and better for at least one. Otherwise, multiple matching signatures are ambiguous. An absence value alone cannot distinguish an optional integer parameter from an optional string parameter.

Source-level operations for constructing optional type descriptions and handling absence values are supplied outside the core language syntax. See the stdlib optional Controls.

Evaluation

DETLEF programs are evaluated as expressions.

An expression may produce a runtime value, complete successfully without producing a value, or provide information used by the evaluator. Evaluation may also fail with an error.

The behavior of an expression depends on its form.

Literals

A literal evaluates directly to its corresponding value.

42
3.14
"hello"

For example, evaluating 42 produces an integer value.

Identifiers

An identifier is resolved in the current evaluation environment.

Depending on the name, it may refer to a variable, a binding, a type, or another entity provided by the environment.

For example:

x
true
integer

may resolve respectively to a variable value, a boolean binding, and a type description.

An identifier that cannot be resolved causes evaluation to fail.

Calls

Calls are the primary way behavior is exposed to DETLEF programs.

Although all calls use the same syntax, DETLEF distinguishes between two kinds of callable constructs: Functions and Controls.

name(argument1, argument2)

The distinction determines how the argument expressions are evaluated.

Functions

A Function operates on values.

Before a Function is invoked, its argument expressions are evaluated from left to right.

For:

foo(a(), b(), c())

the evaluation order is:

a()
b()
c()
foo(...)

Each argument must produce a value. If evaluating an argument fails, later arguments are not evaluated and the Function is not called.

Likewise, if an argument completes successfully but does not produce a value, it cannot be supplied as a Function argument.

A Function itself may either return a value or complete without producing one.

Parameters and Overloads

Function parameters have runtime types.

A Function may therefore have a signature conceptually resembling:

add(integer, integer)

This notation describes the Function’s signature; it is not DETLEF source syntax.

Multiple Functions may share a name when their parameter types distinguish their signatures.

Because argument types are known only after the argument expressions have been evaluated, overload selection occurs after argument evaluation.

A Function may also be variadic, allowing additional arguments of a specified type.

Controls

A Control operates on unevaluated expressions rather than receiving pre-evaluated argument values.

This allows the Control to decide whether, when, and how often each argument is evaluated.

For example, a conditional construct can evaluate a condition and then evaluate only one of two branches:

if(
    condition,
    { print("yes") },
    { print("no") }
)

If if were an ordinary Function, both branches would be evaluated before the Function was called. As a Control, it can instead choose the appropriate branch.

Controls can also interpret the structure of their arguments. A variable declaration construct, for example, may treat its first argument as the name being declared rather than evaluating it as an existing variable:

declare(x, integer)

A Control may evaluate an argument once, multiple times, or not at all. It may also choose the order in which its arguments are evaluated.

The stdlib reference documents the evaluation rules for each supplied Control.

Callable Resolution

When the callee of a call names a Control, the Control receives the raw argument expressions.

Otherwise, the runtime first determines whether the name refers to a known Function.

An unknown callable fails before any of its argument expressions are evaluated.

For example:

unknown(set(x, 42))

does not evaluate set(x, 42) if unknown is not callable. Therefore, the failed call cannot cause side effects through its arguments.

For a known Function, arguments are then evaluated from left to right and the resulting runtime types are used to select a matching Function signature.

If no matching signature exists, the call fails.

Blocks

A block evaluates its expressions from top to bottom.

{
    first()
    second()
    third()
}

The result of the final expression becomes the result of the block.

For example:

{
    42
}

produces the integer value 42.

An empty block:

{}

completes without producing a value.

Likewise, if the final expression completes without producing a value, the block itself produces no value.

Blocks also introduce lexical scope.

Expressions Without Values

Successful evaluation does not necessarily produce a runtime value.

This is useful for operations whose purpose is an effect rather than computing data. An operation that changes a variable, for example, may complete successfully without returning anything.

This is not a runtime value. In particular, it differs from the absence value none.

The distinction becomes observable when an expression is used where a value is required.

For example, if set changes a variable but produces no value:

foo(set(x, 42))

set(x, 42) may successfully update x, after which the outer call fails because foo requires a value for its argument.

This differs from an unknown outer callable:

unknown(set(x, 42))

Because callable resolution fails first, set(x, 42) is never evaluated in this case.

Errors and Evaluation Order

Evaluation stops when an error occurs unless the active Control defines different behavior by deciding what to evaluate itself.

For ordinary Function calls, evaluation is deterministic:

  1. resolve the callable name;
  2. evaluate arguments from left to right;
  3. determine their runtime types;
  4. select a matching Function;
  5. invoke the Function.

Blocks similarly evaluate their contained expressions from top to bottom.

An error does not undo side effects that have already occurred.

Scoping

DETLEF uses lexical scoping.

Variables are resolved according to the lexical environment in which code is evaluated rather than according to the chain of Function calls that happened to lead there.

Block Scopes

Blocks introduce nested lexical scopes.

For example, if an environment provides variable declaration:

{
    declare(x, integer, 42)
    print(x)
}

x belongs to the block’s scope.

After evaluation leaves the block, that declaration is no longer visible from the surrounding scope.

A nested scope can access variables from its enclosing scopes:

declare(x, integer, 42)

{
    print(x)
}

Here, the inner block can resolve x from the surrounding environment.

Mutation

Resolving a variable from an enclosing scope does not create a copy of that variable.

If a nested scope modifies an enclosing variable, it modifies the same variable:

declare(x, integer, 1)

{
    set(x, 2)
}

After the block finishes, x contains 2.

The exact operations declare and set are environment-provided constructs; they are used here only to illustrate scoping behavior.

Function Environments

A script Function retains the lexical environment in which it is defined.

Conceptually, consider a Function created while x is visible:

declare(x, integer, 42)

function(... {
    print(x)
})

The example is schematic; see the stdlib Function definition Control for working definitions. A Function retains access to the lexical environment in which it was created.

This is a lexical closure.

Captured Variables

Captured variables are shared rather than copied.

If a Function captures a variable and that variable is later modified, the Function observes the modified value.

Conceptually:

declare(x, integer, 1)

/ Create a Function that refers to x.

set(x, 2)

/ Calling the Function observes x as 2.

The Function retains access to the variable itself, not merely the value that the variable contained when the Function was created.

Captured Names

While captured variables remain shared, the set of names visible to a captured environment is established when the environment is captured.

A declaration introduced afterward does not retroactively become visible to a Function that has already captured the environment.

This distinguishes two properties of a captured environment:

  • the visible names are captured;
  • the variables associated with those names remain shared.

As a result, mutations to already-visible variables remain observable while later declarations do not automatically appear in existing closures.

Lexical, Not Dynamic, Scope

Function calls do not implicitly expose the caller’s local variables to the called Function.

Conceptually:

/ f is defined here.

{
    declare(x, integer, 42)

    / Calling f here does not make this x visible inside f.
}

A Function resolves free variables through the environment it captured when it was defined, not through the local environment of its caller.

Parameters

Function parameters are local to an invocation.

When a Function is called, its parameters are bound in a Function-local environment. Variables captured from the Function’s defining environment remain available outside those parameter bindings.

Each invocation receives its own parameter bindings.

Lifetime of Captured Variables

Normally, leaving a scope removes its local variables from ordinary name resolution.

A captured variable may outlive that scope if a Function still refers to it.

This allows closures to retain state without making the captured variable globally visible.

Standard Library

The CLI installs the standard library before running a script. An embedding application chooses whether to install it and supplies its input and output callbacks. The examples here assume the library is available.

The library provides operations through ordinary calls. Names such as if and declare are not keywords; see call syntax.

TopicOperations
Variablesdeclare, set, unset
Control flowif, while, for_each, chain
Functionsfunction, parameter, vararg
Types and optionalsoptional, is_none, unwrap, or_else
NumbersArithmetic and comparisons
StringsConversion, formatting, concatenation, and searching
ArraysArray types, construction, access, and mutation
RecordsRecord types, construction, field access, and mutation
Input and outputprint, read_line

Reading the Reference

Each operation is marked as a Function or Control. Functions evaluate all arguments from left to right before selecting an overload. Controls decide which expressions to evaluate and can accept literal names or type descriptions. See evaluation for the difference.

Signatures in tables describe arguments and results; they are not declaration syntax. ... means additional arguments. A name or field name argument must be an identifier written directly in the call, not a string or an evaluated value.

No value means the operation completes without returning data. It is different from returning none, which is a runtime value of type NoneType. Type description, field descriptor, and parameter descriptor are evaluator results, not values that can be stored in variables or passed to Functions. See types and expressions without values.

Wrong argument counts and incompatible argument types cause errors. Individual entries describe additional restrictions. Errors stop evaluation; mutations already performed are not rolled back.

Variables

These Controls operate on typed variables.

ControlResult
declare(name, type)No value
declare(name, type, initial)No value
set(name, value)No value
unset(name)No value

declare

Declares a variable in the current scope. name is a literal identifier. The type expression is evaluated first, followed by the initializer if supplied. The initializer must produce a value accepted by the declared type.

declare(score, integer, 0)
set(score, add(score, 10))
print(score) / 10

Without an initializer, the variable is uninitialized. Reading it is an error until set assigns a value. An optional declaration is not automatically initialized to none:

declare(answer, optional(string), none)
print(is_none(answer)) / true

Declaring the same name twice in one scope fails. A declaration in a child scope can shadow an outer variable; see block scopes.

set

Evaluates the value expression and assigns it to the nearest visible variable named by the first argument. The variable must exist and its type must accept the new value. Assigning a value does not change the variable’s declared type. The value expression is evaluated before the assignment checks, so its side effects may occur even if assignment fails.

declare(count, integer, 1)
{
    set(count, 2)
}
print(count) / 2

Mutation of an enclosing variable affects that same variable; see mutation. To change part of a compound object, use set_at or set_field.

unset

Removes a variable’s assigned value while keeping its name and declared type. The variable must exist. Reading it afterward fails until it is assigned again. Calling unset on an already uninitialized variable is allowed.

declare(value, integer, 42)
unset(value)
set(value, 7)
print(value) / 7

This does not assign none or remove the declaration. See absence values.

Control Flow

These operations are Controls: their arguments are not all evaluated before the call.

ControlResult
if(condition, then)Selected branch result, or no value
if(condition, then, else)Selected branch result
while(condition, body)No value
for_each(array, name, body)No value
chain(name, initial, step...)Final value

if

Evaluates the condition once. It must produce a boolean. Evaluates only the selected branch and returns its result. A false condition with no else branch produces no value. Branches can be expressions or blocks.

declare(score, integer, 12)
declare(message, string, if(greater(score, 10), "passed", "try again"))
print(message) / passed

A branch does not create a scope on its own. A block used as a branch does.

while

Evaluates the condition before each iteration. While it is true, evaluates the body, discards its result, and checks the condition again. The condition must produce a boolean every time. A false initial condition skips the body.

declare(counter, integer, 0)
while(less(counter, 3), {
    print(counter)
    set(counter, add(counter, 1))
})

This prints 0, 1, and 2. There are currently no stdlib break or continue operations. An error in the condition or body stops the loop.

for_each

Evaluates the array once and takes a snapshot of its element values before running the body. The second argument is a literal identifier. Each iteration gets a fresh child scope with that name bound to the current element; the body result is discarded.

declare(items, array(integer), array_of(2, 4, 6))
declare(total, integer, 0)
for_each(items, item, set(total, add(total, item)))
print(total) / 12

Adding, replacing, or removing elements in the source array does not change the snapshot. Object elements still refer to the same objects, so their mutations remain visible. See reference semantics. The iteration binding shadows an outer variable of the same name and does not change its value. A non-array input is an error.

chain

Evaluates the initial expression once. For each following step, creates a fresh child scope binding the literal name to the preceding value, evaluates the step, and uses its value as the next input. Returns the last value. With no steps, returns the initial value.

print(chain(link, array_of(10, 20),
    get_at(link, 1),
    add(link, 2),
    to_string(link),
    concat("Result: ", link)
)) / Result: 22

The value can change type between steps. Every step must return a runtime value; none is allowed, but a type description or an expression with no value is an error. The temporary binding does not escape the step or replace an outer variable of the same name. Mutations to outer variables and shared objects remain visible.

Use this for mixed array/record traversal as well as value transformations. For paths containing only arrays or only records, get_at and get_field accept multiple path segments directly.

Functions

These Controls define script Functions and their parameters. Definitions use ordinary call syntax.

ControlResult
parameter(name, type)Parameter descriptor
vararg(name, type)Variadic parameter descriptor
function(name, parameters..., body)No value

parameter

Builds a descriptor for a required parameter. The name is a literal identifier; the second argument must evaluate to a type description. The descriptor is consumed by function, not stored as a variable value.

vararg

Builds a descriptor for zero or more trailing arguments of the given type. It must be the final parameter descriptor in a Function definition. Inside the body, the parameter name refers to an array of the declared element type. That array is empty when no trailing arguments are supplied.

function

Registers a Function under a literal name. Evaluates parameter descriptors from left to right, captures the current lexical environment, and saves the final argument as the body without evaluating it. The body need not be a block.

function(double, parameter(number, integer), multiply(number, 2))
print(double(21)) / 42

function(sum, vararg(numbers, integer), {
    declare(total, integer, 0)
    for_each(numbers, number, set(total, add(total, number)))
    total
})
print(sum()) / 0
print(sum(1, 2, 3)) / 6

Calls evaluate arguments from left to right. Required parameters receive local bindings, and the body result becomes the Function result. A block returns its final expression; an empty block or a body producing no value yields no value. There is no separate return operation. Returning a descriptor is an error.

Functions capture visible names at definition time, while captured variables remain shared. Later mutations are visible; later declarations are not added to an existing capture. See Function environments and captured names.

declare(offset, integer, 1)
function(adjust, parameter(value, integer), add(value, offset))
set(offset, 5)
print(adjust(10)) / 15

Functions are registered in the runtime’s callable registry, not stored as first-class variable values. A definition inside a block remains callable after the block exits, and retains its captured variables. The caller’s local variables are not automatically visible inside it.

Multiple signatures can share a name when their parameter types distinguish them. Registration rejects overlapping signatures. Wrong parameter descriptors or a misplaced vararg also fail. Duplicate parameter names cause an error when invocation attempts to create the bindings. See overload selection.

Types and Optionals

The runtime provides the type names integer, float, string, boolean, and NoneType, and the value bindings true, false, and none. These names are identifiers, not keywords. Type descriptions are not ordinary values.

ControlResult
optional(type)Type description
is_none(value)Boolean
unwrap(value)Input value, unless absent
or_else(value, fallback)Input value or fallback value

For compound types, see array and record.

optional

Evaluates its argument as a type description and returns a type accepting that type or none. It does not wrap or convert values. Repeated construction of the same optional type returns the same type; nested optionals normalize to one optional, and optional(NoneType) is NoneType.

declare(answer, optional(integer), none)
set(answer, 42)
print(answer) / 42
set(answer, none)

Optional fields still require a value in record_of; pass none explicitly. To build arrays accepting both values and absence, use empty_array with an optional element type.

See optional type compatibility for assignment and overload rules. Passing a value instead of a type is an error.

is_none

Evaluates its argument once and returns whether the resulting value is none. Accepts any runtime value, not just one read from an optional variable. A type description or an expression with no value is an error.

print(is_none(none)) / true
print(is_none(0)) / false

It cannot test an uninitialized variable: reading that variable fails first.

unwrap

Evaluates its argument once. Returns any non-absent value unchanged; none causes an error. It neither changes the variable’s declared type nor copies an object.

declare(answer, optional(integer), 42)
print(unwrap(answer)) / 42

or_else

Evaluates the first argument once. Returns it if it is not none; otherwise, evaluates the fallback and returns its value. The fallback is lazy and must produce a runtime value if selected. It may return none too.

declare(answer, optional(string), none)
print(or_else(answer, "No answer")) / No answer
print(or_else("present", unwrap(none))) / present

There is no requirement that both branches have the same type. The receiving variable or Function checks the type of the result. An uninitialized first argument is an error, not a reason to select the fallback.

Numbers and Comparisons

All operations on this page are Functions: arguments are evaluated from left to right before overload selection. Arithmetic supports separate integer and float signatures. Arguments in one call must all use the same numeric type; there is no implicit conversion.

Arithmetic

FunctionArgumentsResult
add(left, right, rest...)At least two integers or two floatsSum, same type
subtract(left, right)Two integers or two floatsDifference, same type
multiply(left, right, rest...)At least two integers or two floatsProduct, same type
divide(left, right)Two integers or two floatsQuotient, same type
modulo(left, right)Two integers or two floatsRemainder, same type
negate(value)One integer or floatNegated value, same type

add

Sums the arguments in order. Requires at least two arguments.

print(add(1, 2, 3)) / 6
print(add(1.5, 2.5)) / 4

subtract

Subtracts the right argument from the left.

print(subtract(10, 3)) / 7

multiply

Multiplies the arguments in order. Requires at least two arguments.

print(multiply(2, 3, 4)) / 24

divide

Divides the left argument by the right. Integer division truncates toward zero; integer division by zero is a runtime error. Float division follows IEEE 754 arithmetic and may produce infinity or NaN rather than an error.

print(divide(7, 2)) / 3
print(divide(7.0, 2.0)) / 3.5
print(divide(negate(7), 2)) / -3

modulo

Returns the remainder. For integers, its sign follows the left operand; a zero divisor is a runtime error. Float remainder may produce NaN.

print(modulo(7, 3)) / 1
print(modulo(negate(7), 3)) / -1

negate

Changes the sign of its argument. Negative literal syntax is not provided; use this operation when writing a negative number.

print(negate(3)) / -3

Integer operations currently use Rust’s ordinary signed 64-bit arithmetic. Overflow is not converted into a DETLEF error: debug builds can panic, while ordinary release builds wrap for addition, subtraction, multiplication, and negation. Dividing or taking the remainder of the smallest integer by -1 can panic in either build. Avoid overflowing inputs until checked arithmetic is implemented.

Comparisons

FunctionArgumentsResult
equal(first, second, rest...)At least two integers, or at least two stringsBoolean
less(first, second, rest...)At least two integers or floatsBoolean
greater(first, second, rest...)At least two integers or floatsBoolean
less_equal(first, second, rest...)At least two integers or floatsBoolean
greater_equal(first, second, rest...)At least two integers or floatsBoolean
close(left, right, tolerance)Three floatsBoolean

equal

Returns true when every adjacent pair is equal. Strings are compared exactly, including case. There are no equal overloads for floats, booleans, arrays, records, or none; use is_none for absence.

print(equal(3, 3, 3)) / true
print(equal("DETLEF", "detlef")) / false

less

Returns true if each argument is strictly less than the next.

print(less(1, 2, 3)) / true
print(less(1, 3, 2)) / false

greater

Returns true if each argument is strictly greater than the next.

print(greater(3, 2, 1)) / true

less_equal

Returns true if each argument is less than or equal to the next.

print(less_equal(1, 1, 2)) / true

greater_equal

Returns true if each argument is greater than or equal to the next.

print(greater_equal(3, 3, 2)) / true

Variadic comparisons still evaluate all arguments before comparing values; they do not short-circuit argument evaluation.

close

Returns whether the absolute difference between two floats is at most the tolerance. A negative tolerance is an error; zero tests for zero difference.

print(close(1.0, 1.001, 0.01)) / true

NaN makes these comparisons false. close uses an absolute tolerance, not a relative one. Boolean logic can be expressed with if; the library currently has no and, or, or not operations.

Strings

String literals and backslash escapes are part of the language syntax. Formatting and conversion are explicit library calls; a plain string containing {name} stays unchanged.

OperationKindResult
to_string(value)ControlString
format(template)ControlString
concat(first, rest...)FunctionString
length(text)FunctionInteger
contains(text, search)FunctionBoolean
starts_with(text, prefix)FunctionBoolean
ends_with(text, suffix)FunctionBoolean

to_string

Evaluates one runtime value and converts it to text:

ValueRepresentation
StringIts contents, without surrounding quotes
Integer or finite floatDecimal text
Booleantrue or false
nonenull
ArrayCompact JSON array
RecordCompact JSON object, in field declaration order
print(to_string(array_of(1, 2, 3))) / [1,2,3]
record(Person, field(name, string), field(age, integer))
print(to_string(record_of(Person, "Ada", 37))) / {"name":"Ada","age":37}

Inside arrays and records, strings are quoted and escaped as JSON. Optional fields containing none appear as null; they are not omitted. Shared objects can appear more than once, but an object cycle causes an error. Formatting also fails for non-finite floats, invalid object references, or more than 128 levels of nested objects. Descriptors and expressions producing no value are not accepted.

Conversion does not preserve type identity: 1.0 can become "1", and records carry no type name in their JSON representation. No JSON parsing operation is currently provided.

format

Evaluates one string template, then replaces {name} placeholders using normal name lookup in the current lexical scope. Values are converted using to_string. Only identifiers are accepted inside placeholders; expressions, field paths, and whitespace are not allowed.

declare(name, string, "Ada")
declare(score, integer, 42)
print(format("{name} scored {score}.")) / Ada scored 42.
print(format("{{score}} = {score}")) / {score} = 42

Write {{ and }} to insert literal braces. An empty placeholder, unmatched brace, unknown name, uninitialized variable, or name resolving to a type causes an error. Conversion errors from to_string also propagate. Replacement text is inserted as-is and is not scanned for further placeholders.

The template can be computed rather than written as a literal; it is evaluated once. Lookup follows lexical scope, including shadowing and a script Function’s captured environment.

concat

Joins one or more strings in argument order. A single argument is returned unchanged. Zero arguments or non-string arguments have no matching signature. Conversions must be explicit:

print(concat("Score: ", to_string(42))) / Score: 42

length

Returns the number of Unicode scalar values, not UTF-8 bytes or displayed characters. A combining mark counts separately from its base character.

print(length("hello")) / 5
print(length("\u{1F989}")) / 1
print(length("e\u{301}")) / 2

An unrepresentable length would cause an integer-range error.

contains

Tests whether the search string occurs anywhere in the text. Matching is case-sensitive and does not normalize Unicode. An empty search string matches.

print(contains("Hello DETLEF", "DETLEF")) / true

starts_with

Tests for an exact prefix. An empty prefix matches.

print(starts_with("DETLEF", "Det")) / true

ends_with

Tests for an exact suffix. An empty suffix matches.

print(ends_with("example.dtf", ".dtf")) / true

Both prefix and suffix matching are case-sensitive and do not normalize Unicode. All five string Functions require string arguments; see Function arguments.

Arrays

Arrays are mutable objects with one declared element type and reference semantics. All operations on this page are Controls.

ControlResult
array(type)Array type description
array_of(first, rest...)New array
empty_array(type)New empty array
get_at(array, index...)Selected value
set_at(array, index..., value)No value
push(array, value)No value
array_length(array)Integer
remove_at(array, index)Removed value

array

Evaluates an element type description and returns the corresponding array type. It does not create an array. Repeated calls with the same element type identify the same array type.

declare(numbers, array(integer), array_of(1, 2, 3))

A non-type argument fails. Array types are invariant: an array(integer) is not accepted where array(optional(integer)) is required. See optional compatibility.

array_of

Evaluates one or more values from left to right and constructs an array. Its element type is the first value’s runtime type; every other value must have exactly that type. All values are evaluated before the types are compared.

declare(words, array(string), array_of("one", "two"))
print(to_string(words)) / ["one","two"]

Zero arguments and mixed runtime types fail. array_of(1, none) does not infer an optional element type. Use empty_array(optional(integer)) and push when both values and absence must be accepted.

empty_array

Evaluates an element type description and creates an empty array of that type. A non-type argument fails.

declare(answers, array(optional(integer)), empty_array(optional(integer)))
push(answers, 42)
push(answers, none)
print(to_string(answers)) / [42,null]

get_at

Evaluates the array once, then each index expression once from left to right. Indices are zero-based integers. With several indices, each selected value except the last must itself be an array.

declare(grid, array(array(integer)), array_of(array_of(1, 2), array_of(3, 4)))
print(get_at(grid, 1, 0)) / 3

A negative or out-of-bounds index, non-integer index, or non-array at a traversed position fails. Later indices are not evaluated after a failed segment. Selecting an object returns a reference to it, not a copy.

For mixed record/array paths, use chain with get_field.

set_at

Traverses indices with the same rules as get_at, then replaces the final existing element. It validates the whole destination path before evaluating the new value. The new value must be accepted by the destination array’s element type. It does not grow an array or create missing intermediate objects.

declare(grid, array(array(integer)), array_of(array_of(1, 2), array_of(3, 4)))
set_at(grid, 1, 0, 9)
print(get_at(grid, 1, 0)) / 9

The array and indices are evaluated once, from left to right. If the path is invalid, the value expression is skipped. If the path is valid but the value’s type is wrong, its expression has already run when the type error occurs. Mutation is visible through every reference to the destination object.

push

Evaluates the array, then the new value, and appends one element. The array must be an array object, and the value must be accepted by its element type. Produces no value.

declare(numbers, array(integer), empty_array(integer))
push(numbers, 7)
print(array_length(numbers)) / 1

array_length

Evaluates an array and returns its current element count as an integer. A non-array fails; a length exceeding the integer range also fails.

print(array_length(array_of(2, 4, 6))) / 3

remove_at

Evaluates the array, then one index, removes that element, and returns it. Later elements shift down by one index. Negative, non-integer, or out-of-bounds indices fail, as does a non-array input. Unlike get_at and set_at, this operation accepts only one index.

declare(numbers, array(integer), array_of(10, 20, 30))
print(remove_at(numbers, 1)) / 20
print(to_string(numbers)) / [10,30]

To iterate over an array, see for_each.

Records

A record type defines named, typed fields. Record objects have reference semantics. All operations on this page are Controls.

ControlResult
field(name, type)Field descriptor
record_type(name)No value
record(name, fields...)No value
record_of(type, values...)New record
get_field(record, names...)Selected value
set_field(record, names..., value)No value

field

Builds a field descriptor from a literal identifier and an evaluated type description. The descriptor is consumed by record; it is not a runtime value. A non-identifier name or a non-type second argument fails.

record

Defines a named record type. The name is a literal identifier. Field expressions are evaluated from left to right and must return field descriptors. Field order is the order supplied here, and every field name must be unique.

record(Person, field(name, string), field(age, integer))
declare(person, Person, record_of(Person, "Ada", 37))
print(get_field(person, name)) / Ada

A record can have zero fields: record(Empty) defines it and record_of(Empty) constructs it. The type name is available to later type expressions. Types are registered in the runtime, not confined to a block’s variable scope.

Defining an already defined record, conflicting with an existing non-record type, supplying duplicate field names, or supplying a non-field descriptor fails. A failed definition can leave its record name declared but undefined; there is no rollback of registration.

record_type

Declares an undefined record type so it can be referenced by other type expressions before its fields are defined. record can later complete it. This supports recursive and mutually referring record types.

record_type(Node)
record(Node, field(value, integer), field(next, optional(Node)))
declare(tail, Node, record_of(Node, 2, none))
declare(head, Node, record_of(Node, 1, tail))
print(get_field(head, next, value)) / 2

The name must be a literal identifier and must not already be declared. An undefined record type cannot be used to create an object until record defines its fields. Optional links allow recursive structures to terminate; see optionals.

record_of

Evaluates a record type, then its value expressions from left to right. Creates an object with one value per field in declaration order. The count must match exactly, and each value must be accepted by the corresponding field type. A non-record type or an undefined record type fails.

record(Contact, field(name, string), field(phone, optional(string)))
declare(contact, Contact, record_of(Contact, "Ada", none))
print(to_string(contact)) / {"name":"Ada","phone":null}

Optional fields still need explicit arguments. Omission and none are not the same thing.

get_field

Evaluates the record expression once, then follows one or more literal field identifiers in order. Field names are not evaluated as variables and must not be quoted. Every intermediate value must be a record.

record(Address, field(street, string))
record(Person, field(address, Address))
declare(person, Person, record_of(Person, record_of(Address, "Main Street")))
print(get_field(person, address, street)) / Main Street

A missing field, non-identifier field name, or non-record along the path fails. An absent optional link is not traversed automatically. The final result can be any value, including none or a reference to another object.

Use get_at for nested arrays and chain for mixed traversal:

record(Address, field(street, string))
record(Person, field(addresses, array(Address)))
declare(person, Person, record_of(Person, array_of(record_of(Address, "Main Street"))))
print(chain(link, person,
    get_field(link, addresses),
    get_at(link, 0),
    get_field(link, street)
)) / Main Street

set_field

Follows literal field names with the same rules as get_field, then replaces the final existing field. Evaluates the record once and validates the entire path before evaluating the new value. The value must be accepted by that field’s declared type. It does not create fields or intermediate records.

record(Address, field(street, string))
record(Person, field(address, Address))
declare(person, Person, record_of(Person, record_of(Address, "Main Street")))
set_field(person, address, street, "Oak Street")
print(get_field(person, address, street)) / Oak Street

An invalid path skips the value expression. A type mismatch is checked after that expression has run. Mutation affects the existing object and is visible through its other references. Produces no value; use a block ending in the object if a chain step also needs to return it.

Input and Output

The embedding application supplies the library’s input and output callbacks. The CLI connects them to standard input and standard output. These operations are Functions, with normal argument evaluation.

FunctionArgumentsResult
print(value)One string, integer, float, or booleanNo value
read_line()NoneString or none

print

Formats a primitive value and sends its text to the output callback once. Strings are sent as their contents; booleans use true and false. The CLI adds a newline after each call. An embedding application’s callback may choose different output behavior.

print("Hello, DETLEF!")
print(42)
print(true)

There are no direct overloads for records, arrays, or none. Convert them explicitly with to_string, or construct a message with format:

print(to_string(array_of(1, 2, 3))) / [1,2,3]
print(to_string(none)) / null

The operation produces no value, so it cannot be used where a Function expects an argument value. See expressions without values.

read_line

Calls the input callback once. A supplied line becomes a string, with one trailing LF or CRLF removed. Other whitespace is preserved. End of input returns none; a blank line returns "". Input callback errors propagate as runtime errors. The CLI waits for input from its standard input stream, which can also be redirected from a file or a pipe.

print("What is your name?")
declare(name, optional(string), read_line())
if(is_none(name), {
    print("Input ended.")
}, {
    print(format("Hello, {name}!"))
})

Check for end of input with is_none, or provide a lazy fallback with or_else. Do not use an empty string as the end-of-input test. See absence values for the underlying type rules.

SDK documentation

Use the SDK guides to embed DETLEF in an application or write separately compiled packages:

  • C SDK: embed a native runtime or write packages for native and Wasm hosts.
  • JavaScript SDK: embed DETLEF in Node.js or a browser and load compiled Wasm packages.

The guides cover setup, examples, and API details. For the language behavior shared by all hosts, see evaluation, values and types, and scoping.