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