Skip to content

Commit

Permalink
Replace ko.expressionRewriting.parseObjectLiteral with a parser that'…
Browse files Browse the repository at this point in the history
…s three times as fast and 2/3 the size (over 400 bytes smaller, minified).

It also eliminates extra white-space in the string, requiring fewer 'stringTrim' calls, and strips the quotes from keys so they're consistent.
The spec changes are about the removed white-space and quotes, and a new spec shows how removing spaces and quotes fixes a problem.
Also includes an improved stringTrim.
  • Loading branch information
mbest committed Apr 5, 2013
1 parent 7fbb70c commit 992478a
Show file tree
Hide file tree
Showing 3 changed files with 92 additions and 141 deletions.
59 changes: 31 additions & 28 deletions spec/expressionRewritingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,62 +5,65 @@ describe('Expression Rewriting', function() {
var result = ko.expressionRewriting.parseObjectLiteral("a: 1, b: 2, \"quotedKey\": 3, 'aposQuotedKey': 4");
expect(result.length).toEqual(4);
expect(result[0].key).toEqual("a");
expect(result[0].value).toEqual(" 1");
expect(result[1].key).toEqual(" b");
expect(result[1].value).toEqual(" 2");
expect(result[2].key).toEqual(" \"quotedKey\"");
expect(result[2].value).toEqual(" 3");
expect(result[3].key).toEqual(" 'aposQuotedKey'");
expect(result[3].value).toEqual(" 4");
expect(result[0].value).toEqual("1");
expect(result[1].key).toEqual("b");
expect(result[1].value).toEqual("2");
expect(result[2].key).toEqual("quotedKey");
expect(result[2].value).toEqual("3");
expect(result[3].key).toEqual("aposQuotedKey");
expect(result[3].value).toEqual("4");
});

it('Should ignore any outer braces', function() {
var result = ko.expressionRewriting.parseObjectLiteral("{a: 1}");
expect(result.length).toEqual(1);
expect(result[0].key).toEqual("a");
expect(result[0].value).toEqual(" 1");
expect(result[0].value).toEqual("1");
});

it('Should be able to parse object literals containing string literals', function() {
var result = ko.expressionRewriting.parseObjectLiteral("a: \"comma, colon: brace{ bracket[ apos' escapedQuot\\\" end\", b: 'escapedApos\\\' brace} bracket] quot\"'");
expect(result.length).toEqual(2);
expect(result[0].key).toEqual("a");
expect(result[0].value).toEqual(" \"comma, colon: brace{ bracket[ apos' escapedQuot\\\" end\"");
expect(result[1].key).toEqual(" b");
expect(result[1].value).toEqual(" 'escapedApos\\\' brace} bracket] quot\"'");
expect(result[0].value).toEqual("\"comma, colon: brace{ bracket[ apos' escapedQuot\\\" end\"");
expect(result[1].key).toEqual("b");
expect(result[1].value).toEqual("'escapedApos\\\' brace} bracket] quot\"'");
});

it('Should be able to parse object literals containing child objects, arrays, function literals, and newlines', function() {
// The parsing may or may not keep unnecessary spaces. So to avoid confusion, avoid unnecessary spaces.
var result = ko.expressionRewriting.parseObjectLiteral(
"myObject : { someChild: { }, someChildArray: [1,2,3], \"quotedChildProp\": 'string value' },\n"
+ "someFn: function(a, b, c) { var regex = /}/; var str='/})({'; return {}; },"
+ "myArray : [{}, function() { }, \"my'Str\", 'my\"Str']"
"myObject:{someChild:{},someChildArray:[1,2,3],\"quotedChildProp\":'string value'},\n"
+ "someFn:function(a,b,c){var regex=/}/;var str='/})({';return{};},"
+ "myArray:[{},function(){},\"my'Str\",'my\"Str']"
);
expect(result.length).toEqual(3);
expect(result[0].key).toEqual("myObject ");
expect(result[0].value).toEqual(" { someChild: { }, someChildArray: [1,2,3], \"quotedChildProp\": 'string value' }");
expect(result[1].key).toEqual("\nsomeFn");
expect(result[1].value).toEqual(" function(a, b, c) { var regex = /}/; var str='/})({'; return {}; }");
expect(result[2].key).toEqual("myArray ");
expect(result[2].value).toEqual(" [{}, function() { }, \"my'Str\", 'my\"Str']");
expect(result[0].key).toEqual("myObject");
expect(result[0].value).toEqual("{someChild:{},someChildArray:[1,2,3],\"quotedChildProp\":'string value'}");
expect(result[1].key).toEqual("someFn");
expect(result[1].value).toEqual("function(a,b,c){var regex=/}/;var str='/})({';return{};}");
expect(result[2].key).toEqual("myArray");
expect(result[2].value).toEqual("[{},function(){},\"my'Str\",'my\"Str']");
});

it('Should be able to cope with malformed syntax (things that aren\'t key-value pairs)', function() {
var result = ko.expressionRewriting.parseObjectLiteral("malformed1, 'mal:formed2', good:3, { malformed: 4 }");
expect(result.length).toEqual(4);
var result = ko.expressionRewriting.parseObjectLiteral("malformed1, 'mal:formed2', good:3, { malformed: 4 }, good5:5");
expect(result.length).toEqual(5);
expect(result[0].unknown).toEqual("malformed1");
expect(result[1].unknown).toEqual(" 'mal:formed2'");
expect(result[2].key).toEqual(" good");
expect(result[1].unknown).toEqual("mal:formed2");
expect(result[2].key).toEqual("good");
expect(result[2].value).toEqual("3");
expect(result[3].unknown).toEqual(" { malformed: 4 }");
expect(result[4].key).toEqual("good5");
expect(result[4].value).toEqual("5");
// There's not really a good 'should' value for "{ malformed: 4 }", so don't check
});

it('Should ensure all keys are wrapped in quotes', function() {
var rewritten = ko.expressionRewriting.preProcessBindings("a: 1, 'b': 2, \"c\": 3");
expect(rewritten).toEqual("'a': 1, 'b': 2, \"c\": 3");
expect(rewritten).toEqual("'a':1,'b':2,'c':3");
});

it('Should convert JSON values to property accessors', function () {
it('Should convert writable values to property accessors', function () {
var rewritten = ko.expressionRewriting.preProcessBindings(
'a : 1, b : firstName, c : function() { return "returnValue"; }, ' +
'd: firstName+lastName, e: boss.firstName, f: boss . lastName, ' +
Expand Down Expand Up @@ -111,7 +114,7 @@ describe('Expression Rewriting', function() {

it('Should be able to eval rewritten literals that contain unquoted keywords as keys', function() {
var rewritten = ko.expressionRewriting.preProcessBindings("if: true");
expect(rewritten).toEqual("'if': true");
expect(rewritten).toEqual("'if':true");
var evaluated = eval("({" + rewritten + "})");
expect(evaluated['if']).toEqual(true);
});
Expand Down
6 changes: 3 additions & 3 deletions spec/templatingBehaviors.js
Original file line number Diff line number Diff line change
Expand Up @@ -818,8 +818,8 @@ describe('Templating', function() {
});

it('Should not be allowed to rewrite templates that embed control flow bindings', function() {
// Same reason as above
ko.utils.arrayForEach(['if', 'ifnot', 'with', 'foreach'], function(bindingName) {
// Same reason as above (also include binding names with quotes and spaces to show that formatting doesn't matter)
ko.utils.arrayForEach(['if', 'ifnot', 'with', 'foreach', '"if"', ' with '], function(bindingName) {
ko.setTemplateEngine(new dummyTemplateEngine({ myTemplate: "<div data-bind='" + bindingName + ": \"SomeValue\"'>Hello</div>" }));
testNode.innerHTML = "<div data-bind='template: { name: \"myTemplate\" }'></div>";

Expand All @@ -828,7 +828,7 @@ describe('Templating', function() {
try { ko.applyBindings({ someData: { childProp: 'abc' } }, testNode) }
catch (ex) {
didThrow = true;
expect(ex.message).toEqual("This template engine does not support the '" + bindingName + "' binding within its templates");
expect(ex.message).toMatch("This template engine does not support");
}
if (!didThrow)
throw new Error("Did not prevent use of " + bindingName);
Expand Down
168 changes: 58 additions & 110 deletions src/binding/expressionRewriting.js
Original file line number Diff line number Diff line change
@@ -1,128 +1,76 @@
ko.expressionRewriting = (function () {
var restoreCapturedTokensRegex = /\@ko_token_(\d+)\@/g;
var javaScriptReservedWords = ["true", "false", "null", "undefined"];

// Matches something that can be assigned to--either an isolated identifier or something ending with a property accessor
// This is designed to be simple and avoid false negatives, but could produce false positives (e.g., a+b.c).
var javaScriptAssignmentTarget = /^(?:[$_a-z][$\w]*|(.+)(\.\s*[$_a-z][$\w]*|\[.+\]))$/i;

function restoreTokens(string, tokens) {
var prevValue = null;
while (string != prevValue) { // Keep restoring tokens until it no longer makes a difference (they may be nested)
prevValue = string;
string = string.replace(restoreCapturedTokensRegex, function (match, tokenIndex) {
return tokens[tokenIndex];
});
}
return string;
}

function getWriteableValue(expression) {
if (ko.utils.arrayIndexOf(javaScriptReservedWords, ko.utils.stringTrim(expression).toLowerCase()) >= 0)
if (ko.utils.arrayIndexOf(javaScriptReservedWords, expression) >= 0)
return false;
var match = expression.match(javaScriptAssignmentTarget);
return match === null ? false : match[1] ? ('Object(' + match[1] + ')' + match[2]) : expression;
}

function ensureQuoted(key) {
var trimmedKey = ko.utils.stringTrim(key);
switch (trimmedKey.length && trimmedKey.charAt(0)) {
case "'":
case '"':
return key;
default:
return "'" + trimmedKey + "'";
var stringDouble = '(?:"(?:[^"\\\\]|\\\\.)*")',
stringSingle = "(?:'(?:[^'\\\\]|\\\\.)*')",
stringRegexp = '(?:/(?:[^/\\\\]|\\\\.)*/)',
specials = ',"\'{}()/:[\\]',
everyThingElse = '(?:[^\\s:,][^' + specials + ']*[^\\s' + specials + '])',
oneNotSpace = '[^\\s]',
bindingToken = RegExp('(?:' + stringDouble + '|' + stringSingle + '|' + stringRegexp + '|' + everyThingElse + '|' + oneNotSpace + ')', 'g');

function parseObjectLiteral(objectLiteralString) {
// Trim leading and trailing spaces from the string
var str = ko.utils.stringTrim(objectLiteralString);

// Trim braces '{' surrounding the whole object literal
if (str.charCodeAt(0) === 123) str = str.slice(1, -1);

// Split into tokens
var result = [], toks = str.match(bindingToken), key, values, depth = 0;

if (toks) {
// Append a comma so that we don't need a separate code block to deal with the last item
toks.push(',');

for (var i = 0, n = toks.length; i < n; ++i) {
var tok = toks[i], c = tok.charCodeAt(0);
// A comma signals the end of a key/value pair if depth is zero
if (c === 44) { // ","
if (depth <= 0) {
if (key)
result.push(values ? {key: key, value: values.join('')} : {'unknown': key});
key = values = depth = 0;
continue;
}
// Simply skip the colon that separates the name and value
} else if (c === 58) { // ":"
if (!values)
continue;
// Increment depth for parentheses, braces, and brackets so that interior commas are ignored
} else if (c === 40 || c === 123 || c === 91) { // '(', '{', '['
++depth;
} else if (c === 41 || c === 125 || c === 93) { // ')', '}', ']'
--depth;
// The key must be a single token; if it's a string, trim the quotes
} else if (!key) {
key = (c === 34 || c === 39) /* '"', "'" */ ? tok.slice(1, -1) : tok;
continue;
}
if (values)
values.push(tok);
else
values = [tok];
}
}
return result;
}

return {
bindingRewriteValidators: [],

parseObjectLiteral: function(objectLiteralString) {
// A full tokeniser+lexer would add too much weight to this library, so here's a simple parser
// that is sufficient just to split an object literal string into a set of top-level key-value pairs

var str = ko.utils.stringTrim(objectLiteralString);
if (str.length < 3)
return [];
if (str.charAt(0) === "{")// Ignore any braces surrounding the whole object literal
str = str.substring(1, str.length - 1);

// Pull out any string literals and regex literals
var tokens = [];
var tokenStart = null, tokenEndChar;
for (var position = 0; position < str.length; position++) {
var c = str.charAt(position);
if (tokenStart === null) {
switch (c) {
case '"':
case "'":
case "/":
tokenStart = position;
tokenEndChar = c;
break;
}
} else if ((c == tokenEndChar) && (str.charAt(position - 1) !== "\\")) {
var token = str.substring(tokenStart, position + 1);
tokens.push(token);
var replacement = "@ko_token_" + (tokens.length - 1) + "@";
str = str.substring(0, tokenStart) + replacement + str.substring(position + 1);
position -= (token.length - replacement.length);
tokenStart = null;
}
}

// Next pull out balanced paren, brace, and bracket blocks
tokenStart = null;
tokenEndChar = null;
var tokenDepth = 0, tokenStartChar = null;
for (var position = 0; position < str.length; position++) {
var c = str.charAt(position);
if (tokenStart === null) {
switch (c) {
case "{": tokenStart = position; tokenStartChar = c;
tokenEndChar = "}";
break;
case "(": tokenStart = position; tokenStartChar = c;
tokenEndChar = ")";
break;
case "[": tokenStart = position; tokenStartChar = c;
tokenEndChar = "]";
break;
}
}

if (c === tokenStartChar)
tokenDepth++;
else if (c === tokenEndChar) {
tokenDepth--;
if (tokenDepth === 0) {
var token = str.substring(tokenStart, position + 1);
tokens.push(token);
var replacement = "@ko_token_" + (tokens.length - 1) + "@";
str = str.substring(0, tokenStart) + replacement + str.substring(position + 1);
position -= (token.length - replacement.length);
tokenStart = null;
}
}
}

// Now we can safely split on commas to get the key/value pairs
var result = [];
var keyValuePairs = str.split(",");
for (var i = 0, j = keyValuePairs.length; i < j; i++) {
var pair = keyValuePairs[i];
var colonPos = pair.indexOf(":");
if ((colonPos > 0) && (colonPos < pair.length - 1)) {
var key = pair.substring(0, colonPos);
var value = pair.substring(colonPos + 1);
result.push({ 'key': restoreTokens(key, tokens), 'value': restoreTokens(value, tokens) });
} else {
result.push({ 'unknown': restoreTokens(pair, tokens) });
}
}
return result;
},
parseObjectLiteral: parseObjectLiteral,

preProcessBindings: function (objectLiteralStringOrKeyValueArray) {
var keyValueArray = typeof objectLiteralStringOrKeyValueArray === "string"
Expand All @@ -136,12 +84,12 @@ ko.expressionRewriting = (function () {
resultStrings.push(",");

if (keyValueEntry['key']) {
var quotedKey = ensureQuoted(keyValueEntry['key']), val = keyValueEntry['value'];
var quotedKey = "'" + keyValueEntry['key'] + "'", val = keyValueEntry['value'];
resultStrings.push(quotedKey);
resultStrings.push(":");
resultStrings.push(val);

if (val = getWriteableValue(ko.utils.stringTrim(val))) {
if (val = getWriteableValue(val)) {
if (propertyAccessorResultStrings.length > 0)
propertyAccessorResultStrings.push(", ");
propertyAccessorResultStrings.push(quotedKey + " : function(__ko_value) { " + val + " = __ko_value; }");
Expand All @@ -162,7 +110,7 @@ ko.expressionRewriting = (function () {

keyValueArrayContainsKey: function(keyValueArray, key) {
for (var i = 0; i < keyValueArray.length; i++)
if (ko.utils.stringTrim(keyValueArray[i]['key']) == key)
if (keyValueArray[i]['key'] == key)
return true;
return false;
},
Expand Down Expand Up @@ -196,4 +144,4 @@ ko.exportSymbol('expressionRewriting.preProcessBindings', ko.expressionRewriting
// For backward compatibility, define the following aliases. (Previously, these function names were misleading because
// they referred to JSON specifically, even though they actually work with arbitrary JavaScript object literal expressions.)
ko.exportSymbol('jsonExpressionRewriting', ko.expressionRewriting);
ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.expressionRewriting.preProcessBindings);
ko.exportSymbol('jsonExpressionRewriting.insertPropertyAccessorsIntoJson', ko.expressionRewriting.preProcessBindings);

0 comments on commit 992478a

Please sign in to comment.