View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      http://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  package org.apache.commons.lang3.text;
18  
19  import java.text.Format;
20  import java.text.MessageFormat;
21  import java.text.ParsePosition;
22  import java.util.ArrayList;
23  import java.util.Collection;
24  import java.util.Iterator;
25  import java.util.Locale;
26  import java.util.Map;
27  
28  import org.apache.commons.lang3.ObjectUtils;
29  import org.apache.commons.lang3.Validate;
30  
31  /**
32   * Extends <code>java.text.MessageFormat</code> to allow pluggable/additional formatting
33   * options for embedded format elements.  Client code should specify a registry
34   * of <code>FormatFactory</code> instances associated with <code>String</code>
35   * format names.  This registry will be consulted when the format elements are
36   * parsed from the message pattern.  In this way custom patterns can be specified,
37   * and the formats supported by <code>java.text.MessageFormat</code> can be overridden
38   * at the format and/or format style level (see MessageFormat).  A "format element"
39   * embedded in the message pattern is specified (<b>()?</b> signifies optionality):<br>
40   * <code>{</code><i>argument-number</i><b>(</b><code>,</code><i>format-name</i><b>
41   * (</b><code>,</code><i>format-style</i><b>)?)?</b><code>}</code>
42   *
43   * <p>
44   * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace
45   * in the manner of <code>java.text.MessageFormat</code>.  If <i>format-name</i> denotes
46   * <code>FormatFactory formatFactoryInstance</code> in <code>registry</code>, a <code>Format</code>
47   * matching <i>format-name</i> and <i>format-style</i> is requested from
48   * <code>formatFactoryInstance</code>.  If this is successful, the <code>Format</code>
49   * found is used for this format element.
50   * </p>
51   *
52   * <p><b>NOTICE:</b> The various subformat mutator methods are considered unnecessary; they exist on the parent
53   * class to allow the type of customization which it is the job of this class to provide in
54   * a configurable fashion.  These methods have thus been disabled and will throw
55   * <code>UnsupportedOperationException</code> if called.
56   * </p>
57   *
58   * <p>Limitations inherited from <code>java.text.MessageFormat</code>:</p>
59   * <ul>
60   * <li>When using "choice" subformats, support for nested formatting instructions is limited
61   *     to that provided by the base class.</li>
62   * <li>Thread-safety of <code>Format</code>s, including <code>MessageFormat</code> and thus
63   *     <code>ExtendedMessageFormat</code>, is not guaranteed.</li>
64   * </ul>
65   *
66   * @since 2.4
67   * @version $Id: ExtendedMessageFormat.java 1585282 2014-04-06 10:43:47Z britter $
68   */
69  public class ExtendedMessageFormat extends MessageFormat {
70      private static final long serialVersionUID = -2362048321261811743L;
71      private static final int HASH_SEED = 31;
72  
73      private static final String DUMMY_PATTERN = "";
74      private static final String ESCAPED_QUOTE = "''";
75      private static final char START_FMT = ',';
76      private static final char END_FE = '}';
77      private static final char START_FE = '{';
78      private static final char QUOTE = '\'';
79  
80      private String toPattern;
81      private final Map<String, ? extends FormatFactory> registry;
82  
83      /**
84       * Create a new ExtendedMessageFormat for the default locale.
85       *
86       * @param pattern  the pattern to use, not null
87       * @throws IllegalArgumentException in case of a bad pattern.
88       */
89      public ExtendedMessageFormat(final String pattern) {
90          this(pattern, Locale.getDefault());
91      }
92  
93      /**
94       * Create a new ExtendedMessageFormat.
95       *
96       * @param pattern  the pattern to use, not null
97       * @param locale  the locale to use, not null
98       * @throws IllegalArgumentException in case of a bad pattern.
99       */
100     public ExtendedMessageFormat(final String pattern, final Locale locale) {
101         this(pattern, locale, null);
102     }
103 
104     /**
105      * Create a new ExtendedMessageFormat for the default locale.
106      *
107      * @param pattern  the pattern to use, not null
108      * @param registry  the registry of format factories, may be null
109      * @throws IllegalArgumentException in case of a bad pattern.
110      */
111     public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
112         this(pattern, Locale.getDefault(), registry);
113     }
114 
115     /**
116      * Create a new ExtendedMessageFormat.
117      *
118      * @param pattern  the pattern to use, not null
119      * @param locale  the locale to use, not null
120      * @param registry  the registry of format factories, may be null
121      * @throws IllegalArgumentException in case of a bad pattern.
122      */
123     public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
124         super(DUMMY_PATTERN);
125         setLocale(locale);
126         this.registry = registry;
127         applyPattern(pattern);
128     }
129 
130     /**
131      * {@inheritDoc}
132      */
133     @Override
134     public String toPattern() {
135         return toPattern;
136     }
137 
138     /**
139      * Apply the specified pattern.
140      *
141      * @param pattern String
142      */
143     @Override
144     public final void applyPattern(final String pattern) {
145         if (registry == null) {
146             super.applyPattern(pattern);
147             toPattern = super.toPattern();
148             return;
149         }
150         final ArrayList<Format> foundFormats = new ArrayList<Format>();
151         final ArrayList<String> foundDescriptions = new ArrayList<String>();
152         final StringBuilder stripCustom = new StringBuilder(pattern.length());
153 
154         final ParsePosition pos = new ParsePosition(0);
155         final char[] c = pattern.toCharArray();
156         int fmtCount = 0;
157         while (pos.getIndex() < pattern.length()) {
158             switch (c[pos.getIndex()]) {
159             case QUOTE:
160                 appendQuotedString(pattern, pos, stripCustom, true);
161                 break;
162             case START_FE:
163                 fmtCount++;
164                 seekNonWs(pattern, pos);
165                 final int start = pos.getIndex();
166                 final int index = readArgumentIndex(pattern, next(pos));
167                 stripCustom.append(START_FE).append(index);
168                 seekNonWs(pattern, pos);
169                 Format format = null;
170                 String formatDescription = null;
171                 if (c[pos.getIndex()] == START_FMT) {
172                     formatDescription = parseFormatDescription(pattern,
173                             next(pos));
174                     format = getFormat(formatDescription);
175                     if (format == null) {
176                         stripCustom.append(START_FMT).append(formatDescription);
177                     }
178                 }
179                 foundFormats.add(format);
180                 foundDescriptions.add(format == null ? null : formatDescription);
181                 Validate.isTrue(foundFormats.size() == fmtCount);
182                 Validate.isTrue(foundDescriptions.size() == fmtCount);
183                 if (c[pos.getIndex()] != END_FE) {
184                     throw new IllegalArgumentException(
185                             "Unreadable format element at position " + start);
186                 }
187                 //$FALL-THROUGH$
188             default:
189                 stripCustom.append(c[pos.getIndex()]);
190                 next(pos);
191             }
192         }
193         super.applyPattern(stripCustom.toString());
194         toPattern = insertFormats(super.toPattern(), foundDescriptions);
195         if (containsElements(foundFormats)) {
196             final Format[] origFormats = getFormats();
197             // only loop over what we know we have, as MessageFormat on Java 1.3
198             // seems to provide an extra format element:
199             int i = 0;
200             for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
201                 final Format f = it.next();
202                 if (f != null) {
203                     origFormats[i] = f;
204                 }
205             }
206             super.setFormats(origFormats);
207         }
208     }
209 
210     /**
211      * Throws UnsupportedOperationException - see class Javadoc for details.
212      *
213      * @param formatElementIndex format element index
214      * @param newFormat the new format
215      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
216      */
217     @Override
218     public void setFormat(final int formatElementIndex, final Format newFormat) {
219         throw new UnsupportedOperationException();
220     }
221 
222     /**
223      * Throws UnsupportedOperationException - see class Javadoc for details.
224      *
225      * @param argumentIndex argument index
226      * @param newFormat the new format
227      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
228      */
229     @Override
230     public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
231         throw new UnsupportedOperationException();
232     }
233 
234     /**
235      * Throws UnsupportedOperationException - see class Javadoc for details.
236      *
237      * @param newFormats new formats
238      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
239      */
240     @Override
241     public void setFormats(final Format[] newFormats) {
242         throw new UnsupportedOperationException();
243     }
244 
245     /**
246      * Throws UnsupportedOperationException - see class Javadoc for details.
247      *
248      * @param newFormats new formats
249      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
250      */
251     @Override
252     public void setFormatsByArgumentIndex(final Format[] newFormats) {
253         throw new UnsupportedOperationException();
254     }
255 
256     /**
257      * Check if this extended message format is equal to another object.
258      *
259      * @param obj the object to compare to
260      * @return true if this object equals the other, otherwise false
261      */
262     @Override
263     public boolean equals(final Object obj) {
264         if (obj == this) {
265             return true;
266         }
267         if (obj == null) {
268             return false;
269         }
270         if (!super.equals(obj)) {
271             return false;
272         }
273         if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
274           return false;
275         }
276         final ExtendedMessageFormat rhs = (ExtendedMessageFormat)obj;
277         if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
278             return false;
279         }
280         if (ObjectUtils.notEqual(registry, rhs.registry)) {
281             return false;
282         }
283         return true;
284     }
285 
286     /**
287      * {@inheritDoc}
288      */
289     @SuppressWarnings( "deprecation" ) // ObjectUtils.hashCode(Object) has been deprecated in 3.2
290     @Override
291     public int hashCode() {
292         int result = super.hashCode();
293         result = HASH_SEED * result + ObjectUtils.hashCode(registry);
294         result = HASH_SEED * result + ObjectUtils.hashCode(toPattern);
295         return result;
296     }
297 
298     /**
299      * Get a custom format from a format description.
300      *
301      * @param desc String
302      * @return Format
303      */
304     private Format getFormat(final String desc) {
305         if (registry != null) {
306             String name = desc;
307             String args = null;
308             final int i = desc.indexOf(START_FMT);
309             if (i > 0) {
310                 name = desc.substring(0, i).trim();
311                 args = desc.substring(i + 1).trim();
312             }
313             final FormatFactory factory = registry.get(name);
314             if (factory != null) {
315                 return factory.getFormat(name, args, getLocale());
316             }
317         }
318         return null;
319     }
320 
321     /**
322      * Read the argument index from the current format element
323      *
324      * @param pattern pattern to parse
325      * @param pos current parse position
326      * @return argument index
327      */
328     private int readArgumentIndex(final String pattern, final ParsePosition pos) {
329         final int start = pos.getIndex();
330         seekNonWs(pattern, pos);
331         final StringBuilder result = new StringBuilder();
332         boolean error = false;
333         for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
334             char c = pattern.charAt(pos.getIndex());
335             if (Character.isWhitespace(c)) {
336                 seekNonWs(pattern, pos);
337                 c = pattern.charAt(pos.getIndex());
338                 if (c != START_FMT && c != END_FE) {
339                     error = true;
340                     continue;
341                 }
342             }
343             if ((c == START_FMT || c == END_FE) && result.length() > 0) {
344                 try {
345                     return Integer.parseInt(result.toString());
346                 } catch (final NumberFormatException e) { // NOPMD
347                     // we've already ensured only digits, so unless something
348                     // outlandishly large was specified we should be okay.
349                 }
350             }
351             error = !Character.isDigit(c);
352             result.append(c);
353         }
354         if (error) {
355             throw new IllegalArgumentException(
356                     "Invalid format argument index at position " + start + ": "
357                             + pattern.substring(start, pos.getIndex()));
358         }
359         throw new IllegalArgumentException(
360                 "Unterminated format element at position " + start);
361     }
362 
363     /**
364      * Parse the format component of a format element.
365      *
366      * @param pattern string to parse
367      * @param pos current parse position
368      * @return Format description String
369      */
370     private String parseFormatDescription(final String pattern, final ParsePosition pos) {
371         final int start = pos.getIndex();
372         seekNonWs(pattern, pos);
373         final int text = pos.getIndex();
374         int depth = 1;
375         for (; pos.getIndex() < pattern.length(); next(pos)) {
376             switch (pattern.charAt(pos.getIndex())) {
377             case START_FE:
378                 depth++;
379                 break;
380             case END_FE:
381                 depth--;
382                 if (depth == 0) {
383                     return pattern.substring(text, pos.getIndex());
384                 }
385                 break;
386             case QUOTE:
387                 getQuotedString(pattern, pos, false);
388                 break;
389             default:
390                 break;
391             }
392         }
393         throw new IllegalArgumentException(
394                 "Unterminated format element at position " + start);
395     }
396 
397     /**
398      * Insert formats back into the pattern for toPattern() support.
399      *
400      * @param pattern source
401      * @param customPatterns The custom patterns to re-insert, if any
402      * @return full pattern
403      */
404     private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
405         if (!containsElements(customPatterns)) {
406             return pattern;
407         }
408         final StringBuilder sb = new StringBuilder(pattern.length() * 2);
409         final ParsePosition pos = new ParsePosition(0);
410         int fe = -1;
411         int depth = 0;
412         while (pos.getIndex() < pattern.length()) {
413             final char c = pattern.charAt(pos.getIndex());
414             switch (c) {
415             case QUOTE:
416                 appendQuotedString(pattern, pos, sb, false);
417                 break;
418             case START_FE:
419                 depth++;
420                 sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
421                 // do not look for custom patterns when they are embedded, e.g. in a choice
422                 if (depth == 1) {
423                     fe++;
424                     final String customPattern = customPatterns.get(fe);
425                     if (customPattern != null) {
426                         sb.append(START_FMT).append(customPattern);
427                     }
428                 }
429                 break;
430             case END_FE:
431                 depth--;
432                 //$FALL-THROUGH$
433             default:
434                 sb.append(c);
435                 next(pos);
436             }
437         }
438         return sb.toString();
439     }
440 
441     /**
442      * Consume whitespace from the current parse position.
443      *
444      * @param pattern String to read
445      * @param pos current position
446      */
447     private void seekNonWs(final String pattern, final ParsePosition pos) {
448         int len = 0;
449         final char[] buffer = pattern.toCharArray();
450         do {
451             len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
452             pos.setIndex(pos.getIndex() + len);
453         } while (len > 0 && pos.getIndex() < pattern.length());
454     }
455 
456     /**
457      * Convenience method to advance parse position by 1
458      *
459      * @param pos ParsePosition
460      * @return <code>pos</code>
461      */
462     private ParsePosition next(final ParsePosition pos) {
463         pos.setIndex(pos.getIndex() + 1);
464         return pos;
465     }
466 
467     /**
468      * Consume a quoted string, adding it to <code>appendTo</code> if
469      * specified.
470      *
471      * @param pattern pattern to parse
472      * @param pos current parse position
473      * @param appendTo optional StringBuilder to append
474      * @param escapingOn whether to process escaped quotes
475      * @return <code>appendTo</code>
476      */
477     private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
478             final StringBuilder appendTo, final boolean escapingOn) {
479         final int start = pos.getIndex();
480         final char[] c = pattern.toCharArray();
481         if (escapingOn && c[start] == QUOTE) {
482             next(pos);
483             return appendTo == null ? null : appendTo.append(QUOTE);
484         }
485         int lastHold = start;
486         for (int i = pos.getIndex(); i < pattern.length(); i++) {
487             if (escapingOn && pattern.substring(i).startsWith(ESCAPED_QUOTE)) {
488                 appendTo.append(c, lastHold, pos.getIndex() - lastHold).append(
489                         QUOTE);
490                 pos.setIndex(i + ESCAPED_QUOTE.length());
491                 lastHold = pos.getIndex();
492                 continue;
493             }
494             switch (c[pos.getIndex()]) {
495             case QUOTE:
496                 next(pos);
497                 return appendTo == null ? null : appendTo.append(c, lastHold,
498                         pos.getIndex() - lastHold);
499             default:
500                 next(pos);
501             }
502         }
503         throw new IllegalArgumentException(
504                 "Unterminated quoted string at position " + start);
505     }
506 
507     /**
508      * Consume quoted string only
509      *
510      * @param pattern pattern to parse
511      * @param pos current parse position
512      * @param escapingOn whether to process escaped quotes
513      */
514     private void getQuotedString(final String pattern, final ParsePosition pos,
515             final boolean escapingOn) {
516         appendQuotedString(pattern, pos, null, escapingOn);
517     }
518 
519     /**
520      * Learn whether the specified Collection contains non-null elements.
521      * @param coll to check
522      * @return <code>true</code> if some Object was found, <code>false</code> otherwise.
523      */
524     private boolean containsElements(final Collection<?> coll) {
525         if (coll == null || coll.isEmpty()) {
526             return false;
527         }
528         for (final Object name : coll) {
529             if (name != null) {
530                 return true;
531             }
532         }
533         return false;
534     }
535 }