You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
import { tokenize } from './lexer'
|
|
import { Parser } from './parser'
|
|
import { compile, recompile, definitions, freeVars, dependencies, dependents, astRegistry } from './compiler'
|
|
import { prettyPrint } from './ast'
|
|
import type { AST } from './ast'
|
|
import { measure } from './ui';
|
|
|
|
const STORAGE_KEY = 'cg-definitions';
|
|
const CHANGELOG_KEY = 'cg-changelog';
|
|
|
|
export const store: Record<string, any> = {};
|
|
|
|
export const _rt = {
|
|
add: (a: number) => (b: number) => a + b,
|
|
sub: (a: number) => (b: number) => a - b,
|
|
mul: (a: number) => (b: number) => a * b,
|
|
div: (a: number) => (b: number) => a / b,
|
|
mod: (a: number) => (b: number) => a % b,
|
|
pow: (a: number) => (b: number) => a ** b,
|
|
cat: (a: any) => (b: any) => Array.isArray(a) ? [...a, ...b] : a + b,
|
|
max: (a: number) => (b: number) => Math.max(a, b),
|
|
min: (a: number) => (b: number) => Math.min(a, b),
|
|
|
|
eq: (a: any) => (b: any) => ({ _tag: deepEqual(a, b) ? 'True' : 'False' }),
|
|
neq: (a: any) => (b: any) => ({ _tag: deepEqual(a, b) ? 'False' : 'True' }),
|
|
gt: (a: any) => (b: any) => ({ _tag: a > b ? 'True' : 'False' }),
|
|
lt: (a: any) => (b: any) => ({ _tag: a < b ? 'True' : 'False' }),
|
|
gte: (a: any) => (b: any) => ({ _tag: a >= b ? 'True' : 'False' }),
|
|
lte: (a: any) => (b: any) => ({ _tag: a <= b ? 'True' : 'False' }),
|
|
|
|
ui: {
|
|
rect: (config: any) => ({ kind: 'rect', ...config }),
|
|
text: (config: any) => ({ kind: 'text', ...config }),
|
|
stack: (config: any) => ({ kind: 'stack', ...config }),
|
|
row: (config: any) => ({ kind: 'row', ...config }),
|
|
column: (config: any) => ({ kind: 'column', ...config }),
|
|
padding: (config: any) => ({ kind: 'padding', ...config }),
|
|
positioned: (config: any) => ({ kind: 'positioned', ...config }),
|
|
clickable: (config: any) => ({ kind: 'clickable', ...config }),
|
|
scrollable: (config: any) => ({ kind: 'scrollable', ...config }),
|
|
clip: (config: any) => ({ kind: 'clip', ...config }),
|
|
opacity: (config: any) => ({ kind: 'opacity', ...config }),
|
|
stateful: (config: any) => ({ kind: 'stateful', ...config }),
|
|
measure: measure,
|
|
measureText: (text: string) => {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (ctx) {
|
|
ctx.font = '16px "SF Mono", "Monaco", "Menlo", monospace';
|
|
return Math.floor(ctx.measureText(text).width);
|
|
}
|
|
return text.length * 10; // fallback
|
|
},
|
|
},
|
|
|
|
batch: (events: any[]) => ({ _tag: 'Batch', _0: events }),
|
|
noOp: { _tag: 'NoOp' },
|
|
focus: (key: string) => ({ _tag: 'Focus', _0: key }),
|
|
|
|
nth: (i: number) => (xs: any[] | string) => i >= 0 && i < xs.length
|
|
? { _tag: 'Some', _0: xs[i] }
|
|
: { _tag: 'None' },
|
|
len: (xs: any[] | string) => xs.length,
|
|
// str: (x: any) => String(x),
|
|
int: (x: any) => typeof x === 'number' ? Math.floor(x) : parseInt(x, 10) || 0,
|
|
show: (value: any): string => {
|
|
if (value === null || value === undefined) return "None";
|
|
if (typeof value === 'string') return value;
|
|
if (typeof value === 'number') return String(value);
|
|
if (typeof value === 'boolean') return value ? "True" : "False";
|
|
if (value._tag) return value._0 !== undefined ? `${value._tag} ${_rt.show(value._0)}` : value._tag;
|
|
if (Array.isArray(value)) return `[${value.map(_rt.show).join(", ")}]`;
|
|
if (typeof value === 'function') return "<function>";
|
|
if (typeof value === 'object') {
|
|
const entries = Object.entries(value).map(([k, v]) => `${k} = ${_rt.show(v)}`);
|
|
return `{ ${entries.join(", ")} }`;
|
|
}
|
|
return String(value);
|
|
},
|
|
chars: (s: string) => s.split(''),
|
|
join: (delim: string) => (xs: string[]) => xs.join(delim),
|
|
split: (delim: string) => (xs: string) => xs.split(delim),
|
|
slice: (s: string | any[]) => (start: number) => (end: number) => s.slice(start, end),
|
|
"debug!": (label: string) => (value: any) => { console.log(label, value); return value; },
|
|
fuzzyMatch: (query: string) => (target: string) => {
|
|
const q = query.toLowerCase();
|
|
const t = target.toLowerCase();
|
|
let qi = 0;
|
|
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
if (t[ti] === q[qi]) qi++;
|
|
}
|
|
return { _tag: qi === q.length ? 'True' : 'False' };
|
|
},
|
|
hasField: (field: string) => (obj: any) => ({
|
|
_tag: (typeof obj === 'object' && obj !== null && field in obj) ? 'True' : 'False'
|
|
}),
|
|
isFunction: (v: any) => ({ _tag: typeof v === 'function' ? 'True' : 'False' }),
|
|
storeSearch: (query: string) => {
|
|
return Object.keys(store)
|
|
.filter(name => _rt.fuzzyMatch(query)(name)._tag === 'True')
|
|
.sort((a, b) => a.length - b.length);
|
|
},
|
|
getSource: (name: string) => {
|
|
const ast = definitions.get(name);
|
|
if (!ast) return "";
|
|
const printed = prettyPrint(ast);
|
|
return printed;
|
|
},
|
|
"saveImage!": () => {
|
|
const saved: Record<string, string> = {};
|
|
for (const [name, ast] of definitions) {
|
|
const source = prettyPrint({ kind: 'definition', name, body: ast });
|
|
saved[name] = source;
|
|
}
|
|
const content = Object.values(saved).join('\n\n');
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = `cg-image-${new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')}.cg`;
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
return { _tag: 'Ok' };
|
|
},
|
|
rebind: (name: string, pathOrValue: any, maybeValue?: any) => {
|
|
if (maybeValue === undefined) {
|
|
store[name] = pathOrValue;
|
|
} else {
|
|
const path = pathOrValue as string[];
|
|
if (store[name] === undefined) store[name] = {};
|
|
let obj = store[name];
|
|
for (let i = 0; i < path.length - 1; i++) {
|
|
if (obj[path[i]] === undefined || obj[path[i]] === null) {
|
|
obj[path[i]] = {};
|
|
}
|
|
obj = obj[path[i]];
|
|
}
|
|
obj[path[path.length - 1]] = maybeValue;
|
|
}
|
|
syncToAst(name);
|
|
},
|
|
rebindAt: (pathStr: string) => (value: string) => {
|
|
const parts = pathStr.split('.');
|
|
const name = parts[0];
|
|
const path = parts.slice(1);
|
|
|
|
return { _tag: 'Rebind', _0: name, _1: path, _2: value };
|
|
},
|
|
"undefine!": (name: string) => {
|
|
delete store[name];
|
|
definitions.delete(name);
|
|
dependencies.delete(name);
|
|
dependents.delete(name);
|
|
saveDefinitions();
|
|
return { _tag: 'Ok' };
|
|
},
|
|
|
|
reflect: (value: any): any => {
|
|
if (value === null || value === undefined) return { _tag: 'NoneValue' };
|
|
if (typeof value === 'number') return { _tag: 'NumberValue', _0: value };
|
|
if (typeof value === 'string') return { _tag: 'StringValue', _0: value };
|
|
if (Array.isArray(value)) return { _tag: 'ListValue', _0: value.map(_rt.reflect) };
|
|
if (typeof value === 'function') {
|
|
const source = value._astId !== undefined && astRegistry.get(value._astId)
|
|
? prettyPrint(astRegistry.get(value._astId)!)
|
|
: '<native>';
|
|
return { _tag: 'FunctionValue', _0: source };
|
|
}
|
|
if (typeof value === 'object' && value._tag) {
|
|
if ('_0' in value) return { _tag: 'ConstructorValue', _0: { tag: value._tag, value: _rt.reflect(value._0) } };
|
|
return { _tag: 'ConstructorValue', _0: { tag: value._tag } };
|
|
}
|
|
if (typeof value === 'object') {
|
|
const entries = Object.entries(value).map(([k, v]) => ({
|
|
key: k,
|
|
value: _rt.reflect(v)
|
|
}));
|
|
return { _tag: 'RecordValue', _0: entries };
|
|
}
|
|
return { _tag: 'NoneValue' };
|
|
},
|
|
|
|
"eval!": (code: string) => {
|
|
const trimmed = code.trim();
|
|
|
|
// is it a definition
|
|
const defMatch = /^([a-z_][a-zA-Z0-9_]*)\s*=(?![=])/.exec(trimmed);
|
|
|
|
if (defMatch) {
|
|
try {
|
|
const fullCode = trimmed.endsWith(';') ? trimmed : trimmed + ';';
|
|
const tokens = tokenize(fullCode);
|
|
const parser = new Parser(tokens, fullCode);
|
|
const defs = parser.parse();
|
|
|
|
if (defs.length > 0) {
|
|
const def = defs[0];
|
|
recompile(def.name, def.body);
|
|
const source = prettyPrint({ kind: 'definition', name: def.name, body: def.body });
|
|
appendChangeLog(def.name, source);
|
|
saveDefinitions();
|
|
return { _tag: 'Defined', _0: def.name };
|
|
}
|
|
} catch (e: any) {
|
|
return { _tag: 'Err', _0: e.message };
|
|
}
|
|
}
|
|
|
|
// its an expression
|
|
try {
|
|
const wrapped = `_expr = ${trimmed};`;
|
|
const tokens = tokenize(wrapped);
|
|
const parser = new Parser(tokens, wrapped);
|
|
const defs = parser.parse();
|
|
const ast = defs[0].body;
|
|
|
|
// validate free vars
|
|
const free = freeVars(ast);
|
|
const allowed = new Set([
|
|
...Object.keys(store),
|
|
...Object.keys(_rt)
|
|
]);
|
|
const unknown = [...free].filter(v => !allowed.has(v));
|
|
if (unknown.length > 0) {
|
|
return { _tag: 'Err', _0: `Unknown: ${unknown.join(', ')}` };
|
|
}
|
|
|
|
const compiled = compile(defs[0].body);
|
|
const fn = new Function('_rt', 'store', `return ${compiled}`);
|
|
const result = fn(_rt, store);
|
|
return { _tag: 'Value', _0: result };
|
|
} catch (e: any) {
|
|
return { _tag: 'Err', _0: e.message };
|
|
}
|
|
}
|
|
}
|
|
|
|
export function saveDefinitions() {
|
|
const saved: Record<string, string> = {};
|
|
for (const [name, ast] of definitions) {
|
|
const source = prettyPrint({ kind: 'definition', name, body: ast });
|
|
saved[name] = source;
|
|
}
|
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(saved));
|
|
|
|
}
|
|
|
|
export function loadDefinitions() {
|
|
const data = localStorage.getItem(STORAGE_KEY);
|
|
if (!data) return;
|
|
|
|
try {
|
|
const saved = JSON.parse(data);
|
|
for (const [_, source] of Object.entries(saved)) {
|
|
const tokens = tokenize(source as string);
|
|
const parser = new Parser(tokens, source as string);
|
|
const defs = parser.parse();
|
|
if (defs.length > 0) {
|
|
recompile(defs[0].name, defs[0].body);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to load definitions:', e);
|
|
console.log(data);
|
|
}
|
|
}
|
|
|
|
function valueToAst(value: any): AST {
|
|
// Numbers
|
|
if (typeof value === 'number') {
|
|
return {
|
|
kind: 'literal',
|
|
value: Number.isInteger(value)
|
|
? { kind: 'int', value }
|
|
: { kind: 'float', value}
|
|
};
|
|
}
|
|
|
|
// Strings
|
|
if (typeof value === 'string') {
|
|
return { kind: 'literal', value: { kind: 'string', value } };
|
|
}
|
|
|
|
// Arrays
|
|
if (Array.isArray(value)) {
|
|
return { kind: 'list', elements: value.map(valueToAst) };
|
|
}
|
|
|
|
// Constructor
|
|
if (typeof value === 'object' && value !== null && value._tag) {
|
|
const tag = value._tag;
|
|
|
|
if ('_0' in value) {
|
|
return {
|
|
kind: 'apply',
|
|
func: { kind: 'constructor', name: tag },
|
|
args: [valueToAst(value._0)]
|
|
};
|
|
}
|
|
|
|
return { kind: 'constructor', name: tag };
|
|
}
|
|
|
|
// Records
|
|
if (typeof value === 'object' && value !== null && !value._tag) {
|
|
const entries = Object.entries(value).map(([k, v]) => ({
|
|
kind: 'field' as const,
|
|
key: k,
|
|
value: valueToAst(v)
|
|
}));
|
|
return { kind: 'record', entries };
|
|
}
|
|
|
|
// Functions
|
|
if (typeof value === 'function') {
|
|
if (value._astId === undefined) {
|
|
throw new Error('Cannot persist native functions');
|
|
}
|
|
const ast = astRegistry.get(value._astId)!;
|
|
|
|
if (!ast) {
|
|
console.error('Registry size:', astRegistry.size, 'Looking for:', value._astId,
|
|
'Function:', value.toString().slice(0, 200));
|
|
|
|
throw new Error(`AST registry miss for _astId ${value._astId}`);
|
|
}
|
|
|
|
return ast;
|
|
}
|
|
|
|
throw new Error(`Cannot convert to AST: ${typeof value}`);
|
|
}
|
|
|
|
export function syncToAst(name: string) {
|
|
// if (definitions.has(name)) {
|
|
if (name in store) {
|
|
definitions.set(name, valueToAst(store[name]));
|
|
const source = prettyPrint({ kind: 'definition', name, body: definitions.get(name)! });
|
|
appendChangeLog(name, source);
|
|
saveDefinitions();
|
|
}
|
|
}
|
|
|
|
function deepEqual(a: any, b: any): boolean {
|
|
if (a === b) return true;
|
|
if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
|
|
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
if (Array.isArray(a)) {
|
|
if (a.length !== b.length) return false;
|
|
return a.every((v, i) => deepEqual(v, b[i]));
|
|
}
|
|
const keysA = Object.keys(a);
|
|
const keysB = Object.keys(b);
|
|
if (keysA.length !== keysB.length) return false;
|
|
return keysA.every(k => deepEqual(a[k], b[k]));
|
|
}
|
|
|
|
function appendChangeLog(name: string, source: string) {
|
|
try {
|
|
const data = localStorage.getItem(CHANGELOG_KEY);
|
|
const log: Array<{ name: string, source: string, timestamp: number }> = data
|
|
? JSON.parse(data)
|
|
: [];
|
|
|
|
if (log.length > 0 && log[log.length - 1].name === name) {
|
|
log[log.length - 1] = { name, source, timestamp: Date.now() };
|
|
} else {
|
|
log.push({ name, source, timestamp: Date.now() });
|
|
}
|
|
|
|
if (log.length > 200) {
|
|
log.splice(0, log.length - 200);
|
|
}
|
|
localStorage.setItem(CHANGELOG_KEY, JSON.stringify(log));
|
|
} catch (e) {
|
|
console.error('Failed to append changelog:', e);
|
|
}
|
|
}
|