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