• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2009 The Android Open Source Project
3  *
4  * Licensed under the Eclipse Public License, Version 1.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.eclipse.org/org/documents/epl-v10.php
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
18 
19 import com.android.ide.eclipse.adt.AndroidConstants;
20 import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor;
21 import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor;
22 import com.android.ide.eclipse.adt.internal.editors.descriptors.ReferenceAttributeDescriptor;
23 import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode;
24 import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
25 import com.android.ide.eclipse.adt.internal.project.AndroidManifestHelper;
26 import com.android.ide.eclipse.adt.internal.resources.ResourceType;
27 import com.android.ide.eclipse.adt.internal.resources.manager.ResourceFolderType;
28 import com.android.sdklib.SdkConstants;
29 import com.android.sdklib.xml.ManifestData;
30 
31 import org.eclipse.core.resources.IContainer;
32 import org.eclipse.core.resources.IFile;
33 import org.eclipse.core.resources.IProject;
34 import org.eclipse.core.resources.IResource;
35 import org.eclipse.core.resources.ResourceAttributes;
36 import org.eclipse.core.resources.ResourcesPlugin;
37 import org.eclipse.core.runtime.CoreException;
38 import org.eclipse.core.runtime.IPath;
39 import org.eclipse.core.runtime.IProgressMonitor;
40 import org.eclipse.core.runtime.OperationCanceledException;
41 import org.eclipse.core.runtime.Path;
42 import org.eclipse.core.runtime.SubMonitor;
43 import org.eclipse.jdt.core.IBuffer;
44 import org.eclipse.jdt.core.ICompilationUnit;
45 import org.eclipse.jdt.core.JavaCore;
46 import org.eclipse.jdt.core.JavaModelException;
47 import org.eclipse.jdt.core.ToolFactory;
48 import org.eclipse.jdt.core.compiler.IScanner;
49 import org.eclipse.jdt.core.compiler.ITerminalSymbols;
50 import org.eclipse.jdt.core.compiler.InvalidInputException;
51 import org.eclipse.jdt.core.dom.AST;
52 import org.eclipse.jdt.core.dom.ASTNode;
53 import org.eclipse.jdt.core.dom.ASTParser;
54 import org.eclipse.jdt.core.dom.CompilationUnit;
55 import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
56 import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
57 import org.eclipse.jface.text.ITextSelection;
58 import org.eclipse.ltk.core.refactoring.Change;
59 import org.eclipse.ltk.core.refactoring.ChangeDescriptor;
60 import org.eclipse.ltk.core.refactoring.CompositeChange;
61 import org.eclipse.ltk.core.refactoring.Refactoring;
62 import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor;
63 import org.eclipse.ltk.core.refactoring.RefactoringStatus;
64 import org.eclipse.ltk.core.refactoring.TextEditChangeGroup;
65 import org.eclipse.ltk.core.refactoring.TextFileChange;
66 import org.eclipse.text.edits.InsertEdit;
67 import org.eclipse.text.edits.MultiTextEdit;
68 import org.eclipse.text.edits.ReplaceEdit;
69 import org.eclipse.text.edits.TextEdit;
70 import org.eclipse.text.edits.TextEditGroup;
71 import org.eclipse.ui.IEditorPart;
72 import org.eclipse.wst.sse.core.StructuredModelManager;
73 import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
74 import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
75 import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
76 import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion;
77 import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion;
78 import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList;
79 import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext;
80 import org.w3c.dom.Node;
81 
82 import java.io.BufferedReader;
83 import java.io.IOException;
84 import java.io.InputStream;
85 import java.io.InputStreamReader;
86 import java.util.ArrayList;
87 import java.util.HashMap;
88 import java.util.HashSet;
89 import java.util.List;
90 import java.util.Map;
91 
92 /**
93  * This refactoring extracts a string from a file and replaces it by an Android resource ID
94  * such as R.string.foo.
95  * <p/>
96  * There are a number of scenarios, which are not all supported yet. The workflow works as
97  * such:
98  * <ul>
99  * <li> User selects a string in a Java (TODO: or XML file) and invokes
100  *      the {@link ExtractStringAction}.
101  * <li> The action finds the {@link ICompilationUnit} being edited as well as the current
102  *      {@link ITextSelection}. The action creates a new instance of this refactoring as
103  *      well as an {@link ExtractStringWizard} and runs the operation.
104  * <li> Step 1 of the refactoring is to check the preliminary conditions. Right now we check
105  *      that the java source is not read-only and is in sync. We also try to find a string under
106  *      the selection. If this fails, the refactoring is aborted.
107  * <li> TODO: Find the string in an XML file based on selection.
108  * <li> On success, the wizard is shown, which let the user input the new ID to use.
109  * <li> The wizard sets the user input values into this refactoring instance, e.g. the new string
110  *      ID, the XML file to update, etc. The wizard does use the utility method
111  *      {@link XmlStringFileHelper#valueOfStringId(IProject, String, String)} to check whether
112  *      the new ID is already defined in the target XML file.
113  * <li> Once Preview or Finish is selected in the wizard, the
114  *      {@link #checkFinalConditions(IProgressMonitor)} is called to double-check the user input
115  *      and compute the actual changes.
116  * <li> When all changes are computed, {@link #createChange(IProgressMonitor)} is invoked.
117  * </ul>
118  *
119  * The list of changes are:
120  * <ul>
121  * <li> If the target XML does not exist, create it with the new string ID.
122  * <li> If the target XML exists, find the <resources> node and add the new string ID right after.
123  *      If the node is <resources/>, it needs to be opened.
124  * <li> Create an AST rewriter to edit the source Java file and replace all occurences by the
125  *      new computed R.string.foo. Also need to rewrite imports to import R as needed.
126  *      If there's already a conflicting R included, we need to insert the FQCN instead.
127  * <li> TODO: Have a pref in the wizard: [x] Change other XML Files
128  * <li> TODO: Have a pref in the wizard: [x] Change other Java Files
129  * </ul>
130  */
131 public class ExtractStringRefactoring extends Refactoring {
132 
133     public enum Mode {
134         /**
135          * the Extract String refactoring is called on an <em>existing</em> source file.
136          * Its purpose is then to get the selected string of the source and propose to
137          * change it by an XML id. The XML id may be a new one or an existing one.
138          */
139         EDIT_SOURCE,
140         /**
141          * The Extract String refactoring is called without any source file.
142          * Its purpose is then to create a new XML string ID or select/modify an existing one.
143          */
144         SELECT_ID,
145         /**
146          * The Extract String refactoring is called without any source file.
147          * Its purpose is then to create a new XML string ID. The ID must not already exist.
148          */
149         SELECT_NEW_ID
150     }
151 
152     /** The {@link Mode} of operation of the refactoring. */
153     private final Mode mMode;
154     /** Non-null when editing an Android Resource XML file: identifies the attribute name
155      * of the value being edited. When null, the source is an Android Java file. */
156     private String mXmlAttributeName;
157     /** The file model being manipulated.
158      * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
159     private final IFile mFile;
160     /** The editor. Non-null when invoked from {@link ExtractStringAction}. Null otherwise. */
161     private final IEditorPart mEditor;
162     /** The project that contains {@link #mFile} and that contains the target XML file to modify. */
163     private final IProject mProject;
164     /** The start of the selection in {@link #mFile}.
165      * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
166     private final int mSelectionStart;
167     /** The end of the selection in {@link #mFile}.
168      * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
169     private final int mSelectionEnd;
170 
171     /** The compilation unit, only defined if {@link #mFile} points to a usable Java source file. */
172     private ICompilationUnit mUnit;
173     /** The actual string selected, after UTF characters have been escaped, good for display.
174      * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
175     private String mTokenString;
176 
177     /** The XML string ID selected by the user in the wizard. */
178     private String mXmlStringId;
179     /** The XML string value. Might be different than the initial selected string. */
180     private String mXmlStringValue;
181     /** The path of the XML file that will define {@link #mXmlStringId}, selected by the user
182      *  in the wizard. */
183     private String mTargetXmlFileWsPath;
184 
185     /** The list of changes computed by {@link #checkFinalConditions(IProgressMonitor)} and
186      *  used by {@link #createChange(IProgressMonitor)}. */
187     private ArrayList<Change> mChanges;
188 
189     private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();
190 
191     private static final String KEY_MODE = "mode";              //$NON-NLS-1$
192     private static final String KEY_FILE = "file";              //$NON-NLS-1$
193     private static final String KEY_PROJECT = "proj";           //$NON-NLS-1$
194     private static final String KEY_SEL_START = "sel-start";    //$NON-NLS-1$
195     private static final String KEY_SEL_END = "sel-end";        //$NON-NLS-1$
196     private static final String KEY_TOK_ESC = "tok-esc";        //$NON-NLS-1$
197     private static final String KEY_XML_ATTR_NAME = "xml-attr-name";      //$NON-NLS-1$
198 
ExtractStringRefactoring(Map<String, String> arguments)199     public ExtractStringRefactoring(Map<String, String> arguments) throws NullPointerException {
200         mMode = Mode.valueOf(arguments.get(KEY_MODE));
201 
202         IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT));
203         mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
204 
205         if (mMode == Mode.EDIT_SOURCE) {
206             path = Path.fromPortableString(arguments.get(KEY_FILE));
207             mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
208 
209             mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START));
210             mSelectionEnd   = Integer.parseInt(arguments.get(KEY_SEL_END));
211             mTokenString    = arguments.get(KEY_TOK_ESC);
212             mXmlAttributeName = arguments.get(KEY_XML_ATTR_NAME);
213         } else {
214             mFile = null;
215             mSelectionStart = mSelectionEnd = -1;
216             mTokenString = null;
217             mXmlAttributeName = null;
218         }
219 
220         mEditor = null;
221     }
222 
createArgumentMap()223     private Map<String, String> createArgumentMap() {
224         HashMap<String, String> args = new HashMap<String, String>();
225         args.put(KEY_MODE,      mMode.name());
226         args.put(KEY_PROJECT,   mProject.getFullPath().toPortableString());
227         if (mMode == Mode.EDIT_SOURCE) {
228             args.put(KEY_FILE,      mFile.getFullPath().toPortableString());
229             args.put(KEY_SEL_START, Integer.toString(mSelectionStart));
230             args.put(KEY_SEL_END,   Integer.toString(mSelectionEnd));
231             args.put(KEY_TOK_ESC,   mTokenString);
232             args.put(KEY_XML_ATTR_NAME, mXmlAttributeName);
233         }
234         return args;
235     }
236 
237     /**
238      * Constructor to use when the Extract String refactoring is called on an
239      * *existing* source file. Its purpose is then to get the selected string of
240      * the source and propose to change it by an XML id. The XML id may be a new one
241      * or an existing one.
242      *
243      * @param file The source file to process. Cannot be null. File must exist in workspace.
244      * @param editor
245      * @param selection The selection in the source file. Cannot be null or empty.
246      */
ExtractStringRefactoring(IFile file, IEditorPart editor, ITextSelection selection)247     public ExtractStringRefactoring(IFile file, IEditorPart editor, ITextSelection selection) {
248         mMode = Mode.EDIT_SOURCE;
249         mFile = file;
250         mEditor = editor;
251         mProject = file.getProject();
252         mSelectionStart = selection.getOffset();
253         mSelectionEnd = mSelectionStart + Math.max(0, selection.getLength() - 1);
254     }
255 
256     /**
257      * Constructor to use when the Extract String refactoring is called without
258      * any source file. Its purpose is then to create a new XML string ID.
259      *
260      * @param project The project where the target XML file to modify is located. Cannot be null.
261      * @param enforceNew If true the XML ID must be a new one. If false, an existing ID can be
262      *  used.
263      */
ExtractStringRefactoring(IProject project, boolean enforceNew)264     public ExtractStringRefactoring(IProject project, boolean enforceNew) {
265         mMode = enforceNew ? Mode.SELECT_NEW_ID : Mode.SELECT_ID;
266         mFile = null;
267         mEditor = null;
268         mProject = project;
269         mSelectionStart = mSelectionEnd = -1;
270     }
271 
272     /**
273      * @see org.eclipse.ltk.core.refactoring.Refactoring#getName()
274      */
275     @Override
getName()276     public String getName() {
277         if (mMode == Mode.SELECT_ID) {
278             return "Create or USe Android String";
279         } else if (mMode == Mode.SELECT_NEW_ID) {
280             return "Create New Android String";
281         }
282 
283         return "Extract Android String";
284     }
285 
getMode()286     public Mode getMode() {
287         return mMode;
288     }
289 
290     /**
291      * Gets the actual string selected, after UTF characters have been escaped,
292      * good for display.
293      */
getTokenString()294     public String getTokenString() {
295         return mTokenString;
296     }
297 
getXmlStringId()298     public String getXmlStringId() {
299         return mXmlStringId;
300     }
301 
302     /**
303      * Step 1 of 3 of the refactoring:
304      * Checks that the current selection meets the initial condition before the ExtractString
305      * wizard is shown. The check is supposed to be lightweight and quick. Note that at that
306      * point the wizard has not been created yet.
307      * <p/>
308      * Here we scan the source buffer to find the token matching the selection.
309      * The check is successful is a Java string literal is selected, the source is in sync
310      * and is not read-only.
311      * <p/>
312      * This is also used to extract the string to be modified, so that we can display it in
313      * the refactoring wizard.
314      *
315      * @see org.eclipse.ltk.core.refactoring.Refactoring#checkInitialConditions(org.eclipse.core.runtime.IProgressMonitor)
316      *
317      * @throws CoreException
318      */
319     @Override
checkInitialConditions(IProgressMonitor monitor)320     public RefactoringStatus checkInitialConditions(IProgressMonitor monitor)
321             throws CoreException, OperationCanceledException {
322 
323         mUnit = null;
324         mTokenString = null;
325 
326         RefactoringStatus status = new RefactoringStatus();
327 
328         try {
329             monitor.beginTask("Checking preconditions...", 6);
330 
331             if (mMode != Mode.EDIT_SOURCE) {
332                 monitor.worked(6);
333                 return status;
334             }
335 
336             if (!checkSourceFile(mFile, status, monitor)) {
337                 return status;
338             }
339 
340             // Try to get a compilation unit from this file. If it fails, mUnit is null.
341             try {
342                 mUnit = JavaCore.createCompilationUnitFrom(mFile);
343 
344                 // Make sure the unit is not read-only, e.g. it's not a class file or inside a Jar
345                 if (mUnit.isReadOnly()) {
346                     status.addFatalError("The file is read-only, please make it writeable first.");
347                     return status;
348                 }
349 
350                 // This is a Java file. Check if it contains the selection we want.
351                 if (!findSelectionInJavaUnit(mUnit, status, monitor)) {
352                     return status;
353                 }
354 
355             } catch (Exception e) {
356                 // That was not a Java file. Ignore.
357             }
358 
359             if (mUnit != null) {
360                 monitor.worked(1);
361                 return status;
362             }
363 
364             // Check this a Layout XML file and get the selection and its context.
365             if (mFile != null && AndroidConstants.EXT_XML.equals(mFile.getFileExtension())) {
366 
367                 // Currently we only support Android resource XML files, so they must have a path
368                 // similar to
369                 //    project/res/<type>[-<configuration>]/*.xml
370                 // There is no support for sub folders, so the segment count must be 4.
371                 // We don't need to check the type folder name because a/ we only accept
372                 // an AndroidXmlEditor source and b/ aapt generates a compilation error for
373                 // unknown folders.
374                 IPath path = mFile.getFullPath();
375                 // check if we are inside the project/res/* folder.
376                 if (path.segmentCount() == 4) {
377                     if (path.segment(1).equalsIgnoreCase(SdkConstants.FD_RESOURCES)) {
378                         if (!findSelectionInXmlFile(mFile, status, monitor)) {
379                             return status;
380                         }
381                     }
382                 }
383             }
384 
385             if (!status.isOK()) {
386                 status.addFatalError(
387                         "Selection must be inside a Java source or an Android Layout XML file.");
388             }
389 
390         } finally {
391             monitor.done();
392         }
393 
394         return status;
395     }
396 
397     /**
398      * Try to find the selected Java element in the compilation unit.
399      *
400      * If selection matches a string literal, capture it, otherwise add a fatal error
401      * to the status.
402      *
403      * On success, advance the monitor by 3.
404      * Returns status.isOK().
405      */
findSelectionInJavaUnit(ICompilationUnit unit, RefactoringStatus status, IProgressMonitor monitor)406     private boolean findSelectionInJavaUnit(ICompilationUnit unit,
407             RefactoringStatus status, IProgressMonitor monitor) {
408         try {
409             IBuffer buffer = unit.getBuffer();
410 
411             IScanner scanner = ToolFactory.createScanner(
412                     false, //tokenizeComments
413                     false, //tokenizeWhiteSpace
414                     false, //assertMode
415                     false  //recordLineSeparator
416                     );
417             scanner.setSource(buffer.getCharacters());
418             monitor.worked(1);
419 
420             for(int token = scanner.getNextToken();
421                     token != ITerminalSymbols.TokenNameEOF;
422                     token = scanner.getNextToken()) {
423                 if (scanner.getCurrentTokenStartPosition() <= mSelectionStart &&
424                         scanner.getCurrentTokenEndPosition() >= mSelectionEnd) {
425                     // found the token, but only keep if the right type
426                     if (token == ITerminalSymbols.TokenNameStringLiteral) {
427                         mTokenString = new String(scanner.getCurrentTokenSource());
428                     }
429                     break;
430                 } else if (scanner.getCurrentTokenStartPosition() > mSelectionEnd) {
431                     // scanner is past the selection, abort.
432                     break;
433                 }
434             }
435         } catch (JavaModelException e1) {
436             // Error in unit.getBuffer. Ignore.
437         } catch (InvalidInputException e2) {
438             // Error in scanner.getNextToken. Ignore.
439         } finally {
440             monitor.worked(1);
441         }
442 
443         if (mTokenString != null) {
444             // As a literal string, the token should have surrounding quotes. Remove them.
445             int len = mTokenString.length();
446             if (len > 0 &&
447                     mTokenString.charAt(0) == '"' &&
448                     mTokenString.charAt(len - 1) == '"') {
449                 mTokenString = mTokenString.substring(1, len - 1);
450             }
451             // We need a non-empty string literal
452             if (mTokenString.length() == 0) {
453                 mTokenString = null;
454             }
455         }
456 
457         if (mTokenString == null) {
458             status.addFatalError("Please select a Java string literal.");
459         }
460 
461         monitor.worked(1);
462         return status.isOK();
463     }
464     /**
465      * Try to find the selected XML element. This implementation replies on the refactoring
466      * originating from an Android Layout Editor. We rely on some internal properties of the
467      * Structured XML editor to retrieve file content to avoid parsing it again. We also rely
468      * on our specific Android XML model to get element & attribute descriptor properties.
469      *
470      * If selection matches a string literal, capture it, otherwise add a fatal error
471      * to the status.
472      *
473      * On success, advance the monitor by 1.
474      * Returns status.isOK().
475      */
findSelectionInXmlFile(IFile file, RefactoringStatus status, IProgressMonitor monitor)476     private boolean findSelectionInXmlFile(IFile file,
477             RefactoringStatus status,
478             IProgressMonitor monitor) {
479 
480         try {
481             if (!(mEditor instanceof AndroidXmlEditor)) {
482                 status.addFatalError("Only the Android XML Editor is currently supported.");
483                 return status.isOK();
484             }
485 
486             AndroidXmlEditor editor = (AndroidXmlEditor) mEditor;
487             IStructuredModel smodel = null;
488             Node node = null;
489             String currAttrName = null;
490 
491             try {
492                 // See the portability note in AndroidXmlEditor#getModelForRead() javadoc.
493                 smodel = editor.getModelForRead();
494                 if (smodel != null) {
495                     // The structured model gives the us the actual XML Node element where the
496                     // offset is. By using this Node, we can find the exact UiElementNode of our
497                     // model and thus we'll be able to get the properties of the attribute -- to
498                     // check if it accepts a string reference. This does not however tell us if
499                     // the selection is actually in an attribute value, nor which attribute is
500                     // being edited.
501                     for(int offset = mSelectionStart; offset >= 0 && node == null; --offset) {
502                         node = (Node) smodel.getIndexedRegion(offset);
503                     }
504 
505                     if (node == null) {
506                         status.addFatalError(
507                                 "The selection does not match any element in the XML document.");
508                         return status.isOK();
509                     }
510 
511                     if (node.getNodeType() != Node.ELEMENT_NODE) {
512                         status.addFatalError("The selection is not inside an actual XML element.");
513                         return status.isOK();
514                     }
515 
516                     IStructuredDocument sdoc = smodel.getStructuredDocument();
517                     if (sdoc != null) {
518                         // Portability note: all the structured document implementation is
519                         // under wst.sse.core.internal.provisional so we can expect it to change in
520                         // a distant future if they start cleaning their codebase, however unlikely
521                         // that is.
522 
523                         int selStart = mSelectionStart;
524                         IStructuredDocumentRegion region =
525                             sdoc.getRegionAtCharacterOffset(selStart);
526                         if (region != null &&
527                                 DOMRegionContext.XML_TAG_NAME.equals(region.getType())) {
528                             // Find if any sub-region representing an attribute contains the
529                             // selection. If it does, returns the name of the attribute in
530                             // currAttrName and returns the value in the field mTokenString.
531                             currAttrName = findSelectionInRegion(region, selStart);
532 
533                             if (mTokenString == null) {
534                                 status.addFatalError(
535                                     "The selection is not inside an actual XML attribute value.");
536                             }
537                         }
538                     }
539 
540                     if (mTokenString != null && node != null && currAttrName != null) {
541 
542                         // Validate that the attribute accepts a string reference.
543                         // This sets mTokenString to null by side-effect when it fails and
544                         // adds a fatal error to the status as needed.
545                         validateSelectedAttribute(editor, node, currAttrName, status);
546 
547                     } else {
548                         // We shouldn't get here: we're missing one of the token string, the node
549                         // or the attribute name. All of them have been checked earlier so don't
550                         // set any specific error.
551                         mTokenString = null;
552                     }
553                 }
554             } finally {
555                 if (smodel != null) {
556                     smodel.releaseFromRead();
557                 }
558             }
559 
560         } finally {
561             monitor.worked(1);
562         }
563 
564         return status.isOK();
565     }
566 
567     /**
568      * The region gives us the textual representation of the XML element
569      * where the selection starts, split using sub-regions. We now just
570      * need to iterate through the sub-regions to find which one
571      * contains the actual selection. We're interested in an attribute
572      * value however when we find one we want to memorize the attribute
573      * name that was defined just before.
574      *
575      * @return When the cursor is on a valid attribute name or value, returns the string of
576      * attribute name. As a side-effect, returns the value of the attribute in {@link #mTokenString}
577      */
findSelectionInRegion(IStructuredDocumentRegion region, int selStart)578     private String findSelectionInRegion(IStructuredDocumentRegion region, int selStart) {
579 
580         String currAttrName = null;
581 
582         int startInRegion = selStart - region.getStartOffset();
583 
584         int nb = region.getNumberOfRegions();
585         ITextRegionList list = region.getRegions();
586         String currAttrValue = null;
587 
588         for (int i = 0; i < nb; i++) {
589             ITextRegion subRegion = list.get(i);
590             String type = subRegion.getType();
591 
592             if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
593                 currAttrName = region.getText(subRegion);
594 
595                 // I like to select the attribute definition and invoke
596                 // the extract string wizard. So if the selection is on
597                 // the attribute name part, find the value that is just
598                 // after and use it as if it were the selection.
599 
600                 if (subRegion.getStart() <= startInRegion &&
601                         startInRegion < subRegion.getTextEnd()) {
602                     // A well-formed attribute is composed of a name,
603                     // an equal sign and the value. There can't be any space
604                     // in between, which makes the parsing a lot easier.
605                     if (i <= nb - 3 &&
606                             DOMRegionContext.XML_TAG_ATTRIBUTE_EQUALS.equals(
607                                                    list.get(i + 1).getType())) {
608                         subRegion = list.get(i + 2);
609                         type = subRegion.getType();
610                         if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(
611                                 type)) {
612                             currAttrValue = region.getText(subRegion);
613                         }
614                     }
615                 }
616 
617             } else if (subRegion.getStart() <= startInRegion &&
618                     startInRegion < subRegion.getTextEnd() &&
619                     DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
620                 currAttrValue = region.getText(subRegion);
621             }
622 
623             if (currAttrValue != null) {
624                 // We found the value. Only accept it if not empty
625                 // and if we found an attribute name before.
626                 String text = currAttrValue;
627 
628                 // The attribute value will contain the XML quotes. Remove them.
629                 int len = text.length();
630                 if (len >= 2 &&
631                         text.charAt(0) == '"' &&
632                         text.charAt(len - 1) == '"') {
633                     text = text.substring(1, len - 1);
634                 } else if (len >= 2 &&
635                         text.charAt(0) == '\'' &&
636                         text.charAt(len - 1) == '\'') {
637                     text = text.substring(1, len - 1);
638                 }
639                 if (text.length() > 0 && currAttrName != null) {
640                     // Setting mTokenString to non-null marks the fact we
641                     // accept this attribute.
642                     mTokenString = text;
643                 }
644 
645                 break;
646             }
647         }
648 
649         return currAttrName;
650     }
651 
652     /**
653      * Validates that the attribute accepts a string reference.
654      * This sets mTokenString to null by side-effect when it fails and
655      * adds a fatal error to the status as needed.
656      */
validateSelectedAttribute(AndroidXmlEditor editor, Node node, String attrName, RefactoringStatus status)657     private void validateSelectedAttribute(AndroidXmlEditor editor, Node node,
658             String attrName, RefactoringStatus status) {
659         UiElementNode rootUiNode = editor.getUiRootNode();
660         UiElementNode currentUiNode =
661             rootUiNode == null ? null : rootUiNode.findXmlNode(node);
662         ReferenceAttributeDescriptor attrDesc = null;
663 
664         if (currentUiNode != null) {
665             // remove any namespace prefix from the attribute name
666             String name = attrName;
667             int pos = name.indexOf(':');
668             if (pos > 0 && pos < name.length() - 1) {
669                 name = name.substring(pos + 1);
670             }
671 
672             for (UiAttributeNode attrNode : currentUiNode.getUiAttributes()) {
673                 if (attrNode.getDescriptor().getXmlLocalName().equals(name)) {
674                     AttributeDescriptor desc = attrNode.getDescriptor();
675                     if (desc instanceof ReferenceAttributeDescriptor) {
676                         attrDesc = (ReferenceAttributeDescriptor) desc;
677                     }
678                     break;
679                 }
680             }
681         }
682 
683         // The attribute descriptor is a resource reference. It must either accept
684         // of any resource type or specifically accept string types.
685         if (attrDesc != null &&
686                 (attrDesc.getResourceType() == null ||
687                  attrDesc.getResourceType() == ResourceType.STRING)) {
688             // We have one more check to do: is the current string value already
689             // an Android XML string reference? If so, we can't edit it.
690             if (mTokenString.startsWith("@")) {                             //$NON-NLS-1$
691                 int pos1 = 0;
692                 if (mTokenString.length() > 1 && mTokenString.charAt(1) == '+') {
693                     pos1++;
694                 }
695                 int pos2 = mTokenString.indexOf('/');
696                 if (pos2 > pos1) {
697                     String kind = mTokenString.substring(pos1 + 1, pos2);
698                     if (ResourceType.STRING.getName().equals(kind)) {                            //$NON-NLS-1$
699                         mTokenString = null;
700                         status.addFatalError(String.format(
701                                 "The attribute %1$s already contains a %2$s reference.",
702                                 attrName,
703                                 kind));
704                     }
705                 }
706             }
707 
708             if (mTokenString != null) {
709                 // We're done with all our checks. mTokenString contains the
710                 // current attribute value. We don't memorize the region nor the
711                 // attribute, however we memorize the textual attribute name so
712                 // that we can offer replacement for all its occurrences.
713                 mXmlAttributeName = attrName;
714             }
715 
716         } else {
717             mTokenString = null;
718             status.addFatalError(String.format(
719                     "The attribute %1$s does not accept a string reference.",
720                     attrName));
721         }
722     }
723 
724     /**
725      * Tests from org.eclipse.jdt.internal.corext.refactoringChecks#validateEdit()
726      * Might not be useful.
727      *
728      * On success, advance the monitor by 2.
729      *
730      * @return False if caller should abort, true if caller should continue.
731      */
checkSourceFile(IFile file, RefactoringStatus status, IProgressMonitor monitor)732     private boolean checkSourceFile(IFile file,
733             RefactoringStatus status,
734             IProgressMonitor monitor) {
735         // check whether the source file is in sync
736         if (!file.isSynchronized(IResource.DEPTH_ZERO)) {
737             status.addFatalError("The file is not synchronized. Please save it first.");
738             return false;
739         }
740         monitor.worked(1);
741 
742         // make sure we can write to it.
743         ResourceAttributes resAttr = file.getResourceAttributes();
744         if (resAttr == null || resAttr.isReadOnly()) {
745             status.addFatalError("The file is read-only, please make it writeable first.");
746             return false;
747         }
748         monitor.worked(1);
749 
750         return true;
751     }
752 
753     /**
754      * Step 2 of 3 of the refactoring:
755      * Check the conditions once the user filled values in the refactoring wizard,
756      * then prepare the changes to be applied.
757      * <p/>
758      * In this case, most of the sanity checks are done by the wizard so essentially this
759      * should only be called if the wizard positively validated the user input.
760      *
761      * Here we do check that the target resource XML file either does not exists or
762      * is not read-only.
763      *
764      * @see org.eclipse.ltk.core.refactoring.Refactoring#checkFinalConditions(IProgressMonitor)
765      *
766      * @throws CoreException
767      */
768     @Override
checkFinalConditions(IProgressMonitor monitor)769     public RefactoringStatus checkFinalConditions(IProgressMonitor monitor)
770             throws CoreException, OperationCanceledException {
771         RefactoringStatus status = new RefactoringStatus();
772 
773         try {
774             monitor.beginTask("Checking post-conditions...", 3);
775 
776             if (mXmlStringId == null || mXmlStringId.length() <= 0) {
777                 // this is not supposed to happen
778                 status.addFatalError("Missing replacement string ID");
779             } else if (mTargetXmlFileWsPath == null || mTargetXmlFileWsPath.length() <= 0) {
780                 // this is not supposed to happen
781                 status.addFatalError("Missing target xml file path");
782             }
783             monitor.worked(1);
784 
785             // Either that resource must not exist or it must be a writeable file.
786             IResource targetXml = getTargetXmlResource(mTargetXmlFileWsPath);
787             if (targetXml != null) {
788                 if (targetXml.getType() != IResource.FILE) {
789                     status.addFatalError(
790                             String.format("XML file '%1$s' is not a file.", mTargetXmlFileWsPath));
791                 } else {
792                     ResourceAttributes attr = targetXml.getResourceAttributes();
793                     if (attr != null && attr.isReadOnly()) {
794                         status.addFatalError(
795                                 String.format("XML file '%1$s' is read-only.",
796                                         mTargetXmlFileWsPath));
797                     }
798                 }
799             }
800             monitor.worked(1);
801 
802             if (status.hasError()) {
803                 return status;
804             }
805 
806             mChanges = new ArrayList<Change>();
807 
808 
809             // Prepare the change for the XML file.
810 
811             if (mXmlHelper.valueOfStringId(mProject, mTargetXmlFileWsPath, mXmlStringId) == null) {
812                 // We actually change it only if the ID doesn't exist yet
813                 Change change = createXmlChange((IFile) targetXml, mXmlStringId, mXmlStringValue,
814                         status, SubMonitor.convert(monitor, 1));
815                 if (change != null) {
816                     mChanges.add(change);
817                 }
818             }
819 
820             if (status.hasError()) {
821                 return status;
822             }
823 
824             if (mMode == Mode.EDIT_SOURCE) {
825                 List<Change> changes = null;
826                 if (mXmlAttributeName != null) {
827                     // Prepare the change to the Android resource XML file
828                     changes = computeXmlSourceChanges(mFile,
829                             mXmlStringId, mTokenString, mXmlAttributeName,
830                             status, monitor);
831 
832                 } else {
833                     // Prepare the change to the Java compilation unit
834                     changes = computeJavaChanges(mUnit, mXmlStringId, mTokenString,
835                             status, SubMonitor.convert(monitor, 1));
836                 }
837                 if (changes != null) {
838                     mChanges.addAll(changes);
839                 }
840             }
841 
842             monitor.worked(1);
843         } finally {
844             monitor.done();
845         }
846 
847         return status;
848     }
849 
850     /**
851      * Internal helper that actually prepares the {@link Change} that adds the given
852      * ID to the given XML File.
853      * <p/>
854      * This does not actually modify the file.
855      *
856      * @param targetXml The file resource to modify.
857      * @param xmlStringId The new ID to insert.
858      * @param tokenString The old string, which will be the value in the XML string.
859      * @return A new {@link TextEdit} that describes how to change the file.
860      */
createXmlChange(IFile targetXml, String xmlStringId, String tokenString, RefactoringStatus status, SubMonitor subMonitor)861     private Change createXmlChange(IFile targetXml,
862             String xmlStringId,
863             String tokenString,
864             RefactoringStatus status,
865             SubMonitor subMonitor) {
866 
867         TextFileChange xmlChange = new TextFileChange(getName(), targetXml);
868         xmlChange.setTextType("xml");   //$NON-NLS-1$
869 
870         TextEdit edit = null;
871         TextEditGroup editGroup = null;
872 
873         if (!targetXml.exists()) {
874             // The XML file does not exist. Simply create it.
875             StringBuilder content = new StringBuilder();
876             content.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n"); //$NON-NLS-1$
877             content.append("<resources>\n");                                //$NON-NLS-1$
878             content.append("    <string name=\"").                          //$NON-NLS-1$
879                         append(xmlStringId).
880                         append("\">").                                      //$NON-NLS-1$
881                         append(tokenString).
882                         append("</string>\n");                              //$NON-NLS-1$
883             content.append("</resources>\n");                                //$NON-NLS-1$
884 
885             edit = new InsertEdit(0, content.toString());
886             editGroup = new TextEditGroup("Create <string> in new XML file", edit);
887         } else {
888             // The file exist. Attempt to parse it as a valid XML document.
889             try {
890                 int[] indices = new int[2];
891 
892                 // TODO case where we replace the value of an existing XML String ID
893 
894                 if (findXmlOpeningTagPos(targetXml.getContents(), "resources", indices)) {  //$NON-NLS-1$
895                     // Indices[1] indicates whether we found > or />. It can only be 1 or 2.
896                     // Indices[0] is the position of the first character of either > or />.
897                     //
898                     // Note: we don't even try to adapt our formatting to the existing structure (we
899                     // could by capturing whatever whitespace is after the closing bracket and
900                     // applying it here before our tag, unless we were dealing with an empty
901                     // resource tag.)
902 
903                     int offset = indices[0];
904                     int len = indices[1];
905                     StringBuilder content = new StringBuilder();
906                     content.append(">\n");                                      //$NON-NLS-1$
907                     content.append("    <string name=\"").                      //$NON-NLS-1$
908                                 append(xmlStringId).
909                                 append("\">").                                  //$NON-NLS-1$
910                                 append(tokenString).
911                                 append("</string>");                            //$NON-NLS-1$
912                     if (len == 2) {
913                         content.append("\n</resources>");                       //$NON-NLS-1$
914                     }
915 
916                     edit = new ReplaceEdit(offset, len, content.toString());
917                     editGroup = new TextEditGroup("Insert <string> in XML file", edit);
918                 }
919             } catch (CoreException e) {
920                 // Failed to read file. Ignore. Will return null below.
921             }
922         }
923 
924         if (edit == null) {
925             status.addFatalError(String.format("Failed to modify file %1$s",
926                     mTargetXmlFileWsPath));
927             return null;
928         }
929 
930         xmlChange.setEdit(edit);
931         // The TextEditChangeGroup let the user toggle this change on and off later.
932         xmlChange.addTextEditChangeGroup(new TextEditChangeGroup(xmlChange, editGroup));
933 
934         subMonitor.worked(1);
935         return xmlChange;
936     }
937 
938     /**
939      * Parse an XML input stream, looking for an opening tag.
940      * <p/>
941      * If found, returns the character offest in the buffer of the closing bracket of that
942      * tag, e.g. the position of > in "<resources>". The first character is at offset 0.
943      * <p/>
944      * The implementation here relies on a simple character-based parser. No DOM nor SAX
945      * parsing is used, due to the simplified nature of the task: we just want the first
946      * opening tag, which in our case should be the document root. We deal however with
947      * with the tag being commented out, so comments are skipped. We assume the XML doc
948      * is sane, e.g. we don't expect the tag to appear in the middle of a string. But
949      * again since in fact we want the root element, that's unlikely to happen.
950      * <p/>
951      * We need to deal with the case where the element is written as <resources/>, in
952      * which case the caller will want to replace /> by ">...</...>". To do that we return
953      * two values: the first offset of the closing tag (e.g. / or >) and the length, which
954      * can only be 1 or 2. If it's 2, the caller has to deal with /> instead of just >.
955      *
956      * @param contents An existing buffer to parse.
957      * @param tag The tag to look for.
958      * @param indices The return values: [0] is the offset of the closing bracket and [1] is
959      *          the length which can be only 1 for > and 2 for />
960      * @return True if we found the tag, in which case <code>indices</code> can be used.
961      */
findXmlOpeningTagPos(InputStream contents, String tag, int[] indices)962     private boolean findXmlOpeningTagPos(InputStream contents, String tag, int[] indices) {
963 
964         BufferedReader br = new BufferedReader(new InputStreamReader(contents));
965         StringBuilder sb = new StringBuilder(); // scratch area
966 
967         tag = "<" + tag;
968         int tagLen = tag.length();
969         int maxLen = tagLen < 3 ? 3 : tagLen;
970 
971         try {
972             int offset = 0;
973             int i = 0;
974             char searching = '<'; // we want opening tags
975             boolean capture = false;
976             boolean inComment = false;
977             boolean inTag = false;
978             while ((i = br.read()) != -1) {
979                 char c = (char) i;
980                 if (c == searching) {
981                     capture = true;
982                 }
983                 if (capture) {
984                     sb.append(c);
985                     int len = sb.length();
986                     if (inComment && c == '>') {
987                         // is the comment being closed?
988                         if (len >= 3 && sb.substring(len-3).equals("-->")) {    //$NON-NLS-1$
989                             // yes, comment is closing, stop capturing
990                             capture = false;
991                             inComment = false;
992                             sb.setLength(0);
993                         }
994                     } else if (inTag && c == '>') {
995                         // we're capturing in our tag, waiting for the closing >, we just got it
996                         // so we're totally done here. Simply detect whether it's /> or >.
997                         indices[0] = offset;
998                         indices[1] = 1;
999                         if (sb.charAt(len - 2) == '/') {
1000                             indices[0]--;
1001                             indices[1]++;
1002                         }
1003                         return true;
1004 
1005                     } else if (!inComment && !inTag) {
1006                         // not a comment and not our tag yet, so we're capturing because a
1007                         // tag is being opened but we don't know which one yet.
1008 
1009                         // look for either the opening or a comment or
1010                         // the opening of our tag.
1011                         if (len == 3 && sb.equals("<--")) {                     //$NON-NLS-1$
1012                             inComment = true;
1013                         } else if (len == tagLen && sb.toString().equals(tag)) {
1014                             inTag = true;
1015                         }
1016 
1017                         // if we're not interested in this tag yet, deal with when to stop
1018                         // capturing: the opening tag ends with either any kind of whitespace
1019                         // or with a > or maybe there's a PI that starts with <?
1020                         if (!inComment && !inTag) {
1021                             if (c == '>' || c == '?' || c == ' ' || c == '\n' || c == '\r') {
1022                                 // stop capturing
1023                                 capture = false;
1024                                 sb.setLength(0);
1025                             }
1026                         }
1027                     }
1028 
1029                     if (capture && len > maxLen) {
1030                         // in any case we don't need to capture more than the size of our tag
1031                         // or the comment opening tag
1032                         sb.deleteCharAt(0);
1033                     }
1034                 }
1035                 offset++;
1036             }
1037         } catch (IOException e) {
1038             // Ignore.
1039         } finally {
1040             try {
1041                 br.close();
1042             } catch (IOException e) {
1043                 // oh come on...
1044             }
1045         }
1046 
1047         return false;
1048     }
1049 
1050 
1051     /**
1052      * Computes the changes to be made to the source Android XML file(s) and
1053      * returns a list of {@link Change}.
1054      */
1055     private List<Change> computeXmlSourceChanges(IFile sourceFile,
1056             String xmlStringId,
1057             String tokenString,
1058             String xmlAttrName,
1059             RefactoringStatus status,
1060             IProgressMonitor monitor) {
1061 
1062         if (!sourceFile.exists()) {
1063             status.addFatalError(String.format("XML file '%1$s' does not exist.",
1064                     sourceFile.getFullPath().toOSString()));
1065             return null;
1066         }
1067 
1068         // In the initial condition check we validated that this file is part of
1069         // an Android resource folder, with a folder path that looks like
1070         //   /project/res/<type>-<configuration>/<filename.xml>
1071         // Here we are going to offer XML source change for the same filename accross all
1072         // configurations of the same res type. E.g. if we're processing a res/layout/main.xml
1073         // file then we want to offer changes for res/layout-fr/main.xml. We compute such a
1074         // list here.
1075         HashSet<IFile> files = new HashSet<IFile>();
1076         files.add(sourceFile);
1077 
1078         if (AndroidConstants.EXT_XML.equals(sourceFile.getFileExtension())) {
1079             IPath path = sourceFile.getFullPath();
1080             if (path.segmentCount() == 4 && path.segment(1).equals(SdkConstants.FD_RESOURCES)) {
1081                 IProject project = sourceFile.getProject();
1082                 String filename = path.segment(3);
1083                 String initialTypeName = path.segment(2);
1084                 ResourceFolderType type = ResourceFolderType.getFolderType(initialTypeName);
1085 
1086                 IContainer res = sourceFile.getParent().getParent();
1087                 if (type != null && res != null && res.getType() == IResource.FOLDER) {
1088                     try {
1089                         for (IResource r : res.members()) {
1090                             if (r != null && r.getType() == IResource.FOLDER) {
1091                                 String name = r.getName();
1092                                 // Skip the initial folder name, it's already in the list.
1093                                 if (!name.equals(initialTypeName)) {
1094                                     // Only accept the same folder type (e.g. layout-*)
1095                                     ResourceFolderType t =
1096                                         ResourceFolderType.getFolderType(name);
1097                                     if (type.equals(t)) {
1098                                         // recompute the path
1099                                         IPath p = res.getProjectRelativePath().append(name).
1100                                                                                append(filename);
1101                                         IResource f = project.findMember(p);
1102                                         if (f != null && f instanceof IFile) {
1103                                             files.add((IFile) f);
1104                                         }
1105                                     }
1106                                 }
1107                             }
1108                         }
1109                     } catch (CoreException e) {
1110                         // Ignore.
1111                     }
1112                 }
1113             }
1114         }
1115 
1116         SubMonitor subMonitor = SubMonitor.convert(monitor, Math.min(1, files.size()));
1117 
1118         ArrayList<Change> changes = new ArrayList<Change>();
1119 
1120         try {
1121             // Portability note: getModelManager is part of wst.sse.core however the
1122             // interface returned is part of wst.sse.core.internal.provisional so we can
1123             // expect it to change in a distant future if they start cleaning their codebase,
1124             // however unlikely that is.
1125             IModelManager modelManager = StructuredModelManager.getModelManager();
1126 
1127             for (IFile file : files) {
1128 
1129                 IStructuredDocument sdoc = modelManager.createStructuredDocumentFor(file);
1130 
1131                 if (sdoc == null) {
1132                     status.addFatalError("XML structured document not found");     //$NON-NLS-1$
1133                     return null;
1134                 }
1135 
1136                 TextFileChange xmlChange = new TextFileChange(getName(), file);
1137                 xmlChange.setTextType("xml");   //$NON-NLS-1$
1138 
1139                 MultiTextEdit multiEdit = new MultiTextEdit();
1140                 ArrayList<TextEditGroup> editGroups = new ArrayList<TextEditGroup>();
1141 
1142                 String quotedReplacement = quotedAttrValue("@string/" + xmlStringId);
1143 
1144                 // Prepare the change set
1145                 try {
1146                     for (IStructuredDocumentRegion region : sdoc.getStructuredDocumentRegions()) {
1147                         // Only look at XML "top regions"
1148                         if (!DOMRegionContext.XML_TAG_NAME.equals(region.getType())) {
1149                             continue;
1150                         }
1151 
1152                         int nb = region.getNumberOfRegions();
1153                         ITextRegionList list = region.getRegions();
1154                         String lastAttrName = null;
1155 
1156                         for (int i = 0; i < nb; i++) {
1157                             ITextRegion subRegion = list.get(i);
1158                             String type = subRegion.getType();
1159 
1160                             if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
1161                                 // Memorize the last attribute name seen
1162                                 lastAttrName = region.getText(subRegion);
1163 
1164                             } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
1165                                 // Check this is the attribute and the original string
1166                                 String text = region.getText(subRegion);
1167 
1168                                 int len = text.length();
1169                                 if (len >= 2 &&
1170                                         text.charAt(0) == '"' &&
1171                                         text.charAt(len - 1) == '"') {
1172                                     text = text.substring(1, len - 1);
1173                                 } else if (len >= 2 &&
1174                                         text.charAt(0) == '\'' &&
1175                                         text.charAt(len - 1) == '\'') {
1176                                     text = text.substring(1, len - 1);
1177                                 }
1178 
1179                                 if (xmlAttrName.equals(lastAttrName) && tokenString.equals(text)) {
1180 
1181                                     // Found an occurrence. Create a change for it.
1182                                     TextEdit edit = new ReplaceEdit(
1183                                             region.getStartOffset() + subRegion.getStart(),
1184                                             subRegion.getTextLength(),
1185                                             quotedReplacement);
1186                                     TextEditGroup editGroup = new TextEditGroup(
1187                                             "Replace attribute string by ID",
1188                                             edit);
1189 
1190                                     multiEdit.addChild(edit);
1191                                     editGroups.add(editGroup);
1192                                 }
1193                             }
1194                         }
1195                     }
1196                 } catch (Throwable t) {
1197                     // Since we use some internal APIs, use a broad catch-all to report any
1198                     // unexpected issue rather than crash the whole refactoring.
1199                     status.addFatalError(
1200                             String.format("XML refactoring error: %1$s", t.getMessage()));
1201                 } finally {
1202                     if (multiEdit.hasChildren()) {
1203                         xmlChange.setEdit(multiEdit);
1204                         for (TextEditGroup group : editGroups) {
1205                             xmlChange.addTextEditChangeGroup(
1206                                     new TextEditChangeGroup(xmlChange, group));
1207                         }
1208                         changes.add(xmlChange);
1209                     }
1210                     subMonitor.worked(1);
1211                 }
1212             } // for files
1213 
1214         } catch (IOException e) {
1215             status.addFatalError(String.format("XML model IO error: %1$s.", e.getMessage()));
1216         } catch (CoreException e) {
1217             status.addFatalError(String.format("XML model core error: %1$s.", e.getMessage()));
1218         } finally {
1219             if (changes.size() > 0) {
1220                 return changes;
1221             }
1222         }
1223 
1224         return null;
1225     }
1226 
1227     /**
1228      * Returns a quoted attribute value suitable to be placed after an attributeName=
1229      * statement in an XML stream.
1230      *
1231      * According to http://www.w3.org/TR/2008/REC-xml-20081126/#NT-AttValue
1232      * the attribute value can be either quoted using ' or " and the corresponding
1233      * entities &apos; or &quot; must be used inside.
1234      */
1235     private String quotedAttrValue(String attrValue) {
1236         if (attrValue.indexOf('"') == -1) {
1237             // no double-quotes inside, use double-quotes around.
1238             return '"' + attrValue + '"';
1239         }
1240         if (attrValue.indexOf('\'') == -1) {
1241             // no single-quotes inside, use single-quotes around.
1242             return '\'' + attrValue + '\'';
1243         }
1244         // If we get here, there's a mix. Opt for double-quote around and replace
1245         // inner double-quotes.
1246         attrValue = attrValue.replace("\"", "&quot;");  //$NON-NLS-1$ //$NON-NLS-2$
1247         return '"' + attrValue + '"';
1248     }
1249 
1250     /**
1251      * Computes the changes to be made to Java file(s) and returns a list of {@link Change}.
1252      */
1253     private List<Change> computeJavaChanges(ICompilationUnit unit,
1254             String xmlStringId,
1255             String tokenString,
1256             RefactoringStatus status,
1257             SubMonitor subMonitor) {
1258 
1259         // Get the Android package name from the Android Manifest. We need it to create
1260         // the FQCN of the R class.
1261         String packageName = null;
1262         String error = null;
1263         IResource manifestFile = mProject.findMember(SdkConstants.FN_ANDROID_MANIFEST_XML);
1264         if (manifestFile == null || manifestFile.getType() != IResource.FILE) {
1265             error = "File not found";
1266         } else {
1267             ManifestData manifestData = AndroidManifestHelper.parseForData((IFile) manifestFile);
1268             if (manifestData == null) {
1269                 error = "Invalid content";
1270             } else {
1271                 packageName = manifestData.getPackage();
1272                 if (packageName == null) {
1273                     error = "Missing package definition";
1274                 }
1275             }
1276         }
1277 
1278         if (error != null) {
1279             status.addFatalError(
1280                     String.format("Failed to parse file %1$s: %2$s.",
1281                             manifestFile == null ? "" : manifestFile.getFullPath(),  //$NON-NLS-1$
1282                             error));
1283             return null;
1284         }
1285 
1286         // TODO in a future version we might want to collect various Java files that
1287         // need to be updated in the same project and process them all together.
1288         // To do that we need to use an ASTRequestor and parser.createASTs, kind of
1289         // like this:
1290         //
1291         // ASTRequestor requestor = new ASTRequestor() {
1292         //    @Override
1293         //    public void acceptAST(ICompilationUnit sourceUnit, CompilationUnit astNode) {
1294         //        super.acceptAST(sourceUnit, astNode);
1295         //        // TODO process astNode
1296         //    }
1297         // };
1298         // ...
1299         // parser.createASTs(compilationUnits, bindingKeys, requestor, monitor)
1300         //
1301         // and then add multiple TextFileChange to the changes arraylist.
1302 
1303         // Right now the changes array will contain one TextFileChange at most.
1304         ArrayList<Change> changes = new ArrayList<Change>();
1305 
1306         // This is the unit that will be modified.
1307         TextFileChange change = new TextFileChange(getName(), (IFile) unit.getResource());
1308         change.setTextType("java"); //$NON-NLS-1$
1309 
1310         // Create an AST for this compilation unit
1311         ASTParser parser = ASTParser.newParser(AST.JLS3);
1312         parser.setProject(unit.getJavaProject());
1313         parser.setSource(unit);
1314         parser.setResolveBindings(true);
1315         ASTNode node = parser.createAST(subMonitor.newChild(1));
1316 
1317         // The ASTNode must be a CompilationUnit, by design
1318         if (!(node instanceof CompilationUnit)) {
1319             status.addFatalError(String.format("Internal error: ASTNode class %s",  //$NON-NLS-1$
1320                     node.getClass()));
1321             return null;
1322         }
1323 
1324         // ImportRewrite will allow us to add the new type to the imports and will resolve
1325         // what the Java source must reference, e.g. the FQCN or just the simple name.
1326         ImportRewrite importRewrite = ImportRewrite.create((CompilationUnit) node, true);
1327         String Rqualifier = packageName + ".R"; //$NON-NLS-1$
1328         Rqualifier = importRewrite.addImport(Rqualifier);
1329 
1330         // Rewrite the AST itself via an ASTVisitor
1331         AST ast = node.getAST();
1332         ASTRewrite astRewrite = ASTRewrite.create(ast);
1333         ArrayList<TextEditGroup> astEditGroups = new ArrayList<TextEditGroup>();
1334         ReplaceStringsVisitor visitor = new ReplaceStringsVisitor(
1335                 ast, astRewrite, astEditGroups,
1336                 tokenString, Rqualifier, xmlStringId);
1337         node.accept(visitor);
1338 
1339         // Finally prepare the change set
1340         try {
1341             MultiTextEdit edit = new MultiTextEdit();
1342 
1343             // Create the edit to change the imports, only if anything changed
1344             TextEdit subEdit = importRewrite.rewriteImports(subMonitor.newChild(1));
1345             if (subEdit.hasChildren()) {
1346                 edit.addChild(subEdit);
1347             }
1348 
1349             // Create the edit to change the Java source, only if anything changed
1350             subEdit = astRewrite.rewriteAST();
1351             if (subEdit.hasChildren()) {
1352                 edit.addChild(subEdit);
1353             }
1354 
1355             // Only create a change set if any edit was collected
1356             if (edit.hasChildren()) {
1357                 change.setEdit(edit);
1358 
1359                 // Create TextEditChangeGroups which let the user turn changes on or off
1360                 // individually. This must be done after the change.setEdit() call above.
1361                 for (TextEditGroup editGroup : astEditGroups) {
1362                     change.addTextEditChangeGroup(new TextEditChangeGroup(change, editGroup));
1363                 }
1364 
1365                 changes.add(change);
1366             }
1367 
1368             // TODO to modify another Java source, loop back to the creation of the
1369             // TextFileChange and accumulate in changes. Right now only one source is
1370             // modified.
1371 
1372             subMonitor.worked(1);
1373 
1374             if (changes.size() > 0) {
1375                 return changes;
1376             }
1377 
1378         } catch (CoreException e) {
1379             // ImportRewrite.rewriteImports failed.
1380             status.addFatalError(e.getMessage());
1381         }
1382         return null;
1383     }
1384 
1385     /**
1386      * Step 3 of 3 of the refactoring: returns the {@link Change} that will be able to do the
1387      * work and creates a descriptor that can be used to replay that refactoring later.
1388      *
1389      * @see org.eclipse.ltk.core.refactoring.Refactoring#createChange(org.eclipse.core.runtime.IProgressMonitor)
1390      *
1391      * @throws CoreException
1392      */
1393     @Override
1394     public Change createChange(IProgressMonitor monitor)
1395             throws CoreException, OperationCanceledException {
1396 
1397         try {
1398             monitor.beginTask("Applying changes...", 1);
1399 
1400             CompositeChange change = new CompositeChange(
1401                     getName(),
1402                     mChanges.toArray(new Change[mChanges.size()])) {
1403                 @Override
1404                 public ChangeDescriptor getDescriptor() {
1405 
1406                     String comment = String.format(
1407                             "Extracts string '%1$s' into R.string.%2$s",
1408                             mTokenString,
1409                             mXmlStringId);
1410 
1411                     ExtractStringDescriptor desc = new ExtractStringDescriptor(
1412                             mProject.getName(), //project
1413                             comment, //description
1414                             comment, //comment
1415                             createArgumentMap());
1416 
1417                     return new RefactoringChangeDescriptor(desc);
1418                 }
1419             };
1420 
1421             monitor.worked(1);
1422 
1423             return change;
1424 
1425         } finally {
1426             monitor.done();
1427         }
1428 
1429     }
1430 
1431     /**
1432      * Given a file project path, returns its resource in the same project than the
1433      * compilation unit. The resource may not exist.
1434      */
1435     private IResource getTargetXmlResource(String xmlFileWsPath) {
1436         IResource resource = mProject.getFile(xmlFileWsPath);
1437         return resource;
1438     }
1439 
1440     /**
1441      * Sets the replacement string ID. Used by the wizard to set the user input.
1442      */
1443     public void setNewStringId(String newStringId) {
1444         mXmlStringId = newStringId;
1445     }
1446 
1447     /**
1448      * Sets the replacement string ID. Used by the wizard to set the user input.
1449      */
1450     public void setNewStringValue(String newStringValue) {
1451         mXmlStringValue = newStringValue;
1452     }
1453 
1454     /**
1455      * Sets the target file. This is a project path, e.g. "/res/values/strings.xml".
1456      * Used by the wizard to set the user input.
1457      */
1458     public void setTargetFile(String targetXmlFileWsPath) {
1459         mTargetXmlFileWsPath = targetXmlFileWsPath;
1460     }
1461 
1462 }
1463