diff --git a/fluent/src/context.js b/fluent/src/context.js index 4f96f27e0..bfe281771 100644 --- a/fluent/src/context.js +++ b/fluent/src/context.js @@ -50,19 +50,20 @@ export class MessageContext { constructor(locales, { functions = {}, useIsolating = true } = {}) { this.locales = Array.isArray(locales) ? locales : [locales]; - this._messages = new Map(); + this._privateMessages = new Map(); + this._publicMessages = new Map(); this._functions = functions; this._useIsolating = useIsolating; this._intls = new WeakMap(); } /* - * Return an iterator over `[id, message]` pairs. + * Return an iterator over public `[id, message]` pairs. * * @returns {Iterator} */ get messages() { - return this._messages[Symbol.iterator](); + return this._publicMessages[Symbol.iterator](); } /* @@ -72,7 +73,7 @@ export class MessageContext { * @returns {bool} */ hasMessage(id) { - return this._messages.has(id); + return this._publicMessages.has(id); } /* @@ -85,7 +86,7 @@ export class MessageContext { * @returns {Any} */ getMessage(id) { - return this._messages.get(id); + return this._publicMessages.get(id); } /** @@ -109,7 +110,13 @@ export class MessageContext { addMessages(source) { const [entries, errors] = parse(source); for (const id in entries) { - this._messages.set(id, entries[id]); + if (id.startsWith('-')) { + // Identifiers starting with a dash (-) are considered private and + // cannot be retrieved from MessageContext. + this._privateMessages.set(id, entries[id]); + } else { + this._publicMessages.set(id, entries[id]); + } } return errors; diff --git a/fluent/src/parser.js b/fluent/src/parser.js index 334aa8929..e6387ce93 100644 --- a/fluent/src/parser.js +++ b/fluent/src/parser.js @@ -2,7 +2,9 @@ const MAX_PLACEABLES = 100; -const identifierRe = new RegExp('[a-zA-Z_][a-zA-Z0-9_-]*', 'y'); +const privateIdentifierRe = new RegExp('-?[a-zA-Z][a-zA-Z0-9_-]*', 'y'); +const publicIdentifierRe = new RegExp('[a-zA-Z][a-zA-Z0-9_-]*', 'y'); +const functionIdentifierRe = /^[A-Z][A-Z_?-]*$/; /** * The `Parser` class is responsible for parsing FTL resources. @@ -118,7 +120,7 @@ class RuntimeParser { * @private */ getMessage() { - const id = this.getIdentifier(); + const id = this.getPrivateIdentifier(); let attrs = null; this.skipInlineWS(); @@ -224,25 +226,44 @@ class RuntimeParser { } /** - * Get Message identifier. + * Get identifier of a Message, Attribute or External Attribute. * * @returns {String} * @private */ - getIdentifier() { - identifierRe.lastIndex = this._index; - - const result = identifierRe.exec(this._source); + getIdentifier(re) { + re.lastIndex = this._index; + const result = re.exec(this._source); if (result === null) { this._index += 1; - throw this.error('Expected an identifier (starting with [a-zA-Z_])'); + throw this.error(`Expected an identifier [${re.toString()}]`); } - this._index = identifierRe.lastIndex; + this._index = re.lastIndex; return result[0]; } + /** + * Get a potentially private identifier (staring with a dash). + * + * @returns {String} + * @private + */ + getPrivateIdentifier() { + return this.getIdentifier(privateIdentifierRe); + } + + /** + * Get a public identifier. + * + * @returns {String} + * @private + */ + getPublicIdentifier() { + return this.getIdentifier(publicIdentifierRe); + } + /** * Get Variant name. * @@ -479,6 +500,12 @@ class RuntimeParser { const ch = this._source[this._index]; if (ch === '}') { + if (selector.type === 'attr' && selector.id.name.startsWith('-')) { + throw this.error( + 'Attributes of private messages cannot be interpolated.' + ); + } + return selector; } @@ -486,6 +513,21 @@ class RuntimeParser { throw this.error('Expected "}" or "->"'); } + if (selector.type === 'ref') { + throw this.error('Message references cannot be used as selectors.'); + } + + if (selector.type === 'var') { + throw this.error('Variants cannot be used as selectors.'); + } + + if (selector.type === 'attr' && !selector.id.name.startsWith('-')) { + throw this.error( + 'Attributes of public messages cannot be used as selectors.' + ); + } + + this._index += 2; // -> this.skipInlineWS(); @@ -526,7 +568,7 @@ class RuntimeParser { if (this._source[this._index] === '.') { this._index++; - const name = this.getIdentifier(); + const name = this.getPublicIdentifier(); this._index++; return { type: 'attr', @@ -551,6 +593,10 @@ class RuntimeParser { this._index++; const args = this.getCallArgs(); + if (!functionIdentifierRe.test(literal.name)) { + throw this.error('Function names must be all upper-case'); + } + this._index++; literal.type = 'fun'; @@ -705,7 +751,7 @@ class RuntimeParser { } this._index++; - const key = this.getIdentifier(); + const key = this.getPublicIdentifier(); this.skipInlineWS(); @@ -810,23 +856,39 @@ class RuntimeParser { * @private */ getLiteral() { - const cc = this._source.charCodeAt(this._index); - if ((cc >= 48 && cc <= 57) || cc === 45) { - return this.getNumber(); - } else if (cc === 34) { // " - return this.getString(); - } else if (cc === 36) { // $ + const cc0 = this._source.charCodeAt(this._index); + + if (cc0 === 36) { // $ this._index++; return { type: 'ext', - name: this.getIdentifier() + name: this.getPublicIdentifier() }; } - return { - type: 'ref', - name: this.getIdentifier() - }; + const cc1 = cc0 === 45 // - + // Peek at the next character after the dash. + ? this._source.charCodeAt(this._index + 1) + // Or keep using the character at the current index. + : cc0; + + if ((cc1 >= 97 && cc1 <= 122) || // a-z + (cc1 >= 65 && cc1 <= 90)) { // A-Z + return { + type: 'ref', + name: this.getPrivateIdentifier() + }; + } + + if ((cc1 >= 48 && cc1 <= 57)) { // 0-9 + return this.getNumber(); + } + + if (cc0 === 34) { // " + return this.getString(); + } + + throw this.error('Expected literal'); } /** @@ -886,7 +948,7 @@ class RuntimeParser { if ((cc >= 97 && cc <= 122) || // a-z (cc >= 65 && cc <= 90) || // A-Z - cc === 95 || cc === 47 || cc === 91) { // _/[ + cc === 47 || cc === 91) { // /[ this._index = start; return; } diff --git a/fluent/src/resolver.js b/fluent/src/resolver.js index 17f9f8005..d3dfa59e7 100644 --- a/fluent/src/resolver.js +++ b/fluent/src/resolver.js @@ -98,7 +98,9 @@ function DefaultMember(env, members, def) { */ function MessageReference(env, {name}) { const { ctx, errors } = env; - const message = ctx.getMessage(name); + const message = name.startsWith('-') + ? ctx._privateMessages.get(name) + : ctx._publicMessages.get(name); if (!message) { errors.push(new ReferenceError(`Unknown message: ${name}`)); diff --git a/fluent/test/context_test.js b/fluent/test/context_test.js index f8860eaa2..b547bb8e3 100644 --- a/fluent/test/context_test.js +++ b/fluent/test/context_test.js @@ -17,27 +17,48 @@ suite('Context', function() { ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` foo = Foo - bar = Bar + -bar = Private Bar `); }); + test('adds messages', function() { + assert.equal(ctx._publicMessages.has('foo'), true); + assert.equal(ctx._privateMessages.has('foo'), false); + assert.equal(ctx._publicMessages.has('-bar'), false); + assert.equal(ctx._privateMessages.has('-bar'), true); + }); + test('preserves existing messages when new are added', function() { ctx.addMessages(ftl` baz = Baz `); - assert(ctx.hasMessage('foo')); - assert(ctx.hasMessage('bar')); - assert(ctx.hasMessage('baz')); + + assert.equal(ctx._publicMessages.has('foo'), true); + assert.equal(ctx._privateMessages.has('foo'), false); + assert.equal(ctx._publicMessages.has('-bar'), false); + assert.equal(ctx._privateMessages.has('-bar'), true); + + assert.equal(ctx._publicMessages.has('baz'), true); + assert.equal(ctx._privateMessages.has('baz'), false); }); + test('public and private can share the same name', function() { + ctx.addMessages(ftl` + -foo = Private Foo + `); + assert.equal(ctx._publicMessages.has('foo'), true); + assert.equal(ctx._privateMessages.has('foo'), false); + assert.equal(ctx._publicMessages.has('-foo'), false); + assert.equal(ctx._privateMessages.has('-foo'), true); + }); + + test('overwrites existing messages if the ids are the same', function() { ctx.addMessages(ftl` foo = New Foo `); - assert(ctx.hasMessage('foo')); - assert(ctx.hasMessage('bar')); - assert(ctx.hasMessage('baz')); - assert.equal(ctx._messages.size, 3); + + assert.equal(ctx._publicMessages.size, 2); const msg = ctx.getMessage('foo'); const val = ctx.format(msg, args, errs); @@ -46,4 +67,44 @@ suite('Context', function() { }); }); + suite('hasMessage', function(){ + suiteSetup(function() { + ctx = new MessageContext('en-US', { useIsolating: false }); + ctx.addMessages(ftl` + foo = Foo + -bar = Private Bar + `); + }); + + test('returns true only for public messages', function() { + assert.equal(ctx.hasMessage('foo'), true); + }); + + test('returns false for private and missing messages', function() { + assert.equal(ctx.hasMessage('-bar'), false); + assert.equal(ctx.hasMessage('baz'), false); + assert.equal(ctx.hasMessage('-baz'), false); + }); + }); + + suite('getMessage', function(){ + suiteSetup(function() { + ctx = new MessageContext('en-US', { useIsolating: false }); + ctx.addMessages(ftl` + foo = Foo + -bar = Private Bar + `); + }); + + test('returns public messages', function() { + assert.equal(ctx.getMessage('foo'), 'Foo'); + }); + + test('returns null for private and missing messages', function() { + assert.equal(ctx.getMessage('-bar'), null); + assert.equal(ctx.getMessage('baz'), null); + assert.equal(ctx.getMessage('-baz'), null); + }); + }); + }); diff --git a/fluent/test/fixtures_structure/private_message.json b/fluent/test/fixtures_structure/private_message.json index 0967ef424..1c3364dd6 100644 --- a/fluent/test/fixtures_structure/private_message.json +++ b/fluent/test/fixtures_structure/private_message.json @@ -1 +1,105 @@ -{} +{ + "-brand-name": { + "val": [ + { + "type": "sel", + "exp": null, + "vars": [ + { + "key": { + "type": "varname", + "name": "nominative" + }, + "val": "Firefox" + }, + { + "key": { + "type": "varname", + "name": "accusative" + }, + "val": "Firefoxa" + } + ], + "def": 0 + } + ], + "attrs": { + "gender": "masculine" + } + }, + "update-command": { + "val": [ + "Zaktualizuj ", + { + "type": "var", + "id": { + "type": "ref", + "name": "-brand-name" + }, + "key": { + "type": "varname", + "name": "accusative" + } + }, + "." + ] + }, + "update-successful": { + "val": [ + { + "type": "sel", + "exp": { + "type": "attr", + "id": { + "type": "ref", + "name": "-brand-name" + }, + "name": "gender" + }, + "vars": [ + { + "key": { + "type": "varname", + "name": "masculine" + }, + "val": [ + { + "type": "ref", + "name": "-brand-name" + }, + " został pomyślnie zaktualizowany." + ] + }, + { + "key": { + "type": "varname", + "name": "feminine" + }, + "val": [ + { + "type": "ref", + "name": "-brand-name" + }, + " została pomyślnie zaktualizowana." + ] + }, + { + "key": { + "type": "varname", + "name": "other" + }, + "val": [ + "Program ", + { + "type": "ref", + "name": "-brand-name" + }, + " został pomyślnie zaktualizowany." + ] + } + ], + "def": 2 + } + ] + } +} diff --git a/fluent/test/patterns_test.js b/fluent/test/patterns_test.js index 316a5096a..352410af7 100644 --- a/fluent/test/patterns_test.js +++ b/fluent/test/patterns_test.js @@ -33,26 +33,45 @@ suite('Patterns', function(){ ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` foo = Foo - bar = { foo } Bar - baz = { missing } - qux = { malformed + -bar = Bar + + ref-public = { foo } + ref-private = { -bar } + + ref-missing-public = { missing } + ref-missing-private = { -missing } + + ref-malformed = { malformed `); }); - test('returns the value', function(){ - const msg = ctx.getMessage('bar'); + test('resolves the reference to a public message', function(){ + const msg = ctx.getMessage('ref-public'); + const val = ctx.format(msg, args, errs); + assert.strictEqual(val, 'Foo'); + assert.equal(errs.length, 0); + }); + + test('resolves the reference to a private message', function(){ + const msg = ctx.getMessage('ref-private'); const val = ctx.format(msg, args, errs); - assert.strictEqual(val, 'Foo Bar'); + assert.strictEqual(val, 'Bar'); assert.equal(errs.length, 0); }); - test('returns the raw string if the referenced message is ' + - 'not found', function(){ - const msg = ctx.getMessage('baz'); + test('returns the id if a public reference is missing', function(){ + const msg = ctx.getMessage('ref-missing-public'); const val = ctx.format(msg, args, errs); assert.strictEqual(val, 'missing'); assert.ok(errs[0] instanceof ReferenceError); // unknown message }); + + test('returns the id if a private reference is missing', function(){ + const msg = ctx.getMessage('ref-missing-private'); + const val = ctx.format(msg, args, errs); + assert.strictEqual(val, '-missing'); + assert.ok(errs[0] instanceof ReferenceError); // unknown message + }); }); suite('Complex string referencing a message with null value', function(){ @@ -125,10 +144,11 @@ suite('Patterns', function(){ suiteSetup(function() { ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` - foo = { $sel -> - *[a] { foo } - [b] Bar - } + foo = + { $sel -> + *[a] { foo } + [b] Bar + } bar = { foo } `); }); @@ -152,11 +172,14 @@ suite('Patterns', function(){ suiteSetup(function() { ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` - foo = { ref-foo -> - *[a] Foo - } - - ref-foo = { foo } + -foo = + { -bar.attr -> + *[a] Foo + } + -bar + .attr = { -foo } + + foo = { -foo } `); }); @@ -172,14 +195,20 @@ suite('Patterns', function(){ suiteSetup(function() { ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` - foo = { foo -> - *[a] Foo - } - - bar = { bar.attr -> - *[a] Bar - } + -foo = + { -bar.attr -> + *[a] Foo + } .attr = a + + -bar = + { -foo.attr -> + *[a] Bar + } + .attr = { -foo } + + foo = { -foo } + bar = { -bar } `); }); diff --git a/fluent/test/primitives_test.js b/fluent/test/primitives_test.js index 682652729..2a0fb4aff 100644 --- a/fluent/test/primitives_test.js +++ b/fluent/test/primitives_test.js @@ -57,7 +57,10 @@ suite('Primitives', function() { placeable-attr = { bar.attr } - selector-attr = { bar.attr -> + -baz + .attr = Bar Attribute + + selector-attr = { -baz.attr -> *[Bar Attribute] Member 3 } `); diff --git a/fluent/test/select_expressions_test.js b/fluent/test/select_expressions_test.js index 249698cab..7c3fabc76 100644 --- a/fluent/test/select_expressions_test.js +++ b/fluent/test/select_expressions_test.js @@ -50,11 +50,11 @@ suite('Select expressions', function() { }); }); - suite('with an invalid selector', function(){ + suite('with a missing selector', function(){ suiteSetup(function() { ctx = new MessageContext('en-US', { useIsolating: false }); ctx.addMessages(ftl` - foo = { bar -> + foo = { $none -> *[a] A [b] B } @@ -66,7 +66,7 @@ suite('Select expressions', function() { const val = ctx.format(msg, args, errs); assert.equal(val, 'A'); assert.equal(errs.length, 1); - assert(errs[0] instanceof ReferenceError); // unknown message + assert(errs[0] instanceof ReferenceError); // unknown external }); });