План статьи:
- Настройка среды разработки
- Пример №1: простейшее расширение-заглушка
- Установка плагина
- Отладка, полезные советы
- Пример №2: модификация кода с помощью R# API
- Функциональное тестирование плагинов средствами 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» и т.д.:
Итак, открываем наш основной экземпляр студии (не экспериментальный), и создаем новый проект Class Library. Устанавливаем R# SDK:
Install-Package JetBrains.ReSharper.SDK
Разместим в нашем проекте следующий класс:
[QuickFix]
public class SimpleFix : QuickFixBase
{
public SimpleFix(NotInitializedLocalVariableError error)
{
}
protected override Action<ITextControl> 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 будет доступен в случае использования неинизиализированной локальной переменной:
В 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 и следующим содержимым:
<?xml version="1.0"?>
<package>
<metadata>
<id>PaperSource.ReSharper.HelloWorld</id>
<version>1.0.5</version>
<authors>You</authors>
<owners>You</owners>
<requireLicenseAcceptance>false</requireLicenseAcceptance>
<description>Не забывайте, что id пакета должен содержать точку!</description>
<tags>habrahabr.ru</tags>
<dependencies>
<dependency id="Wave" version="4.0" />
</dependencies>
</metadata>
<files>
<file src="bin\Debug\PluginV10.dll" target="dotFiles\" />
<file src="bin\Debug\PluginV10.pdb" target="dotFiles\" />
</files>
</package>
Важные моменты:
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#, не перечисленных в теге <dependency>, плагин будет отсутствовать в Extention Manager'е — следовательно, недоступен для установки. Чтобы указать несколько версий, необходимо использовать запись вида [A, B), при этом "[" — значит «включительно» и т.д.
2. Имя плагина, указанное в тэге <id>, обязано содержать в себе точку. Вот просто обязано, и всё! Рекомендуемый формат — “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 будет доступен:
Известные проблемы при установке:
- плагина нет в 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 файле размещаем следующий код:
<PropertyGroup>
<HostFullIdentifier>ReSharperPlatformVs14YourHive</HostFullIdentifier>
</PropertyGroup>
Тег <HostFullIdentifier> заполняется вручную и имеет следующий формат: {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 на пустую коллекцию, соответствующую сигнатуре метода. Например:
Было:
public List<object> FooText()
{
return null;
}
Стало:
public List<object> FooText()
{
return new List<object>();
}
Мы получили частный случай паттерна 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<IMethodDeclaration>();
bool insideOfMethod = method != null;
if (insideOfMethod)
{
bool returnsNull = ReturnsNullOrEmpty();
bool isGenericList = HasCorrectReturnType(method);
return returnsNull && isGenericList;
}
return false;
}
Определить, что мы находимся внутри метода — достаточно просто (см. код). Далее, нам необходимо определить следующее:
- находимся ли мы на подходящей инструкции return;
- возвращает ли метод подходящий тип;
Проверяем return:
private bool ReturnsNullOrEmpty()
{
var returnStatement = Provider.GetSelectedElement<IReturnStatement>(false);
if (returnStatement != null)
{
ICSharpExpression value = returnStatement.Value;
return value == null || value.ConstantValue.IsPureNull(CSharpLanguage.Instance);
}
return false;
}
С возвращаемым типом метода сложнее. Проверим, является ли возвращаемый тип — generic List'ом, либо унаследованным от него (для других коллекций принцип тот же):
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 имени:
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<Foo>():
protected override Action<ITextControl> ExecutePsiTransaction(ISolution solution, IProgressIndicator progress)
{
ReplaceType();
return null;
}
private void ReplaceType()
{
IMethodDeclaration method = Provider.GetSelectedElement<IMethodDeclaration>();
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<IReturnStatement>(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. Для создания минимального работающего примера, необходимо создать следующую структуру файлов в проекте:
Данная структура не является канонической, но проще в развертывании и ближе к традиционной структуре C# solution. Файлы nuget.config и TestEnvironment.cs обязательны:
<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<config>
</config>
<packageSources>
<clear />
<add key="jb-gallery" value="http://jb-gallery.azurewebsites.net/api/v2/curated-feeds/TestNuggets/" />
<add key="nuget.org" value="http://www.nuget.org/api/v2/" />
</packageSources>
<disabledPackageSources>
<clear />
</disabledPackageSources>
<packageRestore>
<add key="enabled" value="True" />
<add key="automatic" value="False" />
</packageRestore>
</configuration>
[assembly: RequiresSTA]
[ZoneDefinition]
public class TestEnvironmentZone : ITestsZone, IRequire<PsiFeatureTestZone>{ }
[SetUpFixture]
public class ReSharperTestEnvironmentAssembly : ExtensionTestEnvironmentAssembly<TestEnvironmentZone> { }
С приготовлениями закончили, переходим непосредственно к написанию тестов. Классы, содержащие тесты Context Action, необходимо наследовать от CSharpContextActionExecuteTestBase:
[TestFixture]
public class EmptyCollectionContextActionTests : CSharpContextActionExecuteTestBase<EmptyCollectionContextAction>
{
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<int> FooMethod()
{
return {caret}null;
}
}
}
Соответствующий gold-файл:
using System;
using System.Collections.Generic;
namespace N
{
public class C
{
public List<int> FooMethod()
{
return { caret}new List<int>();
}
}
}
Если при выполнении теста (исходный файл + Context Action) != gold-файл, то тест упадет, и в той же папке будет создан tmp-файл, содержащий актуальный результат применения Context Action.
Запускаем тест на выполнение, и… я сразу перейду к списку проблем и способам их решения:
- Исключение «file does not exist» — самое простое, проверяем структуру папок и соответствующие значения свойств ExtraPath, RelativeTestDataPath;
- Тест падает с исключением в SetUpFixture — проверяем месторасположение и содержимое файлов nuget.config и TestEnvironment.cs;
- Исключение «The test output differs from the gold file» — изучаем созданный tmp-файл, запускаем тест с отладчиком;
- Tmp-файл вместо кода содержит одну строчку «NOT AVAILABLE» — возможно, нет символа каретки {caret} внутри исходного файла, либо Context Action при работе бросил исключение;
- Самый интересный случай — тест всегда проходит успешно, вне зависимости от содержимого исходного и gold-файлов. При этом падает — если удалить исходный файл. С таким неприятным поведением я сталкивался, когда унаследовал тест для Context Action от ContextActionTestBase и не задал свойство ExtraPath.
На этом все. Полный работоспособный пример доступен на GitHub. Надеюсь, на разработку своего первого плагина у вас теперь уйдет гораздо меньше времени, чем ушло у меня =)
Комментарии (5)
snegovikufa
18.11.2015 16:54+2Хотел как-то сделать простой плагин — переключение между файлами .xaml и .xaml.cs. Через пару часов попыток разобраться как этот плагин сделать, а потом прикрутить к студии я понял, что никуда не двигаюсь, стало жалко времени и пришлось забить. Приходится выкручиваться через «Go to related files». Поражаюсь терпению тех, кто делает плагины для студии или R#.
m0j0
18.11.2015 16:59Есть же горячие клавиши F7 и Shift+F7?
snegovikufa
18.11.2015 17:08+2F7 и Shift+F7 работают, да не так, как нужно.
Ситуация такая: у меня отключен дизайнер XAML. Для XAML файлов по умолчанию редактором установлен «XML (Text Editor)». Открываю я Foo.xaml, переключаюсь на Foo.xaml.cs и пытаюсь переключиться по F7. В итоге .xaml файл открывается в «Xaml Editor» и у меня две вкладки Foo.xaml вместо одной. Вроде бы всё ничего, но как только хоть раз откроешь в Xaml Editor, так начинаются дикие тормоза при редактировании/сохранении того самого xaml (секунд по 30-40).
Weageoo
С приходом Roslyn в .NET появилась возможность писать синтаксические и семантические анализаторы/трансформаторы (ать, двать, трить). Эти анализаторы прикрепляются к проекту (в Visual Studio есть пункт «Add analyzer...»). Так я вот что подумал: с их помощью можно программно установить набор требований к коду, не привлекая сторонние средства вроде ReSharper'а. Эдакий маленький решарпер, который можно закрепить за солюшеном. Правда сложность такого решения на порядок выше — достаточно посмотреть на примеры написания этих анализаторов. Так что я вот жду пока появление инструмента, который бы облегчил написание таких анализаторов.
chumakov-ilya
Мне понравилась эта вводная статья по анализаторам, просто и понятно все расписано.
Насколько я знаю, JetBrains не собирались переходить на Roslyn по ряду технических и экономических причин, так что в ближайшие годы ReSharper API вряд ли потеряет актуальность.