ここに2つのソースコードファイルがあるとします
例えばある時点のリビジョンのコードと、最新のコードのファイルがあるとして、その2つのファイルから変更のあるメソッドの一覧を出すとします
本当に必要か? って数回戦いました
仮に以下のようなファイルが別々に用意されているとします
この例であれば
- Class1.Test() ※変更
- Class1.Test1() ※削除
- Class1.Test2() ※追加
ということになるわけです
ライブラリの準備
今回使うのはマイクロソフト公式の「Microsoft.CodeAnalysis.CSharp」です
このライブラリは、コンパイラを利用してコードを解析することができます
コード解析の知識は、今回のような用途の他にもアナライザーを作るときや、最近流行りのソースジェネレーターを作るときにもお世話になるので知っていて損はないです
処理内容
とりあえずコードのイメージとしては以下のような感じです
public class Analyze
{
public static void Compare(string path1, string path2)
{
// ファイル読み込み
// 前差分チェック
// ソースコード解析
// 差分比較
// 出力用の調整
}
}
今回の本質はソースコード解析のため、それ以外の部分はやや飛ばし気味の解説になります
ファイル読み込み
2つのフォルダを指定されるので、それぞれの配下にあるファイル群を読み込んで返すこととします
private record TextFile(string Text, string FileName, string Key);
private static IList<TextFile> ReadFile(string path)
{
var list = new List<TextFile>();
var fileList = Directory.EnumerateFiles(path, "*.cs", SearchOption.AllDirectories) // 読み込むソースファイル群
.Where(x => !x.Contains("\\obj\\"))
.Select(x => (Text: File.ReadAllText(x), FileName: x));
foreach (var file in fileList)
{
list.Add(new TextFile(file.Text, file.FileName, file.FileName.Substring(path.Length + 1)));
}
return list;
}
TextFileクラス のKeyプロパティは、2つのフォルダにある同じファイルかどうかの判別に利用します(名前の変更は考慮しない)
前差分チェック
ここでやりたいことは、同じファイル名であれば、比較対象として詳細な解析に回すということです
ファイルに増減がある場合は単純にそのファイルにあるメソッドすべてが追加/削除対象になります
こちらに掲載されているFullOuterJoinメソッドを利用します
private record DiffTextFile(TextFile? First, TextFile? Second, string Key, bool IsEqual);
private static DiffTextFile[] DiffFile(ICollection<TextFile> first, ICollection<TextFile> second)
{
//同じファイル名のものをまとめる
var query = FullOuterJoin(first, second,
f => new { f!.Key },
s => new { s!.Key },
(f, s, k) => new DiffTextFile(f, s, k.Key, f?.Text == s?.Text)
);
// 中身が同じファイルは飛ばす
return query.Where(x => !x.IsEqual).ToArray();
}
ソースコード解析の基礎知識
コンパイラはソースコードを木に分解して処理しています
以下はLINQPadでコードを視覚化したものです
だいたい以下のような感じです
人間がコードを認識する時と一緒ですね
- 最初にメソッド (MethodDeclaration)が存在
- それはvoid型 (VoidKeyword)
- 引数はなし (ParameterListの中身がなし)
- コードの本体が始まる (BlockとかOpenBraceTokenとか)
- 中のコードはメソッド呼び出し (ExpressionStatementがInvocationExpression)
- その名前はConsole と WriteLine (SimpleMemberAccessExpressionのExpression とか Name のIdentifierName)
- 呼び出し時の引数はなし (InvocationExpressionのArgumentList)
ちなみにこのあたりは頭で全部考えるのはお勧めしません
LINQPadで見るなり、VisualStudioのSyntax Visualizer(.NET Compiler Platform SDKのインストールが多分必要)を使うなりして眺めたほうがいいです
以上の内容までが構文解析の部分です
この後にオブジェクトの型が実際に何であるのかとかを解析するコンパイルの部分があり、そこから得られる情報をSymbolと呼んだりしますが、今回は取り扱いません
ソースコード解析(本番)
まず、呼び出し元は以下のような感じになりました
public static void Compare(string path1, string path2)
{
// ファイル読み込み
var first = ReadFile(path1);
var second = ReadFile(path2);
// 前差分チェック
var diffFile = DiffFile(first, second);
// ソースコード解析
var source1 = Analysis(diffFile.Where(x => x.First != null).Select(x => x.First!).ToArray());
var source2 = Analysis(diffFile.Where(x => x.Second != null).Select(x => x.Second!).ToArray());
// 差分比較
// 出力用の調整
}
ここから細かい解析の処理になります
まず細かいクラス定義は飛ばして、Analysisメソッドは以下のような感じになります
private static IList<Source> Analysis(ICollection<TextFile> file)
{
// 構文木を格納するリスト
var syntaxTrees = file.Select(f => CSharpSyntaxTree.ParseText(f.Text, CSharpParseOptions.Default, f.FileName));
var list = new List<Source>();
foreach (var tree in syntaxTrees)
{
// 構文木からルートの子ノード群を取得
var nodes = tree.GetRoot().DescendantNodes();
var classList = new List<ClassType>();
// クラス情報の取得
foreach (var syntax in nodes.OfType<TypeDeclarationSyntax>())
{
var ConstructorList = new List<ConstructorType>();
var MethodList = new List<MethodType>();
foreach (var member in syntax.Members)
{
switch (member)
{
case ConstructorDeclarationSyntax c:
ConstructorList.Add(new ConstructorType(c));
break;
case MethodDeclarationSyntax m:
MethodList.Add(new MethodType(m));
break;
}
}
var root = tree.GetCompilationUnitRoot();
var item = new ClassType(root, syntax)
{
Constructor = ConstructorList,
Method = MethodList,
};
classList.Add(item);
}
list.Add(new Source(tree.FilePath, file.First(f => tree.FilePath.EndsWith(f.Key)).Key, classList));
}
return list;
}
1つのソースコードファイルには複数のクラスが定義できます
1つのクラスには複数のメソッドが所属します
そして、メソッドにはコンストラクタとそれ以外の普通のメソッドがあります
メソッドはオーバーロードできるのでそれぞれ複数存在する可能性があります
そうした関係を持った構造が最終出力結果になります
CSharpSyntaxTree.ParseText()を使用することでコンパイラにソースコードをパースさせることができ、基礎知識で上げたような形にバラバラにしてくれます
ここでtreeにあたるのが1ファイルです
nodeは構文解析結果が入っていますので、主にこちらを操作します
とりあえずクラス単位になった結果がTypeDeclarationSyntaxとして手に入るので変換します
TypeDeclarationSyntax.Membersプロパティに、コードで記述した一通りの定義が詰まっていて、例えば以下のようなものがあります
- EnumDeclarationSyntax
- EventFieldDeclarationSyntax
- FieldDeclarationSyntax
- PropertyDeclarationSyntax
- ConstructorDeclarationSyntax
- MethodDeclarationSyntax
今回対象にするのは下の2つだけです
コンストラクタの解析
コンストラクタとそれ以外のメソッドの分かりやすい構文上の違いは、戻り値の定義があるかどうかです
だからといって別物にしておくと後で困りそうなので、インターフェースを定義して後で1つに纏められるようにしておきます
public record KeywordToken(string Name, Microsoft.CodeAnalysis.CSharp.SyntaxKind Kind);
public record Parameter(string Type, string Name);
public interface IMethodType
{
string Name { get; }
KeywordToken[] Keyword { get; }
Parameter[] Args { get; }
string ReturnType { get; }
string Signature { get; }
string Body { get; }
}
名前から類推できると思いますが、一つのメソッドは上記のような内容に分解できるはずです
(厳密にはコメントやジェネリック型制約もある)
Signature は一致判定用で、これが一致している場合は今回の差分比較対象として扱います
厳密な比較は大変なので大雑把にいきます
ここで、解析する上で便利になるようなメソッドを定義しておきます
internal class Utility
{
/// <summary>
/// キーワード (定義トークン) を取得します
/// </summary>
/// <param name="syntax"></param>
/// <returns></returns>
internal static KeywordToken[] GetKeyword(MemberDeclarationSyntax syntax)
{
return syntax.Modifiers.Select(x => new KeywordToken(x.Text, x.Kind())).ToArray();
}
/// <summary>
/// コメントを取得します
/// </summary>
/// <param name="syntax"></param>
/// <returns></returns>
internal static string GetComment(CSharpSyntaxNode syntax)
{
var trivia = syntax.GetLeadingTrivia().FirstOrDefault(x => x.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia));
if (trivia != default &&
trivia.GetStructure() is DocumentationCommentTriviaSyntax node)
{
var xml = node.Content.FirstOrDefault(x => x is XmlElementSyntax element && element.StartTag.Name.LocalName.Text == "summary");
if (xml is XmlElementSyntax comment)
{
return string.Join(' ', ((XmlTextSyntax)comment.Content[0]).TextTokens.Where(x => x.IsKind(SyntaxKind.XmlTextLiteralToken) && !string.IsNullOrWhiteSpace(x.Text))
.Select(x => x.Text.Trim()));
}
}
return "";
}
/// <summary>
/// ジェネリック型の内容を分解する
/// </summary>
/// <returns>Generic:一番外側, TypeArgument=内側, type=合わせたもの</returns>
internal static (string Generic, string TypeArgument, string type) GetGenericName(GenericNameSyntax syntax)
{
var generic = syntax.Identifier.Text;
var typeArgument = string.Join(",", syntax.TypeArgumentList.Arguments);
var typeArgs = "<" + typeArgument + ">";
var type = $"{generic}{typeArgs}";
return (generic, typeArgument, type);
}
}
・・・例えばDocumentationCommentTriviaSyntax って何? とかは実際にコード解析していろいろ試してみてください
こうして、コンストラクタのコードを解析することができます
internal record ConstructorType : IMethodType
{
public string Signature { get; }
public string Name { get; }
public KeywordToken[] Keyword { get; }
public Parameter[] Args { get; }
public string ReturnType => "";
public string Body { get; }
public string Comment { get; }
public ConstructorType(ConstructorDeclarationSyntax syntax)
{
var raw = syntax.ToString();
if (syntax.Body != null)
{
var open = syntax.Body.OpenBraceToken.Text;
var index = raw.IndexOf(open);
Signature = raw.Substring(0, index).Trim();
Body = syntax.Body.ToString();
}
else if (syntax.ExpressionBody != null)
{
var arrow = syntax.ExpressionBody.ArrowToken.Text;
var index = raw.IndexOf(arrow);
Signature = raw.Substring(0, index).Replace(Environment.NewLine, "").Trim();
Body = syntax.ExpressionBody.ToString();
}
else
{
Signature = syntax.ToString().Replace(Environment.NewLine, "");
Body = "";
}
Comment = Utility.GetComment(syntax);
Name = syntax.Identifier.ToString();
Keyword = Utility.GetKeyword(syntax);
Args = syntax.ParameterList.Parameters.Select(p => new Parameter(p.Type!.ToString(), p.Identifier.Text)).ToArray();
}
}
メソッド関係はラムダ式で定義することもできるので、そのあたりを忘れないようにしてください(ExpressionBodyの場合)
パースはしてもらっているので、プロパティを探して上手く入れていくようにすれば済みます
メソッドの解析
コンストラクタの解析までできればほぼ一緒です
ジェネリックがあるとかで少しだけ差があります
internal record MethodType : IMethodType
{
public string Signature { get; }
public string Name { get; }
public KeywordToken[] Keyword { get; }
public Parameter[] Args { get; }
public string[] TypeParameter { get; }
public string ReturnType { get; }
public string Body { get; }
public string Comment { get; }
public MethodType(MethodDeclarationSyntax syntax)
{
var raw = syntax.ToString();
if (syntax.Body != null)
{
var open = syntax.Body.OpenBraceToken.Text;
var index = raw.IndexOf(open);
Signature = raw.Substring(0, index).Replace(Environment.NewLine, "").Trim();
Body = syntax.Body.ToString();
}
else if (syntax.ExpressionBody != null)
{
var arrow = syntax.ExpressionBody.ArrowToken.Text;
var index = raw.IndexOf(arrow);
Signature = raw.Substring(0, index).Replace(Environment.NewLine, "").Trim();
Body = syntax.ExpressionBody.ToString();
}
else
{
Signature = syntax.ToString().Replace(Environment.NewLine, "");
Body = "";
}
Name = syntax.Identifier.ToString();
Keyword = Utility.GetKeyword(syntax);
Args = syntax.ParameterList.Parameters.Select(p => new Parameter(p.Type!.ToString(), p.Identifier.Text)).ToArray();
TypeParameter = syntax.TypeParameterList?.Parameters.Select(x => x.Identifier.Text).ToArray() ?? Array.Empty<string>();
ReturnType = syntax.ReturnType.ToString();
Comment = Utility.GetComment(syntax);
}
}
クラスの解析
最後に残りの定義を書きます
internal record Source(string FilePath, string Key, IEnumerable<ClassType> Class);
クラスの解析も基本は一緒です
public record ClassType
{
public string Signature { get; }
public string NameSpace { get; } = "";
public string Name { get; }
public KeywordToken[] Keyword { get; }
public string Type { get; }
public string[] TypeParameter { get; }
public string Comment { get; }
public IReadOnlyList<IMethodType> Constructor { get; init; } = Array.Empty<ConstructorType>();
public IReadOnlyList<IMethodType> Method { get; init; } = Array.Empty<MethodType>();
public IList<IMethodType> AllMethod => Constructor.Concat(Method).ToArray();
public ClassType(CompilationUnitSyntax root, TypeDeclarationSyntax syntax)
{
if (root.Members[0] is NamespaceDeclarationSyntax name)
{
NameSpace = name.Name.ToString();
}
Name = syntax.Identifier.ToString();
Keyword = Utility.GetKeyword(syntax);
Type = syntax.Keyword.Text;
TypeParameter = syntax.TypeParameterList?.Parameters.Select(x => x.Identifier.Text).ToArray() ?? Array.Empty<string>();
if (TypeParameter.Any()) Name += syntax.TypeParameterList!.ToString();
Comment = Utility.GetComment(syntax);
var open = syntax.OpenBraceToken.Text;
if (!string.IsNullOrEmpty(open))
{
var raw = syntax.ToString().AsSpan();
if (syntax.AttributeLists.Any())
{
var attr = syntax.AttributeLists.Last().ToString().AsSpan();
raw = raw.Slice(raw.LastIndexOf(attr) + attr.Length);
}
var index = raw.IndexOf(open);
Signature = raw.Slice(0, index).Trim().ToString().Replace(Environment.NewLine, "");
}
else
{
Signature = syntax.ToString().Replace(Environment.NewLine, "").Trim();
}
}
}
差分比較
解析が終わったので最後に差分を出して終わりです
一時的な入れ物を定義します
private record CompareObject(string Path, string FullPath, ClassType? Class1, ClassType? Class2);
差分の区分を作っておきます
public enum DiffType
{
Add,
Remove,
Diff,
Equal
}
メソッドの差分について保持するクラスです
public record MethodDiff
{
public IMethodType? First { get; }
public IMethodType? Second { get; }
public DiffType DiffType { get; }
public MethodDiff(IMethodType? first, IMethodType? second)
{
First = first;
Second = second;
if (first == null) DiffType = DiffType.Add;
else if (second == null) DiffType = DiffType.Remove;
else if (first.Body != second.Body) DiffType = DiffType.Diff;
else DiffType = DiffType.Equal;
}
}
そして、最終的に呼び出し元に返す入れ物です
public record DiffObject
{
public string Path { get; }
public string FullPath { get; }
public string ClassName { get; }
public MethodDiff[] Method { get; init; } = default!;
public DiffType DiffType { get; init; }
public DiffObject(string path, string fullPath, string className)
{
Path = path;
FullPath = fullPath;
ClassName = className;
}
}
これらを使って差分のあるメソッドを取り出します
もちろんここまでの内容を応用すればプロパティなんかの解析も可能です
private static ICollection<DiffObject> Compare(IEnumerable<Source> first, IEnumerable<Source> second)
{
//ソースファイル - クラスは [1]--[0..N]の関係
//クラス - メソッドは [1]--[0..N]の関係
//ファイルパス, 名前空間, クラス, メソッドシグネチャが一致しているなら同じメソッドとして扱い、中のソースを比較する
var diff = FullOuterJoin(first.SelectMany(x => x.Class, (source, Class) => (File: source, Class)),
second.SelectMany(x => x.Class, (source, Class) => (File: source, Class)),
f => new
{
f.File.Key,
f.Class.NameSpace,
f.Class.Name,
},
s => new
{
s.File.Key,
s.Class.NameSpace,
s.Class.Name,
},
(f, s, k) => new CompareObject(f.File != null ? f.File.Key : s.File.Key,
f.File != null ? f.File.FilePath : s.File.FilePath,
f.Class, s.Class)
);
var list = new List<DiffObject>();
foreach (var item in diff)
{
var methoddiff = FullOuterJoin(item.Class1?.AllMethod ?? Array.Empty<IMethodType>(),
item.Class2?.AllMethod ?? Array.Empty<IMethodType>(),
f => f?.Signature,
s => s?.Signature,
(f, s, _) => new MethodDiff(f, s)).Where(x => x.DiffType != DiffType.Equal).ToArray();
if (methoddiff.Any())
{
var className = item.Class1?.Name ?? item.Class2!.Name;
var type = DiffType.Diff;
if (item.Class1 == null) type = DiffType.Add;
else if (item.Class2 == null) type = DiffType.Remove;
list.Add(new DiffObject(item.Path, item.FullPath, className)
{
Method = methoddiff,
DiffType = type,
});
}
}
return list;
}
これで必要な内容は全て揃ったので、一度デバッガで内容を見てみます
問題なさそうな結果になりました
終わりに
最初のほうでも書きましたが、ソースコード解析ができるとできることが増えて便利です
しかし日本語の情報が少なかったりする点が厄介ですね
実際はコンパイルまで行ってSymbolの情報を使うことのほうが多いかもしれません
Symbolが扱える場合はさらに多くの情報が手に入るようになり、解析の幅が広がります