Skip to content
This repository was archived by the owner on Dec 17, 2018. It is now read-only.

Commit 9f8d311

Browse files
authored
Merge pull request #8 from messageformat/function-args
Allow MessageFormat content in function parameters + replace the `strictNumberSign` option with a broader `strict` option
2 parents 1286f9d + 35be885 commit 9f8d311

3 files changed

Lines changed: 159 additions & 67 deletions

File tree

README.md

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,28 @@ the following possible keys:
1313
`'zero', 'one', 'two', 'few', 'many', 'other'`. To disable this check, pass in
1414
an empty array.
1515

16-
- `strictNumberSign` – Inside a `plural` or `selectordinal` statement, a pound
17-
symbol (`#`) is replaced with the input number. By default, `#` is also parsed
18-
as a special character in nested statements too, and can be escaped using
19-
apostrophes (`'#'`). Setting `strictNumberSign` to true will make the parser
20-
follow the ICU MessageFormat spec more closely, and only parse `#` as a
21-
special character directly inside a `plural` or `selectordinal` statement.
22-
Outside those, `#` and `'#'` will be parsed as literal text.
23-
24-
The parser only supports the default `DOUBLE_OPTIONAL` [apostrophe mode]. A
25-
single apostrophe only starts quoted literal text if preceded by a curly brace
26-
(`{}`) or a pound symbol (`#`) inside a `plural` or `selectordinal` statement,
27-
depending on the value of `strictNumberSign`. Otherwise, it is a literal
28-
apostrophe. A double apostrophe is always a literal apostrophe.
16+
- `strict` – By default, the parsing applies a few relaxations to the ICU
17+
MessageFormat spec. Setting `strict: true` will disable these relaxations:
18+
- The `argType` of `simpleArg` formatting functions will be restricted to the
19+
set of `number`, `date`, `time`, `spellout`, `ordinal`, and `duration`,
20+
rather than accepting any lower-case identifier that does not start with a
21+
number.
22+
- The optional `argStyle` of `simpleArg` formatting functions will not be
23+
parsed as any other text, but instead as the spec requires: "In
24+
argStyleText, every single ASCII apostrophe begins and ends quoted literal
25+
text, and unquoted {curly braces} must occur in matched pairs."
26+
- Inside a `plural` or `selectordinal` statement, a pound symbol (`#`) is
27+
replaced with the input number. By default, `#` is also parsed as a special
28+
character in nested statements too, and can be escaped using apostrophes
29+
(`'#'`). In strict mode `#` will be parsed as a special character only
30+
directly inside a `plural` or `selectordinal` statement. Outside those, `#`
31+
and `'#'` will be parsed as literal text.
32+
33+
The parser only supports the default `DOUBLE_OPTIONAL` [apostrophe mode], in
34+
which a single apostrophe only starts quoted literal text if it immediately
35+
precedes a curly brace `{}`, or a pound symbol `#` if inside a plural format. A
36+
literal apostrophe `'` is represented by either a single `'` or a doubled `''`
37+
apostrophe character.
2938

3039
[ICU MessageFormat]: https://messageformat.github.io/guide/
3140
[messageformat]: https://messageformat.github.io/
@@ -130,7 +139,9 @@ type Function = {
130139
type: 'function',
131140
arg: Identifier,
132141
key: Identifier,
133-
param: string | null
142+
param: {
143+
tokens: options.strict ? [string] : (Token | Octothorpe)[]
144+
} | null
134145
}
135146

136147
type PluralCase = {
@@ -140,7 +151,7 @@ type PluralCase = {
140151

141152
type SelectCase = {
142153
key: Identifier,
143-
tokens: strictNumberSign ? Token[] : (Token | Octothorpe)[]
154+
tokens: options.strict ? Token[] : (Token | Octothorpe)[]
144155
}
145156

146157
type Octothorpe = {

parser.pegjs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ argument = '{' _ arg:id _ '}' {
1616
};
1717
}
1818

19-
select = '{' _ arg:id _ ',' _ (m:'select' { if (options.strictNumberSign) { inPlural = false; } return m; }) _ ',' _ cases:selectCase+ _ '}' {
19+
select = '{' _ arg:id _ ',' _ (m:'select' { if (options.strict) { inPlural = false; } return m; }) _ ',' _ cases:selectCase+ _ '}' {
2020
return {
2121
type: 'select',
2222
arg: arg,
@@ -42,7 +42,7 @@ plural = '{' _ arg:id _ ',' _ type:(m:('plural'/'selectordinal') { inPlural = tr
4242
};
4343
}
4444

45-
function = '{' _ arg:id _ ',' _ key:(m:id { if (options.strictNumberSign) { inPlural = false; } return m; }) _ param:functionParam? '}' {
45+
function = '{' _ arg:id _ ',' _ key:functionKey _ param:functionParam? '}' {
4646
return {
4747
type: 'function',
4848
arg: arg,
@@ -66,12 +66,21 @@ pluralKey
6666
= id
6767
/ '=' d:digits { return d; }
6868

69-
functionParam = _ ',' str:paramChars+ { return str.join(''); }
70-
71-
paramChars
72-
= doubleapos
73-
/ quotedCurly
74-
/ [^}]
69+
functionKey
70+
= 'number' / 'date' / 'time' / 'spellout' / 'ordinal' / 'duration'
71+
/ ! 'select' ! 'plural' ! 'selectordinal' key:id
72+
& { return !options.strict && key.toLowerCase() === key && !/^\d/.test(key) }
73+
{ return key }
74+
75+
functionParam
76+
= _ ',' tokens:token* & { return !options.strict } { return { tokens: tokens } }
77+
/ _ ',' parts:strictFunctionParamPart* { return { tokens: [parts.join('')] } }
78+
79+
strictFunctionParamPart
80+
= p:[^'{}]+ { return p.join('') }
81+
/ doubleapos
82+
/ "'" quoted:inapos "'" { return quoted }
83+
/ '{' p:strictFunctionParamPart* '}' { return '{' + p.join('') + '}' }
7584

7685
doubleapos = "''" { return "'"; }
7786

test.js

Lines changed: 116 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -230,28 +230,38 @@ describe("Plurals", function() {
230230
});
231231

232232
it("should support quoting", function() {
233-
expect(parse("{NUM, plural, one{{x,date,y-M-dd # '#'}} two{two}}")[0].cases[0].tokens[0].type).to.eql('function');
234-
expect(parse("{NUM, plural, one{{x,date,y-M-dd # '#'}} two{two}}")[0].cases[0].tokens[0].arg).to.eql('x');
235-
expect(parse("{NUM, plural, one{{x,date,y-M-dd # '#'}} two{two}}")[0].cases[0].tokens[0].key).to.eql('date');
236-
// Octothorpe is not special here regardless of strict number sign
237-
expect(parse("{NUM, plural, one{{x,date,y-M-dd # '#'}} two{two}}")[0].cases[0].tokens[0].param).to.eql("y-M-dd # '#'");
233+
expect(parse("{NUM, plural, one{{x,date,y-M-dd # '#'}} two{two}}")[0].cases[0].tokens).to.eql([{
234+
type: 'function', arg: 'x', key: 'date',
235+
param: {
236+
tokens: [ 'y-M-dd ', { type: 'octothorpe' }, ' #' ]
237+
}
238+
}]);
239+
expect(parse("{NUM, plural, one{# '' #} two{two}}")[0].cases[0].tokens).to.eql([
240+
{ type: 'octothorpe' }, " ' ", { type: 'octothorpe' }
241+
]);
242+
expect(parse("{NUM, plural, one{# '#'} two{two}}")[0].cases[0].tokens).to.eql([
243+
{ type: 'octothorpe' }, ' #'
244+
]);
245+
expect(parse("{NUM, plural, one{one#} two{two}}")[0].cases[0].tokens).to.eql([
246+
'one', { type: 'octothorpe' }
247+
]);
248+
})
238249

239-
expect(parse("{NUM, plural, one{# '' #} two{two}}")[0].cases[0].tokens[0].type).to.eql('octothorpe');
240-
expect(parse("{NUM, plural, one{# '' #} two{two}}")[0].cases[0].tokens[1]).to.eql(" ' ");
241-
expect(parse("{NUM, plural, one{# '' #} two{two}}")[0].cases[0].tokens[2].type).to.eql('octothorpe');
242-
expect(parse("{NUM, plural, one{# '#'} two{two}}")[0].cases[0].tokens[0].type).to.eql('octothorpe');
243-
expect(parse("{NUM, plural, one{# '#'} two{two}}")[0].cases[0].tokens[1]).to.eql(" #");
250+
describe('options.strict', function() {
251+
var src = "{NUM, plural, one{# {VAR,select,key{# '#' one#}}} two{two}}";
244252

245-
expect(parse("{NUM, plural, one{one#} two{two}}")[0].cases[0].tokens[0]).to.eql('one');
246-
expect(parse("{NUM, plural, one{one#} two{two}}")[0].cases[0].tokens[1].type).to.eql('octothorpe');
253+
it('should parse # correctly without strict option', function() {
254+
expect(parse(src)[0].cases[0].tokens[2].cases[0].tokens).to.eql([
255+
{ type: 'octothorpe' }, ' # one', { type: 'octothorpe' }
256+
]);
257+
})
247258

248-
// without strict number sign
249-
expect(parse("{NUM, plural, one{# {VAR,select,key{# '#' one#}}} two{two}}")[0].cases[0].tokens[2].cases[0].tokens[0].type).to.eql('octothorpe')
250-
expect(parse("{NUM, plural, one{# {VAR,select,key{# '#' one#}}} two{two}}")[0].cases[0].tokens[2].cases[0].tokens[1]).to.eql(' # one')
251-
expect(parse("{NUM, plural, one{# {VAR,select,key{# '#' one#}}} two{two}}")[0].cases[0].tokens[2].cases[0].tokens[2].type).to.eql('octothorpe')
252-
// with strict number sign
253-
expect(parse("{NUM, plural, one{# {VAR,select,key{# '#' one#}}} two{two}}", { strictNumberSign: true })[0].cases[0].tokens[2].cases[0].tokens[0]).to.eql('# \'#\' one#')
254-
});
259+
it('should parse # correctly with strict option', function() {
260+
expect(parse(src, { strict: true })[0].cases[0].tokens[2].cases[0].tokens).to.eql([
261+
"# '#' one#"
262+
]);
263+
})
264+
})
255265

256266
});
257267
describe("Ordinals", function() {
@@ -277,43 +287,105 @@ describe("Ordinals", function() {
277287

278288
});
279289
describe("Functions", function() {
280-
it("should accept no parameters", function() {
281-
expect(parse('{var,date}')[0].type).to.eql('function');
282-
expect(parse('{var,date}')[0].key).to.eql('date');
283-
expect(parse('{var,date}')[0].param).to.be.null;
290+
it("should require lower-case type", function() {
291+
expect(function(){ parse('{var,date}'); }).to.not.throwError();
292+
expect(function(){ parse('{var,Date}'); }).to.throwError();
293+
expect(function(){ parse('{var,daTe}'); }).to.throwError();
294+
expect(function(){ parse('{var,9ate}'); }).to.throwError();
295+
})
296+
297+
it('should be gracious with whitespace around arg and key', function() {
298+
var expected = { type: 'function', arg: 'var', key: 'date', param: null }
299+
expect(parse('{var,date}')[0]).to.eql(expected);
300+
expect(parse('{var, date}')[0]).to.eql(expected);
301+
expect(parse('{ var, date }')[0]).to.eql(expected);
302+
expect(parse('{\nvar, \ndate\n}')[0]).to.eql(expected);
284303
})
285304

286305
it("should accept parameters", function() {
287-
expect(parse('{var,date,long}')[0].type).to.eql('function');
288-
expect(parse('{var,date,long}')[0].key).to.eql('date');
289-
expect(parse('{var,date,long}')[0].param).to.eql('long');
290-
expect(parse('{var,date,long,short}')[0].param).to.eql('long,short');
306+
expect(parse('{var,date,long}')[0]).to.eql({
307+
type: 'function', arg: 'var', key: 'date', param: { tokens: ['long'] }
308+
});
309+
expect(parse('{var,date,long,short}')[0].param.tokens).to.eql(['long,short']);
291310
})
292311

293312
it("should accept parameters with whitespace", function() {
294-
expect(parse('{var,date,y-M-d HH:mm:ss zzzz}')[0].type).to.eql('function');
295-
expect(parse('{var,date,y-M-d HH:mm:ss zzzz}')[0].key).to.eql('date');
296-
expect(parse('{var,date,y-M-d HH:mm:ss zzzz}')[0].param).to.eql('y-M-d HH:mm:ss zzzz');
297-
expect(parse('{var,date, y-M-d HH:mm:ss zzzz }')[0].param).to.eql(' y-M-d HH:mm:ss zzzz ');
313+
expect(parse('{var,date,y-M-d HH:mm:ss zzzz}')[0]).to.eql({
314+
type: 'function', arg: 'var', key: 'date', param: { tokens: ['y-M-d HH:mm:ss zzzz'] }
315+
});
316+
expect(parse('{var,date, y-M-d HH:mm:ss zzzz }')[0].param.tokens).to.eql([' y-M-d HH:mm:ss zzzz ']);
298317
})
299318

300319
it("should accept parameters with special characters", function() {
301-
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz}")[0].type).to.eql('function');
302-
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz}")[0].key).to.eql('date');
303-
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz}")[0].param).to.eql("y-M-d {,} ' HH:mm:ss zzzz");
304-
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz'}'}")[0].param).to.eql("y-M-d {,} ' HH:mm:ss zzzz}");
305-
expect(parse("{var,date,y-M-d # HH:mm:ss zzzz}")[0].param).to.eql("y-M-d # HH:mm:ss zzzz");
306-
expect(parse("{var,date,y-M-d '#' HH:mm:ss zzzz}")[0].param).to.eql("y-M-d '#' HH:mm:ss zzzz");
307-
expect(parse("{var,date,y-M-d, HH:mm:ss zzzz}")[0].param).to.eql("y-M-d, HH:mm:ss zzzz");
320+
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz}")[0]).to.eql({
321+
type: 'function', arg: 'var', key: 'date', param: { tokens: [ 'y-M-d {,} \' HH:mm:ss zzzz' ] }
322+
});
323+
expect(parse("{var,date,y-M-d '{,}' '' HH:mm:ss zzzz'}'}")[0].param.tokens).to.eql(["y-M-d {,} ' HH:mm:ss zzzz}"]);
324+
expect(parse("{var,date,y-M-d # HH:mm:ss zzzz}")[0].param.tokens).to.eql(["y-M-d # HH:mm:ss zzzz"]);
325+
expect(parse("{var,date,y-M-d '#' HH:mm:ss zzzz}")[0].param.tokens).to.eql(["y-M-d '#' HH:mm:ss zzzz"]);
326+
expect(parse("{var,date,y-M-d, HH:mm:ss zzzz}")[0].param.tokens).to.eql(["y-M-d, HH:mm:ss zzzz"]);
308327
})
309328

310-
it("should be gracious with whitespace around arg and key", function() {
311-
var firstRes = JSON.stringify(parse('{var, date}'));
312-
expect(JSON.stringify(parse('{ var, date }'))).to.eql(firstRes);
313-
expect(JSON.stringify(parse('{var,date}'))).to.eql(firstRes);
314-
expect(JSON.stringify(parse('{\nvar, \ndate\n}'))).to.eql(firstRes);
315-
});
329+
it('should accept parameters containing a basic variable', function() {
330+
expect(parse('{foo, date, {bar}}')[0]).to.eql({
331+
type: 'function',
332+
arg: 'foo',
333+
key: 'date',
334+
param: { tokens: [' ', { arg: 'bar', type: 'argument' }] }
335+
})
336+
})
337+
338+
it('should accept parameters containing a select', function() {
339+
expect(parse('{foo, date, {bar, select, other{baz}}}')[0]).to.eql({
340+
type: 'function',
341+
arg: 'foo',
342+
key: 'date',
343+
param: { tokens: [' ', {
344+
arg: 'bar',
345+
type: 'select',
346+
cases: [{ key: 'other', tokens: ['baz'] }]
347+
}] }
348+
})
349+
})
350+
351+
it('should accept parameters containing a plural', function() {
352+
expect(parse('{foo, date, {bar, plural, other{#}}}')[0]).to.eql({
353+
type: 'function',
354+
arg: 'foo',
355+
key: 'date',
356+
param: { tokens: [' ', {
357+
arg: 'bar',
358+
type: 'plural',
359+
offset: 0,
360+
cases: [{ key: 'other', tokens: [{ type: 'octothorpe' }] }]
361+
}] }
362+
})
363+
})
364+
365+
describe('options.strict', function() {
366+
it('should require known function key with strict option', function() {
367+
expect(function() { parse('{foo, bar}') }).to.not.throwError()
368+
expect(function() { parse('{foo, bar}', { strict: true }) }).to.throwError()
369+
expect(function() { parse('{foo, date}', { strict: true }) }).to.not.throwError()
370+
})
371+
372+
it('parameter parsing should obey strict option', function() {
373+
expect(parse("{foo, date, {bar'}', quote'', other{#}}}", { strict: true })[0]).to.eql({
374+
type: 'function',
375+
arg: 'foo',
376+
key: 'date',
377+
param: { tokens: [" {bar}, quote', other{#}}"] }
378+
})
379+
})
380+
381+
it('should require matched braces in parameter if strict option is set', function() {
382+
expect(function() {
383+
parse("{foo, date, {bar{}}", { strict: true })
384+
}).to.throwError();
385+
})
386+
})
316387
});
388+
317389
describe("Nested/Recursive blocks", function() {
318390

319391
it("should allow a select statement inside of a select statement", function() {

0 commit comments

Comments
 (0)