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