LoginSignup
13
11

More than 1 year has passed since last update.

Roslynアナライザ(+コード修正)を実装する

Last updated at Posted at 2022-09-09

はじめに

Roslynアナライザを作ってみましたという私の別記事で,アナライザの実装に関する資料が少ないですよねというコメントを頂きました。
確かに私も実装時にはそれはとても困った点で,MSDNですらきちんと整備されておらずましてやQiitaなど日本語の資料はほとんどありません。
Stackoverflowは偉い。

というわけで,簡単ではありますがアナライザの作成方法を解説したいと思います。

前提知識など

この記事は,「Roslynアナライザの作成方法に興味があるならばC#についてそれなりの知識があるであろう」という仮定の下に書かれています。
また,「シンタックスツリーとは」みたいな説明をする趣旨の記事でもありません。

MSDNのシンタックスの話セマンティクスの話などは大して長くないので軽く読んでおくと理解の助けになるかと思います。

環境の準備

Visual Studioは必須です。最新のものをインストールしておきましょう(こんな記事を読んでいる方なら既に入っていると思いますが)。
また,「Visual Studio 拡張機能の開発」なるものもインストールする必要があります。

image.png

プロジェクト作成

最終的に以下のような配置にします。

ソリューション
 ├ Analyzer1/
 │ ├ (アナライザ用のファイルたち)
 │ └ Analyzer1.csproj
 ├ Analyzer1.CodeFixes/
 │ ├ (コード修正用のファイルたち)
 │ └ Analyzer1.CodeFixes.csproj
 ├ Analyzer1.Package/
 │ └ Analyzer1.Package.csproj
 └ Analyzer1.Test/
   ├ (テストコードなど)
   └ Analyzer1.Test.csproj

プロジェクトテンプレート

適当に作成してcsprojを編集しても良いのですが,プロジェクトテンプレートで言語を「C#」,種類を「Roslyn」として出てくる「Analyzer with Code Fix (.NET Standard)」というやつを使用すると勝手に諸々を準備してくれるので便利です。
なお,この方法で作成する場合の選択するフレームワークはVsix用プロジェクトのみに反映されます。
というのも,今回の一番大事なプロジェクトであるアナライザはターゲットが.NET Standard 2.0でなければならないからです。
そして,今回はVsix用のプロジェクトは見なかったことにします。
(拡張機能に対する私の理解が追い付いていないというのが正直なところですが,まあ要らないので…)

基本的には作成されたものをそのまま使えばよいのですが,Analyzer1.Test.csproj内にあるライセンスの設定は要修正です。
テンプレートではPackageLicenseUrlというタグが使用されるのですが,このタグは非推奨となっています。
代わりの設定方法はMSDNを参照してください。
そのほかの項目についても必要事項を入力します。
下のcsprojの例も参考にしてください。

また,テンプレートから作成したプロジェクトではMicrosoft.CodeAnalysis.*の古いバージョンが参照されてしまうため,気が向いた時に更新しておきましょう。

csproj

テンプレートなんか使わずにcsprojを直書きするんじゃという方のためにcsprojの中身を書いておきます。

アナライザ
Analyzer1/Analyzer1.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>library</OutputType>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <IsRoslynComponent>true</IsRoslynComponent>
    <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);PackBuildOutputs</TargetsForTfmSpecificContentInPackage>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <IncludeSymbols>false</IncludeSymbols>
    <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
    <DevelopmentDependency>true</DevelopmentDependency>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.2">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.8.0" />
  </ItemGroup>

  <ItemGroup>
    <Compile Update="Resources.Designer.cs" DesignTime="True" AutoGen="True" DependentUpon="Resources.resx" />
    <EmbeddedResource Update="Resources.resx" Generator="ResXFileCodeGenerator" LastGenOutput="Resources.Designer.cs" />
  </ItemGroup>

  <Target Name="PackBuildOutputs" DependsOnTargets="SatelliteDllsProjectOutputGroup;DebugSymbolsProjectOutputGroup">
    <ItemGroup>
      <TfmSpecificPackageFile Include="$(TargetDir)\*.dll" PackagePath="analyzers\dotnet\cs" />
      <TfmSpecificPackageFile Include="@(SatelliteDllsProjectOutputGroupOutput->'%(FinalOutputPath)')" PackagePath="analyzers\dotnet\cs\%(SatelliteDllsProjectOutputGroupOutput.Culture)\" />
    </ItemGroup>
  </Target>

</Project>
コード修正
Analyzer1.CodeFixes/Analyzer1.CodeFixes.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <IsPackable>false</IsPackable>
    <RootNamespace>Analyzer1</RootNamespace>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.0" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Analyzer1\Analyzer1.csproj" />
  </ItemGroup>

  <ItemGroup>
    <Compile Update="CodeFixResources.Designer.cs" DesignTime="True" AutoGen="True" DependentUpon="CodeFixResources.resx" />
    <EmbeddedResource Update="CodeFixResources.resx" Generator="ResXFileCodeGenerator" LastGenOutput="CodeFixResources.Designer.cs" />
  </ItemGroup>

</Project>
パッケージ
Analyzer1.Package/Analyzer1.Package.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <IncludeBuildOutput>false</IncludeBuildOutput>
    <SuppressDependenciesWhenPacking>true</SuppressDependenciesWhenPacking>
    <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
  </PropertyGroup>

  <PropertyGroup>
    <PackageId>Analyzer1</PackageId>
    <PackageVersion>1.0.0.0</PackageVersion>
    <Authors>ikuzak</Authors>
    <PackageProjectUrl>http://PROJECT_URL_HERE_OR_DELETE_THIS_LINE</PackageProjectUrl>
    <PackageIconUrl>http://ICON_URL_HERE_OR_DELETE_THIS_LINE</PackageIconUrl>
    <RepositoryUrl>http://REPOSITORY_URL_HERE_OR_DELETE_THIS_LINE</RepositoryUrl>
    <PackageLicenseExpression>MIT</PackageLicenseExpression>
    <PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
    <Description>Analyzer1</Description>
    <PackageReleaseNotes>Summary of changes made in this release of the package.</PackageReleaseNotes>
    <Copyright>Copyright</Copyright>
    <PackageTags>Analyzer1, analyzers</PackageTags>
    <DevelopmentDependency>true</DevelopmentDependency>
    <NoPackageAnalysis>true</NoPackageAnalysis>

    <TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddAnalyzersToOutput</TargetsForTfmSpecificContentInPackage>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\Analyzer1.CodeFixes\Analyzer1.CodeFixes.csproj" />
    <ProjectReference Include="..\Analyzer1\Analyzer1.csproj" />
  </ItemGroup>

  <Target Name="_AddAnalyzersToOutput">
    <ItemGroup>
      <TfmSpecificPackageFile Include="$(OutputPath)\Analyzer1.dll" PackagePath="analyzers/dotnet/cs" />
      <TfmSpecificPackageFile Include="$(OutputPath)\Analyzer1.CodeFixes.dll" PackagePath="analyzers/dotnet/cs" />
    </ItemGroup>
  </Target>

</Project>
テスト
Analyzer1.Test/Analyzer1.Test.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netcoreapp3.1</TargetFramework>
    <LangVersion>latest</LangVersion>
    <Nullable>enable</Nullable>
    <AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
    <GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.3.1" />
    <PackageReference Include="MSTest.TestAdapter" Version="2.2.10" />
    <PackageReference Include="MSTest.TestFramework" Version="2.2.10" />
    <PackageReference Include="Microsoft.CodeAnalysis" Version="4.3.0" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing.MSTest" Version="1.1.1" />
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeRefactoring.Testing.MSTest" Version="1.1.1" />
  </ItemGroup>

  <ItemGroup>
    <ProjectReference Include="..\Analyzer1.CodeFixes\Analyzer1.CodeFixes.csproj" />
    <ProjectReference Include="..\Analyzer1\Analyzer1.csproj" />
  </ItemGroup>

</Project>

アナライザ作成

具体的な例がないと説明が難しいので,ローカル変数を読み取り専用にするアナライザを例に紹介します。

以下のディレクティブはglobalで書いてもよいかもしれません。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

これらの名前空間は以下の説明では省略します。

アナライザはDiagnosticAnalyzerを継承するクラスです。
ここに必要情報を書いて解析処理を追加するだけでアナライザが完成します!

メタ情報

Analyzer1/Analyzer1Analyzer.cs
namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class Analyzer1Analyzer : DiagnosticAnalyzer
    {
        public static readonly string DiagnosticId = "RO0001";

        private static LocalizableResourceString GetLocalizableResourceString(string resourceName)
            => new(resourceName, Resources.ResourceManager, typeof(Resources));
        private static readonly LocalizableString Title = GetLocalizableResourceString(nameof(Resources.AnalyzerTitle));
        private static readonly LocalizableString MessageFormat = GetLocalizableResourceString(nameof(Resources.AnalyzerMessageFormat));
        private static readonly LocalizableString Description = GetLocalizableResourceString(nameof(Resources.AnalyzerDescription));
        private const string Category = "CodeStyle";

        private static readonly DiagnosticDescriptor Rule = new(
            id                : DiagnosticId,
            title             : title,
            messageFormat     : messageFormat,
            category          : Category,
            defaultSeverity   : DiagnosticSeverity.Error,
            isEnabledByDefault: true
        );

        override public ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics
            => ImmutableArray.Create(Rule);
    }
}

アナライザが提供する診断情報を明示します。
アナライザとして必ず指定しなければならないのはSupportedDiagnosticsのみですが,
これを作成しやすくするために様々なフィールドを定義しています。

DiagnosticIdは"CS8602"などと出てくるアレです。
LocalizableStringの3つは名前のそのままです。
MessageFormatには"ローカル変数'{0}'は読み取り専用です。"のように値を埋めることができます。
これについての動作はきちんと検証していませんがstring.Format(string format, params object?[] args)と同じであると推測されます。
l10nに全く関心がないのであればこれらはstring直書きでも大丈夫です(DiagnosticDescriptorのコンストラクタにstringをとるオーバーロードがあるため)。

カテゴリーに関してはRoslynのリポジトリにあるファイルを参考にそれっぽいものを指定します。

以上の情報を利用してDiagnosticDescriptorを作成し,それをImmutableArrayとして公開します。

初期化

一度だけ呼ばれるコードで,ここで診断処理を登録します。

Analyzer1/Analyzer1Analyzer.cs
namespace Analyzer1
{
    public class Analyzer1Analyzer : DiagnosticAnalyzer
    {
        override public void Initialize(AnalysisContext context)
        {
            // 以下2行は必ず書く
            context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
            context.EnableConcurrentExecution();

            // 診断処理を登録する
            context.RegisterSyntaxNodeAction(AnalyzeAssignmentNode,
                SyntaxKind.AddAssignmentExpression,
                SyntaxKind.AndAssignmentExpression,
                SyntaxKind.CoalesceAssignmentExpression,
                SyntaxKind.DivideAssignmentExpression,
                SyntaxKind.ExclusiveOrAssignmentExpression,
                SyntaxKind.LeftShiftAssignmentExpression,
                SyntaxKind.ModuloAssignmentExpression,
                SyntaxKind.MultiplyAssignmentExpression,
                SyntaxKind.OrAssignmentExpression,
                SyntaxKind.RightShiftAssignmentExpression,
                SyntaxKind.SimpleAssignmentExpression,
                SyntaxKind.SubtractAssignmentExpression
            );
        }
    }
}

ここではノードに対する診断を登録しています。
他にもシンボルに対する診断などいろいろあるので,エディタでcontext.Registerと打ってみて使えそうなものを探してみてください。
Register*の第2引数は診断の対象とするSyntaxKindを好きなだけ指定できます。
SyntaxKindは後述するSyntax Visualizerで簡単に調べることができます。

診断処理

Analyzer1/Analyzer1Analyzer.cs
namespace Analyzer1
{
    public class Analyzer1Analyzer : DiagnosticAnalyzer
    {
        private static async void AnalyzeAssignmentNode(SyntaxNodeAnalysisContext context)
        {
            var semanticModel = context.SemanticModel;

            var node = (AssignmentExpressionSyntax)context.Node;
            if (node.Parent is ForStatementSyntax) return;
            var leftNode = node.Left;
            if (leftNode is TupleExpressionSyntax tuple)
            {
                await CheckTupleNode(tuple, context);
                return;
            }
            var leftSymbol = semanticModel.GetSymbolInfo(leftNode).Symbol;
            await ReportIfNecessary(leftSymbol, node, context);
        }

        private static async Task ReportIfNecessary(ISymbol? symbol, SyntaxNode node, SyntaxNodeAnalysisContext context)
        {
            if (!await CheckIfVariableIsNotReassignable(symbol, node, context.SemanticModel, context.CancellationToken)) return;
            var name = symbol?.Name;
            context.ReportDiagnostic(Diagnostic.Create(Rule, node.GetLocation(), name));
        }

        /* 長々と書いてもしょうがないので他のメソッドは端折ります。
         * 気になる方は
         *   https://github.com/IkuzakIkuzok/ReadonlyLocalVariables
         * を参照してください。
         */
    }
}

SyntaxNodeAnalysisContextを受け取って必要な処理を行うメソッドです。
asyncを付けているのでSystem.Threading.Tasks.Taskを返したくなりますが,そうすると登録で怒られてしまうのでvoidにします。

内容は実現したい診断によるので何とも言えませんが,context.Nodeでシンタックスツリー内の対象ノードを取得して必要な処理を行います。
ここで,登録時に代入式のみを指定したため,AssignmentExpressionSyntaxへのキャストは安全に行うことができます。

SemanticModelは文字通りセマンティックに関する情報を教えてくれる人です。
シンボルを取得したり,ある位置から見えるシンボルを列挙したりすることができます。
例えば,代入の左辺のノードに対してvar leftSymbol = semanticModel.GetSymbolInfo(leftNode).Symbol;としていますが,leftSymbol.Nameとすることで左辺のシンボル名を取得できます。

そして,SyntaxNodeAnalysisContext.ReportDiagnosticを利用して診断情報を報告します。
診断の種類,報告する位置,メッセージを作成するためのパラメータを指定します。

コード生成

独自の属性を定義してその情報を解析で利用したい,と言う場合があります。
このような場合に,特定の名前で決まった型をいちいち作らせるのはあまりにも不親切なので,コード生成によってコードを追加することができます。

Analyzer1/ReassignableVariableAttributeGenerator.cs
using Microsoft.CodeAnalysis;

namespace Analyzer1
{
    [Generator]
    public class ReassignableVariableAttributeGenerator : ISourceGenerator
    {
        public static readonly string ReassignableVariableAttributeName = "Analyzer1.ReassignableVariableAttribute";

        // 初期化処理; コードを入れるだけなら不要
        public void Initialize(GeneratorInitializationContext context) { }

        // コード生成処理
        public void Execute(GeneratorExecutionContext context)
        {
            // ファイル名とコードを文字列で指定すればOK
            context.AddSource(
                "__MutableVariablesRuleAttribute.cs",
                @"
using System;
namespace Analyzer1
{
    [AttributeUsage(AttributeTargets.All, Inherited = false, AllowMultiple = true)]
    internal sealed class ReassignableVariableAttribute : Attribute
    {
        internal string[] Identifiers { get; private set; }

        public ReassignableVariableAttribute(params string[] names)
        {
            this.Identifiers = names;
        }
    }
}
"
            );
        }
    }
}

割と簡単に実現できます。
ただ,サジェストが効かない文字列としてC#のコードを書くのは苦痛でしかないので,適当なファイルでコードを作ってからコピペすることをお勧めします。

なお,ドキュメントコメントを書いておけば利用者側にきちんと表示されます。

コード修正

診断結果に対応するコード修正を提案することができます。
image.png
こんなやつですね。

コード修正プロバイダはMicrosoft.CodeAnalysis.CodeFixes.CodeFixProviderを継承します。

ここでは,先ほどのローカル変数を読み取り専用にするアナライザとセットになる修正として,再代入の代わりに変数宣言を追加するという処理を実装します。

var local = 0;
Console.WriteLine(local);

-local = 1;
-Console.WriteLine(local);
+var local1 = 1;
+Console.WriteLine(local1);

こんな感じです。

メタ情報

Analyzer1.CodeFixes/Analyzer1CodeFixProvider.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Formatting;

namespace Analyzer1
{
    [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(Analyzer1CodeFixProvider)), Shared]
    public class Analyzer1CodeFixProvider : CodeFixProvider
    {
        override public ImmutableArray<string> FixableDiagnosticIds
            => ImmutableArray.Create(Analyzer1Analyzer.DiagnosticId);

        override public FixAllProvider GetFixAllProvider()
            => WellKnownFixAllProviders.BatchFixer;
    }
}

いかなるコード修正を提供するかを明示し,FixAllProviderなるものを示します。
FixAllProviderについてはきちんと理解できていないのですが,複数の修正箇所に対する修正をまとめていい感じにしてくれるそうです。
気になる方はRoslynのドキュメントを読んで下さい。

登録

修正処理を登録します。

Analyzer1.CodeFixes/Analyzer1CodeFixProvider.cs

namespace Analyzer1
{
    public class Analyzer1CodeFixProvider : CodeFixProvider
    {
        override public 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 nodes = root.FindToken(diagnosticSpan.Start).Parent.AncestorsAndSelf();
            var syntaxNodes = nodes.OfType<AssignmentExpressionSyntax>();
            if (!syntaxNodes.Any()) return;
            var syntaxNode = syntaxNodes.First();

            // 登録
            context.RegisterCodeFix(
                CodeAction.Create(
                    title                : "Make new variable",
                    createChangedDocument: c => MakeNewVariable(context.Document, syntaxNode, c),
                    equivalenceKey       : "NewVariable"
                ),
                diagnostic
            );
        }
    }
}

内容はコメントで示した通りです。
ノードなどの必要情報の取得は修正処理の内容に応じて変わるので適宜変更してください。

必要であれば1つの診断結果に対して複数の修正を提案することもできます。

CodeAction.Create

title

上の画像の例における「インライン変数宣言」に相当する部分です。

createChangedDocument

CancellationTokenを受け取ってTask<Document>を返します。
とはいってもCancellationTokenだけ貰っても仕方がないので他にもいろいろ渡してあげます。

equivalenceKey

必須ではありませんが,指定しておくとよいでしょう。
特に,複数の修正を提案する場合に,テストを行いやすくなります。

修正処理

Analyzer1.CodeFixes/Analyzer1CodeFixProvider.cs
namespace Analyzer1
{
    public class Analyzer1CodeFixProvider : CodeFixProvider
    {
        private static async Task<Document> MakeNewVariable(Document document, AssignmentExpressionSyntax assignment, CancellationToken cancellationToken)
        {
            var assignmentStatement = assignment.Parent;
            var oldLen = assignmentStatement.ToString().Length;

            var oldLeftOperand = assignment.Left;
            if (oldLeftOperand is TupleExpressionSyntax syntax)
                return await RewriteTuple(syntax, document, cancellationToken);

            var oldRightOperand = assignment.Right;

            // 追加する変数の名前を決定する
            var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false);
            var oldName = oldLeftOperand.ChildTokens().First().ValueText;
            var newName = CreateNewUniqueName(oldName, semanticModel, assignment.SpanStart);

            // 新しい変数の初期化子を作る
            var newRightOperand = assignment.IsKind(SyntaxKind.SimpleAssignmentExpression)
                ? oldRightOperand                  // 単純代入
                : SyntaxFactory.BinaryExpression(  // 複合代入
                    kind : GetSimpleExpressionKind(assignment.Kind()),
                    left : oldLeftOperand,
                    right: oldRightOperand
                  );
            
            // 変数宣言を作る
            var declarator = SyntaxFactory.VariableDeclarator(
                identifier  : SyntaxFactory.Identifier(newName),
                argumentList: null,
                initializer : SyntaxFactory.EqualsValueClause(newRightOperand)
            );
            var declaration = SyntaxFactory.VariableDeclaration(
                type     : SyntaxFactory.IdentifierName("var"),
                variables: SyntaxFactory.SeparatedList(new[] { declarator })
            );
            var localDeclaration = SyntaxFactory.LocalDeclarationStatement(declaration);

            // 整えた上でノードを書き換える
            var formattedDeclaration = localDeclaration.WithAdditionalAnnotations(Formatter.Annotation)
                                                       .WithTriviaFrom(assignmentStatement);
            var oldRoot = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false);
            var newRoot = oldRoot.ReplaceNode(assignmentStatement, formattedDeclaration);
            var newLen = formattedDeclaration.ToString().Length;

            // 新しい変数を作ってもそれを利用してもらえなかったら意味がないので更新する
            var renameStartPosition = assignmentStatement.GetTrailingTrivia().Span.End - oldLen + newLen;
            var rewriter = new IdentifierNameRewriter(renameStartPosition, oldName, newName);
            var oldMethod = GetMinimumScope(newRoot.FindToken(renameStartPosition).Parent, out var _);
            var newMethod = rewriter.Visit(oldMethod);

            // 修正後の新しいドキュメントを返す
            return document.WithSyntaxRoot(newRoot.ReplaceNode(oldMethod, newMethod));
        }

        // 長くなるので例によって他のメソッドは省略
    }
}

必要なツリーを構築して,変更箇所と入れ替えます。
どのようなツリーを作成すべきかは,作りたいコードをどこかに書いてみてSyntax Visualizerで確認してみるのがよいでしょう。

TrackNodes

ツリーはimmutableであるため,ReplaceNodeで入れ替えた新しいツリーを貰います。
このとき,元のツリーに含まれていたノードは新しいノードには含まれなくなってしまいます。
それでは困ってしまうことがあるため,ノードを追跡することができます。

var trackingRoot = oldRoot.TrackNodes(node);  // 追跡してもらう
/* trackingRootに何かしてnewRootを得る */
var currentNode = newRoot.GetCurrentNode(node);  // 元のノードに対応するノードを取得

SyntaxRewriter

シンタックスツリーを書き換えてくれます(名前のままですね)。

使い方は

  1. CSharpSyntaxRewriterを継承するクラスを作る
  2. 書き換えたいシンタックス(例えばIdentifierName)に対応するVisit*をオーバーライドする
  3. インスタンスを作成し,Visit(node)で書き換える

と言った感じです。

上の例では,メソッド内で特定の位置以降に出てくる識別子名を書き換えるものを使っているためそのコードを紹介します。

Analyzer1.CodeFixes/IdentifierNameRewriter.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Linq;

namespace Analyzer1
{
    internal sealed class IdentifierNameRewriter : CSharpSyntaxRewriter
    {
        private readonly int position;
        private readonly string oldName, newName;

        internal IdentifierNameRewriter(int position, string oldName, string newName)
        {
            this.position = position;
            this.oldName = oldName;
            this.newName = newName;
        }

        override public SyntaxNode VisitIdentifierName(IdentifierNameSyntax node)
        {
            /*
             * クラスメンバが更新しようとしているローカル変数名と衝突している場合には`this`などで修飾しているはずである。
             * その場合には,識別子名の親ノードの最初の子ノードは当該修飾子であるはずなので,識別子名とは一致しない。
             * 本当はSemanticModelで定義を確認する方が良いと思うが,書き変わっていくルートを追えないのでこの方法で対応する。
             */
            if (node.Parent.ChildNodes().First() != node) return node;

            if (node.SpanStart < this.position) return node;
            var oldToken = node.GetFirstToken();
            if (oldToken.ToString() != this.oldName) return node;
            var newToken = SyntaxFactory.Identifier(newName);
            return node.ReplaceToken(oldToken, newToken.WithTriviaFrom(oldToken));
        }
    }
}

Visit*は,対象のノードを受け取って新しいノードを返します。
何もしない場合は受け取ったノードをそのまま返します。

このようにして定義すると,

var rewriter = new IdentifierNameRewriter(renameStartPosition, oldName, newName);
var newMethod = rewriter.Visit(oldMethod);

のようにして識別子名を書き換えることができます。

この辺りの話はMSDNにも記事があるので併せて参考にしてください。

テスト

テストツール

プロジェクトテンプレートが吐いてくれるコードでもよいのですが,コード生成で追加した型を利用する場合にエラーになってしまうため,一式自作します(とはいってもライブラリは使わせてもらいますが)。

まず,テスト実行時にアナライザをロードするローダーを作成します。

Analyzer1.Test/Verifiers/AnalyzerLoader.cs
using Microsoft.CodeAnalysis;
using System.Collections.Generic;
using System.Reflection;

namespace Analyzer1.Test.Verifiers
{
    internal class AnalyzerLoader : IAnalyzerAssemblyLoader
    {
        private readonly object lockObject = new();

        private readonly Dictionary<string, Assembly> loadedAssemblies = new();

        public Assembly LoadFromPath(string fullPath)
        {
            lock (lockObject)
            {
                if (this.loadedAssemblies.TryGetValue(fullPath, out var assembly))
                    return assembly;
            }

            var asm = Assembly.LoadFrom(fullPath);

            lock (lockObject)
            {
                loadedAssemblies[fullPath] = asm;
            }

            return asm;
        }

        // コイツが何をすべきなのかわからない……
        public void AddDependencyLocation(string fullPath) { }
    }
}

不穏なコメントがありますが気にしてはいけません。

続いて,テストケースを表現するクラスを作成します。
ここで先ほど作成したローダーを利用してアナライザの参照を追加します。

Analyzer1.Test/Verifiers/AnalyzerTest.cs
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing.Verifiers;

namespace Analyzer1.Test.Verifiers
{
    internal class AnalyzerTest<TAnalyzer> : CSharpAnalyzerTest<TAnalyzer, MSTestVerifier> where TAnalyzer : DiagnosticAnalyzer, new()
    {
        private static readonly string AnalyzerPath = typeof(TAnalyzer).Assembly.Location;

        internal AnalyzerTest()
        {
            this.SolutionTransforms.Add((solution, projectId) =>
            {
                var project = solution.GetProject(projectId);
                if (project == null) return solution;
                project = project.AddAnalyzerReference(new AnalyzerFileReference(AnalyzerPath, new AnalyzerLoader()));
                return project.Solution;
            });
        }
    }
}
Analyzer1.Test/Verifiers/CodeFixTest.cs
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing.Verifiers;

namespace Analyzer1.Test.Verifiers
{
    internal class CodeFixTest<TAnalyzer, TCodeFix> : CSharpCodeFixTest<TAnalyzer, TCodeFix, MSTestVerifier>
        where TAnalyzer : DiagnosticAnalyzer, new()
        where TCodeFix : CodeFixProvider, new()
    {
        private static readonly string AnalyzerPath = typeof(TAnalyzer).Assembly.Location;

        internal CodeFixTest()
        {
            this.SolutionTransforms.Add((solution, projectId) =>
            {
                var project = solution.GetProject(projectId);
                if (project == null) return solution;
                project = project.AddAnalyzerReference(new AnalyzerFileReference(AnalyzerPath, new AnalyzerLoader()));
                return project.Solution;
            });
        }
    }
}

実は,コード修正のテストにおいて診断のテストも実行されるので修正のテストのみでよいのですが,
これらを分離しておくことでテストに失敗した際に診断と修正のどちらに問題があったのか分かりやすいであろうと思い分けてあります。

最後に,実際にテストを実行するクラスを作成します。

Analyzer1.Test/Verifiers/AnalyzerVerifier.cs
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
using System.Threading;
using System.Threading.Tasks;

namespace Analyzer1.Test.Verifiers
{
    internal class AnalyzerVerifier<TAnalyzer> where TAnalyzer : DiagnosticAnalyzer, new()
    {
        internal static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected)
        {
            var test = new AnalyzerTest<TAnalyzer>()
            {
                TestCode = source,
            };

            test.ExpectedDiagnostics.AddRange(expected);
            await test.RunAsync(CancellationToken.None);
        }
    }
}
Analyzer1.Test/Verifiers/CodeFixVerifier.cs
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;
using System.Threading;
using System.Threading.Tasks;

namespace Analyzer1.Test.Verifiers
{
    internal class CodeFixVerifier<TAnalyzer, TCodeFix> where TAnalyzer : DiagnosticAnalyzer, new() where TCodeFix : CodeFixProvider, new()
    {
        internal static async Task VerifyCodeFixAsync(string source, string fixedSource, int codeActionIndex, params DiagnosticResult[] expected)
        {
            var test = new CodeFixTest<TAnalyzer, TCodeFix>()
            {
                TestCode = source,
                FixedCode = fixedSource,
                CodeActionIndex = codeActionIndex,
            };

            test.ExpectedDiagnostics.AddRange(expected);
            await test.RunAsync(CancellationToken.None);
        }

        internal static async Task VerifyCodeFixAsync(string source, string fixedSource, params DiagnosticResult[] expected)
            => await VerifyCodeFixAsync(source, fixedSource, 0, expected);

        internal static async Task VerifyCodeFixAsync(string source, string fixedSource, string equivalenceKey, params DiagnosticResult[] expected)
        {
            var test = new CodeFixTest<TAnalyzer, TCodeFix>()
            {
                TestCode = source,
                FixedCode = fixedSource,
                CodeActionEquivalenceKey = equivalenceKey,
            };

            test.ExpectedDiagnostics.AddRange(expected);
            await test.RunAsync(CancellationToken.None);
        }
    }
}

CodeFixVerifier.VerifyCodeFixAsyncには3つのオーバーロードがあります。
これらは

  • 既定の(最初に登録された)修正を実行する
  • 登録された順序に依存するインデックスで指定される修正を実行する
  • equivalenceKeyで指定される修正を実行する

というものです。

テスト作成

テストを実行するためのツールの準備ができたため実際にテストを行っていきます。

アナライザ

Analyzer1.Test/AnalyzerTest.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Verifier = Analyzer1.Test.Verifiers.AnalyzerVerifier<Analyzer1.Analyzer1Analyzer>;

namespace Analyzer1.Test
{
    [TestClass]
    public class AnalyzerTest
    {
        private static readonly string DiagnosticId = Analyzer1Analyzer.DiagnosticId;

        [TestMethod]
        public async Task LocalReassignment()
        {
            var test = @"
class C
{
    void M()
    {
        var i = 0;
        {|#0:i = 1|};
    }
}
";
            var expected = new DiagnosticResult(DiagnosticId, DiagnosticSeverity.Error)
                            .WithArguments("i")
                            .WithLocation(0);
            await Verifier.VerifyAnalyzerAsync(test, expected);
        }
    }
}

テストするコードと,期待される診断結果を指定します。
何も診断結果がないことが期待される場合には0個指定します(=コードだけを渡す)。

期待される診断結果はDiagnosticResultで,コンストラクタはIDと重大度を受け取ります。
しかし,これだけでは位置やメッセージのパラメータ('{0}'のように開けておいた部分を埋める値)の情報が不足しているため,これも渡してあげます。
位置に関してはWithSpanというメソッドでも指定できますが,何行目の何文字~というのをいちいち数えるのは煩雑でしかないため,上の例で示したような特殊な記法で番号を振ってその番号を指定するのが良いでしょう。
{||}で該当箇所を囲い,#n:で番号を指定します。

コード修正

Analyzer1.Test/CodeFixTest.cs
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using System.Threading.Tasks;
using Verifier = Analyzer1.Test.Verifiers.CodeFixVerifier<
                    Analyzer1.Analyzer1Analyzer,
                    Analyzer1.Analyzer1CodeFixProvider
                 >;

namespace Analyzer1.Test
{
    [TestClass]
    public class CodeFixTest
    {
        private static readonly string DiagnosticId = Analyzer1Analyzer.DiagnosticId;

        // equivalence key
        private const string NEW_VARIABLE = "NewVariable";

        [TestMethod]
        public async Task UpdateReference()
        {
            var source = @"
using System;

class C
{
    void M()
    {
        var i = 0;
        Console.WriteLine(i);

        {|#0:i = 1|};
        Console.WriteLine(i);
    }
}
";

            var fixedSource = @"
using System;

class C
{
    void M()
    {
        var i = 0;
        Console.WriteLine(i);

        var i1 = 1;
        Console.WriteLine(i1);
    }
}
";

            var expected = new DiagnosticResult(diagnosticId, DiagnosticSeverity.Error)
                            .WithArguments("i")
                            .WithLocation(0);
            await Verifier.VerifyCodeFixAsync(source, fixedSource, NEW_VARIABLE, expected);
        }
    }
}

入力コード,入力コードに対して期待される診断結果,修正適用後の期待されるコードを指定します。
修正後のコード(と必要に応じて修正方法)を指定するほかはアナライザと同様です。

テスト実行

Visual Studioはお利巧なのでテストを勝手に見つけてくれます。
メニューから[テスト]>[すべてのテストを実行]などでテストを実行できます。

期待される診断結果と実際の結果が異なった場合は何個異なったのか教えてくれますし,
コード修正が上手くいかなかった場合には差分を見せてくれます。
image.png
便利ですね。

GitHub Actions

GitHub Actionsで特定の条件(mainにプッシュした時など)で自動的にテストを実行することができます。
この機能を全然理解できていないのでベストであるかは怪しいですが一応動く設定を載せておきます。

test.yml
name: Test

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]

jobs:
  build:

    runs-on: windows-latest

    steps:
    - uses: actions/checkout@v3
    - name: Setup .NET SDK
      uses: actions/setup-dotnet@v2
      with:
        dotnet-version: |
          3.1.x
          6.0.x
        include-prerelease: true
    - name: Restore dependencies
      run: dotnet restore
    - name: Build Analyzer
      run: dotnet build Analyzer1/Analyzer1.csproj --no-restore -p:langversion=latest -p:TargetFramework=netstandard2.0
    - name: Build CodeFixes
      run: dotnet build Analyzer1.CodeFixes/Analyzer1.CodeFixes.csproj --no-restore -p:langversion=latest -p:TargetFramework=netstandard2.0
    - name: Build Test
      run: dotnet build Analyzer1.Test/Analyzer1.Test.csproj --no-restore -p:langversion=latest -p:TargetFramework=netcoreapp3.1
    - name: Test
      run: dotnet test Analyzer1.sln --no-build --verbosity normal

(詳しい方,改善点を教えていただけるとありがたいです)

パッケージ作成

Analyzer1.PackageをビルドするとAnalyzer1.x.x.x.nupkgが作成されます。おしまいです。
NuGetで公開するなどして使ってもらいましょう。

その他

Syntax Visualizer

C#のコードがどのようなシンタックスツリーに変換されているかを確認することができます。
Visual Studioのメニューから[表示]>[その他のウィンドウ]>[Syntax Visualizer]とすることで表示できます。

Syntax Visualizerにより,シンタックスツリーの構造を確認して,各ノードやトークンの型やKindを確認できます。
アナライザやコード修正を実装する際に超便利です(と言うよりなかったら無理)。
どんどん活用していきましょう。

なお,別ファイルに移動したりすると何も表示されなくなってしまうことがありますが,その際にはコードを変更してあげると表示されるようになります。
どこかに適当な改行を入れて消す,といったどんな操作でもOKです。

完全修飾名

シンボルの完全修飾名を取得したという需要はまああると思います。
その際に,symbol.ToDisplayParts(SymbolDisplayFormat.FullyQualifiedFormat)という使えそうなものがありますが,こいつは使えません。
Issueが立っていたのですが,Roslynのバグだそうです。
そして互換性の観点から修正されないだろうということです。

困ったので,シンボルの定義から親ノードを辿って自前で取得するようにして何とかしています。
コメントでご指摘をいただきましたが,原因はmemberOptionsが指定されていないことであるため,

var format = new SymbolDisplayFormat(
    globalNamespaceStyle:
        SymbolDisplayGlobalNamespaceStyle.Included,
    typeQualificationStyle:
        SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces,
    genericsOptions: SymbolDisplayGenericsOptions.IncludeTypeParameters,
    miscellaneousOptions:
        SymbolDisplayMiscellaneousOptions.EscapeKeywordIdentifiers |
        SymbolDisplayMiscellaneousOptions.UseSpecialTypes,
    memberOptions:
        SymbolDisplayMemberOptions.IncludeParameters |
        SymbolDisplayMemberOptions.IncludeAccessibility |
        SymbolDisplayMemberOptions.IncludeType |
        SymbolDisplayMemberOptions.IncludeContainingType |
        SymbolDisplayMemberOptions.IncludeRef |
        SymbolDisplayMemberOptions.IncludeModifiers
);

symbol.ToDisplayString(foramt);

のように自前でフォーマットを指定することで完全修飾名を取得することができます。

Ambiguous project name

きちんと再現性を確認していないのですが,プロジェクト名と作成したパッケージ名が衝突した際に出るのではないかと思います。
(上の例ではAnalyzer1というパッケージを作成した場合など)

この場合,アナライザのプロジェクト名をAnalyzer1からAnalyzer1.Analyzerなどに変更してあげればエラーは解消されます。

資料

冒頭にも書きましたがRoslyn関係の資料は多くありません。
しょうもない記事が乱立しているのとどちらがマシかと言われると難しいのですが,MSDNすらやる気がないので困ったものです。

参考: やる気のないMSDNの例

CSharpSyntaxNode.FindToken(Int32, Boolean) Method
image.png
このくらいなら名前からわかるけど,何か書けよ……

英語でググってみてStackoverflowが出てきたらラッキーです。

やりたいことができそうなことを入力してみてサジェストで出てきた名前からそれっぽそうなものを選ぶと意外と何とかなったりします。

CS8032

作成したアナライザを利用するプロジェクトをビルドする際にCS8032という警告が出る場合があります。
この場合,アナライザを利用する側のプロジェクトで以下のような参照を追加すると警告が解除されます。

<Project Sdk="Microsoft.NET.Sdk">

  <ItemGroup>
    <PackageReference Include="Analyzer1" Version="1.0.0">
      <PrivateAssets>all</PrivateAssets>
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
    </PackageReference>
+   <PackageReference Include="Microsoft.Net.Compilers.Toolset" Version="4.3.0">
+     <PrivateAssets>all</PrivateAssets>
+     <IncludeAssets>runtime; build; native; contentfiles; analyzers;</IncludeAssets>
+   </PackageReference>
  </ItemGroup>

</Project>

本質的な理解はできていないのですが,どうやらMicrosoft.Net.Compilers.ToolsetがいけないらしいのでMicrosoft.Net.Compilers.Toolsetをどうにかしてあげると直るようです。難しい。

さいごに

思っていたよりも長くなってしまいましたが,読んでいただきありがとうございます。
この記事がアナライザ作成に興味がある方の助けになれば幸いです。

13
11
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
11