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