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 }