diff --git a/icu4j/build.xml b/icu4j/build.xml index f5690adba107..566d10f6f027 100644 --- a/icu4j/build.xml +++ b/icu4j/build.xml @@ -1361,6 +1361,7 @@ + @@ -1396,6 +1397,7 @@ + @@ -1442,6 +1444,7 @@ + @@ -1646,6 +1649,7 @@ + @@ -1668,6 +1672,7 @@ + diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/DateTimeFormatterFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/DateTimeFormatterFactory.java new file mode 100644 index 000000000000..e5fe7c18687a --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/DateTimeFormatterFactory.java @@ -0,0 +1,107 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import com.ibm.icu.impl.locale.AsciiUtil; +import com.ibm.icu.text.DateFormat; +import com.ibm.icu.text.SimpleDateFormat; + +/** + * Creates a {@link Formatter} doing formatting of date / time, similar to + * {exp, date} and {exp, time} in {@link com.ibm.icu.text.MessageFormat}. + */ +class DateTimeFormatterFactory implements FormatterFactory { + + private static int stringToStyle(String option) { + switch (AsciiUtil.toUpperString(option)) { + case "FULL": return DateFormat.FULL; + case "LONG": return DateFormat.LONG; + case "MEDIUM": return DateFormat.MEDIUM; + case "SHORT": return DateFormat.SHORT; + case "": // intentional fall-through + case "DEFAULT": return DateFormat.DEFAULT; + default: throw new IllegalArgumentException("Invalid datetime style: " + option); + } + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException when something goes wrong + * (for example conflicting options, invalid option values, etc.) + */ + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + DateFormat df; + + // TODO: how to handle conflicts. What if we have both skeleton and style, or pattern? + Object opt = fixedOptions.get("skeleton"); + if (opt != null) { + String skeleton = Objects.toString(opt); + df = DateFormat.getInstanceForSkeleton(skeleton, locale); + return new DateTimeFormatter(df); + } + + opt = fixedOptions.get("pattern"); + if (opt != null) { + String pattern = Objects.toString(opt); + SimpleDateFormat sf = new SimpleDateFormat(pattern, locale); + return new DateTimeFormatter(sf); + } + + int dateStyle = DateFormat.NONE; + opt = fixedOptions.get("datestyle"); + if (opt != null) { + dateStyle = stringToStyle(Objects.toString(opt, "")); + } + + int timeStyle = DateFormat.NONE; + opt = fixedOptions.get("timestyle"); + if (opt != null) { + timeStyle = stringToStyle(Objects.toString(opt, "")); + } + + if (dateStyle == DateFormat.NONE && timeStyle == DateFormat.NONE) { + // Match the MessageFormat behavior + dateStyle = DateFormat.SHORT; + timeStyle = DateFormat.SHORT; + } + df = DateFormat.getDateTimeInstance(dateStyle, timeStyle, locale); + + return new DateTimeFormatter(df); + } + + private static class DateTimeFormatter implements Formatter { + private final DateFormat icuFormatter; + + private DateTimeFormatter(DateFormat df) { + this.icuFormatter = df; + } + + /** + * {@inheritDoc} + */ + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + // TODO: use a special type to indicate function without input argument. + if (toFormat == null) { + throw new IllegalArgumentException("The date to format can't be null"); + } + String result = icuFormatter.format(toFormat); + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result)); + } + + /** + * {@inheritDoc} + */ + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedMessage.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedMessage.java new file mode 100644 index 000000000000..817aa70223c4 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedMessage.java @@ -0,0 +1,120 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.text.AttributedCharacterIterator; + +import com.ibm.icu.text.ConstrainedFieldPosition; +import com.ibm.icu.text.FormattedValue; + +/** + * Not yet implemented: The result of a message formatting operation. + * + *

This contains information about where the various fields and placeholders + * ended up in the final result.

+ *

This class allows the result to be exported in several data types, + * including a {@link String}, {@link AttributedCharacterIterator}, more (TBD).

+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public class FormattedMessage implements FormattedValue { + + /** + * Not yet implemented. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public FormattedMessage() { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public int length() { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public char charAt(int index) { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public CharSequence subSequence(int start, int end) { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public A appendTo(A appendable) { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public boolean nextPosition(ConstrainedFieldPosition cfpos) { + throw new RuntimeException("Not yet implemented."); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public AttributedCharacterIterator toCharacterIterator() { + throw new RuntimeException("Not yet implemented."); + } + +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedPlaceholder.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedPlaceholder.java new file mode 100644 index 000000000000..d1c206b4fb27 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormattedPlaceholder.java @@ -0,0 +1,79 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import com.ibm.icu.text.FormattedValue; + +/** + * An immutable, richer formatting result, encapsulating a {@link FormattedValue}, + * the original value to format, and we are considering adding some more info. + * Very preliminary. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public class FormattedPlaceholder { + private final FormattedValue formattedValue; + private final Object inputValue; + + /** + * Constructor creating the {@code FormattedPlaceholder}. + * + * @param inputValue the original value to be formatted. + * @param formattedValue the result of formatting the placeholder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public FormattedPlaceholder(Object inputValue, FormattedValue formattedValue) { + if (formattedValue == null) { + throw new IllegalAccessError("Should not try to wrap a null formatted value"); + } + this.inputValue = inputValue; + this.formattedValue = formattedValue; + } + + /** + * Retrieve the original input value that was formatted. + * + * @return the original value to be formatted. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Object getInput() { + return inputValue; + } + + /** + * Retrieve the formatted value. + * + * @return the result of formatting the placeholder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public FormattedValue getFormattedValue() { + return formattedValue; + } + + /** + * Returns a string representation of the object. + * It can be null, which is unusual, and we plan to change that. + * + * @return a string representation of the object. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + return formattedValue.toString(); + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Formatter.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Formatter.java new file mode 100644 index 000000000000..0bb98daa0c17 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Formatter.java @@ -0,0 +1,44 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Map; + +/** + * The interface that must be implemented by all formatters + * that can be used from {@link MessageFormatter}. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public interface Formatter { + /** + * A method that takes the object to format and returns + * the i18n-aware string representation. + * + * @param toFormat the object to format. + * @param variableOptions options that are not know at build time. + * @return the formatted string. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + String formatToString(Object toFormat, Map variableOptions); + + /** + * A method that takes the object to format and returns + * the i18n-aware formatted placeholder. + * + * @param toFormat the object to format. + * @param variableOptions options that are not know at build time. + * @return the formatted placeholder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + FormattedPlaceholder format(Object toFormat, Map variableOptions); +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/FormatterFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormatterFactory.java new file mode 100644 index 000000000000..deb0971d4207 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/FormatterFactory.java @@ -0,0 +1,33 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; + +/** + * The interface that must be implemented for each formatting function name + * that can be used from {@link MessageFormatter}. + * + *

We use it to create and cache various formatters with various options.

+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public interface FormatterFactory { + /** + * The method that is called to create a formatter. + * + * @param locale the locale to use for formatting. + * @param fixedOptions the options to use for formatting. The keys and values are function dependent. + * @return the formatter. + * @throws IllegalArgumentException + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + Formatter createFormatter(Locale locale, Map fixedOptions); +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/IdentityFormatterFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/IdentityFormatterFactory.java new file mode 100644 index 000000000000..b5334b1b9903 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/IdentityFormatterFactory.java @@ -0,0 +1,39 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +/** + * Creates a {@link Formatter} that simply returns the String non-i18n aware representation of an object. + */ +class IdentityFormatterFactory implements FormatterFactory { + /** + * {@inheritDoc} + */ + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + return new IdentityFormatterImpl(); + } + + private static class IdentityFormatterImpl implements Formatter { + /** + * {@inheritDoc} + */ + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(Objects.toString(toFormat))); + } + + /** + * {@inheritDoc} + */ + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/MessageFormatter.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/MessageFormatter.java new file mode 100644 index 000000000000..7c9265ad03e7 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/MessageFormatter.java @@ -0,0 +1,338 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; + +/** + * {@code MessageFormatter} is the next iteration of {@link com.ibm.icu.text.MessageFormat}. + * + *

This new version builds on what we learned from using {@code MessageFormat} for 20 years + * in various environments, either exposed "as is" or as a base for other public APIs.

+ * + *

It is more modular, easier to backport, and provides extension points to add new + * formatters and selectors without having to modify the specification.

+ * + *

We will be able to add formatters for intervals, relative times, lists, measurement units, + * people names, and more, and support custom formatters implemented by developers + * outside of ICU itself, for company or even product specific needs.

+ * + *

MessageFormat 2 will support more complex grammatical features, such as gender, inflections, + * and tagging parts of the message for style changes or speech.

+ * + *

The reasoning for this effort is shared in the + * “Why + * MessageFormat needs a successor” document.

+ * + *

The “MessageFormat 2” project, which develops the new data model, semantics, and syntax, + * is hosted on GitHub.

+ * + *

The current specification for the syntax and data model can be found + * here.

+ * + *

This tech preview implements enough of the {@code MessageFormat} functions to be useful, + * but the final set of functions and the parameters accepted by those functions is not yet finalized.

+ * + *

These are the functions interpreted right now:

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
{@code datetime}Similar to the ICU {@code "date"} and {@code "time"}.
{@code datestyle} and {@code timestyle}
+ * Similar to {@code argStyle : short | medium | long | full}.
+ * Same values are accepted, but we can use both in one placeholder, + * for example {$due :datetime datestyle=full timestyle=long}. + *
{@code pattern}
+ * Similar to {@code argStyle = argStyleText}.
+ * This is bad i18n practice, and will probably be dropped.
+ * This is included just to support migration to MessageFormat 2. + *
{@code skeleton}
+ * Same as {@code argStyle = argSkeletonText}.
+ * These are the date/time skeletons as supported by {@link com.ibm.icu.text.SimpleDateFormat}. + *
{@code number}Similar to the ICU "number".
{@code skeleton}
+ * These are the number skeletons as supported by {@link com.ibm.icu.number.NumberFormatter}.
{@code minimumFractionDigits}
+ * Only implemented to be able to pass the unit tests from the ECMA tech preview implementation, + * which prefers options bags to skeletons.
+ * TBD if the final {@number} function will support skeletons, option backs, or both.
{@code offset}
+ * Used to support plural with an offset.
{@code identity}Returns the direct string value of the argument (calling {@code toString()}).
{@code plural}Similar to the ICU {@code "plural"}.
{@code skeleton}
+ * These are the number skeletons as supported by {@link com.ibm.icu.number.NumberFormatter}.
+ * Can also be indirect, from a local variable of type {@code number} (recommended).
{@code offset}
+ * Used to support plural with an offset.
+ * Can also be indirect, from a local variable of type {@code number} (recommended).
{@code selectordinal}Similar to the ICU {@code "selectordinal"}.
+ * For now it accepts the same parameters as "plural", although there is no use case for them.
+ * TBD if this will be merged into "plural" (with some {@code kind} option) or not.
{@code select}Literal match, same as the ICU4 {@code "select"}.
+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public class MessageFormatter { + private final Locale locale; + private final String pattern; + private final Mf2FunctionRegistry functionRegistry; + private final Mf2DataModel dataModel; + private final Mf2DataModelFormatter modelFormatter; + + private MessageFormatter(Builder builder) { + this.locale = builder.locale; + this.functionRegistry = builder.functionRegistry; + if ((builder.pattern == null && builder.dataModel == null) + || (builder.pattern != null && builder.dataModel != null)) { + throw new IllegalArgumentException("You need to set either a pattern, or a dataModel, but not both."); + } + + if (builder.dataModel != null) { + this.dataModel = builder.dataModel; + this.pattern = Mf2Serializer.dataModelToString(this.dataModel); + } else { + this.pattern = builder.pattern; + Mf2Serializer tree = new Mf2Serializer(); + Mf2Parser parser = new Mf2Parser(pattern, tree); + try { + parser.parse_Message(); + dataModel = tree.build(); + } catch (Mf2Parser.ParseException pe) { + throw new IllegalArgumentException( + "Parse error:\n" + + "Message: <<" + pattern + ">>\n" + + "Error:" + parser.getErrorMessage(pe) + "\n"); + } + } + modelFormatter = new Mf2DataModelFormatter(dataModel, locale, functionRegistry); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * Get the locale to use for all the formatting and selections in + * the current {@code MessageFormatter}. + * + * @return the locale. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Locale getLocale() { + return locale; + } + + /** + * Get the pattern (the serialized message in MessageFormat 2 syntax) of + * the current {@code MessageFormatter}. + * + *

If the {@code MessageFormatter} was created from an {@link Mf2DataModel} + * the this string is generated from that model.

+ * + * @return the pattern. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getPattern() { + return pattern; + } + + /** + * Give public access to the message data model. + * + *

This data model is similar to the functionality we have today + * in {@link com.ibm.icu.text.MessagePatternUtil} maybe even a bit more higher level.

+ * + *

We can also imagine a model where one parses the string syntax, takes the data model, + * modifies it, and then uses that modified model to create a {@code MessageFormatter}.

+ * + * @return the data model. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Mf2DataModel getDataModel() { + return dataModel; + } + + /** + * Formats a map of objects by iterating over the MessageFormat's pattern, + * with the plain text “as is” and the arguments replaced by the formatted objects. + * + * @param arguments a map of objects to be formatted and substituted. + * @return the string representing the message with parameters replaced. + * + * @throws IllegalArgumentException when something goes wrong + * (for example wrong argument type, or null arguments, etc.) + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String formatToString(Map arguments) { + return modelFormatter.format(arguments); + } + + /** + * Not yet implemented: formats a map of objects by iterating over the MessageFormat's + * pattern, with the plain text “as is” and the arguments replaced by the formatted objects. + * + * @param arguments a map of objects to be formatted and substituted. + * @return the {@link FormattedMessage} class representing the message with parameters replaced. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public FormattedMessage format(Map arguments) { + throw new RuntimeException("Not yet implemented."); + } + + /** + * A {@code Builder} used to build instances of {@link MessageFormatter}. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private Locale locale = Locale.getDefault(Locale.Category.FORMAT); + private String pattern = null; + private Mf2FunctionRegistry functionRegistry = Mf2FunctionRegistry.builder().build(); + private Mf2DataModel dataModel = null; + + // Prevent direct creation + private Builder() { + } + + /** + * Sets the locale to use for all formatting and selection operations. + * + * @param locale the locale to set. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setLocale(Locale locale) { + this.locale = locale; + return this; + } + + /** + * Sets the pattern (in MessageFormat 2 syntax) used to create the message.
+ * It conflicts with the data model, so it will reset it (the last call on setter wins). + * + * @param pattern the pattern to set. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setPattern(String pattern) { + this.pattern = pattern; + this.dataModel = null; + return this; + } + + /** + * Sets an instance of {@link Mf2FunctionRegistry} that should register any + * custom functions used by the message. + * + *

There is no need to do this in order to use standard functions + * (for example date / time / number formatting, plural / ordinal / literal selection).
+ * The exact set of standard functions, with the types they format and the options + * they accept is still TBD.

+ * + * @param functionRegistry the function registry to set. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setFunctionRegistry(Mf2FunctionRegistry functionRegistry) { + this.functionRegistry = functionRegistry; + return this; + } + + /** + * Sets the data model used to create the message.
+ * It conflicts with the pattern, so it will reset it (the last call on setter wins). + * + * @param dataModel the pattern to set. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setDataModel(Mf2DataModel dataModel) { + this.dataModel = dataModel; + this.pattern = null; + return this; + } + + /** + * Builds an instance of {@link MessageFormatter}. + * + * @return the {@link MessageFormatter} created. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public MessageFormatter build() { + return new MessageFormatter(this); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModel.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModel.java new file mode 100644 index 000000000000..7fb30e5247d9 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModel.java @@ -0,0 +1,856 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.StringJoiner; + +/** + * This maps closely to the official specification. + * Since it is not final, we will not add javadoc everywhere. + * + *

See the + * description of the syntax with examples and use cases and the corresponding + * EBNF.

+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +@SuppressWarnings("javadoc") +public class Mf2DataModel { + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class SelectorKeys { + private final List keys; + + private SelectorKeys(Builder builder) { + keys = new ArrayList<>(); + keys.addAll(builder.keys); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public List getKeys() { + return Collections.unmodifiableList(keys); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + StringJoiner result = new StringJoiner(" "); + for (String key : keys) { + result.add(key); + } + return result.toString(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private final List keys = new ArrayList<>(); + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder add(String key) { + keys.add(key); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addAll(Collection otherKeys) { + this.keys.addAll(otherKeys); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public SelectorKeys build() { + return new SelectorKeys(this); + } + } + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Pattern { + private final List parts; + + private Pattern(Builder builder) { + parts = new ArrayList<>(); + parts.addAll(builder.parts); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public List getParts() { + return parts; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + StringBuilder result = new StringBuilder(); + result.append("{"); + for (Part part : parts) { + result.append(part); + } + result.append("}"); + return result.toString(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private final List parts = new ArrayList<>(); + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder add(Part part) { + parts.add(part); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addAll(Collection otherParts) { + parts.addAll(otherParts); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Pattern build() { + return new Pattern(this); + } + + } + } + + /** + * No functional role, this is only to be able to say that a message is a sequence of Part(s), + * and that plain text {@link Text} and {@link Expression} are Part(s). + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public interface Part { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Text implements Part { + private final String value; + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + private Text(Builder builder) { + this(builder.value); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Text(String value) { + this.value = value; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getValue() { + return value; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + return value; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private String value; + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setValue(String value) { + this.value = value; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Text build() { + return new Text(this); + } + } + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Expression implements Part { + private final Value operand; // Literal | Variable + private final String functionName; + private final Map options; + Formatter formatter = null; + + private Expression(Builder builder) { + this.operand = builder.operand; + this.functionName = builder.functionName; + this.options = builder.options; + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Value getOperand() { + return operand; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getFunctionName() { + return functionName; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Map getOptions() { + return options; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + StringBuilder result = new StringBuilder(); + result.append("{"); + if (operand != null) { + result.append(operand); + } + if (functionName != null) { + result.append(" :").append(functionName); + } + for (Entry option : options.entrySet()) { + result.append(" ").append(option.getKey()).append("=").append(option.getValue()); + } + result.append("}"); + return result.toString(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private Value operand = null; + private String functionName = null; + private final OrderedMap options = new OrderedMap<>(); + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setOperand(Value operand) { + this.operand = operand; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setFunctionName(String functionName) { + this.functionName = functionName; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addOption(String key, Value value) { + options.put(key, value); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addOptions(Map otherOptions) { + options.putAll(otherOptions); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Expression build() { + return new Expression(this); + } + } + } + +// public static class Placeholder extends Expression implements Part { +// public Placeholder(Builder builder) { +// super(builder); +// } +// } + + /** + * A Value can be either a Literal, or a Variable, but not both. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Value { + private final String literal; + private final String variableName; + + private Value(Builder builder) { + this.literal = builder.literal; + this.variableName = builder.variableName; +// this(builder.literal, builder.variableName); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getLiteral() { + return literal; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getVariableName() { + return variableName; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public boolean isLiteral() { + return literal != null; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public boolean isVariable() { + return variableName != null; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + return isLiteral() ? "(" + literal + ")" : "$" + variableName; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private String literal; + private String variableName; + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setLiteral(String literal) { + this.literal = literal; + this.variableName = null; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setVariableName(String variableName) { + this.variableName = variableName; + this.literal = null; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Value build() { + return new Value(this); + } + } + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Variable { + private final String name; + + private Variable(Builder builder) { + this.name = builder.name; + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getName() { + return name; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private String name; + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setName(String name) { + this.name = name; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Variable build() { + return new Variable(this); + } + } + } + + /** + * This is only to not force LinkedHashMap on the public API. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class OrderedMap extends LinkedHashMap { + private static final long serialVersionUID = -7049361727790825496L; + } + + private final OrderedMap localVariables; + private final List selectors; + private final OrderedMap variants; + private final Pattern pattern; + + private Mf2DataModel(Builder builder) { + this.localVariables = builder.localVariables; + this.selectors = builder.selectors; + this.variants = builder.variants; + this.pattern = builder.pattern; + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public OrderedMap getLocalVariables() { + return localVariables; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public List getSelectors() { + return selectors; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public OrderedMap getVariants() { + return variants; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Pattern getPattern() { + return pattern; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + StringBuilder result = new StringBuilder(); + for (Entry lv : localVariables.entrySet()) { + result.append("let $").append(lv.getKey()); + result.append(" = "); + result.append(lv.getValue()); + result.append("\n"); + } + if (!selectors.isEmpty()) { + result.append("match"); + for (Expression e : this.selectors) { + result.append(" ").append(e); + } + result.append("\n"); + for (Entry variant : variants.entrySet()) { + result.append(" when ").append(variant.getKey()); + result.append(" "); + result.append(variant.getValue()); + result.append("\n"); + } + } else { + result.append(pattern); + } + return result.toString(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private final OrderedMap localVariables = new OrderedMap<>(); // declaration* + private final List selectors = new ArrayList<>(); + private final OrderedMap variants = new OrderedMap<>(); + private Pattern pattern = Pattern.builder().build(); + + // Prevent direct creation + private Builder() { + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addLocalVariable(String variableName, Expression expression) { + this.localVariables.put(variableName, expression); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addLocalVariables(OrderedMap otherLocalVariables) { + this.localVariables.putAll(otherLocalVariables); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addSelector(Expression otherSelector) { + this.selectors.add(otherSelector); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addSelectors(List otherSelectors) { + this.selectors.addAll(otherSelectors); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addVariant(SelectorKeys keys, Pattern newPattern) { + this.variants.put(keys, newPattern); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addVariants(OrderedMap otherVariants) { + this.variants.putAll(otherVariants); + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setPattern(Pattern pattern) { + this.pattern = pattern; + return this; + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Mf2DataModel build() { + return new Mf2DataModel(this); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModelFormatter.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModelFormatter.java new file mode 100644 index 000000000000..bf73791d2004 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2DataModelFormatter.java @@ -0,0 +1,280 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; + +import com.ibm.icu.message2.Mf2DataModel.Expression; +import com.ibm.icu.message2.Mf2DataModel.Part; +import com.ibm.icu.message2.Mf2DataModel.Pattern; +import com.ibm.icu.message2.Mf2DataModel.SelectorKeys; +import com.ibm.icu.message2.Mf2DataModel.Text; +import com.ibm.icu.message2.Mf2DataModel.Value; +import com.ibm.icu.util.Calendar; +import com.ibm.icu.util.CurrencyAmount; + +/** + * Takes an {@link Mf2DataModel} and formats it to a {@link String} + * (and later on we will also implement formatting to a {@code FormattedMessage}). + */ +// TODO: move this in the MessageFormatter +class Mf2DataModelFormatter { + private final Locale locale; + private final Mf2DataModel dm; + + final Mf2FunctionRegistry standardFunctions; + final Mf2FunctionRegistry customFunctions; + private static final Mf2FunctionRegistry EMPTY_REGISTY = Mf2FunctionRegistry.builder().build(); + + Mf2DataModelFormatter(Mf2DataModel dm, Locale locale, Mf2FunctionRegistry customFunctionRegistry) { + this.locale = locale; + this.dm = dm; + this.customFunctions = customFunctionRegistry == null ? EMPTY_REGISTY : customFunctionRegistry; + + standardFunctions = Mf2FunctionRegistry.builder() + // Date/time formatting + .setFormatter("datetime", new DateTimeFormatterFactory()) + .setDefaultFormatterNameForType(Date.class, "datetime") + .setDefaultFormatterNameForType(Calendar.class, "datetime") + + // Number formatting + .setFormatter("number", new NumberFormatterFactory()) + .setDefaultFormatterNameForType(Integer.class, "number") + .setDefaultFormatterNameForType(Double.class, "number") + .setDefaultFormatterNameForType(Number.class, "number") + .setDefaultFormatterNameForType(CurrencyAmount.class, "number") + + // Format that returns "to string" + .setFormatter("identity", new IdentityFormatterFactory()) + .setDefaultFormatterNameForType(String.class, "identity") + .setDefaultFormatterNameForType(CharSequence.class, "identity") + + // Register the standard selectors + .setSelector("plural", new PluralSelectorFactory("cardinal")) + .setSelector("selectordinal", new PluralSelectorFactory("ordinal")) + .setSelector("select", new TextSelectorFactory()) + .setSelector("gender", new TextSelectorFactory()) + + .build(); + } + + private static Map mf2OptToFixedOptions(Map options) { + Map result = new HashMap<>(); + for (Entry option : options.entrySet()) { + Value value = option.getValue(); + if (value.isLiteral()) { + result.put(option.getKey(), value.getLiteral()); + } + } + return result; + } + + private Map mf2OptToVariableOptions(Map options, Map arguments) { + Map result = new HashMap<>(); + for (Entry option : options.entrySet()) { + Value value = option.getValue(); + if (value.isVariable()) { + result.put(option.getKey(), variableToObjectEx(value, arguments)); + } + } + return result; + } + + FormatterFactory getFormattingFunctionFactoryByName(Object toFormat, String functionName) { + // Get a function name from the type of the object to format + if (functionName == null || functionName.isEmpty()) { + if (toFormat == null) { + // The object to format is null, and no function provided. + return null; + } + Class clazz = toFormat.getClass(); + functionName = standardFunctions.getDefaultFormatterNameForType(clazz); + if (functionName == null) { + functionName = customFunctions.getDefaultFormatterNameForType(clazz); + } + if (functionName == null) { + throw new IllegalArgumentException("Object to format without a function, and unknown type: " + + toFormat.getClass().getName()); + } + } + + FormatterFactory func = standardFunctions.getFormatter(functionName); + if (func == null) { + func = customFunctions.getFormatter(functionName); + if (func == null) { + throw new IllegalArgumentException("Can't find an implementation for function: '" + + functionName + "'"); + } + } + return func; + } + + String format(Map arguments) { + List selectors = dm.getSelectors(); + Pattern patternToRender = selectors.isEmpty() + ? dm.getPattern() + : findBestMatchingPattern(selectors, arguments); + + StringBuilder result = new StringBuilder(); + for (Part part : patternToRender.getParts()) { + if (part instanceof Text) { + result.append(part); + } else if (part instanceof Expression) { // Placeholder is an Expression + FormattedPlaceholder fp = formatPlaceholder((Expression) part, arguments, false); + result.append(fp.toString()); + } else { + throw new IllegalArgumentException("Unknown part type: " + part); + } + } + return result.toString(); + } + + private Pattern findBestMatchingPattern(List selectors, Map arguments) { + Pattern patternToRender = null; + + // Collect all the selector functions in an array, to reuse + List selectorFunctions = new ArrayList<>(selectors.size()); + for (Expression selector : selectors) { + String functionName = selector.getFunctionName(); + SelectorFactory funcFactory = standardFunctions.getSelector(functionName); + if (funcFactory == null) { + funcFactory = customFunctions.getSelector(functionName); + } + if (funcFactory != null) { + Map opt = mf2OptToFixedOptions(selector.getOptions()); + selectorFunctions.add(funcFactory.createSelector(locale, opt)); + } else { + throw new IllegalArgumentException("Unknown selector type: " + functionName); + } + } + // This should not be possible, we added one function for each selector, or we have thrown an exception. + // But just in case someone removes the throw above? + if (selectorFunctions.size() != selectors.size()) { + throw new IllegalArgumentException("Something went wrong, not enough selector functions, " + + selectorFunctions.size() + " vs. " + selectors.size()); + } + + // Iterate "vertically", through all variants + for (Entry variant : dm.getVariants().entrySet()) { + int maxCount = selectors.size(); + List keysToCheck = variant.getKey().getKeys(); + if (selectors.size() != keysToCheck.size()) { + throw new IllegalArgumentException("Mismatch between the number of selectors and the number of keys: " + + selectors.size() + " vs. " + keysToCheck.size()); + } + boolean matches = true; + // Iterate "horizontally", through all matching functions and keys + for (int i = 0; i < maxCount; i++) { + Expression selector = selectors.get(i); + String valToCheck = keysToCheck.get(i); + Selector func = selectorFunctions.get(i); + Map options = mf2OptToVariableOptions(selector.getOptions(), arguments); + if (!func.matches(variableToObjectEx(selector.getOperand(), arguments), valToCheck, options)) { + matches = false; + break; + } + } + if (matches) { + patternToRender = variant.getValue(); + break; + } + } + + // TODO: check that there was an entry with all the keys set to `*` + // And should do that only once, when building the data model. + if (patternToRender == null) { + // If there was a case with all entries in the keys `*` this should not happen + throw new IllegalArgumentException("The selection went wrong, cannot select any option."); + } + + return patternToRender; + } + + /* + * Pass a level to prevent local variables calling each-other recursively: + * + *
+     * let $l1 = {$l4 :number}
+     * let $l2 = {$l1 :number}
+     * let $l3 = {$l2 :number}
+     * let $l4 = {$l3 :number}
+     * 
+ * + * We can keep track of the calls (complicated and expensive). + * Or we can forbid the use of variables before they are declared, but that is not in the spec (yet?). + */ + private Object variableToObjectEx(Value value, Map arguments) { + if (value == null) { // function only + return null; + } + // We have an operand. Can be literal, local var, or argument. + if (value.isLiteral()) { + return value.getLiteral(); + } else if (value.isVariable()) { + String varName = value.getVariableName(); + Expression localPh = dm.getLocalVariables().get(varName); + if (localPh != null) { + return formatPlaceholder(localPh, arguments, false); + } + return arguments.get(varName); + } else { + throw new IllegalArgumentException("Invalid operand type " + value); + } + } + + private FormattedPlaceholder formatPlaceholder(Expression ph, Map arguments, boolean localExpression) { + Object toFormat; + Value operand = ph.getOperand(); + if (operand == null) { // function only, "...{:currentOs option=value}..." + toFormat = null; + } else { + // We have an operand. Can be literal, local var, or argument. + if (operand.isLiteral()) { // "...{(1234.56) :number}..." + // If it is a literal, return the string itself + toFormat = operand.getLiteral(); + } else if (operand.isVariable()) { + String varName = operand.getVariableName(); + if (!localExpression) { + Expression localPh = dm.getLocalVariables().get(varName); + if (localPh != null) { + // If it is a local variable, we need to format that (recursive) + // TODO: See if there is any danger to eval the local variables only once + // (on demand in case the local var is not used, for example in a select) + return formatPlaceholder(localPh, arguments, true); + } + } + // Return the object in the argument bag. + toFormat = arguments.get(varName); + // toFormat might still be null here. + } else { + throw new IllegalArgumentException("Invalid operand type " + ph.getOperand()); + } + } + + if (ph.formatter == null) { + FormatterFactory funcFactory = getFormattingFunctionFactoryByName(toFormat, ph.getFunctionName()); + if (funcFactory != null) { + Map fixedOptions = mf2OptToFixedOptions(ph.getOptions()); + Formatter ff = funcFactory.createFormatter(locale, fixedOptions); + ph.formatter = ff; + } + } + if (ph.formatter != null) { + Map variableOptions = mf2OptToVariableOptions(ph.getOptions(), arguments); + try { + return ph.formatter.format(toFormat, variableOptions); + } catch (IllegalArgumentException e) { + // Fall-through to the name of the placeholder without replacement. + } + } + + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue("{" + ph.getOperand() + "}")); + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2FunctionRegistry.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2FunctionRegistry.java new file mode 100644 index 000000000000..3560e42891dd --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2FunctionRegistry.java @@ -0,0 +1,347 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +/** + * This class is used to register mappings between various function + * names and the factories that can create those functions. + * + *

For example to add formatting for a {@code Person} object one would need to:

+ *
    + *
  • write a function (class, lambda, etc.) that does the formatting proper + * (implementing {@link Formatter})
  • + *
  • write a factory that creates such a function + * (implementing {@link FormatterFactory})
  • + *
  • add a mapping from the function name as used in the syntax + * (for example {@code "person"}) to the factory
  • + *
  • optionally add a mapping from the class to format ({@code ...Person.class}) to + * the formatter name ({@code "person"}), so that one can use a placeholder in the message + * without specifying a function (for example {@code "... {$me} ..."} instead of + * {@code "... {$me :person} ..."}, if the class of {@code $me} is an {@code instanceof Person}). + *
  • + *
+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public class Mf2FunctionRegistry { + private final Map formattersMap; + private final Map selectorsMap; + private final Map, String> classToFormatter; + + private Mf2FunctionRegistry(Builder builder) { + this.formattersMap = new HashMap<>(builder.formattersMap); + this.selectorsMap = new HashMap<>(builder.selectorsMap); + this.classToFormatter = new HashMap<>(builder.classToFormatter); + } + + /** + * Creates a builder. + * + * @return the Builder. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static Builder builder() { + return new Builder(); + } + + /** + * Returns the formatter factory used to create the formatter for function + * named {@code name}. + * + *

Note: function name here means the name used to refer to the function in the + * MessageFormat 2 syntax, for example {@code "... {$exp :datetime} ..."}
+ * The function name here is {@code "datetime"}, and does not have to correspond to the + * name of the methods / classes used to implement the functionality.

+ * + *

For example one might write a {@code PersonFormatterFactory} returning a {@code PersonFormatter}, + * and map that to the MessageFormat function named {@code "person"}.
+ * The only name visible to the users of MessageFormat syntax will be {@code "person"}.

+ * + * @param formatterName the function name. + * @return the factory creating formatters for {@code name}. Returns {@code null} if none is registered. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public FormatterFactory getFormatter(String formatterName) { + return formattersMap.get(formatterName); + } + + /** + * Get all know names that have a mappings from name to {@link FormatterFactory}. + * + * @return a set of all the known formatter names. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Set getFormatterNames() { + return formattersMap.keySet(); + } + + /** + * Returns the name of the formatter used to format an object of type {@code clazz}. + * + * @param clazz the class of the object to format. + * @return the name of the formatter class, if registered. Returns {@code null} otherwise. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public String getDefaultFormatterNameForType(Class clazz) { + // Search for the class "as is", to save time. + // If we don't find it then we iterate the registered classes and check + // if the class is an instanceof the ones registered. + // For example a BuddhistCalendar when we only registered Calendar + String result = classToFormatter.get(clazz); + if (result != null) { + return result; + } + // We didn't find the class registered explicitly "as is" + for (Map.Entry, String> e : classToFormatter.entrySet()) { + if (e.getKey().isAssignableFrom(clazz)) { + return e.getValue(); + } + } + return null; + } + + /** + * Get all know classes that have a mappings from class to function name. + * + * @return a set of all the known classes that have mapping to function names. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Set> getDefaultFormatterTypes() { + return classToFormatter.keySet(); + } + + /** + * Returns the selector factory used to create the selector for function + * named {@code name}. + * + *

Note: the same comments about naming as the ones on {@code getFormatter} apply.

+ * + * @param selectorName the selector name. + * @return the factory creating selectors for {@code name}. Returns {@code null} if none is registered. + * @see #getFormatter(String) + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public SelectorFactory getSelector(String selectorName) { + return selectorsMap.get(selectorName); + } + + /** + * Get all know names that have a mappings from name to {@link SelectorFactory}. + * + * @return a set of all the known selector names. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Set getSelectorNames() { + return selectorsMap.keySet(); + } + + /** + * A {@code Builder} used to build instances of {@link Mf2FunctionRegistry}. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public static class Builder { + private final Map formattersMap = new HashMap<>(); + private final Map selectorsMap = new HashMap<>(); + private final Map, String> classToFormatter = new HashMap<>(); + + // Prevent direct creation + private Builder() { + } + + /** + * Adds all the mapping from another registry to this one. + * + * @param functionRegistry the registry to copy from. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder addAll(Mf2FunctionRegistry functionRegistry) { + formattersMap.putAll(functionRegistry.formattersMap); + selectorsMap.putAll(functionRegistry.selectorsMap); + classToFormatter.putAll(functionRegistry.classToFormatter); + return this; + } + + /** + * Adds a mapping from a formatter name to a {@link FormatterFactory} + * + * @param formatterName the function name (as used in the MessageFormat 2 syntax). + * @param formatterFactory the factory that handles the name. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setFormatter(String formatterName, FormatterFactory formatterFactory) { + formattersMap.put(formatterName, formatterFactory); + return this; + } + + /** + * Remove the formatter associated with the name. + * + * @param formatterName the name of the formatter to remove. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder removeFormatter(String formatterName) { + formattersMap.remove(formatterName); + return this; + } + + /** + * Remove all the formatter mappings. + * + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder clearFormatters() { + formattersMap.clear(); + return this; + } + + /** + * Adds a mapping from a type to format to a {@link FormatterFactory} formatter name. + * + * @param clazz the class of the type to format. + * @param formatterName the formatter name (as used in the MessageFormat 2 syntax). + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setDefaultFormatterNameForType(Class clazz, String formatterName) { + classToFormatter.put(clazz, formatterName); + return this; + } + + /** + * Remove the function name associated with the class. + * + * @param clazz the class to remove the mapping for. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder removeDefaultFormatterNameForType(Class clazz) { + classToFormatter.remove(clazz); + return this; + } + + /** + * Remove all the class to formatter-names mappings. + * + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder clearDefaultFormatterNames() { + classToFormatter.clear(); + return this; + } + + /** + * Adds a mapping from a selector name to a {@link SelectorFactory} + * + * @param selectorName the function name (as used in the MessageFormat 2 syntax). + * @param selectorFactory the factory that handles the name. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder setSelector(String selectorName, SelectorFactory selectorFactory) { + selectorsMap.put(selectorName, selectorFactory); + return this; + } + + /** + * Remove the selector associated with the name. + * + * @param selectorName the name of the selector to remove. + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder removeSelector(String selectorName) { + selectorsMap.remove(selectorName); + return this; + } + + /** + * Remove all the selector mappings. + * + * @return the builder, for fluent use. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Builder clearSelectors() { + selectorsMap.clear(); + return this; + } + + /** + * Builds an instance of {@link Mf2FunctionRegistry}. + * + * @return the function registry created. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public Mf2FunctionRegistry build() { + return new Mf2FunctionRegistry(this); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Parser.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Parser.java new file mode 100644 index 000000000000..48b1062d0cd0 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Parser.java @@ -0,0 +1,754 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Arrays; + +/** + * Class generated from EBNF. + */ +@SuppressWarnings("all") // Disable all warnings in the generated file +class Mf2Parser +{ + static class ParseException extends RuntimeException + { + private static final long serialVersionUID = 1L; + private int begin, end, offending, expected, state; + + public ParseException(int b, int e, int s, int o, int x) + { + begin = b; + end = e; + state = s; + offending = o; + expected = x; + } + + @Override + public String getMessage() + { + return offending < 0 + ? "lexical analysis failed" + : "syntax error"; + } + + public void serialize(EventHandler eventHandler) + { + } + + public int getBegin() {return begin;} + public int getEnd() {return end;} + public int getState() {return state;} + public int getOffending() {return offending;} + public int getExpected() {return expected;} + public boolean isAmbiguousInput() {return false;} + } + + public interface EventHandler + { + public void reset(CharSequence string); + public void startNonterminal(String name, int begin); + public void endNonterminal(String name, int end); + public void terminal(String name, int begin, int end); + public void whitespace(int begin, int end); + } + + public static class TopDownTreeBuilder implements EventHandler + { + private CharSequence input = null; + public Nonterminal[] stack = new Nonterminal[64]; + private int top = -1; + + @Override + public void reset(CharSequence input) + { + this.input = input; + top = -1; + } + + @Override + public void startNonterminal(String name, int begin) + { + Nonterminal nonterminal = new Nonterminal(name, begin, begin, new Symbol[0]); + if (top >= 0) addChild(nonterminal); + if (++top >= stack.length) stack = Arrays.copyOf(stack, stack.length << 1); + stack[top] = nonterminal; + } + + @Override + public void endNonterminal(String name, int end) + { + stack[top].end = end; + if (top > 0) --top; + } + + @Override + public void terminal(String name, int begin, int end) + { + addChild(new Terminal(name, begin, end)); + } + + @Override + public void whitespace(int begin, int end) + { + } + + private void addChild(Symbol s) + { + Nonterminal current = stack[top]; + current.children = Arrays.copyOf(current.children, current.children.length + 1); + current.children[current.children.length - 1] = s; + } + + public void serialize(EventHandler e) + { + e.reset(input); + stack[0].send(e); + } + } + + public static abstract class Symbol + { + public String name; + public int begin; + public int end; + + protected Symbol(String name, int begin, int end) + { + this.name = name; + this.begin = begin; + this.end = end; + } + + public abstract void send(EventHandler e); + } + + public static class Terminal extends Symbol + { + public Terminal(String name, int begin, int end) + { + super(name, begin, end); + } + + @Override + public void send(EventHandler e) + { + e.terminal(name, begin, end); + } + } + + public static class Nonterminal extends Symbol + { + public Symbol[] children; + + public Nonterminal(String name, int begin, int end, Symbol[] children) + { + super(name, begin, end); + this.children = children; + } + + @Override + public void send(EventHandler e) + { + e.startNonterminal(name, begin); + int pos = begin; + for (Symbol c : children) + { + if (pos < c.begin) e.whitespace(pos, c.begin); + c.send(e); + pos = c.end; + } + if (pos < end) e.whitespace(pos, end); + e.endNonterminal(name, end); + } + } + + public Mf2Parser(CharSequence string, EventHandler t) + { + initialize(string, t); + } + + public void initialize(CharSequence source, EventHandler parsingEventHandler) + { + eventHandler = parsingEventHandler; + input = source; + size = source.length(); + reset(0, 0, 0); + } + + public CharSequence getInput() + { + return input; + } + + public int getTokenOffset() + { + return b0; + } + + public int getTokenEnd() + { + return e0; + } + + public final void reset(int l, int b, int e) + { + b0 = b; e0 = b; + l1 = l; b1 = b; e1 = e; + end = e; + eventHandler.reset(input); + } + + public void reset() + { + reset(0, 0, 0); + } + + public static String getOffendingToken(ParseException e) + { + return e.getOffending() < 0 ? null : TOKEN[e.getOffending()]; + } + + public static String[] getExpectedTokenSet(ParseException e) + { + String[] expected; + if (e.getExpected() >= 0) + { + expected = new String[]{TOKEN[e.getExpected()]}; + } + else + { + expected = getTokenSet(- e.getState()); + } + return expected; + } + + public String getErrorMessage(ParseException e) + { + String message = e.getMessage(); + String[] tokenSet = getExpectedTokenSet(e); + String found = getOffendingToken(e); + int size = e.getEnd() - e.getBegin(); + message += (found == null ? "" : ", found " + found) + + "\nwhile expecting " + + (tokenSet.length == 1 ? tokenSet[0] : java.util.Arrays.toString(tokenSet)) + + "\n" + + (size == 0 || found != null ? "" : "after successfully scanning " + size + " characters beginning "); + String prefix = input.subSequence(0, e.getBegin()).toString(); + int line = prefix.replaceAll("[^\n]", "").length() + 1; + int column = prefix.length() - prefix.lastIndexOf('\n'); + return message + + "at line " + line + ", column " + column + ":\n..." + + input.subSequence(e.getBegin(), Math.min(input.length(), e.getBegin() + 64)) + + "..."; + } + + public void parse_Message() + { + eventHandler.startNonterminal("Message", e0); + for (;;) + { + lookahead1W(12); // WhiteSpace | 'let' | 'match' | '{' + if (l1 != 13) // 'let' + { + break; + } + whitespace(); + parse_Declaration(); + } + switch (l1) + { + case 16: // '{' + whitespace(); + parse_Pattern(); + break; + default: + whitespace(); + parse_Selector(); + for (;;) + { + whitespace(); + parse_Variant(); + lookahead1W(4); // END | WhiteSpace | 'when' + if (l1 != 15) // 'when' + { + break; + } + } + } + eventHandler.endNonterminal("Message", e0); + } + + private void parse_Declaration() + { + eventHandler.startNonterminal("Declaration", e0); + consume(13); // 'let' + lookahead1W(0); // WhiteSpace | Variable + consume(4); // Variable + lookahead1W(1); // WhiteSpace | '=' + consume(12); // '=' + lookahead1W(2); // WhiteSpace | '{' + consume(16); // '{' + lookahead1W(9); // WhiteSpace | Variable | Function | Literal + whitespace(); + parse_Expression(); + consume(17); // '}' + eventHandler.endNonterminal("Declaration", e0); + } + + private void parse_Selector() + { + eventHandler.startNonterminal("Selector", e0); + consume(14); // 'match' + for (;;) + { + lookahead1W(2); // WhiteSpace | '{' + consume(16); // '{' + lookahead1W(9); // WhiteSpace | Variable | Function | Literal + whitespace(); + parse_Expression(); + consume(17); // '}' + lookahead1W(7); // WhiteSpace | 'when' | '{' + if (l1 != 16) // '{' + { + break; + } + } + eventHandler.endNonterminal("Selector", e0); + } + + private void parse_Variant() + { + eventHandler.startNonterminal("Variant", e0); + consume(15); // 'when' + for (;;) + { + lookahead1W(11); // WhiteSpace | Nmtoken | Literal | '*' + whitespace(); + parse_VariantKey(); + lookahead1W(13); // WhiteSpace | Nmtoken | Literal | '*' | '{' + if (l1 == 16) // '{' + { + break; + } + } + whitespace(); + parse_Pattern(); + eventHandler.endNonterminal("Variant", e0); + } + + private void parse_VariantKey() + { + eventHandler.startNonterminal("VariantKey", e0); + switch (l1) + { + case 10: // Literal + consume(10); // Literal + break; + case 9: // Nmtoken + consume(9); // Nmtoken + break; + default: + consume(11); // '*' + } + eventHandler.endNonterminal("VariantKey", e0); + } + + private void parse_Pattern() + { + eventHandler.startNonterminal("Pattern", e0); + consume(16); // '{' + for (;;) + { + lookahead1(8); // Text | '{' | '}' + if (l1 == 17) // '}' + { + break; + } + switch (l1) + { + case 3: // Text + consume(3); // Text + break; + default: + parse_Placeholder(); + } + } + consume(17); // '}' + eventHandler.endNonterminal("Pattern", e0); + } + + private void parse_Placeholder() + { + eventHandler.startNonterminal("Placeholder", e0); + consume(16); // '{' + lookahead1W(14); // WhiteSpace | Variable | Function | MarkupStart | MarkupEnd | Literal | '}' + if (l1 != 17) // '}' + { + switch (l1) + { + case 6: // MarkupStart + whitespace(); + parse_Markup(); + break; + case 7: // MarkupEnd + consume(7); // MarkupEnd + break; + default: + whitespace(); + parse_Expression(); + } + } + lookahead1W(3); // WhiteSpace | '}' + consume(17); // '}' + eventHandler.endNonterminal("Placeholder", e0); + } + + private void parse_Expression() + { + eventHandler.startNonterminal("Expression", e0); + switch (l1) + { + case 5: // Function + parse_Annotation(); + break; + default: + parse_Operand(); + lookahead1W(5); // WhiteSpace | Function | '}' + if (l1 == 5) // Function + { + whitespace(); + parse_Annotation(); + } + } + eventHandler.endNonterminal("Expression", e0); + } + + private void parse_Operand() + { + eventHandler.startNonterminal("Operand", e0); + switch (l1) + { + case 10: // Literal + consume(10); // Literal + break; + default: + consume(4); // Variable + } + eventHandler.endNonterminal("Operand", e0); + } + + private void parse_Annotation() + { + eventHandler.startNonterminal("Annotation", e0); + consume(5); // Function + for (;;) + { + lookahead1W(6); // WhiteSpace | Name | '}' + if (l1 != 8) // Name + { + break; + } + whitespace(); + parse_Option(); + } + eventHandler.endNonterminal("Annotation", e0); + } + + private void parse_Option() + { + eventHandler.startNonterminal("Option", e0); + consume(8); // Name + lookahead1W(1); // WhiteSpace | '=' + consume(12); // '=' + lookahead1W(10); // WhiteSpace | Variable | Nmtoken | Literal + switch (l1) + { + case 10: // Literal + consume(10); // Literal + break; + case 9: // Nmtoken + consume(9); // Nmtoken + break; + default: + consume(4); // Variable + } + eventHandler.endNonterminal("Option", e0); + } + + private void parse_Markup() + { + eventHandler.startNonterminal("Markup", e0); + consume(6); // MarkupStart + for (;;) + { + lookahead1W(6); // WhiteSpace | Name | '}' + if (l1 != 8) // Name + { + break; + } + whitespace(); + parse_Option(); + } + eventHandler.endNonterminal("Markup", e0); + } + + private void consume(int t) + { + if (l1 == t) + { + whitespace(); + eventHandler.terminal(TOKEN[l1], b1, e1); + b0 = b1; e0 = e1; l1 = 0; + } + else + { + error(b1, e1, 0, l1, t); + } + } + + private void whitespace() + { + if (e0 != b1) + { + eventHandler.whitespace(e0, b1); + e0 = b1; + } + } + + private int matchW(int tokenSetId) + { + int code; + for (;;) + { + code = match(tokenSetId); + if (code != 2) // WhiteSpace + { + break; + } + } + return code; + } + + private void lookahead1W(int tokenSetId) + { + if (l1 == 0) + { + l1 = matchW(tokenSetId); + b1 = begin; + e1 = end; + } + } + + private void lookahead1(int tokenSetId) + { + if (l1 == 0) + { + l1 = match(tokenSetId); + b1 = begin; + e1 = end; + } + } + + private int error(int b, int e, int s, int l, int t) + { + throw new ParseException(b, e, s, l, t); + } + + private int b0, e0; + private int l1, b1, e1; + private EventHandler eventHandler = null; + private CharSequence input = null; + private int size = 0; + private int begin = 0; + private int end = 0; + + private int match(int tokenSetId) + { + begin = end; + int current = end; + int result = INITIAL[tokenSetId]; + int state = 0; + + for (int code = result & 63; code != 0; ) + { + int charclass; + int c0 = current < size ? input.charAt(current) : 0; + ++current; + if (c0 < 0x80) + { + charclass = MAP0[c0]; + } + else if (c0 < 0xd800) + { + int c1 = c0 >> 4; + charclass = MAP1[(c0 & 15) + MAP1[(c1 & 31) + MAP1[c1 >> 5]]]; + } + else + { + if (c0 < 0xdc00) + { + int c1 = current < size ? input.charAt(current) : 0; + if (c1 >= 0xdc00 && c1 < 0xe000) + { + ++current; + c0 = ((c0 & 0x3ff) << 10) + (c1 & 0x3ff) + 0x10000; + } + } + + int lo = 0, hi = 6; + for (int m = 3; ; m = (hi + lo) >> 1) + { + if (MAP2[m] > c0) {hi = m - 1;} + else if (MAP2[7 + m] < c0) {lo = m + 1;} + else {charclass = MAP2[14 + m]; break;} + if (lo > hi) {charclass = 0; break;} + } + } + + state = code; + int i0 = (charclass << 6) + code - 1; + code = TRANSITION[(i0 & 7) + TRANSITION[i0 >> 3]]; + + if (code > 63) + { + result = code; + code &= 63; + end = current; + } + } + + result >>= 6; + if (result == 0) + { + end = current - 1; + int c1 = end < size ? input.charAt(end) : 0; + if (c1 >= 0xdc00 && c1 < 0xe000) + { + --end; + } + return error(begin, end, state, -1, -1); + } + + if (end > size) end = size; + return (result & 31) - 1; + } + + private static String[] getTokenSet(int tokenSetId) + { + java.util.ArrayList expected = new java.util.ArrayList<>(); + int s = tokenSetId < 0 ? - tokenSetId : INITIAL[tokenSetId] & 63; + for (int i = 0; i < 18; i += 32) + { + int j = i; + int i0 = (i >> 5) * 38 + s - 1; + int f = EXPECTED[i0]; + for ( ; f != 0; f >>>= 1, ++j) + { + if ((f & 1) != 0) + { + expected.add(TOKEN[j]); + } + } + } + return expected.toArray(new String[]{}); + } + + private static final int[] MAP0 = + { + /* 0 */ 24, 24, 24, 24, 24, 24, 24, 24, 24, 1, 1, 24, 24, 1, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, + /* 27 */ 24, 24, 24, 24, 24, 1, 24, 24, 24, 2, 24, 24, 24, 3, 4, 5, 6, 24, 7, 8, 24, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + /* 58 */ 9, 24, 24, 10, 24, 24, 24, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + /* 85 */ 11, 11, 11, 11, 11, 11, 24, 12, 24, 24, 11, 24, 13, 11, 14, 11, 15, 11, 11, 16, 11, 11, 11, 17, 18, 19, + /* 111 */ 11, 11, 11, 11, 11, 20, 11, 11, 21, 11, 11, 11, 22, 24, 23, 24, 24 + }; + + private static final int[] MAP1 = + { + /* 0 */ 108, 124, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 156, 181, 181, 181, 181, + /* 21 */ 181, 214, 215, 213, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, + /* 42 */ 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, + /* 63 */ 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, + /* 84 */ 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, 214, + /* 105 */ 214, 214, 214, 383, 330, 396, 353, 291, 262, 247, 308, 330, 330, 330, 322, 292, 284, 292, 284, 292, 292, + /* 126 */ 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 347, 347, 347, 347, 347, 347, 347, + /* 147 */ 277, 292, 292, 292, 292, 292, 292, 292, 292, 369, 330, 330, 331, 329, 330, 330, 292, 292, 292, 292, 292, + /* 168 */ 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 330, 330, 330, 330, 330, 330, 330, 330, + /* 189 */ 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, 330, + /* 210 */ 330, 330, 330, 291, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, + /* 231 */ 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 292, 330, 24, 13, 11, 14, 11, 15, + /* 253 */ 11, 11, 16, 11, 11, 11, 17, 18, 19, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 24, 12, 24, 24, 11, 11, + /* 279 */ 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 24, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, + /* 305 */ 11, 11, 11, 11, 11, 11, 11, 20, 11, 11, 21, 11, 11, 11, 22, 24, 23, 24, 24, 24, 24, 24, 24, 24, 8, 24, 24, + /* 332 */ 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, + /* 363 */ 9, 24, 24, 10, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 11, 11, 24, 24, 24, 24, 24, 24, 24, + /* 390 */ 24, 24, 1, 1, 24, 24, 1, 24, 24, 24, 2, 24, 24, 24, 3, 4, 5, 6, 24, 7, 8, 24 + }; + + private static final int[] MAP2 = + { + /* 0 */ 55296, 63744, 64976, 65008, 65534, 65536, 983040, 63743, 64975, 65007, 65533, 65535, 983039, 1114111, 24, + /* 15 */ 11, 24, 11, 24, 11, 24 + }; + + private static final int[] INITIAL = + { + /* 0 */ 1, 2, 3, 4, 133, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 + }; + + private static final int[] TRANSITION = + { + /* 0 */ 237, 237, 237, 237, 237, 237, 237, 237, 200, 208, 455, 237, 237, 237, 237, 237, 236, 230, 455, 237, 237, + /* 21 */ 237, 237, 237, 237, 245, 376, 382, 237, 237, 237, 237, 237, 380, 314, 382, 237, 237, 237, 237, 237, 263, + /* 42 */ 455, 237, 237, 237, 237, 237, 237, 295, 455, 237, 237, 237, 237, 237, 237, 322, 287, 281, 252, 237, 237, + /* 63 */ 237, 237, 344, 287, 281, 252, 237, 237, 237, 255, 358, 455, 237, 237, 237, 237, 237, 417, 380, 455, 237, + /* 84 */ 237, 237, 237, 237, 419, 390, 215, 329, 252, 237, 237, 237, 237, 398, 275, 382, 237, 237, 237, 237, 419, + /* 105 */ 390, 215, 410, 252, 237, 237, 237, 419, 390, 215, 329, 309, 237, 237, 237, 419, 390, 222, 365, 252, 237, + /* 126 */ 237, 237, 419, 390, 427, 329, 302, 237, 237, 237, 419, 435, 215, 329, 252, 237, 237, 237, 419, 443, 215, + /* 147 */ 329, 252, 237, 237, 237, 419, 390, 215, 329, 372, 237, 237, 237, 419, 390, 215, 336, 451, 237, 237, 237, + /* 168 */ 402, 390, 215, 329, 252, 237, 237, 237, 350, 463, 269, 237, 237, 237, 237, 237, 474, 471, 269, 237, 237, + /* 189 */ 237, 237, 237, 237, 380, 455, 237, 237, 237, 237, 237, 192, 192, 192, 192, 192, 192, 192, 192, 277, 192, + /* 210 */ 192, 192, 192, 192, 192, 0, 414, 595, 0, 277, 22, 663, 0, 414, 595, 0, 277, 22, 663, 32, 277, 16, 16, 0, + /* 234 */ 0, 0, 16, 0, 0, 0, 0, 0, 0, 0, 0, 277, 22, 22, 22, 0, 22, 22, 0, 482, 547, 0, 0, 0, 0, 0, 18, 0, 0, 277, + /* 264 */ 0, 0, 768, 0, 768, 0, 0, 0, 277, 0, 22, 0, 0, 0, 277, 20, 31, 0, 0, 0, 348, 0, 414, 0, 0, 595, 0, 277, 22, + /* 293 */ 663, 0, 277, 0, 0, 0, 0, 0, 26, 0, 482, 547, 0, 0, 960, 0, 0, 482, 547, 0, 38, 0, 0, 0, 0, 277, 704, 0, 0, + /* 322 */ 277, 0, 663, 663, 0, 663, 27, 0, 482, 547, 348, 0, 414, 0, 0, 482, 547, 348, 0, 414, 0, 896, 277, 0, 663, + /* 347 */ 663, 0, 663, 0, 0, 1088, 0, 0, 0, 0, 1088, 277, 18, 0, 0, 0, 0, 18, 0, 482, 547, 348, 36, 414, 0, 0, 482, + /* 374 */ 547, 1024, 0, 0, 0, 0, 277, 0, 0, 0, 0, 0, 0, 0, 22, 0, 277, 0, 663, 663, 0, 663, 0, 348, 20, 0, 0, 0, 0, + /* 403 */ 0, 0, 0, 17, 0, 595, 17, 33, 482, 547, 348, 0, 414, 0, 0, 832, 0, 0, 0, 0, 0, 0, 595, 0, 29, 414, 595, 0, + /* 431 */ 277, 22, 663, 0, 277, 0, 663, 663, 24, 663, 0, 348, 277, 0, 663, 663, 25, 663, 0, 348, 37, 482, 547, 0, 0, + /* 456 */ 0, 0, 0, 277, 22, 0, 0, 1088, 0, 0, 0, 1088, 1088, 0, 0, 1152, 0, 0, 0, 0, 0, 1152, 0, 1152, 1152, 0 + }; + + private static final int[] EXPECTED = + { + /* 0 */ 20, 4100, 65540, 131076, 32772, 131108, 131332, 98308, 196616, 1076, 1556, 3588, 90116, 69124, 132340, 16, + /* 16 */ 32768, 32, 256, 8, 8, 1024, 512, 8192, 16384, 64, 128, 16, 32768, 32, 1024, 8192, 16384, 64, 128, 32768, + /* 36 */ 16384, 16384 + }; + + private static final String[] TOKEN = + { + "(0)", + "END", + "WhiteSpace", + "Text", + "Variable", + "Function", + "MarkupStart", + "MarkupEnd", + "Name", + "Nmtoken", + "Literal", + "'*'", + "'='", + "'let'", + "'match'", + "'when'", + "'{'", + "'}'" + }; +} + +// End diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Serializer.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Serializer.java new file mode 100644 index 000000000000..b7bff50e06dd --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Mf2Serializer.java @@ -0,0 +1,522 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.ArrayList; +import java.util.List; + +import com.ibm.icu.message2.Mf2DataModel.Expression; +import com.ibm.icu.message2.Mf2DataModel.Pattern; +import com.ibm.icu.message2.Mf2DataModel.SelectorKeys; +import com.ibm.icu.message2.Mf2DataModel.Text; +import com.ibm.icu.message2.Mf2DataModel.Value; +import com.ibm.icu.message2.Mf2Parser.EventHandler; +import com.ibm.icu.message2.Mf2Serializer.Token.Type; + +// TODO: find a better name for this class +class Mf2Serializer implements EventHandler { + private String input; + private final List tokens = new ArrayList<>(); + + static class Token { + final String name; + final int begin; + final int end; + final Kind kind; + private final Type type; + private final String input; + + enum Kind { + TERMINAL, + NONTERMINAL_START, + NONTERMINAL_END + } + + enum Type { + MESSAGE, + PATTERN, + TEXT, + PLACEHOLDER, + EXPRESSION, + OPERAND, + VARIABLE, + IGNORE, + FUNCTION, + OPTION, + NAME, + NMTOKEN, + LITERAL, + SELECTOR, + VARIANT, + DECLARATION, VARIANTKEY, DEFAULT, + } + + Token(Kind kind, String name, int begin, int end, String input) { + this.kind = kind; + this.name = name; + this.begin = begin; + this.end = end; + this.input = input; + switch (name) { + case "Message": type = Type.MESSAGE; break; + case "Pattern": type = Type.PATTERN; break; + case "Text": type = Type.TEXT; break; + case "Placeholder": type = Type.PLACEHOLDER; break; + case "Expression": type = Type.EXPRESSION; break; + case "Operand": type = Type.OPERAND; break; + case "Variable": type = Type.VARIABLE; break; + case "Function": type = Type.FUNCTION; break; + case "Option": type = Type.OPTION; break; + case "Annotation": type = Type.IGNORE; break; + case "Name": type = Type.NAME; break; + case "Nmtoken": type = Type.NMTOKEN; break; + case "Literal": type = Type.LITERAL; break; + case "Selector": type = Type.SELECTOR; break; + case "Variant": type = Type.VARIANT; break; + case "VariantKey": type = Type.VARIANTKEY; break; + case "Declaration": type = Type.DECLARATION; break; + + case "Markup": type = Type.IGNORE; break; + case "MarkupStart": type = Type.IGNORE; break; + case "MarkupEnd": type = Type.IGNORE; break; + + case "'['": type = Type.IGNORE; break; + case "']'": type = Type.IGNORE; break; + case "'{'": type = Type.IGNORE; break; + case "'}'": type = Type.IGNORE; break; + case "'='": type = Type.IGNORE; break; + case "'match'": type = Type.IGNORE; break; + case "'when'": type = Type.IGNORE; break; + case "'let'": type = Type.IGNORE; break; + case "'*'": type = Type.DEFAULT; break; + default: + throw new IllegalArgumentException("Parse error: Unknown token \"" + name + "\""); + } + } + + boolean isStart() { + return Kind.NONTERMINAL_START.equals(kind); + } + + boolean isEnd() { + return Kind.NONTERMINAL_END.equals(kind); + } + + boolean isTerminal() { + return Kind.TERMINAL.equals(kind); + } + + @Override + public String toString() { + int from = begin == -1 ? 0 : begin; + String strval = end == -1 ? input.substring(from) : input.substring(from, end); + return String.format("Token(\"%s\", [%d, %d], %s) // \"%s\"", name, begin, end, kind, strval); + } + } + + Mf2Serializer() {} + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public void reset(CharSequence input) { + this.input = input.toString(); + tokens.clear(); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public void startNonterminal(String name, int begin) { + tokens.add(new Token(Token.Kind.NONTERMINAL_START, name, begin, -1, input)); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public void endNonterminal(String name, int end) { + tokens.add(new Token(Token.Kind.NONTERMINAL_END, name, -1, end, input)); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public void terminal(String name, int begin, int end) { + tokens.add(new Token(Token.Kind.TERMINAL, name, begin, end, input)); + } + + /** + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public void whitespace(int begin, int end) { + } + + Mf2DataModel build() { + if (!tokens.isEmpty()) { + Token firstToken = tokens.get(0); + if (Type.MESSAGE.equals(firstToken.type) && firstToken.isStart()) { + return parseMessage(); + } + } + return null; + } + + private Mf2DataModel parseMessage() { + Mf2DataModel.Builder result = Mf2DataModel.builder(); + + for (int i = 0; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case MESSAGE: + if (token.isStart() && i == 0) { + // all good + } else if (token.isEnd() && i == tokens.size() - 1) { + // We check if this last token is at the end of the input + if (token.end != input.length()) { + String leftover = input.substring(token.end) + .replace("\n", "") + .replace("\r", "") + .replace(" ", "") + .replace("\t", "") + ; + if (!leftover.isEmpty()) { + throw new IllegalArgumentException("Parse error: Content detected after the end of the message: '" + + input.substring(token.end) + "'"); + } + } + return result.build(); + } else { + // End of message, we ignore the rest + throw new IllegalArgumentException("Parse error: Extra tokens at the end of the message"); + } + break; + case PATTERN: + ParseResult patternResult = parsePattern(i); + i = patternResult.skipLen; + result.setPattern(patternResult.resultValue); + break; + case DECLARATION: + Declaration declaration = new Declaration(); + i = parseDeclaration(i, declaration); + result.addLocalVariable(declaration.variableName, declaration.expr); + break; + case SELECTOR: + ParseResult> selectorResult = parseSelector(i); + result.addSelectors(selectorResult.resultValue); + i = selectorResult.skipLen; + break; + case VARIANT: + ParseResult variantResult = parseVariant(i); + i = variantResult.skipLen; + Variant variant = variantResult.resultValue; + result.addVariant(variant.getSelectorKeys(), variant.getPattern()); + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseMessage UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing MessageFormatter"); + } + + private ParseResult parseVariant(int startToken) { + Variant.Builder result = Variant.builder(); + + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case VARIANT: + if (token.isStart()) { // all good + } else if (token.isEnd()) { + return new ParseResult<>(i, result.build()); + } + break; + case LITERAL: + result.addSelectorKey(input.substring(token.begin + 1, token.end - 1)); + break; + case NMTOKEN: + result.addSelectorKey(input.substring(token.begin, token.end)); + break; + case DEFAULT: + result.addSelectorKey("*"); + break; + case PATTERN: + ParseResult patternResult = parsePattern(i); + i = patternResult.skipLen; + result.setPattern(patternResult.resultValue); + break; + case VARIANTKEY: +// variant.variantKey = new VariantKey(input.substring(token.begin, token.end)); + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseVariant UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing Variant"); + } + + private ParseResult> parseSelector(int startToken) { + List result = new ArrayList<>(); + + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case SELECTOR: + if (token.isStart()) { // all good, do nothing + } else if (token.isEnd()) { + return new ParseResult<>(i, result); + } + break; + case EXPRESSION: + ParseResult exprResult = parseExpression(i); + i = exprResult.skipLen; + result.add(exprResult.resultValue); + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseSelector UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing selectors"); + } + + private int parseDeclaration(int startToken, Declaration declaration) { + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case DECLARATION: + if (token.isStart()) { // all good + } else if (token.isEnd()) { + return i; + } + break; + case VARIABLE: + declaration.variableName = input.substring(token.begin + 1, token.end); + break; + case EXPRESSION: + ParseResult exprResult = parseExpression(i); + i = exprResult.skipLen; + declaration.expr = exprResult.resultValue; + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseDeclaration UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing Declaration"); + } + + private ParseResult parsePattern(int startToken) { + Pattern.Builder result = Pattern.builder(); + + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case TEXT: + Text text = new Text(input.substring(token.begin, token.end)); + result.add(text); + break; + case PLACEHOLDER: + break; + case EXPRESSION: + ParseResult exprResult = parseExpression(i); + i = exprResult.skipLen; + result.add(exprResult.resultValue); + break; + case VARIABLE: + case IGNORE: + break; + case PATTERN: + if (token.isStart() && i == startToken) { // all good, do nothing + } else if (token.isEnd()) { + return new ParseResult<>(i, result.build()); + } + break; + default: + throw new IllegalArgumentException("Parse error: parsePattern UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing Pattern"); + } + + static class Option { + String name; + Value value; + } + + static class Declaration { + String variableName; + Expression expr; + } + + static class Variant { + private final SelectorKeys selectorKeys; + private final Pattern pattern; + + private Variant(Builder builder) { + this.selectorKeys = builder.selectorKeys.build(); + this.pattern = builder.pattern; + } + + /** + * Creates a builder. + * + * @return the Builder. + */ + public static Builder builder() { + return new Builder(); + } + + public SelectorKeys getSelectorKeys() { + return selectorKeys; + } + + public Pattern getPattern() { + return pattern; + } + + public static class Builder { + private final SelectorKeys.Builder selectorKeys = SelectorKeys.builder(); + private Pattern pattern = Pattern.builder().build(); + + // Prevent direct creation + private Builder() { + } + + public Builder setSelectorKeys(SelectorKeys selectorKeys) { + this.selectorKeys.addAll(selectorKeys.getKeys()); + return this; + } + + public Builder addSelectorKey(String selectorKey) { + this.selectorKeys.add(selectorKey); + return this; + } + + public Builder setPattern(Pattern pattern) { + this.pattern = pattern; + return this; + } + + public Variant build() { + return new Variant(this); + } + } + } + + static class ParseResult { + final int skipLen; + final T resultValue; + + public ParseResult(int skipLen, T resultValue) { + this.skipLen = skipLen; + this.resultValue = resultValue; + } + } + + private ParseResult parseExpression(int startToken) { + Expression.Builder result = Expression.builder(); + + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case EXPRESSION: // intentional fall-through + case PLACEHOLDER: + if (token.isStart() && i == startToken) { + // all good + } else if (token.isEnd()) { + return new ParseResult<>(i, result.build()); + } + break; + case FUNCTION: + result.setFunctionName(input.substring(token.begin + 1, token.end)); + break; + case LITERAL: + result.setOperand(Value.builder() + .setLiteral(input.substring(token.begin + 1, token.end - 1)) + .build()); + break; + case VARIABLE: + result.setOperand(Value.builder() + .setVariableName(input.substring(token.begin + 1, token.end)) + .build()); + break; + case OPTION: + Option option = new Option(); + i = parseOptions(i, option); + result.addOption(option.name, option.value); + break; + case OPERAND: + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseExpression UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing Expression"); + } + + private int parseOptions(int startToken, Option option) { + for (int i = startToken; i < tokens.size(); i++) { + Token token = tokens.get(i); + switch (token.type) { + case OPTION: + if (token.isStart() && i == startToken) { + // all good + } else if (token.isEnd()) { + return i; + } + break; + case NAME: + option.name = input.substring(token.begin, token.end); + break; + case LITERAL: + option.value = Value.builder() + .setLiteral(input.substring(token.begin + 1, token.end - 1)) + .build(); + break; + case NMTOKEN: + option.value = Value.builder() + .setLiteral(input.substring(token.begin, token.end)) + .build(); + break; + case VARIABLE: + option.value = Value.builder() + .setVariableName(input.substring(token.begin + 1, token.end)) + .build(); + break; + case IGNORE: + break; + default: + throw new IllegalArgumentException("Parse error: parseOptions UNEXPECTED TOKEN: '" + token + "'"); + } + } + throw new IllegalArgumentException("Parse error: Error parsing Option"); + } + + static String dataModelToString(Mf2DataModel dataModel) { + return dataModel.toString(); + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/NumberFormatterFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/NumberFormatterFactory.java new file mode 100644 index 000000000000..c7fd15249f68 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/NumberFormatterFactory.java @@ -0,0 +1,132 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import com.ibm.icu.math.BigDecimal; +import com.ibm.icu.number.LocalizedNumberFormatter; +import com.ibm.icu.number.NumberFormatter; +import com.ibm.icu.number.Precision; +import com.ibm.icu.number.UnlocalizedNumberFormatter; +import com.ibm.icu.text.FormattedValue; +import com.ibm.icu.util.CurrencyAmount; + + +/** + * Creates a {@link Formatter} doing numeric formatting, similar to {exp, number} + * in {@link com.ibm.icu.text.MessageFormat}. + */ +class NumberFormatterFactory implements FormatterFactory { + + /** + * {@inheritDoc} + */ + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + return new NumberFormatterImpl(locale, fixedOptions); + } + + static class NumberFormatterImpl implements Formatter { + private final Locale locale; + private final Map fixedOptions; + private final LocalizedNumberFormatter icuFormatter; + final boolean advanced; + + private static LocalizedNumberFormatter formatterForOptions(Locale locale, Map fixedOptions) { + UnlocalizedNumberFormatter nf; + String skeleton = OptUtils.getString(fixedOptions, "skeleton"); + if (skeleton != null) { + nf = NumberFormatter.forSkeleton(skeleton); + } else { + nf = NumberFormatter.with(); + Integer minFractionDigits = OptUtils.getInteger(fixedOptions, "minimumFractionDigits"); + if (minFractionDigits != null) { + nf = nf.precision(Precision.minFraction(minFractionDigits)); + } + } + return nf.locale(locale); + } + + NumberFormatterImpl(Locale locale, Map fixedOptions) { + this.locale = locale; + this.fixedOptions = new HashMap<>(fixedOptions); + String skeleton = OptUtils.getString(fixedOptions, "skeleton"); + boolean fancy = skeleton != null; + this.icuFormatter = formatterForOptions(locale, fixedOptions); + this.advanced = fancy; + } + + LocalizedNumberFormatter getIcuFormatter() { + return icuFormatter; + } + + /** + * {@inheritDoc} + */ + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + + /** + * {@inheritDoc} + */ + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + LocalizedNumberFormatter realFormatter; + if (variableOptions.isEmpty()) { + realFormatter = this.icuFormatter; + } else { + Map mergedOptions = new HashMap<>(fixedOptions); + mergedOptions.putAll(variableOptions); + // This is really wasteful, as we don't use the existing + // formatter if even one option is variable. + // We can optimize, but for now will have to do. + realFormatter = formatterForOptions(locale, mergedOptions); + } + + Integer offset = OptUtils.getInteger(variableOptions, "offset"); + if (offset == null && fixedOptions != null) { + offset = OptUtils.getInteger(fixedOptions, "offset"); + } + if (offset == null) { + offset = 0; + } + + FormattedValue result = null; + if (toFormat == null) { + // This is also what MessageFormat does. + throw new NullPointerException("Argument to format can't be null"); + } else if (toFormat instanceof Double) { + result = realFormatter.format((double) toFormat - offset); + } else if (toFormat instanceof Long) { + result = realFormatter.format((long) toFormat - offset); + } else if (toFormat instanceof Integer) { + result = realFormatter.format((int) toFormat - offset); + } else if (toFormat instanceof BigDecimal) { + BigDecimal bd = (BigDecimal) toFormat; + result = realFormatter.format(bd.subtract(BigDecimal.valueOf(offset))); + } else if (toFormat instanceof Number) { + result = realFormatter.format(((Number) toFormat).doubleValue() - offset); + } else if (toFormat instanceof CurrencyAmount) { + result = realFormatter.format((CurrencyAmount) toFormat); + } else { + // The behavior is not in the spec, will be in the registry. + // We can return "NaN", or try to parse the string as a number + String strValue = Objects.toString(toFormat); + Number nrValue = OptUtils.asNumber(strValue); + if (nrValue != null) { + result = realFormatter.format(nrValue.doubleValue() - offset); + } else { + result = new PlainStringFormattedValue("NaN"); + } + } + return new FormattedPlaceholder(toFormat, result); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/OptUtils.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/OptUtils.java new file mode 100644 index 000000000000..21a839bd8b6d --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/OptUtils.java @@ -0,0 +1,52 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Map; + +class OptUtils { + private OptUtils() {} + + static Number asNumber(Object value) { + if (value instanceof Number) { + return (Number) value; + } + if (value instanceof CharSequence) { + String strValue = value.toString(); + try { + return Double.parseDouble(strValue); + } catch (NumberFormatException e) { + } + try { + return Integer.decode(strValue); + } catch (NumberFormatException e) { + } + } + return null; + } + + static Integer getInteger(Map options, String key) { + Object value = options.get(key); + if (value == null) { + return null; + } + Number nrValue = asNumber(value); + if (nrValue != null) { + return nrValue.intValue(); + } + return null; + } + + static String getString(Map options, String key) { + Object value = options.get(key); + if (value == null) { + return null; + } + if (value instanceof CharSequence) { + return value.toString(); + } + return null; + } + +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/PlainStringFormattedValue.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/PlainStringFormattedValue.java new file mode 100644 index 000000000000..d11c43cc639d --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/PlainStringFormattedValue.java @@ -0,0 +1,132 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.text.AttributedCharacterIterator; + +import com.ibm.icu.text.ConstrainedFieldPosition; +import com.ibm.icu.text.FormattedValue; + +/** + * Very-very rough implementation of FormattedValue, packaging a string. + * Expect it to change. + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public class PlainStringFormattedValue implements FormattedValue { + private final String value; + + /** + * Constructor, taking the string to store. + * + * @param value the string value to store + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + public PlainStringFormattedValue(String value) { + if (value == null) { + throw new IllegalAccessError("Should not try to wrap a null in a formatted value"); + } + this.value = value; + } + + /** + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public int length() { + return value == null ? 0 : value.length(); + } + + /** + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public char charAt(int index) { + return value.charAt(index); + } + + /** + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public CharSequence subSequence(int start, int end) { + return value.subSequence(start, end); + } + + /** + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public A appendTo(A appendable) { + try { + appendable.append(value); + } catch (IOException e) { + throw new UncheckedIOException("problem appending", e); + } + return appendable; + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public boolean nextPosition(ConstrainedFieldPosition cfpos) { + throw new RuntimeException("nextPosition not yet implemented"); + } + + /** + * Not yet implemented. + * + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public AttributedCharacterIterator toCharacterIterator() { + throw new RuntimeException("toCharacterIterator not yet implemented"); + } + + /** + * {@inheritDoc} + * + * @internal ICU 72 technology preview. Visible For Testing. + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + @Override + public String toString() { + return value; + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/PluralSelectorFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/PluralSelectorFactory.java new file mode 100644 index 000000000000..34458ac65744 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/PluralSelectorFactory.java @@ -0,0 +1,110 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; + +import com.ibm.icu.number.FormattedNumber; +import com.ibm.icu.text.FormattedValue; +import com.ibm.icu.text.PluralRules; +import com.ibm.icu.text.PluralRules.PluralType; + +/** + * Creates a {@link Selector} doing plural selection, similar to {exp, plural} + * in {@link com.ibm.icu.text.MessageFormat}. + */ +class PluralSelectorFactory implements SelectorFactory { + private final PluralType pluralType; + + /** + * Creates a {@code PluralSelectorFactory} of the desired type. + * + * @param type the kind of plural selection we want + */ + // TODO: Use an enum + PluralSelectorFactory(String type) { + switch (type) { + case "ordinal": + pluralType = PluralType.ORDINAL; + break; + case "cardinal": // intentional fallthrough + default: + pluralType = PluralType.CARDINAL; + } + } + + /** + * {@inheritDoc} + */ + @Override + public Selector createSelector(Locale locale, Map fixedOptions) { + PluralRules rules = PluralRules.forLocale(locale, pluralType); + return new PluralSelectorImpl(rules, fixedOptions); + } + + private static class PluralSelectorImpl implements Selector { + private final PluralRules rules; + private Map fixedOptions; + + private PluralSelectorImpl(PluralRules rules, Map fixedOptions) { + this.rules = rules; + this.fixedOptions = fixedOptions; + } + + /** + * {@inheritDoc} + */ + @Override + public boolean matches(Object value, String key, Map variableOptions) { + if (value == null) { + return false; + } + if ("*".equals(key)) { + return true; + } + + Integer offset = OptUtils.getInteger(variableOptions, "offset"); + if (offset == null && fixedOptions != null) { + offset = OptUtils.getInteger(fixedOptions, "offset"); + } + if (offset == null) { + offset = 0; + } + + double valToCheck = Double.MIN_VALUE; + FormattedValue formattedValToCheck = null; + if (value instanceof FormattedPlaceholder) { + FormattedPlaceholder fph = (FormattedPlaceholder) value; + value = fph.getInput(); + formattedValToCheck = fph.getFormattedValue(); + } + + if (value instanceof Double) { + valToCheck = (double) value; + } else if (value instanceof Integer) { + valToCheck = (Integer) value; + } else { + return false; + } + + // If there is nothing "tricky" about the formatter part we compare values directly. + // Right now ICU4J checks if the formatter is a DecimalFormt, which also feels "hacky". + // We need something better. + if (!fixedOptions.containsKey("skeleton") && !variableOptions.containsKey("skeleton")) { + try { // for match exact. + if (Double.parseDouble(key) == valToCheck) { + return true; + } + } catch (NumberFormatException e) { + } + } + + String match = formattedValToCheck instanceof FormattedNumber + ? rules.select((FormattedNumber) formattedValToCheck) + : rules.select(valToCheck - offset); + return match.equals(key); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/Selector.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/Selector.java new file mode 100644 index 000000000000..8eb9a2635d0d --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/Selector.java @@ -0,0 +1,37 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Map; + +/** + * The interface that must be implemented by all selectors + * that can be used from {@link MessageFormatter}. + * + *

Selectors are used to choose between different message variants, + * similar to plural, selectordinal, + * and select in {@link com.ibm.icu.text.MessageFormat}.

+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public interface Selector { + /** + * A method that is invoked for the object to match and each key. + * + *

For example an English plural {@code matches} would return {@code true} + * for {@code matches(1, "1")}, {@code matches(1, "one")}, and {@code matches(1, "*")}.

+ * + * @param value the value to select on. + * @param key the key to test for matching. + * @param variableOptions options that are not know at build time. + * @return the formatted string. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + boolean matches(Object value, String key, Map variableOptions); +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/SelectorFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/SelectorFactory.java new file mode 100644 index 000000000000..43f44066c537 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/SelectorFactory.java @@ -0,0 +1,32 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; + +/** + * The interface that must be implemented for each selection function + * that can be used from {@link MessageFormatter}. + * + *

The we use it to create and cache various selectors with various options.

+ * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ +@Deprecated +public interface SelectorFactory { + /** + * The method that is called to create a selector. + * + * @param locale the locale to use for selection. + * @param fixedOptions the options to use for selection. The keys and values are function dependent. + * @return The Selector. + * + * @internal ICU 72 technology preview + * @deprecated This API is for ICU internal use only. + */ + @Deprecated + Selector createSelector(Locale locale, Map fixedOptions); +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/TextSelectorFactory.java b/icu4j/main/classes/core/src/com/ibm/icu/message2/TextSelectorFactory.java new file mode 100644 index 000000000000..6c9cf5031f9c --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/TextSelectorFactory.java @@ -0,0 +1,36 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.message2; + +import java.util.Locale; +import java.util.Map; + + +/** + * Creates a {@link Selector} doing literal selection, similar to {exp, select} + * in {@link com.ibm.icu.text.MessageFormat}. + */ +class TextSelectorFactory implements SelectorFactory { + + /** + * {@inheritDoc} + */ + @Override + public Selector createSelector(Locale locale, Map fixedOptions) { + return new TextSelector(); + } + + private static class TextSelector implements Selector { + /** + * {@inheritDoc} + */ + @Override + public boolean matches(Object value, String key, Map variableOptions) { + if ("*".equals(key)) { + return true; + } + return key.equals(value); + } + } +} diff --git a/icu4j/main/classes/core/src/com/ibm/icu/message2/package.html b/icu4j/main/classes/core/src/com/ibm/icu/message2/package.html new file mode 100644 index 000000000000..95e8f7ae8db1 --- /dev/null +++ b/icu4j/main/classes/core/src/com/ibm/icu/message2/package.html @@ -0,0 +1,15 @@ + + + + +ICU4J com.ibm.icu.message2 Package Overview + + + +

Tech Preview implementation of the +MessageFormat v2 specification.

+ + + diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Args.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Args.java new file mode 100644 index 000000000000..815384d0e0ee --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Args.java @@ -0,0 +1,183 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import com.ibm.icu.message2.MessageFormatter; + +/** + * Convenience class that provides the same functionality as + * Map.of introduced in JDK 11, which can't be used yet for ICU4J. + * + *

The returned Map is immutable, to prove that the {@link MessageFormatter} + * does not change it

+ */ +@SuppressWarnings("javadoc") +public class Args { + + public static final Map NONE = new HashMap<>(); + + public static Map of( + String argName0, Object argValue0) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4, + String argName5, Object argValue5) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + result.put(argName5, argValue5); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4, + String argName5, Object argValue5, + String argName6, Object argValue6) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + result.put(argName5, argValue5); + result.put(argName6, argValue6); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4, + String argName5, Object argValue5, + String argName6, Object argValue6, + String argName7, Object argValue7) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + result.put(argName5, argValue5); + result.put(argName6, argValue6); + result.put(argName7, argValue7); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4, + String argName5, Object argValue5, + String argName6, Object argValue6, + String argName7, Object argValue7, + String argName8, Object argValue8) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + result.put(argName5, argValue5); + result.put(argName6, argValue6); + result.put(argName7, argValue7); + result.put(argName8, argValue8); + return Collections.unmodifiableMap(result); + } + + public static Map of( + String argName0, Object argValue0, + String argName1, Object argValue1, + String argName2, Object argValue2, + String argName3, Object argValue3, + String argName4, Object argValue4, + String argName5, Object argValue5, + String argName6, Object argValue6, + String argName7, Object argValue7, + String argName8, Object argValue8, + String argName9, Object argValue9) { + Map result = new HashMap<>(); + result.put(argName0, argValue0); + result.put(argName1, argValue1); + result.put(argName2, argValue2); + result.put(argName3, argValue3); + result.put(argName4, argValue4); + result.put(argName5, argValue5); + result.put(argName6, argValue6); + result.put(argName7, argValue7); + result.put(argName8, argValue8); + result.put(argName9, argValue9); + return Collections.unmodifiableMap(result); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterGrammarCaseTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterGrammarCaseTest.java new file mode 100644 index 000000000000..065f0cbd7a1e --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterGrammarCaseTest.java @@ -0,0 +1,114 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.FormattedPlaceholder; +import com.ibm.icu.message2.Formatter; +import com.ibm.icu.message2.FormatterFactory; +import com.ibm.icu.message2.MessageFormatter; +import com.ibm.icu.message2.Mf2FunctionRegistry; +import com.ibm.icu.message2.PlainStringFormattedValue; + +/** + * Showing a custom formatter that can handle grammatical cases. + */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class CustomFormatterGrammarCaseTest extends TestFmwk { + + static class GrammarCasesFormatterFactory implements FormatterFactory { + + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + Object grammarCase = fixedOptions.get("case"); + return new GrammarCasesFormatterImpl(grammarCase == null ? "" : grammarCase.toString()); + } + + static class GrammarCasesFormatterImpl implements Formatter { + final String grammarCase; + + GrammarCasesFormatterImpl(String grammarCase) { + this.grammarCase = grammarCase; + } + + // Romanian naive and incomplete rules, just to make things work for testing. + private static String getDativeAndGenitive(String value) { + if (value.endsWith("ana")) + return value.substring(0, value.length() - 3) + "nei"; + if (value.endsWith("ca")) + return value.substring(0, value.length() - 2) + "căi"; + if (value.endsWith("ga")) + return value.substring(0, value.length() - 2) + "găi"; + if (value.endsWith("a")) + return value.substring(0, value.length() - 1) + "ei"; + return "lui " + value; + } + + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + String result; + if (toFormat == null) { + result = null; + } else if (toFormat instanceof CharSequence) { + String value = (String) toFormat; + switch (grammarCase) { + case "dative": // intentional fallback + case "genitive": + result = getDativeAndGenitive(value); + // and so on for other cases, but I don't care to add more for now + break; + default: + result = value; + } + } else { + result = toFormat.toString(); + } + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result)); + } + } + + } + + static final Mf2FunctionRegistry REGISTRY = Mf2FunctionRegistry.builder() + .setFormatter("grammarBB", new GrammarCasesFormatterFactory()) + .build(); + + @Test + public void test() { + MessageFormatter mf = MessageFormatter.builder() + .setFunctionRegistry(REGISTRY) + .setLocale(Locale.forLanguageTag("ro")) + .setPattern("{Cartea {$owner :grammarBB case=genitive}}") + .build(); + + assertEquals("case - genitive", "Cartea Mariei", mf.formatToString(Args.of("owner", "Maria"))); + assertEquals("case - genitive", "Cartea Rodicăi", mf.formatToString(Args.of("owner", "Rodica"))); + assertEquals("case - genitive", "Cartea Ilenei", mf.formatToString(Args.of("owner", "Ileana"))); + assertEquals("case - genitive", "Cartea lui Petre", mf.formatToString(Args.of("owner", "Petre"))); + + mf = MessageFormatter.builder() + .setFunctionRegistry(REGISTRY) + .setLocale(Locale.forLanguageTag("ro")) + .setPattern("{M-a sunat {$owner :grammarBB case=nominative}}") + .build(); + + assertEquals("case - nominative", "M-a sunat Maria", mf.formatToString(Args.of("owner", "Maria"))); + assertEquals("case - nominative", "M-a sunat Rodica", mf.formatToString(Args.of("owner", "Rodica"))); + assertEquals("case - nominative", "M-a sunat Ileana", mf.formatToString(Args.of("owner", "Ileana"))); + assertEquals("case - nominative", "M-a sunat Petre", mf.formatToString(Args.of("owner", "Petre"))); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterListTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterListTest.java new file mode 100644 index 000000000000..1b3bac2ff765 --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterListTest.java @@ -0,0 +1,98 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Arrays; +import java.util.Collection; +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.FormattedPlaceholder; +import com.ibm.icu.message2.Formatter; +import com.ibm.icu.message2.FormatterFactory; +import com.ibm.icu.message2.Mf2FunctionRegistry; +import com.ibm.icu.message2.PlainStringFormattedValue; +import com.ibm.icu.text.ListFormatter; +import com.ibm.icu.text.ListFormatter.Type; +import com.ibm.icu.text.ListFormatter.Width; + +/** + * Showing a custom formatter for a list, using the existing ICU {@link ListFormatter}. + */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class CustomFormatterListTest extends TestFmwk { + + static class ListFormatterFactory implements FormatterFactory { + + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + return new ListFormatterImpl(locale, fixedOptions); + } + + static class ListFormatterImpl implements Formatter { + private final ListFormatter lf; + + ListFormatterImpl(Locale locale, Map fixedOptions) { + Object oType = fixedOptions.get("type"); + Type type = oType == null + ? ListFormatter.Type.AND + : ListFormatter.Type.valueOf(oType.toString()); + Object oWidth = fixedOptions.get("width"); + Width width = oWidth == null + ? ListFormatter.Width.WIDE + : ListFormatter.Width.valueOf(oWidth.toString()); + lf = ListFormatter.getInstance(locale, type, width); + } + + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + String result; + if (toFormat instanceof Object[]) { + result = lf.format((Object[]) toFormat); + } else if (toFormat instanceof Collection) { + result = lf.format((Collection) toFormat); + } else { + result = toFormat == null ? "null" : toFormat.toString(); + } + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result)); + } + } + } + + static final Mf2FunctionRegistry REGISTRY = Mf2FunctionRegistry.builder() + .setFormatter("listformat", new ListFormatterFactory()) + .build(); + + @Test + public void test() { + String [] progLanguages = { + "C/C++", + "Java", + "Python" + }; + + TestUtils.runTestCase(REGISTRY, new TestCase.Builder() + .pattern("{I know {$languages :listformat type=AND}!}") + .arguments(Args.of("languages", progLanguages)) + .expected("I know C/C++, Java, and Python!") + .build()); + + TestUtils.runTestCase(REGISTRY, new TestCase.Builder() + .pattern("{You are allowed to use {$languages :listformat type=OR}!}") + .arguments(Args.of("languages", Arrays.asList(progLanguages))) + .expected("You are allowed to use C/C++, Java, or Python!") + .build()); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterMessageRefTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterMessageRefTest.java new file mode 100644 index 000000000000..9b6b153fdaca --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterMessageRefTest.java @@ -0,0 +1,129 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Locale; +import java.util.Map; +import java.util.Properties; + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.FormattedPlaceholder; +import com.ibm.icu.message2.Formatter; +import com.ibm.icu.message2.FormatterFactory; +import com.ibm.icu.message2.MessageFormatter; +import com.ibm.icu.message2.Mf2FunctionRegistry; +import com.ibm.icu.message2.PlainStringFormattedValue; + +/** + * Showing a custom formatter that can implement message references. + * + *

Supporting this functionality was strongly requested as a part of the core specification. + * But this shows that it can be easily implemented as a custom function.

+ */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class CustomFormatterMessageRefTest extends TestFmwk { + + static class ResourceManagerFactory implements FormatterFactory { + + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + return new ResourceManagerFactoryImpl(locale, fixedOptions); + } + + static class ResourceManagerFactoryImpl implements Formatter { + final Map options; + + ResourceManagerFactoryImpl(Locale locale, Map options) { + this.options = options; + } + + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + String result = null; + Object oProps = options.get("resbundle"); + // If it was not in the fixed options, try in the variable ones + if (oProps == null) { + oProps = variableOptions.get("resbundle"); + } + if (oProps != null && oProps instanceof Properties) { + Properties props = (Properties) oProps; + Object msg = props.get(toFormat.toString()); + MessageFormatter mf = MessageFormatter.builder() + .setPattern(msg.toString()) + .build(); + result = mf.formatToString(options); + } + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result)); + } + + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + } + } + + static final Mf2FunctionRegistry REGISTRY = Mf2FunctionRegistry.builder() + .setFormatter("msgRef", new ResourceManagerFactory()) + .build(); + + static final Properties PROPERTIES = new Properties(); + + @BeforeClass + static public void beforeClass() { + PROPERTIES.put("firefox", "match {$gcase :select} when genitive {Firefoxin} when * {Firefox}"); + PROPERTIES.put("chrome", "match {$gcase :select} when genitive {Chromen} when * {Chrome}"); + PROPERTIES.put("safari", "match {$gcase :select} when genitive {Safarin} when * {Safari}"); + } + + @Test + public void testSimpleGrammarSelection() { + MessageFormatter mf = MessageFormatter.builder() + .setPattern(PROPERTIES.getProperty("firefox")) + .build(); + assertEquals("cust-grammar", "Firefox", mf.formatToString(Args.of("gcase", "whatever"))); + assertEquals("cust-grammar", "Firefoxin", mf.formatToString(Args.of("gcase", "genitive"))); + + mf = MessageFormatter.builder() + .setPattern(PROPERTIES.getProperty("chrome")) + .build(); + assertEquals("cust-grammar", "Chrome", mf.formatToString(Args.of("gcase", "whatever"))); + assertEquals("cust-grammar", "Chromen", mf.formatToString(Args.of("gcase", "genitive"))); + } + + @Test + public void test() { + StringBuffer browser = new StringBuffer(); + Map arguments = Args.of( + "browser", browser, + "res", PROPERTIES); + + MessageFormatter mf1 = MessageFormatter.builder() + .setFunctionRegistry(REGISTRY) + .setPattern("{Please start {$browser :msgRef gcase=genitive resbundle=$res}}") + .build(); + MessageFormatter mf2 = MessageFormatter.builder() + .setFunctionRegistry(REGISTRY) + .setPattern("{Please start {$browser :msgRef resbundle=$res}}") + .build(); + + browser.replace(0, browser.length(), "firefox"); + assertEquals("cust-grammar", "Please start Firefoxin", mf1.formatToString(arguments)); + assertEquals("cust-grammar", "Please start Firefox", mf2.formatToString(arguments)); + + browser.replace(0, browser.length(), "chrome"); + assertEquals("cust-grammar", "Please start Chromen", mf1.formatToString(arguments)); + assertEquals("cust-grammar", "Please start Chrome", mf2.formatToString(arguments)); + + browser.replace(0, browser.length(), "safari"); + assertEquals("cust-grammar", "Please start Safarin", mf1.formatToString(arguments)); + assertEquals("cust-grammar", "Please start Safari", mf2.formatToString(arguments)); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterPersonTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterPersonTest.java new file mode 100644 index 000000000000..b60149a8e4ed --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/CustomFormatterPersonTest.java @@ -0,0 +1,198 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Locale; +import java.util.Map; +import java.util.Objects; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.FormattedPlaceholder; +import com.ibm.icu.message2.Formatter; +import com.ibm.icu.message2.FormatterFactory; +import com.ibm.icu.message2.Mf2FunctionRegistry; +import com.ibm.icu.message2.PlainStringFormattedValue; + +/** + * Showing a custom formatter for a user defined class. + */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class CustomFormatterPersonTest extends TestFmwk { + + public static class Person { + final String title; + final String firstName; + final String lastName; + + public Person(String title, String firstName, String lastName) { + this.title = title; + this.firstName = firstName; + this.lastName = lastName; + } + + @Override + public String toString() { + return "Person {title='" + title + "', firstName='" + firstName + "', lastName='" + lastName + "'}"; + } + } + + public static class PersonNameFormatterFactory implements FormatterFactory { + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + return new PersonNameFormatterImpl(fixedOptions.get("formality"), fixedOptions.get("length")); + } + + static class PersonNameFormatterImpl implements Formatter { + boolean useFormal = false; + final String length; + + public PersonNameFormatterImpl(Object level, Object length) { + this.useFormal = "formal".equals(level); + this.length = Objects.toString(length); + } + + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return format(toFormat, variableOptions).toString(); + } + + // Very-very primitive implementation of the "CLDR Person Name Formatting" spec: + // https://docs.google.com/document/d/1uvv6gdkuFwtbNV26Pk7ddfZult4unYwR6DnnKYbujUo/ + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + String result; + if (toFormat instanceof Person) { + Person person = (Person) toFormat; + switch (length) { + case "long": + result = person.title + " " + person.firstName + " " + person.lastName; + break; + case "medium": + result = useFormal + ? person.firstName + " " + person.lastName + : person.title + " " + person.firstName; + break; + case "short": // intentional fall-through + default: + result = useFormal + ? person.title + " " + person.lastName + : person.firstName; + } + } else { + result = Objects.toString(toFormat); + } + return new FormattedPlaceholder(toFormat, new PlainStringFormattedValue(result)); + } + } + } + + private static final Mf2FunctionRegistry CUSTOM_FUNCTION_REGISTRY = Mf2FunctionRegistry.builder() + .setFormatter("person", new PersonNameFormatterFactory()) + .setDefaultFormatterNameForType(Person.class, "person") + .build(); + + @Test + public void testCustomFunctions() { + Person who = new Person("Mr.", "John", "Doe"); + + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Hello {$name :person formality=formal}}") + .arguments(Args.of("name", who)) + .expected("Hello {$name}") + .errors("person function unknown when called without a custom registry") + .build()); + + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Hello {$name :person formality=informal}}") + .arguments(Args.of("name", who)) + .expected("Hello {$name}") + .errors("person function unknown when called without a custom registry") + .build()); + + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern("{Hello {$name :person formality=formal}}") + .arguments(Args.of("name", who)) + .expected("Hello Mr. Doe") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern("{Hello {$name :person formality=informal}}") + .arguments(Args.of("name", who)) + .expected("Hello John") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern("{Hello {$name :person formality=formal length=long}}") + .arguments(Args.of("name", who)) + .expected("Hello Mr. John Doe") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern("{Hello {$name :person formality=formal length=medium}}") + .arguments(Args.of("name", who)) + .expected("Hello John Doe") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern("{Hello {$name :person formality=formal length=short}}") + .arguments(Args.of("name", who)) + .expected("Hello Mr. Doe") + .build()); + } + + @Test + public void testCustomFunctionsComplexMessage() { + Person femalePerson = new Person("Ms.", "Jane", "Doe"); + Person malePerson = new Person("Mr.", "John", "Doe"); + Person unknownPerson = new Person("Mr./Ms.", "Anonymous", "Doe"); + String message = "" + + "let $hostName = {$host :person length=long}\n" + + "let $guestName = {$guest :person length=long}\n" + + "let $guestsOther = {$guestCount :number offset=1}\n" + // + "\n" + + "match {$hostGender :gender} {$guestCount :plural}\n" + // + "\n" + + "when female 0 {{$hostName} does not give a party.}\n" + + "when female 1 {{$hostName} invites {$guestName} to her party.}\n" + + "when female 2 {{$hostName} invites {$guestName} and one other person to her party.}\n" + + "when female * {{$hostName} invites {$guestName} and {$guestsOther} other people to her party.}\n" + // + "\n" + + "when male 0 {{$hostName} does not give a party.}\n" + + "when male 1 {{$hostName} invites {$guestName} to his party.}\n" + + "when male 2 {{$hostName} invites {$guestName} and one other person to his party.}\n" + + "when male * {{$hostName} invites {$guestName} and {$guestsOther} other people to his party.}\n" + // + "\n" + + "when * 0 {{$hostName} does not give a party.}\n" + + "when * 1 {{$hostName} invites {$guestName} to their party.}\n" + + "when * 2 {{$hostName} invites {$guestName} and one other person to their party.}\n" + + "when * * {{$hostName} invites {$guestName} and {$guestsOther} other people to their party.}\n"; + + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern(message) + .arguments(Args.of("hostGender", "female", "host", femalePerson, "guest", malePerson, "guestCount", 3)) + .expected("Ms. Jane Doe invites Mr. John Doe and 2 other people to her party.") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern(message) + .arguments(Args.of("hostGender", "female", "host", femalePerson, "guest", malePerson, "guestCount", 2)) + .expected("Ms. Jane Doe invites Mr. John Doe and one other person to her party.") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern(message) + .arguments(Args.of("hostGender", "female", "host", femalePerson, "guest", malePerson, "guestCount", 1)) + .expected("Ms. Jane Doe invites Mr. John Doe to her party.") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern(message) + .arguments(Args.of("hostGender", "male", "host", malePerson, "guest", femalePerson, "guestCount", 3)) + .expected("Mr. John Doe invites Ms. Jane Doe and 2 other people to his party.") + .build()); + TestUtils.runTestCase(CUSTOM_FUNCTION_REGISTRY, new TestCase.Builder() + .pattern(message) + .arguments(Args.of("hostGender", "unknown", "host", unknownPerson, "guest", femalePerson, "guestCount", 2)) + .expected("Mr./Ms. Anonymous Doe invites Ms. Jane Doe and one other person to their party.") + .build()); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/FromJsonTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/FromJsonTest.java new file mode 100644 index 000000000000..71030ab5f3fe --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/FromJsonTest.java @@ -0,0 +1,422 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; + +/** + * These tests come from the test suite created for the JavaScript implementation of MessageFormat v2. + * + *

Original JSON file + * here.

+ */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class FromJsonTest extends TestFmwk { + + static final TestCase[] TEST_CASES = { + new TestCase.Builder() + .pattern("{hello}") + .expected("hello") + .build(), + new TestCase.Builder() + .pattern("{hello {(world)}}") + .expected("hello world") + .build(), + new TestCase.Builder() + .pattern("{hello {()}}") + .expected("hello ") + .build(), + new TestCase.Builder() + .pattern("{hello {$place}}") + .arguments(Args.of("place", "world")) + .expected("hello world") + .build(), + new TestCase.Builder() + .pattern("{hello {$place}}") + .expected("hello {$place}") + // errorsJs: ["missing-var"] + .build(), + new TestCase.Builder() + .pattern("{{$one} and {$two}}") + .arguments(Args.of("one", 1.3, "two", 4.2)) + .expected("1.3 and 4.2") + .build(), + new TestCase.Builder() + .pattern("{{$one} et {$two}}") + .locale("fr") + .arguments(Args.of("one", 1.3, "two", 4.2)) + .expected("1,3 et 4,2") + .build(), + new TestCase.Builder() + .pattern("{hello {(4.2) :number}}") + .expected("hello 4.2") + .build(), + new TestCase.Builder() // not in the original JSON + .locale("ar-EG") + .pattern("{hello {(4.2) :number}}") + .expected("hello \u0664\u066B\u0662") + .build(), + new TestCase.Builder() + .pattern("{hello {(foo) :number}}") + .expected("hello NaN") + .build(), + new TestCase.Builder() + .pattern("{hello {:number}}") + .expected("hello NaN") + // This is different from JS, should be an error. + .errors("ICU4J: exception.") + .build(), + new TestCase.Builder() + .pattern("{hello {(4.2) :number minimumFractionDigits=2}}") + .expected("hello 4.20") + .build(), + new TestCase.Builder() + .pattern("{hello {(4.2) :number minimumFractionDigits=(2)}}") + .expected("hello 4.20") + .build(), + new TestCase.Builder() + .pattern("{hello {(4.2) :number minimumFractionDigits=$foo}}") + .arguments(Args.of("foo", 2f)) + .expected("hello 4.20") + .build(), + new TestCase.Builder() + .pattern("{hello {(4.2) :number minimumFractionDigits=$foo}}") + .arguments(Args.of("foo", "2")) + .expected("hello 4.20") + // errorsJs: ["invalid-type"] + .build(), + new TestCase.Builder() + .pattern("let $foo = {(bar)} {bar {$foo}}") + .expected("bar bar") + .build(), + new TestCase.Builder() + .pattern("let $foo = {(bar)} {bar {$foo}}") + .arguments(Args.of("foo", "foo")) + // expectedJs: "bar foo" + // It is undefined if we allow arguments to override local variables, or it is an error. + // And undefined who wins if that happens, the local variable of the argument. + .expected("bar bar") + .build(), + new TestCase.Builder() + .pattern("let $foo = {$bar} {bar {$foo}}") + .arguments(Args.of("bar", "foo")) + .expected("bar foo") + .build(), + new TestCase.Builder() + .pattern("let $foo = {$bar :number} {bar {$foo}}") + .arguments(Args.of("bar", 4.2)) + .expected("bar 4.2") + .build(), + new TestCase.Builder() + .pattern("let $foo = {$bar :number minimumFractionDigits=2} {bar {$foo}}") + .arguments(Args.of("bar", 4.2)) + .expected("bar 4.20") + .build(), + new TestCase.Builder().ignore("Maybe") // Because minimumFractionDigits=foo + .pattern("let $foo = {$bar :number minimumFractionDigits=foo} {bar {$foo}}") + .arguments(Args.of("bar", 4.2)) + .expected("bar 4.2") + .errors("invalid-type") + .build(), + new TestCase.Builder().ignore("Maybe. Function specific behavior.") + .pattern("let $foo = {$bar :number} {bar {$foo}}") + .arguments(Args.of("bar", "foo")) + .expected("bar NaN") + .build(), + new TestCase.Builder() + .pattern("let $foo = {$bar} let $bar = {$baz} {bar {$foo}}") + .arguments(Args.of("baz", "foo")) + // expectedJs: "bar foo" + // It is currently undefined if a local variable (like $foo) + // can reference a local variable that was not yet defined (like $bar). + // That is called hoisting and it is valid in JavaScript or Python. + // Not allowing that would prevent circular references. + // https://github.com/unicode-org/message-format-wg/issues/292 + .expected("bar {$bar}") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when (1) {one} when * {other}") + .pattern("match {$foo :select} when (1) {one} when * {other}") + .arguments(Args.of("foo", "1")) + .expected("one") + .build(), + new TestCase.Builder() + .pattern("match {$foo :plural} when 1 {one} when * {other}") + .arguments(Args.of("foo", "1")) // Should this be error? Plural on string? + // expectedJs: "one" + .expected("other") + .build(), + new TestCase.Builder() + .pattern("match {$foo :select} when (1) {one} when * {other}") + .arguments(Args.of("foo", "1")) + .expected("one") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when 1 {one} when * {other}") + .pattern("match {$foo :plural} when 1 {one} when * {other}") + .arguments(Args.of("foo", 1)) + .expected("one") + .build(), + new TestCase.Builder() + .pattern("match {$foo :plural} when 1 {one} when * {other}") + .arguments(Args.of("foo", 1)) + .expected("one") + .build(), + new TestCase.Builder().ignore("not possible to put a null in a map") + .pattern("match {$foo} when 1 {one} when * {other}") + .arguments(Args.of("foo", null)) + .expected("other") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when 1 {one} when * {other}") + .pattern("match {$foo :plural} when 1 {one} when * {other}") + .expected("other") + .errors("missing-var") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when one {one} when * {other}") + .pattern("match {$foo :plural} when one {one} when * {other}") + .arguments(Args.of("foo", 1)) + .expected("one") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when 1 {=1} when one {one} when * {other}") + .pattern("match {$foo :plural} when 1 {=1} when one {one} when * {other}") + .arguments(Args.of("foo", 1)) + .expected("=1") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} when one {one} when 1 {=1} when * {other}") + .pattern("match {$foo :plural} when one {one} when 1 {=1} when * {other}") + .arguments(Args.of("foo", 1)) + .expected("one") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} {$bar} when one one {one one} when one * {one other} when * * {other}") + .pattern("match {$foo :plural} {$bar :plural} when one one {one one} when one * {one other} when * * {other}") + .arguments(Args.of("foo", 1, "bar", 1)) + .expected("one one") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} {$bar} when one one {one one} when one * {one other} when * * {other}") + .pattern("match {$foo :plural} {$bar :plural} when one one {one one} when one * {one other} when * * {other}") + .arguments(Args.of("foo", 1, "bar", 2)) + .expected("one other") + .build(), + new TestCase.Builder() + .patternJs("match {$foo} {$bar} when one one {one one} when one * {one other} when * * {other}") + .pattern("match {$foo :plural} {$bar :plural} when one one {one one} when one * {one other} when * * {other}") + .arguments(Args.of("foo", 2, "bar", 2)) + .expected("other") + .build(), + new TestCase.Builder() + .patternJs("let $foo = {$bar} match {$foo} when one {one} when * {other}") + .pattern("let $foo = {$bar} match {$foo :plural} when one {one} when * {other}") + .arguments(Args.of("bar", 1)) + .expected("one") + .build(), + new TestCase.Builder() + .patternJs("let $foo = {$bar} match {$foo} when one {one} when * {other}") + .pattern("let $foo = {$bar} match {$foo :plural} when one {one} when * {other}") + .arguments(Args.of("bar", 2)) + .expected("other") + .build(), + new TestCase.Builder() + .patternJs("let $bar = {$none} match {$foo} when one {one} when * {{$bar}}") + .pattern("let $bar = {$none} match {$foo :plural} when one {one} when * {{$bar}}") + .arguments(Args.of("foo", 1)) + .expected("one") + .build(), + new TestCase.Builder() + .patternJs("let $bar = {$none} match {$foo} when one {one} when * {{$bar}}") + .pattern("let $bar = {$none :plural} match {$foo} when one {one} when * {{$bar}}") + .arguments(Args.of("foo", 2)) + .expected("{$bar}") + .errors("missing-var") + .build(), + new TestCase.Builder() + .pattern("let bar = {(foo)} {{$bar}}") + .expected("{$bar}") + .errors("missing-char", "missing-var") + .build(), + new TestCase.Builder() + .pattern("let $bar {(foo)} {{$bar}}") + .expected("foo") + .errors("missing-char") + .build(), + new TestCase.Builder() + .pattern("let $bar = (foo) {{$bar}}") + .expected("{$bar}") + .errors("missing-char", "junk-element") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{+tag}}") + .expected("{+tag}") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{+tag}content}") + .expected("{+tag}content") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{+tag}content{-tag}}") + .expected("{+tag}content{-tag}") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{-tag}content}") + .expected("{-tag}content") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{+tag foo=bar}}") + .expected("{+tag foo=bar}") + .build(), + new TestCase.Builder().ignore("no markup support") + .pattern("{{+tag foo=(foo) bar=$bar}}") + .arguments(Args.of("bar", "b a r")) + .expected("{+tag foo=foo bar=(b a r)}") + .build(), + new TestCase.Builder() + .pattern("{bad {(foo) +markup}}") + .expected("bad {+markup}") + .errors("extra-content") + .build(), + new TestCase.Builder() + .pattern("{{-tag foo=bar}}") + .expected("{-tag}") + .errors("extra-content") + .build(), + new TestCase.Builder() + .pattern("no braces") + .expected("{no braces}") + .errors("parse-error", "junk-element") + .build(), + new TestCase.Builder() + .pattern("no braces {$foo}") + .arguments(Args.of("foo", 2)) + .expected("{no braces {$foo}}") + .errors("parse-error", "junk-element") + .build(), + new TestCase.Builder().ignore("infinite loop!") + .pattern("{missing end brace") + .expected("missing end brace") + .errors("missing-char") + .build(), + new TestCase.Builder() + .pattern("{missing end {$brace") + .expected("missing end {$brace}") + .errors("missing-char", "missing-char", "missing-var") + .build(), + new TestCase.Builder() + .pattern("{extra} content") + .expected("extra") + .errors("extra-content") + .build(), + new TestCase.Builder() + .pattern("{empty { }}") + .expected("empty ") + // errorsJs: ["parse-error", "junk-element"] + .build(), + new TestCase.Builder() + .pattern("{bad {:}}") + .expected("bad {:}") + .errors("empty-token", "missing-func") + .build(), + new TestCase.Builder() + .pattern("{bad {placeholder}}") + .expected("bad {placeholder}") + .errors("parse-error", "extra-content", "junk-element") + .build(), + new TestCase.Builder() + .pattern("{no-equal {(42) :number minimumFractionDigits 2}}") + .expected( "no-equal 42.00") + .errors("missing-char") + .build(), + new TestCase.Builder() + .pattern("{bad {:placeholder option=}}") + .expected("bad {:placeholder}") + .errors("empty-token", "missing-func") + .build(), + new TestCase.Builder() + .pattern("{bad {:placeholder option value}}") + .expected("bad {:placeholder}") + .errors("missing-char", "missing-func") + .build(), + new TestCase.Builder() + .pattern("{bad {:placeholder option}}") + .expected("bad {:placeholder}") + .errors("missing-char", "empty-token", "missing-func") + .build(), + new TestCase.Builder() + .pattern("{bad {$placeholder option}}") + .expected("bad {$placeholder}") + .errors("extra-content", "extra-content", "missing-var") + .build(), + new TestCase.Builder() + .pattern("{no {$placeholder end}") + .expected("no {$placeholder}") + .errors("extra-content", "missing-var") + .build(), + new TestCase.Builder() + .pattern("match {} when * {foo}") + .expected("foo") + .errors("parse-error", "bad-selector", "junk-element") + .build(), + new TestCase.Builder() + .pattern("match {+foo} when * {foo}") + .expected("foo") + .errors("bad-selector") + .build(), + new TestCase.Builder() + .pattern("match {(foo)} when*{foo}") + .expected("foo") + .errors("missing-char") + .build(), + new TestCase.Builder() + .pattern("match when * {foo}") + .expected("foo") + .errors("empty-token") + .build(), + new TestCase.Builder() + .pattern("match {(x)} when * foo") + .expected("") + .errors("key-mismatch", "missing-char") + .build(), + new TestCase.Builder() + .pattern("match {(x)} when * {foo} extra") + .expected("foo") + .errors("extra-content") + .build(), + new TestCase.Builder() + .pattern("match (x) when * {foo}") + .expected("") + .errors("empty-token", "extra-content") + .build(), + new TestCase.Builder() + .pattern("match {$foo} when * * {foo}") + .expected("foo") + .errors("key-mismatch", "missing-var") + .build(), + new TestCase.Builder() + .pattern("match {$foo} {$bar} when * {foo}") + .expected("foo") + .errors("key-mismatch", "missing-var", "missing-var") + .build() + }; + + @Test + public void test() { + int ignoreCount = 0; + for (TestCase testCase : TEST_CASES) { + if (testCase.ignore) + ignoreCount++; + TestUtils.runTestCase(testCase); + } + System.out.printf("Executed %d test cases out of %d, skipped %d%n", + TEST_CASES.length - ignoreCount, TEST_CASES.length, ignoreCount); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/MessageFormat2Test.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/MessageFormat2Test.java new file mode 100644 index 000000000000..b16acb63088e --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/MessageFormat2Test.java @@ -0,0 +1,546 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.FormattedPlaceholder; +import com.ibm.icu.message2.Formatter; +import com.ibm.icu.message2.FormatterFactory; +import com.ibm.icu.message2.MessageFormatter; +import com.ibm.icu.message2.Mf2FunctionRegistry; +import com.ibm.icu.number.FormattedNumber; +import com.ibm.icu.number.LocalizedNumberFormatter; +import com.ibm.icu.number.NumberFormatter; +import com.ibm.icu.util.BuddhistCalendar; +import com.ibm.icu.util.Calendar; +import com.ibm.icu.util.GregorianCalendar; +import com.ibm.icu.util.Measure; +import com.ibm.icu.util.MeasureUnit; + +/** + * Tests migrated from {@link com.ibm.icu.text.MessageFormat}, to show what they look like and that they work. + * + *

It does not include all the tests for edge cases and error handling, only the ones that show real functionality.

+ */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class MessageFormat2Test extends TestFmwk { + + @Test + public void test() { + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern("{Hello World!}").build(); + assertEquals("simple message", + "Hello World!", + mf2.formatToString(Args.NONE)); + } + + @Test + public void testDateFormat() { + Date expiration = new Date(2022 - 1900, java.util.Calendar.OCTOBER, 27); + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime skeleton=yMMMdE}!}") + .build(); + assertEquals("date format", + "Your card expires on Thu, Oct 27, 2022!", + mf2.formatToString(Args.of("exp", expiration))); + + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime datestyle=full}!}") + .build(); + assertEquals("date format", + "Your card expires on Thursday, October 27, 2022!", + mf2.formatToString(Args.of("exp", expiration))); + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime datestyle=long}!}") + .build(); + assertEquals("date format", + "Your card expires on October 27, 2022!", + mf2.formatToString(Args.of("exp", expiration))); + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime datestyle=medium}!}") + .build(); + assertEquals("date format", + "Your card expires on Oct 27, 2022!", + mf2.formatToString(Args.of("exp", expiration))); + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime datestyle=short}!}") + .build(); + assertEquals("date format", + "Your card expires on 10/27/22!", + mf2.formatToString(Args.of("exp", expiration))); + + Calendar cal = new GregorianCalendar(2022, Calendar.OCTOBER, 27); + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime skeleton=yMMMdE}!}") + .build(); + assertEquals("date format", + "Your card expires on Thu, Oct 27, 2022!", + mf2.formatToString(Args.of("exp", cal))); + + // Implied function based on type of the object to format + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp}!}") + .build(); + assertEquals("date format", + "Your card expires on 10/27/22, 12:00\u202FAM!", + mf2.formatToString(Args.of("exp", expiration))); + assertEquals("date format", + "Your card expires on 10/27/22, 12:00\u202FAM!", + mf2.formatToString(Args.of("exp", cal))); + + // Implied function based on type of the object to format + // This is a calendar that is not explicitly added to the registry. + // But we test to see if it works because it extends Calendar, which is registered. + BuddhistCalendar calNotRegistered = new BuddhistCalendar(2022, Calendar.OCTOBER, 27); + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime skeleton=yMMMdE}!}") + .build(); + assertEquals("date format", + "Your card expires on Wed, Oct 27, 1479!", + mf2.formatToString(Args.of("exp", calNotRegistered))); + + mf2 = MessageFormatter.builder() + .setPattern("{Your card expires on {$exp :datetime skeleton=yMMMdE}!}") + .build(); + assertEquals("date format", + "Your card expires on Wed, Oct 27, 1479!", + mf2.formatToString(Args.of("exp", calNotRegistered))); + } + + @Test + public void testPlural() { + String message = "" + + "match {$count :plural}\n" + + " when 1 {You have one notification.}\n" + + " when * {You have {$count} notifications.}\n"; + + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("plural", + "You have one notification.", + mf2.formatToString(Args.of("count", 1))); + assertEquals("plural", + "You have 42 notifications.", + mf2.formatToString(Args.of("count", 42))); + } + + @Test + public void testPluralOrdinal() { + String message = "" + + "match {$place :selectordinal}\n" + + " when 1 {You got the gold medal}\n" + + " when 2 {You got the silver medal}\n" + + " when 3 {You got the bronze medal}\n" + + " when one {You got in the {$place}st place}\n" + + " when two {You got in the {$place}nd place}\n" + + " when few {You got in the {$place}rd place}\n" + + " when * {You got in the {$place}th place}\n" + ; + + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("selectordinal", + "You got the gold medal", + mf2.formatToString(Args.of("place", 1))); + assertEquals("selectordinal", + "You got the silver medal", + mf2.formatToString(Args.of("place", 2))); + assertEquals("selectordinal", + "You got the bronze medal", + mf2.formatToString(Args.of("place", 3))); + assertEquals("selectordinal", + "You got in the 21st place", + mf2.formatToString(Args.of("place", 21))); + assertEquals("selectordinal", + "You got in the 32nd place", + mf2.formatToString(Args.of("place", 32))); + assertEquals("selectordinal", + "You got in the 23rd place", + mf2.formatToString(Args.of("place", 23))); + assertEquals("selectordinal", + "You got in the 15th place", + mf2.formatToString(Args.of("place", 15))); + } + + static class TemperatureFormatterFactory implements FormatterFactory { + int constructCount = 0; + int formatCount = 0; + int fFormatterCount = 0; + int cFormatterCount = 0; + + @Override + public Formatter createFormatter(Locale locale, Map fixedOptions) { + // Check that the formatter can only see the fixed options + Assert.assertTrue(fixedOptions.containsKey("skeleton")); + Assert.assertFalse(fixedOptions.containsKey("unit")); + + Object valSkeleton = fixedOptions.get("skeleton"); + LocalizedNumberFormatter nf = valSkeleton != null + ? NumberFormatter.forSkeleton(valSkeleton.toString()).locale(locale) + : NumberFormatter.withLocale(locale); + + return new TemperatureFormatterImpl(nf, this); + } + + static private class TemperatureFormatterImpl implements Formatter { + private final TemperatureFormatterFactory formatterFactory; + private final LocalizedNumberFormatter nf; + private final Map cachedFormatters = + new HashMap<>(); + + TemperatureFormatterImpl(LocalizedNumberFormatter nf, TemperatureFormatterFactory formatterFactory) { + this.nf = nf; + this.formatterFactory = formatterFactory; + this.formatterFactory.constructCount++; + } + + @Override + public String formatToString(Object toFormat, Map variableOptions) { + return this.format(toFormat, variableOptions).toString(); + } + + @Override + public FormattedPlaceholder format(Object toFormat, Map variableOptions) { + // Check that the formatter can only see the variable options + Assert.assertFalse(variableOptions.containsKey("skeleton")); + Assert.assertTrue(variableOptions.containsKey("unit")); + this.formatterFactory.formatCount++; + + String unit = variableOptions.get("unit").toString(); + LocalizedNumberFormatter realNf = cachedFormatters.get(unit); + if (realNf == null) { + switch (variableOptions.get("unit").toString()) { + case "C": + formatterFactory.cFormatterCount++; + realNf = nf.unit(MeasureUnit.CELSIUS); + break; + case "F": + formatterFactory.fFormatterCount++; + realNf = nf.unit(MeasureUnit.FAHRENHEIT); + break; + default: + realNf = nf; + break; + } + cachedFormatters.put(unit, realNf); + } + + FormattedNumber result; + if (toFormat instanceof Double) { + result = realNf.format((double) toFormat); + } else if (toFormat instanceof Long) { + result = realNf.format((Long) toFormat); + } else if (toFormat instanceof Number) { + result = realNf.format((Number) toFormat); + } else if (toFormat instanceof Measure) { + result = realNf.format((Measure) toFormat); + } else { + result = null; + } + return new FormattedPlaceholder(toFormat, result); + } + } + } + + @Test + public void testFormatterIsCreatedOnce() { + TemperatureFormatterFactory counter = new TemperatureFormatterFactory(); + Mf2FunctionRegistry registry = Mf2FunctionRegistry.builder() + .setFormatter("temp", counter) + .build(); + String message = "{Testing {$count :temp unit=$unit skeleton=(.00/w)}.}"; + MessageFormatter mf2 = MessageFormatter.builder() + .setFunctionRegistry(registry) + .setPattern(message) + .build(); + + final int maxCount = 10; + for (int count = 0; count < maxCount; count++) { + assertEquals("cached formatter", + "Testing " + count + "°C.", + mf2.formatToString(Args.of("count", count, "unit", "C"))); + assertEquals("cached formatter", + "Testing " + count + "°F.", + mf2.formatToString(Args.of("count", count, "unit", "F"))); + } + + // Check that the constructor was only called once, + // and the formatter as many times as the public call to format. + assertEquals("cached formatter", 1, counter.constructCount); + assertEquals("cached formatter", maxCount * 2, counter.formatCount); + assertEquals("cached formatter", 1, counter.fFormatterCount); + assertEquals("cached formatter", 1, counter.cFormatterCount); + + // Check that the skeleton is respected + assertEquals("cached formatter", + "Testing 12°C.", + mf2.formatToString(Args.of("count", 12, "unit", "C"))); + assertEquals("cached formatter", + "Testing 12.50°F.", + mf2.formatToString(Args.of("count", 12.5, "unit", "F"))); + assertEquals("cached formatter", + "Testing 12.54°C.", + mf2.formatToString(Args.of("count", 12.54, "unit", "C"))); + assertEquals("cached formatter", + "Testing 12.54°F.", + mf2.formatToString(Args.of("count", 12.54321, "unit", "F"))); + + message = "{Testing {$count :temp unit=$unit skeleton=(.0/w)}.}"; + mf2 = MessageFormatter.builder() + .setFunctionRegistry(registry) + .setPattern(message) + .build(); + // Check that the skeleton is respected + assertEquals("cached formatter", + "Testing 12°C.", + mf2.formatToString(Args.of("count", 12, "unit", "C"))); + assertEquals("cached formatter", + "Testing 12.5°F.", + mf2.formatToString(Args.of("count", 12.5, "unit", "F"))); + assertEquals("cached formatter", + "Testing 12.5°C.", + mf2.formatToString(Args.of("count", 12.54, "unit", "C"))); + assertEquals("cached formatter", + "Testing 12.5°F.", + mf2.formatToString(Args.of("count", 12.54321, "unit", "F"))); + } + + @Test + public void testPluralWithOffset() { + String message = "" + + "match {$count :plural offset=2}\n" + + " when 1 {Anna}\n" + + " when 2 {Anna and Bob}\n" + + " when one {Anna, Bob, and {$count :number offset=2} other guest}\n" + + " when * {Anna, Bob, and {$count :number offset=2} other guests}\n"; + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("plural with offset", + "Anna", + mf2.formatToString(Args.of("count", 1))); + assertEquals("plural with offset", + "Anna and Bob", + mf2.formatToString(Args.of("count", 2))); + assertEquals("plural with offset", + "Anna, Bob, and 1 other guest", + mf2.formatToString(Args.of("count", 3))); + assertEquals("plural with offset", + "Anna, Bob, and 2 other guests", + mf2.formatToString(Args.of("count", 4))); + assertEquals("plural with offset", + "Anna, Bob, and 10 other guests", + mf2.formatToString(Args.of("count", 12))); + } + + @Test + public void testPluralWithOffsetAndLocalVar() { + String message = "" + + "let $foo = {$count :number offset=2}" + + "match {$foo :plural}\n" // should "inherit" the offset + + " when 1 {Anna}\n" + + " when 2 {Anna and Bob}\n" + + " when one {Anna, Bob, and {$foo} other guest}\n" + + " when * {Anna, Bob, and {$foo} other guests}\n"; + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("plural with offset", + "Anna", + mf2.formatToString(Args.of("count", 1))); + assertEquals("plural with offset", + "Anna and Bob", + mf2.formatToString(Args.of("count", 2))); + assertEquals("plural with offset", + "Anna, Bob, and 1 other guest", + mf2.formatToString(Args.of("count", 3))); + assertEquals("plural with offset", + "Anna, Bob, and 2 other guests", + mf2.formatToString(Args.of("count", 4))); + assertEquals("plural with offset", + "Anna, Bob, and 10 other guests", + mf2.formatToString(Args.of("count", 12))); + } + + @Test + public void testPluralWithOffsetAndLocalVar2() { + String message = "" + + "let $foo = {$amount :number skeleton=(.00/w)}\n" + + "match {$foo :plural}\n" // should "inherit" the offset + + " when 1 {Last dollar}\n" + + " when one {{$foo} dollar}\n" + + " when * {{$foo} dollars}\n"; + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("plural with offset", + "Last dollar", + mf2.formatToString(Args.of("amount", 1))); + assertEquals("plural with offset", + "2 dollars", + mf2.formatToString(Args.of("amount", 2))); + assertEquals("plural with offset", + "3 dollars", + mf2.formatToString(Args.of("amount", 3))); + } + + @Test + public void testLoopOnLocalVars() { + String message = "" + + "let $foo = {$baz :number}\n" + + "let $bar = {$foo}\n" + + "let $baz = {$bar}\n" + + "{The message uses {$baz} and works}\n"; + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern(message) + .build(); + assertEquals("test local vars loop", + "The message uses {$bar} and works", + mf2.formatToString(Args.of("amount", 1))); + } + + @Test + public void testVariableOptionsInSelector() { + String messageVar = "" + + "match {$count :plural offset=$delta}\n" + + " when 1 {A}\n" + + " when 2 {A and B}\n" + + " when one {A, B, and {$count :number offset=$delta} more character}\n" + + " when * {A, B, and {$count :number offset=$delta} more characters}\n"; + MessageFormatter mfVar = MessageFormatter.builder() + .setPattern(messageVar) + .build(); + assertEquals("test local vars loop", "A", + mfVar.formatToString(Args.of("count", 1, "delta", 2))); + assertEquals("test local vars loop", "A and B", + mfVar.formatToString(Args.of("count", 2, "delta", 2))); + assertEquals("test local vars loop", "A, B, and 1 more character", + mfVar.formatToString(Args.of("count", 3, "delta", 2))); + assertEquals("test local vars loop", "A, B, and 5 more characters", + mfVar.formatToString(Args.of("count", 7, "delta", 2))); + + String messageVar2 = "" + + "match {$count :plural offset=$delta}\n" + + " when 1 {Exactly 1}\n" + + " when 2 {Exactly 2}\n" + + " when * {Count = {$count :number offset=$delta} and delta={$delta}.}\n"; + MessageFormatter mfVar2 = MessageFormatter.builder() + .setPattern(messageVar2) + .build(); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 0))); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 1))); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 2))); + + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 0))); + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 1))); + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 2))); + + assertEquals("test local vars loop", "Count = 3 and delta=0.", + mfVar2.formatToString(Args.of("count", 3, "delta", 0))); + assertEquals("test local vars loop", "Count = 2 and delta=1.", + mfVar2.formatToString(Args.of("count", 3, "delta", 1))); + assertEquals("test local vars loop", "Count = 1 and delta=2.", + mfVar2.formatToString(Args.of("count", 3, "delta", 2))); + + assertEquals("test local vars loop", "Count = 23 and delta=0.", + mfVar2.formatToString(Args.of("count", 23, "delta", 0))); + assertEquals("test local vars loop", "Count = 22 and delta=1.", + mfVar2.formatToString(Args.of("count", 23, "delta", 1))); + assertEquals("test local vars loop", "Count = 21 and delta=2.", + mfVar2.formatToString(Args.of("count", 23, "delta", 2))); + } + + @Test + public void testVariableOptionsInSelectorWithLocalVar() { + String messageFix = "" + + "let $offCount = {$count :number offset=2}" + + "match {$offCount :plural}\n" + + " when 1 {A}\n" + + " when 2 {A and B}\n" + + " when one {A, B, and {$offCount} more character}\n" + + " when * {A, B, and {$offCount} more characters}\n"; + MessageFormatter mfFix = MessageFormatter.builder() + .setPattern(messageFix) + .build(); + assertEquals("test local vars loop", "A", mfFix.formatToString(Args.of("count", 1))); + assertEquals("test local vars loop", "A and B", mfFix.formatToString(Args.of("count", 2))); + assertEquals("test local vars loop", "A, B, and 1 more character", mfFix.formatToString(Args.of("count", 3))); + assertEquals("test local vars loop", "A, B, and 5 more characters", mfFix.formatToString(Args.of("count", 7))); + + String messageVar = "" + + "let $offCount = {$count :number offset=$delta}" + + "match {$offCount :plural}\n" + + " when 1 {A}\n" + + " when 2 {A and B}\n" + + " when one {A, B, and {$offCount} more character}\n" + + " when * {A, B, and {$offCount} more characters}\n"; + MessageFormatter mfVar = MessageFormatter.builder() + .setPattern(messageVar) + .build(); + assertEquals("test local vars loop", "A", + mfVar.formatToString(Args.of("count", 1, "delta", 2))); + assertEquals("test local vars loop", "A and B", + mfVar.formatToString(Args.of("count", 2, "delta", 2))); + assertEquals("test local vars loop", "A, B, and 1 more character", + mfVar.formatToString(Args.of("count", 3, "delta", 2))); + assertEquals("test local vars loop", "A, B, and 5 more characters", + mfVar.formatToString(Args.of("count", 7, "delta", 2))); + + String messageVar2 = "" + + "let $offCount = {$count :number offset=$delta}" + + "match {$offCount :plural}\n" + + " when 1 {Exactly 1}\n" + + " when 2 {Exactly 2}\n" + + " when * {Count = {$count}, OffCount = {$offCount}, and delta={$delta}.}\n"; + MessageFormatter mfVar2 = MessageFormatter.builder() + .setPattern(messageVar2) + .build(); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 0))); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 1))); + assertEquals("test local vars loop", "Exactly 1", + mfVar2.formatToString(Args.of("count", 1, "delta", 2))); + + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 0))); + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 1))); + assertEquals("test local vars loop", "Exactly 2", + mfVar2.formatToString(Args.of("count", 2, "delta", 2))); + + assertEquals("test local vars loop", "Count = 3, OffCount = 3, and delta=0.", + mfVar2.formatToString(Args.of("count", 3, "delta", 0))); + assertEquals("test local vars loop", "Count = 3, OffCount = 2, and delta=1.", + mfVar2.formatToString(Args.of("count", 3, "delta", 1))); + assertEquals("test local vars loop", "Count = 3, OffCount = 1, and delta=2.", + mfVar2.formatToString(Args.of("count", 3, "delta", 2))); + + assertEquals("test local vars loop", "Count = 23, OffCount = 23, and delta=0.", + mfVar2.formatToString(Args.of("count", 23, "delta", 0))); + assertEquals("test local vars loop", "Count = 23, OffCount = 22, and delta=1.", + mfVar2.formatToString(Args.of("count", 23, "delta", 1))); + assertEquals("test local vars loop", "Count = 23, OffCount = 21, and delta=2.", + mfVar2.formatToString(Args.of("count", 23, "delta", 2))); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2FeaturesTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2FeaturesTest.java new file mode 100644 index 000000000000..b12bda11214b --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2FeaturesTest.java @@ -0,0 +1,465 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Date; +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.math.BigDecimal; +import com.ibm.icu.util.Currency; +import com.ibm.icu.util.CurrencyAmount; + +/** + * Trying to show off most of the features in one place. + * + *

It covers the examples in the + * spec document, + * except for the custom formatters ones, which are too verbose and were moved to separate test classes.

+ *

+ */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class Mf2FeaturesTest extends TestFmwk { + + // November 23, 2022 at 7:42:37.123 PM + static final Date TEST_DATE = new Date(1669261357123L); + + @Test + public void testEmptyMessage() { + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{}") + .arguments(Args.NONE) + .expected("") + .build()); + } + + @Test + public void testPlainText() { + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Hello World!}") + .arguments(Args.NONE) + .expected("Hello World!") + .build()); + } + + @Test + public void testPlaceholders() { + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Hello, {$userName}!}") + .arguments(Args.of("userName", "John")) + .expected("Hello, John!") + .build()); + } + + @Test + public void testArgumentMissing() { + // Test to check what happens if an argument name from the placeholder is not found + // We do what the old ICU4J MessageFormat does. + String message = "{Hello {$name}, today is {$today :datetime skeleton=yMMMMdEEEE}.}"; + + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("name", "John", "today", TEST_DATE)) + .expected("Hello John, today is Wednesday, November 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("name", "John")) + .expected("Hello John, today is {$today}.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("today", TEST_DATE)) + .expected("Hello {$name}, today is Wednesday, November 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.NONE) + .expected("Hello {$name}, today is {$today}.") + .build()); + } + + @Test + public void testDefaultLocale() { + String message = "{Date: {$date :datetime skeleton=yMMMMdEEEE}.}"; + String expectedEn = "Date: Wednesday, November 23, 2022."; + String expectedRo = "Date: miercuri, 23 noiembrie 2022."; + Map arguments = Args.of("date", TEST_DATE); + + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(arguments) + .expected(expectedEn) + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(arguments) + .locale("ro") + .expected(expectedRo) + .build()); + + Locale originalLocale = Locale.getDefault(); + Locale.setDefault(Locale.forLanguageTag("ro")); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(arguments) + .locale("en-US") + .expected(expectedEn) + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(arguments) + .expected(expectedRo) + .build()); + Locale.setDefault(originalLocale); + } + + @Test + public void testAllKindOfDates() { + // Default function + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date}.}") + .locale("ro") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23.11.2022, 19:42.") + .build()); + // Default options + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime}.}") + .locale("ro") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23.11.2022, 19:42.") + .build()); + + // Skeleton + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime skeleton=yMMMMd}.}") + .locale("ro-RO") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23 noiembrie 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime skeleton=jm}.}") + .locale("ro-RO") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 19:42.") + .build()); + + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime skeleton=yMMMd}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: Nov 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime skeleton=yMMMdjms}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: Nov 23, 2022, 7:42:37\u202FPM.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime skeleton=jm}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 7:42\u202FPM.") + .build()); + + // Style + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime datestyle=long}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: November 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern( + "{Testing date formatting: {$date :datetime datestyle=medium}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: Nov 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime datestyle=short}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 11/23/22.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime timestyle=long}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 7:42:37\u202FPM PST.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime timestyle=medium}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 7:42:37\u202FPM.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern( + "{Testing date formatting: {$date :datetime timestyle=short}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 7:42\u202FPM.") + .build()); + + // Pattern + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime pattern=(d 'of' MMMM, y 'at' HH:mm)}.}") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23 of November, 2022 at 19:42.") + .build()); + } + + @Test + public void testAllKindOfNumbers() { + double value = 1234567890.97531; + + // From literal values + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{From literal: {(123456789) :number}!}") + .locale("ro") + .arguments(Args.of("val", value)) + .expected("From literal: 123.456.789!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{From literal: {(123456789.531) :number}!}") + .locale("ro") + .arguments(Args.of("val", value)) + .expected("From literal: 123.456.789,531!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{From literal: {(123456789.531) :number}!}") + .locale("my") + .arguments(Args.of("val", value)) + .expected("From literal: \u1041\u1042\u1043,\u1044\u1045\u1046,\u1047\u1048\u1049.\u1045\u1043\u1041!") + .build()); + + // Testing that the detection works for various types (without specifying :number) + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Default double: {$val}!}") + .locale("en-IN") + .arguments(Args.of("val", value)) + .expected("Default double: 1,23,45,67,890.97531!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Default double: {$val}!}") + .locale("ro") + .arguments(Args.of("val", value)) + .expected("Default double: 1.234.567.890,97531!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Default float: {$val}!}") + .locale("ro") + .arguments(Args.of("val", 3.1415926535)) + .expected("Default float: 3,141593!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Default long: {$val}!}") + .locale("ro") + .arguments(Args.of("val", 1234567890123456789L)) + .expected("Default long: 1.234.567.890.123.456.789!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Default number: {$val}!}") + .locale("ro") + .arguments(Args.of("val", new BigDecimal("1234567890123456789.987654321"))) + .expected("Default number: 1.234.567.890.123.456.789,987654!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Price: {$val}}") + .locale("de") + .arguments(Args.of("val", new CurrencyAmount(1234.56, Currency.getInstance("EUR")))) + .expected("Price: 1.234,56\u00A0\u20AC") + .build()); + + // Various skeletons + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, minFraction: {$val :number skeleton=(.00000000*)}!}") + .locale("ro") + .arguments(Args.of("val", value)) + .expected("Skeletons, minFraction: 1.234.567.890,97531000!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, maxFraction: {$val :number skeleton=(.###)}!}") + .locale("ro") + .arguments(Args.of("val", value)) + .expected("Skeletons, maxFraction: 1.234.567.890,975!") + .build()); + // Currency + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, currency: {$val :number skeleton=(currency/EUR)}!}") + .locale("de") + .arguments(Args.of("val", value)) + .expected("Skeletons, currency: 1.234.567.890,98\u00A0\u20AC!") + .build()); + // Currency as a parameter + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, currency: {$val :number skeleton=$skel}!}") + .locale("de") + .arguments(Args.of("val", value, "skel", "currency/EUR")) + .expected("Skeletons, currency: 1.234.567.890,98\u00A0\u20AC!") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, currency: {$val :number skeleton=$skel}!}") + .locale("de") + .arguments(Args.of("val", value, "skel", "currency/JPY")) + .expected("Skeletons, currency: 1.234.567.891\u00A0\u00A5!") + .build()); + + // Various measures + double celsius = 27; + TestUtils.runTestCase(new TestCase.Builder() + .pattern("" + + "let $intl = {$valC :number skeleton=(unit/celsius)}\n" + + "let $us = {$valF :number skeleton=(unit/fahrenheit)}\n" + + "{Temperature: {$intl} ({$us})}") + .locale("ro") + .arguments(Args.of("valC", celsius, "valF", celsius * 9 / 5 + 32)) + .expected("Temperature: 27 \u00B0C (80,6 \u00B0F)") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Height: {$len :number skeleton=(unit/meter)}}") + .locale("ro") + .arguments(Args.of("len", 1.75)) + .expected("Height: 1,75 m") + .build()); + + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Skeletons, currency: {$val :number skeleton=(currency/EUR)}!}") + .locale("de") + .arguments(Args.of("val", value)) + .expected("Skeletons, currency: 1.234.567.890,98\u00A0\u20AC!") + .build()); + } + + @Test + public void testSpecialPluralWithDecimals() { + String message; + message = "let $amount = {$count :number}\n" + + "match {$amount :plural}\n" + + " when 1 {I have {$amount} dollar.}\n" + + " when * {I have {$amount} dollars.}\n"; + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .locale("en-US") + .arguments(Args.of("count", 1)) + .expected("I have 1 dollar.") + .build()); + message = "let $amount = {$count :number skeleton=(.00*)}\n" + + "match {$amount :plural skeleton=(.00*)}\n" + + " when 1 {I have {$amount} dollar.}\n" + + " when * {I have {$amount} dollars.}\n"; + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .locale("en-US") + .arguments(Args.of("count", 1)) + .expected("I have 1.00 dollars.") + .build()); + } + + @Test + public void testDefaultFunctionAndOptions() { + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date}.}") + .locale("ro") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23.11.2022, 19:42.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern("{Testing date formatting: {$date :datetime}.}") + .locale("ro") + .arguments(Args.of("date", TEST_DATE)) + .expected("Testing date formatting: 23.11.2022, 19:42.") + .build()); + } + + @Test + public void testSimpleSelection() { + String message = "match {$count :plural}\n" + + " when 1 {You have one notification.}\n" + + " when * {You have {$count} notifications.}\n"; + + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("count", 1)) + .expected("You have one notification.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("count", 42)) + .expected("You have 42 notifications.") + .build()); + } + + @Test + public void testComplexSelection() { + String message = "" + + "match {$photoCount :plural} {$userGender :select}\n" + + " when 1 masculine {{$userName} added a new photo to his album.}\n" + + " when 1 feminine {{$userName} added a new photo to her album.}\n" + + " when 1 * {{$userName} added a new photo to their album.}\n" + + " when * masculine {{$userName} added {$photoCount} photos to his album.}\n" + + " when * feminine {{$userName} added {$photoCount} photos to her album.}\n" + + " when * * {{$userName} added {$photoCount} photos to their album.}"; + + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 1, "userGender", "masculine", "userName", "John")) + .expected("John added a new photo to his album.") + .build()); + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 1, "userGender", "feminine", "userName", "Anna")) + .expected("Anna added a new photo to her album.") + .build()); + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 1, "userGender", "unknown", "userName", "Anonymous")) + .expected("Anonymous added a new photo to their album.") + .build()); + + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 13, "userGender", "masculine", "userName", "John")) + .expected("John added 13 photos to his album.") + .build()); + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 13, "userGender", "feminine", "userName", "Anna")) + .expected("Anna added 13 photos to her album.") + .build()); + TestUtils.runTestCase(new TestCase.Builder().pattern(message) + .arguments(Args.of("photoCount", 13, "userGender", "unknown", "userName", "Anonymous")) + .expected("Anonymous added 13 photos to their album.") + .build()); + } + + // Local Variables + + @Test + public void testSimpleLocaleVariable() { + TestUtils.runTestCase(new TestCase.Builder() + .pattern("" + + "let $expDate = {$expDate :datetime skeleton=yMMMdE}\n" + + "{Your tickets expire on {$expDate}.}") + .arguments(Args.of("count", 1, "expDate", TEST_DATE)) + .expected("Your tickets expire on Wed, Nov 23, 2022.") + .build()); + } + + @Test + public void testLocaleVariableWithSelect() { + String message = "" + + "let $expDate = {$expDate :datetime skeleton=yMMMdE}\n" + + "match {$count :plural}\n" + + " when 1 {Your ticket expires on {$expDate}.}\n" + + " when * {Your {$count} tickets expire on {$expDate}.}\n"; + + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("count", 1, "expDate", TEST_DATE)) + .expected("Your ticket expires on Wed, Nov 23, 2022.") + .build()); + TestUtils.runTestCase(new TestCase.Builder() + .pattern(message) + .arguments(Args.of("count", 3, "expDate", TEST_DATE)) + .expected("Your 3 tickets expire on Wed, Nov 23, 2022.") + .build()); + } + +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2IcuTest.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2IcuTest.java new file mode 100644 index 000000000000..5ac3f5af904b --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/Mf2IcuTest.java @@ -0,0 +1,137 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.Date; +import java.util.Locale; +import java.util.Map; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import com.ibm.icu.dev.test.TestFmwk; +import com.ibm.icu.message2.MessageFormatter; +import com.ibm.icu.text.MessageFormat; +import com.ibm.icu.util.Calendar; +import com.ibm.icu.util.GregorianCalendar; + +/** + * Ported the unit tests from {@link com.ibm.icu.text.MessageFormat} to show that they work. + * + *

It does not include all the tests for edge cases and error handling, only the ones that show real functionality.

+ */ +@RunWith(JUnit4.class) +@SuppressWarnings("javadoc") +public class Mf2IcuTest extends TestFmwk { + + @Test + public void testSample() { + MessageFormatter form = MessageFormatter.builder() + .setPattern("{There are {$count} files on {$where}}") + .build(); + assertEquals("format", "There are abc files on def", + form.formatToString(Args.of("count", "abc", "where", "def"))); + } + + @Test + public void testStaticFormat() { + Map arguments = Args.of("planet", new Integer(7), "when", new Date(871068000000L), "what", + "a disturbance in the Force"); + + assertEquals("format", "At 12:20:00\u202FPM on Aug 8, 1997, there was a disturbance in the Force on planet 7.", + MessageFormatter.builder() + .setPattern("{At {$when :datetime timestyle=default} on {$when :datetime datestyle=default}, " + + "there was {$what} on planet {$planet :number kind=integer}.}") + .build() + .formatToString(arguments)); + } + + static final int FieldPosition_DONT_CARE = -1; + + @Test + public void testSimpleFormat() { + Map testArgs1 = Args.of("fileCount", new Integer(0), "diskName", "MyDisk"); + Map testArgs2 = Args.of("fileCount", new Integer(1), "diskName", "MyDisk"); + Map testArgs3 = Args.of("fileCount", new Integer(12), "diskName", "MyDisk"); + + MessageFormatter form = MessageFormatter.builder() + .setPattern("{The disk \"{$diskName}\" contains {$fileCount} file(s).}") + .build(); + + assertEquals("format", "The disk \"MyDisk\" contains 0 file(s).", form.formatToString(testArgs1)); + + form.formatToString(testArgs2); + assertEquals("format", "The disk \"MyDisk\" contains 1 file(s).", form.formatToString(testArgs2)); + + form.formatToString(testArgs3); + assertEquals("format", "The disk \"MyDisk\" contains 12 file(s).", form.formatToString(testArgs3)); + } + + @Test + public void testSelectFormatToPattern() { + String pattern = "" + + "match {$userGender :select}\n" + + " when female {{$userName} est all\u00E9e \u00E0 Paris.}" + + " when * {{$userName} est all\u00E9 \u00E0 Paris.}" + ; + + MessageFormatter mf = MessageFormatter.builder() + .setPattern(pattern) + .build(); + assertEquals("old icu test", + "Charlotte est allée à Paris.", + mf.formatToString(Args.of("userName", "Charlotte", "userGender", "female"))); + assertEquals("old icu test", + "Guillaume est allé à Paris.", + mf.formatToString(Args.of("userName", "Guillaume", "userGender", "male"))); + assertEquals("old icu test", + "Dominique est allé à Paris.", + mf.formatToString(Args.of("userName", "Dominique", "userGender", "unnown"))); + } + + private static void doTheRealDateTimeSkeletonTesting(Date date, String messagePattern, Locale locale, + String expected) { + + MessageFormatter msgf = MessageFormatter.builder() + .setPattern(messagePattern).setLocale(locale) + .build(); + assertEquals(messagePattern, expected, msgf.formatToString(Args.of("when", date))); + } + + @Test + public void testMessageFormatDateTimeSkeleton() { + Date date = new GregorianCalendar(2021, Calendar.NOVEMBER, 23, 16, 42, 55).getTime(); + + doTheRealDateTimeSkeletonTesting(date, "{{$when :datetime skeleton=MMMMd}}", + Locale.ENGLISH, "November 23"); + doTheRealDateTimeSkeletonTesting(date, "{{$when :datetime skeleton=yMMMMdjm}}", + Locale.ENGLISH, "November 23, 2021 at 4:42\u202FPM"); + doTheRealDateTimeSkeletonTesting(date, "{{$when :datetime skeleton=( yMMMMd )}}", + Locale.ENGLISH, "November 23, 2021"); + doTheRealDateTimeSkeletonTesting(date, "{{$when :datetime skeleton=yMMMMd}}", + Locale.FRENCH, "23 novembre 2021"); + doTheRealDateTimeSkeletonTesting(date, "{Expiration: {$when :datetime skeleton=yMMM}!}", + Locale.ENGLISH, "Expiration: Nov 2021!"); + doTheRealDateTimeSkeletonTesting(date, "{{$when :datetime pattern=('::'yMMMMd)}}", + Locale.ENGLISH, "::2021November23"); // pattern + } + + @Test + public void checkMf1Behavior() { + Date testDate = new Date(1671782400000L); // 2022-12-23 + Map goodArg = Args.of("user", "John", "today", testDate); + Map badArg = Args.of("userX", "John", "todayX", testDate); + + MessageFormat mf1 = new MessageFormat("Hello {user}, today is {today,date,long}."); + assertEquals("old icu test", "Hello {user}, today is {today}.", mf1.format(badArg)); + assertEquals("old icu test", "Hello John, today is December 23, 2022.", mf1.format(goodArg)); + + MessageFormatter mf2 = MessageFormatter.builder() + .setPattern("{Hello {$user}, today is {$today :datetime datestyle=long}.}") + .build(); + assertEquals("old icu test", "Hello {$user}, today is {$today}.", mf2.formatToString(badArg)); + assertEquals("old icu test", "Hello John, today is December 23, 2022.", mf2.formatToString(goodArg)); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestCase.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestCase.java new file mode 100644 index 000000000000..dfaaffd3ed7a --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestCase.java @@ -0,0 +1,105 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Locale.Category; +import java.util.Map; +import java.util.StringJoiner; + +/** Utility class encapsulating what we need for a simple test. */ +class TestCase { + final String message; + final Locale locale; + final Map arguments; + final String expected; + final boolean ignore; + final String ignoreReason; + final List errors; + + @Override + public String toString() { + StringJoiner result = new StringJoiner(",\n ", "TestCase {\n ", "\n}"); + result.add("message: " + message + "'"); + result.add("locale: '" + locale.toLanguageTag() + "'"); + result.add("arguments: " + arguments); + result.add("expected: '" + expected + "'"); + result.add("ignore: " + ignore); + result.add("ignoreReason: '" + ignoreReason + "'"); + result.add("errors: " + errors); + return result.toString(); + } + + private TestCase(TestCase.Builder builder) { + this.ignore = builder.ignore; + this.message = builder.pattern == null ? "" : builder.pattern; + this.locale = (builder.localeId == null) + ? Locale.getDefault(Category.FORMAT) + : Locale.forLanguageTag(builder.localeId); + this.arguments = builder.arguments == null ? Args.NONE : builder.arguments; + this.expected = builder.expected == null ? "" : builder.expected; + this.errors = builder.errors == null ? new ArrayList() : builder.errors; + this.ignoreReason = builder.ignoreReason == null ? "" : builder.ignoreReason; + } + + static class Builder { + private String pattern; + private String localeId; + private Map arguments; + private String expected; + private boolean ignore = false; + private String ignoreReason; + private List errors; + + public TestCase build() { + return new TestCase(this); + } + + public TestCase.Builder pattern(String pattern) { + this.pattern = pattern; + return this; + } + + public TestCase.Builder patternJs(String patternJs) { + // Ignore the JavaScript stuff + return this; + } + + public TestCase.Builder arguments(Map arguments) { + this.arguments = arguments; + return this; + } + + public TestCase.Builder expected(String expected) { + this.expected = expected; + return this; + } + + public TestCase.Builder errors(String ... errors) { + this.errors = new ArrayList<>(); + this.errors.addAll(Arrays.asList(errors)); + return this; + } + + public TestCase.Builder locale(String localeId) { + this.localeId = localeId; + return this; + } + + public TestCase.Builder ignore() { + this.ignore = true; + this.ignoreReason = ""; + return this; + } + + public TestCase.Builder ignore(String reason) { + this.ignore = true; + this.ignoreReason = reason; + return this; + } + } + } \ No newline at end of file diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestUtils.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestUtils.java new file mode 100644 index 000000000000..087e5141bde0 --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/TestUtils.java @@ -0,0 +1,55 @@ +// © 2022 and later: Unicode, Inc. and others. +// License & terms of use: http://www.unicode.org/copyright.html + +package com.ibm.icu.dev.test.message2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +import org.junit.Ignore; + +import com.ibm.icu.message2.MessageFormatter; +import com.ibm.icu.message2.Mf2FunctionRegistry; + +@Ignore("Utility class, has no test methods.") +/** Utility class, has no test methods. */ +public class TestUtils { + + static void runTestCase(TestCase testCase) { + runTestCase(null, testCase); + } + + static void runTestCase(Mf2FunctionRegistry customFunctionsRegistry, TestCase testCase) { + if (testCase.ignore) { + return; + } + + // We can call the "complete" constructor with null values, but we want to test that + // all constructors work properly. + MessageFormatter.Builder mfBuilder = MessageFormatter.builder() + .setPattern(testCase.message) + .setLocale(testCase.locale); + if (customFunctionsRegistry != null) { + mfBuilder.setFunctionRegistry(customFunctionsRegistry); + } + try { // TODO: expected error + MessageFormatter mf = mfBuilder.build(); + String result = mf.formatToString(testCase.arguments); + if (!testCase.errors.isEmpty()) { + fail(reportCase(testCase) + "\nExpected error, but it didn't happen.\n" + + "Result: '" + result + "'"); + } else { + assertEquals(reportCase(testCase), testCase.expected, result); + } + } catch (IllegalArgumentException | NullPointerException e) { + if (testCase.errors.isEmpty()) { + fail(reportCase(testCase) + "\nNo error was expected here, but it happened:\n" + + e.getMessage()); + } + } + } + + private static String reportCase(TestCase testCase) { + return testCase.toString(); + } +} diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/package.html b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/package.html new file mode 100644 index 000000000000..4a5dc7f05402 --- /dev/null +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/message2/package.html @@ -0,0 +1,11 @@ + + + + + + +Tests for MessageFormat2. + + diff --git a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/serializable/SerializableTestUtility.java b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/serializable/SerializableTestUtility.java index 263228b53531..5773e36223e8 100644 --- a/icu4j/main/tests/core/src/com/ibm/icu/dev/test/serializable/SerializableTestUtility.java +++ b/icu4j/main/tests/core/src/com/ibm/icu/dev/test/serializable/SerializableTestUtility.java @@ -39,6 +39,7 @@ import com.ibm.icu.impl.URLHandler; import com.ibm.icu.math.BigDecimal; import com.ibm.icu.math.MathContext; +import com.ibm.icu.message2.Mf2DataModel; import com.ibm.icu.util.AnnualTimeZoneRule; import com.ibm.icu.util.Calendar; import com.ibm.icu.util.Currency; @@ -762,6 +763,28 @@ public Object[] getTestObjects() { } } + private static class Mf2DataModelOrderedMapHandler implements Handler { + @Override + public Object[] getTestObjects() { + Mf2DataModel.OrderedMap mapWithContent = new Mf2DataModel.OrderedMap<>(); + mapWithContent.put("number", Double.valueOf(3.1416)); + mapWithContent.put("date", new Date()); + mapWithContent.put("string", "testing"); + return new Mf2DataModel.OrderedMap[] { + new Mf2DataModel.OrderedMap(), + mapWithContent + }; + } + + @Override + public boolean hasSameBehavior(Object a, Object b) { + // OrderedMap extends LinkedHashMap, without adding any functionality, nothing to test. + Mf2DataModel.OrderedMap ra = (Mf2DataModel.OrderedMap)a; + Mf2DataModel.OrderedMap rb = (Mf2DataModel.OrderedMap)b; + return ra.equals(rb); + } + } + private static HashMap map = new HashMap(); static { @@ -858,6 +881,8 @@ public Object[] getTestObjects() { map.put("com.ibm.icu.util.ICUUncheckedIOException", new ICUUncheckedIOExceptionHandler()); map.put("com.ibm.icu.util.ICUCloneNotSupportedException", new ICUCloneNotSupportedExceptionHandler()); map.put("com.ibm.icu.util.ICUInputTooLongException", new ICUInputTooLongExceptionHandler()); + + map.put("com.ibm.icu.message2.Mf2DataModel$OrderedMap", new Mf2DataModelOrderedMapHandler()); } /*