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