/* mondo.mjs - Copyright (C) 2022 Strudel contributors - see This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . */ // evolved from https://garten.salat.dev/lisp/parser.html export class MondoParser { // these are the tokens we expect token_types = { comment: /^\/\/(.*?)(?=\n|$)/, quotes_double: /^"(.*?)"/, quotes_single: /^'(.*?)'/, open_list: /^\(/, close_list: /^\)/, open_angle: /^/, open_square: /^\[/, close_square: /^\]/, open_curly: /^\{/, close_curly: /^\}/, number: /^-?[0-9]*\.?[0-9]+/, // before pipe! // TODO: better error handling when "-" is used as rest, e.g "s [- bd]" op: /^[*/:!@%?+\-&]|^\.{2}/, // * / : ! @ % ? .. // dollar: /^\$/, pipe: /^#/, stack: /^[,$]/, or: /^[|]/, plain: /^[a-zA-Z0-9-~_^#]+/, }; op_precedence = [['*', '/', ':', '!', '@', '%', '?', '+', '-', '..'], ['&']]; // matches next token next_token(code, offset = 0) { for (let type in this.token_types) { const match = code.match(this.token_types[type]); if (match) { let token = { type, value: match[0] }; if (offset !== -1) { // add location token.loc = [offset, offset + match[0].length]; } return token; } } throw new Error(`mondo: could not match '${code}'`); } // takes code string, returns list of matched tokens (if valid) tokenize(code, offset = 0) { let tokens = []; let locEnabled = offset !== -1; let trim = () => { // trim whitespace at start, update offset offset += code.length - code.trimStart().length; // trim start and end to not confuse parser return code.trim(); }; code = trim(); while (code.length > 0) { code = trim(); const token = this.next_token(code, locEnabled ? offset : -1); code = code.slice(token.value.length); offset += token.value.length; tokens.push(token); } return tokens; } // take code, return abstract syntax tree parse(code, offset) { this.code = code; this.offset = offset; this.tokens = this.tokenize(code, offset); const expressions = []; while (this.tokens.length) { expressions.push(this.parse_expr()); } if (expressions.length === 0) { // empty case return { type: 'list', children: [] }; } // do we have multiple top level expressions or a single non list? if (expressions.length > 1 || expressions[0].type !== 'list') { return { type: 'list', children: this.desugar(expressions), }; } // we have a single list return expressions[0]; } // parses any valid expression parse_expr() { if (!this.tokens[0]) { throw new Error(`unexpected end of file`); // TODO: could we allow that? like (((((((( s bd // return { type: 'list', children: [] }; } let next = this.tokens[0]?.type; if (next === 'open_list') { return this.parse_list(); } if (next === 'open_angle') { return this.parse_angle(); } if (next === 'open_square') { return this.parse_square(); } if (next === 'open_curly') { return this.parse_curly(); } return this.consume(next); } // Token[] => Token[][], e.g. (x , y z) => [['x'],['y','z']] split_children(children, split_type) { const chunks = []; while (true) { let splitIndex = children.findIndex((child) => child.type === split_type); if (splitIndex === -1) break; const chunk = children.slice(0, splitIndex); chunks.push(chunk); children = children.slice(splitIndex + 1); } chunks.push(children); return chunks; } desugar_split(children, split_type, next) { const chunks = this.split_children(children, split_type); if (chunks.length === 1) { return next(children); } // collect args of stack function const args = chunks .map((chunk) => { if (!chunk.length) { return; // useful for things like "$ s bd $ s hh*8" (first chunk is empty) } if (chunk.length === 1) { // chunks of one element can be added to the stack as is return chunk[0]; } // chunks of multiple args chunk = next(chunk); return { type: 'list', children: chunk }; }) .filter(Boolean); // ignore empty chunks return [{ type: 'plain', value: split_type }, ...args]; } // prevents to get a list, e.g. ((x y)) => (x y) unwrap_children(children) { if (children.length === 1) { return children[0].children; } return children; } desugar_ops(children, types) { while (true) { let opIndex = children.findIndex((child) => child.type === 'op' && types.includes(child.value)); if (opIndex === -1) break; const op = { type: 'plain', value: children[opIndex].value }; if (opIndex === children.length - 1) { //throw new Error(`cannot use operator as last child.`); children[opIndex] = op; // ignore operator if last child.. e.g. "note [c -]" continue; } if (opIndex === 0) { // regular function call (assuming each operator exists as function) children[opIndex] = op; continue; } // convert infix to prefix notation const left = children[opIndex - 1]; const right = children[opIndex + 1]; if (left.type === 'pipe') { // "x !* 2" => (* 2 x) children[opIndex] = op; continue; } // some careful error handling if (left.type === 'op') { throw new Error(`got 2 ops in a row: "${left.value}${op.value}"`); } if (right.type === 'op') { let err = `got 2 ops in a row: "${op.value}${right.value}"`; if (op.value === '-') { // yes i know this file is not supposed to know about rests x.X err += '. you probably want a rest, which is "_" in mondo!'; } throw new Error(err); } const call = { type: 'list', children: [op, right, left] }; // insert call while keeping other siblings children = [...children.slice(0, opIndex - 1), call, ...children.slice(opIndex + 2)]; children = this.unwrap_children(children); } return children; } get_lambda(args, children) { // (.fast 2) = (fn (_) (fast _ 2)) children = this.desugar(children); const body = children.length === 1 ? children[0] : { type: 'list', children }; return [{ type: 'plain', value: 'fn' }, { type: 'list', children: args }, body]; } // returns location range of given ast (even if desugared) get_range(ast, range = [Infinity, 0]) { let union = (a, b) => [Math.min(a[0], b[0]), Math.max(a[1], b[1])]; if (ast.loc) { return union(range, ast.loc); } if (ast.type !== 'list') { return range; } return ast.children.reduce((range, child) => { const childrange = this.get_range(child, range); return union(range, childrange); }, range); } errorhead(ast) { return `[mondo ${this.get_range(ast)?.join(':') || '?'}]`; } // returns original user code where the given ast originates (even if desugared) get_code_snippet(ast) { const [min, max] = this.get_range(ast); return this.code.slice(min - this.offset, max - this.offset); } desugar_pipes(children) { let chunks = this.split_children(children, 'pipe'); while (chunks.length > 1) { let [left, right, ...rest] = chunks; if (!left.length) { const arg = { type: 'plain', value: '_' }; return this.get_lambda([arg], [arg, ...children]); } // s jazz hh.fast 2 => (fast 2 (s jazz hh)) const call = left.length > 1 ? { type: 'list', children: left } : left[0]; chunks = [[...right, call], ...rest]; } // return next(chunks[0]); return chunks[0]; } parse_pair(open_type, close_type) { const begin = this.tokens[0].loc?.[0]; this.consume(open_type); const children = []; while (this.tokens[0]?.type !== close_type) { children.push(this.parse_expr()); } const end = this.tokens[0].loc?.[1]; this.consume(close_type); const node = { type: 'list', children }; if (begin !== undefined) { node.loc = [begin, end]; node.raw = this.code.slice(begin, end); } return node; } desugar(children, type) { // if type is given, the first element is expected to contain it as plain value // e.g. with (square a b, c), we want to split (a b, c) and ignore "square" children = type ? children.slice(1) : children; children = this.desugar_split(children, 'stack', (children) => this.desugar_split(children, 'or', (children) => { // chunks of multiple args if (type) { // the type we've removed before splitting needs to be added back children = [{ type: 'plain', value: type }, ...children]; } // for each precendence group, call desugar_ops once this.op_precedence.forEach((ops) => { children = this.desugar_ops(children, ops); }); children = this.desugar_pipes(children); return children; }), ); return children; } parse_list() { let node = this.parse_pair('open_list', 'close_list'); node.children = this.desugar(node.children); return node; } parse_angle() { let node = this.parse_pair('open_angle', 'close_angle'); node.children.unshift({ type: 'plain', value: 'angle' }); node.children = this.desugar(node.children, 'angle'); return node; } parse_square() { let node = this.parse_pair('open_square', 'close_square'); node.children.unshift({ type: 'plain', value: 'square' }); node.children = this.desugar(node.children, 'square'); return node; } parse_curly() { let node = this.parse_pair('open_curly', 'close_curly'); node.children.unshift({ type: 'plain', value: 'curly' }); node.children = this.desugar(node.children, 'curly'); return node; } consume(type) { // shift removes first element and returns it const token = this.tokens.shift(); if (token.type !== type) { throw new Error(`expected token type ${type}, got ${token.type}`); } return token; } get_locations(code, offset = 0) { let walk = (ast, locations = []) => { if (ast.type === 'list') { return ast.children.forEach((child) => walk(child, locations)); } if (ast.loc) { locations.push(ast.loc); } }; const ast = this.parse(code, offset); let locations = []; walk(ast, locations); return locations; } } export function printAst(ast, compact = false, lvl = 0) { const br = compact ? '' : '\n'; const spaces = compact ? '' : Array(lvl).fill(' ').join(''); if (ast.type === 'list') { return `${lvl ? br : ''}${spaces}(${ast.children.map((child) => printAst(child, compact, lvl + 1)).join(' ')}${ ast.children.find((child) => child.type === 'list') ? `${br}${spaces})` : ')' }`; } return `${ast.value}`; } // lisp runner export class MondoRunner { constructor({ evaluator } = {}) { this.parser = new MondoParser(); this.evaluator = evaluator; this.assert(typeof evaluator === 'function', `expected an evaluator function to be passed to new MondoRunner`); } // a helper to check conditions and throw if they are not met assert(condition, error) { if (!condition) { throw new Error(error); } } run(code, scope, offset = 0) { const ast = this.parser.parse(code, offset); //console.log(printAst(ast)); return this.evaluate(ast, scope); } evaluate_let(ast, scope) { // (let ((x 3) (y 4)) ...body) // = ((fn (x y) ...body) 3 4) const defs = ast.children[1].children; const args = defs.map((pair) => pair.children[0]); const vals = defs.map((pair) => pair.children[1]); const body = ast.children.slice(2); const lambda = { type: 'list', children: [{ type: 'plain', value: 'fn' }, { type: 'list', children: args }, ...body], }; return this.evaluate({ type: 'list', children: [lambda, ...vals] }, scope); } evaluate_def(ast, scope) { // function definition special form? if (ast.children[1].type === 'list') { // (def (add a b) (+ a b)) // => (def add (fn (a b) (+ a b)) ) const args = ast.children[1].children.slice(1); const lambda = { // lambda type: 'list', children: [ { type: 'plain', value: 'fn' }, { type: 'list', children: args }, ...ast.children.slice(2), // body ], }; // we mutate to make sure the old ast wont make a mess later ast.children[1] = ast.children[1].children[0]; ast.children[2] = lambda; ast.children = ast.children.slice(0, 3); // throw away rest } // (def name body) if (ast.children.length !== 3) { throw new Error(`expected "def" to have 3 children, but got ${ast.children.length}`); } const name = ast.children[1].value; const body = this.evaluate(ast.children[2], scope); scope[name] = body; // def with fall through } evaluate_match(ast, scope) { // (match (p1 e1) (p2 e2) ... (pn en)) // = cond in lisp if (ast.children.length < 2) { return; } const [_, ...body] = ast.children; for (let i = 0; i < body.length; ++i) { const [predicate, exp] = body[i].children; if (predicate.value === 'else') { return this.evaluate(exp, scope); } const outcome = this.evaluate(predicate, scope); if (outcome) { return this.evaluate(exp, scope); } } return undefined; // nothing was matched } evaluate_if(ast, scope) { // if is a special case of match if (ast.children.length !== 4) { return; } // (if predicate consequent alternative) const [_, predicate, consequent, alternative] = ast.children; // (match (predicate consequent) (else alternative)) const matcher = { type: 'list', children: [ { type: 'plain', value: 'match' }, { type: 'list', children: [predicate, consequent] }, { type: 'list', children: [{ type: 'plain', value: 'else' }, alternative] }, ], }; return this.evaluate_match(matcher, scope); } evaluate_lambda(ast, scope) { // (fn (_) (ply 2 _) // ^args ^ body const [_, formalArgs, ...body] = ast.children; return (...args) => { const params = Object.fromEntries(formalArgs.children.map((arg, i) => [arg.value, args[i]])); const closure = { ...scope, ...params, }; // body can have multiple expressions const res = body.map((exp) => this.evaluate(exp, closure)); // last expression is the return value return res[res.length - 1]; }; } evaluate_list(ast, scope) { // evaluate all children before evaluating list (dont mutate!!!) const args = ast.children .filter((child) => child.type !== 'comment') // ignore comments .map((arg) => this.evaluate(arg, scope)); const node = { type: 'list', children: args }; return this.evaluator(node, scope); } evaluate_leaf(ast, scope) { if (ast.type === 'number') { ast.value = Number(ast.value); } else if (['quotes_double', 'quotes_single'].includes(ast.type)) { ast.value = ast.value.slice(1, -1); ast.type = 'string'; } return this.evaluator(ast, scope); } evaluate(ast, scope = {}) { if (ast.type !== 'list') { return this.evaluate_leaf(ast, scope); } const name = ast.children[0]?.value; if (name === 'fn') { return this.evaluate_lambda(ast, scope); } if (name === 'match') { return this.evaluate_match(ast, scope); } if (name === 'if') { return this.evaluate_if(ast, scope); } if (name === 'let') { return this.evaluate_let(ast, scope); } if (name === 'def') { this.evaluate_def(ast, scope); } return this.evaluate_list(ast, scope); } }