Your CSP blocks eval. So I wrote a parser.
Try it: Plot a function in the tab → No upload, no account. Everything runs in the browser. I needed a graphing calculator that plots sin(2x) + x^2 while you type. The obvious implementation is one line: const y =
Try it: Plot a function in the tab →
No upload, no account. Everything runs in the browser.
I needed a graphing calculator that plots sin(2x) + x^2 while you type. The obvious implementation is one line:
const y = eval(expression.replace(/x/g, String(x)));
It is also the one line that cannot ship, because the page sends this header:
Content-Security-Policy: script-src 'self'; object-src 'none';
No 'unsafe-eval', so eval and new Function throw. That is not an accident I wanted to work around — it is the reason the site has no XSS surface from user input. The expression parser had to be real, so I wrote one. It is about eighty lines.
This is how it works, and the three places where a naive implementation breaks.
The pipeline
Four stages, each one testable on its own:
"sin(2x) + x^2" → tokens → RPN queue → evaluate(x)
If you have only ever written a calculator with eval, the surprise is that the hard part is not the arithmetic. It is deciding what -3^2 means and whether 2x is a multiplication.
Stage 1 — tokenize
A tokenizer turns a string into {type, value} objects. Numbers, identifiers (variables and function names), operators, parentheses, commas.
const OPS = "+-*/^";
const isDigit = (c) => c >= "0" && c <= "9";
const isAlpha = (c) => /[a-zA-Z]/.test(c);
function tokenize(src) {
const out = [];
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === " ") { i++; continue; }
if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
let j = i;
while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
// exponent form: 1e-3
if (src[j] === "e" || src[j] === "E") {
let k = j + 1;
if (src[k] === "+" || src[k] === "-") k++;
if (isDigit(src[k])) {
while (k < src.length && isDigit(src[k])) k++;
j = k;
}
}
out.push({ type: "num", value: Number(src.slice(i, j)) });
i = j;
continue;
}
if (isAlpha(c)) {
let j = i;
while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
out.push({ type: "name", value: src.slice(i, j) });
i = j;
continue;
}
if (OPS.includes(c) || c === "(" || c === ")" || c === ",") {
out.push({ type: c });
i++;
continue;
}
throw new Error(`Unexpected character: ${c}`);
}
return out;
}
Note 1e-3. Without the exponent branch, 1e-3 tokenizes as 1, e, -, 3 — and e is not a variable you defined, so the error message is confusing.
Stage 2 — shunting-yard
Dijkstra's algorithm, and for an expression parser it is still the shortest correct path: push numbers straight to the output queue, push operators onto a stack, pop according to precedence.
const PREC = { "+": 1, "-": 1, "*": 2, "/": 2, "^": 3 };
const RIGHT = new Set(["^"]); // right-associative
const FUNCS = new Set(["sin", "cos", "tan", "log", "ln", "sqrt", "abs", "exp"]);
function toRPN(tokens) {
const out = [];
const stack = [];
let prev = null;
for (const t of tokens) {
if (t.type === "num") { out.push(t); }
else if (t.type === "name") {
if (FUNCS.has(t.value)) stack.push({ type: "func", value: t.value });
else out.push({ type: "var", value: t.value });
}
else if (t.type === "(") { stack.push(t); }
else if (t.type === ")") {
while (stack.length && stack[stack.length - 1].type !== "(") out.push(stack.pop());
if (!stack.length) throw new Error("Unbalanced )");
stack.pop();
if (stack.length && stack[stack.length - 1].type === "func") out.push(stack.pop());
}
else if (t.type === ",") {
while (stack.length && stack[stack.length - 1].type !== "(") out.push(stack.pop());
}
else {
// unary minus: only when nothing can precede it
const unary = (t.type === "-" || t.type === "+") &&
(!prev || prev.type === "(" || prev.type === "," ||
(prev.type !== "num" && prev.type !== "var" && prev.type !== ")"));
if (unary) {
if (t.type === "-") stack.push({ type: "neg", value: "-" });
prev = t;
continue;
}
while (stack.length) {
const top = stack[stack.length - 1];
if (top.type === "(") break;
if (top.type === "neg") { out.push(stack.pop()); continue; }
if (top.type === "func") { out.push(stack.pop()); continue; }
const p1 = PREC[t.type], p2 = PREC[top.type];
if (p2 > p1 || (p2 === p1 && !RIGHT.has(t.type))) out.push(stack.pop());
else break;
}
stack.push(t);
}
prev = t;
}
while (stack.length) {
const t = stack.pop();
if (t.type === "(") throw new Error("Unbalanced (");
out.push(t);
}
return out;
}
Two details worth staring at.
Unary minus is not an operator, it is a different token. I push {type: "neg"} so that -3^2 evaluates as -(3^2) = -9. If you treat it as plain - with high precedence you get 9, and someone will file that as a bug within a day.
^ is right-associative. 2^3^2 must be 2^9 = 512, not 64. That is the RIGHT set.
Stage 3 — implicit multiplication
2x, 3(x+1) and (x+1)(x-1) all need a * that the user never typed. Do not handle this inside the parser; insert tokens first, before shunting-yard:
function insertImplicit(tokens) {
const out = [];
const endsValue = (t) => t && (t.type === "num" || t.type === "var" || t.type === ")");
const startsValue = (t) => t && (t.type === "num" || t.type === "name" || t.type === "(");
for (let i = 0; i < tokens.length; i++) {
const cur = tokens[i];
if (i > 0 && endsValue(tokens[i - 1]) && startsValue(cur)) {
out.push({ type: "*" });
}
out.push(cur);
}
return out;
}
The name check has a trap: sin(x) must not become sin * (x). Only treat a name as a value if it is not a known function — which is why FUNCS is a set and not a prefix guess.
Stage 4 — evaluate RPN
function evaluate(rpn, x) {
const st = [];
const fns = { sin: Math.sin, cos: Math.cos, tan: Math.tan,
sqrt: Math.sqrt, abs: Math.abs, exp: Math.exp,
log: Math.log10, ln: Math.log };
for (const t of rpn) {
if (t.type === "num") st.push(t.value);
else if (t.type === "var") st.push(t.value === "x" ? x : (t.value === "pi" ? Math.PI : t.value === "e" ? Math.E : NaN));
else if (t.type === "neg") st.push(-st.pop());
else if (t.type === "func") st.push(fns[t.value](st.pop()));
else {
const b = st.pop(), a = st.pop();
st.push(t.type === "+" ? a + b : t.type === "-" ? a - b :
t.type === "*" ? a * b : t.type === "/" ? a / b : Math.pow(a, b));
}
}
return st[0];
}
Then the plotting loop is trivial and fast — the expression is parsed once, and evaluate(rpn, x) runs for a few hundred sample points:
const rpn = toRPN(insertImplicit(tokenize(input)));
for (let px = 0; px < width; px++) {
const x = xmin + (px / width) * (xmax - xmin);
const y = evaluate(rpn, x);
if (Number.isFinite(y)) ctx.lineTo(px, toScreenY(y));
else ctx.moveTo(px, toScreenY(y)); // break the line at a pole
}
That moveTo on a non-finite value is the difference between a clean asymptote and a line shooting off to the top of the canvas for tan(x).
What it costs, honestly
About 80 lines and 1.1 KB minified, against roughly 60 KB for a general maths library — and the library would still route through Function internally in most cases, which the CSP blocks anyway.
The limitations are worth stating: no complex numbers, no matrices, no symbolic simplification, and floats are floats (0.1 + 0.2 is not 0.3). For plotting a function of one variable that is the right trade.
The calculator is at https://toolcin.pages.dev/tools/graphing-calculator/ if you want to break it. The parser is the part I would test first — feed it 2x(x+1, sin(), --3, 1e, and a forty-term expression, and see what the error messages say. Bad error messages are how parsers stay bad.
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.