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