Syntax Overview

Comments

Comments exist only in the source file for human readers. The tokenizer
discards them entirely — they never reach the parser or runtime, so they take
no memory, do not affect execution, and cannot be read back from the running
program.

# Line comment — the only comment syntax. Runs to the end of the line.
var x = 1   # trailing comments work too

There are no block comments. A leading #! line is just a comment, so
scripts are directly executable via a shebang:

#!/usr/bin/env scrii_repl
print("runs as ./hello.scr")

Variables and constants

Scrii has four declaration keywords that differ in mutability and
scope/aliasing:

Keyword Mutability Scope / behavior
var mutable top level = global; inside a block/function = local to it
const immutable same scoping as var, but cannot be reassigned or its members changed
local mutable an explicit block-local that shadows an outer binding without touching it
ref n/a (alias) shares storage with another slot — writes through the alias reach the original

var — mutable, scoped

A mutable binding. Where it lives depends on where you declare it:

var x = 42               # top level  -> a GLOBAL, visible everywhere
var empty = nil          # declared without a value -> nil

fn report() {
  print(x)               # 42 (global is visible inside functions)
}

var inside = 0
if (true) {
  var inside = 9         # block-scoped: shadows the outer `inside`
  print(inside)          # 9
}
print(inside)            # 0 (the outer binding was not touched)

var inside a block or function is local to that scope — it shadows any
outer name of the same spelling and the outer value is restored when the scope
ends. At the top level, var creates a global that the whole script (and
functions called from it) can see.

const — immutable

Same scoping as var, but the value is deeply immutable: you can neither
reassign the name nor modify its members, at any depth:

const pi = 3.14
# pi = 3.0              # error: REASSIGN_CONSTANT

const user = {name: "Ann", roles: ["admin"]}
# user = {name: "Bob"}  # error: REASSIGN_CONSTANT
# user.name = "Bob"     # error: MODIFY_CONSTANT (members are locked too)

Use const for values you treat as fixed — config knobs, computed-once
constants — so a later accidental write fails loudly.

Restricted names

restricted is a read-only marker that the script cannot change, applied
from the outside rather than declared: builtins, the whole std object, and
any value a host or plugin registers are restricted. A restricted name can be
read and called, but never reassigned or modified (at any depth):

print(len("abc"))        # calling is fine
len = 5                  # error: REASSIGN_RESTRICTED

std.file.read("a.txt")   # reading module members is fine
std.file = 0             # error: REASSIGN_RESTRICTED

The difference from const is who decides:

  • const is something you declare — your own value, locked by choice.
  • restricted is imposed on you — the engine’s builtins (len, keys,
    items, …), the std library, and anything the host injects as
    restricted. You get REASSIGN_RESTRICTED / MODIFY_RESTRICTED if you try
    to write to one.

Colliding with a restricted name is the usual cause of MODIFY_RESTRICTED:
var items = [...] fails at the declaration itself (items is already a
builtin) — rename the variable.

The host side is covered in Embedding — restricted and const
values
.

local — a binding that never leaks

local declares a binding that is scoped to the current execution and can
never become a global:

  • Inside a block or function, local x = ... behaves exactly like var x = ...: a fresh slot in the current scope that shadows any outer x.
    The difference there is purely intent — local says “I mean a new
    variable, not the outer one”.
  • At the top level, they differ: var x becomes a persistent global
    (visible to later exec() calls, the next REPL line, and host at()
    reads), while local x lives in engine state for the rest of this run
    and is erased when the top-level execution finishes.
var counter = 0
if (enabled) {
  local counter = 5      # shadows; the outer counter is untouched
  counter += 1           # 6 (this local)
}
print(counter)           # 0 (outer unchanged)
# one script file:
local tmp = compute()    # visible for the rest of THIS run...
print(tmp)               # ...but never leaks into engine globals
# a later exec(), or the next REPL line, cannot see `tmp`;
# `var tmp` instead would still be there

Reach for local for top-level temporaries in scripts and libraries (no
global pollution), for deliberate shadowing made explicit, and in the REPL
where you don’t want the name lingering after the line.

ref — shared storage (an alias)

ref does not copy the value — it points at the same storage as another
slot, so reads and writes through the alias reach the original:

var x = 42
ref alias = x            # alias and x share the same storage
alias = 99               # writes through to x
print(x)                 # 99

var user = {count: 0}
ref u = user
u.count = 5              # member write reaches user
print(user.count)        # 5

ref requires an lvalue on the right (ref a = value, not a literal).
Because function arguments are passed by value (a deep copy), ref is how
you share or mutate state that lives outside the current function.

Rebinding and & — references as values

A plain = on a ref slot writes through — it never rebinds. r = y
stores y’s value into r’s target; it does not make r point at y:

var x = 1
var y = 2
ref r = x
r = y                    # writes 2 through into x (r still aliases x)
print(x)                 # 2

To point a name at different storage, assign a reference value built
with the & (address-of) operator. &x boxes x’s storage and yields a
reference to it — assigning that reference replaces the slot instead of
writing through, in both directions from then on:

var x = 1
var y = 2
var r = &x               # r aliases x (and x is linked into the shared box too)
r = &y                   # rebind: r now aliases y, x is untouched
r = 100                  # writes through to y
print(x)                 # 1
print(y)                 # 100

This works even when the slot starts out plain — assigning &target
converts it:

var four = 4
var nine = 9
four = &nine             # four is now an alias of nine...
print(four)              # 9
print(four == 9)         # true
four = 100               # ...so later writes reach through
print(nine)              # 100

So there are two ways to get aliasing: declare it up front (ref alias = x), or convert later by assigning &target. & needs an lvalue just like
ref (&(a + b) is an error). A plain value on the right of = always
writes through; a reference on the right always rebinds.

ref and pipes

ref aliases share storage, so a pipe called on the alias writes through
to the referenced value, just like a direct write:

var a = [1, 2]
ref r = a
r:insert(4)                # writes through the alias
print(a)                   # [1, 2, 4]

Because r and a share the same underlying array, further pipes keep
accumulating in the same storage:

r:insert(9)
print(a)                   # [1, 2, 4, 9]
print(r)                   # [1, 2, 4, 9]

Rules

  • A declaration keyword (var/const/local/ref) must precede the first
    assignment to a new name. Reassignment uses a bare x = 100 and the name
    must already exist.
  • var/const at the top level are global; inside a function/block they
    are scoped to it. local at the top level keeps the binding as a top-level
    local instead of a global.
  • Variables declared inside a block (if/while/for/else or a standalone
    {}) are local to that block.
  • ref requires an lvalue on the right, cannot destructure, and shares storage
    until the ref slot is rebound.
  • Assignment copies values deeply; see
    Nested objects and arrays and
    Value semantics in Types.

Destructuring declarations unpack an array in one step — a comma-separated
name list (two or more names) before the =:

var a, b = [1, 2]        # a is 1, b is 2
const x, y = [10, 20]    # immutable bindings
local u, v = [3, 4]      # block-local destructuring
# var a, b = 5          # error: value must be an array
# var a, b = [1]        # error: not enough elements

A single name binds the whole array. Extra array elements beyond the names
are ignored.

Keywords

Bindings:      var  const  local  ref
Functions:     fn   return
Branching:     if   elif  else
Loops:         while  for  foreach  each
Switch:        switch  default  break  continue
Modules:       import
Literals:      true  false  nil
  • Bindings — var, const, local, ref (covered in Variables above).
  • Functions — fn defines a function; return exits early.
  • Branching — if / elif / else (C-style else if is written
    elif).
  • Loops — while, C-style for, foreach over an iterable; each is
    the implicit loop variable inside foreach(iterable) { ... }.
  • Switch — switch / default multi-way dispatch, with break and
    continue to control flow.
  • Modules — import loads another script (see Builtins — import).
  • Literals — true, false, nil.

Everything else is an identifier. There is no do, goto, try, or
class.

Next: Types for the full value model, or Builtins — import for multi-file programs.