ぺやろぐ

ぺやろぐ

焼きそばよりチャーハンが好き。

【C#】【Roslyn】Roslynを使って、簡単なコードチェック機能を実装した話

スポンサーリンク

Roslyn、Kotlin、Xamarin、リンリンシリーズだけど関連性はない。
ぺやんぐ(@peyangu485)です。

日本語情報少なすぎ問題に対応するため、いくつかの簡単な例をメモ代わりに残しておきます。

基本的にはここを参考にすれば一応はできます。
https://www.buildinsider.net/enterprise/roslynextension/02

他にも参考にさせていただいた記事。

aonasuzutsuki.hatenablog.jp

qiita.com

www.buildinsider.net

ただ、情報が古いところがあったりするので、まんまだと微妙に動かない部分も……。
この記事も2019年8月時点で動くコードになります。

Roslynについての基本は知ってる前提で書いていきます。
導入方法とかも上の記事に書いてますし。

VS2019では、まだできません。(できませんでした)
なので、VS2017 Communityでやります。



クラス名で使用出来ない文字を使用した時に警告を出す

Class名のチェックをしたい時は、「RegisterSymbolAction」を使います。
呼び出すチェックメソッドは「SymbolAnalysisContext」を引数に指定します。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Text.RegularExpressions;

namespace Analyzer1
{
    /// <summary>
    /// クラス名に使用可能な文字をチェックします。
    /// 0~9、a~z、A~Z以外を使用した場合は警告を出します。
    /// </summary>
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CharactersAvailableClassNamesAnalyzer : DiagnosticAnalyzer
    {
        // これは出力時に「コード」の部分に表示される内容なので、プロジェクト内で「Hoge0001」みたいなコード体系を決めておくとよい
        // ↓のような長ったらしいのは不向き
        public const string DiagnosticId = "CharactersAvailableClassNamesAnalyzer";
        private const string Category = "Naming";

        // 警告なのかエラーなのかインフォメーションなのかのルール決め
        // DiagnosticSeverityの値に応じて、出力される種別が変わる
        // (2019/8現在)Hidden,Warning,Error,Infoの4種類
        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
            DiagnosticId, 
            "クラス名に使用できない文字を使用しています。", 
            "クラス名'{0}'に使用できない文字を使用しています。", 
            Category, 
            DiagnosticSeverity.Warning, 
            isEnabledByDefault: true, 
            description: "クラス名には「0-9,a-z,A-Z」以外使用しないでください。");

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSymbolAction(AnalyzeSymbol, SymbolKind.NamedType);
        }

        private static void AnalyzeSymbol(SymbolAnalysisContext context)
        {
            var symbol = context.Symbol as INamedTypeSymbol;

            // Analyzer自体を落とさないためのnullチェック
            if (symbol == null)
                return;

            // クラス名のチェックなので、クラスじゃなければ処理を中断する
            if (symbol.TypeKind != TypeKind.Class)
                return;

       // 中身がある、かつ正規表現でマッチしないものがあれば、警告を出力する。
            if (!string.IsNullOrEmpty(symbol.Name) && !Regex.IsMatch(symbol.Name, "^[0-9a-zA-Z]+$"))
            {
                // エラー情報の作成(適応するルールと対象の名前と対象の場所を設定する)
                var diagnostic = Diagnostic.Create(Rule, symbol.Locations[0], symbol.Name);
                // 警告として出力する
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}



メソッド名で使用出来ない文字を使用した時に警告を出す

メソッド名をチェックする時は、「RegisterSyntaxNodeAction」を使います。
呼び出すチェックメソッドは「SyntaxNodeAnalysisContext」を引数に指定します。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Text.RegularExpressions;

namespace Analyzer1
{
    /// <summary>
    /// メソッド名に使用可能な文字をチェックします。
    /// 0~9、a~z、A~Z以外を使用した場合は警告を出します。
    /// </summary>
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CharactersAvailableMethodNamesAnalyzer : DiagnosticAnalyzer
    {
        // これは出力時に「コード」の部分に表示される内容なので、プロジェクト内で「Hoge0001」みたいなコード体系を決めておくとよい
        // ↓のような長ったらしいのは不向き
        public const string DiagnosticId = "CharactersAvailableMethodNamesAnalyzer";
        private const string Category = "Naming";

        // 警告なのかエラーなのかインフォメーションなのかのルール決め
        // DiagnosticSeverityの値に応じて、出力される種別が変わる
        // (2019/8現在)Hidden,Warning,Error,Infoの4種類
        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
            DiagnosticId,
            "メソッド名に使用できない文字を使用しています。",
            "メソッド名'{0}'に使用できない文字を使用しています。",
            Category,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: true,
            description: "メソッド名には「0-9,a-z,A-Z」以外使用しないでください。");

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction<SyntaxKind>(AnalyzeSymbol, SyntaxKind.MethodDeclaration);
        }

        private static void AnalyzeSymbol(SyntaxNodeAnalysisContext context)
        {
            var syntax = context.Node as MethodDeclarationSyntax;

            // Analyzer自体を落とさないためのnullチェック
            if (syntax == null)
                return;

            // メソッド名はIdentifierを見る。
            var identifier = syntax.Identifier;
            if (identifier != null && !string.IsNullOrEmpty(identifier.Text) && !Regex.IsMatch(identifier.Text, "^[0-9a-zA-Z]+$"))
            {
                // エラー情報の作成(適応するルールと対象の名前と対象の場所を設定する)
                var diagnostic = Diagnostic.Create(Rule, syntax.Identifier.GetLocation(), syntax.Identifier.Text);
                // 警告として出力する
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}



イベント名で使用出来ない文字を使用した時に警告を出す

イベント名をチェックする時は、「RegisterSyntaxNodeAction」を使います。
呼び出すチェックメソッドは「SyntaxNodeAnalysisContext」を引数に指定します。

「EventFieldDeclarationSyntax」は、少し形が違うのでforeach使ってChildNodeを見ます。(なんでなのか、よくわかってない)

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Text.RegularExpressions;

namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CharactersAvailableEventNamesAnalyzer : DiagnosticAnalyzer
    {
        // これは出力時に「コード」の部分に表示される内容なので、プロジェクト内で「Hoge0001」みたいなコード体系を決めておくとよい
        // ↓のような長ったらしいのは不向き
        public const string DiagnosticId = "CharactersAvailableEventNamesAnalyzer";
        private const string Category = "Naming";

        // 警告なのかエラーなのかインフォメーションなのかのルール決め
        // DiagnosticSeverityの値に応じて、出力される種別が変わる
        // (2019/8現在)Hidden,Warning,Error,Infoの4種類
        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
            DiagnosticId,
            "イベント名に使用できない文字を使用しています。",
            "イベント名'{0}'に使用できない文字を使用しています。",
            Category,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: true,
            description: "イベント名には「0-9,a-z,A-Z」以外使用しないでください。");

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction<SyntaxKind>(AnalyzeSymbol, SyntaxKind.EventFieldDeclaration);
        }

        private static void AnalyzeSymbol(SyntaxNodeAnalysisContext context)
        {
            var syntax = context.Node as EventFieldDeclarationSyntax;

            // Analyzer自体を落とさないためのnullチェック
            if (syntax == null)
                return;

            foreach (var childNode in syntax.ChildNodes())
            {
                // VariableDeclarationでなければ次のchildNodeへ
                if (childNode.Kind() != SyntaxKind.VariableDeclaration)
                    continue;

                var childSyntax = childNode as VariableDeclarationSyntax;
                if (childSyntax == null)
                    break;

                if (childSyntax.Variables.Count == 0)
                    break;

                var identifier = childSyntax.Variables[0].Identifier;
                if (identifier != null && !string.IsNullOrEmpty(identifier.Text) && !Regex.IsMatch(identifier.Text, "^[0-9a-zA-Z]+$"))
                {
                    // エラー情報の作成(適応するルールと対象の名前と対象の場所を設定する)
                    var diagnostic = Diagnostic.Create(Rule, identifier.GetLocation(), identifier.Text);
                    // 警告として出力する
                    context.ReportDiagnostic(diagnostic);
                }

                break;
            }
        }
    }
}



変数名で使用出来ない文字を使用した時に警告を出す

変数名をチェックする時は、「RegisterSyntaxNodeAction」を使います。
呼び出すチェックメソッドは「SyntaxNodeAnalysisContext」を引数に指定します。

定数チェックも入れています。
定数も含めて同じ内容のチェックであれば、不要です。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;
using System.Text.RegularExpressions;

namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class CharactersAvailableVariableNamesAnalyzer : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "CharactersAvailableVariableNamesAnalyzer";
        internal const string Category = "CharactersAvailableVariableNamesAnalyzer Category";

        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
            DiagnosticId,
            "変数名に使用できない文字を使用しています。",
            "変数名'{0}'に使用できない文字を使用しています。",
            Category,
            DiagnosticSeverity.Warning,
            isEnabledByDefault: true,
            description: "変数名には「0-9,a-z,A-Z,_」以外使用しないでください。");

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction<SyntaxKind>(Analyze, SyntaxKind.VariableDeclaration);
        }

        private static void Analyze(SyntaxNodeAnalysisContext context)
        {
            var syntax = context.Node as VariableDeclarationSyntax;

            if (syntax == null || syntax.Variables.Count == 0)
                return;

            // 定数ならチェック対象外
            // メソッド化して外に出すことになると思う。
            var field = syntax.Parent as FieldDeclarationSyntax;
            if (field == null)
                return;

            if (field.Modifiers.IndexOf(SyntaxKind.ConstKeyword) != -1)
                return;

            // static readonly対策
            if (field.Modifiers.IndexOf(SyntaxKind.StaticKeyword) != -1
                && field.Modifiers.IndexOf(SyntaxKind.ReadOnlyKeyword) != -1)
                return;

            var identifier = syntax.Variables[0].Identifier;
            if (identifier != null && !string.IsNullOrEmpty(identifier.Text) && !Regex.IsMatch(identifier.Text, "^[0-9a-zA-Z_]+$"))
            {
                // エラー情報の作成(適応するルールと対象の名前と対象の場所を設定する)
                var diagnostic = Diagnostic.Create(Rule, identifier.GetLocation(), identifier.Text);
                // 警告として出力する
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}



変数名予約語チェック

変数名が予約語を使用しているかチェックする時は、「RegisterSyntaxNodeAction」を使います。
呼び出すチェックメソッドは「SyntaxNodeAnalysisContext」を引数に指定します。

予約語はこちらを参照。 https://ufcpp.net/study/csharp/ap_reserved.html

「SyntaxFacts.GetKeywordKind(string)」は、指定した文字列と一致するSyntaxKindの型を返すメソッドです。
予約語の大半がカバーされています。(全部っぽい?ソースがアルファベット順に並んでないから分かりません)

メソッドの実装はこちらです。
http://source.roslyn.io/#Microsoft.CodeAnalysis.CSharp/Syntax/SyntaxKindFacts.cs,839cebe3d0d4c8e2,references

「SyntaxFacts.GetKeywordKind(string)」の戻り値で判断します。

using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;

namespace Analyzer1
{
    [DiagnosticAnalyzer(LanguageNames.CSharp)]
    public class ReservedWordAnalyzer : DiagnosticAnalyzer
    {
        public const string DiagnosticId = "ReservedWordAnalyzer";
        internal const string Category = "ReservedWordAnalyzer Category";

        private static DiagnosticDescriptor Rule = new DiagnosticDescriptor(
           DiagnosticId,
           "変数名に予約語は使用できません。",
           "変数名'{0}'に予約語は使用できません。",
           Category,
           DiagnosticSeverity.Warning,
           isEnabledByDefault: true,
           description: "変数名には予約語を使用しないでください。");

        public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

        public override void Initialize(AnalysisContext context)
        {
            context.RegisterSyntaxNodeAction<SyntaxKind>(Analyze, SyntaxKind.VariableDeclaration);
        }

        private void Analyze(SyntaxNodeAnalysisContext context)
        {
            var syntax = context.Node as VariableDeclarationSyntax;

            if (syntax == null || syntax.Variables.Count == 0)
                return;

            var field = syntax.Parent as FieldDeclarationSyntax;
            if (field == null)
                return;

            var identifier = syntax.Variables[0].Identifier;
            if (identifier != null && SyntaxFacts.GetKeywordKind(identifier.Text) != SyntaxKind.None)
            {
                // エラー情報の作成(適応するルールと対象の名前と対象の場所を設定する)
                var diagnostic = Diagnostic.Create(Rule, identifier.GetLocation(), identifier.Text);
                // 警告として出力する
                context.ReportDiagnostic(diagnostic);
            }
        }
    }
}



constチェック

その変数が定数かどうかの判断をする部分です。

変数名のチェックから抜粋。

var field = syntax.Parent as FieldDeclarationSyntax;
if (field == null)
    return;

if (field.Modifiers.IndexOf(SyntaxKind.ConstKeywo != -1)
    return;

// static readonly対策
if (field.Modifiers.IndexOf(SyntaxKind.StaticKeywo != -1
    && field.Modifiers.IndexOf(SyntaxKind.ReadOnlyKeyword) != -1)
    return;


constの有無は以下のコード。
今回は、constがあればチェックしないようにしたいので、constがあればreturnします。

if (field.Modifiers.IndexOf(SyntaxKind.ConstKeyword) != -1)
    return;


FieldDeclarationSyntaxのModifiersの中に「SyntaxKind.ConstKeyword」が存在するかどうかで、constの有無が確認できます。
基本的には定数はこれで問題ないのですが、「static readonly」の考慮が必要です。

static readonlyの有無は以下のコード。
constと同じくチェックしないようにしたいので、static readonlyがあればreturnします。

if (field.Modifiers.IndexOf(SyntaxKind.StaticKeyword) != -1
    && field.Modifiers.IndexOf(SyntaxKind.ReadOnlyKeyword) != -1)
    return;



SyntaxKindについて

解析において、重要なSyntaxKindについては、以下のリファレンスを参照してください。 ConstKeywordとかのKeyword系からVariableDeclarationとかのDeclaration系などなど。

http://source.roslyn.io/#Microsoft.CodeAnalysis.CSharp/Syntax/SyntaxKind.cs

これを知っていないとスタート地点にすら立てないです。
見つけるまでは、本当にどこに「const」やら「class」を表すものがどこにあるのか分からなくて困りました。

ちなみに、classかどうか判断するのに使うのは「TypeKind.Class」なので、TypeKindも見ておいた方がいいです。

Roslyn全体のソースコードリファレンスはこちらです。 http://source.roslyn.io/#Microsoft.CodeAnalysis.CSharp



終わり

日本語情報なさすぎて、できあがればそんなに難しくないコードでもうんうんうなされるRoslynちゃんでした。

簡単な例を紹介してみましたが、これでまだできることの半分(?)です……。 ここまでの実装だと、警告を出すだけなんですよね。この後に、CodeFixProviderを実装して、「警告→修正」までを行えるようにすると、チェッカーとしては完璧な出来になります。CodeFixProviderについては、まだ勉強中ということで一旦ここまでとさせていただきます。

これを使えば、コーディング規約の一部のチェックは自動化できるので、レビューの負担が相当下がりますね。かなり魅力的だなぁと思いますが、あまり盛り上がりに欠けています。(使う人が少ないのか、観測範囲の問題か)

SI系は、観測範囲内ではだいたいVS使ってるんだから、どんどん使われるといいのになぁと思いました。(まだJavaの方が多いかな?)

これが、誰かの役に立つことを願って……。(数週間後の自分が一番可能性が高い)