1 /** 2 * Copyright 2005-2014 The Kuali Foundation 3 * 4 * Licensed under the Educational Community License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.opensource.org/licenses/ecl2.php 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package org.kuali.rice.krad.uif.util; 17 18 import java.util.Deque; 19 20 /** 21 * Provides modular support for the partial JSP EL path syntax using an arbitrary root bean as the 22 * initial name space. 23 * 24 * <p> 25 * NOTE: This is not full JSP EL, only the path reference portion without support for floating point 26 * literals. See this <a href= "http://jsp.java.net/spec/jsp-2_1-fr-spec-el.pdf"> JSP Reference</a> 27 * for the full BNF. 28 * </p> 29 * 30 * <pre> 31 * Value ::= ValuePrefix (ValueSuffix)* 32 * ValuePrefix ::= Literal 33 * | NonLiteralValuePrefix 34 * NonLiteralValuePrefix ::= '(' Expression ')' 35 * | Identifier 36 * ValueSuffix ::= '.' Identifier 37 * | '[' Expression ']' 38 * Identifier ::= Java language identifier 39 * Literal ::= BooleanLiteral 40 * | IntegerLiteral 41 * | FloatingPointLiteral 42 * | StringLiteral 43 * | NullLiteral 44 * BooleanLiteral ::= 'true' 45 * | 'false' 46 * StringLiteral ::= '([^'\]|\'|\\)*' 47 * | "([^"\]|\"|\\)*" 48 * i.e., a string of any characters enclosed by 49 * single or double quotes, where \ is used to 50 * escape ', ",and \. It is possible to use single 51 * quotes within double quotes, and vice versa, 52 * without escaping. 53 * IntegerLiteral ::= ['0'-'9']+ 54 * NullLiteral ::= 'null' 55 * </pre> 56 * 57 * @author Kuali Rice Team (rice.collab@kuali.org) 58 */ 59 public class ObjectPathExpressionParser { 60 61 /** 62 * Used by {@link #parsePathExpression(Object, String, PathEntry)} to track parse state without 63 * the need to construct a new parser stack for each expression. 64 */ 65 private static final ThreadLocal<ParseState> TL_EL_PARSE_STATE = new ThreadLocal<ParseState>(); 66 67 /** 68 * Tracks parser state for 69 * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}. 70 */ 71 private static final class ParseState { 72 73 /** 74 * The continuation stack. 75 * <p> 76 * When evaluating subexpressions, the outer expression is pushed onto this stack. 77 * </p> 78 */ 79 private final Deque<Object> stack = new java.util.LinkedList<Object>(); 80 81 /** 82 * The lexical index at which to begin the next lexical scan. 83 */ 84 private int nextScanIndex; 85 86 /** 87 * The lexical index of the next path separator token. 88 */ 89 private int nextTokenIndex; 90 91 /** 92 * The root continuation. 93 */ 94 private Object root; 95 96 /** 97 * The continuation point of the parse expression currently being evaluation. 98 */ 99 private Object currentContinuation; 100 101 /** 102 * Determine if this parse state is active. 103 */ 104 private boolean isActive() { 105 return (stack != null && !stack.isEmpty()) || currentContinuation != null; 106 } 107 108 /** 109 * Reset parse state, allowing this state marker to be reused on the next expression. 110 */ 111 private void reset() { 112 stack.clear(); 113 currentContinuation = null; 114 } 115 116 /** 117 * Prepare for the next lexical scan. 118 * 119 * <p> 120 * When a parenthetical expression occurs on the left hand side of the path, remove the 121 * parentheses. 122 * </p> 123 * 124 * <p> 125 * Upon returning from this method, the value of {@link #nextScanIndex} will point at the 126 * position of the character formerly to the right of the removed parenthetical group, if 127 * applicable. If no parenthetical group was removed, {@link #nextScanIndex} will be reset 128 * to 0. 129 * </p> 130 * 131 * @param path The path expression from the current continuation point. 132 * @return The path expression, with parentheses related to a grouping on the left removed. 133 */ 134 public String prepareNextScan(String path) { 135 nextScanIndex = 0; 136 137 char firstChar = path.charAt(0); 138 139 if (firstChar != '(' && firstChar != '\'' && firstChar != '\"') { 140 return path; 141 } 142 143 int parenCount = firstChar == '(' ? 1 : 0; 144 int pathLen = path.length() - 1; 145 146 // Look back during lexical scanning to detect quote and escape characers. 147 char lastChar = firstChar; 148 char currentChar; 149 150 // Track quote state. 151 char inQuote = firstChar == '(' ? '\0' : firstChar; 152 153 while (nextScanIndex < pathLen 154 && ((firstChar == '(' && parenCount > 0) || (firstChar != '(' && inQuote != '\0'))) { 155 nextScanIndex++; 156 currentChar = path.charAt(nextScanIndex); 157 158 // Ignore escaped characters. 159 if (lastChar == '\\') { 160 continue; 161 } 162 163 // Toggle quote state when a quote is encountered. 164 if (inQuote == '\0') { 165 if (currentChar == '\'' || currentChar == '\"') { 166 inQuote = currentChar; 167 } 168 } else if (currentChar == inQuote) { 169 // Emulate BeanWrapper bad quotes support. Automatically escape quotes where the close 170 // quote is not positioned next to a path separator token. 171 // i.e. aBean.aMap['aPoorly'quoted'key'] should reference "aPoorly'quoted'key" as the 172 // key in the map. 173 switch (nextScanIndex >= path.length() - 1 ? '\0' : path.charAt(nextScanIndex + 1)) { 174 // Only accept close quote if followed by a lexical separator token. 175 case ']': 176 case '.': 177 case '[': 178 inQuote = '\0'; 179 break; 180 } 181 } 182 183 // Ignore quoted characters. 184 if (inQuote != '\0') { 185 continue; 186 } 187 188 if (currentChar == '(') { 189 parenCount++; 190 } 191 192 if (currentChar == ')') { 193 parenCount--; 194 } 195 } 196 197 if (parenCount > 0) { 198 throw new IllegalArgumentException("Unmatched '(': " + path); 199 } 200 201 if (firstChar == '(') { 202 assert path.charAt(nextScanIndex) == ')'; 203 if (nextScanIndex < pathLen) { 204 path = path.substring(1, nextScanIndex) + path.substring(nextScanIndex + 1); 205 } else { 206 path = path.substring(1, nextScanIndex); 207 } 208 nextScanIndex--; 209 } else { 210 nextScanIndex++; 211 } 212 213 return path; 214 } 215 216 /** 217 * Update current parse state with the lexical indexes of the next token break. 218 * 219 * @param path The path being parsed, starting from the current continuation point. 220 */ 221 public void scan(String path) { 222 nextTokenIndex = -1; 223 224 // Scan the character sequence, starting with the character following the open marker. 225 for (int currentIndex = nextScanIndex; currentIndex < path.length(); currentIndex++) { 226 switch (path.charAt(currentIndex)) { 227 case ')': 228 // should have been removed by prepareNextScan 229 throw new IllegalArgumentException("Unmatched ')': " + path); 230 case '\'': 231 case '\"': 232 case '(': 233 case '[': 234 case '.': 235 case ']': 236 if (nextTokenIndex == -1) { 237 nextTokenIndex = currentIndex; 238 } 239 return; 240 } 241 } 242 } 243 244 /** 245 * Step to the next continuation point in the parse path. 246 * 247 * <p> 248 * Upon returning from this method, the value of {@link #currentContinuation} will reflect 249 * the resolved state of parsing the path. When null is returned, then 250 * {@link #currentContinuation} will be the reflect the result of parsing the expression. 251 * </p> 252 * 253 * @param path The path expression from the current continuation point. 254 * @return The path expression for the next continuation point, null if the path has been 255 * completely parsed. 256 */ 257 private String step(String path, PathEntry pathEntry) { 258 259 if (nextTokenIndex == -1) { 260 // Only a symbolic reference, resolve it and return. 261 currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation), path, false); 262 return null; 263 } 264 265 char nextToken = path.charAt(nextTokenIndex); 266 267 switch (nextToken) { 268 269 case '[': 270 // Entering bracketed subexpression 271 272 // Resolve non-empty key reference as the current continuation 273 if (nextTokenIndex != 0) { 274 currentContinuation = pathEntry.parse( 275 pathEntry.prepare(currentContinuation), 276 path.substring(0, nextTokenIndex), false); 277 } 278 279 // Push current continuation down in the stack. 280 stack.push(currentContinuation); 281 282 // Reset the current continuation for evaluating the 283 // subexpression 284 currentContinuation = pathEntry.parse(root, null, false); 285 return path.substring(nextTokenIndex + 1); 286 287 case '(': 288 // Approaching a parenthetical expression, not preceded by a subexpression, 289 // resolve the key reference as the current continuation 290 currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation), 291 path.substring(0, nextTokenIndex), false); 292 return path.substring(nextTokenIndex); // Keep the left parenthesis 293 294 case '.': 295 // Crossing a period, not preceded by a subexpression, 296 // resolve the key reference as the current continuation 297 currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation), 298 path.substring(0, nextTokenIndex), false); 299 return path.substring(nextTokenIndex + 1); // Skip the period 300 301 case ']': 302 if (nextTokenIndex > 0) { 303 // Approaching a right bracket, resolve the key reference as the current continuation 304 currentContinuation = pathEntry.parse(pathEntry.prepare(currentContinuation), 305 path.substring(0, nextTokenIndex), false); 306 return path.substring(nextTokenIndex); // Keep the right bracket 307 308 } else { 309 // Crossing a right bracket. 310 311 // Use the current continuation as the parameter for resolving 312 // the top continuation on the stack, then make the result the 313 // current continuation. 314 currentContinuation = pathEntry.parse(pathEntry.prepare(stack.pop()), 315 pathEntry.dereference(currentContinuation), true); 316 if (nextTokenIndex + 1 < path.length()) { 317 // short-circuit the next step, as an optimization for 318 // handling dot resolution without permitting double-dots 319 switch (path.charAt(nextTokenIndex + 1)) { 320 case '.': 321 // crossing a dot, skip it 322 return path.substring(nextTokenIndex + 2); 323 case '[': 324 case ']': 325 // crossing to another subexpression, don't skip it. 326 return path.substring(nextTokenIndex + 1); 327 default: 328 throw new IllegalArgumentException( 329 "Expected '.', '[', or ']': " + path); 330 } 331 } else { 332 return null; 333 } 334 } 335 336 default: 337 throw new IllegalArgumentException("Unexpected '" + nextToken + "' :" + path); 338 } 339 } 340 341 } 342 343 /** 344 * Path entry interface for use with 345 * {@link ObjectPathExpressionParser#parsePathExpression(Object, String, PathEntry)}. 346 */ 347 public static interface PathEntry { 348 349 /** 350 * Parse one node. 351 * 352 * @param node The current parse node. 353 * @param next The next path token. 354 * @param inherit True indicates that the current node is the result of a subexpression, 355 * false indicates the next node in the same expression. 356 * @return A reference to the next parse node. 357 */ 358 Object parse(Object node, String next, boolean inherit); 359 360 /** 361 * Prepare the next parse node based on a reference returned from the prior node. 362 * 363 * @param prev The reference data from the previous node. 364 * @return The next parse node. 365 */ 366 Object prepare(Object prev); 367 368 /** 369 * Resolve the next path element based on reference data from the previous node. 370 * 371 * @param prev The reference data from the previous node. 372 * @return the next path element based on the returned reference data. 373 */ 374 String dereference(Object prev); 375 } 376 377 /** 378 * Parse a path expression. 379 * 380 * @param root The root object. 381 * @param path The path expression. 382 * @param pathEntry The path entry adaptor to use for processing parse node transition. 383 * @param <T> Reference type representing the next parse node. 384 * @param <S> The parse node type. 385 * 386 * @return The valid of the bean property indicated by the given path expression, null if the 387 * path expression doesn't resolve to a valid property. 388 * @see ObjectPathExpressionParser#getPropertyValue(Object, String) 389 */ 390 public static Object parsePathExpression(Object root, String path, 391 final PathEntry pathEntry) { 392 393 // NOTE: This iterative parser allows support for subexpressions 394 // without recursion. When a subexpression start token '[' is 395 // encountered the current continuation is pushed onto a stack. When 396 // the subexpression is resolved, the continuation is popped back 397 // off the stack and resolved using the subexpression result as the 398 // arg. All subexpressions start with the same root passed in as an 399 // argument for this method. - MWF 400 401 ParseState parseState = (ParseState) TL_EL_PARSE_STATE.get(); 402 boolean recycle; 403 404 if (parseState == null) { 405 TL_EL_PARSE_STATE.set(new ParseState()); 406 parseState = TL_EL_PARSE_STATE.get(); 407 recycle = true; 408 } else if (parseState.isActive()) { 409 ProcessLogger.ntrace("el-parse:", ":nested", 100); 410 parseState = new ParseState(); 411 recycle = false; 412 } else { 413 recycle = true; 414 } 415 416 try { 417 parseState.root = root; 418 parseState.currentContinuation = pathEntry.parse(root, null, false); 419 while (path != null) { 420 path = parseState.prepareNextScan(path); 421 parseState.scan(path); 422 path = parseState.step(path, pathEntry); 423 } 424 return parseState.currentContinuation; 425 } finally { 426 assert !recycle || parseState == TL_EL_PARSE_STATE.get(); 427 parseState.reset(); 428 } 429 } 430 431 /** 432 * Private constructor - utility class only. 433 */ 434 private ObjectPathExpressionParser() {} 435 436 }