charged - pannous/warp GitHub Wiki

Charged and uncharged: when code runs

Code is data that is charged: it carries code that fires later. This page is the specification of when an expression is evaluated. Where another page (evaluation, assignment, literal, data, code, block, full-evaluation) disagrees, this page wins.

Goal: as general as lisp (code is data, data can be run, functions can take unevaluated arguments), and as foolproof as possible: the timing of every expression is visible where it is written, a typo never silently becomes data, and every surprising case is a loud diagnostic at the place where it was written.

Vocabulary

Every page of this wiki uses these words in this sense:

word meaning
value the result of an evaluation: x = 1+2 binds the value 3
charged carries code that fires at every use or call: f := e, def f …, a function
uncharged inert until ! fires it: x : e
block unevaluated code meant to run (x : e, {statements}, code e, a block parameter); its type is block
data unevaluated content never meant to run: a data file, data e, plain words
code what a program consists of; code e is a synonym of block e
closure a function value (lambda) together with the variables it reads
group ( … ): evaluated on the spot
symbol an unknown word on its own: red
! / !! force: run a block (once / fully, full-evaluation), unwrap an optional, a plain value stays itself
literal text taken literally: strings, templates, literal arguments (import graphics)

1. Default: programs are code, data files are data

  • A program (a .wasp file run or compiled, the playground, eval) is code.
  • A data file (warp data <file>, parse_data, ParserOptions::data()) is data: nothing is evaluated, (cube 3) [cube 3] {cube 3} are lists, print 3 is the symbol print and the number 3.
  • Inside a program, data is written explicitly (section 4). A later script mode (the shell, REPL) may keep assignments symbolic as evaluation describes; it is not defined yet.

This is the main difference to lisp: lisp quotes data inside code; wasp needs to mark it only there, and a whole file of data needs no marking at all.

Unknown words

One rule everywhere in code, brackets change nothing:

  • A word on its own is a symbol: red, [red green], {color: red} with red undefined are the symbol red (enum-like data). A got-it warning appears only when it is one edit away from a defined name ({age: agee} β†’ "did you mean age?").
  • A word applied to an argument, or called with empty parentheses, is an error: cube 3, cube(3), cube(), [cube 3] with cube undefined are the error undefined function: cube. Write data [cube 3] for the data.

2. Value, charged, uncharged

form kind evaluated type of the name
x = expr value (neither charged nor uncharged) now, once the value's type T
f := expr, f x := expr, f be expr charged (expression form) on every use a function () β†’ T / X β†’ T
def f(x): …, def f {…}, to f x: …, fun f(x){…} charged definition (def = deferred) on every call a function
x : expr uncharged never, until x! block

Charged means it carries code that fires later, at every use. Uncharged means an inert block that fires only when ! charges it. A value has already fired.

:= and def share one timing: := is the expression form, def the statement form. Both behave like Python's def (section 3). A := without parameters is always charged (user decision P71, 2026-10-06): a getter that evaluates its expression at every use and reads the variables as they are then; assigning it afterwards (z = 6, z += 1) is an error that names the definition. Inside an object, key := value is a getter entry too (section 4).

y = 3
x = y*y        // value: 9
z := y*y       // charged
w : y*y        // uncharged: the block y*y
x              // 9
z              // 9, evaluated at this use
w              // y*y, a block
w!             // 9
y = 4
z              // 16: a getter reads the current y

Not getters: f := {…} (a block), f := x => … and f := it*2 (functions), and a name applied to arguments later (sum := fold +; sum [1 2 3], a function value).

3. Charged bodies behave like Python functions

  • Free variables are read at call time (Python's late binding), but a free variable that changes after the definition must be declared: y=3; def z(): y*y; y=4; z() is a compile error at y=4: "z reads y (line 1): declare global y in z to read its current value, or pass y as a parameter". With y=3; def z(): global y; y*y; y=4; z() β†’ 16. Details:
    • a change counts only where a later call can see it: a call in a later statement, or a loop that both changes the variable and calls the function (x=1; inc:={x+1}; x=inc(); x is 2, no error);
    • a free variable that is never reassigned needs no declaration (it is a constant, see below);
    • global may stand on either side: in the reading function (def z(): global y; …) or on the variable at the main level (global limit = 10; def ok(x){x < limit}), which makes it shared for every function;
    • in-place mutation counts as a change (xs.add(1) for a list z reads);
    • a variable of an enclosing function is declared nonlocal y (Python);
    • global y marks z as reading shared state: z is not pure, so it is neither folded nor memoized. Python needs global only for writes; wasp also for reading a changing variable, so the dependency on later state is always visible in the function.
  • Loop variables need no declaration: a function created inside a loop that reads the loop variable captures that iteration's value. fs = []; for i in 0..3 { fs.add(x => x + i) }; fs#1(0), fs#2(0), fs#3(0) gives 0, 1, 2, not Python's 2, 2, 2.
  • Assigning an outer variable inside a body needs global x (Python's global); without it the assignment is a local and a hint names global x.
  • Default arguments are evaluated per call, not once at definition (Python's shared mutable default is a footgun, Footguns).
  • Call arguments are evaluated before the call (strict), unless the parameter is declared block (section 5).
  • Getters are the exception: a := without parameters reads a changed variable without global (y=3; z:=y*y; y=4; z β†’ 16, P71): reading the current value is what a getter is for, and its definition says so.
  • Effectful getters: a zero-parameter charged definition whose body has effects (id := next_id(), now) is allowed but gets a got-it warning at the definition: "id runs next_id() at every read; write id = … for one value". Pure bodies get none: for a pure expression the timing cannot be observed (wasp's effects).
  • Needless charging: a zero-parameter charged definition whose body is pure and reads only constants (area := 3*4) is computed once (folding, below) and gets a got-it note at the definition: "area never changes: write area = 3*4".

Precomputed and precompiled paths

"Every call" is the meaning, not the machine code. The compiler may compute anything earlier whenever no program can tell the difference, that is for every pure part whose inputs are known:

  • Constant calls are folded: a pure call with constant arguments is computed at compile time and replaced by its value (def fib(n): …; fib(10) compiles to 55).
  • Constant free variables are folded: a free variable that is never reassigned after the definition (no late binding can be observed) is inlined as its value; a reassigned one must be declared global (above) and stays a read at call time.
  • Invariant parts are hoisted: inside a body, a pure subexpression that depends on neither the parameters nor a reassigned variable is computed once (at compile time, or at the first call), not at every call.
  • Pure functions may be memoized: results of a pure function may be cached per argument (fib without exponential blow-up); the compiler decides alone, there is no syntax for it.
  • Specialized paths: a call with some constant arguments may get its own precompiled variant (partial evaluation), e.g. power(x, 3) β†’ x*x*x.
  • Never precomputed: anything with effects (I/O, now, random, mutation, task operations, run-time !) and every read of a variable that is reassigned; these run at every call, in order.

So def is Python's semantics with a compiler's speed: the observable behaviour is per call, the work is done as early as purity allows. The same applies to :=.

4. Blocks and data inside programs

Two words for two intentions:

  • A block is code meant to run later: x : expr, or the prefix code expr / block expr (synonyms). Its type is block; x! runs it.
  • data is never meant to run: the prefix data expr (data [cube 3]), and plain words (section 1). ! on data is allowed but gets a got-it warning ("running data as code").

Both are unevaluated Nodes; the difference is the intention, which decides the warnings. The word quote is not used (quote redirects here).

{} is data by default

{ … } in a program is data unless an entry says otherwise:

  • key: value entries are uncharged (a literal or a plain word is simply that value or symbol);
  • key = value entries are values, evaluated now, once, where the object is written;
  • key := value entries are getters (P71, as in section 2): o = {a: 1, s := clock()} reads the clock at every o.s, and the body reads the object's other fields and the variables as they are at that read;
  • statements make a block: x = {1+2} is the block 1+2 (x! β†’ 3), while the group (1+2) is evaluated on the spot (blocks are passed around lazily, groups get evaluated on the spot);
  • control flow takes blocks as block parameters (section 5): if c {…}, while c {…}, for x in xs {…} and def f {…} run their block when they decide to, so they need no special rule.
a = 1; b = 2
o = {
  s1: a+b     // uncharged: the block a+b; o.s1! is 3
  s2 := a+b   // getter: a+b at every read of o.s2
  s3 = a+b    // value: 3
  age: 3      // a literal is the same under every form: no warning
  color: red  // the symbol red
}

Footgun guards:

  • At the definition, not at the use: a computed expression after : in code (s1: a+b, not a literal and not a plain word) gets a got-it warning where it is written: "s1 keeps the block a+b; write s1 = a+b for its value". Literals and words never warn, so JSON-shaped data stays silent.
  • The type is visible: using a block where a value is needed (o.s1 + 1) is a type error with the fix (o.s1! or s1 = …), never an automatic evaluation.
  • Not uncharged: : keeps its other meanings, decided by position: a type after a field name declares a field (person{name:text age:int}), named call arguments are evaluated (f(x: a+b), call arguments are strict), the colon of a definition or branch (to square x: x*x, if c: x else y) is syntax, and a data file is all data.

5. Running blocks: !, and block parameters

  • x! forces x, "get the real value now": a block runs, an optional gives its value or the error unwrapped ΓΈ, a plain value is itself. The kind decides, statically where it is known, else at run time.
  • x! runs a block once: one level is removed. x!! (or interpret x) evaluates fully, including nested blocks (full-evaluation).
  • Names inside a block resolve where ! is written (lisp's eval, not a closure). Because this is not hygienic, when the block is a constant (known at compile time, the usual case) every name is resolved at compile time at the ! site and an unresolved one is an error there; a name that resolves to a different binding than one visible where the block was written gets a got-it warning.
  • help = {print: "there is help"}; help! runs the object as code: each key: value becomes the call key(value) (print "there is help").
  • Lisp macros without macros: a parameter declared block (synonym code) receives its argument unevaluated, and the body runs it with ! when it wants to: unless(c, body:block) := if !c: body!. The laziness is visible in the signature, so the call site looks normal and the IDE marks such arguments.

Run-time !

A block known only at run time (built by the program, read from input) is compiled when ! runs it: the program hands it to warp's compiler through a host import and runs the result in the same store, sharing its values:

  • Rights: a block that arrives at run time is pure by default: no I/O, files or FFI unless the program grants them explicitly (x! with io); data read at run time is tainted (like eval_untrusted).
  • Outside warp's runners: warp compile of a program with run-time ! gives a warning, and the module loads a shared compiler lazily the first time ! runs.
  • Run-time ! is an effect: its function and every caller are not pure (no folding, no memoizing), and everything visible at the ! site stays in the module.
  • A run-time block reads the ! site's local variables as they are when ! runs and assigns only declared globals.

6. Brackets in code

data file program
( … ) list group or call, evaluated on the spot
[ … ] list list of evaluated elements; plain words are symbols (section 1)
{ … } list / map data by default: an object (section 4) or a block
⚠️ **GitHub.com Fallback** ⚠️