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