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 *      http://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 {@code 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 {@code 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 (<b>()?</b> signifies optionality):<br>
043 * {@code {}<i>argument-number</i><b>(</b>{@code ,}<i>format-name</i><b>
044 * (</b>{@code ,}<i>format-style</i><b>)?)?</b>{@code }}
045 *
046 * <p>
047 * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace
048 * in the manner of {@code java.text.MessageFormat}.  If <i>format-name</i> denotes
049 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@code Format}
050 * matching <i>format-name</i> and <i>format-style</i> 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><b>NOTICE:</b> 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 {@code 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     * Our initial seed value for calculating hashes.
080     */
081    private static final int HASH_SEED = 31;
082
083    /**
084     * The empty string.
085     */
086    private static final String DUMMY_PATTERN = StringUtils.EMPTY;
087
088    /**
089     * A comma.
090     */
091    private static final char START_FMT = ',';
092
093    /**
094     * A right side squiggly brace.
095     */
096    private static final char END_FE = '}';
097
098    /**
099     * A left side squiggly brace.
100     */
101    private static final char START_FE = '{';
102
103    /**
104     * A properly escaped character representing a single quote.
105     */
106    private static final char QUOTE = '\'';
107
108    /**
109     * To pattern string.
110     */
111    private String toPattern;
112
113    /**
114     * Our registry of FormatFactory.
115     */
116    private final Map<String, ? extends FormatFactory> registry;
117
118    /**
119     * Constructs a new ExtendedMessageFormat for the default locale.
120     *
121     * @param pattern  the pattern to use, not null
122     * @throws IllegalArgumentException in case of a bad pattern.
123     */
124    public ExtendedMessageFormat(final String pattern) {
125        this(pattern, Locale.getDefault(Category.FORMAT));
126    }
127
128    /**
129     * Constructs a new ExtendedMessageFormat.
130     *
131     * @param pattern  the pattern to use, not null
132     * @param locale  the locale to use, not null
133     * @throws IllegalArgumentException in case of a bad pattern.
134     */
135    public ExtendedMessageFormat(final String pattern, final Locale locale) {
136        this(pattern, locale, null);
137    }
138
139    /**
140     * Constructs a new ExtendedMessageFormat.
141     *
142     * @param pattern  the pattern to use, not null
143     * @param locale  the locale to use, not null
144     * @param registry  the registry of format factories, may be null
145     * @throws IllegalArgumentException in case of a bad pattern.
146     */
147    public ExtendedMessageFormat(final String pattern,
148                                 final Locale locale,
149                                 final Map<String, ? extends FormatFactory> registry) {
150        super(DUMMY_PATTERN);
151        setLocale(locale);
152        this.registry = registry != null
153                ? Collections.unmodifiableMap(new HashMap<>(registry))
154                : null;
155        applyPattern(pattern);
156    }
157
158    /**
159     * Constructs a new ExtendedMessageFormat for the default locale.
160     *
161     * @param pattern  the pattern to use, not null
162     * @param registry  the registry of format factories, may be null
163     * @throws IllegalArgumentException in case of a bad pattern.
164     */
165    public ExtendedMessageFormat(final String pattern,
166                                 final Map<String, ? extends FormatFactory> registry) {
167        this(pattern, Locale.getDefault(Category.FORMAT), registry);
168    }
169
170    /**
171     * Consumes a quoted string, adding it to {@code appendTo} if
172     * specified.
173     *
174     * @param pattern pattern to parse
175     * @param pos current parse position
176     * @param appendTo optional StringBuilder to append
177     */
178    private void appendQuotedString(final String pattern, final ParsePosition pos,
179            final StringBuilder appendTo) {
180        assert pattern.toCharArray()[pos.getIndex()] == QUOTE
181                : "Quoted string must start with quote character";
182
183        // handle quote character at the beginning of the string
184        if (appendTo != null) {
185            appendTo.append(QUOTE);
186        }
187        next(pos);
188
189        final int start = pos.getIndex();
190        final char[] c = pattern.toCharArray();
191        for (int i = pos.getIndex(); i < pattern.length(); i++) {
192            switch (c[pos.getIndex()]) {
193            case QUOTE:
194                next(pos);
195                if (appendTo != null) {
196                    appendTo.append(c, start, pos.getIndex() - start);
197                }
198                return;
199            default:
200                next(pos);
201            }
202        }
203        throw new IllegalArgumentException(
204                "Unterminated quoted string at position " + start);
205    }
206
207    /**
208     * Applies the specified pattern.
209     *
210     * @param pattern String
211     */
212    @Override
213    public final void applyPattern(final String pattern) {
214        if (registry == null) {
215            super.applyPattern(pattern);
216            toPattern = super.toPattern();
217            return;
218        }
219        final ArrayList<Format> foundFormats = new ArrayList<>();
220        final ArrayList<String> foundDescriptions = new ArrayList<>();
221        final StringBuilder stripCustom = new StringBuilder(pattern.length());
222
223        final ParsePosition pos = new ParsePosition(0);
224        final char[] c = pattern.toCharArray();
225        int fmtCount = 0;
226        while (pos.getIndex() < pattern.length()) {
227            switch (c[pos.getIndex()]) {
228            case QUOTE:
229                appendQuotedString(pattern, pos, stripCustom);
230                break;
231            case START_FE:
232                fmtCount++;
233                seekNonWs(pattern, pos);
234                final int start = pos.getIndex();
235                final int index = readArgumentIndex(pattern, next(pos));
236                stripCustom.append(START_FE).append(index);
237                seekNonWs(pattern, pos);
238                Format format = null;
239                String formatDescription = null;
240                if (c[pos.getIndex()] == START_FMT) {
241                    formatDescription = parseFormatDescription(pattern,
242                            next(pos));
243                    format = getFormat(formatDescription);
244                    if (format == null) {
245                        stripCustom.append(START_FMT).append(formatDescription);
246                    }
247                }
248                foundFormats.add(format);
249                foundDescriptions.add(format == null ? null : formatDescription);
250                if (foundFormats.size() != fmtCount) {
251                    throw new IllegalArgumentException("The validated expression is false");
252                }
253                if (foundDescriptions.size() != fmtCount) {
254                    throw new IllegalArgumentException("The validated expression is false");
255                }
256                if (c[pos.getIndex()] != END_FE) {
257                    throw new IllegalArgumentException(
258                            "Unreadable format element at position " + start);
259                }
260                //$FALL-THROUGH$
261            default:
262                stripCustom.append(c[pos.getIndex()]);
263                next(pos);
264            }
265        }
266        super.applyPattern(stripCustom.toString());
267        toPattern = insertFormats(super.toPattern(), foundDescriptions);
268        if (containsElements(foundFormats)) {
269            final Format[] origFormats = getFormats();
270            // only loop over what we know we have, as MessageFormat on Java 1.3
271            // seems to provide an extra format element:
272            int i = 0;
273            for (final Format f : foundFormats) {
274                if (f != null) {
275                    origFormats[i] = f;
276                }
277                i++;
278            }
279            super.setFormats(origFormats);
280        }
281    }
282
283    /**
284     * Tests whether the specified Collection contains non-null elements.
285     * @param coll to check
286     * @return {@code true} if some Object was found, {@code false} otherwise.
287     */
288    private boolean containsElements(final Collection<?> coll) {
289        if (coll == null || coll.isEmpty()) {
290            return false;
291        }
292        return coll.stream().anyMatch(Objects::nonNull);
293    }
294
295    /**
296     * Tests if this extended message format is equal to another object.
297     *
298     * @param obj the object to compare to
299     * @return true if this object equals the other, otherwise false
300     */
301    @Override
302    public boolean equals(final Object obj) {
303        if (obj == this) {
304            return true;
305        }
306        if (obj == null) {
307            return false;
308        }
309        if (!Objects.equals(getClass(), obj.getClass())) {
310          return false;
311        }
312        final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj;
313        if (!Objects.equals(toPattern, rhs.toPattern)) {
314            return false;
315        }
316        if (!super.equals(obj)) {
317            return false;
318        }
319        return Objects.equals(registry, rhs.registry);
320    }
321
322    /**
323     * Gets a custom format from a format description.
324     *
325     * @param desc String
326     * @return Format
327     */
328    private Format getFormat(final String desc) {
329        if (registry != null) {
330            String name = desc;
331            String args = null;
332            final int i = desc.indexOf(START_FMT);
333            if (i > 0) {
334                name = desc.substring(0, i).trim();
335                args = desc.substring(i + 1).trim();
336            }
337            final FormatFactory factory = registry.get(name);
338            if (factory != null) {
339                return factory.getFormat(name, args, getLocale());
340            }
341        }
342        return null;
343    }
344
345    /**
346     * Consumes quoted string only.
347     *
348     * @param pattern pattern to parse
349     * @param pos current parse position
350     */
351    private void getQuotedString(final String pattern, final ParsePosition pos) {
352        appendQuotedString(pattern, pos, null);
353    }
354
355    /**
356     * {@inheritDoc}
357     */
358    @Override
359    public int hashCode() {
360        int result = super.hashCode();
361        result = HASH_SEED * result + Objects.hashCode(registry);
362        result = HASH_SEED * result + Objects.hashCode(toPattern);
363        return result;
364    }
365
366    /**
367     * Inserts formats back into the pattern for toPattern() support.
368     *
369     * @param pattern source
370     * @param customPatterns The custom patterns to re-insert, if any
371     * @return full pattern
372     */
373    private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
374        if (!containsElements(customPatterns)) {
375            return pattern;
376        }
377        final StringBuilder sb = new StringBuilder(pattern.length() * 2);
378        final ParsePosition pos = new ParsePosition(0);
379        int fe = -1;
380        int depth = 0;
381        while (pos.getIndex() < pattern.length()) {
382            final char c = pattern.charAt(pos.getIndex());
383            switch (c) {
384            case QUOTE:
385                appendQuotedString(pattern, pos, sb);
386                break;
387            case START_FE:
388                depth++;
389                sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
390                // do not look for custom patterns when they are embedded, e.g. in a choice
391                if (depth == 1) {
392                    fe++;
393                    final String customPattern = customPatterns.get(fe);
394                    if (customPattern != null) {
395                        sb.append(START_FMT).append(customPattern);
396                    }
397                }
398                break;
399            case END_FE:
400                depth--;
401                //$FALL-THROUGH$
402            default:
403                sb.append(c);
404                next(pos);
405            }
406        }
407        return sb.toString();
408    }
409
410    /**
411     * Advances parse position by 1.
412     *
413     * @param pos ParsePosition
414     * @return {@code pos}
415     */
416    private ParsePosition next(final ParsePosition pos) {
417        pos.setIndex(pos.getIndex() + 1);
418        return pos;
419    }
420
421    /**
422     * Parses the format component of a format element.
423     *
424     * @param pattern string to parse
425     * @param pos current parse position
426     * @return Format description String
427     */
428    private String parseFormatDescription(final String pattern, final ParsePosition pos) {
429        final int start = pos.getIndex();
430        seekNonWs(pattern, pos);
431        final int text = pos.getIndex();
432        int depth = 1;
433        while (pos.getIndex() < pattern.length()) {
434            switch (pattern.charAt(pos.getIndex())) {
435            case START_FE:
436                depth++;
437                next(pos);
438                break;
439            case END_FE:
440                depth--;
441                if (depth == 0) {
442                    return pattern.substring(text, pos.getIndex());
443                }
444                next(pos);
445                break;
446            case QUOTE:
447                getQuotedString(pattern, pos);
448                break;
449            default:
450                next(pos);
451                break;
452            }
453        }
454        throw new IllegalArgumentException(
455                "Unterminated format element at position " + start);
456    }
457
458    /**
459     * Reads the argument index from the current format element.
460     *
461     * @param pattern pattern to parse
462     * @param pos current parse position
463     * @return argument index
464     */
465    private int readArgumentIndex(final String pattern, final ParsePosition pos) {
466        final int start = pos.getIndex();
467        seekNonWs(pattern, pos);
468        final StringBuilder result = new StringBuilder();
469        boolean error = false;
470        for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
471            char c = pattern.charAt(pos.getIndex());
472            if (Character.isWhitespace(c)) {
473                seekNonWs(pattern, pos);
474                c = pattern.charAt(pos.getIndex());
475                if (c != START_FMT && c != END_FE) {
476                    error = true;
477                    continue;
478                }
479            }
480            if ((c == START_FMT || c == END_FE) && result.length() > 0) {
481                try {
482                    return Integer.parseInt(result.toString());
483                } catch (final NumberFormatException e) { // NOPMD
484                    // we've already ensured only digits, so unless something
485                    // outlandishly large was specified we should be okay.
486                }
487            }
488            error = !Character.isDigit(c);
489            result.append(c);
490        }
491        if (error) {
492            throw new IllegalArgumentException(
493                    "Invalid format argument index at position " + start + ": "
494                            + pattern.substring(start, pos.getIndex()));
495        }
496        throw new IllegalArgumentException(
497                "Unterminated format element at position " + start);
498    }
499
500    /**
501     * Consumes whitespace from the current parse position.
502     *
503     * @param pattern String to read
504     * @param pos current position
505     */
506    private void seekNonWs(final String pattern, final ParsePosition pos) {
507        int len = 0;
508        final char[] buffer = pattern.toCharArray();
509        do {
510            len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length);
511            pos.setIndex(pos.getIndex() + len);
512        } while (len > 0 && pos.getIndex() < pattern.length());
513    }
514
515    /**
516     * Throws UnsupportedOperationException - see class Javadoc for details.
517     *
518     * @param formatElementIndex format element index
519     * @param newFormat the new format
520     * @throws UnsupportedOperationException always thrown since this isn't
521     *                                       supported by ExtendMessageFormat
522     */
523    @Override
524    public void setFormat(final int formatElementIndex, final Format newFormat) {
525        throw new UnsupportedOperationException();
526    }
527
528    /**
529     * Throws UnsupportedOperationException - see class Javadoc for details.
530     *
531     * @param argumentIndex argument index
532     * @param newFormat the new format
533     * @throws UnsupportedOperationException always thrown since this isn't
534     *                                       supported by ExtendMessageFormat
535     */
536    @Override
537    public void setFormatByArgumentIndex(final int argumentIndex,
538                                         final Format newFormat) {
539        throw new UnsupportedOperationException();
540    }
541
542    /**
543     * Throws UnsupportedOperationException - see class Javadoc for details.
544     *
545     * @param newFormats new formats
546     * @throws UnsupportedOperationException always thrown since this isn't
547     *                                       supported by ExtendMessageFormat
548     */
549    @Override
550    public void setFormats(final Format[] newFormats) {
551        throw new UnsupportedOperationException();
552    }
553
554    /**
555     * Throws UnsupportedOperationException - see class Javadoc for details.
556     *
557     * @param newFormats new formats
558     * @throws UnsupportedOperationException always thrown since this isn't
559     *                                       supported by ExtendMessageFormat
560     */
561    @Override
562    public void setFormatsByArgumentIndex(final Format[] newFormats) {
563        throw new UnsupportedOperationException();
564    }
565
566    /**
567     * {@inheritDoc}
568     */
569    @Override
570    public String toPattern() {
571        return toPattern;
572    }
573}