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