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 }