Reference

Basics

Generated from LANGUAGE.md at d971c06, 2026-10-15.

Statements

Every statement ends with a period (.).

Print "Hello, World!".

Case Sensitivity

Keywords are case-insensitive. These are equivalent:

  • Print, print, PRINT
  • If, if, IF

Comments

Comments use parentheses ( ), just like parenthetical remarks in natural language writing.

(This is a comment)
print "Hello".

print "World". (end of line comment)

a number (the counter) called x is 5.

(Multi-line comments
work naturally across
several lines)

(Nested (parentheses (are supported)) too)

Comments can appear:

  • On their own line
  • At the end of a statement
  • In the middle of a statement (between tokens)
  • Spanning multiple lines

Paragraph Breaks (Blank Lines)

Vox takes its syntax from written English. In prose, a full stop ends a sentence and a paragraph break ends a section; in Vox, a period ends a clause and a blank line ends a section. The punctuation is the syntax, in exactly the way indentation is the syntax in Python. That leaves layout entirely to the writer. There are no braces and no end keywords, and Vox reads no meaning from leading whitespace, so a program can be indented however reads best. The convention is to indent so the layout mirrors the punctuation (see docs/STYLE.md), and the examples below follow it, so the page reads the way it runs.

A blank line is a paragraph break. Between two complete top-level sentences it groups them, the way paragraphs group the thoughts of an essay:

print "Section 1".

print "Section 2".

Inside a construct, a paragraph break ends it: finishing the paragraph finishes the loop, the condition or the function it holds, along with everything else still open in it. A period ends one clause at a time; a paragraph break ends them all. To space out the actions of a single body, put the blank line after a comma, where the sentence is still going (see The termination rule).

A function definition is one paragraph. The period after each body statement ends that statement, and the blank line after the body ends the function:

To greet.
    Print "hello".
    Print "welcome".

greet.

This prints hello then welcome. A function body also ends at a Return sentence written at the body's own level, and at the next To or Library, since a definition is only ever written at the top level (see Definition); a To reached while some other clause is still open is a compile error. When the last function in a file runs to the end of the file with no blank line or Return to end it, the compiler prints a warning naming that function. A Library file, whose last function ends at the end of the file by design, gets no warning.

Sentence Consumption

Action-consuming constructs (loops, conditionals, error handlers) consume the entire sentence they appear in. Multiple actions within that sentence are separated by commas.

(Single action)
While x is less than 10, increment x.

(Multiple comma-separated actions in one sentence)
While x is less than 10, print x, increment x.

(For loops work the same way)
For each number from 1 to 10, print the number, print " ".

(Error handlers too)
On error print "Something went wrong", exit 1.

(If/else with multiple actions)
If x is greater than 10 then, print "big", set y to 1. Otherwise, print "small", set y to 0.

Key Rules:

  • Period (.) ends the entire construct, including all its actions
  • Comma (,) separates multiple actions within the same construct
  • Only function definitions can span multiple sentences (using paragraph breaks)

The words that open a consuming clause. Comma-chained actions exist only inside a clause one of these opens; everything after its comma belongs to it, to the period that closes it:

  • If <condition> then, (and its Otherwise,)
  • While <condition>,
  • For each <name> in <collection>, / For each <name> from <a> to <b>,
  • Repeat <count> times,
  • On error
  • but if <condition> inside a loop expansion, which branches the expanded action

Nothing else in Vox chains actions with a comma: a bare sentence takes exactly one statement, so Print "hello", print "world". is a compile error at the comma, not two prints.

Sentence ownership (nested constructs):

  • A nested construct (especially if ... then) owns its own trailing period.
  • Outer constructs (while, for each, repeat) do not steal that inner period.
  • After an inner if ends, the outer sentence may continue with more actions.
a number called 'line number' is 1.
While 'line number' is less than 4,
    if 'line number' is 3 then, print "last: " without newline.
    print "line {line number}",
    increment the 'line number'.

In the example above, the period after the inner if closes only that if, so the while body continues with print and increment, and the program prints line 1, line 2 and last: line 3.

The termination rule

Python reads structure from indentation, so re-indenting a line moves it into or out of a block. Vox reads structure from punctuation, the way written English does, so a full stop or a paragraph break is what moves a line. Two rules give the whole of it:

  1. A period ends the innermost open clause (if, on error, for, while, repeat), and only that one. An action's period ends the clause the action belongs to. A nested clause's period ends the nested clause, and the clause around it goes on with its next action, as the first example below shows. One period closes one level; to close more than one, write more than one (see Closing more than one level).
  2. A blank line ends the paragraph, and with it every open clause, including an enclosing function definition. A paragraph break in a book ends every thought in that paragraph at once, and a blank line in Vox does the same: it is a mark, like a full stop, rather than spacing. A line holding only spaces or tabs is a blank line, and several blank lines in a row make one paragraph break.

Reading the structure. Read the periods the way you read full stops in prose: one full stop finishes one thought, two finish two, and a paragraph break finishes the section. To place a line, look at how the sentence before it ended. Indented to the convention, the layout says the same thing on the page.

A period ending a loop:

(The period after "add 1" ends the while, so "done" follows the loop)
a number called retries is 0.
While retries is less than 3,
    if retries is equal to 1 then, print "retrying".
    the retries is retries add 1.
Print "done".

This prints retrying once (when retries is 1), then done once. The if's period ends the if, the while goes on to its next action, and that action's period ends the while.

A blank line ending a section, where the loop's last action is a nested clause:

(The if's period ends the if, and the blank line ends the while)
a number called retries is 0.
While retries is less than 3,
    the retries is retries add 1,
    if retries is equal to 2 then, print "retrying".

Print "done".

This prints retrying then done. After the if's period the while is still open for another action, and the blank line ends it. while, for each, repeat and on error all end this way.

These two programs differ by one blank line. In both, print item. ends the for each and leaves the while open:

(One paragraph: "round done" is the while's next action, and its period ends the while)
a number called round is 0.
While round is less than 2,
    the round is round add 1,
    For each item from 1 to 2,
        print item.
    print "round done".

prints 1 2 round done 1 2 round done, and

(Two paragraphs: the blank line ends the while, and "round done" follows the loop)
a number called round is 0.
While round is less than 2,
    the round is round add 1,
    For each item from 1 to 2,
        print item.

print "round done".

prints 1 2 1 2 round done.

Inside a function, a blank line ends the function's paragraph too. A later statement that reads one of the function's parameters, or a later Return, is a compile error, and the message says that a blank line ended the function's body, naming the line where the body ended.

A blank line after a comma is spacing inside a sentence. The sentence is still going, so nothing closes, and the blank line only spaces the text:

(This blank line follows a comma, so it spaces the sentence and closes nothing)
a number called retries is 0.
While retries is less than 3,
    print "attempt {retries}",

    increment retries.
Print "done".

This prints attempt 0, attempt 1, attempt 2, then done. A comment on a line of its own spaces a body the same way: it is not a blank line, so it closes nothing.

Closing more than one level

Rule 1 closes exactly one level, and rule 2 closes all of them. When you are nested several levels deep and want to come back up some of the way, periods stack: write one period per level you want to close.

(Three nested ifs, so three periods to get all the way back out)
a number called n is 0.
If n is equal to 1 then,
    If n is equal to 1 then,
        If n is equal to 1 then, print "innermost"...
print "back at the top".

This prints back at the top. The three periods close the innermost if, then the middle one, then the outer one, so print "back at the top". runs at the top level. Written with one period or two, print "back at the top". would belong to an if whose condition is false, and would not run.

Indentation is the writer's to choose. Vox reads no meaning from leading whitespace, so a program can be reindented or minified without changing what it does, and the periods alone say where a clause closes. Indented to the convention, the line after ... sits back at the left margin, where the three periods put it.

The stacked constructs need not be the same kind. Here two periods close the if and the enclosing For each together, so the To that follows is read at the top level rather than as a nested statement of the loop:

For each n from 1 to 3,
    If n is 99 then, Break..
To examine with a value called v.
    Print "{v's type}".

examine of "".

This prints Text (dynamic) once. Written with a single period, the For each would still be open when To is reached, which is a compile error (see Definition).

This is how you choose which if an Otherwise belongs to

An Otherwise (or But if) continues the innermost if that is still open. Closing that if first is therefore how you hand the Otherwise to an enclosing one. These two programs differ by one period, and they read differently, the way two sentences do when one of them ends a thought sooner:

(One period: the Otherwise continues the inner if)
a number called m is 5.
If m is equal to 1 then,
    If m is equal to 2 then,
        print "inner then".
    Otherwise,
        print "inner otherwise".

Print "done".

prints only done. The Otherwise continues the inner if, and the whole construct sits inside If m is equal to 1, which is false, so nothing in it runs.

(Two periods: the inner if is finished, so the Otherwise continues the outer one)
a number called m is 5.
If m is equal to 1 then,
    If m is equal to 2 then,
        print "inner then"..
Otherwise,
    print "outer otherwise".

Print "done".

prints outer otherwise then done. The two periods finish the inner if and then the outer if's first branch, so the Otherwise continues the outer if, and the layout puts it at the outer if's margin.

An empty Otherwise,. closes an inner chain the same way and is easier to read than a run of periods, since it names the thing being closed instead of asking you to count:

(Same result as two periods, spelled out instead of counted)
a number called m is 5.
If m is equal to 1 then,
    If m is equal to 2 then,
        print "inner then".
    Otherwise,.
Otherwise,
    print "outer otherwise".

Print "done".

This also prints outer otherwise then done. The first Otherwise,. takes the inner if's else branch and does nothing with it, which closes that chain; the second one is then free to continue the outer if.

The periods are the structure. Each statement belongs to the clause that the periods before it leave open: one period fewer keeps the next statement inside that clause, one more moves it out a level. Where a run of periods grows long, Otherwise,. names the level it closes, and a blank line closes every level at once.

Ranges

Ranges define a sequence of numbers from a start to an end value. They are not allocated as lists - they compile directly to efficient loop constructs with a counter, bounds check, and increment.

(Basic range in for-each loop)
For each number from 1 to 10, print the number.

(Range with variable bounds)
Set start to 1.
Set end to 5.
For each number from start to end, print the number.

(Range in loop expansion - see below)
print each number from 1 to 10.

Key points:

  • Ranges are inclusive - 1 to 5 includes 1, 2, 3, 4, and 5
  • Ranges compile to efficient assembly loops, not list allocations
  • The loop variable (the number) is available inside the loop body

Loop Expansion

The each...from syntax is a universal loop expansion that works with any action. It transforms a single action into a loop that executes for each item in a collection or range.

(Print each item from a list)
print each number from [1, 2, 3].

(Print each number from a range)
print each number from 1 to 15.

(Call a user function for each item)
process of each item from mylist, print "done".

(Open a file for each argument)
a buffer called content.
open a file for reading called source at each filename from arguments's all,
  read from source into content,
  print the content,
  close source.

Syntax: <action> each <variable> from <collection>, <additional actions>

The action executes once per item in the collection or range, with the loop variable bound to each item. Additional comma-separated actions execute inside the loop after the main action.

Works with:

  • print each X from Y - print each item
  • function of each X from Y - call function for each item
  • open ... at each X from Y - open file for each path
  • Any action that takes an argument

Supported collections:

  • Ranges: 1 to 10, start to end - numeric sequences
  • Lists: [1, 2, 3], any list variable
  • A buffer's bytes: any buffer variable - each iteration binds the variable to one byte's value (0-255), in order 1..size, the same value byte N of <buffer> yields (see Buffer Byte Access). byte is itself a legal loop-variable name here.
  • arguments's all - all command-line arguments (argv[1..])

Chained each clauses: a grid

More than one each <variable> from <collection> clause may appear in a single sentence, joined by and. The action then runs once per element of the Cartesian product of the collections, in row-major order: the leftmost clause is the outermost loop, exactly as if the clauses were nested For each loops written left to right:

'pair' of each x from [1, 2] and each y from [10, 20].

runs 'pair' four times: (1,10), (1,20), (2,10), (2,20), identical to:

For each x from [1, 2],
    For each y from [10, 20],
        'pair' of x and y.

There is no limit on the number of clauses. A fixed (non-each) argument may sit among them in any position, and is evaluated once per call:

'pair' of 5 and each y from [10, 20].       (fixed first, then expansion)
'pair' of each x from [1, 2] and 99.        (expansion first, then fixed)

An inner collection may use a variable bound by an outer clause, giving triangle iteration:

'pair' of each row from [1, 2, 3] and each col from 1 to row.

A range bound in an each clause takes a primary, not an expression: each col from row add 1 to 4 is a parse error. Brace an arithmetic bound: each col from {row add 1} to 4.

See Loop Expansion with Collections below for the arity rule, the empty-collection rule, duplicate loop variables, and after-loop values.

Conditional Branching with but if

The but if clause is a generic conditional branch over a base action. It is available in both for each loops and loop expansion (<action> each ... from ...).

(FizzBuzz example - print number, but override with word if divisible)
print each number from 1 to 15,
    but if the number modulo 15 is equal to 0 print "FizzBuzz",
    but if the number modulo 3 is equal to 0 print "Fizz",
    but if the number modulo 5 is equal to 0 print "Buzz".

(Simple even/odd labeling)
print each number from 1 to 10,
    but if the number modulo 2 is equal to 0 print "even".

(Append to a list with a conditional override)
append each number from 1 to 5 to out,
    but if the number modulo 2 is equal to 0 append 0.

(With for-each loop)
For each number from 1 to 15,
  print the number,
    but if divisible of the number and 3 is true print "divisible by 3".

Syntax: <base action>, but if <condition> <alternative action>, but if <condition> <alternative action>, ... [otherwise <default alternative action>].

How it works:

  1. The default action is the base statement.
  2. Each but if clause is checked in order.
  3. If a condition is true, that alternative action runs instead of the default.
  4. If no conditions match, the default action runs.
  5. An optional trailing otherwise clause provides a final alternative.

Key points:

  • Conditions are checked in order - first match wins
  • Multiple but if clauses can be chained
  • The alternative action can be any valid Vox statement
  • otherwise provides a catch-all alternative
  • Works with both ranges and collections
  • The loop variable (the number) is available in conditions
  • In an append branch, the to <list/buffer> target may be omitted and is inherited from the base append statement; retargeting to a different list/buffer is not allowed

Inline Substitution with treating

The treating X as Y clause performs inline value substitution - like bash's ${var//X/Y} but readable.

(Replace '-' with "/dev/stdin" for each filename)
open a file for reading called source at each filename from arguments's all treating "-" as "/dev/stdin",
  read from source into content,
  write content to output,
  close source.

(Print with default value)
print each name from names treating "" as "Anonymous".

(Call function with substitution)
process of each filename from files treating "-" as "/dev/stdin".

(Append with substitution - the clause goes with the `each` clause, before
 the `to <destination>`)
append each name from names treating "" as "Anonymous" to cleaned.

Syntax: ... each <var> from <collection> treating <match> as <replacement>, ...

If the loop variable equals <match>, it's replaced with <replacement> for that iteration.

Equality is by type as well as by value: a <match> whose type differs from the element's never fires, and that element comes through unchanged, and where the compiler can prove the mismatch, it says so at compile time instead. Where the element, the <match> or the <replacement> is a value, the runtime tag it carries is what the comparison reads, and a substitution that fires hands the <replacement>'s own type out with it.