Source: dialects/ztree/lang.js

/*
 * z-Tree's programming language: parse(code) gives a program, run(program, ctx) runs it.
 * Written from z-Tree's manual and example treatments.
 *
 * Programs run on a record of a table (subjects, globals, contracts, summary, session, ...). A
 * variable is the record's, else (in a contract or a table function's record) the enclosing
 * records', else the globals table's; one that was never set is 0. `:x` is the enclosing record's
 * (the record whose program called a table function, or ran a do block), `\x` the globals'.
 *
 *   x = 1; a[i] = 2; array a[10]; array b[0, 10, 2];
 *   if (c) { ... } elseif (c) { ... } else { ... }   (elsif too)
 *   while (c) { ... }   repeat { ... } while (c);
 *   subjects.do { ... }   contracts.new { ... }   later (seconds) do { ... }   later (s) repeat { ... }
 *   expressions: + - * / ^, == != <> < <= > >=, & |, not(), if(c, a, b), numbers, "strings",
 *   table functions [T.]sum/count/average/minimum/maximum/product/median/stddev/find([cond,] expr),
 *   same(x), and abs exp ln log sqrt round rounddown roundup trunc mod min max random power ...
 */

class ZtreeSyntaxError extends Error {
    constructor(message, line) {
        super(message + (line != null ? ' (line ' + line + ')' : ''));
        this.line = line;
    }
}

// --- Tokens -------------------------------------------------------------------------------

function tokenize(code) {
    const tokens = [];
    let i = 0;
    let line = 1;
    while (i < code.length) {
        const c = code[i];
        if (c === '\n') { line++; i++; continue; }
        if (/\s/.test(c)) { i++; continue; }
        if (c === '/' && code[i + 1] === '/') {
            while (i < code.length && code[i] !== '\n') i++;
            continue;
        }
        if (c === '/' && code[i + 1] === '*') {
            const end = code.indexOf('*/', i + 2);
            const text = code.slice(i, end < 0 ? code.length : end + 2);
            line += (text.match(/\n/g) || []).length;
            i += text.length;
            continue;
        }
        if (/[0-9]/.test(c) || (c === '.' && /[0-9]/.test(code[i + 1] || ''))) {
            const m = /^(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?/.exec(code.slice(i));
            tokens.push({ t: 'num', v: Number(m[0]), line });
            i += m[0].length;
            continue;
        }
        if (/[A-Za-z_]/.test(c)) {
            const m = /^[A-Za-z_][A-Za-z_0-9]*/.exec(code.slice(i));
            tokens.push({ t: 'id', v: m[0], line });
            i += m[0].length;
            continue;
        }
        if (c === '"') {
            let j = i + 1;
            let s = '';
            while (j < code.length && code[j] !== '"') {
                if (code[j] === '\\' && j + 1 < code.length) { s += code[j + 1]; j += 2; continue; }
                s += code[j++];
            }
            tokens.push({ t: 'str', v: s, line });
            i = j + 1;
            continue;
        }
        const two = code.slice(i, i + 2);
        if (['==', '!=', '<>', '<=', '>=', '&&', '||'].includes(two)) {
            tokens.push({ t: 'op', v: two === '<>' ? '!=' : two === '&&' ? '&' : two === '||' ? '|' : two, line });
            i += 2;
            continue;
        }
        if ('+-*/^<>=&|!(){}[],;.:\\'.includes(c)) {
            tokens.push({ t: 'op', v: c, line });
            i++;
            continue;
        }
        throw new ZtreeSyntaxError('unexpected character ' + JSON.stringify(c), line);
    }
    tokens.push({ t: 'end', v: '', line });
    return tokens;
}

// --- Parser -------------------------------------------------------------------------------

const AGGREGATES = new Set(['sum', 'count', 'average', 'minimum', 'maximum', 'product', 'median', 'stddev', 'find', 'same']);

function parse(code) {
    const tokens = tokenize(code);
    let p = 0;
    const peek = (o = 0) => tokens[p + o];
    const next = () => tokens[p++];
    const is = (v, o = 0) => peek(o).t === 'op' && peek(o).v === v;
    const isId = (v, o = 0) => peek(o).t === 'id' && peek(o).v.toLowerCase() === v;
    const expect = (v) => {
        const t = next();
        if (t.t !== 'op' || t.v !== v) throw new ZtreeSyntaxError('expected ' + v + ' but found ' + (t.v || 'the end'), t.line);
        return t;
    };

    function statements(until) {
        const out = [];
        while (peek().t !== 'end' && !(until && is(until))) {
            if (is(';')) { next(); continue; }
            out.push(statement());
        }
        return out;
    }

    function block() {
        expect('{');
        const body = statements('}');
        expect('}');
        return body;
    }

    function statement() {
        const t = peek();
        if (isId('if')) {
            next();
            const branches = [];
            expect('(');
            branches.push({ cond: expr(), body: (expect(')'), block()) });
            let otherwise = null;
            for (;;) {
                if (isId('elseif') || isId('elsif')) {
                    next(); expect('(');
                    branches.push({ cond: expr(), body: (expect(')'), block()) });
                } else if (isId('else')) {
                    next();
                    if (isId('if')) {
                        otherwise = [statement()];
                    } else {
                        otherwise = block();
                    }
                    break;
                } else {
                    break;
                }
            }
            return { k: 'if', branches, otherwise, line: t.line };
        }
        if (isId('while')) {
            next(); expect('(');
            const cond = expr();
            expect(')');
            return { k: 'while', cond, body: block(), line: t.line };
        }
        if (isId('repeat')) {
            next();
            const body = block();
            if (!isId('while')) throw new ZtreeSyntaxError('expected while after repeat { }', peek().line);
            next(); expect('(');
            const cond = expr();
            expect(')');
            if (is(';')) next();
            return { k: 'repeat', cond, body, line: t.line };
        }
        if (isId('array')) {
            next();
            const name = next();
            expect('[');
            const dims = [expr()];
            while (is(',')) { next(); dims.push(expr()); }
            expect(']');
            if (is(';')) next();
            return { k: 'array', name: name.v, dims, line: t.line };
        }
        if (isId('later')) {
            next(); expect('(');
            const delay = expr();
            expect(')');
            const kind = next();
            if (kind.t !== 'id' || !['do', 'repeat'].includes(kind.v.toLowerCase())) {
                throw new ZtreeSyntaxError('expected do or repeat after later ( )', kind.line);
            }
            return { k: 'later', delay, repeat: kind.v.toLowerCase() === 'repeat', body: block(), line: t.line };
        }
        if (isId('exit')) {
            next();
            if (is(';')) next();
            return { k: 'exit', line: t.line };
        }
        // for (i, n) { } (i from 1 to n), for (i, from, to[, step]) { } (z-Tree 4): i is set as it
        // goes, a variable of the record (as an assignment would set it: GIMS reads it as :i from
        // the records the loop makes), or \\i of globals.
        if (isId('for') && is('(', 1)) {
            next(); next();
            const target = lvalue();
            const args = [];
            while (is(',')) { next(); args.push(expr()); }
            expect(')');
            return { k: 'for', target, args, body: block(), line: t.line };
        }
        // iterator(i, n).do { } (i from 1 to n), iterator(i, from, to[, step]).do { }
        if (isId('iterator') && is('(', 1)) {
            next(); next();
            const name = next();
            const args = [];
            while (is(',')) { next(); args.push(expr()); }
            expect(')');
            expect('.');
            const kind = next();
            if (kind.t !== 'id' || kind.v.toLowerCase() !== 'do') throw new ZtreeSyntaxError('only iterator( ).do { } is read', kind.line);
            return { k: 'iter', name: name.v, args, body: block(), line: t.line };
        }
        // table.do { } / table.new { }
        if (t.t === 'id' && is('.', 1) && peek(2).t === 'id' && ['do', 'new'].includes(peek(2).v.toLowerCase()) && is('{', 3)) {
            next(); next();
            const kind = next().v.toLowerCase();
            return { k: kind, table: t.v, body: block(), line: t.line };
        }
        // An assignment.
        const target = lvalue();
        expect('=');
        const value = expr();
        if (!is('}')) expect(';');
        return { k: 'set', target, value, line: t.line };
    }

    function lvalue() {
        let scope = 'own';
        let depth = 0;
        // :x is the enclosing record's, ::x the one enclosing that, ...
        while (is(':')) { next(); scope = 'outer'; depth++; }
        if (scope === 'own' && is('\\')) { while (is('\\')) next(); scope = 'globals'; }
        const name = next();
        if (name.t !== 'id') throw new ZtreeSyntaxError('expected a variable but found ' + (name.v || 'the end'), name.line);
        let index = null;
        if (is('[')) { next(); index = expr(); expect(']'); }
        return { name: name.v, scope, index, depth };
    }

    // Precedence, lowest first: | & comparisons + - * / ^ unary.
    function expr() { return or(); }
    function or() {
        let l = and();
        while (is('|')) { next(); l = { k: 'bin', op: '|', l, r: and() }; }
        return l;
    }
    function and() {
        let l = cmp();
        while (is('&')) { next(); l = { k: 'bin', op: '&', l, r: cmp() }; }
        return l;
    }
    function cmp() {
        let l = add();
        while (['==', '!=', '<', '<=', '>', '>=', '='].some((o) => is(o))) {
            const op = next().v;
            l = { k: 'bin', op: op === '=' ? '==' : op, l, r: add() };
        }
        return l;
    }
    function add() {
        let l = mul();
        while (is('+') || is('-')) { const op = next().v; l = { k: 'bin', op, l, r: mul() }; }
        return l;
    }
    function mul() {
        let l = pow();
        while (is('*') || is('/')) { const op = next().v; l = { k: 'bin', op, l, r: pow() }; }
        return l;
    }
    function pow() {
        const l = unary();
        if (is('^')) { next(); return { k: 'bin', op: '^', l, r: pow() }; }
        return l;
    }
    function unary() {
        if (is('-')) { next(); return { k: 'neg', e: unary() }; }
        if (is('+')) { next(); return unary(); }
        if (is('!')) { next(); return { k: 'not', e: unary() }; }
        return primary();
    }
    function args() {
        expect('(');
        const out = [];
        if (!is(')')) {
            out.push(expr());
            while (is(',')) { next(); out.push(expr()); }
        }
        expect(')');
        return out;
    }
    function primary() {
        const t = peek();
        if (t.t === 'num') { next(); return { k: 'num', v: t.v }; }
        if (t.t === 'str') { next(); return { k: 'str', v: t.v }; }
        if (is('(')) { next(); const e = expr(); expect(')'); return e; }
        if (is(':') || is('\\')) {
            const scope = next().v === ':' ? 'outer' : 'globals';
            let depth = scope === 'outer' ? 1 : 0;
            while (scope === 'outer' && is(':')) { next(); depth++; }
            // \\x (as GIMS writes some): globals' still.
            while (scope === 'globals' && is('\\')) next();
            const name = next();
            let index = null;
            if (is('[')) { next(); index = expr(); expect(']'); }
            return { k: 'var', name: name.v, scope, index, depth };
        }
        if (t.t === 'id') {
            next();
            // table.function(...)
            if (is('.') && peek(1).t === 'id' && is('(', 2)) {
                next();
                const fn = next().v.toLowerCase();
                return { k: 'agg', table: t.v, fn, args: args(), line: t.line };
            }
            if (is('(')) {
                const fn = t.v.toLowerCase();
                const a = args();
                if (AGGREGATES.has(fn)) return { k: 'agg', table: null, fn, args: a, line: t.line };
                return { k: 'call', fn, args: a, line: t.line };
            }
            let index = null;
            if (is('[')) { next(); index = expr(); expect(']'); }
            if (/^(true|false)$/i.test(t.v) && index == null) return { k: 'num', v: /^true$/i.test(t.v) ? 1 : 0 };
            return { k: 'var', name: t.v, scope: 'own', index };
        }
        throw new ZtreeSyntaxError('unexpected ' + (t.v || 'end of program'), t.line);
    }

    const body = statements(null);
    return { body };
}

/** An expression (a condition, a timeout, a variable in a layout) parsed. */
function parseExpr(code) {
    const program = parse('__value = ' + code + ';');
    return program.body[0].value;
}

// --- Running ------------------------------------------------------------------------------

/**
 * Where a program runs: a table's record, in a runtime.
 * - rt.records(table, {old}): the table's records (in the current period, or the one before);
 *   rt.globals(): the globals record; rt.isVar(table, name): whether the table has the variable;
 *   rt.newRecord(table): a new record; rt.later(seconds, fn): runs fn later.
 * - ctx: {rt, table, rec, outer}: outer is the enclosing context (for :x).
 */
class Exit extends Error {}

function isArray(v) { return v != null && typeof v === 'object' && v.zArray === true; }
function newArray(dims) {
    let [lo, hi, step] = dims.length === 1 ? [1, dims[0], 1] : [dims[0], dims[1], dims[2] == null ? 1 : dims[2]];
    const n = Math.max(0, Math.floor((hi - lo) / step + 1e-9) + 1);
    return { zArray: true, lo, step, values: new Array(n).fill(0) };
}
/**
 * Whether ctx runs by z-Tree's rules where they differ from jtree's (the session's mode is
 * 'ztree'): an index out of range is its array's first or last element, and arithmetic gives
 * infinities and NaN (1/0, sqrt(-1)) as z-Tree 5 does, with no error. By jtree's (and oTree's,
 * which has none of its own for z-Tree's language), these are errors.
 */
function zTreeRules(ctx) {
    return ctx != null && ctx.rt != null && ctx.rt.mode === 'ztree';
}

/** The position in arr of index i (rounded to its nearest, as z-Tree does). */
function arrayIndex(arr, i, ctx) {
    const k = Math.round((i - arr.lo) / arr.step);
    if (k < 0 || k >= arr.values.length) {
        if (zTreeRules(ctx) && arr.values.length > 0) return k < 0 ? 0 : arr.values.length - 1;
        throw new Error('array index ' + i + ' out of range');
    }
    return k;
}

/** A number arithmetic or a function gave: by jtree's rules, not a finite one is an error. */
function finite(v, ctx, what) {
    if (typeof v === 'number' && !Number.isFinite(v) && !zTreeRules(ctx)) throw new Error(what() + ' is ' + (Number.isNaN(v) ? 'not a number' : v > 0 ? 'infinite' : 'minus infinite'));
    return v;
}

/** The record (and its table) a variable is in, looking out from ctx; null if none. */
function owner(ctx, name, scope, depth = 1) {
    const rt = ctx.rt;
    if (scope === 'globals') return { rec: rt.globals(), table: 'globals' };
    let c = ctx;
    if (scope === 'outer') for (let k = 0; k < (depth || 1) && c != null; k++) c = c.outer;
    if (c == null) throw new Error(':' + name + ' has no enclosing record here');
    for (let d = c; d != null; d = d.outer) {
        if (d.rec != null && (Object.prototype.hasOwnProperty.call(d.rec, name) || rt.isVar(d.table, name))) {
            return { rec: d.rec, table: d.table };
        }
    }
    if (rt.isVar('globals', name) || Object.prototype.hasOwnProperty.call(rt.globals(), name)) {
        return { rec: rt.globals(), table: 'globals' };
    }
    return null;
}

/**
 * rec's array name, made if its table declares it (array X[n]; in any of its programs, even one
 * that does not run: z-Tree's declarations are its table's, as GIMS uses them), sized as its
 * declaration says, here.
 */
function declared(ctx, rec, table, name) {
    const v = rec[name];
    if (isArray(v)) return v;
    const dims = ctx.rt.declaredArray ? ctx.rt.declaredArray(table, name) : null;
    if (dims == null) return v;
    rec[name] = newArray(dims.map((d) => num(evaluate(d, ctx))));
    return rec[name];
}

function readVar(ctx, node) {
    const o = owner(ctx, node.name, node.scope, node.depth);
    let v = o == null ? 0 : o.rec[node.name];
    if (v === undefined) v = 0;
    if (node.index != null) {
        if (o != null && !isArray(v)) v = declared(ctx, o.rec, o.table, node.name);
        if (!isArray(v)) throw new Error(node.name + ' is not an array');
        return v.values[arrayIndex(v, num(evaluate(node.index, ctx)), ctx)];
    }
    return v;
}

function writeVar(ctx, target, value) {
    let rec;
    if (target.scope === 'globals') {
        rec = ctx.rt.globals();
    } else if (target.scope === 'outer') {
        const o = owner(ctx, target.name, 'outer', target.depth);
        let c = ctx;
        for (let k = 0; k < (target.depth || 1) && c.outer != null; k++) c = c.outer;
        rec = o != null ? o.rec : c.rec;
    } else {
        // A variable of this record's table, else of an enclosing record that has it, else new here.
        const o = owner(ctx, target.name, 'own');
        rec = o != null && !ctx.rt.isVar(ctx.table, target.name) && !Object.prototype.hasOwnProperty.call(ctx.rec, target.name) ? o.rec : ctx.rec;
    }
    if (target.index != null) {
        const table = rec === ctx.rec ? ctx.table : rec === ctx.rt.globals() ? 'globals' : (owner(ctx, target.name, target.scope, target.depth) || {}).table;
        const arr = declared(ctx, rec, table, target.name);
        if (!isArray(arr)) throw new Error(target.name + ' is not an array');
        arr.values[arrayIndex(arr, num(evaluate(target.index, ctx)), ctx)] = value;
    } else {
        rec[target.name] = value;
    }
}

const num = (v) => (typeof v === 'string' ? (v.trim() === '' || isNaN(v) ? 0 : Number(v)) : typeof v === 'boolean' ? (v ? 1 : 0) : v == null ? 0 : Number(v));
const bool = (v) => (typeof v === 'string' ? v !== '' : num(v) !== 0);

function roundTo(x, step, mode) {
    step = step == null || num(step) === 0 ? 1 : Math.abs(num(step));
    const q = x / step;
    const r = mode === 'down' ? Math.floor(q + 1e-9) : mode === 'up' ? Math.ceil(q - 1e-9) : Math.round(q + (q >= 0 ? 1e-9 : -1e-9));
    // Avoid 0.30000000000000004.
    const decimals = Math.max(0, -Math.floor(Math.log10(step) + 1e-9));
    return Number((r * step).toFixed(Math.min(decimals + 2, 12)));
}

const FUNCTIONS = {
    if: (a) => (bool(a[0]) ? a[1] : a[2]),
    not: (a) => (bool(a[0]) ? 0 : 1),
    abs: (a) => Math.abs(num(a[0])),
    exp: (a) => Math.exp(num(a[0])),
    ln: (a) => Math.log(num(a[0])),
    log: (a) => Math.log10(num(a[0])),
    sqrt: (a) => Math.sqrt(num(a[0])),
    sin: (a) => Math.sin(num(a[0])),
    cos: (a) => Math.cos(num(a[0])),
    tan: (a) => Math.tan(num(a[0])),
    atan: (a) => Math.atan(num(a[0])),
    power: (a) => Math.pow(num(a[0]), num(a[1])),
    round: (a) => roundTo(num(a[0]), a[1]),
    rounddown: (a) => roundTo(num(a[0]), a[1], 'down'),
    roundup: (a) => roundTo(num(a[0]), a[1], 'up'),
    trunc: (a) => Math.trunc(num(a[0])),
    sign: (a) => Math.sign(num(a[0])),
    mod: (a) => { const m = num(a[1]); return ((num(a[0]) % m) + m) % m; },
    min: (a) => Math.min(...a.map(num)),
    max: (a) => Math.max(...a.map(num)),
    random: () => Math.random(),
    randomgenerator: () => Math.random(),
    gettime: (a, ctx) => ctx.rt.time(),
    pi: () => Math.PI,
    /** A color, from red, green and blue between 0 and 1. */
    rgb: (a) => 'rgb(' + a.slice(0, 3).map((v) => Math.round(Math.max(0, Math.min(1, num(v))) * 255)).join(',') + ')',
    true: () => 1,
    false: () => 0,
};

function evaluate(node, ctx) {
    switch (node.k) {
        case 'num':
        case 'str':
            return node.v;
        case 'var':
            return readVar(ctx, node);
        case 'neg':
            return -num(evaluate(node.e, ctx));
        case 'not':
            return bool(evaluate(node.e, ctx)) ? 0 : 1;
        case 'bin': {
            if (node.op === '&') return bool(evaluate(node.l, ctx)) && bool(evaluate(node.r, ctx)) ? 1 : 0;
            if (node.op === '|') return bool(evaluate(node.l, ctx)) || bool(evaluate(node.r, ctx)) ? 1 : 0;
            const l = evaluate(node.l, ctx);
            const r = evaluate(node.r, ctx);
            if (typeof l === 'string' || typeof r === 'string') {
                switch (node.op) {
                    case '+': return String(l) + String(r);
                    case '==': return String(l) === String(r) ? 1 : 0;
                    case '!=': return String(l) !== String(r) ? 1 : 0;
                    default: break;
                }
            }
            const a = num(l);
            const b = num(r);
            const what = () => a + ' ' + node.op + ' ' + b;
            switch (node.op) {
                case '+': return finite(a + b, ctx, what);
                case '-': return finite(a - b, ctx, what);
                case '*': return finite(a * b, ctx, what);
                case '/':
                    if (b === 0 && !zTreeRules(ctx)) throw new Error('division by zero');
                    return a / b;
                case '^': return finite(Math.pow(a, b), ctx, what);
                // Comparisons allow for rounding errors, as z-Tree's do.
                case '==': return Math.abs(a - b) < 1e-9 ? 1 : 0;
                case '!=': return Math.abs(a - b) >= 1e-9 ? 1 : 0;
                case '<': return a < b - 1e-9 ? 1 : 0;
                case '<=': return a <= b + 1e-9 ? 1 : 0;
                case '>': return a > b + 1e-9 ? 1 : 0;
                case '>=': return a >= b - 1e-9 ? 1 : 0;
                default: throw new Error('unknown operator ' + node.op);
            }
        }
        case 'call': {
            const fn = FUNCTIONS[node.fn];
            if (fn == null) throw new Error('unknown function ' + node.fn + ' (line ' + node.line + ')');
            if (node.fn === 'if') return bool(evaluate(node.args[0], ctx)) ? evaluate(node.args[1], ctx) : evaluate(node.args[2], ctx);
            const args = node.args.map((a) => evaluate(a, ctx));
            return finite(fn(args, ctx), ctx, () => node.fn + '(' + args.join(', ') + ')');
        }
        case 'agg':
            return aggregate(node, ctx);
        default:
            throw new Error('cannot evaluate ' + node.k);
    }
}

/** The records of a table named in a program (OLDsubjects: the period before's). */
function tableRecords(ctx, name) {
    const old = /^OLD/.test(name);
    const table = old ? name.slice(3) : name;
    return { table, records: ctx.rt.records(table, { old }) };
}

function aggregate(node, ctx) {
    if (node.fn === 'same') {
        // same(x): x here is x in the enclosing record.
        const here = evaluate(node.args[0], ctx);
        const there = evaluate(node.args[0], ctx.outer || ctx);
        return (typeof here === 'string' ? here === there : Math.abs(num(here) - num(there)) < 1e-9) ? 1 : 0;
    }
    const { table, records } = node.table == null ? { table: ctx.table, records: ctx.rt.records(ctx.table, {}) } : tableRecords(ctx, node.table);
    const each = (r) => ({ rt: ctx.rt, table, rec: r, outer: ctx });
    let cond = null;
    let value = null;
    if (node.fn === 'count') {
        cond = node.args[0] || null;
    } else if (node.args.length >= 2) {
        cond = node.args[0];
        value = node.args[1];
    } else {
        value = node.args[0];
    }
    const chosen = cond == null ? records : records.filter((r) => bool(evaluate(cond, each(r))));
    if (node.fn === 'count') return chosen.length;
    if (node.fn === 'find') {
        if (chosen.length === 0) return 0;
        return value == null ? 1 : evaluate(value, each(chosen[0]));
    }
    const values = chosen.map((r) => num(evaluate(value, each(r))));
    switch (node.fn) {
        case 'sum': return values.reduce((a, b) => a + b, 0);
        case 'product': return values.reduce((a, b) => a * b, 1);
        case 'average': return values.length === 0 ? 0 : values.reduce((a, b) => a + b, 0) / values.length;
        case 'minimum': return values.length === 0 ? Infinity : Math.min(...values); // none: no offer is lower
        case 'maximum': return values.length === 0 ? -Infinity : Math.max(...values);
        case 'median': {
            if (values.length === 0) return 0;
            const s = values.slice().sort((a, b) => a - b);
            const m = Math.floor(s.length / 2);
            return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
        }
        case 'stddev': {
            if (values.length === 0) return 0;
            const mean = values.reduce((a, b) => a + b, 0) / values.length;
            return Math.sqrt(values.reduce((a, b) => a + (b - mean) * (b - mean), 0) / values.length);
        }
        default: throw new Error('unknown table function ' + node.fn);
    }
}

function exec(stmts, ctx) {
    for (const s of stmts) {
        switch (s.k) {
            case 'set':
                writeVar(ctx, s.target, evaluate(s.value, ctx));
                break;
            case 'if': {
                const branch = s.branches.find((b) => bool(evaluate(b.cond, ctx)));
                if (branch) exec(branch.body, ctx);
                else if (s.otherwise) exec(s.otherwise, ctx);
                break;
            }
            case 'while': {
                let n = 0;
                while (bool(evaluate(s.cond, ctx))) {
                    if (++n > 1e6) throw new Error('a while loop ran a million times (line ' + s.line + ')');
                    exec(s.body, ctx);
                }
                break;
            }
            case 'repeat': {
                let n = 0;
                do {
                    if (++n > 1e6) throw new Error('a repeat loop ran a million times (line ' + s.line + ')');
                    exec(s.body, ctx);
                } while (bool(evaluate(s.cond, ctx)));
                break;
            }
            case 'array': {
                const dims = s.dims.map((d) => num(evaluate(d, ctx)));
                const o = owner(ctx, s.name, 'own');
                const rec = o != null && o.rec === ctx.rt.globals() && ctx.table !== 'globals' && !ctx.rt.isVar(ctx.table, s.name) ? o.rec : ctx.rec;
                rec[s.name] = newArray(dims);
                break;
            }
            case 'do': {
                const { table, records } = tableRecords(ctx, s.table);
                for (const r of records.slice()) exec(s.body, { rt: ctx.rt, table, rec: r, outer: ctx });
                break;
            }
            case 'iter': {
                const a = s.args.map((x) => num(evaluate(x, ctx)));
                const [from, to, step] = a.length === 1 ? [1, a[0], 1] : [a[0], a[1], a[2] == null ? 1 : a[2]];
                if (!(step > 0) && !(step < 0)) break;
                let n = 0;
                for (let v = from; step > 0 ? v <= to + 1e-9 : v >= to - 1e-9; v += step) {
                    if (++n > 1e6) throw new Error('an iterator ran a million times (line ' + s.line + ')');
                    exec(s.body, { rt: ctx.rt, table: '__iterator', rec: { [s.name]: Number(v.toFixed(12)) }, outer: ctx });
                }
                break;
            }
            case 'for': {
                const a = s.args.map((x) => num(evaluate(x, ctx)));
                const [from, to, step] = a.length === 1 ? [1, a[0], 1] : [a[0], a[1], a[2] == null ? 1 : a[2]];
                if (!(step > 0) && !(step < 0)) break;
                let n = 0;
                for (let v = from; step > 0 ? v <= to + 1e-9 : v >= to - 1e-9; v += step) {
                    if (++n > 1e6) throw new Error('a for loop ran a million times (line ' + s.line + ')');
                    writeVar(ctx, s.target, Number(v.toFixed(12)));
                    exec(s.body, ctx);
                }
                break;
            }
            case 'new': {
                const rec = ctx.rt.newRecord(s.table);
                exec(s.body, { rt: ctx.rt, table: s.table, rec, outer: ctx });
                break;
            }
            case 'later': {
                const delay = num(evaluate(s.delay, ctx));
                if (delay <= 0) break;
                const tick = () => {
                    exec(s.body, ctx);
                    if (s.repeat) {
                        const again = num(evaluate(s.delay, ctx));
                        if (again > 0) ctx.rt.later(again, tick);
                    }
                };
                ctx.rt.later(delay, tick);
                break;
            }
            case 'exit':
                throw new Exit();
            default:
                throw new Error('cannot run ' + s.k);
        }
    }
}

/** Runs a parsed program in ctx ({rt, table, rec, outer}). */
function run(program, ctx) {
    try {
        exec(program.body, ctx);
    } catch (err) {
        if (!(err instanceof Exit)) throw err;
    }
}

/** The variables a program sets, by table (its own table's, and do/new blocks' tables'). */
function assignedVariables(program, table, out = {}) {
    const add = (t, name) => { (out[t] = out[t] || new Set()).add(name); };
    const visit = (stmts, t) => {
        for (const s of stmts) {
            // x[i] = ... sets an element of an array declared somewhere (array x[n];), maybe
            // globals' from a subjects program: it does not make x a variable of t.
            if (s.k === 'set' && s.target.scope === 'own' && s.target.index == null) add(t, s.target.name);
            if (s.k === 'set' && s.target.scope === 'globals') add('globals', s.target.name);
            if (s.k === 'array') add(t, s.name);
            if (s.k === 'if') { s.branches.forEach((b) => visit(b.body, t)); if (s.otherwise) visit(s.otherwise, t); }
            if (s.k === 'for') add(s.target.scope === 'globals' ? 'globals' : t, s.target.name);
            if (s.k === 'while' || s.k === 'repeat' || s.k === 'later' || s.k === 'iter' || s.k === 'for') visit(s.body, t);
            if (s.k === 'do' || s.k === 'new') visit(s.body, s.table.replace(/^OLD/, ''));
        }
    };
    visit(program.body, table);
    return out;
}

/** The arrays a program declares (array X[n];), by table: {table: {name: [dimension expressions]}}. */
function arrayDeclarations(program, table, out = {}) {
    const visit = (stmts, t) => {
        for (const s of stmts) {
            if (s.k === 'array') (out[t] = out[t] || {})[s.name] = s.dims;
            if (s.k === 'if') { s.branches.forEach((b) => visit(b.body, t)); if (s.otherwise) visit(s.otherwise, t); }
            if (['while', 'repeat', 'later', 'iter', 'for'].includes(s.k)) visit(s.body, t);
            if (s.k === 'do' || s.k === 'new') visit(s.body, s.table.replace(/^OLD/, ''));
        }
    };
    visit(program.body, table);
    return out;
}

module.exports = { parse, parseExpr, run, evaluate, assignedVariables, arrayDeclarations, num, bool, roundTo, isArray, zTreeRules, ZtreeSyntaxError };