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