• 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 
20 import com.android.SdkConstants;
21 import com.android.ide.common.resources.configuration.FolderConfiguration;
22 import com.android.ide.eclipse.adt.AdtConstants;
23 import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector;
24 import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.SelectorMode;
25 import com.android.resources.ResourceFolderType;
26 
27 import org.eclipse.core.resources.IFolder;
28 import org.eclipse.core.resources.IProject;
29 import org.eclipse.core.resources.IResource;
30 import org.eclipse.core.runtime.CoreException;
31 import org.eclipse.jface.wizard.WizardPage;
32 import org.eclipse.ltk.ui.refactoring.UserInputWizardPage;
33 import org.eclipse.swt.SWT;
34 import org.eclipse.swt.events.ModifyEvent;
35 import org.eclipse.swt.events.ModifyListener;
36 import org.eclipse.swt.events.SelectionAdapter;
37 import org.eclipse.swt.events.SelectionEvent;
38 import org.eclipse.swt.events.SelectionListener;
39 import org.eclipse.swt.layout.GridData;
40 import org.eclipse.swt.layout.GridLayout;
41 import org.eclipse.swt.widgets.Button;
42 import org.eclipse.swt.widgets.Combo;
43 import org.eclipse.swt.widgets.Composite;
44 import org.eclipse.swt.widgets.Group;
45 import org.eclipse.swt.widgets.Label;
46 import org.eclipse.swt.widgets.Text;
47 
48 import java.util.HashMap;
49 import java.util.Locale;
50 import java.util.Map;
51 import java.util.TreeSet;
52 import java.util.regex.Matcher;
53 import java.util.regex.Pattern;
54 
55 /**
56  * @see ExtractStringRefactoring
57  */
58 class ExtractStringInputPage extends UserInputWizardPage {
59 
60     /** Last res file path used, shared across the session instances but specific to the
61      *  current project. The default for unknown projects is {@link #DEFAULT_RES_FILE_PATH}. */
62     private static HashMap<String, String> sLastResFilePath = new HashMap<String, String>();
63 
64     /** The project where the user selection happened. */
65     private final IProject mProject;
66 
67     /** Text field where the user enters the new ID to be generated or replaced with. */
68     private Combo mStringIdCombo;
69     /** Text field where the user enters the new string value. */
70     private Text mStringValueField;
71     /** The configuration selector, to select the resource path of the XML file. */
72     private ConfigurationSelector mConfigSelector;
73     /** The combo to display the existing XML files or enter a new one. */
74     private Combo mResFileCombo;
75     /** Checkbox asking whether to replace in all Java files. */
76     private Button mReplaceAllJava;
77     /** Checkbox asking whether to replace in all XML files with same name but other res config */
78     private Button mReplaceAllXml;
79 
80     /** Regex pattern to read a valid res XML file path. It checks that the are 2 folders and
81      *  a leaf file name ending with .xml */
82     private static final Pattern RES_XML_FILE_REGEX = Pattern.compile(
83                                      "/res/[a-z][a-zA-Z0-9_-]+/[^.]+\\.xml");  //$NON-NLS-1$
84     /** Absolute destination folder root, e.g. "/res/" */
85     private static final String RES_FOLDER_ABS =
86         AdtConstants.WS_RESOURCES + AdtConstants.WS_SEP;
87     /** Relative destination folder root, e.g. "res/" */
88     private static final String RES_FOLDER_REL =
89         SdkConstants.FD_RESOURCES + AdtConstants.WS_SEP;
90 
91     private static final String DEFAULT_RES_FILE_PATH = "/res/values/strings.xml";  //$NON-NLS-1$
92 
93     private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();
94 
95     private final OnConfigSelectorUpdated mOnConfigSelectorUpdated = new OnConfigSelectorUpdated();
96 
97     private ModifyListener mValidateOnModify = new ModifyListener() {
98         @Override
99         public void modifyText(ModifyEvent e) {
100             validatePage();
101         }
102     };
103 
104     private SelectionListener mValidateOnSelection = new SelectionAdapter() {
105         @Override
106         public void widgetSelected(SelectionEvent e) {
107             validatePage();
108         }
109     };
110 
ExtractStringInputPage(IProject project)111     public ExtractStringInputPage(IProject project) {
112         super("ExtractStringInputPage");  //$NON-NLS-1$
113         mProject = project;
114     }
115 
116     /**
117      * Create the UI for the refactoring wizard.
118      * <p/>
119      * Note that at that point the initial conditions have been checked in
120      * {@link ExtractStringRefactoring}.
121      * <p/>
122      *
123      * Note: the special tag below defines this as the entry point for the WindowsDesigner Editor.
124      * @wbp.parser.entryPoint
125      */
126     @Override
createControl(Composite parent)127     public void createControl(Composite parent) {
128         Composite content = new Composite(parent, SWT.NONE);
129         GridLayout layout = new GridLayout();
130         content.setLayout(layout);
131 
132         createStringGroup(content);
133         createResFileGroup(content);
134         createOptionGroup(content);
135 
136         initUi();
137         setControl(content);
138     }
139 
140     /**
141      * Creates the top group with the field to replace which string and by what
142      * and by which options.
143      *
144      * @param content A composite with a 1-column grid layout
145      */
createStringGroup(Composite content)146     public void createStringGroup(Composite content) {
147 
148         final ExtractStringRefactoring ref = getOurRefactoring();
149 
150         Group group = new Group(content, SWT.NONE);
151         group.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
152         group.setText("New String");
153         if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
154             group.setText("String Replacement");
155         }
156 
157         GridLayout layout = new GridLayout();
158         layout.numColumns = 2;
159         group.setLayout(layout);
160 
161         // line: Textfield for string value (based on selection, if any)
162 
163         Label label = new Label(group, SWT.NONE);
164         label.setText("&String");
165 
166         String selectedString = ref.getTokenString();
167 
168         mStringValueField = new Text(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
169         mStringValueField.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
170         mStringValueField.setText(selectedString != null ? selectedString : "");  //$NON-NLS-1$
171 
172         ref.setNewStringValue(mStringValueField.getText());
173 
174         mStringValueField.addModifyListener(new ModifyListener() {
175             @Override
176             public void modifyText(ModifyEvent e) {
177                 validatePage();
178             }
179         });
180 
181         // line : Textfield for new ID
182 
183         label = new Label(group, SWT.NONE);
184         label.setText("ID &R.string.");
185         if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
186             label.setText("&Replace by R.string.");
187         } else if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
188             label.setText("New &R.string.");
189         }
190 
191         mStringIdCombo = new Combo(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.DROP_DOWN);
192         mStringIdCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
193         mStringIdCombo.setText(guessId(selectedString));
194         mStringIdCombo.forceFocus();
195 
196         ref.setNewStringId(mStringIdCombo.getText().trim());
197 
198         mStringIdCombo.addModifyListener(mValidateOnModify);
199         mStringIdCombo.addSelectionListener(mValidateOnSelection);
200     }
201 
202     /**
203      * Creates the lower group with the fields to choose the resource confirmation and
204      * the target XML file.
205      *
206      * @param content A composite with a 1-column grid layout
207      */
createResFileGroup(Composite content)208     private void createResFileGroup(Composite content) {
209 
210         Group group = new Group(content, SWT.NONE);
211         GridData gd = new GridData(GridData.FILL_HORIZONTAL);
212         gd.grabExcessVerticalSpace = true;
213         group.setLayoutData(gd);
214         group.setText("XML resource to edit");
215 
216         GridLayout layout = new GridLayout();
217         layout.numColumns = 2;
218         group.setLayout(layout);
219 
220         // line: selection of the res config
221 
222         Label label;
223         label = new Label(group, SWT.NONE);
224         label.setText("&Configuration:");
225 
226         mConfigSelector = new ConfigurationSelector(group, SelectorMode.DEFAULT);
227         gd = new GridData(GridData.GRAB_HORIZONTAL | GridData.GRAB_VERTICAL);
228         gd.horizontalSpan = 2;
229         gd.widthHint = ConfigurationSelector.WIDTH_HINT;
230         gd.heightHint = ConfigurationSelector.HEIGHT_HINT;
231         mConfigSelector.setLayoutData(gd);
232         mConfigSelector.setOnChangeListener(mOnConfigSelectorUpdated);
233 
234         // line: selection of the output file
235 
236         label = new Label(group, SWT.NONE);
237         label.setText("Resource &file:");
238 
239         mResFileCombo = new Combo(group, SWT.DROP_DOWN);
240         mResFileCombo.select(0);
241         mResFileCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
242         mResFileCombo.addModifyListener(mOnConfigSelectorUpdated);
243     }
244 
245     /**
246      * Creates the bottom option groups with a few checkboxes.
247      *
248      * @param content A composite with a 1-column grid layout
249      */
createOptionGroup(Composite content)250     private void createOptionGroup(Composite content) {
251         Group options = new Group(content, SWT.NONE);
252         options.setText("Options");
253         GridData gd_Options = new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1);
254         gd_Options.widthHint = 77;
255         options.setLayoutData(gd_Options);
256         options.setLayout(new GridLayout(1, false));
257 
258         mReplaceAllJava = new Button(options, SWT.CHECK);
259         mReplaceAllJava.setToolTipText("When checked, the exact same string literal will be replaced in all Java files.");
260         mReplaceAllJava.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
261         mReplaceAllJava.setText("Replace in all &Java files");
262         mReplaceAllJava.addSelectionListener(mValidateOnSelection);
263 
264         mReplaceAllXml = new Button(options, SWT.CHECK);
265         mReplaceAllXml.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
266         mReplaceAllXml.setToolTipText("When checked, string literals will be replaced in other XML resource files having the same name but located in different resource configuration folders.");
267         mReplaceAllXml.setText("Replace in all &XML files for different configuration");
268         mReplaceAllXml.addSelectionListener(mValidateOnSelection);
269     }
270 
271     // -- Start of internal part ----------
272     // Hide everything down-below from WindowsDesigner Editor
273     //$hide>>$
274 
275     /**
276      * Init UI just after it has been created the first time.
277      */
initUi()278     private void initUi() {
279         // set output file name to the last one used
280         String projPath = mProject.getFullPath().toPortableString();
281         String filePath = sLastResFilePath.get(projPath);
282 
283         mResFileCombo.setText(filePath != null ? filePath : DEFAULT_RES_FILE_PATH);
284         mOnConfigSelectorUpdated.run();
285         validatePage();
286     }
287 
288     /**
289      * Utility method to guess a suitable new XML ID based on the selected string.
290      */
guessId(String text)291     public static String guessId(String text) {
292         if (text == null) {
293             return "";  //$NON-NLS-1$
294         }
295 
296         // make lower case
297         text = text.toLowerCase(Locale.US);
298 
299         // everything not alphanumeric becomes an underscore
300         text = text.replaceAll("[^a-zA-Z0-9]+", "_");  //$NON-NLS-1$ //$NON-NLS-2$
301 
302         // the id must be a proper Java identifier, so it can't start with a number
303         if (text.length() > 0 && !Character.isJavaIdentifierStart(text.charAt(0))) {
304             text = "_" + text;  //$NON-NLS-1$
305         }
306         return text;
307     }
308 
309     /**
310      * Returns the {@link ExtractStringRefactoring} instance used by this wizard page.
311      */
getOurRefactoring()312     private ExtractStringRefactoring getOurRefactoring() {
313         return (ExtractStringRefactoring) getRefactoring();
314     }
315 
316     /**
317      * Validates fields of the wizard input page. Displays errors as appropriate and
318      * enable the "Next" button (or not) by calling {@link #setPageComplete(boolean)}.
319      *
320      * If validation succeeds, this updates the text id & value in the refactoring object.
321      *
322      * @return True if the page has been positively validated. It may still have warnings.
323      */
validatePage()324     private boolean validatePage() {
325         boolean success = true;
326 
327         ExtractStringRefactoring ref = getOurRefactoring();
328 
329         ref.setReplaceAllJava(mReplaceAllJava.getSelection());
330         ref.setReplaceAllXml(mReplaceAllXml.isEnabled() && mReplaceAllXml.getSelection());
331 
332         // Analyze fatal errors.
333 
334         String text = mStringIdCombo.getText().trim();
335         if (text == null || text.length() < 1) {
336             setErrorMessage("Please provide a resource ID.");
337             success = false;
338         } else {
339             for (int i = 0; i < text.length(); i++) {
340                 char c = text.charAt(i);
341                 boolean ok = i == 0 ?
342                         Character.isJavaIdentifierStart(c) :
343                         Character.isJavaIdentifierPart(c);
344                 if (!ok) {
345                     setErrorMessage(String.format(
346                             "The resource ID must be a valid Java identifier. The character %1$c at position %2$d is not acceptable.",
347                             c, i+1));
348                     success = false;
349                     break;
350                 }
351             }
352 
353             // update the field in the refactoring object in case of success
354             if (success) {
355                 ref.setNewStringId(text);
356             }
357         }
358 
359         String resFile = mResFileCombo.getText();
360         if (success) {
361             if (resFile == null || resFile.length() == 0) {
362                 setErrorMessage("A resource file name is required.");
363                 success = false;
364             } else if (!RES_XML_FILE_REGEX.matcher(resFile).matches()) {
365                 setErrorMessage("The XML file name is not valid.");
366                 success = false;
367             }
368         }
369 
370         // Analyze info & warnings.
371 
372         if (success) {
373             setErrorMessage(null);
374 
375             ref.setTargetFile(resFile);
376             sLastResFilePath.put(mProject.getFullPath().toPortableString(), resFile);
377 
378             String idValue = mXmlHelper.valueOfStringId(mProject, resFile, text);
379             if (idValue != null) {
380                 String msg = String.format("%1$s already contains a string ID '%2$s' with value '%3$s'.",
381                         resFile,
382                         text,
383                         idValue);
384                 if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
385                     setErrorMessage(msg);
386                     success = false;
387                 } else {
388                     setMessage(msg, WizardPage.WARNING);
389                 }
390             } else if (mProject.findMember(resFile) == null) {
391                 setMessage(
392                         String.format("File %2$s does not exist and will be created.",
393                                 text, resFile),
394                         WizardPage.INFORMATION);
395             } else {
396                 setMessage(null);
397             }
398         }
399 
400         if (success) {
401             // Also update the text value in case of success.
402             ref.setNewStringValue(mStringValueField.getText());
403         }
404 
405         setPageComplete(success);
406         return success;
407     }
408 
updateStringValueCombo()409     private void updateStringValueCombo() {
410         String resFile = mResFileCombo.getText();
411         Map<String, String> ids = mXmlHelper.getResIdsForFile(mProject, resFile);
412 
413         // get the current text from the combo, to make sure we don't change it
414         String currText = mStringIdCombo.getText();
415 
416         // erase the choices and fill with the given ids
417         mStringIdCombo.removeAll();
418         mStringIdCombo.setItems(ids.keySet().toArray(new String[ids.size()]));
419 
420         // set the current text to preserve it in case it changed
421         if (!currText.equals(mStringIdCombo.getText())) {
422             mStringIdCombo.setText(currText);
423         }
424     }
425 
426     private class OnConfigSelectorUpdated implements Runnable, ModifyListener {
427 
428         /** Regex pattern to parse a valid res path: it reads (/res/folder-name/)+(filename). */
429         private final Pattern mPathRegex = Pattern.compile(
430             "(/res/[a-z][a-zA-Z0-9_-]+/)(.+)");  //$NON-NLS-1$
431 
432         /** Temporary config object used to retrieve the Config Selector value. */
433         private FolderConfiguration mTempConfig = new FolderConfiguration();
434 
435         private HashMap<String, TreeSet<String>> mFolderCache =
436             new HashMap<String, TreeSet<String>>();
437         private String mLastFolderUsedInCombo = null;
438         private boolean mInternalConfigChange;
439         private boolean mInternalFileComboChange;
440 
441         /**
442          * Callback invoked when the {@link ConfigurationSelector} has been changed.
443          * <p/>
444          * The callback does the following:
445          * <ul>
446          * <li> Examine the current file name to retrieve the XML filename, if any.
447          * <li> Recompute the path based on the configuration selector (e.g. /res/values-fr/).
448          * <li> Examine the path to retrieve all the files in it. Keep those in a local cache.
449          * <li> If the XML filename from step 1 is not in the file list, it's a custom file name.
450          *      Insert it and sort it.
451          * <li> Re-populate the file combo with all the choices.
452          * <li> Select the original XML file.
453          */
454         @Override
run()455         public void run() {
456             if (mInternalConfigChange) {
457                 return;
458             }
459 
460             // get current leafname, if any
461             String leafName = "";  //$NON-NLS-1$
462             String currPath = mResFileCombo.getText();
463             Matcher m = mPathRegex.matcher(currPath);
464             if (m.matches()) {
465                 // Note: groups 1 and 2 cannot be null.
466                 leafName = m.group(2);
467                 currPath = m.group(1);
468             } else {
469                 // There was a path but it was invalid. Ignore it.
470                 currPath = "";  //$NON-NLS-1$
471             }
472 
473             // recreate the res path from the current configuration
474             mConfigSelector.getConfiguration(mTempConfig);
475             StringBuffer sb = new StringBuffer(RES_FOLDER_ABS);
476             sb.append(mTempConfig.getFolderName(ResourceFolderType.VALUES));
477             sb.append(AdtConstants.WS_SEP);
478 
479             String newPath = sb.toString();
480 
481             if (newPath.equals(currPath) && newPath.equals(mLastFolderUsedInCombo)) {
482                 // Path has not changed. No need to reload.
483                 return;
484             }
485 
486             // Get all the files at the new path
487 
488             TreeSet<String> filePaths = mFolderCache.get(newPath);
489 
490             if (filePaths == null) {
491                 filePaths = new TreeSet<String>();
492 
493                 IFolder folder = mProject.getFolder(newPath);
494                 if (folder != null && folder.exists()) {
495                     try {
496                         for (IResource res : folder.members()) {
497                             String name = res.getName();
498                             if (res.getType() == IResource.FILE && name.endsWith(".xml")) {
499                                 filePaths.add(newPath + name);
500                             }
501                         }
502                     } catch (CoreException e) {
503                         // Ignore.
504                     }
505                 }
506 
507                 mFolderCache.put(newPath, filePaths);
508             }
509 
510             currPath = newPath + leafName;
511             if (leafName.length() > 0 && !filePaths.contains(currPath)) {
512                 filePaths.add(currPath);
513             }
514 
515             // Fill the combo
516             try {
517                 mInternalFileComboChange = true;
518 
519                 mResFileCombo.removeAll();
520 
521                 for (String filePath : filePaths) {
522                     mResFileCombo.add(filePath);
523                 }
524 
525                 int index = -1;
526                 if (leafName.length() > 0) {
527                     index = mResFileCombo.indexOf(currPath);
528                     if (index >= 0) {
529                         mResFileCombo.select(index);
530                     }
531                 }
532 
533                 if (index == -1) {
534                     mResFileCombo.setText(currPath);
535                 }
536 
537                 mLastFolderUsedInCombo = newPath;
538 
539             } finally {
540                 mInternalFileComboChange = false;
541             }
542 
543             // finally validate the whole page
544             updateStringValueCombo();
545             validatePage();
546         }
547 
548         /**
549          * Callback invoked when {@link ExtractStringInputPage#mResFileCombo} has been
550          * modified.
551          */
552         @Override
modifyText(ModifyEvent e)553         public void modifyText(ModifyEvent e) {
554             if (mInternalFileComboChange) {
555                 return;
556             }
557 
558             String wsFolderPath = mResFileCombo.getText();
559 
560             // This is a custom path, we need to sanitize it.
561             // First it should start with "/res/". Then we need to make sure there are no
562             // relative paths, things like "../" or "./" or even "//".
563             wsFolderPath = wsFolderPath.replaceAll("/+\\.\\./+|/+\\./+|//+|\\\\+|^/+", "/");  //$NON-NLS-1$ //$NON-NLS-2$
564             wsFolderPath = wsFolderPath.replaceAll("^\\.\\./+|^\\./+", "");                   //$NON-NLS-1$ //$NON-NLS-2$
565             wsFolderPath = wsFolderPath.replaceAll("/+\\.\\.$|/+\\.$|/+$", "");               //$NON-NLS-1$ //$NON-NLS-2$
566 
567             // We get "res/foo" from selections relative to the project when we want a "/res/foo" path.
568             if (wsFolderPath.startsWith(RES_FOLDER_REL)) {
569                 wsFolderPath = RES_FOLDER_ABS + wsFolderPath.substring(RES_FOLDER_REL.length());
570 
571                 mInternalFileComboChange = true;
572                 mResFileCombo.setText(wsFolderPath);
573                 mInternalFileComboChange = false;
574             }
575 
576             if (wsFolderPath.startsWith(RES_FOLDER_ABS)) {
577                 wsFolderPath = wsFolderPath.substring(RES_FOLDER_ABS.length());
578 
579                 int pos = wsFolderPath.indexOf(AdtConstants.WS_SEP_CHAR);
580                 if (pos >= 0) {
581                     wsFolderPath = wsFolderPath.substring(0, pos);
582                 }
583 
584                 String[] folderSegments = wsFolderPath.split(SdkConstants.RES_QUALIFIER_SEP);
585 
586                 if (folderSegments.length > 0) {
587                     String folderName = folderSegments[0];
588 
589                     if (folderName != null && !folderName.equals(wsFolderPath)) {
590                         // update config selector
591                         mInternalConfigChange = true;
592                         mConfigSelector.setConfiguration(folderSegments);
593                         mInternalConfigChange = false;
594                     }
595                 }
596             }
597 
598             updateStringValueCombo();
599             validatePage();
600         }
601     }
602 
603     // End of hiding from SWT Designer
604     //$hide<<$
605 
606 }
607