Bug 1347801 - Land Fluent MessageContext (Parser, Resolver, Context). r=mossop
authorZibi Braniecki <zbraniecki@mozilla.com>
Fri, 02 Jun 2017 10:35:15 +0200
changeset 422999 a19ff151e785cfc4f51de6faab4444fda3b9178e
parent 422998 f5a478a70af0f718d26cec176d8d30a9f06a363a
child 423000 9c42bcf50f52397b96dcfb6d127202bb8a4485e6
push id7761
push userjlund@mozilla.com
push dateFri, 15 Sep 2017 00:19:52 +0000
treeherdermozilla-beta@c38455951db4 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersmossop
bugs1347801
milestone57.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1347801 - Land Fluent MessageContext (Parser, Resolver, Context). r=mossop This patch lands the core of the new localization API: * Parser for the Fluent syntax called "FTL" * Resolver for the Fluent logic * MessageContext class which is a central class for storing and operating on Fluent messages. MozReview-Commit-ID: E7nKGsLOCJe
browser/base/content/test/static/browser_all_files_referenced.js
intl/l10n/MessageContext.jsm
intl/l10n/moz.build
intl/l10n/test/test_messagecontext.js
intl/l10n/test/xpcshell.ini
intl/moz.build
--- a/browser/base/content/test/static/browser_all_files_referenced.js
+++ b/browser/base/content/test/static/browser_all_files_referenced.js
@@ -116,16 +116,19 @@ var whitelist = [
    platforms: ["linux", "macosx"]},
 
   // browser/extensions/pdfjs/content/web/viewer.js#7450
   {file: "resource://pdf.js/web/debugger.js"},
 
   // Needed by Normandy
   {file: "resource://gre/modules/IndexedDB.jsm"},
 
+  // New L10n API that is not yet used in production
+  {file: "resource://gre/modules/MessageContext.jsm"},
+
   // Starting from here, files in the whitelist are bugs that need fixing.
   // Bug 1339420
   {file: "chrome://branding/content/icon128.png"},
   // Bug 1339424 (wontfix?)
   {file: "chrome://browser/locale/taskbar.properties",
    platforms: ["linux", "macosx"]},
   // Bug 1316187
   {file: "chrome://global/content/customizeToolbar.xul"},
new file mode 100644
--- /dev/null
+++ b/intl/l10n/MessageContext.jsm
@@ -0,0 +1,1864 @@
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+
+/* Copyright 2017 Mozilla Foundation and others
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+/* fluent@0.4.1 */
+
+/*  eslint no-magic-numbers: [0]  */
+
+const MAX_PLACEABLES = 100;
+
+const identifierRe = new RegExp('[a-zA-Z_][a-zA-Z0-9_-]*', 'y');
+
+/**
+ * The `Parser` class is responsible for parsing FTL resources.
+ *
+ * It's only public method is `getResource(source)` which takes an FTL string
+ * and returns a two element Array with an Object of entries generated from the
+ * source as the first element and an array of SyntaxError objects as the
+ * second.
+ *
+ * This parser is optimized for runtime performance.
+ *
+ * There is an equivalent of this parser in syntax/parser which is
+ * generating full AST which is useful for FTL tools.
+ */
+class RuntimeParser {
+  /**
+   * Parse FTL code into entries formattable by the MessageContext.
+   *
+   * Given a string of FTL syntax, return a map of entries that can be passed
+   * to MessageContext.format and a list of errors encountered during parsing.
+   *
+   * @param {String} string
+   * @returns {Array<Object, Array>}
+   */
+  getResource(string) {
+    this._source = string;
+    this._index = 0;
+    this._length = string.length;
+    this.entries = {};
+
+    const errors = [];
+
+    this.skipWS();
+    while (this._index < this._length) {
+      try {
+        this.getEntry();
+      } catch (e) {
+        if (e instanceof SyntaxError) {
+          errors.push(e);
+
+          this.skipToNextEntryStart();
+        } else {
+          throw e;
+        }
+      }
+      this.skipWS();
+    }
+
+    return [this.entries, errors];
+  }
+
+  /**
+   * Parse the source string from the current index as an FTL entry
+   * and add it to object's entries property.
+   *
+   * @private
+   */
+  getEntry() {
+    // The index here should either be at the beginning of the file
+    // or right after new line.
+    if (this._index !== 0 &&
+        this._source[this._index - 1] !== '\n') {
+      throw this.error(`Expected an entry to start
+        at the beginning of the file or on a new line.`);
+    }
+
+    const ch = this._source[this._index];
+
+    // We don't care about comments or sections at runtime
+    if (ch === '/') {
+      this.skipComment();
+      return;
+    }
+
+    if (ch === '[') {
+      this.skipSection();
+      return;
+    }
+
+    this.getMessage();
+  }
+
+  /**
+   * Skip the section entry from the current index.
+   *
+   * @private
+   */
+  skipSection() {
+    this._index += 1;
+    if (this._source[this._index] !== '[') {
+      throw this.error('Expected "[[" to open a section');
+    }
+
+    this._index += 1;
+
+    this.skipInlineWS();
+    this.getSymbol();
+    this.skipInlineWS();
+
+    if (this._source[this._index] !== ']' ||
+        this._source[this._index + 1] !== ']') {
+      throw this.error('Expected "]]" to close a section');
+    }
+
+    this._index += 2;
+  }
+
+  /**
+   * Parse the source string from the current index as an FTL message
+   * and add it to the entries property on the Parser.
+   *
+   * @private
+   */
+  getMessage() {
+    const id = this.getIdentifier();
+    let attrs = null;
+    let tags = null;
+
+    this.skipInlineWS();
+
+    let ch = this._source[this._index];
+
+    let val;
+
+    if (ch === '=') {
+      this._index++;
+
+      this.skipInlineWS();
+
+      val = this.getPattern();
+    } else {
+      this.skipWS();
+    }
+
+    ch = this._source[this._index];
+
+    if (ch === '\n') {
+      this._index++;
+      this.skipInlineWS();
+      ch = this._source[this._index];
+    }
+
+    if (ch === '.') {
+      attrs = this.getAttributes();
+    }
+
+    if (ch === '#') {
+      if (attrs !== null) {
+        throw this.error('Tags cannot be added to a message with attributes.');
+      }
+      tags = this.getTags();
+    }
+
+    if (tags === null && attrs === null && typeof val === 'string') {
+      this.entries[id] = val;
+    } else {
+      if (val === undefined) {
+        if (tags === null && attrs === null) {
+          throw this.error(`Expected a value (like: " = value") or
+            an attribute (like: ".key = value")`);
+        }
+      }
+
+      this.entries[id] = { val };
+      if (attrs) {
+        this.entries[id].attrs = attrs;
+      }
+      if (tags) {
+        this.entries[id].tags = tags;
+      }
+    }
+  }
+
+  /**
+   * Skip whitespace.
+   *
+   * @private
+   */
+  skipWS() {
+    let ch = this._source[this._index];
+    while (ch === ' ' || ch === '\n' || ch === '\t' || ch === '\r') {
+      ch = this._source[++this._index];
+    }
+  }
+
+  /**
+   * Skip inline whitespace (space and \t).
+   *
+   * @private
+   */
+  skipInlineWS() {
+    let ch = this._source[this._index];
+    while (ch === ' ' || ch === '\t') {
+      ch = this._source[++this._index];
+    }
+  }
+
+  /**
+   * Get Message identifier.
+   *
+   * @returns {String}
+   * @private
+   */
+  getIdentifier() {
+    identifierRe.lastIndex = this._index;
+
+    const result = identifierRe.exec(this._source);
+
+    if (result === null) {
+      this._index += 1;
+      throw this.error('Expected an identifier (starting with [a-zA-Z_])');
+    }
+
+    this._index = identifierRe.lastIndex;
+    return result[0];
+  }
+
+  /**
+   * Get Symbol.
+   *
+   * @returns {Object}
+   * @private
+   */
+  getSymbol() {
+    let name = '';
+
+    const start = this._index;
+    let cc = this._source.charCodeAt(this._index);
+
+    if ((cc >= 97 && cc <= 122) || // a-z
+        (cc >= 65 && cc <= 90) ||  // A-Z
+        cc === 95 || cc === 32) {  // _ <space>
+      cc = this._source.charCodeAt(++this._index);
+    } else {
+      throw this.error('Expected a keyword (starting with [a-zA-Z_])');
+    }
+
+    while ((cc >= 97 && cc <= 122) || // a-z
+           (cc >= 65 && cc <= 90) ||  // A-Z
+           (cc >= 48 && cc <= 57) ||  // 0-9
+           cc === 95 || cc === 45 || cc === 32) {  // _- <space>
+      cc = this._source.charCodeAt(++this._index);
+    }
+
+    // If we encountered the end of name, we want to test if the last
+    // collected character is a space.
+    // If it is, we will backtrack to the last non-space character because
+    // the keyword cannot end with a space character.
+    while (this._source.charCodeAt(this._index - 1) === 32) {
+      this._index--;
+    }
+
+    name += this._source.slice(start, this._index);
+
+    return { type: 'sym', name };
+  }
+
+  /**
+   * Get simple string argument enclosed in `"`.
+   *
+   * @returns {String}
+   * @private
+   */
+  getString() {
+    const start = this._index + 1;
+
+    while (++this._index < this._length) {
+      const ch = this._source[this._index];
+
+      if (ch === '"') {
+        break;
+      }
+
+      if (ch === '\n') {
+        break;
+      }
+    }
+
+    return this._source.substring(start, this._index++);
+  }
+
+  /**
+   * Parses a Message pattern.
+   * Message Pattern may be a simple string or an array of strings
+   * and placeable expressions.
+   *
+   * @returns {String|Array}
+   * @private
+   */
+  getPattern() {
+    // We're going to first try to see if the pattern is simple.
+    // If it is we can just look for the end of the line and read the string.
+    //
+    // Then, if either the line contains a placeable opening `{` or the
+    // next line starts an indentation, we switch to complex pattern.
+    const start = this._index;
+    let eol = this._source.indexOf('\n', this._index);
+
+    if (eol === -1) {
+      eol = this._length;
+    }
+
+    const line = start !== eol ?
+      this._source.slice(start, eol) : undefined;
+
+    if (line !== undefined && line.includes('{')) {
+      return this.getComplexPattern();
+    }
+
+    this._index = eol + 1;
+
+    if (this._source[this._index] === ' ') {
+      this._index = start;
+      return this.getComplexPattern();
+    }
+
+    return line;
+  }
+
+  /**
+   * Parses a complex Message pattern.
+   * This function is called by getPattern when the message is multiline,
+   * or contains escape chars or placeables.
+   * It does full parsing of complex patterns.
+   *
+   * @returns {Array}
+   * @private
+   */
+  /* eslint-disable complexity */
+  getComplexPattern() {
+    let buffer = '';
+    const content = [];
+    let placeables = 0;
+
+    let ch = this._source[this._index];
+
+    while (this._index < this._length) {
+      // This block handles multi-line strings combining strings separated
+      // by new line and `|` character at the beginning of the next one.
+      if (ch === '\n') {
+        this._index++;
+        if (this._source[this._index] !== ' ') {
+          break;
+        }
+        this.skipInlineWS();
+
+        if (this._source[this._index] === '}' ||
+            this._source[this._index] === '[' ||
+            this._source[this._index] === '*' ||
+            this._source[this._index] === '#' ||
+            this._source[this._index] === '.') {
+          break;
+        }
+
+        if (buffer.length || content.length) {
+          buffer += '\n';
+        }
+        ch = this._source[this._index];
+        continue;
+      } else if (ch === '\\') {
+        const ch2 = this._source[this._index + 1];
+        if (ch2 === '"' || ch2 === '{' || ch2 === '\\') {
+          ch = ch2;
+          this._index++;
+        }
+      } else if (ch === '{') {
+        // Push the buffer to content array right before placeable
+        if (buffer.length) {
+          content.push(buffer);
+        }
+        if (placeables > MAX_PLACEABLES - 1) {
+          throw this.error(
+            `Too many placeables, maximum allowed is ${MAX_PLACEABLES}`);
+        }
+        buffer = '';
+        content.push(this.getPlaceable());
+
+        this._index++;
+
+        ch = this._source[this._index];
+        placeables++;
+        continue;
+      }
+
+      if (ch) {
+        buffer += ch;
+      }
+      this._index++;
+      ch = this._source[this._index];
+    }
+
+    if (content.length === 0) {
+      return buffer.length ? buffer : undefined;
+    }
+
+    if (buffer.length) {
+      content.push(buffer);
+    }
+
+    return content;
+  }
+  /* eslint-enable complexity */
+
+  /**
+   * Parses a single placeable in a Message pattern and returns its
+   * expression.
+   *
+   * @returns {Object}
+   * @private
+   */
+  getPlaceable() {
+    const start = ++this._index;
+
+    this.skipWS();
+
+    if (this._source[this._index] === '*' ||
+       (this._source[this._index] === '[' &&
+        this._source[this._index + 1] !== ']')) {
+      const variants = this.getVariants();
+
+      return {
+        type: 'sel',
+        exp: null,
+        vars: variants[0],
+        def: variants[1]
+      };
+    }
+
+    // Rewind the index and only support in-line white-space now.
+    this._index = start;
+    this.skipInlineWS();
+
+    const selector = this.getSelectorExpression();
+
+    this.skipWS();
+
+    const ch = this._source[this._index];
+
+    if (ch === '}') {
+      return selector;
+    }
+
+    if (ch !== '-' || this._source[this._index + 1] !== '>') {
+      throw this.error('Expected "}" or "->"');
+    }
+
+    this._index += 2; // ->
+
+    this.skipInlineWS();
+
+    if (this._source[this._index] !== '\n') {
+      throw this.error('Variants should be listed in a new line');
+    }
+
+    this.skipWS();
+
+    const variants = this.getVariants();
+
+    if (variants[0].length === 0) {
+      throw this.error('Expected members for the select expression');
+    }
+
+    return {
+      type: 'sel',
+      exp: selector,
+      vars: variants[0],
+      def: variants[1]
+    };
+  }
+
+  /**
+   * Parses a selector expression.
+   *
+   * @returns {Object}
+   * @private
+   */
+  getSelectorExpression() {
+    const literal = this.getLiteral();
+
+    if (literal.type !== 'ref') {
+      return literal;
+    }
+
+    if (this._source[this._index] === '.') {
+      this._index++;
+
+      const name = this.getIdentifier();
+      this._index++;
+      return {
+        type: 'attr',
+        id: literal,
+        name
+      };
+    }
+
+    if (this._source[this._index] === '[') {
+      this._index++;
+
+      const key = this.getVariantKey();
+      this._index++;
+      return {
+        type: 'var',
+        id: literal,
+        key
+      };
+    }
+
+    if (this._source[this._index] === '(') {
+      this._index++;
+      const args = this.getCallArgs();
+
+      this._index++;
+
+      literal.type = 'fun';
+
+      return {
+        type: 'call',
+        fun: literal,
+        args
+      };
+    }
+
+    return literal;
+  }
+
+  /**
+   * Parses call arguments for a CallExpression.
+   *
+   * @returns {Array}
+   * @private
+   */
+  getCallArgs() {
+    const args = [];
+
+    if (this._source[this._index] === ')') {
+      return args;
+    }
+
+    while (this._index < this._length) {
+      this.skipInlineWS();
+
+      const exp = this.getSelectorExpression();
+
+      // MessageReference in this place may be an entity reference, like:
+      // `call(foo)`, or, if it's followed by `:` it will be a key-value pair.
+      if (exp.type !== 'ref' ||
+         exp.namespace !== undefined) {
+        args.push(exp);
+      } else {
+        this.skipInlineWS();
+
+        if (this._source[this._index] === ':') {
+          this._index++;
+          this.skipInlineWS();
+
+          const val = this.getSelectorExpression();
+
+          // If the expression returned as a value of the argument
+          // is not a quote delimited string or number, throw.
+          //
+          // We don't have to check here if the pattern is quote delimited
+          // because that's the only type of string allowed in expressions.
+          if (typeof val === 'string' ||
+              Array.isArray(val) ||
+              val.type === 'num') {
+            args.push({
+              type: 'narg',
+              name: exp.name,
+              val
+            });
+          } else {
+            this._index = this._source.lastIndexOf(':', this._index) + 1;
+            throw this.error(
+              'Expected string in quotes, number.');
+          }
+
+        } else {
+          args.push(exp);
+        }
+      }
+
+      this.skipInlineWS();
+
+      if (this._source[this._index] === ')') {
+        break;
+      } else if (this._source[this._index] === ',') {
+        this._index++;
+      } else {
+        throw this.error('Expected "," or ")"');
+      }
+    }
+
+    return args;
+  }
+
+  /**
+   * Parses an FTL Number.
+   *
+   * @returns {Object}
+   * @private
+   */
+  getNumber() {
+    let num = '';
+    let cc = this._source.charCodeAt(this._index);
+
+    // The number literal may start with negative sign `-`.
+    if (cc === 45) {
+      num += '-';
+      cc = this._source.charCodeAt(++this._index);
+    }
+
+    // next, we expect at least one digit
+    if (cc < 48 || cc > 57) {
+      throw this.error(`Unknown literal "${num}"`);
+    }
+
+    // followed by potentially more digits
+    while (cc >= 48 && cc <= 57) {
+      num += this._source[this._index++];
+      cc = this._source.charCodeAt(this._index);
+    }
+
+    // followed by an optional decimal separator `.`
+    if (cc === 46) {
+      num += this._source[this._index++];
+      cc = this._source.charCodeAt(this._index);
+
+      // followed by at least one digit
+      if (cc < 48 || cc > 57) {
+        throw this.error(`Unknown literal "${num}"`);
+      }
+
+      // and optionally more digits
+      while (cc >= 48 && cc <= 57) {
+        num += this._source[this._index++];
+        cc = this._source.charCodeAt(this._index);
+      }
+    }
+
+    return {
+      type: 'num',
+      val: num
+    };
+  }
+
+  /**
+   * Parses a list of Message attributes.
+   *
+   * @returns {Object}
+   * @private
+   */
+  getAttributes() {
+    const attrs = {};
+
+    while (this._index < this._length) {
+      const ch = this._source[this._index];
+
+      if (ch !== '.') {
+        break;
+      }
+      this._index++;
+
+      const key = this.getIdentifier();
+
+      this.skipInlineWS();
+
+      this._index++;
+
+      this.skipInlineWS();
+
+      const val = this.getPattern();
+
+      if (typeof val === 'string') {
+        attrs[key] = val;
+      } else {
+        attrs[key] = {
+          val
+        };
+      }
+
+      this.skipWS();
+    }
+
+    return attrs;
+  }
+
+  /**
+   * Parses a list of Message tags.
+   *
+   * @returns {Array}
+   * @private
+   */
+  getTags() {
+    const tags = [];
+
+    while (this._index < this._length) {
+      const ch = this._source[this._index];
+
+      if (ch !== '#') {
+        break;
+      }
+      this._index++;
+
+      const symbol = this.getSymbol();
+
+      tags.push(symbol.name);
+
+      this.skipWS();
+    }
+
+    return tags;
+  }
+
+  /**
+   * Parses a list of Selector variants.
+   *
+   * @returns {Array}
+   * @private
+   */
+  getVariants() {
+    const variants = [];
+    let index = 0;
+    let defaultIndex;
+
+    while (this._index < this._length) {
+      const ch = this._source[this._index];
+
+      if ((ch !== '[' || this._source[this._index + 1] === '[') &&
+          ch !== '*') {
+        break;
+      }
+      if (ch === '*') {
+        this._index++;
+        defaultIndex = index;
+      }
+
+      if (this._source[this._index] !== '[') {
+        throw this.error('Expected "["');
+      }
+
+      this._index++;
+
+      const key = this.getVariantKey();
+
+      this.skipInlineWS();
+
+      const variant = {
+        key,
+        val: this.getPattern()
+      };
+      variants[index++] = variant;
+
+      this.skipWS();
+    }
+
+    return [variants, defaultIndex];
+  }
+
+  /**
+   * Parses a Variant key.
+   *
+   * @returns {String}
+   * @private
+   */
+  getVariantKey() {
+    // VariantKey may be a Keyword or Number
+
+    const cc = this._source.charCodeAt(this._index);
+    let literal;
+
+    if ((cc >= 48 && cc <= 57) || cc === 45) {
+      literal = this.getNumber();
+    } else {
+      literal = this.getSymbol();
+    }
+
+    if (this._source[this._index] !== ']') {
+      throw this.error('Expected "]"');
+    }
+
+    this._index++;
+    return literal;
+  }
+
+  /**
+   * Parses an FTL literal.
+   *
+   * @returns {Object}
+   * @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) { // $
+      this._index++;
+      return {
+        type: 'ext',
+        name: this.getIdentifier()
+      };
+    }
+
+    return {
+      type: 'ref',
+      name: this.getIdentifier()
+    };
+  }
+
+  /**
+   * Skips an FTL comment.
+   *
+   * @private
+   */
+  skipComment() {
+    // At runtime, we don't care about comments so we just have
+    // to parse them properly and skip their content.
+    let eol = this._source.indexOf('\n', this._index);
+
+    while (eol !== -1 &&
+      this._source[eol + 1] === '/' && this._source[eol + 2] === '/') {
+      this._index = eol + 3;
+
+      eol = this._source.indexOf('\n', this._index);
+
+      if (eol === -1) {
+        break;
+      }
+    }
+
+    if (eol === -1) {
+      this._index = this._length;
+    } else {
+      this._index = eol + 1;
+    }
+  }
+
+  /**
+   * Creates a new SyntaxError object with a given message.
+   *
+   * @param {String} message
+   * @returns {Object}
+   * @private
+   */
+  error(message) {
+    return new SyntaxError(message);
+  }
+
+  /**
+   * Skips to the beginning of a next entry after the current position.
+   * This is used to mark the boundary of junk entry in case of error,
+   * and recover from the returned position.
+   *
+   * @private
+   */
+  skipToNextEntryStart() {
+    let start = this._index;
+
+    while (true) {
+      if (start === 0 || this._source[start - 1] === '\n') {
+        const cc = this._source.charCodeAt(start);
+
+        if ((cc >= 97 && cc <= 122) || // a-z
+            (cc >= 65 && cc <= 90) ||  // A-Z
+             cc === 95 || cc === 47 || cc === 91) {  // _/[
+          this._index = start;
+          return;
+        }
+      }
+
+      start = this._source.indexOf('\n', start);
+
+      if (start === -1) {
+        this._index = this._length;
+        return;
+      }
+      start++;
+    }
+  }
+}
+
+/**
+ * Parses an FTL string using RuntimeParser and returns the generated
+ * object with entries and a list of errors.
+ *
+ * @param {String} string
+ * @returns {Array<Object, Array>}
+ */
+function parse(string) {
+  const parser = new RuntimeParser();
+  return parser.getResource(string);
+}
+
+/* global Intl */
+
+/**
+ * The `FluentType` class is the base of Fluent's type system.
+ *
+ * Fluent types wrap JavaScript values and store additional configuration for
+ * them, which can then be used in the `valueOf` method together with a proper
+ * `Intl` formatter.
+ */
+class FluentType {
+
+  /**
+   * Create an `FluentType` instance.
+   *
+   * @param   {Any}    value - JavaScript value to wrap.
+   * @param   {Object} opts  - Configuration.
+   * @returns {FluentType}
+   */
+  constructor(value, opts) {
+    this.value = value;
+    this.opts = opts;
+  }
+
+  /**
+   * Unwrap the instance of `FluentType`.
+   *
+   * Unwrapped values are suitable for use outside of the `MessageContext`.
+   * This method can use `Intl` formatters memoized by the `MessageContext`
+   * instance passed as an argument.
+   *
+   * In most cases, valueOf returns a string, but it can be overriden
+   * and there are use cases, where the return type is not a string.
+   *
+   * An example is fluent-react which implements a custom `FluentType`
+   * to represent React elements passed as arguments to format().
+   *
+   * @param   {MessageContext} [ctx]
+   * @returns {string}
+   */
+  valueOf() {
+    throw new Error('Subclasses of FluentType must implement valueOf.');
+  }
+}
+
+class FluentNone extends FluentType {
+  valueOf() {
+    return this.value || '???';
+  }
+}
+
+class FluentNumber extends FluentType {
+  constructor(value, opts) {
+    super(parseFloat(value), opts);
+  }
+
+  valueOf(ctx) {
+    const nf = ctx._memoizeIntlObject(
+      Intl.NumberFormat, this.opts
+    );
+    return nf.format(this.value);
+  }
+
+  /**
+   * Compare the object with another instance of a FluentType.
+   *
+   * @param   {MessageContext} ctx
+   * @param   {FluentType}     other
+   * @returns {bool}
+   */
+  match(ctx, other) {
+    if (other instanceof FluentNumber) {
+      return this.value === other.value;
+    }
+    return false;
+  }
+}
+
+class FluentDateTime extends FluentType {
+  constructor(value, opts) {
+    super(new Date(value), opts);
+  }
+
+  valueOf(ctx) {
+    const dtf = ctx._memoizeIntlObject(
+      Intl.DateTimeFormat, this.opts
+    );
+    return dtf.format(this.value);
+  }
+}
+
+class FluentSymbol extends FluentType {
+  valueOf() {
+    return this.value;
+  }
+
+  /**
+   * Compare the object with another instance of a FluentType.
+   *
+   * @param   {MessageContext} ctx
+   * @param   {FluentType}     other
+   * @returns {bool}
+   */
+  match(ctx, other) {
+    if (other instanceof FluentSymbol) {
+      return this.value === other.value;
+    } else if (typeof other === 'string') {
+      return this.value === other;
+    } else if (other instanceof FluentNumber) {
+      const pr = ctx._memoizeIntlObject(
+        Intl.PluralRules, other.opts
+      );
+      return this.value === pr.select(other.value);
+    } else if (Array.isArray(other)) {
+      const values = other.map(symbol => symbol.value);
+      return values.includes(this.value);
+    }
+    return false;
+  }
+}
+
+/**
+ * @overview
+ *
+ * The FTL resolver ships with a number of functions built-in.
+ *
+ * Each function take two arguments:
+ *   - args - an array of positional args
+ *   - opts - an object of key-value args
+ *
+ * Arguments to functions are guaranteed to already be instances of
+ * `FluentType`.  Functions must return `FluentType` objects as well.
+ */
+
+const builtins = {
+  'NUMBER': ([arg], opts) =>
+    new FluentNumber(arg.value, merge(arg.opts, opts)),
+  'DATETIME': ([arg], opts) =>
+    new FluentDateTime(arg.value, merge(arg.opts, opts)),
+};
+
+function merge(argopts, opts) {
+  return Object.assign({}, argopts, values(opts));
+}
+
+function values(opts) {
+  const unwrapped = {};
+  for (const name of Object.keys(opts)) {
+    unwrapped[name] = opts[name].value;
+  }
+  return unwrapped;
+}
+
+/**
+ * @overview
+ *
+ * The role of the Fluent resolver is to format a translation object to an
+ * instance of `FluentType` or an array of instances.
+ *
+ * Translations can contain references to other messages or external arguments,
+ * conditional logic in form of select expressions, traits which describe their
+ * grammatical features, and can use Fluent builtins which make use of the
+ * `Intl` formatters to format numbers, dates, lists and more into the
+ * context's language.  See the documentation of the Fluent syntax for more
+ * information.
+ *
+ * In case of errors the resolver will try to salvage as much of the
+ * translation as possible.  In rare situations where the resolver didn't know
+ * how to recover from an error it will return an instance of `FluentNone`.
+ *
+ * `MessageReference`, `VariantExpression`, `AttributeExpression` and
+ * `SelectExpression` resolve to raw Runtime Entries objects and the result of
+ * the resolution needs to be passed into `Type` to get their real value.
+ * This is useful for composing expressions.  Consider:
+ *
+ *     brand-name[nominative]
+ *
+ * which is a `VariantExpression` with properties `id: MessageReference` and
+ * `key: Keyword`.  If `MessageReference` was resolved eagerly, it would
+ * instantly resolve to the value of the `brand-name` message.  Instead, we
+ * want to get the message object and look for its `nominative` variant.
+ *
+ * All other expressions (except for `FunctionReference` which is only used in
+ * `CallExpression`) resolve to an instance of `FluentType`.  The caller should
+ * use the `valueOf` method to convert the instance to a native value.
+ *
+ *
+ * All functions in this file pass around a special object called `env`.
+ * This object stores a set of elements used by all resolve functions:
+ *
+ *  * {MessageContext} ctx
+ *      context for which the given resolution is happening
+ *  * {Object} args
+ *      list of developer provided arguments that can be used
+ *  * {Array} errors
+ *      list of errors collected while resolving
+ *  * {WeakSet} dirty
+ *      Set of patterns already encountered during this resolution.
+ *      This is used to prevent cyclic resolutions.
+ */
+
+
+// Prevent expansion of too long placeables.
+const MAX_PLACEABLE_LENGTH = 2500;
+
+// Unicode bidi isolation characters.
+const FSI = '\u2068';
+const PDI = '\u2069';
+
+
+/**
+ * Helper for computing the total character length of a placeable.
+ *
+ * Used in Pattern.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Array}  parts
+ *    List of parts of a placeable.
+ * @returns {Number}
+ * @private
+ */
+function PlaceableLength(env, parts) {
+  const { ctx } = env;
+  return parts.reduce(
+    (sum, part) => sum + part.valueOf(ctx).length,
+    0
+  );
+}
+
+
+/**
+ * Helper for choosing the default value from a set of members.
+ *
+ * Used in SelectExpressions and Type.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} members
+ *    Hash map of variants from which the default value is to be selected.
+ * @param   {Number} def
+ *    The index of the default variant.
+ * @returns {FluentType}
+ * @private
+ */
+function DefaultMember(env, members, def) {
+  if (members[def]) {
+    return members[def];
+  }
+
+  const { errors } = env;
+  errors.push(new RangeError('No default'));
+  return new FluentNone();
+}
+
+
+/**
+ * Resolve a reference to another message.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} id
+ *    The identifier of the message to be resolved.
+ * @param   {String} id.name
+ *    The name of the identifier.
+ * @returns {FluentType}
+ * @private
+ */
+function MessageReference(env, {name}) {
+  const { ctx, errors } = env;
+  const message = ctx.getMessage(name);
+
+  if (!message) {
+    errors.push(new ReferenceError(`Unknown message: ${name}`));
+    return new FluentNone(name);
+  }
+
+  return message;
+}
+
+/**
+ * Resolve an array of tags.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} id
+ *    The identifier of the message with tags.
+ * @param   {String} id.name
+ *    The name of the identifier.
+ * @returns {Array}
+ * @private
+ */
+function Tags(env, {name}) {
+  const { ctx, errors } = env;
+  const message = ctx.getMessage(name);
+
+  if (!message) {
+    errors.push(new ReferenceError(`Unknown message: ${name}`));
+    return new FluentNone(name);
+  }
+
+  if (!message.tags) {
+    errors.push(new RangeError(`No tags in message "${name}"`));
+    return new FluentNone(name);
+  }
+
+  return message.tags.map(
+    tag => new FluentSymbol(tag)
+  );
+}
+
+
+/**
+ * Resolve a variant expression to the variant object.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {Object} expr.id
+ *    An Identifier of a message for which the variant is resolved.
+ * @param   {Object} expr.id.name
+ *    Name a message for which the variant is resolved.
+ * @param   {Object} expr.key
+ *    Variant key to be resolved.
+ * @returns {FluentType}
+ * @private
+ */
+function VariantExpression(env, {id, key}) {
+  const message = MessageReference(env, id);
+  if (message instanceof FluentNone) {
+    return message;
+  }
+
+  const { ctx, errors } = env;
+  const keyword = Type(env, key);
+
+  function isVariantList(node) {
+    return Array.isArray(node) &&
+      node[0].type === 'sel' &&
+      node[0].exp === null;
+  }
+
+  if (isVariantList(message.val)) {
+    // Match the specified key against keys of each variant, in order.
+    for (const variant of message.val[0].vars) {
+      const variantKey = Type(env, variant.key);
+      if (keyword.match(ctx, variantKey)) {
+        return variant;
+      }
+    }
+  }
+
+  errors.push(new ReferenceError(`Unknown variant: ${keyword.valueOf(ctx)}`));
+  return Type(env, message);
+}
+
+
+/**
+ * Resolve an attribute expression to the attribute object.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {String} expr.id
+ *    An ID of a message for which the attribute is resolved.
+ * @param   {String} expr.name
+ *    Name of the attribute to be resolved.
+ * @returns {FluentType}
+ * @private
+ */
+function AttributeExpression(env, {id, name}) {
+  const message = MessageReference(env, id);
+  if (message instanceof FluentNone) {
+    return message;
+  }
+
+  if (message.attrs) {
+    // Match the specified name against keys of each attribute.
+    for (const attrName in message.attrs) {
+      if (name === attrName) {
+        return message.attrs[name];
+      }
+    }
+  }
+
+  const { errors } = env;
+  errors.push(new ReferenceError(`Unknown attribute: ${name}`));
+  return Type(env, message);
+}
+
+/**
+ * Resolve a select expression to the member object.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {String} expr.exp
+ *    Selector expression
+ * @param   {Array} expr.vars
+ *    List of variants for the select expression.
+ * @param   {Number} expr.def
+ *    Index of the default variant.
+ * @returns {FluentType}
+ * @private
+ */
+function SelectExpression(env, {exp, vars, def}) {
+  if (exp === null) {
+    return DefaultMember(env, vars, def);
+  }
+
+  const selector = exp.type === 'ref'
+    ? Tags(env, exp)
+    : Type(env, exp);
+  if (selector instanceof FluentNone) {
+    return DefaultMember(env, vars, def);
+  }
+
+  // Match the selector against keys of each variant, in order.
+  for (const variant of vars) {
+    const key = Type(env, variant.key);
+    const keyCanMatch =
+      key instanceof FluentNumber || key instanceof FluentSymbol;
+
+    if (!keyCanMatch) {
+      continue;
+    }
+
+    const { ctx } = env;
+
+    if (key.match(ctx, selector)) {
+      return variant;
+    }
+  }
+
+  return DefaultMember(env, vars, def);
+}
+
+
+/**
+ * Resolve expression to a Fluent type.
+ *
+ * JavaScript strings are a special case.  Since they natively have the
+ * `valueOf` method they can be used as if they were a Fluent type without
+ * paying the cost of creating a instance of one.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression object to be resolved into a Fluent type.
+ * @returns {FluentType}
+ * @private
+ */
+function Type(env, expr) {
+  // A fast-path for strings which are the most common case, and for
+  // `FluentNone` which doesn't require any additional logic.
+  if (typeof expr === 'string' || expr instanceof FluentNone) {
+    return expr;
+  }
+
+  // The Runtime AST (Entries) encodes patterns (complex strings with
+  // placeables) as Arrays.
+  if (Array.isArray(expr)) {
+    return Pattern(env, expr);
+  }
+
+
+  switch (expr.type) {
+    case 'sym':
+      return new FluentSymbol(expr.name);
+    case 'num':
+      return new FluentNumber(expr.val);
+    case 'ext':
+      return ExternalArgument(env, expr);
+    case 'fun':
+      return FunctionReference(env, expr);
+    case 'call':
+      return CallExpression(env, expr);
+    case 'ref': {
+      const message = MessageReference(env, expr);
+      return Type(env, message);
+    }
+    case 'attr': {
+      const attr = AttributeExpression(env, expr);
+      return Type(env, attr);
+    }
+    case 'var': {
+      const variant = VariantExpression(env, expr);
+      return Type(env, variant);
+    }
+    case 'sel': {
+      const member = SelectExpression(env, expr);
+      return Type(env, member);
+    }
+    case undefined: {
+      // If it's a node with a value, resolve the value.
+      if (expr.val !== undefined) {
+        return Type(env, expr.val);
+      }
+
+      const { errors } = env;
+      errors.push(new RangeError('No value'));
+      return new FluentNone();
+    }
+    default:
+      return new FluentNone();
+  }
+}
+
+/**
+ * Resolve a reference to an external argument.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {String} expr.name
+ *    Name of an argument to be returned.
+ * @returns {FluentType}
+ * @private
+ */
+function ExternalArgument(env, {name}) {
+  const { args, errors } = env;
+
+  if (!args || !args.hasOwnProperty(name)) {
+    errors.push(new ReferenceError(`Unknown external: ${name}`));
+    return new FluentNone(name);
+  }
+
+  const arg = args[name];
+
+  if (arg instanceof FluentType) {
+    return arg;
+  }
+
+  // Convert the argument to a Fluent type.
+  switch (typeof arg) {
+    case 'string':
+      return arg;
+    case 'number':
+      return new FluentNumber(arg);
+    case 'object':
+      if (arg instanceof Date) {
+        return new FluentDateTime(arg);
+      }
+    default:
+      errors.push(
+        new TypeError(`Unsupported external type: ${name}, ${typeof arg}`)
+      );
+      return new FluentNone(name);
+  }
+}
+
+/**
+ * Resolve a reference to a function.
+ *
+ * @param   {Object}  env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {String} expr.name
+ *    Name of the function to be returned.
+ * @returns {Function}
+ * @private
+ */
+function FunctionReference(env, {name}) {
+  // Some functions are built-in.  Others may be provided by the runtime via
+  // the `MessageContext` constructor.
+  const { ctx: { _functions }, errors } = env;
+  const func = _functions[name] || builtins[name];
+
+  if (!func) {
+    errors.push(new ReferenceError(`Unknown function: ${name}()`));
+    return new FluentNone(`${name}()`);
+  }
+
+  if (typeof func !== 'function') {
+    errors.push(new TypeError(`Function ${name}() is not callable`));
+    return new FluentNone(`${name}()`);
+  }
+
+  return func;
+}
+
+/**
+ * Resolve a call to a Function with positional and key-value arguments.
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Object} expr
+ *    An expression to be resolved.
+ * @param   {Object} expr.fun
+ *    FTL Function object.
+ * @param   {Array} expr.args
+ *    FTL Function argument list.
+ * @returns {FluentType}
+ * @private
+ */
+function CallExpression(env, {fun, args}) {
+  const callee = FunctionReference(env, fun);
+
+  if (callee instanceof FluentNone) {
+    return callee;
+  }
+
+  const posargs = [];
+  const keyargs = [];
+
+  for (const arg of args) {
+    if (arg.type === 'narg') {
+      keyargs[arg.name] = Type(env, arg.val);
+    } else {
+      posargs.push(Type(env, arg));
+    }
+  }
+
+  // XXX functions should also report errors
+  return callee(posargs, keyargs);
+}
+
+/**
+ * Resolve a pattern (a complex string with placeables).
+ *
+ * @param   {Object} env
+ *    Resolver environment object.
+ * @param   {Array} ptn
+ *    Array of pattern elements.
+ * @returns {Array}
+ * @private
+ */
+function Pattern(env, ptn) {
+  const { ctx, dirty, errors } = env;
+
+  if (dirty.has(ptn)) {
+    errors.push(new RangeError('Cyclic reference'));
+    return new FluentNone();
+  }
+
+  // Tag the pattern as dirty for the purpose of the current resolution.
+  dirty.add(ptn);
+  const result = [];
+
+  for (const elem of ptn) {
+    if (typeof elem === 'string') {
+      result.push(elem);
+      continue;
+    }
+
+    const part = Type(env, elem);
+
+    if (ctx._useIsolating) {
+      result.push(FSI);
+    }
+
+    if (Array.isArray(part)) {
+      const len = PlaceableLength(env, part);
+
+      if (len > MAX_PLACEABLE_LENGTH) {
+        errors.push(
+          new RangeError(
+            'Too many characters in placeable ' +
+            `(${len}, max allowed is ${MAX_PLACEABLE_LENGTH})`
+          )
+        );
+        result.push(new FluentNone());
+      } else {
+        result.push(...part);
+      }
+    } else {
+      result.push(part);
+    }
+
+    if (ctx._useIsolating) {
+      result.push(PDI);
+    }
+  }
+
+  dirty.delete(ptn);
+  return result;
+}
+
+/**
+ * Format a translation into an `FluentType`.
+ *
+ * The return value must be unwrapped via `valueOf` by the caller.
+ *
+ * @param   {MessageContext} ctx
+ *    A MessageContext instance which will be used to resolve the
+ *    contextual information of the message.
+ * @param   {Object}         args
+ *    List of arguments provided by the developer which can be accessed
+ *    from the message.
+ * @param   {Object}         message
+ *    An object with the Message to be resolved.
+ * @param   {Array}          errors
+ *    An error array that any encountered errors will be appended to.
+ * @returns {FluentType}
+ */
+function resolve(ctx, args, message, errors = []) {
+  const env = {
+    ctx, args, errors, dirty: new WeakSet()
+  };
+  return Type(env, message);
+}
+
+/**
+ * Message contexts are single-language stores of translations.  They are
+ * responsible for parsing translation resources in the Fluent syntax and can
+ * format translation units (entities) to strings.
+ *
+ * Always use `MessageContext.format` to retrieve translation units from
+ * a context.  Translations can contain references to other entities or
+ * external arguments, conditional logic in form of select expressions, traits
+ * which describe their grammatical features, and can use Fluent builtins which
+ * make use of the `Intl` formatters to format numbers, dates, lists and more
+ * into the context's language.  See the documentation of the Fluent syntax for
+ * more information.
+ */
+class MessageContext {
+
+  /**
+   * Create an instance of `MessageContext`.
+   *
+   * The `locales` argument is used to instantiate `Intl` formatters used by
+   * translations.  The `options` object can be used to configure the context.
+   *
+   * Examples:
+   *
+   *     const ctx = new MessageContext(locales);
+   *
+   *     const ctx = new MessageContext(locales, { useIsolating: false });
+   *
+   *     const ctx = new MessageContext(locales, {
+   *       useIsolating: true,
+   *       functions: {
+   *         NODE_ENV: () => process.env.NODE_ENV
+   *       }
+   *     });
+   *
+   * Available options:
+   *
+   *   - `functions` - an object of additional functions available to
+   *                   translations as builtins.
+   *
+   *   - `useIsolating` - boolean specifying whether to use Unicode isolation
+   *                    marks (FSI, PDI) for bidi interpolations.
+   *
+   * @param   {string|Array<string>} locales - Locale or locales of the context
+   * @param   {Object} [options]
+   * @returns {MessageContext}
+   */
+  constructor(locales, { functions = {}, useIsolating = true } = {}) {
+    this.locales = Array.isArray(locales) ? locales : [locales];
+
+    this._messages = new Map();
+    this._functions = functions;
+    this._useIsolating = useIsolating;
+    this._intls = new WeakMap();
+  }
+
+  /*
+   * Return an iterator over `[id, message]` pairs.
+   *
+   * @returns {Iterator}
+   */
+  get messages() {
+    return this._messages[Symbol.iterator]();
+  }
+
+  /*
+   * Check if a message is present in the context.
+   *
+   * @param {string} id - The identifier of the message to check.
+   * @returns {bool}
+   */
+  hasMessage(id) {
+    return this._messages.has(id);
+  }
+
+  /*
+   * Return the internal representation of a message.
+   *
+   * The internal representation should only be used as an argument to
+   * `MessageContext.format` and `MessageContext.formatToParts`.
+   *
+   * @param {string} id - The identifier of the message to check.
+   * @returns {Any}
+   */
+  getMessage(id) {
+    return this._messages.get(id);
+  }
+
+  /**
+   * Add a translation resource to the context.
+   *
+   * The translation resource must use the Fluent syntax.  It will be parsed by
+   * the context and each translation unit (message) will be available in the
+   * context by its identifier.
+   *
+   *     ctx.addMessages('foo = Foo');
+   *     ctx.getMessage('foo');
+   *
+   *     // Returns a raw representation of the 'foo' message.
+   *
+   * Parsed entities should be formatted with the `format` method in case they
+   * contain logic (references, select expressions etc.).
+   *
+   * @param   {string} source - Text resource with translations.
+   * @returns {Array<Error>}
+   */
+  addMessages(source) {
+    const [entries, errors] = parse(source);
+    for (const id in entries) {
+      this._messages.set(id, entries[id]);
+    }
+
+    return errors;
+  }
+
+  /**
+   * Format a message to an array of `FluentTypes` or null.
+   *
+   * Format a raw `message` from the context into an array of `FluentType`
+   * instances which may be used to build the final result.  It may also return
+   * `null` if it has a null value.  `args` will be used to resolve references
+   * to external arguments inside of the translation.
+   *
+   * See the documentation of {@link MessageContext#format} for more
+   * information about error handling.
+   *
+   * In case of errors `format` will try to salvage as much of the translation
+   * as possible and will still return a string.  For performance reasons, the
+   * encountered errors are not returned but instead are appended to the
+   * `errors` array passed as the third argument.
+   *
+   *     ctx.addMessages('hello = Hello, { $name }!');
+   *     const hello = ctx.getMessage('hello');
+   *     ctx.formatToParts(hello, { name: 'Jane' }, []);
+   *     // → ['Hello, ', '\u2068', 'Jane', '\u2069']
+   *
+   * The returned parts need to be formatted via `valueOf` before they can be
+   * used further.  This will ensure all values are correctly formatted
+   * according to the `MessageContext`'s locale.
+   *
+   *     const parts = ctx.formatToParts(hello, { name: 'Jane' }, []);
+   *     const str = parts.map(part => part.valueOf(ctx)).join('');
+   *
+   * @see MessageContext#format
+   * @param   {Object | string}    message
+   * @param   {Object | undefined} args
+   * @param   {Array}              errors
+   * @returns {?Array<FluentType>}
+   */
+  formatToParts(message, args, errors) {
+    // optimize entities which are simple strings with no attributes
+    if (typeof message === 'string') {
+      return [message];
+    }
+
+    // optimize simple-string entities with attributes
+    if (typeof message.val === 'string') {
+      return [message.val];
+    }
+
+    // optimize entities with null values
+    if (message.val === undefined) {
+      return null;
+    }
+
+    const result = resolve(this, args, message, errors);
+
+    return result instanceof FluentNone ? null : result;
+  }
+
+  /**
+   * Format a message to a string or null.
+   *
+   * Format a raw `message` from the context into a string (or a null if it has
+   * a null value).  `args` will be used to resolve references to external
+   * arguments inside of the translation.
+   *
+   * In case of errors `format` will try to salvage as much of the translation
+   * as possible and will still return a string.  For performance reasons, the
+   * encountered errors are not returned but instead are appended to the
+   * `errors` array passed as the third argument.
+   *
+   *     const errors = [];
+   *     ctx.addMessages('hello = Hello, { $name }!');
+   *     const hello = ctx.getMessage('hello');
+   *     ctx.format(hello, { name: 'Jane' }, errors);
+   *
+   *     // Returns 'Hello, Jane!' and `errors` is empty.
+   *
+   *     ctx.format(hello, undefined, errors);
+   *
+   *     // Returns 'Hello, name!' and `errors` is now:
+   *
+   *     [<ReferenceError: Unknown external: name>]
+   *
+   * @param   {Object | string}    message
+   * @param   {Object | undefined} args
+   * @param   {Array}              errors
+   * @returns {?string}
+   */
+  format(message, args, errors) {
+    // optimize entities which are simple strings with no attributes
+    if (typeof message === 'string') {
+      return message;
+    }
+
+    // optimize simple-string entities with attributes
+    if (typeof message.val === 'string') {
+      return message.val;
+    }
+
+    // optimize entities with null values
+    if (message.val === undefined) {
+      return null;
+    }
+
+    const result = resolve(this, args, message, errors);
+
+    if (result instanceof FluentNone) {
+      return null;
+    }
+
+    return result.map(part => part.valueOf(this)).join('');
+  }
+
+  _memoizeIntlObject(ctor, opts) {
+    const cache = this._intls.get(ctor) || {};
+    const id = JSON.stringify(opts);
+
+    if (!cache[id]) {
+      cache[id] = new ctor(this.locales, opts);
+      this._intls.set(ctor, cache);
+    }
+
+    return cache[id];
+  }
+}
+
+this.MessageContext = MessageContext;
+this.EXPORTED_SYMBOLS = [];
new file mode 100644
--- /dev/null
+++ b/intl/l10n/moz.build
@@ -0,0 +1,13 @@
+# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*-
+# vim: set filetype=python:
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+EXTRA_JS_MODULES += [
+    'MessageContext.jsm',
+]
+
+XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell.ini']
+
+FINAL_LIBRARY = 'xul'
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/test_messagecontext.js
@@ -0,0 +1,39 @@
+/* Any copyright is dedicated to the Public Domain.
+   http://creativecommons.org/publicdomain/zero/1.0/ */
+
+function run_test() {
+  const { MessageContext } = Components.utils.import("resource://gre/modules/MessageContext.jsm", {});
+
+  test_methods_presence(MessageContext);
+  test_methods_calling(MessageContext);
+
+  ok(true);
+}
+
+function test_methods_presence(MessageContext) {
+  const ctx = new MessageContext(["en-US", "pl"]);
+  equal(typeof ctx.addMessages, "function");
+  equal(typeof ctx.format, "function");
+  equal(typeof ctx.formatToParts, "function");
+}
+
+function test_methods_calling(MessageContext) {
+  const ctx = new MessageContext(["en-US", "pl"], {
+    useIsolating: false
+  });
+  ctx.addMessages("key = Value");
+
+  const msg = ctx.getMessage("key");
+  equal(ctx.format(msg), "Value");
+  deepEqual(ctx.formatToParts(msg), ["Value"]);
+
+  ctx.addMessages("key2 = Hello { $name }");
+
+  const msg2 = ctx.getMessage("key2");
+  equal(ctx.format(msg2, { name: "Amy" }), "Hello Amy");
+  deepEqual(ctx.formatToParts(msg2), ["Hello ", {
+    value: "name",
+    opts: undefined
+  }]);
+  ok(true);
+}
new file mode 100644
--- /dev/null
+++ b/intl/l10n/test/xpcshell.ini
@@ -0,0 +1,4 @@
+[DEFAULT]
+head =
+
+[test_messagecontext.js]
--- a/intl/moz.build
+++ b/intl/moz.build
@@ -11,16 +11,17 @@ TEST_DIRS += [
 DIRS += [
     'hyphenation/hyphen',
     'hyphenation/glue',
     'locale',
     'locales',
     'lwbrk',
     'strres',
     'unicharutil',
+    'l10n',
 ]
 
 DIRS += [
     'uconv',
     'build',
 ]
 
 EXPORTS.mozilla += [