1

I am trying to create a custom formula evaluator in JavaScript that allows for nested functions. I have been able to create basic calculation functions such as AVERAGE, SUM, and MIN, (amongst many other functions) but I am having trouble with nested functions and I could use some help.

The desired behavior is as follows:

The user enters a formula into a text input, using the syntax funcName(arg1, arg2, ...), where funcName is the name of the calculation function, and arg1, arg2, ... are the arguments to the function. Arguments can either be numbers or other functions (i.e. AVERAGE(1,2) or AVERAGE(SUM(1,2),2). I would like the formulas to have an arbitrarily complex form so long as they are coded in appropriate syntax.

The user presses a button to evaluate the formula. The result of the calculation is displayed on the screen. Here's another example of what a formula might look like: AVERAGE(SUM(1, 2), 4). This formula should evaluate as follows:

The SUM(1, 2) function is evaluated first and returns 3. The AVERAGE(3, 4) function and therefore the final output, is evaluated next and returns 3.5. So far, I have been able to create the basic calculation functions such as sum and min, but I am having trouble with the nested functions. I would greatly appreciate any help or guidance that anyone can offer.

Thank you in advance!

Below is one of several different attempts I have made;

      function sum(...args) {
        return args.reduce((acc, arg) => {
          if (isNaN(arg)) {
            return acc;
          }
          return acc + arg;
        }, 0);
      }

      function min(...args) {
        return Math.min(...args);
      }

      function avg(...args) {
        if (args.length === 1 && typeof args[0] === 'number') {
          return args[0];
        }
        return sum(...args) / args.length;
      }

      function evaluate(formula) {
        let match = /^(\w+)\((.*)\)$/.exec(formula);
        if (!match) {
          return parseFloat(formula);
        }

        let [, func, argsStr] = match;
        let args = argsStr.split(',').map(arg => evaluate(arg.trim()));

        switch (func) {
          case 'SUM':
            return sum(...args);
          case 'MIN':
            return min(...args);
          case 'AVERAGE':
            return avg(...args);
          default:
            return NaN;
        }
      }

      let formulaInput = document.getElementById('formula');
      let evalButton = document.getElementById('eval-btn');
      let result = document.getElementById('result');

      evalButton.addEventListener('click', () => {
        let formula = formulaInput.value;
        result.innerHTML = evaluate(formula);
      });

I've been toying around with various approaches, and so far have found some success with this approach, but I don't believe this is the best path forward. For instance if I don't include the "if (isNaN(arg))" or some other variation of this, in the console I get: {Array(3): 0:NaN 1:3 2:3} for the first argument, when trying to create a complex formula like: SUM(AVERAGE(1,3), 3) with an output of NaN.

J M
  • 23
  • 3
  • 1
    You need a language parser to create a tree that represents the hierarchy of your functions. It is also possible to use several regex passes to make sense of the hierarchy: https://stackoverflow.com/a/74437880/7475450 – Peter Thoeny Feb 13 '23 at 08:57

2 Answers2

2

You could replace the string from inside to out with the values from the most nested function.

function calc(s) {
    const functions = {
        SUM (...args) {
            return args.reduce((a, b) => a + b, 0);
        },
        AVERAGE (...args) {
            return args.reduce((a, b) => a + b, 0) / args.length;
        }
    }
    let t = s;
    do {
        s = t;
        t = s.replace(/([^(),]+)\(([^()]+)\)/, (_, fn, v) => fn in functions
            ? functions[fn](...v.split(',').map(Number))
            : NaN
        );
    } while (s !== t)
    return t;
} 

console.log(calc('SUM(AVERAGE(1,3),3)'));
console.log(calc('SUM(AVERAGE(1,3),3,AVERAGE(5,7))'));
Nina Scholz
  • 376,160
  • 25
  • 347
  • 392
  • Good approach, the answer might benefit from a quick explanation of the regex, which I assume locates multiple parentheses pairs without intervening opening or closing singles. good use of .reduce. – Dave Pritlove Feb 13 '23 at 10:47
  • This works as long as you have only one function per level. It fails for example for `calc('SUM(AVERAGE(1,3),3,AVERAGE(5,7))')` – Peter Thoeny Feb 13 '23 at 17:24
  • @PeterThoeny, right. please see edit. – Nina Scholz Feb 13 '23 at 17:51
2

As pointed out in the comments, you need a proper language parser for this. The good news, you can use a parser generator instead of coding the parser by hand. One good parser generator is PEG.js, for which your grammar could be like this:

Expression
  = head:Term tail:(_ [+-] _ Term)* {
    return tail.reduce((t, n) => [n[1], t, n[3]], head)
}

Term
  = head:Factor tail:(_ [*/] _ Factor)* {
    return tail.reduce((t, n) => [n[1], t, n[3]], head)
}

Factor
  = "(" _ expr:Expression _ ")" { return expr; }
  / Integer
  / Funcall

Funcall 
  = name:Ident '(' _ ')' { return [name] }
  / name:Ident '(' args:Args ')' { return [name, ...args] }


Args 
  = head:Expression tail:(_ ',' _ Expression)* {
    return [head, ...tail.map(t => t.pop())]
}

Integer = _ [0-9]+ { return parseInt(text(), 10); }

Ident = [A-Za-z]+ { return text() }

_ = [ \t\n\r]* { return null }

You can experiment with it on https://pegjs.org/online, then simply click "download parser" and you're ready.

This grammar converts the input to so-called s-expressions, which are nested arrays like [operator-or-function arg1 arg2 etc]. For example,

 FOO(1+2, BAR(3*4), 5)

will be parsed into this:

[
   "FOO",
   ["+", 1, 2],
   [
      "BAR",
      ["*", 3, 4]
   ],
   5
]

Evaluating this shouldn't be a problem.

gog
  • 10,367
  • 2
  • 24
  • 38
  • Thank you for the replies, I am going to attempt to implement a few of the suggestions and I will get back to you guys as soon as I have something working! – J M Feb 13 '23 at 18:45