aboutsummaryrefslogtreecommitdiff
path: root/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring
diff options
context:
space:
mode:
Diffstat (limited to 'eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring')
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/EnabledTextEditGroup.java40
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringAction.java184
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringContribution.java53
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringDescriptor.java71
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringInputPage.java606
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringProposal.java185
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java1933
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringWizard.java50
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ReplaceStringsVisitor.java480
-rw-r--r--eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/XmlStringFileHelper.java187
10 files changed, 3789 insertions, 0 deletions
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/EnabledTextEditGroup.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/EnabledTextEditGroup.java
new file mode 100644
index 000000000..15f6c4719
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/EnabledTextEditGroup.java
@@ -0,0 +1,40 @@
+/*
+ * Copyright (C) 2010 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import org.eclipse.text.edits.TextEditGroup;
+
+/**
+ * A {@link TextEditGroup} that we want to enable or disable by default.
+ * This is used to propose a change that may not compile, so we'll create
+ * a change that is disabled.
+ * <p/>
+ * Disabling the change is done by the refactoring class when processing
+ * the text edit groups generated by the Java AST visitor.
+ */
+class EnabledTextEditGroup extends TextEditGroup {
+ private final boolean mEnabled;
+
+ public EnabledTextEditGroup(String name, boolean enabled) {
+ super(name);
+ mEnabled = enabled;
+ }
+
+ public boolean isEnabled() {
+ return mEnabled;
+ }
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringAction.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringAction.java
new file mode 100644
index 000000000..14556fd9f
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringAction.java
@@ -0,0 +1,184 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import com.android.ide.eclipse.adt.AdtConstants;
+
+import org.eclipse.core.resources.IFile;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.jdt.core.ICompilationUnit;
+import org.eclipse.jdt.core.JavaCore;
+import org.eclipse.jface.action.IAction;
+import org.eclipse.jface.text.ITextSelection;
+import org.eclipse.jface.viewers.ISelection;
+import org.eclipse.ltk.ui.refactoring.RefactoringWizard;
+import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation;
+import org.eclipse.ui.IEditorInput;
+import org.eclipse.ui.IEditorPart;
+import org.eclipse.ui.IWorkbenchPage;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.eclipse.ui.IWorkbenchWindowActionDelegate;
+import org.eclipse.ui.PlatformUI;
+import org.eclipse.ui.part.FileEditorInput;
+
+/*
+ * Quick Reference Link:
+ * http://www.eclipse.org/articles/article.php?file=Article-Unleashing-the-Power-of-Refactoring/index.html
+ * and
+ * http://www.ibm.com/developerworks/opensource/library/os-ecjdt/
+ */
+
+/**
+ * Action executed when the "Extract String" menu item is invoked.
+ * <p/>
+ * The intent of the action is to start a refactoring that extracts a source string and
+ * replaces it by an Android string resource ID.
+ * <p/>
+ * Workflow:
+ * <ul>
+ * <li> The action is currently located in the Refactoring menu in the main menu.
+ * <li> TODO: extend the popup refactoring menu in a Java or Android XML file.
+ * <li> The action is only enabled if the selection is 1 character or more. That is at least part
+ * of the string must be selected, it's not enough to just move the insertion point. This is
+ * a limitation due to {@link #selectionChanged(IAction, ISelection)} not being called when
+ * the insertion point is merely moved. TODO: address this limitation.
+ * <ul> The action gets the current {@link ISelection}. It also knows the current
+ * {@link IWorkbenchWindow}. However for the refactoring we are also interested in having the
+ * actual resource file. By looking at the Active Window > Active Page > Active Editor we
+ * can get the {@link IEditorInput} and find the {@link ICompilationUnit} (aka Java file)
+ * that is being edited.
+ * <ul> TODO: change this to find the {@link IFile} being manipulated. The {@link ICompilationUnit}
+ * can be inferred using {@link JavaCore#createCompilationUnitFrom(IFile)}. This will allow
+ * us to be able to work with a selection from an Android XML file later.
+ * <li> The action creates a new {@link ExtractStringRefactoring} and make it run on in a new
+ * {@link ExtractStringWizard}.
+ * <ul>
+ */
+public class ExtractStringAction implements IWorkbenchWindowActionDelegate {
+
+ /** Keep track of the current workbench window. */
+ private IWorkbenchWindow mWindow;
+ private ITextSelection mSelection;
+ private IEditorPart mEditor;
+ private IFile mFile;
+
+ /**
+ * Keep track of the current workbench window.
+ */
+ @Override
+ public void init(IWorkbenchWindow window) {
+ mWindow = window;
+ }
+
+ @Override
+ public void dispose() {
+ // Nothing to do
+ }
+
+ /**
+ * Examine the selection to determine if the action should be enabled or not.
+ * <p/>
+ * Keep a link to the relevant selection structure (i.e. a part of the Java AST).
+ */
+ @Override
+ public void selectionChanged(IAction action, ISelection selection) {
+
+ // Note, two kinds of selections are returned here:
+ // ITextSelection on a Java source window
+ // IStructuredSelection in the outline or navigator
+ // This simply deals with the refactoring based on a non-empty selection.
+ // At that point, just enable the action and later decide if it's valid when it actually
+ // runs since we don't have access to the AST yet.
+
+ mSelection = null;
+ mFile = null;
+
+ if (selection instanceof ITextSelection) {
+ mSelection = (ITextSelection) selection;
+ if (mSelection.getLength() > 0) {
+ mEditor = getActiveEditor();
+ mFile = getSelectedFile(mEditor);
+ }
+ }
+
+ action.setEnabled(mSelection != null && mFile != null);
+ }
+
+ /**
+ * Create a new instance of our refactoring and a wizard to configure it.
+ */
+ @Override
+ public void run(IAction action) {
+ if (mSelection != null && mFile != null) {
+ ExtractStringRefactoring ref = new ExtractStringRefactoring(mFile, mEditor, mSelection);
+ RefactoringWizard wizard = new ExtractStringWizard(ref, mFile.getProject());
+ RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard);
+ try {
+ op.run(mWindow.getShell(), wizard.getDefaultPageTitle());
+ } catch (InterruptedException e) {
+ // Interrupted. Pass.
+ }
+ }
+ }
+
+ /**
+ * Returns the active editor (hopefully matching our selection) or null.
+ */
+ private IEditorPart getActiveEditor() {
+ IWorkbenchWindow wwin = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
+ if (wwin != null) {
+ IWorkbenchPage page = wwin.getActivePage();
+ if (page != null) {
+ return page.getActiveEditor();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Returns the active {@link IFile} (hopefully matching our selection) or null.
+ * The file is only returned if it's a file from a project with an Android nature.
+ * <p/>
+ * At that point we do not try to analyze if the selection nor the file is suitable
+ * for the refactoring. This check is performed when the refactoring is invoked since
+ * it can then produce meaningful error messages as needed.
+ */
+ private IFile getSelectedFile(IEditorPart editor) {
+ if (editor != null) {
+ IEditorInput input = editor.getEditorInput();
+
+ if (input instanceof FileEditorInput) {
+ FileEditorInput fi = (FileEditorInput) input;
+ IFile file = fi.getFile();
+ if (file.exists()) {
+ IProject proj = file.getProject();
+ try {
+ if (proj != null && proj.hasNature(AdtConstants.NATURE_DEFAULT)) {
+ return file;
+ }
+ } catch (CoreException e) {
+ // ignore
+ }
+ }
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringContribution.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringContribution.java
new file mode 100644
index 000000000..61bd06e81
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringContribution.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import org.eclipse.ltk.core.refactoring.RefactoringContribution;
+import org.eclipse.ltk.core.refactoring.RefactoringDescriptor;
+
+import java.util.Map;
+
+/**
+ * @see ExtractStringDescriptor
+ */
+public class ExtractStringContribution extends RefactoringContribution {
+
+ /* (non-Javadoc)
+ * @see org.eclipse.ltk.core.refactoring.RefactoringContribution#createDescriptor(java.lang.String, java.lang.String, java.lang.String, java.lang.String, java.util.Map, int)
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ @Override
+ public RefactoringDescriptor createDescriptor(
+ String id,
+ String project,
+ String description,
+ String comment,
+ Map arguments,
+ int flags)
+ throws IllegalArgumentException {
+ return new ExtractStringDescriptor(project, description, comment, arguments);
+ }
+
+ @SuppressWarnings("rawtypes")
+ @Override
+ public Map retrieveArgumentMap(RefactoringDescriptor descriptor) {
+ if (descriptor instanceof ExtractStringDescriptor) {
+ return ((ExtractStringDescriptor) descriptor).getArguments();
+ }
+ return super.retrieveArgumentMap(descriptor);
+ }
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringDescriptor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringDescriptor.java
new file mode 100644
index 000000000..190736aad
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringDescriptor.java
@@ -0,0 +1,71 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.ltk.core.refactoring.Refactoring;
+import org.eclipse.ltk.core.refactoring.RefactoringDescriptor;
+import org.eclipse.ltk.core.refactoring.RefactoringStatus;
+
+import java.util.Map;
+
+/**
+ * A descriptor that allows an {@link ExtractStringRefactoring} to be created from
+ * a previous instance of itself.
+ */
+public class ExtractStringDescriptor extends RefactoringDescriptor {
+
+ public static final String ID =
+ "com.android.ide.eclipse.adt.refactoring.extract.string"; //$NON-NLS-1$
+
+ private final Map<String, String> mArguments;
+
+ public ExtractStringDescriptor(String project, String description, String comment,
+ Map<String, String> arguments) {
+ super(ID, project, description, comment,
+ RefactoringDescriptor.STRUCTURAL_CHANGE | RefactoringDescriptor.MULTI_CHANGE //flags
+ );
+ mArguments = arguments;
+ }
+
+ public Map<String, String> getArguments() {
+ return mArguments;
+ }
+
+ /**
+ * Creates a new refactoring instance for this refactoring descriptor based on
+ * an argument map. The argument map is created by the refactoring itself in
+ * {@link ExtractStringRefactoring#createChange(org.eclipse.core.runtime.IProgressMonitor)}
+ * <p/>
+ * This is apparently used to replay a refactoring.
+ *
+ * {@inheritDoc}
+ *
+ * @throws CoreException
+ */
+ @Override
+ public Refactoring createRefactoring(RefactoringStatus status) throws CoreException {
+ try {
+ ExtractStringRefactoring ref = new ExtractStringRefactoring(mArguments);
+ return ref;
+ } catch (NullPointerException e) {
+ status.addFatalError("Failed to recreate ExtractStringRefactoring from descriptor");
+ return null;
+ }
+ }
+
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringInputPage.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringInputPage.java
new file mode 100644
index 000000000..5ac5f5c4e
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringInputPage.java
@@ -0,0 +1,606 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+
+import com.android.SdkConstants;
+import com.android.ide.common.resources.configuration.FolderConfiguration;
+import com.android.ide.eclipse.adt.AdtConstants;
+import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector;
+import com.android.ide.eclipse.adt.internal.ui.ConfigurationSelector.SelectorMode;
+import com.android.resources.ResourceFolderType;
+
+import org.eclipse.core.resources.IFolder;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.IResource;
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.jface.wizard.WizardPage;
+import org.eclipse.ltk.ui.refactoring.UserInputWizardPage;
+import org.eclipse.swt.SWT;
+import org.eclipse.swt.events.ModifyEvent;
+import org.eclipse.swt.events.ModifyListener;
+import org.eclipse.swt.events.SelectionAdapter;
+import org.eclipse.swt.events.SelectionEvent;
+import org.eclipse.swt.events.SelectionListener;
+import org.eclipse.swt.layout.GridData;
+import org.eclipse.swt.layout.GridLayout;
+import org.eclipse.swt.widgets.Button;
+import org.eclipse.swt.widgets.Combo;
+import org.eclipse.swt.widgets.Composite;
+import org.eclipse.swt.widgets.Group;
+import org.eclipse.swt.widgets.Label;
+import org.eclipse.swt.widgets.Text;
+
+import java.util.HashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeSet;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * @see ExtractStringRefactoring
+ */
+class ExtractStringInputPage extends UserInputWizardPage {
+
+ /** Last res file path used, shared across the session instances but specific to the
+ * current project. The default for unknown projects is {@link #DEFAULT_RES_FILE_PATH}. */
+ private static HashMap<String, String> sLastResFilePath = new HashMap<String, String>();
+
+ /** The project where the user selection happened. */
+ private final IProject mProject;
+
+ /** Text field where the user enters the new ID to be generated or replaced with. */
+ private Combo mStringIdCombo;
+ /** Text field where the user enters the new string value. */
+ private Text mStringValueField;
+ /** The configuration selector, to select the resource path of the XML file. */
+ private ConfigurationSelector mConfigSelector;
+ /** The combo to display the existing XML files or enter a new one. */
+ private Combo mResFileCombo;
+ /** Checkbox asking whether to replace in all Java files. */
+ private Button mReplaceAllJava;
+ /** Checkbox asking whether to replace in all XML files with same name but other res config */
+ private Button mReplaceAllXml;
+
+ /** Regex pattern to read a valid res XML file path. It checks that the are 2 folders and
+ * a leaf file name ending with .xml */
+ private static final Pattern RES_XML_FILE_REGEX = Pattern.compile(
+ "/res/[a-z][a-zA-Z0-9_-]+/[^.]+\\.xml"); //$NON-NLS-1$
+ /** Absolute destination folder root, e.g. "/res/" */
+ private static final String RES_FOLDER_ABS =
+ AdtConstants.WS_RESOURCES + AdtConstants.WS_SEP;
+ /** Relative destination folder root, e.g. "res/" */
+ private static final String RES_FOLDER_REL =
+ SdkConstants.FD_RESOURCES + AdtConstants.WS_SEP;
+
+ private static final String DEFAULT_RES_FILE_PATH = "/res/values/strings.xml"; //$NON-NLS-1$
+
+ private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();
+
+ private final OnConfigSelectorUpdated mOnConfigSelectorUpdated = new OnConfigSelectorUpdated();
+
+ private ModifyListener mValidateOnModify = new ModifyListener() {
+ @Override
+ public void modifyText(ModifyEvent e) {
+ validatePage();
+ }
+ };
+
+ private SelectionListener mValidateOnSelection = new SelectionAdapter() {
+ @Override
+ public void widgetSelected(SelectionEvent e) {
+ validatePage();
+ }
+ };
+
+ public ExtractStringInputPage(IProject project) {
+ super("ExtractStringInputPage"); //$NON-NLS-1$
+ mProject = project;
+ }
+
+ /**
+ * Create the UI for the refactoring wizard.
+ * <p/>
+ * Note that at that point the initial conditions have been checked in
+ * {@link ExtractStringRefactoring}.
+ * <p/>
+ *
+ * Note: the special tag below defines this as the entry point for the WindowsDesigner Editor.
+ * @wbp.parser.entryPoint
+ */
+ @Override
+ public void createControl(Composite parent) {
+ Composite content = new Composite(parent, SWT.NONE);
+ GridLayout layout = new GridLayout();
+ content.setLayout(layout);
+
+ createStringGroup(content);
+ createResFileGroup(content);
+ createOptionGroup(content);
+
+ initUi();
+ setControl(content);
+ }
+
+ /**
+ * Creates the top group with the field to replace which string and by what
+ * and by which options.
+ *
+ * @param content A composite with a 1-column grid layout
+ */
+ public void createStringGroup(Composite content) {
+
+ final ExtractStringRefactoring ref = getOurRefactoring();
+
+ Group group = new Group(content, SWT.NONE);
+ group.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ group.setText("New String");
+ if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
+ group.setText("String Replacement");
+ }
+
+ GridLayout layout = new GridLayout();
+ layout.numColumns = 2;
+ group.setLayout(layout);
+
+ // line: Textfield for string value (based on selection, if any)
+
+ Label label = new Label(group, SWT.NONE);
+ label.setText("&String");
+
+ String selectedString = ref.getTokenString();
+
+ mStringValueField = new Text(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER);
+ mStringValueField.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ mStringValueField.setText(selectedString != null ? selectedString : ""); //$NON-NLS-1$
+
+ ref.setNewStringValue(mStringValueField.getText());
+
+ mStringValueField.addModifyListener(new ModifyListener() {
+ @Override
+ public void modifyText(ModifyEvent e) {
+ validatePage();
+ }
+ });
+
+ // line : Textfield for new ID
+
+ label = new Label(group, SWT.NONE);
+ label.setText("ID &R.string.");
+ if (ref.getMode() == ExtractStringRefactoring.Mode.EDIT_SOURCE) {
+ label.setText("&Replace by R.string.");
+ } else if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
+ label.setText("New &R.string.");
+ }
+
+ mStringIdCombo = new Combo(group, SWT.SINGLE | SWT.LEFT | SWT.BORDER | SWT.DROP_DOWN);
+ mStringIdCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ mStringIdCombo.setText(guessId(selectedString));
+ mStringIdCombo.forceFocus();
+
+ ref.setNewStringId(mStringIdCombo.getText().trim());
+
+ mStringIdCombo.addModifyListener(mValidateOnModify);
+ mStringIdCombo.addSelectionListener(mValidateOnSelection);
+ }
+
+ /**
+ * Creates the lower group with the fields to choose the resource confirmation and
+ * the target XML file.
+ *
+ * @param content A composite with a 1-column grid layout
+ */
+ private void createResFileGroup(Composite content) {
+
+ Group group = new Group(content, SWT.NONE);
+ GridData gd = new GridData(GridData.FILL_HORIZONTAL);
+ gd.grabExcessVerticalSpace = true;
+ group.setLayoutData(gd);
+ group.setText("XML resource to edit");
+
+ GridLayout layout = new GridLayout();
+ layout.numColumns = 2;
+ group.setLayout(layout);
+
+ // line: selection of the res config
+
+ Label label;
+ label = new Label(group, SWT.NONE);
+ label.setText("&Configuration:");
+
+ mConfigSelector = new ConfigurationSelector(group, SelectorMode.DEFAULT);
+ gd = new GridData(GridData.GRAB_HORIZONTAL | GridData.GRAB_VERTICAL);
+ gd.horizontalSpan = 2;
+ gd.widthHint = ConfigurationSelector.WIDTH_HINT;
+ gd.heightHint = ConfigurationSelector.HEIGHT_HINT;
+ mConfigSelector.setLayoutData(gd);
+ mConfigSelector.setOnChangeListener(mOnConfigSelectorUpdated);
+
+ // line: selection of the output file
+
+ label = new Label(group, SWT.NONE);
+ label.setText("Resource &file:");
+
+ mResFileCombo = new Combo(group, SWT.DROP_DOWN);
+ mResFileCombo.select(0);
+ mResFileCombo.setLayoutData(new GridData(GridData.FILL_HORIZONTAL));
+ mResFileCombo.addModifyListener(mOnConfigSelectorUpdated);
+ }
+
+ /**
+ * Creates the bottom option groups with a few checkboxes.
+ *
+ * @param content A composite with a 1-column grid layout
+ */
+ private void createOptionGroup(Composite content) {
+ Group options = new Group(content, SWT.NONE);
+ options.setText("Options");
+ GridData gd_Options = new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1);
+ gd_Options.widthHint = 77;
+ options.setLayoutData(gd_Options);
+ options.setLayout(new GridLayout(1, false));
+
+ mReplaceAllJava = new Button(options, SWT.CHECK);
+ mReplaceAllJava.setToolTipText("When checked, the exact same string literal will be replaced in all Java files.");
+ mReplaceAllJava.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
+ mReplaceAllJava.setText("Replace in all &Java files");
+ mReplaceAllJava.addSelectionListener(mValidateOnSelection);
+
+ mReplaceAllXml = new Button(options, SWT.CHECK);
+ mReplaceAllXml.setLayoutData(new GridData(SWT.FILL, SWT.CENTER, true, false, 1, 1));
+ 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.");
+ mReplaceAllXml.setText("Replace in all &XML files for different configuration");
+ mReplaceAllXml.addSelectionListener(mValidateOnSelection);
+ }
+
+ // -- Start of internal part ----------
+ // Hide everything down-below from WindowsDesigner Editor
+ //$hide>>$
+
+ /**
+ * Init UI just after it has been created the first time.
+ */
+ private void initUi() {
+ // set output file name to the last one used
+ String projPath = mProject.getFullPath().toPortableString();
+ String filePath = sLastResFilePath.get(projPath);
+
+ mResFileCombo.setText(filePath != null ? filePath : DEFAULT_RES_FILE_PATH);
+ mOnConfigSelectorUpdated.run();
+ validatePage();
+ }
+
+ /**
+ * Utility method to guess a suitable new XML ID based on the selected string.
+ */
+ public static String guessId(String text) {
+ if (text == null) {
+ return ""; //$NON-NLS-1$
+ }
+
+ // make lower case
+ text = text.toLowerCase(Locale.US);
+
+ // everything not alphanumeric becomes an underscore
+ text = text.replaceAll("[^a-zA-Z0-9]+", "_"); //$NON-NLS-1$ //$NON-NLS-2$
+
+ // the id must be a proper Java identifier, so it can't start with a number
+ if (text.length() > 0 && !Character.isJavaIdentifierStart(text.charAt(0))) {
+ text = "_" + text; //$NON-NLS-1$
+ }
+ return text;
+ }
+
+ /**
+ * Returns the {@link ExtractStringRefactoring} instance used by this wizard page.
+ */
+ private ExtractStringRefactoring getOurRefactoring() {
+ return (ExtractStringRefactoring) getRefactoring();
+ }
+
+ /**
+ * Validates fields of the wizard input page. Displays errors as appropriate and
+ * enable the "Next" button (or not) by calling {@link #setPageComplete(boolean)}.
+ *
+ * If validation succeeds, this updates the text id & value in the refactoring object.
+ *
+ * @return True if the page has been positively validated. It may still have warnings.
+ */
+ private boolean validatePage() {
+ boolean success = true;
+
+ ExtractStringRefactoring ref = getOurRefactoring();
+
+ ref.setReplaceAllJava(mReplaceAllJava.getSelection());
+ ref.setReplaceAllXml(mReplaceAllXml.isEnabled() && mReplaceAllXml.getSelection());
+
+ // Analyze fatal errors.
+
+ String text = mStringIdCombo.getText().trim();
+ if (text == null || text.length() < 1) {
+ setErrorMessage("Please provide a resource ID.");
+ success = false;
+ } else {
+ for (int i = 0; i < text.length(); i++) {
+ char c = text.charAt(i);
+ boolean ok = i == 0 ?
+ Character.isJavaIdentifierStart(c) :
+ Character.isJavaIdentifierPart(c);
+ if (!ok) {
+ setErrorMessage(String.format(
+ "The resource ID must be a valid Java identifier. The character %1$c at position %2$d is not acceptable.",
+ c, i+1));
+ success = false;
+ break;
+ }
+ }
+
+ // update the field in the refactoring object in case of success
+ if (success) {
+ ref.setNewStringId(text);
+ }
+ }
+
+ String resFile = mResFileCombo.getText();
+ if (success) {
+ if (resFile == null || resFile.length() == 0) {
+ setErrorMessage("A resource file name is required.");
+ success = false;
+ } else if (!RES_XML_FILE_REGEX.matcher(resFile).matches()) {
+ setErrorMessage("The XML file name is not valid.");
+ success = false;
+ }
+ }
+
+ // Analyze info & warnings.
+
+ if (success) {
+ setErrorMessage(null);
+
+ ref.setTargetFile(resFile);
+ sLastResFilePath.put(mProject.getFullPath().toPortableString(), resFile);
+
+ String idValue = mXmlHelper.valueOfStringId(mProject, resFile, text);
+ if (idValue != null) {
+ String msg = String.format("%1$s already contains a string ID '%2$s' with value '%3$s'.",
+ resFile,
+ text,
+ idValue);
+ if (ref.getMode() == ExtractStringRefactoring.Mode.SELECT_NEW_ID) {
+ setErrorMessage(msg);
+ success = false;
+ } else {
+ setMessage(msg, WizardPage.WARNING);
+ }
+ } else if (mProject.findMember(resFile) == null) {
+ setMessage(
+ String.format("File %2$s does not exist and will be created.",
+ text, resFile),
+ WizardPage.INFORMATION);
+ } else {
+ setMessage(null);
+ }
+ }
+
+ if (success) {
+ // Also update the text value in case of success.
+ ref.setNewStringValue(mStringValueField.getText());
+ }
+
+ setPageComplete(success);
+ return success;
+ }
+
+ private void updateStringValueCombo() {
+ String resFile = mResFileCombo.getText();
+ Map<String, String> ids = mXmlHelper.getResIdsForFile(mProject, resFile);
+
+ // get the current text from the combo, to make sure we don't change it
+ String currText = mStringIdCombo.getText();
+
+ // erase the choices and fill with the given ids
+ mStringIdCombo.removeAll();
+ mStringIdCombo.setItems(ids.keySet().toArray(new String[ids.size()]));
+
+ // set the current text to preserve it in case it changed
+ if (!currText.equals(mStringIdCombo.getText())) {
+ mStringIdCombo.setText(currText);
+ }
+ }
+
+ private class OnConfigSelectorUpdated implements Runnable, ModifyListener {
+
+ /** Regex pattern to parse a valid res path: it reads (/res/folder-name/)+(filename). */
+ private final Pattern mPathRegex = Pattern.compile(
+ "(/res/[a-z][a-zA-Z0-9_-]+/)(.+)"); //$NON-NLS-1$
+
+ /** Temporary config object used to retrieve the Config Selector value. */
+ private FolderConfiguration mTempConfig = new FolderConfiguration();
+
+ private HashMap<String, TreeSet<String>> mFolderCache =
+ new HashMap<String, TreeSet<String>>();
+ private String mLastFolderUsedInCombo = null;
+ private boolean mInternalConfigChange;
+ private boolean mInternalFileComboChange;
+
+ /**
+ * Callback invoked when the {@link ConfigurationSelector} has been changed.
+ * <p/>
+ * The callback does the following:
+ * <ul>
+ * <li> Examine the current file name to retrieve the XML filename, if any.
+ * <li> Recompute the path based on the configuration selector (e.g. /res/values-fr/).
+ * <li> Examine the path to retrieve all the files in it. Keep those in a local cache.
+ * <li> If the XML filename from step 1 is not in the file list, it's a custom file name.
+ * Insert it and sort it.
+ * <li> Re-populate the file combo with all the choices.
+ * <li> Select the original XML file.
+ */
+ @Override
+ public void run() {
+ if (mInternalConfigChange) {
+ return;
+ }
+
+ // get current leafname, if any
+ String leafName = ""; //$NON-NLS-1$
+ String currPath = mResFileCombo.getText();
+ Matcher m = mPathRegex.matcher(currPath);
+ if (m.matches()) {
+ // Note: groups 1 and 2 cannot be null.
+ leafName = m.group(2);
+ currPath = m.group(1);
+ } else {
+ // There was a path but it was invalid. Ignore it.
+ currPath = ""; //$NON-NLS-1$
+ }
+
+ // recreate the res path from the current configuration
+ mConfigSelector.getConfiguration(mTempConfig);
+ StringBuffer sb = new StringBuffer(RES_FOLDER_ABS);
+ sb.append(mTempConfig.getFolderName(ResourceFolderType.VALUES));
+ sb.append(AdtConstants.WS_SEP);
+
+ String newPath = sb.toString();
+
+ if (newPath.equals(currPath) && newPath.equals(mLastFolderUsedInCombo)) {
+ // Path has not changed. No need to reload.
+ return;
+ }
+
+ // Get all the files at the new path
+
+ TreeSet<String> filePaths = mFolderCache.get(newPath);
+
+ if (filePaths == null) {
+ filePaths = new TreeSet<String>();
+
+ IFolder folder = mProject.getFolder(newPath);
+ if (folder != null && folder.exists()) {
+ try {
+ for (IResource res : folder.members()) {
+ String name = res.getName();
+ if (res.getType() == IResource.FILE && name.endsWith(".xml")) {
+ filePaths.add(newPath + name);
+ }
+ }
+ } catch (CoreException e) {
+ // Ignore.
+ }
+ }
+
+ mFolderCache.put(newPath, filePaths);
+ }
+
+ currPath = newPath + leafName;
+ if (leafName.length() > 0 && !filePaths.contains(currPath)) {
+ filePaths.add(currPath);
+ }
+
+ // Fill the combo
+ try {
+ mInternalFileComboChange = true;
+
+ mResFileCombo.removeAll();
+
+ for (String filePath : filePaths) {
+ mResFileCombo.add(filePath);
+ }
+
+ int index = -1;
+ if (leafName.length() > 0) {
+ index = mResFileCombo.indexOf(currPath);
+ if (index >= 0) {
+ mResFileCombo.select(index);
+ }
+ }
+
+ if (index == -1) {
+ mResFileCombo.setText(currPath);
+ }
+
+ mLastFolderUsedInCombo = newPath;
+
+ } finally {
+ mInternalFileComboChange = false;
+ }
+
+ // finally validate the whole page
+ updateStringValueCombo();
+ validatePage();
+ }
+
+ /**
+ * Callback invoked when {@link ExtractStringInputPage#mResFileCombo} has been
+ * modified.
+ */
+ @Override
+ public void modifyText(ModifyEvent e) {
+ if (mInternalFileComboChange) {
+ return;
+ }
+
+ String wsFolderPath = mResFileCombo.getText();
+
+ // This is a custom path, we need to sanitize it.
+ // First it should start with "/res/". Then we need to make sure there are no
+ // relative paths, things like "../" or "./" or even "//".
+ wsFolderPath = wsFolderPath.replaceAll("/+\\.\\./+|/+\\./+|//+|\\\\+|^/+", "/"); //$NON-NLS-1$ //$NON-NLS-2$
+ wsFolderPath = wsFolderPath.replaceAll("^\\.\\./+|^\\./+", ""); //$NON-NLS-1$ //$NON-NLS-2$
+ wsFolderPath = wsFolderPath.replaceAll("/+\\.\\.$|/+\\.$|/+$", ""); //$NON-NLS-1$ //$NON-NLS-2$
+
+ // We get "res/foo" from selections relative to the project when we want a "/res/foo" path.
+ if (wsFolderPath.startsWith(RES_FOLDER_REL)) {
+ wsFolderPath = RES_FOLDER_ABS + wsFolderPath.substring(RES_FOLDER_REL.length());
+
+ mInternalFileComboChange = true;
+ mResFileCombo.setText(wsFolderPath);
+ mInternalFileComboChange = false;
+ }
+
+ if (wsFolderPath.startsWith(RES_FOLDER_ABS)) {
+ wsFolderPath = wsFolderPath.substring(RES_FOLDER_ABS.length());
+
+ int pos = wsFolderPath.indexOf(AdtConstants.WS_SEP_CHAR);
+ if (pos >= 0) {
+ wsFolderPath = wsFolderPath.substring(0, pos);
+ }
+
+ String[] folderSegments = wsFolderPath.split(SdkConstants.RES_QUALIFIER_SEP);
+
+ if (folderSegments.length > 0) {
+ String folderName = folderSegments[0];
+
+ if (folderName != null && !folderName.equals(wsFolderPath)) {
+ // update config selector
+ mInternalConfigChange = true;
+ mConfigSelector.setConfiguration(folderSegments);
+ mInternalConfigChange = false;
+ }
+ }
+ }
+
+ updateStringValueCombo();
+ validatePage();
+ }
+ }
+
+ // End of hiding from SWT Designer
+ //$hide<<$
+
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringProposal.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringProposal.java
new file mode 100644
index 000000000..5400be4e4
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringProposal.java
@@ -0,0 +1,185 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import com.android.ide.eclipse.adt.AdtPlugin;
+import com.android.ide.eclipse.adt.AdtUtils;
+
+import org.eclipse.core.resources.IFile;
+import org.eclipse.jdt.core.IBuffer;
+import org.eclipse.jdt.core.JavaModelException;
+import org.eclipse.jdt.core.dom.ASTNode;
+import org.eclipse.jdt.ui.text.java.IInvocationContext;
+import org.eclipse.jdt.ui.text.java.IJavaCompletionProposal;
+import org.eclipse.jface.text.IDocument;
+import org.eclipse.jface.text.ITextSelection;
+import org.eclipse.jface.text.TextSelection;
+import org.eclipse.jface.text.contentassist.IContextInformation;
+import org.eclipse.ltk.ui.refactoring.RefactoringWizard;
+import org.eclipse.ltk.ui.refactoring.RefactoringWizardOpenOperation;
+import org.eclipse.swt.graphics.Image;
+import org.eclipse.swt.graphics.Point;
+import org.eclipse.ui.IEditorPart;
+import org.eclipse.ui.IWorkbenchWindow;
+import org.eclipse.ui.PlatformUI;
+
+/**
+ * Proposal for extracting strings in Java files
+ */
+public class ExtractStringProposal implements IJavaCompletionProposal {
+ private IInvocationContext mContext;
+
+ public ExtractStringProposal(IInvocationContext context) {
+ mContext = context;
+ }
+
+ @Override
+ public void apply(IDocument document) {
+ IEditorPart editor = AdtUtils.getActiveEditor();
+ IFile file = AdtUtils.getActiveFile();
+ if (editor == null || file == null) {
+ return;
+ }
+
+ ASTNode coveringNode = mContext.getCoveringNode();
+ int start = coveringNode.getStartPosition();
+ int length = coveringNode.getLength();
+ ITextSelection selection = new TextSelection(start, length);
+
+ ExtractStringRefactoring refactoring = new ExtractStringRefactoring(file, editor,
+ selection);
+
+ RefactoringWizard wizard = new ExtractStringWizard(refactoring, file.getProject());
+ RefactoringWizardOpenOperation op = new RefactoringWizardOpenOperation(wizard);
+ try {
+ IWorkbenchWindow window = PlatformUI.getWorkbench().getActiveWorkbenchWindow();
+ op.run(window.getShell(), wizard.getDefaultPageTitle());
+ } catch (InterruptedException e) {
+ }
+ }
+
+ @Override
+ public Point getSelection(IDocument document) {
+ return null;
+ }
+
+ @Override
+ public String getAdditionalProposalInfo() {
+ try {
+ ASTNode coveringNode = mContext.getCoveringNode();
+ int start = coveringNode.getStartPosition();
+ int length = coveringNode.getLength();
+ IBuffer buffer = mContext.getCompilationUnit().getBuffer();
+ StringBuilder sb = new StringBuilder();
+ String string = buffer.getText(start, length);
+ string = ExtractStringRefactoring.unquoteAttrValue(string);
+ String token = ExtractStringInputPage.guessId(string);
+
+ // Look up the beginning and the end of the line (outside of the extracted string)
+ // such that we can show a preview of the diff, e.g. if you have
+ // foo.setTitle("Hello"); we want to show foo.setTitle(R.string.hello);
+ // so we need to extract "foo.setTitle(" and ");".
+
+ // Look backwards to the beginning of the line (and strip whitespace)
+ int i = start - 1;
+ while (i > 0) {
+ char c = buffer.getChar(i);
+ if (c == '\r' || (c == '\n')) {
+ break;
+ }
+ i--;
+ }
+ String linePrefix = buffer.getText(i + 1, start - (i + 1)).trim();
+
+ // Look forwards to the end of the line (and strip whitespace)
+ i = start + length;
+ while (i < buffer.getLength()) {
+ char c = buffer.getChar(i);
+ if (c == '\r' || (c == '\n')) {
+ break;
+ }
+ i++;
+ }
+ String lineSuffix = buffer.getText(start + length, i - (start + length));
+
+ // Should we show the replacement as just R.string.foo or
+ // context.getString(R.string.foo) ?
+ boolean useContext = false;
+ ASTNode parent = coveringNode.getParent();
+ if (parent != null) {
+ int type = parent.getNodeType();
+ if (type == ASTNode.ASSIGNMENT
+ || type == ASTNode.VARIABLE_DECLARATION_STATEMENT
+ || type == ASTNode.VARIABLE_DECLARATION_FRAGMENT
+ || type == ASTNode.VARIABLE_DECLARATION_EXPRESSION) {
+ useContext = true;
+ }
+ }
+
+ // Display .java change:
+ sb.append("...<br>"); //$NON-NLS-1$
+ sb.append(linePrefix);
+ sb.append("<b>"); //$NON-NLS-1$
+ if (useContext) {
+ sb.append("context.getString("); //$NON-NLS-1$
+ }
+ sb.append("R.string."); //$NON-NLS-1$
+ sb.append(token);
+ if (useContext) {
+ sb.append(")"); //$NON-NLS-1$
+ }
+ sb.append("</b>"); //$NON-NLS-1$
+ sb.append(lineSuffix);
+ sb.append("<br>...<br>"); //$NON-NLS-1$
+
+ // Display strings.xml change:
+ sb.append("<br>"); //$NON-NLS-1$
+ sb.append("&lt;resources&gt;<br>"); //$NON-NLS-1$
+ sb.append(" <b>&lt;string name=\""); //$NON-NLS-1$
+ sb.append(token);
+ sb.append("\"&gt;"); //$NON-NLS-1$
+ sb.append(string);
+ sb.append("&lt;/string&gt;</b><br>"); //$NON-NLS-1$
+ sb.append("&lt;/resources&gt;"); //$NON-NLS-1$
+
+ return sb.toString();
+ } catch (JavaModelException e) {
+ AdtPlugin.log(e, null);
+ }
+
+ return "Initiates the Extract String refactoring operation";
+ }
+
+ @Override
+ public String getDisplayString() {
+ return "Extract String";
+ }
+
+ @Override
+ public Image getImage() {
+ return AdtPlugin.getAndroidLogo();
+ }
+
+ @Override
+ public IContextInformation getContextInformation() {
+ return null;
+ }
+
+ @Override
+ public int getRelevance() {
+ return 80;
+ }
+} \ No newline at end of file
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java
new file mode 100644
index 000000000..db0b0967d
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringRefactoring.java
@@ -0,0 +1,1933 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import static com.android.SdkConstants.QUOT_ENTITY;
+import static com.android.SdkConstants.STRING_PREFIX;
+
+import com.android.SdkConstants;
+import com.android.ide.common.res2.ValueXmlHelper;
+import com.android.ide.common.xml.ManifestData;
+import com.android.ide.eclipse.adt.AdtConstants;
+import com.android.ide.eclipse.adt.internal.editors.AndroidXmlEditor;
+import com.android.ide.eclipse.adt.internal.editors.descriptors.AttributeDescriptor;
+import com.android.ide.eclipse.adt.internal.editors.descriptors.ReferenceAttributeDescriptor;
+import com.android.ide.eclipse.adt.internal.editors.uimodel.UiAttributeNode;
+import com.android.ide.eclipse.adt.internal.editors.uimodel.UiElementNode;
+import com.android.ide.eclipse.adt.internal.project.AndroidManifestHelper;
+import com.android.resources.ResourceFolderType;
+import com.android.resources.ResourceType;
+
+import org.eclipse.core.resources.IContainer;
+import org.eclipse.core.resources.IFile;
+import org.eclipse.core.resources.IFolder;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.IResource;
+import org.eclipse.core.resources.ResourceAttributes;
+import org.eclipse.core.resources.ResourcesPlugin;
+import org.eclipse.core.runtime.CoreException;
+import org.eclipse.core.runtime.IPath;
+import org.eclipse.core.runtime.IProgressMonitor;
+import org.eclipse.core.runtime.OperationCanceledException;
+import org.eclipse.core.runtime.Path;
+import org.eclipse.core.runtime.SubMonitor;
+import org.eclipse.jdt.core.IBuffer;
+import org.eclipse.jdt.core.ICompilationUnit;
+import org.eclipse.jdt.core.IJavaProject;
+import org.eclipse.jdt.core.IPackageFragment;
+import org.eclipse.jdt.core.IPackageFragmentRoot;
+import org.eclipse.jdt.core.JavaCore;
+import org.eclipse.jdt.core.JavaModelException;
+import org.eclipse.jdt.core.ToolFactory;
+import org.eclipse.jdt.core.compiler.IScanner;
+import org.eclipse.jdt.core.compiler.ITerminalSymbols;
+import org.eclipse.jdt.core.compiler.InvalidInputException;
+import org.eclipse.jdt.core.dom.AST;
+import org.eclipse.jdt.core.dom.ASTNode;
+import org.eclipse.jdt.core.dom.ASTParser;
+import org.eclipse.jdt.core.dom.CompilationUnit;
+import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
+import org.eclipse.jdt.core.dom.rewrite.ImportRewrite;
+import org.eclipse.jface.text.ITextSelection;
+import org.eclipse.ltk.core.refactoring.Change;
+import org.eclipse.ltk.core.refactoring.ChangeDescriptor;
+import org.eclipse.ltk.core.refactoring.CompositeChange;
+import org.eclipse.ltk.core.refactoring.Refactoring;
+import org.eclipse.ltk.core.refactoring.RefactoringChangeDescriptor;
+import org.eclipse.ltk.core.refactoring.RefactoringStatus;
+import org.eclipse.ltk.core.refactoring.TextEditChangeGroup;
+import org.eclipse.ltk.core.refactoring.TextFileChange;
+import org.eclipse.text.edits.InsertEdit;
+import org.eclipse.text.edits.MultiTextEdit;
+import org.eclipse.text.edits.ReplaceEdit;
+import org.eclipse.text.edits.TextEdit;
+import org.eclipse.text.edits.TextEditGroup;
+import org.eclipse.ui.IEditorPart;
+import org.eclipse.wst.sse.core.StructuredModelManager;
+import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
+import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
+import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocument;
+import org.eclipse.wst.sse.core.internal.provisional.text.IStructuredDocumentRegion;
+import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegion;
+import org.eclipse.wst.sse.core.internal.provisional.text.ITextRegionList;
+import org.eclipse.wst.xml.core.internal.regions.DOMRegionContext;
+import org.w3c.dom.Node;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+
+/**
+ * This refactoring extracts a string from a file and replaces it by an Android resource ID
+ * such as R.string.foo.
+ * <p/>
+ * There are a number of scenarios, which are not all supported yet. The workflow works as
+ * such:
+ * <ul>
+ * <li> User selects a string in a Java and invokes the {@link ExtractStringAction}.
+ * <li> The action finds the {@link ICompilationUnit} being edited as well as the current
+ * {@link ITextSelection}. The action creates a new instance of this refactoring as
+ * well as an {@link ExtractStringWizard} and runs the operation.
+ * <li> Step 1 of the refactoring is to check the preliminary conditions. Right now we check
+ * that the java source is not read-only and is in sync. We also try to find a string under
+ * the selection. If this fails, the refactoring is aborted.
+ * <li> On success, the wizard is shown, which lets the user input the new ID to use.
+ * <li> The wizard sets the user input values into this refactoring instance, e.g. the new string
+ * ID, the XML file to update, etc. The wizard does use the utility method
+ * {@link XmlStringFileHelper#valueOfStringId(IProject, String, String)} to check whether
+ * the new ID is already defined in the target XML file.
+ * <li> Once Preview or Finish is selected in the wizard, the
+ * {@link #checkFinalConditions(IProgressMonitor)} is called to double-check the user input
+ * and compute the actual changes.
+ * <li> When all changes are computed, {@link #createChange(IProgressMonitor)} is invoked.
+ * </ul>
+ *
+ * The list of changes are:
+ * <ul>
+ * <li> If the target XML does not exist, create it with the new string ID.
+ * <li> If the target XML exists, find the <resources> node and add the new string ID right after.
+ * If the node is <resources/>, it needs to be opened.
+ * <li> Create an AST rewriter to edit the source Java file and replace all occurrences by the
+ * new computed R.string.foo. Also need to rewrite imports to import R as needed.
+ * If there's already a conflicting R included, we need to insert the FQCN instead.
+ * <li> TODO: Have a pref in the wizard: [x] Change other XML Files
+ * <li> TODO: Have a pref in the wizard: [x] Change other Java Files
+ * </ul>
+ */
+@SuppressWarnings("restriction")
+public class ExtractStringRefactoring extends Refactoring {
+
+ public enum Mode {
+ /**
+ * the Extract String refactoring is called on an <em>existing</em> source file.
+ * Its purpose is then to get the selected string of the source and propose to
+ * change it by an XML id. The XML id may be a new one or an existing one.
+ */
+ EDIT_SOURCE,
+ /**
+ * The Extract String refactoring is called without any source file.
+ * Its purpose is then to create a new XML string ID or select/modify an existing one.
+ */
+ SELECT_ID,
+ /**
+ * The Extract String refactoring is called without any source file.
+ * Its purpose is then to create a new XML string ID. The ID must not already exist.
+ */
+ SELECT_NEW_ID
+ }
+
+ /** The {@link Mode} of operation of the refactoring. */
+ private final Mode mMode;
+ /** Non-null when editing an Android Resource XML file: identifies the attribute name
+ * of the value being edited. When null, the source is an Android Java file. */
+ private String mXmlAttributeName;
+ /** The file model being manipulated.
+ * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
+ private final IFile mFile;
+ /** The editor. Non-null when invoked from {@link ExtractStringAction}. Null otherwise. */
+ private final IEditorPart mEditor;
+ /** The project that contains {@link #mFile} and that contains the target XML file to modify. */
+ private final IProject mProject;
+ /** The start of the selection in {@link #mFile}.
+ * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
+ private final int mSelectionStart;
+ /** The end of the selection in {@link #mFile}.
+ * Value is -1 when not on {@link Mode#EDIT_SOURCE} mode. */
+ private final int mSelectionEnd;
+
+ /** The compilation unit, only defined if {@link #mFile} points to a usable Java source file. */
+ private ICompilationUnit mUnit;
+ /** The actual string selected, after UTF characters have been escaped, good for display.
+ * Value is null when not on {@link Mode#EDIT_SOURCE} mode. */
+ private String mTokenString;
+
+ /** The XML string ID selected by the user in the wizard. */
+ private String mXmlStringId;
+ /** The XML string value. Might be different than the initial selected string. */
+ private String mXmlStringValue;
+ /** The path of the XML file that will define {@link #mXmlStringId}, selected by the user
+ * in the wizard. This is relative to the project, e.g. "/res/values/string.xml" */
+ private String mTargetXmlFileWsPath;
+ /** True if we should find & replace in all Java files. */
+ private boolean mReplaceAllJava;
+ /** True if we should find & replace in all XML files of the same name in other res configs
+ * (other than the main {@link #mTargetXmlFileWsPath}.) */
+ private boolean mReplaceAllXml;
+
+ /** The list of changes computed by {@link #checkFinalConditions(IProgressMonitor)} and
+ * used by {@link #createChange(IProgressMonitor)}. */
+ private ArrayList<Change> mChanges;
+
+ private XmlStringFileHelper mXmlHelper = new XmlStringFileHelper();
+
+ private static final String KEY_MODE = "mode"; //$NON-NLS-1$
+ private static final String KEY_FILE = "file"; //$NON-NLS-1$
+ private static final String KEY_PROJECT = "proj"; //$NON-NLS-1$
+ private static final String KEY_SEL_START = "sel-start"; //$NON-NLS-1$
+ private static final String KEY_SEL_END = "sel-end"; //$NON-NLS-1$
+ private static final String KEY_TOK_ESC = "tok-esc"; //$NON-NLS-1$
+ private static final String KEY_XML_ATTR_NAME = "xml-attr-name"; //$NON-NLS-1$
+ private static final String KEY_RPLC_ALL_JAVA = "rplc-all-java"; //$NON-NLS-1$
+ private static final String KEY_RPLC_ALL_XML = "rplc-all-xml"; //$NON-NLS-1$
+
+ /**
+ * This constructor is solely used by {@link ExtractStringDescriptor},
+ * to replay a previous refactoring.
+ * <p/>
+ * To create a refactoring from code, please use one of the two other constructors.
+ *
+ * @param arguments A map previously created using {@link #createArgumentMap()}.
+ * @throws NullPointerException
+ */
+ public ExtractStringRefactoring(Map<String, String> arguments) throws NullPointerException {
+
+ mReplaceAllJava = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_JAVA));
+ mReplaceAllXml = Boolean.parseBoolean(arguments.get(KEY_RPLC_ALL_XML));
+ mMode = Mode.valueOf(arguments.get(KEY_MODE));
+
+ IPath path = Path.fromPortableString(arguments.get(KEY_PROJECT));
+ mProject = (IProject) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
+
+ if (mMode == Mode.EDIT_SOURCE) {
+ path = Path.fromPortableString(arguments.get(KEY_FILE));
+ mFile = (IFile) ResourcesPlugin.getWorkspace().getRoot().findMember(path);
+
+ mSelectionStart = Integer.parseInt(arguments.get(KEY_SEL_START));
+ mSelectionEnd = Integer.parseInt(arguments.get(KEY_SEL_END));
+ mTokenString = arguments.get(KEY_TOK_ESC);
+ mXmlAttributeName = arguments.get(KEY_XML_ATTR_NAME);
+ } else {
+ mFile = null;
+ mSelectionStart = mSelectionEnd = -1;
+ mTokenString = null;
+ mXmlAttributeName = null;
+ }
+
+ mEditor = null;
+ }
+
+ private Map<String, String> createArgumentMap() {
+ HashMap<String, String> args = new HashMap<String, String>();
+ args.put(KEY_RPLC_ALL_JAVA, Boolean.toString(mReplaceAllJava));
+ args.put(KEY_RPLC_ALL_XML, Boolean.toString(mReplaceAllXml));
+ args.put(KEY_MODE, mMode.name());
+ args.put(KEY_PROJECT, mProject.getFullPath().toPortableString());
+ if (mMode == Mode.EDIT_SOURCE) {
+ args.put(KEY_FILE, mFile.getFullPath().toPortableString());
+ args.put(KEY_SEL_START, Integer.toString(mSelectionStart));
+ args.put(KEY_SEL_END, Integer.toString(mSelectionEnd));
+ args.put(KEY_TOK_ESC, mTokenString);
+ args.put(KEY_XML_ATTR_NAME, mXmlAttributeName);
+ }
+ return args;
+ }
+
+ /**
+ * Constructor to use when the Extract String refactoring is called on an
+ * *existing* source file. Its purpose is then to get the selected string of
+ * the source and propose to change it by an XML id. The XML id may be a new one
+ * or an existing one.
+ *
+ * @param file The source file to process. Cannot be null. File must exist in workspace.
+ * @param editor The editor.
+ * @param selection The selection in the source file. Cannot be null or empty.
+ */
+ public ExtractStringRefactoring(IFile file, IEditorPart editor, ITextSelection selection) {
+ mMode = Mode.EDIT_SOURCE;
+ mFile = file;
+ mEditor = editor;
+ mProject = file.getProject();
+ mSelectionStart = selection.getOffset();
+ mSelectionEnd = mSelectionStart + Math.max(0, selection.getLength() - 1);
+ }
+
+ /**
+ * Constructor to use when the Extract String refactoring is called without
+ * any source file. Its purpose is then to create a new XML string ID.
+ * <p/>
+ * For example this is currently invoked by the ResourceChooser when
+ * the user wants to create a new string rather than select an existing one.
+ *
+ * @param project The project where the target XML file to modify is located. Cannot be null.
+ * @param enforceNew If true the XML ID must be a new one.
+ * If false, an existing ID can be used.
+ */
+ public ExtractStringRefactoring(IProject project, boolean enforceNew) {
+ mMode = enforceNew ? Mode.SELECT_NEW_ID : Mode.SELECT_ID;
+ mFile = null;
+ mEditor = null;
+ mProject = project;
+ mSelectionStart = mSelectionEnd = -1;
+ }
+
+ /**
+ * Sets the replacement string ID. Used by the wizard to set the user input.
+ */
+ public void setNewStringId(String newStringId) {
+ mXmlStringId = newStringId;
+ }
+
+ /**
+ * Sets the replacement string ID. Used by the wizard to set the user input.
+ */
+ public void setNewStringValue(String newStringValue) {
+ mXmlStringValue = newStringValue;
+ }
+
+ /**
+ * Sets the target file. This is a project path, e.g. "/res/values/strings.xml".
+ * Used by the wizard to set the user input.
+ */
+ public void setTargetFile(String targetXmlFileWsPath) {
+ mTargetXmlFileWsPath = targetXmlFileWsPath;
+ }
+
+ public void setReplaceAllJava(boolean replaceAllJava) {
+ mReplaceAllJava = replaceAllJava;
+ }
+
+ public void setReplaceAllXml(boolean replaceAllXml) {
+ mReplaceAllXml = replaceAllXml;
+ }
+
+ /**
+ * @see org.eclipse.ltk.core.refactoring.Refactoring#getName()
+ */
+ @Override
+ public String getName() {
+ if (mMode == Mode.SELECT_ID) {
+ return "Create or Use Android String";
+ } else if (mMode == Mode.SELECT_NEW_ID) {
+ return "Create New Android String";
+ }
+
+ return "Extract Android String";
+ }
+
+ public Mode getMode() {
+ return mMode;
+ }
+
+ /**
+ * Gets the actual string selected, after UTF characters have been escaped,
+ * good for display. Value can be null.
+ */
+ public String getTokenString() {
+ return mTokenString;
+ }
+
+ /** Returns the XML string ID selected by the user in the wizard. */
+ public String getXmlStringId() {
+ return mXmlStringId;
+ }
+
+ /**
+ * Step 1 of 3 of the refactoring:
+ * Checks that the current selection meets the initial condition before the ExtractString
+ * wizard is shown. The check is supposed to be lightweight and quick. Note that at that
+ * point the wizard has not been created yet.
+ * <p/>
+ * Here we scan the source buffer to find the token matching the selection.
+ * The check is successful is a Java string literal is selected, the source is in sync
+ * and is not read-only.
+ * <p/>
+ * This is also used to extract the string to be modified, so that we can display it in
+ * the refactoring wizard.
+ *
+ * @see org.eclipse.ltk.core.refactoring.Refactoring#checkInitialConditions(org.eclipse.core.runtime.IProgressMonitor)
+ *
+ * @throws CoreException
+ */
+ @Override
+ public RefactoringStatus checkInitialConditions(IProgressMonitor monitor)
+ throws CoreException, OperationCanceledException {
+
+ mUnit = null;
+ mTokenString = null;
+
+ RefactoringStatus status = new RefactoringStatus();
+
+ try {
+ monitor.beginTask("Checking preconditions...", 6);
+
+ if (mMode != Mode.EDIT_SOURCE) {
+ monitor.worked(6);
+ return status;
+ }
+
+ if (!checkSourceFile(mFile, status, monitor)) {
+ return status;
+ }
+
+ // Try to get a compilation unit from this file. If it fails, mUnit is null.
+ try {
+ mUnit = JavaCore.createCompilationUnitFrom(mFile);
+
+ // Make sure the unit is not read-only, e.g. it's not a class file or inside a Jar
+ if (mUnit.isReadOnly()) {
+ status.addFatalError("The file is read-only, please make it writeable first.");
+ return status;
+ }
+
+ // This is a Java file. Check if it contains the selection we want.
+ if (!findSelectionInJavaUnit(mUnit, status, monitor)) {
+ return status;
+ }
+
+ } catch (Exception e) {
+ // That was not a Java file. Ignore.
+ }
+
+ if (mUnit != null) {
+ monitor.worked(1);
+ return status;
+ }
+
+ // Check this a Layout XML file and get the selection and its context.
+ if (mFile != null && SdkConstants.EXT_XML.equals(mFile.getFileExtension())) {
+
+ // Currently we only support Android resource XML files, so they must have a path
+ // similar to
+ // project/res/<type>[-<configuration>]/*.xml
+ // project/AndroidManifest.xml
+ // There is no support for sub folders, so the segment count must be 4 or 2.
+ // We don't need to check the type folder name because a/ we only accept
+ // an AndroidXmlEditor source and b/ aapt generates a compilation error for
+ // unknown folders.
+
+ IPath path = mFile.getFullPath();
+ if ((path.segmentCount() == 4 &&
+ path.segment(1).equalsIgnoreCase(SdkConstants.FD_RESOURCES)) ||
+ (path.segmentCount() == 2 &&
+ path.segment(1).equalsIgnoreCase(SdkConstants.FN_ANDROID_MANIFEST_XML))) {
+ if (!findSelectionInXmlFile(mFile, status, monitor)) {
+ return status;
+ }
+ }
+ }
+
+ if (!status.isOK()) {
+ status.addFatalError(
+ "Selection must be inside a Java source or an Android Layout XML file.");
+ }
+
+ } finally {
+ monitor.done();
+ }
+
+ return status;
+ }
+
+ /**
+ * Try to find the selected Java element in the compilation unit.
+ *
+ * If selection matches a string literal, capture it, otherwise add a fatal error
+ * to the status.
+ *
+ * On success, advance the monitor by 3.
+ * Returns status.isOK().
+ */
+ private boolean findSelectionInJavaUnit(ICompilationUnit unit,
+ RefactoringStatus status, IProgressMonitor monitor) {
+ try {
+ IBuffer buffer = unit.getBuffer();
+
+ IScanner scanner = ToolFactory.createScanner(
+ false, //tokenizeComments
+ false, //tokenizeWhiteSpace
+ false, //assertMode
+ false //recordLineSeparator
+ );
+ scanner.setSource(buffer.getCharacters());
+ monitor.worked(1);
+
+ for(int token = scanner.getNextToken();
+ token != ITerminalSymbols.TokenNameEOF;
+ token = scanner.getNextToken()) {
+ if (scanner.getCurrentTokenStartPosition() <= mSelectionStart &&
+ scanner.getCurrentTokenEndPosition() >= mSelectionEnd) {
+ // found the token, but only keep if the right type
+ if (token == ITerminalSymbols.TokenNameStringLiteral) {
+ mTokenString = new String(scanner.getCurrentTokenSource());
+ }
+ break;
+ } else if (scanner.getCurrentTokenStartPosition() > mSelectionEnd) {
+ // scanner is past the selection, abort.
+ break;
+ }
+ }
+ } catch (JavaModelException e1) {
+ // Error in unit.getBuffer. Ignore.
+ } catch (InvalidInputException e2) {
+ // Error in scanner.getNextToken. Ignore.
+ } finally {
+ monitor.worked(1);
+ }
+
+ if (mTokenString != null) {
+ // As a literal string, the token should have surrounding quotes. Remove them.
+ // Note: unquoteAttrValue technically removes either " or ' paired quotes, whereas
+ // the Java token should only have " quotes. Since we know the type to be a string
+ // literal, there should be no confusion here.
+ mTokenString = unquoteAttrValue(mTokenString);
+
+ // We need a non-empty string literal
+ if (mTokenString.length() == 0) {
+ mTokenString = null;
+ }
+ }
+
+ if (mTokenString == null) {
+ status.addFatalError("Please select a Java string literal.");
+ }
+
+ monitor.worked(1);
+ return status.isOK();
+ }
+
+ /**
+ * Try to find the selected XML element. This implementation replies on the refactoring
+ * originating from an Android Layout Editor. We rely on some internal properties of the
+ * Structured XML editor to retrieve file content to avoid parsing it again. We also rely
+ * on our specific Android XML model to get element & attribute descriptor properties.
+ *
+ * If selection matches a string literal, capture it, otherwise add a fatal error
+ * to the status.
+ *
+ * On success, advance the monitor by 1.
+ * Returns status.isOK().
+ */
+ private boolean findSelectionInXmlFile(IFile file,
+ RefactoringStatus status,
+ IProgressMonitor monitor) {
+
+ try {
+ if (!(mEditor instanceof AndroidXmlEditor)) {
+ status.addFatalError("Only the Android XML Editor is currently supported.");
+ return status.isOK();
+ }
+
+ AndroidXmlEditor editor = (AndroidXmlEditor) mEditor;
+ IStructuredModel smodel = null;
+ Node node = null;
+ String currAttrName = null;
+
+ try {
+ // See the portability note in AndroidXmlEditor#getModelForRead() javadoc.
+ smodel = editor.getModelForRead();
+ if (smodel != null) {
+ // The structured model gives the us the actual XML Node element where the
+ // offset is. By using this Node, we can find the exact UiElementNode of our
+ // model and thus we'll be able to get the properties of the attribute -- to
+ // check if it accepts a string reference. This does not however tell us if
+ // the selection is actually in an attribute value, nor which attribute is
+ // being edited.
+ for(int offset = mSelectionStart; offset >= 0 && node == null; --offset) {
+ node = (Node) smodel.getIndexedRegion(offset);
+ }
+
+ if (node == null) {
+ status.addFatalError(
+ "The selection does not match any element in the XML document.");
+ return status.isOK();
+ }
+
+ if (node.getNodeType() != Node.ELEMENT_NODE) {
+ status.addFatalError("The selection is not inside an actual XML element.");
+ return status.isOK();
+ }
+
+ IStructuredDocument sdoc = smodel.getStructuredDocument();
+ if (sdoc != null) {
+ // Portability note: all the structured document implementation is
+ // under wst.sse.core.internal.provisional so we can expect it to change in
+ // a distant future if they start cleaning their codebase, however unlikely
+ // that is.
+
+ int selStart = mSelectionStart;
+ IStructuredDocumentRegion region =
+ sdoc.getRegionAtCharacterOffset(selStart);
+ if (region != null &&
+ DOMRegionContext.XML_TAG_NAME.equals(region.getType())) {
+ // Find if any sub-region representing an attribute contains the
+ // selection. If it does, returns the name of the attribute in
+ // currAttrName and returns the value in the field mTokenString.
+ currAttrName = findSelectionInRegion(region, selStart);
+
+ if (mTokenString == null) {
+ status.addFatalError(
+ "The selection is not inside an actual XML attribute value.");
+ }
+ }
+ }
+
+ if (mTokenString != null && node != null && currAttrName != null) {
+
+ // Validate that the attribute accepts a string reference.
+ // This sets mTokenString to null by side-effect when it fails and
+ // adds a fatal error to the status as needed.
+ validateSelectedAttribute(editor, node, currAttrName, status);
+
+ } else {
+ // We shouldn't get here: we're missing one of the token string, the node
+ // or the attribute name. All of them have been checked earlier so don't
+ // set any specific error.
+ mTokenString = null;
+ }
+ }
+ } catch (Throwable t) {
+ // Since we use some internal APIs, use a broad catch-all to report any
+ // unexpected issue rather than crash the whole refactoring.
+ status.addFatalError(
+ String.format("XML parsing error: %1$s", t.getMessage()));
+ } finally {
+ if (smodel != null) {
+ smodel.releaseFromRead();
+ }
+ }
+
+ } finally {
+ monitor.worked(1);
+ }
+
+ return status.isOK();
+ }
+
+ /**
+ * The region gives us the textual representation of the XML element
+ * where the selection starts, split using sub-regions. We now just
+ * need to iterate through the sub-regions to find which one
+ * contains the actual selection. We're interested in an attribute
+ * value however when we find one we want to memorize the attribute
+ * name that was defined just before.
+ *
+ * @return When the cursor is on a valid attribute name or value, returns the string of
+ * attribute name. As a side-effect, returns the value of the attribute in {@link #mTokenString}
+ */
+ private String findSelectionInRegion(IStructuredDocumentRegion region, int selStart) {
+
+ String currAttrName = null;
+
+ int startInRegion = selStart - region.getStartOffset();
+
+ int nb = region.getNumberOfRegions();
+ ITextRegionList list = region.getRegions();
+ String currAttrValue = null;
+
+ for (int i = 0; i < nb; i++) {
+ ITextRegion subRegion = list.get(i);
+ String type = subRegion.getType();
+
+ if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
+ currAttrName = region.getText(subRegion);
+
+ // I like to select the attribute definition and invoke
+ // the extract string wizard. So if the selection is on
+ // the attribute name part, find the value that is just
+ // after and use it as if it were the selection.
+
+ if (subRegion.getStart() <= startInRegion &&
+ startInRegion < subRegion.getTextEnd()) {
+ // A well-formed attribute is composed of a name,
+ // an equal sign and the value. There can't be any space
+ // in between, which makes the parsing a lot easier.
+ if (i <= nb - 3 &&
+ DOMRegionContext.XML_TAG_ATTRIBUTE_EQUALS.equals(
+ list.get(i + 1).getType())) {
+ subRegion = list.get(i + 2);
+ type = subRegion.getType();
+ if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(
+ type)) {
+ currAttrValue = region.getText(subRegion);
+ }
+ }
+ }
+
+ } else if (subRegion.getStart() <= startInRegion &&
+ startInRegion < subRegion.getTextEnd() &&
+ DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
+ currAttrValue = region.getText(subRegion);
+ }
+
+ if (currAttrValue != null) {
+ // We found the value. Only accept it if not empty
+ // and if we found an attribute name before.
+ String text = currAttrValue;
+
+ // The attribute value contains XML quotes. Remove them.
+ text = unquoteAttrValue(text);
+ if (text.length() > 0 && currAttrName != null) {
+ // Setting mTokenString to non-null marks the fact we
+ // accept this attribute.
+ mTokenString = text;
+ }
+
+ break;
+ }
+ }
+
+ return currAttrName;
+ }
+
+ /**
+ * Attribute values found as text for {@link DOMRegionContext#XML_TAG_ATTRIBUTE_VALUE}
+ * contain XML quotes. This removes the quotes (either single or double quotes).
+ *
+ * @param attrValue The attribute value, as extracted by
+ * {@link IStructuredDocumentRegion#getText(ITextRegion)}.
+ * Must not be null.
+ * @return The attribute value, without quotes. Whitespace is not trimmed, if any.
+ * String may be empty, but not null.
+ */
+ static String unquoteAttrValue(String attrValue) {
+ int len = attrValue.length();
+ int len1 = len - 1;
+ if (len >= 2 &&
+ attrValue.charAt(0) == '"' &&
+ attrValue.charAt(len1) == '"') {
+ attrValue = attrValue.substring(1, len1);
+ } else if (len >= 2 &&
+ attrValue.charAt(0) == '\'' &&
+ attrValue.charAt(len1) == '\'') {
+ attrValue = attrValue.substring(1, len1);
+ }
+
+ return attrValue;
+ }
+
+ /**
+ * Validates that the attribute accepts a string reference.
+ * This sets mTokenString to null by side-effect when it fails and
+ * adds a fatal error to the status as needed.
+ */
+ private void validateSelectedAttribute(AndroidXmlEditor editor, Node node,
+ String attrName, RefactoringStatus status) {
+ UiElementNode rootUiNode = editor.getUiRootNode();
+ UiElementNode currentUiNode =
+ rootUiNode == null ? null : rootUiNode.findXmlNode(node);
+ ReferenceAttributeDescriptor attrDesc = null;
+
+ if (currentUiNode != null) {
+ // remove any namespace prefix from the attribute name
+ String name = attrName;
+ int pos = name.indexOf(':');
+ if (pos > 0 && pos < name.length() - 1) {
+ name = name.substring(pos + 1);
+ }
+
+ for (UiAttributeNode attrNode : currentUiNode.getAllUiAttributes()) {
+ if (attrNode.getDescriptor().getXmlLocalName().equals(name)) {
+ AttributeDescriptor desc = attrNode.getDescriptor();
+ if (desc instanceof ReferenceAttributeDescriptor) {
+ attrDesc = (ReferenceAttributeDescriptor) desc;
+ }
+ break;
+ }
+ }
+ }
+
+ // The attribute descriptor is a resource reference. It must either accept
+ // of any resource type or specifically accept string types.
+ if (attrDesc != null &&
+ (attrDesc.getResourceType() == null ||
+ attrDesc.getResourceType() == ResourceType.STRING)) {
+ // We have one more check to do: is the current string value already
+ // an Android XML string reference? If so, we can't edit it.
+ if (mTokenString != null && mTokenString.startsWith("@")) { //$NON-NLS-1$
+ int pos1 = 0;
+ if (mTokenString.length() > 1 && mTokenString.charAt(1) == '+') {
+ pos1++;
+ }
+ int pos2 = mTokenString.indexOf('/');
+ if (pos2 > pos1) {
+ String kind = mTokenString.substring(pos1 + 1, pos2);
+ if (ResourceType.STRING.getName().equals(kind)) {
+ mTokenString = null;
+ status.addFatalError(String.format(
+ "The attribute %1$s already contains a %2$s reference.",
+ attrName,
+ kind));
+ }
+ }
+ }
+
+ if (mTokenString != null) {
+ // We're done with all our checks. mTokenString contains the
+ // current attribute value. We don't memorize the region nor the
+ // attribute, however we memorize the textual attribute name so
+ // that we can offer replacement for all its occurrences.
+ mXmlAttributeName = attrName;
+ }
+
+ } else {
+ mTokenString = null;
+ status.addFatalError(String.format(
+ "The attribute %1$s does not accept a string reference.",
+ attrName));
+ }
+ }
+
+ /**
+ * Tests from org.eclipse.jdt.internal.corext.refactoringChecks#validateEdit()
+ * Might not be useful.
+ *
+ * On success, advance the monitor by 2.
+ *
+ * @return False if caller should abort, true if caller should continue.
+ */
+ private boolean checkSourceFile(IFile file,
+ RefactoringStatus status,
+ IProgressMonitor monitor) {
+ // check whether the source file is in sync
+ if (!file.isSynchronized(IResource.DEPTH_ZERO)) {
+ status.addFatalError("The file is not synchronized. Please save it first.");
+ return false;
+ }
+ monitor.worked(1);
+
+ // make sure we can write to it.
+ ResourceAttributes resAttr = file.getResourceAttributes();
+ if (resAttr == null || resAttr.isReadOnly()) {
+ status.addFatalError("The file is read-only, please make it writeable first.");
+ return false;
+ }
+ monitor.worked(1);
+
+ return true;
+ }
+
+ /**
+ * Step 2 of 3 of the refactoring:
+ * Check the conditions once the user filled values in the refactoring wizard,
+ * then prepare the changes to be applied.
+ * <p/>
+ * In this case, most of the sanity checks are done by the wizard so essentially this
+ * should only be called if the wizard positively validated the user input.
+ *
+ * Here we do check that the target resource XML file either does not exists or
+ * is not read-only.
+ *
+ * @see org.eclipse.ltk.core.refactoring.Refactoring#checkFinalConditions(IProgressMonitor)
+ *
+ * @throws CoreException
+ */
+ @Override
+ public RefactoringStatus checkFinalConditions(IProgressMonitor monitor)
+ throws CoreException, OperationCanceledException {
+ RefactoringStatus status = new RefactoringStatus();
+
+ try {
+ monitor.beginTask("Checking post-conditions...", 5);
+
+ if (mXmlStringId == null || mXmlStringId.length() <= 0) {
+ // this is not supposed to happen
+ status.addFatalError("Missing replacement string ID");
+ } else if (mTargetXmlFileWsPath == null || mTargetXmlFileWsPath.length() <= 0) {
+ // this is not supposed to happen
+ status.addFatalError("Missing target xml file path");
+ }
+ monitor.worked(1);
+
+ // Either that resource must not exist or it must be a writable file.
+ IResource targetXml = getTargetXmlResource(mTargetXmlFileWsPath);
+ if (targetXml != null) {
+ if (targetXml.getType() != IResource.FILE) {
+ status.addFatalError(
+ String.format("XML file '%1$s' is not a file.", mTargetXmlFileWsPath));
+ } else {
+ ResourceAttributes attr = targetXml.getResourceAttributes();
+ if (attr != null && attr.isReadOnly()) {
+ status.addFatalError(
+ String.format("XML file '%1$s' is read-only.",
+ mTargetXmlFileWsPath));
+ }
+ }
+ }
+ monitor.worked(1);
+
+ if (status.hasError()) {
+ return status;
+ }
+
+ mChanges = new ArrayList<Change>();
+
+
+ // Prepare the change to create/edit the String ID in the res/values XML file.
+ if (!mXmlStringValue.equals(
+ mXmlHelper.valueOfStringId(mProject, mTargetXmlFileWsPath, mXmlStringId))) {
+ // We actually change it only if the ID doesn't exist yet or has a different value
+ Change change = createXmlChanges((IFile) targetXml, mXmlStringId, mXmlStringValue,
+ status, SubMonitor.convert(monitor, 1));
+ if (change != null) {
+ mChanges.add(change);
+ }
+ }
+
+ if (status.hasError()) {
+ return status;
+ }
+
+ if (mMode == Mode.EDIT_SOURCE) {
+ List<Change> changes = null;
+ if (mXmlAttributeName != null) {
+ // Prepare the change to the Android resource XML file
+ changes = computeXmlSourceChanges(mFile,
+ mXmlStringId,
+ mTokenString,
+ mXmlAttributeName,
+ true, // allConfigurations
+ status,
+ monitor);
+
+ } else if (mUnit != null) {
+ // Prepare the change to the Java compilation unit
+ changes = computeJavaChanges(mUnit, mXmlStringId, mTokenString,
+ status, SubMonitor.convert(monitor, 1));
+ }
+ if (changes != null) {
+ mChanges.addAll(changes);
+ }
+ }
+
+ if (mReplaceAllJava) {
+ String currentIdentifier = mUnit != null ? mUnit.getHandleIdentifier() : ""; //$NON-NLS-1$
+
+ SubMonitor submon = SubMonitor.convert(monitor, 1);
+ for (ICompilationUnit unit : findAllJavaUnits()) {
+ // Only process Java compilation units that exist, are not derived
+ // and are not read-only.
+ if (unit == null || !unit.exists()) {
+ continue;
+ }
+ IResource resource = unit.getResource();
+ if (resource == null || resource.isDerived()) {
+ continue;
+ }
+
+ // Ensure that we don't process the current compilation unit (processed
+ // as mUnit above) twice
+ if (currentIdentifier.equals(unit.getHandleIdentifier())) {
+ continue;
+ }
+
+ ResourceAttributes attrs = resource.getResourceAttributes();
+ if (attrs != null && attrs.isReadOnly()) {
+ continue;
+ }
+
+ List<Change> changes = computeJavaChanges(
+ unit, mXmlStringId, mTokenString,
+ status, SubMonitor.convert(submon, 1));
+ if (changes != null) {
+ mChanges.addAll(changes);
+ }
+ }
+ }
+
+ if (mReplaceAllXml) {
+ SubMonitor submon = SubMonitor.convert(monitor, 1);
+ for (IFile xmlFile : findAllResXmlFiles()) {
+ if (xmlFile != null) {
+ List<Change> changes = computeXmlSourceChanges(xmlFile,
+ mXmlStringId,
+ mTokenString,
+ mXmlAttributeName,
+ false, // allConfigurations
+ status,
+ SubMonitor.convert(submon, 1));
+ if (changes != null) {
+ mChanges.addAll(changes);
+ }
+ }
+ }
+ }
+
+ monitor.worked(1);
+ } finally {
+ monitor.done();
+ }
+
+ return status;
+ }
+
+ // --- XML changes ---
+
+ /**
+ * Returns a foreach-compatible iterator over all XML files in the project's
+ * /res folder, excluding the target XML file (the one where we'll write/edit
+ * the string id).
+ */
+ private Iterable<IFile> findAllResXmlFiles() {
+ return new Iterable<IFile>() {
+ @Override
+ public Iterator<IFile> iterator() {
+ return new Iterator<IFile>() {
+ final Queue<IFile> mFiles = new LinkedList<IFile>();
+ final Queue<IResource> mFolders = new LinkedList<IResource>();
+ IPath mFilterPath1 = null;
+ IPath mFilterPath2 = null;
+ {
+ // Filter out the XML file where we'll be writing the XML string id.
+ IResource filterRes = mProject.findMember(mTargetXmlFileWsPath);
+ if (filterRes != null) {
+ mFilterPath1 = filterRes.getFullPath();
+ }
+ // Filter out the XML source file, if any (e.g. typically a layout)
+ if (mFile != null) {
+ mFilterPath2 = mFile.getFullPath();
+ }
+
+ // We want to process the manifest
+ IResource man = mProject.findMember("AndroidManifest.xml"); // TODO find a constant
+ if (man.exists() && man instanceof IFile && !man.equals(mFile)) {
+ mFiles.add((IFile) man);
+ }
+
+ // Add all /res folders (technically we don't need to process /res/values
+ // XML files that contain resources/string elements, but it's easier to
+ // not filter them out.)
+ IFolder f = mProject.getFolder(AdtConstants.WS_RESOURCES);
+ if (f.exists()) {
+ try {
+ mFolders.addAll(
+ Arrays.asList(f.members(IContainer.EXCLUDE_DERIVED)));
+ } catch (CoreException e) {
+ // pass
+ }
+ }
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (!mFiles.isEmpty()) {
+ return true;
+ }
+
+ while (!mFolders.isEmpty()) {
+ IResource res = mFolders.poll();
+ if (res.exists() && res instanceof IFolder) {
+ IFolder f = (IFolder) res;
+ try {
+ getFileList(f);
+ if (!mFiles.isEmpty()) {
+ return true;
+ }
+ } catch (CoreException e) {
+ // pass
+ }
+ }
+ }
+ return false;
+ }
+
+ private void getFileList(IFolder folder) throws CoreException {
+ for (IResource res : folder.members(IContainer.EXCLUDE_DERIVED)) {
+ // Only accept file resources which are not derived and actually exist
+ if (res.exists() && !res.isDerived() && res instanceof IFile) {
+ IFile file = (IFile) res;
+ // Must have an XML extension
+ if (SdkConstants.EXT_XML.equals(file.getFileExtension())) {
+ IPath p = file.getFullPath();
+ // And not be either paths we want to filter out
+ if ((mFilterPath1 != null && mFilterPath1.equals(p)) ||
+ (mFilterPath2 != null && mFilterPath2.equals(p))) {
+ continue;
+ }
+ mFiles.add(file);
+ }
+ }
+ }
+ }
+
+ @Override
+ public IFile next() {
+ IFile file = mFiles.poll();
+ hasNext();
+ return file;
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException(
+ "This iterator does not support removal"); //$NON-NLS-1$
+ }
+ };
+ }
+ };
+ }
+
+ /**
+ * Internal helper that actually prepares the {@link Change} that adds the given
+ * ID to the given XML File.
+ * <p/>
+ * This does not actually modify the file.
+ *
+ * @param targetXml The file resource to modify.
+ * @param xmlStringId The new ID to insert.
+ * @param tokenString The old string, which will be the value in the XML string.
+ * @return A new {@link TextEdit} that describes how to change the file.
+ */
+ private Change createXmlChanges(IFile targetXml,
+ String xmlStringId,
+ String tokenString,
+ RefactoringStatus status,
+ SubMonitor monitor) {
+
+ TextFileChange xmlChange = new TextFileChange(getName(), targetXml);
+ xmlChange.setTextType(SdkConstants.EXT_XML);
+
+ String error = ""; //$NON-NLS-1$
+ TextEdit edit = null;
+ TextEditGroup editGroup = null;
+
+ try {
+ if (!targetXml.exists()) {
+ // Kludge: use targetXml==null as a signal this is a new file being created
+ targetXml = null;
+ }
+
+ edit = createXmlReplaceEdit(targetXml, xmlStringId, tokenString, status,
+ SubMonitor.convert(monitor, 1));
+ } catch (IOException e) {
+ error = e.toString();
+ } catch (CoreException e) {
+ // Failed to read file. Ignore. Will handle error below.
+ error = e.toString();
+ }
+
+ if (edit == null) {
+ status.addFatalError(String.format("Failed to modify file %1$s%2$s",
+ targetXml == null ? "" : targetXml.getFullPath(), //$NON-NLS-1$
+ error == null ? "" : ": " + error)); //$NON-NLS-1$
+ return null;
+ }
+
+ editGroup = new TextEditGroup(targetXml == null ? "Create <string> in new XML file"
+ : "Insert <string> in XML file",
+ edit);
+
+ xmlChange.setEdit(edit);
+ // The TextEditChangeGroup let the user toggle this change on and off later.
+ xmlChange.addTextEditChangeGroup(new TextEditChangeGroup(xmlChange, editGroup));
+
+ monitor.worked(1);
+ return xmlChange;
+ }
+
+ /**
+ * Scan the XML file to find the best place where to insert the new string element.
+ * <p/>
+ * This handles a variety of cases, including replacing existing ids in place,
+ * adding the top resources element if missing and the XML PI if not present.
+ * It tries to preserve indentation when adding new elements at the end of an existing XML.
+ *
+ * @param file The XML file to modify, that must be present in the workspace.
+ * Pass null to create a change for a new file that doesn't exist yet.
+ * @param xmlStringId The new ID to insert.
+ * @param tokenString The old string, which will be the value in the XML string.
+ * @param status The in-out refactoring status. Used to log a more detailed error if the
+ * XML has a top element that is not a resources element.
+ * @param monitor A monitor to track progress.
+ * @return A new {@link TextEdit} for either a replace or an insert operation, or null in case
+ * of error.
+ * @throws CoreException - if the file's contents or description can not be read.
+ * @throws IOException - if the file's contents can not be read or its detected encoding does
+ * not support its contents.
+ */
+ private TextEdit createXmlReplaceEdit(IFile file,
+ String xmlStringId,
+ String tokenString,
+ RefactoringStatus status,
+ SubMonitor monitor)
+ throws IOException, CoreException {
+
+ IModelManager modelMan = StructuredModelManager.getModelManager();
+
+ final String NODE_RESOURCES = SdkConstants.TAG_RESOURCES;
+ final String NODE_STRING = SdkConstants.TAG_STRING;
+ final String ATTR_NAME = SdkConstants.ATTR_NAME;
+
+
+ // Scan the source to find the best insertion point.
+
+ // 1- The most common case we need to handle is the one of inserting at the end
+ // of a valid XML document, respecting the whitespace last used.
+ //
+ // Ideally we have this structure:
+ // <xml ...>
+ // <resource>
+ // ...ws1...<string>blah</string>...ws2...
+ // </resource>
+ //
+ // where ws1 and ws2 are the whitespace respectively before and after the last element
+ // just before the closing </resource>.
+ // In this case we want to generate the new string just before ws2...</resource> with
+ // the same whitespace as ws1.
+ //
+ // 2- Another expected case is there's already an existing string which "name" attribute
+ // equals to xmlStringId and we just want to replace its value.
+ //
+ // Other cases we need to handle:
+ // 3- There is no element at all -> create a full new <resource>+<string> content.
+ // 4- There is <resource/>, that is the tag is not opened. This can be handled as the
+ // previous case, generating full content but also replacing <resource/>.
+ // 5- There is a top element that is not <resource>. That's a fatal error and we abort.
+
+ IStructuredModel smodel = null;
+
+ // Single and double quotes must be escaped in the <string>value</string> declaration
+ tokenString = ValueXmlHelper.escapeResourceString(tokenString);
+
+ try {
+ IStructuredDocument sdoc = null;
+ boolean checkTopElement = true;
+ boolean replaceStringContent = false;
+ boolean hasPiXml = false;
+ int newResStart = 0;
+ int newResLength = 0;
+ String lineSep = "\n"; //$NON-NLS-1$
+
+ if (file != null) {
+ smodel = modelMan.getExistingModelForRead(file);
+ if (smodel != null) {
+ sdoc = smodel.getStructuredDocument();
+ } else if (smodel == null) {
+ // The model is not currently open.
+ if (file.exists()) {
+ sdoc = modelMan.createStructuredDocumentFor(file);
+ } else {
+ sdoc = modelMan.createNewStructuredDocumentFor(file);
+ }
+ }
+ }
+
+ if (sdoc == null && file != null) {
+ // Get a document matching the actual saved file
+ sdoc = modelMan.createStructuredDocumentFor(file);
+ }
+
+ if (sdoc != null) {
+ String wsBefore = ""; //$NON-NLS-1$
+ String lastWs = null;
+
+ lineSep = sdoc.getLineDelimiter();
+ if (lineSep == null || lineSep.length() == 0) {
+ // That wasn't too useful, let's go back to a reasonable default
+ lineSep = "\n"; //$NON-NLS-1$
+ }
+
+ for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) {
+ String type = regions.getType();
+
+ if (DOMRegionContext.XML_CONTENT.equals(type)) {
+
+ if (replaceStringContent) {
+ // Generate a replacement for a <string> value matching the string ID.
+ return new ReplaceEdit(
+ regions.getStartOffset(), regions.getLength(), tokenString);
+ }
+
+ // Otherwise capture what should be whitespace content
+ lastWs = regions.getFullText();
+ continue;
+
+ } else if (DOMRegionContext.XML_PI_OPEN.equals(type) && !hasPiXml) {
+
+ int nb = regions.getNumberOfRegions();
+ ITextRegionList list = regions.getRegions();
+ for (int i = 0; i < nb; i++) {
+ ITextRegion region = list.get(i);
+ type = region.getType();
+ if (DOMRegionContext.XML_TAG_NAME.equals(type)) {
+ String name = regions.getText(region);
+ if ("xml".equals(name)) { //$NON-NLS-1$
+ hasPiXml = true;
+ break;
+ }
+ }
+ }
+ continue;
+
+ } else if (!DOMRegionContext.XML_TAG_NAME.equals(type)) {
+ // ignore things which are not a tag nor text content (such as comments)
+ continue;
+ }
+
+ int nb = regions.getNumberOfRegions();
+ ITextRegionList list = regions.getRegions();
+
+ String name = null;
+ String attrName = null;
+ String attrValue = null;
+ boolean isEmptyTag = false;
+ boolean isCloseTag = false;
+
+ for (int i = 0; i < nb; i++) {
+ ITextRegion region = list.get(i);
+ type = region.getType();
+
+ if (DOMRegionContext.XML_END_TAG_OPEN.equals(type)) {
+ isCloseTag = true;
+ } else if (DOMRegionContext.XML_EMPTY_TAG_CLOSE.equals(type)) {
+ isEmptyTag = true;
+ } else if (DOMRegionContext.XML_TAG_NAME.equals(type)) {
+ name = regions.getText(region);
+ } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type) &&
+ NODE_STRING.equals(name)) {
+ // Record the attribute names into a <string> element.
+ attrName = regions.getText(region);
+ } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type) &&
+ ATTR_NAME.equals(attrName)) {
+ // Record the value of a <string name=...> attribute
+ attrValue = regions.getText(region);
+
+ if (attrValue != null &&
+ unquoteAttrValue(attrValue).equals(xmlStringId)) {
+ // We found a <string name=> matching the string ID to replace.
+ // We'll generate a replacement when we process the string value
+ // (that is the next XML_CONTENT region.)
+ replaceStringContent = true;
+ }
+ }
+ }
+
+ if (checkTopElement) {
+ // Check the top element has a resource name
+ checkTopElement = false;
+ if (!NODE_RESOURCES.equals(name)) {
+ status.addFatalError(
+ String.format("XML file lacks a <resource> tag: %1$s",
+ mTargetXmlFileWsPath));
+ return null;
+
+ }
+
+ if (isEmptyTag) {
+ // The top element is an empty "<resource/>" tag. We need to do
+ // a full new resource+string replacement.
+ newResStart = regions.getStartOffset();
+ newResLength = regions.getLength();
+ }
+ }
+
+ if (NODE_RESOURCES.equals(name)) {
+ if (isCloseTag) {
+ // We found the </resource> tag and we want
+ // to insert just before this one.
+
+ StringBuilder content = new StringBuilder();
+ content.append(wsBefore)
+ .append("<string name=\"") //$NON-NLS-1$
+ .append(xmlStringId)
+ .append("\">") //$NON-NLS-1$
+ .append(tokenString)
+ .append("</string>"); //$NON-NLS-1$
+
+ // Backup to insert before the whitespace preceding </resource>
+ IStructuredDocumentRegion insertBeforeReg = regions;
+ while (true) {
+ IStructuredDocumentRegion previous = insertBeforeReg.getPrevious();
+ if (previous != null &&
+ DOMRegionContext.XML_CONTENT.equals(previous.getType()) &&
+ previous.getText().trim().length() == 0) {
+ insertBeforeReg = previous;
+ } else {
+ break;
+ }
+ }
+ if (insertBeforeReg == regions) {
+ // If we have not found any whitespace before </resources>,
+ // at least add a line separator.
+ content.append(lineSep);
+ }
+
+ return new InsertEdit(insertBeforeReg.getStartOffset(),
+ content.toString());
+ }
+ } else {
+ // For any other tag than <resource>, capture whitespace before and after.
+ if (!isCloseTag) {
+ wsBefore = lastWs;
+ }
+ }
+ }
+ }
+
+ // We reach here either because there's no XML content at all or because
+ // there's an empty <resource/>.
+ // Provide a full new resource+string replacement.
+ StringBuilder content = new StringBuilder();
+ if (!hasPiXml) {
+ content.append("<?xml version=\"1.0\" encoding=\"utf-8\"?>"); //$NON-NLS-1$
+ content.append(lineSep);
+ } else if (newResLength == 0 && sdoc != null) {
+ // If inserting at the end, check if the last region is some whitespace.
+ // If there's no newline, insert one ourselves.
+ IStructuredDocumentRegion lastReg = sdoc.getLastStructuredDocumentRegion();
+ if (lastReg != null && lastReg.getText().indexOf('\n') == -1) {
+ content.append('\n');
+ }
+ }
+
+ // FIXME how to access formatting preferences to generate the proper indentation?
+ content.append("<resources>").append(lineSep); //$NON-NLS-1$
+ content.append(" <string name=\"") //$NON-NLS-1$
+ .append(xmlStringId)
+ .append("\">") //$NON-NLS-1$
+ .append(tokenString)
+ .append("</string>") //$NON-NLS-1$
+ .append(lineSep);
+ content.append("</resources>").append(lineSep); //$NON-NLS-1$
+
+ if (newResLength > 0) {
+ // Replace existing piece
+ return new ReplaceEdit(newResStart, newResLength, content.toString());
+ } else {
+ // Insert at the end.
+ int offset = sdoc == null ? 0 : sdoc.getLength();
+ return new InsertEdit(offset, content.toString());
+ }
+ } catch (IOException e) {
+ // This is expected to happen and is properly reported to the UI.
+ throw e;
+ } catch (CoreException e) {
+ // This is expected to happen and is properly reported to the UI.
+ throw e;
+ } catch (Throwable t) {
+ // Since we use some internal APIs, use a broad catch-all to report any
+ // unexpected issue rather than crash the whole refactoring.
+ status.addFatalError(
+ String.format("XML replace error: %1$s", t.getMessage()));
+ } finally {
+ if (smodel != null) {
+ smodel.releaseFromRead();
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Computes the changes to be made to the source Android XML file and
+ * returns a list of {@link Change}.
+ * <p/>
+ * This function scans an XML file, looking for an attribute value equals to
+ * <code>tokenString</code>. If non null, <code>xmlAttrName</code> limit the search
+ * to only attributes that have that name.
+ * If found, a change is made to replace each occurrence of <code>tokenString</code>
+ * by a new "@string/..." using the new <code>xmlStringId</code>.
+ *
+ * @param sourceFile The file to process.
+ * A status error will be generated if it does not exists.
+ * Must not be null.
+ * @param tokenString The string to find. Must not be null or empty.
+ * @param xmlAttrName Optional attribute name to limit the search. Can be null.
+ * @param allConfigurations True if this function should can all XML files with the same
+ * name and the same resource type folder but with different configurations.
+ * @param status Status used to report fatal errors.
+ * @param monitor Used to log progress.
+ */
+ private List<Change> computeXmlSourceChanges(IFile sourceFile,
+ String xmlStringId,
+ String tokenString,
+ String xmlAttrName,
+ boolean allConfigurations,
+ RefactoringStatus status,
+ IProgressMonitor monitor) {
+
+ if (!sourceFile.exists()) {
+ status.addFatalError(String.format("XML file '%1$s' does not exist.",
+ sourceFile.getFullPath().toOSString()));
+ return null;
+ }
+
+ // We shouldn't be trying to replace a null or empty string.
+ assert tokenString != null && tokenString.length() > 0;
+ if (tokenString == null || tokenString.length() == 0) {
+ return null;
+ }
+
+ // Note: initially this method was only processing files using a pattern
+ // /project/res/<type>-<configuration>/<filename.xml>
+ // However the last version made that more generic to be able to process any XML
+ // files. We should probably revisit and simplify this later.
+ HashSet<IFile> files = new HashSet<IFile>();
+ files.add(sourceFile);
+
+ if (allConfigurations && SdkConstants.EXT_XML.equals(sourceFile.getFileExtension())) {
+ IPath path = sourceFile.getFullPath();
+ if (path.segmentCount() == 4 && path.segment(1).equals(SdkConstants.FD_RESOURCES)) {
+ IProject project = sourceFile.getProject();
+ String filename = path.segment(3);
+ String initialTypeName = path.segment(2);
+ ResourceFolderType type = ResourceFolderType.getFolderType(initialTypeName);
+
+ IContainer res = sourceFile.getParent().getParent();
+ if (type != null && res != null && res.getType() == IResource.FOLDER) {
+ try {
+ for (IResource r : res.members()) {
+ if (r != null && r.getType() == IResource.FOLDER) {
+ String name = r.getName();
+ // Skip the initial folder name, it's already in the list.
+ if (!name.equals(initialTypeName)) {
+ // Only accept the same folder type (e.g. layout-*)
+ ResourceFolderType t =
+ ResourceFolderType.getFolderType(name);
+ if (type.equals(t)) {
+ // recompute the path
+ IPath p = res.getProjectRelativePath().append(name).
+ append(filename);
+ IResource f = project.findMember(p);
+ if (f != null && f instanceof IFile) {
+ files.add((IFile) f);
+ }
+ }
+ }
+ }
+ }
+ } catch (CoreException e) {
+ // Ignore.
+ }
+ }
+ }
+ }
+
+ SubMonitor subMonitor = SubMonitor.convert(monitor, Math.min(1, files.size()));
+
+ ArrayList<Change> changes = new ArrayList<Change>();
+
+ // Portability note: getModelManager is part of wst.sse.core however the
+ // interface returned is part of wst.sse.core.internal.provisional so we can
+ // expect it to change in a distant future if they start cleaning their codebase,
+ // however unlikely that is.
+ IModelManager modelManager = StructuredModelManager.getModelManager();
+
+ for (IFile file : files) {
+
+ IStructuredModel smodel = null;
+ MultiTextEdit multiEdit = null;
+ TextFileChange xmlChange = null;
+ ArrayList<TextEditGroup> editGroups = null;
+
+ try {
+ IStructuredDocument sdoc = null;
+
+ smodel = modelManager.getExistingModelForRead(file);
+ if (smodel != null) {
+ sdoc = smodel.getStructuredDocument();
+ } else if (smodel == null) {
+ // The model is not currently open.
+ if (file.exists()) {
+ sdoc = modelManager.createStructuredDocumentFor(file);
+ } else {
+ sdoc = modelManager.createNewStructuredDocumentFor(file);
+ }
+ }
+
+ if (sdoc == null) {
+ status.addFatalError("XML structured document not found"); //$NON-NLS-1$
+ continue;
+ }
+
+ multiEdit = new MultiTextEdit();
+ editGroups = new ArrayList<TextEditGroup>();
+ xmlChange = new TextFileChange(getName(), file);
+ xmlChange.setTextType("xml"); //$NON-NLS-1$
+
+ String quotedReplacement = quotedAttrValue(STRING_PREFIX + xmlStringId);
+
+ // Prepare the change set
+ for (IStructuredDocumentRegion regions : sdoc.getStructuredDocumentRegions()) {
+ // Only look at XML "top regions"
+ if (!DOMRegionContext.XML_TAG_NAME.equals(regions.getType())) {
+ continue;
+ }
+
+ int nb = regions.getNumberOfRegions();
+ ITextRegionList list = regions.getRegions();
+ String lastAttrName = null;
+
+ for (int i = 0; i < nb; i++) {
+ ITextRegion subRegion = list.get(i);
+ String type = subRegion.getType();
+
+ if (DOMRegionContext.XML_TAG_ATTRIBUTE_NAME.equals(type)) {
+ // Memorize the last attribute name seen
+ lastAttrName = regions.getText(subRegion);
+
+ } else if (DOMRegionContext.XML_TAG_ATTRIBUTE_VALUE.equals(type)) {
+ // Check this is the attribute and the original string
+ String text = regions.getText(subRegion);
+
+ // Remove " or ' quoting present in the attribute value
+ text = unquoteAttrValue(text);
+
+ if (tokenString.equals(text) &&
+ (xmlAttrName == null || xmlAttrName.equals(lastAttrName))) {
+
+ // Found an occurrence. Create a change for it.
+ TextEdit edit = new ReplaceEdit(
+ regions.getStartOffset() + subRegion.getStart(),
+ subRegion.getTextLength(),
+ quotedReplacement);
+ TextEditGroup editGroup = new TextEditGroup(
+ "Replace attribute string by ID",
+ edit);
+
+ multiEdit.addChild(edit);
+ editGroups.add(editGroup);
+ }
+ }
+ }
+ }
+ } catch (Throwable t) {
+ // Since we use some internal APIs, use a broad catch-all to report any
+ // unexpected issue rather than crash the whole refactoring.
+ status.addFatalError(
+ String.format("XML refactoring error: %1$s", t.getMessage()));
+ } finally {
+ if (smodel != null) {
+ smodel.releaseFromRead();
+ }
+
+ if (multiEdit != null &&
+ xmlChange != null &&
+ editGroups != null &&
+ multiEdit.hasChildren()) {
+ xmlChange.setEdit(multiEdit);
+ for (TextEditGroup group : editGroups) {
+ xmlChange.addTextEditChangeGroup(
+ new TextEditChangeGroup(xmlChange, group));
+ }
+ changes.add(xmlChange);
+ }
+ subMonitor.worked(1);
+ }
+ } // for files
+
+ if (changes.size() > 0) {
+ return changes;
+ }
+ return null;
+ }
+
+ /**
+ * Returns a quoted attribute value suitable to be placed after an attributeName=
+ * statement in an XML stream.
+ *
+ * According to http://www.w3.org/TR/2008/REC-xml-20081126/#NT-AttValue
+ * the attribute value can be either quoted using ' or " and the corresponding
+ * entities &apos; or &quot; must be used inside.
+ */
+ private String quotedAttrValue(String attrValue) {
+ if (attrValue.indexOf('"') == -1) {
+ // no double-quotes inside, use double-quotes around.
+ return '"' + attrValue + '"';
+ }
+ if (attrValue.indexOf('\'') == -1) {
+ // no single-quotes inside, use single-quotes around.
+ return '\'' + attrValue + '\'';
+ }
+ // If we get here, there's a mix. Opt for double-quote around and replace
+ // inner double-quotes.
+ attrValue = attrValue.replace("\"", QUOT_ENTITY); //$NON-NLS-1$
+ return '"' + attrValue + '"';
+ }
+
+ // --- Java changes ---
+
+ /**
+ * Returns a foreach compatible iterator over all ICompilationUnit in the project.
+ */
+ private Iterable<ICompilationUnit> findAllJavaUnits() {
+ final IJavaProject javaProject = JavaCore.create(mProject);
+
+ return new Iterable<ICompilationUnit>() {
+ @Override
+ public Iterator<ICompilationUnit> iterator() {
+ return new Iterator<ICompilationUnit>() {
+ final Queue<ICompilationUnit> mUnits = new LinkedList<ICompilationUnit>();
+ final Queue<IPackageFragment> mFragments = new LinkedList<IPackageFragment>();
+ {
+ try {
+ IPackageFragment[] tmpFrags = javaProject.getPackageFragments();
+ if (tmpFrags != null && tmpFrags.length > 0) {
+ mFragments.addAll(Arrays.asList(tmpFrags));
+ }
+ } catch (JavaModelException e) {
+ // pass
+ }
+ }
+
+ @Override
+ public boolean hasNext() {
+ if (!mUnits.isEmpty()) {
+ return true;
+ }
+
+ while (!mFragments.isEmpty()) {
+ try {
+ IPackageFragment fragment = mFragments.poll();
+ if (fragment.getKind() == IPackageFragmentRoot.K_SOURCE) {
+ ICompilationUnit[] tmpUnits = fragment.getCompilationUnits();
+ if (tmpUnits != null && tmpUnits.length > 0) {
+ mUnits.addAll(Arrays.asList(tmpUnits));
+ return true;
+ }
+ }
+ } catch (JavaModelException e) {
+ // pass
+ }
+ }
+ return false;
+ }
+
+ @Override
+ public ICompilationUnit next() {
+ ICompilationUnit unit = mUnits.poll();
+ hasNext();
+ return unit;
+ }
+
+ @Override
+ public void remove() {
+ throw new UnsupportedOperationException(
+ "This iterator does not support removal"); //$NON-NLS-1$
+ }
+ };
+ }
+ };
+ }
+
+ /**
+ * Computes the changes to be made to Java file(s) and returns a list of {@link Change}.
+ * <p/>
+ * This function scans a Java compilation unit using {@link ReplaceStringsVisitor}, looking
+ * for a string literal equals to <code>tokenString</code>.
+ * If found, a change is made to replace each occurrence of <code>tokenString</code> by
+ * a piece of Java code that somehow accesses R.string.<code>xmlStringId</code>.
+ *
+ * @param unit The compilated unit to process. Must not be null.
+ * @param tokenString The string to find. Must not be null or empty.
+ * @param status Status used to report fatal errors.
+ * @param monitor Used to log progress.
+ */
+ private List<Change> computeJavaChanges(ICompilationUnit unit,
+ String xmlStringId,
+ String tokenString,
+ RefactoringStatus status,
+ SubMonitor monitor) {
+
+ // We shouldn't be trying to replace a null or empty string.
+ assert tokenString != null && tokenString.length() > 0;
+ if (tokenString == null || tokenString.length() == 0) {
+ return null;
+ }
+
+ // Get the Android package name from the Android Manifest. We need it to create
+ // the FQCN of the R class.
+ String packageName = null;
+ String error = null;
+ IResource manifestFile = mProject.findMember(SdkConstants.FN_ANDROID_MANIFEST_XML);
+ if (manifestFile == null || manifestFile.getType() != IResource.FILE) {
+ error = "File not found";
+ } else {
+ ManifestData manifestData = AndroidManifestHelper.parseForData((IFile) manifestFile);
+ if (manifestData == null) {
+ error = "Invalid content";
+ } else {
+ packageName = manifestData.getPackage();
+ if (packageName == null) {
+ error = "Missing package definition";
+ }
+ }
+ }
+
+ if (error != null) {
+ status.addFatalError(
+ String.format("Failed to parse file %1$s: %2$s.",
+ manifestFile == null ? "" : manifestFile.getFullPath(), //$NON-NLS-1$
+ error));
+ return null;
+ }
+
+ // Right now the changes array will contain one TextFileChange at most.
+ ArrayList<Change> changes = new ArrayList<Change>();
+
+ // This is the unit that will be modified.
+ TextFileChange change = new TextFileChange(getName(), (IFile) unit.getResource());
+ change.setTextType("java"); //$NON-NLS-1$
+
+ // Create an AST for this compilation unit
+ ASTParser parser = ASTParser.newParser(AST.JLS3);
+ parser.setProject(unit.getJavaProject());
+ parser.setSource(unit);
+ parser.setResolveBindings(true);
+ ASTNode node = parser.createAST(monitor.newChild(1));
+
+ // The ASTNode must be a CompilationUnit, by design
+ if (!(node instanceof CompilationUnit)) {
+ status.addFatalError(String.format("Internal error: ASTNode class %s", //$NON-NLS-1$
+ node.getClass()));
+ return null;
+ }
+
+ // ImportRewrite will allow us to add the new type to the imports and will resolve
+ // what the Java source must reference, e.g. the FQCN or just the simple name.
+ ImportRewrite importRewrite = ImportRewrite.create((CompilationUnit) node, true);
+ String Rqualifier = packageName + ".R"; //$NON-NLS-1$
+ Rqualifier = importRewrite.addImport(Rqualifier);
+
+ // Rewrite the AST itself via an ASTVisitor
+ AST ast = node.getAST();
+ ASTRewrite astRewrite = ASTRewrite.create(ast);
+ ArrayList<TextEditGroup> astEditGroups = new ArrayList<TextEditGroup>();
+ ReplaceStringsVisitor visitor = new ReplaceStringsVisitor(
+ ast, astRewrite, astEditGroups,
+ tokenString, Rqualifier, xmlStringId);
+ node.accept(visitor);
+
+ // Finally prepare the change set
+ try {
+ MultiTextEdit edit = new MultiTextEdit();
+
+ // Create the edit to change the imports, only if anything changed
+ TextEdit subEdit = importRewrite.rewriteImports(monitor.newChild(1));
+ if (subEdit.hasChildren()) {
+ edit.addChild(subEdit);
+ }
+
+ // Create the edit to change the Java source, only if anything changed
+ subEdit = astRewrite.rewriteAST();
+ if (subEdit.hasChildren()) {
+ edit.addChild(subEdit);
+ }
+
+ // Only create a change set if any edit was collected
+ if (edit.hasChildren()) {
+ change.setEdit(edit);
+
+ // Create TextEditChangeGroups which let the user turn changes on or off
+ // individually. This must be done after the change.setEdit() call above.
+ for (TextEditGroup editGroup : astEditGroups) {
+ TextEditChangeGroup group = new TextEditChangeGroup(change, editGroup);
+ if (editGroup instanceof EnabledTextEditGroup) {
+ group.setEnabled(((EnabledTextEditGroup) editGroup).isEnabled());
+ }
+ change.addTextEditChangeGroup(group);
+ }
+
+ changes.add(change);
+ }
+
+ monitor.worked(1);
+
+ if (changes.size() > 0) {
+ return changes;
+ }
+
+ } catch (CoreException e) {
+ // ImportRewrite.rewriteImports failed.
+ status.addFatalError(e.getMessage());
+ }
+ return null;
+ }
+
+ // ----
+
+ /**
+ * Step 3 of 3 of the refactoring: returns the {@link Change} that will be able to do the
+ * work and creates a descriptor that can be used to replay that refactoring later.
+ *
+ * @see org.eclipse.ltk.core.refactoring.Refactoring#createChange(org.eclipse.core.runtime.IProgressMonitor)
+ *
+ * @throws CoreException
+ */
+ @Override
+ public Change createChange(IProgressMonitor monitor)
+ throws CoreException, OperationCanceledException {
+
+ try {
+ monitor.beginTask("Applying changes...", 1);
+
+ CompositeChange change = new CompositeChange(
+ getName(),
+ mChanges.toArray(new Change[mChanges.size()])) {
+ @Override
+ public ChangeDescriptor getDescriptor() {
+
+ String comment = String.format(
+ "Extracts string '%1$s' into R.string.%2$s",
+ mTokenString,
+ mXmlStringId);
+
+ ExtractStringDescriptor desc = new ExtractStringDescriptor(
+ mProject.getName(), //project
+ comment, //description
+ comment, //comment
+ createArgumentMap());
+
+ return new RefactoringChangeDescriptor(desc);
+ }
+ };
+
+ monitor.worked(1);
+
+ return change;
+
+ } finally {
+ monitor.done();
+ }
+
+ }
+
+ /**
+ * Given a file project path, returns its resource in the same project than the
+ * compilation unit. The resource may not exist.
+ */
+ private IResource getTargetXmlResource(String xmlFileWsPath) {
+ IResource resource = mProject.getFile(xmlFileWsPath);
+ return resource;
+ }
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringWizard.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringWizard.java
new file mode 100644
index 000000000..556dff0df
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ExtractStringWizard.java
@@ -0,0 +1,50 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import org.eclipse.core.resources.IProject;
+import org.eclipse.ltk.ui.refactoring.RefactoringWizard;
+
+/**
+ * A wizard for ExtractString based on a simple dialog with one page.
+ *
+ * @see ExtractStringInputPage
+ * @see ExtractStringRefactoring
+ */
+public class ExtractStringWizard extends RefactoringWizard {
+
+ private final IProject mProject;
+
+ /**
+ * Create a wizard for ExtractString based on a simple dialog with one page.
+ *
+ * @param ref The instance of {@link ExtractStringRefactoring} to associate to the wizard.
+ * @param project The project where the wizard was invoked from (e.g. where the user selection
+ * happened, so that we can retrieve project resources.)
+ */
+ public ExtractStringWizard(ExtractStringRefactoring ref, IProject project) {
+ super(ref, DIALOG_BASED_USER_INTERFACE | PREVIEW_EXPAND_FIRST_NODE);
+ mProject = project;
+ setDefaultPageTitle(ref.getName());
+ }
+
+ @Override
+ protected void addUserInputPages() {
+ addPage(new ExtractStringInputPage(mProject));
+ }
+
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ReplaceStringsVisitor.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ReplaceStringsVisitor.java
new file mode 100644
index 000000000..e058ce1ba
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/ReplaceStringsVisitor.java
@@ -0,0 +1,480 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import org.eclipse.jdt.core.dom.AST;
+import org.eclipse.jdt.core.dom.ASTNode;
+import org.eclipse.jdt.core.dom.ASTVisitor;
+import org.eclipse.jdt.core.dom.Assignment;
+import org.eclipse.jdt.core.dom.ClassInstanceCreation;
+import org.eclipse.jdt.core.dom.Expression;
+import org.eclipse.jdt.core.dom.IMethodBinding;
+import org.eclipse.jdt.core.dom.ITypeBinding;
+import org.eclipse.jdt.core.dom.IVariableBinding;
+import org.eclipse.jdt.core.dom.MethodDeclaration;
+import org.eclipse.jdt.core.dom.MethodInvocation;
+import org.eclipse.jdt.core.dom.Modifier;
+import org.eclipse.jdt.core.dom.Name;
+import org.eclipse.jdt.core.dom.SimpleName;
+import org.eclipse.jdt.core.dom.SimpleType;
+import org.eclipse.jdt.core.dom.SingleVariableDeclaration;
+import org.eclipse.jdt.core.dom.StringLiteral;
+import org.eclipse.jdt.core.dom.Type;
+import org.eclipse.jdt.core.dom.TypeDeclaration;
+import org.eclipse.jdt.core.dom.VariableDeclarationExpression;
+import org.eclipse.jdt.core.dom.VariableDeclarationFragment;
+import org.eclipse.jdt.core.dom.VariableDeclarationStatement;
+import org.eclipse.jdt.core.dom.rewrite.ASTRewrite;
+import org.eclipse.text.edits.TextEditGroup;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.TreeMap;
+
+/**
+ * Visitor used by {@link ExtractStringRefactoring} to extract a string from an existing
+ * Java source and replace it by an Android XML string reference.
+ *
+ * @see ExtractStringRefactoring#computeJavaChanges
+ */
+class ReplaceStringsVisitor extends ASTVisitor {
+
+ private static final String CLASS_ANDROID_CONTEXT = "android.content.Context"; //$NON-NLS-1$
+ private static final String CLASS_JAVA_CHAR_SEQUENCE = "java.lang.CharSequence"; //$NON-NLS-1$
+ private static final String CLASS_JAVA_STRING = "java.lang.String"; //$NON-NLS-1$
+
+
+ private final AST mAst;
+ private final ASTRewrite mRewriter;
+ private final String mOldString;
+ private final String mRQualifier;
+ private final String mXmlId;
+ private final ArrayList<TextEditGroup> mEditGroups;
+
+ public ReplaceStringsVisitor(AST ast,
+ ASTRewrite astRewrite,
+ ArrayList<TextEditGroup> editGroups,
+ String oldString,
+ String rQualifier,
+ String xmlId) {
+ mAst = ast;
+ mRewriter = astRewrite;
+ mEditGroups = editGroups;
+ mOldString = oldString;
+ mRQualifier = rQualifier;
+ mXmlId = xmlId;
+ }
+
+ @SuppressWarnings("unchecked")
+ @Override
+ public boolean visit(StringLiteral node) {
+ if (node.getLiteralValue().equals(mOldString)) {
+
+ // We want to analyze the calling context to understand whether we can
+ // just replace the string literal by the named int constant (R.id.foo)
+ // or if we should generate a Context.getString() call.
+ boolean useGetResource = false;
+ useGetResource = examineVariableDeclaration(node) ||
+ examineMethodInvocation(node) ||
+ examineAssignment(node);
+
+ Name qualifierName = mAst.newName(mRQualifier + ".string"); //$NON-NLS-1$
+ SimpleName idName = mAst.newSimpleName(mXmlId);
+ ASTNode newNode = mAst.newQualifiedName(qualifierName, idName);
+ boolean disabledChange = false;
+ String title = "Replace string by ID";
+
+ if (useGetResource) {
+ Expression context = methodHasContextArgument(node);
+ if (context == null && !isClassDerivedFromContext(node)) {
+ // if we don't have a class that derives from Context and
+ // we don't have a Context method argument, then try a bit harder:
+ // can we find a method or a field that will give us a context?
+ context = findContextFieldOrMethod(node);
+
+ if (context == null) {
+ // If not, let's write Context.getString(), which is technically
+ // invalid but makes it a good clue on how to fix it. Since these
+ // will not compile, we create a disabled change by default.
+ context = mAst.newSimpleName("Context"); //$NON-NLS-1$
+ disabledChange = true;
+ }
+ }
+
+ MethodInvocation mi2 = mAst.newMethodInvocation();
+ mi2.setName(mAst.newSimpleName("getString")); //$NON-NLS-1$
+ mi2.setExpression(context);
+ mi2.arguments().add(newNode);
+
+ newNode = mi2;
+ title = "Replace string by Context.getString(R.string...)";
+ }
+
+ TextEditGroup editGroup = new EnabledTextEditGroup(title, !disabledChange);
+ mEditGroups.add(editGroup);
+ mRewriter.replace(node, newNode, editGroup);
+ }
+ return super.visit(node);
+ }
+
+ /**
+ * Examines if the StringLiteral is part of an assignment corresponding to the
+ * a string variable declaration, e.g. String foo = id.
+ *
+ * The parent fragment is of syntax "var = expr" or "var[] = expr".
+ * We want the type of the variable, which is either held by a
+ * VariableDeclarationStatement ("type [fragment]") or by a
+ * VariableDeclarationExpression. In either case, the type can be an array
+ * but for us all that matters is to know whether the type is an int or
+ * a string.
+ */
+ private boolean examineVariableDeclaration(StringLiteral node) {
+ VariableDeclarationFragment fragment = findParentClass(node,
+ VariableDeclarationFragment.class);
+
+ if (fragment != null) {
+ ASTNode parent = fragment.getParent();
+
+ Type type = null;
+ if (parent instanceof VariableDeclarationStatement) {
+ type = ((VariableDeclarationStatement) parent).getType();
+ } else if (parent instanceof VariableDeclarationExpression) {
+ type = ((VariableDeclarationExpression) parent).getType();
+ }
+
+ if (type instanceof SimpleType) {
+ return isJavaString(type.resolveBinding());
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Examines if the StringLiteral is part of a assignment to a variable that
+ * is a string. We need to lookup the variable to find its type, either in the
+ * enclosing method or class type.
+ */
+ private boolean examineAssignment(StringLiteral node) {
+
+ Assignment assignment = findParentClass(node, Assignment.class);
+ if (assignment != null) {
+ Expression left = assignment.getLeftHandSide();
+
+ ITypeBinding typeBinding = left.resolveTypeBinding();
+ return isJavaString(typeBinding);
+ }
+
+ return false;
+ }
+
+ /**
+ * If the expression is part of a method invocation (aka a function call) or a
+ * class instance creation (aka a "new SomeClass" constructor call), we try to
+ * find the type of the argument being used. If it is a String (most likely), we
+ * want to return true (to generate a getString() call). However if there might
+ * be a similar method that takes an int, in which case we don't want to do that.
+ *
+ * This covers the case of Activity.setTitle(int resId) vs setTitle(String str).
+ */
+ @SuppressWarnings("rawtypes")
+ private boolean examineMethodInvocation(StringLiteral node) {
+
+ ASTNode parent = null;
+ List arguments = null;
+ IMethodBinding methodBinding = null;
+
+ MethodInvocation invoke = findParentClass(node, MethodInvocation.class);
+ if (invoke != null) {
+ parent = invoke;
+ arguments = invoke.arguments();
+ methodBinding = invoke.resolveMethodBinding();
+ } else {
+ ClassInstanceCreation newclass = findParentClass(node, ClassInstanceCreation.class);
+ if (newclass != null) {
+ parent = newclass;
+ arguments = newclass.arguments();
+ methodBinding = newclass.resolveConstructorBinding();
+ }
+ }
+
+ if (parent != null && arguments != null && methodBinding != null) {
+ // We want to know which argument this is.
+ // Walk up the hierarchy again to find the immediate child of the parent,
+ // which should turn out to be one of the invocation arguments.
+ ASTNode child = null;
+ for (ASTNode n = node; n != parent; ) {
+ ASTNode p = n.getParent();
+ if (p == parent) {
+ child = n;
+ break;
+ }
+ n = p;
+ }
+ if (child == null) {
+ // This can't happen: a parent of 'node' must be the child of 'parent'.
+ return false;
+ }
+
+ // Find the index
+ int index = 0;
+ for (Object arg : arguments) {
+ if (arg == child) {
+ break;
+ }
+ index++;
+ }
+
+ if (index == arguments.size()) {
+ // This can't happen: one of the arguments of 'invoke' must be 'child'.
+ return false;
+ }
+
+ // Eventually we want to determine if the parameter is a string type,
+ // in which case a Context.getString() call must be generated.
+ boolean useStringType = false;
+
+ // Find the type of that argument
+ ITypeBinding[] types = methodBinding.getParameterTypes();
+ if (index < types.length) {
+ ITypeBinding type = types[index];
+ useStringType = isJavaString(type);
+ }
+
+ // Now that we know that this method takes a String parameter, can we find
+ // a variant that would accept an int for the same parameter position?
+ if (useStringType) {
+ String name = methodBinding.getName();
+ ITypeBinding clazz = methodBinding.getDeclaringClass();
+ nextMethod: for (IMethodBinding mb2 : clazz.getDeclaredMethods()) {
+ if (methodBinding == mb2 || !mb2.getName().equals(name)) {
+ continue;
+ }
+ // We found a method with the same name. We want the same parameters
+ // except that the one at 'index' must be an int type.
+ ITypeBinding[] types2 = mb2.getParameterTypes();
+ int len2 = types2.length;
+ if (types.length == len2) {
+ for (int i = 0; i < len2; i++) {
+ if (i == index) {
+ ITypeBinding type2 = types2[i];
+ if (!("int".equals(type2.getQualifiedName()))) { //$NON-NLS-1$
+ // The argument at 'index' is not an int.
+ continue nextMethod;
+ }
+ } else if (!types[i].equals(types2[i])) {
+ // One of the other arguments do not match our original method
+ continue nextMethod;
+ }
+ }
+ // If we got here, we found a perfect match: a method with the same
+ // arguments except the one at 'index' is an int. In this case we
+ // don't need to convert our R.id into a string.
+ useStringType = false;
+ break;
+ }
+ }
+ }
+
+ return useStringType;
+ }
+ return false;
+ }
+
+ /**
+ * Examines if the StringLiteral is part of a method declaration (a.k.a. a function
+ * definition) which takes a Context argument.
+ * If such, it returns the name of the variable as a {@link SimpleName}.
+ * Otherwise it returns null.
+ */
+ private SimpleName methodHasContextArgument(StringLiteral node) {
+ MethodDeclaration decl = findParentClass(node, MethodDeclaration.class);
+ if (decl != null) {
+ for (Object obj : decl.parameters()) {
+ if (obj instanceof SingleVariableDeclaration) {
+ SingleVariableDeclaration var = (SingleVariableDeclaration) obj;
+ if (isAndroidContext(var.getType())) {
+ return mAst.newSimpleName(var.getName().getIdentifier());
+ }
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Walks up the node hierarchy to find the class (aka type) where this statement
+ * is used and returns true if this class derives from android.content.Context.
+ */
+ private boolean isClassDerivedFromContext(StringLiteral node) {
+ TypeDeclaration clazz = findParentClass(node, TypeDeclaration.class);
+ if (clazz != null) {
+ // This is the class that the user is currently writing, so it can't be
+ // a Context by itself, it has to be derived from it.
+ return isAndroidContext(clazz.getSuperclassType());
+ }
+ return false;
+ }
+
+ private Expression findContextFieldOrMethod(StringLiteral node) {
+ TypeDeclaration clazz = findParentClass(node, TypeDeclaration.class);
+ return clazz == null ? null : findContextFieldOrMethod(clazz.resolveBinding());
+ }
+
+ private Expression findContextFieldOrMethod(ITypeBinding clazzType) {
+ TreeMap<Integer, Expression> results = new TreeMap<Integer, Expression>();
+ findContextCandidates(results, clazzType, 0 /*superType*/);
+ if (results.size() > 0) {
+ Integer bestRating = results.keySet().iterator().next();
+ return results.get(bestRating);
+ }
+ return null;
+ }
+
+ /**
+ * Find all method or fields that are candidates for providing a Context.
+ * There can be various choices amongst this class or its super classes.
+ * Sort them by rating in the results map.
+ *
+ * The best ever choice is to find a method with no argument that returns a Context.
+ * The second suitable choice is to find a Context field.
+ * The least desirable choice is to find a method with arguments. It's not really
+ * desirable since we can't generate these arguments automatically.
+ *
+ * Methods and fields from supertypes are ignored if they are private.
+ *
+ * The rating is reversed: the lowest rating integer is used for the best candidate.
+ * Because the superType argument is actually a recursion index, this makes the most
+ * immediate classes more desirable.
+ *
+ * @param results The map that accumulates the rating=>expression results. The lower
+ * rating number is the best candidate.
+ * @param clazzType The class examined.
+ * @param superType The recursion index.
+ * 0 for the immediate class, 1 for its super class, etc.
+ */
+ private void findContextCandidates(TreeMap<Integer, Expression> results,
+ ITypeBinding clazzType,
+ int superType) {
+ for (IMethodBinding mb : clazzType.getDeclaredMethods()) {
+ // If we're looking at supertypes, we can't use private methods.
+ if (superType != 0 && Modifier.isPrivate(mb.getModifiers())) {
+ continue;
+ }
+
+ if (isAndroidContext(mb.getReturnType())) {
+ // We found a method that returns something derived from Context.
+
+ int argsLen = mb.getParameterTypes().length;
+ if (argsLen == 0) {
+ // We'll favor any method that takes no argument,
+ // That would be the best candidate ever, so we can stop here.
+ MethodInvocation mi = mAst.newMethodInvocation();
+ mi.setName(mAst.newSimpleName(mb.getName()));
+ results.put(Integer.MIN_VALUE, mi);
+ return;
+ } else {
+ // A method with arguments isn't as interesting since we wouldn't
+ // know how to populate such arguments. We'll use it if there are
+ // no other alternatives. We'll favor the one with the less arguments.
+ Integer rating = Integer.valueOf(10000 + 1000 * superType + argsLen);
+ if (!results.containsKey(rating)) {
+ MethodInvocation mi = mAst.newMethodInvocation();
+ mi.setName(mAst.newSimpleName(mb.getName()));
+ results.put(rating, mi);
+ }
+ }
+ }
+ }
+
+ // A direct Context field would be more interesting than a method with
+ // arguments. Try to find one.
+ for (IVariableBinding var : clazzType.getDeclaredFields()) {
+ // If we're looking at supertypes, we can't use private field.
+ if (superType != 0 && Modifier.isPrivate(var.getModifiers())) {
+ continue;
+ }
+
+ if (isAndroidContext(var.getType())) {
+ // We found such a field. Let's use it.
+ Integer rating = Integer.valueOf(superType);
+ results.put(rating, mAst.newSimpleName(var.getName()));
+ break;
+ }
+ }
+
+ // Examine the super class to see if we can locate a better match
+ clazzType = clazzType.getSuperclass();
+ if (clazzType != null) {
+ findContextCandidates(results, clazzType, superType + 1);
+ }
+ }
+
+ /**
+ * Walks up the node hierarchy and returns the first ASTNode of the requested class.
+ * Only look at parents.
+ *
+ * Implementation note: this is a generic method so that it returns the node already
+ * casted to the requested type.
+ */
+ @SuppressWarnings("unchecked")
+ private <T extends ASTNode> T findParentClass(ASTNode node, Class<T> clazz) {
+ if (node != null) {
+ for (node = node.getParent(); node != null; node = node.getParent()) {
+ if (node.getClass().equals(clazz)) {
+ return (T) node;
+ }
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns true if the given type is or derives from android.content.Context.
+ */
+ private boolean isAndroidContext(Type type) {
+ if (type != null) {
+ return isAndroidContext(type.resolveBinding());
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if the given type is or derives from android.content.Context.
+ */
+ private boolean isAndroidContext(ITypeBinding type) {
+ for (; type != null; type = type.getSuperclass()) {
+ if (CLASS_ANDROID_CONTEXT.equals(type.getQualifiedName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Returns true if this type binding represents a String or CharSequence type.
+ */
+ private boolean isJavaString(ITypeBinding type) {
+ for (; type != null; type = type.getSuperclass()) {
+ if (CLASS_JAVA_STRING.equals(type.getQualifiedName()) ||
+ CLASS_JAVA_CHAR_SEQUENCE.equals(type.getQualifiedName())) {
+ return true;
+ }
+ }
+ return false;
+ }
+}
diff --git a/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/XmlStringFileHelper.java b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/XmlStringFileHelper.java
new file mode 100644
index 000000000..01e814ef2
--- /dev/null
+++ b/eclipse/plugins/com.android.ide.eclipse.adt/src/com/android/ide/eclipse/adt/internal/refactorings/extractstring/XmlStringFileHelper.java
@@ -0,0 +1,187 @@
+/*
+ * Copyright (C) 2009 The Android Open Source Project
+ *
+ * Licensed under the Eclipse Public License, Version 1.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.eclipse.org/org/documents/epl-v10.php
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ide.eclipse.adt.internal.refactorings.extractstring;
+
+import com.android.SdkConstants;
+import com.android.ide.eclipse.adt.AdtPlugin;
+
+import org.eclipse.core.resources.IFile;
+import org.eclipse.core.resources.IProject;
+import org.eclipse.core.resources.IResource;
+import org.eclipse.wst.sse.core.StructuredModelManager;
+import org.eclipse.wst.sse.core.internal.provisional.IModelManager;
+import org.eclipse.wst.sse.core.internal.provisional.IStructuredModel;
+import org.eclipse.wst.xml.core.internal.provisional.document.IDOMDocument;
+import org.eclipse.wst.xml.core.internal.provisional.document.IDOMModel;
+import org.w3c.dom.NamedNodeMap;
+import org.w3c.dom.Node;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.TreeMap;
+
+/**
+ * An helper utility to get IDs out of an Android XML resource file.
+ */
+@SuppressWarnings("restriction")
+class XmlStringFileHelper {
+
+ /** A temporary cache of R.string IDs defined by a given xml file. The key is the
+ * project path of the file, the data is a set of known string Ids for that file.
+ *
+ * Map type: map [String filename] => map [String id => String value].
+ */
+ private HashMap<String, Map<String, String>> mResIdCache =
+ new HashMap<String, Map<String, String>>();
+
+ public XmlStringFileHelper() {
+ }
+
+ /**
+ * Utility method used by the wizard to retrieve the actual value definition of a given
+ * string ID.
+ *
+ * @param project The project contain the XML file.
+ * @param xmlFileWsPath The project path of the XML file, e.g. "/res/values/strings.xml".
+ * The given file may or may not exist.
+ * @param stringId The string ID to find.
+ * @return The value string if the ID is defined, null otherwise.
+ */
+ public String valueOfStringId(IProject project, String xmlFileWsPath, String stringId) {
+ Map<String, String> cache = getResIdsForFile(project, xmlFileWsPath);
+ return cache.get(stringId);
+ }
+
+ /**
+ * Utility method that retrieves all the *string* IDs defined in the given Android resource
+ * file. The instance maintains an internal cache so a given file is retrieved only once.
+ * Callers should consider the set to be read-only.
+ *
+ * @param project The project contain the XML file.
+ * @param xmlFileWsPath The project path of the XML file, e.g. "/res/values/strings.xml".
+ * The given file may or may not exist.
+ * @return The map of string IDs => values defined in the given file. Cached. Never null.
+ */
+ public Map<String, String> getResIdsForFile(IProject project, String xmlFileWsPath) {
+ Map<String, String> cache = mResIdCache.get(xmlFileWsPath);
+ if (cache == null) {
+ cache = internalGetResIdsForFile(project, xmlFileWsPath);
+ mResIdCache.put(xmlFileWsPath, cache);
+ }
+ return cache;
+ }
+
+ /**
+ * Extract all the defined string IDs from a given file using XPath.
+ * @param project The project contain the XML file.
+ * @param xmlFileWsPath The project path of the file to parse. It may not exist.
+ * @return The map of all string IDs => values defined in the file.
+ * The returned set is always non null. It is empty if the file does not exist.
+ */
+ private Map<String, String> internalGetResIdsForFile(IProject project, String xmlFileWsPath) {
+
+ TreeMap<String, String> ids = new TreeMap<String, String>();
+
+ // Access the project that contains the resource that contains the compilation unit
+ IResource resource = project.getFile(xmlFileWsPath);
+
+ if (resource != null && resource.exists() && resource.getType() == IResource.FILE) {
+ IStructuredModel smodel = null;
+
+ try {
+ IFile file = (IFile) resource;
+ IModelManager modelMan = StructuredModelManager.getModelManager();
+ smodel = modelMan.getExistingModelForRead(file);
+ if (smodel == null) {
+ smodel = modelMan.getModelForRead(file);
+ }
+
+ if (smodel instanceof IDOMModel) {
+ IDOMDocument doc = ((IDOMModel) smodel).getDocument();
+
+ // We want all the IDs in an XML structure like this:
+ // <resources>
+ // <string name="ID">something</string>
+ // </resources>
+
+ Node root = findChild(doc, null, SdkConstants.TAG_RESOURCES);
+ if (root != null) {
+ for (Node strNode = findChild(root, null,
+ SdkConstants.TAG_STRING);
+ strNode != null;
+ strNode = findChild(null, strNode,
+ SdkConstants.TAG_STRING)) {
+ NamedNodeMap attrs = strNode.getAttributes();
+ Node nameAttr = attrs.getNamedItem(SdkConstants.ATTR_NAME);
+ if (nameAttr != null) {
+ String id = nameAttr.getNodeValue();
+
+ // Find the TEXT node right after the element.
+ // Whitespace matters so we don't try to normalize it.
+ String text = ""; //$NON-NLS-1$
+ for (Node txtNode = strNode.getFirstChild();
+ txtNode != null && txtNode.getNodeType() == Node.TEXT_NODE;
+ txtNode = txtNode.getNextSibling()) {
+ text += txtNode.getNodeValue();
+ }
+
+ ids.put(id, text);
+ }
+ }
+ }
+ }
+
+ } catch (Throwable e) {
+ AdtPlugin.log(e, "GetResIds failed in %1$s", xmlFileWsPath); //$NON-NLS-1$
+ } finally {
+ if (smodel != null) {
+ smodel.releaseFromRead();
+ }
+ }
+ }
+
+ return ids;
+ }
+
+ /**
+ * Utility method that finds the next node of the requested element name.
+ *
+ * @param parent The parent node. If not null, will to start searching its children.
+ * Set to null when iterating through children.
+ * @param lastChild The last child returned. Use null when visiting a parent the first time.
+ * @param elementName The element name of the node to find.
+ * @return The next children or sibling nide with the requested element name or null.
+ */
+ private Node findChild(Node parent, Node lastChild, String elementName) {
+ if (lastChild == null && parent != null) {
+ lastChild = parent.getFirstChild();
+ } else if (lastChild != null) {
+ lastChild = lastChild.getNextSibling();
+ }
+
+ for ( ; lastChild != null ; lastChild = lastChild.getNextSibling()) {
+ if (lastChild.getNodeType() == Node.ELEMENT_NODE &&
+ lastChild.getNamespaceURI() == null && // resources don't have any NS URI
+ elementName.equals(lastChild.getLocalName())) {
+ return lastChild;
+ }
+ }
+
+ return null;
+ }
+
+}