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