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