Bladeren bron

expression parser & test

Evan You 11 jaren geleden
bovenliggende
commit
af194e48bb
3 gewijzigde bestanden met toevoegingen van 254 en 1 verwijderingen
  1. 1 1
      package.json
  2. 155 0
      src/parse/expression.js
  3. 98 0
      test/unit/expression_spec.js

+ 1 - 1
package.json

@@ -18,7 +18,7 @@
   "homepage": "http://vuejs.org",
   "homepage": "http://vuejs.org",
   "scripts": {
   "scripts": {
     "test": "grunt ci",
     "test": "grunt ci",
-    "jasmine": "jasmine-node test/unit/ --verbose"
+    "jasmine": "jasmine-node test/unit/"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "browserify": "^4.2.0",
     "browserify": "^4.2.0",

+ 155 - 0
src/parse/expression.js

@@ -0,0 +1,155 @@
+var _ = require('../util')
+var Cache = require('../cache')
+var expressionCache = new Cache(1000)
+
+function noop () {}
+
+/**
+ * Extract all accessor paths from an expression.
+ *
+ * @param {String} code
+ * @return {Array} - extracted paths
+ */
+
+// remove strings and object literal keys that could contain arbitrary chars
+var PREPARE_RE = /'[^']*'|"[^"]*"|[\{,]\s*[\w\$_]+\s*:/g
+// turn anything that is not valid path char into commas
+var CONVERT_RE = /[^\w$\.]+/g
+// remove keywords & number literals
+var KEYWORDS = 'Math,break,case,catch,continue,debugger,default,delete,do,else,false,finally,for,function,if,in,instanceof,new,null,return,switch,this,throw,true,try,typeof,var,void,while,with,undefined,abstract,boolean,byte,char,class,const,double,enum,export,extends,final,float,goto,implements,import,int,interface,long,native,package,private,protected,public,short,static,super,synchronized,throws,transient,volatile,arguments,let,yield'
+var KEYWORDS_RE = new RegExp('\\b' + KEYWORDS.replace(/,/g, '\\b|\\b') + '\\b|\\b\\d[^,]*', 'g')
+// remove trailing commas
+var COMMA_RE = /^,+|,+$/
+// split by commas
+var SPLIT_RE = /,+/
+
+function extractPaths (code) {
+  code = code
+    .replace(PREPARE_RE, ',')
+    .replace(CONVERT_RE, ',')
+    .replace(KEYWORDS_RE, '')
+    .replace(COMMA_RE, '')
+  return code
+    ? code.split(SPLIT_RE)
+    : []
+}
+
+/**
+ * Escape leading dollar signs from paths for regex construction.
+ * Otherwise it can be mistaken as the linestart token.
+ *
+ * @param {String} path
+ * @return {String}
+ */
+
+function escapeDollar (path) {
+  return path.charAt(0) === '$'
+    ? '\\' + path
+    : path
+}
+
+/**
+ * Save / Rewrite / Restore
+ *
+ * When rewriting paths found in an expression, it is possible
+ * for the same letter sequences to be found in strings and Object
+ * literal property keys. Therefore we remove and store these
+ * parts in a temporary array, and restore them after the path
+ * rewrite.
+ */
+
+var saved = []
+var NEWLINE_RE = /\n/g
+var RESTORE_RE = /<%(\d+)%>/g
+
+/**
+ * Save replacer
+ *
+ * @param {String} str
+ * @return {String} - placeholder with index
+ */
+
+function save (str) {
+  var i = saved.length
+  saved[i] = str.replace(NEWLINE_RE, '\\n')
+  return '<%' + i + '%>'
+}
+
+/**
+ * Path rewrite replacer
+ *
+ * @param {String} path
+ * @return {String}
+ */
+
+function rewrite (path) {
+  return path.charAt(0) + 'scope.' + path.slice(1)
+}
+
+/**
+ * Restore replacer
+ *
+ * @param {String} str
+ * @param {String} i - matched save index
+ * @return {String}
+ */
+
+function restore (str, i) {
+  return saved[i]
+}
+
+/**
+ * Build a getter function. Requires eval.
+ * We isolate the try/catch so it doesn't affect the optimization
+ * of the parse function when it is not called.
+ *
+ * @param {String} body
+ * @return {Function|undefined}
+ */
+
+function build (body) {
+  try {
+    return new Function('scope', body)
+  } catch (e) {}
+}
+
+/**
+ * Parse an expression and rewrite into a getter function
+ *
+ * @param {String} code
+ * @return {Function}
+ */
+
+exports.parse = function (code) {
+  // try cache
+  var hit = expressionCache.get(code)
+  if (hit) {
+    return hit
+  }
+  // extract paths
+  var paths = extractPaths(code)
+  var body = 'return ' + code + ';'
+  // rewrite paths
+  if (paths.length) {
+    var pathRE = new RegExp(
+      '[^$\\w\\.](' +
+      paths.map(escapeDollar).join('|') +
+      ')[^$\\w\\.]',
+      'g'
+    )
+    saved.length = 0
+    body = body
+      .replace(PREPARE_RE, save)
+      .replace(pathRE, rewrite)
+      .replace(RESTORE_RE, restore)
+  }
+  // generate function
+  var fn = build(body)
+  if (fn) {
+    expressionCache.put(code, fn)
+  } else {
+    _.warn('Invalid expression: "' + code + '"\nGenerated function body: ' + body)
+    console.log(paths)
+  }
+  return fn || noop
+}

+ 98 - 0
test/unit/expression_spec.js

@@ -0,0 +1,98 @@
+var expParser = require('../../src/parse/expression')
+
+function assertExp (testCase) {
+  var fn = expParser.parse(testCase.exp)
+  expect(fn(testCase.scope)).toEqual(testCase.expected)
+}
+
+var testCases = [
+  {
+    // string concat
+    exp: 'a + b',
+    scope: {
+      a: 'hello',
+      b: 'world'
+    },
+    expected: 'helloworld'
+  },
+  {
+    // math
+    exp: 'a - b * 2 + 45',
+    scope: {
+      a: 100,
+      b: 23
+    },
+    expected: 100 - 23 * 2 + 45
+  },
+  {
+    // boolean logic
+    exp: '(a && b) ? c : d || e',
+    scope: {
+      a: true,
+      b: false,
+      c: null,
+      d: false,
+      e: 'worked'
+    },
+    expected: 'worked'
+  },
+  {
+    // inline string
+    exp: "a + 'hello'",
+    scope: {
+      a: 'inline '
+    },
+    expected: 'inline hello'
+  },
+  {
+    // complex with nested values
+    exp: "todo.title + ' : ' + (todo.done ? 'yep' : 'nope')",
+    scope: {
+      todo: {
+        title: 'write tests',
+        done: false
+      }
+    },
+    expected: 'write tests : nope'
+  },
+  {
+    // expression with no data variables
+    exp: "'a' + 'b'",
+    scope: {},
+    expected: 'ab'
+  },
+  {
+    // values with same variable name inside strings
+    exp: "'\"test\"' + test + \"'hi'\" + hi",
+    scope: {
+      test: 1,
+      hi: 2
+    },
+    expected: '"test"1\'hi\'2'
+  },
+  {
+    // expressions with inline object literals
+    exp: "sortRows({ column: 'name', test: haha, durrr: 123 })",
+    scope: {
+      sortRows: function (params) {
+        return params.column + params.test + params.durrr
+      },
+      haha: 'hoho'
+    },
+    expected: 'namehoho123'
+  }
+]
+
+describe('Expression Parser', function () {
+  
+  it('parse', function () {
+    testCases.forEach(assertExp)
+  })
+
+  it('cache', function () {
+    var fn1 = expParser.parse('a + b')
+    var fn2 = expParser.parse('a + b')
+    expect(fn1).toBe(fn2)
+  })
+
+})