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.commons.Location; 016import org.apache.tapestry5.commons.Resource; 017import org.apache.tapestry5.commons.internal.util.TapestryException; 018import org.apache.tapestry5.commons.util.CollectionFactory; 019import org.apache.tapestry5.commons.util.ExceptionUtils; 020import org.apache.tapestry5.internal.parser.*; 021import org.apache.tapestry5.ioc.internal.util.InternalUtils; 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 * 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 * 343 * An element can be: 344 * 345 * a Tapestry component via <t:type> 346 * 347 * a Tapestry component via t:type="type" and/or t:id="id" 348 * 349 * a Tapestry component via a library namespace 350 * 351 * A parameter element via <t:parameter> 352 * 353 * A parameter element via <p:name> 354 * 355 * A <t:remove> element (in the 5.1 schema) 356 * 357 * A <t:content> element (in the 5.1 schema) 358 * 359 * A <t:block> element 360 * 361 * The body <t:body> 362 * 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 processTextBuffer(newState); 638 639 active = false; 640 641 break; 642 643 default: 644 textContent(state); 645 } 646 } 647 648 } 649 650 private void removeContent() 651 { 652 int depth = 1; 653 654 while (active) 655 { 656 switch (tokenStream.next()) 657 { 658 case START_ELEMENT: 659 depth++; 660 break; 661 662 // The matching end element. 663 664 case END_ELEMENT: 665 depth--; 666 667 if (depth == 0) 668 return; 669 670 break; 671 672 default: 673 // Ignore anything else (text, comments, etc.) 674 } 675 } 676 } 677 678 private String nullForBlank(String input) 679 { 680 return InternalUtils.isBlank(input) ? null : input; 681 } 682 683 /** 684 * Added in release 5.1. 685 */ 686 private void libraryNamespaceComponent(TemplateParserState state) 687 { 688 String uri = tokenStream.getNamespaceURI(); 689 690 // The library path is encoded into the namespace URI. 691 692 String path = uri.substring(LIB_NAMESPACE_URI_PREFIX.length()); 693 694 if (!LIBRARY_PATH_PATTERN.matcher(path).matches()) 695 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)); 696 697 possibleTapestryComponent(state, null, path + "/" + tokenStream.getLocalName()); 698 } 699 700 /** 701 * @param elementName 702 * @param identifiedType 703 * the type of the element, usually null, but may be the 704 * component type derived from element 705 */ 706 private void possibleTapestryComponent(TemplateParserState state, String elementName, 707 String identifiedType) 708 { 709 String id = null; 710 String type = identifiedType; 711 String mixins = null; 712 713 int count = tokenStream.getAttributeCount(); 714 715 Location location = getLocation(); 716 717 List<TemplateToken> attributeTokens = CollectionFactory.newList(); 718 719 for (int i = 0; i < count; i++) 720 { 721 QName qname = tokenStream.getAttributeName(i); 722 723 if (isXMLSpaceAttribute(qname)) 724 continue; 725 726 // The name will be blank for an xmlns: attribute 727 728 String localName = qname.getLocalPart(); 729 730 if (InternalUtils.isBlank(localName)) 731 continue; 732 733 String uri = qname.getNamespaceURI(); 734 735 String value = tokenStream.getAttributeValue(i); 736 737 738 Version version = NAMESPACE_URI_TO_VERSION.get(uri); 739 740 if (version != null) 741 { 742 // We are kind of assuming that the namespace URI appears once, in the outermost element of the template. 743 // And we don't and can't handle the case that it appears multiple times in the template. 744 745 if (T_5_4.sameOrEarlier(version)) { 746 strictMixinParameters = true; 747 } 748 749 if (localName.equalsIgnoreCase(ID_ATTRIBUTE_NAME)) 750 { 751 id = nullForBlank(value); 752 753 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."); 754 755 continue; 756 } 757 758 if (type == null && localName.equalsIgnoreCase(TYPE_ATTRIBUTE_NAME)) 759 { 760 type = nullForBlank(value); 761 continue; 762 } 763 764 if (localName.equalsIgnoreCase(MIXINS_ATTRIBUTE_NAME)) 765 { 766 mixins = nullForBlank(value); 767 continue; 768 } 769 770 // Anything else is the name of a Tapestry component parameter 771 // that is simply 772 // not part of the template's doctype for the element being 773 // instrumented. 774 } 775 776 attributeTokens.add(new AttributeToken(uri, localName, value, location)); 777 } 778 779 boolean isComponent = (id != null || type != null); 780 781 // If provided t:mixins but not t:id or t:type, then its not quite a 782 // component 783 784 if (mixins != null && !isComponent) 785 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), 786 location, null); 787 788 if (isComponent) 789 { 790 tokenAccumulator.add(new StartComponentToken(elementName, id, type, mixins, location)); 791 } else 792 { 793 tokenAccumulator.add(new StartElementToken(tokenStream.getNamespaceURI(), elementName, 794 location)); 795 } 796 797 addDefineNamespaceTokens(); 798 799 tokenAccumulator.addAll(attributeTokens); 800 801 if (id != null) 802 componentIds.put(id, location); 803 804 processBody(state.insideComponent(isComponent)); 805 } 806 807 private void addDefineNamespaceTokens() 808 { 809 for (int i = 0; i < tokenStream.getNamespaceCount(); i++) 810 { 811 String uri = tokenStream.getNamespaceURI(i); 812 813 // These URIs are strictly part of the server-side Tapestry template 814 // and are not ever sent to the client. 815 816 if (NAMESPACE_URI_TO_VERSION.containsKey(uri)) 817 continue; 818 819 if (uri.equals(TAPESTRY_PARAMETERS_URI)) 820 continue; 821 822 if (uri.startsWith(LIB_NAMESPACE_URI_PREFIX)) 823 continue; 824 825 tokenAccumulator.add(new DefineNamespacePrefixToken(uri, tokenStream 826 .getNamespacePrefix(i), getLocation())); 827 } 828 } 829 830 private TemplateParserState checkForXMLSpaceAttribute(TemplateParserState state) 831 { 832 for (int i = 0; i < tokenStream.getAttributeCount(); i++) 833 { 834 QName qName = tokenStream.getAttributeName(i); 835 836 if (isXMLSpaceAttribute(qName)) 837 { 838 boolean compress = !"preserve".equals(tokenStream.getAttributeValue(i)); 839 840 return state.compressWhitespace(compress); 841 } 842 } 843 844 return state; 845 } 846 847 /** 848 * Processes the text buffer and then adds an end element token. 849 */ 850 private void endElement(TemplateParserState state) 851 { 852 processTextBuffer(state); 853 854 tokenAccumulator.add(new EndElementToken(getLocation())); 855 } 856 857 /** 858 * Handler for Tapestry 5.0's "classic" <t:parameter> element. This 859 * turns into a {@link org.apache.tapestry5.internal.parser.ParameterToken} 860 * and the body and end element are provided normally. 861 */ 862 private void classicParameter(TemplateParserState state) 863 { 864 String parameterName = getSingleParameter("name"); 865 866 if (InternalUtils.isBlank(parameterName)) 867 throw new TapestryException("The name attribute of the <parameter> element must be specified.", 868 getLocation(), null); 869 870 ensureParameterWithinComponent(state); 871 872 tokenAccumulator.add(new ParameterToken(parameterName, getLocation())); 873 874 processBody(state.insideComponent(false)); 875 } 876 877 private void ensureParameterWithinComponent(TemplateParserState state) 878 { 879 if (!state.isInsideComponent()) 880 throw new RuntimeException( 881 "Block parameters are only allowed directly within component elements."); 882 } 883 884 /** 885 * Tapestry 5.1 uses a special namespace (usually mapped to "p:") and the 886 * name becomes the parameter element. 887 */ 888 private void parameterElement(TemplateParserState state) 889 { 890 ensureParameterWithinComponent(state); 891 892 if (tokenStream.getAttributeCount() > 0) 893 throw new TapestryException("A block parameter element does not allow any additional attributes. The element name defines the parameter name.", 894 getLocation(), null); 895 896 tokenAccumulator.add(new ParameterToken(tokenStream.getLocalName(), getLocation())); 897 898 processBody(state.insideComponent(false)); 899 } 900 901 /** 902 * Checks that a body element is empty. Returns after the body's close 903 * element. Adds a single body token (but not an 904 * end token). 905 */ 906 private void body() 907 { 908 tokenAccumulator.add(new BodyToken(getLocation())); 909 910 while (active) 911 { 912 switch (tokenStream.next()) 913 { 914 case END_ELEMENT: 915 return; 916 917 default: 918 throw new IllegalStateException(String.format("Content inside a Tapestry body element is not allowed (at %s). The content has been ignored.", getLocation())); 919 } 920 } 921 } 922 923 /** 924 * Driven by the <t:container> element, this state adds elements for 925 * its body but not its start or end tags. 926 * 927 * @param state 928 */ 929 private void container(TemplateParserState state) 930 { 931 while (active) 932 { 933 switch (tokenStream.next()) 934 { 935 case START_ELEMENT: 936 element(state); 937 break; 938 939 // The matching end-element for the container. Don't add a 940 // token. 941 942 case END_ELEMENT: 943 944 processTextBuffer(state); 945 946 return; 947 948 default: 949 textContent(state); 950 } 951 } 952 } 953 954 /** 955 * A block adds a token for its start tag and end tag and allows any content 956 * within. 957 */ 958 private void block(TemplateParserState state) 959 { 960 String blockId = getSingleParameter("id"); 961 962 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."); 963 964 tokenAccumulator.add(new BlockToken(blockId, getLocation())); 965 966 processBody(state.insideComponent(false)); 967 } 968 969 private String getSingleParameter(String attributeName) 970 { 971 String result = null; 972 973 for (int i = 0; i < tokenStream.getAttributeCount(); i++) 974 { 975 QName qName = tokenStream.getAttributeName(i); 976 977 if (isXMLSpaceAttribute(qName)) 978 continue; 979 980 if (qName.getLocalPart().equalsIgnoreCase(attributeName)) 981 { 982 result = tokenStream.getAttributeValue(i); 983 continue; 984 } 985 986 // Only the named attribute is allowed. 987 988 throw new TapestryException(String.format("Element <%s> does not support an attribute named '%s'. The only allowed attribute name is '%s'.", tokenStream 989 .getLocalName(), qName.toString(), attributeName), getLocation(), null); 990 } 991 992 return result; 993 } 994 995 private void validateId(String id, String messageKey) 996 { 997 if (id == null) 998 return; 999 1000 if (ID_PATTERN.matcher(id).matches()) 1001 return; 1002 1003 // Not a match. 1004 1005 throw new TapestryException(String.format(messageKey, id), getLocation(), null); 1006 } 1007 1008 private boolean isXMLSpaceAttribute(QName qName) 1009 { 1010 return XML_NAMESPACE_URI.equals(qName.getNamespaceURI()) 1011 && "space".equals(qName.getLocalPart()); 1012 } 1013 1014 /** 1015 * Processes text content if in the correct state, or throws an exception. 1016 * This is used as a default for matching 1017 * case statements. 1018 * 1019 * @param state 1020 */ 1021 private void textContent(TemplateParserState state) 1022 { 1023 switch (tokenStream.getEventType()) 1024 { 1025 case COMMENT: 1026 comment(state); 1027 break; 1028 1029 case CDATA: 1030 cdata(state); 1031 break; 1032 1033 case CHARACTERS: 1034 case SPACE: 1035 characters(); 1036 break; 1037 1038 default: 1039 unexpectedEventType(); 1040 } 1041 } 1042 1043 private void characters() 1044 { 1045 if (textStartLocation == null) 1046 textStartLocation = getLocation(); 1047 1048 textBuffer.append(tokenStream.getText()); 1049 } 1050 1051 private void cdata(TemplateParserState state) 1052 { 1053 processTextBuffer(state); 1054 1055 tokenAccumulator.add(new CDATAToken(tokenStream.getText(), getLocation())); 1056 } 1057 1058 private void comment(TemplateParserState state) 1059 { 1060 processTextBuffer(state); 1061 1062 String comment = tokenStream.getText(); 1063 1064 tokenAccumulator.add(new CommentToken(comment, getLocation())); 1065 } 1066 1067 /** 1068 * Processes the accumulated text in the text buffer as a text token. 1069 */ 1070 private void processTextBuffer(TemplateParserState state) 1071 { 1072 if (textBuffer.length() != 0) 1073 convertTextBufferToTokens(state); 1074 1075 textStartLocation = null; 1076 } 1077 1078 private void convertTextBufferToTokens(TemplateParserState state) 1079 { 1080 String text = textBuffer.toString(); 1081 1082 textBuffer.setLength(0); 1083 1084 if (state.isCompressWhitespace()) 1085 { 1086 text = compressWhitespaceInText(text); 1087 1088 if (InternalUtils.isBlank(text)) 1089 return; 1090 } 1091 1092 addTokensForText(text); 1093 } 1094 1095 /** 1096 * Reduces vertical whitespace to a single newline, then reduces horizontal 1097 * whitespace to a single space. 1098 * 1099 * @param text 1100 * @return compressed version of text 1101 */ 1102 private String compressWhitespaceInText(String text) 1103 { 1104 String linebreaksReduced = REDUCE_LINEBREAKS_PATTERN.matcher(text).replaceAll("\n"); 1105 1106 return REDUCE_WHITESPACE_PATTERN.matcher(linebreaksReduced).replaceAll(" "); 1107 } 1108 1109 /** 1110 * Scans the text, using a regular expression pattern, for expansion 1111 * patterns, and adds appropriate tokens for what 1112 * it finds. 1113 * 1114 * @param text 1115 * to add as 1116 * {@link org.apache.tapestry5.internal.parser.TextToken}s and 1117 * {@link org.apache.tapestry5.internal.parser.ExpansionToken}s 1118 */ 1119 private void addTokensForText(String text) 1120 { 1121 Matcher matcher = EXPANSION_PATTERN.matcher(text); 1122 1123 int startx = 0; 1124 1125 // The big problem with all this code is that everything gets assigned 1126 // to the 1127 // start of the text block, even if there are line breaks leading up to 1128 // it. 1129 // That's going to take a lot more work and there are bigger fish to 1130 // fry. In addition, 1131 // TAPESTRY-2028 means that the whitespace has likely been stripped out 1132 // of the text 1133 // already anyway. 1134 while (matcher.find()) 1135 { 1136 int matchStart = matcher.start(); 1137 1138 if (matchStart != startx) 1139 { 1140 String prefix = text.substring(startx, matchStart); 1141 tokenAccumulator.add(new TextToken(prefix, textStartLocation)); 1142 } 1143 1144 // Group 1 includes the real text of the expansion, with whitespace 1145 // around the 1146 // expression (but inside the curly braces) excluded. 1147 // But note that we run into a problem. The original 1148 // EXPANSION_PATTERN used a reluctant quantifier to match the 1149 // smallest instance of ${} possible. But if you have ${'}'} or 1150 // ${{'key': 'value'}} (maps, cf TAP5-1605) then you run into issues 1151 // b/c the expansion becomes {'key': 'value' which is wrong. 1152 // A fix to use greedy matching with negative lookahead to prevent 1153 // ${...}...${...} all matching a single expansion is close, but 1154 // has issues when an expansion is used inside a javascript function 1155 // (see TAP5-1620). The solution is to use the greedy 1156 // EXPANSION_PATTERN as before to bound the search for a single 1157 // expansion, then check for {} consistency, ignoring opening and 1158 // closing braces that occur within '' (the property expression 1159 // language doesn't support "" for strings). That should include: 1160 // 'This string has a } in it' and 'This string has a { in it.' 1161 // Note also that the property expression language doesn't support 1162 // escaping the string character ('), so we don't have to worry 1163 // about that. 1164 String expression = matcher.group(1); 1165 //count of 'open' braces. Expression ends when it hits 0. In most cases, 1166 // it should end up as 1 b/c "expression" is everything inside ${}, so 1167 // the following will typically not find the end of the expression. 1168 int openBraceCount = 1; 1169 int expressionEnd = expression.length(); 1170 boolean inQuote = false; 1171 for (int i = 0; i < expression.length(); i++) 1172 { 1173 char c = expression.charAt(i); 1174 //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). 1175 //note that I don't believe expression support escaped quotes... 1176 if (c == EXPANSION_STRING_DELIMITTER) 1177 { 1178 inQuote = !inQuote; 1179 continue; 1180 } else if (inQuote) 1181 { 1182 continue; 1183 } else if (c == CLOSE_BRACE) 1184 { 1185 openBraceCount--; 1186 if (openBraceCount == 0) 1187 { 1188 expressionEnd = i; 1189 break; 1190 } 1191 } else if (c == OPEN_BRACE) 1192 { 1193 openBraceCount++; 1194 } 1195 } 1196 if (expressionEnd < expression.length()) 1197 { 1198 //then we gobbled up some } that we shouldn't have... like the closing } of a javascript 1199 //function. 1200 tokenAccumulator.add(new ExpansionToken(expression.substring(0, expressionEnd), textStartLocation)); 1201 //can't just assign to 1202 startx = matcher.start(1) + expressionEnd + 1; 1203 } else 1204 { 1205 tokenAccumulator.add(new ExpansionToken(expression.trim(), textStartLocation)); 1206 1207 startx = matcher.end(); 1208 } 1209 } 1210 1211 // Catch anything after the final regexp match. 1212 1213 if (startx < text.length()) 1214 tokenAccumulator.add(new TextToken(text.substring(startx, text.length()), 1215 textStartLocation)); 1216 } 1217 1218 static enum Version 1219 { 1220 T_5_0(5, 0), T_5_1(5, 1), T_5_3(5, 3), T_5_4(5, 4); 1221 1222 private int major; 1223 private int minor; 1224 1225 1226 private Version(int major, int minor) 1227 { 1228 this.major = major; 1229 this.minor = minor; 1230 } 1231 1232 /** 1233 * Returns true if this Version is the same as, or ordered before the other Version. This is often used to enable new 1234 * template features for a specific version. 1235 */ 1236 public boolean sameOrEarlier(Version other) 1237 { 1238 if (other == null) 1239 return false; 1240 1241 if (this == other) 1242 return true; 1243 1244 return major <= other.major && minor <= other.minor; 1245 } 1246 } 1247 1248}