Создание плагина разрешения ссылок для PhpStorm (IntelliJ IDEA)

Моя статья на хабре - http://habrahabr.ru/post/161877/
Я работаю веб-программистом, пишу на 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): image

Разработка плагина

Итак, мне захотелось, чтобы можно было переходить из файла контроллера по 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)

Скачать плагин и исходные коды