001// Licensed under the Apache License, Version 2.0 (the "License"); 002// you may not use this file except in compliance with the License. 003// You may obtain a copy of the License at 004// 005// http://www.apache.org/licenses/LICENSE-2.0 006// 007// Unless required by applicable law or agreed to in writing, software 008// distributed under the License is distributed on an "AS IS" BASIS, 009// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 010// See the License for the specific language governing permissions and 011// limitations under the License. 012 013package org.apache.tapestry5.internal.services; 014 015import org.apache.tapestry5.internal.parser.*; 016import org.apache.tapestry5.ioc.Location; 017import org.apache.tapestry5.ioc.Resource; 018import org.apache.tapestry5.ioc.internal.util.CollectionFactory; 019import org.apache.tapestry5.ioc.internal.util.InternalUtils; 020import org.apache.tapestry5.ioc.internal.util.TapestryException; 021import org.apache.tapestry5.ioc.util.ExceptionUtils; 022 023import javax.xml.namespace.QName; 024import java.net.URL; 025import java.util.List; 026import java.util.Map; 027import java.util.Set; 028import java.util.regex.Matcher; 029import java.util.regex.Pattern; 030 031import static org.apache.tapestry5.internal.services.SaxTemplateParser.Version.*; 032 033/** 034 * SAX-based template parser logic, taking a {@link Resource} to a Tapestry 035 * template file and returning 036 * a {@link ComponentTemplate}. 037 * <p/> 038 * Earlier versions of this code used the StAX (streaming XML parser), but that 039 * was really, really bad for Google App Engine. This version uses SAX under the 040 * covers, but kind of replicates the important bits of the StAX API as 041 * {@link XMLTokenStream}. 042 * 043 * @since 5.2.0 044 */ 045@SuppressWarnings( 046 {"JavaDoc"}) 047public class SaxTemplateParser 048{ 049 private static final String MIXINS_ATTRIBUTE_NAME = "mixins"; 050 051 private static final String TYPE_ATTRIBUTE_NAME = "type"; 052 053 private static final String ID_ATTRIBUTE_NAME = "id"; 054 055 public static final String XML_NAMESPACE_URI = "http://www.w3.org/XML/1998/namespace"; 056 057 private static final Map<String, Version> NAMESPACE_URI_TO_VERSION = CollectionFactory.newMap(); 058 059 { 060 NAMESPACE_URI_TO_VERSION.put("http://tapestry.apache.org/schema/tapestry_5_0_0.xsd", T_5_0); 061 NAMESPACE_URI_TO_VERSION.put("http://tapestry.apache.org/schema/tapestry_5_1_0.xsd", T_5_1); 062 // 5.2 didn't change the schmea, so the 5_1_0.xsd was still used. 063 // 5.3 fixes an incorrect element name in the XSD ("replacement" should be "replace") 064 // The parser code here always expected "replace". 065 NAMESPACE_URI_TO_VERSION.put("http://tapestry.apache.org/schema/tapestry_5_3.xsd", T_5_3); 066 // 5.4 is pretty much the same as 5.3, but allows block inside extend 067 // as per TAP5-1847 068 NAMESPACE_URI_TO_VERSION.put("http://tapestry.apache.org/schema/tapestry_5_4.xsd", T_5_4); 069 } 070 071 /** 072 * Special namespace used to denote Block parameters to components, as a 073 * (preferred) alternative to the t:parameter 074 * element. The simple element name is the name of the parameter. 075 */ 076 private static final String TAPESTRY_PARAMETERS_URI = "tapestry:parameter"; 077 078 /** 079 * URI prefix used to identify a Tapestry library, the remainder of the URI 080 * becomes a prefix on the element name. 081 */ 082 private static final String LIB_NAMESPACE_URI_PREFIX = "tapestry-library:"; 083 084 /** 085 * Pattern used to parse the path portion of the library namespace URI. A 086 * series of simple identifiers with slashes 087 * allowed as seperators. 088 */ 089 090 private static final Pattern LIBRARY_PATH_PATTERN = Pattern.compile("^[a-z]\\w*(/[a-z]\\w*)*$", 091 Pattern.CASE_INSENSITIVE); 092 093 private static final Pattern ID_PATTERN = Pattern.compile("^[a-z]\\w*$", 094 Pattern.CASE_INSENSITIVE); 095 096 /** 097 * Any amount of mixed simple whitespace (space, tab, form feed) mixed with 098 * at least one carriage return or line 099 * feed, followed by any amount of whitespace. Will be reduced to a single 100 * linefeed. 101 */ 102 private static final Pattern REDUCE_LINEBREAKS_PATTERN = Pattern.compile( 103 "[ \\t\\f]*[\\r\\n]\\s*", Pattern.MULTILINE); 104 105 /** 106 * Used when compressing whitespace, matches any sequence of simple 107 * whitespace (space, tab, formfeed). Applied after 108 * REDUCE_LINEBREAKS_PATTERN. 109 */ 110 private static final Pattern REDUCE_WHITESPACE_PATTERN = Pattern.compile("[ \\t\\f]+", 111 Pattern.MULTILINE); 112 113 // Note the use of the non-greedy modifier; this prevents the pattern from 114 // merging multiple 115 // expansions on the same text line into a single large 116 // but invalid expansion. 117 118 private static final Pattern EXPANSION_PATTERN = Pattern.compile("\\$\\{\\s*(((?!\\$\\{).)*)\\s*}"); 119 private static final char EXPANSION_STRING_DELIMITTER = '\''; 120 private static final char OPEN_BRACE = '{'; 121 private static final char CLOSE_BRACE = '}'; 122 123 private static final Set<String> MUST_BE_ROOT = CollectionFactory.newSet("extend", "container"); 124 125 private final Resource resource; 126 127 private final XMLTokenStream tokenStream; 128 129 private final StringBuilder textBuffer = new StringBuilder(); 130 131 private final List<TemplateToken> tokens = CollectionFactory.newList(); 132 133 // This starts pointing at tokens but occasionally shifts to a list inside 134 // the overrides Map. 135 private List<TemplateToken> tokenAccumulator = tokens; 136 137 /** 138 * Primarily used as a set of componentIds (to check for duplicates and 139 * conflicts). 140 */ 141 private final Map<String, Location> componentIds = CollectionFactory.newCaseInsensitiveMap(); 142 143 /** 144 * Map from override id to a list of tokens; this actually works both for 145 * overrides defined by this template and 146 * overrides provided by this template. 147 */ 148 private Map<String, List<TemplateToken>> overrides; 149 150 private boolean extension; 151 152 private Location textStartLocation; 153 154 private boolean active = true; 155 156 private boolean strictMixinParameters = false; 157 158 private final Map<String, Boolean> extensionPointIdSet = CollectionFactory.newCaseInsensitiveMap(); 159 160 public SaxTemplateParser(Resource resource, Map<String, URL> publicIdToURL) 161 { 162 this.resource = resource; 163 this.tokenStream = new XMLTokenStream(resource, publicIdToURL); 164 } 165 166 public ComponentTemplate parse(boolean compressWhitespace) 167 { 168 try 169 { 170 tokenStream.parse(); 171 172 TemplateParserState initialParserState = new TemplateParserState() 173 .compressWhitespace(compressWhitespace); 174 175 root(initialParserState); 176 177 return new ComponentTemplateImpl(resource, tokens, componentIds, extension, strictMixinParameters, overrides); 178 } catch (Exception ex) 179 { 180 throw new TapestryException(String.format("Failure parsing template %s: %s", resource, 181 ExceptionUtils.toMessage(ex)), tokenStream.getLocation(), ex); 182 } 183 184 } 185 186 void root(TemplateParserState state) 187 { 188 while (active && tokenStream.hasNext()) 189 { 190 switch (tokenStream.next()) 191 { 192 case DTD: 193 194 dtd(); 195 196 break; 197 198 case START_ELEMENT: 199 200 rootElement(state); 201 202 break; 203 204 case END_DOCUMENT: 205 // Ignore it. 206 break; 207 208 default: 209 textContent(state); 210 } 211 } 212 } 213 214 private void rootElement(TemplateParserState initialState) 215 { 216 TemplateParserState state = setupForElement(initialState); 217 218 String uri = tokenStream.getNamespaceURI(); 219 String name = tokenStream.getLocalName(); 220 Version version = NAMESPACE_URI_TO_VERSION.get(uri); 221 222 if (T_5_1.sameOrEarlier(version)) 223 { 224 if (name.equalsIgnoreCase("extend")) 225 { 226 extend(state); 227 return; 228 } 229 } 230 231 if (version != null) 232 { 233 if (name.equalsIgnoreCase("container")) 234 { 235 container(state); 236 return; 237 } 238 } 239 240 element(state); 241 } 242 243 private void extend(TemplateParserState state) 244 { 245 extension = true; 246 247 while (active) 248 { 249 switch (tokenStream.next()) 250 { 251 case START_ELEMENT: 252 253 if (isTemplateVersion(Version.T_5_1) && isElementName("replace")) 254 { 255 replace(state); 256 break; 257 } 258 259 boolean is54 = isTemplateVersion(Version.T_5_4); 260 261 if (is54 && isElementName("block")) 262 { 263 block(state); 264 break; 265 } 266 267 throw new RuntimeException( 268 is54 269 ? "Child element of <extend> must be <replace> or <block>." 270 : "Child element of <extend> must be <replace>."); 271 272 case END_ELEMENT: 273 274 return; 275 276 // Ignore spaces and comments directly inside <extend>. 277 278 case COMMENT: 279 case SPACE: 280 break; 281 282 // Other non-whitespace content (characters, etc.) are forbidden. 283 284 case CHARACTERS: 285 if (InternalUtils.isBlank(tokenStream.getText())) 286 break; 287 288 default: 289 unexpectedEventType(); 290 } 291 } 292 } 293 294 /** 295 * Returns true if the <em>local name</em> is the element name (ignoring case). 296 */ 297 private boolean isElementName(String elementName) 298 { 299 return tokenStream.getLocalName().equalsIgnoreCase(elementName); 300 } 301 302 /** 303 * Returns true if the template version is at least the required version. 304 */ 305 private boolean isTemplateVersion(Version requiredVersion) 306 { 307 Version templateVersion = NAMESPACE_URI_TO_VERSION.get(tokenStream.getNamespaceURI()); 308 309 return requiredVersion.sameOrEarlier(templateVersion); 310 } 311 312 private void replace(TemplateParserState state) 313 { 314 String id = getRequiredIdAttribute(); 315 316 addContentToOverride(setupForElement(state), id); 317 } 318 319 private void unexpectedEventType() 320 { 321 XMLTokenType eventType = tokenStream.getEventType(); 322 323 throw new IllegalStateException(String.format("Unexpected XML parse event %s.", eventType 324 .name())); 325 } 326 327 private void dtd() 328 { 329 DTDData dtdInfo = tokenStream.getDTDInfo(); 330 331 tokenAccumulator.add(new DTDToken(dtdInfo.rootName, dtdInfo.publicId, dtdInfo 332 .systemId, getLocation())); 333 } 334 335 private Location getLocation() 336 { 337 return tokenStream.getLocation(); 338 } 339 340 /** 341 * Processes an element through to its matching end tag. 342 * <p/> 343 * An element can be: 344 * <p/> 345 * a Tapestry component via <t:type> 346 * <p/> 347 * a Tapestry component via t:type="type" and/or t:id="id" 348 * <p/> 349 * a Tapestry component via a library namespace 350 * <p/> 351 * A parameter element via <t:parameter> 352 * <p/> 353 * A parameter element via <p:name> 354 * <p/> 355 * A <t:remove> element (in the 5.1 schema) 356 * <p/> 357 * A <t:content> element (in the 5.1 schema) 358 * <p/> 359 * A <t:block> element 360 * <p/> 361 * The body <t:body> 362 * <p/> 363 * An ordinary element 364 */ 365 void element(TemplateParserState initialState) 366 { 367 TemplateParserState state = setupForElement(initialState); 368 369 String uri = tokenStream.getNamespaceURI(); 370 String name = tokenStream.getLocalName(); 371 Version version = NAMESPACE_URI_TO_VERSION.get(uri); 372 373 if (T_5_1.sameOrEarlier(version)) 374 { 375 376 if (name.equalsIgnoreCase("remove")) 377 { 378 removeContent(); 379 380 return; 381 } 382 383 if (name.equalsIgnoreCase("content")) 384 { 385 limitContent(state); 386 387 return; 388 } 389 390 if (name.equalsIgnoreCase("extension-point")) 391 { 392 extensionPoint(state); 393 394 return; 395 } 396 397 if (name.equalsIgnoreCase("replace")) 398 { 399 throw new RuntimeException( 400 "The <replace> element may only appear directly within an extend element."); 401 } 402 403 if (MUST_BE_ROOT.contains(name)) 404 mustBeRoot(name); 405 } 406 407 if (version != null) 408 { 409 410 if (name.equalsIgnoreCase("body")) 411 { 412 body(); 413 return; 414 } 415 416 if (name.equalsIgnoreCase("container")) 417 { 418 mustBeRoot(name); 419 } 420 421 if (name.equalsIgnoreCase("block")) 422 { 423 block(state); 424 return; 425 } 426 427 if (name.equalsIgnoreCase("parameter")) 428 { 429 if (T_5_3.sameOrEarlier(version)) 430 { 431 throw new RuntimeException( 432 String.format("The <parameter> element has been deprecated in Tapestry 5.3 in favour of '%s' namespace.", TAPESTRY_PARAMETERS_URI)); 433 } 434 435 classicParameter(state); 436 437 return; 438 } 439 440 possibleTapestryComponent(state, null, tokenStream.getLocalName().replace('.', '/')); 441 442 return; 443 } 444 445 if (uri != null && uri.startsWith(LIB_NAMESPACE_URI_PREFIX)) 446 { 447 libraryNamespaceComponent(state); 448 449 return; 450 } 451 452 if (TAPESTRY_PARAMETERS_URI.equals(uri)) 453 { 454 parameterElement(state); 455 456 return; 457 } 458 459 // Just an ordinary element ... unless it has t:id or t:type 460 461 possibleTapestryComponent(state, tokenStream.getLocalName(), null); 462 } 463 464 /** 465 * Processes a body of an element including text and (recursively) nested 466 * elements. Adds an 467 * {@link org.apache.tapestry5.internal.parser.TokenType#END_ELEMENT} token 468 * before returning. 469 * 470 * @param state 471 */ 472 private void processBody(TemplateParserState state) 473 { 474 while (active) 475 { 476 switch (tokenStream.next()) 477 { 478 case START_ELEMENT: 479 480 // The recursive part: when we see a new element start. 481 482 element(state); 483 break; 484 485 case END_ELEMENT: 486 487 // At the end of an element, we're done and can return. 488 // This is the matching end element for the start element 489 // that invoked this method. 490 491 endElement(state); 492 493 return; 494 495 default: 496 textContent(state); 497 } 498 } 499 } 500 501 private TemplateParserState setupForElement(TemplateParserState initialState) 502 { 503 processTextBuffer(initialState); 504 505 return checkForXMLSpaceAttribute(initialState); 506 } 507 508 /** 509 * Handles an extension point, putting a RenderExtension token in position 510 * in the template. 511 * 512 * @param state 513 */ 514 private void extensionPoint(TemplateParserState state) 515 { 516 // An extension point adds a token that represents where the override 517 // (either the default 518 // provided in the parent template, or the true override from a child 519 // template) is positioned. 520 521 String id = getRequiredIdAttribute(); 522 523 if (extensionPointIdSet.containsKey(id)) 524 { 525 throw new TapestryException(String.format("Extension point '%s' is already defined for this template. Extension point ids must be unique.", id), getLocation(), null); 526 } else 527 { 528 extensionPointIdSet.put(id, true); 529 } 530 531 tokenAccumulator.add(new ExtensionPointToken(id, getLocation())); 532 533 addContentToOverride(state.insideComponent(false), id); 534 } 535 536 private String getRequiredIdAttribute() 537 { 538 String id = getSingleParameter("id"); 539 540 if (InternalUtils.isBlank(id)) 541 throw new RuntimeException(String.format("The <%s> element must have an id attribute.", 542 tokenStream.getLocalName())); 543 544 return id; 545 } 546 547 private void addContentToOverride(TemplateParserState state, String id) 548 549 { 550 List<TemplateToken> savedTokenAccumulator = tokenAccumulator; 551 552 tokenAccumulator = CollectionFactory.newList(); 553 554 // TODO: id should probably be unique; i.e., you either define an 555 // override or you 556 // provide an override, but you don't do both in the same template. 557 558 if (overrides == null) 559 overrides = CollectionFactory.newCaseInsensitiveMap(); 560 561 overrides.put(id, tokenAccumulator); 562 563 while (active) 564 { 565 switch (tokenStream.next()) 566 { 567 case START_ELEMENT: 568 element(state); 569 break; 570 571 case END_ELEMENT: 572 573 processTextBuffer(state); 574 575 // Restore everthing to how it was before the 576 // extention-point was reached. 577 578 tokenAccumulator = savedTokenAccumulator; 579 return; 580 581 default: 582 textContent(state); 583 } 584 } 585 } 586 587 private void mustBeRoot(String name) 588 { 589 throw new RuntimeException(String.format( 590 "Element <%s> is only valid as the root element of a template.", name)); 591 } 592 593 /** 594 * Triggered by <t:content> element; limits template content to just 595 * what's inside. 596 */ 597 598 private void limitContent(TemplateParserState state) 599 { 600 if (state.isCollectingContent()) 601 throw new IllegalStateException( 602 "The <content> element may not be nested within another <content> element."); 603 604 TemplateParserState newState = state.collectingContent().insideComponent(false); 605 606 // Clear out any tokens that precede the <t:content> element 607 608 tokens.clear(); 609 610 // I'm not happy about this; you really shouldn't define overrides just 611 // to clear them out, 612 // but it is consistent. Perhaps this should be an error if overrides is 613 // non-empty. 614 615 overrides = null; 616 617 // Make sure that if the <t:content> appears inside a <t:replace> or 618 // <t:extension-point>, that 619 // it is still handled correctly. 620 621 tokenAccumulator = tokens; 622 623 while (active) 624 { 625 switch (tokenStream.next()) 626 { 627 case START_ELEMENT: 628 element(newState); 629 break; 630 631 case END_ELEMENT: 632 633 // The active flag is global, once we hit it, the entire 634 // parse is aborted, leaving 635 // tokens with just tokens defined inside <t:content>. 636 637 active = false; 638 639 break; 640 641 default: 642 textContent(state); 643 } 644 } 645 646 } 647 648 private void removeContent() 649 { 650 int depth = 1; 651 652 while (active) 653 { 654 switch (tokenStream.next()) 655 { 656 case START_ELEMENT: 657 depth++; 658 break; 659 660 // The matching end element. 661 662 case END_ELEMENT: 663 depth--; 664 665 if (depth == 0) 666 return; 667 668 break; 669 670 default: 671 // Ignore anything else (text, comments, etc.) 672 } 673 } 674 } 675 676 private String nullForBlank(String input) 677 { 678 return InternalUtils.isBlank(input) ? null : input; 679 } 680 681 /** 682 * Added in release 5.1. 683 */ 684 private void libraryNamespaceComponent(TemplateParserState state) 685 { 686 String uri = tokenStream.getNamespaceURI(); 687 688 // The library path is encoded into the namespace URI. 689 690 String path = uri.substring(LIB_NAMESPACE_URI_PREFIX.length()); 691 692 if (!LIBRARY_PATH_PATTERN.matcher(path).matches()) 693 throw new RuntimeException(String.format("The path portion of library namespace URI '%s' is not valid: it must be a simple identifier, or a series of identifiers seperated by slashes.", uri)); 694 695 possibleTapestryComponent(state, null, path + "/" + tokenStream.getLocalName()); 696 } 697 698 /** 699 * @param elementName 700 * @param identifiedType 701 * the type of the element, usually null, but may be the 702 * component type derived from element 703 */ 704 private void possibleTapestryComponent(TemplateParserState state, String elementName, 705 String identifiedType) 706 { 707 String id = null; 708 String type = identifiedType; 709 String mixins = null; 710 711 int count = tokenStream.getAttributeCount(); 712 713 Location location = getLocation(); 714 715 List<TemplateToken> attributeTokens = CollectionFactory.newList(); 716 717 for (int i = 0; i < count; i++) 718 { 719 QName qname = tokenStream.getAttributeName(i); 720 721 if (isXMLSpaceAttribute(qname)) 722 continue; 723 724 // The name will be blank for an xmlns: attribute 725 726 String localName = qname.getLocalPart(); 727 728 if (InternalUtils.isBlank(localName)) 729 continue; 730 731 String uri = qname.getNamespaceURI(); 732 733 String value = tokenStream.getAttributeValue(i); 734 735 736 Version version = NAMESPACE_URI_TO_VERSION.get(uri); 737 738 if (version != null) 739 { 740 // We are kind of assuming that the namespace URI appears once, in the outermost element of the template. 741 // And we don't and can't handle the case that it appears multiple times in the template. 742 743 if (T_5_4.sameOrEarlier(version)) { 744 strictMixinParameters = true; 745 } 746 747 if (localName.equalsIgnoreCase(ID_ATTRIBUTE_NAME)) 748 { 749 id = nullForBlank(value); 750 751 validateId(id, "Component id '%s' is not valid; component ids must be valid Java identifiers: start with a letter, and consist of letters, numbers and underscores."); 752 753 continue; 754 } 755 756 if (type == null && localName.equalsIgnoreCase(TYPE_ATTRIBUTE_NAME)) 757 { 758 type = nullForBlank(value); 759 continue; 760 } 761 762 if (localName.equalsIgnoreCase(MIXINS_ATTRIBUTE_NAME)) 763 { 764 mixins = nullForBlank(value); 765 continue; 766 } 767 768 // Anything else is the name of a Tapestry component parameter 769 // that is simply 770 // not part of the template's doctype for the element being 771 // instrumented. 772 } 773 774 attributeTokens.add(new AttributeToken(uri, localName, value, location)); 775 } 776 777 boolean isComponent = (id != null || type != null); 778 779 // If provided t:mixins but not t:id or t:type, then its not quite a 780 // component 781 782 if (mixins != null && !isComponent) 783 throw new TapestryException(String.format("You may not specify mixins for element <%s> because it does not represent a component (which requires either an id attribute or a type attribute).", elementName), 784 location, null); 785 786 if (isComponent) 787 { 788 tokenAccumulator.add(new StartComponentToken(elementName, id, type, mixins, location)); 789 } else 790 { 791 tokenAccumulator.add(new StartElementToken(tokenStream.getNamespaceURI(), elementName, 792 location)); 793 } 794 795 addDefineNamespaceTokens(); 796 797 tokenAccumulator.addAll(attributeTokens); 798 799 if (id != null) 800 componentIds.put(id, location); 801 802 processBody(state.insideComponent(isComponent)); 803 } 804 805 private void addDefineNamespaceTokens() 806 { 807 for (int i = 0; i < tokenStream.getNamespaceCount(); i++) 808 { 809 String uri = tokenStream.getNamespaceURI(i); 810 811 // These URIs are strictly part of the server-side Tapestry template 812 // and are not ever sent to the client. 813 814 if (NAMESPACE_URI_TO_VERSION.containsKey(uri)) 815 continue; 816 817 if (uri.equals(TAPESTRY_PARAMETERS_URI)) 818 continue; 819 820 if (uri.startsWith(LIB_NAMESPACE_URI_PREFIX)) 821 continue; 822 823 tokenAccumulator.add(new DefineNamespacePrefixToken(uri, tokenStream 824 .getNamespacePrefix(i), getLocation())); 825 } 826 } 827 828 private TemplateParserState checkForXMLSpaceAttribute(TemplateParserState state) 829 { 830 for (int i = 0; i < tokenStream.getAttributeCount(); i++) 831 { 832 QName qName = tokenStream.getAttributeName(i); 833 834 if (isXMLSpaceAttribute(qName)) 835 { 836 boolean compress = !"preserve".equals(tokenStream.getAttributeValue(i)); 837 838 return state.compressWhitespace(compress); 839 } 840 } 841 842 return state; 843 } 844 845 /** 846 * Processes the text buffer and then adds an end element token. 847 */ 848 private void endElement(TemplateParserState state) 849 { 850 processTextBuffer(state); 851 852 tokenAccumulator.add(new EndElementToken(getLocation())); 853 } 854 855 /** 856 * Handler for Tapestry 5.0's "classic" <t:parameter> element. This 857 * turns into a {@link org.apache.tapestry5.internal.parser.ParameterToken} 858 * and the body and end element are provided normally. 859 */ 860 private void classicParameter(TemplateParserState state) 861 { 862 String parameterName = getSingleParameter("name"); 863 864 if (InternalUtils.isBlank(parameterName)) 865 throw new TapestryException("The name attribute of the <parameter> element must be specified.", 866 getLocation(), null); 867 868 ensureParameterWithinComponent(state); 869 870 tokenAccumulator.add(new ParameterToken(parameterName, getLocation())); 871 872 processBody(state.insideComponent(false)); 873 } 874 875 private void ensureParameterWithinComponent(TemplateParserState state) 876 { 877 if (!state.isInsideComponent()) 878 throw new RuntimeException( 879 "Block parameters are only allowed directly within component elements."); 880 } 881 882 /** 883 * Tapestry 5.1 uses a special namespace (usually mapped to "p:") and the 884 * name becomes the parameter element. 885 */ 886 private void parameterElement(TemplateParserState state) 887 { 888 ensureParameterWithinComponent(state); 889 890 if (tokenStream.getAttributeCount() > 0) 891 throw new TapestryException("A block parameter element does not allow any additional attributes. The element name defines the parameter name.", 892 getLocation(), null); 893 894 tokenAccumulator.add(new ParameterToken(tokenStream.getLocalName(), getLocation())); 895 896 processBody(state.insideComponent(false)); 897 } 898 899 /** 900 * Checks that a body element is empty. Returns after the body's close 901 * element. Adds a single body token (but not an 902 * end token). 903 */ 904 private void body() 905 { 906 tokenAccumulator.add(new BodyToken(getLocation())); 907 908 while (active) 909 { 910 switch (tokenStream.next()) 911 { 912 case END_ELEMENT: 913 return; 914 915 default: 916 throw new IllegalStateException(String.format("Content inside a Tapestry body element is not allowed (at %s). The content has been ignored.", getLocation())); 917 } 918 } 919 } 920 921 /** 922 * Driven by the <t:container> element, this state adds elements for 923 * its body but not its start or end tags. 924 * 925 * @param state 926 */ 927 private void container(TemplateParserState state) 928 { 929 while (active) 930 { 931 switch (tokenStream.next()) 932 { 933 case START_ELEMENT: 934 element(state); 935 break; 936 937 // The matching end-element for the container. Don't add a 938 // token. 939 940 case END_ELEMENT: 941 942 processTextBuffer(state); 943 944 return; 945 946 default: 947 textContent(state); 948 } 949 } 950 } 951 952 /** 953 * A block adds a token for its start tag and end tag and allows any content 954 * within. 955 */ 956 private void block(TemplateParserState state) 957 { 958 String blockId = getSingleParameter("id"); 959 960 validateId(blockId, "Block id '%s' is not valid; block ids must be valid Java identifiers: start with a letter, and consist of letters, numbers and underscores."); 961 962 tokenAccumulator.add(new BlockToken(blockId, getLocation())); 963 964 processBody(state.insideComponent(false)); 965 } 966 967 private String getSingleParameter(String attributeName) 968 { 969 String result = null; 970 971 for (int i = 0; i < tokenStream.getAttributeCount(); i++) 972 { 973 QName qName = tokenStream.getAttributeName(i); 974 975 if (isXMLSpaceAttribute(qName)) 976 continue; 977 978 if (qName.getLocalPart().equalsIgnoreCase(attributeName)) 979 { 980 result = tokenStream.getAttributeValue(i); 981 continue; 982 } 983 984 // Only the named attribute is allowed. 985 986 throw new TapestryException(String.format("Element <%s> does not support an attribute named '%s'. The only allowed attribute name is '%s'.", tokenStream 987 .getLocalName(), qName.toString(), attributeName), getLocation(), null); 988 } 989 990 return result; 991 } 992 993 private void validateId(String id, String messageKey) 994 { 995 if (id == null) 996 return; 997 998 if (ID_PATTERN.matcher(id).matches()) 999 return; 1000 1001 // Not a match. 1002 1003 throw new TapestryException(String.format(messageKey, id), getLocation(), null); 1004 } 1005 1006 private boolean isXMLSpaceAttribute(QName qName) 1007 { 1008 return XML_NAMESPACE_URI.equals(qName.getNamespaceURI()) 1009 && "space".equals(qName.getLocalPart()); 1010 } 1011 1012 /** 1013 * Processes text content if in the correct state, or throws an exception. 1014 * This is used as a default for matching 1015 * case statements. 1016 * 1017 * @param state 1018 */ 1019 private void textContent(TemplateParserState state) 1020 { 1021 switch (tokenStream.getEventType()) 1022 { 1023 case COMMENT: 1024 comment(state); 1025 break; 1026 1027 case CDATA: 1028 cdata(state); 1029 break; 1030 1031 case CHARACTERS: 1032 case SPACE: 1033 characters(); 1034 break; 1035 1036 default: 1037 unexpectedEventType(); 1038 } 1039 } 1040 1041 private void characters() 1042 { 1043 if (textStartLocation == null) 1044 textStartLocation = getLocation(); 1045 1046 textBuffer.append(tokenStream.getText()); 1047 } 1048 1049 private void cdata(TemplateParserState state) 1050 { 1051 processTextBuffer(state); 1052 1053 tokenAccumulator.add(new CDATAToken(tokenStream.getText(), getLocation())); 1054 } 1055 1056 private void comment(TemplateParserState state) 1057 { 1058 processTextBuffer(state); 1059 1060 String comment = tokenStream.getText(); 1061 1062 tokenAccumulator.add(new CommentToken(comment, getLocation())); 1063 } 1064 1065 /** 1066 * Processes the accumulated text in the text buffer as a text token. 1067 */ 1068 private void processTextBuffer(TemplateParserState state) 1069 { 1070 if (textBuffer.length() != 0) 1071 convertTextBufferToTokens(state); 1072 1073 textStartLocation = null; 1074 } 1075 1076 private void convertTextBufferToTokens(TemplateParserState state) 1077 { 1078 String text = textBuffer.toString(); 1079 1080 textBuffer.setLength(0); 1081 1082 if (state.isCompressWhitespace()) 1083 { 1084 text = compressWhitespaceInText(text); 1085 1086 if (InternalUtils.isBlank(text)) 1087 return; 1088 } 1089 1090 addTokensForText(text); 1091 } 1092 1093 /** 1094 * Reduces vertical whitespace to a single newline, then reduces horizontal 1095 * whitespace to a single space. 1096 * 1097 * @param text 1098 * @return compressed version of text 1099 */ 1100 private String compressWhitespaceInText(String text) 1101 { 1102 String linebreaksReduced = REDUCE_LINEBREAKS_PATTERN.matcher(text).replaceAll("\n"); 1103 1104 return REDUCE_WHITESPACE_PATTERN.matcher(linebreaksReduced).replaceAll(" "); 1105 } 1106 1107 /** 1108 * Scans the text, using a regular expression pattern, for expansion 1109 * patterns, and adds appropriate tokens for what 1110 * it finds. 1111 * 1112 * @param text 1113 * to add as 1114 * {@link org.apache.tapestry5.internal.parser.TextToken}s and 1115 * {@link org.apache.tapestry5.internal.parser.ExpansionToken}s 1116 */ 1117 private void addTokensForText(String text) 1118 { 1119 Matcher matcher = EXPANSION_PATTERN.matcher(text); 1120 1121 int startx = 0; 1122 1123 // The big problem with all this code is that everything gets assigned 1124 // to the 1125 // start of the text block, even if there are line breaks leading up to 1126 // it. 1127 // That's going to take a lot more work and there are bigger fish to 1128 // fry. In addition, 1129 // TAPESTRY-2028 means that the whitespace has likely been stripped out 1130 // of the text 1131 // already anyway. 1132 while (matcher.find()) 1133 { 1134 int matchStart = matcher.start(); 1135 1136 if (matchStart != startx) 1137 { 1138 String prefix = text.substring(startx, matchStart); 1139 tokenAccumulator.add(new TextToken(prefix, textStartLocation)); 1140 } 1141 1142 // Group 1 includes the real text of the expansion, with whitespace 1143 // around the 1144 // expression (but inside the curly braces) excluded. 1145 // But note that we run into a problem. The original 1146 // EXPANSION_PATTERN used a reluctant quantifier to match the 1147 // smallest instance of ${} possible. But if you have ${'}'} or 1148 // ${{'key': 'value'}} (maps, cf TAP5-1605) then you run into issues 1149 // b/c the expansion becomes {'key': 'value' which is wrong. 1150 // A fix to use greedy matching with negative lookahead to prevent 1151 // ${...}...${...} all matching a single expansion is close, but 1152 // has issues when an expansion is used inside a javascript function 1153 // (see TAP5-1620). The solution is to use the greedy 1154 // EXPANSION_PATTERN as before to bound the search for a single 1155 // expansion, then check for {} consistency, ignoring opening and 1156 // closing braces that occur within '' (the property expression 1157 // language doesn't support "" for strings). That should include: 1158 // 'This string has a } in it' and 'This string has a { in it.' 1159 // Note also that the property expression language doesn't support 1160 // escaping the string character ('), so we don't have to worry 1161 // about that. 1162 String expression = matcher.group(1); 1163 //count of 'open' braces. Expression ends when it hits 0. In most cases, 1164 // it should end up as 1 b/c "expression" is everything inside ${}, so 1165 // the following will typically not find the end of the expression. 1166 int openBraceCount = 1; 1167 int expressionEnd = expression.length(); 1168 boolean inQuote = false; 1169 for (int i = 0; i < expression.length(); i++) 1170 { 1171 char c = expression.charAt(i); 1172 //basically, if we're inQuote, we ignore everything until we hit the quote end, so we only care if the character matches the quote start (meaning we're at the end of the quote). 1173 //note that I don't believe expression support escaped quotes... 1174 if (c == EXPANSION_STRING_DELIMITTER) 1175 { 1176 inQuote = !inQuote; 1177 continue; 1178 } else if (inQuote) 1179 { 1180 continue; 1181 } else if (c == CLOSE_BRACE) 1182 { 1183 openBraceCount--; 1184 if (openBraceCount == 0) 1185 { 1186 expressionEnd = i; 1187 break; 1188 } 1189 } else if (c == OPEN_BRACE) 1190 { 1191 openBraceCount++; 1192 } 1193 } 1194 if (expressionEnd < expression.length()) 1195 { 1196 //then we gobbled up some } that we shouldn't have... like the closing } of a javascript 1197 //function. 1198 tokenAccumulator.add(new ExpansionToken(expression.substring(0, expressionEnd), textStartLocation)); 1199 //can't just assign to 1200 startx = matcher.start(1) + expressionEnd + 1; 1201 } else 1202 { 1203 tokenAccumulator.add(new ExpansionToken(expression.trim(), textStartLocation)); 1204 1205 startx = matcher.end(); 1206 } 1207 } 1208 1209 // Catch anything after the final regexp match. 1210 1211 if (startx < text.length()) 1212 tokenAccumulator.add(new TextToken(text.substring(startx, text.length()), 1213 textStartLocation)); 1214 } 1215 1216 static enum Version 1217 { 1218 T_5_0(5, 0), T_5_1(5, 1), T_5_3(5, 3), T_5_4(5, 4); 1219 1220 private int major; 1221 private int minor; 1222 1223 1224 private Version(int major, int minor) 1225 { 1226 this.major = major; 1227 this.minor = minor; 1228 } 1229 1230 /** 1231 * Returns true if this Version is the same as, or ordered before the other Version. This is often used to enable new 1232 * template features for a specific version. 1233 */ 1234 public boolean sameOrEarlier(Version other) 1235 { 1236 if (other == null) 1237 return false; 1238 1239 if (this == other) 1240 return true; 1241 1242 return major <= other.major && minor <= other.minor; 1243 } 1244 } 1245 1246}