DEV Community

coder3662
coder3662

Posted on Edited on Originally published at toolcin.pages.dev

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 = eval(expression.replace(/x/g, String(x)));
Enter fullscreen mode Exit fullscreen mode

It is also the one line that cannot ship, because the page sends this header:

Content-Security-Policy: script-src 'self'; object-src 'none';
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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];
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)