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