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