// @flow 'use strict' /*:: export type AST = Element[] export type Element = string | Placeholder export type Placeholder = Plural | Styled | Typed | Simple export type Plural = [ string, 'plural' | 'selectordinal', number, SubMessages ] export type Styled = [ string, string, string | SubMessages ] export type Typed = [ string, string ] export type Simple = [ string ] export type SubMessages = { [string]: AST } export type Token = [ TokenType, string ] export type TokenType = 'text' | 'space' | 'id' | 'type' | 'style' | 'offset' | 'number' | 'selector' | 'syntax' type Context = {| pattern: string, index: number, tagsType: ?string, tokens: ?Token[] |} */ var ARG_OPN = '{' var ARG_CLS = '}' var ARG_SEP = ',' var NUM_ARG = '#' var TAG_OPN = '<' var TAG_CLS = '>' var TAG_END = '' var ESC = '\'' var OFFSET = 'offset:' var simpleTypes = [ 'number', 'date', 'time', 'ordinal', 'duration', 'spellout' ] var submTypes = [ 'plural', 'select', 'selectordinal' ] /** * parse * * Turns this: * `You have { numBananas, plural, * =0 {no bananas} * one {a banana} * other {# bananas} * } for sale` * * into this: * [ "You have ", [ "numBananas", "plural", 0, { * "=0": [ "no bananas" ], * "one": [ "a banana" ], * "other": [ [ '#' ], " bananas" ] * } ], " for sale." ] * * tokens: * [ * [ "text", "You have " ], * [ "syntax", "{" ], * [ "space", " " ], * [ "id", "numBananas" ], * [ "syntax", ", " ], * [ "space", " " ], * [ "type", "plural" ], * [ "syntax", "," ], * [ "space", "\n " ], * [ "selector", "=0" ], * [ "space", " " ], * [ "syntax", "{" ], * [ "text", "no bananas" ], * [ "syntax", "}" ], * [ "space", "\n " ], * [ "selector", "one" ], * [ "space", " " ], * [ "syntax", "{" ], * [ "text", "a banana" ], * [ "syntax", "}" ], * [ "space", "\n " ], * [ "selector", "other" ], * [ "space", " " ], * [ "syntax", "{" ], * [ "syntax", "#" ], * [ "text", " bananas" ], * [ "syntax", "}" ], * [ "space", "\n" ], * [ "syntax", "}" ], * [ "text", " for sale." ] * ] **/ exports = module.exports = function parse ( pattern/*: string */, options/*:: ?: { tagsType?: string, tokens?: Token[] } */ )/*: AST */ { return parseAST({ pattern: String(pattern), index: 0, tagsType: (options && options.tagsType) || null, tokens: (options && options.tokens) || null }, '') } function parseAST (current/*: Context */, parentType/*: string */)/*: AST */ { var pattern = current.pattern var length = pattern.length var elements/*: AST */ = [] var start = current.index var text = parseText(current, parentType) if (text) elements.push(text) if (text && current.tokens) current.tokens.push(['text', pattern.slice(start, current.index)]) while (current.index < length) { if (pattern[current.index] === ARG_CLS) { if (!parentType) throw expected(current) break } if (parentType && current.tagsType && pattern.slice(current.index, current.index + TAG_END.length) === TAG_END) break elements.push(parsePlaceholder(current)) start = current.index text = parseText(current, parentType) if (text) elements.push(text) if (text && current.tokens) current.tokens.push(['text', pattern.slice(start, current.index)]) } return elements } function parseText (current/*: Context */, parentType/*: string */)/*: string */ { var pattern = current.pattern var length = pattern.length var isHashSpecial = (parentType === 'plural' || parentType === 'selectordinal') var isAngleSpecial = !!current.tagsType var isArgStyle = (parentType === '{style}') var text = '' while (current.index < length) { var char = pattern[current.index] if ( char === ARG_OPN || char === ARG_CLS || (isHashSpecial && char === NUM_ARG) || (isAngleSpecial && char === TAG_OPN) || (isArgStyle && isWhitespace(char.charCodeAt(0))) ) { break } else if (char === ESC) { char = pattern[++current.index] if (char === ESC) { // double is always 1 ' text += char ++current.index } else if ( // only when necessary char === ARG_OPN || char === ARG_CLS || (isHashSpecial && char === NUM_ARG) || (isAngleSpecial && char === TAG_OPN) || isArgStyle ) { text += char while (++current.index < length) { char = pattern[current.index] if (char === ESC && pattern[current.index + 1] === ESC) { // double is always 1 ' text += ESC ++current.index } else if (char === ESC) { // end of quoted ++current.index break } else { text += char } } } else { // lone ' is just a ' text += ESC // already incremented } } else { text += char ++current.index } } return text } function isWhitespace (code/*: number */)/*: boolean */ { return ( (code >= 0x09 && code <= 0x0D) || code === 0x20 || code === 0x85 || code === 0xA0 || code === 0x180E || (code >= 0x2000 && code <= 0x200D) || code === 0x2028 || code === 0x2029 || code === 0x202F || code === 0x205F || code === 0x2060 || code === 0x3000 || code === 0xFEFF ) } function skipWhitespace (current/*: Context */)/*: void */ { var pattern = current.pattern var length = pattern.length var start = current.index while (current.index < length && isWhitespace(pattern.charCodeAt(current.index))) { ++current.index } if (start < current.index && current.tokens) { current.tokens.push(['space', current.pattern.slice(start, current.index)]) } } function parsePlaceholder (current/*: Context */)/*: Placeholder */ { var pattern = current.pattern if (pattern[current.index] === NUM_ARG) { if (current.tokens) current.tokens.push(['syntax', NUM_ARG]) ++current.index // move passed # return [NUM_ARG] } var tag = parseTag(current) if (tag) return tag /* istanbul ignore if should be unreachable if parseAST and parseText are right */ if (pattern[current.index] !== ARG_OPN) throw expected(current, ARG_OPN) if (current.tokens) current.tokens.push(['syntax', ARG_OPN]) ++current.index // move passed { skipWhitespace(current) var id = parseId(current) if (!id) throw expected(current, 'placeholder id') if (current.tokens) current.tokens.push(['id', id]) skipWhitespace(current) var char = pattern[current.index] if (char === ARG_CLS) { // end placeholder if (current.tokens) current.tokens.push(['syntax', ARG_CLS]) ++current.index // move passed } return [id] } if (char !== ARG_SEP) throw expected(current, ARG_SEP + ' or ' + ARG_CLS) if (current.tokens) current.tokens.push(['syntax', ARG_SEP]) ++current.index // move passed , skipWhitespace(current) var type = parseId(current) if (!type) throw expected(current, 'placeholder type') if (current.tokens) current.tokens.push(['type', type]) skipWhitespace(current) char = pattern[current.index] if (char === ARG_CLS) { // end placeholder if (current.tokens) current.tokens.push(['syntax', ARG_CLS]) if (type === 'plural' || type === 'selectordinal' || type === 'select') { throw expected(current, type + ' sub-messages') } ++current.index // move passed } return [id, type] } if (char !== ARG_SEP) throw expected(current, ARG_SEP + ' or ' + ARG_CLS) if (current.tokens) current.tokens.push(['syntax', ARG_SEP]) ++current.index // move passed , skipWhitespace(current) var arg if (type === 'plural' || type === 'selectordinal') { var offset = parsePluralOffset(current) skipWhitespace(current) arg = [id, type, offset, parseSubMessages(current, type)] } else if (type === 'select') { arg = [id, type, parseSubMessages(current, type)] } else if (simpleTypes.indexOf(type) >= 0) { arg = [id, type, parseSimpleFormat(current)] } else { // custom placeholder type var index = current.index var format/*: string | SubMessages */ = parseSimpleFormat(current) skipWhitespace(current) if (pattern[current.index] === ARG_OPN) { current.index = index // rewind, since should have been submessages format = parseSubMessages(current, type) } arg = [id, type, format] } skipWhitespace(current) if (pattern[current.index] !== ARG_CLS) throw expected(current, ARG_CLS) if (current.tokens) current.tokens.push(['syntax', ARG_CLS]) ++current.index // move passed } return arg } function parseTag (current/*: Context */)/*: ?Placeholder */ { var tagsType = current.tagsType if (!tagsType || current.pattern[current.index] !== TAG_OPN) return if (current.pattern.slice(current.index, current.index + TAG_END.length) === TAG_END) { throw expected(current, null, 'closing tag without matching opening tag') } if (current.tokens) current.tokens.push(['syntax', TAG_OPN]) ++current.index // move passed < var id = parseId(current, true) if (!id) throw expected(current, 'placeholder id') if (current.tokens) current.tokens.push(['id', id]) skipWhitespace(current) if (current.pattern.slice(current.index, current.index + TAG_SELF_CLS.length) === TAG_SELF_CLS) { if (current.tokens) current.tokens.push(['syntax', TAG_SELF_CLS]) current.index += TAG_SELF_CLS.length return [id, tagsType] } if (current.pattern[current.index] !== TAG_CLS) throw expected(current, TAG_CLS) if (current.tokens) current.tokens.push(['syntax', TAG_CLS]) ++current.index // move passed > var children = parseAST(current, tagsType) var end = current.index if (current.pattern.slice(current.index, current.index + TAG_END.length) !== TAG_END) throw expected(current, TAG_END + id + TAG_CLS) if (current.tokens) current.tokens.push(['syntax', TAG_END]) current.index += TAG_END.length var closeId = parseId(current, true) if (closeId && current.tokens) current.tokens.push(['id', closeId]) if (id !== closeId) { current.index = end // rewind for better error message throw expected(current, TAG_END + id + TAG_CLS, TAG_END + closeId + TAG_CLS) } skipWhitespace(current) if (current.pattern[current.index] !== TAG_CLS) throw expected(current, TAG_CLS) if (current.tokens) current.tokens.push(['syntax', TAG_CLS]) ++current.index // move passed > return [id, tagsType, { children: children }] } function parseId (current/*: Context */, isTag/*:: ?: boolean */)/*: string */ { var pattern = current.pattern var length = pattern.length var id = '' while (current.index < length) { var char = pattern[current.index] if ( char === ARG_OPN || char === ARG_CLS || char === ARG_SEP || char === NUM_ARG || char === ESC || isWhitespace(char.charCodeAt(0)) || (isTag && (char === TAG_OPN || char === TAG_CLS || char === '/')) ) break id += char ++current.index } return id } function parseSimpleFormat (current/*: Context */)/*: string */ { var start = current.index var style = parseText(current, '{style}') if (!style) throw expected(current, 'placeholder style name') if (current.tokens) current.tokens.push(['style', current.pattern.slice(start, current.index)]) return style } function parsePluralOffset (current/*: Context */)/*: number */ { var pattern = current.pattern var length = pattern.length var offset = 0 if (pattern.slice(current.index, current.index + OFFSET.length) === OFFSET) { if (current.tokens) current.tokens.push(['offset', 'offset'], ['syntax', ':']) current.index += OFFSET.length // move passed offset: skipWhitespace(current) var start = current.index while (current.index < length && isDigit(pattern.charCodeAt(current.index))) { ++current.index } if (start === current.index) throw expected(current, 'offset number') if (current.tokens) current.tokens.push(['number', pattern.slice(start, current.index)]) offset = +pattern.slice(start, current.index) } return offset } function isDigit (code/*: number */)/*: boolean */ { return (code >= 0x30 && code <= 0x39) } function parseSubMessages (current/*: Context */, parentType/*: string */)/*: SubMessages */ { var pattern = current.pattern var length = pattern.length var options/*: SubMessages */ = {} while (current.index < length && pattern[current.index] !== ARG_CLS) { var selector = parseId(current) if (!selector) throw expected(current, 'sub-message selector') if (current.tokens) current.tokens.push(['selector', selector]) skipWhitespace(current) options[selector] = parseSubMessage(current, parentType) skipWhitespace(current) } if (!options.other && submTypes.indexOf(parentType) >= 0) { throw expected(current, null, null, '"other" sub-message must be specified in ' + parentType) } return options } function parseSubMessage (current/*: Context */, parentType/*: string */)/*: AST */ { if (current.pattern[current.index] !== ARG_OPN) throw expected(current, ARG_OPN + ' to start sub-message') if (current.tokens) current.tokens.push(['syntax', ARG_OPN]) ++current.index // move passed { var message = parseAST(current, parentType) if (current.pattern[current.index] !== ARG_CLS) throw expected(current, ARG_CLS + ' to end sub-message') if (current.tokens) current.tokens.push(['syntax', ARG_CLS]) ++current.index // move passed } return message } function expected (current/*: Context */, expected/*:: ?: ?string */, found/*:: ?: ?string */, message/*:: ?: string */) { var pattern = current.pattern var lines = pattern.slice(0, current.index).split(/\r?\n/) var offset = current.index var line = lines.length var column = lines.slice(-1)[0].length found = found || ( (current.index >= pattern.length) ? 'end of message pattern' : (parseId(current) || pattern[current.index]) ) if (!message) message = errorMessage(expected, found) message += ' in ' + pattern.replace(/\r?\n/g, '\n') return new SyntaxError(message, expected, found, offset, line, column) } function errorMessage (expected/*: ?string */, found/* string */) { if (!expected) return 'Unexpected ' + found + ' found' return 'Expected ' + expected + ' but found ' + found } /** * SyntaxError * Holds information about bad syntax found in a message pattern **/ function SyntaxError (message/*: string */, expected/*: ?string */, found/*: ?string */, offset/*: number */, line/*: number */, column/*: number */) { Error.call(this, message) this.name = 'SyntaxError' this.message = message this.expected = expected this.found = found this.offset = offset this.line = line this.column = column } SyntaxError.prototype = Object.create(Error.prototype) exports.SyntaxError = SyntaxError