| /* |
| * 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; |
| } |
| } |