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