001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      https://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.commons.text;
018
019import java.text.Format;
020import java.text.MessageFormat;
021import java.text.ParsePosition;
022import java.util.ArrayList;
023import java.util.Collection;
024import java.util.Collections;
025import java.util.HashMap;
026import java.util.Locale;
027import java.util.Locale.Category;
028import java.util.Map;
029import java.util.Objects;
030
031import org.apache.commons.lang3.StringUtils;
032import org.apache.commons.text.matcher.StringMatcherFactory;
033
034/**
035 * Extends {@link java.text.MessageFormat} to allow pluggable/additional formatting
036 * options for embedded format elements.  Client code should specify a registry
037 * of {@code FormatFactory} instances associated with {@code String}
038 * format names.  This registry will be consulted when the format elements are
039 * parsed from the message pattern.  In this way custom patterns can be specified,
040 * and the formats supported by {@link java.text.MessageFormat} can be overridden
041 * at the format and/or format style level (see MessageFormat).  A "format element"
042 * embedded in the message pattern is specified (<strong>()?</strong> signifies optionality):<br>
043 * {@code {}<em>argument-number</em><strong>(</strong>{@code ,}<em>format-name</em><b>
044 * (</b>{@code ,}<em>format-style</em><strong>)?)?</strong>{@code }}
045 *
046 * <p>
047 * <em>format-name</em> and <em>format-style</em> values are trimmed of surrounding whitespace
048 * in the manner of {@link java.text.MessageFormat}.  If <em>format-name</em> denotes
049 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@code Format}
050 * matching <em>format-name</em> and <em>format-style</em> is requested from
051 * {@code formatFactoryInstance}.  If this is successful, the {@code Format}
052 * found is used for this format element.
053 * </p>
054 *
055 * <p><strong>NOTICE:</strong> The various subformat mutator methods are considered unnecessary; they exist on the parent
056 * class to allow the type of customization which it is the job of this class to provide in
057 * a configurable fashion.  These methods have thus been disabled and will throw
058 * {@code UnsupportedOperationException} if called.
059 * </p>
060 *
061 * <p>Limitations inherited from {@link java.text.MessageFormat}:</p>
062 * <ul>
063 * <li>When using "choice" subformats, support for nested formatting instructions is limited
064 *     to that provided by the base class.</li>
065 * <li>Thread-safety of {@code Format}s, including {@code MessageFormat} and thus
066 *     {@code ExtendedMessageFormat}, is not guaranteed.</li>
067 * </ul>
068 *
069 * @since 1.0
070 */
071public class ExtendedMessageFormat extends MessageFormat {
072
073    /**
074     * Serializable Object.
075     */
076    private static final long serialVersionUID = -2362048321261811743L;
077
078    /**
079     * The empty string.
080     */
081    private static final String EMPTY_PATTERN = StringUtils.EMPTY;
082
083    /**
084     * A comma.
085     */
086    private static final char START_FMT = ',';
087
088    /**
089     * A right curly bracket.
090     */
091    private static final char END_FE = '}';
092
093    /**
094     * A left curly bracket.
095     */
096    private static final char START_FE = '{';
097
098    /**
099     * A properly escaped character representing a single quote.
100     */
101    private static final char QUOTE = '\'';
102
103    /**
104     * To pattern string.
105     */
106    private String toPattern;
107
108    /**
109     * Our registry of FormatFactory.
110     */
111    private final Map<String, ? extends FormatFactory> registry;
112
113    /**
114     * Constructs a new ExtendedMessageFormat for the default locale.
115     *
116     * @param pattern  the pattern to use, not null
117     * @throws IllegalArgumentException in case of a bad pattern.
118     */
119    public ExtendedMessageFormat(final String pattern) {
120        this(pattern, Locale.getDefault(Category.FORMAT));
121    }
122
123    /**
124     * Constructs a new ExtendedMessageFormat.
125     *
126     * @param pattern  the pattern to use, not null
127     * @param locale  the locale to use, not null
128     * @throws IllegalArgumentException in case of a bad pattern.
129     */
130    public ExtendedMessageFormat(final String pattern, final Locale locale) {
131        this(pattern, locale, null);
132    }
133
134    /**
135     * Constructs a new ExtendedMessageFormat.
136     *
137     * @param pattern  the pattern to use, not null
138     * @param locale  the locale to use, not null
139     * @param registry  the registry of format factories, may be null
140     * @throws IllegalArgumentException in case of a bad pattern.
141     */
142    public ExtendedMessageFormat(final String pattern,
143                                 final Locale locale,
144                                 final Map<String, ? extends FormatFactory> registry) {
145        super(EMPTY_PATTERN);
146        setLocale(locale);
147        this.registry = registry != null
148                ? Collections.unmodifiableMap(new HashMap<>(registry))
149                : null;
150        applyPattern(pattern);
151    }
152
153    /**
154     * Constructs a new ExtendedMessageFormat for the default locale.
155     *
156     * @param pattern  the pattern to use, not null
157     * @param registry  the registry of format factories, may be null
158     * @throws IllegalArgumentException in case of a bad pattern.
159     */
160    public ExtendedMessageFormat(final String pattern,
161                                 final Map<String, ? extends FormatFactory> registry) {
162        this(pattern, Locale.getDefault(Category.FORMAT), registry);
163    }
164
165    /**
166     * Consumes a quoted string, adding it to {@code appendTo} if
167     * specified.
168     *
169     * @param pattern pattern to parse
170     * @param pos current parse position
171     * @param appendTo optional StringBuilder to append
172     */
173    private void appendQuotedString(final String pattern, final ParsePosition pos,
174            final StringBuilder appendTo) {
175        assert pattern.toCharArray()[pos.getIndex()] == QUOTE
176                : "Quoted string must start with quote character";
177
178        // handle quote character at the beginning of the string
179        if (appendTo != null) {
180            appendTo.append(QUOTE);
181        }
182        next(pos);
183
184        final int start = pos.getIndex();
185        final char[] c = pattern.toCharArray();
186        for (int i = pos.getIndex(); i < pattern.length(); i++) {
187            switch (c[pos.getIndex()]) {
188            case QUOTE:
189                next(pos);
190                if (appendTo != null) {
191                    appendTo.append(c, start, pos.getIndex() - start);
192                }
193                return;
194            default:
195                next(pos);
196            }
197        }
198        throw new IllegalArgumentException(
199                "Unterminated quoted string at position " + start);
200    }
201
202    /**
203     * Applies the specified pattern.
204     *
205     * @param pattern String
206     */
207    @Override
208    public final void applyPattern(final String pattern) {
209        if (registry == null) {
210            super.applyPattern(pattern);
211            toPattern = super.toPattern();
212            return;
213        }
214        final ArrayList<Format> foundFormats = new ArrayList<>();
215        final ArrayList<String> foundDescriptions = new ArrayList<>();
216        final StringBuilder stripCustom = new StringBuilder(pattern.length());
217
218        final ParsePosition pos = new ParsePosition(0);
219        final char[] c = pattern.toCharArray();
220        int fmtCount = 0;
221        while (pos.getIndex() < pattern.length()) {
222            switch (c[pos.getIndex()]) {
223            case QUOTE:
224                appendQuotedString(pattern, pos, stripCustom);
225                break;
226            case START_FE:
227                fmtCount++;
228                seekNonWs(pattern, pos);
229                final int start = pos.getIndex();
230                final int index = readArgumentIndex(pattern, next(pos));
231                stripCustom.append(START_FE).append(index);
232                seekNonWs(pattern, pos);
233                Format format = null;
234                String formatDescription = null;
235                if (c[pos.getIndex()] == START_FMT) {
236                    formatDescription = parseFormatDescription(pattern,
237                            next(pos));
238                    format = getFormat(formatDescription);
239                    if (format == null) {
240                        stripCustom.append(START_FMT).append(formatDescription);
241                    }
242                }
243                foundFormats.add(format);
244                foundDescriptions.add(format == null ? null : formatDescription);
245                if (foundFormats.size() != fmtCount) {
246                    throw new IllegalArgumentException("The validated expression is false");
247                }
248                if (foundDescriptions.size() != fmtCount) {
249                    throw new IllegalArgumentException("The validated expression is false");
250                }
251                if (c[pos.getIndex()] != END_FE) {
252                    throw new IllegalArgumentException(
253                            "Unreadable format element at position " + start);
254                }
255                //$FALL-THROUGH$
256            default:
257                stripCustom.append(c[pos.getIndex()]);
258                next(pos);
259            }
260        }
261        super.applyPattern(stripCustom.toString());
262        toPattern = insertFormats(super.toPattern(), foundDescriptions);
263        if (containsElements(foundFormats)) {
264            final Format[] origFormats = getFormats();
265            // only loop over what we know we have, as MessageFormat on Java 1.3
266            // seems to provide an extra format element:
267            int i = 0;
268            for (final Format f : foundFormats) {
269                if (f != null) {
270                    origFormats[i] = f;
271                }
272                i++;
273            }
274            super.setFormats(origFormats);
275        }
276    }
277
278    /**
279     * Tests whether the specified Collection contains non-null elements.
280     * @param coll to check
281     * @return {@code true} if some Object was found, {@code false} otherwise.
282     */
283    private boolean containsElements(final Collection<?> coll) {
284        if (coll == null || coll.isEmpty()) {
285            return false;
286        }
287        return coll.stream().anyMatch(Objects::nonNull);
288    }
289
290    @Override
291    public boolean equals(final Object obj) {
292        if (this == obj) {
293            return true;
294        }
295        if (!super.equals(obj)) {
296            return false;
297        }
298        if (!(obj instanceof ExtendedMessageFormat)) {
299            return false;
300        }
301        final ExtendedMessageFormat other = (ExtendedMessageFormat) obj;
302        return Objects.equals(registry, other.registry) && Objects.equals(toPattern, other.toPattern);
303    }
304
305    /**
306     * Gets a custom format from a format description.
307     *
308     * @param desc String
309     * @return Format
310     */
311    private Format getFormat(final String desc) {
312        if (registry != null) {
313            String name = desc;
314            String args = null;
315            final int i = desc.indexOf(START_FMT);
316            if (i > 0) {
317                name = desc.substring(0, i).trim();
318                args = desc.substring(i + 1).trim();
319            }
320            final FormatFactory factory = registry.get(name);
321            if (factory != null) {
322                return factory.getFormat(name, args, getLocale());
323            }
324        }
325        return null;
326    }
327
328    /**
329     * Consumes quoted string only.
330     *
331     * @param pattern pattern to parse
332     * @param pos current parse position
333     */
334    private void getQuotedString(final String pattern, final ParsePosition pos) {
335        appendQuotedString(pattern, pos, null);
336    }
337
338    @Override
339    public int hashCode() {
340        final int prime = 31;
341        final int result = super.hashCode();
342        return prime * result + Objects.hash(registry, toPattern);
343    }
344
345    /**
346     * Inserts formats back into the pattern for toPattern() support.
347     *
348     * @param pattern source
349     * @param customPatterns The custom patterns to re-insert, if any
350     * @return full pattern
351     */
352    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
353        if (!containsElements(customPatterns)) {
354            return pattern;
355        }
356        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
357        final ParsePosition pos = new ParsePosition(0);
358        int fe = -1;
359        int depth = 0;
360        while (pos.getIndex() < pattern.length()) {
361            final char c = pattern.charAt(pos.getIndex());
362            switch (c) {
363            case QUOTE:
364                appendQuotedString(pattern, pos, sb);
365                break;
366            case START_FE:
367                depth++;
368                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
369                // do not look for custom patterns when they are embedded, e.g. in a choice
370                if (depth == 1) {
371                    fe++;
372                    final String customPattern = customPatterns.get(fe);
373                    if (customPattern != null) {
374                        sb.append(START_FMT).append(customPattern);
375                    }
376                }
377                break;
378            case END_FE:
379                depth--;
380                //$FALL-THROUGH$
381            default:
382                sb.append(c);
383                next(pos);
384            }
385        }
386        return sb.toString();
387    }
388
389    /**
390     * Advances parse position by 1.
391     *
392     * @param pos ParsePosition
393     * @return {@code pos}
394     */
395    private ParsePosition next(final ParsePosition pos) {
396        pos.setIndex(pos.getIndex() + 1);
397        return pos;
398    }
399
400    /**
401     * Parses the format component of a format element.
402     *
403     * @param pattern string to parse
404     * @param pos current parse position
405     * @return Format description String
406     */
407    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
408        final int start = pos.getIndex();
409        seekNonWs(pattern, pos);
410        final int text = pos.getIndex();
411        int depth = 1;
412        while (pos.getIndex() < pattern.length()) {
413            switch (pattern.charAt(pos.getIndex())) {
414            case START_FE:
415                depth++;
416                next(pos);
417                break;
418            case END_FE:
419                depth--;
420                if (depth == 0) {
421                    return pattern.substring(text, pos.getIndex());
422                }
423                next(pos);
424                break;
425            case QUOTE:
426                getQuotedString(pattern, pos);
427                break;
428            default:
429                next(pos);
430                break;
431            }
432        }
433        throw new IllegalArgumentException(
434                "Unterminated format element at position " + start);
435    }
436
437    /**
438     * Reads the argument index from the current format element.
439     *
440     * @param pattern pattern to parse
441     * @param pos current parse position
442     * @return argument index
443     */
444    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
445        final int start = pos.getIndex();
446        seekNonWs(pattern, pos);
447        final StringBuilder result = new StringBuilder();
448        boolean error = false;
449        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
450            char c = pattern.charAt(pos.getIndex());
451            if (Character.isWhitespace(c)) {
452                seekNonWs(pattern, pos);
453                c = pattern.charAt(pos.getIndex());
454                if (c != START_FMT && c != END_FE) {
455                    error = true;
456                    continue;
457                }
458            }
459            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
460                try {
461                    return Integer.parseInt(result.toString());
462                } catch (final NumberFormatException e) { // NOPMD
463                    // we've already ensured only digits, so unless something
464                    // outlandishly large was specified we should be okay.
465                }
466            }
467            error = !Character.isDigit(c);
468            result.append(c);
469        }
470        if (error) {
471            throw new IllegalArgumentException(
472                    "Invalid format argument index at position " + start + ": "
473                            + pattern.substring(start, pos.getIndex()));
474        }
475        throw new IllegalArgumentException(
476                "Unterminated format element at position " + start);
477    }
478
479    /**
480     * Consumes whitespace from the current parse position.
481     *
482     * @param pattern String to read
483     * @param pos current position
484     */
485    private void seekNonWs(final String pattern, final ParsePosition pos) {
486        int len = 0;
487        final char[] buffer = pattern.toCharArray();
488        do {
489            len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
490            pos.setIndex(pos.getIndex() + len);
491        } while (len > 0 && pos.getIndex() < pattern.length());
492    }
493
494    /**
495     * Throws UnsupportedOperationException - see class Javadoc for details.
496     *
497     * @param formatElementIndex format element index
498     * @param newFormat the new format
499     * @throws UnsupportedOperationException always thrown since this isn't
500     *                                       supported by ExtendMessageFormat
501     */
502    @Override
503    public void setFormat(final int formatElementIndex, final Format newFormat) {
504        throw new UnsupportedOperationException();
505    }
506
507    /**
508     * Throws UnsupportedOperationException - see class Javadoc for details.
509     *
510     * @param argumentIndex argument index
511     * @param newFormat the new format
512     * @throws UnsupportedOperationException always thrown since this isn't
513     *                                       supported by ExtendMessageFormat
514     */
515    @Override
516    public void setFormatByArgumentIndex(final int argumentIndex,
517                                         final Format newFormat) {
518        throw new UnsupportedOperationException();
519    }
520
521    /**
522     * Throws UnsupportedOperationException - see class Javadoc for details.
523     *
524     * @param newFormats new formats
525     * @throws UnsupportedOperationException always thrown since this isn't
526     *                                       supported by ExtendMessageFormat
527     */
528    @Override
529    public void setFormats(final Format[] newFormats) {
530        throw new UnsupportedOperationException();
531    }
532
533    /**
534     * Throws UnsupportedOperationException - see class Javadoc for details.
535     *
536     * @param newFormats new formats
537     * @throws UnsupportedOperationException always thrown since this isn't
538     *                                       supported by ExtendMessageFormat
539     */
540    @Override
541    public void setFormatsByArgumentIndex(final Format[] newFormats) {
542        throw new UnsupportedOperationException();
543    }
544
545    /**
546     * {@inheritDoc}
547     */
548    @Override
549    public String toPattern() {
550        return toPattern;
551    }
552}