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