315 lines
11 KiB
JavaScript
315 lines
11 KiB
JavaScript
/*
|
|
mondo.mjs - <short description TODO>
|
|
Copyright (C) 2022 Strudel contributors - see <https://github.com/tidalcycles/strudel/blob/main/packages/mini/test/mini.test.mjs>
|
|
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 <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
// evolved from https://garten.salat.dev/lisp/parser.html
|
|
export class MondoParser {
|
|
// these are the tokens we expect
|
|
token_types = {
|
|
string: /^"(.*?)"/,
|
|
open_list: /^\(/,
|
|
close_list: /^\)/,
|
|
open_cat: /^</,
|
|
close_cat: /^>/,
|
|
open_seq: /^\[/,
|
|
close_seq: /^\]/,
|
|
number: /^[0-9]*\.?[0-9]+/, // before pipe!
|
|
pipe: /^\./,
|
|
stack: /^,/,
|
|
op: /^[*/]/,
|
|
plain: /^[a-zA-Z0-9-_]+/,
|
|
};
|
|
constructor(config) {
|
|
this.config = config;
|
|
}
|
|
// matches next token
|
|
next_token(code) {
|
|
for (let type in this.token_types) {
|
|
const match = code.match(this.token_types[type]);
|
|
if (match) {
|
|
return { type, value: match[0] };
|
|
}
|
|
}
|
|
throw new Error(`mondo: could not match '${code}'`);
|
|
}
|
|
// takes code string, returns list of matched tokens (if valid)
|
|
tokenize(code) {
|
|
let tokens = [];
|
|
while (code.length > 0) {
|
|
code = code.trim();
|
|
const token = this.next_token(code);
|
|
code = code.slice(token.value.length);
|
|
tokens.push(token);
|
|
}
|
|
return tokens;
|
|
}
|
|
// take code, return abstract syntax tree
|
|
parse(code) {
|
|
this.tokens = this.tokenize(code);
|
|
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_children(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`);
|
|
}
|
|
let next = this.tokens[0]?.type;
|
|
if (next === 'open_list') {
|
|
return this.parse_list();
|
|
}
|
|
if (next === 'open_cat') {
|
|
return this.parse_cat();
|
|
}
|
|
if (next === 'open_seq') {
|
|
return this.parse_seq();
|
|
}
|
|
return this.consume(next);
|
|
}
|
|
desugar_children(children) {
|
|
children = this.resolve_ops(children);
|
|
children = this.resolve_pipes(children);
|
|
return children;
|
|
}
|
|
// Token[] => Token[][] . returns empty list if type not found
|
|
split_children(children, split_type, sequence_type) {
|
|
if (sequence_type) {
|
|
// if given, the first child is ignored
|
|
children = children.slice(1);
|
|
}
|
|
const chunks = [];
|
|
while (true) {
|
|
let commaIndex = children.findIndex((child) => child.type === split_type);
|
|
if (commaIndex === -1) break;
|
|
const chunk = children.slice(0, commaIndex);
|
|
chunks.push(chunk);
|
|
children = children.slice(commaIndex + 1);
|
|
}
|
|
if (!chunks.length) {
|
|
return [];
|
|
}
|
|
chunks.push(children);
|
|
return chunks;
|
|
}
|
|
desugar_stack(children, sequence_type) {
|
|
// children is expected to contain seq or cat as first item
|
|
const chunks = this.split_children(children, 'stack', sequence_type);
|
|
if (!chunks.length) {
|
|
return this.desugar_children(children);
|
|
}
|
|
// collect args of stack function
|
|
const args = chunks.map((chunk) => {
|
|
if (chunk.length === 1) {
|
|
// chunks of one element can be added to the stack as is
|
|
return chunk[0];
|
|
} else {
|
|
// chunks of multiple args
|
|
if (sequence_type) {
|
|
// if given, each chunk needs to be prefixed
|
|
// [a b, c d] => (stack (seq a b) (seq c d))
|
|
chunk = [{ type: 'plain', value: sequence_type }, ...chunk];
|
|
}
|
|
chunk = this.desugar_children(chunk);
|
|
return { type: 'list', children: chunk };
|
|
}
|
|
});
|
|
return [{ type: 'plain', value: 'stack' }, ...args];
|
|
}
|
|
resolve_ops(children) {
|
|
while (true) {
|
|
let opIndex = children.findIndex((child) => child.type === 'op');
|
|
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.`);
|
|
}
|
|
if (opIndex === 0) {
|
|
// regular function call (assuming each operator exists as function)
|
|
children[opIndex] = op;
|
|
continue;
|
|
}
|
|
const left = children[opIndex - 1];
|
|
const right = children[opIndex + 1];
|
|
if (left.type === 'pipe') {
|
|
// "x !* 2" => (* 2 x)
|
|
children[opIndex] = op;
|
|
continue;
|
|
}
|
|
// convert infix to prefix notation
|
|
const call = { type: 'list', children: [op, left, right] };
|
|
// insert call while keeping other siblings
|
|
children = [...children.slice(0, opIndex - 1), call, ...children.slice(opIndex + 2)];
|
|
// unwrap double list.. e.g. (s jazz) * 2
|
|
if (children.length === 1) {
|
|
// there might be a cleaner solution
|
|
children = children[0].children;
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
resolve_pipes(children) {
|
|
while (true) {
|
|
let pipeIndex = children.findIndex((child) => child.type === 'pipe');
|
|
// no pipe => we're done
|
|
if (pipeIndex === -1) break;
|
|
// pipe up front => lambda
|
|
if (pipeIndex === 0) {
|
|
// . as lambda: (.fast 2) = x=>x.fast(2)
|
|
// TODO: this doesn't work for (.fast 2 .speed 2)
|
|
// probably needs proper ast representation of lambda
|
|
children[pipeIndex] = { type: 'plain', value: '.' };
|
|
continue;
|
|
}
|
|
const rightSide = children.slice(pipeIndex + 2);
|
|
const right = children[pipeIndex + 1];
|
|
if (right.type === 'list') {
|
|
// apply function only to left sibling (high precedence)
|
|
// s jazz.(fast 2) => s (fast jazz 2)
|
|
const [callee, ...rest] = right.children;
|
|
const leftSide = children.slice(0, pipeIndex - 1);
|
|
const left = children[pipeIndex - 1];
|
|
let args = [callee, left, ...rest];
|
|
const call = { type: 'list', children: args };
|
|
children = [...leftSide, call, ...rightSide];
|
|
} else {
|
|
// apply function to all left siblings (low precedence)
|
|
// s jazz . fast 2 => fast (s jazz) 2
|
|
let leftSide = children.slice(0, pipeIndex);
|
|
if (leftSide.length === 1) {
|
|
leftSide = leftSide[0];
|
|
} else {
|
|
// wrap in (..) if multiple items on the left side
|
|
leftSide = { type: 'list', children: leftSide };
|
|
}
|
|
children = [right, leftSide, ...rightSide];
|
|
}
|
|
}
|
|
return children;
|
|
}
|
|
flip_call(children) {
|
|
let [name, first, ...rest] = children;
|
|
return [name, ...rest, first];
|
|
}
|
|
parse_pair(open_type, close_type) {
|
|
this.consume(open_type);
|
|
const children = [];
|
|
while (this.tokens[0]?.type !== close_type) {
|
|
children.push(this.parse_expr());
|
|
}
|
|
this.consume(close_type);
|
|
return children;
|
|
}
|
|
parse_list() {
|
|
let children = this.parse_pair('open_list', 'close_list');
|
|
children = this.desugar_stack(children);
|
|
children = this.desugar_children(children);
|
|
return { type: 'list', children };
|
|
}
|
|
parse_cat() {
|
|
let children = this.parse_pair('open_cat', 'close_cat');
|
|
children = [{ type: 'plain', value: 'cat' }, ...children];
|
|
children = this.desugar_stack(children, 'cat');
|
|
children = this.desugar_children(children);
|
|
return { type: 'list', children };
|
|
}
|
|
parse_seq() {
|
|
let children = this.parse_pair('open_seq', 'close_seq');
|
|
children = [{ type: 'plain', value: 'seq' }, ...children];
|
|
children = this.desugar_stack(children, 'seq');
|
|
children = this.desugar_children(children);
|
|
return { type: 'list', children };
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
|
|
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(lib, config = {}) {
|
|
this.parser = new MondoParser(config);
|
|
this.lib = lib;
|
|
this.config = config;
|
|
}
|
|
// a helper to check conditions and throw if they are not met
|
|
assert(condition, error) {
|
|
if (!condition) {
|
|
throw new Error(error);
|
|
}
|
|
}
|
|
run(code) {
|
|
const ast = this.parser.parse(code);
|
|
console.log(printAst(ast));
|
|
return this.call(ast);
|
|
}
|
|
call(ast) {
|
|
// for a node to be callable, it needs to be a list
|
|
this.assert(ast.type === 'list', `function call: expected list, got ${ast.type}`);
|
|
// the first element is expected to be the function name
|
|
this.assert(ast.children[0]?.type === 'plain', `function call: expected first child to be plain, got ${ast.type}`);
|
|
|
|
// process args
|
|
const args = ast.children.slice(1).map((arg) => {
|
|
if (arg.type === 'string') {
|
|
return this.lib.string(arg.value.slice(1, -1));
|
|
}
|
|
if (arg.type === 'plain') {
|
|
return this.lib.plain(arg.value);
|
|
}
|
|
if (arg.type === 'number') {
|
|
return this.lib.number(Number(arg.value));
|
|
}
|
|
return this.call(arg);
|
|
});
|
|
|
|
const name = ast.children[0].value;
|
|
if (name === '.') {
|
|
// lambda : (.fast 2) = x=>fast(2, x)
|
|
const callee = ast.children[1].value;
|
|
const innerFn = this.lib[callee];
|
|
this.assert(innerFn, `function call: unknown function name "${callee}"`);
|
|
return (pat) => innerFn(pat, args.slice(1));
|
|
}
|
|
|
|
// look up function in lib
|
|
const fn = this.lib[name];
|
|
this.assert(fn, `function call: unknown function name "${name}"`);
|
|
if (this.lib.call) {
|
|
return this.lib.call(fn, args, name);
|
|
}
|
|
return fn(...args);
|
|
}
|
|
}
|