758 lines
17 KiB
JavaScript
758 lines
17 KiB
JavaScript
/* Tabloid: the clickbait headline programming language */
|
|
|
|
/* tokenizer */
|
|
|
|
/**
|
|
* Reads in char or word chunks
|
|
*/
|
|
class Reader {
|
|
constructor(str, base = "") {
|
|
this.base = base;
|
|
this.i = 0;
|
|
this.str = str;
|
|
}
|
|
peek() {
|
|
return this.str[this.i];
|
|
}
|
|
next() {
|
|
return this.str[this.i++];
|
|
}
|
|
hasNext() {
|
|
return this.str[this.i] !== undefined;
|
|
}
|
|
backstep() {
|
|
this.i--;
|
|
}
|
|
readUntil(pred) {
|
|
let result = this.base.slice();
|
|
while (this.hasNext() && !pred(this.peek())) {
|
|
result += this.next();
|
|
}
|
|
return result;
|
|
}
|
|
dropWhitespace() {
|
|
this.readUntil((c) => !!c.trim());
|
|
}
|
|
expect(tok) {
|
|
const next = this.next();
|
|
if (next !== tok) {
|
|
throw new Error(`Parsing error: expected ${tok}, got ${next}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Split into words for easier tokenization
|
|
* with keywords.
|
|
*/
|
|
class Wordifier {
|
|
constructor(str) {
|
|
this.reader = new Reader(str.trim());
|
|
this.tokens = [];
|
|
}
|
|
wordify() {
|
|
if (this.tokens.length) return this.tokens;
|
|
|
|
while (this.reader.hasNext()) {
|
|
const next = this.reader.next();
|
|
switch (next) {
|
|
case "(": {
|
|
this.tokens.push("(");
|
|
break;
|
|
}
|
|
case ")": {
|
|
this.tokens.push(")");
|
|
break;
|
|
}
|
|
case ",": {
|
|
this.tokens.push(",");
|
|
break;
|
|
}
|
|
case '"':
|
|
case "'": {
|
|
this.wordifyString(next);
|
|
break;
|
|
}
|
|
default: {
|
|
// read until WS
|
|
this.reader.backstep();
|
|
this.tokens.push(
|
|
this.reader.readUntil((c) => {
|
|
return !c.trim() || ["(", ")", ","].includes(c);
|
|
})
|
|
);
|
|
}
|
|
}
|
|
this.reader.dropWhitespace();
|
|
}
|
|
return this.tokens;
|
|
}
|
|
wordifyString(endChar) {
|
|
let acc = "";
|
|
acc += this.reader.readUntil((c) => c == endChar);
|
|
while (acc.endsWith("\\") || !this.reader.hasNext()) {
|
|
acc = acc.substr(0, acc.length - 1);
|
|
this.reader.next(); // endChar
|
|
acc += endChar + this.reader.readUntil((c) => c == endChar);
|
|
}
|
|
this.reader.next(); // throw away closing char
|
|
this.tokens.push('"' + acc);
|
|
}
|
|
}
|
|
|
|
const T = {
|
|
LParen: Symbol("LParen"),
|
|
RParen: Symbol("RParen"),
|
|
Comma: Symbol("Comma"),
|
|
DiscoverHowTo: Symbol("DiscoverHowTo"),
|
|
With: Symbol("With"),
|
|
Of: Symbol("Of"),
|
|
RumorHasIt: Symbol("RumorHasIt"),
|
|
WhatIf: Symbol("WhatIf"),
|
|
LiesBang: Symbol("LiesBang"),
|
|
EndOfStory: Symbol("EndOfStory"),
|
|
ExpertsClaim: Symbol("ExpertsClaim"),
|
|
ToBe: Symbol("ToBe"),
|
|
YouWontWantToMiss: Symbol("YouWontWantToMiss"),
|
|
LatestNewsOn: Symbol("LatestNewsOn"),
|
|
TotallyRight: Symbol("TotallyRight"),
|
|
CompletelyWrong: Symbol("CompletelyWrong"),
|
|
IsActually: Symbol("IsActually"),
|
|
And: Symbol("And"),
|
|
Or: Symbol("Or"),
|
|
Plus: Symbol("Plus"),
|
|
Minus: Symbol("Minus"),
|
|
Times: Symbol("Times"),
|
|
DividedBy: Symbol("DividedBy"),
|
|
Modulo: Symbol("Modulo"),
|
|
Beats: Symbol("Beats"), // >
|
|
SmallerThan: Symbol("SmallerThan"), // <
|
|
ShockingDevelopment: Symbol("ShockingDevelopment"),
|
|
PleaseLikeAndSubscribe: Symbol("PleaseLikeAndSubscribe"),
|
|
};
|
|
|
|
const BINARY_OPS = [
|
|
T.IsActually,
|
|
T.And,
|
|
T.Or,
|
|
T.Plus,
|
|
T.Minus,
|
|
T.Times,
|
|
T.DividedBy,
|
|
T.Modulo,
|
|
T.Beats,
|
|
T.SmallerThan,
|
|
];
|
|
|
|
function tokenize(prog) {
|
|
const reader = new Reader(new Wordifier(prog).wordify(), []);
|
|
const tokens = [];
|
|
|
|
while (reader.hasNext()) {
|
|
const next = reader.next();
|
|
switch (next) {
|
|
case "DISCOVER": {
|
|
reader.expect("HOW");
|
|
reader.expect("TO");
|
|
tokens.push(T.DiscoverHowTo);
|
|
break;
|
|
}
|
|
case "WITH": {
|
|
tokens.push(T.With);
|
|
break;
|
|
}
|
|
case "OF": {
|
|
tokens.push(T.Of);
|
|
break;
|
|
}
|
|
case "RUMOR": {
|
|
reader.expect("HAS");
|
|
reader.expect("IT");
|
|
tokens.push(T.RumorHasIt);
|
|
break;
|
|
}
|
|
case "WHAT": {
|
|
reader.expect("IF");
|
|
tokens.push(T.WhatIf);
|
|
break;
|
|
}
|
|
case "LIES!": {
|
|
tokens.push(T.LiesBang);
|
|
break;
|
|
}
|
|
case "END": {
|
|
reader.expect("OF");
|
|
reader.expect("STORY");
|
|
tokens.push(T.EndOfStory);
|
|
break;
|
|
}
|
|
case "EXPERTS": {
|
|
reader.expect("CLAIM");
|
|
tokens.push(T.ExpertsClaim);
|
|
break;
|
|
}
|
|
case "TO": {
|
|
reader.expect("BE");
|
|
tokens.push(T.ToBe);
|
|
break;
|
|
}
|
|
case "YOU": {
|
|
reader.expect("WON'T");
|
|
reader.expect("WANT");
|
|
reader.expect("TO");
|
|
reader.expect("MISS");
|
|
tokens.push(T.YouWontWantToMiss);
|
|
break;
|
|
}
|
|
case "LATEST": {
|
|
reader.expect("NEWS");
|
|
reader.expect("ON");
|
|
tokens.push(T.LatestNewsOn);
|
|
break;
|
|
}
|
|
case "IS": {
|
|
reader.expect("ACTUALLY");
|
|
tokens.push(T.IsActually);
|
|
break;
|
|
}
|
|
case "AND": {
|
|
tokens.push(T.And);
|
|
break;
|
|
}
|
|
case "OR": {
|
|
tokens.push(T.Or);
|
|
break;
|
|
}
|
|
case "PLUS": {
|
|
tokens.push(T.Plus);
|
|
break;
|
|
}
|
|
case "MINUS": {
|
|
tokens.push(T.Minus);
|
|
break;
|
|
}
|
|
case "TIMES": {
|
|
tokens.push(T.Times);
|
|
break;
|
|
}
|
|
case "DIVIDED": {
|
|
reader.expect("BY");
|
|
tokens.push(T.DividedBy);
|
|
break;
|
|
}
|
|
case "MODULO": {
|
|
tokens.push(T.Modulo);
|
|
break;
|
|
}
|
|
case "BEATS": {
|
|
tokens.push(T.Beats);
|
|
break;
|
|
}
|
|
case "SMALLER": {
|
|
reader.expect("THAN");
|
|
tokens.push(T.SmallerThan);
|
|
break;
|
|
}
|
|
case "SHOCKING": {
|
|
reader.expect("DEVELOPMENT");
|
|
tokens.push(T.ShockingDevelopment);
|
|
break;
|
|
}
|
|
case "PLEASE": {
|
|
reader.expect("LIKE");
|
|
reader.expect("AND");
|
|
reader.expect("SUBSCRIBE");
|
|
tokens.push(T.PleaseLikeAndSubscribe);
|
|
break;
|
|
}
|
|
case "TOTALLY": {
|
|
reader.expect("RIGHT");
|
|
tokens.push(T.TotallyRight);
|
|
break;
|
|
}
|
|
case "COMPLETELY": {
|
|
reader.expect("WRONG");
|
|
tokens.push(T.CompletelyWrong);
|
|
break;
|
|
}
|
|
case "(": {
|
|
tokens.push(T.LParen);
|
|
break;
|
|
}
|
|
case ")": {
|
|
tokens.push(T.RParen);
|
|
break;
|
|
}
|
|
case ",": {
|
|
tokens.push(T.Comma);
|
|
break;
|
|
}
|
|
default: {
|
|
if (!isNaN(parseFloat(next))) {
|
|
// number literal
|
|
tokens.push(parseFloat(next));
|
|
} else {
|
|
// string or varname
|
|
tokens.push(next);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
/* parser */
|
|
|
|
const N = {
|
|
NumberLiteral: Symbol("NumberLiteral"),
|
|
StringLiteral: Symbol("StringLiteral"),
|
|
BoolLiteral: Symbol("BoolLiteral"),
|
|
FnDecl: Symbol("FnDecl"),
|
|
FnCall: Symbol("FnCall"),
|
|
Ident: Symbol("Ident"),
|
|
Assignment: Symbol("Assignment"),
|
|
BinaryOp: Symbol("BinaryOp"),
|
|
IfExpr: Symbol("IfExpr"),
|
|
ExprGroup: Symbol("ExprGroup"),
|
|
ReturnExpr: Symbol("ReturnExpr"),
|
|
ProgEndExpr: Symbol("ProgEndExpr"),
|
|
PrintExpr: Symbol("PrintExpr"),
|
|
InputExpr: Symbol("InputExpr"),
|
|
};
|
|
|
|
class Parser {
|
|
constructor(tokens) {
|
|
this.tokens = new Reader(tokens, []);
|
|
}
|
|
/**
|
|
* Atom
|
|
* Ident
|
|
* NumberLiteral
|
|
* StringLiteral
|
|
* BoolLiteral
|
|
* FnCall
|
|
* FnDecl
|
|
* ExprGroup
|
|
*
|
|
* Expression:
|
|
* (begins with atom)
|
|
* BinaryOp
|
|
* Atom
|
|
* (begins with keyword)
|
|
* IfExpr
|
|
* Assignment
|
|
* ReturnExpr
|
|
* ProgEndExpr
|
|
* PrintExpr
|
|
* InputExpr
|
|
*
|
|
*/
|
|
parse() {
|
|
const nodes = [];
|
|
while (this.tokens.hasNext()) {
|
|
nodes.push(this.expr());
|
|
}
|
|
|
|
if (nodes[nodes.length - 1].type !== N.ProgEndExpr) {
|
|
throw new Error(
|
|
"Parsing error: A Tabloid program MUST end with PLEASE LIKE AND SUBSCRIBE"
|
|
);
|
|
}
|
|
|
|
return nodes;
|
|
}
|
|
expectIdentString() {
|
|
const ident = this.tokens.next();
|
|
if (typeof ident === "string" && !ident.startsWith('"')) {
|
|
return ident;
|
|
}
|
|
throw new Error(
|
|
`Parsing error: expected identifier, got ${ident.toString()}`
|
|
);
|
|
}
|
|
atom() {
|
|
const next = this.tokens.next();
|
|
if (typeof next === "number") {
|
|
return {
|
|
type: N.NumberLiteral,
|
|
val: next,
|
|
};
|
|
} else if (typeof next === "string") {
|
|
if (next.startsWith('"')) {
|
|
return {
|
|
type: N.StringLiteral,
|
|
val: next.substr(1),
|
|
};
|
|
}
|
|
const ident = {
|
|
type: N.Ident,
|
|
val: next,
|
|
};
|
|
if (this.tokens.peek() === T.Of) {
|
|
return this.fnCall(ident);
|
|
}
|
|
return ident;
|
|
} else if (next === T.TotallyRight) {
|
|
return {
|
|
type: N.BoolLiteral,
|
|
val: true,
|
|
};
|
|
} else if (next === T.CompletelyWrong) {
|
|
return {
|
|
type: N.BoolLiteral,
|
|
val: false,
|
|
};
|
|
} else if (next === T.DiscoverHowTo) {
|
|
// fn literal
|
|
const fnName = this.tokens.next();
|
|
if (this.tokens.peek(T.With)) {
|
|
this.tokens.next(); // with
|
|
// with args
|
|
const args = [this.expectIdentString()];
|
|
while (this.tokens.peek() === T.Comma) {
|
|
this.tokens.next(); // comma
|
|
args.push(this.expectIdentString());
|
|
}
|
|
return {
|
|
type: N.FnDecl,
|
|
name: fnName,
|
|
args: args,
|
|
body: this.expr(),
|
|
};
|
|
} else {
|
|
return {
|
|
type: N.FnDecl,
|
|
name: fnName,
|
|
args: [],
|
|
body: this.expr(),
|
|
};
|
|
}
|
|
} else if (next === T.RumorHasIt) {
|
|
// block
|
|
const exprs = [];
|
|
while (this.tokens.hasNext() && this.tokens.peek() !== T.EndOfStory) {
|
|
exprs.push(this.expr());
|
|
}
|
|
this.tokens.expect(T.EndOfStory);
|
|
return {
|
|
type: N.ExprGroup,
|
|
exprs: exprs,
|
|
};
|
|
} else if (next === T.LParen) {
|
|
// block, but guarded by parens, for binary exprs
|
|
const exprs = [];
|
|
while (this.tokens.hasNext() && this.tokens.peek() !== T.RParen) {
|
|
exprs.push(this.expr());
|
|
}
|
|
this.tokens.expect(T.RParen);
|
|
return {
|
|
type: N.ExprGroup,
|
|
exprs: exprs,
|
|
};
|
|
}
|
|
|
|
throw new Error(
|
|
`Parsing error: expected ident, literal, or block, got ${next.toString()} before ${this.tokens
|
|
.peek()
|
|
.toString()}`
|
|
);
|
|
}
|
|
expr() {
|
|
const next = this.tokens.next();
|
|
if (next === T.WhatIf) {
|
|
// if expr
|
|
const cond = this.expr();
|
|
const ifBody = this.expr();
|
|
|
|
let elseBody = null;
|
|
if (this.tokens.peek() == T.LiesBang) {
|
|
this.tokens.next(); // LiesBang
|
|
elseBody = this.expr();
|
|
}
|
|
return {
|
|
type: N.IfExpr,
|
|
cond: cond,
|
|
ifBody: ifBody,
|
|
elseBody: elseBody,
|
|
};
|
|
} else if (next === T.ExpertsClaim) {
|
|
// assignment
|
|
const name = this.expectIdentString();
|
|
this.tokens.expect(T.ToBe);
|
|
const val = this.expr();
|
|
return {
|
|
type: N.Assignment,
|
|
name,
|
|
val,
|
|
};
|
|
} else if (next === T.ShockingDevelopment) {
|
|
// return
|
|
return {
|
|
type: N.ReturnExpr,
|
|
val: this.expr(),
|
|
};
|
|
} else if (next === T.PleaseLikeAndSubscribe) {
|
|
// prog end
|
|
return {
|
|
type: N.ProgEndExpr,
|
|
};
|
|
} else if (next === T.YouWontWantToMiss) {
|
|
// print expr
|
|
return {
|
|
type: N.PrintExpr,
|
|
val: this.expr(),
|
|
};
|
|
} else if (next === T.LatestNewsOn) {
|
|
// input expr
|
|
return {
|
|
type: N.InputExpr,
|
|
val: this.expr(),
|
|
};
|
|
}
|
|
|
|
this.tokens.backstep();
|
|
const atom = this.atom();
|
|
if (BINARY_OPS.includes(this.tokens.peek())) {
|
|
// infix binary ops
|
|
const left = atom;
|
|
const op = this.tokens.next();
|
|
const right = this.atom();
|
|
return {
|
|
type: N.BinaryOp,
|
|
op,
|
|
left,
|
|
right,
|
|
};
|
|
}
|
|
|
|
return atom;
|
|
}
|
|
fnCall(fnNode) {
|
|
this.tokens.expect(T.Of);
|
|
const args = [this.expr()];
|
|
while (this.tokens.peek() === T.Comma) {
|
|
this.tokens.next(); // comma
|
|
args.push(this.expr());
|
|
}
|
|
// if (this.tokens.peek() === T.Of) {
|
|
// const x = this.fnCall(fnNode);
|
|
// return {
|
|
// type: N.FnCall,
|
|
// fn: x,
|
|
// args: args,
|
|
// };
|
|
// }
|
|
|
|
const x = {
|
|
type: N.FnCall,
|
|
fn: fnNode,
|
|
args: args,
|
|
};
|
|
// console.log("Top", x);
|
|
return x;
|
|
}
|
|
}
|
|
|
|
/* executor (tree walk) */
|
|
|
|
/**
|
|
* Abused (slightly) to easily return values upstack
|
|
*/
|
|
class ReturnError {
|
|
constructor(value) {
|
|
this.value = value;
|
|
}
|
|
unwrap() {
|
|
return this.value;
|
|
}
|
|
}
|
|
|
|
class Environment {
|
|
constructor(runtime) {
|
|
/**
|
|
* Runtime contains the following functions:
|
|
* - print(s)
|
|
* - input(s)
|
|
*/
|
|
this.runtime = runtime;
|
|
this.scopes = [{}]; // begin with global scope
|
|
}
|
|
run(nodes) {
|
|
let rv;
|
|
for (const node of nodes) {
|
|
rv = this.eval(node);
|
|
}
|
|
return rv;
|
|
}
|
|
|
|
putClosureOnAllSubNodes(node, closure) {
|
|
if (Array.isArray(node)) {
|
|
for (let i = 0; i < node.length; ++i) {
|
|
node[i] = this.putClosureOnAllSubNodes(node[i], closure);
|
|
}
|
|
} else if (typeof node === "object") {
|
|
for (let key of Object.keys(node)) {
|
|
node[key] = this.putClosureOnAllSubNodes(node[key], closure);
|
|
}
|
|
node.closure = { ...closure };
|
|
}
|
|
return node;
|
|
}
|
|
|
|
eval(node) {
|
|
const scope = this.scopes[this.scopes.length - 1];
|
|
|
|
switch (node.type) {
|
|
case N.NumberLiteral:
|
|
case N.StringLiteral:
|
|
case N.BoolLiteral:
|
|
return node.val;
|
|
case N.FnDecl: {
|
|
console.log("DECL", node, scope);
|
|
node.closure = { ...scope };
|
|
if (node.closure) {
|
|
node.closure = { ...node.closure, ...scope };
|
|
}
|
|
scope[node.name] = node;
|
|
return node;
|
|
}
|
|
case N.FnCall: {
|
|
const fn = this.eval(node.fn);
|
|
|
|
console.log("fn - closure", node);
|
|
fn.body = this.putClosureOnAllSubNodes(fn.body, fn.closure);
|
|
console.log("fn", fn);
|
|
|
|
const args = node.args.map((arg) => this.eval(arg));
|
|
const calleeScope = {};
|
|
fn.args.forEach((argName, i) => {
|
|
calleeScope[argName] = args[i];
|
|
});
|
|
|
|
this.scopes.push(calleeScope);
|
|
let rv;
|
|
try {
|
|
this.eval(fn.body);
|
|
} catch (maybeReturnErr) {
|
|
if (maybeReturnErr instanceof ReturnError) {
|
|
rv = maybeReturnErr.unwrap();
|
|
} else {
|
|
// re-throw
|
|
throw maybeReturnErr;
|
|
}
|
|
}
|
|
this.scopes.pop();
|
|
|
|
return rv;
|
|
}
|
|
case N.Ident: {
|
|
let i = this.scopes.length - 1;
|
|
while (i >= 0) {
|
|
if (node.val in this.scopes[i]) {
|
|
return this.scopes[i][node.val];
|
|
}
|
|
i--;
|
|
}
|
|
if (node.closure && node.closure.hasOwnProperty(node.val)) {
|
|
return node.closure[node.val];
|
|
}
|
|
throw new Error(`Runtime error: Undefined variable "${node.val}"`);
|
|
}
|
|
case N.Assignment: {
|
|
scope[node.name] = this.eval(node.val);
|
|
console.log("assn", node);
|
|
return scope[node.name];
|
|
}
|
|
case N.BinaryOp: {
|
|
const left = this.eval(node.left);
|
|
const right = this.eval(node.right);
|
|
switch (node.op) {
|
|
case T.IsActually:
|
|
return left === right;
|
|
case T.And:
|
|
return left && right;
|
|
case T.Or:
|
|
return left || right;
|
|
case T.Plus:
|
|
return left + right;
|
|
case T.Minus:
|
|
return left - right;
|
|
case T.Times:
|
|
return left * right;
|
|
case T.DividedBy:
|
|
return left / right;
|
|
case T.Modulo:
|
|
return left % right;
|
|
case T.Beats:
|
|
return left > right;
|
|
case T.SmallerThan:
|
|
return left < right;
|
|
default:
|
|
throw new Error(
|
|
`Runtime error: Unknown binary op ${node.op.toString()}`
|
|
);
|
|
}
|
|
}
|
|
case N.IfExpr: {
|
|
if (this.eval(node.cond)) {
|
|
return this.eval(node.ifBody);
|
|
}
|
|
if (node.elseBody != null) {
|
|
return this.eval(node.elseBody);
|
|
}
|
|
}
|
|
case N.ExprGroup: {
|
|
if (!node.exprs.length) {
|
|
throw new Error(
|
|
"Runtime error: Empty expression group with no expressions"
|
|
);
|
|
}
|
|
|
|
let rv;
|
|
for (const expr of node.exprs) {
|
|
rv = this.eval(expr);
|
|
}
|
|
return rv;
|
|
}
|
|
case N.ReturnExpr: {
|
|
const rv = this.eval(node.val);
|
|
throw new ReturnError(rv);
|
|
}
|
|
case N.ProgEndExpr: {
|
|
// do nothing
|
|
break;
|
|
}
|
|
case N.PrintExpr: {
|
|
let val = this.eval(node.val);
|
|
// shim for boolean to-string's
|
|
if (val === true) {
|
|
val = "TOTALLY RIGHT";
|
|
} else if (val === false) {
|
|
val = "COMPLETELY WRONG";
|
|
}
|
|
this.runtime.print(val);
|
|
return val;
|
|
}
|
|
case N.InputExpr: {
|
|
let val = this.eval(node.val);
|
|
// shim for boolean to-string's
|
|
if (val === true) {
|
|
val = "TOTALLY RIGHT";
|
|
} else if (val === false) {
|
|
val = "COMPLETELY WRONG";
|
|
}
|
|
return this.runtime.input(val);
|
|
}
|
|
default:
|
|
console.log(JSON.stringify(node, null, 2));
|
|
throw new Error(
|
|
`Runtime error: Unknown AST Node of type ${node.type.toString()}:\n${JSON.stringify(
|
|
node,
|
|
null,
|
|
2
|
|
)}`
|
|
);
|
|
}
|
|
}
|
|
}
|