Bug 1347801 - Land Fluent MessageContext (Parser, Resolver, Context). r?mossop draft
authorZibi Braniecki <zbraniecki@mozilla.com>
Fri, 02 Jun 2017 10:35:15 +0200
changeset 642881 c6e02db23b901ff33dce210545e490660ea0eed0
parent 642173 fde1450a4368d04e97174e2eb00fb48901179857
child 642882 143d5d3b336826445d4ca0a6526b98849117a457
push id72899
push userbmo:gandalf@aviary.pl
push dateTue, 08 Aug 2017 22:33:04 +0000
reviewersmossop
bugs1347801
milestone57.0a1
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 += [