diff --git a/src/ast.ts b/src/ast.ts index f4c4722..a3d64df 100644 --- a/src/ast.ts +++ b/src/ast.ts @@ -227,7 +227,7 @@ export function prettyPrint(ast: AST, indent = 0): string { ? `...${prettyPrint(entry.expr, )}` : `${entry.key} = ${prettyPrint(entry.value, 0)}` ); - return `{ ${parts.join(', ') }`; + return `{ ${parts.join(', ')} }`; case 'lambda': { const params = ast.params.join(' '); @@ -264,7 +264,7 @@ export function prettyPrint(ast: AST, indent = 0): string { return `...${prettyPrint(ast.spread, 0)}`; case 'definition': - return `${ast.name} = ${prettyPrint(ast.body, indent)}`; + return `${ast.name} = ${prettyPrint(ast.body, indent)};`; default: return `Unknown AST kind: ${i}${(ast as any).kind}` diff --git a/src/cg/01-stdlib.cg b/src/cg/01-stdlib.cg index 978eb6c..9ba23ce 100644 --- a/src/cg/01-stdlib.cg +++ b/src/cg/01-stdlib.cg @@ -1,5 +1,5 @@ # nth : Int \ List a \ Maybe a -# in host at the moment, until we get typeclasses or something +# in host at the moment, until we get typeclasses or something and this can work on strings too # nth = i list \ [i, list] # | [_, []] \ None # | [0, [x, ...xs]] \ (Some x) diff --git a/src/cg/03-ui-components.cg b/src/cg/03-ui-components.cg index def3506..8e2647d 100644 --- a/src/cg/03-ui-components.cg +++ b/src/cg/03-ui-components.cg @@ -168,8 +168,6 @@ textInput = config \ ui.stateful { cursorX = ui.measureText textBeforeCursor; padding = 8; - _ = debug "focused" state.focused; - ui.clip { w = config.w, h = config.h, diff --git a/src/cg/06-inspector.cg b/src/cg/06-inspector.cg index e825a77..30f3757 100644 --- a/src/cg/06-inspector.cg +++ b/src/cg/06-inspector.cg @@ -3,9 +3,8 @@ inspector = config \ windowWidth = 600; source = getSource config.name; + _ = debug "inspector source" source; sourceLines = split "\n" source; - _ = debug "source" source; - _ = debug "sourceLines" sourceLines; dialogPadding = 0; diff --git a/src/cg/10-os.cg b/src/cg/10-os.cg index f9ce073..269a185 100644 --- a/src/cg/10-os.cg +++ b/src/cg/10-os.cg @@ -1,6 +1,6 @@ osState = { palette = { - visible = False, + visible = True, query = "", focusedIndex = 0, }, @@ -10,6 +10,22 @@ osState = { } }; +run = cmd \ + _ = debug "run" cmd; + result = eval cmd; + _ = debug "result" result; + result + | Defined name \ debug "defined" name + | Value v \ debug "result" v + | Err msg \ debug "error" msg + | _ \ noOp; + +onSelect = item \ + _ = debug "onSelect" item; + item + | "" \ run osState.palette.query + | a \ inspect a; + inspect = item \ batch [ osState.palette.visible := False, @@ -41,7 +57,7 @@ view = state viewport \ | True \ palette { state = osState.palette, search = storeSearch, - onSelect = item \ inspect item, + onSelect = onSelect, viewport = viewport, } | False \ empty, diff --git a/src/compiler.ts b/src/compiler.ts index 21c2c1e..ccb2e8c 100644 --- a/src/compiler.ts +++ b/src/compiler.ts @@ -2,10 +2,10 @@ import type { AST, Pattern, Definition } from './ast'; import { _rt, store } from './runtime-js'; export const definitions: Map = new Map(); -const dependencies: Map> = new Map(); -const dependents: Map> = new Map(); +export const dependencies: Map> = new Map(); +export const dependents: Map> = new Map(); -export function compile(ast: AST): string { +export function compile(ast: AST, useStore = true, bound = new Set()): string { switch (ast.kind) { case 'literal': if (ast.value.kind === 'string') @@ -14,56 +14,63 @@ export function compile(ast: AST): string { return JSON.stringify(ast.value.value); throw new Error(`Cannot compile literal`); // of kind ${ast.value.kind}`); - case 'variable': - return sanitize(ast.name); + case 'variable': { + if (bound.has(ast.name)) { + return sanitizeName(ast.name); + } + return sanitize(ast.name, useStore); + } - case 'lambda': + case 'lambda': { + const newBound = new Set([...bound, ...ast.params]); const params = ast.params.map(sanitizeName).join(') => ('); - return `Object.assign((${params}) => ${compile(ast.body)}, { _ast: (${JSON.stringify(ast)}) })`; + return `Object.assign((${params}) => ${compile(ast.body, useStore, newBound)}, { _ast: (${JSON.stringify(ast)}) })`; + } case 'apply': // Constructor if (ast.func.kind === 'constructor') { const ctorName = ast.func.name; - const arg = compile(ast.args[0]); + const arg = compile(ast.args[0], useStore, bound); return `((_a) => _a && typeof _a === 'object' && !Array.isArray(_a) && !_a._tag ? { _tag: "${ctorName}", ..._a } : { _tag: "${ctorName}", _0: _a })(${arg})`; } - const args = ast.args.map(compile).join(')('); - return `${compile(ast.func)}(${args})`; + const args = ast.args.map(a => compile(a, useStore, bound)).join(')('); + return `${compile(ast.func, useStore, bound)}(${args})`; case 'record': { const parts = ast.entries.map(entry => entry.kind === 'spread' - ? `...${compile(entry.expr)}` - : `${sanitizeName(entry.key)}: ${compile(entry.value)}` + ? `...${compile(entry.expr, useStore, bound)}` + : `${sanitizeName(entry.key)}: ${compile(entry.value, useStore, bound)}` ) return `({${parts.join(', ')}})`; } case 'list': { const elements = ast.elements.map(e => - 'spread' in e ? `...${compile(e.spread)}` : compile(e) + 'spread' in e ? `...${compile(e.spread, useStore, bound)}` : compile(e, useStore, bound) ); return `[${elements.join(', ')}]`; } case 'record-access': - return `${compile(ast.record)}.${sanitizeName(ast.field)}`; + return `${compile(ast.record, useStore, bound)}.${sanitizeName(ast.field)}`; case 'record-update': const updates = Object.entries(ast.updates) - .map(([k, v]) => `${sanitizeName(k)}: ${compile(v)}`); - return `({...${compile(ast.record)}, ${updates.join(', ')}})`; + .map(([k, v]) => `${sanitizeName(k)}: ${compile(v, useStore, bound)}`); + return `({...${compile(ast.record, useStore, bound)}, ${updates.join(', ')}})`; case 'let': + const newBound = new Set([...bound, ast.name]); return `((${sanitizeName(ast.name)}) => - ${compile(ast.body)})(${compile(ast.value)})`; + ${compile(ast.body, useStore, newBound)})(${compile(ast.value, useStore, bound)})`; case 'match': - return compileMatch(ast); + return compileMatch(ast, useStore, bound); case 'constructor': return `({ _tag: "${ast.name}" })`; @@ -74,15 +81,20 @@ export function compile(ast: AST): string { */ case 'rebind': { - if (ast.target.kind === 'variable') { - return `({ _tag: "Rebind", _0: "${ast.target.name}", _1: ${compile(ast.value)} })`; - } else if (ast.target.kind === 'record-access') { - const field = ast.target.field; - const obj = compile(ast.target.record); - const value = compile(ast.value); - return `(() => { ${obj}.${sanitize(field)} = ${value}; return { _tag: "Rerender" }; })()`; + const rootName = getRootName(ast.target); + const path = getPath(ast.target); + const value = compile(ast.value, useStore, bound); + + if (!useStore || !rootName) { + const target = compile(ast.target, useStore, bound); + return `(() => { ${target} = ${value}; return { _tag: "Rerender" }; })()`; + } + + if (path.length === 0) { + return `({ _tag: "Rebind", _0: "${rootName}", _1: ${value} })`; + } else { + return `({ _tag: "Rebind", _0: "${rootName}", _1: ${JSON.stringify(path)}, _2: ${value} })`; } - throw new Error('Invalid rebind target'); } default: @@ -91,7 +103,7 @@ export function compile(ast: AST): string { } } -function sanitize(name: string): string { +function sanitize(name: string, useStore = true): string { const ops: Record = { 'add': '_rt.add', 'sub': '_rt.sub', 'mul': '_rt.mul', 'div': '_rt.div', 'mod': '_rt.mod', 'eq': '_rt.eq', @@ -101,9 +113,12 @@ function sanitize(name: string): string { if (ops[name]) return ops[name]; - const natives = ['measure', 'measureText', 'storeSearch', 'getSource', 'debug', 'nth', 'len', 'slice', 'join', 'chars', 'split', 'str', 'redefine', 'stateful', 'batch', 'noOp', 'rerender', 'focus', 'ui']; + const natives = ['eval', 'measure', 'measureText', 'storeSearch', 'getSource', 'debug', 'nth', 'len', 'slice', 'join', 'chars', 'split', 'str', 'redefine', 'undefine', 'stateful', 'batch', 'noOp', 'rerender', 'focus', 'ui']; if (natives.includes(name)) return `_rt.${name}`; + if (useStore) { + return `store.${sanitizeName(name)}`; + } return sanitizeName(name); } @@ -113,26 +128,29 @@ function sanitizeName(name: string): string { 'if','else','switch','case','for','while','do','break', 'continue','new','delete','typeof','in','this','super', 'import','export','extends','static','yield','await','async', - 'try','catch','finally','throw','null','true','false' + 'try','catch','finally','throw','null','true','false', + 'eval','Function','window','document','console' ]; if (reserved.includes(name)) return `_${name}`; return name.replace(/-/g, '_'); } -function compileMatch(ast: AST & { kind: 'match'}): string { - const expr = compile(ast.expr); +function compileMatch(ast: AST & { kind: 'match'}, useStore = true, bound = new Set()): string { + const expr = compile(ast.expr, useStore, bound); const tmpVar = `_m${Math.floor(Math.random() * 10000)}`; let code = `((${tmpVar}) => { `; for (const c of ast.cases) { const { condition, bindings } = compilePattern(c.pattern, tmpVar); + const patternBound = patternVars(c.pattern); + const newBound = new Set([...bound, ...patternBound]); code += `if (${condition}) { `; if (bindings.length > 0) { code += `const ${bindings.join(', ')}; `; } - code += `return ${compile(c.result)}; }`; + code += `return ${compile(c.result, useStore, newBound)}; }`; } code += `console.error("No match for:", ${tmpVar}); throw new Error("No match"); })(${expr})`; @@ -182,7 +200,7 @@ function compilePattern(pattern: Pattern, expr: string): { condition: string, bi if (sub.condition !== 'true') condition += ` && ${sub.condition}`; bindings.push(...sub.bindings); }); - bindings.push(`${sanitize(pattern.spread)} = ${expr}.slice(${pattern.head.length})`); + bindings.push(`${sanitizeName(pattern.spread)} = ${expr}.slice(${pattern.head.length})`); return { condition, bindings }; } @@ -191,7 +209,7 @@ function compilePattern(pattern: Pattern, expr: string): { condition: string, bi let condition = 'true'; const bindings: string[] = []; for (const [field, fieldPattern] of Object.entries(pattern.fields)) { - const sub = compilePattern(fieldPattern, `${expr}.${sanitize(field)}`); + const sub = compilePattern(fieldPattern, `${expr}.${sanitizeName(field)}`); if (sub.condition !== 'true') condition += ` && ${sub.condition}`; bindings.push(...sub.bindings); } @@ -227,9 +245,8 @@ export function compileAndRun(defs: Definition[]) { } } - for (const def of defs) { - const compiled = `const ${sanitize(def.name)} = ${compile(def.body)};`; + const compiled = `const ${sanitizeName(def.name)} = ${compile(def.body, false)};`; compiledDefs.push(compiled); try { @@ -242,13 +259,13 @@ export function compileAndRun(defs: Definition[]) { } const lastName = defs[defs.length - 1].name; - const defNames = defs.map(d => sanitize(d.name)).join(', '); + const defNames = defs.map(d => sanitizeName(d.name)).join(', '); const code = `${compiledDefs.join('\n')} -return { ${defNames}, __result: ${sanitize(lastName)} };`; +return { ${defNames}, __result: ${sanitizeName(lastName)} };`; - const fn = new Function('_rt', code); - const allDefs = fn(_rt); + const fn = new Function('_rt', 'store', code); + const allDefs = fn(_rt, store); // Populate store for (const [name, value] of Object.entries(allDefs)) { @@ -260,7 +277,7 @@ return { ${defNames}, __result: ${sanitize(lastName)} };`; return allDefs.__result; } -function freeVars(ast: AST, bound: Set = new Set()): Set { +export function freeVars(ast: AST, bound: Set = new Set()): Set { switch (ast.kind) { case 'literal': case 'constructor': @@ -385,3 +402,17 @@ export function recompile(name: string, newAst: AST) { store[defName] = fn(_rt, store); } } + +function getRootName(ast: AST): string | null { + if (ast.kind === 'variable') return ast.name; + if (ast.kind === 'record-access') return getRootName(ast.record); + return null; +} + +function getPath(ast: AST): string[] { + if (ast.kind === 'variable') return []; + if (ast.kind === 'record-access') { + return [...getPath(ast.record), ast.field]; + } + return []; +} diff --git a/src/main.ts b/src/main.ts index ac318c0..1c77c51 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { compileAndRun } from './compiler' import { tokenize } from './lexer' import { Parser } from './parser' import { runAppCompiled } from './runtime-compiled' -import { _rt } from './runtime-js' +import { _rt, loadDefinitions, saveDefinitions } from './runtime-js' const modules = import.meta.glob('./cg/*.cg', { query: 'raw', import: 'default', eager: true }); const cgCode = Object.keys(modules) @@ -17,7 +17,9 @@ try { const tokens = tokenize(cgCode); const parser = new Parser(tokens, cgCode); const defs = parser.parse(); + loadDefinitions(); const os = compileAndRun(defs); + saveDefinitions(); runAppCompiled( { init: os.init, update: os.update, view: os.view }, diff --git a/src/parser.ts b/src/parser.ts index f4690f5..825b3ea 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -337,7 +337,7 @@ export class Parser { } this.expect('equals'); - const value = this.parseExpressionNoMatch(); + const value = this.parseExpression(); this.expect('semicolon'); const body = this.parseExpressionNoMatch(); diff --git a/src/runtime-js.ts b/src/runtime-js.ts index 698b57e..2186b77 100644 --- a/src/runtime-js.ts +++ b/src/runtime-js.ts @@ -1,7 +1,10 @@ import { tokenize } from './lexer' import { Parser } from './parser' -import { recompile, definitions } from './compiler' +import { compile, recompile, definitions, freeVars, dependencies, dependents } from './compiler' import { prettyPrint } from './ast' +import type { AST } from './ast' + +const STORAGE_KEY = 'cg-definitions'; export const store: Record = {}; @@ -46,55 +49,6 @@ export const _rt = { }, }, - batch: (events: any[]) => ({ _tag: 'Batch', _0: events }), - noOp: { _tag: 'NoOp' }, - rerender: { _tag: 'Rerender' }, - 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), - 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' }; - }, - storeSearch: (query: string) => { - return Object.keys(store).filter(name => _rt.fuzzyMatch(query)(name)._tag === 'True'); - }, - getSource: (name: string) => { - const ast = definitions.get(name); - if (!ast) return ""; - const printed = prettyPrint(ast); - return printed; - }, - rebind: (name: string, pathOrValue: any, maybeValue?: any) => { - if (maybeValue === undefined) { - if (pathOrValue && pathOrValue.ast) { - recompile(name, pathOrValue._ast); - } else { - store[name] = pathOrValue; - } - } else { - const path = pathOrValue as string[]; - let obj = store[name]; - for (let i = 0; i < path.length - 1; i++) { - obj = obj[path[i]]; - } - obj[path[path.length - 1]] = maybeValue; - } - }, measure: (ui: any): { width: number, height: number } => { switch (ui._kind) { case 'rect': return { width: ui.w, height: ui.h }; @@ -153,11 +107,209 @@ export const _rt = { return { width: 0, height: 0 }; } }, + + batch: (events: any[]) => ({ _tag: 'Batch', _0: events }), + noOp: { _tag: 'NoOp' }, + rerender: { _tag: 'Rerender' }, + 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), + 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' }; + }, + storeSearch: (query: string) => { + return Object.keys(store).filter(name => _rt.fuzzyMatch(query)(name)._tag === 'True'); + }, + getSource: (name: string) => { + const ast = definitions.get(name); + if (!ast) return ""; + const printed = prettyPrint(ast); + return printed; + }, + rebind: (name: string, pathOrValue: any, maybeValue?: any) => { + if (maybeValue === undefined) { + store[name] = pathOrValue; + } else { + const path = pathOrValue as string[]; + let obj = store[name]; + for (let i = 0; i < path.length - 1; i++) { + obj = obj[path[i]]; + } + obj[path[path.length - 1]] = maybeValue; + } + syncToAst(name); + }, redefine: (name: string) => (code: string) => { const tokens = tokenize(`_tmp = ${code};`); const parser = new Parser(tokens, ""); const defs = parser.parse(); recompile(name, defs[0]. body); return { _tag: 'Ok' }; + }, + undefine: (name: string) => { + delete store[name]; + definitions.delete(name); + dependencies.delete(name); + dependents.delete(name); + saveDefinitions(); + return { _tag: 'Ok' }; + }, + + 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); + 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), + 'True' + ]) + + 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 = {}; + 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 [name, 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._ast) { + return value._ast; + } + + throw new Error('Cannot serialize function without _ast'); + } + + throw new Error(`Cannot convert to AST: ${typeof value}`); +} + +export function syncToAst(name: string) { + if (definitions.has(name)) { + definitions.set(name, valueToAst(store[name])); + saveDefinitions(); } }