12
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

はじめに

皆さんこんにちは。つい先日もアドカレでC#関連の記事を書いたところですが、.NET6のアドカレの枠が少し余っていたのを契機に、今度はより汎用的な内容をお送りしたいと思います。

今回は、上の記事で紹介したTypescriptとの相互運用機能を例に、.NET6から使えるようになったIncremental Source GeneratorのAPIの使い方を解説していきます。

若干ノイズ1がありますが、特に本質に関してはわかりやすく砕いて解説するつもりなので、是非みていってください。

対象読者

C#の基礎とLINQを理解しており、メタプログラミングに興味のある方。

今回作るもの

画像のように、C#コードと同じディレクトリにTypescriptのファイルを配置すると、partial classとして、Typescriptのバインディングを生成するジェネレーターを作ってみましょう。

作るものが具体的すぎると感じる方は、ジェネレーター作りの項からざっと見るのが良いかもしれません。

image.png

export function Add(a: number, b: number): number {
    return a + b;
}
export function Print(a: string): number {
    console.log(a);
}
export function DoSomething(a : HTMLElement) : number {
    return 1;
}
export function Array(a : number[], b : number[]) {
}
namespace Exprazor.TSInterop.ConsoleSandbox {
	public partial class TSInteropCheck
	{
		protected double Add (double a, double b)
		{
			throw new NotImplementedException();
		}
		protected double Sub (double a, string b)
		{
			throw new NotImplementedException();
		}
		protected double DoSomething (global::Exprazor.ElementReference a)
		{
			throw new NotImplementedException();
		}
		protected void Array (double[] a, double[] b)
		{
			throw new NotImplementedException();
		}
	}
}

このように、C#以外の外部ファイルを読んでコードを生成する手法は、SourceGeneratorに限らず、アナライザの目的の一つとして想定されており、今回はこれに当てはまります。(≠黒魔術)

セットアップ

ではやっていきましょう!
ではまず、アナライズの対象となるプロジェクトを作りましょう。
現在開発時だと、依存関係によって後に説明するデバッグの機能が使えなくなってしまうという問題があるので、
ここはシンプルにコンソールアプリケーションを新規作成しておきましょう。今後このプロジェクトをサンドボックスと呼びます。

次にアナライザのプロジェクトを.NET Standard 2.0 のクラスライブラリとして作りましょう。
通常のクラスライブラリに対して、RoslynComponentとして動作するために、csprojで何点かプロパティの指定が必要になります。今回はこのようにしておきましょう。

<Project Sdk="Microsoft.NET.Sdk">
	<PropertyGroup>
		<TargetFramework>netstandard2.0</TargetFramework>
		<LangVersion>latest</LangVersion>
		<Nullable>enable</Nullable>
		<IncludeBuildOutput>false</IncludeBuildOutput>
		<DevelopmentDependency>true</DevelopmentDependency>
		<IsRoslynComponent>true</IsRoslynComponent>
	</PropertyGroup>
	<ItemGroup>
		<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.3">
			<PrivateAssets>all</PrivateAssets>
			<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
		</PackageReference>
		<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.1" PrivateAssets="all" />
	</ItemGroup>
</Project>

次に、サンドボックスで、上記のプロジェクトをRoslynComponentとして参照します。

<ItemGroup>
    <ProjectReference Include="<path-to-the-generator-project>"
					  OutputItemType="Analyzer"
					  ReferenceOutputAssembly="false" />
</ItemGroup>

パスのミスを防ぐ為に、一度IDEなどで参照してから OutputItemTypeとReferenceOutputAssemblyを足した方が良いかもしれません。
詰まったときは実際のリポジトリを参考にしてみてください。

次項からジェネレーターを実際に作っていくのですが、Typescriptのパース自体は主眼では無いので、
これらのソースコードは、ジェネレーターのプロジェクトにコピペしてしまいましょう。

Lexer.cs
Lexer.cs
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;

namespace Exprazor.TSParser
{
	public interface Token { }
	public interface LiteralToken : Token { }
	public class ExportKeyword : Token
	{
		public static ExportKeyword Instance = new ExportKeyword();
	}
	public class ImportKeyword : Token
	{
		public static ImportKeyword Instance = new ImportKeyword();
	}
	public class FromKeyword : Token
	{
		public static FromKeyword Instance = new FromKeyword();
	}
	public class FunctionKeyword : Token
	{
		public static FunctionKeyword Instance = new FunctionKeyword();
	}
	public class ConstKeyword : Token
	{
		public static ConstKeyword Instance = new ConstKeyword();
	}
	public class LetKeyword : Token
	{
		public static LetKeyword Instance = new LetKeyword();
	}
	public class VarKeyword : Token
	{
		public static VarKeyword Instance = new VarKeyword();
	}
	public class Endline : Token
	{
		public static Endline Instance = new Endline();
	}
	public class LParen : Token
	{
		public static LParen Instance = new LParen();
	}
	public class RParen : Token
	{
		public static RParen Instance = new RParen();
	}
	public class LBrace : Token
	{
		public static LBrace Instance = new LBrace();
	}
	public class RBrace : Token
	{
		public static RBrace Instance = new RBrace();
	}
	public class LBracket : Token
	{
		public static LBracket Instance = new LBracket();
	}
	public class RBracket : Token
	{
		public static RBracket Instance = new RBracket();
	}
	public class LAngleBracket : Token
    {
		public static LAngleBracket Instance = new LAngleBracket();
    }
	public class RAngleBracket : Token
    {
		public static RAngleBracket Instance = new RAngleBracket();
    }
	public class Comma : Token
	{
		public static Comma Instance = new Comma();
	}
	public class Colon : Token
	{
		public static Colon Instance = new Colon();
	}
	public class SemiColon : Token
	{
		public static SemiColon Instance = new SemiColon();
	}
	public class SingleQuot : Token
	{
		public static SingleQuot Instance = new SingleQuot();
	}
	public class DoubleQuot : Token
	{
		public static DoubleQuot Instance = new DoubleQuot();
	}
	public class Dot : Token
	{
		public static Dot Instance = new Dot();
	}
	public class Plus : Token
	{
		public static Plus Instance = new Plus();
	}
	public class Minus : Token
	{
		public static Minus Instance = new Minus();
	}
	public class Astarisk : Token
	{
		public static Astarisk Instance = new Astarisk();
	}
	public class Slash : Token
	{
		public static Slash Instance = new Slash();
	}
	public class Pipe : Token
    {
		public static Pipe Instance = new Pipe();
    }
	public class Question : Token
    {
		public static Question Instance = new Question();
    }
	public class EQ : Token
	{
		public static EQ Instance = new EQ();
	}
	public class NumberLiteral : LiteralToken
	{
		public NumberLiteral(string numString)
		{
			NumString = numString;
		}
		public string NumString { get; }
	}
	public class StringLiteral : LiteralToken
	{
		public StringLiteral(string value)
		{
			Value = value;
		}
		public string Value { get; }
	}
	public class NullLiteral : LiteralToken
    {
		public static NullLiteral Instance = new NullLiteral();
    }
	public class UndefinedLiteral : LiteralToken
    {
		public static UndefinedLiteral Instance = new UndefinedLiteral();
	}
	public class NumberKeyword : Token
	{
		public static NumberKeyword Instance = new NumberKeyword();
	}
	public class StringKeyword : Token
    {
		public static StringKeyword Instance = new StringKeyword();
	}
	public class AnyKeyword : Token
	{
		public static AnyKeyword Instance = new AnyKeyword();
	}
	public class ReturnKeyword : Token
    {
		public static ReturnKeyword Instance = new ReturnKeyword();
    }
	public class Identifier : Token
	{
		public Identifier(string name)
		{
			Name = name;
		}
		public string Name { get; }
	}
	public static class CharSpanExtention
	{
		public static bool startWith(this ReadOnlySpan<char> span, string str)
		{
			if (span.Length < str.Length) return false;
			for (int i = 0; i < str.Length; i++)
			{
				if (span[i] != str[i]) return false;
			}
			return true;
		}
	}
	public static class Lexer
	{
		public static Queue<Token> Tokenize(string source)
		{
			var ret = new Queue<Token>();
			source = Regex.Replace(source, "/\\*[\\s\\S]*\\*/", string.Empty);
			source = Regex.Replace(source, "//.+", string.Empty);
			source = source.Replace("\r\n", " ");
			TokenizeInternal(source.AsSpan(), ret);

			return ret;
		}
		static void TokenizeInternal(ReadOnlySpan<char> source, Queue<Token> tokens)
		{
			if (source.Length < 1) return;

			if (source[0] == ' ')
			{
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '\t')
			{
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '\n')
			{
				tokens.Enqueue(Endline.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '=')
			{
				tokens.Enqueue(EQ.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '(')
			{
				tokens.Enqueue(LParen.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == ')')
			{
				tokens.Enqueue(RParen.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '{')
			{
				tokens.Enqueue(LBrace.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '}')
			{
				tokens.Enqueue(RBrace.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '[')
			{
				tokens.Enqueue(LBracket.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == ']')
			{
				tokens.Enqueue(RBracket.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '<')
            {
				tokens.Enqueue(LAngleBracket.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '>')
			{
				tokens.Enqueue(RAngleBracket.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == ':')
			{
				tokens.Enqueue(Colon.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == ';')
			{
				tokens.Enqueue(SemiColon.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == ',')
			{
				tokens.Enqueue(Comma.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '\'')
			{
				tokens.Enqueue(SingleQuot.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if(source[0] == '|')
            {
				tokens.Enqueue(Pipe.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '"')
			{
				tokens.Enqueue(DoubleQuot.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '+')
			{
				if (char.IsNumber(source[1]))
				{
					int wordLength = 1;
					while (source.Length > 0 && (char.IsLetterOrDigit(source[wordLength]))) wordLength++;
					tokens.Enqueue(new NumberLiteral(source.Slice(0, wordLength).ToString()));
					TokenizeInternal(source.Slice(wordLength), tokens);
					return;
				}
				else
				{
					tokens.Enqueue(Plus.Instance);
					TokenizeInternal(source.Slice(1), tokens);
					return;
				}
			}
			if (source[0] == '-')
			{
				if (char.IsNumber(source[1]))
				{
					int wordLength = 1;
					while (source.Length > 0 && (char.IsLetterOrDigit(source[wordLength]))) wordLength++;
					tokens.Enqueue(new NumberLiteral(source.Slice(0, wordLength).ToString()));
					TokenizeInternal(source.Slice(wordLength), tokens);
					return;
				}
				else
				{
					tokens.Enqueue(Minus.Instance);
					TokenizeInternal(source.Slice(1), tokens);
					return;
				}
			}
			if (source[0] == '*')
			{
				tokens.Enqueue(Astarisk.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if (source[0] == '/')
			{
				tokens.Enqueue(Slash.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
			}
			if(source[0] == '?')
            {
				tokens.Enqueue(Question.Instance);
				TokenizeInternal(source.Slice(1), tokens);
				return;
            }

			if (char.IsDigit(source[0]))
			{
				int wordLength = 1;
				while (source.Length > 0 && (char.IsLetterOrDigit(source[wordLength]))) wordLength++;
				tokens.Enqueue(new NumberLiteral(source.Slice(0, wordLength).ToString()));
				TokenizeInternal(source.Slice(wordLength), tokens);
				return;
			}

			if (char.IsLetter(source[0]) || source[0] == '_')
			{
				int wordLength = 1;
				while (source.Length > 0 && (char.IsLetterOrDigit(source[wordLength]) || source[wordLength] == '_')) wordLength++;

				string ident = source.Slice(0, wordLength).ToString();

				tokens.Enqueue(ident switch
				{
					"export" => ExportKeyword.Instance,
					"function" => FunctionKeyword.Instance,
					"import" => ImportKeyword.Instance,
					"from" => FromKeyword.Instance,
					"var" => VarKeyword.Instance,
					"let" => LetKeyword.Instance,
					"const" => ConstKeyword.Instance,
					"null" => NullLiteral.Instance,
					"undefined" => UndefinedLiteral.Instance,
					"number" => NumberKeyword.Instance,
					"string" => StringKeyword.Instance,
					"any" => AnyKeyword.Instance,
					"return" => ReturnKeyword.Instance,
					_ => new Identifier(ident)
				});
				TokenizeInternal(source.Slice(wordLength), tokens);
				return;
			}

		}
	}
}

AST.cs
AST.cs

using System.Collections.Generic;

namespace Exprazor.TSParser.AST
{
	public interface AST {}
	public interface TopLevel : AST {}
	public class SourceTree : AST
	{
		public SourceTree(List<TopLevel> topLevels)
		{
			TopLevels = topLevels;
		}

		public List<TopLevel> TopLevels { get; }
    }
	public interface TypeSyntax : AST { }
	public class NumberType : TypeSyntax
	{
		public static NumberType Instance = new NumberType();
    }
	public class StringType : TypeSyntax
	{
		public static StringType Instance = new StringType();
	}
	public class NotSpecified : TypeSyntax
    {
		public static NotSpecified Instance = new NotSpecified();
    }
	public class LiteralType : TypeSyntax
    {
        public LiteralType(LiteralToken literal)
        {
            Literal = literal;
        }

        public LiteralToken Literal { get; }
    }

	public class TypeReference : TypeSyntax
    {
        public TypeReference(Identifier identifier)
        {
            Identifier = identifier;
        }

        public Identifier Identifier { get; }
    }
	public class AnyType : TypeSyntax
	{
		public static AnyType Instance = new AnyType();
	}

	public class UnionType : TypeSyntax
    {
        public UnionType(TypeSyntax[] types)
        {
            Types = types;
        }

        public TypeSyntax[] Types { get; }
    }

	public class ArrayType : TypeSyntax
	{
		public ArrayType(TypeSyntax arrayOf)
		{
			ArrayOf = arrayOf;
		}

		public TypeSyntax ArrayOf { get; }
	}
	public class VoidType : TypeSyntax
    {
		public static VoidType Instance = new VoidType();
	}
	public class UnspecifiedType : TypeSyntax
	{
		public static UnspecifiedType Instance = new UnspecifiedType();
	}
	public class Parameter : AST
	{
		public Parameter(Identifier identifier, TypeSyntax type)
		{
			Identifier = identifier;
			Type = type;
		}

		public Identifier Identifier { get; }
		public TypeSyntax Type { get; }
	}
	public class Block : AST
	{
		public bool HasReturnValue { get; }

        public Block(bool hasReturnValue)
        {
            HasReturnValue = hasReturnValue;
        }
    }
	public class ExportStatement : TopLevel
	{
		public ExportStatement(TopLevel what)
		{
			What = what;
		}

		public TopLevel What { get; }
	}

	public class ImportStatement : TopLevel
	{
		public static ImportStatement Instance = new ImportStatement();
	}

	public interface TopLevelDecl : TopLevel { }
	public class FunctionDecl : TopLevelDecl
	{
		public FunctionDecl(Identifier identifier, List<Parameter> parameters, TypeSyntax returnType, Block block)
		{
			Identifier = identifier;
			Parameters = parameters;
			this.ReturnType = returnType;
			Block = block;
		}

		public Identifier Identifier { get; }
		public List<Parameter> Parameters { get; }
		public TypeSyntax ReturnType { get; }
		public Block Block { get; }
	}
}
Parser.cs
using Exprazor.TSParser.AST;
using System;
using System.Collections.Generic;
using System.Linq;

namespace Exprazor.TSParser
{
	public static class Parser
	{
		public class ParseException : Exception
		{
			public ParseException(string message) : base(message) { }
		}
		public static SourceTree Parse(string source)
		{
			var tokens = Lexer.Tokenize(source);
			return ParseSourceFile(tokens);
		}

		static SourceTree ParseSourceFile(Queue<Token> tokens)
		{
			List<TopLevel> topLevels = new List<TopLevel>();
			while (tokens.Count > 0)
			{
				topLevels.Add(ParseTopLevel(tokens));
			}
			return new SourceTree(topLevels);
		}

		static TopLevel ParseTopLevel(Queue<Token> tokens)
		{
			while (tokens.Peek() is Endline)
			{
				tokens.Dequeue();
			}
			if (tokens.Peek() is ExportKeyword)
			{
				return ParseExportStatement(tokens);
			}
			else if (tokens.Peek() is ImportStatement)
			{
				return ParseImportStatement(tokens);
			}
			else
			{
				return ParseTopLevelDecl(tokens);
			}
		}

		public static ImportStatement ParseImportStatement(Queue<Token> tokens)
		{
			tokens.Dequeue();
			while (true)
			{
				var token = tokens.Dequeue();
				if (token is FromKeyword)
				{
					if (tokens.Peek() is SemiColon)
					{
						tokens.Dequeue();
					}
				}
				return ImportStatement.Instance;
			}
		}

		public static ExportStatement ParseExportStatement(Queue<Token> tokens)
		{
			tokens.Dequeue();
			var what = ParseTopLevelDecl(tokens);
			return new ExportStatement(what);
		}

		public static void SkipTopLevelVariableDecls(Queue<Token> tokens)
		{
			//while(tokens.Peek() is ExportKeyword or FunctionDecl == false) {
			//	tokens.Dequeue();
			//}
			while (tokens.Count > 0)
			{
				var token = tokens.Peek();
				if (token is ExportKeyword || token is FunctionKeyword)
				{
					break;
				}
				tokens.Dequeue();
			}
		}

		public static TopLevelDecl ParseTopLevelDecl(Queue<Token> tokens)
		{
			SkipTopLevelVariableDecls(tokens);
			var token = tokens.Dequeue();
			if (token is FunctionKeyword)
			{
				return ParseFunctionDecl(tokens);
			}
			else
			{
				throw new ParseException("TopLevel declaration other than function declarations are not supported for now.");
			}
		}

		public static FunctionDecl ParseFunctionDecl(Queue<Token> tokens)
		{
			var token = tokens.Dequeue();
			if (token is Identifier ident == false) throw new ParseException("An identifier is expected after function keyword");
			var nextToken = tokens.Dequeue();
			if (nextToken is LParen == false) throw new ParseException("The character '(' is exptected after function identifier");

			var parameters = new List<Parameter>();
			while (true)
			{
				var _token = tokens.Peek();
				if (_token is RParen)
				{
					tokens.Dequeue();

					TypeSyntax type = VoidType.Instance;
					if(tokens.Peek() is Colon)
                    {
						tokens.Dequeue();
						type = ParseTypeSyntax(tokens, false);
                    }
					if (tokens.Peek() is LBrace)
					{
						var block = ParseBlock(tokens);
						if(type is VoidType && block.HasReturnValue)
                        {
							type = UnspecifiedType.Instance;
                        }
						return new FunctionDecl(ident, parameters, type, block);
					}
					throw new ParseException("Function decl must be proceeded with type or block.");
				}
				parameters.Add(ParseParameter(tokens));
				if (tokens.Peek() is Comma)
				{
					tokens.Dequeue();
					continue;
				}
			}
		}

		public static Parameter ParseParameter(Queue<Token> tokens)
		{
			var token = tokens.Dequeue();
			if (token is Identifier ident == false) throw new ParseException("A parameter name is required.");
			bool optional = false;
			if(tokens.Peek() is Question)
            {
				tokens.Dequeue();
				optional = true;
            }
			var next = tokens.Dequeue();
			if (next is Colon)
			{
				return new Parameter(ident, ParseTypeSyntax(tokens, optional));
			}
			else
			{
				return new Parameter(ident, AnyType.Instance);
			}
		}

		static IEnumerable<TypeSyntax> FlattenUnion(TypeSyntax t)
		{
			if (t is not UnionType uni)
			{
				yield return t;
			}
			else
			{
				foreach (var c in uni.Types)
					foreach (var cc in FlattenUnion(c))
						yield return cc;
			}
		}
		public static TypeSyntax ParseTypeSyntax(Queue<Token> tokens, bool optional)
		{
			TypeSyntax type = tokens.Dequeue() switch
			{
				NumberKeyword => NumberType.Instance,
				StringKeyword => StringType.Instance,
				AnyKeyword => AnyType.Instance,
				LiteralToken literal => literal switch
				{
					UndefinedLiteral uk => new LiteralType(uk),
					NullLiteral nk => new LiteralType(nk),
					StringLiteral stl => new LiteralType(stl),
					NumberLiteral nl => new LiteralType(nl),
					_ => throw new NotSupportedException("Literal type except null and undefined is currently not supported.")
				},
				Identifier ident => new TypeReference(new Identifier(ident.Name)),
				_ => throw new ParseException("Type syntax is invalid")
			};
			if (tokens.Peek() is LBracket)
			{
				tokens.Dequeue();
				if (tokens.Dequeue() is RBracket)
				{
					type = new ArrayType(type);
				}
				else
				{
					throw new ParseException("Array type is invalid");
				}
			}
			if(tokens.Peek() is Pipe)
            {
				if(optional)
                {
					type = new UnionType(new[] { type, new LiteralType(UndefinedLiteral.Instance), ParseTypeSyntax(tokens, false)});
                } else
				{
					type = new UnionType(new[] { type, ParseTypeSyntax(tokens, false) });
				}
				return new UnionType(FlattenUnion(type).ToArray());
			}

			return type;
		}

		public static Block ParseBlock(Queue<Token> tokens)
		{
			if (tokens.Peek() is not LBrace) throw new ParseException("Start of block '{' exptected.");
			tokens.Dequeue();
			bool hasReturnValue = false;
			while (true)
			{
				if (tokens.Count == 0) throw new ParseException("A block must be closed with '}'");
				var token = tokens.Dequeue();
				if (token is RBrace)
				{
					return new Block(hasReturnValue);
				}
				if(token is ReturnKeyword)
                {
					if(tokens.Peek() is not SemiColon and not RBrace)
                    {
						hasReturnValue = true;
                    }
					continue;
                }
			}
		}
	}
}

ジェネレーター作り

いよいよ作って行きましょう。
ジェネレータのプロジェクトにて、(Class1.csなど余計なものは取り除いておきましょう)ジェネレータ用のファイルを作ります。ここでは、簡便にGenerator.csとしておきます。
最低限の何もしないジェネレータは以下のとおりです。

Generator.cs
[Generator(LanguageNames.CSharp)]
public sealed class Generator2 : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // 何もしない
    }
}

[Generator(LanguageNames.CSharp)]を忘れないようにしましょう。筆者はこれを忘れて、何故か動かない、としばらくの間頭を抱えていました。

Incremental SourceGenerator のメンタルモデル

SourceGeneratorが、文字を書く毎に何度も重い処理を走らせていたという問題を省みて、Incremental SourceGeneratorでは、ソースコードをジェネレートするかしないかの判定をしたり、キャッシュを用いるか否かなどを指定するために、アナライザの動きに沿って、副作用を追加するパイプラインとして記述していくAPIとなっています。
そのため、APIが、関数を合成していくLINQに近いです。

大まかに、流れは以下の3点です。

  • ソースコード等のデータを捕まえる
  • データを変形して流す
  • データを元に、文字列として、ソースコードを生成する

こう考えると、難しそうに見えるIncrementalSourceGeneratorも、だいぶスッキリ見えてくるのではないでしょうか。
(私は最初IncremetalSourceGeneratorが怖かったですが、今の所上の理解でスッキリしています。)

ソースコード解析から必要な箇所を取り出して、データとして流す為のIncrementalValuesProvider

既存のコードから有用なコードを自動生成するためには、既存のコードのうち、自動生成のもととなる情報を取り出すことに関心があるはずです。
IncrementalValuesProviderは、まさに、アナライザの解析が走ったときに必要なコードの情報を取り出して、必要な情報を実際のコード生成に必要なデータを流すための「イベント」としての機能を持ちます。

IncrementalValuesProviderのうち、C#コードの流れに関与するものが、SyntaxProviderに当たります。

たとえば、ソースコードのうち、クラス宣言のみを抽出して、その情報を流すSyntaxProviderを生成するコードは以下のようになります。
引数の通り、predicateとtransformの2つの関数からなります。
これらは簡単に言ってしまえば、それぞれLINQのWhereSelectですね。

var syntaxProvider = initContext.SyntaxProvider.CreateSyntaxProvider(
    predicate: static (node, token) =>
    {
        token.ThrowIfCancellationRequested();
        return node is ClassDeclarationSyntax;
    },
    transform: static (ctx, token) =>
    {
        token.ThrowIfCancellationRequested();
        var syntax = (ctx.Node as ClassDeclarationSyntax)!;
        var symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(ctx.SemanticModel, syntax, token);
        return symbol;
    }
)
.Where(x => x is not null);

predicateの型は
Func<SyntaxNode, CancellationToken, bool>で、
transformの型は、
Func<GeneratorSyntaxContext, CancellationToken, T>です。

SyntaxNodeの型は、慣れ親しみは正直ありませんが、アナライザにも出てくるので、他のコードやドキュメントを参考にしたり、IntelliSenceに頼ってなんとなく書いたりする、という感じです。

それぞれの関数にCancellationTokenの引数が渡ってることにお気づきかと思います。
アナライザの処理は常に中断できることが求められますが、毎回token.ThrowIfCancellationRequested();をしておけば問題ないでしょう。

これらの関数は高頻度で呼ばれ、エディタの快適さに直結する為、なるべく低負荷であることが望ましいです。例えば、C#9 ~ のstaticラムダ式によって、関数オブジェクトのアロケーションを防ぐ事ができます。

簡単なジェネレーターの作成

外部ファイルを読んだりTypescriptをパースしたりと複雑なことをする前に、まずは現在把握している知識で、小さなジェネレーターである、Santaと名前のつくクラスがpartial宣言だった場合、SaySomethingメソッドを生やすジェネレーター + α
と作ってみましょう。

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using static Microsoft.CodeAnalysis.CSharp.CSharpExtensions;
namespace Exprazor.Web.TSInterop
{
    [Generator(LanguageNames.CSharp)]
    public sealed class SantaGenerator : IIncrementalGenerator
    {
        public void Initialize(IncrementalGeneratorInitializationContext context)
        {
            IncrementalValuesProvider<(INamedTypeSymbol, SyntaxTokenList)> syntaxProvider = context.SyntaxProvider.CreateSyntaxProvider(
                predicate: static (node, token) =>
                {
                    token.ThrowIfCancellationRequested();
                    // クラス宣言であるか、だけを判定する
                    return node is ClassDeclarationSyntax;
                },
                transform: static (context, token) =>
                {
                    token.ThrowIfCancellationRequested();
                    // predicateで保証されているので、null forgivingしておく
                    ClassDeclarationSyntax classDecl = (context.Node as ClassDeclarationSyntax)!;
                    // ASTであるSyntaxから「宣言のシンボル」の情報として変換する。
                    INamedTypeSymbol symbol = Microsoft.CodeAnalysis.CSharp.CSharpExtensions.GetDeclaredSymbol(context.SemanticModel, classDecl, token)!;
                    if (classDecl.Modifiers.Any(m => m.IsKind(Microsoft.CodeAnalysis.CSharp.SyntaxKind.PartialKeyword)) &&
                        symbol.Name.Contains("Santa"))
                    {
                        return (symbol, classDecl.Modifiers);
                    }
                    // タプルを使うことが多いので、defaultがおすすめ。
                    return default;
                }
                // 条件に当てはまらない場合は、transformでnullを返し、ここでフィルタリングする。
            ).Where(x => x is (not null, _))!; // タプルの判定はパターンマッチングが便利

            context.RegisterSourceOutput(syntaxProvider, static (context, tuple) =>
            {
                var (symbol, modifiers) = tuple;
                var source = @$"namespace {symbol.ContainingNamespace.ToDisplayString()} 
{{
    {string.Join(" ", modifiers.Select(x => x.ToString()))} class {symbol.Name}
    {{
        {(symbol.Name.Contains("Santana") ? // サンタナの場合の特殊処理
        @"protected void SaySomething() => global::System.Console.WriteLine(@""うっとおしい・・・・ぞ・・・・この原始人・・・・が・・・・・・・・・・"")" :
        @"protected void SaySomething() => global::System.Console.WriteLine(@""Merry Christmas🎅"")")}
    }}
}}";
                // 忘れずに。この関数自体はただのActionなので、ここで登録しておくことで初めて副作用が生まれる
                context.AddSource($"{symbol.Name}.g.cs", source);
            });
        }
    }
}

生成結果

SourceGeneratorは、実際のファイルでは無く、メモリにファイルを出力します。そのため、ファイルマネージャーで確認することは出来ません。
しかし例えば、VisualStudioであればここで確認できますので、デバッグがすごく大変!というわけではありません。
image.png

Santa.g.cs
namespace Exprazor.TSInterop.ConsoleSandbox 
{
    internal partial class Santa
    {
        protected void SaySomething() => global::System.Console.WriteLine(@"Merry Christmas🎅")
    }
}
Santana.g.cs
namespace Exprazor.TSInterop.ConsoleSandbox 
{
    internal partial class Santana
    {
        protected void SaySomething() => global::System.Console.WriteLine(@"うっとおしい・・・・ぞ・・・・この原始人・・・・が・・・・・・・・・・")
    }
}

参考

IncrementalSourceGeneratorに関しては、情報が少なく、何も分からん状態でしたが、先駆者の方々の情報により、目的を達成することができました。ここに感謝を表します。

次回予告

長くなってきたし、クリスマスを普通に楽しみたくなってきてしまったので、今回はこれを「序」とし、続きを続編に預けたいと思います。
目標は、Typescriptのバインディング生成でしたが、まだTの字も出ていませんね汗
ということで、次回は、

  • 外部ファイルの読み込み方 (AdditionalTextsProviderについて)
  • RoslynComponentデバッグの為のセットアップ及び方法について

これらを解説していきたいと思います。

有用で快適なコード生成の為に、これらも重要になってくると思いますので、是非お楽しみに!

  1. サンプルのジェネレーターで、おちゃめ機能が発動しました。https://dic.pixiv.net/a/%E3%82%B5%E3%83%B3%E3%82%BF%E3%83%8A%28%E3%82%B8%E3%83%A7%E3%82%B8%E3%83%A7%E3%81%AE%E5%A5%87%E5%A6%99%E3%81%AA%E5%86%92%E9%99%BA%29

12
16
0

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
12
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?