Создание плагина разрешения ссылок для PhpStorm (IntelliJ IDEA)
Я работаю веб-программистом, пишу на PHP и использую фреймворк Kohana. Для разработки использую потрясающую, на мой взгляд, среду PhpStorm. При работе с большими и не очень проектами меня всегда угнетало, что я много времени трачу на навигацию по проекту, на поиск того или иного файла (контроллера или шаблона) в дереве проекта. Ctrl+Shift+N, к сожалению, удобен далеко не всегда. Для начала мне захотелось сделать так, чтобы можно было переходить из файла контроллера по нажатию Ctrl+B (или Ctrl+Click) над именем шаблона, передаваемого в кохановский View::factory(), непосредственно в файл шаблона:
Поэтому я решил написать небольшой плагин для PhpStorm, который облегчил бы мою работу и освободил бы от некоторой части рутины.
Подготовка окружения
Нам потребуются: — IntelliJ IDEA Community Edition или Ultimate. — JDK ( необходимо скачать версию, с которой собран PhpStorm, иначе плагин не запустится, в моем случае это была Java 1.6); Поскольку документация по созданию плагинов IDEA очень скудна, рекомендуется также обзавестись копией исходных кодов Intellij IDEA, и использовать ее в качестве наглядной документации :)
Настройка инструментов:
Необходимо настроить Java SDK и IntelliJ IDEA Plugin SDK: — запускаем IntelliJ IDEA — открываем пункт меню File | Project Structure — выбираем вкладку SDKs, жмем на плюсик и выбираем путь к JDK — выбираем вкладку Project — нажимаем на new, далее IntelliJ IDEA Plugin SDK и в открывшемся меню — выбираем путь к PhpStorm (можно и к IntelliJ IDEA, но тогда мы не сможем отлаживать плагин в PhpStorm)
Также необходимо создать Run/Debug Configuration, чтобы можно было отлаживать плагин в PhpStorm.
Создадим проект
File | new project: Выбираем "Create from scratch", Вводим имя, выбираем тип Plugin Module, выбираем SDK, который мы настроили ранее, создаем проект. Добавляем пафосные копирайты в файл plugin.xml (без этого никак!)
<name>KohanaStorm</name>
<description>KohanaStorm framework integration for PhpStorm<br/>
Authors: zenden2k@gmail.com
</description>
<version>0.1</version>
<vendor url="http://zenden.ws/" email="zenden2k@gmail.com">zenden.ws</vendor>
<idea-version since-build="8000"/>
Чтобы наш плагин запускался не только под IDEA, но и в PhpStorm, добавим в plugin.xml следующую зависимость:
<depends>com.intellij.modules.platform</depends>
Основы
Для каждого файла IntelliJ IDEA строит дерево PSI. PSI (Program Structure Interface) — это структура, представляющая содержимое файла как иерархию элементов определенного языка программирования. PsiFile является общим родительским классом для всех PSI файлов, а конкретные языки программирования представлены в виде классов, унаследованных от PsiFile. Например, класс PsiJavaFile представляет файл java, класс XmlFile представляет XML файл. Дерево PSI можно посмотреть, используя инструмент PSI Viewer (Tools -> View PSI Structure):
Разработка плагина
Итак, мне захотелось, чтобы можно было переходить из файла контроллера по Ctrl+B (или Ctrl+Click) по View::factory('имя_шаблона') непосредственно в файл шаблона.
Как реализовать задуманное?
Для разрешения ссылок нам нужно создать 3 класса, унаследованных от: PsiReference - объект, реализующий этот интерфейс, представляет собой ссылку. Он содержит в себе данные о местонахождении в родительском элементе (положение в тексте) и данные (текст ссылки), позволяющие в дальнейшем "разрешить ссылку". Ссылка должна уметь сама себя разрешать, т.е. ее метод resolve() должен уметь найти элемент, на который она указывает. PsiReferenceProvider - класс, который находит ссылки внутри одного элемента PSI дерева. Он возвращает массив объектов PsiReference. PsiReferenceContributor - класс, который будет регистрировать наш PsiReferenceProvider как обработчик PSI элементов.
1. Создаем класс ссылки MyReference, реализующий интерфейс PsiReference, и в нем переопределить следующие методы
public class MyReference implements PsiReference {
@Override
public String toString() {
}
public PsiElement getElement() {
}
public TextRange getRangeInElement() {
return textRange;
}
public PsiElement handleElementRename(String newElementName)
}
public PsiElement bindToElement(PsiElement element) throws IncorrectOperationException {
}
public boolean isReferenceTo(PsiElement element) {
return resolve() == element;
}
public Object[] getVariants() {
return new Object[0];
}
public boolean isSoft() {
return false;
}
@Nullable
public PsiElement resolve() {
}
@Override
public String getCanonicalText() {
}
}
В этом классе самое большое значение имеет метод resolve(). В нем мы должны вернуть те элементы, на которые указывает наша ссылка. В нашем случае мы возвращаем ссылку на php-файл, но в общем случае это может быть любой элемент psi- дерева или языковой модели, лежащей над ним, например класс, метод, переменная и т.д.
2. Создаем класс, унаследованный от PsiReferenceProvider и переопределить метод getReferencesByElement:
public class MyPsiReferenceProvider extends PsiReferenceProvider {
@Override
public PsiReference[] getReferencesByElement(@NotNull PsiElement element, @NotNull final ProcessingContext context) {
}
}
Метод getReferencesByElement должен возвратить список ссылок (PsiReference), которые содержатся в переданном ему элементу PsiElement. В нашем случае возвращается только одна ссылка, но в общем случае их может быть несколько, при этом каждая ссылка должна будет содержать соответствующий textRange (начальный индекс и конечный индекс нахождения ссылки внутри текста psi-элемента) Основной проблемой при разработке этого метода стало то, что JetBrains не открыла плагинам доступа к языковому API (в нашем случае PHP). Но тут на помощь пришел Reflection. Что мы знаем об объекте element? То, что он должен быть экземпляром класса StringLiteralExpressionImpl.
public PsiReference[] getReferencesByElement(@NotNull PsiElement element, @NotNull final ProcessingContext context) {
Project project = element.getProject();
PropertiesComponent properties = PropertiesComponent.getInstance(project);
String kohanaAppDir = properties.getValue("kohanaAppPath", "application/");
VirtualFile appDir = project.getBaseDir().findFileByRelativePath(kohanaAppDir);
if (appDir == null) {
return PsiReference.EMPTY_ARRAY;
}
String className = element.getClass().getName();
Class elementClass = element.getClass();
// определяем, что объект является экземпляром StringLiteralExpressionImpl
if (className.endsWith("StringLiteralExpressionImpl")) {
try {
// Вызываем метод getValueRange, чтобы получить символьный диапазон, в котором находится наша ссылка
Method method = elementClass.getMethod("getValueRange");
Object obj = method.invoke(element);
TextRange textRange = (TextRange) obj;
Class _PhpPsiElement = elementClass.getSuperclass().getSuperclass().getSuperclass();
// Вызываем метод getText, чтобы получить значение PHP-строки
Method phpPsiElementGetText = _PhpPsiElement.getMethod("getText");
Object obj2 = phpPsiElementGetText.invoke(element);
String str = obj2.toString();
String uri = str.substring(textRange.getStartOffset(), textRange.getEndOffset());
int start = textRange.getStartOffset();
int len = textRange.getLength();
// Проверяем, подходит ли нам данная PHP-строка (путь к шаблону) или нет
if (uri.endsWith(".tpl") || uri.startsWith("smarty:") || isViewFactoryCall(element)) {
PsiReference ref = new MyReference(uri, element, new TextRange(start, start + len), project, appDir);
return new PsiReference[]{ref};
}
} catch (Exception e) {
}
}
return PsiReference.EMPTY_ARRAY;
}
Чтобы определить, что нам попался не просто PHP-литерал, а строка, переданная именно в View::factory(), снова воспользуемся магией рефлекшн:
public static boolean isViewFactoryCall(PsiElement element) {
PsiElement prevEl = element.getParent();
String elClassName;
if (prevEl != null) {
elClassName = prevEl.getClass().getName();
}
prevEl = prevEl.getParent();
if (prevEl != null) {
elClassName = prevEl.getClass().getName();
if (elClassName.endsWith("MethodReferenceImpl")) {
try {
Method phpPsiElementGetName = prevEl.getClass().getMethod("getName");
String name = (String) phpPsiElementGetName.invoke(prevEl);
if (name.toLowerCase().equals("factory")) {
Method getClassReference = prevEl.getClass().getMethod("getClassReference");
Object classRef = getClassReference.invoke(prevEl);
PrintElementClassDescription(classRef);
String phpClassName = (String) phpPsiElementGetName.invoke(classRef);
if (phpClassName.toLowerCase().equals("view")) {
return true;
}
}
} catch (Exception ex) {
}
}
}
return false;
}
Чтобы было понятнее, с чем мы имеем дело, картинка:
Данный код определяет, что наш элемент действительно вложен в вызов метода (MethodReference), который называется "factory" и находится в классе "view".
3. Создать класс, унаследованный от PsiReferenceContributor и переопределить следующий метод:
@Override
public void registerReferenceProviders(PsiReferenceRegistrar registrar) {
registrar.registerReferenceProvider(StandardPatterns.instanceOf(PsiElement.class), provider);
}
Всё, что делает наш класс - регистрирует наш PsiReferenceProvider в неком реестре, и задает шаблон, к какому типу (подклассу) PsiElement его надо применять. Если бы нужный нам элемент документа был, скажем, значением XML-атрибута, всё было бы проще:
registrar.registerReferenceProvider(StandardPatterns.instanceOf(XmlAttributeValue.class), provider);
Но поскольку JetBrains не открыла доступа к языковому API (в нашем случае PHP), нам приходится подписываться на абсолютно все элементы PsiElement, чтобы затем динамически определить, нужный нам это элемент или нет.
4. Зарегистрировать Contributor в файле plugin.xml:
<extensions defaultExtensionNs="com.intellij">
<psi.referenceContributor implementation="MyPsiReferenceContributor"/>
</extensions>
Создаем страницу настроек
В phpstorm настройки бывают двух типов - относящиеся к проекту и глобальные. Чтобы создать страницу настроек для нашего плагина, создадим класс KohanaStormSettingsPage, реализующий интерфейс Configurable. Метод getDisplayName должен возвращать название таба, которое будет отображаться в списке настроек PhpStorm. Метод createComponent должен возвращать нашу форму. В методе apply мы должны сохранить все настройки.
public class KohanaStormSettingsPage implements Configurable {
private JTextField appPathTextField;
private JCheckBox enableKohanaStorm;
private JTextField secretKeyTextField;
Project project;
public KohanaStormSettingsPage(Project project) {
this.project = project;
}
@Nls
@Override
public String getDisplayName() {
return "KohanaStorm";
}
@Override
public JComponent createComponent() {
JPanel panel = new JPanel();
panel.setLayout(new BoxLayout
(panel, BoxLayout.Y_AXIS));
JPanel panel1 = new JPanel();
panel1.setLayout(new BoxLayout(panel1, BoxLayout.X_AXIS));
enableKohanaStorm = new JCheckBox("Enable Kohana Storm for this project");
...
PropertiesComponent properties = PropertiesComponent.getInstance(project);
appPathTextField.setText(properties.getValue("kohanaAppPath", DefaultSettings.kohanaAppPath));
return panel;
}
@Override
public void apply() throws ConfigurationException {
PropertiesComponent properties = PropertiesComponent.getInstance(project);
properties.setValue("kohanaAppPath", appPathTextField.getText());
properties.setValue("enableKohanaStorm", String.valueOf(enableKohanaStorm.isSelected()) );
properties.setValue("kohanaStormSecretKey", secretKeyTextField.getText());
}
@Override
public boolean isModified() {
return true;
}
@Override
public String getHelpTopic() {
return null;
}
@Override
public void disposeUIResources() {
}
@Override
public void reset() {
}
}
Зарегистрируем нашу страницу настроек в файле plugin.xml:
<extensions defaultExtensionNs="com.intellij">
<psi.referenceContributor implementation="MyPsiReferenceContributor"/>
<projectConfigurable implementation="KohanaStormSettingsPage"></projectConfigurable >
</extensions>
(если бы мы наша страница настроек была глобальной, мы бы использовали applicationConfigurable)
Хранение настроек
Наименее замороченный способ хранения настроек для плагина - использование класса PropertiesComponent и методов setValue и getValue. Более сложный способ описан в документации.
Установка плагина
После того, как разработка плагина будет завершена, необходимо выполнить Build -> Prepare plugin for deployment. После этого в папке проекта появится файл с именем jar, который можно будет распространять. Установить в phpstorm его можно выполнив (File->Settings->Plugins->Install From Disk)