SOURCE / PINNED RELEASE
Made of little things.
Sketch Loop
- Release
- 14966bba4a2a…
- Author-recorded commit
- e68361295d81…
- License
- LICENSE
- Author’s source reference
- nostr://npub1n8ga89w8h6tvwamxusfyzexw8gjy84yxu9rxgnmk955cxtml4ujswzxydd/wss%3A%2F%2Fgit.napplet.soy%2F/n-929bc2a88cb
Archive hash verified: 5e0385e3c08f944f…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
import { parse } from 'acorn';
// AST values never carry browser objects. Only explicit native capabilities may
// be called; property access is restricted to interpreter-owned data.
type Node = any;
type Value = any;
type Native = (...args: Value[]) => Value;
class Scope {
values = new Map<string, Value>();
constructor(public parent?: Scope) {}
find(name: string): Scope | undefined { return this.values.has(name) ? this : this.parent?.find(name); }
get(name: string): Value {
const owner = this.find(name);
if (!owner) throw new Error(`${name} is not available. See the supported drawing API in Help.`);
return owner.values.get(name);
}
}
class Closure { constructor(public node: Node, public scope: Scope) {} }
class Flow { constructor(public kind: string, public value?: Value) {} }
const forbiddenKeys = new Set(['constructor', 'prototype', '__proto__', 'caller', 'callee', 'arguments']);
export function normalizeSource(raw: string): string {
return raw.trim().replace(/^```(?:javascript|js)?\s*\n?/i, '').replace(/\n?```$/, '')
.replace(/\\([_*~])/g, '$1')
.replace(/\[(#[^\]]+)\]\(https?:\/\/[^)]+\)/g, '$1');
}
export function parseSketch(raw: string): { source: string; ast: Node } {
if (raw.length > 24000) throw new Error('Keep sketches under 24,000 characters.');
let source = normalizeSource(raw);
// Social posts often collapse the line after the sketch's title comment.
source = source.replace(/(\/\/[^\n]*?)\s+(?=(?:draw|setup)\s*=)/, '$1\n');
try { return { source, ast: parse(source, { ecmaVersion: 2022, locations: true }) }; }
catch {
// Recover missing line breaks in compact, copied posts; ordinary valid JS
// is always parsed unchanged above.
source = source.replace(/([)\]}])\s+(?=(?:for\s*\(|[\w$]+\s*=(?!=)))/g, '$1;\n');
return { source, ast: parse(source, { ecmaVersion: 2022, locations: true }) };
}
}
export class Interpreter {
private root = new Scope();
private natives = new Set<Native>();
private owned = new WeakSet<object>();
private steps = 0;
private deadline = 0;
private depth = 0;
constructor(globals: Record<string, Value>) {
for (const [key, value] of Object.entries(globals)) this.expose(key, value);
this.expose('undefined', undefined); this.expose('NaN', NaN); this.expose('Infinity', Infinity);
}
expose(name: string, value: Value): void {
this.root.values.set(name, value);
if (typeof value === 'function') this.natives.add(value);
else if (value && typeof value === 'object') {
this.owned.add(value);
for (const v of Object.values(value)) if (typeof v === 'function') this.natives.add(v as Native);
}
}
has(name: string): boolean { return !!this.root.find(name); }
value(name: string): Value { return this.root.get(name); }
run(ast: Node): void { this.reset(); this.evaluate(ast, this.root); }
invoke(name: string): void { this.reset(); if (this.has(name)) this.call(this.value(name), []); }
private reset(): void { this.steps = 0; this.depth = 0; this.deadline = performance.now() + 90; }
private tick(node: Node): void {
if (++this.steps > 180000 || (this.steps % 256 === 0 && performance.now() > this.deadline)) {
throw new Error(`Line ${node.loc?.start.line ?? '?'}: sketch exceeded its frame budget. Reduce the loop or drawing count.`);
}
}
private data(value: object): object { this.owned.add(value); return value; }
private key(value: Value): string {
if (typeof value !== 'string' && typeof value !== 'number') throw new Error('Use a string or number as a property.');
const key = String(value);
if (forbiddenKeys.has(key)) throw new Error(`Property ${key} is not available.`);
return key;
}
private member(object: Value, key: string): Value {
if (object == null) throw new Error(`Cannot read ${key} on an empty value.`);
if ((typeof object !== 'object' || !this.owned.has(object)) && typeof object !== 'string') throw new Error('Only sketch data has accessible properties.');
if (key === 'length' && (Array.isArray(object) || typeof object === 'string')) return object.length;
if (Object.hasOwn(object, key)) return object[key];
throw new Error(`Property or method ${key} is not supported.`);
}
private reference(node: Node, scope: Scope): {get: () => Value; set: (value: Value) => Value} {
if (node.type === 'Identifier') {
const owner = scope.find(node.name) ?? this.root;
return { get: () => owner.values.get(node.name), set: value => { owner.values.set(node.name, value); return value; } };
}
if (node.type === 'MemberExpression') {
const object = this.evaluate(node.object, scope);
const key = this.key(node.computed ? this.evaluate(node.property, scope) : node.property.name);
if (!object || !this.owned.has(object)) throw new Error('Cannot modify this value.');
return { get: () => this.member(object, key), set: value => {
if (Array.isArray(object) && (!/^\d+$/.test(key) || Number(key) > 20000)) throw new Error('Array index must be between 0 and 20,000.');
object[key] = value; return value;
} };
}
throw new Error('Unsupported assignment target.');
}
private call(fn: Value, args: Value[]): Value {
if (++this.depth > 64) { this.depth--; throw new Error('Sketch recursion limit reached.'); }
try {
if (fn instanceof Closure) {
const local = new Scope(fn.scope);
fn.node.params.forEach((param: Node, i: number) => {
if (param.type !== 'Identifier') throw new Error('Use simple named function parameters.');
local.values.set(param.name, args[i]);
});
const result = this.evaluate(fn.node.body, local);
return result instanceof Flow ? result.value : fn.node.body.type !== 'BlockStatement' ? result : undefined;
}
if (typeof fn === 'function' && this.natives.has(fn)) {
if (args.some(v => v instanceof Closure || typeof v === 'function')) throw new Error('Drawing functions accept data, not callbacks.');
const result = fn(...args);
if (result && typeof result === 'object') this.owned.add(result);
return result;
}
throw new Error('This value is not a supported function.');
} finally { this.depth--; }
}
private binary(op: string, a: Value, b: Value): Value {
if (a instanceof Closure || b instanceof Closure || (a && typeof a === 'object') || (b && typeof b === 'object')) throw new Error('Arithmetic requires simple values.');
switch (op) {
case '+': { const v = a + b; if (typeof v === 'string' && v.length > 24000) throw new Error('String size limit reached.'); return v; }
case '-': return a - b; case '*': return a * b; case '/': return a / b; case '%': return a % b; case '**': return a ** b;
case '^': return a ^ b; case '&': return a & b; case '|': return a | b; case '<<': return a << b; case '>>': return a >> b; case '>>>': return a >>> b;
case '<': return a < b; case '>': return a > b; case '<=': return a <= b; case '>=': return a >= b;
case '==': return a == b; case '!=': return a != b; case '===': return a === b; case '!==': return a !== b;
default: throw new Error(`Operator ${op} is not supported.`);
}
}
private block(body: Node[], scope: Scope): Value {
for (const node of body) if (node.type === 'FunctionDeclaration') scope.values.set(node.id.name, new Closure(node, scope));
for (const node of body) { const result = this.evaluate(node, scope); if (result instanceof Flow) return result; }
}
private evaluate(node: Node | null, scope: Scope): Value {
if (!node) return undefined;
this.tick(node);
const ev = (n: Node) => this.evaluate(n, scope);
switch (node.type) {
case 'Program': return this.block(node.body, scope);
case 'BlockStatement': return this.block(node.body, new Scope(scope));
case 'EmptyStatement': return;
case 'ExpressionStatement': return ev(node.expression);
case 'Literal': if (node.regex || typeof node.value === 'bigint') throw new Error('Regular expressions and BigInt are not supported.'); return node.value;
case 'Identifier': return scope.get(node.name);
case 'VariableDeclaration': for (const d of node.declarations) { if (d.id.type !== 'Identifier') throw new Error('Use simple variable names.'); scope.values.set(d.id.name, d.init ? ev(d.init) : undefined); } return;
case 'FunctionDeclaration': return;
case 'ArrowFunctionExpression': case 'FunctionExpression': if (node.async || node.generator) throw new Error('Async sketches are not supported.'); return new Closure(node, scope);
case 'CallExpression': return this.call(ev(node.callee), node.arguments.map(ev));
case 'MemberExpression': return this.member(ev(node.object), this.key(node.computed ? ev(node.property) : node.property.name));
case 'ArrayExpression': return this.data(node.elements.map(ev));
case 'ObjectExpression': {
const obj = Object.create(null);
for (const p of node.properties) { if (p.type !== 'Property' || p.kind !== 'init' || p.method) throw new Error('Use plain object properties.'); obj[this.key(p.computed ? ev(p.key) : p.key.name ?? p.key.value)] = ev(p.value); }
return this.data(obj);
}
case 'SequenceExpression': { let result; for (const n of node.expressions) result = ev(n); return result; }
case 'BinaryExpression': return this.binary(node.operator, ev(node.left), ev(node.right));
case 'LogicalExpression': { const a = ev(node.left); return node.operator === '&&' ? a && ev(node.right) : node.operator === '||' ? a || ev(node.right) : a ?? ev(node.right); }
case 'ConditionalExpression': return ev(node.test) ? ev(node.consequent) : ev(node.alternate);
case 'AssignmentExpression': { const ref = this.reference(node.left, scope); return ref.set(node.operator === '=' ? ev(node.right) : this.binary(node.operator.slice(0, -1), ref.get(), ev(node.right))); }
case 'UpdateExpression': { const ref = this.reference(node.argument, scope); const old = Number(ref.get()); const value = ref.set(old + (node.operator === '++' ? 1 : -1)); return node.prefix ? value : old; }
case 'UnaryExpression': {
if (node.operator === 'typeof' && node.argument.type === 'Identifier' && !scope.find(node.argument.name)) return 'undefined';
const v = ev(node.argument);
switch (node.operator) { case '+': return +v; case '-': return -v; case '~': return ~v; case '!': return !v; case 'typeof': return typeof v; case 'void': return undefined; default: throw new Error('Unsupported unary operator.'); }
}
case 'IfStatement': return ev(node.test) ? ev(node.consequent) : ev(node.alternate);
case 'ReturnStatement': return new Flow('return', ev(node.argument));
case 'BreakStatement': return new Flow('break');
case 'ContinueStatement': return new Flow('continue');
case 'ForStatement': {
const local = new Scope(scope); this.evaluate(node.init, local);
while (!node.test || this.evaluate(node.test, local)) {
this.tick(node);
const flow = this.evaluate(node.body, local);
if (flow instanceof Flow) { if (flow.kind === 'return') return flow; if (flow.kind === 'break') break; }
this.evaluate(node.update, local);
} return;
}
case 'WhileStatement': case 'DoWhileStatement': {
let first = node.type === 'DoWhileStatement';
while (first || ev(node.test)) { first = false; this.tick(node); const flow = ev(node.body); if (flow instanceof Flow) { if (flow.kind === 'return') return flow; if (flow.kind === 'break') break; } } return;
}
default: throw new Error(`Line ${node.loc?.start.line ?? '?'}: ${node.type} is not supported in 2D sketches.`);
}
}
}