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