5

Browsers support dynamic JavaScript evaluation through eval or new Function. This is very convenient for compiling small data-binding expressions provided as strings into JavaScript functions.

E.g.

var add2 = new Function('x', 'return x + 2');
var y = add2(5); //7

I would like to preprocess these expressions to support ES6 arrow function syntax without using babel or any other library with more than a few hundred lines of JavaScript.

var selectId = new Function('x', 'return x.map(a=>a.id)');

Unfortunately, this doesn't work even with the latest IE version.

The function should take a string and return another string. E.g.

resolveArrows('return x.map(a=>a.id)') 

should return

'return x.map(function(a) { return a.id })'

Any ideas on how to implement such a thing?

Marko
  • 5,437
  • 4
  • 28
  • 47
  • You want to write a parser? – SparK Apr 05 '16 at 13:51
  • @Andy that's JavaScript fat arrow lambda notation (argument is `a`, return value is `a.id`). Hence the `a=>a.id`, which is a function argument to `x.map()`. – TheHans255 Apr 05 '16 at 14:15
  • You can't expect this to magically work on browsers which don't support arrow functions. You need a parser, like babel or traceur. – Oriol Apr 11 '16 at 13:53
  • It isn't as simple as doing a string replacement, plus you'd still need to consider the proper lexical scope for `this` and `arguments`. Unless your arrow functions aren't the same as the ES6 spec. – MinusFour Apr 11 '16 at 13:58
  • I'm not looking for a bulletproof solution. Something that works for common scenarios would be ok. – Marko Apr 11 '16 at 14:02
  • Unfortunately there isn't simple solution even for common solition. If you have very limited set of expression you can end with very ugly regular expression. But it sill be very very fragile. This is job for regular parser, and parse is not small. – farincz Apr 12 '16 at 20:44
  • What if we add a constraint that brackets are mandatory on the right of the arrow? Lexical this is not required in my use case. – Marko Apr 13 '16 at 04:14
  • @Marko: No, because the *body* of the arrow function can be as complicated as *any* js function. You need a complete js parser, either build it yourself (pretty hard) or use an existing parser, like Babel. If external is ok, we can try to cook up a solution. Non-parsing solutions will just [trip you](http://stackoverflow.com/a/1732454/893578). – Sheepy Apr 13 '16 at 06:20

2 Answers2

5

As others have already explained that such a utility would be extremely fragile and can not be trusted with very complex code.

However for simple cases it's possible to implement this. Following is the link to the Fat Arrow function expansion.

https://github.com/ConsciousObserver/stackoverflow/blob/master/Es6FatArrowExpansion/fatArrowUtil.js

Import fatArrowUtil.js and call expandFatArrow(code) on your code.

Following is sample usage

expandFatArrow("()=>'test me';");

And below is the result

(function (){return 'test me';}).bind(this)

Below is the output for your suggested test case

//actual
var selectId = new Function('x', 'return x.map(a=>a.id)');
//after expansion
var selectId = new Function('x', 'return x.map((function (a){return a.id}).bind(this))');

Note: This utility uses bind() of Function to preserve the 'this' context. It doesn't try to compile your code, any errors in the original code would be present in expanded code.

Below is the working sample with tests and results.

//start of fat arrow utility
'use strict';
function expandFatArrow(code) {
 var arrowHeadRegex = RegExp(/(\((?:\w+,)*\w+\)|\(\)|\w+)[\r\t ]*=>\s*/);
 var arrowHeadMatch = arrowHeadRegex.exec(code);
 
 if(arrowHeadMatch) {//if no match return as it is
  var params = arrowHeadMatch[1];
  if(params.charAt(0) !== "(") {
   params = "(" + params + ")";
  }
  var index = arrowHeadMatch.index;
  var startCode = code.substring(0, index);
  
  var bodyAndNext = code.substring(index + arrowHeadMatch[0].length);
  
  var curlyCount = 0;
  var curlyPresent = false;
  var singleLineBodyEnd = 0;
  var bodyEnd = 0;
  var openingQuote = null;
  
  for(var i = 0; i < bodyAndNext.length; i++) {
   var ch = bodyAndNext[i];
   if(ch === '"' || ch === "'") {
    openingQuote = ch;
    i = skipQuotedString(bodyAndNext, openingQuote, i);
    ch = bodyAndNext[i];
   }
   
   if(ch === '{'){
    curlyPresent = true;
    curlyCount++;
   } else if(ch === '}') {
     curlyCount--;
   } else if(!curlyPresent) {
    //any character other than { or }
    singleLineBodyEnd = getSingeLineBodyEnd(bodyAndNext, i);
    break;
   }
   if(curlyPresent && curlyCount === 0) {
    bodyEnd = i;
    break;
   }
  }
  var body = null;
  if(curlyPresent) {
   if(curlyCount !== 0) {
    throw Error("Could not match curly braces for function at : " + index);
   }
   body = bodyAndNext.substring(0, bodyEnd+1);
   
   var restCode = bodyAndNext.substring(bodyEnd + 1);
   var expandedFun = "(function " + params + body + ").bind(this)";
   code = startCode + expandedFun + restCode;
  } else {
   if(singleLineBodyEnd <=0) {
    throw Error("could not get function body at : " + index);
   }
   
   body = bodyAndNext.substring(0, singleLineBodyEnd+1);
   
   restCode = bodyAndNext.substring(singleLineBodyEnd + 1);
   expandedFun = "(function " + params + "{return " + body + "}).bind(this)";
   code = startCode + expandedFun + restCode;
  }

  return expandFatArrow(code);//recursive call
 }
 return code;
}
function getSingeLineBodyEnd(bodyCode, startI) {
 var braceCount = 0;
 var openingQuote = null;
 
 for(var i = startI; i < bodyCode.length; i++) {
  var ch = bodyCode[i];
  var lastCh = null;
  if(ch === '"' || ch === "'") {
   openingQuote = ch;
   i = skipQuotedString(bodyCode, openingQuote, i);
   ch = bodyCode[i];
  }
  
  if(i !== 0 && !bodyCode[i-1].match(/[\t\r ]/)) {
   lastCh = bodyCode[i-1];
  }

  if(ch === '{' || ch === '(') {
   braceCount++;
  } else if(ch === '}' || ch === ')') {
   braceCount--;
  }
  
  if(braceCount < 0 || (lastCh !== '.' && ch === '\n')) {
   return i-1;
  }
 }
 
 return bodyCode.length;
}
function skipQuotedString(bodyAndNext, openingQuote, i) {
 var matchFound = false;//matching quote
 var openingQuoteI = i;
 i++;
 for(; i < bodyAndNext.length; i++) {
  var ch = bodyAndNext[i];
  var lastCh = (i !== 0) ? bodyAndNext[i-1] : null;
  
  if(ch !== openingQuote || (ch === openingQuote && lastCh === '\\' ) ) {
   continue;//skip quoted string
  } else if(ch === openingQuote) {//matched closing quote
   matchFound = false;
   break;
  }
 }
 if(matchFound) {
  throw new Error("Could not find closing quote for quote at : " + openingQuoteI);
 }
 return i;
}
//end of fat arrow utility

//validation of test cases
(function () {
 var tests = document.querySelectorAll('.test');
 var currentExpansionNode = null;
 var currentLogNode = null;
 for(var i = 0; i < tests.length; i++) {
  var currentNode = tests[i];
  addTitle("Test " + (i+1), currentNode);
  createExpansionAndLogNode(currentNode);
  
  var testCode = currentNode.innerText;
  var expandedCode = expandFatArrow(testCode);

  logDom(expandedCode, 'expanded');
  
  eval(expandedCode);
  
 };
 function createExpansionAndLogNode(node) {
  var expansionNode = document.createElement('pre');
  expansionNode.classList.add('expanded');
  currentExpansionNode = expansionNode;
  
  var logNode = document.createElement('div');
  logNode.classList.add('log');
  currentLogNode = logNode;
  
  appendAfter(node,expansionNode);
  addTitle("Expansion Result", expansionNode);
  appendAfter(expansionNode, logNode);
  addTitle("Output", logNode);
 }
 function appendAfter(afterNode, newNode) {
  afterNode.parentNode.insertBefore(newNode, afterNode.nextSibling);
 }

 //logs to expansion node or log node
 function logDom(str, cssClass) {
  console.log(str);
  var node = null;
  if(cssClass === 'expanded') {
   node = currentExpansionNode;
  } else {
   node = currentLogNode;
  }
  
  var newNode = document.createElement("pre");
  
  newNode.innerText = str;
  node.appendChild(newNode);
 }
 function addTitle(title, onNode) {
  var titleNode = document.createElement('h3');
  titleNode.innerText = title;
  onNode.parentNode.insertBefore(titleNode, onNode);
 }
})();
pre {
 padding: 5px;
}
* {
 margin: 2px;
}
.test-unit{
 border: 2px solid black;
 padding: 5px;
}
.test{
 border: 1px solid gray;
 background-color: #eef;
 margin-top: 5px;
}
.expanded{
 border: 1px solid gray;
 background-color: #ffe;
}
.log{
 border: 1px solid gray;
 background-color: #ddd;
}
.error {
 border: 1px solid gray;
 background-color: #fff;
 color: red;
}
<html>
 <head>
  <link rel='stylesheet' href='style.css'>
 </head>
 <body>
<div class='test-unit'>
<pre class='test'>
 //skip braces in string, with curly braces
 var fun = ()=> {
  return "test me {{{{{{} {{{}";
 };
 logDom( fun());
 var fun1 = ()=> logDom('test1: ' + 'test me again{ { {}{{ }}}}}}}}}}}}}}');
 fun1();
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 var selectId = new Function('x', 'return x.map(a=>a.id)');;
 var mappedArr = selectId([{id:'test'},{id:'test1'}]);
 console.log("test2: " + JSON.stringify(mappedArr));
 logDom("test2: " + JSON.stringify(mappedArr), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //with surrounding code
 var numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9];
 var es6OddNumbers = numbers.filter(number => number % 2);
 logDom("test3 : " + es6OddNumbers, 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //standalone fat arrow
 var square = x => x * x;
 logDom("test4: " + square(10), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //with mutiple parameters, single line
 var add = (a, b) => a + b;
 logDom("test5: " + add(3, 4), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //test with surrounding like test1
 var developers = [{name: 'Rob'}, {name: 'Jake'}];
 var es6Output = developers.map(developer => developer.name);
 logDom("test6: " + es6Output, 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //empty braces, returns undefined
 logDom("test7: " + ( ()=>{} )(), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //return empty object
 logDom("test8: " + ( ()=>{return {}} )(), 'log');
</pre>
</div>

<div class='test-unit'>
<pre class='test'>
 //working with the 'this' scope and multiline
 function CounterES6() {
   this.seconds = 0;
   var intervalCounter = 0;
   var intervalId = null;
   intervalId = window.setInterval(() => {
   this.seconds++;
   logDom("test9: interval seconds: " + this.seconds, 'log');
   if(++intervalCounter > 9) {
    clearInterval(intervalId);
    logDom("Clearing interval", 'log');
   }
  }, 1000);
 }

 var counterB = new CounterES6();
 window.setTimeout(() => {
  var seconds = counterB.seconds;
  logDom("test9:   timeout seconds: " +counterB.seconds, 'log');
 }, 1200);
</pre>
</div>
  
 </body>
</html>
11thdimension
  • 10,333
  • 4
  • 33
  • 71
  • 1
    Cool. It works. It's exactly what I asked for. I see only one potential problem and that's if braces are found inside strings. I guess brace counting should be disabled inside strings, but that's something I can hack on my own. – Marko Apr 16 '16 at 12:16
  • You're right, I didn't think of that. I'll try to change it. – 11thdimension Apr 16 '16 at 18:18
  • @Marko Updated the code to handle curly braces in the quoted strings. – 11thdimension Apr 16 '16 at 22:04
0

I searched the web with the same question as the OP's when I stumbled upon this post. However, with pure luck, I accidentally executed a lambda expression using eval, and anno 2019, it just works! I tested latest Chrome & Edge, which is good enough for me.

Here is what I did:

var lambda = '(a, b) => a + b';
var fun = eval(lambda);
var sum = fun(40, 2);

document.write(`Sum: ${sum}`);
Sipke Schoorstra
  • 3,074
  • 1
  • 20
  • 28