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