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