001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * https://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.commons.text; 018 019import java.text.Format; 020import java.text.MessageFormat; 021import java.text.ParsePosition; 022import java.util.ArrayList; 023import java.util.Collection; 024import java.util.Collections; 025import java.util.HashMap; 026import java.util.Locale; 027import java.util.Locale.Category; 028import java.util.Map; 029import java.util.Objects; 030 031import org.apache.commons.lang3.StringUtils; 032import org.apache.commons.text.matcher.StringMatcherFactory; 033 034/** 035 * Extends {@link java.text.MessageFormat} to allow pluggable/additional formatting 036 * options for embedded format elements. Client code should specify a registry 037 * of {@code FormatFactory} instances associated with {@code String} 038 * format names. This registry will be consulted when the format elements are 039 * parsed from the message pattern. In this way custom patterns can be specified, 040 * and the formats supported by {@link java.text.MessageFormat} can be overridden 041 * at the format and/or format style level (see MessageFormat). A "format element" 042 * embedded in the message pattern is specified (<strong>()?</strong> signifies optionality):<br> 043 * {@code {}<em>argument-number</em><strong>(</strong>{@code ,}<em>format-name</em><b> 044 * (</b>{@code ,}<em>format-style</em><strong>)?)?</strong>{@code }} 045 * 046 * <p> 047 * <em>format-name</em> and <em>format-style</em> values are trimmed of surrounding whitespace 048 * in the manner of {@link java.text.MessageFormat}. If <em>format-name</em> denotes 049 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@code Format} 050 * matching <em>format-name</em> and <em>format-style</em> is requested from 051 * {@code formatFactoryInstance}. If this is successful, the {@code Format} 052 * found is used for this format element. 053 * </p> 054 * 055 * <p><strong>NOTICE:</strong> The various subformat mutator methods are considered unnecessary; they exist on the parent 056 * class to allow the type of customization which it is the job of this class to provide in 057 * a configurable fashion. These methods have thus been disabled and will throw 058 * {@code UnsupportedOperationException} if called. 059 * </p> 060 * 061 * <p>Limitations inherited from {@link java.text.MessageFormat}:</p> 062 * <ul> 063 * <li>When using "choice" subformats, support for nested formatting instructions is limited 064 * to that provided by the base class.</li> 065 * <li>Thread-safety of {@code Format}s, including {@code MessageFormat} and thus 066 * {@code ExtendedMessageFormat}, is not guaranteed.</li> 067 * </ul> 068 * 069 * @since 1.0 070 */ 071public class ExtendedMessageFormat extends MessageFormat { 072 073 /** 074 * Serializable Object. 075 */ 076 private static final long serialVersionUID = -2362048321261811743L; 077 078 /** 079 * The empty string. 080 */ 081 private static final String EMPTY_PATTERN = StringUtils.EMPTY; 082 083 /** 084 * A comma. 085 */ 086 private static final char START_FMT = ','; 087 088 /** 089 * A right curly bracket. 090 */ 091 private static final char END_FE = '}'; 092 093 /** 094 * A left curly bracket. 095 */ 096 private static final char START_FE = '{'; 097 098 /** 099 * 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}