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.