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