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:
constis something you declare — your own value, locked by choice.restrictedis imposed on you — the engine’s builtins (len,keys,
items, …), thestdlibrary, and anything the host injects as
restricted. You getREASSIGN_RESTRICTED/MODIFY_RESTRICTEDif 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 likevar x = ...: a fresh slot in the current scope that shadows any outerx.
The difference there is purely intent —localsays “I mean a new
variable, not the outer one”. - At the top level, they differ:
var xbecomes a persistent global
(visible to laterexec()calls, the next REPL line, and hostat()
reads), whilelocal xlives 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 barex = 100and the name
must already exist. var/constat the top level are global; inside a function/block they
are scoped to it.localat the top level keeps the binding as a top-level
local instead of a global.- Variables declared inside a block (
if/while/for/elseor a standalone
{}) are local to that block. refrequires an lvalue on the right, cannot destructure, and shares storage
until therefslot 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 —
fndefines a function;returnexits early. - Branching —
if/elif/else(C-styleelse ifis written
elif). - Loops —
while, C-stylefor,foreachover an iterable;eachis
the implicit loop variable insideforeach(iterable) { ... }. - Switch —
switch/defaultmulti-way dispatch, withbreakand
continueto control flow. - Modules —
importloads 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.