All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.apache.jasper.compiler.Parser Maven / Gradle / Ivy

There is a newer version: 2.3.3
Show newest version
/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 1997-2010 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * https://glassfish.dev.java.net/public/CDDL+GPL_1_1.html
 * or packager/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at packager/legal/LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 *
 *
 * This file incorporates work covered by the following copyright and
 * permission notice:
 *
 * Copyright 2004 The Apache Software Foundation
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.jasper.compiler;

import java.io.CharArrayWriter;
import java.io.FileNotFoundException;
import java.net.URL;
// START GlassFish 750
import java.util.concurrent.ConcurrentHashMap;
// START GlassFish 750
import java.util.Iterator;
import java.util.List;

import javax.servlet.jsp.tagext.TagAttributeInfo;
import javax.servlet.jsp.tagext.TagFileInfo;
import javax.servlet.jsp.tagext.TagInfo;
import javax.servlet.jsp.tagext.TagLibraryInfo;

import org.apache.jasper.Constants;
import org.apache.jasper.JasperException;
import org.apache.jasper.JspCompilationContext;
import org.xml.sax.Attributes;
import org.xml.sax.helpers.AttributesImpl;

/**
 * This class implements a parser for a JSP page (non-xml view).
 * JSP page grammar is included here for reference.  The token '#'
 * that appears in the production indicates the current input token
 * location in the production.
 * 
 * @author Kin-man Chung
 * @author Shawn Bayern
 * @author Mark Roth
 */

class Parser implements TagConstants {

    private ParserController parserController;
    private JspCompilationContext ctxt;
    private JspReader reader;
    private Mark start;
    private ErrorDispatcher err;
    private int scriptlessCount;
    private boolean isTagFile;
    private boolean directivesOnly;
    private URL jarFileUrl;
    private PageInfo pageInfo;
    private boolean errorOnUndeclaredNamespace;

    // Virtual body content types, to make parsing a little easier.
    // These are not accessible from outside the parser.
    private static final String JAVAX_BODY_CONTENT_PARAM = 
        "JAVAX_BODY_CONTENT_PARAM";
    private static final String JAVAX_BODY_CONTENT_PLUGIN = 
        "JAVAX_BODY_CONTENT_PLUGIN";
    private static final String JAVAX_BODY_CONTENT_TEMPLATE_TEXT = 
        "JAVAX_BODY_CONTENT_TEMPLATE_TEXT";

    /**
     * The constructor
     */
    private Parser(ParserController pc, JspReader reader, boolean isTagFile,
                   boolean directivesOnly, URL jarFileUrl, boolean hasBom) {
	this.parserController = pc;
	this.ctxt = pc.getJspCompilationContext();
	this.pageInfo = pc.getCompiler().getPageInfo();
	this.err = pc.getCompiler().getErrorDispatcher();
	this.reader = reader;
        this.scriptlessCount = 0;
	this.isTagFile = isTagFile;
	this.directivesOnly = directivesOnly;
	this.jarFileUrl = jarFileUrl;
        start = reader.mark();
    }

    /**
     * The main entry for Parser
     * 
     * @param pc The ParseController, use for getting other objects in compiler
     *		 and for parsing included pages
     * @param reader To read the page
     * @param parent The parent node to this page, null for top level page
     * @return list of nodes representing the parsed page
     */
    public static Node.Nodes parse(ParserController pc,
                                   String path,
				   JspReader reader,
				   Node parent,
				   boolean isTagFile,
				   boolean directivesOnly,
				   URL jarFileUrl,
				   String pageEnc,
				   String jspConfigPageEnc,
                                   boolean isDefaultPageEncoding,
                                   boolean hasBom)
		throws JasperException {

	Parser parser = new Parser(pc, reader, isTagFile, directivesOnly,
                                   jarFileUrl, hasBom);

	Node.Root root = new Node.Root(reader.mark(), parent, false);
	root.setPageEncoding(pageEnc);
	root.setJspConfigPageEncoding(jspConfigPageEnc);
	root.setIsDefaultPageEncoding(isDefaultPageEncoding);
        root.setHasBom(hasBom);

        if (hasBom) {
            // Consume (remove) BOM, so it won't appear in page output
            char bomChar = (char) reader.nextChar();
            if (bomChar != 0xFEFF) {
                parser.err.jspError(
                        reader.mark(),
                        "jsp.error.invalidBom",
                        Integer.toHexString(bomChar).toUpperCase());
            }
        }

	if (directivesOnly) {
	    parser.parseTagFileDirectives(root);
	    return new Node.Nodes(root);
	}

	// For the Top level page, add inlcude-prelude and include-coda
	PageInfo pageInfo = pc.getCompiler().getPageInfo();
	if (parent == null) {
	    parser.addInclude(root, pageInfo.getIncludePrelude());
	}
	while (reader.hasMoreInput()) {
	    parser.parseElements(root);
	}
	if (parent == null) {
	    parser.addInclude(root, pageInfo.getIncludeCoda());
	    parser.pageInfo.setRootPath(path);
	}

	Node.Nodes page = new Node.Nodes(root);
	return page;
    }

    /**
     * Attributes ::= (S Attribute)* S?
     */
    Attributes parseAttributes() throws JasperException {
	AttributesImpl attrs = new AttributesImpl();

	reader.skipSpaces();
	while (parseAttribute(attrs))
	    reader.skipSpaces();

	return attrs;
    }

    /**
     * Parse Attributes for a reader, provided for external use
     */
    public static Attributes parseAttributes(ParserController pc,
					     JspReader reader)
		throws JasperException {
	Parser tmpParser = new Parser(pc, reader, false, false, null, false);
	return tmpParser.parseAttributes();
    }

    /**
     * Attribute ::= Name S? Eq S?
     *               (   '"<%=' RTAttributeValueDouble
     *                 | '"' AttributeValueDouble
     *                 | "'<%=" RTAttributeValueSingle
     *                 | "'" AttributeValueSingle
     *               }
     * Note: JSP and XML spec does not allow while spaces around Eq.  It is
     * added to be backward compatible with Tomcat, and with other xml parsers.
     */
    private boolean parseAttribute(AttributesImpl attrs)
	        throws JasperException {

	// Get the qualified name
	String qName = parseName();
	if (qName == null)
	    return false;

	// Determine prefix and local name components
	String localName = qName;
	String uri = "";
	int index = qName.indexOf(':');
	if (index != -1) {
	    String prefix = qName.substring(0, index);
	    uri = pageInfo.getURI(prefix);
	    if (uri == null) {
		err.jspError(reader.mark(),
			     "jsp.error.attribute.invalidPrefix", prefix);
	    }
	    localName = qName.substring(index+1);
	}

 	reader.skipSpaces();
	if (!reader.matches("="))
	    err.jspError(reader.mark(), "jsp.error.attribute.noequal");

 	reader.skipSpaces();
	char quote = (char) reader.nextChar();
	if (quote != '\'' && quote != '"')
	    err.jspError(reader.mark(), "jsp.error.attribute.noquote");

 	String watchString = "";
	if (reader.matches("<%="))
	    watchString = "%>";
	watchString = watchString + quote;
	
	String attrValue = parseAttributeValue(watchString);
	attrs.addAttribute(uri, localName, qName, "CDATA", attrValue);
	return true;
    }

    /**
     * Name ::= (Letter | '_' | ':') (Letter | Digit | '.' | '_' | '-' | ':')*
     */
    private String parseName() throws JasperException {
	char ch = (char)reader.peekChar();
	if (Character.isLetter(ch) || ch == '_' || ch == ':') {
	    StringBuilder buf = new StringBuilder();
	    buf.append(ch);
	    reader.nextChar();
	    ch = (char)reader.peekChar();
	    while (Character.isLetter(ch) || Character.isDigit(ch) ||
			ch == '.' || ch == '_' || ch == '-' || ch == ':') {
		buf.append(ch);
		reader.nextChar();
		ch = (char) reader.peekChar();
	    }
	    return buf.toString();
	}
	return null;
    }

    /**
     * AttributeValueDouble ::= (QuotedChar - '"')*
     *				('"' | )
     * RTAttributeValueDouble ::= ((QuotedChar - '"')* - ((QuotedChar-'"')'%>"')
     *				  ('%>"' | TRANSLATION_ERROR)
     */
    private String parseAttributeValue(String watch) throws JasperException {
	Mark start = reader.mark();
	Mark stop = reader.skipUntilIgnoreEsc(watch);
	if (stop == null) {
	    err.jspError(start, "jsp.error.attribute.unterminated", watch);
	}

	String ret = parseQuoted(reader.getText(start, stop));
	if (watch.length() == 1)	// quote
	    return ret;

	// putback delimiter '<%=' and '%>', since they are needed if the
	// attribute does not allow RTexpression.
	return "<%=" + ret + "%>";
    }

    /**
     * QuotedChar ::=   '''
     *	              | '"'
     *                | '\\'
     *                | '\"'
     *                | "\'"
     *                | '\>'
     *                | '\$'
     *                | '\#'
     *                | Char
     */
    // Need to preserve "\$", and "\#" here since they
    // may be send to EL processor.
    private String parseQuoted(String tx) {
	StringBuilder buf = new StringBuilder();
	int size = tx.length();
	int i = 0;
	while (i < size) {
	    char ch = tx.charAt(i);
	    if (ch == '&') {
		if (i+5 < size && tx.charAt(i+1) == 'a'
		        && tx.charAt(i+2) == 'p' && tx.charAt(i+3) == 'o'
		        && tx.charAt(i+4) == 's' && tx.charAt(i+5) == ';') {
		    buf.append('\'');
		    i += 6;
		} else if (i+5 < size && tx.charAt(i+1) == 'q'
			   && tx.charAt(i+2) == 'u' && tx.charAt(i+3) == 'o'
			   && tx.charAt(i+4) == 't' && tx.charAt(i+5) == ';') {
		    buf.append('"');
		    i += 6;
		} else {
		    buf.append(ch);
		    ++i;
		}
	    } else if (ch == '\\' && i+1 < size) {
		ch = tx.charAt(i+1);
		if (ch == '\\' || ch == '\"' || ch == '\'' || ch == '>') {
		    buf.append(ch);
		    i += 2;
		} else {
		    buf.append('\\');
		    ++i;
		}
	    } else {
		buf.append(ch);
		++i;
	    }
	}
	return buf.toString();
    }

    private String parseScriptText(String tx) {
	CharArrayWriter cw = new CharArrayWriter();
	int size = tx.length();
	int i = 0;
	while (i < size) {
	    char ch = tx.charAt(i);
	    if (i+2 < size && ch == '%' && tx.charAt(i+1) == '\\'
		    && tx.charAt(i+2) == '>') {
		cw.write('%');
		cw.write('>');
		i += 3;
	    } else {
		cw.write(ch);
		++i;
	    }
	}
	cw.close();
	return cw.toString();
    }

    /*
     * Invokes parserController to parse the included page
     */
    private void processIncludeDirective(String file, Node parent) 
		throws JasperException {
	if (file == null) {
	    return;
	}

	try {
	    parserController.parse(file, parent, jarFileUrl);
	} catch (FileNotFoundException ex) {
	    err.jspError(start, "jsp.error.file.not.found", file);
	} catch (Exception ex) {
	    err.jspError(start, ex);
	}
    }

    /*
     * Parses a page directive with the following syntax:
     *   PageDirective ::= ( S Attribute)*
     */
    private void parsePageDirective(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	Node.PageDirective n = new Node.PageDirective(attrs, start, parent);

	/*
	 * A page directive may contain multiple 'import' attributes, each of
	 * which consists of a comma-separated list of package names.
	 * Store each list with the node, where it is parsed.
	 */
	for (int i = 0; i < attrs.getLength(); i++) {
	    if ("import".equals(attrs.getQName(i))) {
		n.addImport(attrs.getValue(i));
	    }
	}
    }

    /*
     * Parses an include directive with the following syntax:
     *   IncludeDirective ::= ( S Attribute)*
     */
    private void parseIncludeDirective(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();

	// Included file expanded here
	Node includeNode = new Node.IncludeDirective(attrs, start, parent);
	processIncludeDirective(attrs.getValue("file"), includeNode);
    }

    /**
     * Add a list of files.  This is used for implementing include-prelude
     * and include-coda of jsp-config element in web.xml
     */
    private void addInclude(Node parent, List files) throws JasperException {
        if( files != null ) {
            Iterator iter = files.iterator();
            while (iter.hasNext()) {
                String file = (String) iter.next();
                AttributesImpl attrs = new AttributesImpl();
                attrs.addAttribute("", "file", "file", "CDATA", file);

                // Create a dummy Include directive node
                Node includeNode = new Node.IncludeDirective(attrs, 
                    reader.mark(), parent);
                processIncludeDirective(file, includeNode);
            }
        }
    }

    /*
     * Parses a taglib directive with the following syntax:
     *   Directive ::= ( S Attribute)*
     */
    private void parseTaglibDirective(Node parent) throws JasperException {

	Attributes attrs = parseAttributes();
	String uri = attrs.getValue("uri");
	String prefix = attrs.getValue("prefix");
	if (prefix != null) {
            Mark prevMark = pageInfo.getNonCustomTagPrefix(prefix);
            if (prevMark != null) {
                err.jspError(reader.mark(), "jsp.error.prefix.use_before_dcl",
                    prefix, prevMark.getFile(), "" + prevMark.getLineNumber());
            }
	    if (uri != null) {
		String uriPrev = pageInfo.getURI(prefix);
		if (uriPrev != null && !uriPrev.equals(uri)) {
		    err.jspError(reader.mark(), "jsp.error.prefix.refined",
			prefix, uri, uriPrev);
		}
                /* GlassFish 750
		if (pageInfo.getTaglib(uri) == null) {
		    String[] location = ctxt.getTldLocation(uri);
                    TagLibraryInfoImpl taglib = null;
                    try {
		        taglib = new TagLibraryInfoImpl(ctxt,
                                                        parserController,
                                                        prefix,
                                                        uri,
                                                        location,
                                                        err);
                    } catch (JasperException je) {
                        err.throwException(reader.mark(), je);
                    }
		    pageInfo.addTaglib(uri, taglib);
		}
                */
                // START GlassFish 750
                ConcurrentHashMap taglibs =
                    ctxt.getTaglibs();
                TagLibraryInfoImpl taglib = taglibs.get(uri);
                if (taglib == null) {
                    synchronized (taglibs) {
                        taglib = taglibs.get(uri);
                        if (taglib == null) {
                            String[] location = ctxt.getTldLocation(uri);
                            try {
                                taglib = new TagLibraryInfoImpl(
                                                        ctxt,
                                                        parserController,
                                                        prefix,
                                                        uri,
                                                        location,
                                                        err);
                            } catch (JasperException je) {
                                err.throwException(reader.mark(), je);
                            }
                            ctxt.addTaglib(uri, taglib);
                            pageInfo.addTaglib(uri, taglib);
                        }
                    }
                }
                if (pageInfo.getTaglib(uri) == null) {
                    pageInfo.addTaglib(uri,
                                       new TagLibraryInfoImpl(prefix,
                                                              uri,
                                                              taglib,
                                                              pageInfo));
                }
                // END GlassFish 750  
		pageInfo.addPrefixMapping(prefix, uri);
	    } else {
		String tagdir = attrs.getValue("tagdir");
		if (tagdir != null) {
		    String urnTagdir = URN_JSPTAGDIR + tagdir;
		    if (pageInfo.getTaglib(urnTagdir) == null) {
			pageInfo.addTaglib(urnTagdir,
					   new ImplicitTagLibraryInfo(
                                                   ctxt,
						   parserController,
						   prefix, 
						   tagdir,
						   err));
		    }
		    pageInfo.addPrefixMapping(prefix, urnTagdir);
		}
	    }
	}

	new Node.TaglibDirective(attrs, start, parent);
    }

    /*
     * Parses a directive with the following syntax:
     *   Directive ::= S? (   'page' PageDirective
     *			    | 'include' IncludeDirective
     *			    | 'taglib' TagLibDirective)
     *		       S? '%>'
     *
     *   TagDirective ::= S? ('tag' PageDirective
     *			    | 'include' IncludeDirective
     *			    | 'taglib' TagLibDirective)
     *                      | 'attribute AttributeDirective
     *                      | 'variable VariableDirective
     *		       S? '%>'
     */
    private void parseDirective(Node parent) throws JasperException {
	reader.skipSpaces();

	String directive = null;
	if (reader.matches("page")) {
	    directive = "<%@ page";
	    if (isTagFile) {
		err.jspError(reader.mark(), "jsp.error.directive.istagfile",
					    directive);
	    }
	    parsePageDirective(parent);
	} else if (reader.matches("include")) {
	    directive = "<%@ include";
	    parseIncludeDirective(parent);
	} else if (reader.matches("taglib")) {
	    if (directivesOnly) {
	        // No need to get the tagLibInfo objects.  This alos suppresses
	        // parsing of any tag files used in this tag file.
	        return;
	    }
	    directive = "<%@ taglib";
	    parseTaglibDirective(parent);
	} else if (reader.matches("tag")) {
	    directive = "<%@ tag";
	    if (!isTagFile) {
		err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
					    directive);
	    }
	    parseTagDirective(parent);
	} else if (reader.matches("attribute")) {
	    directive = "<%@ attribute";
	    if (!isTagFile) {
		err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
					    directive);
	    }
	    parseAttributeDirective(parent);
	} else if (reader.matches("variable")) {
	    directive = "<%@ variable";
	    if (!isTagFile) {
		err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
					    directive);
	    }
	    parseVariableDirective(parent);
	} else {
	    err.jspError(reader.mark(), "jsp.error.invalid.directive",
                reader.parseToken(false));
	}

	reader.skipSpaces();
	if (!reader.matches("%>")) {
	    err.jspError(start, "jsp.error.unterminated", directive);
	}
    }
	
    /*
     * Parses a directive with the following syntax:
     *
     *   XMLJSPDirectiveBody ::= S? (   ( 'page' PageDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                               | ( 'include' IncludeDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                           | 
     *
     *   XMLTagDefDirectiveBody ::= (   ( 'tag' TagDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                                | ( 'include' IncludeDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                                | ( 'attribute' AttributeDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                                | ( 'variable' VariableDirectiveAttrList
     *                                    S? ( '/>' | ( '>' S? ETag ) )
     *                              )
     *                            | 
     */
    private void parseXMLDirective(Node parent) throws JasperException {
       reader.skipSpaces();

        String eTag = null;
       if (reader.matches("page")) {
            eTag = "jsp:directive.page";
           if (isTagFile) {
               err.jspError(reader.mark(), "jsp.error.directive.istagfile",
                                           "<" + eTag);
           }
           parsePageDirective(parent);
       } else if (reader.matches("include")) {
            eTag = "jsp:directive.include";
           parseIncludeDirective(parent);
       } else if (reader.matches("tag")) {
            eTag = "jsp:directive.tag";
           if (!isTagFile) {
               err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
                                           "<" + eTag);
           }
           parseTagDirective(parent);
       } else if (reader.matches("attribute")) {
            eTag = "jsp:directive.attribute";
           if (!isTagFile) {
               err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
                                           "<" + eTag);
           }
           parseAttributeDirective(parent);
       } else if (reader.matches("variable")) {
            eTag = "jsp:directive.variable";
           if (!isTagFile) {
               err.jspError(reader.mark(), "jsp.error.directive.isnottagfile",
                                           "<" + eTag);
           }
           parseVariableDirective(parent);
       } else {
	   err.jspError(reader.mark(), "jsp.error.invalid.directive",
               reader.parseToken(false));
       }

       reader.skipSpaces();
        if( reader.matches( ">" ) ) {
            reader.skipSpaces();
            if( !reader.matchesETag( eTag ) ) {
                err.jspError(start, "jsp.error.unterminated", "<" + eTag );
            }
        }
        else if( !reader.matches( "/>" ) ) {
            err.jspError(start, "jsp.error.unterminated", "<" + eTag );
        }
    }

    /*
     * Parses a tag directive with the following syntax:
     *   PageDirective ::= ( S Attribute)*
     */
    private void parseTagDirective(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	Node.TagDirective n = new Node.TagDirective(attrs, start, parent);

        /*
         * A page directive may contain multiple 'import' attributes, each of
         * which consists of a comma-separated list of package names.
         * Store each list with the node, where it is parsed.
         */
        for (int i = 0; i < attrs.getLength(); i++) {
            if ("import".equals(attrs.getQName(i))) {
                n.addImport(attrs.getValue(i));
            }
        }
    }

    /*
     * Parses a attribute directive with the following syntax:
     *   AttributeDirective ::= ( S Attribute)*
     */
    private void parseAttributeDirective(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
        new Node.AttributeDirective(attrs, start, parent);
    }

    /*
     * Parses a variable directive with the following syntax:
     *   PageDirective ::= ( S Attribute)*
     */
    private void parseVariableDirective(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
        new Node.VariableDirective(attrs, start, parent);
    }

    /*
     * JSPCommentBody ::= (Char* - (Char* '--%>')) '--%>'
     */
    private void parseComment(Node parent) throws JasperException {	
	start = reader.mark();
	Mark stop = reader.skipUntil("--%>");
	if (stop == null) {
	    err.jspError(start, "jsp.error.unterminated", "<%--");
	}

	new Node.Comment(reader.getText(start, stop), start, parent);
    }

    /*
     * DeclarationBody ::= (Char* - (char* '%>')) '%>'
     */
    private void parseDeclaration(Node parent) throws JasperException {
	start = reader.mark();
	Mark stop = reader.skipUntil("%>");
	if (stop == null) {
	    err.jspError(start, "jsp.error.unterminated", "<%!");
	}

	new Node.Declaration(parseScriptText(reader.getText(start, stop)),
			     start, parent);
    }

    /*
     * XMLDeclarationBody ::=   ( S? '/>' )
     *                        | ( S? '>' (Char* - (char* '<')) CDSect?)* ETag
     *                        | 
     * CDSect ::= CDStart CData CDEnd
     * CDStart ::= '' Char*))
     * CDEnd ::= ']]>'
     */
    private void parseXMLDeclaration(Node parent) throws JasperException {
        reader.skipSpaces();
        if( !reader.matches( "/>" ) ) {
            if( !reader.matches( ">" ) ) {
                err.jspError(start, "jsp.error.unterminated",
                        "<jsp:declaration>");
            }
	    Mark stop;
            String text;
            while (true) {
                start = reader.mark();
                stop = reader.skipUntil("<");
                if (stop == null) {
                    err.jspError(start, "jsp.error.unterminated",
                        "<jsp:declaration>");
                }
		text = parseScriptText(reader.getText(start, stop));
                new Node.Declaration(text, start, parent);
                if (reader.matches("![CDATA[")) {
                    start = reader.mark();
                    stop = reader.skipUntil("]]>");
                    if (stop == null) {
                        err.jspError(start, "jsp.error.unterminated", "CDATA");
                    }
		    text = parseScriptText(reader.getText(start, stop));
                    new Node.Declaration(text, start, parent);
                }
                else {
                    break;
                }
	    }
		
            if (!reader.matchesETagWithoutLessThan( "jsp:declaration" ) ) {
                err.jspError(start, "jsp.error.unterminated",
                        "<jsp:declaration>");
            }
        }
    }

    /*
     * ExpressionBody ::= (Char* - (char* '%>')) '%>'
     */
    private void parseExpression(Node parent) throws JasperException {
	start = reader.mark();
	Mark stop = reader.skipUntil("%>");
	if (stop == null) {
	    err.jspError(start, "jsp.error.unterminated", "<%=");
	}

	new Node.Expression(parseScriptText(reader.getText(start, stop)),
			    start, parent);
    }

    /*
     * XMLExpressionBody ::=   ( S? '/>' )
     *                       | ( S? '>' (Char* - (char* '<')) CDSect?)* ETag )
     *                       | 
     */
    private void parseXMLExpression(Node parent) throws JasperException {
        reader.skipSpaces();
        if( !reader.matches( "/>" ) ) {
            if( !reader.matches( ">" ) ) {
                err.jspError(start, "jsp.error.unterminated",
                    "<jsp:expression>");
            }
            Mark stop;
            String text;
            while (true) {
                start = reader.mark();
                stop = reader.skipUntil("<");
                if (stop == null) {
                    err.jspError(start, "jsp.error.unterminated",
                        "<jsp:expression>");
                }
                text = parseScriptText(reader.getText(start, stop));
                new Node.Expression(text, start, parent);
                if (reader.matches("![CDATA[")) {
                    start = reader.mark();
                    stop = reader.skipUntil("]]>");
                    if (stop == null) {
                        err.jspError(start, "jsp.error.unterminated", "CDATA");
                    }
                    text = parseScriptText(reader.getText(start, stop));
                    new Node.Expression(text, start, parent);
                }
                else {
                    break;
                }
            }
            if (!reader.matchesETagWithoutLessThan( "jsp:expression" )) {
                err.jspError(start, "jsp.error.unterminated",
                    "<jsp:expression>");
            }
        }
    }

    /*
     * ELExpressionBody
     * (following "${" or "#{"to first unquoted "}")
     * // XXX add formal production and confirm implementation against it,
     * //     once it's decided
     */
    private void parseELExpression(Node parent, String typeEL)
            throws JasperException {
        start = reader.mark();
        boolean singleQuoted = false, doubleQuoted = false;
        int curl = 0;
        int currentChar;
        do {
            // XXX could move this logic to JspReader
            currentChar = reader.nextChar();
            if (currentChar == '\\' && (singleQuoted || doubleQuoted)) {
                // skip character following '\' within quotes
                reader.nextChar();
                currentChar = reader.nextChar();
            }
            if (currentChar == -1)
                err.jspError(start, "jsp.error.unterminated", typeEL);
            if (currentChar == '"')
                doubleQuoted = !doubleQuoted;
            else if (currentChar == '\'')
                singleQuoted = !singleQuoted;
            else if (currentChar == '{')
                curl++;
            else if (currentChar == '}')
                curl--;
        } while (currentChar != '}' || curl >= 0 || singleQuoted || doubleQuoted);

        String text = typeEL + reader.getText(start, reader.mark());
        new Node.ELExpression(text, start, parent);
    }

    /*
     * ScriptletBody ::= (Char* - (char* '%>')) '%>'
     */
    private void parseScriptlet(Node parent) throws JasperException {
	start = reader.mark();
	Mark stop = reader.skipUntil("%>");
	if (stop == null) {
	    err.jspError(start, "jsp.error.unterminated", "<%");
	}

	new Node.Scriptlet(parseScriptText(reader.getText(start, stop)),
			   start, parent);
    }

    /*
     * XMLScriptletBody ::=   ( S? '/>' )
     *                      | ( S? '>' (Char* - (char* '<')) CDSect?)* ETag )
     *                      | 
     */
    private void parseXMLScriptlet(Node parent) throws JasperException {
        reader.skipSpaces();
        if( !reader.matches( "/>" ) ) {
            if( !reader.matches( ">" ) ) {
                err.jspError(start, "jsp.error.unterminated",
                    "<jsp:scriptlet>");
            }
            Mark stop;
            String text;
            while (true) {
                start = reader.mark();
                stop = reader.skipUntil("<");
                if (stop == null) {
                    err.jspError(start, "jsp.error.unterminated",
                        "<jsp:scriptlet>");
                }
                text = parseScriptText(reader.getText(start, stop));
                new Node.Scriptlet(text, start, parent);
                if (reader.matches("![CDATA[")) {
                    start = reader.mark();
                    stop = reader.skipUntil("]]>");
                    if (stop == null) {
                        err.jspError(start, "jsp.error.unterminated", "CDATA");
                    }
                    text = parseScriptText(reader.getText(start, stop));
                    new Node.Scriptlet(text, start, parent);
                }
                else {
                    break;
                }
            }

            if (!reader.matchesETagWithoutLessThan( "jsp:scriptlet" )) {
                err.jspError(start, "jsp.error.unterminated",
                    "<jsp:scriptlet>");
            }
        }
    }
	
    /**
     * Param ::= '' S? ( ' )
     *                   S? ETag
     *                 )
     *               | ( '>' S? Param* ETag )
     *
     * EmptyBody ::=   '/>'
     *               | ( '>' ETag )
     *               | ( '>' S? '' Param* ''
     */
    private void parseInclude(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node includeNode = new Node.IncludeAction( attrs, start, parent );
        
        parseOptionalBody(includeNode, "jsp:include", 
			  JAVAX_BODY_CONTENT_PARAM);
    }

    /*
     * For Forward:
     * StdActionContent ::= Attributes ParamBody
     */
    private void parseForward(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node forwardNode = new Node.ForwardAction( attrs, start, parent );
        
        parseOptionalBody(forwardNode, "jsp:forward",
			  JAVAX_BODY_CONTENT_PARAM);
    }

    private void parseInvoke(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node invokeNode = new Node.InvokeAction(attrs, start, parent);
        
        parseEmptyBody(invokeNode, "jsp:invoke");
    }

    private void parseDoBody(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node doBodyNode = new Node.DoBodyAction(attrs, start, parent);
        
        parseEmptyBody(doBodyNode, "jsp:doBody");
    }

    private void parseElement(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node elementNode = new Node.JspElement(attrs, start, parent);
        
        parseOptionalBody( elementNode, "jsp:element", 
            TagInfo.BODY_CONTENT_JSP );
    }

    /*
     * For GetProperty:
     * StdActionContent ::= Attributes EmptyBody
     */
    private void parseGetProperty(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node getPropertyNode = new Node.GetProperty( attrs, start, parent );
        
        parseOptionalBody(getPropertyNode, "jsp:getProperty",
			  TagInfo.BODY_CONTENT_EMPTY);
    }

    /*
     * For SetProperty:
     * StdActionContent ::= Attributes EmptyBody
     */
    private void parseSetProperty(Node parent) throws JasperException {
	Attributes attrs = parseAttributes();
	reader.skipSpaces();

        Node setPropertyNode = new Node.SetProperty( attrs, start, parent );
        
        parseOptionalBody(setPropertyNode, "jsp:setProperty",
			  TagInfo.BODY_CONTENT_EMPTY);
    }

    /*
     * EmptyBody ::=   '/>'
     *               | ( '>' ETag )
     *               | ( '>' S? '") ) {
            // Done
        }
        else if( reader.matches( ">" ) ) {
            if( reader.matchesETag( tag ) ) {
                // Done
            }
            else if( reader.matchesOptionalSpacesFollowedBy(
                "' ETag )
     *               | ( '>' S? '' Body ETag )
     *
     * ScriptlessActionBody ::=   JspAttributeAndBody 
     *                          | ( '>' ScriptlessBody ETag )
     * 
     * TagDependentActionBody ::=   JspAttributeAndBody
     *                            | ( '>' TagDependentBody ETag )
     *
     */
    private void parseOptionalBody( Node parent, String tag, String bodyType ) 
        throws JasperException 
    {
	if (reader.matches("/>")) {
	    // EmptyBody
	    return;
	}

	if (!reader.matches(">")) {
	    err.jspError(reader.mark(), "jsp.error.unterminated",
			 "<" + tag );
	}
        
        if( reader.matchesETag( tag ) ) {
            // EmptyBody
            return;
        }
        
        if( !parseJspAttributeAndBody( parent, tag, bodyType ) ) {
            // Must be ( '>' # Body ETag )
            parseBody(parent, tag, bodyType );
        }
    }
    
    /**
     * Attempts to parse 'JspAttributeAndBody' production.  Returns true if
     * it matched, or false if not.  Assumes EmptyBody is okay as well.
     *
     * JspAttributeAndBody ::=
     *                  ( '>' # S? ( ' )
     *                    S? ETag
     *                  )
     */
    private boolean parseJspAttributeAndBody( Node parent, String tag, 
        String bodyType ) 
        throws JasperException
    {
        boolean result = false;
        
        if( reader.matchesOptionalSpacesFollowedBy( " elements:
            parseNamedAttributes( parent );
            
            result = true;
        }
        
        if( reader.matchesOptionalSpacesFollowedBy( " but something other than
            //  or the end tag, translation error.
            err.jspError(reader.mark(), "jsp.error.jspbody.required", 
                "<" + tag );
        }
        
        return result;
    }

    /*
     * Params ::=  `>' S?
     *              (   ( `'
     *                    ( ( S? Param+ S? `' )
     *                      | 
     *                    )
     *                  )
     *                | Param+
     *              )
     *              ''
     */
    private void parseJspParams(Node parent) throws JasperException {
	Node jspParamsNode = new Node.ParamsAction(start, parent);
	parseOptionalBody(jspParamsNode, "jsp:params",
			  JAVAX_BODY_CONTENT_PARAM );
    }

    /*
     * Fallback ::=   '/>'
     *               | ( `>' S? `'
     *                   (   ( S?
     *                         ( Char* - ( Char* `' ) )
     *                         `' S?
     *                       )
     *                     | 
     *                   )
     *                   `'
     *                 )
     *               | ( '>'
     *                   ( Char* - ( Char* '' ) )
     *                   ''
     *                 )
     */
    private void parseFallBack(Node parent) throws JasperException {
	Node fallBackNode = new Node.FallBackAction(start, parent);
	parseOptionalBody(fallBackNode, "jsp:fallback", 
			  JAVAX_BODY_CONTENT_TEMPLATE_TEXT);
    }

    /*
     * For Plugin:
     * StdActionContent ::= Attributes PluginBody
     *
     * PluginBody ::=   EmptyBody 
     *                | ( '>' S? ( ' )
     *                    S? ETag
     *                  )
     *                | ( '>' S? PluginTags ETag )
     *
     * EmptyBody ::=   '/>'
     *               | ( '>' ETag )
     *               | ( '>' S? '
     *
     * Attributes ::= ( S Attribute )* S?
     *
     * CustomActionEnd ::=   CustomActionTagDependent
     *                     | CustomActionJSPContent
     *                     | CustomActionScriptlessContent
     *
     * CustomActionTagDependent ::= TagDependentOptionalBody
     *
     * CustomActionJSPContent ::= OptionalBody
     *
     * CustomActionScriptlessContent ::= ScriptlessOptionalBody
     */
    private boolean parseCustomTag(Node parent) throws JasperException {

	if (reader.peekChar() != '<') {
	    return false;
	}

        // Parse 'CustomAction' production (tag prefix and custom action name)
	reader.nextChar();	// skip '<'
	String tagName = reader.parseToken(false);
	int i = tagName.indexOf(':');
	if (i == -1) {
	    reader.reset(start);
	    return false;
	}

	String prefix = tagName.substring(0, i);
	String shortTagName = tagName.substring(i+1);

	// Check if this is a user-defined tag.
	String uri = pageInfo.getURI(prefix);
        if (uri == null) {
            // If error-on-undeclared-namespace is set to true in
            // jsp-property-group, then it is an error
            if (pageInfo.errorOnUndeclaredNamespace()) {
                 err.jspError(start, "jsp.error.undeclared.namespace", prefix);
            }

	    reader.reset(start);
            // Remember the prefix for later error checking
            pageInfo.putNonCustomTagPrefix(prefix, reader.mark());
	    return false;
	}

        TagLibraryInfo tagLibInfo = pageInfo.getTaglib(uri);
	TagInfo tagInfo = tagLibInfo.getTag(shortTagName);
	TagFileInfo tagFileInfo = tagLibInfo.getTagFile(shortTagName);
	if (tagInfo == null && tagFileInfo == null) {
	    err.jspError(start, "jsp.error.bad_tag", shortTagName, prefix);
	}
	Class tagHandlerClass = null;
	if (tagInfo != null) {
	    // Must be a classic tag, load it here.
	    // tag files will be loaded later, in TagFileProcessor
	    String handlerClassName = tagInfo.getTagClassName();
	    try {
	        tagHandlerClass = ctxt.getClassLoader().loadClass(handlerClassName);
	    } catch (Exception e) {
	        err.jspError(start, "jsp.error.loadclass.taghandler",
			     handlerClassName, tagName);
	    }
	}

        // Parse 'CustomActionBody' production:
        // At this point we are committed - if anything fails, we produce
        // a translation error.

        // Parse 'Attributes' production:
	Attributes attrs = parseAttributes();
	reader.skipSpaces();
	
        // Parse 'CustomActionEnd' production:
	if (reader.matches("/>")) {
	    if (tagInfo != null) {
		new Node.CustomTag(tagLibInfo.getRequiredVersion(),
                                   tagName, prefix, shortTagName, uri, attrs,
				   start, parent, tagInfo, tagHandlerClass);
	    } else {
		new Node.CustomTag(tagLibInfo.getRequiredVersion(),
                                   tagName, prefix, shortTagName, uri, attrs,
				   start, parent, tagFileInfo);
	    }
	    return true;
	}
	
        // Now we parse one of 'CustomActionTagDependent', 
        // 'CustomActionJSPContent', or 'CustomActionScriptlessContent'.
        // depending on body-content in TLD.

	// Looking for a body, it still can be empty; but if there is a
	// a tag body, its syntax would be dependent on the type of
	// body content declared in the TLD.
	String bc;
	if (tagInfo != null) {
	    bc = tagInfo.getBodyContent();
	} else {
	    bc = tagFileInfo.getTagInfo().getBodyContent();
	}

	Node tagNode = null;
	if (tagInfo != null) {
	    tagNode = new Node.CustomTag(tagLibInfo.getRequiredVersion(),
                                         tagName, prefix, shortTagName, uri,
					 attrs, start, parent, tagInfo,
					 tagHandlerClass);
	} else {
	    tagNode = new Node.CustomTag(tagLibInfo.getRequiredVersion(),
                                         tagName, prefix, shortTagName, uri,
					 attrs, start, parent, tagFileInfo);
	}

	parseOptionalBody( tagNode, tagName, bc );

	return true;
    }

    /*
     * Parse for a template text string until '<' or "${" is encountered, 
     * recognizing escape sequences "\%" ,"\$", and \#.
     */
    private void parseTemplateText(Node parent) throws JasperException {

	if (!reader.hasMoreInput())
	    return;

	CharArrayWriter ttext = new CharArrayWriter();
	// Output the first character
	int ch = reader.nextChar();
        if (ch == '\\') {
            reader.pushChar();
        } else {
            ttext.write(ch);
        }

	while (reader.hasMoreInput()) {
	    ch = reader.nextChar();
	    if (ch == '<') {
                reader.pushChar();
                break;
            }
	    else if( ch == '$' || ch == '#') {
		if (!reader.hasMoreInput()) {
		    ttext.write(ch);
		    break;
                }
		if (reader.nextChar() == '{') {
		    reader.pushChar();
		    reader.pushChar();
		    break;
		}
		ttext.write(ch);
		reader.pushChar();
		continue;
	    }
	    else if (ch == '\\') {
		if (!reader.hasMoreInput()) {
		    ttext.write('\\');
		    break;
		}
                char next = (char)reader.peekChar();
                // Looking for \% or \$
                // Note that this behavior can be altered by the attributes
                // el-ignored and deferred-syntax-allowed-as-literal and
                // similar attributes in a page directive.  However, since
                // the page direcitve may appear later in the same page, the
                // '\' will be regenerated in Generator.java.
                if (next == '%' || next == '$' || next == '#') {
                    ch = reader.nextChar();
                }
	    }
	    ttext.write(ch);
	}
	new Node.TemplateText(ttext.toString(), start, parent);
    }
    
    /*
     * XMLTemplateText ::=   ( S? '/>' )
     *                     | ( S? '>'
     *                         ( ( Char* - ( Char* ( '<' | '${' ) ) )
     *                           ( '${' ELExpressionBody )?
     *                           CDSect?
     *                         )* ETag
     *                       )
     *                     | 
     */
    private void parseXMLTemplateText(Node parent) throws JasperException {
        reader.skipSpaces();
        if( !reader.matches( "/>" ) ) {
            if( !reader.matches( ">" ) ) {
                err.jspError(start, "jsp.error.unterminated",
                    "<jsp:text>" );
            }
            CharArrayWriter ttext = new CharArrayWriter();
            while (reader.hasMoreInput()) {
        	int ch = reader.nextChar();
                if( ch == '<' ) {
                    // Check for ");
                    if (stop == null) {
                        err.jspError(start, "jsp.error.unterminated", "CDATA");
                    }
                    String text = reader.getText(start, stop);
                    ttext.write(text, 0, text.length());
                }
                else if( ch == '\\') {
                    if (!reader.hasMoreInput()) {
                        ttext.write('\\');
                        break;
		    }
                    ch = reader.nextChar();
                    if (ch != '$' && ch != '#') {
                        ttext.write('\\');
                    }
                    ttext.write(ch);
                }
                else if( ch == '$' || ch == '#') {
                    if (!reader.hasMoreInput()) {
                        ttext.write(ch);
                        break;
                    }
                    if (reader.nextChar() != '{') {
                        ttext.write(ch);
                        reader.pushChar();
                        continue;
                    }
                    // Create a template text node
                    new Node.TemplateText( ttext.toString(), start, parent);

                    // Mark and parse the EL expression and create its node:
                    start = reader.mark();
                    parseELExpression(parent, (ch == '$')? "${": "#{");

                    start = reader.mark();
                    ttext = new CharArrayWriter();
                }
                else {
                    ttext.write( ch );
                }
            }

            new Node.TemplateText( ttext.toString(), start, parent );

	    if (! reader.hasMoreInput()) {
                err.jspError( start, "jsp.error.unterminated",
                    "<jsp:text>" );
	    } else if( !reader.matchesETagWithoutLessThan( "jsp:text" ) ) {
                err.jspError( start, "jsp.error.jsptext.badcontent");
            }
        }
    }

    /*
     * AllBody ::=       ( '<%--'              JSPCommentBody     )
     *                 | ( '<%@'               DirectiveBody      )
     *                 | ( ' 0 ) {
            // vc: ScriptlessBody
            // We must follow the ScriptlessBody production if one of
            // our parents is ScriptlessBody.
            parseElementsScriptless( parent );
            return;
        }
        
	start = reader.mark();
	if (reader.matches("<%--")) {
	    parseComment(parent);
	} else if (reader.matches("<%@")) {
	    parseDirective(parent);
        } else if (reader.matches(" )
     *                   | ( ' )
     *                   | ( '<%='                )
     *                   | ( ' )
     *                   | ( '<%'                 )
     *                   | ( ' )
     *                   | ( ' )
     *                      | ( ' )
     *                      | ( '<%='                )
     *                      | ( ' )
     *                      | ( '<%'                 )
     *                      | ( ' )
     *                      | ( ' )
     *                      | ( '${'                 )
     *                      | ( ' )
     *                      | TemplateText
     */
    private void parseElementsTemplateText(Node parent)
        throws JasperException
    {
        start = reader.mark();
        if (reader.matches("<%--")) {
            parseComment(parent);
        } else if (reader.matches("<%@")) {
            parseDirective(parent);
        } else if (reader.matches("")) {
	    if (!reader.matches(">")) {
		err.jspError(start, "jsp.error.unterminated",
			     "<jsp:body");
	    }
	    parseBody( bodyNode, "jsp:body", bodyType );
	}
    }

    /*
     * Parse the body as JSP content.
     * @param tag The name of the tag whose end tag would terminate the body
     * @param bodyType One of the TagInfo body types
     */
    private void parseBody(Node parent, String tag, String bodyType) 
        throws JasperException 
    {
        if( bodyType.equalsIgnoreCase( TagInfo.BODY_CONTENT_TAG_DEPENDENT ) ) {
            parseTagDependentBody( parent, tag );
        }
        else if( bodyType.equalsIgnoreCase( TagInfo.BODY_CONTENT_EMPTY ) ) {
            if( !reader.matchesETag( tag ) ) {
		err.jspError(start, "jasper.error.emptybodycontent.nonempty",
			     tag);
            }
        }
        else if( bodyType == JAVAX_BODY_CONTENT_PLUGIN ) {
            // (note the == since we won't recognize JAVAX_* 
            // from outside this module).
            parsePluginTags(parent);
            if( !reader.matchesETag( tag ) ) {
                err.jspError( reader.mark(), "jsp.error.unterminated",
                    "<" + tag  );
            }
        }
        else if( bodyType.equalsIgnoreCase( TagInfo.BODY_CONTENT_JSP ) ||
            bodyType.equalsIgnoreCase( TagInfo.BODY_CONTENT_SCRIPTLESS ) ||
            (bodyType == JAVAX_BODY_CONTENT_PARAM) ||
            (bodyType == JAVAX_BODY_CONTENT_TEMPLATE_TEXT) )
        {
            while (reader.hasMoreInput()) {
                if (reader.matchesETag(tag)) {
                    return;
                }
                
                // Check for nested jsp:body or jsp:attribute
                if (tag.equals("jsp:body") || tag.equals("jsp:attribute")) {
                    if (reader.matches("")) {
		if (!reader.matches(">")) {
		    err.jspError(start, "jsp.error.unterminated",
				 "<jsp:attribute");
		}
                if (namedAttributeNode.isTrim()) {
                    reader.skipSpaces();
                }
                parseBody(namedAttributeNode, "jsp:attribute", 
			  getAttributeBodyType(parent,
					       attrs.getValue("name")));
                if (namedAttributeNode.isTrim()) {
                    Node.Nodes subElems = namedAttributeNode.getBody();
		    if (subElems != null) {
			Node lastNode = subElems.getNode(subElems.size() - 1);
			if (lastNode instanceof Node.TemplateText) {
			    ((Node.TemplateText)lastNode).rtrim();
			}
		    }
                }
            }
            reader.skipSpaces();
        } while( reader.matches( " from the enclosing node
     */
    private String getAttributeBodyType(Node n, String name) {

	if (n instanceof Node.CustomTag) {
	    TagInfo tagInfo = ((Node.CustomTag)n).getTagInfo();
	    TagAttributeInfo[] tldAttrs = tagInfo.getAttributes();
	    for (int i=0; i




© 2015 - 2024 Weber Informatics LLC | Privacy Policy