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