/* * Copyright (C) 2012 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.refactorings.core; import static com.android.SdkConstants.ANDROID_PREFIX; import static com.android.SdkConstants.ANDROID_URI; import static com.android.SdkConstants.ATTR_ID; 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.EXT_XML; import static com.android.SdkConstants.FD_RES; import static com.android.SdkConstants.FN_RESOURCE_CLASS; import static com.android.SdkConstants.NEW_ID_PREFIX; import static com.android.SdkConstants.PREFIX_RESOURCE_REF; import static com.android.SdkConstants.PREFIX_THEME_REF; import static com.android.SdkConstants.R_CLASS; import static com.android.SdkConstants.TAG_ITEM; import com.android.SdkConstants; import com.android.annotations.NonNull; import com.android.annotations.Nullable; import com.android.ide.eclipse.adt.AdtPlugin; import com.android.ide.eclipse.adt.AdtUtils; import com.android.ide.eclipse.adt.internal.editors.manifest.ManifestInfo; import com.android.ide.eclipse.adt.internal.project.BaseProjectHelper; import com.android.ide.eclipse.adt.internal.resources.ResourceNameValidator; import com.android.ide.eclipse.adt.internal.sdk.ProjectState; import com.android.ide.eclipse.adt.internal.sdk.Sdk; import com.android.resources.ResourceFolderType; import com.android.resources.ResourceType; import com.android.utils.SdkUtils; import org.eclipse.core.resources.IFile; import org.eclipse.core.resources.IFolder; import org.eclipse.core.resources.IProject; import org.eclipse.core.resources.IResource; import org.eclipse.core.runtime.CoreException; import org.eclipse.core.runtime.IPath; import org.eclipse.core.runtime.IProgressMonitor; import org.eclipse.core.runtime.OperationCanceledException; import org.eclipse.jdt.core.IField; import org.eclipse.jdt.core.IJavaElement; import org.eclipse.jdt.core.IJavaProject; import org.eclipse.jdt.core.IType; import org.eclipse.jdt.internal.corext.refactoring.rename.RenameFieldProcessor; import org.eclipse.jface.text.BadLocationException; import org.eclipse.jface.text.IDocument; import org.eclipse.ltk.core.refactoring.Change; import org.eclipse.ltk.core.refactoring.CompositeChange; import org.eclipse.ltk.core.refactoring.RefactoringStatus; import org.eclipse.ltk.core.refactoring.TextChange; import org.eclipse.ltk.core.refactoring.TextFileChange; import org.eclipse.ltk.core.refactoring.participants.CheckConditionsContext; import org.eclipse.ltk.core.refactoring.participants.RenameParticipant; import org.eclipse.ltk.core.refactoring.participants.RenameRefactoring; import org.eclipse.ltk.core.refactoring.resource.RenameResourceChange; import org.eclipse.text.edits.MultiTextEdit; import org.eclipse.text.edits.ReplaceEdit; import org.eclipse.text.edits.TextEdit; 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.sse.core.internal.provisional.IndexedRegion; import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument; import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel; import org.w3c.dom.Attr; import org.w3c.dom.Element; import org.w3c.dom.NamedNodeMap; import org.w3c.dom.Node; import org.w3c.dom.NodeList; import java.io.IOException; import java.util.ArrayList; import java.util.List; /** * A rename participant handling renames of resources (such as R.id.foo and R.layout.bar). * This reacts to refactorings of fields in the R inner classes (such as R.id), and updates * the XML files as appropriate; renaming .xml files, updating XML attributes, resource * references in style declarations, and so on. */ @SuppressWarnings("restriction") // WTP API public class RenameResourceParticipant extends RenameParticipant { /** The project we're refactoring in */ private @NonNull IProject mProject; /** The type of the resource we're refactoring, such as {@link ResourceType#ID} */ private @NonNull ResourceType mType; /** * The type of the resource folder we're refactoring in, such as * {@link ResourceFolderType#VALUES}. When refactoring non value files, we need to * rename the files as well. */ private @NonNull ResourceFolderType mFolderType; /** The previous name of the resource */ private @NonNull String mOldName; /** The new name of the resource */ private @NonNull String mNewName; /** Whether references to the resource should be updated */ private boolean mUpdateReferences; /** A match pattern to look for in XML, such as {@code @attr/foo} */ private @NonNull String mXmlMatch1; /** A match pattern to look for in XML, such as {@code ?attr/foo} */ private @Nullable String mXmlMatch2; /** A match pattern to look for in XML, such as {@code ?foo} */ private @Nullable String mXmlMatch3; /** The value to replace a reference to {@link #mXmlMatch1} with, such as {@code @attr/bar} */ private @NonNull String mXmlNewValue1; /** The value to replace a reference to {@link #mXmlMatch2} with, such as {@code ?attr/bar} */ private @Nullable String mXmlNewValue2; /** The value to replace a reference to {@link #mXmlMatch3} with, such as {@code ?bar} */ private @Nullable String mXmlNewValue3; /** * If non null, this refactoring was initiated as a file rename of an XML file (and if * null, we are just reacting to a Java field rename) */ private IFile mRenamedFile; /** * If renaming a field, we need to create an embedded field refactoring to update the * Java sources referring to the corresponding R class field. This is stored as an * instance such that we can have it participate in both the condition check methods * as well as the {@link #createChange(IProgressMonitor)} refactoring operation. */ private RenameRefactoring mFieldRefactoring; /** * Set while we are creating an embedded Java refactoring. This could cause a recursive * invocation of the XML renaming refactoring to react to the field, so this is flag * during the call to the Java processor, and is used to ignore requests for adding in * field reactions during that time. */ private static boolean sIgnore; /** * Creates a new {@linkplain RenameResourceParticipant} */ public RenameResourceParticipant() { } @Override public String getName() { return "Android Rename Field Participant"; } @Override protected boolean initialize(Object element) { if (sIgnore) { return false; } if (element instanceof IField) { IField field = (IField) element; IType declaringType = field.getDeclaringType(); if (declaringType != null) { if (R_CLASS.equals(declaringType.getParent().getElementName())) { String typeName = declaringType.getElementName(); mType = ResourceType.getEnum(typeName); if (mType != null) { mUpdateReferences = getArguments().getUpdateReferences(); mFolderType = AdtUtils.getFolderTypeFor(mType); IJavaProject javaProject = (IJavaProject) field.getAncestor( IJavaElement.JAVA_PROJECT); mProject = javaProject.getProject(); mOldName = field.getElementName(); mNewName = getArguments().getNewName(); mFieldRefactoring = null; mRenamedFile = null; createXmlSearchPatterns(); return true; } } } return false; } else if (element instanceof IFile) { IFile file = (IFile) element; mProject = file.getProject(); if (BaseProjectHelper.isAndroidProject(mProject)) { IPath path = file.getFullPath(); int segments = path.segmentCount(); if (segments == 4 && path.segment(1).equals(FD_RES)) { String parentName = file.getParent().getName(); mFolderType = ResourceFolderType.getFolderType(parentName); if (mFolderType != null && mFolderType != ResourceFolderType.VALUES) { mType = AdtUtils.getResourceTypeFor(mFolderType); if (mType != null) { mUpdateReferences = getArguments().getUpdateReferences(); mProject = file.getProject(); mOldName = AdtUtils.stripAllExtensions(file.getName()); mNewName = AdtUtils.stripAllExtensions(getArguments().getNewName()); mRenamedFile = file; createXmlSearchPatterns(); mFieldRefactoring = null; IField field = getResourceField(mProject, mType, mOldName); if (field != null) { mFieldRefactoring = createFieldRefactoring(field); } else { // no corresponding field; aapt has not run yet. Perhaps user has // turned off auto build. mFieldRefactoring = null; } return true; } } } } } else if (element instanceof String) { String uri = (String) element; if (uri.startsWith(PREFIX_RESOURCE_REF) && !uri.startsWith(ANDROID_PREFIX)) { RenameResourceProcessor processor = (RenameResourceProcessor) getProcessor(); mProject = processor.getProject(); mType = processor.getType(); mFolderType = AdtUtils.getFolderTypeFor(mType); mOldName = processor.getCurrentName(); mNewName = processor.getNewName(); assert uri.endsWith(mOldName) && uri.contains(mType.getName()) : uri; mUpdateReferences = getArguments().getUpdateReferences(); if (mNewName.isEmpty()) { mUpdateReferences = false; } mRenamedFile = null; createXmlSearchPatterns(); mFieldRefactoring = null; if (!mNewName.isEmpty()) { IField field = getResourceField(mProject, mType, mOldName); if (field != null) { mFieldRefactoring = createFieldRefactoring(field); } } return true; } } return false; } /** Create nested Java refactoring which updates the R field references, if applicable */ private RenameRefactoring createFieldRefactoring(IField field) { RenameFieldProcessor processor = new RenameFieldProcessor(field); processor.setRenameGetter(false); processor.setRenameSetter(false); RenameRefactoring refactoring = new RenameRefactoring(processor); processor.setUpdateReferences(mUpdateReferences); processor.setUpdateTextualMatches(false); processor.setNewElementName(mNewName); try { if (refactoring.isApplicable()) { return refactoring; } } catch (CoreException e) { AdtPlugin.log(e, null); } return null; } private void createXmlSearchPatterns() { // Set up search strings for the attribute iterator. This will // identify string matches for mXmlMatch1, 2 and 3, and when matched, // will add a replacement edit for mXmlNewValue1, 2, or 3. mXmlMatch2 = null; mXmlNewValue2 = null; mXmlMatch3 = null; mXmlNewValue3 = null; String typeName = mType.getName(); if (mUpdateReferences) { mXmlMatch1 = PREFIX_RESOURCE_REF + typeName + '/' + mOldName; mXmlNewValue1 = PREFIX_RESOURCE_REF + typeName + '/' + mNewName; if (mType == ResourceType.ID) { mXmlMatch2 = NEW_ID_PREFIX + mOldName; mXmlNewValue2 = NEW_ID_PREFIX + mNewName; } else if (mType == ResourceType.ATTR) { // When renaming @attr/foo, also edit ?attr/foo mXmlMatch2 = PREFIX_THEME_REF + typeName + '/' + mOldName; mXmlNewValue2 = PREFIX_THEME_REF + typeName + '/' + mNewName; // as well as ?foo mXmlMatch3 = PREFIX_THEME_REF + mOldName; mXmlNewValue3 = PREFIX_THEME_REF + mNewName; } } else if (mType == ResourceType.ID) { mXmlMatch1 = NEW_ID_PREFIX + mOldName; mXmlNewValue1 = NEW_ID_PREFIX + mNewName; } } @Override public RefactoringStatus checkConditions(IProgressMonitor pm, CheckConditionsContext context) throws OperationCanceledException { if (mRenamedFile != null && getArguments().getNewName().indexOf('.') == -1 && mRenamedFile.getName().indexOf('.') != -1) { return RefactoringStatus.createErrorStatus( String.format("You must include the file extension (%1$s?)", mRenamedFile.getName().substring(mRenamedFile.getName().indexOf('.')))); } // Ensure that the new name is valid if (mNewName != null && !mNewName.isEmpty()) { ResourceNameValidator validator = ResourceNameValidator.create(false, mProject, mType); String error = validator.isValid(mNewName); if (error != null) { return RefactoringStatus.createErrorStatus(error); } } if (mFieldRefactoring != null) { try { sIgnore = true; return mFieldRefactoring.checkAllConditions(pm); } catch (CoreException e) { AdtPlugin.log(e, null); } finally { sIgnore = false; } } return new RefactoringStatus(); } @Override public Change createChange(IProgressMonitor monitor) throws CoreException, OperationCanceledException { if (monitor.isCanceled()) { return null; } CompositeChange result = new CompositeChange("Update resource references"); // Only show the children in the refactoring preview dialog result.markAsSynthetic(); addResourceFileChanges(result, mProject, monitor); // If renaming resources in a library project, also offer to rename references // in including projects if (mUpdateReferences) { ProjectState projectState = Sdk.getProjectState(mProject); if (projectState != null && projectState.isLibrary()) { List<ProjectState> parentProjects = projectState.getParentProjects(); for (ProjectState state : parentProjects) { IProject project = state.getProject(); CompositeChange nested = new CompositeChange( String.format("Update references in %1$s", project.getName())); addResourceFileChanges(nested, project, monitor); if (nested.getChildren().length > 0) { result.add(nested); } } } } if (mFieldRefactoring != null) { // We have to add in Java field refactoring try { sIgnore = true; addJavaChanges(result, monitor); } finally { sIgnore = false; } } else { // Disable field refactoring added by the default Java field rename handler disableExistingResourceFileChange(); } return (result.getChildren().length == 0) ? null : result; } /** * Adds all changes to resource files (typically XML but also renaming drawable files * * @param project the Android project * @param className the layout classes */ private void addResourceFileChanges( CompositeChange change, IProject project, IProgressMonitor monitor) throws OperationCanceledException { if (monitor.isCanceled()) { return; } try { // Update resource references in the manifest IFile manifest = project.getFile(SdkConstants.ANDROID_MANIFEST_XML); if (manifest != null) { addResourceXmlChanges(manifest, change, null); } // Update references in XML resource files IFolder resFolder = project.getFolder(SdkConstants.FD_RESOURCES); IResource[] folders = resFolder.members(); for (IResource folder : folders) { if (!(folder instanceof IFolder)) { continue; } String folderName = folder.getName(); ResourceFolderType folderType = ResourceFolderType.getFolderType(folderName); IResource[] files = ((IFolder) folder).members(); for (int i = 0; i < files.length; i++) { IResource member = files[i]; if ((member instanceof IFile) && member.exists()) { IFile file = (IFile) member; String fileName = member.getName(); if (SdkUtils.endsWith(fileName, DOT_XML)) { addResourceXmlChanges(file, change, folderType); } if ((mRenamedFile == null || !mRenamedFile.equals(file)) && fileName.startsWith(mOldName) && fileName.length() > mOldName.length() && fileName.charAt(mOldName.length()) == '.' && mFolderType != ResourceFolderType.VALUES && mFolderType == folderType) { // Rename this file String newFile = mNewName + fileName.substring(mOldName.length()); IPath path = file.getFullPath(); change.add(new RenameResourceChange(path, newFile)); } } } } } catch (CoreException e) { RefactoringUtil.log(e); } } private void addJavaChanges(CompositeChange result, IProgressMonitor monitor) throws CoreException, OperationCanceledException { if (monitor.isCanceled()) { return; } RefactoringStatus status = mFieldRefactoring.checkAllConditions(monitor); if (status != null && !status.hasError()) { Change fieldChanges = mFieldRefactoring.createChange(monitor); if (fieldChanges != null) { result.add(fieldChanges); // Look for the field change on the R.java class; it's a derived file // and will generate file modified manually warnings. Disable it. disableResourceFileChange(fieldChanges); } } } private boolean addResourceXmlChanges( IFile file, CompositeChange changes, ResourceFolderType folderType) { IModelManager modelManager = StructuredModelManager.getModelManager(); IStructuredModel model = null; try { model = modelManager.getExistingModelForRead(file); if (model == null) { model = modelManager.getModelForRead(file); } if (model != null) { IStructuredDocument document = model.getStructuredDocument(); if (model instanceof IDOMModel) { IDOMModel domModel = (IDOMModel) model; Element root = domModel.getDocument().getDocumentElement(); if (root != null) { List<TextEdit> edits = new ArrayList<TextEdit>(); addReplacements(edits, root, document, folderType); if (!edits.isEmpty()) { MultiTextEdit rootEdit = new MultiTextEdit(); rootEdit.addChildren(edits.toArray(new TextEdit[edits.size()])); TextFileChange change = new TextFileChange(file.getName(), file); change.setTextType(EXT_XML); change.setEdit(rootEdit); changes.add(change); } } } else { return false; } } return true; } catch (IOException e) { AdtPlugin.log(e, null); } catch (CoreException e) { AdtPlugin.log(e, null); } finally { if (model != null) { model.releaseFromRead(); } } return false; } private int getAttributeValueRangeStart(Attr attr, IDocument document) { IndexedRegion region = (IndexedRegion) attr; int potentialStart = attr.getName().length() + 2; // + 2: add =" String text; try { text = document.get(region.getStartOffset(), region.getLength()); } catch (BadLocationException e) { return -1; } String value = attr.getValue(); int index = text.indexOf(value, potentialStart); if (index != -1) { return region.getStartOffset() + index; } else { return -1; } } private void addReplacements( @NonNull List<TextEdit> edits, @NonNull Element element, @NonNull IStructuredDocument document, @Nullable ResourceFolderType folderType) { String tag = element.getTagName(); if (folderType == ResourceFolderType.VALUES) { // Look for // <item name="main_layout" type="layout">...</item> // <item name="myid" type="id"/> // <string name="mystring">...</string> // etc if (tag.equals(mType.getName()) || (tag.equals(TAG_ITEM) && (mType == ResourceType.ID || mType.getName().equals(element.getAttribute(ATTR_TYPE))))) { Attr nameNode = element.getAttributeNode(ATTR_NAME); if (nameNode != null && nameNode.getValue().equals(mOldName)) { int start = getAttributeValueRangeStart(nameNode, document); if (start != -1) { int end = start + mOldName.length(); edits.add(new ReplaceEdit(start, end - start, mNewName)); } } } } NamedNodeMap attributes = element.getAttributes(); for (int i = 0, n = attributes.getLength(); i < n; i++) { Attr attr = (Attr) attributes.item(i); String value = attr.getValue(); // If not updating references, only update XML matches that define the id if (!mUpdateReferences && (!ATTR_ID.equals(attr.getLocalName()) || !ANDROID_URI.equals(attr.getNamespaceURI()))) { continue; } // Replace XML attribute reference, such as // android:id="@+id/oldName" => android:id="+id/newName" String match = null; String matchedValue = null; if (value.equals(mXmlMatch1)) { match = mXmlMatch1; matchedValue = mXmlNewValue1; } else if (value.equals(mXmlMatch2)) { match = mXmlMatch2; matchedValue = mXmlNewValue2; } else if (value.equals(mXmlMatch3)) { match = mXmlMatch3; matchedValue = mXmlNewValue3; } else { continue; } if (match != null) { if (mNewName.isEmpty() && ATTR_ID.equals(attr.getLocalName()) && ANDROID_URI.equals(attr.getNamespaceURI())) { // Delete attribute IndexedRegion region = (IndexedRegion) attr; int start = region.getStartOffset(); int end = region.getEndOffset(); edits.add(new ReplaceEdit(start, end - start, "")); } else { int start = getAttributeValueRangeStart(attr, document); if (start != -1) { int end = start + match.length(); edits.add(new ReplaceEdit(start, end - start, matchedValue)); } } } } NodeList children = element.getChildNodes(); for (int i = 0, n = children.getLength(); i < n; i++) { Node child = children.item(i); if (child.getNodeType() == Node.ELEMENT_NODE) { addReplacements(edits, (Element) child, document, folderType); } else if (child.getNodeType() == Node.TEXT_NODE && mUpdateReferences) { // Replace XML text, such as @color/custom_theme_color in // <item name="android:windowBackground">@color/custom_theme_color</item> // String text = child.getNodeValue(); int index = getFirstNonBlankIndex(text); if (index != -1) { String match = null; String matchedValue = null; if (mXmlMatch1 != null && text.startsWith(mXmlMatch1) && text.trim().equals(mXmlMatch1)) { match = mXmlMatch1; matchedValue = mXmlNewValue1; } else if (mXmlMatch2 != null && text.startsWith(mXmlMatch2) && text.trim().equals(mXmlMatch2)) { match = mXmlMatch2; matchedValue = mXmlNewValue2; } else if (mXmlMatch3 != null && text.startsWith(mXmlMatch3) && text.trim().equals(mXmlMatch3)) { match = mXmlMatch3; matchedValue = mXmlNewValue3; } if (match != null) { IndexedRegion region = (IndexedRegion) child; int start = region.getStartOffset() + index; int end = start + match.length(); edits.add(new ReplaceEdit(start, end - start, matchedValue)); } } } } } /** * Returns the index of the first non-space character in the string, or -1 * if the string is empty or has only whitespace * * @param s the string to check * @return the index of the first non whitespace character */ private int getFirstNonBlankIndex(String s) { for (int i = 0, n = s.length(); i < n; i++) { if (!Character.isWhitespace(s.charAt(i))) { return i; } } return -1; } /** * Initiates a renaming of a resource item * * @param project the project containing the resource references * @param type the type of resource * @param name the name of the resource * @return false if initiating the rename failed */ @Nullable private static IField getResourceField( @NonNull IProject project, @NonNull ResourceType type, @NonNull String name) { try { IJavaProject javaProject = BaseProjectHelper.getJavaProject(project); if (javaProject == null) { return null; } String pkg = ManifestInfo.get(project).getPackage(); IType t = javaProject.findType(pkg + '.' + R_CLASS + '.' + type.getName()); if (t == null) { return null; } return t.getField(name); } catch (CoreException e) { AdtPlugin.log(e, null); } return null; } /** * Searches for existing changes in the refactoring which modifies the R * field to rename it. it's derived so performing this change will generate * a "generated code was modified manually" warning */ private void disableExistingResourceFileChange() { IFolder genFolder = mProject.getFolder(SdkConstants.FD_GEN_SOURCES); if (genFolder != null && genFolder.exists()) { ManifestInfo manifestInfo = ManifestInfo.get(mProject); String pkg = manifestInfo.getPackage(); if (pkg != null) { IFile rFile = genFolder.getFile(pkg.replace('.', '/') + '/' + FN_RESOURCE_CLASS); TextChange change = getTextChange(rFile); if (change != null) { change.setEnabled(false); } } } } /** * Searches for existing changes in the refactoring which modifies the R * field to rename it. it's derived so performing this change will generate * a "generated code was modified manually" warning */ private void disableResourceFileChange(Change change) { // Look for the field change on the R.java class; it's a derived file // and will generate file modified manually warnings. Disable it. if (change instanceof CompositeChange) { for (Change outer : ((CompositeChange) change).getChildren()) { if (outer instanceof CompositeChange) { for (Change inner : ((CompositeChange) outer).getChildren()) { if (FN_RESOURCE_CLASS.equals(inner.getName())) { inner.setEnabled(false); } } } } } } }