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