DEV Community

Monsibd
Monsibd

Posted on

Turning "price - product cost - shipping" into a calculator: a plain-word formula parser with no eval

Most people don't think in spreadsheet syntax. Ask an online seller how they work out profit per order and they'll say something like "price minus product cost minus shipping." Not =B2-C2-D2.

I wanted a calculator builder where you can type exactly that, price - product cost - shipping, and get a working calculator with labelled inputs. Here's how the parser behind it works, in about 100 lines of plain JavaScript and with no eval.

Step 1: tidy the formula without changing its meaning

People type formulas the way they think: =Price x 2, fee ÷ 100, revenue – costs (with an en dash). So before parsing anything, a tidyFormula pass normalizes the typography:

function tidyFormula(text) {
  let f = String(text || '').replace(/^\s*=\s*/, '');           // drop a leading "="
  f = f.replace(/[×✕✖]/g, '*').replace(/÷/g, '/')
       .replace(/[−–—]/g, '-').replace(/\^/g, '**');
  f = f.replace(/([\w)\]])\s+x\s+(?=[\w(])/g, '$1 * ');          // " x " means multiply
  // ...then join multi-word names (next step)
}
Enter fullscreen mode Exit fullscreen mode

The x rule only fires between two operands, so a variable called x on its own still works.

Step 2: multi-word names become one variable

This is the part that makes plain words work. Any run of words separated only by spaces becomes a single snake_case name:

price - product cost - shipping   ->   price - product_cost - shipping
Enter fullscreen mode Exit fullscreen mode

The regex walks the string and grabs [A-Za-z_][A-Za-z0-9_]* followed by more space-separated words. Two edge cases matter:

  1. Numbers like 1e6. If the character before a match is a digit or a dot, it's part of a number, not a name.
  2. Function calls. In selling price round(...) you don't want selling_price_round. If the last word is a known function and it's followed by (, it's kept separate.

Step 3: detect the inputs, in order

Once the formula is tidy, every remaining identifier that isn't a function name (min, max, round, abs, sqrt, floor, ceil) or a constant (e, pi) is an input. They're collected in the order they first appear, so the form fields come out in the same order you wrote them.

Step 4: turn keys back into human labels

product_cost becomes "Product cost". A small dictionary handles the abbreviations people actually use:

const LABEL_WORDS = { pct: '%', qty: 'quantity', cogs: 'COGS', roas: 'ROAS', mrr: 'MRR', cac: 'CAC', ltv: 'LTV', aov: 'AOV', yr: 'year', wk: 'week' };
// "fee_pct" -> "Fee %", "units_sold" -> "Units sold"
Enter fullscreen mode Exit fullscreen mode

It also guesses how the answer should read: if the formula ends in * 100 it's a percentage, and if any input looks like money (price, cost, revenue, fee...) the result is formatted as currency. It's a guess you can override, but it's right most of the time.

Step 5: evaluate safely, without eval

The site's Content Security Policy doesn't allow unsafe-eval, so new Function("return " + expr) was never an option. Instead there's a small recursive-descent evaluator that supports exactly what business formulas need:

  • numbers (including 1e6), + - * /, and ** (right-associative)
  • parentheses and unary +/-
  • comparisons < > <= >= == !=, logical && and || (short-circuit)
  • the ternary ?:, so units > 100 ? price * 0.9 : price gives you volume pricing
  • a whitelist of functions with argument-count limits (round(x, 2) takes one or two arguments, min/max up to 20)

Each grammar level is its own function: ternary, then ||, then &&, then equality, then relational, then + -, then * /, then **, then unary (-, +, !), then primary (numbers, parentheses and function calls). Anything unexpected throws an error that names the position, which then becomes a friendly message in the UI.

One bug worth sharing: numbers of 1e21 or more stringify as 1e+21. An early version scanned the substituted string for leftover identifiers, saw the e, and zeroed it out, which quietly turned 1e+21 into 21. The fix was to match the full scientific-notation token first and only treat real identifiers as names. That kind of wrong-but-plausible number is the worst kind of calculator bug, because nothing looks broken.

Step 6: chain calculators together

A single answer is rarely the end of the question. Pricing a product means landed cost, then a price, then profit per order. So the second half of the product is a workflow builder: each calculator's result can be passed as an input to a later step, including one from two steps back. For example: 320 customers at $49 is $15,680 MRR, which annualizes to $188,160 ARR, which is $1,128,960 at a 6x multiple.

Step 7: let an AI assistant run them

The same calculators are exposed through a JSON API and a remote MCP server, with three tools: search_calculators, describe_calculator and calculate. An assistant like Claude can find a calculator by keyword, read its inputs and run it, so you and the assistant get the same number every time instead of it improvising the formula.

Try it

The builder is live at monsitools.com/build-tool. Type price - product cost - shipping and watch the fields appear. The workflow builder is at monsitools.com/build-workflow.

I'm building this solo from Dhaka, and I'd love feedback on the parser, especially formulas you'd expect to work that don't.

Top comments (0)