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  import org.apache.commons.text.matcher.StringMatcherFactory;
31  
32  /**
33   * Extends <code>java.text.MessageFormat</code> to allow pluggable/additional formatting
34   * options for embedded format elements.  Client code should specify a registry
35   * of <code>FormatFactory</code> instances associated with <code>String</code>
36   * format names.  This registry will be consulted when the format elements are
37   * parsed from the message pattern.  In this way custom patterns can be specified,
38   * and the formats supported by <code>java.text.MessageFormat</code> can be overridden
39   * at the format and/or format style level (see MessageFormat).  A "format element"
40   * embedded in the message pattern is specified (<b>()?</b> signifies optionality):<br>
41   * <code>{</code><i>argument-number</i><b>(</b><code>,</code><i>format-name</i><b>
42   * (</b><code>,</code><i>format-style</i><b>)?)?</b><code>}</code>
43   *
44   * <p>
45   * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace
46   * in the manner of <code>java.text.MessageFormat</code>.  If <i>format-name</i> denotes
47   * <code>FormatFactory formatFactoryInstance</code> in <code>registry</code>, a <code>Format</code>
48   * matching <i>format-name</i> and <i>format-style</i> is requested from
49   * <code>formatFactoryInstance</code>.  If this is successful, the <code>Format</code>
50   * found is used for this format element.
51   * </p>
52   *
53   * <p><b>NOTICE:</b> The various subformat mutator methods are considered unnecessary; they exist on the parent
54   * class to allow the type of customization which it is the job of this class to provide in
55   * a configurable fashion.  These methods have thus been disabled and will throw
56   * <code>UnsupportedOperationException</code> if called.
57   * </p>
58   *
59   * <p>Limitations inherited from <code>java.text.MessageFormat</code>:</p>
60   * <ul>
61   * <li>When using "choice" subformats, support for nested formatting instructions is limited
62   *     to that provided by the base class.</li>
63   * <li>Thread-safety of <code>Format</code>s, including <code>MessageFormat</code> and thus
64   *     <code>ExtendedMessageFormat</code>, is not guaranteed.</li>
65   * </ul>
66   *
67   * @since 1.0
68   */
69  public class ExtendedMessageFormat extends MessageFormat {
70  
71      /**
72       * Serializable Object.
73       */
74      private static final long serialVersionUID = -2362048321261811743L;
75  
76      /**
77       * Our initial seed value for calculating hashes.
78       */
79      private static final int HASH_SEED = 31;
80  
81      /**
82       * The empty string.
83       */
84      private static final String DUMMY_PATTERN = "";
85  
86      /**
87       * A comma.
88       */
89      private static final char START_FMT = ',';
90  
91      /**
92       * A right side squigly brace.
93       */
94      private static final char END_FE = '}';
95  
96      /**
97       * A left side squigly brace.
98       */
99      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 }