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 1535547 2013-10-24 20:54:34Z bayard $ 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 @Override 293 public int hashCode() { 294 int result = super.hashCode(); 295 result = HASH_SEED * result + ObjectUtils.hashCode(registry); 296 result = HASH_SEED * result + ObjectUtils.hashCode(toPattern); 297 return result; 298 } 299 300 /** 301 * Get a custom format from a format description. 302 * 303 * @param desc String 304 * @return Format 305 */ 306 private Format getFormat(final String desc) { 307 if (registry != null) { 308 String name = desc; 309 String args = null; 310 final int i = desc.indexOf(START_FMT); 311 if (i > 0) { 312 name = desc.substring(0, i).trim(); 313 args = desc.substring(i + 1).trim(); 314 } 315 final FormatFactory factory = registry.get(name); 316 if (factory != null) { 317 return factory.getFormat(name, args, getLocale()); 318 } 319 } 320 return null; 321 } 322 323 /** 324 * Read the argument index from the current format element 325 * 326 * @param pattern pattern to parse 327 * @param pos current parse position 328 * @return argument index 329 */ 330 private int readArgumentIndex(final String pattern, final ParsePosition pos) { 331 final int start = pos.getIndex(); 332 seekNonWs(pattern, pos); 333 final StringBuilder result = new StringBuilder(); 334 boolean error = false; 335 for (; !error && pos.getIndex() < pattern.length(); next(pos)) { 336 char c = pattern.charAt(pos.getIndex()); 337 if (Character.isWhitespace(c)) { 338 seekNonWs(pattern, pos); 339 c = pattern.charAt(pos.getIndex()); 340 if (c != START_FMT && c != END_FE) { 341 error = true; 342 continue; 343 } 344 } 345 if ((c == START_FMT || c == END_FE) && result.length() > 0) { 346 try { 347 return Integer.parseInt(result.toString()); 348 } catch (final NumberFormatException e) { // NOPMD 349 // we've already ensured only digits, so unless something 350 // outlandishly large was specified we should be okay. 351 } 352 } 353 error = !Character.isDigit(c); 354 result.append(c); 355 } 356 if (error) { 357 throw new IllegalArgumentException( 358 "Invalid format argument index at position " + start + ": " 359 + pattern.substring(start, pos.getIndex())); 360 } 361 throw new IllegalArgumentException( 362 "Unterminated format element at position " + start); 363 } 364 365 /** 366 * Parse the format component of a format element. 367 * 368 * @param pattern string to parse 369 * @param pos current parse position 370 * @return Format description String 371 */ 372 private String parseFormatDescription(final String pattern, final ParsePosition pos) { 373 final int start = pos.getIndex(); 374 seekNonWs(pattern, pos); 375 final int text = pos.getIndex(); 376 int depth = 1; 377 for (; pos.getIndex() < pattern.length(); next(pos)) { 378 switch (pattern.charAt(pos.getIndex())) { 379 case START_FE: 380 depth++; 381 break; 382 case END_FE: 383 depth--; 384 if (depth == 0) { 385 return pattern.substring(text, pos.getIndex()); 386 } 387 break; 388 case QUOTE: 389 getQuotedString(pattern, pos, false); 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}