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.lang3.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.Locale;
25 import java.util.Map;
26 import java.util.Objects;
27
28 import org.apache.commons.lang3.LocaleUtils;
29 import org.apache.commons.lang3.StringUtils;
30 import org.apache.commons.lang3.Validate;
31
32 /**
33 * Extends {@link java.text.MessageFormat} to allow pluggable/additional formatting
34 * options for embedded format elements.
35 * <p>
36 * Client code should specify a registry
37 * of {@link FormatFactory} instances associated with {@link 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):
43 * </p>
44 * <p>
45 * <code>{</code><em>argument-number</em><strong>(</strong>{@code ,}<em>format-name</em><b>
46 * (</b>{@code ,}<em>format-style</em><strong>)?)?</strong><code>}</code>
47 * </p>
48 *
49 * <p>
50 * <em>format-name</em> and <em>format-style</em> values are trimmed of surrounding whitespace
51 * in the manner of {@link java.text.MessageFormat}. If <em>format-name</em> denotes
52 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@link Format}
53 * matching <em>format-name</em> and <em>format-style</em> is requested from
54 * {@code formatFactoryInstance}. If this is successful, the {@link Format}
55 * found is used for this format element.
56 * </p>
57 *
58 * <p>
59 * <strong>NOTICE:</strong> The various subformat mutator methods are considered unnecessary; they exist on the parent
60 * class to allow the type of customization which it is the job of this class to provide in
61 * a configurable fashion. These methods have thus been disabled and will throw
62 * {@link UnsupportedOperationException} if called.
63 * </p>
64 *
65 * <p>
66 * Limitations inherited from {@link java.text.MessageFormat}:
67 * </p>
68 * <ul>
69 * <li>When using "choice" subformats, support for nested formatting instructions is limited
70 * to that provided by the base class.</li>
71 * <li>Thread-safety of {@link Format}s, including {@link MessageFormat} and thus
72 * {@link ExtendedMessageFormat}, is not guaranteed.</li>
73 * </ul>
74 *
75 * @since 2.4
76 * @deprecated As of <a href="https://commons.apache.org/proper/commons-lang/changes-report.html#a3.6">3.6</a>, use Apache Commons Text
77 * <a href="https://commons.apache.org/proper/commons-text/javadocs/api-release/org/apache/commons/text/ExtendedMessageFormat.html">
78 * ExtendedMessageFormat</a>.
79 */
80 @Deprecated
81 public class ExtendedMessageFormat extends MessageFormat {
82
83 private static final long serialVersionUID = -2362048321261811743L;
84 private static final String EMPTY_PATTERN = StringUtils.EMPTY;
85 private static final char START_FMT = ',';
86 private static final char END_FE = '}';
87 private static final char START_FE = '{';
88 private static final char QUOTE = '\'';
89
90 /**
91 * To pattern string.
92 */
93 private String toPattern;
94
95 /**
96 * Our registry of FormatFactory.
97 */
98 private final Map<String, ? extends FormatFactory> registry;
99
100 /**
101 * Create a new ExtendedMessageFormat for the default locale.
102 *
103 * @param pattern The pattern to use, not null
104 * @throws IllegalArgumentException Thrown in case of a bad pattern.
105 */
106 public ExtendedMessageFormat(final String pattern) {
107 this(pattern, Locale.getDefault());
108 }
109
110 /**
111 * Create a new ExtendedMessageFormat.
112 *
113 * @param pattern The pattern to use, not null
114 * @param locale The locale to use, not null
115 * @throws IllegalArgumentException Thrown in case of a bad pattern.
116 */
117 public ExtendedMessageFormat(final String pattern, final Locale locale) {
118 this(pattern, locale, null);
119 }
120
121 /**
122 * Create a new ExtendedMessageFormat.
123 *
124 * @param pattern The pattern to use, not null.
125 * @param locale The locale to use.
126 * @param registry The registry of format factories, may be null.
127 * @throws IllegalArgumentException Thrown in case of a bad pattern.
128 */
129 public ExtendedMessageFormat(final String pattern, final Locale locale, final Map<String, ? extends FormatFactory> registry) {
130 super(EMPTY_PATTERN);
131 setLocale(LocaleUtils.toLocale(locale));
132 this.registry = registry;
133 applyPattern(pattern);
134 }
135
136 /**
137 * Create a new ExtendedMessageFormat for the default locale.
138 *
139 * @param pattern The pattern to use, not null
140 * @param registry The registry of format factories, may be null
141 * @throws IllegalArgumentException Thrown in case of a bad pattern.
142 */
143 public ExtendedMessageFormat(final String pattern, final Map<String, ? extends FormatFactory> registry) {
144 this(pattern, Locale.getDefault(), registry);
145 }
146
147 /**
148 * Consume a quoted string, adding it to {@code appendTo} if
149 * specified.
150 *
151 * @param pattern pattern to parse, as a char array created once by the caller (avoids copying
152 * the entire pattern for every token parsed)
153 * @param pos current parse position
154 * @param appendTo optional StringBuilder to append
155 * @return {@code appendTo}
156 */
157 private StringBuilder appendQuotedString(final char[] pattern, final ParsePosition pos,
158 final StringBuilder appendTo) {
159 assert pattern[pos.getIndex()] == QUOTE :
160 "Quoted string must start with quote character";
161
162 // handle quote character at the beginning of the string
163 if (appendTo != null) {
164 appendTo.append(QUOTE);
165 }
166 next(pos);
167
168 final int start = pos.getIndex();
169 for (int i = pos.getIndex(); i < pattern.length; i++) {
170 if (pattern[pos.getIndex()] == QUOTE) {
171 next(pos);
172 return appendTo == null ? null : appendTo.append(pattern, start,
173 pos.getIndex() - start);
174 }
175 next(pos);
176 }
177 throw new IllegalArgumentException(
178 "Unterminated quoted string at position " + start);
179 }
180
181 /**
182 * Apply the specified pattern.
183 *
184 * @param pattern String
185 */
186 @Override
187 public final void applyPattern(final String pattern) {
188 if (registry == null) {
189 super.applyPattern(pattern);
190 toPattern = super.toPattern();
191 return;
192 }
193 final ArrayList<Format> foundFormats = new ArrayList<>();
194 final ArrayList<String> foundDescriptions = new ArrayList<>();
195 final StringBuilder stripCustom = new StringBuilder(pattern.length());
196
197 final ParsePosition pos = new ParsePosition(0);
198 final char[] c = pattern.toCharArray();
199 int fmtCount = 0;
200 while (pos.getIndex() < pattern.length()) {
201 switch (c[pos.getIndex()]) {
202 case QUOTE:
203 appendQuotedString(c, pos, stripCustom);
204 break;
205 case START_FE:
206 fmtCount++;
207 seekNonWs(c, pos);
208 final int start = pos.getIndex();
209 final int index = readArgumentIndex(pattern, c, next(pos));
210 stripCustom.append(START_FE).append(index);
211 seekNonWs(c, pos);
212 Format format = null;
213 String formatDescription = null;
214 if (c[pos.getIndex()] == START_FMT) {
215 formatDescription = parseFormatDescription(pattern, c,
216 next(pos));
217 format = getFormat(formatDescription);
218 if (format == null) {
219 stripCustom.append(START_FMT).append(formatDescription);
220 }
221 }
222 foundFormats.add(format);
223 foundDescriptions.add(format == null ? null : formatDescription);
224 Validate.isTrue(foundFormats.size() == fmtCount);
225 Validate.isTrue(foundDescriptions.size() == fmtCount);
226 if (c[pos.getIndex()] != END_FE) {
227 throw new IllegalArgumentException(
228 "Unreadable format element at position " + start);
229 }
230 // falls-through
231 default:
232 stripCustom.append(c[pos.getIndex()]);
233 next(pos);
234 }
235 }
236 super.applyPattern(stripCustom.toString());
237 toPattern = insertFormats(super.toPattern(), foundDescriptions);
238 if (containsElements(foundFormats)) {
239 final Format[] origFormats = getFormats();
240 // only loop over what we know we have, as MessageFormat on Java 1.3
241 // seems to provide an extra format element:
242 int i = 0;
243 for (final Format f : foundFormats) {
244 if (f != null) {
245 origFormats[i] = f;
246 }
247 i++;
248 }
249 super.setFormats(origFormats);
250 }
251 }
252
253 /**
254 * Learn whether the specified Collection contains non-null elements.
255 *
256 * @param coll to check
257 * @return {@code true} if some Object was found, {@code false} otherwise.
258 */
259 private boolean containsElements(final Collection<?> coll) {
260 if (coll == null || coll.isEmpty()) {
261 return false;
262 }
263 return coll.stream().anyMatch(Objects::nonNull);
264 }
265
266 @Override
267 public boolean equals(final Object obj) {
268 if (this == obj) {
269 return true;
270 }
271 if (!super.equals(obj) || !(obj instanceof ExtendedMessageFormat)) {
272 return false;
273 }
274 final ExtendedMessageFormat other = (ExtendedMessageFormat) obj;
275 return Objects.equals(registry, other.registry) && Objects.equals(toPattern, other.toPattern);
276 }
277
278 /**
279 * Gets a custom format from a format description.
280 *
281 * @param desc String
282 * @return Format
283 */
284 private Format getFormat(final String desc) {
285 if (registry != null) {
286 String name = desc;
287 String args = null;
288 final int i = desc.indexOf(START_FMT);
289 if (i > 0) {
290 name = desc.substring(0, i).trim();
291 args = desc.substring(i + 1).trim();
292 }
293 final FormatFactory factory = registry.get(name);
294 if (factory != null) {
295 return factory.getFormat(name, args, getLocale());
296 }
297 }
298 return null;
299 }
300
301 /**
302 * Gets to the end of the quoted string by advancing the parse position.
303 *
304 * @param pattern pattern to parse, as a char array created once by the caller
305 * @param pos current parse position
306 */
307 private void getQuotedString(final char[] pattern, final ParsePosition pos) {
308 appendQuotedString(pattern, pos, null);
309 }
310
311 @Override
312 public int hashCode() {
313 final int prime = 31;
314 final int result = super.hashCode();
315 return prime * result + Objects.hash(registry, toPattern);
316 }
317
318 /**
319 * Insert formats back into the pattern for toPattern() support.
320 *
321 * @param pattern source
322 * @param customPatterns The custom patterns to re-insert, if any
323 * @return full pattern
324 */
325 private String insertFormats(final String pattern, final ArrayList<String> customPatterns) {
326 if (!containsElements(customPatterns)) {
327 return pattern;
328 }
329 final StringBuilder sb = new StringBuilder(pattern.length() * 2);
330 final ParsePosition pos = new ParsePosition(0);
331 final char[] chars = pattern.toCharArray();
332 int fe = -1;
333 int depth = 0;
334 while (pos.getIndex() < pattern.length()) {
335 final char c = pattern.charAt(pos.getIndex());
336 switch (c) {
337 case QUOTE:
338 appendQuotedString(chars, pos, sb);
339 break;
340 case START_FE:
341 depth++;
342 sb.append(START_FE).append(readArgumentIndex(pattern, chars, next(pos)));
343 // do not look for custom patterns when they are embedded, e.g. in a choice
344 if (depth == 1) {
345 fe++;
346 final String customPattern = customPatterns.get(fe);
347 if (customPattern != null) {
348 sb.append(START_FMT).append(customPattern);
349 }
350 }
351 break;
352 case END_FE:
353 depth--;
354 // falls-through
355 default:
356 sb.append(c);
357 next(pos);
358 }
359 }
360 return sb.toString();
361 }
362
363 /**
364 * Convenience method to advance parse position by 1
365 *
366 * @param pos ParsePosition
367 * @return {@code pos}
368 */
369 private ParsePosition next(final ParsePosition pos) {
370 pos.setIndex(pos.getIndex() + 1);
371 return pos;
372 }
373
374 /**
375 * Parse the format component of a format element.
376 *
377 * @param pattern string to parse
378 * @param chars the pattern as a char array created once by the caller
379 * @param pos current parse position
380 * @return Format description String
381 */
382 private String parseFormatDescription(final String pattern, final char[] chars, final ParsePosition pos) {
383 final int start = pos.getIndex();
384 seekNonWs(chars, pos);
385 final int text = pos.getIndex();
386 int depth = 1;
387 while (pos.getIndex() < pattern.length()) {
388 switch (pattern.charAt(pos.getIndex())) {
389 case START_FE:
390 depth++;
391 next(pos);
392 break;
393 case END_FE:
394 depth--;
395 if (depth == 0) {
396 return pattern.substring(text, pos.getIndex());
397 }
398 next(pos);
399 break;
400 case QUOTE:
401 getQuotedString(chars, pos);
402 break;
403 default:
404 next(pos);
405 break;
406 }
407 }
408 throw new IllegalArgumentException(
409 "Unterminated format element at position " + start);
410 }
411
412 /**
413 * Reads the argument index from the current format element
414 *
415 * @param pattern pattern to parse
416 * @param chars the pattern as a char array created once by the caller
417 * @param pos current parse position
418 * @return argument index
419 */
420 private int readArgumentIndex(final String pattern, final char[] chars, final ParsePosition pos) {
421 final int start = pos.getIndex();
422 seekNonWs(chars, pos);
423 final StringBuilder result = new StringBuilder();
424 boolean error = false;
425 for (; !error && pos.getIndex() < pattern.length(); next(pos)) {
426 char c = pattern.charAt(pos.getIndex());
427 if (Character.isWhitespace(c)) {
428 seekNonWs(chars, pos);
429 if (pos.getIndex() >= pattern.length()) {
430 break;
431 }
432 c = pattern.charAt(pos.getIndex());
433 if (c != START_FMT && c != END_FE) {
434 error = true;
435 continue;
436 }
437 }
438 if ((c == START_FMT || c == END_FE) && result.length() > 0) {
439 try {
440 return Integer.parseInt(result.toString());
441 } catch (final NumberFormatException ignored) {
442 // we've already ensured only digits, so unless something
443 // outlandishly large was specified we should be okay.
444 }
445 }
446 error = !Character.isDigit(c);
447 result.append(c);
448 }
449 if (error) {
450 throw new IllegalArgumentException("Invalid format argument index at position " + start + ": " + pattern.substring(start, pos.getIndex()));
451 }
452 throw new IllegalArgumentException("Unterminated format element at position " + start);
453 }
454
455 /**
456 * Consume whitespace from the current parse position.
457 *
458 * @param buffer the pattern to read, as a char array created once by the caller (avoids
459 * copying the entire pattern on every call)
460 * @param pos current position
461 */
462 private void seekNonWs(final char[] buffer, final ParsePosition pos) {
463 while (pos.getIndex() < buffer.length) {
464 final int len = StrMatcher.splitMatcher().isMatch(buffer, pos.getIndex());
465 if (len == 0) {
466 break;
467 }
468 pos.setIndex(pos.getIndex() + len);
469 }
470 }
471
472 /**
473 * Sets no format and always throws {@link UnsupportedOperationException}. See the class Javadoc for details.
474 *
475 * @param formatElementIndex format element index
476 * @param newFormat The new format
477 * @throws UnsupportedOperationException Thrown because this operation is not supported.
478 */
479 @Override
480 public void setFormat(final int formatElementIndex, final Format newFormat) {
481 throw new UnsupportedOperationException();
482 }
483
484 /**
485 * Sets no format and always throws {@link UnsupportedOperationException}. See the class Javadoc for details.
486 *
487 * @param argumentIndex argument index
488 * @param newFormat The new format
489 * @throws UnsupportedOperationException Thrown because this operation is not supported.
490 */
491 @Override
492 public void setFormatByArgumentIndex(final int argumentIndex, final Format newFormat) {
493 throw new UnsupportedOperationException();
494 }
495
496 /**
497 * Sets no format and always throws {@link UnsupportedOperationException}. See the class Javadoc for details.
498 *
499 * @param newFormats new formats
500 * @throws UnsupportedOperationException Thrown because this operation is not supported.
501 */
502 @Override
503 public void setFormats(final Format[] newFormats) {
504 throw new UnsupportedOperationException();
505 }
506
507 /**
508 * Sets no format and always throws {@link UnsupportedOperationException}. See the class Javadoc for details.
509 *
510 * @param newFormats new formats
511 * @throws UnsupportedOperationException Thrown because this operation is not supported.
512 */
513 @Override
514 public void setFormatsByArgumentIndex(final Format[] newFormats) {
515 throw new UnsupportedOperationException();
516 }
517
518 /**
519 * {@inheritDoc}
520 */
521 @Override
522 public String toPattern() {
523 return toPattern;
524 }
525 }