Пишем простейший плагин для ReSharper

Цель: написать, протестировать и развернуть простейший плагин для R#, содержащий пользовательские Quick-Fix и Context Action.

План статьи:

  1. Настройка среды разработки
  2. Пример №1: простейшее расширение-заглушка
  3. Установка плагина
  4. Отладка, полезные советы
  5. Пример №2: модификация кода с помощью R# API
  6. Функциональное тестирование плагинов средствами R# API


В ролях:

Visual Studio 2015
ReSharper Ultimate 10


Заинтересовавшихся приглашаю под кат.

1. Настройка среды разработки


Чтобы не мешать работе основного экземпляра Visual Studio, в котором мы будем писать код, лучше всего сразу подготовить отдельную «экосистему» для нашего будущего плагина. Начинаем со скачивания т.н. «checked build» — сборки R# Ultimate с расширенной диагностической информацией, а в остальном — идентичной натуральной.
Также нам понадобится Visual Studio Experimental Instance — в некотором роде «профиль», содержащий все пользовательские настройки от расположения окон до установленных расширений (ага!). Профили изолированы друг от друга именно на уровне настроек, а исполняемые файлы Студии никуда не копируются. Для VS2015 управлять профилями возможно при помощи утилиты CreateExpInstance.exe, но у нас есть способ еще проще. Запускаем скачанный ранее установщик checked build’а, переходим в Options — Experimental Hive и вводим имя нового профиля, который впоследствии будет использоваться для установки разработанного плагина, а умница R# сам этот профиль создаст и установится туда же. Именно так, в каждый профиль возможно установить свой набор расширений, в том числе — свою версию R#, что облегчает тестирование плагинов под несколько версий сразу. Как уже говорилось, профили независимы друг от друга, поэтому ваш рабочий профиль Visual Studio не пострадает.

Для запуска Студии с новым профилем потребуется ярлык вида:

«X:\Microsoft Visual Studio 14.0\Common7\IDE\devenv.exe» /rootSuffix YourHive /ReSharper.Internal


Здесь YourHive — имя ранее созданного профиля, а параметр /ReSharper.Internal запускает R# в режиме разработки, тем самым включив полезные фичи, такие как уведомления о исключениях, сгенерированных внутри плагинов.

2. Пример №1: простейшее расширение-заглушка


R# предлагает различные способы модификации и генерации кода, среди которых Quick-Fix, Context Action, Refactoring и т.д. Как мне показалось, самым простым для реализации является Quick-Fix, поэтому с него мы и начнем. Quick-Fix’ы — это известные любому пользователю R# команды с иконками в виде желтой/красной лампочки во вплывающем по Alt+Enter меню, такие как «Remove unused variable», «Initialize property from constructor» и т.д.:
ba81de849c95439e99d8a995e5ce713d.png

Итак, открываем наш основной экземпляр студии (не экспериментальный), и создаем новый проект Class Library. Устанавливаем R# SDK:

Install-Package JetBrains.ReSharper.SDK


О версиях R#, R# SDK и обратной совместимости, а точнее — ее отсутствии
На момент написания статьи, последней выпущенной версией JetBrains.ReSharper.SDK является версия 10.0.20151101.194233, соответствующая R# 10. JetBrains не обеспечивает совместимости между мажорными и минорными версиями продуктов, поэтому работа плагина, собранного в SDK 9.1 не гарантируется в R# 9.2 и т.д. Здесь и далее будет использоваться SDK 10, что означает поддержку только R# 10 и невозможность установки и корректной работы в R# 9.2. При этом нет никаких препятствий для того, чтобы пересобрать весь рассмотренный в статье код под SDK 9.2, получив тем самым рабочий в R# 9.2 плагин — проверено.


Разместим в нашем проекте следующий класс:

[QuickFix]
public class SimpleFix : QuickFixBase
{
        public SimpleFix(NotInitializedLocalVariableError error)
        {
        }
        
        protected override Action ExecutePsiTransaction(ISolution solution, IProgressIndicator progress)
        {
                return null;
        }
        
        public override string Text => "SimpleFix";
        
        public override bool IsAvailable(IUserDataHolder cache)
        {
                return true;
        }
}


Обратите внимание, любой Quick-Fix обязан иметь минимум один подобный конструктор с входным параметром типа SomeError/SomeWarning. Тип параметра и определяет, для какой ошибки в коде Quick-Fix будет доступен в выпадающем меню. Наш Quick-Fix будет доступен в случае использования неинизиализированной локальной переменной:
88b469e3dc4d482398ed71346b9ed7da.png

В R# SDK определено несколько сотен классов ошибок, доступных в пространстве имен JetBrains.ReSharper.Daemon.CSharp.Errors. Классы, соответствующие ошибкам компиляции, имеют в имени постфикс Error, различные некритические улучшения — постфикс Warning. Весь следующий раздел мы будем мучаться с развертыванием нашего Quick-Fix.

3. Установка плагина


Добавим в наш проект еще один класс:

[ZoneMarker]
public class ZoneMarker { }


Зоны — это новая функциональность R# SDK, появившаяся в версии 9.0, и дорабатываемая до сих пор. В том числе, с помощью зоны указывается, для какого продукта из состава R# Platform предназначено разрабатываемое расширение. К счастью, на данный момент нам достаточно ограничиться классом-заглушкой.

Важно: ZoneMarker должен находиться в одном пространстве имен с созданным ранее классом SimpleFix, либо выше.


Следующий нюанс — распространение и установка плагина в R# 9+ производится только через NuGet-пакеты. Для создания правильного пакета, в состав проекта добавим файл с расширением .nuspec и следующим содержимым:



        
                PaperSource.ReSharper.HelloWorld
                1.0.5
                You
                You
                false
                Не забывайте, что id пакета должен содержать точку!
                habrahabr.ru
                
                        
                
        
        
                
                
        



Важные моменты:
1. Зависимость от пакета «Wave» обязательна. Wave — новая модель распространения R# Platform, в которую, помимо R#, входят dotPeek, dotTrace и т.п… Не вдаваясь в подробности:

ReSharper 9.0 — Wave 1.0;
ReSharper 9.1 — Wave 2.0;
ReSharper 9.2 — Wave 3.0;
ReSharper 10.0 — Wave 4.0;


Для версий R#, не перечисленных в теге, плагин будет отсутствовать в Extention Manager’е — следовательно, недоступен для установки. Чтобы указать несколько версий, необходимо использовать запись вида [A, B), при этом »[» — значит «включительно» и т.д.

2. Имя плагина, указанное в тэге , обязано содержать в себе точку. Вот просто обязано, и всё! Рекомендуемый формат — «Company.Package».

После установки NuGet.exe открываем Package Manager Console и выполняем команду:

nuget pack «PaperSource.ReSharper.HelloWorld\package.nuspec»


Готовый .nupkg файл появится в папке вашего solution (либо воспользуйтесь параметром -OutputDirectory для создания пакета в нужной вам папке). На предупреждения вида «Issue: Assembly outside lib folder.» можно не обращать внимания. Для установки плагина, в нашем экспериментальном экземпляре Visual Studio идем ReSharper — Options — Extention Manager и указываем путь до папки с .nupkg файлом.

Момент истины: открываем ReSharper — Extention Manager, ищем наш плагин по имени через поиск, устанавливаем. Если всё сделано правильно — SimpleFix будет доступен:
88b469e3dc4d482398ed71346b9ed7da.png

Известные проблемы при установке:

  • плагина нет в Extention Manager;
  • плагин удается установить, но Quick-Fix не появляется в нужном месте.


В обоих случаях я бы посоветовал начать с проверки .nuspec файла, а затем — с чтения официального руководства по поиску ошибок. Кстати, т.н. installer logs по примерному адресу %LOCALAPPDATA%\JetBrains\Shared\v02\InstallerLogXXX для меня каждый раз оказывались бесполезными. Даже в случае успешной установки в логи пишется масса информации о каких-то выброшенных исключениях, а уж понять, что приводит к ошибке установки — и вовсе затруднительно.

4. Отладка, полезные советы


Для того, чтобы пройтись отладчиком по коду расширения, достаточно через Debug — Attach to Process присоединиться к процессу denenv.exe экспериментального экземпляра.

Любой плагин должен быть установлен через Extension Manager. При внесении правок в код, гарантированным способом развернуть эти изменения является аналогичное обновление/переустановка. Однако, из этого правила существует исключение: если в код не добавлялись новые файлы/классы, и не было изменений точек интеграции со студией (например, не изменялся тип ошибки для уже существующего quick-fix), то переустанавливать плагин необязательно. Достаточно подменить сборку плагина новой версией. R# хранит сборки с плагинами глубоко в своих недрах, и чтобы лишний раз в них не погружаться, стоит использовать MSBuild target, копирующий сборку «куда надо» после компиляции. Для этого в .csproj файле размещаем следующий код:


        ReSharperPlatformVs14YourHive



Тег заполняется вручную и имеет следующий формат: {Host}{Visual Studio version}{Visual Studio instance name}. Приведенный в листинге вариант сработает для R# Ultimate, VS 2015 и профиля с названием YourHive. Если указать некорректный HostFullIdentifier, при сборке проекта в Output будут выведены все возможные варианты HostFullIdentifier.

5. Пример №2: модификация кода с помощью R# API


«Что нам всякие заглушки? Реальный код давай, код!» — в этом разделе напишем простой плагин, по-честному читающий и модифицирующий синтасическое дерево кода R#. Хотелось показать вам что-то, не дублирующее функционал R#, при этом как достаточно простое, так и применимое на практике. И вот что удалось придумать. Пусть у нас есть метод, возвращающий тип List, и по каким-то причинам мы хотим быстро заменить в инструкции return значение null на пустую коллекцию, соответствующую сигнатуре метода. Например:
0f612be4a4304d4298cc4e95b927a69f.png

Было:

public List FooText()
{
        return null;
}


Стало:

public List FooText()
{
        return new List();
}


Мы получили частный случай паттерна Null Object. Конечно же, я согласен, что null и пустая коллекция различаются семантически и о плюсах/минусах такого подхода можно было бы поговорить, но это не входит в задачи данной статьи. Поэтому перейдем к технической реализации. Можно заметить, что исходный код корректен (using’и опустим) — с точки зрения компилятора и R# здесь незачем выдавать даже warning. Поэтому рассмотренный выше механизм Quick-Fix нам не подойдет, и мы используем Context Action — намного более гибкое средство, позволяющее назначить пользовательские действия на практически любой участок кода:

[ContextAction(Group = "C#", Name = "Empty Collection Action", Description = "something new")]
public class EmptyCollectionContextAction : ContextActionBase
{
        public ICSharpContextActionDataProvider Provider { get; set; }

        public EmptyCollectionContextAction(ICSharpContextActionDataProvider provider)
        {
                Provider = provider;
        }

        public override string Text { get; } = "Return empty collection";
}


Управление видимостью Context Action осуществляется аналогично Quick-Fix через переопределение метода IsAvailable:

  public override bool IsAvailable(IUserDataHolder cache)
        {
                var method = Provider.GetSelectedElement();

                bool insideOfMethod = method != null;

                if (insideOfMethod)
                {
                        bool returnsNull = ReturnsNullOrEmpty();

                        bool isGenericList = HasCorrectReturnType(method);

                        return returnsNull && isGenericList;
                }

                return false;
        }


Определить, что мы находимся внутри метода — достаточно просто (см. код). Далее, нам необходимо определить следующее:

  • находимся ли мы на подходящей инструкции return;
  • возвращает ли метод подходящий тип;


Проверяем return:

ReturnsNullOrEmpty ()
  private bool ReturnsNullOrEmpty()
        {
                var returnStatement = Provider.GetSelectedElement(false);

                if (returnStatement != null)
                {
                        ICSharpExpression value = returnStatement.Value;

                        return value == null || value.ConstantValue.IsPureNull(CSharpLanguage.Instance);
                }

                return false;
        }



С возвращаемым типом метода сложнее. Проверим, является ли возвращаемый тип — generic List’ом, либо унаследованным от него (для других коллекций принцип тот же):

HasCorrectReturnType () — вариант №1
private static bool HasCorrectReturnType(IMethodDeclaration method)
{
        IDeclaredType declaredType = method.DeclaredElement.ReturnType as IDeclaredType;

        if (declaredType == null || declaredType.IsVoid()) return false;

        ISubstitution sub = declaredType.GetSubstitution();

        if (sub.IsEmpty()) return false;

        IType  parameterType = sub.Apply(sub.Domain[0]);

        IMethod declaredElement = method.DeclaredElement;

        IType realType = declaredElement.Type();

        var predefinedType = declaredElement.Module.GetPredefinedType();

        ITypeElement generic = predefinedType.GenericList.GetTypeElement();

        IType sampleType = EmptySubstitution.INSTANCE
                .Extend(generic.TypeParameters, new IType[] { parameterType })
                .Apply(predefinedType.GenericList);

        bool isGenericList = realType.IsImplicitlyConvertibleTo(sampleType, new CSharpTypeConversionRule(declaredElement.Module));

        return isGenericList;
}



Есть вариант попроще, но не такой гибкий — сравнить типы по CLR имени:

HasCorrectReturnType () — вариант №2
private static bool HasCorrectReturnType(IMethodDeclaration method)
{
        IDeclaredType declaredType = method.DeclaredElement.ReturnType as IDeclaredType;

        if (declaredType == null || declaredType.IsVoid()) return false;

        ITypeElement element = declaredType.GetTypeElement();

        string fullName = element.GetClrName().FullName;

        bool isGenericList = fullName == "System.Collections.Generic.List`1";

        return isGenericList;
}



Наконец, самое вкусное — замена null на new List ():

ReplaceType ()
protected override Action   ExecutePsiTransaction(ISolution solution, IProgressIndicator progress)
{
        ReplaceType();

        return null;
}

private void ReplaceType()
{
        IMethodDeclaration method = Provider.GetSelectedElement();

        IType type = method.DeclaredElement.ReturnType;

        string typePresentableName = type.GetPresentableName(CSharpLanguage.Instance);

        CSharpElementFactory factory = CSharpElementFactory.GetInstance(Provider.PsiModule);

        string code = $"new {typePresentableName}()";

        ICSharpExpression newExpression = factory.CreateExpression(code);

        IReturnStatement returnStatement = Provider.GetSelectedElement(false);

        returnStatement.SetValue(newExpression);
}



Я с умыслом не стал комментировать вышеприведенные листинги, чтобы излить всю боль в одном месте: писать или разбирать код на R# API… непросто. В документации есть пробелы, доступных примеров мало, даже XML-комментарии к коду отсутствуют. Приходится мучаться с каждым разрабатываемым методом и активно использовать отладчик. Подчеркну — отладчик при работе с R# API становится важнейшим инструментом для путешествий по синтаксическому дереву. Cерьезным подспорьем также выступает поиск по ключевым классам в GitHub — какое-то количество образцов кода удается найти.

6. Функциональное тестирование плагинов средствами R# API


Одна из замечательных фич R# API — это возможность неявно развернуть экземпляр R# в памяти, скормить ему кусок текста (применив к тексту тестируемый Quick-Fix или Context Action), а затем сравнить преобразованный текст с ожидаемым. И все это путем написания малого количества кода, сравнимого с написанием простейших юнит-тестов. Кстати, R# использует NUnit. Поехали!

Добавим в состав solution с нашим плагином еще один проект, который будет содержать тесты. Установим пакет JetBrains.ReSharper.SDK.Tests. Для создания минимального работающего примера, необходимо создать следующую структуру файлов в проекте:
ad6dea760cc74a409c526dfd87b19552.png

Данная структура не является канонической, но проще в развертывании и ближе к традиционной структуре C# solution. Файлы nuget.config и TestEnvironment.cs обязательны:

nuget.config


        
        
        
                
                
                
        
        
                
        
        
                
                
        




TestEnvironment.cs
[assembly: RequiresSTA]

[ZoneDefinition]
public class TestEnvironmentZone : ITestsZone, IRequire{ }

[SetUpFixture]
public class ReSharperTestEnvironmentAssembly : ExtensionTestEnvironmentAssembly { }



С приготовлениями закончили, переходим непосредственно к написанию тестов. Классы, содержащие тесты Context Action, необходимо наследовать от CSharpContextActionExecuteTestBase:

[TestFixture]
public class EmptyCollectionContextActionTests : CSharpContextActionExecuteTestBase
{
        protected override string ExtraPath => "EmptyCollectionContextActionTests";

        protected override string RelativeTestDataPath => "EmptyCollectionContextActionTests";

        [Test]
        public void Test01()
        {
                DoTestFiles("Test01.cs");
        }
}
        


Файл Test01.cs вы уже видели на скриншоте, это исходный файл с кодом, к которому будет применяться наш Context Action. Test01.cs.gold — своего рода «expected output», ожидаемый код после применения Context Action. Тест считается пройденным, если применив Context Action к Test01.cs, мы получаем Test01.cs.gold.
При написании собственных тестов, необходимо определить значения свойств ExtraPath и RelativeTestDataPath, задав их равными названию папки, содержащей исходный и gold-файл. Нет никакой необходимости компилировать эти файлы, поэтому им необходимо смело выставлять BuildAction: None и добавлять в игнор R#, чтобы избавиться от мнимых сообщений об ошибках. Что касается содержимого исходного и gold-файлов, то для Context Action обязательно указать позицию каретки на момент вызова контекстного действия, делается это с помощью инструкции {caret}:

using System;
using System.Collections.Generic;

namespace N
{
        public class C
        {
                public List FooMethod()
                {
                        return {caret}null;
                }
        }
}

Соответствующий gold-файл:

using System;
using System.Collections.Generic;

namespace N
{
        public class C
        {
                public List FooMethod()
                {
                        return { caret}new List();
                }
        }
}


Если при выполнении теста (исходный файл + Context Action) != gold-файл, то тест упадет, и в той же папке будет создан tmp-файл, содержащий актуальный результат применения Context Action.

Запускаем тест на выполнение, и… я сразу перейду к списку проблем и способам их решения:

  1. Исключение «file does not exist» — самое простое, проверяем структуру папок и соответствующие значения свойств ExtraPath, RelativeTestDataPath;
  2. Тест падает с исключением в SetUpFixture — проверяем месторасположение и содержимое файлов nuget.config и TestEnvironment.cs;
  3. Исключение «The test output differs from the gold file» — изучаем созданный tmp-файл, запускаем тест с отладчиком;
  4. Tmp-файл вместо кода содержит одну строчку «NOT AVAILABLE» — возможно, нет символа каретки {caret} внутри исходного файла, либо Context Action при работе бросил исключение;
  5. Самый интересный случай — тест всегда проходит успешно, вне зависимости от содержимого исходного и gold-файлов. При этом падает — если удалить исходный файл. С таким неприятным поведением я сталкивался, когда унаследовал тест для Context Action от ContextActionTestBase и не задал свойство ExtraPath.

На этом все. Полный работоспособный пример доступен на GitHub. Надеюсь, на разработку своего первого плагина у вас теперь уйдет гораздо меньше времени, чем ушло у меня =)

© Habrahabr.ru