/*
* Copyright (C) 2013 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.tools.idea.gradle.output.parser.aapt;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.res2.MergedResourceWriter;
import com.android.tools.idea.gradle.output.GradleMessage;
import com.android.tools.idea.gradle.output.parser.PatternAwareOutputParser;
import com.android.tools.idea.gradle.output.parser.OutputLineReader;
import com.android.tools.idea.gradle.output.parser.ParsingFailedException;
import com.android.utils.SdkUtils;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.openapi.util.Pair;
import com.intellij.util.containers.SoftValueHashMap;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.xml.sax.Attributes;
import org.xml.sax.InputSource;
import org.xml.sax.Locator;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
import javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
import java.io.File;
import java.io.IOException;
import java.io.StringReader;
import java.net.MalformedURLException;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import static com.android.SdkConstants.*;
@VisibleForTesting
public abstract class AbstractAaptOutputParser implements PatternAwareOutputParser {
private static final Logger LOG = Logger.getInstance(AbstractAaptOutputParser.class);
@VisibleForTesting
public static File ourRootDir;
/**
* Portion of the error message which states the context in which the error occurred,
* such as which property was being processed and what the string value was that
* caused the error.
* <pre>
* error: No resource found that matches the given name (at 'text' with value '@string/foo')
* </pre>
*/
private static final Pattern PROPERTY_NAME_AND_VALUE = Pattern.compile("\\(at '(.+)' with value '(.*)'\\)");
/**
* Portion of error message which points to the second occurrence of a repeated resource
* definition.
* <p/>
* Example:
* error: Resource entry repeatedStyle1 already has bag item android:gravity.
*/
private static final Pattern REPEATED_RESOURCE = Pattern.compile("Resource entry (.+) already has bag item (.+)\\.");
/**
* Suffix of error message which points to the first occurrence of a repeated resource
* definition.
* Example:
* Originally defined here.
*/
private static final String ORIGINALLY_DEFINED_HERE = "Originally defined here.";
private static final Pattern NO_RESOURCE_FOUND = Pattern.compile("No resource found that matches the given name: attr '(.+)'\\.");
/**
* Portion of error message which points to a missing required attribute in a
* resource definition.
* <p/>
* Example:
* error: error: A 'name' attribute is required for <style>
*/
private static final Pattern REQUIRED_ATTRIBUTE = Pattern.compile("A '(.+)' attribute is required for <(.+)>");
private static final String START_MARKER = "<!-- From: "; // Keep in sync with MergedResourceWriter#FILENAME_PREFIX
private static final String END_MARKER = " -->";
@NotNull private static final SoftValueHashMap<String, ReadOnlyDocument> ourDocumentsByPathCache =
new SoftValueHashMap<String, ReadOnlyDocument>();
@Nullable
final Matcher getNextLineMatcher(@NotNull OutputLineReader reader, @NotNull Pattern pattern) {
// unless we can't, because we reached the last line
String line = reader.readLine();
if (line == null) {
// we expected a 2nd line, so we flag as error and we bail
return null;
}
Matcher m = pattern.matcher(line);
return m.matches() ? m : null;
}
@NotNull
GradleMessage createMessage(@NotNull GradleMessage.Kind kind,
@NotNull String text,
@Nullable String sourcePath,
@Nullable String lineNumberAsText) throws ParsingFailedException {
File file = null;
if (sourcePath != null) {
file = new File(sourcePath);
if (!file.isFile()) {
throw new ParsingFailedException();
}
}
int lineNumber = -1;
if (lineNumberAsText != null) {
try {
lineNumber = Integer.parseInt(lineNumberAsText);
}
catch (NumberFormatException e) {
throw new ParsingFailedException();
}
}
int column = -1;
if (sourcePath != null) {
Pair<File, Integer> source = findSourcePosition(file, lineNumber, text);
if (source != null) {
file = source.getFirst();
sourcePath = file.getPath();
if (source.getSecond() != null) {
lineNumber = source.getSecond();
}
}
}
// Attempt to determine the exact range of characters affected by this error.
// This will look up the actual text of the file, go to the particular error line and findText for the specific string mentioned in the
// error.
if (file != null && lineNumber != -1) {
Position errorPosition = findMessagePositionInFile(file, text, lineNumber);
if (errorPosition != null) {
lineNumber = errorPosition.myLineNumber;
column = errorPosition.myColumn;
}
}
return new GradleMessage(kind, text, sourcePath, lineNumber, column);
}
@Nullable
private static Position findMessagePositionInFile(@NotNull File file, @NotNull String msgText, int locationLine) {
Matcher matcher = PROPERTY_NAME_AND_VALUE.matcher(msgText);
if (matcher.find()) {
String name = matcher.group(1);
String value = matcher.group(2);
if (!value.isEmpty()) {
return findText(file, name, value, locationLine);
}
Position position1 = findText(file, name, "\"\"", locationLine);
Position position2 = findText(file, name, "''", locationLine);
if (position1 == null) {
if (position2 == null) {
// position at the property name instead.
return findText(file, name, null, locationLine);
}
return position2;
}
else if (position2 == null) {
return position1;
}
else if (position1.myOffset < position2.myOffset) {
return position1;
}
else {
return position2;
}
}
matcher = REPEATED_RESOURCE.matcher(msgText);
if (matcher.find()) {
String property = matcher.group(2);
return findText(file, property, null, locationLine);
}
matcher = NO_RESOURCE_FOUND.matcher(msgText);
if (matcher.find()) {
String property = matcher.group(1);
return findText(file, property, null, locationLine);
}
matcher = REQUIRED_ATTRIBUTE.matcher(msgText);
if (matcher.find()) {
String elementName = matcher.group(2);
return findText(file, '<' + elementName, null, locationLine);
}
if (msgText.endsWith(ORIGINALLY_DEFINED_HERE)) {
return findLineStart(file, locationLine);
}
return null;
}
@Nullable
private static Position findText(@NotNull File file, @NotNull String first, @Nullable String second, int locationLine) {
ReadOnlyDocument document = getDocument(file);
if (document == null) {
return null;
}
int offset = document.lineOffset(locationLine);
if (offset == -1L) {
return null;
}
int resultOffset = document.findText(first, offset);
if (resultOffset == -1L) {
return null;
}
if (second != null) {
resultOffset = document.findText(second, resultOffset + first.length());
if (resultOffset == -1L) {
return null;
}
}
int lineNumber = document.lineNumber(resultOffset);
int lineOffset = document.lineOffset(lineNumber);
return new Position(lineNumber, resultOffset - lineOffset + 1, resultOffset);
}
@Nullable
private static Position findLineStart(@NotNull File file, int locationLine) {
ReadOnlyDocument document = getDocument(file);
if (document == null) {
return null;
}
int lineOffset = document.lineOffset(locationLine);
if (lineOffset == -1L) {
return null;
}
int nextLineOffset = document.lineOffset(locationLine + 1);
if (nextLineOffset == -1) {
nextLineOffset = document.length();
}
int resultOffset = -1;
for (int i = lineOffset; i < nextLineOffset; i++) {
char c = document.charAt(i);
if (!Character.isWhitespace(c)) {
resultOffset = i;
break;
}
}
if (resultOffset == -1L) {
return null;
}
return new Position(locationLine, resultOffset - lineOffset + 1, resultOffset);
}
@Nullable
private static ReadOnlyDocument getDocument(@NotNull File file) {
String filePath = file.getAbsolutePath();
ReadOnlyDocument document = ourDocumentsByPathCache.get(filePath);
if (document == null || document.isStale()) {
try {
if (!file.exists()) {
if (ourRootDir != null && ourRootDir.isAbsolute() && !file.isAbsolute()) {
file = new File(ourRootDir, file.getPath());
return getDocument(file);
}
return null;
}
document = new ReadOnlyDocument(file);
ourDocumentsByPathCache.put(filePath, document);
}
catch (IOException e) {
String format = "Unexpected error occurred while reading file '%s'";
LOG.warn(String.format(format, file.getAbsolutePath()), e);
return null;
}
}
return document;
}
@Nullable
protected Pair<File, Integer> findSourcePosition(@NotNull File file, int locationLine, String message) {
if (!file.getPath().endsWith(DOT_XML)) {
return null;
}
ReadOnlyDocument document = getDocument(file);
if (document == null) {
return null;
}
// All value files get merged together into a single values file; in that case, we need to
// search for comment markers backwards which indicates the source file for the current file
int searchStart;
String fileName = file.getName();
boolean isManifest = fileName.equals(ANDROID_MANIFEST_XML);
boolean isValueFile = fileName.equals(MergedResourceWriter.FN_VALUES_XML);
if (isValueFile || isManifest) {
searchStart = document.lineOffset(locationLine);
}
else {
searchStart = document.length();
}
if (searchStart == -1L) {
return null;
}
int start = document.findTextBackwards(START_MARKER, searchStart);
assert start < searchStart;
if (start == -1 && isManifest && searchStart < document.length()) {
// If the manifest file didn't need to merge, it will place the source reference at the end instead
searchStart = document.length();
if (searchStart != -1L) {
start = document.findTextBackwards(START_MARKER, searchStart);
assert start < searchStart;
}
}
if (start == -1) {
return null;
}
start += START_MARKER.length();
int end = document.findText(END_MARKER, start);
if (end == -1) {
return null;
}
String sourcePath = document.subsequence(start, end);
File sourceFile;
if (sourcePath.startsWith("file:")) {
String originalPath = sourcePath;
sourcePath = urlToPath(sourcePath);
sourceFile = new File(sourcePath);
if (!sourceFile.exists()) {
// JpsPathUtil.urlToPath just chops off the prefix; try a little harder
// for example to decode %2D's which are used by the MergedResourceWriter to
// encode --'s in the path, since those are invalid in XML comments
try {
sourceFile = SdkUtils.urlToFile(originalPath);
}
catch (MalformedURLException e) {
LOG.warn("Invalid file URL: " + originalPath);
}
}
}
else {
sourceFile = new File(sourcePath);
}
if (isValueFile) {
// Look up the line number
locationLine = -1;
Position position = findMessagePositionInFile(sourceFile, message, 1); // Search from the beginning
if (position != null) {
locationLine = position.myLineNumber;
}
}
return Pair.create(sourceFile, locationLine);
}
@NotNull
private static String urlToPath(@NotNull String url) {
if (url.startsWith("file:")) {
String prefix;
if (url.startsWith("file://")) {
prefix = "file://";
}
else {
prefix = "file:";
}
return url.substring(prefix.length());
}
return url;
}
/**
* Locates a resource value definition in a given file for a given key, and returns the corresponding line number, or -1 if not found.
* For example, given the key "string/group2_string" it will locate an element
* {@code <string name="group2_string">} or {@code <item type="string" name="group2_string"}
*/
public static int findResourceLine(@NotNull File file, @NotNull String key) {
int slash = key.indexOf('/');
if (slash == -1) {
assert false : slash; // invalid key format
return -1;
}
final String type = key.substring(0, slash);
final String name = key.substring(slash + 1);
return findValueDeclaration(file, type, name);
}
/**
* Locates a resource value declaration in a given file and returns the corresponding line number, or -1 if not found.
*/
public static int findValueDeclaration(@NotNull File file, @NotNull final String type, @NotNull final String name) {
if (!file.exists()) {
return -1;
}
final ReadOnlyDocument document = getDocument(file);
if (document == null) {
return -1;
}
// First just do something simple: scan for the string. If it only occurs once, it's easy!
int index = document.findText(name, 0);
if (index == -1) {
return -1;
}
// See if there are any more occurrences; if not, we're done
if (document.findText(name, index + name.length()) == -1) {
return document.lineNumber(index);
}
// Try looking for name="$name"
int nameIndex = document.findText("name=\"" + name + "\"", 0);
if (nameIndex != -1) {
// TODO: Disambiguate by type, so if values.xml contains both R.string.foo and R.dimen.foo we
// pick the right one!
return document.lineNumber(nameIndex);
}
int lineNumber = findValueDeclarationViaParse(type, name, document);
if (lineNumber != -1) {
return lineNumber;
}
// Just fall back to the first occurrence of the string
//noinspection ConstantConditions
assert index != -1;
return document.lineNumber(index);
}
private static int findValueDeclarationViaParse(final String type, final String name, ReadOnlyDocument document) {
// Finally do a full SAX parse to identify the position
final int[] certain = new int[]{-1, 0}; // line,column for exact match
final int[] possible = new int[]{-1, 0}; // line,column for possible match, not confirmed by type
final AtomicReference<Integer> line = new AtomicReference<Integer>(-1);
final DefaultHandler handler = new DefaultHandler() {
private int myDepth;
private Locator myLocator;
@Override
public void setDocumentLocator(final Locator locator) {
myLocator = locator;
}
@Override
public void startElement(String uri, String localName, String qName, Attributes attributes) throws SAXException {
myDepth++;
if (myDepth == 2) {
if (name.equals(attributes.getValue(ATTR_NAME))) {
int lineNumber = myLocator.getLineNumber();
int column = myLocator.getColumnNumber();
if (qName.equals(type) || TAG_ITEM.equals(qName) && type.equals(attributes.getValue(ATTR_TYPE))) {
line.set(lineNumber);
certain[0] = lineNumber;
certain[1] = column;
}
else if (line.get() < 0) {
// Use a negative number to indicate a match where we're not totally confident (type didn't match)
line.set(-lineNumber);
possible[0] = lineNumber;
possible[1] = column;
}
}
}
}
@Override
public void endElement(String uri, String localName, String qName) throws SAXException {
myDepth--;
}
};
SAXParserFactory factory = SAXParserFactory.newInstance();
try {
// Parse the input
SAXParser saxParser = factory.newSAXParser();
saxParser.parse(new InputSource(new StringReader(document.getContents())), handler);
}
catch (Throwable t) {
// Ignore parser errors; we might have found the error position earlier than the parse error position
}
int lineNumber;
int column;
if (certain[0] != -1) {
lineNumber = certain[0];
column = certain[1];
}
else {
lineNumber = possible[0];
column = possible[1];
}
if (lineNumber != -1) {
// SAX' locator will point to the END of the opening declaration, meaning that if it spans multiple lines, we are pointing
// to the last line:
// <item
// type="dimen"
// name="attribute"
// > <--- this is where the locator points, so we need to search backwards
int offset = document.lineOffset(lineNumber) + column;
offset = document.findTextBackwards(name, offset);
if (offset != -1) {
lineNumber = document.lineNumber(offset);
}
return lineNumber;
}
return -1;
}
private static class Position {
final int myLineNumber;
final int myColumn;
final int myOffset;
Position(int lineNumber, int column, int offset) {
myLineNumber = lineNumber;
myColumn = column;
myOffset = offset;
}
}
}