Skip to main content

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.

// C-style line comment

/* block
comment */

Variables and constants

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

KeywordMutabilityScope / behavior
varmutabletop level = global; inside a block/function = local to it
constimmutablesame scoping as var, but cannot be reassigned or its members changed
localmutablean explicit block-local that shadows an outer binding without touching it
refn/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 // 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 = [...] declares a variable, but items is already a builtin, so using it afterwards fails. Rename the variable.

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

local — explicit block-local

local is var's intent-revealing sibling: it always declares a new local binding that shadows an outer name and never modifies or reuses the outer slot. Inside a block both behave the same; local is the clearest way to say "isolate me from the outer scope":

var counter = 0
if (enabled) {
local counter = 5 // shadows; the outer counter is untouched
counter += 1 // 6 (this local)
}
print(counter) // 0 (outer unchanged)

Reach for local when you are shadowing a name deliberately — especially at the top level where it keeps a local binding that won't clobber a global.

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.

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:

var [a, b] = [1, 2] // a is 1, b is 2
var [head] = [9, 8] // extra elements are ignored
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

Literals

// integers
42
-7
0

// prefixed integers — always produce a long
0xFF // hex 255
0b1010 // binary 10
0o17 // octal 15
-0x10 // negative hex is parsed as a unary minus on the literal

// longs (L/l suffix)
3000000000L
2000000000l

// floats (decimal point or F/f suffix)
3.14
1.0f
3.14F

// doubles (D/d suffix)
3.14D
1.0d

// scientific notation (decimal only)
1.5e10
2.0E-3
5E+2
1e5D

// strings and chars
"hello world"
'c'
'"raw\\nstring"' // raw string literal: \n stays literal

// booleans and nil
true
false
nil

// arrays
[]
[1, 2, 3]
[1, "two", true]

// objects
{}
{name: "John", age: 30}
{"key with spaces": 1}
{nested: {inner: true}}

String and character escapes

Regular double-quoted strings ("...") decode escape sequences:

\n \t \r \\ \" \' \b \f \0 \a \v \/ \uXXXX
print("line1\nline2") // line1 <newline> line2
print("tab\there") // tab
print("quote: \" end") // an embedded double quote
print("\u0041") // "A"
  • \uXXXX decodes exactly four hex digits (e.g. \u0041 is A); values above 0xFF are rejected (non-BMP/surrogates).
  • An unknown escape such as \q is an error rather than a silent strip.
  • escape_string and parseString are inverse: every string that escape_string emits re-parses to the same value.

Raw strings — escapes do not apply

To take text literally (including backslashes), wrap it in a raw string literal using '" ... "' (a single quote followed by a double quote on each end). Inside a raw string, backslash escapes like \n are not decoded — they print verbatim:

print("this\nline") // regular string: \n is a newline
print('"this\nline"') // raw string: \n prints as "\n"
this
line
"this\nline"

The two quote forms in short:

SyntaxKindBackslash escapes
"..."Regular stringDecoded (\n → newline)
'"..."'Raw string literalLiteral (\n prints \n)

Adjacent string literals are concatenated:

var paragraph = "This is a long string that "
"spans multiple lines."
print(paragraph)
// This is a long string that spans multiple lines.

The joins are exact — no whitespace or newline is inserted between the literals, and escape sequences do not apply across the join. If you want a line break at that point, write the \n escape explicitly inside one of the literals:

var two = "first line\n"
"second line"
print(two)
// first line
// second line

A name immediately after a number is an error — there is no implicit multiplication. Write the operator explicitly:

var y = 9
var x = 99 * y // correct: 891
// var x = 99y // error: unexpected 'y' after number '99'

See Descriptors for the -> describe operator.

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
  • Bindingsvar, const, local, ref (covered in Variables above).
  • Functionsfn defines a function; return exits early.
  • Branchingif / elif / else (C-style else if is written elif).
  • Loopswhile, C-style for, foreach over an iterable; each is the implicit loop variable inside foreach(iterable) { ... }.
  • Switchswitch / default multi-way dispatch, with break and continue to control flow.
  • Modulesimport loads another script (see Builtins — import).
  • Literalstrue, false, nil.

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

Hello world

// Run with: scrii_repl hello.scr
print("Hello, world!")

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