/* * 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.rendering; import com.android.annotations.NonNull; import com.android.annotations.VisibleForTesting; import com.android.ide.common.res2.ResourceItem; import com.android.ide.common.resources.configuration.FolderConfiguration; import com.android.resources.FolderTypeRelationship; import com.android.resources.ResourceFolderType; import com.android.resources.ResourceType; import com.android.sdklib.IAndroidTarget; import com.android.tools.idea.configurations.ConfigurationManager; import com.android.tools.lint.detector.api.LintUtils; import com.google.common.collect.*; import com.intellij.openapi.application.ApplicationManager; import com.intellij.openapi.diagnostic.Logger; import com.intellij.openapi.fileTypes.FileType; import com.intellij.openapi.fileTypes.FileTypeManager; import com.intellij.openapi.fileTypes.StdFileTypes; import com.intellij.openapi.module.Module; import com.intellij.openapi.project.Project; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.psi.*; import com.intellij.psi.util.PsiTreeUtil; import com.intellij.psi.xml.*; import org.jetbrains.android.facet.AndroidFacet; import org.jetbrains.android.sdk.AndroidTargetData; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import java.util.*; import static com.android.SdkConstants.*; import static com.android.resources.ResourceFolderType.*; import static com.android.tools.idea.rendering.PsiProjectListener.isRelevantFile; import static com.android.tools.idea.rendering.PsiProjectListener.isRelevantFileType; import static com.android.tools.idea.rendering.ResourceHelper.getFolderType; import static com.android.tools.lint.detector.api.LintUtils.stripIdPrefix; /** * Remaining work: * <ul> * <li>Find some way to have event updates in this resource folder directly update parent repositories * (typically {@link ModuleResourceRepository}</li> * <li>consider *initializing* this repository initially from IO files to not force full modelling of * XML objects for all these tiny files (translations etc) ? Or find some way to persist the data in the index.</li> * <li>Add defensive checks for non-read permission reads of resource values</li> * <li>Idea: For {@link #rescan}; compare the removed items from the added items, and if they're the same, avoid * creating a new generation.</li> * <li>Register the psi project listener as a project service instead</li> * </ul> */ public final class ResourceFolderRepository extends LocalResourceRepository { private static final Logger LOG = Logger.getInstance(ResourceFolderRepository.class); private final Module myModule; private final AndroidFacet myFacet; private final PsiListener myListener; private final VirtualFile myResourceDir; private final Map<ResourceType, ListMultimap<String, ResourceItem>> myItems = Maps.newEnumMap(ResourceType.class); private final Map<PsiFile, PsiResourceFile> myResourceFiles = Maps.newHashMap(); private final Object SCAN_LOCK = new Object(); private Set<PsiFile> myPendingScans; @VisibleForTesting static int ourFullRescans; private ResourceFolderRepository(@NotNull AndroidFacet facet, @NotNull VirtualFile resourceDir) { super(resourceDir.getName()); myFacet = facet; myModule = facet.getModule(); myListener = new PsiListener(); myResourceDir = resourceDir; scan(); } @NotNull AndroidFacet getFacet() { return myFacet; } VirtualFile getResourceDir() { return myResourceDir; } /** NOTE: You should normally use {@link ResourceFolderRegistry#get} rather than this method. */ @NotNull static ResourceFolderRepository create(@NotNull final AndroidFacet facet, @NotNull VirtualFile dir) { return new ResourceFolderRepository(facet, dir); } private void scan() { ApplicationManager.getApplication().runReadAction(new Runnable() { @Override public void run() { PsiManager manager = PsiManager.getInstance(myFacet.getModule().getProject()); if (myResourceDir.isValid()) { PsiDirectory directory = manager.findDirectory(myResourceDir); if (directory != null) { scanResFolder(directory); } } } }); } @Nullable private PsiFile ensureValid(@NotNull PsiFile psiFile) { if (psiFile.isValid()) { return psiFile; } else { VirtualFile virtualFile = psiFile.getVirtualFile(); if (virtualFile != null && virtualFile.exists()) { Project project = myModule.getProject(); if (!project.isDisposed()) { return PsiManager.getInstance(project).findFile(virtualFile); } } } return null; } private void scanResFolder(@NotNull PsiDirectory res) { for (PsiDirectory dir : res.getSubdirectories()) { String name = dir.getName(); ResourceFolderType folderType = ResourceFolderType.getFolderType(name); if (folderType != null) { String qualifiers = getQualifiers(name); FolderConfiguration folderConfiguration = FolderConfiguration.getConfigForFolder(name); if (folderConfiguration == null) { continue; } if (folderType == VALUES) { scanValueResFolder(dir, qualifiers, folderConfiguration); } else { scanFileResourceFolder(dir, folderType, qualifiers, folderConfiguration); } } } } private static String getQualifiers(String dirName) { int index = dirName.indexOf('-'); return index != -1 ? dirName.substring(index + 1) : ""; } private void scanFileResourceFolder(@NotNull PsiDirectory directory, ResourceFolderType folderType, String qualifiers, FolderConfiguration folderConfiguration) { List<ResourceType> resourceTypes = FolderTypeRelationship.getRelatedResourceTypes(folderType); assert resourceTypes.size() >= 1 : folderType; ResourceType type = resourceTypes.get(0); boolean idGenerating = resourceTypes.size() > 1; assert !idGenerating || resourceTypes.size() == 2 && resourceTypes.get(1) == ResourceType.ID; ListMultimap<String, ResourceItem> map = myItems.get(type); if (map == null) { map = ArrayListMultimap.create(); myItems.put(type, map); } for (PsiFile file : directory.getFiles()) { FileType fileType = file.getFileType(); if (isRelevantFileType(fileType) || folderType == ResourceFolderType.RAW) { scanFileResourceFile(qualifiers, folderType, folderConfiguration, type, idGenerating, map, file); } // TODO: Else warn about files that aren't expected to be found here? } } private void scanFileResourceFile(String qualifiers, ResourceFolderType folderType, FolderConfiguration folderConfiguration, ResourceType type, boolean idGenerating, ListMultimap<String, ResourceItem> map, PsiFile file) { // XML or Image String name = ResourceHelper.getResourceName(file); ResourceItem item = new PsiResourceItem(name, type, null, file); if (idGenerating) { List<ResourceItem> items = Lists.newArrayList(); items.add(item); map.put(name, item); addIds(items, file); PsiResourceFile resourceFile = new PsiResourceFile(file, items, qualifiers, folderType, folderConfiguration); myResourceFiles.put(file, resourceFile); } else { PsiResourceFile resourceFile = new PsiResourceFile(file, item, qualifiers, folderType, folderConfiguration); myResourceFiles.put(file, resourceFile); map.put(name, item); } } @NonNull @Override protected Map<ResourceType, ListMultimap<String, ResourceItem>> getMap() { return myItems; } @Nullable @Override protected ListMultimap<String, ResourceItem> getMap(ResourceType type, boolean create) { ListMultimap<String, ResourceItem> multimap = myItems.get(type); if (multimap == null && create) { multimap = ArrayListMultimap.create(); myItems.put(type, multimap); } return multimap; } @Override public void clear() { super.clear(); myResourceFiles.clear(); } private void addIds(List<ResourceItem> items, PsiFile file) { addIds(items, file, file); } private void addIds(List<ResourceItem> items, PsiElement element, PsiFile file) { Collection<XmlTag> xmlTags = PsiTreeUtil.findChildrenOfType(element, XmlTag.class); if (element instanceof XmlTag) { addId(items, file, (XmlTag)element); } if (!xmlTags.isEmpty()) { for (XmlTag tag : xmlTags) { addId(items, file, tag); } } } private void addId(List<ResourceItem> items, PsiFile file, XmlTag tag) { assert tag.isValid(); String id = tag.getAttributeValue(ATTR_ID, ANDROID_URI); if (id != null && id.startsWith(NEW_ID_PREFIX)) { String name = id.substring(NEW_ID_PREFIX.length()); PsiResourceItem item = new PsiResourceItem(name, ResourceType.ID, null, file); items.add(item); ListMultimap<String, ResourceItem> map = myItems.get(ResourceType.ID); if (map == null) { map = ArrayListMultimap.create(); myItems.put(ResourceType.ID, map); } map.put(name, item); } } private void scanValueResFolder(@NotNull PsiDirectory directory, String qualifiers, FolderConfiguration folderConfiguration) { //noinspection ConstantConditions assert directory.getName().startsWith(FD_RES_VALUES); for (PsiFile file : directory.getFiles()) { scanValueFile(qualifiers, file, folderConfiguration); } } private boolean scanValueFile(String qualifiers, PsiFile file, FolderConfiguration folderConfiguration) { boolean added = false; FileType fileType = file.getFileType(); if (fileType == StdFileTypes.XML) { XmlFile xmlFile = (XmlFile)file; assert xmlFile.isValid(); XmlDocument document = xmlFile.getDocument(); if (document != null) { XmlTag root = document.getRootTag(); if (root == null) { return false; } if (!root.getName().equals(TAG_RESOURCES)) { return false; } XmlTag[] subTags = root.getSubTags(); // Not recursive, right? List<ResourceItem> items = Lists.newArrayListWithExpectedSize(subTags.length); for (XmlTag tag : subTags) { String name = tag.getAttributeValue(ATTR_NAME); if (name != null) { ResourceType type = getType(tag); if (type != null) { ListMultimap<String, ResourceItem> map = myItems.get(type); if (map == null) { map = ArrayListMultimap.create(); myItems.put(type, map); } ResourceItem item = new PsiResourceItem(name, type, tag, file); map.put(name, item); items.add(item); added = true; if (type == ResourceType.DECLARE_STYLEABLE) { // for declare styleables we also need to create attr items for its children XmlTag[] attrs = tag.getSubTags(); if (attrs.length > 0) { map = myItems.get(ResourceType.ATTR); if (map == null) { map = ArrayListMultimap.create(); myItems.put(ResourceType.ATTR, map); } for (XmlTag child : attrs) { String attrName = child.getAttributeValue(ATTR_NAME); if (attrName != null && !attrName.startsWith(ANDROID_NS_NAME_PREFIX) // Only add attr nodes for elements that specify a format or have flag/enum children; otherwise // it's just a reference to an existing attr && (child.getAttribute(ATTR_FORMAT) != null || child.getSubTags().length > 0)) { ResourceItem attrItem = new PsiResourceItem(attrName, ResourceType.ATTR, child, file); items.add(attrItem); map.put(attrName, attrItem); } } } } } } } if (items != null) { PsiResourceFile resourceFile = new PsiResourceFile(file, items, qualifiers, ResourceFolderType.VALUES, folderConfiguration); myResourceFiles.put(file, resourceFile); } } } return added; } /** * Returns the type of the ResourceItem based on a node's attributes. * @param node the node * @return the ResourceType or null if it could not be inferred. */ @Nullable private static ResourceType getType(XmlTag node) { String nodeName = node.getLocalName(); String typeString = null; if (TAG_ITEM.equals(nodeName)) { String attribute = node.getAttributeValue(ATTR_TYPE); if (attribute != null) { typeString = attribute; } } else { // the type is the name of the node. typeString = nodeName; } if (typeString != null) { return ResourceType.getEnum(typeString); } return null; } private boolean isResourceFolder(@Nullable PsiElement parent) { // Returns true if the given element represents a resource folder (e.g. res/values-en-rUS or layout-land, *not* the root res/ folder) if (parent instanceof PsiDirectory) { PsiDirectory directory = (PsiDirectory)parent; PsiDirectory parentDirectory = directory.getParentDirectory(); if (parentDirectory != null) { VirtualFile dir = parentDirectory.getVirtualFile(); return dir.equals(myResourceDir); } } return false; } private boolean isResourceFile(PsiFile psiFile) { return isResourceFolder(psiFile.getParent()); } @Override public boolean isScanPending(@NonNull PsiFile psiFile) { synchronized (SCAN_LOCK) { return myPendingScans != null && myPendingScans.contains(psiFile); } } private void rescan(@NonNull final PsiFile psiFile, final @NonNull ResourceFolderType folderType) { synchronized(SCAN_LOCK) { if (isScanPending(psiFile)) { return; } if (myPendingScans == null) { myPendingScans = Sets.newHashSet(); } myPendingScans.add(psiFile); } ApplicationManager.getApplication().invokeLater(new Runnable() { @Override public void run() { ApplicationManager.getApplication().runWriteAction(new Runnable() { @Override public void run() { rescanImmediately(psiFile, folderType); synchronized (SCAN_LOCK) { myPendingScans.remove(psiFile); if (myPendingScans.isEmpty()) { myPendingScans = null; } } } }); } }); } private void rescanImmediately(@NonNull final PsiFile psiFile, final @NonNull ResourceFolderType folderType) { PsiFile file = psiFile; if (folderType == VALUES) { // For unit test tracking purposes only //noinspection AssignmentToStaticFieldFromInstanceMethod ourFullRescans++; // First delete out the previous items PsiResourceFile resourceFile = myResourceFiles.get(file); boolean removed = false; if (resourceFile != null) { for (ResourceItem item : resourceFile) { boolean removeFromFile = false; // Will throw away file removed |= removeItems(resourceFile, item.getType(), item.getName(), removeFromFile); } myResourceFiles.remove(file); } file = ensureValid(file); boolean added = false; if (file != null) { // Add items for this file PsiDirectory parent = file.getParent(); assert parent != null; // since we have a folder type String dirName = parent.getName(); PsiDirectory fileParent = psiFile.getParent(); if (fileParent != null) { FolderConfiguration folderConfiguration = FolderConfiguration.getConfigForFolder(fileParent.getName()); if (folderConfiguration != null) { added = scanValueFile(getQualifiers(dirName), file, folderConfiguration); } } } if (added || removed) { // TODO: Consider doing a deeper diff of the changes to the resource items // to determine if the removed and added items actually differ myGeneration++; invalidateItemCaches(); } } else { PsiResourceFile resourceFile = myResourceFiles.get(file); if (resourceFile != null) { // Already seen this file; no need to do anything unless it's a layout or // menu file; in that case we may need to update the id's if (folderType == LAYOUT || folderType == MENU) { // For unit test tracking purposes only //noinspection AssignmentToStaticFieldFromInstanceMethod ourFullRescans++; // We've already seen this resource, so no change in the ResourceItem for the // file itself (e.g. @layout/foo from layout-land/foo.xml). However, we may have // to update the id's: Set<String> idsBefore = Sets.newHashSet(); Set<String> idsAfter = Sets.newHashSet(); ListMultimap<String, ResourceItem> map = myItems.get(ResourceType.ID); if (map != null) { List<ResourceItem> idItems = Lists.newArrayList(); for (ResourceItem item : resourceFile) { if (item.getType() == ResourceType.ID) { idsBefore.add(item.getName()); idItems.add(item); } } for (String id : idsBefore) { // Note that ResourceFile has a flat map (not a multimap) so it doesn't // record all items (unlike the myItems map) so we need to remove the map // items manually, can't just do map.remove(item.getName(), item) List<ResourceItem> mapItems = map.get(id); if (mapItems != null && !mapItems.isEmpty()) { List<ResourceItem> toDelete = Lists.newArrayListWithExpectedSize(mapItems.size()); for (ResourceItem mapItem : mapItems) { if (mapItem.getSource() == resourceFile) { toDelete.add(mapItem); } } for (ResourceItem delete : toDelete) { map.remove(delete.getName(), delete); } } } resourceFile.removeItems(idItems); } // Add items for this file List<ResourceItem> idItems = Lists.newArrayList(); file = ensureValid(file); if (file != null) { addIds(idItems, file); } if (!idItems.isEmpty()) { resourceFile.addItems(idItems); for (ResourceItem item : idItems) { idsAfter.add(item.getName()); } } if (!idsBefore.equals(idsAfter)) { myGeneration++; } // Identities may have changed even if the ids are the same, so update maps invalidateItemCaches(ResourceType.ID); } } else { // For unit test tracking purposes only //noinspection AssignmentToStaticFieldFromInstanceMethod ourFullRescans++; PsiDirectory parent = file.getParent(); assert parent != null; // since we have a folder type String dirName = parent.getName(); List<ResourceType> resourceTypes = FolderTypeRelationship.getRelatedResourceTypes(folderType); assert resourceTypes.size() >= 1 : folderType; ResourceType type = resourceTypes.get(0); boolean idGenerating = resourceTypes.size() > 1; assert !idGenerating || resourceTypes.size() == 2 && resourceTypes.get(1) == ResourceType.ID; ListMultimap<String, ResourceItem> map = myItems.get(type); if (map == null) { map = ArrayListMultimap.create(); myItems.put(type, map); } file = ensureValid(file); if (file != null) { PsiDirectory fileParent = psiFile.getParent(); if (fileParent != null) { FolderConfiguration folderConfiguration = FolderConfiguration.getConfigForFolder(fileParent.getName()); if (folderConfiguration != null) { scanFileResourceFile(getQualifiers(dirName), folderType, folderConfiguration, type, idGenerating, map, file); } } myGeneration++; invalidateItemCaches(); } } } } private boolean removeItems(PsiResourceFile resourceFile, ResourceType type, String name, boolean removeFromFile) { boolean removed = false; // Remove the item of the given name and type from the given resource file. // We CAN'T just remove items found in ResourceFile.getItems() because that map // flattens everything down to a single item for a given name (it's using a flat // map rather than a multimap) so instead we have to look up from the map instead ListMultimap<String, ResourceItem> map = myItems.get(type); if (map != null) { List<ResourceItem> mapItems = map.get(name); if (mapItems != null) { ListIterator<ResourceItem> iterator = mapItems.listIterator(); while (iterator.hasNext()) { ResourceItem next = iterator.next(); if (next.getSource() == resourceFile) { iterator.remove(); if (removeFromFile) { resourceFile.removeItem(next); } removed = true; } } } } return removed; } /** * Called when a bitmap has been changed/deleted. In that case we need to clear out any caches for that * image held by layout lib. */ private void bitmapUpdated() { ConfigurationManager configurationManager = myFacet.getConfigurationManager(false); if (configurationManager != null) { IAndroidTarget target = configurationManager.getTarget(); if (target != null) { Module module = myFacet.getModule(); AndroidTargetData targetData = AndroidTargetData.getTargetData(target, module); if (targetData != null) { targetData.clearLayoutBitmapCache(module); } } } } @NotNull public PsiTreeChangeListener getPsiListener() { return myListener; } /** PSI listener which keeps the repository up to date */ private final class PsiListener extends PsiTreeChangeAdapter { private boolean myIgnoreChildrenChanged; @Override public void childAdded(@NotNull PsiTreeChangeEvent event) { PsiFile psiFile = event.getFile(); if (psiFile == null) { // Called when you've added a file PsiElement child = event.getChild(); if (child instanceof PsiFile) { psiFile = (PsiFile)child; if (isRelevantFile(psiFile)) { addFile(psiFile); } } else if (child instanceof PsiDirectory) { PsiDirectory directory = (PsiDirectory)child; if (isResourceFolder(directory)) { for (PsiFile file : directory.getFiles()) { if (isRelevantFile(file)) { addFile(file); } } } } } else if (isRelevantFile(psiFile)) { if (isScanPending(psiFile)) { return; } // Some child was added within a file ResourceFolderType folderType = getFolderType(psiFile); if (folderType != null && isResourceFile(psiFile)) { PsiElement child = event.getChild(); PsiElement parent = event.getParent(); if (folderType == ResourceFolderType.VALUES) { if (child instanceof XmlTag) { XmlTag tag = (XmlTag)child; if (isItemElement(tag)) { PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { String name = tag.getAttributeValue(ATTR_NAME); if (name != null) { ResourceType type = getType(tag); if (type == ResourceType.DECLARE_STYLEABLE) { // Can't handle declare styleable additions incrementally yet; need to update paired attr items rescan(psiFile, folderType); return; } if (type != null) { ListMultimap<String, ResourceItem> map = myItems.get(type); if (map == null) { map = ArrayListMultimap.create(); myItems.put(type, map); } ResourceItem item = new PsiResourceItem(name, type, tag, psiFile); map.put(name, item); resourceFile.addItems(Collections.singletonList(item)); myGeneration++; invalidateItemCaches(type); } } return; } } // See if you just added a new item inside a <style> or <array> or <declare-styleable> etc XmlTag parentTag = tag.getParentTag(); if (parentTag != null && ResourceType.getEnum(parentTag.getName()) != null) { // Yes just invalidate the corresponding style value ResourceItem style = findValueResourceItem(parentTag, psiFile); if (style instanceof PsiResourceItem) { if (((PsiResourceItem)style).recomputeValue()) { myGeneration++; } return; } } rescan(psiFile, folderType); // Else: fall through and do full file rescan } else if (parent instanceof XmlText) { // If the edit is within an item tag XmlText text = (XmlText)parent; handleValueXmlTextEdit(text.getParentTag(), psiFile); return; } else if (child instanceof XmlText) { // If the edit is within an item tag handleValueXmlTextEdit(parent, psiFile); return; } else if (parent instanceof XmlComment || child instanceof XmlComment) { // Can ignore comment edits or new comments return; } rescan(psiFile, folderType); } else if (folderType == LAYOUT || folderType == MENU) { if (parent instanceof XmlComment || child instanceof XmlComment) { return; } if (parent instanceof XmlText || (child instanceof XmlText && child.getText().trim().isEmpty())) { return; } if (parent instanceof XmlElement && child instanceof XmlElement) { if (child instanceof XmlTag) { List<ResourceItem> ids = Lists.newArrayList(); addIds(ids, child, psiFile); if (!ids.isEmpty()) { PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { resourceFile.addItems(ids); } } return; } else if (child instanceof XmlAttributeValue) { assert parent instanceof XmlAttribute : parent; @SuppressWarnings("CastConflictsWithInstanceof") // IDE bug? Cast is valid. XmlAttribute attribute = (XmlAttribute)parent; if (ATTR_ID.equals(attribute.getLocalName()) && ANDROID_URI.equals(attribute.getNamespace())) { // TODO: Update it incrementally rescan(psiFile, folderType); } } } } } } myIgnoreChildrenChanged = true; } @Override public void childRemoved(@NotNull PsiTreeChangeEvent event) { PsiFile psiFile = event.getFile(); if (psiFile == null) { // Called when you've removed a file PsiElement child = event.getChild(); if (child instanceof PsiFile) { psiFile = (PsiFile)child; if (isRelevantFile(psiFile)) { removeFile(psiFile); } } else if (child instanceof PsiDirectory) { // We can't iterate the children here because the dir is already empty. // Instead, try to locate the files String dirName = ((PsiDirectory)child).getName(); ResourceFolderType folderType = ResourceFolderType.getFolderType(dirName); if (folderType != null) { // Make sure it's really a resource folder. We can't look at the directory // itself since the file no longer exists, but make sure the parent directory is // a resource directory root PsiDirectory parentDirectory = ((PsiDirectory)child).getParent(); if (parentDirectory != null) { VirtualFile dir = parentDirectory.getVirtualFile(); if (!myFacet.getLocalResourceManager().isResourceDir(dir)) { return; } } else { return; } int index = dirName.indexOf('-'); String qualifiers; if (index == -1) { qualifiers = ""; } else { qualifiers = dirName.substring(index + 1); } // Copy file map so we can delete while iterating Collection<PsiResourceFile> resourceFiles = new ArrayList<PsiResourceFile>(myResourceFiles.values()); for (PsiResourceFile file : resourceFiles) { if (folderType == file.getFolderType() && qualifiers.equals(file.getQualifiers())) { removeFile(file); } } } } } else if (isRelevantFile(psiFile)) { if (isScanPending(psiFile)) { return; } // Some child was removed within a file ResourceFolderType folderType = getFolderType(psiFile); if (folderType != null && isResourceFile(psiFile)) { PsiElement child = event.getChild(); PsiElement parent = event.getParent(); if (folderType == ResourceFolderType.VALUES) { if (child instanceof XmlTag) { XmlTag tag = (XmlTag)child; // See if you just removed an item inside a <style> or <array> or <declare-styleable> etc if (parent instanceof XmlTag) { XmlTag parentTag = (XmlTag)parent; if (ResourceType.getEnum(parentTag.getName()) != null) { // Yes just invalidate the corresponding style value ResourceItem style = findValueResourceItem(parentTag, psiFile); if (style instanceof PsiResourceItem) { if (((PsiResourceItem)style).recomputeValue()) { myGeneration++; } if (style.getType() == ResourceType.ATTR) { parentTag = parentTag.getParentTag(); if (parentTag != null && parentTag.getName().equals(ResourceType.DECLARE_STYLEABLE.getName())) { ResourceItem declareStyleable = findValueResourceItem(parentTag, psiFile); if (declareStyleable instanceof PsiResourceItem) { if (((PsiResourceItem)declareStyleable).recomputeValue()) { myGeneration++; } } } } return; } } } if (isItemElement(tag)) { PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { String name; if (!tag.isValid()) { ResourceItem item = findValueResourceItem(tag, psiFile); if (item != null) { name = item.getName(); } else { // Can't find the name of the deleted tag; just do a full rescan rescan(psiFile, folderType); return; } } else { name = tag.getAttributeValue(ATTR_NAME); } if (name != null) { ResourceType type = getType(tag); if (type != null) { ListMultimap<String, ResourceItem> map = myItems.get(type); if (map == null) { return; } if (removeItems(resourceFile, type, name, true)) { myGeneration++; invalidateItemCaches(type); } } } return; } } rescan(psiFile, folderType); } else if (parent instanceof XmlText) { // If the edit is within an item tag XmlText text = (XmlText)parent; handleValueXmlTextEdit(text.getParentTag(), psiFile); } else if (child instanceof XmlText) { handleValueXmlTextEdit(parent, psiFile); } else if (parent instanceof XmlComment || child instanceof XmlComment) { // Can ignore comment edits or removed comments return; } else { // Some other change: do full file rescan rescan(psiFile, folderType); } } else if (folderType == LAYOUT || folderType == MENU) { // TODO: Handle removals of id's (values an attributes) incrementally rescan(psiFile, folderType); } } } myIgnoreChildrenChanged = true; } private void removeFile(@Nullable PsiResourceFile resourceFile) { if (resourceFile == null) { // No resources for this file return; } for (Map.Entry<PsiFile, PsiResourceFile> entry : myResourceFiles.entrySet()) { PsiResourceFile file = entry.getValue(); if (resourceFile == file) { PsiFile psiFile = entry.getKey(); myResourceFiles.remove(psiFile); break; } } myGeneration++; invalidateItemCaches(); ResourceFolderType folderType = resourceFile.getFolderType(); if (folderType == VALUES || folderType == LAYOUT || folderType == MENU) { removeItemsFromFile(resourceFile); } else if (folderType != null) { // Remove the file item List<ResourceType> resourceTypes = FolderTypeRelationship.getRelatedResourceTypes(folderType); for (ResourceType type : resourceTypes) { if (type != ResourceType.ID) { String name = LintUtils.getBaseName(resourceFile.getName()); boolean removeFromFile = false; // no need since we're discarding the file removeItems(resourceFile, type, name, removeFromFile); } } } // else: not a resource folder } private void removeFile(PsiFile psiFile) { assert !psiFile.isValid() || isRelevantFile(psiFile); PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile == null) { // No resources for this file return; } myResourceFiles.remove(psiFile); myGeneration++; invalidateItemCaches(); ResourceFolderType folderType = getFolderType(psiFile); if (folderType == VALUES || folderType == LAYOUT || folderType == MENU) { removeItemsFromFile(resourceFile); } else if (folderType != null) { if (folderType == DRAWABLE) { FileType fileType = psiFile.getFileType(); if (fileType.isBinary() && fileType == FileTypeManager.getInstance().getFileTypeByExtension(EXT_PNG)) { bitmapUpdated(); } } // Remove the file item List<ResourceType> resourceTypes = FolderTypeRelationship.getRelatedResourceTypes(folderType); for (ResourceType type : resourceTypes) { if (type != ResourceType.ID) { String name = ResourceHelper.getResourceName(psiFile); boolean removeFromFile = false; // no need since we're discarding the file removeItems(resourceFile, type, name, removeFromFile); } } } // else: not a resource folder } private void addFile(PsiFile psiFile) { assert isRelevantFile(psiFile); // Same handling as rescan, where the initial deletion is a no-op ResourceFolderType folderType = getFolderType(psiFile); if (folderType != null && isResourceFile(psiFile)) { rescanImmediately(psiFile, folderType); } } @Override public void childReplaced(@NotNull PsiTreeChangeEvent event) { PsiFile psiFile = event.getFile(); if (psiFile != null) { if (isScanPending(psiFile)) { return; } // This method is called when you edit within a file if (isRelevantFile(psiFile)) { // First determine if the edit is non-consequential. // That's the case if the XML edited is not a resource file (e.g. the manifest file), // or if it's within a file that is not a value file or an id-generating file (layouts and menus), // such as editing the content of a drawable XML file. ResourceFolderType folderType = getFolderType(psiFile); if (folderType == LAYOUT || folderType == MENU) { // The only way the edit affected the set of resources was if the user added or removed an // id attribute. Since these can be added redundantly we can't automatically remove the old // value if you renamed one, so we'll need a full file scan. // However, we only need to do this scan if the change appears to be related to ids; this can // only happen if the attribute value is changed. PsiElement parent = event.getParent(); PsiElement child = event.getChild(); if (parent instanceof XmlText || child instanceof XmlText || parent instanceof XmlComment || child instanceof XmlComment) { return; } if (parent instanceof XmlElement && child instanceof XmlElement) { if (event.getOldChild() == event.getNewChild()) { // We're not getting accurate PSI information: we have to do a full file scan rescan(psiFile, folderType); return; } if (child instanceof XmlAttributeValue) { assert parent instanceof XmlAttribute : parent; @SuppressWarnings("CastConflictsWithInstanceof") // IDE bug? Cast is valid. XmlAttribute attribute = (XmlAttribute)parent; if (ATTR_ID.equals(attribute.getLocalName()) && ANDROID_URI.equals(attribute.getNamespace())) { // for each id attribute! PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { XmlTag xmlTag = attribute.getParent(); PsiElement oldChild = event.getOldChild(); PsiElement newChild = event.getNewChild(); if (oldChild instanceof XmlAttributeValue && newChild instanceof XmlAttributeValue) { XmlAttributeValue oldValue = (XmlAttributeValue)oldChild; XmlAttributeValue newValue = (XmlAttributeValue)newChild; String oldName = stripIdPrefix(oldValue.getValue()); String newName = stripIdPrefix(newValue.getValue()); if (oldName.equals(newName)) { // Can happen when there are error nodes (e.g. attribute value not yet closed during typing etc) return; } ResourceItem item = findResourceItem(ResourceType.ID, psiFile, oldName, xmlTag); if (item != null) { ListMultimap<String, ResourceItem> map = myItems.get(item.getType()); if (map != null) { // Found the relevant item: delete it and create a new one in a new location map.remove(oldName, item); ResourceItem newItem = new PsiResourceItem(newName, ResourceType.ID, xmlTag, psiFile); map.put(newName, newItem); resourceFile.replace(item, newItem); myGeneration++; invalidateItemCaches(ResourceType.ID); return; } } } } rescan(psiFile, folderType); } } else if (parent instanceof XmlAttributeValue) { assert parent.getParent() instanceof XmlAttribute : parent; XmlAttribute attribute = (XmlAttribute)parent.getParent(); if (ATTR_ID.equals(attribute.getLocalName()) && ANDROID_URI.equals(attribute.getNamespace())) { // for each id attribute! PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { XmlTag xmlTag = attribute.getParent(); PsiElement oldChild = event.getOldChild(); PsiElement newChild = event.getNewChild(); String oldName = stripIdPrefix(oldChild.getText()); String newName = stripIdPrefix(newChild.getText()); if (oldName.equals(newName)) { // Can happen when there are error nodes (e.g. attribute value not yet closed during typing etc) return; } ResourceItem item = findResourceItem(ResourceType.ID, psiFile, oldName, xmlTag); if (item != null) { ListMultimap<String, ResourceItem> map = myItems.get(item.getType()); if (map != null) { // Found the relevant item: delete it and create a new one in a new location map.remove(oldName, item); ResourceItem newItem = new PsiResourceItem(newName, ResourceType.ID, xmlTag, psiFile); map.put(newName, newItem); resourceFile.replace(item, newItem); myGeneration++; invalidateItemCaches(ResourceType.ID); return; } } } rescan(psiFile, folderType); } } return; } // TODO: Handle adding/removing elements in layouts incrementally rescan(psiFile, folderType); } else if (folderType == VALUES) { PsiElement parent = event.getParent(); if (parent instanceof XmlElement) { // Editing within an XML file // An edit in a comment can be ignored // An edit in a text inside an element can be used to invalidate the ResourceValue of an element // (need to search upwards since strings can have HTML content) // An edit between elements can be ignored // An edit to an attribute name (not the attribute value for the attribute named "name"...) can // sometimes be ignored (if you edit type or name, consider what to do) // An edit of an attribute value can affect the name of type so update item // An edit of other parts; for example typing in a new <string> item character by character. // etc. // See if you just removed an item inside a <style> or <array> or <declare-styleable> etc if (parent instanceof XmlTag) { XmlTag parentTag = (XmlTag)parent; if (ResourceType.getEnum(parentTag.getName()) != null) { // Yes just invalidate the corresponding style value ResourceItem style = findValueResourceItem(parentTag, psiFile); if (style instanceof PsiResourceItem) { if (((PsiResourceItem)style).recomputeValue()) { myGeneration++; } return; } } if (parentTag.getName().equals(TAG_RESOURCES) && event.getOldChild() instanceof XmlText && event.getNewChild() instanceof XmlText) { return; } } if (parent instanceof XmlText) { XmlText text = (XmlText)parent; handleValueXmlTextEdit(text.getParentTag(), psiFile); return; } else if (parent instanceof XmlComment) { // Nothing to do return; } if (parent instanceof XmlAttributeValue) { PsiElement attribute = parent.getParent(); if (attribute instanceof XmlProcessingInstruction) { // Don't care about edits in the processing instructions, e.g. editing the encoding attribute in // <?xml version="1.0" encoding="utf-8"?> return; } PsiElement tag = attribute.getParent(); assert attribute instanceof XmlAttribute : attribute; XmlAttribute xmlAttribute = (XmlAttribute)attribute; assert tag instanceof XmlTag : tag; XmlTag xmlTag = (XmlTag)tag; String attributeName = xmlAttribute.getName(); // We could also special case handling of editing the type attribute, and the parent attribute, // but editing these is rare enough that we can just stick with the fallback full file scan for those // scenarios. if (isItemElement(xmlTag) && attributeName.equals(ATTR_NAME)) { // Edited the name of the item: replace it ResourceType type = getType(xmlTag); if (type != null) { String oldName = event.getOldChild().getText(); String newName = event.getNewChild().getText(); if (oldName.equals(newName)) { // Can happen when there are error nodes (e.g. attribute value not yet closed during typing etc) return; } ResourceItem item = findResourceItem(type, psiFile, oldName, xmlTag); if (item != null) { ListMultimap<String, ResourceItem> map = myItems.get(item.getType()); if (map != null) { // Found the relevant item: delete it and create a new one in a new location map.remove(oldName, item); ResourceItem newItem = new PsiResourceItem(newName, type, xmlTag, psiFile); map.put(newName, newItem); PsiResourceFile resourceFile = myResourceFiles.get(psiFile); if (resourceFile != null) { resourceFile.replace(item, newItem); } else { assert false : item; } myGeneration++; invalidateItemCaches(type); // Invalidate surrounding declare styleable if any if (type == ResourceType.ATTR) { XmlTag parentTag = xmlTag.getParentTag(); if (parentTag != null && parentTag.getName().equals(ResourceType.DECLARE_STYLEABLE.getName())) { ResourceItem style = findValueResourceItem(parentTag, psiFile); if (style instanceof PsiResourceItem) { ((PsiResourceItem)style).recomputeValue(); } } } return; } } } else { XmlTag parentTag = xmlTag.getParentTag(); if (parentTag != null && ResourceType.getEnum(parentTag.getName()) != null) { // <style>, or <plurals>, or <array>, or <string-array>, ... // Edited the attribute value of an item that is wrapped in a <style> tag: invalidate parent cached value ResourceItem style = findValueResourceItem(parentTag, psiFile); if (style instanceof PsiResourceItem) { if (((PsiResourceItem)style).recomputeValue()) { myGeneration++; } return; } } } } } } // Fall through: We were not able to directly manipulate the repository to accommodate // the edit, so re-scan the whole value file instead rescan(psiFile, folderType); } // else: can ignore this edit } } else { PsiElement parent = event.getParent(); if (isResourceFolder(parent)) { PsiElement oldChild = event.getOldChild(); PsiElement newChild = event.getNewChild(); if (oldChild instanceof PsiFile) { PsiFile oldFile = (PsiFile)oldChild; if (isRelevantFile(oldFile)) { removeFile(oldFile); } } if (newChild instanceof PsiFile) { PsiFile newFile = (PsiFile)newChild; if (isRelevantFile(newFile)) { addFile(newFile); } } } } myIgnoreChildrenChanged = true; } private void handleValueXmlTextEdit(@Nullable PsiElement parent, @NotNull PsiFile psiFile) { if (!(parent instanceof XmlTag)) { // Edited text outside the root element return; } XmlTag parentTag = (XmlTag)parent; String parentTagName = parentTag.getName(); if (parentTagName.equals(TAG_RESOURCES)) { // Editing whitespace between top level elements; ignore return; } if (parentTagName.equals(TAG_ITEM)) { XmlTag style = parentTag.getParentTag(); if (style != null && ResourceType.getEnum(style.getName()) != null) { // <style>, or <plurals>, or <array>, or <string-array>, ... // Edited the text value of an item that is wrapped in a <style> tag: invalidate ResourceItem item = findValueResourceItem(style, psiFile); if (item instanceof PsiResourceItem) { boolean cleared = ((PsiResourceItem)item).recomputeValue(); if (cleared) { // Only bump revision if this is a value which has already been observed! myGeneration++; } } return; } } // Find surrounding item while (parentTag != null) { if (isItemElement(parentTag)) { ResourceItem item = findValueResourceItem(parentTag, psiFile); if (item instanceof PsiResourceItem) { // Edited XML value boolean cleared = ((PsiResourceItem)item).recomputeValue(); if (cleared) { // Only bump revision if this is a value which has already been observed! myGeneration++; } } break; } parentTag = parentTag.getParentTag(); } // Fully handled; other whitespace changes do not affect resources } @Override public void childMoved(@NotNull PsiTreeChangeEvent event) { PsiElement child = event.getChild(); PsiFile psiFile = event.getFile(); //noinspection StatementWithEmptyBody if (psiFile == null) { // This is called when you move a file from one folder to another if (child instanceof PsiFile) { psiFile = (PsiFile)child; if (!isRelevantFile(psiFile)) { return; } // If you are renaming files, determine whether we can do a simple replacement // (e.g. swap out ResourceFile instances), or whether it changes the type // (e.g. moving foo.xml from layout/ to animator/), or whether it adds or removes // the type (e.g. moving from invalid to valid resource directories), or whether // it just affects the qualifiers (special case of swapping resource file instances). String name = psiFile.getName(); PsiElement oldParent = event.getOldParent(); PsiDirectory oldParentDir; if (oldParent instanceof PsiDirectory) { oldParentDir = (PsiDirectory)oldParent; } else { // Can't find old location: treat this as a file add addFile(psiFile); return; } String oldDirName = oldParentDir.getName(); ResourceFolderType oldFolderType = ResourceFolderType.getFolderType(oldDirName); ResourceFolderType newFolderType = getFolderType(psiFile); boolean wasResourceFolder = oldFolderType != null && isResourceFolder(oldParentDir); boolean isResourceFolder = newFolderType != null && isResourceFile(psiFile); if (wasResourceFolder == isResourceFolder) { if (!isResourceFolder) { // Moved a non-resource file around: nothing to do return; } // Moved a resource file from one resource folder to another: we need to update // the ResourceFile entries for this file. We may also need to update the types. PsiResourceFile resourceFile = findResourceFile(oldDirName, name); if (resourceFile != null) { if (oldFolderType != newFolderType) { // In some cases we can do this cheaply, e.g. if you move from layout to menu // we can just look up and change @layout/foo to @menu/foo, but if you move // to or from values folder it gets trickier, so for now just treat this as a delete // followed by an add removeFile(resourceFile); addFile(psiFile); } else { myResourceFiles.remove(resourceFile.getPsiFile()); myResourceFiles.put(psiFile, resourceFile); PsiDirectory newParent = psiFile.getParent(); assert newParent != null; // Since newFolderType != null String newDirName = newParent.getName(); resourceFile.setPsiFile(psiFile, getQualifiers(newDirName)); myGeneration++; // qualifiers may have changed: can affect configuration matching // We need to recompute resource values too, since some of these can point to // the old file (e.g. a drawable resource could have a DensityBasedResourceValue // pointing to the old file for (ResourceItem item : resourceFile) { // usually just 1 if (item instanceof PsiResourceItem) { ((PsiResourceItem)item).recomputeValue(); } } invalidateItemCaches(); } } else { // Couldn't find previous file; just add new file addFile(psiFile); } } else if (isResourceFolder) { // Moved file into resource folder: treat it as a file add addFile(psiFile); } else { //noinspection ConstantConditions assert wasResourceFolder; // Moved file out of resource folders: treat it as a file deletion. // The only trick here is that we don't actually have the PsiFile anymore. // Work around this by searching our PsiFile to ResourceFile map for a match. String dirName = oldParentDir.getName(); PsiResourceFile resourceFile = findResourceFile(dirName, name); if (resourceFile != null) { removeFile(resourceFile); } } } } else { // Change inside a file // Ignore: moving elements around doesn't affect the resources } myIgnoreChildrenChanged = true; } @Override public final void beforeChildrenChange(@NotNull PsiTreeChangeEvent event) { myIgnoreChildrenChanged = false; } @Override public void childrenChanged(@NotNull PsiTreeChangeEvent event) { // Called after children have changed. There are typically individual childMoved, childAdded etc // calls that we hook into for more specific details. However, there are some events we don't // catch using those methods, and for that we have the below handling. if (myIgnoreChildrenChanged) { // We've already processed this change as one or more individual childMoved, childAdded, childRemoved etc calls // However, we sometimes get some surprising (=bogus) events where the parent and the child // are the same, and in those cases there may be other child events we need to process // so fall through and process the whole file if (event.getParent() != event.getChild()) { return; } } else if (event.getNewChild() == null && event.getOldChild() == null && event.getOldParent() == null && event.getNewParent() == null && event.getParent() instanceof PsiFile) { return; } PsiFile psiFile = event.getFile(); if (psiFile != null && isRelevantFile(psiFile)) { VirtualFile file = psiFile.getVirtualFile(); if (file != null) { ResourceFolderType folderType = getFolderType(psiFile); if (folderType != null && isResourceFile(psiFile)) { rescan(psiFile, folderType); } } } else { Throwable throwable = new Throwable(); throwable.fillInStackTrace(); LOG.debug("Received unexpected childrenChanged event for inter-file operations", throwable); } } // There are cases where a file is renamed, and I don't get a pre-notification. Use this flag // to detect those scenarios, and in that case, do proper cleanup. // (Note: There are also cases where *only* beforePropertyChange is called, not propertyChange. // One example is the unit test for the raw folder, where we're renaming a file, and we get // the beforePropertyChange notification, followed by childReplaced on the PsiDirectory, and // nothing else. private boolean mySeenPrePropertyChange; @Override public final void beforePropertyChange(@NotNull PsiTreeChangeEvent event) { if (PsiTreeChangeEvent.PROP_FILE_NAME == event.getPropertyName()) { // This is called when you rename a file (before the file has been renamed) PsiElement child = event.getChild(); if (child instanceof PsiFile) { PsiFile psiFile = (PsiFile)child; if (isRelevantFile(psiFile) && isResourceFolder(event.getParent())) { removeFile(psiFile); } } // The new name will be added in the post hook (propertyChanged rather than beforePropertyChange) } mySeenPrePropertyChange = true; } @Override public void propertyChanged(@NotNull PsiTreeChangeEvent event) { if (PsiTreeChangeEvent.PROP_FILE_NAME == event.getPropertyName() && isResourceFolder(event.getParent())) { // This is called when you rename a file (after the file has been renamed) PsiElement child = event.getElement(); if (child instanceof PsiFile) { PsiFile psiFile = (PsiFile)child; if (isRelevantFile(psiFile) && isResourceFolder(event.getParent())) { if (!mySeenPrePropertyChange) { Object oldValue = event.getOldValue(); if (oldValue instanceof String) { PsiDirectory parent = psiFile.getParent(); String oldName = (String)oldValue; if (parent != null && parent.findFile(oldName) == null) { removeFile(findResourceFile(parent.getName(), oldName)); } } } addFile(psiFile); } } } // TODO: Do we need to handle PROP_DIRECTORY_NAME for users renaming any of the resource folders? // and what about PROP_FILE_TYPES -- can users change the type of an XML File to something else? mySeenPrePropertyChange = false; } } @Nullable private PsiResourceFile findResourceFile(String dirName, String fileName) { int index = dirName.indexOf('-'); String qualifiers; String folderTypeName; if (index == -1) { qualifiers = ""; folderTypeName = dirName; } else { qualifiers = dirName.substring(index + 1); folderTypeName = dirName.substring(0, index); } ResourceFolderType folderType = ResourceFolderType.getTypeByName(folderTypeName); for (PsiResourceFile file : myResourceFiles.values()) { String name = file.getName(); if (folderType == file.getFolderType() && fileName.equals(name) && qualifiers.equals(file.getQualifiers())) { return file; } } return null; } private void removeItemsFromFile(PsiResourceFile resourceFile) { for (ResourceItem item : resourceFile) { boolean removeFromFile = false; // no need since we're discarding the file removeItems(resourceFile, item.getType(), item.getName(), removeFromFile); } } private static boolean isItemElement(XmlTag xmlTag) { String tag = xmlTag.getName(); if (tag.equals(TAG_RESOURCES)) { return false; } return tag.equals(TAG_ITEM) || ResourceType.getEnum(tag) != null; } @Nullable private ResourceItem findValueResourceItem(XmlTag tag, PsiFile file) { if (!tag.isValid()) { PsiResourceFile resourceFile = myResourceFiles.get(file); if (resourceFile != null) { for (ResourceItem item : resourceFile) { PsiResourceItem pri = (PsiResourceItem)item; XmlTag xmlTag = pri.getTag(); if (xmlTag == tag) { return item; } } } return null; } String name = tag.getAttributeValue(ATTR_NAME); return name != null ? findValueResourceItem(tag, file, name) : null; } @Nullable private ResourceItem findValueResourceItem(XmlTag tag, PsiFile file, String name) { ResourceType type = getType(tag); return findResourceItem(type, file, name, tag); } @Nullable private ResourceItem findResourceItem(@Nullable ResourceType type, @Nullable PsiFile file, @Nullable String name, @Nullable XmlTag tag) { if (type != null && name != null) { ListMultimap<String, ResourceItem> map = myItems.get(type); if (map != null) { List<ResourceItem> items = map.get(name); assert items != null; if (tag != null) { for (ResourceItem item : items) { assert item instanceof PsiResourceItem; PsiResourceItem psiItem = (PsiResourceItem)item; if (psiItem.getTag() == tag) { return item; } } } for (ResourceItem item : items) { assert item instanceof PsiResourceItem; PsiResourceItem psiItem = (PsiResourceItem)item; PsiFile virtualFile = psiItem.getPsiFile(); if (virtualFile == file) { return item; } } } } return null; } // For debugging only @Override public String toString() { return getClass().getSimpleName() + " for " + myResourceDir + ": @" + Integer.toHexString(System.identityHashCode(this)); } }