Home | History | Annotate | Download | only in store
      1 /*
      2  * Copyright (C) 2015 The Android Open Source Project
      3  * Licensed under the Apache License, Version 2.0 (the "License");
      4  * you may not use this file except in compliance with the License.
      5  * You may obtain a copy of the License at
      6  *      http://www.apache.org/licenses/LICENSE-2.0
      7  * Unless required by applicable law or agreed to in writing, software
      8  * distributed under the License is distributed on an "AS IS" BASIS,
      9  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
     10  * See the License for the specific language governing permissions and
     11  * limitations under the License.
     12  */
     13 
     14 package android.databinding.tool.store;
     15 
     16 import android.databinding.parser.XMLLexer;
     17 import android.databinding.parser.XMLParser;
     18 import android.databinding.parser.XMLParserBaseVisitor;
     19 import android.databinding.tool.LayoutXmlProcessor;
     20 import android.databinding.tool.processing.ErrorMessages;
     21 import android.databinding.tool.processing.Scope;
     22 import android.databinding.tool.processing.scopes.FileScopeProvider;
     23 import android.databinding.tool.util.L;
     24 import android.databinding.tool.util.ParserHelper;
     25 import android.databinding.tool.util.Preconditions;
     26 import android.databinding.tool.util.StringUtils;
     27 import android.databinding.tool.util.XmlEditor;
     28 
     29 import com.google.common.base.Strings;
     30 
     31 import org.antlr.v4.runtime.ANTLRInputStream;
     32 import org.antlr.v4.runtime.CommonTokenStream;
     33 import org.antlr.v4.runtime.ParserRuleContext;
     34 import org.antlr.v4.runtime.misc.NotNull;
     35 import org.apache.commons.io.FileUtils;
     36 import org.mozilla.universalchardet.UniversalDetector;
     37 import org.w3c.dom.Document;
     38 import org.w3c.dom.Node;
     39 import org.w3c.dom.NodeList;
     40 import org.xml.sax.SAXException;
     41 
     42 import java.io.File;
     43 import java.io.FileInputStream;
     44 import java.io.IOException;
     45 import java.io.InputStreamReader;
     46 import java.util.ArrayList;
     47 import java.util.HashMap;
     48 import java.util.List;
     49 import java.util.Map;
     50 
     51 import javax.xml.parsers.DocumentBuilder;
     52 import javax.xml.parsers.DocumentBuilderFactory;
     53 import javax.xml.parsers.ParserConfigurationException;
     54 import javax.xml.xpath.XPath;
     55 import javax.xml.xpath.XPathConstants;
     56 import javax.xml.xpath.XPathExpression;
     57 import javax.xml.xpath.XPathExpressionException;
     58 import javax.xml.xpath.XPathFactory;
     59 
     60 /**
     61  * Gets the list of XML files and creates a list of
     62  * {@link android.databinding.tool.store.ResourceBundle} that can be persistent or converted to
     63  * LayoutBinder.
     64  */
     65 public class LayoutFileParser {
     66 
     67     private static final String XPATH_BINDING_LAYOUT = "/layout";
     68 
     69     private static final String LAYOUT_PREFIX = "@layout/";
     70 
     71     public ResourceBundle.LayoutFileBundle parseXml(final File inputFile, final File outputFile,
     72             String pkg, final LayoutXmlProcessor.OriginalFileLookup originalFileLookup)
     73             throws ParserConfigurationException, IOException, SAXException,
     74             XPathExpressionException {
     75         File originalFileFor = originalFileLookup.getOriginalFileFor(inputFile);
     76         final String originalFilePath = originalFileFor.getAbsolutePath();
     77         try {
     78             Scope.enter(new FileScopeProvider() {
     79                 @Override
     80                 public String provideScopeFilePath() {
     81                     return originalFilePath;
     82                 }
     83             });
     84             final String encoding = findEncoding(inputFile);
     85             stripFile(inputFile, outputFile, encoding, originalFileLookup);
     86             return parseOriginalXml(originalFileFor, pkg, encoding);
     87         } finally {
     88             Scope.exit();
     89         }
     90     }
     91 
     92     private ResourceBundle.LayoutFileBundle parseOriginalXml(final File original, String pkg,
     93             String encoding) throws IOException {
     94         try {
     95             Scope.enter(new FileScopeProvider() {
     96                 @Override
     97                 public String provideScopeFilePath() {
     98                     return original.getAbsolutePath();
     99                 }
    100             });
    101             final String xmlNoExtension = ParserHelper.stripExtension(original.getName());
    102             FileInputStream fin = new FileInputStream(original);
    103             InputStreamReader reader = new InputStreamReader(fin, encoding);
    104             ANTLRInputStream inputStream = new ANTLRInputStream(reader);
    105             XMLLexer lexer = new XMLLexer(inputStream);
    106             CommonTokenStream tokenStream = new CommonTokenStream(lexer);
    107             XMLParser parser = new XMLParser(tokenStream);
    108             XMLParser.DocumentContext expr = parser.document();
    109             XMLParser.ElementContext root = expr.element();
    110             if (!"layout".equals(root.elmName.getText())) {
    111                 return null;
    112             }
    113             XMLParser.ElementContext data = getDataNode(root);
    114             XMLParser.ElementContext rootView = getViewNode(original, root);
    115 
    116             if (hasMergeInclude(rootView)) {
    117                 L.e(ErrorMessages.INCLUDE_INSIDE_MERGE);
    118                 return null;
    119             }
    120             boolean isMerge = "merge".equals(rootView.elmName.getText());
    121 
    122             ResourceBundle.LayoutFileBundle bundle = new ResourceBundle.LayoutFileBundle(original,
    123                     xmlNoExtension, original.getParentFile().getName(), pkg, isMerge);
    124             final String newTag = original.getParentFile().getName() + '/' + xmlNoExtension;
    125             parseData(original, data, bundle);
    126             parseExpressions(newTag, rootView, isMerge, bundle);
    127             return bundle;
    128         } finally {
    129             Scope.exit();
    130         }
    131     }
    132 
    133     private static boolean isProcessedElement(String name) {
    134         if (Strings.isNullOrEmpty(name)) {
    135             return false;
    136         }
    137         if ("view".equals(name) || "include".equals(name) || name.indexOf('.') >= 0) {
    138             return true;
    139         }
    140         return !name.toLowerCase().equals(name);
    141     }
    142 
    143     private void parseExpressions(String newTag, final XMLParser.ElementContext rootView,
    144             final boolean isMerge, ResourceBundle.LayoutFileBundle bundle) {
    145         final List<XMLParser.ElementContext> bindingElements
    146                 = new ArrayList<XMLParser.ElementContext>();
    147         final List<XMLParser.ElementContext> otherElementsWithIds
    148                 = new ArrayList<XMLParser.ElementContext>();
    149         rootView.accept(new XMLParserBaseVisitor<Void>() {
    150             @Override
    151             public Void visitElement(@NotNull XMLParser.ElementContext ctx) {
    152                 if (filter(ctx)) {
    153                     bindingElements.add(ctx);
    154                 } else {
    155                     String name = ctx.elmName.getText();
    156                     if (isProcessedElement(name) &&
    157                             attributeMap(ctx).containsKey("android:id")) {
    158                         otherElementsWithIds.add(ctx);
    159                     }
    160                 }
    161                 visitChildren(ctx);
    162                 return null;
    163             }
    164 
    165             private boolean filter(XMLParser.ElementContext ctx) {
    166                 if (isMerge) {
    167                     // account for XMLParser.ContentContext
    168                     if (ctx.getParent().getParent() == rootView) {
    169                         return true;
    170                     }
    171                 } else if (ctx == rootView) {
    172                     return true;
    173                 }
    174                 return hasIncludeChild(ctx) || XmlEditor.hasExpressionAttributes(ctx) ||
    175                         "include".equals(ctx.elmName.getText());
    176             }
    177 
    178             private boolean hasIncludeChild(XMLParser.ElementContext ctx) {
    179                 for (XMLParser.ElementContext child : XmlEditor.elements(ctx)) {
    180                     if ("include".equals(child.elmName.getText())) {
    181                         return true;
    182                     }
    183                 }
    184                 return false;
    185             }
    186         });
    187 
    188         final HashMap<XMLParser.ElementContext, String> nodeTagMap =
    189                 new HashMap<XMLParser.ElementContext, String>();
    190         L.d("number of binding nodes %d", bindingElements.size());
    191         int tagNumber = 0;
    192         for (XMLParser.ElementContext parent : bindingElements) {
    193             final Map<String, String> attributes = attributeMap(parent);
    194             String nodeName = parent.elmName.getText();
    195             String viewName = null;
    196             String includedLayoutName = null;
    197             final String id = attributes.get("android:id");
    198             final String tag;
    199             final String originalTag = attributes.get("android:tag");
    200             if ("include".equals(nodeName)) {
    201                 // get the layout attribute
    202                 final String includeValue = attributes.get("layout");
    203                 if (Strings.isNullOrEmpty(includeValue)) {
    204                     L.e("%s must include a layout", parent);
    205                 }
    206                 if (!includeValue.startsWith(LAYOUT_PREFIX)) {
    207                     L.e("included value (%s) must start with %s.",
    208                             includeValue, LAYOUT_PREFIX);
    209                 }
    210                 // if user is binding something there, there MUST be a layout file to be
    211                 // generated.
    212                 includedLayoutName = includeValue.substring(LAYOUT_PREFIX.length());
    213                 final ParserRuleContext myParentContent = parent.getParent();
    214                 Preconditions.check(myParentContent instanceof XMLParser.ContentContext,
    215                         "parent of an include tag must be a content context but it is %s",
    216                         myParentContent.getClass().getCanonicalName());
    217                 final ParserRuleContext grandParent = myParentContent.getParent();
    218                 Preconditions.check(grandParent instanceof XMLParser.ElementContext,
    219                         "grandparent of an include tag must be an element context but it is %s",
    220                         grandParent.getClass().getCanonicalName());
    221                 //noinspection SuspiciousMethodCalls
    222                 tag = nodeTagMap.get(grandParent);
    223             } else if ("fragment".equals(nodeName)) {
    224                 if (XmlEditor.hasExpressionAttributes(parent)) {
    225                     L.e("fragments do not support data binding expressions.");
    226                 }
    227                 continue;
    228             } else {
    229                 viewName = getViewName(parent);
    230                 // account for XMLParser.ContentContext
    231                 if (rootView == parent || (isMerge && parent.getParent().getParent() == rootView)) {
    232                     tag = newTag + "_" + tagNumber;
    233                 } else {
    234                     tag = "binding_" + tagNumber;
    235                 }
    236                 tagNumber++;
    237             }
    238             final ResourceBundle.BindingTargetBundle bindingTargetBundle =
    239                     bundle.createBindingTarget(id, viewName, true, tag, originalTag,
    240                             new Location(parent));
    241             nodeTagMap.put(parent, tag);
    242             bindingTargetBundle.setIncludedLayout(includedLayoutName);
    243 
    244             for (XMLParser.AttributeContext attr : XmlEditor.expressionAttributes(parent)) {
    245                 String value = escapeQuotes(attr.attrValue.getText(), true);
    246                 final boolean isOneWay = value.startsWith("@{");
    247                 final boolean isTwoWay = value.startsWith("@={");
    248                 if (isOneWay || isTwoWay) {
    249                     if (value.charAt(value.length() - 1) != '}') {
    250                         L.e("Expecting '}' in expression '%s'", attr.attrValue.getText());
    251                     }
    252                     final int startIndex = isTwoWay ? 3 : 2;
    253                     final int endIndex = value.length() - 1;
    254                     final String strippedValue = value.substring(startIndex, endIndex);
    255                     Location attrLocation = new Location(attr);
    256                     Location valueLocation = new Location();
    257                     // offset to 0 based
    258                     valueLocation.startLine = attr.attrValue.getLine() - 1;
    259                     valueLocation.startOffset = attr.attrValue.getCharPositionInLine() +
    260                             attr.attrValue.getText().indexOf(strippedValue);
    261                     valueLocation.endLine = attrLocation.endLine;
    262                     valueLocation.endOffset = attrLocation.endOffset - 2; // account for: "}
    263                     bindingTargetBundle.addBinding(escapeQuotes(attr.attrName.getText(), false),
    264                             strippedValue, isTwoWay, attrLocation, valueLocation);
    265                 }
    266             }
    267         }
    268 
    269         for (XMLParser.ElementContext elm : otherElementsWithIds) {
    270             final String id = attributeMap(elm).get("android:id");
    271             final String className = getViewName(elm);
    272             bundle.createBindingTarget(id, className, true, null, null, new Location(elm));
    273         }
    274     }
    275 
    276     private String getViewName(XMLParser.ElementContext elm) {
    277         String viewName = elm.elmName.getText();
    278         if ("view".equals(viewName)) {
    279             String classNode = attributeMap(elm).get("class");
    280             if (Strings.isNullOrEmpty(classNode)) {
    281                 L.e("No class attribute for 'view' node");
    282             }
    283             viewName = classNode;
    284         } else if ("include".equals(viewName) && !XmlEditor.hasExpressionAttributes(elm)) {
    285             viewName = "android.view.View";
    286         }
    287         return viewName;
    288     }
    289 
    290     private void parseData(File xml, XMLParser.ElementContext data,
    291             ResourceBundle.LayoutFileBundle bundle) {
    292         if (data == null) {
    293             return;
    294         }
    295         for (XMLParser.ElementContext imp : filter(data, "import")) {
    296             final Map<String, String> attrMap = attributeMap(imp);
    297             String type = attrMap.get("type");
    298             String alias = attrMap.get("alias");
    299             Preconditions.check(StringUtils.isNotBlank(type), "Type of an import cannot be empty."
    300                     + " %s in %s", imp.toStringTree(), xml);
    301             if (Strings.isNullOrEmpty(alias)) {
    302                 alias = type.substring(type.lastIndexOf('.') + 1);
    303             }
    304             bundle.addImport(alias, type, new Location(imp));
    305         }
    306 
    307         for (XMLParser.ElementContext variable : filter(data, "variable")) {
    308             final Map<String, String> attrMap = attributeMap(variable);
    309             String type = attrMap.get("type");
    310             String name = attrMap.get("name");
    311             Preconditions.checkNotNull(type, "variable must have a type definition %s in %s",
    312                     variable.toStringTree(), xml);
    313             Preconditions.checkNotNull(name, "variable must have a name %s in %s",
    314                     variable.toStringTree(), xml);
    315             bundle.addVariable(name, type, new Location(variable), true);
    316         }
    317         final XMLParser.AttributeContext className = findAttribute(data, "class");
    318         if (className != null) {
    319             final String name = escapeQuotes(className.attrValue.getText(), true);
    320             if (StringUtils.isNotBlank(name)) {
    321                 Location location = new Location(
    322                         className.attrValue.getLine() - 1,
    323                         className.attrValue.getCharPositionInLine() + 1,
    324                         className.attrValue.getLine() - 1,
    325                         className.attrValue.getCharPositionInLine() + name.length()
    326                 );
    327                 bundle.setBindingClass(name, location);
    328             }
    329         }
    330     }
    331 
    332     private XMLParser.ElementContext getDataNode(XMLParser.ElementContext root) {
    333         final List<XMLParser.ElementContext> data = filter(root, "data");
    334         if (data.size() == 0) {
    335             return null;
    336         }
    337         Preconditions.check(data.size() == 1, "XML layout can have only 1 data tag");
    338         return data.get(0);
    339     }
    340 
    341     private XMLParser.ElementContext getViewNode(File xml, XMLParser.ElementContext root) {
    342         final List<XMLParser.ElementContext> view = filterNot(root, "data");
    343         Preconditions.check(view.size() == 1, "XML layout %s must have 1 view but has %s. root"
    344                         + " children count %s", xml, view.size(), root.getChildCount());
    345         return view.get(0);
    346     }
    347 
    348     private List<XMLParser.ElementContext> filter(XMLParser.ElementContext root,
    349             String name) {
    350         List<XMLParser.ElementContext> result = new ArrayList<XMLParser.ElementContext>();
    351         if (root == null) {
    352             return result;
    353         }
    354         final XMLParser.ContentContext content = root.content();
    355         if (content == null) {
    356             return result;
    357         }
    358         for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
    359             if (name.equals(child.elmName.getText())) {
    360                 result.add(child);
    361             }
    362         }
    363         return result;
    364     }
    365 
    366     private List<XMLParser.ElementContext> filterNot(XMLParser.ElementContext root,
    367             String name) {
    368         List<XMLParser.ElementContext> result = new ArrayList<XMLParser.ElementContext>();
    369         if (root == null) {
    370             return result;
    371         }
    372         final XMLParser.ContentContext content = root.content();
    373         if (content == null) {
    374             return result;
    375         }
    376         for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
    377             if (!name.equals(child.elmName.getText())) {
    378                 result.add(child);
    379             }
    380         }
    381         return result;
    382     }
    383 
    384     private boolean hasMergeInclude(XMLParser.ElementContext rootView) {
    385         return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0;
    386     }
    387 
    388     private void stripFile(File xml, File out, String encoding,
    389             LayoutXmlProcessor.OriginalFileLookup originalFileLookup)
    390             throws ParserConfigurationException, IOException, SAXException,
    391             XPathExpressionException {
    392         DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
    393         DocumentBuilder builder = factory.newDocumentBuilder();
    394         Document doc = builder.parse(xml);
    395         XPathFactory xPathFactory = XPathFactory.newInstance();
    396         XPath xPath = xPathFactory.newXPath();
    397         File actualFile = originalFileLookup == null ? null
    398                 : originalFileLookup.getOriginalFileFor(xml);
    399         // TODO get rid of original file lookup
    400         if (actualFile == null) {
    401             actualFile = xml;
    402         }
    403         // always create id from actual file when available. Gradle may duplicate files.
    404         String noExt = ParserHelper.stripExtension(actualFile.getName());
    405         String binderId = actualFile.getParentFile().getName() + '/' + noExt;
    406         // now if file has any binding expressions, find and delete them
    407         boolean changed = isBindingLayout(doc, xPath);
    408         if (changed) {
    409             stripBindingTags(xml, out, binderId, encoding);
    410         } else if (!xml.equals(out)){
    411             FileUtils.copyFile(xml, out);
    412         }
    413     }
    414 
    415     private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException {
    416         return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty();
    417     }
    418 
    419     private List<Node> get(Document doc, XPath xPath, String pattern)
    420             throws XPathExpressionException {
    421         final XPathExpression expr = xPath.compile(pattern);
    422         return toList((NodeList) expr.evaluate(doc, XPathConstants.NODESET));
    423     }
    424 
    425     private List<Node> toList(NodeList nodeList) {
    426         List<Node> result = new ArrayList<Node>();
    427         for (int i = 0; i < nodeList.getLength(); i++) {
    428             result.add(nodeList.item(i));
    429         }
    430         return result;
    431     }
    432 
    433     private void stripBindingTags(File xml, File output, String newTag, String encoding) throws IOException {
    434         String res = XmlEditor.strip(xml, newTag, encoding);
    435         Preconditions.checkNotNull(res, "layout file should've changed %s", xml.getAbsolutePath());
    436         if (res != null) {
    437             L.d("file %s has changed, overwriting %s", xml.getName(), xml.getAbsolutePath());
    438             FileUtils.writeStringToFile(output, res, encoding);
    439         }
    440     }
    441 
    442     private static String findEncoding(File f) throws IOException {
    443         FileInputStream fin = new FileInputStream(f);
    444         try {
    445             UniversalDetector universalDetector = new UniversalDetector(null);
    446 
    447             byte[] buf = new byte[4096];
    448             int nread;
    449             while ((nread = fin.read(buf)) > 0 && !universalDetector.isDone()) {
    450                 universalDetector.handleData(buf, 0, nread);
    451             }
    452 
    453             universalDetector.dataEnd();
    454 
    455             String encoding = universalDetector.getDetectedCharset();
    456             if (encoding == null) {
    457                 encoding = "utf-8";
    458             }
    459             return encoding;
    460         } finally {
    461             fin.close();
    462         }
    463     }
    464 
    465     private static Map<String, String> attributeMap(XMLParser.ElementContext root) {
    466         final Map<String, String> result = new HashMap<String, String>();
    467         for (XMLParser.AttributeContext attr : XmlEditor.attributes(root)) {
    468             result.put(escapeQuotes(attr.attrName.getText(), false),
    469                     escapeQuotes(attr.attrValue.getText(), true));
    470         }
    471         return result;
    472     }
    473 
    474     private static XMLParser.AttributeContext findAttribute(XMLParser.ElementContext element,
    475             String name) {
    476         for (XMLParser.AttributeContext attr : element.attribute()) {
    477             if (escapeQuotes(attr.attrName.getText(), false).equals(name)) {
    478                 return attr;
    479             }
    480         }
    481         return null;
    482     }
    483 
    484     private static String escapeQuotes(String textWithQuotes, boolean unescapeValue) {
    485         char first = textWithQuotes.charAt(0);
    486         int start = 0, end = textWithQuotes.length();
    487         if (first == '"' || first == '\'') {
    488             start = 1;
    489         }
    490         char last = textWithQuotes.charAt(textWithQuotes.length() - 1);
    491         if (last == '"' || last == '\'') {
    492             end -= 1;
    493         }
    494         String val = textWithQuotes.substring(start, end);
    495         if (unescapeValue) {
    496             return StringUtils.unescapeXml(val);
    497         } else {
    498             return val;
    499         }
    500     }
    501 }
    502