Skip to content

Latest commit

 

History

History
647 lines (516 loc) · 24.2 KB

File metadata and controls

647 lines (516 loc) · 24.2 KB

Language Reference

This is the consolidated reference for Vibescript syntax and core semantics. Use this with focused guides in docs/ for deeper examples.

Language Boundary

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.

Source Structure

  • Files are UTF-8 text, typically with .vibe extension.
  • # 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 with require. Statements are separated by newlines or semicolons.
  • Expressions can be used as statements.

Values and Literals

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 and Assignment

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.

Functions

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 with yield.

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

Parameter forms

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.

Functions are not values

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.

Classes

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

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.

Method Calls

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 &param 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.

Operators

Core operator families:

  • Arithmetic: +, -, *, /, %, **
  • Comparison: ==, !=, <, <=, >, >=, <=>
  • Case equality: ===
  • Regex match: text =~ /re/ (character index of the first match, or nil) and text !~ /re/ (true when 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.

Control Flow

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 / else
  • unless / else
  • while
  • until
  • for ... in
  • break
  • next
  • return

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"

Error Handling

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.

Modules

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

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.

Built-in Facilities

Notable built-ins include:

  • Assertions and conversion helpers.
  • Time, Duration, Money helpers.
  • JSON and Regex utility families.

See docs/builtins.md and family-specific docs for full API details.