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