LayoutFileParser.java revision 0c2ed0cbaee2f206e926bfc780b05e9f1e52b551
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.apache.commons.io.FileUtils;
17import org.apache.commons.lang3.StringUtils;
18import org.w3c.dom.Document;
19import org.w3c.dom.NamedNodeMap;
20import org.w3c.dom.Node;
21import org.w3c.dom.NodeList;
22import org.xml.sax.SAXException;
23
24import android.databinding.tool.util.L;
25import android.databinding.tool.util.ParserHelper;
26import android.databinding.tool.util.XmlEditor;
27
28import java.io.File;
29import java.io.IOException;
30import java.net.MalformedURLException;
31import java.net.URISyntaxException;
32import java.net.URL;
33import java.util.ArrayList;
34import java.util.HashMap;
35import java.util.List;
36
37import javax.xml.parsers.DocumentBuilder;
38import javax.xml.parsers.DocumentBuilderFactory;
39import javax.xml.parsers.ParserConfigurationException;
40import javax.xml.xpath.XPath;
41import javax.xml.xpath.XPathConstants;
42import javax.xml.xpath.XPathExpression;
43import javax.xml.xpath.XPathExpressionException;
44import javax.xml.xpath.XPathFactory;
45
46/**
47 * Gets the list of XML files and creates a list of
48 * {@link android.databinding.tool.store.ResourceBundle} that can be persistent or converted to
49 * LayoutBinder.
50 */
51public class LayoutFileParser {
52    private static final String XPATH_VARIABLE_DEFINITIONS = "/layout/data/variable";
53    private static final String XPATH_BINDING_ELEMENTS = "/layout/*[name() != 'data' and name() != 'merge'] | /layout/merge/* | //*[include/.. or @*[starts-with(., '@{') and substring(., string-length(.)) = '}']]";
54    private static final String XPATH_ID_ELEMENTS = "//*[@*[local-name()='id']]";
55    private static final String XPATH_IMPORT_DEFINITIONS = "/layout/data/import";
56    private static final String XPATH_MERGE_ELEMENT = "/layout/merge";
57    private static final String XPATH_BINDING_LAYOUT = "/layout";
58    private static final String XPATH_MERGE_INCLUDE = "/layout/merge/include";
59    private static final String XPATH_BINDING_CLASS = "/layout/data/@class";
60    final String LAYOUT_PREFIX = "@layout/";
61
62    public ResourceBundle.LayoutFileBundle parseXml(File xml, String pkg, int minSdk)
63            throws ParserConfigurationException, IOException, SAXException,
64            XPathExpressionException {
65        final String xmlNoExtension = ParserHelper.stripExtension(xml.getName());
66        final String newTag = xml.getParentFile().getName() + '/' + xmlNoExtension;
67        File original = stripFileAndGetOriginal(xml, newTag);
68        if (original == null) {
69            L.d("assuming the file is the original for %s", xml.getAbsoluteFile());
70            original = xml;
71        }
72        L.d("parsing file %s", xml.getAbsolutePath());
73
74        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
75        final DocumentBuilder builder = factory.newDocumentBuilder();
76        final Document doc = builder.parse(original);
77
78        final XPathFactory xPathFactory = XPathFactory.newInstance();
79        final XPath xPath = xPathFactory.newXPath();
80
81        if (!isBindingLayout(doc, xPath)) {
82            return null;
83        }
84
85        if (hasMergeInclude(doc, xPath)) {
86            L.e("Data binding does not support include elements as direct children of a " +
87                    "merge element: %s", xml.getPath());
88            return null;
89        }
90
91        List<Node> variableNodes = getVariableNodes(doc, xPath);
92        final List<Node> imports = getImportNodes(doc, xPath);
93
94        ResourceBundle.LayoutFileBundle bundle = new ResourceBundle.LayoutFileBundle(
95                xmlNoExtension, xml.getParentFile().getName(), pkg, isMerge(doc, xPath));
96
97        L.d("number of variable nodes %d", variableNodes.size());
98        for (Node item : variableNodes) {
99            L.d("reading variable node %s", item);
100            NamedNodeMap attributes = item.getAttributes();
101            String variableName = attributes.getNamedItem("name").getNodeValue();
102            String variableType = attributes.getNamedItem("type").getNodeValue();
103            L.d("name: %s, type:%s", variableName, variableType);
104            bundle.addVariable(variableName, variableType);
105        }
106
107        L.d("import node count %d", imports.size());
108        for (Node item : imports) {
109            NamedNodeMap attributes = item.getAttributes();
110            String type = attributes.getNamedItem("type").getNodeValue();
111            final Node aliasNode = attributes.getNamedItem("alias");
112            final String alias;
113            if (aliasNode == null) {
114                final String[] split = StringUtils.split(type, '.');
115                alias = split[split.length - 1];
116            } else {
117                alias = aliasNode.getNodeValue();
118            }
119            bundle.addImport(alias, type);
120        }
121
122        final Node layoutParent = getLayoutParent(doc, xPath);
123        final List<Node> bindingNodes = getBindingNodes(doc, xPath);
124        final HashMap<Node, String> nodeTagMap = new HashMap<Node, String>();
125        L.d("number of binding nodes %d", bindingNodes.size());
126        int tagNumber = 0;
127        for (Node parent : bindingNodes) {
128            NamedNodeMap attributes = parent.getAttributes();
129            String nodeName = parent.getNodeName();
130            String viewName = null;
131            String includedLayoutName = null;
132            final Node id = attributes.getNamedItem("android:id");
133            final String tag;
134            final Node originalTag = attributes.getNamedItem("android:tag");
135            if ("include".equals(nodeName)) {
136                // get the layout attribute
137                final Node includedLayout = attributes.getNamedItem("layout");
138                if (includedLayout == null) {
139                    L.e("%s must include a layout", xml.getAbsolutePath());
140                }
141                final String includeValue = includedLayout.getNodeValue();
142                if (!includeValue.startsWith(LAYOUT_PREFIX)) {
143                    L.e("included value in %s must start with %s.",
144                            xml.getAbsolutePath(), LAYOUT_PREFIX);
145                }
146                // if user is binding something there, there MUST be a layout file to be
147                // generated.
148                String layoutName = includeValue.substring(LAYOUT_PREFIX.length());
149                includedLayoutName = layoutName;
150                tag = nodeTagMap.get(parent.getParentNode());
151            } else if ("fragment".equals(nodeName)) {
152                L.e("fragments do not support data binding expressions: %s", xml.getPath());
153                continue;
154            } else {
155                viewName = getViewName(parent);
156                if (layoutParent == parent.getParentNode()) {
157                    tag = newTag + "_" + tagNumber;
158                } else {
159                    tag = "binding_" + tagNumber;
160                }
161                tagNumber++;
162            }
163            final ResourceBundle.BindingTargetBundle bindingTargetBundle =
164                    bundle.createBindingTarget(id == null ? null : id.getNodeValue(),
165                            viewName, true, tag,
166                            originalTag == null ? null : originalTag.getNodeValue());
167            nodeTagMap.put(parent, tag);
168            bindingTargetBundle.setIncludedLayout(includedLayoutName);
169
170            final int attrCount = attributes.getLength();
171            for (int i = 0; i < attrCount; i ++) {
172                final Node attr = attributes.item(i);
173                String value = attr.getNodeValue();
174                if (value.charAt(0) == '@' && value.charAt(1) == '{' &&
175                        value.charAt(value.length() - 1) == '}') {
176                    final String strippedValue = value.substring(2, value.length() - 1);
177                    bindingTargetBundle.addBinding(attr.getNodeName(), strippedValue);
178                }
179            }
180        }
181
182        final List<Node> idNodes = getNakedIds(doc, xPath);
183        for (Node node : idNodes) {
184            if (!bindingNodes.contains(node) && !"include".equals(node.getNodeName()) &&
185                    !"fragment".equals(node.getNodeName())) {
186                final Node id = node.getAttributes().getNamedItem("android:id");
187                final String className = getViewName(node);
188                bundle.createBindingTarget(id.getNodeValue(), className, true, null, null);
189            }
190        }
191
192        bundle.setBindingClass(getBindingClass(doc, xPath, original));
193
194        return bundle;
195    }
196
197    private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException {
198        return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty();
199    }
200
201    private boolean hasMergeInclude(Document doc, XPath xPath) throws XPathExpressionException {
202        return !get(doc, xPath, XPATH_MERGE_INCLUDE).isEmpty();
203    }
204
205    private Node getLayoutParent(Document doc, XPath xPath) throws XPathExpressionException {
206        if (isMerge(doc, xPath)) {
207            return get(doc, xPath, XPATH_MERGE_ELEMENT).get(0);
208        } else {
209            return get(doc, xPath, XPATH_BINDING_LAYOUT).get(0);
210        }
211    }
212
213    private String getBindingClass(Document doc, XPath xPath, File file)
214            throws XPathExpressionException {
215        List<Node> nodes = get(doc, xPath, XPATH_BINDING_CLASS);
216        if (nodes.isEmpty()) {
217            return null;
218        }
219        if (nodes.size() > 1) {
220            L.e("More than one binding class declared in %s", file.getAbsolutePath());
221        }
222        return nodes.get(0).getNodeValue();
223    }
224
225    private List<Node> getBindingNodes(Document doc, XPath xPath) throws XPathExpressionException {
226        return get(doc, xPath, XPATH_BINDING_ELEMENTS);
227    }
228
229    private List<Node> getVariableNodes(Document doc, XPath xPath) throws XPathExpressionException {
230        return get(doc, xPath, XPATH_VARIABLE_DEFINITIONS);
231    }
232
233    private List<Node> getImportNodes(Document doc, XPath xPath) throws XPathExpressionException {
234        return get(doc, xPath, XPATH_IMPORT_DEFINITIONS);
235    }
236
237    private List<Node> getNakedIds(Document doc, XPath xPath) throws XPathExpressionException {
238        return get(doc, xPath, XPATH_ID_ELEMENTS);
239    }
240
241    private boolean isMerge(Document doc, XPath xPath) throws XPathExpressionException {
242        return !get(doc, xPath, XPATH_MERGE_ELEMENT).isEmpty();
243    }
244
245    private List<Node> get(Document doc, XPath xPath, String pattern)
246            throws XPathExpressionException {
247        final XPathExpression expr = xPath.compile(pattern);
248        return toList((NodeList) expr.evaluate(doc, XPathConstants.NODESET));
249    }
250
251    private List<Node> toList(NodeList nodeList) {
252        List<Node> result = new ArrayList<Node>();
253        for (int i = 0; i < nodeList.getLength(); i ++) {
254            result.add(nodeList.item(i));
255        }
256        return result;
257    }
258
259    private String getViewName(Node viewNode) {
260        String viewName = viewNode.getNodeName();
261        if ("view".equals(viewName)) {
262            Node classNode = viewNode.getAttributes().getNamedItem("class");
263            if (classNode == null) {
264                L.e("No class attribute for 'view' node");
265            } else {
266                viewName = classNode.getNodeValue();
267            }
268        }
269        return viewName;
270    }
271
272    private void stripBindingTags(File xml, String newTag) throws IOException {
273        String res = XmlEditor.strip(xml, newTag);
274        if (res != null) {
275            L.d("file %s has changed, overwriting %s", xml.getName(), xml.getAbsolutePath());
276            FileUtils.writeStringToFile(xml, res);
277        }
278    }
279
280    private File stripFileAndGetOriginal(File xml, String binderId)
281            throws ParserConfigurationException, IOException, SAXException,
282            XPathExpressionException {
283        L.d("parsing resource file %s", xml.getAbsolutePath());
284        DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
285        DocumentBuilder builder = factory.newDocumentBuilder();
286        Document doc = builder.parse(xml);
287        XPathFactory xPathFactory = XPathFactory.newInstance();
288        XPath xPath = xPathFactory.newXPath();
289        final XPathExpression commentElementExpr = xPath
290                .compile("//comment()[starts-with(., \" From: file:\")][last()]");
291        final NodeList commentElementNodes = (NodeList) commentElementExpr
292                .evaluate(doc, XPathConstants.NODESET);
293        L.d("comment element nodes count %s", commentElementNodes.getLength());
294        if (commentElementNodes.getLength() == 0) {
295            L.d("cannot find comment element to find the actual file");
296            return null;
297        }
298        final Node first = commentElementNodes.item(0);
299        String actualFilePath = first.getNodeValue().substring(" From:".length()).trim();
300        L.d("actual file to parse: %s", actualFilePath);
301        File actualFile = urlToFile(new java.net.URL(actualFilePath));
302        if (!actualFile.canRead()) {
303            L.d("cannot find original, skipping. %s", actualFile.getAbsolutePath());
304            return null;
305        }
306
307        // now if file has any binding expressions, find and delete them
308        // TODO we should rely on namespace to avoid parsing file twice
309        boolean changed = isBindingLayout(doc, xPath);
310        if (changed) {
311            stripBindingTags(xml, binderId);
312        }
313        return actualFile;
314    }
315
316    public static File urlToFile(String url) throws MalformedURLException {
317        return urlToFile(new URL(url));
318    }
319
320    public static File urlToFile(URL url) throws MalformedURLException {
321        try {
322            return new File(url.toURI());
323        }
324        catch (IllegalArgumentException e) {
325            MalformedURLException ex = new MalformedURLException(e.getLocalizedMessage());
326            ex.initCause(e);
327            throw ex;
328        }
329        catch (URISyntaxException e) {
330            return new File(url.getPath());
331        }
332    }
333}
334