LayoutFileParser.java revision 6a5a45a07b074230c07f78f271e417c7f844c1ec
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.w3c.dom.Document; 24import org.w3c.dom.Node; 25import org.w3c.dom.NodeList; 26import org.xml.sax.SAXException; 27 28import android.databinding.parser.XMLLexer; 29import android.databinding.parser.XMLParser; 30import android.databinding.parser.XMLParserBaseVisitor; 31import android.databinding.tool.processing.ErrorMessages; 32import android.databinding.tool.processing.Scope; 33import android.databinding.tool.processing.scopes.FileScopeProvider; 34import android.databinding.tool.util.L; 35import android.databinding.tool.util.ParserHelper; 36import android.databinding.tool.util.Preconditions; 37import android.databinding.tool.util.XmlEditor; 38 39import java.io.File; 40import java.io.FileReader; 41import java.io.IOException; 42import java.net.MalformedURLException; 43import java.net.URISyntaxException; 44import java.net.URL; 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 xml, String pkg, int minSdk) 71 throws ParserConfigurationException, IOException, SAXException, 72 XPathExpressionException { 73 try { 74 Scope.enter(new FileScopeProvider() { 75 @Override 76 public String provideScopeFilePath() { 77 return xml.getAbsolutePath(); 78 } 79 }); 80 final String xmlNoExtension = ParserHelper.stripExtension(xml.getName()); 81 final String newTag = xml.getParentFile().getName() + '/' + xmlNoExtension; 82 File original = stripFileAndGetOriginal(xml, newTag); 83 if (original == null) { 84 L.d("assuming the file is the original for %s", xml.getAbsoluteFile()); 85 original = xml; 86 } 87 return parseXml(original, pkg); 88 } finally { 89 Scope.exit(); 90 } 91 } 92 93 private ResourceBundle.LayoutFileBundle parseXml(final File original, String pkg) 94 throws IOException { 95 try { 96 Scope.enter(new FileScopeProvider() { 97 @Override 98 public String provideScopeFilePath() { 99 return original.getAbsolutePath(); 100 } 101 }); 102 final String xmlNoExtension = ParserHelper.stripExtension(original.getName()); 103 ANTLRInputStream inputStream = new ANTLRInputStream(new FileReader(original)); 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 void parseExpressions(String newTag, final XMLParser.ElementContext rootView, 133 final boolean isMerge, ResourceBundle.LayoutFileBundle bundle) { 134 final List<XMLParser.ElementContext> bindingElements = new ArrayList<>(); 135 final List<XMLParser.ElementContext> otherElementsWithIds = new ArrayList<>(); 136 rootView.accept(new XMLParserBaseVisitor<Void>() { 137 @Override 138 public Void visitElement(@NotNull XMLParser.ElementContext ctx) { 139 if (filter(ctx)) { 140 bindingElements.add(ctx); 141 } else { 142 String name = ctx.elmName.getText(); 143 if (!"fragment".equals(name) && 144 attributeMap(ctx).containsKey("android:id")) { 145 otherElementsWithIds.add(ctx); 146 } 147 } 148 visitChildren(ctx); 149 return null; 150 } 151 152 private boolean filter(XMLParser.ElementContext ctx) { 153 if (isMerge) { 154 // account for XMLParser.ContentContext 155 if (ctx.getParent().getParent() == rootView) { 156 return true; 157 } 158 } else if (ctx == rootView) { 159 return true; 160 } 161 if (hasIncludeChild(ctx)) { 162 return true; 163 } 164 if (XmlEditor.hasExpressionAttributes(ctx)) { 165 return true; 166 } 167 return false; 168 } 169 170 private boolean hasIncludeChild(XMLParser.ElementContext ctx) { 171 for (XMLParser.ElementContext child : XmlEditor.elements(ctx)) { 172 if ("include".equals(child.elmName.getText())) { 173 return true; 174 } 175 } 176 return false; 177 } 178 }); 179 180 final HashMap<XMLParser.ElementContext, String> nodeTagMap = 181 new HashMap<XMLParser.ElementContext, String>(); 182 L.d("number of binding nodes %d", bindingElements.size()); 183 int tagNumber = 0; 184 for (XMLParser.ElementContext parent : bindingElements) { 185 final Map<String, String> attributes = attributeMap(parent); 186 String nodeName = parent.elmName.getText(); 187 String viewName = null; 188 String includedLayoutName = null; 189 final String id = attributes.get("android:id"); 190 final String tag; 191 final String originalTag = attributes.get("android:tag"); 192 if ("include".equals(nodeName)) { 193 // get the layout attribute 194 final String includeValue = attributes.get("layout"); 195 if (StringUtils.isEmpty(includeValue)) { 196 L.e("%s must include a layout", parent); 197 } 198 if (!includeValue.startsWith(LAYOUT_PREFIX)) { 199 L.e("included value (%s) must start with %s.", 200 includeValue, LAYOUT_PREFIX); 201 } 202 // if user is binding something there, there MUST be a layout file to be 203 // generated. 204 String layoutName = includeValue.substring(LAYOUT_PREFIX.length()); 205 includedLayoutName = layoutName; 206 final ParserRuleContext myParentContent = parent.getParent(); 207 Preconditions.check(myParentContent instanceof XMLParser.ContentContext, 208 "parent of an include tag must be a content context but it is %s", 209 myParentContent.getClass().getCanonicalName()); 210 final ParserRuleContext grandParent = myParentContent.getParent(); 211 Preconditions.check(grandParent instanceof XMLParser.ElementContext, 212 "grandparent of an include tag must be an element context but it is %s", 213 grandParent.getClass().getCanonicalName()); 214 //noinspection SuspiciousMethodCalls 215 tag = nodeTagMap.get(grandParent); 216 } else if ("fragment".equals(nodeName)) { 217 if (XmlEditor.hasExpressionAttributes(parent)) { 218 L.e("fragments do not support data binding expressions."); 219 } 220 continue; 221 } else { 222 viewName = getViewName(parent); 223 // account for XMLParser.ContentContext 224 if (rootView == parent || (isMerge && parent.getParent().getParent() == rootView)) { 225 tag = newTag + "_" + tagNumber; 226 } else { 227 tag = "binding_" + tagNumber; 228 } 229 tagNumber++; 230 } 231 final ResourceBundle.BindingTargetBundle bindingTargetBundle = 232 bundle.createBindingTarget(id, viewName, true, tag, originalTag, 233 new Location(parent)); 234 nodeTagMap.put(parent, tag); 235 bindingTargetBundle.setIncludedLayout(includedLayoutName); 236 237 for (XMLParser.AttributeContext attr : XmlEditor.expressionAttributes(parent)) { 238 String value = escapeQuotes(attr.attrValue.getText(), true); 239 if (value.charAt(0) == '@' && value.charAt(1) == '{' && 240 value.charAt(value.length() - 1) == '}') { 241 final String strippedValue = value.substring(2, value.length() - 1); 242 Location attrLocation = new Location(attr); 243 Location valueLocation = new Location(); 244 // offset to 0 based 245 valueLocation.startLine = attr.attrValue.getLine() - 1; 246 valueLocation.startOffset = attr.attrValue.getCharPositionInLine() + 247 attr.attrValue.getText().indexOf(strippedValue); 248 valueLocation.endLine = attrLocation.endLine; 249 valueLocation.endOffset = attrLocation.endOffset - 2; // account for: "} 250 bindingTargetBundle.addBinding(escapeQuotes(attr.attrName.getText(), false) 251 , strippedValue, attrLocation, valueLocation); 252 } 253 } 254 } 255 256 for (XMLParser.ElementContext elm : otherElementsWithIds) { 257 final String id = attributeMap(elm).get("android:id"); 258 final String className = getViewName(elm); 259 bundle.createBindingTarget(id, className, true, null, null, new Location(elm)); 260 } 261 } 262 263 private String getViewName(XMLParser.ElementContext elm) { 264 String viewName = elm.elmName.getText(); 265 if ("view".equals(viewName)) { 266 String classNode = attributeMap(elm).get("class"); 267 if (StringUtils.isEmpty(classNode)) { 268 L.e("No class attribute for 'view' node"); 269 } 270 viewName = classNode; 271 } else if ("include".equals(viewName) && !XmlEditor.hasExpressionAttributes(elm)) { 272 viewName = "android.view.View"; 273 } 274 return viewName; 275 } 276 277 private void parseData(File xml, XMLParser.ElementContext data, 278 ResourceBundle.LayoutFileBundle bundle) { 279 if (data == null) { 280 return; 281 } 282 for (XMLParser.ElementContext imp : filter(data, "import")) { 283 final Map<String, String> attrMap = attributeMap(imp); 284 String type = attrMap.get("type"); 285 String alias = attrMap.get("alias"); 286 Preconditions.check(StringUtils.isNotBlank(type), "Type of an import cannot be empty." 287 + " %s in %s", imp.toStringTree(), xml); 288 if (StringUtils.isEmpty(alias)) { 289 final String[] split = StringUtils.split(type, '.'); 290 alias = split[split.length - 1]; 291 } 292 bundle.addImport(alias, type, new Location(imp)); 293 } 294 295 for (XMLParser.ElementContext variable : filter(data, "variable")) { 296 final Map<String, String> attrMap = attributeMap(variable); 297 String type = attrMap.get("type"); 298 String name = attrMap.get("name"); 299 Preconditions.checkNotNull(type, "variable must have a type definition %s in %s", 300 variable.toStringTree(), xml); 301 Preconditions.checkNotNull(name, "variable must have a name %s in %s", 302 variable.toStringTree(), xml); 303 bundle.addVariable(name, type, new Location(variable), true); 304 } 305 final XMLParser.AttributeContext className = findAttribute(data, "class"); 306 if (className != null) { 307 final String name = escapeQuotes(className.attrValue.getText(), true); 308 if (StringUtils.isNotBlank(name)) { 309 Location location = new Location( 310 className.attrValue.getLine() - 1, 311 className.attrValue.getCharPositionInLine() + 1, 312 className.attrValue.getLine() - 1, 313 className.attrValue.getCharPositionInLine() + name.length() 314 ); 315 bundle.setBindingClass(name, location); 316 } 317 } 318 } 319 320 private XMLParser.ElementContext getDataNode(XMLParser.ElementContext root) { 321 final List<XMLParser.ElementContext> data = filter(root, "data"); 322 if (data.size() == 0) { 323 return null; 324 } 325 Preconditions.check(data.size() == 1, "XML layout can have only 1 data tag"); 326 return data.get(0); 327 } 328 329 private XMLParser.ElementContext getViewNode(File xml, XMLParser.ElementContext root) { 330 final List<XMLParser.ElementContext> view = filterNot(root, "data"); 331 Preconditions.check(view.size() == 1, "XML layout %s must have 1 view but has %s. root" 332 + " children count %s", xml, view.size(), root.getChildCount()); 333 return view.get(0); 334 } 335 336 private List<XMLParser.ElementContext> filter(XMLParser.ElementContext root, 337 String name) { 338 List<XMLParser.ElementContext> result = new ArrayList<>(); 339 if (root == null) { 340 return result; 341 } 342 final XMLParser.ContentContext content = root.content(); 343 if (content == null) { 344 return result; 345 } 346 for (XMLParser.ElementContext child : XmlEditor.elements(root)) { 347 if (name.equals(child.elmName.getText())) { 348 result.add(child); 349 } 350 } 351 return result; 352 } 353 354 private List<XMLParser.ElementContext> filterNot(XMLParser.ElementContext root, 355 String name) { 356 List<XMLParser.ElementContext> result = new ArrayList<>(); 357 if (root == null) { 358 return result; 359 } 360 final XMLParser.ContentContext content = root.content(); 361 if (content == null) { 362 return result; 363 } 364 for (XMLParser.ElementContext child : XmlEditor.elements(root)) { 365 if (!name.equals(child.elmName.getText())) { 366 result.add(child); 367 } 368 } 369 return result; 370 } 371 372 private boolean hasMergeInclude(XMLParser.ElementContext rootView) { 373 return "merge".equals(rootView.elmName.getText()) && filter(rootView, "include").size() > 0; 374 } 375 376 private File stripFileAndGetOriginal(File xml, String binderId) 377 throws ParserConfigurationException, IOException, SAXException, 378 XPathExpressionException { 379 L.d("parsing resource file %s", xml.getAbsolutePath()); 380 DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); 381 DocumentBuilder builder = factory.newDocumentBuilder(); 382 Document doc = builder.parse(xml); 383 XPathFactory xPathFactory = XPathFactory.newInstance(); 384 XPath xPath = xPathFactory.newXPath(); 385 final XPathExpression commentElementExpr = xPath 386 .compile("//comment()[starts-with(., \" From: file:\")][last()]"); 387 final NodeList commentElementNodes = (NodeList) commentElementExpr 388 .evaluate(doc, XPathConstants.NODESET); 389 L.d("comment element nodes count %s", commentElementNodes.getLength()); 390 if (commentElementNodes.getLength() == 0) { 391 L.d("cannot find comment element to find the actual file"); 392 return null; 393 } 394 final Node first = commentElementNodes.item(0); 395 String actualFilePath = first.getNodeValue().substring(" From:".length()).trim(); 396 L.d("actual file to parse: %s", actualFilePath); 397 File actualFile = urlToFile(new java.net.URL(actualFilePath)); 398 if (!actualFile.canRead()) { 399 L.d("cannot find original, skipping. %s", actualFile.getAbsolutePath()); 400 return null; 401 } 402 403 // now if file has any binding expressions, find and delete them 404 // TODO we should rely on namespace to avoid parsing file twice 405 boolean changed = isBindingLayout(doc, xPath); 406 if (changed) { 407 stripBindingTags(xml, binderId); 408 } 409 return actualFile; 410 } 411 412 private boolean isBindingLayout(Document doc, XPath xPath) throws XPathExpressionException { 413 return !get(doc, xPath, XPATH_BINDING_LAYOUT).isEmpty(); 414 } 415 416 private List<Node> get(Document doc, XPath xPath, String pattern) 417 throws XPathExpressionException { 418 final XPathExpression expr = xPath.compile(pattern); 419 return toList((NodeList) expr.evaluate(doc, XPathConstants.NODESET)); 420 } 421 422 private List<Node> toList(NodeList nodeList) { 423 List<Node> result = new ArrayList<Node>(); 424 for (int i = 0; i < nodeList.getLength(); i++) { 425 result.add(nodeList.item(i)); 426 } 427 return result; 428 } 429 430 private void stripBindingTags(File xml, String newTag) throws IOException { 431 String res = XmlEditor.strip(xml, newTag); 432 if (res != null) { 433 L.d("file %s has changed, overwriting %s", xml.getName(), xml.getAbsolutePath()); 434 FileUtils.writeStringToFile(xml, res); 435 } 436 } 437 438 public static File urlToFile(URL url) throws MalformedURLException { 439 try { 440 return new File(url.toURI()); 441 } catch (IllegalArgumentException e) { 442 MalformedURLException ex = new MalformedURLException(e.getLocalizedMessage()); 443 ex.initCause(e); 444 throw ex; 445 } catch (URISyntaxException e) { 446 return new File(url.getPath()); 447 } 448 } 449 450 private static Map<String, String> attributeMap(XMLParser.ElementContext root) { 451 final Map<String, String> result = new HashMap<>(); 452 for (XMLParser.AttributeContext attr : XmlEditor.attributes(root)) { 453 result.put(escapeQuotes(attr.attrName.getText(), false), 454 escapeQuotes(attr.attrValue.getText(), true)); 455 } 456 return result; 457 } 458 459 private static XMLParser.AttributeContext findAttribute(XMLParser.ElementContext element, 460 String name) { 461 for (XMLParser.AttributeContext attr : element.attribute()) { 462 if (escapeQuotes(attr.attrName.getText(), false).equals(name)) { 463 return attr; 464 } 465 } 466 return null; 467 } 468 469 private static String escapeQuotes(String textWithQuotes, boolean unescapeValue) { 470 char first = textWithQuotes.charAt(0); 471 int start = 0, end = textWithQuotes.length(); 472 if (first == '"' || first == '\'') { 473 start = 1; 474 } 475 char last = textWithQuotes.charAt(textWithQuotes.length() - 1); 476 if (last == '"' || last == '\'') { 477 end -= 1; 478 } 479 String val = textWithQuotes.substring(start, end); 480 if (unescapeValue) { 481 return StringEscapeUtils.unescapeXml(val); 482 } else { 483 return val; 484 } 485 } 486} 487