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