| /* |
| * 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.refactorings.extractstring; |
| |
| import com.android.ide.eclipse.common.AndroidConstants; |
| import com.android.ide.eclipse.common.project.AndroidManifestParser; |
| |
| import org.eclipse.core.resources.IFile; |
| 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.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.ASTVisitor; |
| import org.eclipse.jdt.core.dom.CompilationUnit; |
| import org.eclipse.jdt.core.dom.Name; |
| import org.eclipse.jdt.core.dom.QualifiedName; |
| import org.eclipse.jdt.core.dom.SimpleName; |
| import org.eclipse.jdt.core.dom.StringLiteral; |
| 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 java.io.BufferedReader; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.util.ArrayList; |
| import java.util.HashMap; |
| import java.util.List; |
| import java.util.Map; |
| |
| /** |
| * 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 (TODO: or XML file) 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> TODO: to support refactoring from an XML file, the action should give the {@link IFile} |
| * and then here we would have to determine whether it's a suitable Android XML file or a |
| * suitable Java file. |
| * TODO: enumerate the exact valid contexts in Android XML files, e.g. attributes in layout |
| * files or text elements (e.g. <string>foo</string>) for values, etc. |
| * <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> TODO: Find the string in an XML file based on selection. |
| * <li> On success, the wizard is shown, which let 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#isResIdDuplicate(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 occurences 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: If the source is an XML file, determine if we need to change an attribute or a |
| * a text element. |
| * <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> |
| */ |
| 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; |
| /** The file model being manipulated. |
| * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */ |
| private final IFile mFile; |
| /** 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. */ |
| private String mTargetXmlFileWsPath; |
| |
| /** 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$ |
| |
| public ExtractStringRefactoring(Map<String, String> arguments) |
| throws NullPointerException { |
| 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); |
| } else { |
| mFile = null; |
| mSelectionStart = mSelectionEnd = -1; |
| mTokenString = null; |
| } |
| } |
| |
| private Map<String, String> createArgumentMap() { |
| HashMap<String, String> args = new HashMap<String, String>(); |
| 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); |
| } |
| 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 selection The selection in the source file. Cannot be null or empty. |
| */ |
| public ExtractStringRefactoring(IFile file, ITextSelection selection) { |
| mMode = Mode.EDIT_SOURCE; |
| mFile = file; |
| 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. |
| * |
| * @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; |
| mProject = project; |
| mSelectionStart = mSelectionEnd = -1; |
| } |
| |
| /** |
| * @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. |
| */ |
| public String getTokenString() { |
| return mTokenString; |
| } |
| |
| 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...", 5); |
| |
| if (mMode != Mode.EDIT_SOURCE) { |
| monitor.worked(5); |
| 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) { |
| // Check this an XML file and get the selection and its context. |
| // TODO |
| status.addFatalError("Selection must be inside a Java source 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. |
| */ |
| 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 of 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. |
| int len = mTokenString.length(); |
| if (len > 0 && |
| mTokenString.charAt(0) == '"' && |
| mTokenString.charAt(len - 1) == '"') { |
| mTokenString = mTokenString.substring(1, len - 1); |
| } |
| // 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(); |
| } |
| |
| /** |
| * 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...", 3); |
| |
| 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 writeable 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 for the XML file. |
| |
| if (!mXmlHelper.isResIdDuplicate(mProject, mTargetXmlFileWsPath, mXmlStringId)) { |
| // We actually change it only if the ID doesn't exist yet |
| Change change = createXmlChange((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) { |
| // Prepare the change to the Java compilation unit |
| List<Change> changes = computeJavaChanges(mUnit, mXmlStringId, mTokenString, |
| status, SubMonitor.convert(monitor, 1)); |
| if (changes != null) { |
| mChanges.addAll(changes); |
| } |
| } |
| |
| monitor.worked(1); |
| } finally { |
| monitor.done(); |
| } |
| |
| return status; |
| } |
| |
| /** |
| * 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 createXmlChange(IFile targetXml, |
| String xmlStringId, |
| String tokenString, |
| RefactoringStatus status, |
| SubMonitor subMonitor) { |
| |
| TextFileChange xmlChange = new TextFileChange(getName(), targetXml); |
| xmlChange.setTextType("xml"); //$NON-NLS-1$ |
| |
| TextEdit edit = null; |
| TextEditGroup editGroup = null; |
| |
| if (!targetXml.exists()) { |
| // The XML file does not exist. Simply create it. |
| StringBuilder content = new StringBuilder(); |
| content.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"); //$NON-NLS-1$ |
| content.append("<resources>\n"); //$NON-NLS-1$ |
| content.append(" <string name=\""). //$NON-NLS-1$ |
| append(xmlStringId). |
| append("\">"). //$NON-NLS-1$ |
| append(tokenString). |
| append("</string>\n"); //$NON-NLS-1$ |
| content.append("<resources>\n"); //$NON-NLS-1$ |
| |
| edit = new InsertEdit(0, content.toString()); |
| editGroup = new TextEditGroup("Create <string> in new XML file", edit); |
| } else { |
| // The file exist. Attempt to parse it as a valid XML document. |
| try { |
| int[] indices = new int[2]; |
| |
| // TODO case where we replace the value of an existing XML String ID |
| |
| if (findXmlOpeningTagPos(targetXml.getContents(), "resources", indices)) { //$NON-NLS-1$ |
| // Indices[1] indicates whether we found > or />. It can only be 1 or 2. |
| // Indices[0] is the position of the first character of either > or />. |
| // |
| // Note: we don't even try to adapt our formatting to the existing structure (we |
| // could by capturing whatever whitespace is after the closing bracket and |
| // applying it here before our tag, unless we were dealing with an empty |
| // resource tag.) |
| |
| int offset = indices[0]; |
| int len = indices[1]; |
| StringBuilder content = new StringBuilder(); |
| content.append(">\n"); //$NON-NLS-1$ |
| content.append(" <string name=\""). //$NON-NLS-1$ |
| append(xmlStringId). |
| append("\">"). //$NON-NLS-1$ |
| append(tokenString). |
| append("</string>"); //$NON-NLS-1$ |
| if (len == 2) { |
| content.append("\n</resources>"); //$NON-NLS-1$ |
| } |
| |
| edit = new ReplaceEdit(offset, len, content.toString()); |
| editGroup = new TextEditGroup("Insert <string> in XML file", edit); |
| } |
| } catch (CoreException e) { |
| // Failed to read file. Ignore. Will return null below. |
| } |
| } |
| |
| if (edit == null) { |
| status.addFatalError(String.format("Failed to modify file %1$s", |
| mTargetXmlFileWsPath)); |
| return null; |
| } |
| |
| xmlChange.setEdit(edit); |
| // The TextEditChangeGroup let the user toggle this change on and off later. |
| xmlChange.addTextEditChangeGroup(new TextEditChangeGroup(xmlChange, editGroup)); |
| |
| subMonitor.worked(1); |
| return xmlChange; |
| } |
| |
| /** |
| * Parse an XML input stream, looking for an opening tag. |
| * <p/> |
| * If found, returns the character offest in the buffer of the closing bracket of that |
| * tag, e.g. the position of > in "<resources>". The first character is at offset 0. |
| * <p/> |
| * The implementation here relies on a simple character-based parser. No DOM nor SAX |
| * parsing is used, due to the simplified nature of the task: we just want the first |
| * opening tag, which in our case should be the document root. We deal however with |
| * with the tag being commented out, so comments are skipped. We assume the XML doc |
| * is sane, e.g. we don't expect the tag to appear in the middle of a string. But |
| * again since in fact we want the root element, that's unlikely to happen. |
| * <p/> |
| * We need to deal with the case where the element is written as <resources/>, in |
| * which case the caller will want to replace /> by ">...</...>". To do that we return |
| * two values: the first offset of the closing tag (e.g. / or >) and the length, which |
| * can only be 1 or 2. If it's 2, the caller have to deal with /> instead of just >. |
| * |
| * @param contents An existing buffer to parse. |
| * @param tag The tag to look for. |
| * @param indices The return values: [0] is the offset of the closing bracket and [1] is |
| * the length which can be only 1 for > and 2 for /> |
| * @return True if we found the tag, in which case <code>indices</code> can be used. |
| */ |
| private boolean findXmlOpeningTagPos(InputStream contents, String tag, int[] indices) { |
| |
| BufferedReader br = new BufferedReader(new InputStreamReader(contents)); |
| StringBuilder sb = new StringBuilder(); // scratch area |
| |
| tag = "<" + tag; |
| int tagLen = tag.length(); |
| int maxLen = tagLen < 3 ? 3 : tagLen; |
| |
| try { |
| int offset = 0; |
| int i = 0; |
| char searching = '<'; // we want opening tags |
| boolean capture = false; |
| boolean inComment = false; |
| boolean inTag = false; |
| while ((i = br.read()) != -1) { |
| char c = (char) i; |
| if (c == searching) { |
| capture = true; |
| } |
| if (capture) { |
| sb.append(c); |
| int len = sb.length(); |
| if (inComment && c == '>') { |
| // is the comment being closed? |
| if (len >= 3 && sb.substring(len-3).equals("-->")) { //$NON-NLS-1$ |
| // yes, comment is closing, stop capturing |
| capture = false; |
| inComment = false; |
| sb.setLength(0); |
| } |
| } else if (inTag && c == '>') { |
| // we're capturing in our tag, waiting for the closing >, we just got it |
| // so we're totally done here. Simply detect whether it's /> or >. |
| indices[0] = offset; |
| indices[1] = 1; |
| if (sb.charAt(len - 2) == '/') { |
| indices[0]--; |
| indices[1]++; |
| } |
| return true; |
| |
| } else if (!inComment && !inTag) { |
| // not a comment and not our tag yet, so we're capturing because a |
| // tag is being opened but we don't know which one yet. |
| |
| // look for either the opening or a comment or |
| // the opening of our tag. |
| if (len == 3 && sb.equals("<--")) { //$NON-NLS-1$ |
| inComment = true; |
| } else if (len == tagLen && sb.toString().equals(tag)) { |
| inTag = true; |
| } |
| |
| // if we're not interested in this tag yet, deal with when to stop |
| // capturing: the opening tag ends with either any kind of whitespace |
| // or with a > or maybe there's a PI that starts with <? |
| if (!inComment && !inTag) { |
| if (c == '>' || c == '?' || c == ' ' || c == '\n' || c == '\r') { |
| // stop capturing |
| capture = false; |
| sb.setLength(0); |
| } |
| } |
| } |
| |
| if (capture && len > maxLen) { |
| // in any case we don't need to capture more than the size of our tag |
| // or the comment opening tag |
| sb.deleteCharAt(0); |
| } |
| } |
| offset++; |
| } |
| } catch (IOException e) { |
| // Ignore. |
| } finally { |
| try { |
| br.close(); |
| } catch (IOException e) { |
| // oh come on... |
| } |
| } |
| |
| return false; |
| } |
| |
| /** |
| * Computes the changes to be made to Java file(s) and returns a list of {@link Change}. |
| */ |
| private List<Change> computeJavaChanges(ICompilationUnit unit, |
| String xmlStringId, |
| String tokenString, |
| RefactoringStatus status, |
| SubMonitor subMonitor) { |
| |
| // 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(AndroidConstants.FN_ANDROID_MANIFEST); |
| if (manifestFile == null || manifestFile.getType() != IResource.FILE) { |
| error = "File not found"; |
| } else { |
| try { |
| AndroidManifestParser manifest = AndroidManifestParser.parseForData( |
| (IFile) manifestFile); |
| if (manifest == null) { |
| error = "Invalid content"; |
| } else { |
| packageName = manifest.getPackage(); |
| if (packageName == null) { |
| error = "Missing package definition"; |
| } |
| } |
| } catch (CoreException e) { |
| error = e.getLocalizedMessage(); |
| } |
| } |
| |
| if (error != null) { |
| status.addFatalError( |
| String.format("Failed to parse file %1$s: %2$s.", |
| manifestFile.getFullPath(), error)); |
| return null; |
| } |
| |
| // TODO in a future version we might want to collect various Java files that |
| // need to be updated in the same project and process them all together. |
| // To do that we need to use an ASTRequestor and parser.createASTs, kind of |
| // like this: |
| // |
| // ASTRequestor requestor = new ASTRequestor() { |
| // @Override |
| // public void acceptAST(ICompilationUnit sourceUnit, CompilationUnit astNode) { |
| // super.acceptAST(sourceUnit, astNode); |
| // // TODO process astNode |
| // } |
| // }; |
| // ... |
| // parser.createASTs(compilationUnits, bindingKeys, requestor, monitor) |
| // |
| // and then add multiple TextFileChange to the changes arraylist. |
| |
| // 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(subMonitor.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(subMonitor.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) { |
| change.addTextEditChangeGroup(new TextEditChangeGroup(change, editGroup)); |
| } |
| |
| changes.add(change); |
| } |
| |
| // TODO to modify another Java source, loop back to the creation of the |
| // TextFileChange and accumulate in changes. Right now only one source is |
| // modified. |
| |
| if (changes.size() > 0) { |
| return changes; |
| } |
| |
| subMonitor.worked(1); |
| |
| } catch (CoreException e) { |
| // ImportRewrite.rewriteImports failed. |
| status.addFatalError(e.getMessage()); |
| } |
| return null; |
| } |
| |
| public class ReplaceStringsVisitor extends ASTVisitor { |
| |
| private final AST mAst; |
| private final ASTRewrite mRewriter; |
| private final String mOldString; |
| private final String mRQualifier; |
| private final String mXmlId; |
| private final ArrayList<TextEditGroup> mEditGroups; |
| |
| public ReplaceStringsVisitor(AST ast, |
| ASTRewrite astRewrite, |
| ArrayList<TextEditGroup> editGroups, |
| String oldString, |
| String rQualifier, |
| String xmlId) { |
| mAst = ast; |
| mRewriter = astRewrite; |
| mEditGroups = editGroups; |
| mOldString = oldString; |
| mRQualifier = rQualifier; |
| mXmlId = xmlId; |
| } |
| |
| @Override |
| public boolean visit(StringLiteral node) { |
| if (node.getLiteralValue().equals(mOldString)) { |
| |
| Name qualifierName = mAst.newName(mRQualifier + ".string"); //$NON-NLS-1$ |
| SimpleName idName = mAst.newSimpleName(mXmlId); |
| QualifiedName newNode = mAst.newQualifiedName(qualifierName, idName); |
| |
| TextEditGroup editGroup = new TextEditGroup("Replace string by ID"); |
| mEditGroups.add(editGroup); |
| mRewriter.replace(node, newNode, editGroup); |
| } |
| return super.visit(node); |
| } |
| } |
| |
| /** |
| * 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; |
| } |
| |
| /** |
| * 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; |
| } |
| |
| } |