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