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.Iterator; 025import java.util.Locale; 026import java.util.Locale.Category; 027import java.util.Map; 028import java.util.Objects; 029 030import org.apache.commons.lang3.StringUtils; 031import org.apache.commons.text.matcher.StringMatcherFactory; 032 033/** 034 * Extends {@code java.text.MessageFormat} to allow pluggable/additional formatting 035 * options for embedded format elements. Client code should specify a registry 036 * of {@code FormatFactory} instances associated with {@code String} 037 * format names. This registry will be consulted when the format elements are 038 * parsed from the message pattern. In this way custom patterns can be specified, 039 * and the formats supported by {@code java.text.MessageFormat} can be overridden 040 * at the format and/or format style level (see MessageFormat). A "format element" 041 * embedded in the message pattern is specified (<b>()?</b> signifies optionality):<br> 042 * {@code {}<i>argument-number</i><b>(</b>{@code ,}<i>format-name</i><b> 043 * (</b>{@code ,}<i>format-style</i><b>)?)?</b>{@code }} 044 * 045 * <p> 046 * <i>format-name</i> and <i>format-style</i> values are trimmed of surrounding whitespace 047 * in the manner of {@code java.text.MessageFormat}. If <i>format-name</i> denotes 048 * {@code FormatFactory formatFactoryInstance} in {@code registry}, a {@code Format} 049 * matching <i>format-name</i> and <i>format-style</i> is requested from 050 * {@code formatFactoryInstance}. If this is successful, the {@code Format} 051 * found is used for this format element. 052 * </p> 053 * 054 * <p><b>NOTICE:</b> The various subformat mutator methods are considered unnecessary; they exist on the parent 055 * class to allow the type of customization which it is the job of this class to provide in 056 * a configurable fashion. These methods have thus been disabled and will throw 057 * {@code UnsupportedOperationException} if called. 058 * </p> 059 * 060 * <p>Limitations inherited from {@code java.text.MessageFormat}:</p> 061 * <ul> 062 * <li>When using "choice" subformats, support for nested formatting instructions is limited 063 * to that provided by the base class.</li> 064 * <li>Thread-safety of {@code Format}s, including {@code MessageFormat} and thus 065 * {@code ExtendedMessageFormat}, is not guaranteed.</li> 066 * </ul> 067 * 068 * @since 1.0 069 */ 070public class ExtendedMessageFormat extends MessageFormat { 071 072 /** 073 * Serializable Object. 074 */ 075 private static final long serialVersionUID = -2362048321261811743L; 076 077 /** 078 * Our initial seed value for calculating hashes. 079 */ 080 private static final int HASH_SEED = 31; 081 082 /** 083 * The empty string. 084 */ 085 private static final String DUMMY_PATTERN = StringUtils.EMPTY; 086 087 /** 088 * A comma. 089 */ 090 private static final char START_FMT = ','; 091 092 /** 093 * A right side squiggly brace. 094 */ 095 private static final char END_FE = '}'; 096 097 /** 098 * A left side squiggly brace. 099 */ 100 private static final char START_FE = '{'; 101 102 /** 103 * A properly escaped character representing a single quote. 104 */ 105 private static final char QUOTE = '\''; 106 107 /** 108 * To pattern string. 109 */ 110 private String toPattern; 111 112 /** 113 * Our registry of FormatFactory's. 114 */ 115 private final Map<String, ? extends FormatFactory> registry; 116 117 /** 118 * Create a new ExtendedMessageFormat for the default locale. 119 * 120 * @param pattern the pattern to use, not null 121 * @throws IllegalArgumentException in case of a bad pattern. 122 */ 123 public ExtendedMessageFormat(final String pattern) { 124 this(pattern, Locale.getDefault(Category.FORMAT)); 125 } 126 127 /** 128 * Create a new ExtendedMessageFormat. 129 * 130 * @param pattern the pattern to use, not null 131 * @param locale the locale to use, not null 132 * @throws IllegalArgumentException in case of a bad pattern. 133 */ 134 public ExtendedMessageFormat(final String pattern, final Locale locale) { 135 this(pattern, locale, null); 136 } 137 138 /** 139 * Create a new ExtendedMessageFormat for the default locale. 140 * 141 * @param pattern the pattern to use, not null 142 * @param registry the registry of format factories, may be null 143 * @throws IllegalArgumentException in case of a bad pattern. 144 */ 145 public ExtendedMessageFormat(final String pattern, 146 final Map<String, ? extends FormatFactory> registry) { 147 this(pattern, Locale.getDefault(Category.FORMAT), registry); 148 } 149 150 /** 151 * Create a new ExtendedMessageFormat. 152 * 153 * @param pattern the pattern to use, not null 154 * @param locale the locale to use, not null 155 * @param registry the registry of format factories, may be null 156 * @throws IllegalArgumentException in case of a bad pattern. 157 */ 158 public ExtendedMessageFormat(final String pattern, 159 final Locale locale, 160 final Map<String, ? extends FormatFactory> registry) { 161 super(DUMMY_PATTERN); 162 setLocale(locale); 163 this.registry = registry; 164 applyPattern(pattern); 165 } 166 167 /** 168 * {@inheritDoc} 169 */ 170 @Override 171 public String toPattern() { 172 return toPattern; 173 } 174 175 /** 176 * Apply the specified pattern. 177 * 178 * @param pattern String 179 */ 180 @Override 181 public final void applyPattern(final String pattern) { 182 if (registry == null) { 183 super.applyPattern(pattern); 184 toPattern = super.toPattern(); 185 return; 186 } 187 final ArrayList<Format> foundFormats = new ArrayList<>(); 188 final ArrayList<String> foundDescriptions = new ArrayList<>(); 189 final StringBuilder stripCustom = new StringBuilder(pattern.length()); 190 191 final ParsePosition pos = new ParsePosition(0); 192 final char[] c = pattern.toCharArray(); 193 int fmtCount = 0; 194 while (pos.getIndex() < pattern.length()) { 195 switch (c[pos.getIndex()]) { 196 case QUOTE: 197 appendQuotedString(pattern, pos, stripCustom); 198 break; 199 case START_FE: 200 fmtCount++; 201 seekNonWs(pattern, pos); 202 final int start = pos.getIndex(); 203 final int index = readArgumentIndex(pattern, next(pos)); 204 stripCustom.append(START_FE).append(index); 205 seekNonWs(pattern, pos); 206 Format format = null; 207 String formatDescription = null; 208 if (c[pos.getIndex()] == START_FMT) { 209 formatDescription = parseFormatDescription(pattern, 210 next(pos)); 211 format = getFormat(formatDescription); 212 if (format == null) { 213 stripCustom.append(START_FMT).append(formatDescription); 214 } 215 } 216 foundFormats.add(format); 217 foundDescriptions.add(format == null ? null : formatDescription); 218 if (foundFormats.size() != fmtCount) { 219 throw new IllegalArgumentException("The validated expression is false"); 220 } 221 if (foundDescriptions.size() != fmtCount) { 222 throw new IllegalArgumentException("The validated expression is false"); 223 } 224 if (c[pos.getIndex()] != END_FE) { 225 throw new IllegalArgumentException( 226 "Unreadable format element at position " + start); 227 } 228 //$FALL-THROUGH$ 229 default: 230 stripCustom.append(c[pos.getIndex()]); 231 next(pos); 232 } 233 } 234 super.applyPattern(stripCustom.toString()); 235 toPattern = insertFormats(super.toPattern(), foundDescriptions); 236 if (containsElements(foundFormats)) { 237 final Format[] origFormats = getFormats(); 238 // only loop over what we know we have, as MessageFormat on Java 1.3 239 // seems to provide an extra format element: 240 int i = 0; 241 for (final Iterator<Format> it = foundFormats.iterator(); it.hasNext(); i++) { 242 final Format f = it.next(); 243 if (f != null) { 244 origFormats[i] = f; 245 } 246 } 247 super.setFormats(origFormats); 248 } 249 } 250 251 /** 252 * Throws UnsupportedOperationException - see class Javadoc for details. 253 * 254 * @param formatElementIndex format element index 255 * @param newFormat the new format 256 * @throws UnsupportedOperationException always thrown since this isn't 257 * supported by ExtendMessageFormat 258 */ 259 @Override 260 public void setFormat(final int formatElementIndex, final Format newFormat) { 261 throw new UnsupportedOperationException(); 262 } 263 264 /** 265 * Throws UnsupportedOperationException - see class Javadoc for details. 266 * 267 * @param argumentIndex argument index 268 * @param newFormat the new format 269 * @throws UnsupportedOperationException always thrown since this isn't 270 * supported by ExtendMessageFormat 271 */ 272 @Override 273 public void setFormatByArgumentIndex(final int argumentIndex, 274 final Format newFormat) { 275 throw new UnsupportedOperationException(); 276 } 277 278 /** 279 * Throws UnsupportedOperationException - see class Javadoc for details. 280 * 281 * @param newFormats new formats 282 * @throws UnsupportedOperationException always thrown since this isn't 283 * supported by ExtendMessageFormat 284 */ 285 @Override 286 public void setFormats(final Format[] newFormats) { 287 throw new UnsupportedOperationException(); 288 } 289 290 /** 291 * Throws UnsupportedOperationException - see class Javadoc for details. 292 * 293 * @param newFormats new formats 294 * @throws UnsupportedOperationException always thrown since this isn't 295 * supported by ExtendMessageFormat 296 */ 297 @Override 298 public void setFormatsByArgumentIndex(final Format[] newFormats) { 299 throw new UnsupportedOperationException(); 300 } 301 302 /** 303 * Check if this extended message format is equal to another object. 304 * 305 * @param obj the object to compare to 306 * @return true if this object equals the other, otherwise false 307 */ 308 @Override 309 public boolean equals(final Object obj) { 310 if (obj == this) { 311 return true; 312 } 313 if (obj == null) { 314 return false; 315 } 316 if (!Objects.equals(getClass(), obj.getClass())) { 317 return false; 318 } 319 final ExtendedMessageFormat rhs = (ExtendedMessageFormat) obj; 320 if (!Objects.equals(toPattern, rhs.toPattern)) { 321 return false; 322 } 323 if (!super.equals(obj)) { 324 return false; 325 } 326 return Objects.equals(registry, rhs.registry); 327 } 328 329 /** 330 * {@inheritDoc} 331 */ 332 @Override 333 public int hashCode() { 334 int result = super.hashCode(); 335 result = HASH_SEED * result + Objects.hashCode(registry); 336 result = HASH_SEED * result + Objects.hashCode(toPattern); 337 return result; 338 } 339 340 /** 341 * Get a custom format from a format description. 342 * 343 * @param desc String 344 * @return Format 345 */ 346 private Format getFormat(final String desc) { 347 if (registry != null) { 348 String name = desc; 349 String args = null; 350 final int i = desc.indexOf(START_FMT); 351 if (i > 0) { 352 name = desc.substring(0, i).trim(); 353 args = desc.substring(i + 1).trim(); 354 } 355 final FormatFactory factory = registry.get(name); 356 if (factory != null) { 357 return factory.getFormat(name, args, getLocale()); 358 } 359 } 360 return null; 361 } 362 363 /** 364 * Read the argument index from the current format element. 365 * 366 * @param pattern pattern to parse 367 * @param pos current parse position 368 * @return argument index 369 */ 370 private int readArgumentIndex(final String pattern, final ParsePosition pos) { 371 final int start = pos.getIndex(); 372 seekNonWs(pattern, pos); 373 final StringBuilder result = new StringBuilder(); 374 boolean error = false; 375 for (; !error && pos.getIndex() < pattern.length(); next(pos)) { 376 char c = pattern.charAt(pos.getIndex()); 377 if (Character.isWhitespace(c)) { 378 seekNonWs(pattern, pos); 379 c = pattern.charAt(pos.getIndex()); 380 if (c != START_FMT && c != END_FE) { 381 error = true; 382 continue; 383 } 384 } 385 if ((c == START_FMT || c == END_FE) && result.length() > 0) { 386 try { 387 return Integer.parseInt(result.toString()); 388 } catch (final NumberFormatException e) { // NOPMD 389 // we've already ensured only digits, so unless something 390 // outlandishly large was specified we should be okay. 391 } 392 } 393 error = !Character.isDigit(c); 394 result.append(c); 395 } 396 if (error) { 397 throw new IllegalArgumentException( 398 "Invalid format argument index at position " + start + ": " 399 + pattern.substring(start, pos.getIndex())); 400 } 401 throw new IllegalArgumentException( 402 "Unterminated format element at position " + start); 403 } 404 405 /** 406 * Parse the format component of a format element. 407 * 408 * @param pattern string to parse 409 * @param pos current parse position 410 * @return Format description String 411 */ 412 private String parseFormatDescription(final String pattern, final ParsePosition pos) { 413 final int start = pos.getIndex(); 414 seekNonWs(pattern, pos); 415 final int text = pos.getIndex(); 416 int depth = 1; 417 while (pos.getIndex() < pattern.length()) { 418 switch (pattern.charAt(pos.getIndex())) { 419 case START_FE: 420 depth++; 421 next(pos); 422 break; 423 case END_FE: 424 depth--; 425 if (depth == 0) { 426 return pattern.substring(text, pos.getIndex()); 427 } 428 next(pos); 429 break; 430 case QUOTE: 431 getQuotedString(pattern, pos); 432 break; 433 default: 434 next(pos); 435 break; 436 } 437 } 438 throw new IllegalArgumentException( 439 "Unterminated format element at position " + start); 440 } 441 442 /** 443 * Insert formats back into the pattern for toPattern() support. 444 * 445 * @param pattern source 446 * @param customPatterns The custom patterns to re-insert, if any 447 * @return full pattern 448 */ 449 private String insertFormats(final String pattern, final ArrayList<String> customPatterns) { 450 if (!containsElements(customPatterns)) { 451 return pattern; 452 } 453 final StringBuilder sb = new StringBuilder(pattern.length() * 2); 454 final ParsePosition pos = new ParsePosition(0); 455 int fe = -1; 456 int depth = 0; 457 while (pos.getIndex() < pattern.length()) { 458 final char c = pattern.charAt(pos.getIndex()); 459 switch (c) { 460 case QUOTE: 461 appendQuotedString(pattern, pos, sb); 462 break; 463 case START_FE: 464 depth++; 465 sb.append(START_FE).append(readArgumentIndex(pattern, next(pos))); 466 // do not look for custom patterns when they are embedded, e.g. in a choice 467 if (depth == 1) { 468 fe++; 469 final String customPattern = customPatterns.get(fe); 470 if (customPattern != null) { 471 sb.append(START_FMT).append(customPattern); 472 } 473 } 474 break; 475 case END_FE: 476 depth--; 477 //$FALL-THROUGH$ 478 default: 479 sb.append(c); 480 next(pos); 481 } 482 } 483 return sb.toString(); 484 } 485 486 /** 487 * Consume whitespace from the current parse position. 488 * 489 * @param pattern String to read 490 * @param pos current position 491 */ 492 private void seekNonWs(final String pattern, final ParsePosition pos) { 493 int len = 0; 494 final char[] buffer = pattern.toCharArray(); 495 do { 496 len = StringMatcherFactory.INSTANCE.splitMatcher().isMatch(buffer, pos.getIndex(), 0, buffer.length); 497 pos.setIndex(pos.getIndex() + len); 498 } while (len > 0 && pos.getIndex() < pattern.length()); 499 } 500 501 /** 502 * Convenience method to advance parse position by 1. 503 * 504 * @param pos ParsePosition 505 * @return {@code pos} 506 */ 507 private ParsePosition next(final ParsePosition pos) { 508 pos.setIndex(pos.getIndex() + 1); 509 return pos; 510 } 511 512 /** 513 * Consume a quoted string, adding it to {@code appendTo} if 514 * specified. 515 * 516 * @param pattern pattern to parse 517 * @param pos current parse position 518 * @param appendTo optional StringBuilder to append 519 * @return {@code appendTo} 520 */ 521 private StringBuilder appendQuotedString(final String pattern, final ParsePosition pos, 522 final StringBuilder appendTo) { 523 assert pattern.toCharArray()[pos.getIndex()] == QUOTE 524 : "Quoted string must start with quote character"; 525 526 // handle quote character at the beginning of the string 527 if (appendTo != null) { 528 appendTo.append(QUOTE); 529 } 530 next(pos); 531 532 final int start = pos.getIndex(); 533 final char[] c = pattern.toCharArray(); 534 final int lastHold = start; 535 for (int i = pos.getIndex(); i < pattern.length(); i++) { 536 switch (c[pos.getIndex()]) { 537 case QUOTE: 538 next(pos); 539 return appendTo == null ? null : appendTo.append(c, lastHold, 540 pos.getIndex() - lastHold); 541 default: 542 next(pos); 543 } 544 } 545 throw new IllegalArgumentException( 546 "Unterminated quoted string at position " + start); 547 } 548 549 /** 550 * Consume quoted string only. 551 * 552 * @param pattern pattern to parse 553 * @param pos current parse position 554 */ 555 private void getQuotedString(final String pattern, final ParsePosition pos) { 556 appendQuotedString(pattern, pos, null); 557 } 558 559 /** 560 * Learn whether the specified Collection contains non-null elements. 561 * @param coll to check 562 * @return {@code true} if some Object was found, {@code false} otherwise. 563 */ 564 private boolean containsElements(final Collection<?> coll) { 565 if (coll == null || coll.isEmpty()) { 566 return false; 567 } 568 for (final Object name : coll) { 569 if (name != null) { 570 return true; 571 } 572 } 573 return false; 574 } 575}