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