001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.wicket.markup.parser;
018
019import java.io.BufferedInputStream;
020import java.io.IOException;
021import java.io.InputStream;
022import java.io.StringReader;
023import java.text.ParseException;
024import java.util.Locale;
025
026import org.apache.wicket.markup.parser.XmlTag.TagType;
027import org.apache.wicket.markup.parser.XmlTag.TextSegment;
028import org.apache.wicket.util.io.FullyBufferedReader;
029import org.apache.wicket.util.io.IOUtils;
030import org.apache.wicket.util.io.XmlReader;
031import org.apache.wicket.util.lang.Args;
032import org.apache.wicket.util.parse.metapattern.parsers.TagNameParser;
033import org.apache.wicket.util.parse.metapattern.parsers.VariableAssignmentParser;
034import org.apache.wicket.util.string.Strings;
035
036/**
037 * A fairly shallow markup pull parser which parses a markup string of a given type of markup (for
038 * example, html, xml, vxml or wml) into ComponentTag and RawMarkup tokens.
039 *
040 * @author Jonathan Locke
041 * @author Juergen Donnerstag
042 */
043public final class XmlPullParser implements IXmlPullParser
044{
045        /** */
046        public static final String STYLE = "style";
047
048        /** */
049        public static final String SCRIPT = "script";
050
051        /**
052         * The encoding of the XML.
053         */
054        private String encoding;
055
056        /**
057         * A XML independent reader which loads the whole source data into memory and which provides
058         * convenience methods to access the data.
059         */
060        private FullyBufferedReader input;
061
062        /** temporary variable which will hold the name of the closing tag. */
063        private String skipUntilText;
064
065        /** The last substring selected from the input */
066        private CharSequence lastText;
067
068        /** Everything in between <!DOCTYPE ... > */
069        private CharSequence doctype;
070
071        /** The type of what is in lastText */
072        private HttpTagType lastType = HttpTagType.NOT_INITIALIZED;
073
074        /** The last tag found */
075        private XmlTag lastTag;
076
077        /**
078         * Construct.
079         */
080        public XmlPullParser()
081        {
082        }
083
084        @Override
085        public final String getEncoding()
086        {
087                return encoding;
088        }
089
090        @Override
091        public final CharSequence getDoctype()
092        {
093                return doctype;
094        }
095
096        @Override
097        public final CharSequence getInputFromPositionMarker(final int toPos)
098        {
099                return input.getSubstring(toPos);
100        }
101
102        @Override
103        public final CharSequence getInput(final int fromPos, final int toPos)
104        {
105                return input.getSubstring(fromPos, toPos);
106        }
107
108        /**
109         * Whatever will be in between the current index and the closing tag, will be ignored (and thus
110         * treated as raw markup (text). This is useful for tags like 'script'.
111         *
112         * @throws ParseException
113         */
114        private void skipUntil() throws ParseException
115        {
116                // this is a tag with non-XHTML text as body - skip this until the
117                // skipUntilText is found.
118                final int startIndex = input.getPosition();
119                final int tagNameLen = skipUntilText.length();
120
121                int pos = input.getPosition() - 1;
122                String endTagText = null;
123                int lastPos = 0;
124                while (!skipUntilText.equalsIgnoreCase(endTagText))
125                {
126                        pos = input.find("</", pos + 1);
127                        if ((pos == -1) || ((pos + (tagNameLen + 2)) >= input.size()))
128                        {
129                                throw new ParseException(
130                                        skipUntilText + " tag not closed" + getLineAndColumnText(), startIndex);
131                        }
132
133                        lastPos = pos + 2;
134                        endTagText = input.getSubstring(lastPos, lastPos + tagNameLen).toString();
135                }
136
137                input.setPosition(pos);
138                lastText = input.getSubstring(startIndex, pos);
139                lastType = HttpTagType.BODY;
140
141                // Check that the tag is properly closed
142                lastPos = input.find('>', lastPos + tagNameLen);
143                if (lastPos == -1)
144                {
145                        throw new ParseException(skipUntilText + " tag not closed" + getLineAndColumnText(),
146                                startIndex);
147                }
148
149                // Reset the state variable
150                skipUntilText = null;
151        }
152
153        /**
154         *
155         * @return line and column number
156         */
157        private String getLineAndColumnText()
158        {
159                return " (line " + input.getLineNumber() + ", column " + input.getColumnNumber() + ")";
160        }
161
162        /**
163         * @return XXX
164         * @throws ParseException
165         */
166        @Override
167        public final HttpTagType next() throws ParseException
168        {
169                // Reached end of markup file?
170                if (input.getPosition() >= input.size())
171                {
172                        return HttpTagType.NOT_INITIALIZED;
173                }
174
175                if (skipUntilText != null)
176                {
177                        skipUntil();
178                        return lastType;
179                }
180
181                // Any more tags in the markup?
182                final int openBracketIndex = input.find('<');
183
184                // Tag or Body?
185                if (input.charAt(input.getPosition()) != '<')
186                {
187                        // It's a BODY
188                        if (openBracketIndex == -1)
189                        {
190                                // There is no next matching tag.
191                                lastText = input.getSubstring(-1);
192                                input.setPosition(input.size());
193                                lastType = HttpTagType.BODY;
194                                return lastType;
195                        }
196
197                        lastText = input.getSubstring(openBracketIndex);
198                        input.setPosition(openBracketIndex);
199                        lastType = HttpTagType.BODY;
200                        return lastType;
201                }
202
203                // Determine the line number
204                input.countLinesTo(openBracketIndex);
205
206                // Get index of closing tag and advance past the tag
207                int closeBracketIndex = -1;
208
209                if (openBracketIndex != -1 && openBracketIndex < input.size() - 1)
210                {
211                        char nextChar = input.charAt(openBracketIndex + 1);
212
213                        if ((nextChar == '!') || (nextChar == '?'))
214                                closeBracketIndex = input.find('>', openBracketIndex);
215                        else
216                                closeBracketIndex = input.findOutOfQuotes('>', openBracketIndex);
217                }
218
219                if (closeBracketIndex == -1)
220                {
221                        throw new ParseException("No matching close bracket at" + getLineAndColumnText(),
222                                input.getPosition());
223                }
224
225                // Get the complete tag text
226                lastText = input.getSubstring(openBracketIndex, closeBracketIndex + 1);
227
228                // Get the tagtext between open and close brackets
229                String tagText = lastText.subSequence(1, lastText.length() - 1).toString();
230                if (tagText.length() == 0)
231                {
232                        throw new ParseException("Found empty tag: '<>' at" + getLineAndColumnText(),
233                                input.getPosition());
234                }
235
236                // Type of the tag, to be determined next
237                final TagType type;
238
239                // If the tag ends in '/', it's a "simple" tag like <foo/>
240                if (tagText.endsWith("/"))
241                {
242                        type = TagType.OPEN_CLOSE;
243                        tagText = tagText.substring(0, tagText.length() - 1);
244                }
245                else if (tagText.startsWith("/"))
246                {
247                        // The tag text starts with a '/', it's a simple close tag
248                        type = TagType.CLOSE;
249                        tagText = tagText.substring(1);
250                }
251                else
252                {
253                        // It must be an open tag
254                        type = TagType.OPEN;
255
256                        // If open tag and starts with "s" like "script" or "style", than ...
257                        if ((tagText.length() > STYLE.length()) &&
258                                ((tagText.charAt(0) == 's') || (tagText.charAt(0) == 'S')))
259                        {
260                                final String lowerCase = tagText.toLowerCase(Locale.ROOT);
261                                if (lowerCase.startsWith(SCRIPT))
262                                {
263                                        String typeAttr = "type=";
264                                        int idxOfType = lowerCase.indexOf(typeAttr);
265                                        if (idxOfType > 0)
266                                        {
267                                                // +1 to remove the ' or "
268                                                String typePrefix = lowerCase.substring(idxOfType + typeAttr.length() + 1);
269                                                if (typePrefix.startsWith("text/javascript") || typePrefix.startsWith("module"))
270                                                {
271                                                        // prepare to skip everything between the open and close tag
272                                                        skipUntilText = SCRIPT;
273                                                }
274                                                // any other type is assumed to be a template so it can contain child nodes.
275                                                // See WICKET-5288
276                                        }
277                                        else
278                                        {
279                                                // no type attribute so it is 'text/javascript'
280                                                // prepare to skip everything between the open and close tag
281                                                skipUntilText = SCRIPT;
282                                        }
283                                }
284                                else if (lowerCase.startsWith(STYLE))
285                                {
286                                        // prepare to skip everything between the open and close tag
287                                        skipUntilText = STYLE;
288                                }
289                        }
290                }
291
292                // Handle special tags like <!-- and <![CDATA ...
293                final char firstChar = tagText.charAt(0);
294                if ((firstChar == '!') || (firstChar == '?'))
295                {
296                        specialTagHandling(tagText, openBracketIndex, closeBracketIndex);
297
298                        input.countLinesTo(openBracketIndex);
299                        TextSegment text = new TextSegment(lastText, openBracketIndex, input.getLineNumber(),
300                                input.getColumnNumber());
301                        lastTag = new XmlTag(text, type);
302
303                        return lastType;
304                }
305
306                TextSegment text = new TextSegment(lastText, openBracketIndex, input.getLineNumber(),
307                        input.getColumnNumber());
308                XmlTag tag = new XmlTag(text, type);
309                lastTag = tag;
310
311                // Parse the tag text and populate tag attributes
312                if (parseTagText(tag, tagText))
313                {
314                        // Move to position after the tag
315                        input.setPosition(closeBracketIndex + 1);
316                        lastType = HttpTagType.TAG;
317                        return lastType;
318                }
319                else
320                {
321                        throw new ParseException("Malformed tag" + getLineAndColumnText(), openBracketIndex);
322                }
323        }
324
325        /**
326         * Handle special tags like &lt;!-- --&gt; or &lt;![CDATA[..]]&gt; or &lt;?xml&gt;
327         *
328         * @param tagText
329         * @param openBracketIndex
330         * @param closeBracketIndex
331         * @throws ParseException
332         */
333        protected void specialTagHandling(String tagText, final int openBracketIndex,
334                int closeBracketIndex) throws ParseException
335        {
336                // Handle comments
337                if (tagText.startsWith("!--"))
338                {
339                        // downlevel-revealed conditional comments e.g.: <!--[if (gt IE9)|!(IE)]><!-->
340                        if (tagText.contains("![endif]--"))
341                        {
342                                lastType = HttpTagType.CONDITIONAL_COMMENT_ENDIF;
343
344                                // Move to position after the tag
345                                input.setPosition(closeBracketIndex + 1);
346                                return;
347                        }
348
349                        // Conditional comment? E.g.
350                        // "<!--[if IE]><a href='test.html'>my link</a><![endif]-->"
351                        if (tagText.startsWith("!--[if ") && tagText.endsWith("]"))
352                        {
353                                int pos = input.find("]-->", openBracketIndex + 1);
354                                if (pos == -1)
355                                {
356                                        throw new ParseException("Unclosed conditional comment beginning at" +
357                                                getLineAndColumnText(), openBracketIndex);
358                                }
359
360                                pos += 4;
361                                lastText = input.getSubstring(openBracketIndex, pos);
362
363                                // Actually it is no longer a comment. It is now
364                                // up to the browser to select the section appropriate.
365                                input.setPosition(closeBracketIndex + 1);
366                                lastType = HttpTagType.CONDITIONAL_COMMENT;
367                        }
368                        else
369                        {
370                                // Normal comment section.
371                                // Skip ahead to "-->". Note that you can not simply test for
372                                // tagText.endsWith("--") as the comment might contain a '>'
373                                // inside.
374                                int pos = input.find("-->", openBracketIndex + 1);
375                                if (pos == -1)
376                                {
377                                        throw new ParseException("Unclosed comment beginning at" +
378                                                getLineAndColumnText(), openBracketIndex);
379                                }
380
381                                pos += 3;
382                                lastText = input.getSubstring(openBracketIndex, pos);
383                                lastType = HttpTagType.COMMENT;
384                                input.setPosition(pos);
385                        }
386                        return;
387                }
388
389                // The closing tag of a conditional comment, e.g.
390                // "<!--[if IE]><a href='test.html'>my link</a><![endif]-->
391                // and also <!--<![endif]-->"
392                if (tagText.equals("![endif]--"))
393                {
394                        lastType = HttpTagType.CONDITIONAL_COMMENT_ENDIF;
395                        input.setPosition(closeBracketIndex + 1);
396                        return;
397                }
398
399                // CDATA sections might contain "<" which is not part of an XML tag.
400                // Make sure escaped "<" are treated right
401                if (tagText.startsWith("!["))
402                {
403                        final String startText = (tagText.length() <= 8 ? tagText : tagText.substring(0, 8));
404                        if (startText.toUpperCase(Locale.ROOT).equals("![CDATA["))
405                        {
406                                int pos1 = openBracketIndex;
407                                do
408                                {
409                                        // Get index of closing tag and advance past the tag
410                                        closeBracketIndex = findChar('>', pos1);
411
412                                        if (closeBracketIndex == -1)
413                                        {
414                                                throw new ParseException("No matching close bracket at" +
415                                                        getLineAndColumnText(), input.getPosition());
416                                        }
417
418                                        // Get the tagtext between open and close brackets
419                                        tagText = input.getSubstring(openBracketIndex + 1, closeBracketIndex)
420                                                .toString();
421
422                                        pos1 = closeBracketIndex + 1;
423                                }
424                                while (tagText.endsWith("]]") == false);
425
426                                // Move to position after the tag
427                                input.setPosition(closeBracketIndex + 1);
428
429                                lastText = tagText;
430                                lastType = HttpTagType.CDATA;
431                                return;
432                        }
433                }
434
435                if (tagText.charAt(0) == '?')
436                {
437                        lastType = HttpTagType.PROCESSING_INSTRUCTION;
438
439                        // Move to position after the tag
440                        input.setPosition(closeBracketIndex + 1);
441                        return;
442                }
443
444                if (tagText.startsWith("!DOCTYPE"))
445                {
446                        lastType = HttpTagType.DOCTYPE;
447
448                        // Get the tagtext between open and close brackets
449                        doctype = input.getSubstring(openBracketIndex + 1, closeBracketIndex);
450
451                        // Move to position after the tag
452                        input.setPosition(closeBracketIndex + 1);
453                        return;
454                }
455
456                // Move to position after the tag
457                lastType = HttpTagType.SPECIAL_TAG;
458                input.setPosition(closeBracketIndex + 1);
459        }
460
461        /**
462         * @return MarkupElement
463         */
464        @Override
465        public final XmlTag getElement()
466        {
467                return lastTag;
468        }
469
470        /**
471         * @return The xml string from the last element
472         */
473        @Override
474        public final CharSequence getString()
475        {
476                return lastText;
477        }
478
479        /**
480         * @return The next XML tag
481         * @throws ParseException
482         */
483        public final XmlTag nextTag() throws ParseException
484        {
485                while (next() != HttpTagType.NOT_INITIALIZED)
486                {
487                        switch (lastType)
488                        {
489                                case TAG :
490                                        return lastTag;
491
492                                case BODY :
493                                        break;
494
495                                case COMMENT :
496                                        break;
497
498                                case CONDITIONAL_COMMENT :
499                                        break;
500
501                                case CDATA :
502                                        break;
503
504                                case PROCESSING_INSTRUCTION :
505                                        break;
506
507                                case SPECIAL_TAG :
508                                        break;
509                        }
510                }
511
512                return null;
513        }
514
515        /**
516         * Find the char but ignore any text within ".." and '..'
517         *
518         * @param ch
519         *            The character to search
520         * @param startIndex
521         *            Start index
522         * @return -1 if not found, else the index
523         */
524        private int findChar(final char ch, int startIndex)
525        {
526                char quote = 0;
527
528                for (; startIndex < input.size(); startIndex++)
529                {
530                        final char charAt = input.charAt(startIndex);
531                        if (quote != 0)
532                        {
533                                if (quote == charAt)
534                                {
535                                        quote = 0;
536                                }
537                        }
538                        else if ((charAt == '"') || (charAt == '\''))
539                        {
540                                quote = charAt;
541                        }
542                        else if (charAt == ch)
543                        {
544                                return startIndex;
545                        }
546                }
547
548                return -1;
549        }
550
551        /**
552         * Parse the given string.
553         * <p>
554         * Note: xml character encoding is NOT applied. It is assumed the input provided does have the
555         * correct encoding already.
556         *
557         * @param string
558         *            The input string
559         * @throws IOException
560         *             Error while reading the resource
561         */
562        @Override
563        public void parse(final CharSequence string) throws IOException
564        {
565                Args.notNull(string, "string");
566
567                this.input = new FullyBufferedReader(new StringReader(string.toString()));
568                this.encoding = null;
569        }
570
571        /**
572         * Reads and parses markup from an input stream, using UTF-8 encoding by default when not
573         * specified in XML declaration.
574         *
575         * @param in
576         *            The input stream to read and parse
577         * @throws IOException
578         *
579         * @see #parse(InputStream, String)
580         */
581        @Override
582        public void parse(final InputStream in) throws IOException
583        {
584                // When XML declaration does not specify encoding, it defaults to UTF-8
585                parse(in, "UTF-8");
586        }
587
588        /**
589         * Reads and parses markup from an input stream.
590         * <p>
591         * Note: The input is closed after parsing.
592         *
593         * @param inputStream
594         *            The input stream to read and parse
595         * @param encoding
596         *            The default character encoding of the input
597         * @throws IOException
598         */
599        @Override
600        public void parse(final InputStream inputStream, final String encoding) throws IOException
601        {
602                Args.notNull(inputStream, "inputStream");
603
604                try
605                {
606                        XmlReader xmlReader = new XmlReader(new BufferedInputStream(inputStream, 4000),
607                                encoding);
608                        this.input = new FullyBufferedReader(xmlReader);
609                        this.encoding = xmlReader.getEncoding();
610                }
611                finally
612                {
613                        IOUtils.closeQuietly(inputStream);
614                }
615        }
616
617        @Override
618        public final void setPositionMarker()
619        {
620                input.setPositionMarker(input.getPosition());
621        }
622
623        @Override
624        public final void setPositionMarker(final int pos)
625        {
626                input.setPositionMarker(pos);
627        }
628
629        @Override
630        public String toString()
631        {
632                return input.toString();
633        }
634
635        /**
636         * Parses the text between tags. For example, "a href=foo.html".
637         *
638         * @param tag
639         * @param tagText
640         *            The text between tags
641         * @return false in case of an error
642         * @throws ParseException
643         */
644        private boolean parseTagText(final XmlTag tag, final String tagText) throws ParseException
645        {
646                // Get the length of the tagtext
647                final int tagTextLength = tagText.length();
648
649                // If we match tagname pattern
650                final TagNameParser tagnameParser = new TagNameParser(tagText);
651                if (tagnameParser.matcher().lookingAt())
652                {
653                        // Extract the tag from the pattern matcher
654                        tag.name = tagnameParser.getName();
655                        tag.namespace = tagnameParser.getNamespace();
656
657                        // Are we at the end? Then there are no attributes, so we just
658                        // return the tag
659                        int pos = tagnameParser.matcher().end(0);
660                        if (pos == tagTextLength)
661                        {
662                                return true;
663                        }
664
665                        // Extract attributes
666                        final VariableAssignmentParser attributeParser = new VariableAssignmentParser(tagText);
667                        while (attributeParser.matcher().find(pos))
668                        {
669                                // Get key and value using attribute pattern
670                                String value = attributeParser.getValue();
671
672                                // In case like <html xmlns:wicket> will the value be null
673                                if (value == null)
674                                {
675                                        value = "";
676                                }
677
678                                // Set new position to end of attribute
679                                pos = attributeParser.matcher().end(0);
680
681                                // Chop off double quotes or single quotes
682                                if (value.startsWith("\"") || value.startsWith("\'"))
683                                {
684                                        value = value.substring(1, value.length() - 1);
685                                }
686
687                                // Trim trailing whitespace
688                                value = value.trim();
689
690                                // Unescape
691                                value = Strings.unescapeMarkup(value).toString();
692
693                                // Get key
694                                final String key = attributeParser.getKey();
695
696                                // Put the attribute in the attributes hash
697                                if (null != tag.getAttributes().put(key, value))
698                                {
699                                        throw new ParseException("Same attribute found twice: " + key +
700                                                getLineAndColumnText(), input.getPosition());
701                                }
702
703                                // The input has to match exactly (no left over junk after
704                                // attributes)
705                                if (pos == tagTextLength)
706                                {
707                                        return true;
708                                }
709                        }
710
711                        return true;
712                }
713
714                return false;
715        }
716}