Home | History | Annotate | Download | only in extractstring
      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 
    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
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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      */
    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 
    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
    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
    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