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