Source Generatorの小技集みたいなものを不定期に書いていこうと思います。
今回はコード生成時の小テクニックです。
TL;DR
コード整形機能を盛り込んだStringBuilderラッパーを作成し、それを使ってコード生成を行う。
課題
Source Generatorとは書いて字のごとく、ソースコードをプログラム的に生成する仕組みです。
なので、最も単純な形態は以下のようになります。1
// ソースコードを直接文字列で書く
var source = $$"""
namespace GeneratedNamespace;
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
""";
context.AddSource("GeneratedFile.g.cs", source);
とはいえ、実際にはもっと複雑なことをしたいことが多いと思います。
一例
例えば、上記の例ではGeneratedNamespaceに吐き出していましたが、ここをユーザーコードの名前空間に合わせることを考えてみます。
素朴に吐き出すと以下のようになりますが
var namespaceName = GetNamespaceFromUserCode(parseRecordObject);
var source = $$"""
namespace {{namespaceName}}
{
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
}
""";
この場合、名前空間が無い場合(global直下の場合)にnamespace { ... }となってしまい、コンパイルエラーになります。
ということで修正してみます。
var namespaceName = GetNamespaceFromUserCode(parseRecordObject);
var sourceBase = $$"""
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
""";
if (!string.IsNullOrEmpty(namespaceName))
{
generatedCode = $"""
namespace {namespaceName}
{{
{sourceBase}
}}
""";
}
else {
generatedCode = sourceBase;
}
このようにすると、 生成されたコードは以下のようになります。
// 生成されたコード例
namespace MyApp.Models
{
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
}
間違ってはいないですし、このままで問題なく動作しますが、コードのインデントが崩れています。
ここで内部処理の生成を別関数で実施したりすると、インデントがさらに崩れていきます。
// 生成されたコード例
namespace MyApp.Models
{
public class GeneratedClass
{
public void GeneratedMethod()
{
var source = "Some processing";
if(CallAnotherMethod())
{
source += " More processing";
}
Console.WriteLine(source);
}
}
}
別に自動生成なので気にしなくても良いと言われればそうですが、何となく嫌ですよね?
対策
その1: インデントをあらかじめ埋め込んでおく
こんなイメージです。
// あらかじめインデントを埋め込んでコードを生成する
var generatedMethod = $$"""
public void GeneratedMethod()
{
// 何か処理
}
""";
var source = $$"""
public class GeneratedClass
{
{{generatedMethod}}
}
""";
確かに機能しますが、保守が辛くなっていきます。
また、上記のnamespaceのように動的にインデントが変わる場合はさらに辛くなります。
その2: 関数の結果に対して都度インデントを付与する
こんなイメージです。
// 普通にコードを生成する(外部関数など)
var generatedMethod = $$"""
public void GeneratedMethod()
{
// 何か処理
}
""";
// 生成したコードに対してインデントを付与(あるいは除去)する
var generatedMethodWithIndent = Indent(generatedMethod, 1);
// その結果を使用
var source = $$"""
public class GeneratedClass
{
{{generatedMethodWithIndent}}
}
""";
// インデント付与関数の例
string Indent(string code, int indentLevel)
{
const int spacesPerIndent = 4;
var indent = new string(' ', indentLevel * spacesPerIndent);
var lines = code.Split(new[] { "\r\n", "\r", "\n" }, StringSplitOptions.None);
for (int i = 0; i < lines.Length; i++)
{
lines[i] = indent + lines[i];
}
// Environment.NewLineは使えないので注意
return string.Join('\n', lines);
}
この方法でも良いですが、都度呼び出しが必要なのがネックです。また、後述の方法のほうが読みやすいのでそちらを推奨します。
その3: NormalizeWhitespaceを使う
RoslynのAPIにNormalizeWhitespaceというコード整形機能があります。
var tree = CSharpSyntaxTree.ParseText(generatedCode);
var formattedCode = tree.GetRoot().NormalizeWhitespace().ToFullString();
すごくうってつけの関数だと思いきや、実はSource Generatorでは使ってはいけない扱いをされている関数です。
上記Issueにもある通り、Source Generatorのパフォーマンスを著しく悪化させる原因となります。
そして、これを高速で実施する良い方法は提供されていません。
その4: csharpierを使う
それならば、外部のコード整形ツールを使えばいいと考えるかもしれません。
csharpierは.NET向けのコード整形ツールで、ライブラリとしても利用できます。
なので以下のように書けば良さそうに見えますが……
var formattedCode = CSharpFormatter.Format(generatedCode).Code;
Source Generatorで外部ライブラリを使うのはとっても面倒です。
具体的にはパッケージの依存関係DLLをすべて生成物に含める必要があり、設定が非常に煩雑です。
ちなみにcsharpier自身の依存関係はこれだけあります(これらの依存的推移も全て含める必要があります)。

というわけで、このアプローチも微妙です。
その5: IndentStringBuilderを使う
上記のIssueにてメンテナーのCyrus Najmabadi氏が提案している方法です。
仕組みはすごくシンプルで、インデント管理機能を持ったStringBuilderラッパーを作成し、それを使ってコード生成を行います。
実装は以下の通りです。
/// <summary>
/// Utility class for efficiently building indented strings.
/// </summary>
internal class IndentedStringBuilder(StringBuilder stringBuilder, int indentLevel = 0)
{
public const int IndentSize = 4;
public IndentedStringBuilder(int indentLevel = 0)
: this(new StringBuilder(), indentLevel) { }
public int IndentLevel { get; private set; } = indentLevel;
public string Indent => new(' ', IndentLevel * IndentSize);
public void IncreaseIndent() => IndentLevel += 1;
public void DecreaseIndent() => IndentLevel = Math.Max(0, IndentLevel - 1);
public void AppendLine(string text)
{
var lines = text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
foreach (var line in lines)
{
stringBuilder.AppendLine(Indent + line);
}
}
public override string ToString() => stringBuilder.ToString();
}
AppendLine,ToStringがあるのは普通のStringBuilderと同じですが、そこにIncreaseIndent/DecreaseIndentが加わっています。
たったこれだけですが、コード生成時にインデントを管理するのが非常に楽になります。
例えば「その2」のようなケースの場合。
public void Generate()
{
var builder = new IndentedStringBuilder();
builder.AppendLine("public class GeneratedClass {");
builder.IncreaseIndent();
GenerateMethodCode(builder);
builder.DecreaseIndent();
builder.AppendLine("}");
}
public void GenerateMethodCode(IndentedStringBuilder builder)
{
builder.AppendLine("public void GeneratedMethod() {");
builder.IncreaseIndent();
GenerateAnotherMethod(builder);
builder.DecreaseIndent();
builder.AppendLine("}");
return builder;
}
public void GenerateAnotherMethod(IndentedStringBuilder builder)
{
builder.AppendLine("var source = \"Some processing\";");
builder.AppendLine("if(CallAnotherMethod()) {");
builder.IncreaseIndent();
builder.AppendLine("source += \" More processing\";");
builder.DecreaseIndent();
builder.AppendLine("}");
}
このように、別の関数にコード生成を任せる場合でも、builderを渡すことでインデントを維持できます。
また、その2のように都度戻り値で受け取る必要もないのでコードの見栄えが良くなります。
さらに改良する
このIndentedStringBuilderを使用して、冒頭のnamespace対応コードを生成してみます。
var builder = new IndentedStringBuilder();
// namespace
var namespaceName = GetNamespaceFromUserCode(parseRecordObject);
// 名前空間がある場合はnamespace宣言を追加してインデントに入る
if (!string.IsNullOrEmpty(namespaceName))
{
builder.AppendLine($"namespace {namespaceName}");
builder.AppendLine("{");
builder.IncreaseIndent();
}
builder.AppendLine($$"""
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
""");
// 終わったらインデントを戻して閉じ括弧を追加
if (!string.IsNullOrEmpty(namespaceName))
{
builder.DecreaseIndent();
builder.AppendLine("}");
}
同じようなif判定文が2箇所発生するのが少々気になります。
そこで、以下のような機能を追加してみます。
internal class IndentedStringBuilder(StringBuilder stringBuilder, int indentLevel = 0)
{
public IndentedStringBuilder(int indentLevel = 0)
: this(new StringBuilder(), indentLevel) { }
public const int IndentSize = 4;
public int IndentLevel { get; private set; } = indentLevel;
public string Indent => new(' ', IndentLevel * IndentSize);
public void IncreaseIndent() => IndentLevel += 1;
public void DecreaseIndent() => IndentLevel = Math.Max(0, IndentLevel - 1);
public void AppendLine(string text)
{
var lines = text.Split(["\r\n", "\r", "\n"], StringSplitOptions.None);
foreach (var line in lines)
{
stringBuilder.AppendLine(Indent + line);
}
}
public override string ToString() => stringBuilder.ToString();
// ----------------------
// 以下追加
public void AppendLineIf(bool condition, string text)
{
if (condition)
{
AppendLine(text);
}
}
public IDisposable? IndentScopeWithBraceIf(bool condition, string open = "{", string close = "}")
{
if (condition)
{
AppendLine(open);
IncreaseIndent();
return new IndentScopeDisposable(this, close);
}
return null;
}
private sealed class IndentScopeDisposable(IndentedStringBuilder builder, string? closeBraceText = null) : IDisposable
{
public void Dispose()
{
builder.DecreaseIndent();
if (closeBraceText is not null)
{
builder.AppendLine(closeBraceText);
}
}
}
}
AppendLineIfは条件付きで行を追加するメソッドです。
IndentScopeWithBraceIfはusingステートメントと組み合わせてインデントスコープを管理するためのメソッドです。
これらを使うと、namespace対応コードは以下のように書けます。
var builder = new IndentedStringBuilder();
// namespace
var namespaceName = GetNamespaceFromUserCode(parseRecordObject);
var hasNamespace = !string.IsNullOrEmpty(namespaceName);
builder.AppendLineIf(hasNamespace, $"namespace {namespaceName}");
using (builder.IndentScopeWithBraceIf(hasNamespace))
{
builder.AppendLine($$"""
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
""");
}
どうでしょう、かなりスッキリしたのではないでしょうか。
実際に実行してみると、以下のようなコードが生成されます。
namespace test // testの場合
{
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
}
// 名前空間無しの場合
public class GeneratedClass
{
public void GeneratedMethod()
{
// 何か処理
}
}
まとめ
この記事では、Source Generatorでのコード生成時にインデントを管理する方法を紹介しました。
NormalizeWhitespaceの使用は避け、IndentedStringBuilderのようなインデント管理機能を持ったラッパーを使うことで、効率的かつ可読性の高いコード生成が可能になります。
Footnotes
-
こういった複数行にわたるソースコードを生成する場合、
$$"""..."""のようなRaw String Literalを使うと便利です。 ↩