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   */
68  public class ExtendedMessageFormat extends MessageFormat {
69      private static final long serialVersionUID = -2362048321261811743L;
70      private static final int HASH_SEED = 31;
71  
72      private static final String DUMMY_PATTERN = "";
73      private static final char START_FMT = ',';
74      private static final char END_FE = '}';
75      private static final char START_FE = '{';
76      private static final char QUOTE = '\'';
77  
78      private String toPattern;
79      private final Map<String, ? extends FormatFactory> registry;
80  
81      /**
82       * Create a new ExtendedMessageFormat for the default locale.
83       *
84       * @param pattern  the pattern to use, not null
85       * @throws IllegalArgumentException in case of a bad pattern.
86       */
87      public ExtendedMessageFormat(final String pattern) {
88          this(pattern, Locale.getDefault());
89      }
90  
91      /**
92       * Create a new ExtendedMessageFormat.
93       *
94       * @param pattern  the pattern to use, not null
95       * @param locale  the locale to use, not null
96       * @throws IllegalArgumentException in case of a bad pattern.
97       */
98      public ExtendedMessageFormat(final String pattern, final Locale locale) {
99          this(pattern, locale, null);
100     }
101 
102     /**
103      * Create a new ExtendedMessageFormat for the default locale.
104      *
105      * @param pattern  the pattern to use, not null
106      * @param registry  the registry of format factories, may be null
107      * @throws IllegalArgumentException in case of a bad pattern.
108      */
109     public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
110         this(pattern, Locale.getDefault(), registry);
111     }
112 
113     /**
114      * Create a new ExtendedMessageFormat.
115      *
116      * @param pattern  the pattern to use, not null
117      * @param locale  the locale to use, not null
118      * @param registry  the registry of format factories, may be null
119      * @throws IllegalArgumentException in case of a bad pattern.
120      */
121     public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
122         super(DUMMY_PATTERN);
123         setLocale(locale);
124         this.registry = registry;
125         applyPattern(pattern);
126     }
127 
128     /**
129      * {@inheritDoc}
130      */
131     @Override
132     public String toPattern() {
133         return toPattern;
134     }
135 
136     /**
137      * Apply the specified pattern.
138      *
139      * @param pattern String
140      */
141     @Override
142     public final void applyPattern(final String pattern) {
143         if (registry == null) {
144             super.applyPattern(pattern);
145             toPattern = super.toPattern();
146             return;
147         }
148         final ArrayList<Format> foundFormats = new ArrayList<Format>();
149         final ArrayList<String> foundDescriptions = new ArrayList<String>();
150         final StringBuilder stripCustom = new StringBuilder(pattern.length());
151 
152         final ParsePosition pos = new ParsePosition(0);
153         final char[] c = pattern.toCharArray();
154         int fmtCount = 0;
155         while (pos.getIndex() < pattern.length()) {
156             switch (c[pos.getIndex()]) {
157             case QUOTE:
158                 appendQuotedString(pattern, pos, stripCustom);
159                 break;
160             case START_FE:
161                 fmtCount++;
162                 seekNonWs(pattern, pos);
163                 final int start = pos.getIndex();
164                 final int index = readArgumentIndex(pattern, next(pos));
165                 stripCustom.append(START_FE).append(index);
166                 seekNonWs(pattern, pos);
167                 Format format = null;
168                 String formatDescription = null;
169                 if (c[pos.getIndex()] == START_FMT) {
170                     formatDescription = parseFormatDescription(pattern,
171                             next(pos));
172                     format = getFormat(formatDescription);
173                     if (format == null) {
174                         stripCustom.append(START_FMT).append(formatDescription);
175                     }
176                 }
177                 foundFormats.add(format);
178                 foundDescriptions.add(format == null ? null : formatDescription);
179                 Validate.isTrue(foundFormats.size() == fmtCount);
180                 Validate.isTrue(foundDescriptions.size() == fmtCount);
181                 if (c[pos.getIndex()] != END_FE) {
182                     throw new IllegalArgumentException(
183                             "Unreadable format element at position " + start);
184                 }
185                 //$FALL-THROUGH$
186             default:
187                 stripCustom.append(c[pos.getIndex()]);
188                 next(pos);
189             }
190         }
191         super.applyPattern(stripCustom.toString());
192         toPattern = insertFormats(super.toPattern(), foundDescriptions);
193         if (containsElements(foundFormats)) {
194             final Format[] origFormats = getFormats();
195             // only loop over what we know we have, as MessageFormat on Java 1.3
196             // seems to provide an extra format element:
197             int i = 0;
198             for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) {
199                 final Format f = it.next();
200                 if (f != null) {
201                     origFormats[i] = f;
202                 }
203             }
204             super.setFormats(origFormats);
205         }
206     }
207 
208     /**
209      * Throws UnsupportedOperationException - see class Javadoc for details.
210      *
211      * @param formatElementIndex format element index
212      * @param newFormat the new format
213      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
214      */
215     @Override
216     public void setFormat(final int formatElementIndex, final Format newFormat) {
217         throw new UnsupportedOperationException();
218     }
219 
220     /**
221      * Throws UnsupportedOperationException - see class Javadoc for details.
222      *
223      * @param argumentIndex argument index
224      * @param newFormat the new format
225      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
226      */
227     @Override
228     public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
229         throw new UnsupportedOperationException();
230     }
231 
232     /**
233      * Throws UnsupportedOperationException - see class Javadoc for details.
234      *
235      * @param newFormats new formats
236      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
237      */
238     @Override
239     public void setFormats(final Format[] newFormats) {
240         throw new UnsupportedOperationException();
241     }
242 
243     /**
244      * Throws UnsupportedOperationException - see class Javadoc for details.
245      *
246      * @param newFormats new formats
247      * @throws UnsupportedOperationException always thrown since this isn't supported by ExtendMessageFormat
248      */
249     @Override
250     public void setFormatsByArgumentIndex(final Format[] newFormats) {
251         throw new UnsupportedOperationException();
252     }
253 
254     /**
255      * Check if this extended message format is equal to another object.
256      *
257      * @param obj the object to compare to
258      * @return true if this object equals the other, otherwise false
259      */
260     @Override
261     public boolean equals(final Object obj) {
262         if (obj == this) {
263             return true;
264         }
265         if (obj == null) {
266             return false;
267         }
268         if (!super.equals(obj)) {
269             return false;
270         }
271         if (ObjectUtils.notEqual(getClass(), obj.getClass())) {
272           return false;
273         }
274         final ExtendedMessageFormat rhs = (ExtendedMessageFormat)obj;
275         if (ObjectUtils.notEqual(toPattern, rhs.toPattern)) {
276             return false;
277         }
278         if (ObjectUtils.notEqual(registry, rhs.registry)) {
279             return false;
280         }
281         return true;
282     }
283 
284     /**
285      * {@inheritDoc}
286      */
287     @SuppressWarnings( "deprecation" ) // ObjectUtils.hashCode(Object) has been deprecated in 3.2
288     @Override
289     public int hashCode() {
290         int result = super.hashCode();
291         result = HASH_SEED * result + ObjectUtils.hashCode(registry);
292         result = HASH_SEED * result + ObjectUtils.hashCode(toPattern);
293         return result;
294     }
295 
296     /**
297      * Get a custom format from a format description.
298      *
299      * @param desc String
300      * @return Format
301      */
302     private Format getFormat(final String desc) {
303         if (registry != null) {
304             String name = desc;
305             String args = null;
306             final int i = desc.indexOf(START_FMT);
307             if (i > 0) {
308                 name = desc.substring(0, i).trim();
309                 args = desc.substring(i + 1).trim();
310             }
311             final FormatFactory factory = registry.get(name);
312             if (factory != null) {
313                 return factory.getFormat(name, args, getLocale());
314             }
315         }
316         return null;
317     }
318 
319     /**
320      * Read the argument index from the current format element
321      *
322      * @param pattern pattern to parse
323      * @param pos current parse position
324      * @return argument index
325      */
326     private int readArgumentIndex(final String pattern, final ParsePosition pos) {
327         final int start = pos.getIndex();
328         seekNonWs(pattern, pos);
329         final StringBuilder result = new StringBuilder();
330         boolean error = false;
331         for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
332             char c = pattern.charAt(pos.getIndex());
333             if (Character.isWhitespace(c)) {
334                 seekNonWs(pattern, pos);
335                 c = pattern.charAt(pos.getIndex());
336                 if (c != START_FMT && c != END_FE) {
337                     error = true;
338                     continue;
339                 }
340             }
341             if ((c == START_FMT || c == END_FE) && result.length() > 0) {
342                 try {
343                     return Integer.parseInt(result.toString());
344                 } catch (final NumberFormatException e) { // NOPMD
345                     // we've already ensured only digits, so unless something
346                     // outlandishly large was specified we should be okay.
347                 }
348             }
349             error = !Character.isDigit(c);
350             result.append(c);
351         }
352         if (error) {
353             throw new IllegalArgumentException(
354                     "Invalid format argument index at position " + start + ": "
355                             + pattern.substring(start, pos.getIndex()));
356         }
357         throw new IllegalArgumentException(
358                 "Unterminated format element at position " + start);
359     }
360 
361     /**
362      * Parse the format component of a format element.
363      *
364      * @param pattern string to parse
365      * @param pos current parse position
366      * @return Format description String
367      */
368     private String parseFormatDescription(final String pattern, final ParsePosition pos) {
369         final int start = pos.getIndex();
370         seekNonWs(pattern, pos);
371         final int text = pos.getIndex();
372         int depth = 1;
373         for (; pos.getIndex() < pattern.length(); next(pos)) {
374             switch (pattern.charAt(pos.getIndex())) {
375             case START_FE:
376                 depth++;
377                 break;
378             case END_FE:
379                 depth--;
380                 if (depth == 0) {
381                     return pattern.substring(text, pos.getIndex());
382                 }
383                 break;
384             case QUOTE:
385                 getQuotedString(pattern, pos);
386                 break;
387             default:
388                 break;
389             }
390         }
391         throw new IllegalArgumentException(
392                 "Unterminated format element at position " + start);
393     }
394 
395     /**
396      * Insert formats back into the pattern for toPattern() support.
397      *
398      * @param pattern source
399      * @param customPatterns The custom patterns to re-insert, if any
400      * @return full pattern
401      */
402     private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
403         if (!containsElements(customPatterns)) {
404             return pattern;
405         }
406         final StringBuilder sb = new StringBuilder(pattern.length() * 2);
407         final ParsePosition pos = new ParsePosition(0);
408         int fe = -1;
409         int depth = 0;
410         while (pos.getIndex() < pattern.length()) {
411             final char c = pattern.charAt(pos.getIndex());
412             switch (c) {
413             case QUOTE:
414                 appendQuotedString(pattern, pos, sb);
415                 break;
416             case START_FE:
417                 depth++;
418                 sb.append(START_FE).append(readArgumentIndex(pattern, next(pos)));
419                 // do not look for custom patterns when they are embedded, e.g. in a choice
420                 if (depth == 1) {
421                     fe++;
422                     final String customPattern = customPatterns.get(fe);
423                     if (customPattern != null) {
424                         sb.append(START_FMT).append(customPattern);
425                     }
426                 }
427                 break;
428             case END_FE:
429                 depth--;
430                 //$FALL-THROUGH$
431             default:
432                 sb.append(c);
433                 next(pos);
434             }
435         }
436         return sb.toString();
437     }
438 
439     /**
440      * Consume whitespace from the current parse position.
441      *
442      * @param pattern String to read
443      * @param pos current position
444      */
445     private void seekNonWs(final String pattern, final ParsePosition pos) {
446         int len = 0;
447         final char[] buffer = pattern.toCharArray();
448         do {
449             len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
450             pos.setIndex(pos.getIndex() + len);
451         } while (len > 0 && pos.getIndex() < pattern.length());
452     }
453 
454     /**
455      * Convenience method to advance parse position by 1
456      *
457      * @param pos ParsePosition
458      * @return <code>pos</code>
459      */
460     private ParsePosition next(final ParsePosition pos) {
461         pos.setIndex(pos.getIndex() + 1);
462         return pos;
463     }
464 
465     /**
466      * Consume a quoted string, adding it to <code>appendTo</code> if
467      * specified.
468      *
469      * @param pattern pattern to parse
470      * @param pos current parse position
471      * @param appendTo optional StringBuilder to append
472      * @return <code>appendTo</code>
473      */
474     private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos,
475             final StringBuilder appendTo) {
476         assert pattern.toCharArray()[pos.getIndex()] == QUOTE : 
477             "Quoted string must start with quote character";
478 
479         // handle quote character at the beginning of the string
480         if(appendTo != null) {
481             appendTo.append(QUOTE);
482         }
483         next(pos);
484 
485         final int start = pos.getIndex();
486         final char[] c = pattern.toCharArray();
487         int lastHold = start;
488         for (int i = pos.getIndex(); i < pattern.length(); i++) {
489             switch (c[pos.getIndex()]) {
490             case QUOTE:
491                 next(pos);
492                 return appendTo == null ? null : appendTo.append(c, lastHold,
493                         pos.getIndex() - lastHold);
494             default:
495                 next(pos);
496             }
497         }
498         throw new IllegalArgumentException(
499                 "Unterminated quoted string at position " + start);
500     }
501 
502     /**
503      * Consume quoted string only
504      *
505      * @param pattern pattern to parse
506      * @param pos current parse position
507      */
508     private void getQuotedString(final String pattern, final ParsePosition pos) {
509         appendQuotedString(pattern, pos, null);
510     }
511 
512     /**
513      * Learn whether the specified Collection contains non-null elements.
514      * @param coll to check
515      * @return <code>true</code> if some Object was found, <code>false</code> otherwise.
516      */
517     private boolean containsElements(final Collection<?> coll) {
518         if (coll == null || coll.isEmpty()) {
519             return false;
520         }
521         for (final Object name : coll) {
522             if (name != null) {
523                 return true;
524             }
525         }
526         return false;
527     }
528 }