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