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