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 }