diff --git a/fluent-bundle/src/ast.ts b/fluent-bundle/src/ast.ts index 30e325979..b5ae84380 100644 --- a/fluent-bundle/src/ast.ts +++ b/fluent-bundle/src/ast.ts @@ -68,7 +68,7 @@ export type Variant = { export type NamedArgument = { type: "narg"; name: string; - value: Literal; + value: Literal | VariableReference; }; export type Literal = StringLiteral | NumberLiteral; diff --git a/fluent-bundle/src/bundle.ts b/fluent-bundle/src/bundle.ts index 3ec8dcd8f..6a614da6a 100644 --- a/fluent-bundle/src/bundle.ts +++ b/fluent-bundle/src/bundle.ts @@ -204,7 +204,7 @@ export class FluentBundle { return value.toString(scope); } catch (err) { if (scope.errors && err instanceof Error) { - scope.errors.push(err); + scope.errors.unshift(err); return new FluentNone().toString(scope); } throw err; diff --git a/fluent-bundle/src/resolver.ts b/fluent-bundle/src/resolver.ts index dd8afc355..d40c801c5 100644 --- a/fluent-bundle/src/resolver.ts +++ b/fluent-bundle/src/resolver.ts @@ -177,6 +177,8 @@ function resolveVariableReference( switch (typeof arg) { case "string": return arg; + case "boolean": + return String(arg); case "number": return new FluentNumber(arg); case "object": @@ -195,8 +197,16 @@ function resolveVariableReference( /** Resolve a reference to another message. */ function resolveMessageReference( scope: Scope, - { name, attr }: MessageReference + ref: MessageReference ): FluentValue { + const { name, attr } = ref; + + if (scope.dirty.has(ref)) { + scope.reportError(new RangeError("Cyclic reference")); + return new FluentNone(name); + } + scope.dirty.add(ref); + const message = scope.bundle._messages.get(name); if (!message) { scope.reportError(new ReferenceError(`Unknown message: ${name}`)); @@ -221,11 +231,16 @@ function resolveMessageReference( } /** Resolve a call to a Term with key-value arguments. */ -function resolveTermReference( - scope: Scope, - { name, attr, args }: TermReference -): FluentValue { +function resolveTermReference(scope: Scope, ref: TermReference): FluentValue { + const { name, attr, args } = ref; const id = `-${name}`; + + if (scope.dirty.has(ref)) { + scope.reportError(new RangeError("Cyclic reference")); + return new FluentNone(id); + } + scope.dirty.add(ref); + const term = scope.bundle._terms.get(id); if (!term) { scope.reportError(new ReferenceError(`Unknown term: ${id}`)); @@ -304,13 +319,6 @@ export function resolveComplexPattern( scope: Scope, ptn: ComplexPattern ): FluentValue { - if (scope.dirty.has(ptn)) { - scope.reportError(new RangeError("Cyclic reference")); - return new FluentNone(); - } - - // Tag the pattern as dirty for the purpose of the current resolution. - scope.dirty.add(ptn); const result = []; // Wrap interpolations with Directional Isolate Formatting characters @@ -325,7 +333,6 @@ export function resolveComplexPattern( scope.placeables++; if (scope.placeables > MAX_PLACEABLES) { - scope.dirty.delete(ptn); // This is a fatal error which causes the resolver to instantly bail out // on this pattern. The length check protects against excessive memory // usage, and throwing protects against eating up the CPU when long @@ -347,7 +354,6 @@ export function resolveComplexPattern( } } - scope.dirty.delete(ptn); return result.join(""); } diff --git a/fluent-bundle/src/resource.ts b/fluent-bundle/src/resource.ts index 778893fe3..aee343f1b 100644 --- a/fluent-bundle/src/resource.ts +++ b/fluent-bundle/src/resource.ts @@ -29,6 +29,7 @@ const RE_VARIANT_START = /\*?\[/y; const RE_NUMBER_LITERAL = /(-?[0-9]+(?:\.([0-9]+))?)/y; const RE_IDENTIFIER = /([a-zA-Z][\w-]*)/y; const RE_REFERENCE = /([$-])?([a-zA-Z][\w-]*)(?:\.([a-zA-Z][\w-]*))?/y; +const RE_VARIABLE_REF = /[$]([a-zA-Z][\w-]*)/y; const RE_FUNCTION_NAME = /^[A-Z][A-Z0-9_-]*$/; // A "run" is a sequence of text or string literal characters which don't @@ -76,7 +77,6 @@ export class FluentResource { this.body = []; RE_MESSAGE_START.lastIndex = 0; - let cursor = 0; // Iterate over the beginnings of messages and terms to efficiently skip // comments and recover from errors. @@ -86,9 +86,9 @@ export class FluentResource { break; } - cursor = RE_MESSAGE_START.lastIndex; + const cursor = RE_MESSAGE_START.lastIndex; try { - this.body.push(parseMessage(next[1])); + this.body.push(parseMessage(source, cursor, next[1])); } catch (err) { if (err instanceof SyntaxError) { // Don't report any Fluent syntax errors. Skip directly to the @@ -98,451 +98,422 @@ export class FluentResource { throw err; } } + } +} - // The parser implementation is inlined below for performance reasons, - // as well as for convenience of accessing `source` and `cursor`. - - // The parser focuses on minimizing the number of false negatives at the - // expense of increasing the risk of false positives. In other words, it - // aims at parsing valid Fluent messages with a success rate of 100%, but it - // may also parse a few invalid messages which the reference parser would - // reject. The parser doesn't perform any validation and may produce entries - // which wouldn't make sense in the real world. For best results users are - // advised to validate translations with the fluent-syntax parser - // pre-runtime. - - // The parser makes an extensive use of sticky regexes which can be anchored - // to any offset of the source string without slicing it. Errors are thrown - // to bail out of parsing of ill-formed messages. - - function test(re: RegExp): boolean { - re.lastIndex = cursor; - return re.test(source); - } - - // Advance the cursor by the char if it matches. May be used as a predicate - // (was the match found?) or, if errorClass is passed, as an assertion. - function consumeChar( - char: string, - errorClass?: typeof SyntaxError - ): boolean { - if (source[cursor] === char) { - cursor++; - return true; - } - if (errorClass) { - throw new errorClass(`Expected ${char}`); - } - return false; - } - - // Advance the cursor by the token if it matches. May be used as a predicate - // (was the match found?) or, if errorClass is passed, as an assertion. - function consumeToken( - re: RegExp, - errorClass?: typeof SyntaxError - ): boolean { - if (test(re)) { - cursor = re.lastIndex; - return true; - } - if (errorClass) { - throw new errorClass(`Expected ${re.toString()}`); - } - return false; +/** + * The parser implementation is inlined within parseMessage() for performance reasons, + * as well as for convenience of accessing `source` and `cursor`. + * + * The parser focuses on minimizing the number of false negatives + * at the expense of increasing the risk of false positives. + * In other words, it aims at parsing valid Fluent messages with a success rate of 100%, + * but it may also parse a few invalid messages which the reference parser would reject. + * The parser doesn't perform any validation and may produce entries which wouldn't make sense in the real world. + * For best results users are advised to validate translations with the fluent-syntax parser pre-runtime. + * + * The parser makes an extensive use of sticky regexes which can be + * anchored to any offset of the source string without slicing it. + * Errors are thrown to bail out of parsing of ill-formed messages. + */ +function parseMessage(source: string, cursor: number, id: string): Message { + /** + * Advance the cursor by the char if it matches. + * May be used as a predicate (was the match found?) or, + * if errorClass is passed, as an assertion. + */ + function consumeChar( + char: string, + errorClass?: typeof SyntaxError, + errorMsg?: string + ): boolean { + if (source[cursor] === char) { + cursor++; + return true; } + if (errorClass) { + throw new errorClass(errorMsg ?? `Expected ${char}`); + } + return false; + } - // Execute a regex, advance the cursor, and return all capture groups. - function match(re: RegExp): RegExpExecArray { - re.lastIndex = cursor; - let result = re.exec(source); - if (result === null) { - throw new SyntaxError(`Expected ${re.toString()}`); - } + /** + * Advance the cursor by the token if it matches. + * May be used as a predicate (was the match found?) or, + * if errorClass is passed, as an assertion. + */ + function consumeToken(re: RegExp, errorClass?: typeof SyntaxError): boolean { + re.lastIndex = cursor; + if (re.test(source)) { cursor = re.lastIndex; - return result; + return true; } - - // Execute a regex, advance the cursor, and return the capture group. - function match1(re: RegExp): string { - return match(re)[1]; + if (errorClass) { + throw new errorClass(`Expected ${re.toString()}`); } + return false; + } - function parseMessage(id: string): Message { - let value = parsePattern(); - let attributes = parseAttributes(); - - if (value === null && Object.keys(attributes).length === 0) { - throw new SyntaxError("Expected message value or attributes"); + /** Execute a regex, advance the cursor, and return all capture groups. */ + function match(re: RegExp, required: true): RegExpExecArray; + function match(re: RegExp, required: false): RegExpExecArray | null; + function match(re: RegExp, required: boolean): RegExpExecArray | null { + re.lastIndex = cursor; + let result = re.exec(source); + if (result === null) { + if (required) { + throw new SyntaxError(`Expected ${re.toString()}`); + } else { + return null; } + } + cursor = re.lastIndex; + return result; + } - return { id, value, attributes }; + function parsePattern(): Pattern | null { + let first; + // First try to parse any simple text on the same line as the id. + const text = match(RE_TEXT_RUN, false); + if (text) { + first = text[1]; } - function parseAttributes(): Record { - let attrs = Object.create(null) as Record; + // If there's a placeable on the first line, parse a complex pattern. + if (source[cursor] === "{" || source[cursor] === "}") { + // Re-use the text parsed above, if possible. + return parsePatternElements(first ? [first] : [], Infinity); + } - while (test(RE_ATTRIBUTE_START)) { - let name = match1(RE_ATTRIBUTE_START); - let value = parsePattern(); - if (value === null) { - throw new SyntaxError("Expected attribute value"); - } - attrs[name] = value; - } + // RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if + // what comes after the newline is indented. + const indent = parseIndent(); + if (indent) { + if (first) { + // If there's text on the first line, the blank block is part of the + // translation content in its entirety. + return parsePatternElements([first, indent], indent.length); + } + // Otherwise, we're dealing with a block pattern, i.e. a pattern which + // starts on a new line. Discrad the leading newlines but keep the + // inline indent; it will be used by the dedentation logic. + indent.value = indent.value.replace(RE_LEADING_NEWLINES, ""); + return parsePatternElements([indent], indent.length); + } - return attrs; + if (first) { + // It was just a simple inline text after all. + return first.replace(RE_TRAILING_SPACES, ""); } - function parsePattern(): Pattern | null { - let first; - // First try to parse any simple text on the same line as the id. - if (test(RE_TEXT_RUN)) { - first = match1(RE_TEXT_RUN); + return null; + } + + // Parse a complex pattern as an array of elements. + function parsePatternElements( + elements: Array = [], + commonIndent: number + ): ComplexPattern { + while (true) { + const text = match(RE_TEXT_RUN, false); + if (text) { + elements.push(text[1]); + continue; } - // If there's a placeable on the first line, parse a complex pattern. - if (source[cursor] === "{" || source[cursor] === "}") { - // Re-use the text parsed above, if possible. - return parsePatternElements(first ? [first] : [], Infinity); + if (source[cursor] === "{") { + elements.push(parsePlaceable()); + continue; } - // RE_TEXT_VALUE stops at newlines. Only continue parsing the pattern if - // what comes after the newline is indented. - let indent = parseIndent(); - if (indent) { - if (first) { - // If there's text on the first line, the blank block is part of the - // translation content in its entirety. - return parsePatternElements([first, indent], indent.length); - } - // Otherwise, we're dealing with a block pattern, i.e. a pattern which - // starts on a new line. Discrad the leading newlines but keep the - // inline indent; it will be used by the dedentation logic. - indent.value = trim(indent.value, RE_LEADING_NEWLINES); - return parsePatternElements([indent], indent.length); + if (source[cursor] === "}") { + throw new SyntaxError("Unbalanced closing brace"); } - if (first) { - // It was just a simple inline text after all. - return trim(first, RE_TRAILING_SPACES); + const indent = parseIndent(); + if (indent) { + elements.push(indent); + commonIndent = Math.min(commonIndent, indent.length); + continue; } - return null; + break; } - // Parse a complex pattern as an array of elements. - function parsePatternElements( - elements: Array = [], - commonIndent: number - ): ComplexPattern { - while (true) { - if (test(RE_TEXT_RUN)) { - elements.push(match1(RE_TEXT_RUN)); - continue; - } - - if (source[cursor] === "{") { - elements.push(parsePlaceable()); - continue; - } - - if (source[cursor] === "}") { - throw new SyntaxError("Unbalanced closing brace"); - } - - let indent = parseIndent(); - if (indent) { - elements.push(indent); - commonIndent = Math.min(commonIndent, indent.length); - continue; - } + const lastIndex = elements.length - 1; + const lastElement = elements[lastIndex]; + if (typeof lastElement === "string") { + elements[lastIndex] = lastElement.replace(RE_TRAILING_SPACES, ""); + } - break; + const baked: PatternElement[] = []; + for (let element of elements) { + if (element instanceof Indent) { + // Dedent indented lines by the maximum common indent. + element = element.value.slice(0, element.value.length - commonIndent); } - - let lastIndex = elements.length - 1; - let lastElement = elements[lastIndex]; - // Trim the trailing spaces in the last element if it's a TextElement. - if (typeof lastElement === "string") { - elements[lastIndex] = trim(lastElement, RE_TRAILING_SPACES); + if (element) { + baked.push(element); } - - let baked: Array = []; - for (let element of elements) { - if (element instanceof Indent) { - // Dedent indented lines by the maximum common indent. - element = element.value.slice(0, element.value.length - commonIndent); - } - if (element) { - baked.push(element); - } - } - return baked; } + return baked; + } - function parsePlaceable(): Expression { - consumeToken(TOKEN_BRACE_OPEN, SyntaxError); - - let selector = parseInlineExpression(); - if (consumeToken(TOKEN_BRACE_CLOSE)) { - return selector; - } - - if (consumeToken(TOKEN_ARROW)) { - let variants = parseVariants(); - consumeToken(TOKEN_BRACE_CLOSE, SyntaxError); - return { - type: "select", - selector, - ...variants, - } as SelectExpression; - } + function parsePlaceable(): Expression { + consumeToken(TOKEN_BRACE_OPEN, SyntaxError); - throw new SyntaxError("Unclosed placeable"); + const expression = parseInlineExpression(); + if (consumeToken(TOKEN_BRACE_CLOSE)) { + return expression; } - function parseInlineExpression(): Expression { - if (source[cursor] === "{") { - // It's a nested placeable. - return parsePlaceable(); - } + if (consumeToken(TOKEN_ARROW)) { + let variants = parseVariants(); + consumeToken(TOKEN_BRACE_CLOSE, SyntaxError); + return { + type: "select", + selector: expression, + ...variants, + } satisfies SelectExpression; + } - if (test(RE_REFERENCE)) { - let [, sigil, name, attr = null] = match(RE_REFERENCE); + throw new SyntaxError("Unclosed placeable"); + } - if (sigil === "$") { - return { type: "var", name } as VariableReference; - } + function parseInlineExpression(): Expression { + if (source[cursor] === "{") { + // It's a nested placeable. + return parsePlaceable(); + } - if (consumeToken(TOKEN_PAREN_OPEN)) { - let args = parseArguments(); + const ref = match(RE_REFERENCE, false); + if (ref === null) { + return parseLiteral(); + } - if (sigil === "-") { - // A parameterized term: -term(...). - return { type: "term", name, attr, args } as TermReference; - } + const [, sigil, name, attr = null] = ref; - if (RE_FUNCTION_NAME.test(name)) { - return { type: "func", name, args } as FunctionReference; - } + if (sigil === "$") { + return { type: "var", name } satisfies VariableReference; + } - throw new SyntaxError("Function names must be all upper-case"); - } + if (consumeToken(TOKEN_PAREN_OPEN)) { + let args = parseArguments(); - if (sigil === "-") { - // A non-parameterized term: -term. - return { - type: "term", - name, - attr, - args: [], - } as TermReference; - } + if (sigil === "-") { + // A parameterized term: -term(...). + return { type: "term", name, attr, args } satisfies TermReference; + } - return { type: "mesg", name, attr } as MessageReference; + if (RE_FUNCTION_NAME.test(name)) { + return { type: "func", name, args } satisfies FunctionReference; } - return parseLiteral(); + throw new SyntaxError("Function names must be all upper-case"); } - function parseArguments(): Array { - let args: Array = []; - while (true) { - switch (source[cursor]) { - case ")": // End of the argument list. - cursor++; - return args; - case undefined: // EOF - throw new SyntaxError("Unclosed argument list"); - } - - args.push(parseArgument()); - // Commas between arguments are treated as whitespace. - consumeToken(TOKEN_COMMA); - } + if (sigil === "-") { + // A non-parameterized term: -term. + return { + type: "term", + name, + attr, + args: [], + } satisfies TermReference; } - function parseArgument(): Expression | NamedArgument { - let expr = parseInlineExpression(); - if (expr.type !== "mesg") { - return expr; - } + return { type: "mesg", name, attr } as MessageReference; + } - if (consumeToken(TOKEN_COLON)) { - // The reference is the beginning of a named argument. - return { - type: "narg", - name: expr.name, - value: parseLiteral(), - } as NamedArgument; + function parseArguments(): Array { + const args: Array = []; + while (true) { + switch (source[cursor]) { + case ")": // End of the argument list. + cursor++; + return args; + case undefined: // EOF + throw new SyntaxError("Unclosed argument list"); } - // It's a regular message reference. + args.push(parseArgument()); + // Commas between arguments are treated as whitespace. + consumeToken(TOKEN_COMMA); + } + } + + function parseArgument(): Expression | NamedArgument { + const expr = parseInlineExpression(); + if (expr.type !== "mesg") { return expr; } - function parseVariants(): { - variants: Array; - star: number; - } | null { - let variants: Array = []; - let count = 0; - let star; + if (consumeToken(TOKEN_COLON)) { + // The reference is the beginning of a named argument. + const ref = match(RE_VARIABLE_REF, false); + const value: Literal | VariableReference = + ref === null ? parseLiteral() : { type: "var", name: ref[1] }; + return { type: "narg", name: expr.name, value } satisfies NamedArgument; + } - while (test(RE_VARIANT_START)) { - if (consumeChar("*")) { - star = count; - } + // It's a regular message reference. + return expr; + } - let key = parseVariantKey(); - let value = parsePattern(); - if (value === null) { - throw new SyntaxError("Expected variant value"); - } - variants[count++] = { key, value }; - } + function parseVariants(): { + variants: Array; + star: number; + } { + const variants: Array = []; + let star; - if (count === 0) { - return null; + RE_VARIANT_START.lastIndex = cursor; + while (RE_VARIANT_START.test(source)) { + if (consumeChar("*")) { + star = variants.length; } - if (star === undefined) { - throw new SyntaxError("Expected default variant"); + const key = parseVariantKey(); + const value = parsePattern(); + if (value === null) { + throw new SyntaxError("Expected variant value"); } - - return { variants, star }; + variants.push({ key, value }); + RE_VARIANT_START.lastIndex = cursor; } - function parseVariantKey(): Literal { - consumeToken(TOKEN_BRACKET_OPEN, SyntaxError); - let key; - if (test(RE_NUMBER_LITERAL)) { - key = parseNumberLiteral(); - } else { - key = { - type: "str", - value: match1(RE_IDENTIFIER), - } as StringLiteral; - } - consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError); - return key; + if (star === undefined) { + throw new SyntaxError("Expected default variant"); } - function parseLiteral(): Literal { - if (test(RE_NUMBER_LITERAL)) { - return parseNumberLiteral(); - } + return { variants, star }; + } - if (source[cursor] === '"') { - return parseStringLiteral(); - } + function parseVariantKey(): Literal { + consumeToken(TOKEN_BRACKET_OPEN, SyntaxError); + const key: Literal = parseNumberLiteral() ?? { + type: "str", + value: match(RE_IDENTIFIER, true)[1], + }; + consumeToken(TOKEN_BRACKET_CLOSE, SyntaxError); + return key; + } - throw new SyntaxError("Invalid expression"); + function parseLiteral(): Literal { + const num = parseNumberLiteral(); + if (num) { + return num; } - function parseNumberLiteral(): NumberLiteral { - let [, value, fraction = ""] = match(RE_NUMBER_LITERAL); - let precision = fraction.length; - return { - type: "num", - value: parseFloat(value), - precision, - } as NumberLiteral; + consumeChar('"', SyntaxError, "Invalid expression"); + let value = ""; + while (true) { + value += match(RE_STRING_RUN, true)[1]; + if (source[cursor] === "\\") { + value += parseEscapeSequence(); + continue; + } + consumeChar('"', SyntaxError, "Unclosed string literal"); + return { type: "str", value } satisfies StringLiteral; } + } - function parseStringLiteral(): StringLiteral { - consumeChar('"', SyntaxError); - let value = ""; - while (true) { - value += match1(RE_STRING_RUN); - - if (source[cursor] === "\\") { - value += parseEscapeSequence(); - continue; - } - - if (consumeChar('"')) { - return { type: "str", value } as StringLiteral; + function parseNumberLiteral(): NumberLiteral | null { + const num = match(RE_NUMBER_LITERAL, false); + return num + ? { + type: "num", + value: parseFloat(num[1]), + precision: num[2]?.length ?? 0, } + : null; + } - // We've reached an EOL of EOF. - throw new SyntaxError("Unclosed string literal"); - } + // Unescape known escape sequences. + function parseEscapeSequence(): string { + const strEsc = match(RE_STRING_ESCAPE, false); + if (strEsc) { + return strEsc[1]; } - // Unescape known escape sequences. - function parseEscapeSequence(): string { - if (test(RE_STRING_ESCAPE)) { - return match1(RE_STRING_ESCAPE); - } - - if (test(RE_UNICODE_ESCAPE)) { - let [, codepoint4, codepoint6] = match(RE_UNICODE_ESCAPE); - let codepoint = parseInt(codepoint4 || codepoint6, 16); - return codepoint <= 0xd7ff || 0xe000 <= codepoint - ? // It's a Unicode scalar value. - String.fromCodePoint(codepoint) - : // Lonely surrogates can cause trouble when the parsing result is - // saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead. - "�"; - } - + const unicEsc = match(RE_UNICODE_ESCAPE, false); + if (unicEsc === null) { throw new SyntaxError("Unknown escape sequence"); } - // Parse blank space. Return it if it looks like indent before a pattern - // line. Skip it othwerwise. - function parseIndent(): Indent | false { - let start = cursor; - consumeToken(TOKEN_BLANK); - - // Check the first non-blank character after the indent. - switch (source[cursor]) { - case ".": - case "[": - case "*": - case "}": - case undefined: // EOF - // A special character. End the Pattern. - return false; - case "{": - // Placeables don't require indentation (in EBNF: block-placeable). - // Continue the Pattern. - return makeIndent(source.slice(start, cursor)); - } - - // If the first character on the line is not one of the special characters - // listed above, it's a regular text character. Check if there's at least - // one space of indent before it. - if (source[cursor - 1] === " ") { - // It's an indented text character (in EBNF: indented-char). Continue - // the Pattern. - return makeIndent(source.slice(start, cursor)); - } + const codepoint = parseInt(unicEsc[1] || unicEsc[2], 16); + return codepoint <= 0xd7ff || 0xe000 <= codepoint + ? // It's a Unicode scalar value. + String.fromCodePoint(codepoint) + : // Lone surrogates can cause trouble when the parsing result is + // saved using UTF-8. Use U+FFFD REPLACEMENT CHARACTER instead. + "�"; + } - // A not-indented text character is likely the identifier of the next - // message. End the Pattern. - return false; + // Parse blank space. Return it if it looks like indent before a pattern + // line. Skip it othwerwise. + function parseIndent(): Indent | false { + const start = cursor; + consumeToken(TOKEN_BLANK); + + // Check the first non-blank character after the indent. + switch (source[cursor]) { + case ".": + case "[": + case "*": + case "}": + case undefined: // EOF + // A special character. End the Pattern. + return false; + case "{": + // Placeables don't require indentation (in EBNF: block-placeable). + // Continue the Pattern. + return new Indent(source.slice(start, cursor)); } - // Trim blanks in text according to the given regex. - function trim(text: string, re: RegExp): string { - return text.replace(re, ""); + // If the first character on the line is not one of the special characters + // listed above, it's a regular text character. Check if there's at least + // one space of indent before it. + if (source[cursor - 1] === " ") { + // It's an indented text character (in EBNF: indented-char). Continue + // the Pattern. + return new Indent(source.slice(start, cursor)); } - // Normalize a blank block and extract the indent details. - function makeIndent(blank: string): Indent { - let value = blank.replace(RE_BLANK_LINES, "\n"); - let length = RE_INDENT.exec(blank)![1].length; - return new Indent(value, length); + // A not-indented text character is likely the identifier of the next + // message. End the Pattern. + return false; + } + + const value = parsePattern(); + + const attributes = Object.create(null) as Record; + let hasAttributes = false; + let attr; + while ((attr = match(RE_ATTRIBUTE_START, false))) { + const name = attr[1]; + const pattern = parsePattern(); + if (pattern === null) { + throw new SyntaxError("Expected attribute value"); } + attributes[name] = pattern; + hasAttributes ||= true; + } + + if (value === null && !hasAttributes) { + throw new SyntaxError("Expected message value or attributes"); } + + return { id, value, attributes }; } class Indent { - constructor( - public value: string, - public length: number - ) {} + value: string; + length: number; + + // Normalize a blank block and extract the indent details. + constructor(blank: string) { + this.value = blank.replace(RE_BLANK_LINES, "\n"); + this.length = RE_INDENT.exec(blank)![1].length; + } } diff --git a/fluent-bundle/src/scope.ts b/fluent-bundle/src/scope.ts index b8ba6b61b..79b43ea1a 100644 --- a/fluent-bundle/src/scope.ts +++ b/fluent-bundle/src/scope.ts @@ -1,6 +1,6 @@ -import { FluentBundle } from "./bundle.js"; -import { ComplexPattern } from "./ast.js"; -import { FluentVariable } from "./types.js"; +import type { FluentBundle } from "./bundle.js"; +import type { MessageReference, TermReference } from "./ast.js"; +import type { FluentVariable } from "./types.js"; export class Scope { /** The bundle for which the given resolution is happening. */ @@ -14,7 +14,7 @@ export class Scope { * Used to detect and prevent cyclic resolutions. * @ignore */ - public dirty: WeakSet = new WeakSet(); + public dirty: WeakSet = new WeakSet(); /** A dict of parameters passed to a TermReference. */ public params: Record | null = null; /** diff --git a/fluent-bundle/src/types.ts b/fluent-bundle/src/types.ts index 5594861b0..2b483a6c6 100644 --- a/fluent-bundle/src/types.ts +++ b/fluent-bundle/src/types.ts @@ -28,6 +28,7 @@ export type FluentVariable = | TemporalObject | string | number + | boolean | Date; export type FluentFunction = ( @@ -69,7 +70,7 @@ export abstract class FluentType { * This method can use `Intl` formatters available through the `scope` * argument. */ - abstract toString(scope: Scope): string; + abstract toString(scope?: Scope): string; } /** @@ -87,7 +88,7 @@ export class FluentNone extends FluentType { /** * Format this `FluentNone` to the fallback string. */ - toString(scope: Scope): string { + toString(scope?: Scope): string { return `{${this.value}}`; } } diff --git a/fluent-bundle/test/arguments_test.js b/fluent-bundle/test/arguments_test.js index b823992fd..54b0d6652 100644 --- a/fluent-bundle/test/arguments_test.js +++ b/fluent-bundle/test/arguments_test.js @@ -126,13 +126,6 @@ suite("Variables", function () { assert(errs[0] instanceof TypeError); // unsupported variable type }); - test("cannot be a boolean", function () { - const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, { arg: true }, errs); - assert.strictEqual(val, "{$arg}"); - assert(errs[0] instanceof TypeError); // unsupported variable type - }); - test("cannot be undefined", function () { const msg = bundle.getMessage("foo"); const val = bundle.formatPattern(msg.value, { arg: undefined }, errs); @@ -178,6 +171,41 @@ suite("Variables", function () { }); }); + suite("and booleans", function () { + let args; + + beforeAll(function () { + bundle = new FluentBundle("en-US", { useIsolating: false }); + bundle.addResource( + new FluentResource(ftl` + foo = { $arg } + bar = + { $arg -> + [true] yes + *[false] no + } + `) + ); + args = { + arg: true, + }; + }); + + test("a placeholder can be a boolean", function () { + const msg = bundle.getMessage("foo"); + const val = bundle.formatPattern(msg.value, args, errs); + assert.strictEqual(val, "true"); + assert.strictEqual(errs.length, 0); + }); + + test("a selector can be a boolean", function () { + const msg = bundle.getMessage("bar"); + const val = bundle.formatPattern(msg.value, args, errs); + assert.strictEqual(val, "yes"); + assert.strictEqual(errs.length, 0); + }); + }); + suite("and numbers", function () { beforeAll(function () { bundle = new FluentBundle("en-US", { useIsolating: false }); diff --git a/fluent-bundle/test/bomb_test.js b/fluent-bundle/test/bomb_test.js index b4b6a0ec7..b3c331da2 100644 --- a/fluent-bundle/test/bomb_test.js +++ b/fluent-bundle/test/bomb_test.js @@ -1,21 +1,14 @@ -import assert from "assert"; import ftl from "@fluent/dedent"; import { FluentBundle } from "../src/bundle.ts"; import { FluentResource } from "../src/resource.ts"; +import { expect } from "vitest"; suite("Reference bombs", function () { - let bundle, args, errs; - - beforeEach(function () { - errs = []; - }); - suite("Billion Laughs", function () { - beforeAll(function () { - bundle = new FluentBundle("en-US", { useIsolating: false }); - bundle.addResource( - new FluentResource(ftl` + const bundle = new FluentBundle("en-US", { useIsolating: false }); + bundle.addResource( + new FluentResource(ftl` lol0 = LOL lol1 = {lol0} {lol0} {lol0} {lol0} {lol0} {lol0} {lol0} {lol0} {lol0} {lol0} lol2 = {lol1} {lol1} {lol1} {lol1} {lol1} {lol1} {lol1} {lol1} {lol1} {lol1} @@ -28,24 +21,20 @@ suite("Reference bombs", function () { lol9 = {lol8} {lol8} {lol8} {lol8} {lol8} {lol8} {lol8} {lol8} {lol8} {lol8} lolz = {lol9} `) - ); - }); + ); test("does not expand all placeables", function () { const msg = bundle.getMessage("lolz"); - const val = bundle.formatPattern(msg.value, args, errs); - assert.strictEqual(val, "{???}"); - assert.strictEqual(errs.length, 1); - assert.ok(errs[0] instanceof RangeError); + const errs = []; + const val = bundle.formatPattern(msg.value, undefined, errs); + expect(val).toBe("{???}"); + expect(errs).toHaveLength(74); + expect(errs[0]).toBeInstanceOf(RangeError); }); test("throws when errors are undefined", function () { const msg = bundle.getMessage("lolz"); - assert.throws( - () => bundle.formatPattern(msg.value), - RangeError, - "Too many characters in placeable" - ); + expect(() => bundle.formatPattern(msg.value)).toThrow(RangeError); }); }); }); diff --git a/fluent-bundle/test/fixtures_reference/call_expressions.json b/fluent-bundle/test/fixtures_reference/call_expressions.json index 137aedc39..8c031c2fd 100644 --- a/fluent-bundle/test/fixtures_reference/call_expressions.json +++ b/fluent-bundle/test/fixtures_reference/call_expressions.json @@ -160,6 +160,31 @@ ], "attributes": {} }, + { + "id": "variable-args", + "value": [ + { + "type": "func", + "name": "FUN", + "args": [ + { + "type": "var", + "name": "foo" + }, + { + "type": "narg", + "name": "arg", + "value": { + "type": "var", + "name": "bar" + } + } + + ] + } + ], + "attributes": {} + }, { "id": "shuffled-args", "value": [ @@ -555,7 +580,7 @@ "attributes": {} }, { - "id": "mulitline-args", + "id": "multiline-args", "value": [ { "type": "func", @@ -577,7 +602,7 @@ "attributes": {} }, { - "id": "mulitline-sparse-args", + "id": "multiline-sparse-args", "value": [ { "type": "func", diff --git a/fluent-bundle/test/fixtures_reference/crlf.json b/fluent-bundle/test/fixtures_reference/crlf.json index c1b5828bb..eaf6e29ce 100644 --- a/fluent-bundle/test/fixtures_reference/crlf.json +++ b/fluent-bundle/test/fixtures_reference/crlf.json @@ -15,19 +15,6 @@ "attributes": { "title": "Title" } - }, - { - "id": "err04", - "value": [ - { - "type": "select", - "selector": { - "type": "var", - "name": "sel" - } - } - ], - "attributes": {} } ] } diff --git a/fluent-bundle/test/fixtures_reference/term_parameters.json b/fluent-bundle/test/fixtures_reference/term_parameters.json index 2f393a04c..1c64e8c96 100644 --- a/fluent-bundle/test/fixtures_reference/term_parameters.json +++ b/fluent-bundle/test/fixtures_reference/term_parameters.json @@ -103,6 +103,27 @@ } ], "attributes": {} + }, + { + "id": "key05", + "value": [ + { + "type": "term", + "name": "term", + "attr": null, + "args": [ + { + "type": "narg", + "name": "arg", + "value": { + "type": "var", + "name": "foo" + } + } + ] + } + ], + "attributes": {} } ] } diff --git a/fluent-bundle/test/fixtures_structure/crlf.json b/fluent-bundle/test/fixtures_structure/crlf.json index c1b5828bb..eaf6e29ce 100644 --- a/fluent-bundle/test/fixtures_structure/crlf.json +++ b/fluent-bundle/test/fixtures_structure/crlf.json @@ -15,19 +15,6 @@ "attributes": { "title": "Title" } - }, - { - "id": "err04", - "value": [ - { - "type": "select", - "selector": { - "type": "var", - "name": "sel" - } - } - ], - "attributes": {} } ] } diff --git a/fluent-bundle/test/fixtures_structure/select_expression_without_variants.json b/fluent-bundle/test/fixtures_structure/select_expression_without_variants.json index 5c497a617..74b3731ac 100644 --- a/fluent-bundle/test/fixtures_structure/select_expression_without_variants.json +++ b/fluent-bundle/test/fixtures_structure/select_expression_without_variants.json @@ -1,30 +1,3 @@ { - "body": [ - { - "id": "err01", - "value": [ - { - "type": "select", - "selector": { - "type": "var", - "name": "foo" - } - } - ], - "attributes": {} - }, - { - "id": "err02", - "value": [ - { - "type": "select", - "selector": { - "type": "var", - "name": "foo" - } - } - ], - "attributes": {} - } - ] + "body": [] } diff --git a/fluent-bundle/test/functions_builtin_test.js b/fluent-bundle/test/functions_builtin_test.js index 9c30fc5a3..2d88e5484 100644 --- a/fluent-bundle/test/functions_builtin_test.js +++ b/fluent-bundle/test/functions_builtin_test.js @@ -14,7 +14,8 @@ suite("Built-in functions", function () { bundle.addResource( new FluentResource(ftl` num-bare = { NUMBER($arg) } - num-fraction-valid = { NUMBER($arg, minimumFractionDigits: 1) } + num-fraction-literal = { NUMBER($arg, minimumFractionDigits: 1) } + num-fraction-variable = { NUMBER($arg, minimumFractionDigits: $mfd) } num-fraction-bad = { NUMBER($arg, minimumFractionDigits: "oops") } num-style = { NUMBER($arg, style: "percent") } num-currency = { NUMBER($arg, currency: "EUR") } @@ -35,7 +36,7 @@ suite("Built-in functions", function () { assert.strictEqual(errors[0].message, "Unknown variable: $arg"); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, {}, errors), "{NUMBER($arg)}" @@ -44,6 +45,18 @@ suite("Built-in functions", function () { assert.ok(errors[0] instanceof ReferenceError); assert.strictEqual(errors[0].message, "Unknown variable: $arg"); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + assert.strictEqual( + bundle.formatPattern(msg.value, {}, errors), + "{NUMBER($arg)}" + ); + assert.strictEqual(errors.length, 2); + assert.ok(errors[0] instanceof ReferenceError); + assert.strictEqual(errors[0].message, "Unknown variable: $arg"); + assert.ok(errors[1] instanceof ReferenceError); + assert.strictEqual(errors[1].message, "Unknown variable: $mfd"); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -97,13 +110,21 @@ suite("Built-in functions", function () { assert.strictEqual(errors.length, 0); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "1,234.0" ); assert.strictEqual(errors.length, 0); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + assert.strictEqual( + bundle.formatPattern(msg.value, { arg, mfd: 1 }, errors), + "1,234.0" + ); + assert.strictEqual(errors.length, 0); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -152,13 +173,22 @@ suite("Built-in functions", function () { assert.strictEqual(errors.length, 0); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "1,234.0" ); assert.strictEqual(errors.length, 0); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + const mfd = new FluentNumber(1); + assert.strictEqual( + bundle.formatPattern(msg.value, { arg, mfd }, errors), + "1,234.0" + ); + assert.strictEqual(errors.length, 0); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -208,13 +238,22 @@ suite("Built-in functions", function () { assert.strictEqual(errors.length, 0); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "$1,234.0" ); assert.strictEqual(errors.length, 0); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + const mfd = new FluentNumber(1, { style: "currency", currency: "USD" }); + assert.strictEqual( + bundle.formatPattern(msg.value, { arg, mfd }, errors), + "$1,234.0" + ); + assert.strictEqual(errors.length, 0); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -266,7 +305,7 @@ suite("Built-in functions", function () { assert.strictEqual(errors.length, 0); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "1,475,107,200,000.0" @@ -288,7 +327,7 @@ suite("Built-in functions", function () { assert.strictEqual(errors[0].message, "Invalid argument to NUMBER"); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "{NUMBER()}" @@ -297,6 +336,14 @@ suite("Built-in functions", function () { assert.ok(errors[0] instanceof TypeError); assert.strictEqual(errors[0].message, "Invalid argument to NUMBER"); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + assert.strictEqual( + bundle.formatPattern(msg.value, { arg: 10, mfd: " 1 " }, errors), + "10.0" + ); + assert.strictEqual(errors.length, 0); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -350,7 +397,7 @@ suite("Built-in functions", function () { assert.strictEqual(errors.length, 0); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "1,475,107,200,000.0" @@ -408,7 +455,7 @@ suite("Built-in functions", function () { ); errors = []; - msg = bundle.getMessage("num-fraction-valid"); + msg = bundle.getMessage("num-fraction-literal"); assert.strictEqual( bundle.formatPattern(msg.value, { arg }, errors), "{NUMBER($arg)}" @@ -420,6 +467,20 @@ suite("Built-in functions", function () { "Variable type not supported: $arg, object" ); + errors = []; + msg = bundle.getMessage("num-fraction-variable"); + assert.strictEqual( + bundle.formatPattern(msg.value, { arg: 10, mfd: [] }, errors), + "10" + ); + assert.strictEqual(errors.length, 2); + assert.ok(errors[0] instanceof TypeError); + assert.strictEqual( + errors[0].message, + "Variable type not supported: $mfd, object" + ); + assert.ok(errors[1] instanceof RangeError); + errors = []; msg = bundle.getMessage("num-fraction-bad"); assert.strictEqual( @@ -472,6 +533,30 @@ suite("Built-in functions", function () { "Variable type not supported: $arg, object" ); }); + + test("numbering system", () => { + const res = new FluentResource("key = {$arg}\n"); + errors = []; + + let ar = new FluentBundle("ar", { useIsolating: false }); + ar.addResource(res); + msg = ar.getMessage("key"); + + let fmt = ar.formatPattern(msg.value, { arg: 10 }, errors); + assert.strictEqual(fmt, "10"); + + const arg = new FluentNumber(10, { numberingSystem: "arab" }); + fmt = ar.formatPattern(msg.value, { arg }, errors); + assert.strictEqual(fmt, "١٠"); + + ar = new FluentBundle("ar-u-nu-arab", { useIsolating: false }); + ar.addResource(res); + msg = ar.getMessage("key"); + fmt = ar.formatPattern(msg.value, { arg: 10 }, errors); + assert.strictEqual(fmt, "١٠"); + + assert.strictEqual(errors.length, 0); + }); }); suite("DATETIME", function () { diff --git a/fluent-bundle/test/functions_test.js b/fluent-bundle/test/functions_test.js index 3a47634d2..b7b91bb30 100644 --- a/fluent-bundle/test/functions_test.js +++ b/fluent-bundle/test/functions_test.js @@ -5,7 +5,9 @@ import { FluentBundle } from "../src/bundle.ts"; import { FluentResource } from "../src/resource.ts"; suite("Functions", function () { - let bundle, errs; + /** @type {FluentBundle} */ + let bundle; + let errs; beforeEach(function () { errs = []; @@ -30,7 +32,7 @@ suite("Functions", function () { }); }); - suite("arguments", function () { + suite("positional arguments", function () { beforeAll(function () { bundle = new FluentBundle("en-US", { useIsolating: false, @@ -107,4 +109,60 @@ suite("Functions", function () { assert.strictEqual(errs.length, 0); }); }); + + suite("named arguments", function () { + beforeAll(function () { + bundle = new FluentBundle("en-US", { + useIsolating: false, + functions: { + IDENTITY: (args, nargs) => nargs.arg, + }, + }); + bundle.addResource( + new FluentResource(ftl` + foo = Foo + .attr = Attribute + pass-string = { IDENTITY(arg: "a") } + pass-number = { IDENTITY(arg: 1) } + pass-variable = { IDENTITY(arg: $var) } + pass-message = { IDENTITY(arg: foo) } + pass-attr = { IDENTITY(arg: foo.attr) } + pass-function-call = { IDENTITY(arg: IDENTITY(1)) } + `) + ); + }); + + test("accepts strings", function () { + const msg = bundle.getMessage("pass-string"); + const val = bundle.formatPattern(msg.value, undefined, errs); + assert.strictEqual(val, "a"); + assert.strictEqual(errs.length, 0); + }); + + test("accepts numbers", function () { + const msg = bundle.getMessage("pass-number"); + const val = bundle.formatPattern(msg.value, undefined, errs); + assert.strictEqual(val, "1"); + assert.strictEqual(errs.length, 0); + }); + + test("accepts variables", function () { + const msg = bundle.getMessage("pass-variable"); + const val = bundle.formatPattern(msg.value, { var: "Variable" }, errs); + assert.strictEqual(val, "Variable"); + assert.strictEqual(errs.length, 0); + }); + + test("does not accept entities", function () { + assert.strictEqual(bundle.hasMessage("pass-message"), false); + }); + + test("does not accept attributes", function () { + assert.strictEqual(bundle.hasMessage("pass-attr"), false); + }); + + test("does not accept function calls", function () { + assert.strictEqual(bundle.hasMessage("pass-function-call"), false); + }); + }); }); diff --git a/fluent-bundle/test/patterns_test.js b/fluent-bundle/test/patterns_test.js index 148c2a0a6..52a784712 100644 --- a/fluent-bundle/test/patterns_test.js +++ b/fluent-bundle/test/patterns_test.js @@ -5,7 +5,9 @@ import { FluentBundle } from "../src/bundle.ts"; import { FluentResource } from "../src/resource.ts"; suite("Patterns", function () { - let bundle, args, errs; + /** @type {FluentBundle} */ + let bundle; + let errs; beforeEach(function () { errs = []; @@ -23,7 +25,7 @@ suite("Patterns", function () { test("returns the value", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Foo"); assert.strictEqual(errs.length, 0); }); @@ -50,28 +52,28 @@ suite("Patterns", function () { test("resolves the reference to a message", function () { const msg = bundle.getMessage("ref-message"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Foo"); assert.strictEqual(errs.length, 0); }); test("resolves the reference to a term", function () { const msg = bundle.getMessage("ref-term"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Bar"); assert.strictEqual(errs.length, 0); }); test("returns the id if a message reference is missing", function () { const msg = bundle.getMessage("ref-missing-message"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "{missing}"); assert.ok(errs[0] instanceof ReferenceError); // unknown message }); test("returns the id if a term reference is missing", function () { const msg = bundle.getMessage("ref-missing-term"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "{-missing}"); assert.ok(errs[0] instanceof ReferenceError); // unknown message }); @@ -91,21 +93,21 @@ suite("Patterns", function () { test("returns {???} when trying to format a null value", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "{???}"); assert.strictEqual(errs.length, 1); }); test("formats the attribute", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.attributes.attr, args, errs); + const val = bundle.formatPattern(msg.attributes.attr, undefined, errs); assert.strictEqual(val, "Foo Attr"); assert.strictEqual(errs.length, 0); }); test("falls back to id when the referenced message has no value", function () { const msg = bundle.getMessage("bar"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "{foo} Bar"); assert.ok(errs[0] instanceof ReferenceError); // no value }); @@ -118,16 +120,30 @@ suite("Patterns", function () { new FluentResource(ftl` foo = { bar } bar = { foo } + open-foo = Open foo + .accesskey = O + .tooltip = Press {open-foo.accesskey} to open a Foo. `) ); }); - test("returns ???", function () { + test("returns {bar}", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); - assert.strictEqual(val, "{???}"); + const val = bundle.formatPattern(msg.value, undefined, errs); + assert.strictEqual(val, "{bar}"); assert.ok(errs[0] instanceof RangeError); // cyclic reference }); + + test("Succeeds on non-cyclic references to own attributes", () => { + const msg = bundle.getMessage("open-foo"); + const attr = bundle.formatPattern( + msg.attributes.tooltip, + undefined, + errs + ); + assert.strictEqual(attr, "Press O to open a Foo."); + assert.strictEqual(errs.length, 0); + }); }); suite("Cyclic self-reference", function () { @@ -140,10 +156,10 @@ suite("Patterns", function () { ); }); - test("returns ???", function () { + test("returns {foo}", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); - assert.strictEqual(val, "{???}"); + const val = bundle.formatPattern(msg.value, undefined, errs); + assert.strictEqual(val, "{foo}"); assert.ok(errs[0] instanceof RangeError); // cyclic reference }); }); @@ -163,10 +179,10 @@ suite("Patterns", function () { ); }); - test("returns ???", function () { + test("returns {foo}", function () { const msg = bundle.getMessage("foo"); const val = bundle.formatPattern(msg.value, { sel: "a" }, errs); - assert.strictEqual(val, "{???}"); + assert.strictEqual(val, "{foo}"); assert.ok(errs[0] instanceof RangeError); // cyclic reference }); @@ -197,7 +213,7 @@ suite("Patterns", function () { test("returns the default variant", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Foo"); assert.ok(errs[0] instanceof RangeError); // cyclic reference }); @@ -228,14 +244,14 @@ suite("Patterns", function () { test("returns the default variant", function () { const msg = bundle.getMessage("foo"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Foo"); assert.ok(errs[0] instanceof RangeError); // cyclic reference }); test("can reference an attribute", function () { const msg = bundle.getMessage("bar"); - const val = bundle.formatPattern(msg.value, args, errs); + const val = bundle.formatPattern(msg.value, undefined, errs); assert.strictEqual(val, "Bar"); assert.strictEqual(errs.length, 0); }); diff --git a/fluent-syntax/src/ast.ts b/fluent-syntax/src/ast.ts index 0167bb42d..bb2865f42 100644 --- a/fluent-syntax/src/ast.ts +++ b/fluent-syntax/src/ast.ts @@ -390,9 +390,9 @@ export class Variant extends SyntaxNode { export class NamedArgument extends SyntaxNode { public type = "NamedArgument" as const; public name: Identifier; - public value: Literal; + public value: Literal | VariableReference; - constructor(name: Identifier, value: Literal) { + constructor(name: Identifier, value: Literal | VariableReference) { super(); this.name = name; this.value = value; diff --git a/fluent-syntax/src/errors.ts b/fluent-syntax/src/errors.ts index 9150e8358..da5924ba4 100644 --- a/fluent-syntax/src/errors.ts +++ b/fluent-syntax/src/errors.ts @@ -50,7 +50,7 @@ function getErrorMessage(code: string, args: Array): string { case "E0013": return "Expected variant key"; case "E0014": - return "Expected literal"; + return "Expected literal or variable reference"; case "E0015": return "Only one variant can be marked as default (*)"; case "E0016": diff --git a/fluent-syntax/src/parser.ts b/fluent-syntax/src/parser.ts index 92afb20c7..7421bfb3d 100644 --- a/fluent-syntax/src/parser.ts +++ b/fluent-syntax/src/parser.ts @@ -65,7 +65,7 @@ export class FluentParser { this.getCallArgument = withSpan(this.getCallArgument); this.getCallArguments = withSpan(this.getCallArguments); this.getString = withSpan(this.getString); - this.getLiteral = withSpan(this.getLiteral); + this.getNamedArgumentValue = withSpan(this.getNamedArgumentValue); this.getComment = withSpan(this.getComment); /* eslint-enable @typescript-eslint/unbound-method */ } @@ -784,7 +784,7 @@ export class FluentParser { ps.next(); ps.skipBlank(); - const value = this.getLiteral(ps); + const value = this.getNamedArgumentValue(ps); return new AST.NamedArgument(exp.id, value); } @@ -857,7 +857,9 @@ export class FluentParser { } /** @internal */ - getLiteral(ps: FluentParserStream): AST.Literal { + getNamedArgumentValue( + ps: FluentParserStream + ): AST.Literal | AST.VariableReference { if (ps.isNumberStart()) { return this.getNumber(ps); } @@ -866,6 +868,12 @@ export class FluentParser { return this.getString(ps); } + if (ps.currentChar() === "$") { + ps.next(); + const id = this.getIdentifier(ps); + return new AST.VariableReference(id); + } + throw new ParseError("E0014"); } } diff --git a/fluent-syntax/test/fixtures_reference/call_expressions.ftl b/fluent-syntax/test/fixtures_reference/call_expressions.ftl index 77c2188ad..e9dfe7103 100644 --- a/fluent-syntax/test/fixtures_reference/call_expressions.ftl +++ b/fluent-syntax/test/fixtures_reference/call_expressions.ftl @@ -19,6 +19,7 @@ positional-args = {FUN(1, "a", msg)} named-args = {FUN(x: 1, y: "Y")} dense-named-args = {FUN(x:1, y:"Y")} mixed-args = {FUN(1, "a", msg, x: 1, y: "Y")} +variable-args = {FUN($foo, arg: $bar)} # ERROR Positional arg must not follow keyword args shuffled-args = {FUN(1, x: 1, "a", y: "Y", msg)} @@ -80,11 +81,11 @@ unindented-closing-paren = {FUN( one-argument = {FUN(1,)} many-arguments = {FUN(1, 2, 3,)} inline-sparse-args = {FUN( 1, 2, 3, )} -mulitline-args = {FUN( +multiline-args = {FUN( 1, 2, )} -mulitline-sparse-args = {FUN( +multiline-sparse-args = {FUN( 1 , diff --git a/fluent-syntax/test/fixtures_reference/call_expressions.json b/fluent-syntax/test/fixtures_reference/call_expressions.json index 626711723..25e566e78 100644 --- a/fluent-syntax/test/fixtures_reference/call_expressions.json +++ b/fluent-syntax/test/fixtures_reference/call_expressions.json @@ -351,6 +351,58 @@ "attributes": [], "comment": null }, + { + "type": "Message", + "id": { + "type": "Identifier", + "name": "variable-args" + }, + "value": { + "type": "Pattern", + "elements": [ + { + "type": "Placeable", + "expression": { + "type": "FunctionReference", + "id": { + "type": "Identifier", + "name": "FUN" + }, + "arguments": { + "type": "CallArguments", + "positional": [ + { + "type": "VariableReference", + "id": { + "type": "Identifier", + "name": "foo" + } + } + ], + "named": [ + { + "type": "NamedArgument", + "name": { + "type": "Identifier", + "name": "arg" + }, + "value": { + "type": "VariableReference", + "id": { + "type": "Identifier", + "name": "bar" + } + } + } + ] + } + } + } + ] + }, + "attributes": [], + "comment": null + }, { "type": "Comment", "content": "ERROR Positional arg must not follow keyword args" @@ -1022,7 +1074,7 @@ "type": "Message", "id": { "type": "Identifier", - "name": "mulitline-args" + "name": "multiline-args" }, "value": { "type": "Pattern", @@ -1060,7 +1112,7 @@ "type": "Message", "id": { "type": "Identifier", - "name": "mulitline-sparse-args" + "name": "multiline-sparse-args" }, "value": { "type": "Pattern", diff --git a/fluent-syntax/test/fixtures_reference/term_parameters.ftl b/fluent-syntax/test/fixtures_reference/term_parameters.ftl index 600c12c9c..02aff9a57 100644 --- a/fluent-syntax/test/fixtures_reference/term_parameters.ftl +++ b/fluent-syntax/test/fixtures_reference/term_parameters.ftl @@ -6,3 +6,4 @@ key01 = { -term } key02 = { -term () } key03 = { -term(arg: 1) } key04 = { -term("positional", narg1: 1, narg2: 2) } +key05 = { -term(arg: $foo) } diff --git a/fluent-syntax/test/fixtures_reference/term_parameters.json b/fluent-syntax/test/fixtures_reference/term_parameters.json index 18d97cd1f..59bbce039 100644 --- a/fluent-syntax/test/fixtures_reference/term_parameters.json +++ b/fluent-syntax/test/fixtures_reference/term_parameters.json @@ -202,6 +202,51 @@ }, "attributes": [], "comment": null + }, + { + "type": "Message", + "id": { + "type": "Identifier", + "name": "key05" + }, + "value": { + "type": "Pattern", + "elements": [ + { + "type": "Placeable", + "expression": { + "type": "TermReference", + "id": { + "type": "Identifier", + "name": "term" + }, + "attribute": null, + "arguments": { + "type": "CallArguments", + "positional": [], + "named": [ + { + "type": "NamedArgument", + "name": { + "type": "Identifier", + "name": "arg" + }, + "value": { + "type": "VariableReference", + "id": { + "type": "Identifier", + "name": "foo" + } + } + } + ] + } + } + } + ] + }, + "attributes": [], + "comment": null } ] } diff --git a/fluent-syntax/test/fixtures_structure/call_expression_errors.json b/fluent-syntax/test/fixtures_structure/call_expression_errors.json index 3710025d8..91898b17a 100644 --- a/fluent-syntax/test/fixtures_structure/call_expression_errors.json +++ b/fluent-syntax/test/fixtures_structure/call_expression_errors.json @@ -52,7 +52,7 @@ "type": "Annotation", "code": "E0014", "arguments": [], - "message": "Expected literal", + "message": "Expected literal or variable reference", "span": { "type": "Span", "start": 80,