/*
* Copyright (C) 2015 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.ide.common.blame.parser.aapt;
import static com.android.SdkConstants.ANDROID_MANIFEST_XML;
import static com.android.SdkConstants.ATTR_NAME;
import static com.android.SdkConstants.ATTR_TYPE;
import static com.android.SdkConstants.DOT_XML;
import static com.android.SdkConstants.TAG_ITEM;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.blame.Message;
import com.android.ide.common.blame.SourceFile;
import com.android.ide.common.blame.SourceFilePosition;
import com.android.ide.common.blame.SourcePosition;
import com.android.ide.common.blame.parser.util.OutputLineReader;
import com.android.ide.common.blame.parser.ParsingFailedException;
import com.android.ide.common.blame.parser.PatternAwareOutputParser;
import com.android.resources.ResourceFolderType;
import com.android.utils.ILogger;
import com.android.utils.SdkUtils;
import com.google.common.cache.Cache;
import com.google.common.cache.CacheBuilder;
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 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 javax.xml.parsers.SAXParser;
import javax.xml.parsers.SAXParserFactory;
@VisibleForTesting
public abstract class AbstractAaptOutputParser implements PatternAwareOutputParser {
/**
* 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 <(.+)>");
// Keep in sync with SdkUtils#FILENAME_PREFIX
private static final String START_MARKER = "<!-- From: ";
private static final String END_MARKER = " -->";
private static final Cache<String, ReadOnlyDocument> ourDocumentsByPathCache = CacheBuilder
.newBuilder()
.weakValues().build();
@VisibleForTesting
public static File ourRootDir;
@NonNull
private static SourcePosition findMessagePositionInFile(@NonNull File file, @NonNull String msgText, int locationLine, ILogger logger) {
SourcePosition exactPosition = findExactMessagePositionInFile(file, msgText, locationLine, logger);
if (exactPosition != null) {
return exactPosition;
} else {
return new SourcePosition(locationLine, -1, -1);
}
}
@Nullable
private static SourcePosition findExactMessagePositionInFile(@NonNull File file, @NonNull String msgText, int locationLine, @NonNull ILogger logger) {
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, logger);
}
SourcePosition position1 = findText(file, name, "\"\"", locationLine, logger);
SourcePosition position2 = findText(file, name, "''", locationLine, logger);
if (position1 == null) {
if (position2 == null) {
// position at the property name instead.
return findText(file, name, null, locationLine, logger);
}
return position2;
} else if (position2 == null) {
return position1;
} else if (position1.getStartOffset() < position2.getStartOffset()) {
return position1;
} else {
return position2;
}
}
matcher = REPEATED_RESOURCE.matcher(msgText);
if (matcher.find()) {
String property = matcher.group(2);
return findText(file, property, null, locationLine, logger);
}
matcher = NO_RESOURCE_FOUND.matcher(msgText);
if (matcher.find()) {
String property = matcher.group(1);
return findText(file, property, null, locationLine, logger);
}
matcher = REQUIRED_ATTRIBUTE.matcher(msgText);
if (matcher.find()) {
String elementName = matcher.group(2);
return findText(file, '<' + elementName, null, locationLine, logger);
}
if (msgText.endsWith(ORIGINALLY_DEFINED_HERE)) {
return findLineStart(file, locationLine, logger);
}
return null;
}
@Nullable
private static SourcePosition findText(@NonNull File file, @NonNull String first,
@Nullable String second, int locationLine, @NonNull ILogger logger) {
ReadOnlyDocument document = getDocument(file, logger);
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 startLineNumber = document.lineNumber(resultOffset);
int startLineOffset = document.lineOffset(startLineNumber);
int endResultOffset = resultOffset + (second != null ? second.length() : first.length());
int endLineNumber = document.lineNumber(endResultOffset);
int endLineOffset = document.lineOffset((endLineNumber));
return new SourcePosition(startLineNumber, resultOffset - startLineOffset, resultOffset,
endLineNumber, endResultOffset - endLineOffset, endResultOffset);
}
@Nullable
private static SourcePosition findLineStart(@NonNull File file, int locationLine, ILogger logger) {
ReadOnlyDocument document = getDocument(file, logger);
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();
}
// Ignore whitespace at the beginning of the line
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;
}
//Ignore whitespace at the end of the line
int endResultOffset = resultOffset;
for (int i = nextLineOffset - 1; i >= resultOffset; i--) {
char c = document.charAt(i);
if (!Character.isWhitespace(c)) {
endResultOffset = i;
break;
}
}
return new SourcePosition(locationLine, resultOffset - lineOffset, resultOffset,
locationLine, endResultOffset - lineOffset, endResultOffset);
}
@Nullable
private static ReadOnlyDocument getDocument(@NonNull File file, ILogger logger) {
String filePath = file.getAbsolutePath();
ReadOnlyDocument document = ourDocumentsByPathCache.getIfPresent(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, logger);
}
return null;
}
document = new ReadOnlyDocument(file);
ourDocumentsByPathCache.put(filePath, document);
} catch (IOException e) {
String format = "Unexpected error occurred while reading file '%s' [%s]";
logger.warning(format, file.getAbsolutePath(), e);
return null;
}
}
return document;
}
@NonNull
private static String urlToPath(@NonNull 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 SourcePosition findResourceLine(@NonNull File file, @NonNull String key, @NonNull ILogger logger) {
int slash = key.indexOf('/');
if (slash == -1) {
assert false : slash; // invalid key format
return SourcePosition.UNKNOWN;
}
final String type = key.substring(0, slash);
final String name = key.substring(slash + 1);
return findValueDeclaration(file, type, name, logger);
}
/**
* Locates a resource value declaration in a given file and returns the corresponding line
* number, or -1 if not found.
*/
public static SourcePosition findValueDeclaration(@NonNull File file, @NonNull final String type,
@NonNull final String name, @NonNull ILogger logger) {
if (!file.exists()) {
return SourcePosition.UNKNOWN;
}
final ReadOnlyDocument document = getDocument(file, logger);
if (document == null) {
return SourcePosition.UNKNOWN;
}
// 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 SourcePosition.UNKNOWN;
}
// See if there are any more occurrences; if not, we're done
if (document.findText(name, index + name.length()) == -1) {
return document.sourcePosition(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.sourcePosition(nameIndex);
}
SourcePosition lineNumber = findValueDeclarationViaParse(type, name, document);
if (!SourcePosition.UNKNOWN.equals(lineNumber)) {
return lineNumber;
}
// Just fall back to the first occurrence of the string
//noinspection ConstantConditions
assert index != -1;
return document.sourcePosition(index);
}
private static SourcePosition 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() - 1;
int column = myLocator.getColumnNumber() - 1;
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 endLineNumber;
int endColumn;
if (certain[0] != -1) {
endLineNumber = certain[0];
endColumn = certain[1];
} else {
endLineNumber = possible[0];
endColumn = possible[1];
}
if (endLineNumber != -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 endOffset = document.lineOffset(endLineNumber) + endColumn;
int offset = document.findTextBackwards(name, endOffset);
if (offset != -1) {
SourcePosition start = document.sourcePosition(offset);
return new SourcePosition(start.getStartLine(), start.getStartColumn(), start.getStartOffset(),
endLineNumber, endColumn, endOffset);
}
return new SourcePosition(endLineNumber, endColumn, endOffset);
}
return SourcePosition.UNKNOWN;
}
@Nullable
final Matcher getNextLineMatcher(@NonNull OutputLineReader reader, @NonNull 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;
}
@NonNull
Message createMessage(@NonNull Message.Kind kind,
@NonNull String text,
@Nullable String sourcePath,
@Nullable String lineNumberAsText,
@NonNull String original,
ILogger logger) throws ParsingFailedException {
File file = null;
if (sourcePath != null) {
file = new File(sourcePath);
if (!file.isFile()) {
throw new ParsingFailedException();
}
}
SourcePosition errorPosition = parseLineNumber(lineNumberAsText);
if (sourcePath != null) {
SourceFilePosition source = findSourcePosition(file, errorPosition.getStartLine(), text, logger);
if (source != null) {
file = source.getFile().getSourceFile();
sourcePath = file.getPath();
if (source.getPosition().getStartLine() != -1) {
errorPosition = source.getPosition();
}
}
}
// 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 && errorPosition.getStartLine() != -1) {
errorPosition = findMessagePositionInFile(file, text, errorPosition.getStartLine(), logger);
}
return new Message(kind, text, original, new SourceFilePosition(file, errorPosition));
}
private SourcePosition parseLineNumber(String lineNumberAsText) throws ParsingFailedException {
int lineNumber = -1;
if (lineNumberAsText != null) {
try {
lineNumber = Integer.parseInt(lineNumberAsText);
} catch (NumberFormatException e) {
throw new ParsingFailedException();
}
}
return new SourcePosition(lineNumber - 1, -1, -1);
}
/**
*
* @param file
* @param locationLine
* @param message
* @param logger
* @return null if could not be found, new SourceFilePosition(new SourceFile file,
*/
@Nullable
protected static SourceFilePosition findSourcePosition(@NonNull File file, int locationLine,
String message, ILogger logger) {
if (!file.getPath().endsWith(DOT_XML)) {
return null;
}
ReadOnlyDocument document = getDocument(file, logger);
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.startsWith(ResourceFolderType.VALUES.getName());
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) {
logger.warning("Invalid file URL: " + originalPath);
}
}
} else {
sourceFile = new File(sourcePath);
}
if (isValueFile) {
// Look up the line number
SourcePosition position = findMessagePositionInFile(sourceFile, message,
1, logger); // Search from the beginning
return new SourceFilePosition(new SourceFile(sourceFile), position);
}
return new SourceFilePosition(new SourceFile(sourceFile), SourcePosition.UNKNOWN);
}
}