Pull to refresh

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

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

UPD: полезные ссылки:
ReSharper DevGuide
JetBrains Developer Community -> ReSharper Open API / SDK
Google Groups: resharper-plugins
Tags:
Hubs:
+18
Comments 8
Comments Comments 8

Articles