Автор: Sergey Teplyakov

С выходом новой версии студии (VS2015) у каждого из нас появилась возможность почувствовать себя причастным к разработке инструментов для разработчиков. Камрады из команд компиляторов C#/VB проделали отличную работу по выставлению внутренностей компилятора наружу, что позволяет теперь писать свои собственные анализаторы кода, затрачивая вполне разумные на это силы.

Но, прежде чем приступать к обсуждению примеров, давайте рассмотрим, для чего они могут понадобиться.

Для чего нужны свои собственные анализаторы?

Вопрос вполне разумный. Есть реактивные мозги, есть DevExpress, есть же мелкомягкие товарищи из DevDiv-а, которые пилят инструменты для разработчиков. Зачем мне разбираться со всякими иммутабельными синтаксическими деревьями и control flow анализом? Это довольно весело, но разве этого достаточно, чтобы тратить на это свое ценное время?

Любой инструмент общего назначения рассчитан на решение наиболее типичных задач. Достаточно открыть список анализаторов Решарпера, чтобы понять, о чем идет речь. Такие анализаторы отлично справиться с поиском недостижимого кода или с предупреждением о неправильной реализации синглтона, но он не подскажет о правилах специфичных для вашего проекта и/или библиотеки.

Например, вы можете захотеть реагировать более жестко на некорректное логгирование исключений (детектить и бить по пальцам, если вашему методу логирования передается ex.Message, а не ex.ToString()), или же это может быть кастомное правило, запрещающее использовать LINQ в определенных сборках во избежание потери производительности. Если в вашей команде есть правило или набор правил, которому должны следовать все члены команды, но которое нельзя выразить в виде правил FxCop/StyleCop. Все эти задачи отлично будут решаться с помощью самописных анализаторов.

Способы распространения анализаторов

После того, как вы решите, что же вы хотите анализировать в среде разработке, вам придется решить вопрос распространения своего анализатора. Существует три способа:

  • Путем установки VSIX
  • С помощью NuGet-пакетов
  • Путем явного добавления анализатора через Analyzers ->, Add Analyzer

Первый способ позволит установить глобальный анализатор, который будет работать для всех проектов, а два последних позволяет использовать анализаторы, специфичные для определенного проекта.

Первый способ наиболее подходит для универсальных анализаторов, аналогичных правилам Решарпера. Анализаторы же на базе NuGet-пакетов, позволяет использовать один и тот же набор правил всеми участниками команды (включая билд-сервер). Поскольку кастомные анализаторы ничем не отличаются от ошибок компиляции, то их использование на билд сервере позволит ломать билд, если код вдруг перестанет следовать некоторым правилам.

Хорошая новость заключается в том, что первые два подхода не являются взаимоисключающими и вы можете поставить набор любимых анализаторов на свою машину, и прописать их же для определенных проектов.

Первый анализатор. Постановка задачи

В качестве упражнения, давайте напишем следующий анализатор. Предположим, у нас есть библиотека MvvmUltraLight с единственной структурой RelayCommand:public struct RelayCommand
{
private readonly Action _action,
public RelayCommand(Action action)
{
Contract
.Requires(action != null),
_action
= action,
}
public void Run()
{
_action(),
}
}

Структуры в C# плохо дружат с ООП, но могут помочь в плане производительности, поскольку не приводят к выделению памяти в куче. Наша задача, написать анализатор, который будет выдавать предупреждение об использовании конструктора по умолчанию для данной структуры.

clip_image002

ПРИМЕЧАНИЕ
Данный пример, как и многие синтетические примеры, несколько надуман. Но, если вам будет легче, вместо структуры RelayCommand это мог бы быть List<,T>,.Enumerator, или другая кастомная структура, использование конструктора по умолчанию которой не имеет смысла или приводит к ошибке времени исполнения. Так, например, следующий приводит к NullReferenceException: new List<,int>,.Enumerator().MoveNext().

Базовая структура анализаторов

Для создания своего анализатора достаточно создать новый проект: File ->, New ->, Project ->, Analyzer with Code Fix (NuGet + VSIX), после чего будет создан проект анализатора (плюс NuGet-пакет), проект с тестами и проект с VSIX. Пустой проект сразу же будет содержать простой анализатор и фиксер (класса, который устраняет проблему, задетекченную анализатором).

Ключевые классы показаны на следующем рисунке (учитывая, что наш анализатор носит название DoNotUseDefaultCtorAnalyzer, а фиксер UseNonDefaultCtorCodeFixProvider).

clip_image004

Каждый анализатор должен содержать идентификатор, DiagnosticDescriptor (который, в свою очередь состоит из Id, названия, форматированного сообщения и уровня сообщения Warning/Error). А фиксер должен возвращать список анализаторов, проблемы которых он готов решить.

Анализ вызова конструктора

Начинаем разработку собственного анализатора. Для начала, нужно создать DiagnosticDescriptor с нужной информацией: [DiagnosticAnalyzer(LanguageNames.CSharp)]
public class DoNotUseDefaultCtorAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = 'DoNotUseRelayCommandDefaultCtor',
private static readonly string Title =
'Default constructor for 'RelayCommand' considered harmful',
public static readonly string MessageFormat =
'Do not use default constructor for 'RelayCommand' struct',
private const string Category = 'CodeStyle',
private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
DiagnosticId, Title, MessageFormat, Category,
DiagnosticSeverity.Warning, isEnabledByDefault: true),

Теперь нужно переопределить метод Initializer и зарегистрировать определенный метод обратного вызова для обработки определенных узлов синтаксического дерева (там можно зарегистрировать много чего, но об это как-нибудь в другой раз). Чтобы понять, какой же узел синтаксического дерева нас интересует в этом случае, можно воспользоваться Roslyn Syntax Tree Visualizer или же 5-й версией LINQ Pad-а, в которую эта функциональность встроена. Для поиска нужного типа узла достаточно открыть LINQ Pad и вбить в него выражение var cmd = new RelayCommand():

clip_image006

Ок, нам нужно обрабатывать ObjectCreationExpression. Регистрируем нужный обработчик и делаем первую реализацию:public override void Initialize(AnalysisContext context)
{
context
.RegisterSyntaxNodeAction(ObjectCreationHandler,
SyntaxKind.ObjectCreationExpression),
}
private void ObjectCreationHandler(SyntaxNodeAnalysisContext
context)
{
var objectCreation = (ObjectCreationExpressionSyntax)context.Node,
if (IsRelayCommandType(context, objectCreation.Type) &&
objectCreation.ArgumentList.Arguments.Count == 0
)
{
context
.ReportDiagnostic(Diagnostic.Create(Rule, context.Node.GetLocation())),
}
}
private bool IsRelayCommandType(SyntaxNodeAnalysisContext context, TypeSyntax
type)
{
var expectedType = typeof(RelayCommand),
// Сверх наивная реализация
return type.GetText().ToString().Contains(expectedType.Name) == true,
}

Данная реализация весьма примитивна. Мы просто проверяем, что создается экземпляр нужной нам команды, и что число аргументов конструктора равно 0. При этом проверка типа осуществляется на уровне синтаксиса - путем проверки, содержит ли тип создаваемого экземпляра текст RelayCommand. Дальше мы посмотрим, как сделать эту проверку более вменяемым образом.

Все, наш анализатор готов, и можно приступать к тестированию. Поскольку наш солюшн уже содержит проект с тестами (и класс с тестами встроенного анализатора), то написать тест будет довольно просто:[TestMethod]
public void
ShouldBeWarningOnDefaultConstructor()
{
var test = @'
namespace ConsoleApplication1 {
class TypeName {
public static void Run() {
var r = new MvvmUltraLight.Core.RelayCommand(),
}
}
}'

,
var expected = new DiagnosticResult
{
Id
= DoNotUseDefaultCtorAnalyzer.DiagnosticId,
Message
= DoNotUseDefaultCtorAnalyzer.MessageFormat,
Severity
= DiagnosticSeverity.Warning,
Locations
= new[] { new DiagnosticResultLocation('Test0.cs', 5, 14) }
},
VerifyCSharpDiagnostic(test, expected),
}

Методы VerifyCSharpDiagnostic уже находятся в созданном проекте с юнит-тестами и главная наша задача заключается в подборе правильных координат для объекта DiagnosticResultLocation.

Пишем фиксер

Прежде чем переходить к более правильной реализации определения типа нашей команды, давайте сделаем простой фиксер. Его главная задача заменять вызов конструктора по умолчания на конструктор, принимающий объект Action. Поскольку придумать вменяемое поведение сложно, мы просто будем передавать туда пустое лябда-выражение.

Первая задача фиксера подсказать инфраструктуре диагностики, какие проблемы он может решить. Делается это с помощью свойства FixableDiagnosticIds:[ExportCodeFixProvider(LanguageNames.CSharp,
Name
= nameof(UseNonDefaultCtorCodeFixProvider)), Shared]
public class UseNonDefaultCtorCodeFixProvider : CodeFixProvider
{
private const string title = 'Use non-default constructor',
public sealed override ImmutableArray<,string>, FixableDiagnosticIds
{
get { return ImmutableArray.Create(DoNotUseDefaultCtorAnalyzer.DiagnosticId), }
}

Теперь, нам нужно зарегистрировать действие по исправлению проблемы:public sealed override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false),
var diagnostic = context.Diagnostics.First(),
var diagnosticSpan = diagnostic.Location.SourceSpan,
// Фикс доступен только для вызова конструктора.
// Для фабричных методов ничего не получится!
var construction =
root
.FindToken(diagnosticSpan.Start).Parent
.AncestorsAndSelf().OfType<,ObjectCreationExpressionSyntax>,()
.First(),
// Регистрируем действие, которое выполнит нужное преобразование
var action = CodeAction.
Create(
title: title,
createChangedDocument: c
=>, AddEmptyLambda(context.Document, construction, c),
equivalenceKey: title),
context
.RegisterCodeFix(action, diagnostic),
}

Тут мы видим одну особенность: в текущей инфраструктуре диагностики не существует возможности передать данные между диагностикой и фиксером. Данный фиксер знает, что диагностика жаловалась на вызов конструктора, поэтому нам нужно вручную получить экземпляр ObjectCreationExpression. Это приводит к довольно жесткой связи между фиксером и анализатором, что может вызвать проблемы, когда диагностика будет выдаваться на различные узлы дерева.

Главное действие в этом методе происходит в методе AddEmptyLambda, который принимает старый документ, проблемный узел ObjectCreationExpression, и возвращает обновленный документ:private async Task<,Document>, AddEmptyLambda(
Document document, ObjectCreationExpressionSyntax expression, CancellationToken ct)
{
var arguments = SyntaxFactory.ParseArgumentList('(() =>, {})'),
var updatedNewExpression = expression.WithArgumentList(arguments),
var root = await document.GetSyntaxRootAsync(ct),
return document.WithSyntaxRoot(root.ReplaceNode(expression, updatedNewExpression)),
}

Поскольку синтаксическое дерево в Roslyn-е является неизменяемым, то нам нужно использовать набор методов WithXXX для создания новых элементов с замененными узлами. Еще одной задачей этого метода является создание аргументов конструктора пустого лямбда-выражения, что делается с помощью класса SyntaxFactory.

Все, наш фиксер готов и можно приступать к тестированию:[TestMethod]
public void
FixShouldAddLambdaToConstructor()
{
var code = @'
namespace ConsoleApplication1 {
class TypeName {
public static void Run() {
var r = new MvvmUltraLight.Core.RelayCommand(),
}
}
}'

,
var fixedCode = @'
namespace ConsoleApplication1 {
class TypeName {
public static void Run() {
var r = new MvvmUltraLight.Core.RelayCommand(() =>, {}),
}
}
}'

,
VerifyCSharpFix(code, fixedCode),
}

Наш проект с тестами уже содержит вспомогательный метод VerifyCSharpFix, что делает тестирование фиксера достаточно простой задачей.

Семантическая информация и правильный поиск типов

Первая реализация диагностики определяла использование структуры RelayCommand путем сравнения текста. Это далеко не лучший способ, поскольку вполне может быть, что в коде будет более одного типа с именем RelayCommand. Наш анализатор должен отличать одни типы от других и выдавать предупреждения только на структуру из сборки MvvmUltraLight. И поможет нам в этом семантическая модель.

Главное отличие синтаксических деревьев от символов (получаемых через класс SemanticModel), в том, что последние определяют связанные символы, которые указывают на конкретные типы. Синтаксическое дерево определяет низкоуровневыми узлами, такими как имена, пробельные символы, комментарии и т.п., и не знает ничего о правилах перегрузки методов или видимости. Семантическая модель же знает, где и кем использовался именно этот метод, как найти определение вызванного метода и что за тип здесь использовался. Синтаксическое дерево может быть создано даже для некомпилируемой программы, а семантическая модель будет доступна только в случае успешной компиляции. private bool IsRelayCommandType(SyntaxNodeAnalysisContext context, TypeSyntax type)
{
// Старый подход
// return type.GetText().ToString().Contains(expectedType.Name) == true,
// Получаем семантическую информацию типа
var symbolInfo = context.SemanticModel.
GetSymbolInfo(type),
// Получаем семантическую информацию класса RelayCommand
var relayCommandSymbol = context.SemanticModel.Compilation.
GetTypeByMetadataName(
typeof(RelayCommand).FullName),
return symbolInfo.Symbol?.Equals(relayCommandSymbol) == true,
}

Теперь, метод IsRelayCommandType вернет true, только при создании экземпляра структуры RelayCommand, определенной в сборке MvvmUltraLight.

Заключение

В данной заметке создан очень простой анализатора, который покрывает лишь малую толику требуемого функционала. Например, здесь не покрыты фабричные методы, или создание структуры с помощью default(RelayCommand).

Главная моя цель в том, чтобы показать сложность (точнее, относительную простоту) решения. Скачайте пример этого проекта с гитхаба и попробуйте расширить функционал. Или же подумайте, какой анализатор был бы олезен в вашем собственном проекте и попробуйте его реализовать. Вы удивитесь, насколько это проще, чем кажется!

Дополнительные ссылки

  • Рассмотренные примеры на github
  • Мой анализатор исключений на github
  • Простой Syntax Highlighter на базе Roslyn
  • Learn Roslyn Now

Помогла статья? Оцените её!
0 из 5. Общее количество голосов - 0
 

You have no rights to post comments

Дмитрий Крикунов

Публикую статьи, обучающие курсы и новости по программированию: алгоритмам, языкам (С++, Java), параллельному программированию, паттернам и библиотекам (Qt, boost).