cg/src/runtime-js.ts

518 lines
19 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, Definition } from './ast'
import { measure } from './ui';
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),
floor: (a: number) => Math.floor(a),
ceiling: (a: number) => Math.ceil(a),
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(''),
strIndexOf: (needle: string) => (haystack: string) => (start: number) => {
const i = haystack.indexOf(needle, start);
return i === -1 ? { _tag: 'None' } : { _tag: 'Some', _0: i };
},
strLastIndexOf: (needle: string) => (haystack: string) => {
const i = haystack.lastIndexOf(needle);
return i === -1 ? { _tag: 'None' } : { _tag: 'Some', _0: i };
},
isWordChar: (s: string) => ({ _tag: /^\w$/.test(s) ? 'True' : 'False' }),
// 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'
}),
getField: (field: string) => (obj: any) => obj[field] !== undefined
? { _tag: 'Some', _0: obj[field] }
: { _tag: 'None' },
entries: (obj: any) => Object.entries(obj).map(([k, v]) => ({ key: k, value: v })),
keys: (obj: any) => Object.keys(obj),
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);
},
getAt: (path: any[]) => {
let obj: any = store[path[0]];
for (let i = 1; i < path.length; i++) {
if (obj === undefined || obj === null) return { _tag: 'None' };
if (!Object.prototype.hasOwnProperty.call(obj, path[i])) return { _tag: 'None' };
obj = obj[path[i]];
}
return obj === undefined ? { _tag: 'None' } : { _tag: 'Some', _0: obj };
},
getSource: (name: string) => {
const def = definitions.get(name);
if (!def) return "";
return prettyPrint(def);
},
getModuleSource: (moduleName: string) => {
const moduleDefs = [...definitions.values()]
.filter(d => d.module === moduleName);
if (moduleDefs.length === 0) return { _tag: 'None' };
return { _tag: 'Some', _0: moduleDefs.map(d => prettyPrint(d)).join('\n\n') };
},
"saveImage!": () => {
const modules = new Map<string, Definition[]>();
const standalone: Definition[] = [];
for (const [_name, def] of definitions) {
if (def.module) {
if (!modules.has(def.module)) modules.set(def.module, []);
modules.get(def.module)!.push(def);
} else {
standalone.push(def);
}
}
// Save modules
for (const [moduleName, defs] of modules) {
const content = '@' + moduleName + '\n\n' +
defs.map(d => prettyPrint(d)).join('\n\n') + '\n\n@\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: moduleName, content })
});
}
// Save standalone defs
for (const def of definitions.values()) {
const content = prettyPrint(def) + '\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: def.name, content })
});
}
},
"applyModule!": (moduleName: string) => (code: string) => {
try {
const tokens = tokenize(code);
const parser = new Parser(tokens, code);
const { definitions: defs } = parser.parse();
// Find existing module defs
const existingNames = new Set(
[...definitions.values()]
.filter(d => d.module === moduleName)
.map(d => d.name)
);
// Apply new defs
const newNames = new Set<string>();
for (const def of defs) {
def.module = moduleName;
if (def.body) {
recompile(def.name, def.body);
definitions.set(def.name, def);
newNames.add(def.name);
}
}
// Delete missing defs
for (const name of existingNames) {
if (!newNames.has(name)) {
delete store[name];
definitions.delete(name);
}
}
// Sync to disk
syncToFilesystem([...newNames][0] || moduleName);
return { _tag: 'Defined', _0: moduleName };
} catch (e: any) {
return { _tag: 'Err', _0: e.message };
}
},
"saveModule!": (moduleName: string) => {
const moduleDefs = [...definitions.values()]
.filter(d => d.module === moduleName);
if (moduleDefs.length === 0) return { _tag: 'Err', _0: 'No module: ' + moduleName };
const content = '@' + moduleName + '\n\n' +
moduleDefs.map(d => prettyPrint(d)).join('\n\n') + '\n\n@\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: moduleName, content })
});
return { _tag: 'Defined', _0: moduleName };
},
"saveDef!": (name: string) => {
const def = definitions.get(name);
if (!def) return { _tag: 'Err', _0: 'Unknown: ' + name };
const content = prettyPrint(def) + '\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: name, content })
});
return { _tag: 'Defined', _0: name };
},
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: (path: any[]) => (value: any) => {
const name = path[0];
const rest = path.slice(1);
return { _tag: 'Rebind', _0: name, _1: rest, _2: value };
},
deleteAt: (path: any[]) => {
return { _tag: 'DeleteAt', _0: path };
},
"undefine!": (name: string) => {
delete store[name];
definitions.delete(name);
dependencies.delete(name);
dependents.delete(name);
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 { definitions: defs } = parser.parse();
if (defs.length > 0) {
const def = defs[0];
const existing = definitions.get(def.name);
if (existing?.module) def.module = existing.module;
if (existing?.annotation && !def.annotation) def.annotation = existing.annotation;
recompile(def.name, def.body!);
definitions.set(def.name, def);
const source = prettyPrint(def);
appendChangeLog(def.name, source);
syncToFilesystem(def.name);
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 { definitions: 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);
if (result === undefined) {
return { _tag: 'None' };
}
return { _tag: 'Value', _0: result };
} catch (e: any) {
return { _tag: 'Err', _0: e.message };
}
}
}
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}`);
}
let syncTimer: any = null;
let dirtyModules = new Set<string>();
export function syncToAst(name: string) {
if (name in store) {
const existing = definitions.get(name);
const newDef: Definition = {
kind: 'definition',
name,
body: valueToAst(store[name]),
annotation: existing?.annotation,
module: existing?.module,
};
definitions.set(name, newDef); // valueToAst(store[name]));
const source = prettyPrint(definitions.get(name)!);
appendChangeLog(name, source);
// Debounce writes
dirtyModules.add(name);
if (!syncTimer) {
syncTimer = setTimeout(() => {
for (const n of dirtyModules) {
syncToFilesystem(n);
}
dirtyModules.clear();
syncTimer = null;
}, 2000);
}
}
}
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);
}
}
function syncToFilesystem(name: string) {
const def = definitions.get(name);
if (!def) return;
if (def.module) {
const moduleDefs = [...definitions.values()]
.filter(d => d.module === def.module);
const content = '@' + def.module + '\n\n' +
moduleDefs.map(d => prettyPrint(d)).join('\n\n') + '\n\n@\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: def.module, content })
});
console.log(`Wrote ${name} to ${def.module}.cg`);
} else {
const content = prettyPrint(def) + '\n';
fetch('/api/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ filename: name, content })
});
console.log(`Wrote ${name} to ${name}.cg`);
}
}