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