Skip to content

Commit

Permalink
Merge pull request #156 from eemeli/lax-number
Browse files Browse the repository at this point in the history
Throw fewer runtime errors for `#`
  • Loading branch information
eemeli committed Sep 1, 2016
2 parents f332d63 + 1fa9b93 commit dd9c8d5
Show file tree
Hide file tree
Showing 5 changed files with 137 additions and 92 deletions.
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ CHK=${GREEN} ✓${STOP}
ERR=${RED} ✖${STOP}

BIN=./node_modules/.bin
SRC=lib/index.js lib/compiler.js
SRC=lib/index.js lib/compiler.js lib/runtime.js

.PHONY: all test test-browser doc release clean

Expand All @@ -35,7 +35,7 @@ test-browser: messageformat.js

doc: doc/index.html

doc/index.html: lib/index.js lib/compiler.js | node_modules
doc/index.html: $(SRC) | node_modules
@${BIN}/jsdoc -c doc/jsdoc-conf.json
@git apply doc/jsdoc-fix-fonts.patch
@rm -r doc/fonts
Expand Down
2 changes: 1 addition & 1 deletion doc/jsdoc-conf.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"source": { "include": [ "lib/index.js", "lib/compiler.js" ] },
"source": { "include": [ "lib/" ] },
"plugins": [ "plugins/markdown" ],
"opts": { "destination": "./doc/" }
}
90 changes: 7 additions & 83 deletions lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

var Compiler = require('./compiler');
var Runtime = require('./runtime');


/** Utility getter/wrapper for pluralization functions from
Expand Down Expand Up @@ -65,6 +66,7 @@ function MessageFormat(locale) {
}
}
this.fmt = {};
this.runtime = new Runtime(this);
}


Expand Down Expand Up @@ -199,86 +201,6 @@ MessageFormat.formatters = {
};


/** A set of utility functions that are called by the compiled Javascript
* functions, these are included locally in the output of {@link
* MessageFormat#compile compile()}.
*
* @namespace
*/
MessageFormat.runtime = {


/** Utility function for `#` in plural rules
*
* @param {number} value - The value to operate on
* @param {string} argumentName - The name of the argument containing `value`
* @param {number} [offset=0] - An optional offset, set by the surrounding context
*/
number: function(value, argumentName, offset) {
if (isNaN(value)) throw new Error("'" + value + "' from argument '" + argumentName + "' isn't a number.");
return value - (offset || 0);
},


/** Utility function for `{N, plural|selectordinal, ...}`
*
* @param {number} value - The key to use to find a pluralization rule
* @param {number} offset - An offset to apply to `value`
* @param {function} lcfunc - A locale function from `pluralFuncs`
* @param {Object.<string,string>} data - The object from which results are looked up
* @param {?boolean} isOrdinal - If true, use ordinal rather than cardinal rules
* @returns {string} The result of the pluralization
*/
plural: function(value, offset, lcfunc, data, isOrdinal) {
if ({}.hasOwnProperty.call(data, value)) return data[value];
if (offset) value -= offset;
var key = lcfunc(value, isOrdinal);
if (key in data) return data[key];
return data.other;
},


/** Utility function for `{N, select, ...}`
*
* @param {number} value - The key to use to find a selection
* @param {Object.<string,string>} data - The object from which results are looked up
* @returns {string} The result of the select statement
*/
select: function(value, data) {
if ({}.hasOwnProperty.call(data, value)) return data[value];
return data.other;
},


/** @private */
toString: function(pluralFuncs, fmt, compiler) {
function _stringify(o, level) {
if (typeof o != 'object') {
var funcStr = o.toString().replace(/^(function )\w*/, '$1');
var indent = /([ \t]*)\S.*$/.exec(funcStr);
return indent ? funcStr.replace(new RegExp('^' + indent[1], 'mg'), '') : funcStr;
}
var s = [];
for (var i in o) {
if (level == 0) s.push('var ' + i + ' = ' + _stringify(o[i], level + 1) + ';\n');
else s.push(Compiler.propname(i) + ': ' + _stringify(o[i], level + 1));
}
if (level == 0) return s.join('');
if (s.length == 0) return '{}';
var indent = ' '; while (--level) indent += ' ';
return '{\n' + s.join(',\n').replace(/^/gm, indent) + '\n}';
}

var obj = {};
Object.keys(compiler.locales).forEach(function(lc) { obj[Compiler.funcname(lc)] = pluralFuncs[lc]; });
Object.keys(compiler.runtime).forEach(function(fn) { obj[fn] = MessageFormat.runtime[fn]; });
var fmtKeys = Object.keys(compiler.formatters);
if (fmtKeys.length) obj.fmt = fmtKeys.reduce(function(o, key) { o[key] = fmt[key]; return o; }, {});
return _stringify(obj, 0);
}
};


/** Add custom formatter functions to this MessageFormat instance
*
* The general syntax for calling a formatting function in MessageFormat is
Expand Down Expand Up @@ -405,7 +327,8 @@ MessageFormat.prototype.setIntlSupport = function(enable) {
* replace `#` signs with the value of the nearest surrounding `plural` or
* `selectordinal` statement.
*
* Set this to true to follow the stricter ICU MessageFormat spec.
* Set this to true to follow the stricter ICU MessageFormat spec, and to
* throw a runtime error if `#` is used with non-numeric input.
*
* @memberof MessageFormat
* @param {boolean} [enable=true]
Expand All @@ -428,6 +351,7 @@ MessageFormat.prototype.setIntlSupport = function(enable) {
*/
MessageFormat.prototype.setStrictNumberSign = function(enable) {
this.strictNumberSign = !!enable || (typeof enable == 'undefined');
this.runtime.setStrictNumber(this.strictNumberSign);
return this;
};

Expand Down Expand Up @@ -534,11 +458,11 @@ MessageFormat.prototype.compile = function(messages, locale) {
var fn = new Function(
'number, plural, select, fmt', Compiler.funcname(locale),
'return ' + obj);
var rt = MessageFormat.runtime;
var rt = this.runtime;
return fn(rt.number, rt.plural, rt.select, this.fmt, pf[locale]);
}

var rtStr = MessageFormat.runtime.toString(pf, this.fmt, compiler) + '\n';
var rtStr = this.runtime.toString(pf, compiler) + '\n';
var objStr = _stringify(obj);
var result = new Function(rtStr + 'return ' + objStr)();
if (result.hasOwnProperty('toString')) throw new Error('The top-level message key `toString` is reserved');
Expand Down
118 changes: 118 additions & 0 deletions lib/runtime.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
var Compiler = require('./compiler');


/** A set of utility functions that are called by the compiled Javascript
* functions, these are included locally in the output of {@link
* MessageFormat#compile compile()}.
*
* @class
* @param {MessageFormat} mf - A MessageFormat instance
*/
function Runtime(mf) {
this.mf = mf;
this.setStrictNumber(mf.strictNumberSign);
}

module.exports = Runtime;


/** Utility function for `#` in plural rules
*
* Will throw an Error if `value` has a non-numeric value and `offset` is
* non-zero or {@link MessageFormat#setStrictNumberSign} is set.
*
* @function Runtime#number
* @param {number} value - The value to operate on
* @param {string} name - The name of the argument, used for error reporting
* @param {number} [offset=0] - An optional offset, set by the surrounding context
* @returns {number|string} The result of applying the offset to the input value
*/
function defaultNumber(value, name, offset) {
if (!offset) return value;
if (isNaN(value)) throw new Error('Can\'t apply offset:' + offset + ' to argument `' + name +
'` with non-numerical value ' + JSON.stringify(value) + '.');
return value - offset;
}


/** @private */
function strictNumber(value, name, offset) {
if (isNaN(value)) throw new Error('Argument `' + name + '` has non-numerical value ' + JSON.stringify(value) + '.');
return value - (offset || 0);
}


/** Set how strictly the {@link number} method parses its input.
*
* According to the ICU MessageFormat spec, `#` can only be used to replace a
* number input of a `plural` statement. By default, messageformat.js does not
* throw a runtime error if you use non-numeric argument with a `plural` rule,
* unless rule also includes a non-zero `offset`.
*
* This is called by {@link MessageFormat#setStrictNumberSign} to follow the
* stricter ICU MessageFormat spec.
*
* @param {boolean} [enable=false]
*/
Runtime.prototype.setStrictNumber = function(enable) {
this.number = enable ? strictNumber : defaultNumber;
}


/** Utility function for `{N, plural|selectordinal, ...}`
*
* @param {number} value - The key to use to find a pluralization rule
* @param {number} offset - An offset to apply to `value`
* @param {function} lcfunc - A locale function from `pluralFuncs`
* @param {Object.<string,string>} data - The object from which results are looked up
* @param {?boolean} isOrdinal - If true, use ordinal rather than cardinal rules
* @returns {string} The result of the pluralization
*/
Runtime.prototype.plural = function(value, offset, lcfunc, data, isOrdinal) {
if ({}.hasOwnProperty.call(data, value)) return data[value];
if (offset) value -= offset;
var key = lcfunc(value, isOrdinal);
if (key in data) return data[key];
return data.other;
}


/** Utility function for `{N, select, ...}`
*
* @param {number} value - The key to use to find a selection
* @param {Object.<string,string>} data - The object from which results are looked up
* @returns {string} The result of the select statement
*/
Runtime.prototype.select = function(value, data) {
if ({}.hasOwnProperty.call(data, value)) return data[value];
return data.other;
}


/** @private */
Runtime.prototype.toString = function(pluralFuncs, compiler) {
function _stringify(o, level) {
if (typeof o != 'object') {
var funcStr = o.toString().replace(/^(function )\w*/, '$1');
var indent = /([ \t]*)\S.*$/.exec(funcStr);
return indent ? funcStr.replace(new RegExp('^' + indent[1], 'mg'), '') : funcStr;
}
var s = [];
for (var i in o) {
if (level == 0) s.push('var ' + i + ' = ' + _stringify(o[i], level + 1) + ';\n');
else s.push(Compiler.propname(i) + ': ' + _stringify(o[i], level + 1));
}
if (level == 0) return s.join('');
if (s.length == 0) return '{}';
var indent = ' '; while (--level) indent += ' ';
return '{\n' + s.join(',\n').replace(/^/gm, indent) + '\n}';
}

var obj = {};
Object.keys(compiler.locales).forEach(function(lc) { obj[Compiler.funcname(lc)] = pluralFuncs[lc]; });
Object.keys(compiler.runtime).forEach(function(fn) { obj[fn] = this[fn]; }, this);
var fmtKeys = Object.keys(compiler.formatters);
var fmt = this.mf.fmt;
if (fmtKeys.length) obj.fmt = fmtKeys.reduce(function(o, key) { o[key] = fmt[key]; return o; }, {});
return _stringify(obj, 0);
}
15 changes: 9 additions & 6 deletions test/messageformat.js
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,12 @@ describe("Basic Message Formatting", function() {

it("should have configurable # parsing support", function() {
var mf = new MessageFormat('en');
var msg = '{X, plural, other{{Y, select, other{#}}}}';
var msg = '{X, plural, one{#} other{{Y, select, other{#}}}}';
expect(mf.compile(msg)({ X: 3, Y: 5 })).to.eql('3');
expect(mf.compile(msg)({ X: 'x' })).to.eql('x');
mf.setStrictNumberSign(true);
expect(mf.compile(msg)({ X: 3, Y: 5 })).to.eql('#');
expect(function() { mf.compile(msg)({ X: 'x' }); }).to.throwError(/\bX\b.*non-numerical value/);
});

it("obeys plural functions", function() {
Expand Down Expand Up @@ -286,12 +288,13 @@ describe("Basic Message Formatting", function() {
it("should reject number injections of numbers that don't exist", function() {
var mf = new MessageFormat('en');
var mfunc = mf.compile(
"I have {FRIENDS, plural, one{one friend} other{# friends but {ENEMIES, plural, one{one enemy} other{# enemies}}}}."
"I have {FRIENDS, plural, one{one friend} other{# friends but {ENEMIES, plural, offset:1 " +
"=0{no enemies} =1{one nemesis} one{two enemies} other{one nemesis and # enemies}}}}."
);
expect(mfunc({FRIENDS:0, ENEMIES: 0})).to.eql("I have 0 friends but 0 enemies.");
expect(function(){ var x = mfunc({FRIENDS:0,ENEMIES:'none'}); }).to.throwError(/\'ENEMIES\' isn't a number\.$/);
expect(function(){ var x = mfunc({}); }).to.throwError(/\'.+\' isn't a number\.$/);
expect(function(){ var x = mfunc({ENEMIES:0}); }).to.throwError(/\'FRIENDS\' isn't a number\.$/);
expect(mfunc({FRIENDS:0, ENEMIES: 0})).to.eql("I have 0 friends but no enemies.");
expect(function(){ var x = mfunc({}); }).to.throwError(/\bENEMIES\b.*non-numerical value/);
expect(function(){ var x = mfunc({FRIENDS:0}); }).to.throwError(/\bENEMIES\b.*non-numerical value/);
expect(mfunc({ENEMIES:1})).to.eql('I have undefined friends but one nemesis.');
});

it("should not expose prototype members - selects", function() {
Expand Down

0 comments on commit dd9c8d5

Please sign in to comment.