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