blob: 7bab914e524d3f634db0d3ce9184d086f152b318 [file] [log] [blame]
/*
* Copyright (C) 2010 The Android Open Source Project
*
* Licensed under the Eclipse Public License, Version 1.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.eclipse.org/org/documents/epl-v10.php
*
* 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.eclipse.adt.internal.editors.layout.gle2;
import static com.android.SdkConstants.ATTR_LAYOUT;
import static com.android.SdkConstants.EXT_XML;
import static com.android.SdkConstants.FD_RESOURCES;
import static com.android.SdkConstants.FD_RES_LAYOUT;
import static com.android.SdkConstants.TOOLS_URI;
import static com.android.SdkConstants.VIEW_FRAGMENT;
import static com.android.SdkConstants.VIEW_INCLUDE;
import static com.android.ide.eclipse.adt.AdtConstants.WS_LAYOUTS;
import static com.android.ide.eclipse.adt.AdtConstants.WS_SEP;
import static com.android.resources.ResourceType.LAYOUT;
import static org.eclipse.core.resources.IResourceDelta.ADDED;
import static org.eclipse.core.resources.IResourceDelta.CHANGED;
import static org.eclipse.core.resources.IResourceDelta.CONTENT;
import static org.eclipse.core.resources.IResourceDelta.REMOVED;
import com.android.annotations.NonNull;
import com.android.annotations.Nullable;
import com.android.annotations.VisibleForTesting;
import com.android.ide.common.resources.ResourceFile;
import com.android.ide.common.resources.ResourceFolder;
import com.android.ide.common.resources.ResourceItem;
import com.android.ide.eclipse.adt.AdtPlugin;
import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper;
import com.android.ide.eclipse.adt.internal.resources.manager.ProjectResources;
import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager;
import com.android.ide.eclipse.adt.internal.resources.manager.ResourceManager.IResourceListener;
import com.android.ide.eclipse.adt.io.IFileWrapper;
import com.android.io.IAbstractFile;
import com.android.resources.ResourceType;
import org.eclipse.core.resources.IFile;
import org.eclipse.core.resources.IMarker;
import org.eclipse.core.resources.IProject;
import org.eclipse.core.resources.IResource;
import org.eclipse.core.runtime.CoreException;
import org.eclipse.core.runtime.IStatus;
import org.eclipse.core.runtime.QualifiedName;
import org.eclipse.swt.widgets.Display;
import org.eclipse.wst.sse.core.StructuredModelManager;
import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* The include finder finds other XML files that are including a given XML file, and does
* so efficiently (caching results across IDE sessions etc).
*/
@SuppressWarnings("restriction") // XML model
public class IncludeFinder {
/** Qualified name for the per-project persistent property include-map */
private final static QualifiedName CONFIG_INCLUDES = new QualifiedName(AdtPlugin.PLUGIN_ID,
"includes");//$NON-NLS-1$
/**
* Qualified name for the per-project non-persistent property storing the
* {@link IncludeFinder} for this project
*/
private final static QualifiedName INCLUDE_FINDER = new QualifiedName(AdtPlugin.PLUGIN_ID,
"includefinder"); //$NON-NLS-1$
/** Project that the include finder locates includes for */
private final IProject mProject;
/** Map from a layout resource name to a set of layouts included by the given resource */
private Map<String, List<String>> mIncludes = null;
/**
* Reverse map of {@link #mIncludes}; points to other layouts that are including a
* given layouts
*/
private Map<String, List<String>> mIncludedBy = null;
/** Flag set during a refresh; ignore updates when this is true */
private static boolean sRefreshing;
/** Global (cross-project) resource listener */
private static ResourceListener sListener;
/**
* Constructs an {@link IncludeFinder} for the given project. Don't use this method;
* use the {@link #get} factory method instead.
*
* @param project project to create an {@link IncludeFinder} for
*/
private IncludeFinder(IProject project) {
mProject = project;
}
/**
* Returns the {@link IncludeFinder} for the given project
*
* @param project the project the finder is associated with
* @return an {@link IncludeFinder} for the given project, never null
*/
@NonNull
public static IncludeFinder get(IProject project) {
IncludeFinder finder = null;
try {
finder = (IncludeFinder) project.getSessionProperty(INCLUDE_FINDER);
} catch (CoreException e) {
// Not a problem; we will just create a new one
}
if (finder == null) {
finder = new IncludeFinder(project);
try {
project.setSessionProperty(INCLUDE_FINDER, finder);
} catch (CoreException e) {
AdtPlugin.log(e, "Can't store IncludeFinder");
}
}
return finder;
}
/**
* Returns a list of resource names that are included by the given resource
*
* @param includer the resource name to return included layouts for
* @return the layouts included by the given resource
*/
private List<String> getIncludesFrom(String includer) {
ensureInitialized();
return mIncludes.get(includer);
}
/**
* Gets the list of all other layouts that are including the given layout.
*
* @param included the file that is included
* @return the files that are including the given file, or null or empty
*/
@Nullable
public List<Reference> getIncludedBy(IResource included) {
ensureInitialized();
String mapKey = getMapKey(included);
List<String> result = mIncludedBy.get(mapKey);
if (result == null) {
String name = getResourceName(included);
if (!name.equals(mapKey)) {
result = mIncludedBy.get(name);
}
}
if (result != null && result.size() > 0) {
List<Reference> references = new ArrayList<Reference>(result.size());
for (String s : result) {
references.add(new Reference(mProject, s));
}
return references;
} else {
return null;
}
}
/**
* Returns true if the given resource is included from some other layout in the
* project
*
* @param included the resource to check
* @return true if the file is included by some other layout
*/
public boolean isIncluded(IResource included) {
ensureInitialized();
String mapKey = getMapKey(included);
List<String> result = mIncludedBy.get(mapKey);
if (result == null) {
String name = getResourceName(included);
if (!name.equals(mapKey)) {
result = mIncludedBy.get(name);
}
}
return result != null && result.size() > 0;
}
@VisibleForTesting
/* package */ List<String> getIncludedBy(String included) {
ensureInitialized();
return mIncludedBy.get(included);
}
/** Initialize the inclusion data structures, if not already done */
private void ensureInitialized() {
if (mIncludes == null) {
// Initialize
if (!readSettings()) {
// Couldn't read settings: probably the first time this code is running
// so there is no known data about includes.
// Yes, these should be multimaps! If we start using Guava replace
// these with multimaps.
mIncludes = new HashMap<String, List<String>>();
mIncludedBy = new HashMap<String, List<String>>();
scanProject();
saveSettings();
}
}
}
// ----- Persistence -----
/**
* Create a String serialization of the includes map. The map attempts to be compact;
* it strips out the @layout/ prefix, and eliminates the values for empty string
* values. The map can be restored by calling {@link #decodeMap}. The encoded String
* will have sorted keys.
*
* @param map the map to be serialized
* @return a serialization (never null) of the given map
*/
@VisibleForTesting
public static String encodeMap(Map<String, List<String>> map) {
StringBuilder sb = new StringBuilder();
if (map != null) {
// Process the keys in sorted order rather than just
// iterating over the entry set to ensure stable output
List<String> keys = new ArrayList<String>(map.keySet());
Collections.sort(keys);
for (String key : keys) {
List<String> values = map.get(key);
if (sb.length() > 0) {
sb.append(',');
}
sb.append(key);
if (values.size() > 0) {
sb.append('=').append('>');
sb.append('{');
boolean first = true;
for (String value : values) {
if (first) {
first = false;
} else {
sb.append(',');
}
sb.append(value);
}
sb.append('}');
}
}
}
return sb.toString();
}
/**
* Decodes the encoding (produced by {@link #encodeMap}) back into the original map,
* modulo any key sorting differences.
*
* @param encoded an encoding of a map created by {@link #encodeMap}
* @return a map corresponding to the encoded values, never null
*/
@VisibleForTesting
public static Map<String, List<String>> decodeMap(String encoded) {
HashMap<String, List<String>> map = new HashMap<String, List<String>>();
if (encoded.length() > 0) {
int i = 0;
int end = encoded.length();
while (i < end) {
// Find key range
int keyBegin = i;
int keyEnd = i;
while (i < end) {
char c = encoded.charAt(i);
if (c == ',') {
break;
} else if (c == '=') {
i += 2; // Skip =>
break;
}
i++;
keyEnd = i;
}
List<String> values = new ArrayList<String>();
// Find values
if (i < end && encoded.charAt(i) == '{') {
i++;
while (i < end) {
int valueBegin = i;
int valueEnd = i;
char c = 0;
while (i < end) {
c = encoded.charAt(i);
if (c == ',' || c == '}') {
valueEnd = i;
break;
}
i++;
}
if (valueEnd > valueBegin) {
values.add(encoded.substring(valueBegin, valueEnd));
}
if (c == '}') {
if (i < end-1 && encoded.charAt(i+1) == ',') {
i++;
}
break;
}
assert c == ',';
i++;
}
}
String key = encoded.substring(keyBegin, keyEnd);
map.put(key, values);
i++;
}
}
return map;
}
/**
* Stores the settings in the persistent project storage.
*/
private void saveSettings() {
// Serialize the mIncludes map into a compact String. The mIncludedBy map can be
// inferred from it.
String encoded = encodeMap(mIncludes);
try {
if (encoded.length() >= 2048) {
// The maximum length of a setting key is 2KB, according to the javadoc
// for the project class. It's unlikely that we'll
// hit this -- even with an average layout root name of 20 characters
// we can still store over a hundred names. But JUST IN CASE we run
// into this, we'll clear out the key in this name which means that the
// information will need to be recomputed in the next IDE session.
mProject.setPersistentProperty(CONFIG_INCLUDES, null);
} else {
String existing = mProject.getPersistentProperty(CONFIG_INCLUDES);
if (!encoded.equals(existing)) {
mProject.setPersistentProperty(CONFIG_INCLUDES, encoded);
}
}
} catch (CoreException e) {
AdtPlugin.log(e, "Can't store include settings");
}
}
/**
* Reads previously stored settings from the persistent project storage
*
* @return true iff settings were restored from the project
*/
private boolean readSettings() {
try {
String encoded = mProject.getPersistentProperty(CONFIG_INCLUDES);
if (encoded != null) {
mIncludes = decodeMap(encoded);
// Set up a reverse map, pointing from included files to the files that
// included them
mIncludedBy = new HashMap<String, List<String>>(2 * mIncludes.size());
for (Map.Entry<String, List<String>> entry : mIncludes.entrySet()) {
// File containing the <include>
String includer = entry.getKey();
// Files being <include>'ed by the above file
List<String> included = entry.getValue();
setIncludedBy(includer, included);
}
return true;
}
} catch (CoreException e) {
AdtPlugin.log(e, "Can't read include settings");
}
return false;
}
// ----- File scanning -----
/**
* Scan the whole project for XML layout resources that are performing includes.
*/
private void scanProject() {
ProjectResources resources = ResourceManager.getInstance().getProjectResources(mProject);
if (resources != null) {
Collection<ResourceItem> layouts = resources.getResourceItemsOfType(LAYOUT);
for (ResourceItem layout : layouts) {
List<ResourceFile> sources = layout.getSourceFileList();
for (ResourceFile source : sources) {
updateFileIncludes(source, false);
}
}
return;
}
}
/**
* Scans the given {@link ResourceFile} and if it is a layout resource, updates the
* includes in it.
*
* @param resourceFile the {@link ResourceFile} to be scanned for includes (doesn't
* have to be only layout XML files; this method will filter the type)
* @param singleUpdate true if this is a single file being updated, false otherwise
* (e.g. during initial project scanning)
* @return true if we updated the includes for the resource file
*/
private boolean updateFileIncludes(ResourceFile resourceFile, boolean singleUpdate) {
Collection<ResourceType> resourceTypes = resourceFile.getResourceTypes();
for (ResourceType type : resourceTypes) {
if (type == ResourceType.LAYOUT) {
ensureInitialized();
List<String> includes = Collections.emptyList();
if (resourceFile.getFile() instanceof IFileWrapper) {
IFile file = ((IFileWrapper) resourceFile.getFile()).getIFile();
// See if we have an existing XML model for this file; if so, we can
// just look directly at the parse tree
boolean hadXmlModel = false;
IStructuredModel model = null;
try {
IModelManager modelManager = StructuredModelManager.getModelManager();
model = modelManager.getExistingModelForRead(file);
if (model instanceof IDOMModel) {
IDOMModel domModel = (IDOMModel) model;
Document document = domModel.getDocument();
includes = findIncludesInDocument(document);
hadXmlModel = true;
}
} finally {
if (model != null) {
model.releaseFromRead();
}
}
// If no XML model we have to read the XML contents and (possibly) parse it.
// The actual file may not exist anymore (e.g. when deleting a layout file
// or when the workspace is out of sync.)
if (!hadXmlModel) {
String xml = AdtPlugin.readFile(file);
if (xml != null) {
includes = findIncludes(xml);
}
}
} else {
String xml = AdtPlugin.readFile(resourceFile);
if (xml != null) {
includes = findIncludes(xml);
}
}
String key = getMapKey(resourceFile);
if (includes.equals(getIncludesFrom(key))) {
// Common case -- so avoid doing settings flush etc
return false;
}
boolean detectCycles = singleUpdate;
setIncluded(key, includes, detectCycles);
if (singleUpdate) {
saveSettings();
}
return true;
}
}
return false;
}
/**
* Finds the list of includes in the given XML content. It attempts quickly return
* empty if the file does not include any include tags; it does this by only parsing
* if it detects the string &lt;include in the file.
*/
@VisibleForTesting
@NonNull
static List<String> findIncludes(@NonNull String xml) {
int index = xml.indexOf(ATTR_LAYOUT);
if (index != -1) {
return findIncludesInXml(xml);
}
return Collections.emptyList();
}
/**
* Parses the given XML content and extracts all the included URLs and returns them
*
* @param xml layout XML content to be parsed for includes
* @return a list of included urls, or null
*/
@VisibleForTesting
@NonNull
static List<String> findIncludesInXml(@NonNull String xml) {
Document document = DomUtilities.parseDocument(xml, false /*logParserErrors*/);
if (document != null) {
return findIncludesInDocument(document);
}
return Collections.emptyList();
}
/** Searches the given DOM document and returns the list of includes, if any */
@NonNull
private static List<String> findIncludesInDocument(@NonNull Document document) {
List<String> includes = findIncludesInDocument(document, null);
if (includes == null) {
includes = Collections.emptyList();
}
return includes;
}
@Nullable
private static List<String> findIncludesInDocument(@NonNull Node node,
@Nullable List<String> urls) {
if (node.getNodeType() == Node.ELEMENT_NODE) {
String tag = node.getNodeName();
boolean isInclude = tag.equals(VIEW_INCLUDE);
boolean isFragment = tag.equals(VIEW_FRAGMENT);
if (isInclude || isFragment) {
Element element = (Element) node;
String url;
if (isInclude) {
url = element.getAttribute(ATTR_LAYOUT);
} else {
url = element.getAttributeNS(TOOLS_URI, ATTR_LAYOUT);
}
if (url.length() > 0) {
String resourceName = urlToLocalResource(url);
if (resourceName != null) {
if (urls == null) {
urls = new ArrayList<String>();
}
urls.add(resourceName);
}
}
}
}
NodeList children = node.getChildNodes();
for (int i = 0, n = children.getLength(); i < n; i++) {
urls = findIncludesInDocument(children.item(i), urls);
}
return urls;
}
/**
* Returns the layout URL to a local resource name (provided the URL is a local
* resource, not something in @android etc.) Returns null otherwise.
*/
private static String urlToLocalResource(String url) {
if (!url.startsWith("@")) { //$NON-NLS-1$
return null;
}
int typeEnd = url.indexOf('/', 1);
if (typeEnd == -1) {
return null;
}
int nameBegin = typeEnd + 1;
int typeBegin = 1;
int colon = url.lastIndexOf(':', typeEnd);
if (colon != -1) {
String packageName = url.substring(typeBegin, colon);
if ("android".equals(packageName)) { //$NON-NLS-1$
// Don't want to point to non-local resources
return null;
}
typeBegin = colon + 1;
assert "layout".equals(url.substring(typeBegin, typeEnd)); //$NON-NLS-1$
}
return url.substring(nameBegin);
}
/**
* Record the list of included layouts from the given layout
*
* @param includer the layout including other layouts
* @param included the layouts that were included by the including layout
* @param detectCycles if true, check for cycles and report them as project errors
*/
@VisibleForTesting
/* package */ void setIncluded(String includer, List<String> included, boolean detectCycles) {
// Remove previously linked inverse mappings
List<String> oldIncludes = mIncludes.get(includer);
if (oldIncludes != null && oldIncludes.size() > 0) {
for (String includee : oldIncludes) {
List<String> includers = mIncludedBy.get(includee);
if (includers != null) {
includers.remove(includer);
}
}
}
mIncludes.put(includer, included);
// Reverse mapping: for included items, point back to including file
setIncludedBy(includer, included);
if (detectCycles) {
detectCycles(includer);
}
}
/** Record the list of included layouts from the given layout */
private void setIncludedBy(String includer, List<String> included) {
for (String target : included) {
List<String> list = mIncludedBy.get(target);
if (list == null) {
list = new ArrayList<String>(2); // We don't expect many includes
mIncludedBy.put(target, list);
}
if (!list.contains(includer)) {
list.add(includer);
}
}
}
/** Start listening on project resources */
public static void start() {
assert sListener == null;
sListener = new ResourceListener();
ResourceManager.getInstance().addListener(sListener);
}
/** Stop listening on project resources */
public static void stop() {
assert sListener != null;
ResourceManager.getInstance().addListener(sListener);
}
private static String getMapKey(ResourceFile resourceFile) {
IAbstractFile file = resourceFile.getFile();
String name = file.getName();
String folderName = file.getParentFolder().getName();
return getMapKey(folderName, name);
}
private static String getMapKey(IResource resourceFile) {
String folderName = resourceFile.getParent().getName();
String name = resourceFile.getName();
return getMapKey(folderName, name);
}
private static String getResourceName(IResource resourceFile) {
String name = resourceFile.getName();
int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot
if (baseEnd > 0) {
name = name.substring(0, baseEnd);
}
return name;
}
private static String getMapKey(String folderName, String name) {
int baseEnd = name.length() - EXT_XML.length() - 1; // -1: the dot
if (baseEnd > 0) {
name = name.substring(0, baseEnd);
}
// Create a map key for the given resource file
// This will map
// /res/layout/foo.xml => "foo"
// /res/layout-land/foo.xml => "-land/foo"
if (FD_RES_LAYOUT.equals(folderName)) {
// Normal case -- keep just the basename
return name;
} else {
// Store the relative path from res/ on down, so
// /res/layout-land/foo.xml becomes "layout-land/foo"
//if (folderName.startsWith(FD_LAYOUT)) {
// folderName = folderName.substring(FD_LAYOUT.length());
//}
return folderName + WS_SEP + name;
}
}
/** Listener of resource file saves, used to update layout inclusion data structures */
private static class ResourceListener implements IResourceListener {
@Override
public void fileChanged(IProject project, ResourceFile file, int eventType) {
if (sRefreshing) {
return;
}
if ((eventType & (CHANGED | ADDED | REMOVED | CONTENT)) == 0) {
return;
}
IncludeFinder finder = get(project);
if (finder != null) {
if (finder.updateFileIncludes(file, true)) {
finder.saveSettings();
}
}
}
@Override
public void folderChanged(IProject project, ResourceFolder folder, int eventType) {
// We only care about layout resource files
}
}
// ----- Cycle detection -----
private void detectCycles(String from) {
// Perform DFS on the include graph and look for a cycle; if we find one, produce
// a chain of includes on the way back to show to the user
if (mIncludes.size() > 0) {
Set<String> visiting = new HashSet<String>(mIncludes.size());
String chain = dfs(from, visiting);
if (chain != null) {
addError(from, chain);
} else {
// Is there an existing error for us to clean up?
removeErrors(from);
}
}
}
/** Format to chain include cycles in: a=>b=>c=>d etc */
private final String CHAIN_FORMAT = "%1$s=>%2$s"; //$NON-NLS-1$
private String dfs(String from, Set<String> visiting) {
visiting.add(from);
List<String> includes = mIncludes.get(from);
if (includes != null && includes.size() > 0) {
for (String include : includes) {
if (visiting.contains(include)) {
return String.format(CHAIN_FORMAT, from, include);
}
String chain = dfs(include, visiting);
if (chain != null) {
return String.format(CHAIN_FORMAT, from, chain);
}
}
}
visiting.remove(from);
return null;
}
private void removeErrors(String from) {
final IResource resource = findResource(from);
if (resource != null) {
try {
final String markerId = IMarker.PROBLEM;
IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO);
for (final IMarker marker : markers) {
String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null);
if (tmpMsg == null || tmpMsg.startsWith(MESSAGE)) {
// Remove
runLater(new Runnable() {
@Override
public void run() {
try {
sRefreshing = true;
marker.delete();
} catch (CoreException e) {
AdtPlugin.log(e, "Can't delete problem marker");
} finally {
sRefreshing = false;
}
}
});
}
}
} catch (CoreException e) {
// if we couldn't get the markers, then we just mark the file again
// (since markerAlreadyExists is initialized to false, we do nothing)
}
}
}
/** Error message for cycles */
private static final String MESSAGE = "Found cyclical <include> chain";
private void addError(String from, String chain) {
final IResource resource = findResource(from);
if (resource != null) {
final String markerId = IMarker.PROBLEM;
final String message = String.format("%1$s: %2$s", MESSAGE, chain);
final int lineNumber = 1;
final int severity = IMarker.SEVERITY_ERROR;
// check if there's a similar marker already, since aapt is launched twice
boolean markerAlreadyExists = false;
try {
IMarker[] markers = resource.findMarkers(markerId, true, IResource.DEPTH_ZERO);
for (IMarker marker : markers) {
int tmpLine = marker.getAttribute(IMarker.LINE_NUMBER, -1);
if (tmpLine != lineNumber) {
break;
}
int tmpSeverity = marker.getAttribute(IMarker.SEVERITY, -1);
if (tmpSeverity != severity) {
break;
}
String tmpMsg = marker.getAttribute(IMarker.MESSAGE, null);
if (tmpMsg == null || tmpMsg.equals(message) == false) {
break;
}
// if we're here, all the marker attributes are equals, we found it
// and exit
markerAlreadyExists = true;
break;
}
} catch (CoreException e) {
// if we couldn't get the markers, then we just mark the file again
// (since markerAlreadyExists is initialized to false, we do nothing)
}
if (!markerAlreadyExists) {
runLater(new Runnable() {
@Override
public void run() {
try {
sRefreshing = true;
// Adding a resource will force a refresh on the file;
// ignore these updates
BaseProjectHelper.markResource(resource, markerId, message, lineNumber,
severity);
} finally {
sRefreshing = false;
}
}
});
}
}
}
// FIXME: Find more standard Eclipse way to do this.
// We need to run marker registration/deletion "later", because when the include
// scanning is running it's in the middle of resource notification, so the IDE
// throws an exception
private static void runLater(Runnable runnable) {
Display display = Display.findDisplay(Thread.currentThread());
if (display != null) {
display.asyncExec(runnable);
} else {
AdtPlugin.log(IStatus.WARNING, "Could not find display");
}
}
/**
* Finds the project resource for the given layout path
*
* @param from the resource name
* @return the {@link IResource}, or null if not found
*/
private IResource findResource(String from) {
final IResource resource = mProject.findMember(WS_LAYOUTS + WS_SEP + from + '.' + EXT_XML);
return resource;
}
/**
* Creates a blank, project-less {@link IncludeFinder} <b>for use by unit tests
* only</b>
*/
@VisibleForTesting
/* package */ static IncludeFinder create() {
IncludeFinder finder = new IncludeFinder(null);
finder.mIncludes = new HashMap<String, List<String>>();
finder.mIncludedBy = new HashMap<String, List<String>>();
return finder;
}
/** A reference to a particular file in the project */
public static class Reference {
/** The unique id referencing the file, such as (for res/layout-land/main.xml)
* "layout-land/main") */
private final String mId;
/** The project containing the file */
private final IProject mProject;
/** The resource name of the file, such as (for res/layout/main.xml) "main" */
private String mName;
/** Creates a new include reference */
private Reference(IProject project, String id) {
super();
mProject = project;
mId = id;
}
/**
* Returns the id identifying the given file within the project
*
* @return the id identifying the given file within the project
*/
public String getId() {
return mId;
}
/**
* Returns the {@link IFile} in the project for the given file. May return null if
* there is an error in locating the file or if the file no longer exists.
*
* @return the project file, or null
*/
public IFile getFile() {
String reference = mId;
if (!reference.contains(WS_SEP)) {
reference = FD_RES_LAYOUT + WS_SEP + reference;
}
String projectPath = FD_RESOURCES + WS_SEP + reference + '.' + EXT_XML;
IResource member = mProject.findMember(projectPath);
if (member instanceof IFile) {
return (IFile) member;
}
return null;
}
/**
* Returns a description of this reference, suitable to be shown to the user
*
* @return a display name for the reference
*/
public String getDisplayName() {
// The ID is deliberately kept in a pretty user-readable format but we could
// consider prepending layout/ on ids that don't have it (to make the display
// more uniform) or ripping out all layout[-constraint] prefixes out and
// instead prepending @ etc.
return mId;
}
/**
* Returns the name of the reference, suitable for resource lookup. For example,
* for "res/layout/main.xml", as well as for "res/layout-land/main.xml", this
* would be "main".
*
* @return the resource name of the reference
*/
public String getName() {
if (mName == null) {
mName = mId;
int index = mName.lastIndexOf(WS_SEP);
if (index != -1) {
mName = mName.substring(index + 1);
}
}
return mName;
}
@Override
public int hashCode() {
final int prime = 31;
int result = 1;
result = prime * result + ((mId == null) ? 0 : mId.hashCode());
return result;
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
Reference other = (Reference) obj;
if (mId == null) {
if (other.mId != null)
return false;
} else if (!mId.equals(other.mId))
return false;
return true;
}
@Override
public String toString() {
return "Reference [getId()=" + getId() //$NON-NLS-1$
+ ", getDisplayName()=" + getDisplayName() //$NON-NLS-1$
+ ", getName()=" + getName() //$NON-NLS-1$
+ ", getFile()=" + getFile() + "]"; //$NON-NLS-1$
}
/**
* Creates a reference to the given file
*
* @param file the file to create a reference for
* @return a reference to the given file
*/
public static Reference create(IFile file) {
return new Reference(file.getProject(), getMapKey(file));
}
/**
* Returns the resource name of this layout, such as {@code @layout/foo}.
*
* @return the resource name
*/
public String getResourceName() {
return '@' + FD_RES_LAYOUT + '/' + getName();
}
}
/**
* Returns a collection of layouts (expressed as resource names, such as
* {@code @layout/foo} which would be invalid includes in the given layout
* (because it would introduce a cycle)
*
* @param layout the layout file to check for cyclic dependencies from
* @return a collection of layout resources which cannot be included from
* the given layout, never null
*/
public Collection<String> getInvalidIncludes(IFile layout) {
IProject project = layout.getProject();
Reference self = Reference.create(layout);
// Add anyone who transitively can reach this file via includes.
LinkedList<Reference> queue = new LinkedList<Reference>();
List<Reference> invalid = new ArrayList<Reference>();
queue.add(self);
invalid.add(self);
Set<String> seen = new HashSet<String>();
seen.add(self.getId());
while (!queue.isEmpty()) {
Reference reference = queue.removeFirst();
String refId = reference.getId();
// Look up both configuration specific includes as well as includes in the
// base versions
List<String> included = getIncludedBy(refId);
if (refId.indexOf('/') != -1) {
List<String> baseIncluded = getIncludedBy(reference.getName());
if (included == null) {
included = baseIncluded;
} else if (baseIncluded != null) {
included = new ArrayList<String>(included);
included.addAll(baseIncluded);
}
}
if (included != null && included.size() > 0) {
for (String id : included) {
if (!seen.contains(id)) {
seen.add(id);
Reference ref = new Reference(project, id);
invalid.add(ref);
queue.addLast(ref);
}
}
}
}
List<String> result = new ArrayList<String>();
for (Reference reference : invalid) {
result.add(reference.getResourceName());
}
return result;
}
}