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

android.databinding.tool.store.LayoutFileParser Maven / Gradle / Ivy

/*
 * Copyright (C) 2015 The Android Open Source Project
 * 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 android.databinding.tool.store;

import android.databinding.parser.XMLLexer;
import android.databinding.parser.XMLParser;
import android.databinding.parser.XMLParserBaseVisitor;
import android.databinding.tool.LayoutXmlProcessor;
import android.databinding.tool.processing.ErrorMessages;
import android.databinding.tool.processing.Scope;
import android.databinding.tool.processing.scopes.FileScopeProvider;
import android.databinding.tool.util.L;
import android.databinding.tool.util.ParserHelper;
import android.databinding.tool.util.Preconditions;
import android.databinding.tool.util.StringUtils;
import android.databinding.tool.util.XmlEditor;

import com.google.common.base.Strings;

import org.antlr.v4.runtime.ANTLRInputStream;
import org.antlr.v4.runtime.CommonTokenStream;
import org.antlr.v4.runtime.ParserRuleContext;
import org.antlr.v4.runtime.misc.NotNull;
import org.apache.commons.io.FileUtils;
import org.mozilla.universalchardet.UniversalDetector;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathExpressionException;
import javax.xml.xpath.XPathFactory;

/**
 * Gets the list of XML files and creates a list of
 * {@link android.databinding.tool.store.ResourceBundle} that can be persistent or converted to
 * LayoutBinder.
 */
public class LayoutFileParser {

    private static final String XPATH_BINDING_LAYOUT = "/layout";

    private static final String LAYOUT_PREFIX = "@layout/";

    public ResourceBundle.LayoutFileBundle parseXml(final File inputFile, final File outputFile,
            String pkg, final LayoutXmlProcessor.OriginalFileLookup originalFileLookup)
            throws ParserConfigurationException, IOException, SAXException,
            XPathExpressionException {
        File originalFileFor = originalFileLookup.getOriginalFileFor(inputFile);
        final String originalFilePath = originalFileFor.getAbsolutePath();
        try {
            Scope.enter(new FileScopeProvider() {
                @Override
                public String provideScopeFilePath() {
                    return originalFilePath;
                }
            });
            final String encoding = findEncoding(inputFile);
            stripFile(inputFile, outputFile, encoding, originalFileLookup);
            return parseOriginalXml(originalFileFor, pkg, encoding);
        } finally {
            Scope.exit();
        }
    }

    private ResourceBundle.LayoutFileBundle parseOriginalXml(final File original, String pkg,
            String encoding) throws IOException {
        try {
            Scope.enter(new FileScopeProvider() {
                @Override
                public String provideScopeFilePath() {
                    return original.getAbsolutePath();
                }
            });
            final String xmlNoExtension = ParserHelper.stripExtension(original.getName());
            FileInputStream fin = new FileInputStream(original);
            InputStreamReader reader = new InputStreamReader(fin, encoding);
            ANTLRInputStream inputStream = new ANTLRInputStream(reader);
            XMLLexer lexer = new XMLLexer(inputStream);
            CommonTokenStream tokenStream = new CommonTokenStream(lexer);
            XMLParser parser = new XMLParser(tokenStream);
            XMLParser.DocumentContext expr = parser.document();
            XMLParser.ElementContext root = expr.element();
            if (!"layout".equals(root.elmName.getText())) {
                return null;
            }
            XMLParser.ElementContext data = getDataNode(root);
            XMLParser.ElementContext rootView = getViewNode(original, root);

            if (hasMergeInclude(rootView)) {
                L.e(ErrorMessages.INCLUDE_INSIDE_MERGE);
                return null;
            }
            boolean isMerge = "merge".equals(rootView.elmName.getText());

            ResourceBundle.LayoutFileBundle bundle = new ResourceBundle.LayoutFileBundle(original,
                    xmlNoExtension, original.getParentFile().getName(), pkg, isMerge);
            final String newTag = original.getParentFile().getName() + '/' + xmlNoExtension;
            parseData(original, data, bundle);
            parseExpressions(newTag, rootView, isMerge, bundle);
            return bundle;
        } finally {
            Scope.exit();
        }
    }

    private static boolean isProcessedElement(String name) {
        if (Strings.isNullOrEmpty(name)) {
            return false;
        }
        if ("view".equals(name) || "include".equals(name) || name.indexOf('.') >= 0) {
            return true;
        }
        return !name.toLowerCase().equals(name);
    }

    private void parseExpressions(String newTag, final XMLParser.ElementContext rootView,
            final boolean isMerge, ResourceBundle.LayoutFileBundle bundle) {
        final List bindingElements
                = new ArrayList();
        final List otherElementsWithIds
                = new ArrayList();
        rootView.accept(new XMLParserBaseVisitor() {
            @Override
            public Void visitElement(@NotNull XMLParser.ElementContext ctx) {
                if (filter(ctx)) {
                    bindingElements.add(ctx);
                } else {
                    String name = ctx.elmName.getText();
                    if (isProcessedElement(name) &&
                            attributeMap(ctx).containsKey("android:id")) {
                        otherElementsWithIds.add(ctx);
                    }
                }
                visitChildren(ctx);
                return null;
            }

            private boolean filter(XMLParser.ElementContext ctx) {
                if (isMerge) {
                    // account for XMLParser.ContentContext
                    if (ctx.getParent().getParent() == rootView) {
                        return true;
                    }
                } else if (ctx == rootView) {
                    return true;
                }
                return hasIncludeChild(ctx) || XmlEditor.hasExpressionAttributes(ctx) ||
                        "include".equals(ctx.elmName.getText());
            }

            private boolean hasIncludeChild(XMLParser.ElementContext ctx) {
                for (XMLParser.ElementContext child : XmlEditor.elements(ctx)) {
                    if ("include".equals(child.elmName.getText())) {
                        return true;
                    }
                }
                return false;
            }
        });

        final HashMap nodeTagMap =
                new HashMap();
        L.d("number of binding nodes %d", bindingElements.size());
        int tagNumber = 0;
        for (XMLParser.ElementContext parent : bindingElements) {
            final Map attributes = attributeMap(parent);
            String nodeName = parent.elmName.getText();
            String viewName = null;
            String includedLayoutName = null;
            final String id = attributes.get("android:id");
            final String tag;
            final String originalTag = attributes.get("android:tag");
            if ("include".equals(nodeName)) {
                // get the layout attribute
                final String includeValue = attributes.get("layout");
                if (Strings.isNullOrEmpty(includeValue)) {
                    L.e("%s must include a layout", parent);
                }
                if (!includeValue.startsWith(LAYOUT_PREFIX)) {
                    L.e("included value (%s) must start with %s.",
                            includeValue, LAYOUT_PREFIX);
                }
                // if user is binding something there, there MUST be a layout file to be
                // generated.
                includedLayoutName = includeValue.substring(LAYOUT_PREFIX.length());
                final ParserRuleContext myParentContent = parent.getParent();
                Preconditions.check(myParentContent instanceof XMLParser.ContentContext,
                        "parent of an include tag must be a content context but it is %s",
                        myParentContent.getClass().getCanonicalName());
                final ParserRuleContext grandParent = myParentContent.getParent();
                Preconditions.check(grandParent instanceof XMLParser.ElementContext,
                        "grandparent of an include tag must be an element context but it is %s",
                        grandParent.getClass().getCanonicalName());
                //noinspection SuspiciousMethodCalls
                tag = nodeTagMap.get(grandParent);
            } else if ("fragment".equals(nodeName)) {
                if (XmlEditor.hasExpressionAttributes(parent)) {
                    L.e("fragments do not support data binding expressions.");
                }
                continue;
            } else {
                viewName = getViewName(parent);
                // account for XMLParser.ContentContext
                if (rootView == parent || (isMerge && parent.getParent().getParent() == rootView)) {
                    tag = newTag + "_" + tagNumber;
                } else {
                    tag = "binding_" + tagNumber;
                }
                tagNumber++;
            }
            final ResourceBundle.BindingTargetBundle bindingTargetBundle =
                    bundle.createBindingTarget(id, viewName, true, tag, originalTag,
                            new Location(parent));
            nodeTagMap.put(parent, tag);
            bindingTargetBundle.setIncludedLayout(includedLayoutName);

            for (XMLParser.AttributeContext attr : XmlEditor.expressionAttributes(parent)) {
                String value = escapeQuotes(attr.attrValue.getText(), true);
                final boolean isOneWay = value.startsWith("@{");
                final boolean isTwoWay = value.startsWith("@={");
                if (isOneWay || isTwoWay) {
                    if (value.charAt(value.length() - 1) != '}') {
                        L.e("Expecting '}' in expression '%s'", attr.attrValue.getText());
                    }
                    final int startIndex = isTwoWay ? 3 : 2;
                    final int endIndex = value.length() - 1;
                    final String strippedValue = value.substring(startIndex, endIndex);
                    Location attrLocation = new Location(attr);
                    Location valueLocation = new Location();
                    // offset to 0 based
                    valueLocation.startLine = attr.attrValue.getLine() - 1;
                    valueLocation.startOffset = attr.attrValue.getCharPositionInLine() +
                            attr.attrValue.getText().indexOf(strippedValue);
                    valueLocation.endLine = attrLocation.endLine;
                    valueLocation.endOffset = attrLocation.endOffset - 2; // account for: "}
                    bindingTargetBundle.addBinding(escapeQuotes(attr.attrName.getText(), false),
                            strippedValue, isTwoWay, attrLocation, valueLocation);
                }
            }
        }

        for (XMLParser.ElementContext elm : otherElementsWithIds) {
            final String id = attributeMap(elm).get("android:id");
            final String className = getViewName(elm);
            bundle.createBindingTarget(id, className, true, null, null, new Location(elm));
        }
    }

    private String getViewName(XMLParser.ElementContext elm) {
        String viewName = elm.elmName.getText();
        if ("view".equals(viewName)) {
            String classNode = attributeMap(elm).get("class");
            if (Strings.isNullOrEmpty(classNode)) {
                L.e("No class attribute for 'view' node");
            }
            viewName = classNode;
        } else if ("include".equals(viewName) && !XmlEditor.hasExpressionAttributes(elm)) {
            viewName = "android.view.View";
        }
        return viewName;
    }

    private void parseData(File xml, XMLParser.ElementContext data,
            ResourceBundle.LayoutFileBundle bundle) {
        if (data == null) {
            return;
        }
        for (XMLParser.ElementContext imp : filter(data, "import")) {
            final Map attrMap = attributeMap(imp);
            String type = attrMap.get("type");
            String alias = attrMap.get("alias");
            Preconditions.check(StringUtils.isNotBlank(type), "Type of an import cannot be empty."
                    + " %s in %s", imp.toStringTree(), xml);
            if (Strings.isNullOrEmpty(alias)) {
                alias = type.substring(type.lastIndexOf('.') + 1);
            }
            bundle.addImport(alias, type, new Location(imp));
        }

        for (XMLParser.ElementContext variable : filter(data, "variable")) {
            final Map attrMap = attributeMap(variable);
            String type = attrMap.get("type");
            String name = attrMap.get("name");
            Preconditions.checkNotNull(type, "variable must have a type definition %s in %s",
                    variable.toStringTree(), xml);
            Preconditions.checkNotNull(name, "variable must have a name %s in %s",
                    variable.toStringTree(), xml);
            bundle.addVariable(name, type, new Location(variable), true);
        }
        final XMLParser.AttributeContext className = findAttribute(data, "class");
        if (className != null) {
            final String name = escapeQuotes(className.attrValue.getText(), true);
            if (StringUtils.isNotBlank(name)) {
                Location location = new Location(
                        className.attrValue.getLine() - 1,
                        className.attrValue.getCharPositionInLine() + 1,
                        className.attrValue.getLine() - 1,
                        className.attrValue.getCharPositionInLine() + name.length()
                );
                bundle.setBindingClass(name, location);
            }
        }
    }

    private XMLParser.ElementContext getDataNode(XMLParser.ElementContext root) {
        final List data = filter(root, "data");
        if (data.size() == 0) {
            return null;
        }
        Preconditions.check(data.size() == 1, "XML layout can have only 1 data tag");
        return data.get(0);
    }

    private XMLParser.ElementContext getViewNode(File xml, XMLParser.ElementContext root) {
        final List view = filterNot(root, "data");
        Preconditions.check(view.size() == 1, "XML layout %s must have 1 view but has %s. root"
                        + " children count %s", xml, view.size(), root.getChildCount());
        return view.get(0);
    }

    private List filter(XMLParser.ElementContext root,
            String name) {
        List result = new ArrayList();
        if (root == null) {
            return result;
        }
        final XMLParser.ContentContext content = root.content();
        if (content == null) {
            return result;
        }
        for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
            if (name.equals(child.elmName.getText())) {
                result.add(child);
            }
        }
        return result;
    }

    private List filterNot(XMLParser.ElementContext root,
            String name) {
        List result = new ArrayList();
        if (root == null) {
            return result;
        }
        final XMLParser.ContentContext content = root.content();
        if (content == null) {
            return result;
        }
        for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
            if (!name.equals(child.elmName.getText())) {
                result.add(child);
            }
        }
        return result;
    }

    private boolean hasMergeInclude(XMLParser.ElementContext rootView) {
        return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0;
    }

    private void stripFile(File xml, File out, String encoding,
            LayoutXmlProcessor.OriginalFileLookup originalFileLookup)
            throws ParserConfigurationException, IOException, SAXException,
            XPathExpressionException {
        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
        DocumentBuilder builder = factory.newDocumentBuilder();
        Document doc = builder.parse(xml);
        XPathFactory xPathFactory = XPathFactory.newInstance();
        XPath xPath = xPathFactory.newXPath();
        File actualFile = originalFileLookup == null ? null
                : originalFileLookup.getOriginalFileFor(xml);
        // TODO get rid of original file lookup
        if (actualFile == null) {
            actualFile = xml;
        }
        // always create id from actual file when available. Gradle may duplicate files.
        String noExt = ParserHelper.stripExtension(actualFile.getName());
        String binderId = actualFile.getParentFile().getName() + '/' + noExt;
        // now if file has any binding expressions, find and delete them
        boolean changed = isBindingLayout(doc, xPath);
        if (changed) {
            stripBindingTags(xml, out, binderId, encoding);
        } else if (!xml.equals(out)){
            FileUtils.copyFile(xml, out);
        }
    }

    private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException {
        return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty();
    }

    private List get(Document doc, XPath xPath, String pattern)
            throws XPathExpressionException {
        final XPathExpression expr = xPath.compile(pattern);
        return toList((NodeList) expr.evaluate(doc, XPathConstants.NODESET));
    }

    private List toList(NodeList nodeList) {
        List result = new ArrayList();
        for (int i = 0; i < nodeList.getLength(); i++) {
            result.add(nodeList.item(i));
        }
        return result;
    }

    private void stripBindingTags(File xml, File output, String newTag, String encoding) throws IOException {
        String res = XmlEditor.strip(xml, newTag, encoding);
        Preconditions.checkNotNull(res, "layout file should've changed %s", xml.getAbsolutePath());
        if (res != null) {
            L.d("file %s has changed, overwriting %s", xml.getName(), xml.getAbsolutePath());
            FileUtils.writeStringToFile(output, res, encoding);
        }
    }

    private static String findEncoding(File f) throws IOException {
        FileInputStream fin = new FileInputStream(f);
        try {
            UniversalDetector universalDetector = new UniversalDetector(null);

            byte[] buf = new byte[4096];
            int nread;
            while ((nread = fin.read(buf)) > 0 && !universalDetector.isDone()) {
                universalDetector.handleData(buf, 0, nread);
            }

            universalDetector.dataEnd();

            String encoding = universalDetector.getDetectedCharset();
            if (encoding == null) {
                encoding = "utf-8";
            }
            return encoding;
        } finally {
            fin.close();
        }
    }

    private static Map attributeMap(XMLParser.ElementContext root) {
        final Map result = new HashMap();
        for (XMLParser.AttributeContext attr : XmlEditor.attributes(root)) {
            result.put(escapeQuotes(attr.attrName.getText(), false),
                    escapeQuotes(attr.attrValue.getText(), true));
        }
        return result;
    }

    private static XMLParser.AttributeContext findAttribute(XMLParser.ElementContext element,
            String name) {
        for (XMLParser.AttributeContext attr : element.attribute()) {
            if (escapeQuotes(attr.attrName.getText(), false).equals(name)) {
                return attr;
            }
        }
        return null;
    }

    private static String escapeQuotes(String textWithQuotes, boolean unescapeValue) {
        char first = textWithQuotes.charAt(0);
        int start = 0, end = textWithQuotes.length();
        if (first == '"' || first == '\'') {
            start = 1;
        }
        char last = textWithQuotes.charAt(textWithQuotes.length() - 1);
        if (last == '"' || last == '\'') {
            end -= 1;
        }
        String val = textWithQuotes.substring(start, end);
        if (unescapeValue) {
            return StringUtils.unescapeXml(val);
        } else {
            return val;
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy