• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
parseXml(final File inputFile, final File outputFile, String pkg, final LayoutXmlProcessor.OriginalFileLookup originalFileLookup)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 
parseOriginalXml(final File original, String pkg, String encoding)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 
isProcessedElement(String name)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 
parseExpressions(String newTag, final XMLParser.ElementContext rootView, final boolean isMerge, ResourceBundle.LayoutFileBundle bundle)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 
getViewName(XMLParser.ElementContext elm)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 
parseData(File xml, XMLParser.ElementContext data, ResourceBundle.LayoutFileBundle bundle)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 
getDataNode(XMLParser.ElementContext root)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 
getViewNode(File xml, XMLParser.ElementContext root)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 
filter(XMLParser.ElementContext root, String name)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 
filterNot(XMLParser.ElementContext root, String name)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 
hasMergeInclude(XMLParser.ElementContext rootView)384     private boolean hasMergeInclude(XMLParser.ElementContext rootView) {
385         return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0;
386     }
387 
stripFile(File xml, File out, String encoding, LayoutXmlProcessor.OriginalFileLookup originalFileLookup)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 
isBindingLayout(Document doc, XPath xPath)415     private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException {
416         return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty();
417     }
418 
get(Document doc, XPath xPath, String pattern)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 
toList(NodeList nodeList)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 
stripBindingTags(File xml, File output, String newTag, String encoding)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 
findEncoding(File f)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 
attributeMap(XMLParser.ElementContext root)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 
findAttribute(XMLParser.ElementContext element, String name)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 
escapeQuotes(String textWithQuotes, boolean unescapeValue)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