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