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