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}