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