Создание плагина разрешения ссылок для 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):

    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)

    Скачать плагин и исходные коды
    Поделиться публикацией

    Похожие публикации

    Комментарии 16
      +2
      И столько кода чтобы переходить ко вьюшке? А за плагин спасибо, как раз коханой пользуюсь.
        +2
        Кода было бы намного меньше, если бы API, относящийся к языкам программирования (Php, Ruby), был открытым. А сейчас приходится извращаться.

        Несмотря на то, что существующий API достаточно полон и позволит разработать даже плагин поддержки нового языка программирования, расширить поддержку существующих языков на платформе IDEA не представляется возможным.
          +1
          Строго говоря, RubyMine поддерживает т.н. «extensions», которые пишутся как раз на ruby (и выполняются искаробочным JRuby). Но паковать их в плагины, как я понимаю, нельзя.
        +1
        А что за «secret key»? Зачем его нужно указывать?
          0
          Эта настройка будет нужна в следующей версии плагина. Планируется переход по URL в файл контроллера и шаблона.
          Чтобы понять, о чем идет речь, взгляните на аналогичный плагин, разработанный мной.
          Пока я думаю, как это лучше реализовать.
            0
            Я взглянул на описание аналогичного плагина, но там ни слова о «secret key»
          0
          А что там с поддержкой Kohana самой IDE? Когда обещают? Ибо в моем любимом PyCharm такая функция есть (для Django).
            +1
            Я перехожу по вьющкам так: выделяю имя вьюхи и нажимаю Ctr+Shift+N (поиск по файлам), мне IDE выводит все файлы с подобным названием. Обычно, нужный или один или в самом верху

            В итоге выделяю текст+Ctr+Shift+N+Enter )
              0
              Не думали упрощать шорткаты для частых операций? Поиск файлов по имени у меня на cmd+P вместо неудобного cmd+shift+N.
              +2
              С закрытым API все просто — берете JAR и декомпилируете его в папку, а эту папку открываете потом как Java-проект (для легкой навигации и исследования). Минус — оно может не таким быть стабильным, как открытое (типа меняться от релиза к релизу).

              Лучший путь — просто написать в Jetbrains (форум, youtrack) и конкретный разработчик вам подскажет API.
                +2
                Для IDEA и для PhpStorm в частности есть Poor Man's IDE Plugin (PMIP).
                Он идеально подходит для простых расширений. Автоматизация сделана на ruby.
                В свое время я делал cakephp навигацию с его помощью. github.com/skie/PIMP
                  0
                  Спасибо, посмотрю, жаль, что я плохо знаю Ruby. Оно позволяет создавать плагины с GUI? Также меня интересует, можно ли создать с его помощью страницу настроек для плагина.
                    +1
                    Простые элементы gui создавать можно. Диалоги, выпадающие списки, при нескольких результатах поиска. А вот страницу настроек создать не получится.
                    Все же PMIP — это плагин для быстрого тюнинга IDE.
                  0
                  А не думали еще и автокомплит сделать? Тоже было бы интересно :)
                    +1
                    FYI: 6.0 openapi will include full PHP PSI and more. youtrack.jetbrains.com/issue/WI-6027#comment=27-456914
                      0
                      В IntelliJ IDEA sdk можно добавить ссылку на PHP plugin jar, а в plugin.xml <depends>com.jetbrains.php≷/depends> и после этого reflection не нужен

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое