UNPKG

@fluent/syntax

Version:
1,386 lines (1,381 loc) 62.4 kB
/** @fluent/syntax@0.19.0 */ (function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : typeof define === 'function' && define.amd ? define('@fluent/syntax', ['exports'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.FluentSyntax = {})); })(this, (function (exports) { 'use strict'; /** * Base class for all Fluent AST nodes. * * All productions described in the ASDL subclass BaseNode, including Span and * Annotation. * */ class BaseNode { equals(other, ignoredFields = ["span"]) { const thisKeys = new Set(Object.keys(this)); const otherKeys = new Set(Object.keys(other)); if (ignoredFields) { for (const fieldName of ignoredFields) { thisKeys.delete(fieldName); otherKeys.delete(fieldName); } } if (thisKeys.size !== otherKeys.size) { return false; } for (const fieldName of thisKeys) { if (!otherKeys.has(fieldName)) { return false; } const thisVal = this[fieldName]; const otherVal = other[fieldName]; if (typeof thisVal !== typeof otherVal) { return false; } if (thisVal instanceof Array && otherVal instanceof Array) { if (thisVal.length !== otherVal.length) { return false; } for (let i = 0; i < thisVal.length; ++i) { if (!scalarsEqual(thisVal[i], otherVal[i], ignoredFields)) { return false; } } } else if (!scalarsEqual(thisVal, otherVal, ignoredFields)) { return false; } } return true; } clone() { function visit(value) { if (value instanceof BaseNode) { return value.clone(); } if (Array.isArray(value)) { return value.map(visit); } return value; } // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const clone = Object.create(this.constructor.prototype); for (const prop of Object.keys(this)) { clone[prop] = visit(this[prop]); } return clone; } } function scalarsEqual(thisVal, otherVal, ignoredFields) { if (thisVal instanceof BaseNode && otherVal instanceof BaseNode) { return thisVal.equals(otherVal, ignoredFields); } return thisVal === otherVal; } /** * Base class for AST nodes which can have Spans. */ class SyntaxNode extends BaseNode { /** @ignore */ addSpan(start, end) { this.span = new Span(start, end); } } class Resource extends SyntaxNode { constructor(body = []) { super(); this.type = "Resource"; this.body = body; } } class Message extends SyntaxNode { constructor(id, value = null, attributes = [], comment = null) { super(); this.type = "Message"; this.id = id; this.value = value; this.attributes = attributes; this.comment = comment; } } class Term extends SyntaxNode { constructor(id, value, attributes = [], comment = null) { super(); this.type = "Term"; this.id = id; this.value = value; this.attributes = attributes; this.comment = comment; } } class Pattern extends SyntaxNode { constructor(elements) { super(); this.type = "Pattern"; this.elements = elements; } } class TextElement extends SyntaxNode { constructor(value) { super(); this.type = "TextElement"; this.value = value; } } class Placeable extends SyntaxNode { constructor(expression) { super(); this.type = "Placeable"; this.expression = expression; } } // An abstract base class for Literals. class BaseLiteral extends SyntaxNode { constructor(value) { super(); // The "value" field contains the exact contents of the literal, // character-for-character. this.value = value; } } class StringLiteral extends BaseLiteral { constructor() { super(...arguments); this.type = "StringLiteral"; } parse() { // Backslash backslash, backslash double quote, uHHHH, UHHHHHH. const KNOWN_ESCAPES = /(?:\\\\|\\"|\\u([0-9a-fA-F]{4})|\\U([0-9a-fA-F]{6}))/g; function fromEscapeSequence(match, codepoint4, codepoint6) { switch (match) { case "\\\\": return "\\"; case '\\"': return '"'; default: { let codepoint = parseInt(codepoint4 || codepoint6, 16); if (codepoint <= 0xd7ff || 0xe000 <= codepoint) { // It's a Unicode scalar value. return String.fromCodePoint(codepoint); } // Escape sequences reresenting surrogate code points are // well-formed but invalid in Fluent. Replace them with U+FFFD // REPLACEMENT CHARACTER. return "�"; } } } let value = this.value.replace(KNOWN_ESCAPES, fromEscapeSequence); return { value }; } } class NumberLiteral extends BaseLiteral { constructor() { super(...arguments); this.type = "NumberLiteral"; } parse() { let value = parseFloat(this.value); let decimalPos = this.value.indexOf("."); let precision = decimalPos > 0 ? this.value.length - decimalPos - 1 : 0; return { value, precision }; } } class MessageReference extends SyntaxNode { constructor(id, attribute = null) { super(); this.type = "MessageReference"; this.id = id; this.attribute = attribute; } } class TermReference extends SyntaxNode { constructor(id, attribute = null, args = null) { super(); this.type = "TermReference"; this.id = id; this.attribute = attribute; this.arguments = args; } } class VariableReference extends SyntaxNode { constructor(id) { super(); this.type = "VariableReference"; this.id = id; } } class FunctionReference extends SyntaxNode { constructor(id, args) { super(); this.type = "FunctionReference"; this.id = id; this.arguments = args; } } class SelectExpression extends SyntaxNode { constructor(selector, variants) { super(); this.type = "SelectExpression"; this.selector = selector; this.variants = variants; } } class CallArguments extends SyntaxNode { constructor(positional = [], named = []) { super(); this.type = "CallArguments"; this.positional = positional; this.named = named; } } class Attribute extends SyntaxNode { constructor(id, value) { super(); this.type = "Attribute"; this.id = id; this.value = value; } } class Variant extends SyntaxNode { constructor(key, value, def) { super(); this.type = "Variant"; this.key = key; this.value = value; this.default = def; } } class NamedArgument extends SyntaxNode { constructor(name, value) { super(); this.type = "NamedArgument"; this.name = name; this.value = value; } } class Identifier extends SyntaxNode { constructor(name) { super(); this.type = "Identifier"; this.name = name; } } class BaseComment extends SyntaxNode { constructor(content) { super(); this.content = content; } } class Comment extends BaseComment { constructor() { super(...arguments); this.type = "Comment"; } } class GroupComment extends BaseComment { constructor() { super(...arguments); this.type = "GroupComment"; } } class ResourceComment extends BaseComment { constructor() { super(...arguments); this.type = "ResourceComment"; } } class Junk extends SyntaxNode { constructor(content) { super(); this.type = "Junk"; this.annotations = []; this.content = content; } addAnnotation(annotation) { this.annotations.push(annotation); } } class Span extends BaseNode { constructor(start, end) { super(); this.type = "Span"; this.start = start; this.end = end; } } class Annotation extends SyntaxNode { constructor(code, args = [], message) { super(); this.type = "Annotation"; this.code = code; this.arguments = args; this.message = message; } } /* eslint-disable @typescript-eslint/restrict-template-expressions */ class ParseError extends Error { constructor(code, ...args) { super(); this.code = code; this.args = args; this.message = getErrorMessage(code, args); } } /* eslint-disable complexity */ function getErrorMessage(code, args) { switch (code) { case "E0001": return "Generic error"; case "E0002": return "Expected an entry start"; case "E0003": { const [token] = args; return `Expected token: "${token}"`; } case "E0004": { const [range] = args; return `Expected a character from range: "${range}"`; } case "E0005": { const [id] = args; return `Expected message "${id}" to have a value or attributes`; } case "E0006": { const [id] = args; return `Expected term "-${id}" to have a value`; } case "E0007": return "Keyword cannot end with a whitespace"; case "E0008": return "The callee has to be an upper-case identifier or a term"; case "E0009": return "The argument name has to be a simple identifier"; case "E0010": return "Expected one of the variants to be marked as default (*)"; case "E0011": return 'Expected at least one variant after "->"'; case "E0012": return "Expected value"; case "E0013": return "Expected variant key"; case "E0014": return "Expected literal"; case "E0015": return "Only one variant can be marked as default (*)"; case "E0016": return "Message references cannot be used as selectors"; case "E0017": return "Terms cannot be used as selectors"; case "E0018": return "Attributes of messages cannot be used as selectors"; case "E0019": return "Attributes of terms cannot be used as placeables"; case "E0020": return "Unterminated string expression"; case "E0021": return "Positional arguments must not follow named arguments"; case "E0022": return "Named arguments must be unique"; case "E0024": return "Cannot access variants of a message."; case "E0025": { const [char] = args; return `Unknown escape sequence: \\${char}.`; } case "E0026": { const [sequence] = args; return `Invalid Unicode escape sequence: ${sequence}.`; } case "E0027": return "Unbalanced closing brace in TextElement."; case "E0028": return "Expected an inline expression"; case "E0029": return "Expected simple expression as selector"; default: return code; } } /* eslint no-magic-numbers: "off" */ class ParserStream { constructor(string) { this.string = string; this.index = 0; this.peekOffset = 0; } charAt(offset) { // When the cursor is at CRLF, return LF but don't move the cursor. // The cursor still points to the EOL position, which in this case is the // beginning of the compound CRLF sequence. This ensures slices of // [inclusive, exclusive) continue to work properly. if (this.string[offset] === "\r" && this.string[offset + 1] === "\n") { return "\n"; } return this.string[offset]; } currentChar() { return this.charAt(this.index); } currentPeek() { return this.charAt(this.index + this.peekOffset); } next() { this.peekOffset = 0; // Skip over the CRLF as if it was a single character. if (this.string[this.index] === "\r" && this.string[this.index + 1] === "\n") { this.index++; } this.index++; return this.string[this.index]; } peek() { // Skip over the CRLF as if it was a single character. if (this.string[this.index + this.peekOffset] === "\r" && this.string[this.index + this.peekOffset + 1] === "\n") { this.peekOffset++; } this.peekOffset++; return this.string[this.index + this.peekOffset]; } resetPeek(offset = 0) { this.peekOffset = offset; } skipToPeek() { this.index += this.peekOffset; this.peekOffset = 0; } } const EOL = "\n"; const EOF = undefined; const SPECIAL_LINE_START_CHARS = ["}", ".", "[", "*"]; class FluentParserStream extends ParserStream { peekBlankInline() { const start = this.index + this.peekOffset; while (this.currentPeek() === " ") { this.peek(); } return this.string.slice(start, this.index + this.peekOffset); } skipBlankInline() { const blank = this.peekBlankInline(); this.skipToPeek(); return blank; } peekBlankBlock() { let blank = ""; while (true) { const lineStart = this.peekOffset; this.peekBlankInline(); if (this.currentPeek() === EOL) { blank += EOL; this.peek(); continue; } if (this.currentPeek() === EOF) { // Treat the blank line at EOF as a blank block. return blank; } // Any other char; reset to column 1 on this line. this.resetPeek(lineStart); return blank; } } skipBlankBlock() { const blank = this.peekBlankBlock(); this.skipToPeek(); return blank; } peekBlank() { while (this.currentPeek() === " " || this.currentPeek() === EOL) { this.peek(); } } skipBlank() { this.peekBlank(); this.skipToPeek(); } expectChar(ch) { if (this.currentChar() === ch) { this.next(); return; } throw new ParseError("E0003", ch); } expectLineEnd() { if (this.currentChar() === EOF) { // EOF is a valid line end in Fluent. return; } if (this.currentChar() === EOL) { this.next(); return; } // Unicode Character 'SYMBOL FOR NEWLINE' (U+2424) throw new ParseError("E0003", "\u2424"); } takeChar(f) { const ch = this.currentChar(); if (ch === EOF) { return EOF; } if (f(ch)) { this.next(); return ch; } return null; } isCharIdStart(ch) { if (ch === EOF) { return false; } const cc = ch.charCodeAt(0); return ((cc >= 97 && cc <= 122) || // a-z (cc >= 65 && cc <= 90)); // A-Z } isIdentifierStart() { return this.isCharIdStart(this.currentPeek()); } isNumberStart() { const ch = this.currentChar() === "-" ? this.peek() : this.currentChar(); if (ch === EOF) { this.resetPeek(); return false; } const cc = ch.charCodeAt(0); const isDigit = cc >= 48 && cc <= 57; // 0-9 this.resetPeek(); return isDigit; } isCharPatternContinuation(ch) { if (ch === EOF) { return false; } return !SPECIAL_LINE_START_CHARS.includes(ch); } isValueStart() { // Inline Patterns may start with any char. const ch = this.currentPeek(); return ch !== EOL && ch !== EOF; } isValueContinuation() { const column1 = this.peekOffset; this.peekBlankInline(); if (this.currentPeek() === "{") { this.resetPeek(column1); return true; } if (this.peekOffset - column1 === 0) { return false; } if (this.isCharPatternContinuation(this.currentPeek())) { this.resetPeek(column1); return true; } return false; } /** * @param level - -1: any, 0: comment, 1: group comment, 2: resource comment */ isNextLineComment(level = -1) { if (this.currentChar() !== EOL) { return false; } let i = 0; while (i <= level || (level === -1 && i < 3)) { if (this.peek() !== "#") { if (i <= level && level !== -1) { this.resetPeek(); return false; } break; } i++; } // The first char after #, ## or ###. const ch = this.peek(); if (ch === " " || ch === EOL) { this.resetPeek(); return true; } this.resetPeek(); return false; } isVariantStart() { const currentPeekOffset = this.peekOffset; if (this.currentPeek() === "*") { this.peek(); } if (this.currentPeek() === "[") { this.resetPeek(currentPeekOffset); return true; } this.resetPeek(currentPeekOffset); return false; } isAttributeStart() { return this.currentPeek() === "."; } skipToNextEntryStart(junkStart) { let lastNewline = this.string.lastIndexOf(EOL, this.index); if (junkStart < lastNewline) { // Last seen newline is _after_ the junk start. It's safe to rewind // without the risk of resuming at the same broken entry. this.index = lastNewline; } while (this.currentChar()) { // We're only interested in beginnings of line. if (this.currentChar() !== EOL) { this.next(); continue; } // Break if the first char in this line looks like an entry start. const first = this.next(); if (this.isCharIdStart(first) || first === "-" || first === "#") { break; } } } takeIDStart() { if (this.isCharIdStart(this.currentChar())) { const ret = this.currentChar(); this.next(); return ret; } throw new ParseError("E0004", "a-zA-Z"); } takeIDChar() { const closure = (ch) => { const cc = ch.charCodeAt(0); return ((cc >= 97 && cc <= 122) || // a-z (cc >= 65 && cc <= 90) || // A-Z (cc >= 48 && cc <= 57) || // 0-9 cc === 95 || cc === 45); // _- }; return this.takeChar(closure); } takeDigit() { const closure = (ch) => { const cc = ch.charCodeAt(0); return cc >= 48 && cc <= 57; // 0-9 }; return this.takeChar(closure); } takeHexDigit() { const closure = (ch) => { const cc = ch.charCodeAt(0); return ((cc >= 48 && cc <= 57) || // 0-9 (cc >= 65 && cc <= 70) || // A-F (cc >= 97 && cc <= 102)); // a-f }; return this.takeChar(closure); } } /* eslint no-magic-numbers: [0] */ const trailingWSRe = /[ \n\r]+$/; function withSpan(fn) { return function (ps, ...args) { if (!this.withSpans) { return fn.call(this, ps, ...args); } const start = ps.index; const node = fn.call(this, ps, ...args); // Don't re-add the span if the node already has it. This may happen when // one decorated function calls another decorated function. if (node.span) { return node; } const end = ps.index; node.addSpan(start, end); return node; }; } class FluentParser { constructor({ withSpans = true } = {}) { this.withSpans = withSpans; // Poor man's decorators. /* eslint-disable @typescript-eslint/unbound-method */ this.getComment = withSpan(this.getComment); this.getMessage = withSpan(this.getMessage); this.getTerm = withSpan(this.getTerm); this.getAttribute = withSpan(this.getAttribute); this.getIdentifier = withSpan(this.getIdentifier); this.getVariant = withSpan(this.getVariant); this.getNumber = withSpan(this.getNumber); this.getPattern = withSpan(this.getPattern); this.getTextElement = withSpan(this.getTextElement); this.getPlaceable = withSpan(this.getPlaceable); this.getExpression = withSpan(this.getExpression); this.getInlineExpression = withSpan(this.getInlineExpression); this.getCallArgument = withSpan(this.getCallArgument); this.getCallArguments = withSpan(this.getCallArguments); this.getString = withSpan(this.getString); this.getLiteral = withSpan(this.getLiteral); this.getComment = withSpan(this.getComment); /* eslint-enable @typescript-eslint/unbound-method */ } parse(source) { const ps = new FluentParserStream(source); ps.skipBlankBlock(); const entries = []; let lastComment = null; while (ps.currentChar()) { const entry = this.getEntryOrJunk(ps); const blankLines = ps.skipBlankBlock(); // Regular Comments require special logic. Comments may be attached to // Messages or Terms if they are followed immediately by them. However // they should parse as standalone when they're followed by Junk. // Consequently, we only attach Comments once we know that the Message // or the Term parsed successfully. if (entry instanceof Comment && blankLines.length === 0 && ps.currentChar()) { // Stash the comment and decide what to do with it in the next pass. lastComment = entry; continue; } if (lastComment) { if (entry instanceof Message || entry instanceof Term) { entry.comment = lastComment; if (this.withSpans) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion entry.span.start = entry.comment.span.start; } } else { entries.push(lastComment); } // In either case, the stashed comment has been dealt with; clear it. lastComment = null; } // No special logic for other types of entries. entries.push(entry); } const res = new Resource(entries); if (this.withSpans) { res.addSpan(0, ps.index); } return res; } /** * Parse the first Message or Term in `source`. * * Skip all encountered comments and start parsing at the first Message or * Term start. Return Junk if the parsing is not successful. * * Preceding comments are ignored unless they contain syntax errors * themselves, in which case Junk for the invalid comment is returned. */ parseEntry(source) { const ps = new FluentParserStream(source); ps.skipBlankBlock(); while (ps.currentChar() === "#") { const skipped = this.getEntryOrJunk(ps); if (skipped instanceof Junk) { // Don't skip Junk comments. return skipped; } ps.skipBlankBlock(); } return this.getEntryOrJunk(ps); } getEntryOrJunk(ps) { const entryStartPos = ps.index; try { const entry = this.getEntry(ps); ps.expectLineEnd(); return entry; } catch (err) { if (!(err instanceof ParseError)) { throw err; } let errorIndex = ps.index; ps.skipToNextEntryStart(entryStartPos); const nextEntryStart = ps.index; if (nextEntryStart < errorIndex) { // The position of the error must be inside of the Junk's span. errorIndex = nextEntryStart; } // Create a Junk instance const slice = ps.string.substring(entryStartPos, nextEntryStart); const junk = new Junk(slice); if (this.withSpans) { junk.addSpan(entryStartPos, nextEntryStart); } const annot = new Annotation(err.code, err.args, err.message); annot.addSpan(errorIndex, errorIndex); junk.addAnnotation(annot); return junk; } } getEntry(ps) { if (ps.currentChar() === "#") { return this.getComment(ps); } if (ps.currentChar() === "-") { return this.getTerm(ps); } if (ps.isIdentifierStart()) { return this.getMessage(ps); } throw new ParseError("E0002"); } getComment(ps) { // 0 - comment // 1 - group comment // 2 - resource comment let level = -1; let content = ""; while (true) { let i = -1; while (ps.currentChar() === "#" && i < (level === -1 ? 2 : level)) { ps.next(); i++; } if (level === -1) { level = i; } if (ps.currentChar() !== EOL) { ps.expectChar(" "); let ch; while ((ch = ps.takeChar(x => x !== EOL))) { content += ch; } } if (ps.isNextLineComment(level)) { content += ps.currentChar(); ps.next(); } else { break; } } let Comment$1; switch (level) { case 0: Comment$1 = Comment; break; case 1: Comment$1 = GroupComment; break; default: Comment$1 = ResourceComment; } return new Comment$1(content); } getMessage(ps) { const id = this.getIdentifier(ps); ps.skipBlankInline(); ps.expectChar("="); const value = this.maybeGetPattern(ps); const attrs = this.getAttributes(ps); if (value === null && attrs.length === 0) { throw new ParseError("E0005", id.name); } return new Message(id, value, attrs); } getTerm(ps) { ps.expectChar("-"); const id = this.getIdentifier(ps); ps.skipBlankInline(); ps.expectChar("="); const value = this.maybeGetPattern(ps); if (value === null) { throw new ParseError("E0006", id.name); } const attrs = this.getAttributes(ps); return new Term(id, value, attrs); } getAttribute(ps) { ps.expectChar("."); const key = this.getIdentifier(ps); ps.skipBlankInline(); ps.expectChar("="); const value = this.maybeGetPattern(ps); if (value === null) { throw new ParseError("E0012"); } return new Attribute(key, value); } getAttributes(ps) { const attrs = []; ps.peekBlank(); while (ps.isAttributeStart()) { ps.skipToPeek(); const attr = this.getAttribute(ps); attrs.push(attr); ps.peekBlank(); } return attrs; } getIdentifier(ps) { let name = ps.takeIDStart(); let ch; while ((ch = ps.takeIDChar())) { name += ch; } return new Identifier(name); } getVariantKey(ps) { const ch = ps.currentChar(); if (ch === EOF) { throw new ParseError("E0013"); } const cc = ch.charCodeAt(0); if ((cc >= 48 && cc <= 57) || cc === 45) { // 0-9, - return this.getNumber(ps); } return this.getIdentifier(ps); } getVariant(ps, hasDefault = false) { let defaultIndex = false; if (ps.currentChar() === "*") { if (hasDefault) { throw new ParseError("E0015"); } ps.next(); defaultIndex = true; } ps.expectChar("["); ps.skipBlank(); const key = this.getVariantKey(ps); ps.skipBlank(); ps.expectChar("]"); const value = this.maybeGetPattern(ps); if (value === null) { throw new ParseError("E0012"); } return new Variant(key, value, defaultIndex); } getVariants(ps) { const variants = []; let hasDefault = false; ps.skipBlank(); while (ps.isVariantStart()) { const variant = this.getVariant(ps, hasDefault); if (variant.default) { hasDefault = true; } variants.push(variant); ps.expectLineEnd(); ps.skipBlank(); } if (variants.length === 0) { throw new ParseError("E0011"); } if (!hasDefault) { throw new ParseError("E0010"); } return variants; } getDigits(ps) { let num = ""; let ch; while ((ch = ps.takeDigit())) { num += ch; } if (num.length === 0) { throw new ParseError("E0004", "0-9"); } return num; } getNumber(ps) { let value = ""; if (ps.currentChar() === "-") { ps.next(); value += `-${this.getDigits(ps)}`; } else { value += this.getDigits(ps); } if (ps.currentChar() === ".") { ps.next(); value += `.${this.getDigits(ps)}`; } return new NumberLiteral(value); } /** * maybeGetPattern distinguishes between patterns which start on the same line * as the identifier (a.k.a. inline signleline patterns and inline multiline * patterns) and patterns which start on a new line (a.k.a. block multiline * patterns). The distinction is important for the dedentation logic: the * indent of the first line of a block pattern must be taken into account when * calculating the maximum common indent. */ maybeGetPattern(ps) { ps.peekBlankInline(); if (ps.isValueStart()) { ps.skipToPeek(); return this.getPattern(ps, false); } ps.peekBlankBlock(); if (ps.isValueContinuation()) { ps.skipToPeek(); return this.getPattern(ps, true); } return null; } getPattern(ps, isBlock) { const elements = []; let commonIndentLength; if (isBlock) { // A block pattern is a pattern which starts on a new line. Store and // measure the indent of this first line for the dedentation logic. const blankStart = ps.index; const firstIndent = ps.skipBlankInline(); elements.push(this.getIndent(ps, firstIndent, blankStart)); commonIndentLength = firstIndent.length; } else { commonIndentLength = Infinity; } let ch; elements: while ((ch = ps.currentChar())) { switch (ch) { case EOL: { const blankStart = ps.index; const blankLines = ps.peekBlankBlock(); if (ps.isValueContinuation()) { ps.skipToPeek(); const indent = ps.skipBlankInline(); commonIndentLength = Math.min(commonIndentLength, indent.length); elements.push(this.getIndent(ps, blankLines + indent, blankStart)); continue elements; } // The end condition for getPattern's while loop is a newline // which is not followed by a valid pattern continuation. ps.resetPeek(); break elements; } case "{": elements.push(this.getPlaceable(ps)); continue elements; case "}": throw new ParseError("E0027"); default: elements.push(this.getTextElement(ps)); } } const dedented = this.dedent(elements, commonIndentLength); return new Pattern(dedented); } /** * Create a token representing an indent. It's not part of the AST and it will * be trimmed and merged into adjacent TextElements, or turned into a new * TextElement, if it's surrounded by two Placeables. */ getIndent(ps, value, start) { return new Indent(value, start, ps.index); } /** * Dedent a list of elements by removing the maximum common indent from the * beginning of text lines. The common indent is calculated in getPattern. */ dedent(elements, commonIndent) { const trimmed = []; for (let element of elements) { if (element instanceof Placeable) { trimmed.push(element); continue; } if (element instanceof Indent) { // Strip common indent. element.value = element.value.slice(0, element.value.length - commonIndent); if (element.value.length === 0) { continue; } } let prev = trimmed[trimmed.length - 1]; if (prev && prev instanceof TextElement) { // Join adjacent TextElements by replacing them with their sum. const sum = new TextElement(prev.value + element.value); if (this.withSpans) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sum.addSpan(prev.span.start, element.span.end); } trimmed[trimmed.length - 1] = sum; continue; } if (element instanceof Indent) { // If the indent hasn't been merged into a preceding TextElement, // convert it into a new TextElement. const textElement = new TextElement(element.value); if (this.withSpans) { textElement.addSpan(element.span.start, element.span.end); } element = textElement; } trimmed.push(element); } // Trim trailing whitespace from the Pattern. const lastElement = trimmed[trimmed.length - 1]; if (lastElement instanceof TextElement) { lastElement.value = lastElement.value.replace(trailingWSRe, ""); if (lastElement.value.length === 0) { trimmed.pop(); } } return trimmed; } getTextElement(ps) { let buffer = ""; let ch; while ((ch = ps.currentChar())) { if (ch === "{" || ch === "}") { return new TextElement(buffer); } if (ch === EOL) { return new TextElement(buffer); } buffer += ch; ps.next(); } return new TextElement(buffer); } getEscapeSequence(ps) { const next = ps.currentChar(); switch (next) { case "\\": case '"': ps.next(); return `\\${next}`; case "u": return this.getUnicodeEscapeSequence(ps, next, 4); case "U": return this.getUnicodeEscapeSequence(ps, next, 6); default: throw new ParseError("E0025", next); } } getUnicodeEscapeSequence(ps, u, digits) { ps.expectChar(u); let sequence = ""; for (let i = 0; i < digits; i++) { const ch = ps.takeHexDigit(); if (!ch) { throw new ParseError("E0026", `\\${u}${sequence}${ps.currentChar()}`); } sequence += ch; } return `\\${u}${sequence}`; } getPlaceable(ps) { ps.expectChar("{"); ps.skipBlank(); const expression = this.getExpression(ps); ps.expectChar("}"); return new Placeable(expression); } getExpression(ps) { const selector = this.getInlineExpression(ps); ps.skipBlank(); if (ps.currentChar() === "-") { if (ps.peek() !== ">") { ps.resetPeek(); return selector; } // Validate selector expression according to // abstract.js in the Fluent specification if (selector instanceof MessageReference) { if (selector.attribute === null) { throw new ParseError("E0016"); } else { throw new ParseError("E0018"); } } else if (selector instanceof TermReference) { if (selector.attribute === null) { throw new ParseError("E0017"); } } else if (selector instanceof Placeable) { throw new ParseError("E0029"); } ps.next(); ps.next(); ps.skipBlankInline(); ps.expectLineEnd(); const variants = this.getVariants(ps); return new SelectExpression(selector, variants); } if (selector instanceof TermReference && selector.attribute !== null) { throw new ParseError("E0019"); } return selector; } getInlineExpression(ps) { if (ps.currentChar() === "{") { return this.getPlaceable(ps); } if (ps.isNumberStart()) { return this.getNumber(ps); } if (ps.currentChar() === '"') { return this.getString(ps); } if (ps.currentChar() === "$") { ps.next(); const id = this.getIdentifier(ps); return new VariableReference(id); } if (ps.currentChar() === "-") { ps.next(); const id = this.getIdentifier(ps); let attr; if (ps.currentChar() === ".") { ps.next(); attr = this.getIdentifier(ps); } let args; ps.peekBlank(); if (ps.currentPeek() === "(") { ps.skipToPeek(); args = this.getCallArguments(ps); } return new TermReference(id, attr, args); } if (ps.isIdentifierStart()) { const id = this.getIdentifier(ps); ps.peekBlank(); if (ps.currentPeek() === "(") { // It's a Function. Ensure it's all upper-case. if (!/^[A-Z][A-Z0-9_-]*$/.test(id.name)) { throw new ParseError("E0008"); } ps.skipToPeek(); let args = this.getCallArguments(ps); return new FunctionReference(id, args); } let attr; if (ps.currentChar() === ".") { ps.next(); attr = this.getIdentifier(ps); } return new MessageReference(id, attr); } throw new ParseError("E0028"); } getCallArgument(ps) { const exp = this.getInlineExpression(ps); ps.skipBlank(); if (ps.currentChar() !== ":") { return exp; } if (exp instanceof MessageReference && exp.attribute === null) { ps.next(); ps.skipBlank(); const value = this.getLiteral(ps); return new NamedArgument(exp.id, value); } throw new ParseError("E0009"); } getCallArguments(ps) { const positional = []; const named = []; const argumentNames = new Set(); ps.expectChar("("); ps.skipBlank(); while (true) { if (ps.currentChar() === ")") { break; } const arg = this.getCallArgument(ps); if (arg instanceof NamedArgument) { if (argumentNames.has(arg.name.name)) { throw new ParseError("E0022"); } named.push(arg); argumentNames.add(arg.name.name); } else if (argumentNames.size > 0) { throw new ParseError("E0021"); } else { positional.push(arg); } ps.skipBlank(); if (ps.currentChar() === ",") { ps.next(); ps.skipBlank(); continue; } break; } ps.expectChar(")"); return new CallArguments(positional, named); } getString(ps) { ps.expectChar('"'); let value = ""; let ch; while ((ch = ps.takeChar(x => x !== '"' && x !== EOL))) { if (ch === "\\") { value += this.getEscapeSequence(ps); } else { value += ch; } } if (ps.currentChar() === EOL) { throw new ParseError("E0020"); } ps.expectChar('"'); return new StringLiteral(value); } getLiteral(ps) { if (ps.isNumberStart()) { return this.getNumber(ps); } if (ps.currentChar() === '"') { return this.getString(ps); } throw new ParseError("E0014"); } } class Indent { /** @ignore */ constructor(value, start, end) { this.type = "Indent"; this.value = value; this.span = new Span(start, end); } } /* eslint-disable @typescript-eslint/restrict-template-expressions */ function indentExceptFirstLine(content) { return content.split("\n").join("\n "); } function includesNewLine(elem) { return elem instanceof TextElement && elem.value.includes("\n"); } function isSelectExpr(elem) { return (elem instanceof Placeable && elem.expression instanceof SelectExpression); } function shouldStartOnNewLine(pattern) { const isMultiline = pattern.elements.some(isSelectExpr) || pattern.elements.some(includesNewLine); if (isMultiline) { const firstElement = pattern.elements[0]; if (firstElement instanceof TextElement) { const firstChar = firstElement.value[0]; // Due to the indentation requirement these text characters may not appear // as the first character on a new line. if (firstChar === "[" || firstChar === "." || firstChar === "*") { return false; } } return true; } return false; } /** Bit masks representing the state of the serializer. */ const HAS_ENTRIES = 1; class FluentSerializer { constructor({ withJunk = false } = {}) { this.withJunk = withJ