This commit is contained in:
nitowa
2022-04-09 14:37:20 +02:00
commit 0086c95dc0
21 changed files with 5836 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
<html>
<head>
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/calc.css">
<script id="program" language="json" type="{{ content-type }}">
{{ program }}
</script>
<script type="module">
window.addEventListener("load", () => {
import("/js/calc.mjs");
})
</script>
</head>
<body>
<div class="body">
<h1 class="title" id="name">Loading</h1>
<div id="input"></div>
<span id="output"></span>
<span id="error"></span>
<button id="report">Show me your math!</span>
</div>
</body>
</html>
+49
View File
@@ -0,0 +1,49 @@
.body #input {
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.body #input > input {
justify-self: center;
outline: none;
padding: 16px;
margin: 16px;
font-size: 24px;
border: 2px solid #2a2a2a;
width: 128px;
height: 64px;
border-radius: 16px;
text-align: center;
}
.body #output {
font-size: 24px;
text-align: center;
}
.body #output:empty {
display: none;
}
.body #output::before {
content: "Result:";
color: #2a2a2a;
padding-right: 0.5em;
}
.body #error {
font-size: 24px;
text-align: center;
color: #e21c1c;
}
.body #error:empty {
display: none;
}
.body #error::before {
content: "Error:";
color: #e94e4e;
padding-right: 0.5em;
}
+21
View File
@@ -0,0 +1,21 @@
body {
font-family: 'Oxygen';
}
.body {
display: flex;
max-width: 800px;
margin: auto;
flex-direction: column;
}
.body > * {
margin: 8px 0px;
}
.body .title {
justify-content: center;
display: flex;
flex-direction: row;
}
+9
View File
@@ -0,0 +1,9 @@
.body #name {
max-width: 400px;
font-size: 24px;
align-self: center;
}
.body #program {
resize: vertical;
}
+28
View File
@@ -0,0 +1,28 @@
<html>
<head>
<link rel="stylesheet" href="/css/common.css">
<link rel="stylesheet" href="/css/main.css">
<script type="module">
window.addEventListener("load", () => {
import("/js/main.mjs");
})
</script>
</head>
<body>
<div class="body">
<h1 class="title">Upload your Program to Run</h1>
<input id="name" placeholder="New Program"/>
<textarea id="program" style="min-height: 200px;">
(-b + sqrt(b^2 - 4a*c)) / 2a
</textarea>
<div>
Choose a program type:
<select id="content-type">
<option value="application/x-yaca-ast">AST</option>
<option value="application/x-yaca-code" selected>Code</option>
</select>
</div>
<button id="upload">Upload</button>
</div>
</body>
</html>
+106
View File
@@ -0,0 +1,106 @@
const allowedMathFunctions = new Set([
"abs",
"acos",
"asin",
"atan",
"cos",
"sin",
"tan",
"ceil",
"floor",
"exp",
"log",
"log2",
"log10",
"sqrt",
]);
export default function astToJs(ast) {
if (typeof ast !== "object" || ast === null) {
throw new Error("Ast node must be an object");
}
switch (ast.kind) {
case "number": {
if (typeof ast.value !== "number") {
throw new Error("Number is of the wrong type");
}
return {
code: `${ast.value}`,
variables: new Set(),
};
}
case "variable": {
if (typeof ast.variable !== "string") {
throw new Error("Variable name not specified");
}
if (!ast.variable.match(/^[a-z][a-z0-9_]*$/)) {
throw new Error(`Invalid variable name: ${ast.variable}`);
}
const name = `var_${ast.variable}`;
return {
code: name,
variables: new Set([name]),
}
}
case "function": {
const { name, argument } = ast;
const { code: argumentCode, variables } = astToJs(argument);
if (typeof name !== "string") {
throw new Error("Function name must be a string");
}
if (!allowedMathFunctions.has(name)) {
throw new Error(`Invalid function: ${name}`);
}
const code = `Math.${name}(${argumentCode})`;
return { code, variables };
}
case "unop": {
const { code: nestedCode, variables } = astToJs(ast.value);
const op =
ast.op === "negate" ? "-" :
ast.op === "invert" ? "~" :
null;
if (op === null) {
throw new Error("Invalid unary operator");
}
const code = `${op}(${nestedCode})`;
return { code, variables };
}
case "binop":
{
const [left, right] = ast.values;
const leftResult = astToJs(left);
const rightResult = astToJs(right);
const op =
ast.op === "add" ? "+" :
ast.op === "subtract" ? "-" :
ast.op === "multiply" ? "*" :
ast.op === "divide" ? "/" :
ast.op === "exponent" ? "**" :
null; // null: never
if (op === null) {
throw new Error("Invalid binary operator");
}
return {
code: `(${leftResult.code} ${op} ${rightResult.code})`,
variables: new Set([...leftResult.variables, ...rightResult.variables]),
}
}
default: {
throw new Error(`Unknown ast kind: ${ast.kind}`);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
import astToJs from "/js/ast-to-js.mjs";
import evalCode from "/js/eval-code.mjs";
import lex from "/js/lex.mjs";
import parse from "/js/parse.mjs";
const $ = document.querySelector.bind(document);
const nameEl = $("#name");
const errorEl = $("#error");
const reportEl = $("#report");
const astProgram = $("#program");
const program = JSON.parse(astProgram.textContent);
nameEl.innerText = `Running: ${program.name}`;
reportEl.addEventListener("click", () => {
const file = location.pathname.split("/").slice(-1)[0];
reportEl.disabled = true;
reportEl.innerText = "Reported!";
fetch(`/report`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({ file })
});
})
try {
let ast;
if (astProgram.type === "application/x-yaca-code") {
const tokens = lex(program.code);
ast = parse(tokens);
} else {
ast = JSON.parse(program.code);
}
const jsProgram = astToJs(ast);
evalCode(jsProgram);
} catch(e) {
console.error(e);
const msg = e instanceof Error ? e.message : "Something went wrong";
errorEl.innerText = msg;
}
+64
View File
@@ -0,0 +1,64 @@
const $ = document.querySelector.bind(document);
const prepareInputs = (variables, onChange) => {
const inputEl = $("#input");
for (const node of [...inputEl.childNodes]) {
node.remove();
}
for (const variable of variables) {
const input = document.createElement("input");
input.setAttribute("type", "text");
input.setAttribute("placeholder", `${variable.slice(4)}`);
input.setAttribute("value", "");
input.addEventListener("keyup", () => {
let value = Number(input.value);
if (isNaN(value)) {
value = 0;
}
onChange(variable, value);
});
inputEl.appendChild(input);
}
}
const updateOutput = (result) => {
// Float truncation
if (typeof result === "number" && Math.floor(result) !== result) {
result = result.toFixed(2);
}
const $ = document.querySelector.bind(document);
const outputEl = $("#output");
outputEl.innerText = result;
}
export default ({ code, variables }) => {
const varList = [...variables].sort((a, b) => a.localeCompare(b));
const fn = new Function(...varList, `return (${code});`);
const values = new Map(varList.map((val) => [val, 0]));
const refresh = () => {
const result = fn(...varList.map((val) => values.get(val)));
if (isNaN(result)) {
throw new Error("Output was not a number")
}
$("#error").innerText = "";
updateOutput(result);
}
const onChange = (variable, value) => {
values.set(variable, value);
refresh();
}
prepareInputs(varList, onChange);
refresh();
}
+86
View File
@@ -0,0 +1,86 @@
export default (source) => {
let index = 0;
const tokens = [];
while (index < source.length) {
const token = source[index];
index += 1;
switch (token) {
case ' ':
case '\t':
case '\n':
case '\r':
break;
case "~":
case "+":
case "-":
case "*":
case "/":
case "^": {
const op =
token === "~" ? "invert" :
token === "+" ? "add" :
token === "-" ? "subtract" :
token === "*" ? "multiply" :
token === "/" ? "divide" :
token === "^" ? "exponent" :
null;
tokens.push({
kind: "operator",
value: op,
});
break
}
case "(": {
tokens.push({
kind: "open-paren",
});
break;
}
case ")": {
tokens.push({
kind: "close-paren",
});
break;
}
default: {
if (token.match(/^[0-9\.]$/)) {
let currentToken = token;
while (index < source.length && source[index].match(/^[0-9\.]$/)) {
currentToken += source[index];
index += 1;
}
const value = Number(currentToken);
tokens.push({
kind: "number",
value,
});
break;
}
if (token.match(/^[a-z]$/)) {
let currentToken = token;
while (index < source.length && source[index].match(/^[a-z0-9_]$/)) {
currentToken += source[index];
index += 1;
}
tokens.push({
kind: "variable",
value: currentToken,
});
break;
}
throw new Error(`Syntax error: Unexpected "${token}"`)
}
}
}
return tokens;
}
+98
View File
@@ -0,0 +1,98 @@
const $ = document.querySelector.bind(document);
const nameEl = $("#name");
const uploadEl = $("#upload");
const programEl = $("#program");
const contentTypeEl = $("#content-type");
uploadEl.addEventListener("click", async () => {
const code = programEl.value;
// We use logical or instead of nullish coalescing because
// [input] values are always coerced to a string
const name = nameEl.value || nameEl.getAttribute("placeholder");
const type = contentTypeEl.value;
const res = await fetch("/upload", {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({ type, program: { name, code } })
});
const url = await res.text();
window.location.href = url;
});
const staticAst = `{
"kind": "binop",
"op": "divide",
"values": [
{
"kind": "binop",
"op": "add",
"values": [
{
"kind": "unop",
"op": "negate",
"value": { "kind": "variable", "variable": "b"}
},
{
"kind": "function",
"name": "sqrt",
"argument": {
"kind": "binop",
"op": "subtract",
"values": [
{
"kind": "binop",
"op": "exponent",
"values": [
{ "kind": "variable", "variable": "b"},
{ "kind": "number", "value": 2}
]
},
{
"kind": "binop",
"op": "multiply",
"values": [
{ "kind": "number", "value": 4 },
{
"kind": "binop",
"op": "multiply",
"values": [
{ "kind": "variable", "variable": "a" },
{ "kind": "variable", "variable": "c" }
]
}
]
}
]
}
}
]
},
{
"kind": "binop",
"op": "multiply",
"values": [
{ "kind": "number", "value": 2 },
{ "kind": "variable", "variable": "a" }
]
}
]
}`;
const staticCode = `(-b + sqrt(b^2 - 4a*c)) / 2a`;
const resetTextArea = () => {
const contentType = contentTypeEl.value;
if (contentType === "application/x-yaca-ast") {
programEl.value = staticAst;
} else {
programEl.value = staticCode;
}
}
contentTypeEl.addEventListener("change", resetTextArea);
resetTextArea();
+234
View File
@@ -0,0 +1,234 @@
/*
Operation precedences:
0: exponentiation
1: implicit multiplication
2: multiplication/division
3: unary operators
4: addition/subtraction
*/
// We could probably have lexed directly into the parse token, but
// this way we keep a bit more separation of church & state
const lexTokenToParseToken = (token) => {
if (token === undefined) {
return { kind: "EOF" };
}
switch (token.kind) {
case "operator": {
switch (token.value) {
case "invert": return { kind: "UNOP", value: token.value, precedence: 3, isOp: true };
case "subtract": return { kind: "MAYBE_UNOP", value: token.value, unopValue: "negate", precedence: 4, isOp: true };
case "exponent": return { kind: "BINOP", value: token.value, precedence: 0, isOp: true, };
case "multiply": return { kind: "BINOP", value: token.value, precedence: 2, isOp: true, };
case "divide": return { kind: "BINOP", value: token.value, precedence: 2, isOp: true, };
case "add": return { kind: "BINOP", value: token.value, precedence: 4, isOp: true, };
default: throw new Error(`Unknown operator ${token.value}`);
}
}
case "open-paren": return { kind: "EXPR_START" };
case "close-paren": return { kind: "EXPR_END" };
case "number": return { kind: "VALUE", ast: { kind: "number", value: token.value } };
case "variable": return { kind: "VALUE", ast: { kind: "variable", variable: token.value } };
default: throw new Error(`Unknown token kind ${token.kind}`);
}
}
const mightBeUnop = (token) => {
return (
token.kind === "UNOP"
|| token.kind === "MAYBE_UNOP"
|| token.kind === "FUNCTION"
|| token.kind === "IMPLICIT_MULTIPLICATION"
);
}
const mightBeBinop = (token) => {
return (
token.kind === "BINOP"
|| token.kind === "MAYBE_UNOP"
);
}
const parseOne = (stack, lookahead) => {
// If we can reduce a parenthetical expression, we want to
if (stack[0]?.kind === "EXPR_END") {
const [_end, value, _start, ...rest] = stack;
if (stack[1]?.kind !== "VALUE" || stack[2]?.kind !== "EXPR_START") {
throw new Error("Received unexpected close parenthesis");
}
return ["reduce", [value, ...rest]];
}
// Otherwise, all of our reductions occur on values
if (stack[0]?.kind === "VALUE") {
// We have some special cases for two adjacent values, when the first one is
// a pure variable (function call) or number (term multiplication)
if (lookahead.kind === "VALUE" || lookahead.kind === "EXPR_START") {
if (stack[0].ast.kind === "variable") {
const [token, ...rest] = stack;
const newToken = {
kind: "FUNCTION",
isOp: true,
precedence: 3,
name: token.ast.variable
}
const newStack = [newToken, ...rest];
return ["reduce", newStack];
}
if (stack[0].ast.kind === "number") {
const [token, ...rest] = stack;
const newToken = {
kind: "IMPLICIT_MULTIPLICATION",
isOp: true,
precedence: 1,
value: token.ast.value
}
const newStack = [newToken, ...rest];
return ["reduce", newStack];
}
}
// If we have an operator on our stack, we want to reduce it unless the upcoming
// operator has a stronger precedence
if (stack[1]?.isOp) {
const shouldShift = mightBeBinop(lookahead) && lookahead.precedence < stack[1].precedence;
if (shouldShift) {
return ["shift"];
}
const isBinop = mightBeBinop(stack[1]) && stack[2]?.kind === "VALUE";
if (isBinop) {
const [right, binop, left, ...rest] = stack;
// Binops are easy
const newValue = {
kind: "VALUE",
ast: {
kind: "binop",
op: binop.value,
values: [
left.ast,
right.ast,
]
}
};
return ["reduce", [newValue, ...rest]]
}
const isUnop = mightBeUnop(stack[1]);
if (!isUnop) {
throw new Error("Unexpected operator");
}
const [value, unop, ...rest] = stack;
let newToken;
// Handle each of the different types of unary operators
switch (unop.kind) {
case "UNOP":
case "MAYBE_UNOP": {
const op = unop.kind === "MAYBE_UNOP" ? unop.unopValue : unop.value;
newToken = {
kind: "VALUE",
ast: {
kind: "unop",
op,
value: value.ast
}
};
break;
}
case "FUNCTION": {
newToken = {
kind: "VALUE",
ast: {
kind: "function",
name: unop.name,
argument: value.ast,
}
};
break;
}
case "IMPLICIT_MULTIPLICATION": {
newToken = {
kind: "VALUE",
ast: {
kind: "binop",
op: "multiply",
values: [
{ kind: "number", value: unop.value },
value.ast,
]
}
};
break
}
default: {
throw new Error(`Unknown unary operator: ${unop.kind}`);
}
}
return ["reduce", [newToken, ...rest]];
}
}
// Otherwise, we have no reductions to do, so we shift in a new token
return ["shift"];
}
export default (tokens) => {
const queue = [...tokens];
let stack = [];
const maxIter = 1000;
let iter = 0;
while (queue.length > 0 || stack.length > 1) {
// I haven't proven that this terminates so uh
// Hopefully this will keep me from nuking anyone's chrome
if (iter >= maxIter) {
throw new Error("Timeout");
}
iter++;
const lookahead = lexTokenToParseToken(queue[0]);
const action = parseOne(stack, lookahead);
if (window.DEBUG) {
console.log([...stack], lookahead, action);
}
switch (action[0]) {
case "shift": {
if (lookahead.kind === "EOF") {
throw new Error("Attempting to shift EOF, which indicates a malformed program");
}
queue.shift();
stack = [lookahead, ...stack]
break;
}
case "reduce": {
stack = action[1];
}
}
}
// If we parsed correctly, we should be left with a single value
// representing our final result
if (stack[0]?.kind !== "VALUE") {
throw new Error("Parser did not return a value");
}
return stack[0].ast;
}