>_ RESTERM
v1.10.2
restermscript.md

docs RestermScript

Language

Comments, literals, operators, types and error handling.

Comments

# starts a comment that runs to the end of the line. It can appear after whitespace or code.

Blocks and statement endings

Blocks use { ... } and group statements together. A newline can end a statement when the previous token can finish a statement. Newlines inside () and [] are ignored. The language also accepts the semicolon token as a statement terminator, but this guide uses newlines for clarity.

Identifiers and keywords

Identifiers start with a letter or _ and can contain letters, digits, and _ characters. The language reserves keywords and they cannot be used as identifiers.

Keywords:

text
export module fn let const if elif else switch case default try return for break continue range
true false null and or not

A reserved word cannot be a bare name anywhere, which includes dict keys and field access. Data that carries a key spelled like a keyword uses the quoted forms instead:

text
let cfg = {"default": 1}
let v = cfg["default"]

Literals

text
null
true / false
123  3.14
"string"  'string'
[1, 2, 3]
{a: 1, "b": 2}

String escapes include \n, \r, \t, \\, \", and \'. Dict keys in literals are identifiers or quoted strings, and dict keys are always strings at runtime.

Operators by precedence

Listed from tightest to loosest. Each level binds more tightly than the one below it.

  • Postfix: function calls, indexing, and member access.
  • Unary: not or !, try, and unary -.
  • Multiplicative: *, /, and %.
  • Additive: + and -.
  • Comparison: <, <=, >, >=, in, and not in.
  • Equality: == and !=.
  • Logical AND: and or &&.
  • Logical OR: or or ||.
  • Coalescing: ?? returns the right side when the left side is null.
  • Ternary: cond ? a : b selects between two values.

?? sits near the bottom, so it binds looser than arithmetic, comparison, and both logical operators. a ?? b + c means a ?? (b + c), and a ?? b or c means a ?? (b or c). Parenthesise when you want the other grouping.

+ adds numbers or concatenates strings. Non numeric values are converted to string using str(). Ordering comparisons only work for numbers or strings, and equality only works for primitive types.

Membership operators

value in container is the infix form of contains(container, value). value not in container applies the same membership test and negates its result:

rts
response.statusCode in [200, 201, 204]
"json" in response.header("Content-Type")
"request_id" in response.json()
response.statusCode not in [400, 404, 500]

The container determines how membership works:

  • A list compares each item to the value with RestermScript equality. Kinds must match, primitive values compare by value, and lists, dicts, functions, and objects are never deeply equal.
  • A string converts the value with str() and tests for a substring. The empty string is contained in every string.
  • A dict converts the value with str() and checks for an exact, case-sensitive key. null converts to the empty string, so null in dict asks for a "" key.
  • Any other container is an evaluation error.

Host bindings such as response, vars, and env are objects, not containers, so they take the error branch instead of testing membership. Use the accessor each one already provides: vars.has("token") rather than "token" in vars, and "request_id" in response.json() rather than "request_id" in response.

not in is one comparison operator. Because unary not binds more tightly than every binary operator, write value not in container, not not value in container, when you want to negate membership. Like the existing ordering operators, membership is left-associative; comparisons are not rewritten into chained tests.

in is contextual rather than reserved. It remains valid as a binding, function or module name, member, and dict key when it is not between two expressions:

rts
let in = {in: true}
in.in

In .rts modules and @rts blocks, a line may end after either membership operator:

rts
let allowed = code in
  [200, 201, 204]

Request-file directives retain their delimiter-based multiline rule. Open a group when a directive needs to span lines:

http
# @assert (
#   response.statusCode in
#   [200, 201, 204]
# )

Logical operators

RestermScript supports word and symbolic forms for its logical operators: and or &&, or or ||, and not or !. Each pair is interchangeable and has the same precedence and short-circuit behavior:

text
if r.ok && !r.retry { return r.value }
if r.ok and not r.retry { return r.value }

Logical AND and OR always return a bool, not one of their operands. For example, 1 && 2 evaluates to true, not 2.

Like not, ! binds more tightly than any binary operator. This means !a == b is parsed as (!a) == b. To negate the equality expression instead, write !(a == b) or simply a != b.

When a line ends with && or ||, the expression continues on the next line:

text
let ready = r.ok &&
  r.value.count > 0

RestermScript does not have bitwise operators, so a single & or | is a parse error.

Fallback values with ??

?? is the way to supply a fallback. It returns the left side unless it is null, and it is lazy: the right side is only evaluated when the left side is null.

text
let token = vars.get("auth.token") ?? env.get("auth.token")
let label = candidate ?? "unknown"

Laziness means the fallback can be expensive or failing without cost when it is not needed:

text
"ok" ?? fail("boom")   # "ok", fail is never called

?? reacts to null only. false, 0, "", the empty list, and the empty dict are real values and pass straight through:

text
0 ?? 5      # 0
"" ?? "x"   # ""

When you do want any falsey value replaced, that is a different question and the ternary answers it:

text
let value = candidate ? candidate : "something"

?? does not rescue an undefined name. missingName ?? "fallback" is still an error, which keeps typos visible. Optional lookups return null explicitly instead, so vars.get("missing") ?? "fallback" works.

Migrating from default()

default(a, b), rts.default(a, b), and stdlib.default(a, b) were removed, and default became a reserved word. Replace every call with (a ?? b):

text
default(vars.get("token"), "anon")     # removed
(vars.get("token") ?? "anon")          # replacement

Keep the parentheses. A call is a single tight unit, but ?? binds looser than every operator except the ternary, so dropping them regroups the expression whenever the call was part of a larger one:

text
default(a, b) + c   # old, means (a ?? b) + c
a ?? b + c          # wrong, parses as a ?? (b + c)
(a ?? b) + c        # right

The parentheses are only redundant when the call was the entire expression, as in the vars.get example above.

The replacement is not only shorter. default(a, b) was an ordinary call, so b was evaluated before the call ran, whether or not a was null. ?? evaluates b only when a is null. If a fallback did real work, that work now happens only when it is actually needed:

text
default(a, fail("missing"))   # always failed
a ?? fail("missing")          # fails only when a is null

Check fallbacks that call uuid(), mutate vars, or fail. Migrating them changes when they run, not just how they are spelled.

Because default is reserved, these spellings are now parse errors: let default = 1, fn default() {}, {default: 1}, and value.default. Dict data that genuinely has a default key uses {"default": 1} and value["default"].

Error handling with try

text
try expr

The try operator evaluates its expression and returns an object with ok, value, and error fields. ok is true on success and false on error. value holds the result on success and is null on error. error is a single line error string on failure and null on success. It does not catch hard aborts such as step limits, timeouts, or cancellations. You can use try expr directly in conditionals, but checking r.ok is often clearer.

Example:

text
let r = try json.file("_data/users.json")
if not r.ok { return [] }
return r.value

Use in .http expressions and directives:

text
# @when try json.file("_data/flags.json")
# @for-each ((try json.file("_data/users.json")).value ?? []) as user
# @assert try response.json("data")

Authorization: Bearer {{= (try last.json("auth.token")).value ?? "" }}

This pattern is most useful for optional files, optional JSON bodies, or helper calls that may fail.

Types and truthiness

RTS has several runtime types.

  • Null represents the absence of a value.
  • Bool represents true or false.
  • Number uses float64 for numeric values.
  • String stores UTF 8 text.
  • List stores ordered values.
  • Dict stores key value pairs.
  • Function represents a callable value.
  • Object represents host objects provided by Resterm.

Truthiness follows consistent rules. Null, false, zero, the empty string, the empty list, and the empty dict are false. All other values are true unless a host object defines custom truthiness (for example, try results are truthy only when ok is true).

Indexing and member access

List indexing uses numeric indices such as list[0], and out of range accesses return null. Dict access uses dict["key"] or dict.key, and missing keys return null. Object member access is supported, while indexing depends on the object implementation.

Keys and names

Dictionary and query keys are exact strings. Case and whitespace are preserved, including empty query keys. The rts.dict helpers behave like dict[key], so Token, token, and token are separate keys.

Names used by env, vars, and request headers have different rules. Resterm makes env and vars names case-insensitive and ignores surrounding whitespace. Header names are case-insensitive HTTP field names. A name your script supplies is a value, so whitespace around it is rejected instead of trimmed. The header block of a request file is syntax rather than a value, so the parser trims around the colon and then holds what is left to the same rule.

Host maps are validated before evaluation. Blank env or vars names, and two spellings with the same identity, are errors instead of choices made by map order. Header blocks likewise reject invalid field names and equivalent spellings.

What happens to a name a rule does not accept depends on where it came from. A name your script writes is your own word, and a header name that is not an HTTP field name asks a question no request can answer, so it is reported:

rts
request.header("X Token")             // error, not an HTTP field name
headers.get({"X-Ok": "yes"}, "X Tok") // error, same rule
headers.set(h, " X-Token ", "1")      // error, whitespace is not trimmed

A malformed host map or header block fails as one value; helpers never silently discard an entry. This keeps all evaluations deterministic and makes bad input visible at the boundary:

rts
env.get("   ")                                      // error
headers.get({"X Token": "a", "X-Ok": "yes"}, "X-Ok") // error

Header names are checked when the file is parsed, so a header whose name is not an HTTP field name is reported against its line before anything runs. Runtime construction and dispatch also reject it; invalid names are never exposed through request.headers.

Search

↑↓ moveEnter openEsc close

Help

These keys work like the ones in the TUI.

/
Search the docs (also Ctrl K)
j k
Scroll down and up
g g G
Jump to the top or the bottom
[ ]
Previous and next docs page
g d
Docs index
g h
Home page
t
Switch between dark and light
?
Show this help
Esc
Close a dialog