Bug 1288639 - Implement MessageContext, r?Pike draft
authorStaś Małolepszy <stas@mozilla.com>
Thu, 28 Jul 2016 17:36:41 +0200
changeset 393781 5d20c066750e95998cab659625d21d00bac75bdd
parent 383145 fdcee57b4e4f66a82831ab01e61500da98a858e8
child 526678 b31e0d418af5fadde6b22f0b73b183832df5d41a
push id24424
push usersmalolepszy@mozilla.com
push dateThu, 28 Jul 2016 15:42:20 +0000
reviewersPike
bugs1288639
milestone50.0a1
Bug 1288639 - Implement MessageContext, r?Pike This is a JSM polyfill for a future Intl.MessageContext proposal. MozReview-Commit-ID: 1BhjR6TNUDQ
toolkit/modules/IntlMessageContext.jsm
toolkit/modules/moz.build
new file mode 100644
--- /dev/null
+++ b/toolkit/modules/IntlMessageContext.jsm
@@ -0,0 +1,1243 @@
+/* vim: set ts=2 et sw=2 tw=80 filetype=javascript: */
+/* 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/. */
+'use strict';
+
+class L10nError extends Error {
+  constructor(message, id, lang) {
+    super();
+    this.name = 'L10nError';
+    this.message = message;
+    this.id = id;
+    this.lang = lang;
+  }
+}
+
+const MAX_PLACEABLES = 100;
+
+
+class ParseContext {
+  constructor(string) {
+    this._source = string;
+    this._index = 0;
+    this._length = string.length;
+
+    this._lastGoodEntryEnd = 0;
+  }
+
+  getResource() {
+    const entries = {};
+    const errors = [];
+
+    this.getWS();
+    while (this._index < this._length) {
+      try {
+        const entry = this.getEntry();
+        if (!entry) {
+          this.getWS();
+          continue;
+        }
+
+        const id = entry.id;
+        entries[id] = {};
+
+        if (entry.traits !== null &&
+           entry.traits.length !== 0) {
+          entries[id].traits = entry.traits;
+          if (entry.value) {
+            entries[id].val = entry.value;
+          }
+        } else {
+          entries[id] = entry.value;
+        }
+        this._lastGoodEntryEnd = this._index;
+      } catch (e) {
+        if (e instanceof L10nError) {
+          errors.push(e);
+          this.getJunkEntry();
+        } else {
+          throw e;
+        }
+      }
+      this.getWS();
+    }
+
+    return [entries, errors];
+  }
+
+  getEntry() {
+    if (this._index !== 0 &&
+        this._source[this._index - 1] !== '\n') {
+      throw this.error('Expected new line and a new entry');
+    }
+
+    if (this._source[this._index] === '#') {
+      this.getComment();
+      return null;
+    }
+
+    if (this._source[this._index] === '[') {
+      this.getSection();
+      return null;
+    }
+
+    if (this._index < this._length &&
+        this._source[this._index] !== '\n') {
+      return this.getEntity();
+    }
+    return null;
+  }
+
+  getSection() {
+    this._index += 1;
+    if (this._source[this._index] !== '[') {
+      throw this.error('Expected "[[" to open a section');
+    }
+
+    this._index += 1;
+
+    this.getLineWS();
+    this.getKeyword();
+    this.getLineWS();
+
+    if (this._source[this._index] !== ']' ||
+        this._source[this._index + 1] !== ']') {
+      throw this.error('Expected "]]" to close a section');
+    }
+
+    this._index += 2;
+
+    // sections are ignored in the runtime ast
+    return undefined;
+  }
+
+  getEntity() {
+    const id = this.getIdentifier();
+
+    let traits = null;
+    let value = null;
+
+    this.getLineWS();
+
+    let ch = this._source[this._index];
+
+    if (ch !== '=') {
+      throw this.error('Expected "=" after Entity ID');
+    }
+    ch = this._source[++this._index];
+
+    this.getLineWS();
+
+    value = this.getPattern();
+
+    ch = this._source[this._index];
+
+    if (ch === '\n') {
+      this._index++;
+      this.getLineWS();
+      ch = this._source[this._index];
+    }
+
+    if ((ch === '[' && this._source[this._index + 1] !== '[') ||
+        ch === '*') {
+      traits = this.getMembers();
+    } else if (value === null) {
+      throw this.error(
+        'Expected a value (like: " = value") or a trait (like: "[key] value")'
+      );
+    }
+
+    return {
+      id,
+      value,
+      traits
+    };
+  }
+
+  getWS() {
+    let cc = this._source.charCodeAt(this._index);
+    // space, \n, \t, \r
+    while (cc === 32 || cc === 10 || cc === 9 || cc === 13) {
+      cc = this._source.charCodeAt(++this._index);
+    }
+  }
+
+  getLineWS() {
+    let cc = this._source.charCodeAt(this._index);
+    // space, \t
+    while (cc === 32 || cc === 9) {
+      cc = this._source.charCodeAt(++this._index);
+    }
+  }
+
+  getIdentifier() {
+    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 = this._source.charCodeAt(++this._index);
+    } else if (name.length === 0) {
+      throw this.error('Expected an identifier (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 = this._source.charCodeAt(++this._index);
+    }
+
+    name += this._source.slice(start, this._index);
+
+    return name;
+  }
+
+  getKeyword() {
+    let name = '';
+    let namespace = this.getIdentifier();
+
+    if (this._source[this._index] === '/') {
+      this._index++;
+    } else if (namespace) {
+      name = namespace;
+      namespace = null;
+    }
+
+    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) {  //  _
+      cc = this._source.charCodeAt(++this._index);
+    } else if (name.length === 0) {
+      throw this.error('Expected an identifier (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) {  //  _-
+      cc = this._source.charCodeAt(++this._index);
+    }
+
+    name += this._source.slice(start, this._index).trimRight();
+
+    return namespace ?
+      { type: 'kw', ns: namespace, name } :
+      { type: 'kw', name };
+  }
+
+  getPattern() {
+    const start = this._index;
+    if (this._source[start] === '"') {
+      return this.getComplexPattern();
+    }
+    let eol = this._source.indexOf('\n', this._index);
+
+    if (eol === -1) {
+      eol = this._length;
+    }
+
+    const line = this._source.slice(start, eol);
+
+    if (line.indexOf('{') !== -1) {
+      return this.getComplexPattern();
+    }
+
+    this._index = eol + 1;
+
+    this.getWS();
+
+    if (this._source[this._index] === '|') {
+      this._index = start;
+      return this.getComplexPattern();
+    }
+
+    return this._source.slice(start, eol);
+  }
+
+  /* eslint-disable complexity */
+  getComplexPattern() {
+    let buffer = '';
+    const content = [];
+    let placeables = 0;
+    let quoteDelimited = null;
+    let firstLine = true;
+
+    let ch = this._source[this._index];
+
+
+    if (ch === '\\' &&
+      (this._source[this._index + 1] === '"' ||
+       this._source[this._index + 1] === '{' ||
+       this._source[this._index + 1] === '\\')) {
+      buffer += this._source[this._index + 1];
+      this._index += 2;
+      ch = this._source[this._index];
+    } else if (ch === '"') {
+      quoteDelimited = true;
+      this._index++;
+      ch = this._source[this._index];
+    }
+
+    while (this._index < this._length) {
+      if (ch === '\n') {
+        if (quoteDelimited) {
+          throw this.error('Unclosed string');
+        }
+        this._index++;
+        this.getLineWS();
+        if (this._source[this._index] !== '|') {
+          break;
+        }
+        if (firstLine && buffer.length) {
+          throw this.error('Multiline string should have the ID line empty');
+        }
+        firstLine = false;
+        this._index++;
+        if (this._source[this._index] === ' ') {
+          this._index++;
+        }
+        if (buffer.length) {
+          buffer += '\n';
+        }
+        ch = this._source[this._index];
+        continue;
+      } else if (ch === '\\') {
+        const ch2 = this._source[this._index + 1];
+        if ((quoteDelimited && ch2 === '"') ||
+            ch2 === '{') {
+          ch = ch2;
+          this._index++;
+        }
+      } else if (quoteDelimited && ch === '"') {
+        this._index++;
+        quoteDelimited = false;
+        break;
+      } else if (ch === '{') {
+        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());
+        ch = this._source[this._index];
+        placeables++;
+        continue;
+      }
+
+      if (ch) {
+        buffer += ch;
+      }
+      this._index++;
+      ch = this._source[this._index];
+    }
+
+    if (quoteDelimited) {
+      throw this.error('Unclosed string');
+    }
+
+    if (buffer.length) {
+      content.push(buffer);
+    }
+
+    if (content.length === 0) {
+      if (quoteDelimited !== null) {
+        return '';
+      } else {
+        return null;
+      }
+    }
+
+    if (content.length === 1 &&
+        typeof content[0] === 'string') {
+      return content[0];
+    }
+
+    return content;
+  }
+  /* eslint-enable complexity */
+
+  getPlaceable() {
+    this._index++;
+
+    const expressions = [];
+
+    this.getLineWS();
+
+    while (this._index < this._length) {
+      const start = this._index;
+      try {
+        expressions.push(this.getPlaceableExpression());
+      } catch (e) {
+        throw this.error(e.description, start);
+      }
+      this.getWS();
+      if (this._source[this._index] === '}') {
+        this._index++;
+        break;
+      } else if (this._source[this._index] === ',') {
+        this._index++;
+        this.getWS();
+      } else {
+        throw this.error('Expected "}" or ","');
+      }
+    }
+
+    return expressions;
+  }
+
+  getPlaceableExpression() {
+    const selector = this.getCallExpression();
+    let members = null;
+
+    this.getWS();
+
+    if (this._source[this._index] !== '}' &&
+        this._source[this._index] !== ',') {
+      if (this._source[this._index] !== '-' ||
+          this._source[this._index + 1] !== '>') {
+        throw this.error('Expected "}", "," or "->"');
+      }
+      this._index += 2; // ->
+
+      this.getLineWS();
+
+      if (this._source[this._index] !== '\n') {
+        throw this.error('Members should be listed in a new line');
+      }
+
+      this.getWS();
+
+      members = this.getMembers();
+
+      if (members.length === 0) {
+        throw this.error('Expected members for the select expression');
+      }
+    }
+
+    if (members === null) {
+      return selector;
+    }
+    return {
+      type: 'sel',
+      exp: selector,
+      vars: members
+    };
+  }
+
+  getCallExpression() {
+    const exp = this.getMemberExpression();
+
+    if (this._source[this._index] !== '(') {
+      return exp;
+    }
+
+    this._index++;
+
+    const args = this.getCallArgs();
+
+    this._index++;
+
+    if (exp.type = 'ref') {
+      exp.type = 'fun';
+    }
+
+    return {
+      type: 'call',
+      name: exp,
+      args
+    };
+  }
+
+  getCallArgs() {
+    const args = [];
+
+    if (this._source[this._index] === ')') {
+      return args;
+    }
+
+    while (this._index < this._length) {
+      this.getLineWS();
+
+      const exp = this.getCallExpression();
+
+      if (exp.type !== 'ref' ||
+         exp.namespace !== undefined) {
+        args.push(exp);
+      } else {
+        this.getLineWS();
+
+        if (this._source[this._index] === ':') {
+          this._index++;
+          this.getLineWS();
+
+          const val = this.getCallExpression();
+
+          if (val.type === 'ref' ||
+              val.type === 'member') {
+            this._index = this._source.lastIndexOf('=', this._index) + 1;
+            throw this.error('Expected string in quotes');
+          }
+
+          args.push({
+            type: 'kv',
+            name: exp.name,
+            val
+          });
+        } else {
+          args.push(exp);
+        }
+      }
+
+      this.getLineWS();
+
+      if (this._source[this._index] === ')') {
+        break;
+      } else if (this._source[this._index] === ',') {
+        this._index++;
+      } else {
+        throw this.error('Expected "," or ")"');
+      }
+    }
+
+    return args;
+  }
+
+  getNumber() {
+    let num = '';
+    let cc = this._source.charCodeAt(this._index);
+
+    if (cc === 45) {
+      num += '-';
+      cc = this._source.charCodeAt(++this._index);
+    }
+
+    if (cc < 48 || cc > 57) {
+      throw this.error(`Unknown literal "${num}"`);
+    }
+
+    while (cc >= 48 && cc <= 57) {
+      num += this._source[this._index++];
+      cc = this._source.charCodeAt(this._index);
+    }
+
+    if (cc === 46) {
+      num += this._source[this._index++];
+      cc = this._source.charCodeAt(this._index);
+
+      if (cc < 48 || cc > 57) {
+        throw this.error(`Unknown literal "${num}"`);
+      }
+
+      while (cc >= 48 && cc <= 57) {
+        num += this._source[this._index++];
+        cc = this._source.charCodeAt(this._index);
+      }
+    }
+
+    return {
+      type: 'num',
+      val: num
+    };
+  }
+
+  getMemberExpression() {
+    let exp = this.getLiteral();
+
+    while (this._source[this._index] === '[') {
+      const keyword = this.getMemberKey();
+      exp = {
+        type: 'mem',
+        key: keyword,
+        obj: exp
+      };
+    }
+
+    return exp;
+  }
+
+  getMembers() {
+    const members = [];
+
+    while (this._index < this._length) {
+      if ((this._source[this._index] !== '[' ||
+           this._source[this._index + 1] === '[') &&
+          this._source[this._index] !== '*') {
+        break;
+      }
+      let def = false;
+      if (this._source[this._index] === '*') { 
+        this._index++;
+        def = true;
+      }
+
+      if (this._source[this._index] !== '[') {
+        throw this.error('Expected "["');
+      }
+
+      const key = this.getMemberKey();
+
+      this.getLineWS();
+
+      const value = this.getPattern();
+
+      const member = {
+        key,
+        val: value
+      };
+      if (def) {
+        member.def = true;
+      }
+      members.push(member);
+
+      this.getWS();
+    }
+
+    return members;
+  }
+
+  getMemberKey() {
+    this._index++;
+
+    const cc = this._source.charCodeAt(this._index);
+    let literal;
+
+    if ((cc >= 48 && cc <= 57) || cc === 45) {
+      literal = this.getNumber();
+    } else {
+      literal = this.getKeyword();
+    }
+
+    if (this._source[this._index] !== ']') {
+      throw this.error('Expected "]"');
+    }
+
+    this._index++;
+    return literal;
+  }
+
+  getLiteral() {
+    const cc = this._source.charCodeAt(this._index);
+    if ((cc >= 48 && cc <= 57) || cc === 45) {
+      return this.getNumber();
+    } else if (cc === 34) { // "
+      return this.getPattern();
+    } else if (cc === 36) { // $
+      this._index++;
+      return {
+        type: 'ext',
+        name: this.getIdentifier()
+      };
+    }
+
+    return {
+      type: 'ref',
+      name: this.getIdentifier()
+    };
+  }
+
+  getComment() {
+    let eol = this._source.indexOf('\n', this._index);
+
+    while (eol !== -1 && this._source[eol + 1] === '#') {
+      this._index = eol + 2;
+
+      eol = this._source.indexOf('\n', this._index);
+
+      if (eol === -1) {
+        break;
+      }
+    }
+
+    if (eol === -1) {
+      this._index = this._length;
+    } else {
+      this._index = eol + 1;
+    }
+  }
+
+  error(message, start=null) {
+    const pos = this._index;
+
+    if (start === null) {
+      start = pos;
+    }
+    start = this._findEntityStart(start);
+
+    const context = this._source.slice(start, pos + 10);
+
+    const msg = '\n\n  ' + message +
+      '\nat pos ' + pos + ':\n------\n…' + context + '\n------';
+    const err = new L10nError(msg);
+
+    const row = this._source.slice(0, pos).split('\n').length;
+    const col = pos - this._source.lastIndexOf('\n', pos - 1);
+    err._pos = {start: pos, end: undefined, col: col, row: row};
+    err.offset = pos - start;
+    err.description = message;
+    err.context = context;
+    return err;
+  }
+
+  getJunkEntry() {
+    const pos = this._index;
+
+    let nextEntity = this._findNextEntryStart(pos);
+
+    if (nextEntity === -1) {
+      nextEntity = this._length;
+    }
+
+    this._index = nextEntity;
+
+    let entityStart = this._findEntityStart(pos);
+
+    if (entityStart < this._lastGoodEntryEnd) {
+      entityStart = this._lastGoodEntryEnd;
+    }
+  }
+
+  _findEntityStart(pos) {
+    let start = pos;
+
+    while (true) {
+      start = this._source.lastIndexOf('\n', start - 2);
+      if (start === -1 || start === 0) {
+        start = 0;
+        break;
+      }
+      const cc = this._source.charCodeAt(start + 1);
+
+      if ((cc >= 97 && cc <= 122) || // a-z
+          (cc >= 65 && cc <= 90) ||  // A-Z
+           cc === 95) {              // _
+        start++;
+        break;
+      }
+    }
+
+    return start;
+  }
+
+  _findNextEntryStart(pos) {
+    let start = pos;
+
+    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 === 35 || cc === 91) {  // _#[
+          break;
+        }
+      }
+
+      start = this._source.indexOf('\n', start);
+
+      if (start === -1) {
+        break;
+      }
+      start++;
+    }
+
+    return start;
+  }
+}
+
+var FTLRuntimeParser = {
+  parseResource: function(string) {
+    const parseContext = new ParseContext(string);
+    return parseContext.getResource();
+  },
+};
+
+class ReadWrite {
+  constructor(fn) {
+    this.fn = fn;
+  }
+
+  run(ctx) {
+    return this.fn(ctx);
+  }
+
+  flatMap(fn) {
+    return new ReadWrite(ctx => {
+      const [cur, curErrs] = this.run(ctx);
+      const [val, valErrs] = fn(cur).run(ctx);
+      return [val, [...curErrs, ...valErrs]];
+    });
+  }
+}
+
+function ask() {
+  return new ReadWrite(ctx => [ctx, []]);
+}
+
+function tell(log) {
+  return new ReadWrite(() => [null, [log]]);
+}
+
+function unit(val) {
+  return new ReadWrite(() => [val, []]);
+}
+
+function resolve(iter) {
+  return function step(resume) {
+    const {value, done} = iter.next(resume);
+    const rw = (value instanceof ReadWrite) ?
+      value : unit(value);
+    return done ? rw : rw.flatMap(step);
+  }();
+}
+
+class FTLBase {
+  constructor(value, opts) {
+    this.value = value;
+    this.opts = opts;
+  }
+  valueOf() {
+    return this.value;
+  }
+}
+
+class FTLNone extends FTLBase {
+  toString() {
+    return this.value || '???';
+  }
+}
+
+class FTLNumber extends FTLBase {
+  constructor(value, opts) {
+    super(parseFloat(value), opts);
+  }
+  toString(ctx) {
+    const nf = ctx._memoizeIntlObject(
+      Intl.NumberFormat, this.opts
+    );
+    return nf.format(this.value);
+  }
+}
+
+class FTLDateTime extends FTLBase {
+  constructor(value, opts) {
+    super(new Date(value), opts);
+  }
+  toString(ctx) {
+    const dtf = ctx._memoizeIntlObject(
+      Intl.DateTimeFormat, this.opts
+    );
+    return dtf.format(this.value);
+  }
+}
+
+class FTLKeyword extends FTLBase {
+  toString() {
+    const { name, namespace } = this.value;
+    return namespace ? `${namespace}:${name}` : name;
+  }
+  match(ctx, other) {
+    const { name, namespace } = this.value;
+    if (other instanceof FTLKeyword) {
+      return name === other.value.name && namespace === other.value.namespace;
+    } else if (namespace) {
+      return false;
+    } else if (typeof other === 'string') {
+      return name === other;
+    } else if (other instanceof FTLNumber) {
+      const pr = ctx._memoizeIntlObject(
+        Intl.PluralRules, other.opts
+      );
+      return name === pr.select(other.valueOf());
+    } else {
+      return false;
+    }
+  }
+}
+
+class FTLList extends Array {
+  toString(ctx) {
+    const lf = ctx._memoizeIntlObject(
+      Intl.ListFormat // XXX add this.opts
+    );
+    const elems = this.map(
+      elem => elem.toString(ctx)
+    );
+    return lf.format(elems);
+  }
+}
+
+// each builtin takes two arguments:
+//  - args = an array of positional args
+//  - opts  = an object of key-value args
+
+var builtins = {
+  'NUMBER': ([arg], opts) =>
+    new FTLNumber(arg.valueOf(), merge(arg.opts, opts)),
+  'PLURAL': ([arg], opts) =>
+    new FTLNumber(arg.valueOf(), merge(arg.opts, opts)),
+  'DATETIME': ([arg], opts) =>
+    new FTLDateTime(arg.valueOf(), merge(arg.opts, opts)),
+  'LIST': (args) => FTLList.from(args),
+  'LEN': ([arg]) => new FTLNumber(arg.valueOf().length),
+  'TAKE': ([num, arg]) => FTLList.from(arg.valueOf().slice(0, num.value)),
+  'DROP': ([num, arg]) => FTLList.from(arg.valueOf().slice(num.value)),
+};
+
+function merge(argopts, opts) {
+  return Object.assign({}, argopts, valuesOf(opts));
+}
+
+function valuesOf(opts) {
+  return Object.keys(opts).reduce(
+    (seq, cur) => Object.assign({}, seq, {
+      [cur]: opts[cur].valueOf()
+    }), {});
+}
+
+// Unicode bidi isolation characters
+const FSI = '\u2068';
+const PDI = '\u2069';
+
+const MAX_PLACEABLE_LENGTH = 2500;
+
+function* mapValues(arr) {
+  let values = new FTLList();
+  for (let elem of arr) {
+    values.push(yield* Value(elem));
+  }
+  return values;
+}
+
+// Helper for choosing entity value
+function* DefaultMember(members, allowNoDefault = false) {
+  for (let member of members) {
+    if (member.def) {
+      return member;
+    }
+  }
+
+  if (!allowNoDefault) {
+    yield tell(new RangeError('No default'));
+  }
+
+  return { val: new FTLNone() };
+}
+
+
+// Half-resolved expressions evaluate to raw Runtime AST nodes
+
+function* EntityReference({name}) {
+  const { ctx } = yield ask();
+  const entity = ctx.messages.get(name);
+
+  if (!entity) {
+    yield tell(new ReferenceError(`Unknown entity: ${name}`));
+    return new FTLNone(name);
+  }
+
+  return entity;
+}
+
+function* MemberExpression({obj, key}) {
+  const entity = yield* EntityReference(obj);
+  if (entity instanceof FTLNone) {
+    return { val: entity };
+  }
+
+  const { ctx } = yield ask();
+  const keyword = yield* Value(key);
+
+  for (let member of entity.traits) {
+    const memberKey = yield* Value(member.key);
+    if (keyword.match(ctx, memberKey)) {
+      return member;
+    }
+  }
+
+  yield tell(new ReferenceError(`Unknown trait: ${keyword.toString(ctx)}`));
+  return {
+    val: yield* Entity(entity)
+  };
+}
+
+function* SelectExpression({exp, vars}) {
+  const selector = yield* Value(exp);
+  if (selector instanceof FTLNone) {
+    return yield* DefaultMember(vars);
+  }
+
+  for (let variant of vars) {
+    const key = yield* Value(variant.key);
+
+    if (key instanceof FTLNumber &&
+        selector instanceof FTLNumber &&
+        key.valueOf() === selector.valueOf()) {
+      return variant;
+    }
+
+    const { ctx } = yield ask();
+
+    if (key instanceof FTLKeyword &&
+        key.match(ctx, selector)) {
+      return variant;
+    }
+  }
+
+  return yield* DefaultMember(vars);
+}
+
+
+// Fully-resolved expressions evaluate to FTL types
+
+function* Value(expr) {
+  if (typeof expr === 'string' || expr instanceof FTLNone) {
+    return expr;
+  }
+
+  if (Array.isArray(expr)) {
+    return yield* Pattern(expr);
+  }
+
+  switch (expr.type) {
+    case 'kw':
+      return new FTLKeyword(expr);
+    case 'num':
+      return new FTLNumber(expr.val);
+    case 'ext':
+      return yield* ExternalArgument(expr);
+    case 'fun':
+      return yield* FunctionReference(expr);
+    case 'call':
+      return yield* CallExpression(expr);
+    case 'ref':
+      const ref = yield* EntityReference(expr);
+      return yield* Entity(ref);
+    case 'mem':
+      const mem = yield* MemberExpression(expr);
+      return yield* Value(mem.val);
+    case 'sel':
+      const sel = yield* SelectExpression(expr);
+      return yield* Value(sel.val);
+    default:
+      return yield* Value(expr.val);
+  }
+}
+
+function* ExternalArgument({name}) {
+  const { args } = yield ask();
+
+  if (!args || !args.hasOwnProperty(name)) {
+    yield tell(new ReferenceError(`Unknown external: ${name}`));
+    return new FTLNone(name);
+  }
+
+  const arg = args[name];
+
+  if (arg instanceof FTLBase) {
+    return arg;
+  }
+
+  switch (typeof arg) {
+    case 'string':
+      return arg;
+    case 'number':
+      return new FTLNumber(arg);
+    case 'object':
+      if (Array.isArray(arg)) {
+        return yield* mapValues(arg);
+      }
+      if (arg instanceof Date) {
+        return new FTLDateTime(arg);
+      }
+    default:
+      yield tell(
+        new TypeError(`Unsupported external type: ${name}, ${typeof arg}`)
+      );
+      return new FTLNone(name);
+  }
+}
+
+function* FunctionReference({name}) {
+  const { ctx: { functions } } = yield ask();
+  const func = functions[name] || builtins[name];
+
+  if (!func) {
+    yield tell(new ReferenceError(`Unknown built-in: ${name}()`));
+    return new FTLNone(`${name}()`);
+  }
+
+  if (typeof func !== 'function') {
+    yield tell(new TypeError(`Function ${name}() is not callable`));
+    return new FTLNone(`${name}()`);
+  }
+
+  return func;
+}
+
+function* CallExpression({name, args}) {
+  const callee = yield* FunctionReference(name);
+
+  if (callee instanceof FTLNone) {
+    return callee;
+  }
+
+  const posargs = [];
+  const keyargs = [];
+
+  for (let arg of args) {
+    if (arg.type === 'kv') {
+      keyargs[arg.name] = yield* Value(arg.val);
+    } else {
+      posargs.push(yield* Value(arg));
+    }
+  }
+
+  // XXX builtins should also returns [val, errs] tuples
+  return callee(posargs, keyargs);
+}
+
+function* Pattern(ptn) {
+  const { ctx, dirty } = yield ask();
+
+  if (dirty.has(ptn)) {
+    yield tell(new RangeError('Cyclic reference'));
+    return new FTLNone();
+  }
+
+  dirty.add(ptn);
+  let result = '';
+
+  for (let part of ptn) {
+    if (typeof part === 'string') {
+      result += part;
+    } else {
+      const value = part.length === 1 ?
+        yield* Value(part[0]) : yield* mapValues(part);
+
+      const str = value.toString(ctx);
+      if (str.length > MAX_PLACEABLE_LENGTH) {
+        yield tell(
+          new RangeError(
+            'Too many characters in placeable ' +
+            `(${str.length}, max allowed is ${MAX_PLACEABLE_LENGTH})`
+          )
+        );
+        result += FSI + str.substr(0, MAX_PLACEABLE_LENGTH) + PDI;
+      } else {
+        result += FSI + str + PDI;
+      }
+    }
+  }
+
+  dirty.delete(ptn);
+  return result;
+}
+
+function* Entity(entity, allowNoDefault = false) {
+  if (entity.val !== undefined) {
+    return yield* Value(entity.val);
+  }
+
+  if (!entity.traits) {
+    return yield* Value(entity);
+  }
+
+  const def = yield* DefaultMember(entity.traits, allowNoDefault);
+  return yield* Value(def);
+}
+
+// evaluate `entity` to an FTL Value type: string or FTLNone
+function* toFTLType(entity, opts) {
+  if (entity === undefined) {
+    return new FTLNone();
+  }
+
+  return yield* Entity(entity, opts.allowNoDefault);
+}
+
+const _opts = {
+  allowNoDefault: false
+};
+
+function format(ctx, args, entity, opts = _opts) {
+  // optimization: many translations are simple strings and we can very easily 
+  // avoid the cost of a proper resolution by having this shortcut here
+  if (typeof entity === 'string') {
+    return [entity, []];
+  }
+
+  return resolve(toFTLType(entity, opts)).run({
+    ctx, args, dirty: new WeakSet()
+  });
+}
+
+const optsPrimitive = { allowNoDefault: true };
+
+class MessageContext {
+  constructor(lang, { functions } = {}) {
+    this.lang = lang;
+    this.functions = functions || {}
+    this.messages = new Map();
+    this.intls = new WeakMap();
+  }
+
+  addMessages(source) {
+    const [entries, errors] = FTLRuntimeParser.parseResource(source);
+    for (let id in entries) {
+      this.messages.set(id, entries[id]);
+    }
+
+    return errors;
+  }
+
+  // format `entity` to a string or null
+  formatToPrimitive(entity, args) {
+    const result = format(this, args, entity, optsPrimitive);
+    return (result[0] instanceof FTLNone) ?
+      [null, result[1]] : result;
+  }
+
+  // format `entity` to a string
+  format(entity, args) {
+    const result = format(this, args, entity);
+    return [result[0].toString(), result[1]];
+  }
+
+  _memoizeIntlObject(ctor, opts) {
+    const cache = this.intls.get(ctor) || {};
+    const id = JSON.stringify(opts);
+
+    if (!cache[id]) {
+      cache[id] = new ctor(this.lang, opts);
+      this.intls.set(ctor, cache);
+    }
+
+    return cache[id];
+  }
+
+}
+
+this.EXPORTED_SYMBOLS = ['MessageContext'];
+
+Components.utils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetter(Intl, "ListFormat",
+  "resource://gre/modules/IntlListFormat.jsm");
+XPCOMUtils.defineLazyModuleGetter(Intl, "PluralRules",
+  "resource://gre/modules/IntlPluralRules.jsm");
+XPCOMUtils.defineLazyModuleGetter(Intl, "RelativeTimeFormat",
+  "resource://gre/modules/IntlRelativeTimeFormat.jsm");
+
+this.MessageContext = MessageContext;
\ No newline at end of file
--- a/toolkit/modules/moz.build
+++ b/toolkit/modules/moz.build
@@ -42,16 +42,17 @@ EXTRA_JS_MODULES += [
     'FinderHighlighter.jsm',
     'Geometry.jsm',
     'GMPInstallManager.jsm',
     'GMPUtils.jsm',
     'Http.jsm',
     'InlineSpellChecker.jsm',
     'InlineSpellCheckerContent.jsm',
     'Integration.jsm',
+    'IntlMessageContext.jsm',
     'LoadContextInfo.jsm',
     'Locale.jsm',
     'Log.jsm',
     'NewTabUtils.jsm',
     'ObjectUtils.jsm',
     'PageMenu.jsm',
     'PageMetadata.jsm',
     'PermissionsUtils.jsm',