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