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