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