はじめに
最近,万物をSource Generatorで書いていてどハマりしています。
変な仕組みを作り出して快適な開発を実現しているので,その一部を簡単に紹介したいと思います。
IDisposableの自動実装で紹介したSG Dirge を例として紹介します。
SGのつくりかた
プロジェクト構成
Dirge.slnx
├ Dirge/
│ ├ (メインのSG)
│ └ Dirge.csproj
├ Dirge.CodeFixes/
│ ├ (コード修正)
│ └ Dirge.CodeFixes.csproj
├ Dirge.Test/
│ ├ (テストコード)
│ └ Dirge.Test.csproj
├ Dirge.TestGenerator/
│ ├ (テストのボイラープレート生成)
│ └ Dirge.TestGenerator.csproj
└ Dirge.SourceGeneratorUtils/
├ (SGのユーティリティ)
└ Dirge.SourceGeneratorUtils.csproj
私のプロジェクトでは,テストのボイラープレートを書くことすら面倒になってしまったのでSGで自動生成しています。
したがって,メインSGとテスト生成SGが存在しており,両者に同じユーティリティを入れるためのSourceGeneratorUtilsがあり,これもSGとして作成しています(後述)。
言語バージョンなど
SGは.NET Standard 2.0縛りがありますが,言語バージョンは自由なので,とりあえず最新のC#14を使っています。
また,テストについては制限はないので.NET10/C#14にしています。
ユーティリティの生成
複数のSGに共通のコードを入れる際に,プロジェクト参照を利用するとパッケージ作成の際に参照がややこしくなります。
そのため,SGプロジェクト内にユーティリティコードを生成するSGを使っています。
例えばこのような感じで簡単にコードを入れることができます。
namespace Dirge.SourceGeneratorUtils;
[Generator(LanguageNames.CSharp)]
internal sealed class EquatableArray : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(static (context) => context.AddSource("EquatableArray.g.cs", Source));
}
// lang=C#
private const string Source = """
// <auto-generated />
namespace Dirge.Utils;
/// <summary>
/// Represents an array of equatable elements that can be compared for equality based on their contents.
/// </summary>
/// <typeparam name="T">The type of the elements in the array.</typeparam>
internal readonly struct EquatableArray<T> : global::System.IEquatable<EquatableArray<T>>, global::System.Collections.Generic.IEnumerable<T> where T : global::System.IEquatable<T>
{
private readonly T[] _array;
/// <summary>
/// Gets the length of the array.
/// </summary>
internal int Length => this._array.Length;
/// <summary>
/// Gets the element at the specified index.
/// </summary>
/// <param name="index">The zero-based index of the element to get.</param>
internal ref readonly T this[int index] => ref this._array[index];
/// <summary>
/// Initializes a new instance of the <see cref="EquatableArray{T}"/> struct with the specified array.
/// </summary>
/// <param name="array">The array to wrap in the <see cref="EquatableArray{T}"/> struct.</param>
internal EquatableArray(T[] array)
{
this._array = array;
}
/// <summary>
/// Returns a span representing the contents of the underlying array.
/// </summary>
/// <returns>A <see cref="global::System.Span{T}"/> that provides access to the elements of the underlying array.</returns>
internal global::System.Span<T> AsSpan()
=> this._array;
public global::System.Collections.Generic.IEnumerator<T> GetEnumerator()
=> ((global::System.Collections.Generic.IEnumerable<T>)this._array).GetEnumerator();
global::System.Collections.IEnumerator global::System.Collections.IEnumerable.GetEnumerator()
=> this._array.GetEnumerator();
public bool Equals(EquatableArray<T> other)
=> global::System.MemoryExtensions.SequenceEqual(AsSpan(), other.AsSpan());
public static implicit operator EquatableArray<T>(T[] array)
=> new(array);
public static implicit operator T[](EquatableArray<T> equatableArray)
=> equatableArray._array;
}
""";
}
言語機能の改善
C#には「そのままでは使えないけど特定の型があったら使えるようになる」という機能が色々あります。
例えば
-
init専用セッタ (System.Runtime.CompilerServices.IsExternalInit) - スライス (
System.Index/System.Range)
などです。
特にinitについては,recordでプライマリコンストラクタが使えるようになるので,SG開発がとても楽になります。1
また,スライスもROS<char>を切り出す際などにあると嬉しいです。
便利機能
主にRoslyn API系でよく使うけどめんどくさいようなものも用意しておくといいでしょう。
特にITypeSymbol.ToDisplayString(Microsoft.CodeAnalysis.SymbolDisplayFormat.FullyQualifiedFormat.WithGlobalNamespaceStyle(Microsoft.CodeAnalysis.SymbolDisplayGlobalNamespaceStyle.Included));なんて頻出のくせに長すぎてやってられない(using Microsoft.CodeAnalysisがあってもなお長い)ので,拡張メンバでプロパティとして取れるようにしておくと楽になります。
多くのSGでは属性で出力を制御するので,属性の引数を取りやすくしておくと便利です。
例えば
extension (AttributeData attribute)
{
/// <summary>
/// Attempts to retrieve the value of a named argument from the attribute and cast it to the specified type.
/// </summary>
/// <typeparam name="T">The expected type of the named argument value.</typeparam>
/// <param name="argumentName">The name of the argument to retrieve from the attribute.</param>
/// <param name="value">When this method returns, contains the value of the named argument cast to type <typeparamref name="T"/> if found and of the correct type;
/// otherwise, the default value for type <typeparamref name="T"/>.</param>
/// <returns><see langword="true"/> if the named argument exists and can be cast to type <typeparamref name="T"/>; otherwise, <see langword="false"/>.</returns>
internal bool TryGetNamedArgumentValue<T>(string argumentName, out T? value)
{
foreach (var namedArg in attribute.NamedArguments)
{
if (namedArg.Key != argumentName) continue;
if (namedArg.Value.Value is not T typedValue) break; // If the argument exists but cannot be cast to the expected type, treat it as not found.
value = typedValue;
return true;
}
value = default;
return false;
}
}
のような感じです(params指定の配列などは.Valueあたりで落ちることがあるので気をつけましょう)。
また,SGに限りませんがLINQ系もお気に入りのメソッドを足したりしています。
extension<T> (IEnumerable<T?> source) where T : notnull
{
/// <summary>
/// Projects each element of a sequence into a new form and filters out null results.
/// </summary>
/// <typeparam name="TResult">The type of the value returned by selector.</typeparam>
/// <param name="selector">A transform function to apply to each element.</param>
/// <returns>A sequence of non-null values returned by selector.</returns>
internal IEnumerable<TResult> SelectNotNull<TResult>(Func<T, TResult?> selector)
{
foreach (var item in source)
{
if (item is null) continue;
if (selector(item) is { } result)
yield return result;
}
}
}
SG本体
特定の属性が付けられた要素を取ってきていい感じにする機能が,今ではとても簡単に書けます。
[Generator(LanguageNames.CSharp)]
internal sealed class DisposeGenerator : IIncrementalGenerator
{
private const string AttributeName = "Dirge.AutoDisposeAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var sources = context.SyntaxProvider.ForAttributeWithMetadataName(
AttributeName, // 引っかける属性の名前
static (node, token) => node is ClassDeclarationSyntax or StructDeclarationSyntax, // よく通るので軽量なフィルタ
static (context, token) => DisposableTypeInfo.Create(context) // 早い段階でrecordに変換
);
context.RegisterSourceOutput(sources, Execute);
}
private static void Execute(SourceProductionContext context, DisposableTypeInfo info)
{
// DisposableTypeInfoに必要な情報を詰めているので,
// それを元にコードを生成して
// context.AddSource(hintName, code)
// で出力
}
}
GeneratorAttributeSyntaxContextをそのまま持っているとSGが走るたびに中身が変わってしまう(コードが同じでも別オブジェクトが渡ってくる)のでIncrementalGeneratorと言いながら毎回SGが走ってしまいます。
個別具体的な話になってしまいますが,DisposableTypeInfoには型の名前やフィールドの情報が入っていて,さらに正しい等価性判定のためにフィールド情報(それぞれはrecord)の配列をEquatableArrayでラップしています。
フィールド情報は
var fieldResults = targetSymbol.GetMembers()
.OfType<IFieldSymbol>()
.SelectNotNull(f => DisposableFieldInfo.Create(f, targetSymbol, disposableSymbol, compilation))
.ToArray();
// これをEquatableArrayでラップする
という感じで取っており,DisposableFieldInfo.CreateはDisposableFieldInfo?を返す(関係ないフィールドに対してはnull)を返してくるので,自作のSelectNotNullで非nullの有効な値だけを選んでいます。
型のメンバを列挙して対象のものについて処理をする,と言うパターンはよく出てくるので,このようなSelectNotNullで必要な情報を取るパターンを多用しています。
コード修正
コード修正も昔に比べて劇的に書きやすくなった印象です。
例えば型にpartialを追加するだけの簡単なお仕事は
using Dirge.Diagnostics;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Editing;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace Dirge.CodeFixes;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddPartialModifierCodeFixProvider)), Shared]
internal sealed class AddPartialModifierCodeFixProvider : CodeFixProvider
{
override public ImmutableArray<string> FixableDiagnosticIds => [DiagnosticDescriptors.TypeMustBePartialId];
override public FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false);
if (root is null) return;
var diagnostic = context.Diagnostics.First();
var diagnosticSpan = diagnostic.Location.SourceSpan;
var declaration = root.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType<TypeDeclarationSyntax>().FirstOrDefault();
if (declaration is null) return;
context.RegisterCodeFix(
CodeAction.Create(
title: "Add 'partial' modifier",
createChangedDocument: c => AddPartialModifierAsync(context.Document, declaration, c),
equivalenceKey: nameof(AddPartialModifierCodeFixProvider)
),
diagnostic);
}
private static async Task<Document> AddPartialModifierAsync(Document document, TypeDeclarationSyntax typeDecl, CancellationToken cancellationToken)
{
var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false);
var typesToFix = typeDecl.AncestorsAndSelf().OfType<TypeDeclarationSyntax>();
foreach (var typeToFix in typesToFix)
{
if (typeToFix.Modifiers.Any(SyntaxKind.PartialKeyword))
continue;
var partialToken = SyntaxFactory.Token(SyntaxKind.PartialKeyword).WithTrailingTrivia(SyntaxFactory.ElasticSpace);
var newModifiers = typeToFix.Modifiers.Add(partialToken);
var newTypeDecl = typeToFix.WithModifiers(newModifiers);
editor.ReplaceNode(typeToFix, newTypeDecl);
}
return editor.GetChangedDocument();
}
}
だけでできてしまいます。
当然ですがやりたことによって中身は変わりますが,DocumentEditor.CreateAsyncでDocumentEditorを作ってReplaceNodeで差し替えという構造は変わらないと思います。
テスト
SGは裏で勝手に動くものなので出力が見えづらく,テストがとても重要になります(そうでなくてもテストは大事ですが)。
ここではVerifyテストとコード修正のテストについて紹介します。
(統合テストとかは個別の話になってしまうので各自でいい感じに頑張ってください。)
テストプロジェクトの例
メインSG側で
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<InternalsVisibleTo Include="Dirge.Test" />
</ItemGroup>
</Project>
としてテスト用に中身を見せています。
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>14</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.CodeFix.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing" Version="1.1.3" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="5.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="Verify.SourceGenerators" Version="2.5.0" />
<PackageReference Include="Verify.XunitV3" Version="31.15.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="xunit.v3" Version="3.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Dirge\Dirge.csproj" />
<ProjectReference Include="..\Dirge\Dirge.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Dirge.CodeFixes\Dirge.CodeFixes.csproj" />
<ProjectReference Include="..\Dirge.CodeFixes\Dirge.CodeFixes.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\Dirge.TestGenerator\Dirge.TestGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>
以下で紹介する「自動生成されたテストコード」は自分だけで使うことを想定してvarを多用していますが,外に出すコードではvarを使うべきではありません。必ず型名を明記しましょう。
Verifyテスト
SGが吐いたコードが,事前に指定したものと一致するかどうかをテストします。
リファクタリングした際に出力が変化しないかを勝手に見てくれます。
簡単な例であればシンプルなのですが,私の場合は
- 属性を吐くSGとメインSGが独立している
- 固定の属性は固定なのでテストからは除外して,メインSGが動的に吐くコードだけをテストしたい
- でもボイラープレートは書きたくない!
といった状況になっています。
そこで,テストプロジェクトでは
[VerifyTest(LanguageVersion.CSharp12, LanguageVersion.CSharp14)]
public sealed partial class DisposeGenerationTest
{
// lang=C#
[TestSource]
private static readonly string _simpleDispose = """
using Dirge;
using System.IO;
namespace Test;
[AutoDispose]
internal sealed partial class MyClass
{
private readonly Stream _stream;
}
""";
private static readonly string[] _ignoreFiles = [
"ExtensionMethods.g.cs",
"Microsoft.CodeAnalysis.EmbeddedAttribute.cs",
];
private static partial bool IgnoreRule(GeneratedSourceResult result)
{
if (result.HintName.EndsWith("Attribute.g.cs", StringComparison.OrdinalIgnoreCase)) return true;
if (_ignoreFiles.Contains(result.HintName)) return true;
return false;
}
}
だけを書けば
partial class DisposeGenerationTest
{
[global::Xunit.Theory]
[global::Xunit.InlineData(global::Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp12)]
[global::Xunit.InlineData(global::Microsoft.CodeAnalysis.CSharp.LanguageVersion.CSharp14)]
internal global::System.Threading.Tasks.Task SimpleDispose(global::Microsoft.CodeAnalysis.CSharp.LanguageVersion languageVersion)
{
return global::Dirge.Test.Verify.VerifyTestHelper.RunTest(_simpleDispose, languageVersion, IgnoreRule, @"C:\xxx\Dirge\Dirge.Test\Verify\Snapshots\SimpleDispose");
}
private static partial bool IgnoreRule(global::Microsoft.CodeAnalysis.GeneratedSourceResult result);
}
file static class VerifyTestHelper
{
[global::System.Runtime.CompilerServices.ModuleInitializer]
internal static void Init()
{
global::VerifyTests.VerifySourceGenerators.Initialize();
}
private static global::Microsoft.CodeAnalysis.GeneratorDriver GetDriver(string source, global::Microsoft.CodeAnalysis.CSharp.LanguageVersion languageVersion)
{
var options = new global::Microsoft.CodeAnalysis.CSharp.CSharpParseOptions(languageVersion);
var disposeGenerator = new global::Dirge.Generators.DisposeGenerator();
var typesGenerator = new global::Dirge.Generators.TypesGenerator();
var syntaxTree = global::Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(source, options, cancellationToken: TestContext.Current.CancellationToken);
var compilation = global::Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create(
assemblyName: "TestComp",
options: new(global::Microsoft.CodeAnalysis.OutputKind.DynamicallyLinkedLibrary),
syntaxTrees: [syntaxTree]
)
.AddReferences(
global::Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
global::Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(global::Dirge.Generators.DisposeGenerator).Assembly.Location)
);
return global::Microsoft.CodeAnalysis.CSharp.CSharpGeneratorDriver.Create(typesGenerator, disposeGenerator)
.WithUpdatedParseOptions(options)
.RunGenerators(compilation, cancellationToken: global::Xunit.TestContext.Current.CancellationToken);
}
internal static global::System.Threading.Tasks.Task RunTest(string source, global::Microsoft.CodeAnalysis.CSharp.LanguageVersion languageVersion, global::System.Func<global::Microsoft.CodeAnalysis.GeneratedSourceResult, bool> ignoreRule, string snapshotDirectory)
{
var driver = GetDriver(source, languageVersion);
var results = driver.GetRunResult();
var targetSource =
results.Results.SelectMany(r => r.GeneratedSources)
.Single(s => !ignoreRule(s))
.SourceText.ToString();
return global::VerifyXunit.Verifier.Verify(target: targetSource, extension: "cs")
.UseDirectory(snapshotDirectory)
.UseFileName(languageVersion.ToString());
}
}
が生えてくるようになっています。
これも考え方はメインSGと同じで,VerifyTestAttributeでマークされた型のstring型のフィールドを列挙して,それぞれに対するテストを生成しています。
言語バージョンはparamsにしているので好きなだけ足すことができて,それぞれに対応するテストが生成されます。
コード修正
コード修正では,修正前のコードと修正後のコードをそれぞれベタ書きして,正しく修正できるかをテストします。
しかし,これでは修正箇所が空間的に離れてしまうので,どこが変換されるべきなのかがわかりにくくなります。
そこで,diff風記法で修正箇所を示せば自動的に修正前後に分離したテストを生成する仕組みを作りました。
例えば
[CodeFixTest]
public sealed partial class CodeFixTests
{
// lang=C#
[CodeFixSource<AddPartialModifierCodeFixProvider>]
private const string _nonPartialClass = """
using Dirge;
using System.IO;
[AutoDispose]
-public class {|DIRGE002:NonPartialClass|}
+public partial class NonPartialClass
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
""";
}
と書くことで
partial class CodeFixTests
{
[global::Xunit.Fact]
async internal global::System.Threading.Tasks.Task NonPartialClass()
{
// lang=C#
var before = """
using Dirge;
using System.IO;
[AutoDispose]
public class {|DIRGE002:NonPartialClass|}
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
""";
// lang=C#
var after = """
using Dirge;
using System.IO;
[AutoDispose]
public partial class NonPartialClass
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
""";
var test = new Dirge.Test.CodeFixes.GeneratorCodeFixTest<global::Dirge.CodeFixes.AddPartialModifierCodeFixProvider>()
{
TestCode = before,
FixedCode = after,
};
await test.RunAsync(global::Xunit.TestContext.Current.CancellationToken);
}
}
file sealed class GeneratorCodeFixTest<TCodeFix> : global::Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixTest<global::Microsoft.CodeAnalysis.Testing.EmptyDiagnosticAnalyzer, TCodeFix, global::Microsoft.CodeAnalysis.Testing.DefaultVerifier>
where TCodeFix : global::Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider, new()
{
protected override global::System.Collections.Generic.IEnumerable<global::System.Type> GetSourceGenerators()
{
yield return typeof(global::Dirge.Generators.DisposeGenerator);
yield return typeof(global::Dirge.Generators.TypesGenerator);
}
public GeneratorCodeFixTest()
{
this.TestBehaviors = global::Microsoft.CodeAnalysis.Testing.TestBehaviors.SkipGeneratedSourcesCheck;
}
}
というコードが生成されます。
ポイントは
using Dirge;
using System.IO;
[AutoDispose]
-public class {|DIRGE002:NonPartialClass|}
+public partial class NonPartialClass
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
を見て,行頭が-/+であれば修正前後のみ,それ以外は両方に割り振るということを自動でしている点です。
以下のように分割されますが,この状態だと間違い探しになってどこが変更されたのかがわかりにくいと思います(この程度ならマシですが,もっと長くなってくるとしんどいです)。
using Dirge;
using System.IO;
[AutoDispose]
public class {|DIRGE002:NonPartialClass|}
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
using Dirge;
using System.IO;
[AutoDispose]
public partial class NonPartialClass
{
private readonly Stream _stream;
public NonPartialClass(Stream stream)
{
this._stream = stream;
}
}
これをdiff風記法でまとめることで,テスト自体が仕様を表現できるようになっています。
コードの分割方法
テスト時にしか走らないコードですが,パフォーマンスに気をつけて分割しています。
internal readonly ref struct DiffSources
{
internal readonly string Before { get; }
internal readonly string After { get; }
internal DiffSources(string input)
{
var length = input.Length;
var maxLength = length << 1;
char[]? pooled = null;
try
{
var buffer = maxLength <= 0x2000 ? stackalloc char[maxLength] : (pooled = ArrayPool<char>.Shared.Rent(maxLength));
var before = new SpanBuilder<char>(buffer[..length]);
var after = new SpanBuilder<char>(buffer[length..(length << 1)]);
ParseLines(input, ref before, ref after);
this.Before = before.AsSpan().ToString();
this.After = after.AsSpan().ToString();
}
finally
{
if (pooled is not null)
ArrayPool<char>.Shared.Return(pooled);
}
}
private void ParseLines(ReadOnlySpan<char> input, ref SpanBuilder<char> before, ref SpanBuilder<char> after)
{
while (!input.IsEmpty)
{
var sep = input.IndexOfAny('\r', '\n');
if (sep == -1)
{
AppendLine(input, ref before, ref after);
break;
}
AppendLine(input[..sep], ref before, ref after);
input = input[(sep + 1)..];
if (!input.IsEmpty && input[0] == '\n')
{
input = input[1..];
}
}
}
private static void AppendLine(ReadOnlySpan<char> line, ref SpanBuilder<char> before, ref SpanBuilder<char> after)
{
if (line.IsEmpty)
{
before.AppendLine();
after.AppendLine();
return;
}
if (line[0] == '-')
{
before.AppendLine(line[1..]);
}
else if (line[0] == '+')
{
after.AppendLine(line[1..]);
}
else
{
before.AppendLine(line);
after.AppendLine(line);
}
}
}
var sources = new DiffSources(testCase.Source);
var beforeCode = sources.Before;
var afterCode = sources.After;
SpanBuilder<T>は受け取ったSpan<T>を書き込みバッファとしてAppendを提供するref構造体で,SpanBuilder<char>に限りAppendLineを提供します。2
こちらも技術的には属性を見て列挙しているだけですが,今回はコンパイル時に文字列の中身を読むためにフィールドではなく定数になっています(定数はSGで簡単に値を取れる)。
また,コード中に生文字列リテラルがあってもエラーにならないように引用符の数は一応気にしています。
static int GetDelimiterCount(string text, char delimiter = '"')
{
var maxQuotes = 0;
var currentQuotes = 0;
foreach (var c in text)
{
if (c == delimiter)
{
currentQuotes++;
if (currentQuotes > maxQuotes)
{
maxQuotes = currentQuotes;
}
}
else
{
currentQuotes = 0;
}
}
return Math.Max(maxQuotes + 1, 3);
}
var beforeDelimiter = new string('"', GetDelimiterCount(beforeCode));
var afterDelimiter = new string('"', GetDelimiterCount(afterCode));
さいごに
テストのボイラープレートを書きたくない怠惰で短気で傲慢な皆さんの楽しいSG開発の参考になれば幸いです。
この記事は要点だけを紹介したので,細かい話は実際のリポジトリを見ていただければと思います。