diff options
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java')
-rw-r--r-- | eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java | 1933 |
1 files changed, 1933 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java new file mode 100644 index 000000000..db0b0967d --- /dev/null +++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java @@ -0,0 +1,1933 @@ +/* + * Copyright (C) 2009 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.extractstring; + +import static com.android.SdkConstants.QUOT_ENTITY; +import static com.android.SdkConstants.STRING_PREFIX; + +import com.android.SdkConstants; +import com.android.ide.common.res2.ValueXmlHelper; +import com.android.ide.common.xml.ManifestData; +import com.android.ide.eclipse.adt.AdtConstants; +import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.descriptors.ReferenceAttributeDescriptor; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode; +import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode; +import com.android.ide.eclipse.adt.internal.project.AndroidManifestHelper; +import com.android.resources.ResourceFolderType; +import com.android.resources.ResourceType; + +import org.eclipse.core.resources.IContainer; +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.resources.ResourceAttributes; +import org.eclipse.core.resources.ResourcesPlugin; +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.core.runtime.Path; +import org.eclipse.core.runtime.SubMonitor; +import org.eclipse.jdt.core.IBuffer; +import org.eclipse.jdt.core.ICompilationUnit; +import org.eclipse.jdt.core.IJavaProject; +import org.eclipse.jdt.core.IPackageFragment; +import org.eclipse.jdt.core.IPackageFragmentRoot; +import org.eclipse.jdt.core.JavaCore; +import org.eclipse.jdt.core.JavaModelException; +import org.eclipse.jdt.core.ToolFactory; +import org.eclipse.jdt.core.compiler.IScanner; +import org.eclipse.jdt.core.compiler.ITerminalSymbols; +import org.eclipse.jdt.core.compiler.InvalidInputException; +import org.eclipse.jdt.core.dom.AST; +import org.eclipse.jdt.core.dom.ASTNode; +import org.eclipse.jdt.core.dom.ASTParser; +import org.eclipse.jdt.core.dom.CompilationUnit; +import org.eclipse.jdt.core.dom.rewrite.ASTRewrite; +import org.eclipse.jdt.core.dom.rewrite.ImportRewrite; +import org.eclipse.jface.text.ITextSelection; +import org.eclipse.ltk.core.refactoring.Change; +import org.eclipse.ltk.core.refactoring.ChangeDescriptor; +import org.eclipse.ltk.core.refactoring.CompositeChange; +import org.eclipse.ltk.core.refactoring.Refactoring; +import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor; +import org.eclipse.ltk.core.refactoring.RefactoringStatus; +import org.eclipse.ltk.core.refactoring.TextEditChangeGroup; +import org.eclipse.ltk.core.refactoring.TextFileChange; +import org.eclipse.text.edits.InsertEdit; +import org.eclipse.text.edits.MultiTextEdit; +import org.eclipse.text.edits.ReplaceEdit; +import org.eclipse.text.edits.TextEdit; +import org.eclipse.text.edits.TextEditGroup; +import org.eclipse.ui.IEditorPart; +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.text.IStructuredDocument; +import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion; +import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList; +import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext; +import org.w3c.dom.Node; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; +import java.util.Queue; + +/** + * This refactoring extracts a string from a file and replaces it by an Android resource ID + * such as R.string.foo. + * <p/> + * There are a number of scenarios, which are not all supported yet. The workflow works as + * such: + * <ul> + * <li> User selects a string in a Java and invokes the {@link ExtractStringAction}. + * <li> The action finds the {@link ICompilationUnit} being edited as well as the current + * {@link ITextSelection}. The action creates a new instance of this refactoring as + * well as an {@link ExtractStringWizard} and runs the operation. + * <li> Step 1 of the refactoring is to check the preliminary conditions. Right now we check + * that the java source is not read-only and is in sync. We also try to find a string under + * the selection. If this fails, the refactoring is aborted. + * <li> On success, the wizard is shown, which lets the user input the new ID to use. + * <li> The wizard sets the user input values into this refactoring instance, e.g. the new string + * ID, the XML file to update, etc. The wizard does use the utility method + * {@link XmlStringFileHelper#valueOfStringId(IProject, String, String)} to check whether + * the new ID is already defined in the target XML file. + * <li> Once Preview or Finish is selected in the wizard, the + * {@link #checkFinalConditions(IProgressMonitor)} is called to double-check the user input + * and compute the actual changes. + * <li> When all changes are computed, {@link #createChange(IProgressMonitor)} is invoked. + * </ul> + * + * The list of changes are: + * <ul> + * <li> If the target XML does not exist, create it with the new string ID. + * <li> If the target XML exists, find the <resources> node and add the new string ID right after. + * If the node is <resources/>, it needs to be opened. + * <li> Create an AST rewriter to edit the source Java file and replace all occurrences by the + * new computed R.string.foo. Also need to rewrite imports to import R as needed. + * If there's already a conflicting R included, we need to insert the FQCN instead. + * <li> TODO: Have a pref in the wizard: [x] Change other XML Files + * <li> TODO: Have a pref in the wizard: [x] Change other Java Files + * </ul> + */ +@SuppressWarnings("restriction") +public class ExtractStringRefactoring extends Refactoring { + + public enum Mode { + /** + * the Extract String refactoring is called on an <em>existing</em> source file. + * Its purpose is then to get the selected string of the source and propose to + * change it by an XML id. The XML id may be a new one or an existing one. + */ + EDIT_SOURCE, + /** + * The Extract String refactoring is called without any source file. + * Its purpose is then to create a new XML string ID or select/modify an existing one. + */ + SELECT_ID, + /** + * The Extract String refactoring is called without any source file. + * Its purpose is then to create a new XML string ID. The ID must not already exist. + */ + SELECT_NEW_ID + } + + /** The {@link Mode} of operation of the refactoring. */ + private final Mode mMode; + /** Non-null when editing an Android Resource XML file: identifies the attribute name + * of the value being edited. When null, the source is an Android Java file. */ + private String mXmlAttributeName; + /** The file model being manipulated. + * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */ + private final IFile mFile; + /** The editor. Non-null when invoked from {@link ExtractStringAction}. Null otherwise. */ + private final IEditorPart mEditor; + /** The project that contains {@link #mFile} and that contains the target XML file to modify. */ + private final IProject mProject; + /** The start of the selection in {@link #mFile}. + * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */ + private final int mSelectionStart; + /** The end of the selection in {@link #mFile}. + * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */ + private final int mSelectionEnd; + + /** The compilation unit, only defined if {@link #mFile} points to a usable Java source file. */ + private ICompilationUnit mUnit; + /** The actual string selected, after UTF characters have been escaped, good for display. + * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */ + private String mTokenString; + + /** The XML string ID selected by the user in the wizard. */ + private String mXmlStringId; + /** The XML string value. Might be different than the initial selected string. */ + private String mXmlStringValue; + /** The path of the XML file that will define {@link #mXmlStringId}, selected by the user + * in the wizard. This is relative to the project, e.g. "/res/values/string.xml" */ + private String mTargetXmlFileWsPath; + /** True if we should find & replace in all Java files. */ + private boolean mReplaceAllJava; + /** True if we should find & replace in all XML files of the same name in other res configs + * (other than the main {@link #mTargetXmlFileWsPath}.) */ + private boolean mReplaceAllXml; + + /** The list of changes computed by {@link #checkFinalConditions(IProgressMonitor)} and + * used by {@link #createChange(IProgressMonitor)}. */ + private ArrayList<Change> mChanges; + + private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper(); + + private static final String KEY_MODE = "mode"; //$NON-NLS-1$ + private static final String KEY_FILE = "file"; //$NON-NLS-1$ + private static final String KEY_PROJECT = "proj"; //$NON-NLS-1$ + private static final String KEY_SEL_START = "sel-start"; //$NON-NLS-1$ + private static final String KEY_SEL_END = "sel-end"; //$NON-NLS-1$ + private static final String KEY_TOK_ESC = "tok-esc"; //$NON-NLS-1$ + private static final String KEY_XML_ATTR_NAME = "xml-attr-name"; //$NON-NLS-1$ + private static final String KEY_RPLC_ALL_JAVA = "rplc-all-java"; //$NON-NLS-1$ + private static final String KEY_RPLC_ALL_XML = "rplc-all-xml"; //$NON-NLS-1$ + + /** + * This constructor is solely used by {@link ExtractStringDescriptor}, + * to replay a previous refactoring. + * <p/> + * To create a refactoring from code, please use one of the two other constructors. + * + * @param arguments A map previously created using {@link #createArgumentMap()}. + * @throws NullPointerException + */ + public ExtractStringRefactoring(Map<String, String> arguments) throws NullPointerException { + + mReplaceAllJava = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_JAVA)); + mReplaceAllXml = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_XML)); + mMode = Mode.valueOf(arguments.get(KEY_MODE)); + + IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT)); + mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + + if (mMode == Mode.EDIT_SOURCE) { + path = Path.fromPortableString(arguments.get(KEY_FILE)); + mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path); + + mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START)); + mSelectionEnd = Integer.parseInt(arguments.get(KEY_SEL_END)); + mTokenString = arguments.get(KEY_TOK_ESC); + mXmlAttributeName = arguments.get(KEY_XML_ATTR_NAME); + } else { + mFile = null; + mSelectionStart = mSelectionEnd = -1; + mTokenString = null; + mXmlAttributeName = null; + } + + mEditor = null; + } + + private Map<String, String> createArgumentMap() { + HashMap<String, String> args = new HashMap<String, String>(); + args.put(KEY_RPLC_ALL_JAVA, Boolean.toString(mReplaceAllJava)); + args.put(KEY_RPLC_ALL_XML, Boolean.toString(mReplaceAllXml)); + args.put(KEY_MODE, mMode.name()); + args.put(KEY_PROJECT, mProject.getFullPath().toPortableString()); + if (mMode == Mode.EDIT_SOURCE) { + args.put(KEY_FILE, mFile.getFullPath().toPortableString()); + args.put(KEY_SEL_START, Integer.toString(mSelectionStart)); + args.put(KEY_SEL_END, Integer.toString(mSelectionEnd)); + args.put(KEY_TOK_ESC, mTokenString); + args.put(KEY_XML_ATTR_NAME, mXmlAttributeName); + } + return args; + } + + /** + * Constructor to use when the Extract String refactoring is called on an + * *existing* source file. Its purpose is then to get the selected string of + * the source and propose to change it by an XML id. The XML id may be a new one + * or an existing one. + * + * @param file The source file to process. Cannot be null. File must exist in workspace. + * @param editor The editor. + * @param selection The selection in the source file. Cannot be null or empty. + */ + public ExtractStringRefactoring(IFile file, IEditorPart editor, ITextSelection selection) { + mMode = Mode.EDIT_SOURCE; + mFile = file; + mEditor = editor; + mProject = file.getProject(); + mSelectionStart = selection.getOffset(); + mSelectionEnd = mSelectionStart + Math.max(0, selection.getLength() - 1); + } + + /** + * Constructor to use when the Extract String refactoring is called without + * any source file. Its purpose is then to create a new XML string ID. + * <p/> + * For example this is currently invoked by the ResourceChooser when + * the user wants to create a new string rather than select an existing one. + * + * @param project The project where the target XML file to modify is located. Cannot be null. + * @param enforceNew If true the XML ID must be a new one. + * If false, an existing ID can be used. + */ + public ExtractStringRefactoring(IProject project, boolean enforceNew) { + mMode = enforceNew ? Mode.SELECT_NEW_ID : Mode.SELECT_ID; + mFile = null; + mEditor = null; + mProject = project; + mSelectionStart = mSelectionEnd = -1; + } + + /** + * Sets the replacement string ID. Used by the wizard to set the user input. + */ + public void setNewStringId(String newStringId) { + mXmlStringId = newStringId; + } + + /** + * Sets the replacement string ID. Used by the wizard to set the user input. + */ + public void setNewStringValue(String newStringValue) { + mXmlStringValue = newStringValue; + } + + /** + * Sets the target file. This is a project path, e.g. "/res/values/strings.xml". + * Used by the wizard to set the user input. + */ + public void setTargetFile(String targetXmlFileWsPath) { + mTargetXmlFileWsPath = targetXmlFileWsPath; + } + + public void setReplaceAllJava(boolean replaceAllJava) { + mReplaceAllJava = replaceAllJava; + } + + public void setReplaceAllXml(boolean replaceAllXml) { + mReplaceAllXml = replaceAllXml; + } + + /** + * @see org.eclipse.ltk.core.refactoring.Refactoring#getName() + */ + @Override + public String getName() { + if (mMode == Mode.SELECT_ID) { + return "Create or Use Android String"; + } else if (mMode == Mode.SELECT_NEW_ID) { + return "Create New Android String"; + } + + return "Extract Android String"; + } + + public Mode getMode() { + return mMode; + } + + /** + * Gets the actual string selected, after UTF characters have been escaped, + * good for display. Value can be null. + */ + public String getTokenString() { + return mTokenString; + } + + /** Returns the XML string ID selected by the user in the wizard. */ + public String getXmlStringId() { + return mXmlStringId; + } + + /** + * Step 1 of 3 of the refactoring: + * Checks that the current selection meets the initial condition before the ExtractString + * wizard is shown. The check is supposed to be lightweight and quick. Note that at that + * point the wizard has not been created yet. + * <p/> + * Here we scan the source buffer to find the token matching the selection. + * The check is successful is a Java string literal is selected, the source is in sync + * and is not read-only. + * <p/> + * This is also used to extract the string to be modified, so that we can display it in + * the refactoring wizard. + * + * @see org.eclipse.ltk.core.refactoring.Refactoring#checkInitialConditions(org.eclipse.core.runtime.IProgressMonitor) + * + * @throws CoreException + */ + @Override + public RefactoringStatus checkInitialConditions(IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + + mUnit = null; + mTokenString = null; + + RefactoringStatus status = new RefactoringStatus(); + + try { + monitor.beginTask("Checking preconditions...", 6); + + if (mMode != Mode.EDIT_SOURCE) { + monitor.worked(6); + return status; + } + + if (!checkSourceFile(mFile, status, monitor)) { + return status; + } + + // Try to get a compilation unit from this file. If it fails, mUnit is null. + try { + mUnit = JavaCore.createCompilationUnitFrom(mFile); + + // Make sure the unit is not read-only, e.g. it's not a class file or inside a Jar + if (mUnit.isReadOnly()) { + status.addFatalError("The file is read-only, please make it writeable first."); + return status; + } + + // This is a Java file. Check if it contains the selection we want. + if (!findSelectionInJavaUnit(mUnit, status, monitor)) { + return status; + } + + } catch (Exception e) { + // That was not a Java file. Ignore. + } + + if (mUnit != null) { + monitor.worked(1); + return status; + } + + // Check this a Layout XML file and get the selection and its context. + if (mFile != null && SdkConstants.EXT_XML.equals(mFile.getFileExtension())) { + + // Currently we only support Android resource XML files, so they must have a path + // similar to + // project/res/<type>[-<configuration>]/*.xml + // project/AndroidManifest.xml + // There is no support for sub folders, so the segment count must be 4 or 2. + // We don't need to check the type folder name because a/ we only accept + // an AndroidXmlEditor source and b/ aapt generates a compilation error for + // unknown folders. + + IPath path = mFile.getFullPath(); + if ((path.segmentCount() == 4 && + path.segment(1).equalsIgnoreCase(SdkConstants.FD_RESOURCES)) || + (path.segmentCount() == 2 && + path.segment(1).equalsIgnoreCase(SdkConstants.FN_ANDROID_MANIFEST_XML))) { + if (!findSelectionInXmlFile(mFile, status, monitor)) { + return status; + } + } + } + + if (!status.isOK()) { + status.addFatalError( + "Selection must be inside a Java source or an Android Layout XML file."); + } + + } finally { + monitor.done(); + } + + return status; + } + + /** + * Try to find the selected Java element in the compilation unit. + * + * If selection matches a string literal, capture it, otherwise add a fatal error + * to the status. + * + * On success, advance the monitor by 3. + * Returns status.isOK(). + */ + private boolean findSelectionInJavaUnit(ICompilationUnit unit, + RefactoringStatus status, IProgressMonitor monitor) { + try { + IBuffer buffer = unit.getBuffer(); + + IScanner scanner = ToolFactory.createScanner( + false, //tokenizeComments + false, //tokenizeWhiteSpace + false, //assertMode + false //recordLineSeparator + ); + scanner.setSource(buffer.getCharacters()); + monitor.worked(1); + + for(int token = scanner.getNextToken(); + token != ITerminalSymbols.TokenNameEOF; + token = scanner.getNextToken()) { + if (scanner.getCurrentTokenStartPosition() <= mSelectionStart && + scanner.getCurrentTokenEndPosition() >= mSelectionEnd) { + // found the token, but only keep if the right type + if (token == ITerminalSymbols.TokenNameStringLiteral) { + mTokenString = new String(scanner.getCurrentTokenSource()); + } + break; + } else if (scanner.getCurrentTokenStartPosition() > mSelectionEnd) { + // scanner is past the selection, abort. + break; + } + } + } catch (JavaModelException e1) { + // Error in unit.getBuffer. Ignore. + } catch (InvalidInputException e2) { + // Error in scanner.getNextToken. Ignore. + } finally { + monitor.worked(1); + } + + if (mTokenString != null) { + // As a literal string, the token should have surrounding quotes. Remove them. + // Note: unquoteAttrValue technically removes either " or ' paired quotes, whereas + // the Java token should only have " quotes. Since we know the type to be a string + // literal, there should be no confusion here. + mTokenString = unquoteAttrValue(mTokenString); + + // We need a non-empty string literal + if (mTokenString.length() == 0) { + mTokenString = null; + } + } + + if (mTokenString == null) { + status.addFatalError("Please select a Java string literal."); + } + + monitor.worked(1); + return status.isOK(); + } + + /** + * Try to find the selected XML element. This implementation replies on the refactoring + * originating from an Android Layout Editor. We rely on some internal properties of the + * Structured XML editor to retrieve file content to avoid parsing it again. We also rely + * on our specific Android XML model to get element & attribute descriptor properties. + * + * If selection matches a string literal, capture it, otherwise add a fatal error + * to the status. + * + * On success, advance the monitor by 1. + * Returns status.isOK(). + */ + private boolean findSelectionInXmlFile(IFile file, + RefactoringStatus status, + IProgressMonitor monitor) { + + try { + if (!(mEditor instanceof AndroidXmlEditor)) { + status.addFatalError("Only the Android XML Editor is currently supported."); + return status.isOK(); + } + + AndroidXmlEditor editor = (AndroidXmlEditor) mEditor; + IStructuredModel smodel = null; + Node node = null; + String currAttrName = null; + + try { + // See the portability note in AndroidXmlEditor#getModelForRead() javadoc. + smodel = editor.getModelForRead(); + if (smodel != null) { + // The structured model gives the us the actual XML Node element where the + // offset is. By using this Node, we can find the exact UiElementNode of our + // model and thus we'll be able to get the properties of the attribute -- to + // check if it accepts a string reference. This does not however tell us if + // the selection is actually in an attribute value, nor which attribute is + // being edited. + for(int offset = mSelectionStart; offset >= 0 && node == null; --offset) { + node = (Node) smodel.getIndexedRegion(offset); + } + + if (node == null) { + status.addFatalError( + "The selection does not match any element in the XML document."); + return status.isOK(); + } + + if (node.getNodeType() != Node.ELEMENT_NODE) { + status.addFatalError("The selection is not inside an actual XML element."); + return status.isOK(); + } + + IStructuredDocument sdoc = smodel.getStructuredDocument(); + if (sdoc != null) { + // Portability note: all the structured document implementation is + // under wst.sse.core.internal.provisional so we can expect it to change in + // a distant future if they start cleaning their codebase, however unlikely + // that is. + + int selStart = mSelectionStart; + IStructuredDocumentRegion region = + sdoc.getRegionAtCharacterOffset(selStart); + if (region != null && + DOMRegionContext.XML_TAG_NAME.equals(region.getType())) { + // Find if any sub-region representing an attribute contains the + // selection. If it does, returns the name of the attribute in + // currAttrName and returns the value in the field mTokenString. + currAttrName = findSelectionInRegion(region, selStart); + + if (mTokenString == null) { + status.addFatalError( + "The selection is not inside an actual XML attribute value."); + } + } + } + + if (mTokenString != null && node != null && currAttrName != null) { + + // Validate that the attribute accepts a string reference. + // This sets mTokenString to null by side-effect when it fails and + // adds a fatal error to the status as needed. + validateSelectedAttribute(editor, node, currAttrName, status); + + } else { + // We shouldn't get here: we're missing one of the token string, the node + // or the attribute name. All of them have been checked earlier so don't + // set any specific error. + mTokenString = null; + } + } + } catch (Throwable t) { + // Since we use some internal APIs, use a broad catch-all to report any + // unexpected issue rather than crash the whole refactoring. + status.addFatalError( + String.format("XML parsing error: %1$s", t.getMessage())); + } finally { + if (smodel != null) { + smodel.releaseFromRead(); + } + } + + } finally { + monitor.worked(1); + } + + return status.isOK(); + } + + /** + * The region gives us the textual representation of the XML element + * where the selection starts, split using sub-regions. We now just + * need to iterate through the sub-regions to find which one + * contains the actual selection. We're interested in an attribute + * value however when we find one we want to memorize the attribute + * name that was defined just before. + * + * @return When the cursor is on a valid attribute name or value, returns the string of + * attribute name. As a side-effect, returns the value of the attribute in {@link #mTokenString} + */ + private String findSelectionInRegion(IStructuredDocumentRegion region, int selStart) { + + String currAttrName = null; + + int startInRegion = selStart - region.getStartOffset(); + + int nb = region.getNumberOfRegions(); + ITextRegionList list = region.getRegions(); + String currAttrValue = null; + + for (int i = 0; i < nb; i++) { + ITextRegion subRegion = list.get(i); + String type = subRegion.getType(); + + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + currAttrName = region.getText(subRegion); + + // I like to select the attribute definition and invoke + // the extract string wizard. So if the selection is on + // the attribute name part, find the value that is just + // after and use it as if it were the selection. + + if (subRegion.getStart() <= startInRegion && + startInRegion < subRegion.getTextEnd()) { + // A well-formed attribute is composed of a name, + // an equal sign and the value. There can't be any space + // in between, which makes the parsing a lot easier. + if (i <= nb - 3 && + DOMRegionContext.XML_TAG_ATTRIBUTE_EQUALS.equals( + list.get(i + 1).getType())) { + subRegion = list.get(i + 2); + type = subRegion.getType(); + if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals( + type)) { + currAttrValue = region.getText(subRegion); + } + } + } + + } else if (subRegion.getStart() <= startInRegion && + startInRegion < subRegion.getTextEnd() && + DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + currAttrValue = region.getText(subRegion); + } + + if (currAttrValue != null) { + // We found the value. Only accept it if not empty + // and if we found an attribute name before. + String text = currAttrValue; + + // The attribute value contains XML quotes. Remove them. + text = unquoteAttrValue(text); + if (text.length() > 0 && currAttrName != null) { + // Setting mTokenString to non-null marks the fact we + // accept this attribute. + mTokenString = text; + } + + break; + } + } + + return currAttrName; + } + + /** + * Attribute values found as text for {@link DOMRegionContext#XML_TAG_ATTRIBUTE_VALUE} + * contain XML quotes. This removes the quotes (either single or double quotes). + * + * @param attrValue The attribute value, as extracted by + * {@link IStructuredDocumentRegion#getText(ITextRegion)}. + * Must not be null. + * @return The attribute value, without quotes. Whitespace is not trimmed, if any. + * String may be empty, but not null. + */ + static String unquoteAttrValue(String attrValue) { + int len = attrValue.length(); + int len1 = len - 1; + if (len >= 2 && + attrValue.charAt(0) == '"' && + attrValue.charAt(len1) == '"') { + attrValue = attrValue.substring(1, len1); + } else if (len >= 2 && + attrValue.charAt(0) == '\'' && + attrValue.charAt(len1) == '\'') { + attrValue = attrValue.substring(1, len1); + } + + return attrValue; + } + + /** + * Validates that the attribute accepts a string reference. + * This sets mTokenString to null by side-effect when it fails and + * adds a fatal error to the status as needed. + */ + private void validateSelectedAttribute(AndroidXmlEditor editor, Node node, + String attrName, RefactoringStatus status) { + UiElementNode rootUiNode = editor.getUiRootNode(); + UiElementNode currentUiNode = + rootUiNode == null ? null : rootUiNode.findXmlNode(node); + ReferenceAttributeDescriptor attrDesc = null; + + if (currentUiNode != null) { + // remove any namespace prefix from the attribute name + String name = attrName; + int pos = name.indexOf(':'); + if (pos > 0 && pos < name.length() - 1) { + name = name.substring(pos + 1); + } + + for (UiAttributeNode attrNode : currentUiNode.getAllUiAttributes()) { + if (attrNode.getDescriptor().getXmlLocalName().equals(name)) { + AttributeDescriptor desc = attrNode.getDescriptor(); + if (desc instanceof ReferenceAttributeDescriptor) { + attrDesc = (ReferenceAttributeDescriptor) desc; + } + break; + } + } + } + + // The attribute descriptor is a resource reference. It must either accept + // of any resource type or specifically accept string types. + if (attrDesc != null && + (attrDesc.getResourceType() == null || + attrDesc.getResourceType() == ResourceType.STRING)) { + // We have one more check to do: is the current string value already + // an Android XML string reference? If so, we can't edit it. + if (mTokenString != null && mTokenString.startsWith("@")) { //$NON-NLS-1$ + int pos1 = 0; + if (mTokenString.length() > 1 && mTokenString.charAt(1) == '+') { + pos1++; + } + int pos2 = mTokenString.indexOf('/'); + if (pos2 > pos1) { + String kind = mTokenString.substring(pos1 + 1, pos2); + if (ResourceType.STRING.getName().equals(kind)) { + mTokenString = null; + status.addFatalError(String.format( + "The attribute %1$s already contains a %2$s reference.", + attrName, + kind)); + } + } + } + + if (mTokenString != null) { + // We're done with all our checks. mTokenString contains the + // current attribute value. We don't memorize the region nor the + // attribute, however we memorize the textual attribute name so + // that we can offer replacement for all its occurrences. + mXmlAttributeName = attrName; + } + + } else { + mTokenString = null; + status.addFatalError(String.format( + "The attribute %1$s does not accept a string reference.", + attrName)); + } + } + + /** + * Tests from org.eclipse.jdt.internal.corext.refactoringChecks#validateEdit() + * Might not be useful. + * + * On success, advance the monitor by 2. + * + * @return False if caller should abort, true if caller should continue. + */ + private boolean checkSourceFile(IFile file, + RefactoringStatus status, + IProgressMonitor monitor) { + // check whether the source file is in sync + if (!file.isSynchronized(IResource.DEPTH_ZERO)) { + status.addFatalError("The file is not synchronized. Please save it first."); + return false; + } + monitor.worked(1); + + // make sure we can write to it. + ResourceAttributes resAttr = file.getResourceAttributes(); + if (resAttr == null || resAttr.isReadOnly()) { + status.addFatalError("The file is read-only, please make it writeable first."); + return false; + } + monitor.worked(1); + + return true; + } + + /** + * Step 2 of 3 of the refactoring: + * Check the conditions once the user filled values in the refactoring wizard, + * then prepare the changes to be applied. + * <p/> + * In this case, most of the sanity checks are done by the wizard so essentially this + * should only be called if the wizard positively validated the user input. + * + * Here we do check that the target resource XML file either does not exists or + * is not read-only. + * + * @see org.eclipse.ltk.core.refactoring.Refactoring#checkFinalConditions(IProgressMonitor) + * + * @throws CoreException + */ + @Override + public RefactoringStatus checkFinalConditions(IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + RefactoringStatus status = new RefactoringStatus(); + + try { + monitor.beginTask("Checking post-conditions...", 5); + + if (mXmlStringId == null || mXmlStringId.length() <= 0) { + // this is not supposed to happen + status.addFatalError("Missing replacement string ID"); + } else if (mTargetXmlFileWsPath == null || mTargetXmlFileWsPath.length() <= 0) { + // this is not supposed to happen + status.addFatalError("Missing target xml file path"); + } + monitor.worked(1); + + // Either that resource must not exist or it must be a writable file. + IResource targetXml = getTargetXmlResource(mTargetXmlFileWsPath); + if (targetXml != null) { + if (targetXml.getType() != IResource.FILE) { + status.addFatalError( + String.format("XML file '%1$s' is not a file.", mTargetXmlFileWsPath)); + } else { + ResourceAttributes attr = targetXml.getResourceAttributes(); + if (attr != null && attr.isReadOnly()) { + status.addFatalError( + String.format("XML file '%1$s' is read-only.", + mTargetXmlFileWsPath)); + } + } + } + monitor.worked(1); + + if (status.hasError()) { + return status; + } + + mChanges = new ArrayList<Change>(); + + + // Prepare the change to create/edit the String ID in the res/values XML file. + if (!mXmlStringValue.equals( + mXmlHelper.valueOfStringId(mProject, mTargetXmlFileWsPath, mXmlStringId))) { + // We actually change it only if the ID doesn't exist yet or has a different value + Change change = createXmlChanges((IFile) targetXml, mXmlStringId, mXmlStringValue, + status, SubMonitor.convert(monitor, 1)); + if (change != null) { + mChanges.add(change); + } + } + + if (status.hasError()) { + return status; + } + + if (mMode == Mode.EDIT_SOURCE) { + List<Change> changes = null; + if (mXmlAttributeName != null) { + // Prepare the change to the Android resource XML file + changes = computeXmlSourceChanges(mFile, + mXmlStringId, + mTokenString, + mXmlAttributeName, + true, // allConfigurations + status, + monitor); + + } else if (mUnit != null) { + // Prepare the change to the Java compilation unit + changes = computeJavaChanges(mUnit, mXmlStringId, mTokenString, + status, SubMonitor.convert(monitor, 1)); + } + if (changes != null) { + mChanges.addAll(changes); + } + } + + if (mReplaceAllJava) { + String currentIdentifier = mUnit != null ? mUnit.getHandleIdentifier() : ""; //$NON-NLS-1$ + + SubMonitor submon = SubMonitor.convert(monitor, 1); + for (ICompilationUnit unit : findAllJavaUnits()) { + // Only process Java compilation units that exist, are not derived + // and are not read-only. + if (unit == null || !unit.exists()) { + continue; + } + IResource resource = unit.getResource(); + if (resource == null || resource.isDerived()) { + continue; + } + + // Ensure that we don't process the current compilation unit (processed + // as mUnit above) twice + if (currentIdentifier.equals(unit.getHandleIdentifier())) { + continue; + } + + ResourceAttributes attrs = resource.getResourceAttributes(); + if (attrs != null && attrs.isReadOnly()) { + continue; + } + + List<Change> changes = computeJavaChanges( + unit, mXmlStringId, mTokenString, + status, SubMonitor.convert(submon, 1)); + if (changes != null) { + mChanges.addAll(changes); + } + } + } + + if (mReplaceAllXml) { + SubMonitor submon = SubMonitor.convert(monitor, 1); + for (IFile xmlFile : findAllResXmlFiles()) { + if (xmlFile != null) { + List<Change> changes = computeXmlSourceChanges(xmlFile, + mXmlStringId, + mTokenString, + mXmlAttributeName, + false, // allConfigurations + status, + SubMonitor.convert(submon, 1)); + if (changes != null) { + mChanges.addAll(changes); + } + } + } + } + + monitor.worked(1); + } finally { + monitor.done(); + } + + return status; + } + + // --- XML changes --- + + /** + * Returns a foreach-compatible iterator over all XML files in the project's + * /res folder, excluding the target XML file (the one where we'll write/edit + * the string id). + */ + private Iterable<IFile> findAllResXmlFiles() { + return new Iterable<IFile>() { + @Override + public Iterator<IFile> iterator() { + return new Iterator<IFile>() { + final Queue<IFile> mFiles = new LinkedList<IFile>(); + final Queue<IResource> mFolders = new LinkedList<IResource>(); + IPath mFilterPath1 = null; + IPath mFilterPath2 = null; + { + // Filter out the XML file where we'll be writing the XML string id. + IResource filterRes = mProject.findMember(mTargetXmlFileWsPath); + if (filterRes != null) { + mFilterPath1 = filterRes.getFullPath(); + } + // Filter out the XML source file, if any (e.g. typically a layout) + if (mFile != null) { + mFilterPath2 = mFile.getFullPath(); + } + + // We want to process the manifest + IResource man = mProject.findMember("AndroidManifest.xml"); // TODO find a constant + if (man.exists() && man instanceof IFile && !man.equals(mFile)) { + mFiles.add((IFile) man); + } + + // Add all /res folders (technically we don't need to process /res/values + // XML files that contain resources/string elements, but it's easier to + // not filter them out.) + IFolder f = mProject.getFolder(AdtConstants.WS_RESOURCES); + if (f.exists()) { + try { + mFolders.addAll( + Arrays.asList(f.members(IContainer.EXCLUDE_DERIVED))); + } catch (CoreException e) { + // pass + } + } + } + + @Override + public boolean hasNext() { + if (!mFiles.isEmpty()) { + return true; + } + + while (!mFolders.isEmpty()) { + IResource res = mFolders.poll(); + if (res.exists() && res instanceof IFolder) { + IFolder f = (IFolder) res; + try { + getFileList(f); + if (!mFiles.isEmpty()) { + return true; + } + } catch (CoreException e) { + // pass + } + } + } + return false; + } + + private void getFileList(IFolder folder) throws CoreException { + for (IResource res : folder.members(IContainer.EXCLUDE_DERIVED)) { + // Only accept file resources which are not derived and actually exist + if (res.exists() && !res.isDerived() && res instanceof IFile) { + IFile file = (IFile) res; + // Must have an XML extension + if (SdkConstants.EXT_XML.equals(file.getFileExtension())) { + IPath p = file.getFullPath(); + // And not be either paths we want to filter out + if ((mFilterPath1 != null && mFilterPath1.equals(p)) || + (mFilterPath2 != null && mFilterPath2.equals(p))) { + continue; + } + mFiles.add(file); + } + } + } + } + + @Override + public IFile next() { + IFile file = mFiles.poll(); + hasNext(); + return file; + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "This iterator does not support removal"); //$NON-NLS-1$ + } + }; + } + }; + } + + /** + * Internal helper that actually prepares the {@link Change} that adds the given + * ID to the given XML File. + * <p/> + * This does not actually modify the file. + * + * @param targetXml The file resource to modify. + * @param xmlStringId The new ID to insert. + * @param tokenString The old string, which will be the value in the XML string. + * @return A new {@link TextEdit} that describes how to change the file. + */ + private Change createXmlChanges(IFile targetXml, + String xmlStringId, + String tokenString, + RefactoringStatus status, + SubMonitor monitor) { + + TextFileChange xmlChange = new TextFileChange(getName(), targetXml); + xmlChange.setTextType(SdkConstants.EXT_XML); + + String error = ""; //$NON-NLS-1$ + TextEdit edit = null; + TextEditGroup editGroup = null; + + try { + if (!targetXml.exists()) { + // Kludge: use targetXml==null as a signal this is a new file being created + targetXml = null; + } + + edit = createXmlReplaceEdit(targetXml, xmlStringId, tokenString, status, + SubMonitor.convert(monitor, 1)); + } catch (IOException e) { + error = e.toString(); + } catch (CoreException e) { + // Failed to read file. Ignore. Will handle error below. + error = e.toString(); + } + + if (edit == null) { + status.addFatalError(String.format("Failed to modify file %1$s%2$s", + targetXml == null ? "" : targetXml.getFullPath(), //$NON-NLS-1$ + error == null ? "" : ": " + error)); //$NON-NLS-1$ + return null; + } + + editGroup = new TextEditGroup(targetXml == null ? "Create <string> in new XML file" + : "Insert <string> in XML file", + edit); + + xmlChange.setEdit(edit); + // The TextEditChangeGroup let the user toggle this change on and off later. + xmlChange.addTextEditChangeGroup(new TextEditChangeGroup(xmlChange, editGroup)); + + monitor.worked(1); + return xmlChange; + } + + /** + * Scan the XML file to find the best place where to insert the new string element. + * <p/> + * This handles a variety of cases, including replacing existing ids in place, + * adding the top resources element if missing and the XML PI if not present. + * It tries to preserve indentation when adding new elements at the end of an existing XML. + * + * @param file The XML file to modify, that must be present in the workspace. + * Pass null to create a change for a new file that doesn't exist yet. + * @param xmlStringId The new ID to insert. + * @param tokenString The old string, which will be the value in the XML string. + * @param status The in-out refactoring status. Used to log a more detailed error if the + * XML has a top element that is not a resources element. + * @param monitor A monitor to track progress. + * @return A new {@link TextEdit} for either a replace or an insert operation, or null in case + * of error. + * @throws CoreException - if the file's contents or description can not be read. + * @throws IOException - if the file's contents can not be read or its detected encoding does + * not support its contents. + */ + private TextEdit createXmlReplaceEdit(IFile file, + String xmlStringId, + String tokenString, + RefactoringStatus status, + SubMonitor monitor) + throws IOException, CoreException { + + IModelManager modelMan = StructuredModelManager.getModelManager(); + + final String NODE_RESOURCES = SdkConstants.TAG_RESOURCES; + final String NODE_STRING = SdkConstants.TAG_STRING; + final String ATTR_NAME = SdkConstants.ATTR_NAME; + + + // Scan the source to find the best insertion point. + + // 1- The most common case we need to handle is the one of inserting at the end + // of a valid XML document, respecting the whitespace last used. + // + // Ideally we have this structure: + // <xml ...> + // <resource> + // ...ws1...<string>blah</string>...ws2... + // </resource> + // + // where ws1 and ws2 are the whitespace respectively before and after the last element + // just before the closing </resource>. + // In this case we want to generate the new string just before ws2...</resource> with + // the same whitespace as ws1. + // + // 2- Another expected case is there's already an existing string which "name" attribute + // equals to xmlStringId and we just want to replace its value. + // + // Other cases we need to handle: + // 3- There is no element at all -> create a full new <resource>+<string> content. + // 4- There is <resource/>, that is the tag is not opened. This can be handled as the + // previous case, generating full content but also replacing <resource/>. + // 5- There is a top element that is not <resource>. That's a fatal error and we abort. + + IStructuredModel smodel = null; + + // Single and double quotes must be escaped in the <string>value</string> declaration + tokenString = ValueXmlHelper.escapeResourceString(tokenString); + + try { + IStructuredDocument sdoc = null; + boolean checkTopElement = true; + boolean replaceStringContent = false; + boolean hasPiXml = false; + int newResStart = 0; + int newResLength = 0; + String lineSep = "\n"; //$NON-NLS-1$ + + if (file != null) { + smodel = modelMan.getExistingModelForRead(file); + if (smodel != null) { + sdoc = smodel.getStructuredDocument(); + } else if (smodel == null) { + // The model is not currently open. + if (file.exists()) { + sdoc = modelMan.createStructuredDocumentFor(file); + } else { + sdoc = modelMan.createNewStructuredDocumentFor(file); + } + } + } + + if (sdoc == null && file != null) { + // Get a document matching the actual saved file + sdoc = modelMan.createStructuredDocumentFor(file); + } + + if (sdoc != null) { + String wsBefore = ""; //$NON-NLS-1$ + String lastWs = null; + + lineSep = sdoc.getLineDelimiter(); + if (lineSep == null || lineSep.length() == 0) { + // That wasn't too useful, let's go back to a reasonable default + lineSep = "\n"; //$NON-NLS-1$ + } + + for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) { + String type = regions.getType(); + + if (DOMRegionContext.XML_CONTENT.equals(type)) { + + if (replaceStringContent) { + // Generate a replacement for a <string> value matching the string ID. + return new ReplaceEdit( + regions.getStartOffset(), regions.getLength(), tokenString); + } + + // Otherwise capture what should be whitespace content + lastWs = regions.getFullText(); + continue; + + } else if (DOMRegionContext.XML_PI_OPEN.equals(type) && !hasPiXml) { + + int nb = regions.getNumberOfRegions(); + ITextRegionList list = regions.getRegions(); + for (int i = 0; i < nb; i++) { + ITextRegion region = list.get(i); + type = region.getType(); + if (DOMRegionContext.XML_TAG_NAME.equals(type)) { + String name = regions.getText(region); + if ("xml".equals(name)) { //$NON-NLS-1$ + hasPiXml = true; + break; + } + } + } + continue; + + } else if (!DOMRegionContext.XML_TAG_NAME.equals(type)) { + // ignore things which are not a tag nor text content (such as comments) + continue; + } + + int nb = regions.getNumberOfRegions(); + ITextRegionList list = regions.getRegions(); + + String name = null; + String attrName = null; + String attrValue = null; + boolean isEmptyTag = false; + boolean isCloseTag = false; + + for (int i = 0; i < nb; i++) { + ITextRegion region = list.get(i); + type = region.getType(); + + if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) { + isCloseTag = true; + } else if (DOMRegionContext.XML_EMPTY_TAG_CLOSE.equals(type)) { + isEmptyTag = true; + } else if (DOMRegionContext.XML_TAG_NAME.equals(type)) { + name = regions.getText(region); + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type) && + NODE_STRING.equals(name)) { + // Record the attribute names into a <string> element. + attrName = regions.getText(region); + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type) && + ATTR_NAME.equals(attrName)) { + // Record the value of a <string name=...> attribute + attrValue = regions.getText(region); + + if (attrValue != null && + unquoteAttrValue(attrValue).equals(xmlStringId)) { + // We found a <string name=> matching the string ID to replace. + // We'll generate a replacement when we process the string value + // (that is the next XML_CONTENT region.) + replaceStringContent = true; + } + } + } + + if (checkTopElement) { + // Check the top element has a resource name + checkTopElement = false; + if (!NODE_RESOURCES.equals(name)) { + status.addFatalError( + String.format("XML file lacks a <resource> tag: %1$s", + mTargetXmlFileWsPath)); + return null; + + } + + if (isEmptyTag) { + // The top element is an empty "<resource/>" tag. We need to do + // a full new resource+string replacement. + newResStart = regions.getStartOffset(); + newResLength = regions.getLength(); + } + } + + if (NODE_RESOURCES.equals(name)) { + if (isCloseTag) { + // We found the </resource> tag and we want + // to insert just before this one. + + StringBuilder content = new StringBuilder(); + content.append(wsBefore) + .append("<string name=\"") //$NON-NLS-1$ + .append(xmlStringId) + .append("\">") //$NON-NLS-1$ + .append(tokenString) + .append("</string>"); //$NON-NLS-1$ + + // Backup to insert before the whitespace preceding </resource> + IStructuredDocumentRegion insertBeforeReg = regions; + while (true) { + IStructuredDocumentRegion previous = insertBeforeReg.getPrevious(); + if (previous != null && + DOMRegionContext.XML_CONTENT.equals(previous.getType()) && + previous.getText().trim().length() == 0) { + insertBeforeReg = previous; + } else { + break; + } + } + if (insertBeforeReg == regions) { + // If we have not found any whitespace before </resources>, + // at least add a line separator. + content.append(lineSep); + } + + return new InsertEdit(insertBeforeReg.getStartOffset(), + content.toString()); + } + } else { + // For any other tag than <resource>, capture whitespace before and after. + if (!isCloseTag) { + wsBefore = lastWs; + } + } + } + } + + // We reach here either because there's no XML content at all or because + // there's an empty <resource/>. + // Provide a full new resource+string replacement. + StringBuilder content = new StringBuilder(); + if (!hasPiXml) { + content.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); //$NON-NLS-1$ + content.append(lineSep); + } else if (newResLength == 0 && sdoc != null) { + // If inserting at the end, check if the last region is some whitespace. + // If there's no newline, insert one ourselves. + IStructuredDocumentRegion lastReg = sdoc.getLastStructuredDocumentRegion(); + if (lastReg != null && lastReg.getText().indexOf('\n') == -1) { + content.append('\n'); + } + } + + // FIXME how to access formatting preferences to generate the proper indentation? + content.append("<resources>").append(lineSep); //$NON-NLS-1$ + content.append(" <string name=\"") //$NON-NLS-1$ + .append(xmlStringId) + .append("\">") //$NON-NLS-1$ + .append(tokenString) + .append("</string>") //$NON-NLS-1$ + .append(lineSep); + content.append("</resources>").append(lineSep); //$NON-NLS-1$ + + if (newResLength > 0) { + // Replace existing piece + return new ReplaceEdit(newResStart, newResLength, content.toString()); + } else { + // Insert at the end. + int offset = sdoc == null ? 0 : sdoc.getLength(); + return new InsertEdit(offset, content.toString()); + } + } catch (IOException e) { + // This is expected to happen and is properly reported to the UI. + throw e; + } catch (CoreException e) { + // This is expected to happen and is properly reported to the UI. + throw e; + } catch (Throwable t) { + // Since we use some internal APIs, use a broad catch-all to report any + // unexpected issue rather than crash the whole refactoring. + status.addFatalError( + String.format("XML replace error: %1$s", t.getMessage())); + } finally { + if (smodel != null) { + smodel.releaseFromRead(); + } + } + + return null; + } + + /** + * Computes the changes to be made to the source Android XML file and + * returns a list of {@link Change}. + * <p/> + * This function scans an XML file, looking for an attribute value equals to + * <code>tokenString</code>. If non null, <code>xmlAttrName</code> limit the search + * to only attributes that have that name. + * If found, a change is made to replace each occurrence of <code>tokenString</code> + * by a new "@string/..." using the new <code>xmlStringId</code>. + * + * @param sourceFile The file to process. + * A status error will be generated if it does not exists. + * Must not be null. + * @param tokenString The string to find. Must not be null or empty. + * @param xmlAttrName Optional attribute name to limit the search. Can be null. + * @param allConfigurations True if this function should can all XML files with the same + * name and the same resource type folder but with different configurations. + * @param status Status used to report fatal errors. + * @param monitor Used to log progress. + */ + private List<Change> computeXmlSourceChanges(IFile sourceFile, + String xmlStringId, + String tokenString, + String xmlAttrName, + boolean allConfigurations, + RefactoringStatus status, + IProgressMonitor monitor) { + + if (!sourceFile.exists()) { + status.addFatalError(String.format("XML file '%1$s' does not exist.", + sourceFile.getFullPath().toOSString())); + return null; + } + + // We shouldn't be trying to replace a null or empty string. + assert tokenString != null && tokenString.length() > 0; + if (tokenString == null || tokenString.length() == 0) { + return null; + } + + // Note: initially this method was only processing files using a pattern + // /project/res/<type>-<configuration>/<filename.xml> + // However the last version made that more generic to be able to process any XML + // files. We should probably revisit and simplify this later. + HashSet<IFile> files = new HashSet<IFile>(); + files.add(sourceFile); + + if (allConfigurations && SdkConstants.EXT_XML.equals(sourceFile.getFileExtension())) { + IPath path = sourceFile.getFullPath(); + if (path.segmentCount() == 4 && path.segment(1).equals(SdkConstants.FD_RESOURCES)) { + IProject project = sourceFile.getProject(); + String filename = path.segment(3); + String initialTypeName = path.segment(2); + ResourceFolderType type = ResourceFolderType.getFolderType(initialTypeName); + + IContainer res = sourceFile.getParent().getParent(); + if (type != null && res != null && res.getType() == IResource.FOLDER) { + try { + for (IResource r : res.members()) { + if (r != null && r.getType() == IResource.FOLDER) { + String name = r.getName(); + // Skip the initial folder name, it's already in the list. + if (!name.equals(initialTypeName)) { + // Only accept the same folder type (e.g. layout-*) + ResourceFolderType t = + ResourceFolderType.getFolderType(name); + if (type.equals(t)) { + // recompute the path + IPath p = res.getProjectRelativePath().append(name). + append(filename); + IResource f = project.findMember(p); + if (f != null && f instanceof IFile) { + files.add((IFile) f); + } + } + } + } + } + } catch (CoreException e) { + // Ignore. + } + } + } + } + + SubMonitor subMonitor = SubMonitor.convert(monitor, Math.min(1, files.size())); + + ArrayList<Change> changes = new ArrayList<Change>(); + + // Portability note: getModelManager is part of wst.sse.core however the + // interface returned is part of wst.sse.core.internal.provisional so we can + // expect it to change in a distant future if they start cleaning their codebase, + // however unlikely that is. + IModelManager modelManager = StructuredModelManager.getModelManager(); + + for (IFile file : files) { + + IStructuredModel smodel = null; + MultiTextEdit multiEdit = null; + TextFileChange xmlChange = null; + ArrayList<TextEditGroup> editGroups = null; + + try { + IStructuredDocument sdoc = null; + + smodel = modelManager.getExistingModelForRead(file); + if (smodel != null) { + sdoc = smodel.getStructuredDocument(); + } else if (smodel == null) { + // The model is not currently open. + if (file.exists()) { + sdoc = modelManager.createStructuredDocumentFor(file); + } else { + sdoc = modelManager.createNewStructuredDocumentFor(file); + } + } + + if (sdoc == null) { + status.addFatalError("XML structured document not found"); //$NON-NLS-1$ + continue; + } + + multiEdit = new MultiTextEdit(); + editGroups = new ArrayList<TextEditGroup>(); + xmlChange = new TextFileChange(getName(), file); + xmlChange.setTextType("xml"); //$NON-NLS-1$ + + String quotedReplacement = quotedAttrValue(STRING_PREFIX + xmlStringId); + + // Prepare the change set + for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) { + // Only look at XML "top regions" + if (!DOMRegionContext.XML_TAG_NAME.equals(regions.getType())) { + continue; + } + + int nb = regions.getNumberOfRegions(); + ITextRegionList list = regions.getRegions(); + String lastAttrName = null; + + for (int i = 0; i < nb; i++) { + ITextRegion subRegion = list.get(i); + String type = subRegion.getType(); + + if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) { + // Memorize the last attribute name seen + lastAttrName = regions.getText(subRegion); + + } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) { + // Check this is the attribute and the original string + String text = regions.getText(subRegion); + + // Remove " or ' quoting present in the attribute value + text = unquoteAttrValue(text); + + if (tokenString.equals(text) && + (xmlAttrName == null || xmlAttrName.equals(lastAttrName))) { + + // Found an occurrence. Create a change for it. + TextEdit edit = new ReplaceEdit( + regions.getStartOffset() + subRegion.getStart(), + subRegion.getTextLength(), + quotedReplacement); + TextEditGroup editGroup = new TextEditGroup( + "Replace attribute string by ID", + edit); + + multiEdit.addChild(edit); + editGroups.add(editGroup); + } + } + } + } + } catch (Throwable t) { + // Since we use some internal APIs, use a broad catch-all to report any + // unexpected issue rather than crash the whole refactoring. + status.addFatalError( + String.format("XML refactoring error: %1$s", t.getMessage())); + } finally { + if (smodel != null) { + smodel.releaseFromRead(); + } + + if (multiEdit != null && + xmlChange != null && + editGroups != null && + multiEdit.hasChildren()) { + xmlChange.setEdit(multiEdit); + for (TextEditGroup group : editGroups) { + xmlChange.addTextEditChangeGroup( + new TextEditChangeGroup(xmlChange, group)); + } + changes.add(xmlChange); + } + subMonitor.worked(1); + } + } // for files + + if (changes.size() > 0) { + return changes; + } + return null; + } + + /** + * Returns a quoted attribute value suitable to be placed after an attributeName= + * statement in an XML stream. + * + * According to http://www.w3.org/TR/2008/REC-xml-20081126/#NT-AttValue + * the attribute value can be either quoted using ' or " and the corresponding + * entities ' or " must be used inside. + */ + private String quotedAttrValue(String attrValue) { + if (attrValue.indexOf('"') == -1) { + // no double-quotes inside, use double-quotes around. + return '"' + attrValue + '"'; + } + if (attrValue.indexOf('\'') == -1) { + // no single-quotes inside, use single-quotes around. + return '\'' + attrValue + '\''; + } + // If we get here, there's a mix. Opt for double-quote around and replace + // inner double-quotes. + attrValue = attrValue.replace("\"", QUOT_ENTITY); //$NON-NLS-1$ + return '"' + attrValue + '"'; + } + + // --- Java changes --- + + /** + * Returns a foreach compatible iterator over all ICompilationUnit in the project. + */ + private Iterable<ICompilationUnit> findAllJavaUnits() { + final IJavaProject javaProject = JavaCore.create(mProject); + + return new Iterable<ICompilationUnit>() { + @Override + public Iterator<ICompilationUnit> iterator() { + return new Iterator<ICompilationUnit>() { + final Queue<ICompilationUnit> mUnits = new LinkedList<ICompilationUnit>(); + final Queue<IPackageFragment> mFragments = new LinkedList<IPackageFragment>(); + { + try { + IPackageFragment[] tmpFrags = javaProject.getPackageFragments(); + if (tmpFrags != null && tmpFrags.length > 0) { + mFragments.addAll(Arrays.asList(tmpFrags)); + } + } catch (JavaModelException e) { + // pass + } + } + + @Override + public boolean hasNext() { + if (!mUnits.isEmpty()) { + return true; + } + + while (!mFragments.isEmpty()) { + try { + IPackageFragment fragment = mFragments.poll(); + if (fragment.getKind() == IPackageFragmentRoot.K_SOURCE) { + ICompilationUnit[] tmpUnits = fragment.getCompilationUnits(); + if (tmpUnits != null && tmpUnits.length > 0) { + mUnits.addAll(Arrays.asList(tmpUnits)); + return true; + } + } + } catch (JavaModelException e) { + // pass + } + } + return false; + } + + @Override + public ICompilationUnit next() { + ICompilationUnit unit = mUnits.poll(); + hasNext(); + return unit; + } + + @Override + public void remove() { + throw new UnsupportedOperationException( + "This iterator does not support removal"); //$NON-NLS-1$ + } + }; + } + }; + } + + /** + * Computes the changes to be made to Java file(s) and returns a list of {@link Change}. + * <p/> + * This function scans a Java compilation unit using {@link ReplaceStringsVisitor}, looking + * for a string literal equals to <code>tokenString</code>. + * If found, a change is made to replace each occurrence of <code>tokenString</code> by + * a piece of Java code that somehow accesses R.string.<code>xmlStringId</code>. + * + * @param unit The compilated unit to process. Must not be null. + * @param tokenString The string to find. Must not be null or empty. + * @param status Status used to report fatal errors. + * @param monitor Used to log progress. + */ + private List<Change> computeJavaChanges(ICompilationUnit unit, + String xmlStringId, + String tokenString, + RefactoringStatus status, + SubMonitor monitor) { + + // We shouldn't be trying to replace a null or empty string. + assert tokenString != null && tokenString.length() > 0; + if (tokenString == null || tokenString.length() == 0) { + return null; + } + + // Get the Android package name from the Android Manifest. We need it to create + // the FQCN of the R class. + String packageName = null; + String error = null; + IResource manifestFile = mProject.findMember(SdkConstants.FN_ANDROID_MANIFEST_XML); + if (manifestFile == null || manifestFile.getType() != IResource.FILE) { + error = "File not found"; + } else { + ManifestData manifestData = AndroidManifestHelper.parseForData((IFile) manifestFile); + if (manifestData == null) { + error = "Invalid content"; + } else { + packageName = manifestData.getPackage(); + if (packageName == null) { + error = "Missing package definition"; + } + } + } + + if (error != null) { + status.addFatalError( + String.format("Failed to parse file %1$s: %2$s.", + manifestFile == null ? "" : manifestFile.getFullPath(), //$NON-NLS-1$ + error)); + return null; + } + + // Right now the changes array will contain one TextFileChange at most. + ArrayList<Change> changes = new ArrayList<Change>(); + + // This is the unit that will be modified. + TextFileChange change = new TextFileChange(getName(), (IFile) unit.getResource()); + change.setTextType("java"); //$NON-NLS-1$ + + // Create an AST for this compilation unit + ASTParser parser = ASTParser.newParser(AST.JLS3); + parser.setProject(unit.getJavaProject()); + parser.setSource(unit); + parser.setResolveBindings(true); + ASTNode node = parser.createAST(monitor.newChild(1)); + + // The ASTNode must be a CompilationUnit, by design + if (!(node instanceof CompilationUnit)) { + status.addFatalError(String.format("Internal error: ASTNode class %s", //$NON-NLS-1$ + node.getClass())); + return null; + } + + // ImportRewrite will allow us to add the new type to the imports and will resolve + // what the Java source must reference, e.g. the FQCN or just the simple name. + ImportRewrite importRewrite = ImportRewrite.create((CompilationUnit) node, true); + String Rqualifier = packageName + ".R"; //$NON-NLS-1$ + Rqualifier = importRewrite.addImport(Rqualifier); + + // Rewrite the AST itself via an ASTVisitor + AST ast = node.getAST(); + ASTRewrite astRewrite = ASTRewrite.create(ast); + ArrayList<TextEditGroup> astEditGroups = new ArrayList<TextEditGroup>(); + ReplaceStringsVisitor visitor = new ReplaceStringsVisitor( + ast, astRewrite, astEditGroups, + tokenString, Rqualifier, xmlStringId); + node.accept(visitor); + + // Finally prepare the change set + try { + MultiTextEdit edit = new MultiTextEdit(); + + // Create the edit to change the imports, only if anything changed + TextEdit subEdit = importRewrite.rewriteImports(monitor.newChild(1)); + if (subEdit.hasChildren()) { + edit.addChild(subEdit); + } + + // Create the edit to change the Java source, only if anything changed + subEdit = astRewrite.rewriteAST(); + if (subEdit.hasChildren()) { + edit.addChild(subEdit); + } + + // Only create a change set if any edit was collected + if (edit.hasChildren()) { + change.setEdit(edit); + + // Create TextEditChangeGroups which let the user turn changes on or off + // individually. This must be done after the change.setEdit() call above. + for (TextEditGroup editGroup : astEditGroups) { + TextEditChangeGroup group = new TextEditChangeGroup(change, editGroup); + if (editGroup instanceof EnabledTextEditGroup) { + group.setEnabled(((EnabledTextEditGroup) editGroup).isEnabled()); + } + change.addTextEditChangeGroup(group); + } + + changes.add(change); + } + + monitor.worked(1); + + if (changes.size() > 0) { + return changes; + } + + } catch (CoreException e) { + // ImportRewrite.rewriteImports failed. + status.addFatalError(e.getMessage()); + } + return null; + } + + // ---- + + /** + * Step 3 of 3 of the refactoring: returns the {@link Change} that will be able to do the + * work and creates a descriptor that can be used to replay that refactoring later. + * + * @see org.eclipse.ltk.core.refactoring.Refactoring#createChange(org.eclipse.core.runtime.IProgressMonitor) + * + * @throws CoreException + */ + @Override + public Change createChange(IProgressMonitor monitor) + throws CoreException, OperationCanceledException { + + try { + monitor.beginTask("Applying changes...", 1); + + CompositeChange change = new CompositeChange( + getName(), + mChanges.toArray(new Change[mChanges.size()])) { + @Override + public ChangeDescriptor getDescriptor() { + + String comment = String.format( + "Extracts string '%1$s' into R.string.%2$s", + mTokenString, + mXmlStringId); + + ExtractStringDescriptor desc = new ExtractStringDescriptor( + mProject.getName(), //project + comment, //description + comment, //comment + createArgumentMap()); + + return new RefactoringChangeDescriptor(desc); + } + }; + + monitor.worked(1); + + return change; + + } finally { + monitor.done(); + } + + } + + /** + * Given a file project path, returns its resource in the same project than the + * compilation unit. The resource may not exist. + */ + private IResource getTargetXmlResource(String xmlFileWsPath) { + IResource resource = mProject.getFile(xmlFileWsPath); + return resource; + } +} |