Как стать автором
Обновить

Плагин для webstorm и авто-дополнение

Время на прочтение 10 мин
Количество просмотров 17K
Хотелось бы поделиться простым способом добавить недостающее авто-дополнение в IDE семейства IDEA. В нашем случае к WebStorm или PhpStrom.

У нас на проекте на фронте используется библиотека require.js. И при работе с ней нужно указывать пути к тем или иным файлам что бы добавить их в зависимости. К сожалению пути к этим файлам приходиться писать руками или копировать по частям.
И я подумал что это нужно бы исправить, и добавить авто дополнение пути до файлов.


После этого я начал искать информацию как писать плагины к Idea и вспомнил о статье хабраюзера zenden2k, в которой он рассказывал как сделать плагин для разрешения ссылок для kohana. Перед прочтением моей статьи обязательно нужно её прочитать.

Решив что разрешение ссылок тоже очень полезный функционал, я для начала написал плагин именно для этого.
При написании плагина я столкнулся с проблемой отсутствия PSI структуры для javascript файлов в Idea Community Edition, а без этого было не возможно определить структуру файла JS, которая нужна для определения нужного элемента для разрешения ссылки. Мне пришлось поставить себе Idea Ultimate EAP. В Idea UT нужно поставить плагин для Javascript, и тогда в PSI Viewer (Tools ->View PSI Structure) будет доступен выбор PSI структуры для Javascript файлов.
Скриншот
image

Так же в связи с тем что со времени написания той статьи JetBrains выкатили openapi для PHP и JS, я использовал привязку уже к конкретному PSI элементу JSLiteralExpression. Мой PsiReferenceContributor стал выглядеть так:
RequirejsPsiReferenceContributor.java
package requirejs;

import com.intellij.lang.javascript.psi.JSLiteralExpression;
import com.intellij.patterns.StandardPatterns;
import com.intellij.psi.PsiReferenceContributor;
import com.intellij.psi.PsiReferenceRegistrar;

public class RequirejsPsiReferenceContributor extends PsiReferenceContributor {
    @Override
    public void registerReferenceProviders(PsiReferenceRegistrar psiReferenceRegistrar) {
        RequirejsPsiReferenceProvider provider = new RequirejsPsiReferenceProvider();

        psiReferenceRegistrar.registerReferenceProvider(StandardPatterns.instanceOf(JSLiteralExpression.class), provider);
    }
}

Как видно вместо PsiElement.class я уже использовал конкретно JSLiteralExpression.class, что бы не приходилось обрабатывать все подряд элементы.
Но для того что бы можно было использовать openapi нужно подключить его в проекте плагина в idea. Для этого нужно зайти в Project Structure, там выбрать Libraries. Нажимаем на + над центральной колонкой, выбираем Java и в открывшемся окне выбора файла выбираем файл "/path_to_webstrom/plugins/JavaScriptLanguage/lib/javascript-openapi.jar":
Скриншот
image

Потом переходим к Modules, открываем вкладку Dependencies, и там на против javascript-openapi указываем Scope как Provided:
Скриншот
image

После этих манипуляций IDE будет подсказывать названия классов и других вещей что входят в openapi для javascript.

Так же нужно было изменить PsiReferenceProvider, избавив его от рефлексии, вышло примерно так:
RequirejsPsiReferenceProvider.java
package requirejs;

import com.intellij.ide.util.PropertiesComponent;
import com.intellij.lang.javascript.psi.JSCallExpression;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiReference;
import com.intellij.psi.PsiReferenceProvider;
import com.intellij.util.ProcessingContext;
import org.jetbrains.annotations.NotNull;

public class RequirejsPsiReferenceProvider extends PsiReferenceProvider {
    @NotNull
    @Override
    public PsiReference[] getReferencesByElement(@NotNull PsiElement psiElement, @NotNull ProcessingContext processingContext) {
        Project project = psiElement.getProject();

        PropertiesComponent properties = PropertiesComponent.getInstance(project);
        String webDirPrefString = properties.getValue("web_dir", "webfront/web");
        VirtualFile webDir = project.getBaseDir().findFileByRelativePath(webDirPrefString);

        if (webDir == null) {
            return PsiReference.EMPTY_ARRAY;
        }

        try {
            String path = psiElement.getText();
            if (isRequireCall(psiElement)) {
                PsiReference ref = new RequirejsReference(psiElement, new TextRange(1, path.length() - 1), project, webDir);
                return new PsiReference[] {ref};
            }
        } catch (Exception ignored) {}

        return new PsiReference[0];
    }

    public static boolean isRequireCall(PsiElement element) {
        PsiElement prevEl = element.getParent();
        if (prevEl != null) {
            prevEl = prevEl.getParent();
        }

        if (prevEl != null) {
            if (prevEl instanceof JSCallExpression) {
                try {
                    if (prevEl.getChildren().length > 1) {
                        if (prevEl.getChildren()[0].getText().toLowerCase().equals("require")) {
                            return true;
                        }
                    }
                } catch (Exception ignored) {}
            }
        }
        return false;
    }
}

Далее необходимо реализовать метод отвечающий за разрешение данной ссылки.
И вот тут у меня возник затык связанный с очень скупой информацией о написании плагинов к idea. Дело в том что изначально у меня было желание начинать поиск файлов от директорий помеченных как «Resource Root», но увы я не смог найти как можно получить такие директории. Поэтому решил брать путь до директории из настроек, для чего реализовал страницу настройки как в описано в статье zenden2k, поэтому не буду повторяться.
После того как мы узнали директорию в которой нам нужно искать файлы по пути, всё было просто. У класса VirtualFile есть метод findFileByRelativePath, который принимает на вход строку пути, и ищет существует ли файл по данному пути и есть да, то возвращает его как экземпляр VirtualFile класса. Так что нужно было взять значение строки из PsiElement, вырезать лишнее, добавить недостающее и проверить существует ли такой файл. Если существует, то просто вернуть ссылку на него в виде экземпляра PsiElement. Метод resolve выглядит так:
RequirejsReverence.java::resolve()
    @Nullable
    @Override
    public PsiElement resolve() {
        String path = element.getText();
        path = path.replace("'", "").replace("\"", "");
        if (path.startsWith("tpl!")) {
            path = path.replace("tpl!", "");
        } else {
            path = path.concat(".js");
        }
        if (path.startsWith("./")) {
            path = path.replaceFirst(
                    ".",
                    element
                            .getContainingFile()
                            .getVirtualFile()
                            .getParent()
                            .getPath()
                            .replace(webDir.getPath(), "")
            );
        }
        VirtualFile targetFile = webDir.findFileByRelativePath(path);

        if (targetFile != null) {
            return PsiManager.getInstance(project).findFile(targetFile);
        }

        return null;
    }

Сделав это я получил разрешение ссылок и можно было приступать к реализации авто-дополнения.

В idea есть два способа реализовать авто-дополнение. Первый простой, это реализовать метод getVariants интерфейса PsiReference, и второй продвинутый использовать CompletionContributor. Я в попробовал оба способа, но каких-то преимуществ в CompletionContributor, для себя не нашёл, поэтому остановился на использовании первого способа.
Для авто-дополнения нам нужно возвращать список элементов в виде массива. Это может быть массив со строками, LoookupElement или PsiElement.
В начале я попробовал возвращать строки. Но тут меня ждал сюрприз. Дело в том что idea строки со слешами вставляет после последнего слеша всю строку. При этом если выдавать строку только со значением после слеша, то idea не воспринимает эту строку как подходящую для авто-дополнения. Это поведение мне не совсем понятно. И найти информацию о том как правильно сделать авто-дополнения строк со слешами или как вариант с путями для файлов мне не удалось.
По этому сделал по своему.
Для того что бы самому управлять вставкой значения нужно реализовать интерфейс InsertHandler и в нем в методе handleInsert произвести необходимые действия. А что бы его использовать нужно возвращать не просто строку, а LookupElement, в котором будет нужный нам InsertHandler.
Так что я расширил класс LookupElement, таким образом:
RequirejsLookupElement.java
package requirejs;

import com.intellij.codeInsight.completion.InsertHandler;
import com.intellij.codeInsight.completion.InsertionContext;
import com.intellij.codeInsight.lookup.LookupElement;
import com.intellij.psi.PsiElement;
import org.jetbrains.annotations.NotNull;

public class RequirejsLookupElement extends LookupElement {
    String path;
    PsiElement element;
    private InsertHandler<LookupElement> insertHandler = null;

    public RequirejsLookupElement(String path, InsertHandler<LookupElement> insertHandler, PsiElement element) {
        this.path = path;
        this.insertHandler = insertHandler;
        this.element = element;
    }

    public void handleInsert(InsertionContext context) {
        if (this.insertHandler != null) {
            this.insertHandler.handleInsert(context, this);
        }
    }

    @NotNull
    @Override
    public String getLookupString() {
        return path;
    }
}

Реализация InsertHandler выглядит так:
RequirejsInsertHandler.java
package requirejs;

import com.intellij.codeInsight.completion.InsertHandler;
import com.intellij.codeInsight.completion.InsertionContext;
import com.intellij.codeInsight.lookup.LookupElement;

public class RequirejsInsertHandler implements InsertHandler {
    private static final RequirejsInsertHandler instance = new RequirejsInsertHandler();

    @Override
    public void handleInsert(InsertionContext insertionContext, LookupElement lookupElement) {
        if (lookupElement instanceof RequirejsLookupElement) {
            insertionContext.getDocument().replaceString(
                    ((RequirejsLookupElement) lookupElement).element.getTextOffset() + 1,
                    insertionContext.getTailOffset(),
                    ((RequirejsLookupElement) lookupElement).path
            );
        }
    }

    public static RequirejsInsertHandler getInstance() {
        return instance;
    }
}

Суть метода handleInsert в том что мы берём lookupElement получаем PsiElement для которого он был показан и выбран, из PsiElement мы получаем его местоположение в файле и заменяем текстом из lookupElement.path всю длинну строки элемента. Конечно это не лучший способ, но к сожалению другого я найти не смог.

После этого я сделал поиск всех подходящих файлов, и возвращал их в виде массива LookupElement.
Вот полный листинг RequirejsReference:
RequirejsReference.java
package requirejs;

import com.intellij.codeInsight.lookup.LookupElement;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.util.TextRange;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.newvfs.impl.VirtualDirectoryImpl;
import com.intellij.openapi.vfs.newvfs.impl.VirtualFileImpl;
import com.intellij.psi.PsiElement;
import com.intellij.psi.PsiManager;
import com.intellij.psi.PsiReference;
import com.intellij.util.IncorrectOperationException;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

import java.util.ArrayList;

public class RequirejsReference implements PsiReference {
    PsiElement element;
    TextRange textRange;
    Project project;
    VirtualFile webDir;

    public RequirejsReference(PsiElement element, TextRange textRange, Project project, VirtualFile webDir) {
        this.element = element;
        this.textRange = textRange;
        this.project = project;
        this.webDir = webDir;
    }

    @Override
    public PsiElement getElement() {
        return this.element;
    }

    @Nullable
    @Override
    public PsiElement resolve() {
        String path = element.getText();
        path = path.replace("'", "").replace("\"", "");
        if (path.startsWith("tpl!")) {
            path = path.replace("tpl!", "");
        } else {
            path = path.concat(".js");
        }
        if (path.startsWith("./")) {
            path = path.replaceFirst(
                    ".",
                    element
                            .getContainingFile()
                            .getVirtualFile()
                            .getParent()
                            .getPath()
                            .replace(webDir.getPath(), "")
            );
        }
        VirtualFile targetFile = webDir.findFileByRelativePath(path);

        if (targetFile != null) {
            return PsiManager.getInstance(project).findFile(targetFile);
        }

        return null;
    }

    @Override
    public String toString() {
        return getCanonicalText();
    }

    @Override
    public boolean isSoft() {
        return false;
    }

    @NotNull
    @Override
    public Object[] getVariants() {
        ArrayList<String> files = filterFiles(this.element);

        ArrayList<LookupElement> completionResultSet = new ArrayList<LookupElement>();

        for (int i = 0; i < files.size(); i++) {
            completionResultSet.add(
                    new RequirejsLookupElement(
                            files.get(i),
                            RequirejsInsertHandler.getInstance(),
                            this.element
                    )
            );
        }

        return completionResultSet.toArray();
    }

    protected ArrayList<String> getAllFilesInDirectory(VirtualFile directory) {
        ArrayList<String> files = new ArrayList<String>();

        VirtualFile[] childrens = directory.getChildren();
        if (childrens.length != 0) {
            for (int i = 0; i < childrens.length; i++) {
                if (childrens[i] instanceof VirtualDirectoryImpl) {
                    files.addAll(getAllFilesInDirectory(childrens[i]));
                } else if (childrens[i] instanceof VirtualFileImpl) {
                    files.add(childrens[i].getPath().replace(webDir.getPath() + "/", ""));
                }
            }
        }

        return files;
    }

    protected ArrayList<String> filterFiles (PsiElement element) {
        String value = element.getText().replace("'", "").replace("\"", "").replace("IntellijIdeaRulezzz ", "");
        Boolean tpl = value.startsWith("tpl!");
        String valuePath = value.replaceFirst("tpl!", "");

        ArrayList<String> allFiles = getAllFilesInDirectory(webDir);
        ArrayList<String> trueFiles = new ArrayList<String>();

        String file;

        for (int i = 0; i < allFiles.size(); i++) {
            file = allFiles.get(i);
            if (file.startsWith(valuePath)) {
                if (tpl && file.endsWith(".html")) {
                    trueFiles.add("tpl!" + file);
                } else if (file.endsWith(".js")) {
                    trueFiles.add(file.replace(".js", ""));
                }
            }
        }

        return trueFiles;
    }

    @Override
    public boolean isReferenceTo(PsiElement psiElement) {
        return false;
    }

    @Override
    public PsiElement bindToElement(@NotNull PsiElement psiElement) throws IncorrectOperationException {
        throw new IncorrectOperationException();
    }

    @Override
    public PsiElement handleElementRename(String s) throws IncorrectOperationException {
        throw new IncorrectOperationException();
    }

    @Override
    public TextRange getRangeInElement() {
        return textRange;
    }

    @NotNull
    @Override
    public String getCanonicalText() {
        return element.getText();
    }
}

Я выделил метод поиска файлов отдельно, так как он рекурсивный, и так же выделил метод фильтрации файлов, так как для темплейтов нужны только html, а для остального нужны js файлы. Так же при вставке темплейты вставляются вместе с префиксом tpl!, а js файлы вставляются без расширения js.

UPD:
В комментариях, пользователь VISTALL, подсказал что создание собственного класса наследника LookupElement было лишним. Вместо этого можно использовать LookupElementBuilder, который позволяет указать какой какой insertHandler использовать и к какому PsiElement он относится.
Для использования LookupElementBuilder, я изменил метод RequirejsReference::getVariants, следующим образом:
RequirejsReference::getVariants
    @NotNull
    @Override
    public Object[] getVariants() {
        ArrayList<String> files = filterFiles(element);

        ArrayList<LookupElement> completionResultSet = new ArrayList<LookupElement>();

        for (int i = 0; i < files.size(); i++) {
            completionResultSet.add(
                    LookupElementBuilder
                            .create(element, files.get(i))
                            .withInsertHandler(
                                    RequirejsInsertHandler.getInstance()
                            )
            );
        }

        return completionResultSet.toArray();
    }

Что бы сгенерированный LookupElement знал к какому PsiElement он относится, достаточно вызвать метод create, первым параметром передав PsiElement, а вторым строку, которую нужно использовать для авто-дополнения.
Так же я изменил сам RequirejsInsertHandler::handleInsert так:
RequirejsInsertHandler::handleInsert
    @Override
    public void handleInsert(InsertionContext insertionContext, LookupElement lookupElement) {
        insertionContext.getDocument().replaceString(
                lookupElement.getPsiElement().getTextOffset() + 1,
                insertionContext.getTailOffset(),
                lookupElement.getLookupString()
        );
    }

Из него я убрал проверку на тип lookupElement и использовал методы для получения PsiElement и строки для замены.
После этих манипуляций, класс RequirejsLookupElement, более не нужен.

UPD 2:
Плагин слегка допилен и выложен на github: github.com/Fedott/WebStormRequireJsPlugin
Тае же плагин теперь доступен в официальном репозитории jetbrains: plugins.jetbrains.com/plugin/7337
Хотелки можно отправлять писать на github или здесь.

На этом всё.
Есть есть вопросы или советы как лучше реализовать, буду рад их прочесть.
Теги:
Хабы:
+26
Комментарии 6
Комментарии Комментарии 6

Публикации

Истории

Работа

Ближайшие события

PG Bootcamp 2024
Дата 16 апреля
Время 09:30 – 21:00
Место
Минск Онлайн
EvaConf 2024
Дата 16 апреля
Время 11:00 – 16:00
Место
Москва Онлайн
Weekend Offer в AliExpress
Дата 20 – 21 апреля
Время 10:00 – 20:00
Место
Онлайн