This is the consolidated reference for Vibescript syntax and core semantics.
Use this with focused guides in docs/ for deeper examples.
Vibescript is an embedded workflow language, not a general-purpose Ruby runtime. Scripts call named functions and methods, transform value-semantic collections, and invoke host-provided capabilities. Blocks are synchronous syntax attached to a call, executable code cannot be retained as a value, and modules provide namespaces without mixins. The embedding host owns concurrency, delay, scheduling, and external authority. See ADR-006 for the rationale and Migrating to 1.0 for replacement patterns.
- Files are UTF-8 text, typically with
.vibeextension. #starts a comment that runs to end-of-line.- Top-level declarations are functions, classes, and enums. Executable top-level
statements form the default script body when a file is run without
-function, and form a module initializer when a file is loaded withrequire. Statements are separated by newlines or semicolons. - Expressions can be used as statements.
Vibescript supports these literal/value categories:
nil,true,false- integers and floats (
1,42,3.14,1e3,1.5e-2,0xFF,0b1010) - strings (
"hello","hello #{name}") - symbols (
:name, or quoted as:"with-punctuation"/:'with spaces') - arrays (
[1, 2, 3]) - hashes (
{name: "Ada", active: true}) - ranges (
1..5,1...5) - duration literals (
5.minutes,2.days)
Integers are arbitrary precision, as in Ruby: literals parse up to a
100,000-digit parser guard (integer literal exceeds 100000 digits; larger
values remain constructible through arithmetic), arithmetic that leaves the
signed 64-bit range promotes transparently, and a result that fits 64 bits
again returns to the compact fast representation. There is a single int
type; scripts never observe a separate "bignum" kind. A few surfaces
deliberately stay within 64 bits and raise a clear error for larger values:
range endpoints, iteration counts (times, upto, downto, step),
Money/Duration/Time arithmetic, and argument positions that denote
indexes, counts, sizes, or precisions.
Numeric literals accept underscores as visual separators between digits
(1_000, 1_000.50). Floats may use scientific notation with an e/E
marker, an optional sign, and one or more exponent digits (1e3, 1.5e-2,
1E6, 1e1_0). Any literal carrying an exponent is a float even without a
decimal point, matching Ruby (1e3 is 1000.0). Exponent underscores are
visual separators only between two digits. A literal whose exponent overflows
the 64-bit float range saturates to Infinity. An e/E only opens an
exponent when followed by a sign or digit; otherwise it begins a trailing
identifier, so 5end keeps the end keyword while 1e and 1e_3 are
rejected by the rule below.
A numeric literal may not directly abut an identifier. Forms such as 1e3foo,
123abc, and 1.5x are reported as parse errors rather than splitting into a
number followed by an identifier, matching Ruby. A keyword suffix is exempt
because Ruby keeps the keyword (5if cond and 1e3if cond are valid modifier
statements). Committed-but-malformed exponents (1e+, 1e3_, 1e3__4) are
likewise reported as parse errors.
Hash literals support label keys (name:) and quoted string keys ("name":).
Ruby's hash rocket syntax (=>) is not supported.
Ranges with .. include the final endpoint. Ranges with ... exclude it.
Integer literals accept Ruby's base prefixes: 0x/0X for hexadecimal,
0b/0B for binary, 0o/0O for octal, and 0d/0D for explicit decimal.
Underscores may separate digits in any base (0xDEAD_BEEF, 1_000) but only
between two digits. A prefix must be followed by at least one valid digit, and a
prefixed literal may not carry a fractional part or trailing letters; otherwise
the literal is rejected with an invalid numeric literal error. A bare leading
zero (010) stays decimal rather than being read as legacy octal.
Double-quoted strings support #{...} interpolation. Each interpolation must
contain one expression; the expression value is converted with the same string
form used by to_s. The expression may contain its own double-quoted strings
and even nested interpolations (for example "#{name || "guest"}"); the
interpolation extends to its matching }. Escape an interpolation marker as
\#{...} for literal text. Single-quoted strings do not interpolate.
Symbols are usually written bare (:name), but a quoted form lets a symbol hold
punctuation, spaces, or be empty: :"foo-bar", :'foo bar', :"". Quoted
symbols use the same escapes as the matching string quote (double-quoted symbols
decode \n, \t, \", \\; single-quoted symbols decode \' and \\).
Interpolation is not supported in symbol literals, so :"a#{b}" is a parse
error. :"name" is accepted anywhere a symbol literal is.
Hash keys live in one string keyspace. In a hash literal a bare label and a
quoted label both make a string key, so { name: 1 } and { "name": 1 } are
the same hash and either h[:name] or h["name"] reads it. A symbol remains a
distinct value kind everywhere else; only keys normalize. See docs/hashes.md.
See docs/arrays.md, docs/hashes.md, docs/strings.md, docs/durations.md,
and docs/time.md for full method coverage.
Variables are dynamically bound by assignment:
total = 0
total = total + 10
Parallel and destructuring assignment split array values across targets:
a, b = [1, 2]
first, *middle, last = [1, 2, 3, 4]
x, (y, z) = [1, [2, 3]]
Missing values bind as nil, extra values are ignored unless captured by a
*rest target, and scalar right-hand values are treated as one value.
A bare * is an anonymous rest target: it discards the values it captures
without binding a name, which is useful for ignoring leading, trailing, or
interior values:
first, * = [1, 2, 3]
head, *, tail = [1, 2, 3, 4]
A leading * discards the values before the named targets:
*, last = [1, 2, 3]
Index assignment is supported for arrays and hashes. Array targets accept a negative index, which counts back from the end:
items = [1, 2, 3]
items[0] = 10
items[-1] = 30
Reading with [] mirrors Ruby's Array#[] and String#[], including negative
indexes, value[start, length], and value[range] slices; see
Arrays and Strings
for the full semantics.
Compound assignment is supported for single assignment targets, including variables, member targets, and index targets:
total += amount
items[0] *= 2
record[:score] **= 2
Supported compound assignment operators are +=, -=, *=, /=, %=, and
**=. They reuse the corresponding arithmetic operator semantics.
Define functions with def/end:
def add(a, b)
a + b
end
Function features:
- Positional arguments.
- Keyword/default arguments.
- Optional type annotations.
- Optional return type annotations.
- An optional call-attached block, inspected with
block_given?and run withyield.
Run a supplied block with yield, and ask block_given? whether the current
call was given one before yielding. See docs/blocks.md for details.
Typed signature example:
def charge(amount: int, currency: string = "USD") -> hash
{amount: amount, currency: currency}
end
A parameter's spelling chooses how it receives a value. The token after the colon disambiguates the keyword and typed forms:
| Form | Meaning |
|---|---|
name |
required positional parameter |
name = default |
optional positional parameter |
name: Type |
typed positional parameter |
name: Type = default |
typed positional parameter with a default |
name: |
required keyword-only parameter |
name: default |
optional keyword-only parameter |
*rest |
captures extra positional arguments |
**rest |
captures extra keyword arguments |
A keyword-only parameter is bound only by a matching keyword label; it never accepts a positional argument. The optional form supplies its default when the label is omitted, and a later default may reference an earlier parameter:
def connect(host:, port: 8080, scheme: "https", timeout: port * 2)
"#{scheme}://#{host}:#{port}"
end
connect(host: "example.com") # uses port 8080, scheme "https"
connect(host: "example.com", port: 443) # overrides port
Because name: Type declares a typed positional parameter, a bare identifier
after the colon resolves as a type name, not a keyword default: write a: int
for a typed positional and a: 0 for an optional keyword. This is why a default
that references an earlier parameter must be parenthesized when it is only
that parameter: timeout: port * 2 is a default, but timeout: port reads as a
type, so write timeout: (port). The name: nil
spelling is the optional keyword default nil, matching Ruby and the stdlib's
documented optional keywords; a bare nil positional type would be useless.
A nil-leading union annotation (a: nil | int) is the exception: the |
continuation keeps the colon a type, so it declares a typed positional
parameter rather than a nil keyword default. When a keyword default must
reference another name on its own, wrap it in parentheses (a: (other)) so it
parses as an expression.
A keyword default may be a full expression, including one that references an
earlier parameter with a comparison (def f(limit:, ok: limit < 10)). A { ... }
default is a hash literal whenever its contents are values rather than types, so
def f(opts: { retry: 3 }) and def f(opts: {}) both declare hash defaults. An
empty hash is degenerate as a shape field, so a nested empty hash is a hash
default too (def f(opts: { headers: {} })). A hash value may reference an
earlier parameter directly, including as a bare identifier
(def g(a:, b: { sum: a })); no parentheses are needed inside the braces. The
name: { field: Type } spelling, whose field values are themselves types, stays
a typed positional parameter with a shape type.
Executable code does not escape: referencing a function by name without
calling it is an error rather than a value, there is no .call, and methods
cannot be detached as callable values. A function or method is invoked where
it is named, and behavior is passed between calls with a block, which the
callee runs synchronously with yield. See
Blocks and Enumerables for the migration
shapes.
Class declarations are supported for grouping behavior and methods:
class Counter
def bump(value: int) -> int
value + 1
end
end
Inheritance is not supported.
Module declarations group module functions and constants under a namespace.
A module holds def self. functions, constants, and nested modules; it is
not a source of methods for other types, and there is no include or
extend:
module Billing
LIMIT = 5
def self.code
"ok"
end
end
Billing.code # "ok"
Billing::LIMIT # 5
module is contextual, not a reserved keyword: it starts a declaration only
when followed by a constant name. Declarations are allowed at the top level
and nested inside other module bodies (Outer::Inner); modules cannot be
instantiated.
See docs/classes.md for class methods, @/@@ variables, accessors, and
visibility semantics.
Enums declare nominal state sets:
enum Status
Draft
Published
end
Members are accessed with :::
Status::Draft
See docs/enums.md for coercion, equality, and serialization behavior.
Calls support positional args and keyword args:
fees.apply(amount)
require("billing/rules", as: "rules")
Calls may omit parentheses when all arguments stay on the same line:
fees.apply amount
normalize input
add 1, 2
require "billing/rules", as: "rules"
render status: "ok"
Label arguments bind as keyword arguments when the callee accepts them. When a script function has a positional hash/options parameter instead, the same source form is passed as a final options hash. This options-hash binding applies to plain function calls in both parenless and parenthesized form, so the two are interchangeable:
accept_options retry: true, limit: 3
accept_options(retry: true, limit: 3)
A function reached through member access binds the same way, so calling a
module function such as rules.accept_options(retry: true, limit: 3) matches
the direct form too.
The synthesized hash is type-checked against a typed options parameter, so
accept_options(retry: "soon") is rejected with the shape mismatch when the
parameter declares { retry: bool, limit: int }.
Constructor calls (Klass.new(...)) and method calls (receiver.method(...))
keep strict parenthesized keyword binding: a parenthesized keyword that has no
matching keyword parameter does not collapse into a positional options hash.
An instance method named call is an ordinary method under that rule. Their
parenless forms still pass the options hash, mirroring the historical behavior.
Positional arguments must come before keyword labels. A positional argument that
follows a keyword label, such as collect(first: 1, "tail"), is a parse error in
both the parenthesized and parenless forms, matching Ruby. Keyword labels after a
positional argument are fine, so collect("head", first: 1) is accepted.
Blocks can be passed with do ... end:
numbers.map do |n|
n * 2
end
Ruby-style call splats expand prepared argument lists in place: f(*args)
spreads an array into positional arguments, f(**opts) spreads a hash into
keyword arguments (string or symbol keys; later arguments win duplicate
keys), and both combine freely with regular arguments and blocks, as in
f(1, *rest, x: 5, **opts) { ... }. The expansion happens before binding, so
arity, keyword, and type errors match the equivalent literal call, and the
expanded arguments are charged against the step and memory quotas exactly
like literal arguments. Splatting a non-array (f(*1), including nil) or
keyword-splatting a non-hash raises. In parenless calls the splat uses the
same spacing rule as block passing: f *args splats, while a * b,
a*b, and any form whose callee is a known local stay multiplication.
A regex literal can be a parenless command argument, matching Ruby:
match /ID-[0-9]+/ is match(/ID-[0-9]+/), flags and further arguments
included (scan /a+/, text). The slash follows the same spacing rule as the
splat and block-pass sigils — a space before the / with none after it opens
a regex when the callee is not a known local variable — so division is
unaffected: total /2, total / 2, and total/2 all divide when total is
a local, and f / 2 or f/2 keep dividing a call's result. Only f /2
(space before the slash, none after, non-local callee) reads as a regex
argument; write f() / 2, f / 2, or f/2 for the division. Known locals
include the implicit it parameter of a parameterless block and the
enclosing class's or module's constants, so it /2 and LIMIT /2 inside a
method of the class that assigns LIMIT divide. Accessor names declared
with getter/property are method calls, not locals, so they follow the
non-local rule.
An array literal can be a parenless command argument under the same
local-variable rule, matching Ruby: puts [3, 1, 2].sort is
puts([3, 1, 2].sort). Only the callee-side spacing decides — a bracket
detached from a non-local callee opens an array argument (puts [ 1 ] and a
multiline puts [ work too, and further arguments may follow:
concat [1], [2]), while a flush bracket keeps indexing, so puts[1] still
tries to index puts and fails at runtime. A known local indexes in every
spacing: when a is a local array, a [0] reads and a [0] = 1 assigns
through the index exactly like a[0]. Member callees have no local reading,
so xs.first [0] passes [0] as an argument to first; write
xs.first[0] to index the result.
There are no ampersand block arguments: a block is not a value, so it cannot
be captured with a ¶m or forwarded with m(&blk), and the m(&:name)
symbol-to-proc shorthand is gone with it. Write the block at the call that
runs it. See Blocks and Enumerables for the
migration shapes.
Ruby-style safe navigation (receiver&.member) reads a member or calls a
method only when the receiver is not nil. When the receiver is nil, the
whole &. access short-circuits to nil without looking up the member or
dispatching the call; otherwise it behaves exactly like the ordinary .
access:
user&.name # nil when user is nil, otherwise user.name
user&.profile("public")
A short-circuited safe call does not evaluate its arguments or block, matching
Ruby. The operator guards only its immediate access, so in user&.profile.name
the trailing .name still dispatches on whatever user&.profile returned; if
that is nil, the .name access raises. Use safe navigation at each link
(user&.profile&.name) to guard a whole chain.
Safe navigation cannot be used as an assignment target. It is rejected anywhere
in the target, so user&.name = "Ada", user&.profile.name = "Ada", and
user&.items[0] = 1 are all parse errors rather than assignments through nil.
Core operator families:
- Arithmetic:
+,-,*,/,%,** - Comparison:
==,!=,<,<=,>,>=,<=> - Case equality:
=== - Regex match:
text =~ /re/(character index of the first match, ornil) andtext !~ /re/(truewhen the pattern does not match); operands work in either order - Boolean:
&&,||, unary! - Collection:
array << value(append),array & other(intersection) - Unary sign: prefix
-negates a number; prefix+is the identity on numbers and strings - Conditional:
condition ? when_true : when_false
The Ruby word forms and, or, and not are not boolean operators in
Vibescript. They are ordinary identifiers, so they can be used as method names,
function names, and hash labels. Use &&, ||, and ! for boolean logic.
The spaceship operator <=> returns -1, 0, or 1 for ordered operands and
nil when the two operands cannot be ordered (different kinds, money values in
different currencies, or a NaN on either side), matching Ruby's spaceship
contract. The relational operators <, <=, >, >= instead raise on
incomparable operands, matching Ruby's ArgumentError.
The case equality operator === treats its left operand as a matcher and its
right operand as the value being tested, mirroring how a case/when clause
compares its patterns. A range matcher checks membership, so (1..3) === 2 is
true and (1...3) === 3 is false. A regex matcher tests a string, so
/el+/ === "hello" is true (and case/when clauses match the same way).
Every other matcher falls back to ==, so 1 === 1 is true and
2 === (1..3) is false (the integer 2 is not a range). Because the scalar
path reuses ==, integers and floats remain distinct kinds, so 1 === 1.0 is
false, unlike Ruby. Class matchers will be added alongside the corresponding
language features.
The collection operators work on arrays. array << value appends a single
value and returns the receiver, so a bare values << x statement accumulates
into the local it names. Arrays are values, so the append reaches that binding
and no other (see Arrays). array & other returns a new array holding the elements
common to both arrays with duplicates removed and the left array's order
preserved. Following Ruby, + binds tighter than
<<, which binds tighter than &. The & operator is disambiguated from the
(unsupported) block-pass sigil by spacing, exactly as Ruby does: only an &
that is detached from the callee yet flush against its operand (call &block)
is read as a block pass and reported as unsupported. Every other shape is the
intersection operator, including a spaced & (items & others), an & flush
on both sides (items&others), and a trailing & that continues the
expression onto the next line. See
Arrays for details.
Operator precedence follows conventional arithmetic/boolean ordering.
Exponentiation with ** is right-associative and binds more tightly than
unary -, so -2 ** 2 is parsed as -(2 ** 2). Integer powers stay int
for non-negative exponents, promoting to arbitrary precision past 64 bits
(2 ** 100 is exact); mixed numeric powers and negative integer exponents
return float. Non-finite float powers raise runtime errors, and an integer
exponent so large that the result could not exist in memory raises
exponent is too large (Ruby's ArgumentError) or trips the sandbox quotas.
Division follows Ruby: integer division by zero (1 / 0) raises, while float
division by zero (1.0 / 0) follows IEEE 754 and yields Infinity,
-Infinity, or NaN.
Inspect those special values with Float#nan?, Float#infinite?, and
Float#finite?. ! is a prefix operator, && binds tighter than ||, and
ternary conditionals have lower precedence than ||, associate to the right,
and evaluate only the selected branch.
Prefix + mirrors Ruby's unary plus: it returns integers, floats, and strings
unchanged and raises on any other operand. Because Vibescript strings are
immutable values, +"x" yields the same string value.
A leading + or - at the start of a fresh line follows Vibescript's
indented-continuation rule, which is shared with - and intentionally differs
from Ruby. When the sign sits flush against its operand it begins a new
statement (total\n+amount parses as two statements, matching Ruby). When the
sign is separated from its operand by surrounding whitespace it continues the
previous line as a binary operator (total\n + amount is addition). Ruby treats
both forms as a new statement and would instead parse total\n + amount as the
two statements total and +amount; Vibescript deliberately supports the
spaced form as an explicit operator continuation so multi-line arithmetic can be
indented under its first operand.
Conditionals:
if amount > 0
"ok"
elsif amount == 0
"zero"
else
"invalid"
end
unless amount <= 0
"ok"
else
"invalid"
end
if / elsif / else can also be used as a value-producing expression:
status = if active
"open"
else
"closed"
end
Looping:
for item in items
if item == nil
next
end
end
Supported control-flow constructs include:
if/elsif/elseunless/elsewhileuntilfor ... inbreaknextreturn
Short expression and assignment statements can also use modifier loops and
unless conditionals:
i = i + 1 while i < 3
i = i + 1 until i >= 3
status = "open" unless suspended
Ternary conditionals are expressions:
status = active ? "open" : "closed"
Raise explicit failures:
raise("missing configuration")
Structured handling supports rescue/ensure:
def run
begin
risky
rescue RuntimeError => err
err.message
ensure
cleanup
end
end
See docs/errors.md for parser/runtime error formats and stack traces.
Load shared code from other files with require:
require("public/helpers", as: "helpers")
helpers.normalize(input)
Module resolution is governed by host Config.ModulePaths and policy lists.
Required exports are called directly through the module namespace; exported
functions cannot be detached, stored, or passed as values.
File-based modules are distinct from in-source module Name ... end
declarations (see the Classes section and docs/classes.md).
Typing is gradual and optional:
- annotate parameters and returns where helpful.
- mark nullable types with
?. - rely on runtime contract checks for typed boundaries.
See docs/typing.md for complete behavior.
Notable built-ins include:
- Assertions and conversion helpers.
Time,Duration,Moneyhelpers.JSONandRegexutility families.
See docs/builtins.md and family-specific docs for full API details.