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 14 package android.databinding.tool.store; 15 16 import android.databinding.parser.XMLLexer; 17 import android.databinding.parser.XMLParser; 18 import android.databinding.parser.XMLParserBaseVisitor; 19 import android.databinding.tool.LayoutXmlProcessor; 20 import android.databinding.tool.processing.ErrorMessages; 21 import android.databinding.tool.processing.Scope; 22 import android.databinding.tool.processing.scopes.FileScopeProvider; 23 import android.databinding.tool.util.L; 24 import android.databinding.tool.util.ParserHelper; 25 import android.databinding.tool.util.Preconditions; 26 import android.databinding.tool.util.StringUtils; 27 import android.databinding.tool.util.XmlEditor; 28 29 import com.google.common.base.Strings; 30 31 import org.antlr.v4.runtime.ANTLRInputStream; 32 import org.antlr.v4.runtime.CommonTokenStream; 33 import org.antlr.v4.runtime.ParserRuleContext; 34 import org.antlr.v4.runtime.misc.NotNull; 35 import org.apache.commons.io.FileUtils; 36 import org.mozilla.universalchardet.UniversalDetector; 37 import org.w3c.dom.Document; 38 import org.w3c.dom.Node; 39 import org.w3c.dom.NodeList; 40 import org.xml.sax.SAXException; 41 42 import java.io.File; 43 import java.io.FileInputStream; 44 import java.io.IOException; 45 import java.io.InputStreamReader; 46 import java.util.ArrayList; 47 import java.util.HashMap; 48 import java.util.List; 49 import java.util.Map; 50 51 import javax.xml.parsers.DocumentBuilder; 52 import javax.xml.parsers.DocumentBuilderFactory; 53 import javax.xml.parsers.ParserConfigurationException; 54 import javax.xml.xpath.XPath; 55 import javax.xml.xpath.XPathConstants; 56 import javax.xml.xpath.XPathExpression; 57 import javax.xml.xpath.XPathExpressionException; 58 import 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 */ 65 public class LayoutFileParser { 66 67 private static final String XPATH_BINDING_LAYOUT = "/layout"; 68 69 private static final String LAYOUT_PREFIX = "@layout/"; 70 parseXml(final File inputFile, final File outputFile, String pkg, final LayoutXmlProcessor.OriginalFileLookup originalFileLookup)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 parseOriginalXml(final File original, String pkg, String encoding)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 isProcessedElement(String name)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 parseExpressions(String newTag, final XMLParser.ElementContext rootView, final boolean isMerge, ResourceBundle.LayoutFileBundle bundle)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 getViewName(XMLParser.ElementContext elm)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 parseData(File xml, XMLParser.ElementContext data, ResourceBundle.LayoutFileBundle bundle)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 getDataNode(XMLParser.ElementContext root)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 getViewNode(File xml, XMLParser.ElementContext root)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 filter(XMLParser.ElementContext root, String name)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 filterNot(XMLParser.ElementContext root, String name)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 hasMergeInclude(XMLParser.ElementContext rootView)384 private boolean hasMergeInclude(XMLParser.ElementContext rootView) { 385 return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0; 386 } 387 stripFile(File xml, File out, String encoding, LayoutXmlProcessor.OriginalFileLookup originalFileLookup)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 isBindingLayout(Document doc, XPath xPath)415 private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException { 416 return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty(); 417 } 418 get(Document doc, XPath xPath, String pattern)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 toList(NodeList nodeList)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 stripBindingTags(File xml, File output, String newTag, String encoding)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 findEncoding(File f)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 attributeMap(XMLParser.ElementContext root)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 findAttribute(XMLParser.ElementContext element, String name)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 escapeQuotes(String textWithQuotes, boolean unescapeValue)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