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