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