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