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