LayoutFileParser.java revision 23910cf498c35704a03ba4f3889de2ab97ccbe21
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
14package android.databinding.tool.store;
15
16import org.antlr.v4.runtime.ANTLRInputStream;
17import org.antlr.v4.runtime.CommonTokenStream;
18import org.antlr.v4.runtime.ParserRuleContext;
19import org.antlr.v4.runtime.misc.NotNull;
20import org.apache.commons.io.FileUtils;
21import org.apache.commons.lang3.StringEscapeUtils;
22import org.apache.commons.lang3.StringUtils;
23import org.w3c.dom.Document;
24import org.w3c.dom.Node;
25import org.w3c.dom.NodeList;
26import org.xml.sax.SAXException;
27
28import android.databinding.parser.XMLLexer;
29import android.databinding.parser.XMLParser;
30import android.databinding.parser.XMLParserBaseVisitor;
31import android.databinding.tool.processing.ErrorMessages;
32import android.databinding.tool.processing.Scope;
33import android.databinding.tool.processing.scopes.FileScopeProvider;
34import android.databinding.tool.util.L;
35import android.databinding.tool.util.ParserHelper;
36import android.databinding.tool.util.Preconditions;
37import android.databinding.tool.util.XmlEditor;
38
39import java.io.File;
40import java.io.FileReader;
41import java.io.IOException;
42import java.net.MalformedURLException;
43import java.net.URISyntaxException;
44import java.net.URL;
45import java.util.ArrayList;
46import java.util.HashMap;
47import java.util.List;
48import java.util.Map;
49
50import javax.xml.parsers.DocumentBuilder;
51import javax.xml.parsers.DocumentBuilderFactory;
52import javax.xml.parsers.ParserConfigurationException;
53import javax.xml.xpath.XPath;
54import javax.xml.xpath.XPathConstants;
55import javax.xml.xpath.XPathExpression;
56import javax.xml.xpath.XPathExpressionException;
57import 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 */
64public class LayoutFileParser {
65
66    private static final String XPATH_BINDING_LAYOUT = "/layout";
67
68    private static final String LAYOUT_PREFIX = "@layout/";
69
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
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
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 (!"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
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        } else if ("include".equals(viewName) && !XmlEditor.hasExpressionAttributes(elm)) {
270            viewName = "android.view.View";
271        }
272        return viewName;
273    }
274
275    private void parseData(File xml, XMLParser.ElementContext data,
276            ResourceBundle.LayoutFileBundle bundle) {
277        if (data == null) {
278            return;
279        }
280        for (XMLParser.ElementContext imp : filter(data, "import")) {
281            final Map<String, String> attrMap = attributeMap(imp);
282            String type = attrMap.get("type");
283            String alias = attrMap.get("alias");
284            Preconditions.check(StringUtils.isNotBlank(type), "Type of an import cannot be empty."
285                    + " %s in %s", imp.toStringTree(), xml);
286            if (StringUtils.isEmpty(alias)) {
287                final String[] split = StringUtils.split(type, '.');
288                alias = split[split.length - 1];
289            }
290            bundle.addImport(alias, type, new Location(imp));
291        }
292
293        for (XMLParser.ElementContext variable : filter(data, "variable")) {
294            final Map<String, String> attrMap = attributeMap(variable);
295            String type = attrMap.get("type");
296            String name = attrMap.get("name");
297            Preconditions.checkNotNull(type, "variable must have a type definition %s in %s",
298                    variable.toStringTree(), xml);
299            Preconditions.checkNotNull(name, "variable must have a name %s in %s",
300                    variable.toStringTree(), xml);
301            bundle.addVariable(name, type, new Location(variable), true);
302        }
303        final XMLParser.AttributeContext className = findAttribute(data, "class");
304        if (className != null) {
305            final String name = escapeQuotes(className.attrValue.getText(), true);
306            if (StringUtils.isNotBlank(name)) {
307                Location location = new Location(
308                        className.attrValue.getLine() - 1,
309                        className.attrValue.getCharPositionInLine() + 1,
310                        className.attrValue.getLine() - 1,
311                        className.attrValue.getCharPositionInLine() + name.length()
312                );
313                bundle.setBindingClass(name, location);
314            }
315        }
316    }
317
318    private XMLParser.ElementContext getDataNode(XMLParser.ElementContext root) {
319        final List<XMLParser.ElementContext> data = filter(root, "data");
320        if (data.size() == 0) {
321            return null;
322        }
323        Preconditions.check(data.size() == 1, "XML layout can have only 1 data tag");
324        return data.get(0);
325    }
326
327    private XMLParser.ElementContext getViewNode(File xml, XMLParser.ElementContext root) {
328        final List<XMLParser.ElementContext> view = filterNot(root, "data");
329        Preconditions.check(view.size() == 1, "XML layout %s must have 1 view but has %s. root"
330                        + " children count %s", xml, view.size(), root.getChildCount());
331        return view.get(0);
332    }
333
334    private List<XMLParser.ElementContext> filter(XMLParser.ElementContext root,
335            String name) {
336        List<XMLParser.ElementContext> result = new ArrayList<>();
337        if (root == null) {
338            return result;
339        }
340        final XMLParser.ContentContext content = root.content();
341        if (content == null) {
342            return result;
343        }
344        for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
345            if (name.equals(child.elmName.getText())) {
346                result.add(child);
347            }
348        }
349        return result;
350    }
351
352    private List<XMLParser.ElementContext> filterNot(XMLParser.ElementContext root,
353            String name) {
354        List<XMLParser.ElementContext> result = new ArrayList<>();
355        if (root == null) {
356            return result;
357        }
358        final XMLParser.ContentContext content = root.content();
359        if (content == null) {
360            return result;
361        }
362        for (XMLParser.ElementContext child : XmlEditor.elements(root)) {
363            if (!name.equals(child.elmName.getText())) {
364                result.add(child);
365            }
366        }
367        return result;
368    }
369
370    private boolean hasMergeInclude(XMLParser.ElementContext rootView) {
371        return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0;
372    }
373
374    private File stripFileAndGetOriginal(File xml, String binderId)
375            throws ParserConfigurationException, IOException, SAXException,
376            XPathExpressionException {
377        L.d("parsing resource file %s", xml.getAbsolutePath());
378        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
379        DocumentBuilder builder = factory.newDocumentBuilder();
380        Document doc = builder.parse(xml);
381        XPathFactory xPathFactory = XPathFactory.newInstance();
382        XPath xPath = xPathFactory.newXPath();
383        final XPathExpression commentElementExpr = xPath
384                .compile("//comment()[starts-with(., \" From: file:\")][last()]");
385        final NodeList commentElementNodes = (NodeList) commentElementExpr
386                .evaluate(doc, XPathConstants.NODESET);
387        L.d("comment element nodes count %s", commentElementNodes.getLength());
388        if (commentElementNodes.getLength() == 0) {
389            L.d("cannot find comment element to find the actual file");
390            return null;
391        }
392        final Node first = commentElementNodes.item(0);
393        String actualFilePath = first.getNodeValue().substring(" From:".length()).trim();
394        L.d("actual file to parse: %s", actualFilePath);
395        File actualFile = urlToFile(new java.net.URL(actualFilePath));
396        if (!actualFile.canRead()) {
397            L.d("cannot find original, skipping. %s", actualFile.getAbsolutePath());
398            return null;
399        }
400
401        // now if file has any binding expressions, find and delete them
402        // TODO we should rely on namespace to avoid parsing file twice
403        boolean changed = isBindingLayout(doc, xPath);
404        if (changed) {
405            stripBindingTags(xml, binderId);
406        }
407        return actualFile;
408    }
409
410    private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException {
411        return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty();
412    }
413
414    private List<Node> get(Document doc, XPath xPath, String pattern)
415            throws XPathExpressionException {
416        final XPathExpression expr = xPath.compile(pattern);
417        return toList((NodeList) expr.evaluate(doc, XPathConstants.NODESET));
418    }
419
420    private List<Node> toList(NodeList nodeList) {
421        List<Node> result = new ArrayList<Node>();
422        for (int i = 0; i < nodeList.getLength(); i++) {
423            result.add(nodeList.item(i));
424        }
425        return result;
426    }
427
428    private void stripBindingTags(File xml, String newTag) throws IOException {
429        String res = XmlEditor.strip(xml, newTag);
430        if (res != null) {
431            L.d("file %s has changed, overwriting %s", xml.getName(), xml.getAbsolutePath());
432            FileUtils.writeStringToFile(xml, res);
433        }
434    }
435
436    public static File urlToFile(URL url) throws MalformedURLException {
437        try {
438            return new File(url.toURI());
439        } catch (IllegalArgumentException e) {
440            MalformedURLException ex = new MalformedURLException(e.getLocalizedMessage());
441            ex.initCause(e);
442            throw ex;
443        } catch (URISyntaxException e) {
444            return new File(url.getPath());
445        }
446    }
447
448    private static Map<String, String> attributeMap(XMLParser.ElementContext root) {
449        final Map<String, String> result = new HashMap<>();
450        for (XMLParser.AttributeContext attr : XmlEditor.attributes(root)) {
451            result.put(escapeQuotes(attr.attrName.getText(), false),
452                    escapeQuotes(attr.attrValue.getText(), true));
453        }
454        return result;
455    }
456
457    private static XMLParser.AttributeContext findAttribute(XMLParser.ElementContext element,
458            String name) {
459        for (XMLParser.AttributeContext attr : element.attribute()) {
460            if (escapeQuotes(attr.attrName.getText(), false).equals(name)) {
461                return attr;
462            }
463        }
464        return null;
465    }
466
467    private static String escapeQuotes(String textWithQuotes, boolean unescapeValue) {
468        char first = textWithQuotes.charAt(0);
469        int start = 0, end = textWithQuotes.length();
470        if (first == '"' || first == '\'') {
471            start = 1;
472        }
473        char last = textWithQuotes.charAt(textWithQuotes.length() - 1);
474        if (last == '"' || last == '\'') {
475            end -= 1;
476        }
477        String val = textWithQuotes.substring(start, end);
478        if (unescapeValue) {
479            return StringEscapeUtils.unescapeXml(val);
480        } else {
481            return val;
482        }
483    }
484}
485