Pull to refresh

Реализация Code Action с помощью Roslyn

Reading time 8 min
Views 2.6K
Original author: Brian Rasmussen
Roslyn Services API позволяют легко реализовывать расширения, которые находят и устраняют проблемы в коде прямо в Visual Studio. Roslyn Services API доступно как часть Roslyn CTP.

В этом посте мы реализуем расширение для Visual Studio, которое детектирует вызовы метода Count() у Enumerable, после чего результат проверяется на равенство больше нуля, например, someSequence.Count() > 0. Проблема, заключающаяся в коде, в том, что Count() должен пройтись по всей последовательности, прежде чем вернуть результат. Более правильным подходом в данном случае является вызов метода Enumerable.Any().

Чтобы исправить это, мы реализуем CodeIssueProvider, детектирующий проблему, и CodeAction, который заменяет условие на вызов Enumerable.Any(), как и требуется. Т.е. наш CodeAction изменит что-то типа someSequence.Count() > 0 на someSequence.Any().

Существует еще пара дополнительных условий, которые хотелось бы также выполнять: прежде всего, выражение может быть перевернуто и записано как 0 < someSequence.Count(). Следующий случай – это запись типа >= 1 вместо > 0, которое представляет собой логически то же самое, как и раньше. Нам нужно, чтобы расширение могло работать в обоих случаях.

Очевидно, что нам бы не хотелось менять не все вызовы с сигнатурой Count(), а только если они относятся к методу-расширению из IEnumerable, определенному в Enumerable.

Начало работы

Roslyn CTP поставляется вместе с набором шаблонов для облегчения начала работы со своим API. Для начала мы создадим новый проект типа Code Issue из Roslyn-шаблонов. Назовем проект как ReplaceCountWithAny.



Шаблон генерирует простой работающий провайдер, который подсвечивает слова, содержащие букву “a”. Чтобы увидеть пример в действии, соберем и запустим проект, созданный шаблоном. При этом запускается новый экземпляр Visual Studio с включенным расширением. Из только что запущенной Visual Studio создадим консольное приложение и увидим как ключевые слова namespace, class и т.п. подчеркнуты нашим расширением.



Хотя пример не настолько и полезен как расширение для Visual Studio, он подготавливает все, что нужно для начала реализации уже собственного расширения. Нам же придется лишь заменить содержимое сгенерированного метода GetIssue. Замечу, что существует три перегрузки для GetIssues. Мы будем работать с перегрузкой, где одним из параметров имеет тип CommonSyntaxNode. Остальные две перегрузки могут быть оставлены в нашем случае.

Сгенерирванный класс CodeIssueProvider реализует интерфейс ICodeIssueProvider и декорирован атрибутом ExportSyntaxNodeCodeIssueProvide. Это позволяет Visual Studio импортировать данный тип как расширение, содержащее контракт, предоставляемый интерфейсом ICodeIssueProvide.

Реализуем GetIssues

Наш метод GetIssues будет вызываться для каждой синтаксической конструкции, так что первым делом мы должны отсеивать не интересующие нас узлы. Так как нам нужны конструкции типа someSequence.Count() > 0, то понадобятся лишь узлы типа BinaryExpressionSyntax. Мы можем сообщить Visual Studio использовать наш провайдер только для специфичных узлов, предоставляя список типов через атрибут ExportSyntaxNodeCodeIssueProvide. Итак, обновим атрибут как показано ниже:
[ExportSyntaxNodeCodeIssueProvider("ReplaceCountWithAny", 
LanguageNames.CSharp, typeof(BinaryExpressionSyntax))]
class CodeIssueProvider : ICodeIssueProvider ...

Это позволяет безопасно приводить CommonSyntaxNode узел к типу BinaryExpressionSyntax в методе GetIssues.

Чтобы выделять случаи, которые хотим обрабатывать, необходимо проверять часть выражения на наличие вызова Enumerable.Count(), и другую на само сравнение. Данные проверки мы выделим во вспомогательные методы, так что наша реализация GetIssues будет выглядеть так:
public IEnumerable<CodeIssue> GetIssues(IDocument document, 
    CommonSyntaxNode node, CancellationToken cancellationToken)
{
    var binaryExpression = (BinaryExpressionSyntax)node;
    var left = binaryExpression.Left;
    var right = binaryExpression.Right;
    var kind = binaryExpression.Kind;
    if (IsCallToEnumerableCount(document, left, cancellationToken) && 
        IsRelevantRightSideComparison(document, right, kind, cancellationToken) ||
        IsCallToEnumerableCount(document, right, cancellationToken) && 
        IsRelevantLeftSideComparison(document, left, kind, cancellationToken))
    {
        yield return new CodeIssue(CodeIssue.Severity.Info, binaryExpression.Span,
            string.Format("Change {0} to use Any() instead of Count() to avoid " +
                          "possible enumeration of entire sequence.", 
                          binaryExpression));
    }
}


Экземпляр класса CodeIssue, который мы возвращаем, указывает уровень проблемы, который может быть Error, Warning или Info, описание, используемое для выделения участка кода, и текст, описывающий проблему пользователю.

Вспомогательные методы

Теперь переместим наше внимание на вспомогательные методы, используемые в GetIssues. Метод IsCallToEnumerableCount возвращает true, если часть выражения, которое мы рассматриваем, является вызовом метода Count() на некоторой последовательности. Напомню еще раз: мы начинаем сначала с фильтрации ненужных выражений.

Прежде всего, выражение должно представлять собой вызов метода. В данном случае, мы получим необходимый вызов из свойства выражения. Итак, если конструкция выглядит как someSequence.Count() > 0, то у нас будет часть с Count(); но как же проверить, принадлежит он к типу Enumerable?

Для ответа на такие вопросы необходимо запросить сематическую модель. К счастью, одним из параметров метода GetIssues является IDocument, представляющий собой документ в проекте и решении. Семантическую модель мы можем получить через него, а уже из нее и сам SymbolInfo, необходимый нам.
С помощью SymbolInfo можно проверить — относится ли наш вызов метода к желаемому [Enumerable.Count()]. Так как Count() – метод расширения, то работа с ним будет немного отличаться. Вспомним, что C# позволяет методам расширения быть вызванным в составе типа. Семантическая модель предоставляет эту информацию через свойство ConstructedFrom класса MethodSymbol с привязкой к оригинальному типу. Возможность сделать это немного проще есть, так что следите за имениями API.

Все что остается нам сделать — указать тип метода расширения. Если он соответствует Enumerable, значит, мы нашли вызов Enumerable.Count().

Реализация выглядит следующим образом:
private bool IsCallToEnumerableCount(IDocument document, 
    ExpressionSyntax expression, CancellationToken cancellationToken)
{
    var invocation = expression as InvocationExpressionSyntax;
    if (invocation == null)
    {
        return false;
    }
 
    var call = invocation.Expression as MemberAccessExpressionSyntax;
    if (call == null)
    {
        return false;
    }
 
    var semanticModel = document.GetSemanticModel(cancellationToken);
    var methodSymbol = semanticModel.GetSemanticInfo(call, cancellationToken).Symbol 
        as MethodSymbol;
    if (methodSymbol == null || 
        methodSymbol.Name != "Count" || 
        methodSymbol.ConstructedFrom == null)
    {
        return false;
    }
 
    var enumerable = semanticModel.Compilation.GetTypeByMetadataName(
        typeof(Enumerable).FullName);
 
    if (enumerable == null || 
        !methodSymbol.ConstructedFrom.ContainingType.Equals(enumerable))
    {
        return false;
    }
 
    return true;
} 

Перед тем как двигаться вперед, необходимо также проверить выражение на правильность сравнения в другой части бинарного выражения; и это является работой для вспомогательных методов IsRelevantRightSideComparison и IsRelevantLeftSideComparison.

Ниже представлены их реализации:

private bool IsRelevantRightSideComparison(IDocument document, 
    ExpressionSyntax expression, SyntaxKind kind, 
    CancellationToken cancellationToken)
{
    var semanticInfo = document.GetSemanticModel(cancellationToken).
        GetSemanticInfo(expression);
 
    int? value;
    if (!semanticInfo.IsCompileTimeConstant || 
        (value = semanticInfo.ConstantValue as int?) == null)
    {
        return false;
    }
 
    if (kind == SyntaxKind.GreaterThanExpression && value == 0 ||
        kind == SyntaxKind.GreaterThanOrEqualExpression && value == 1)
    {
        return true;
    }
 
    return false;
}
 
private bool IsRelevantLeftSideComparison(IDocument document, 
    ExpressionSyntax expression, SyntaxKind kind, 
    CancellationToken cancellationToken)
{
    var semanticInfo = document.GetSemanticModel(cancellationToken).
        GetSemanticInfo(expression);
 
    int? value;
    if (!semanticInfo.IsCompileTimeConstant ||
        (value = semanticInfo.ConstantValue as int?) == null)
    {
        return false;
    }
 
    if (kind == SyntaxKind.LessThanExpression && value == 0 ||
        kind == SyntaxKind.LessThanOrEqualExpression && value == 1)
    {
        return true;
    }
 
    return false;
}

Да, они практически идентичны с той лишь разницей, что проверяются оба варианта сравнения, а также правильность самого значения, так что нам не придется подсвечивать что-то типа Count() >= 0.

Тестируем CodeIssueProvider

На данный момент наш провайдер умеет детектировать интересующие нас проблемы. Скомпилируем и запустим проект вместе с новым экземпляром Visual Studio вместе с включенным расширением. Добавим код и заметим, что вызовы Enumerable.Count() подчеркнуты правильно, в то время как вызовы к другим методам с сигнатурой Count() — нет.



Следующим шагом является предоставление действия для решения проблемы.

CodeAction

Для реализации действия нам необходим класс реализующий интерфейс ICodeAction. ICodeAction – простой интерфейс, определяющий описание и иконку для действия, а также единственный метод GetEdit, возвращающий правку, которая трансформирует текущее синтаксическое дерево. Итак, начнем с конструктора нашего класса CodeAction.

public CodeAction(ICodeActionEditFactory editFactory, 
    IDocument document, BinaryExpressionSyntax binaryExpression)
{
    this.editFactory = editFactory;
    this.document = document;
    this.binaryExpression = binaryExpression;
}

Для каждой найденной проблемы будет создан новый экземпляр класса CodeAction, поэтому для удобства мы опустим некоторые параметры и изменим сам конструктор. Для этого необходима реализация ICodeActionEditFactory для создания трансформации только что созданного синтаксического дерев. Так как синтаксические деревья в проекте Roslyn неизменяемые, то возвращение нового дерева – единственная возможность сделать какие-либо изменения. К счастью, Roslyn старается повторно использовать дерево насколько это возможно, таким образом, предотвращая создание лишних синтаксических узлов.

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

Итак, мы приблизились к методу GetEdit. Именно здесь мы создаем трансформацию, которая заменит обнаруженное бинарное выражение новым с вызовом метода Any(). Создание нового узла возложено на простой вспомогательный метод GetNewNode. Реализация обоих методов приведена ниже:

public ICodeActionEdit GetEdit(CancellationToken cancellationToken)
{
    var syntaxTree = (SyntaxTree)document.GetSyntaxTree(cancellationToken);
    var newExpression = GetNewNode(binaryExpression).
        WithLeadingTrivia(binaryExpression.GetLeadingTrivia()).
        WithTrailingTrivia(binaryExpression.GetTrailingTrivia());
    var newRoot = syntaxTree.Root.ReplaceNode(binaryExpression, newExpression);
 
    return editFactory.CreateTreeTransformEdit(
        document.Project.Solution,
        syntaxTree,
        newRoot,
        cancellationToken: cancellationToken);
}
 
private ExpressionSyntax GetNewNode(BinaryExpressionSyntax node)
{
    var invocation = node.DescendentNodes().
        OfType<InvocationExpressionSyntax>().Single();
    var caller = invocation.DescendentNodes().
        OfType<MemberAccessExpressionSyntax>().Single();
    return invocation.Update(
        caller.Update(caller.Expression, 
        caller.OperatorToken, 
        Syntax.IdentifierName("Any")),
        invocation.ArgumentList);
}

Синтаксическое дерево Roslyn полностью совпадает с оригинальным кодом, так что каждый узел в дереве может содержать лишние пробелы и комментарии. Т.е. мы сохраняем исходный узел вместе с комментариями и структурой кода при изменении самих узлов. Для этого мы вызываем методы расширения WithLeadingTrivia и WithTrailingTrivia.

Также замечу, что метод GetNewNode сохраняет список параметров метода Count(), так что, если был вызван метод расширения через лямбда-выражение для подсчета специфичных элементов в последовательности, то он все равно заменится на Any().

Подведем итоги

Для включения нашего действия следует обновить метод GetIssues в классе CodeIssueProvider, чтобы возвращать экземпляр CodeAction для каждого CodeIssue. Каждый проблемный участок кода может иметь несколько действий, позволяя пользователю выбрать между ними. В данном случае мы возвращаем одно единственное действие как показано ниже.

Обновленная часть метода GetIssues выглядит следующим образом:

yield return new CodeIssue(CodeIssue.Severity.Info, binaryExpression.Span,
    string.Format("Change {0} to use Any() instead of Count() to avoid " +
                  "possible enumeration of entire sequence.", binaryExpression),
    new CodeAction(editFactory, document, binaryExpression));

Пересоберем и запустим проект для запуска нового экземпляра Visual Studio с загруженным расширением. Теперь мы видим, что проблемный участок предоставляет выпадающий список с опциями исправления кода.



Таким образом, мы реализовали расширение для Visual Studio, которое поможет улучшить наш код.
Tags:
Hubs:
+29
Comments 6
Comments Comments 6

Articles