Пишем простейший плагин для 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» и т.д.:

Итак, открываем наш основной экземпляр студии (не экспериментальный), и создаем новый проект 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<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:

ReturnsNullOrEmpty()

	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’ом, либо унаследованным от него (для других коллекций принцип тот же):

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<Foo>():

ReplaceType()

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 обязательны:

nuget.config

<?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> 

TestEnvironment.cs

[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.

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

  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. Надеюсь, на разработку своего первого плагина у вас теперь уйдет гораздо меньше времени, чем ушло у меня =)

ссылка на оригинал статьи http://habrahabr.ru/post/270155/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *