001/**
002 * Copyright 2005-2018 The Kuali Foundation
003 *
004 * Licensed under the Educational Community License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.opensource.org/licenses/ecl2.php
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package org.kuali.rice.krad.uif.util;
017
018
019/**
020 * Provides modular support parsing path expressions using Spring's BeanWrapper expression Syntax.
021 * (see <a href=
022 * "http://static.springsource.org/spring/docs/3.2.x/spring-framework-reference/html/validation.html"
023 * >The Spring Manual</a>)
024 * 
025 * @author Kuali Rice Team (rice.collab@kuali.org)
026 */
027public final class ObjectPathExpressionParser {
028
029    /**
030     * Used by {@link #parsePathExpression(Object, String, PathEntry)} to track parse state without
031     * the need to construct a new parser stack for each expression.
032     */
033    private static final ThreadLocal<ParseState> TL_EL_PARSE_STATE = new ThreadLocal<ParseState>();
034
035    /**
036     * Path entry interface for use with
037     * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
038     */
039    public static interface PathEntry {
040
041        /**
042         * Parse one node.
043         * 
044         * @param parentPath The path expression parsed so far.
045         * @param node The current parse node.
046         * @param next The next path token.
047         * @return A reference to the next parse node.
048         */
049        Object parse(String parentPath, Object node, String next);
050
051    }
052
053    /**
054     * Tracks parser state for
055     * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}.
056     */
057    private static final class ParseState {
058
059        /**
060         * The lexical index at which to begin the next lexical scan.
061         */
062        private int nextScanIndex;
063
064        /**
065         * The lexical index of the next path separator token.
066         */
067        private int nextTokenIndex;
068
069        /**
070         * The full original parse string.
071         */
072        private String originalPath;
073
074        /**
075         * The current lexical index in the original path.
076         */
077        private int originalPathIndex;
078
079        /**
080         * The portion of the path parsed so far.
081         */
082        private String parentPath;
083
084        /**
085         * The continuation point of the parse expression currently being evaluation.
086         */
087        private Object currentContinuation;
088
089        /**
090         * Determine if this parse state is active.
091         */
092        private boolean isActive() {
093            return currentContinuation != null;
094        }
095
096        /**
097         * Reset parse state, allowing this state marker to be reused on the next expression.
098         */
099        private void reset() {
100            currentContinuation = null;
101            originalPath = null;
102            originalPathIndex = 0;
103            parentPath = null;
104        }
105
106        /**
107         * Prepare for the next lexical scan.
108         * 
109         * <p>
110         * When a parenthetical expression occurs on the left hand side of the path, remove the
111         * parentheses.
112         * </p>
113         * 
114         * <p>
115         * When using Spring syntax, treat brackets the same as parentheses.
116         * </p>
117         * 
118         * <p>
119         * Upon returning from this method, the value of {@link #nextScanIndex} will point at the
120         * position of the character formerly to the right of the removed parenthetical group, if
121         * applicable. If no parenthetical group was removed, {@link #nextScanIndex} will be reset
122         * to 0.
123         * </p>
124         * 
125         * @param path The path expression from the current continuation point.
126         * @return The path expression, with brackets and quotes related to a collection reference removed.
127         */
128        public String prepareNextScan(String path) {
129            nextScanIndex = 0;
130
131            if (path.length() == 0) {
132                throw new IllegalArgumentException("Unexpected end of input " + parentPath);
133            }
134
135            int endOfCollectionReference = indexOfCloseBracket(path, 0);
136            
137            if (endOfCollectionReference == -1) {
138                return path;
139            }
140
141            // Strip brackets from parse path.
142            StringBuilder pathBuilder = new StringBuilder(path);
143            pathBuilder.deleteCharAt(endOfCollectionReference);
144            pathBuilder.deleteCharAt(0);
145
146            // Also strip quotes from the front/back of the collection reference.
147            char firstChar = pathBuilder.charAt(0);
148            if ((firstChar == '\'' || firstChar == '\"') &&
149                    path.charAt(endOfCollectionReference - 1) == firstChar) {
150                
151                pathBuilder.deleteCharAt(endOfCollectionReference - 2);
152                pathBuilder.deleteCharAt(0);
153            }
154            
155            int diff = path.length() - pathBuilder.length();
156
157            // Step scan index past collection reference, accounting for stripped characters.
158            nextScanIndex += endOfCollectionReference + 1 - diff;
159
160            // Move original path index forward to correct for stripped characters.
161            originalPathIndex += diff;
162
163            return pathBuilder.toString();
164        }
165
166        /**
167         * Update current parse state with the lexical indexes of the next token break.
168         * 
169         * @param path The path being parsed, starting from the current continuation point.
170         */
171        public void scan(String path) {
172            nextTokenIndex = -1;
173
174            // Scan the character sequence, starting with the character following the open marker.
175            for (int currentIndex = nextScanIndex; currentIndex < path.length(); currentIndex++) {
176                switch (path.charAt(currentIndex)) {
177                    case ']':
178                        // should have been removed by prepareNextScan
179                        throw new IllegalArgumentException("Unmatched ']': " + path);
180                        // else fall through
181                    case '[':
182                    case '.':
183                        if (nextTokenIndex == -1) {
184                            nextTokenIndex = currentIndex;
185                        }
186
187                        // Move original path index forward
188                        originalPathIndex += nextTokenIndex;
189                        return;
190                }
191            }
192        }
193
194        /**
195         * Step to the next continuation point in the parse path.
196         * 
197         * <p>
198         * Upon returning from this method, the value of {@link #currentContinuation} will reflect
199         * the resolved state of parsing the path. When null is returned, then
200         * {@link #currentContinuation} will be the reflect the result of parsing the expression.
201         * </p>
202         * 
203         * @param path The path expression from the current continuation point.
204         * @return The path expression for the next continuation point, null if the path has been
205         *         completely parsed.
206         */
207        private String step(String path, PathEntry pathEntry) {
208
209            if (nextTokenIndex == -1) {
210                // Only a symbolic reference, resolve it and return.
211                currentContinuation = pathEntry.parse(parentPath, currentContinuation, path);
212                parentPath = originalPath.substring(0, originalPathIndex);
213                return null;
214            }
215
216            char nextToken = path.charAt(nextTokenIndex);
217
218            switch (nextToken) {
219
220                case '[':
221                    // Approaching a collection reference.
222                    currentContinuation = pathEntry.parse(parentPath, currentContinuation,
223                            path.substring(0, nextTokenIndex));
224                    parentPath = originalPath.substring(0, originalPathIndex);
225                    return path.substring(nextTokenIndex); // Keep the left parenthesis
226
227                case '.':
228                    // Crossing a period, not preceded by a collection reference.
229                    currentContinuation = pathEntry.parse(parentPath, currentContinuation,
230                            path.substring(0, nextTokenIndex));
231
232                    // Step past the period
233                    parentPath = originalPath.substring(0, originalPathIndex++);
234
235                    return path.substring(nextTokenIndex + 1);
236
237                default:
238                    throw new IllegalArgumentException("Unexpected '" + nextToken + "' :" + path);
239            }
240        }
241
242    }
243
244    /**
245     * Return the index of the close bracket that matches the bracket at the start of the path.
246     * 
247     * @param path The string to scan.
248     * @param leftBracketIndex The index of the left bracket.
249     * @return The index of the right bracket that matches the left bracket at index given. If the
250     *         path does not begin with an open bracket, then -1 is returned.
251     * @throw IllegalArgumentException If the left bracket is unmatched by the right bracket in the
252     *        parse string.
253     */
254    public static int indexOfCloseBracket(String path, int leftBracketIndex) {
255        if (path == null || path.length() <= leftBracketIndex || path.charAt(leftBracketIndex) != '[') {
256            return -1;
257        }
258
259        char inQuote = '\0';
260        int pathLen = path.length() - 1;
261        int bracketCount = 1;
262        int currentPos = leftBracketIndex;
263
264        do {
265            char currentChar = path.charAt(++currentPos);
266
267            // Toggle quoted state as applicable.
268            if (inQuote == '\0' && (currentChar == '\'' || currentChar == '\"')) {
269                inQuote = currentChar;
270            } else if (inQuote == currentChar) {
271                inQuote = '\0';
272            }
273
274            // Ignore quoted characters.
275            if (inQuote != '\0') continue;
276
277            // Adjust bracket count as applicable.
278            if (currentChar == '[') bracketCount++;
279            if (currentChar == ']') bracketCount--;
280        } while (currentPos < pathLen && bracketCount > 0);
281
282        if (bracketCount > 0) {
283            throw new IllegalArgumentException("Unmatched '[': " + path);
284        }
285        
286        return currentPos;
287    }
288
289    /**
290     * Determine if a property name is a path or a plain property reference.
291     *
292     * <p>
293     * This method is used to eliminate parsing and object creation overhead when resolving an
294     * object property reference with a non-complex property path.
295     * </p>
296     * @param propertyName property name
297     *
298     * @return true if the name is a path, false if a plain reference
299     */
300    public static boolean isPath(String propertyName) {
301        if (propertyName == null) {
302            return false;
303        }
304
305        int length = propertyName.length();
306        for (int i = 0; i < length; i++) {
307            char c = propertyName.charAt(i);
308            if (c != '_' && c != '$' && !Character.isLetterOrDigit(c)) {
309                return true;
310            }
311        }
312
313        return false;
314    }
315
316    /**
317     * Parse a path expression.
318     * 
319     * @param root The root object.
320     * @param path The path expression.
321     * @param pathEntry The path entry adaptor to use for processing parse node transition.
322     * 
323     * @return The valid of the bean property indicated by the given path expression, null if the
324     *         path expression doesn't resolve to a valid property.
325     * @see ObjectPropertyUtils#getPropertyValue(Object, String)
326     */
327    @SuppressWarnings("unchecked")
328    public static <T> T parsePathExpression(Object root, String path, final PathEntry pathEntry) {
329
330        // NOTE: This iterative parser allows support for subexpressions
331        // without recursion. When a subexpression start token '[' is
332        // encountered the current continuation is pushed onto a stack. When
333        // the subexpression is resolved, the continuation is popped back
334        // off the stack and resolved using the subexpression result as the
335        // arg. All subexpressions start with the same root passed in as an
336        // argument for this method. - MWF
337
338        ParseState parseState = (ParseState) TL_EL_PARSE_STATE.get();
339        boolean recycle;
340
341        if (parseState == null) {
342            TL_EL_PARSE_STATE.set(new ParseState());
343            parseState = TL_EL_PARSE_STATE.get();
344            recycle = true;
345        } else if (parseState.isActive()) {
346            ProcessLogger.ntrace("el-parse:", ":nested", 100);
347            parseState = new ParseState();
348            recycle = false;
349        } else {
350            recycle = true;
351        }
352
353        try {
354            parseState.originalPath = path;
355            parseState.originalPathIndex = 0;
356            parseState.parentPath = null;
357            parseState.currentContinuation = pathEntry.parse(null, root, null);
358            while (path != null) {
359                path = parseState.prepareNextScan(path);
360                parseState.scan(path);
361                path = parseState.step(path, pathEntry);
362            }
363            return (T) parseState.currentContinuation;
364        } finally {
365            assert !recycle || parseState == TL_EL_PARSE_STATE.get();
366            parseState.reset();
367        }
368    }
369
370    /**
371     * Private constructor - utility class only.
372     */
373    private ObjectPathExpressionParser() {}
374
375}