diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Helpers/TestHelper.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Helpers/TestHelper.cs index 3708f38a..f0bdae8c 100644 --- a/SpreadCheetah.SourceGenerator.SnapshotTest/Helpers/TestHelper.cs +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Helpers/TestHelper.cs @@ -1,19 +1,20 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using SpreadCheetah.SourceGeneration; +using System.Collections; +using System.Collections.Immutable; +using System.Reflection; namespace SpreadCheetah.SourceGenerator.SnapshotTest.Helpers; internal static class TestHelper { - public static SettingsTask CompileAndVerify(string source, params object?[] parameters) where T : IIncrementalGenerator, new() + private static PortableExecutableReference[] GetAssemblyReferences() { - var syntaxTree = CSharpSyntaxTree.ParseText(source); - var dotNetAssemblyPath = Path.GetDirectoryName(typeof(object).Assembly.Location) ?? throw new InvalidOperationException(); - var references = new[] - { + return + [ MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "mscorlib.dll")), MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "netstandard.dll")), MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.dll")), @@ -22,8 +23,13 @@ internal static class TestHelper MetadataReference.CreateFromFile(Path.Combine(dotNetAssemblyPath, "System.Runtime.dll")), MetadataReference.CreateFromFile(typeof(WorksheetRowAttribute).Assembly.Location), MetadataReference.CreateFromFile(typeof(TestHelper).Assembly.Location) - }; + ]; + } + public static SettingsTask CompileAndVerify(string source, bool replaceEscapedLineEndings = false, params object?[] parameters) where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = GetAssemblyReferences(); var compilation = CSharpCompilation.Create("Tests", [syntaxTree], references); var generator = new T(); @@ -35,10 +41,184 @@ internal static class TestHelper var settings = new VerifySettings(); settings.UseDirectory("../Snapshots"); + if (replaceEscapedLineEndings) + settings.ScrubLinesWithReplace(x => x.Replace("\\r\\n", "\\n", StringComparison.Ordinal)); + var task = Verify(target, settings); return parameters.Length > 0 ? task.UseParameters(parameters) : task; } + + /// + /// Based on the implementation from: + /// https://andrewlock.net/creating-a-source-generator-part-10-testing-your-incremental-generator-pipeline-outputs-are-cacheable/ + /// + public static (ImmutableArray Diagnostics, string[] Output) GetGeneratedTrees( + string source, + string[] trackingStages, + bool assertOutputs = true) + where T : IIncrementalGenerator, new() + { + var syntaxTree = CSharpSyntaxTree.ParseText(source); + var references = GetAssemblyReferences(); + var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary); + var compilation = CSharpCompilation.Create("SpreadCheetah.Generated", [syntaxTree], references, options); + + // Run the generator, get the results, and assert cacheability if applicable + var runResult = RunGeneratorAndAssertOutput(compilation, trackingStages, assertOutputs); + + // Return the generator diagnostics and generated sources + return (runResult.Diagnostics, runResult.GeneratedTrees.Select(x => x.ToString()).ToArray()); + } + + private static GeneratorDriverRunResult RunGeneratorAndAssertOutput(CSharpCompilation compilation, string[] trackingNames, bool assertOutput = true) + where T : IIncrementalGenerator, new() + { + var generator = new T().AsSourceGenerator(); + + // ⚠ Tell the driver to track all the incremental generator outputs + // without this, you'll have no tracked outputs! + var opts = new GeneratorDriverOptions( + disabledOutputs: IncrementalGeneratorOutputKind.None, + trackIncrementalGeneratorSteps: true); + + GeneratorDriver driver = CSharpGeneratorDriver.Create([generator], driverOptions: opts); + + // Create a clone of the compilation that we will use later + var clone = compilation.Clone(); + + // Do the initial run + // Note that we store the returned driver value, as it contains cached previous outputs + driver = driver.RunGenerators(compilation); + GeneratorDriverRunResult runResult = driver.GetRunResult(); + + if (assertOutput) + { + // Run again, using the same driver, with a clone of the compilation + var runResult2 = driver.RunGenerators(clone).GetRunResult(); + + // Compare all the tracked outputs, throw if there's a failure + AssertRunsEqual(runResult, runResult2, trackingNames); + + // verify the second run only generated cached source outputs + var outputs = runResult2 + .Results[0] + .TrackedOutputSteps + .SelectMany(x => x.Value) // step executions + .SelectMany(x => x.Outputs); // execution results + + var output = Assert.Single(outputs); + Assert.Equal(IncrementalStepRunReason.Cached, output.Reason); + } + + return runResult; + } + + private static void AssertRunsEqual( + GeneratorDriverRunResult runResult1, + GeneratorDriverRunResult runResult2, + string[] trackingNames) + { + // We're given all the tracking names, but not all the + // stages will necessarily execute, so extract all the + // output steps, and filter to ones we know about + var trackedSteps1 = GetTrackedSteps(runResult1, trackingNames); + var trackedSteps2 = GetTrackedSteps(runResult2, trackingNames); + + // Both runs should have the same tracked steps + var trackedSteps1Keys = trackedSteps1.Keys.ToHashSet(StringComparer.Ordinal); + Assert.True(trackedSteps1Keys.SetEquals(trackedSteps2.Keys)); + + // Get the IncrementalGeneratorRunStep collection for each run + foreach (var (trackingName, runSteps1) in trackedSteps1) + { + // Assert that both runs produced the same outputs + var runSteps2 = trackedSteps2[trackingName]; + AssertEqual(runSteps1, runSteps2, trackingName); + } + + static Dictionary> GetTrackedSteps( + GeneratorDriverRunResult runResult, string[] trackingNames) + { + return runResult + .Results[0] // We're only running a single generator, so this is safe + .TrackedSteps // Get the pipeline outputs + .Where(step => trackingNames.Contains(step.Key, StringComparer.Ordinal)) + .ToDictionary(x => x.Key, x => x.Value, StringComparer.Ordinal); + } + } + + private static void AssertEqual( + ImmutableArray runSteps1, + ImmutableArray runSteps2, + string stepName) + { + Assert.Equal(runSteps1.Length, runSteps2.Length); + + foreach (var (runStep1, runStep2) in runSteps1.Zip(runSteps2)) + { + // The outputs should be equal between different runs + var outputs1 = runStep1.Outputs.Select(x => x.Value); + var outputs2 = runStep2.Outputs.Select(x => x.Value); + + Assert.True(outputs1.SequenceEqual(outputs2), $"Step {stepName} did not produce cacheable outputs"); + + // Therefore, on the second run the results should always be cached or unchanged! + // - Unchanged is when the _input_ has changed, but the output hasn't + // - Cached is when the the input has not changed, so the cached output is used + Assert.All(runStep2.Outputs, x => Assert.True(x.Reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged)); + + // Make sure we're not using anything we shouldn't + AssertObjectGraph(runStep1); + } + } + + private static void AssertObjectGraph(IncrementalGeneratorRunStep runStep) + { + var visited = new HashSet(); + + // Check all of the outputs - probably overkill, but why not + foreach (var (obj, _) in runStep.Outputs) + { + Visit(obj); + } + + void Visit(object? node) + { + // If we've already seen this object, or it's null, stop. + if (node is null || !visited.Add(node)) + return; + + // Make sure it's not a banned type + Assert.IsNotAssignableFrom(node); + Assert.IsNotAssignableFrom(node); + Assert.IsNotAssignableFrom(node); + + // Examine the object + var type = node.GetType(); + if (type.IsPrimitive || type.IsEnum || type == typeof(string)) + return; + + // If the object is a collection, check each of the values + if (node is IEnumerable collection and not string) + { + foreach (object element in collection) + { + // recursively check each element in the collection + Visit(element); + } + + return; + } + + // Recursively check each field in the object + foreach (var field in type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)) + { + var fieldValue = field.GetValue(node); + Visit(fieldValue); + } + } + } } \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders#MyNamespace.MyGenRowContext.g.verified.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders#MyNamespace.MyGenRowContext.g.verified.cs index efe144d1..faf28a3e 100644 --- a/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders#MyNamespace.MyGenRowContext.g.verified.cs +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders#MyNamespace.MyGenRowContext.g.verified.cs @@ -1,4 +1,4 @@ -//HintName: MyNamespace.MyGenRowContext.g.cs +//HintName: MyNamespace.MyGenRowContext.g.cs // #nullable enable using SpreadCheetah; @@ -32,9 +32,9 @@ private static async ValueTask AddHeaderRow0Async(SpreadCheetah.Spreadsheet spre cells[0] = new StyledCell("First name", styleId); cells[1] = new StyledCell("", styleId); cells[2] = new StyledCell("Nationality (escaped characters \", ', \\)", styleId); - cells[3] = new StyledCell("Address line 1 (escaped characters \r\n, \t)", styleId); - cells[4] = new StyledCell("Address line 2 (verbatim\r\nstring: \", \\)", styleId); - cells[5] = new StyledCell(" Age (\r\n raw\r\n string\r\n literal\r\n )", styleId); + cells[3] = new StyledCell("Address line 1 (escaped characters \n, \t)", styleId); + cells[4] = new StyledCell("Address line 2 (verbatim\nstring: \", \\)", styleId); + cells[5] = new StyledCell(" Age (\n raw\n string\n literal\n )", styleId); cells[6] = new StyledCell("Note (unicode escape sequence 🌉, 👍, ç)", styleId); cells[7] = new StyledCell("Note 2 (constant interpolated string: This is a constant)", styleId); await spreadsheet.AddRowAsync(cells.AsMemory(0, 8), token).ConfigureAwait(false); diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorTests.WorksheetRowGenerator_Generate_CachingCorrectly.verified.txt b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorTests.WorksheetRowGenerator_Generate_CachingCorrectly.verified.txt new file mode 100644 index 00000000..9e7ee9e0 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorTests.WorksheetRowGenerator_Generate_CachingCorrectly.verified.txt @@ -0,0 +1,101 @@ +// +#nullable enable +using SpreadCheetah; +using SpreadCheetah.SourceGeneration; +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace MyNamespace +{ + public partial class MyGenRowContext + { + private static MyGenRowContext? _default; + public static MyGenRowContext Default => _default ??= new MyGenRowContext(); + + public MyGenRowContext() + { + } + + private WorksheetRowTypeInfo? _ClassWithSingleProperty; + public WorksheetRowTypeInfo ClassWithSingleProperty => _ClassWithSingleProperty + ??= WorksheetRowMetadataServices.CreateObjectInfo(AddHeaderRow0Async, AddAsRowAsync, AddRangeAsRowsAsync); + + private static async ValueTask AddHeaderRow0Async(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.Styling.StyleId? styleId, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(1); + try + { + cells[0] = new StyledCell("Name", styleId); + await spreadsheet.AddRowAsync(cells.AsMemory(0, 1), token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + + private static ValueTask AddAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.SourceGenerator.SnapshotTest.Models.ClassWithSingleProperty? obj, CancellationToken token) + { + if (spreadsheet is null) + throw new ArgumentNullException(nameof(spreadsheet)); + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + return AddAsRowInternalAsync(spreadsheet, obj, token); + } + + private static ValueTask AddRangeAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable objs, CancellationToken token) + { + if (spreadsheet is null) + throw new ArgumentNullException(nameof(spreadsheet)); + if (objs is null) + throw new ArgumentNullException(nameof(objs)); + return AddRangeAsRowsInternalAsync(spreadsheet, objs, token); + } + + private static async ValueTask AddAsRowInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.SourceGenerator.SnapshotTest.Models.ClassWithSingleProperty obj, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(1); + try + { + await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + + private static async ValueTask AddRangeAsRowsInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable objs, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(1); + try + { + await AddEnumerableAsRowsAsync(spreadsheet, objs, cells, token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + + private static async ValueTask AddEnumerableAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable objs, DataCell[] cells, CancellationToken token) + { + foreach (var obj in objs) + { + await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false); + } + } + + private static ValueTask AddCellsAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.SourceGenerator.SnapshotTest.Models.ClassWithSingleProperty? obj, DataCell[] cells, CancellationToken token) + { + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + + cells[0] = new DataCell(obj.Name); + return spreadsheet.AddRowAsync(cells.AsMemory(0, 1), token); + } + } +} diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs index cbcdf11b..36e15e1a 100644 --- a/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs @@ -38,6 +38,6 @@ public partial class MyGenRowContext : WorksheetRowContext; """; // Act & Assert - return TestHelper.CompileAndVerify(source); + return TestHelper.CompileAndVerify(source, replaceEscapedLineEndings: true); } } diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorTests.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorTests.cs index 2bd7d85b..a8142165 100644 --- a/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorTests.cs +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorTests.cs @@ -5,6 +5,36 @@ namespace SpreadCheetah.SourceGenerator.SnapshotTest.Tests; public class WorksheetRowGeneratorTests { + [Fact] + public Task WorksheetRowGenerator_Generate_CachingCorrectly() + { + // Arrange + const string source = """ + using SpreadCheetah.SourceGeneration; + using SpreadCheetah.SourceGenerator.SnapshotTest.Models; + using System; + + namespace MyNamespace + { + [WorksheetRow(typeof(ClassWithSingleProperty))] + public partial class MyGenRowContext : WorksheetRowContext + { + } + } + """; + + // Act + var (diagnostics, output) = TestHelper.GetGeneratedTrees(source, ["Transform"]); + + // Assert + Assert.Empty(diagnostics); + var outputSource = Assert.Single(output); + + var settings = new VerifySettings(); + settings.UseDirectory("../Snapshots"); + return Verify(outputSource, settings); + } + [Fact] public Task WorksheetRowGenerator_Generate_ClassWithSingleProperty() { diff --git a/SpreadCheetah.SourceGenerator.Test/Helpers/Backporting/StringExtensions.cs b/SpreadCheetah.SourceGenerator.Test/Helpers/Backporting/StringExtensions.cs new file mode 100644 index 00000000..dda9a1f5 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.Test/Helpers/Backporting/StringExtensions.cs @@ -0,0 +1,29 @@ +using System.Text; + +namespace SpreadCheetah.SourceGenerator.Test.Helpers.Backporting; + +internal static class StringExtensions +{ + public static string ReplaceLineEndings(this string value) + { + if (string.Equals(Environment.NewLine, "\n", StringComparison.Ordinal)) + { +#pragma warning disable CA1307 // Specify StringComparison for clarity + return value.Replace("\r\n", Environment.NewLine); +#pragma warning restore CA1307 // Specify StringComparison for clarity + } + + var parts = value.Split('\n'); + var sb = new StringBuilder(); + + foreach (var part in parts) + { + if (part.Length > 1 && part[^1] == '\r') + sb.Append(part.AsSpan(0, part.Length - 1)); + else + sb.Append(part); + } + + return sb.ToString(); + } +} diff --git a/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs b/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs index 76a566d7..346d4d03 100644 --- a/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs +++ b/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs @@ -2,6 +2,7 @@ using DocumentFormat.OpenXml.Spreadsheet; using SpreadCheetah.SourceGeneration; using SpreadCheetah.SourceGenerator.Test.Helpers; +using SpreadCheetah.SourceGenerator.Test.Helpers.Backporting; using SpreadCheetah.SourceGenerator.Test.Models; using SpreadCheetah.SourceGenerator.Test.Models.Accessibility; using SpreadCheetah.SourceGenerator.Test.Models.ColumnHeader; @@ -596,7 +597,7 @@ public async Task Spreadsheet_AddHeaderRow_SpecialCharacterColumnHeaders() // Assert using var sheet = SpreadsheetAssert.SingleSheet(stream); - Assert.Equal(expectedValues, sheet.Row(1).Select(x => x.StringValue)); + Assert.Equal(expectedValues.Select(x => x.ReplaceLineEndings()), sheet.Row(1).Select(x => x.StringValue?.ReplaceLineEndings())); } [Fact] diff --git a/SpreadCheetah.SourceGenerator/Extensions/AttributeDataExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/AttributeDataExtensions.cs new file mode 100644 index 00000000..c59ecabd --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Extensions/AttributeDataExtensions.cs @@ -0,0 +1,102 @@ +using Microsoft.CodeAnalysis; +using SpreadCheetah.SourceGenerator.Helpers; +using SpreadCheetah.SourceGenerator.Models; +using System.Diagnostics.CodeAnalysis; + +namespace SpreadCheetah.SourceGenerator.Extensions; + +internal static class AttributeDataExtensions +{ + public static bool TryParseWorksheetRowAttribute( + this AttributeData attribute, + CancellationToken token, + [NotNullWhen(true)] out INamedTypeSymbol? typeSymbol, + [NotNullWhen(true)] out Location? location) + { + typeSymbol = null; + location = null; + + var args = attribute.ConstructorArguments; + if (args is not [{ Value: INamedTypeSymbol symbol }]) + return false; + + if (symbol.Kind == SymbolKind.ErrorType) + return false; + + var syntaxReference = attribute.ApplicationSyntaxReference; + if (syntaxReference is null) + return false; + + location = syntaxReference.GetSyntax(token).GetLocation(); + typeSymbol = symbol; + return true; + } + + public static bool TryParseOptionsAttribute( + this AttributeData attribute, + [NotNullWhen(true)] out GeneratorOptions? options) + { + options = null; + + if (!string.Equals(Attributes.GenerationOptions, attribute.AttributeClass?.ToDisplayString(), StringComparison.Ordinal)) + return false; + + if (attribute.NamedArguments.IsDefaultOrEmpty) + return false; + + foreach (var (key, value) in attribute.NamedArguments) + { + if (!string.Equals(key, "SuppressWarnings", StringComparison.Ordinal)) + continue; + + if (value.Value is bool suppressWarnings) + { + options = new GeneratorOptions(suppressWarnings); + return true; + } + } + + return false; + } + + public static bool TryParseColumnHeaderAttribute( + this AttributeData attribute, + out TypedConstant attributeArg) + { + attributeArg = default; + + if (!string.Equals(Attributes.ColumnHeader, attribute.AttributeClass?.ToDisplayString(), StringComparison.Ordinal)) + return false; + + var args = attribute.ConstructorArguments; + if (args is not [{ Value: string } arg]) + return false; + + attributeArg = arg; + return true; + } + + public static bool TryParseColumnOrderAttribute( + this AttributeData attribute, + CancellationToken token, + [NotNullWhen(true)] out ColumnOrder? order) + { + order = null; + + if (!string.Equals(Attributes.ColumnOrder, attribute.AttributeClass?.ToDisplayString(), StringComparison.Ordinal)) + return false; + + var args = attribute.ConstructorArguments; + if (args is not [{ Value: int attributeValue }]) + return false; + + var location = attribute + .ApplicationSyntaxReference? + .GetSyntax(token) + .GetLocation() + .ToLocationInfo(); + + order = new ColumnOrder(attributeValue, location); + return true; + } +} diff --git a/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs deleted file mode 100644 index 17d3acad..00000000 --- a/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs +++ /dev/null @@ -1,43 +0,0 @@ -using Microsoft.CodeAnalysis; -using SpreadCheetah.SourceGenerator.Models; -using System.Diagnostics.CodeAnalysis; - -namespace SpreadCheetah.SourceGenerator.Extensions; - -internal static class CompilationExtensions -{ - [ExcludeFromCodeCoverage] - public static bool TryGetCompilationTypes( - this Compilation compilation, - [NotNullWhen(true)] out CompilationTypes? result) - { - result = null; - const string ns = "SpreadCheetah.SourceGeneration"; - - if (!compilation.TryGetType($"{ns}.ColumnHeaderAttribute", out var columnHeader)) - return false; - if (!compilation.TryGetType($"{ns}.ColumnOrderAttribute", out var columnOrder)) - return false; - if (!compilation.TryGetType($"{ns}.WorksheetRowContext", out var context)) - return false; - if (!compilation.TryGetType($"{ns}.WorksheetRowGenerationOptionsAttribute", out var options)) - return false; - - result = new CompilationTypes( - ColumnHeaderAttribute: columnHeader, - ColumnOrderAttribute: columnOrder, - WorksheetRowContext: context, - WorksheetRowGenerationOptionsAttribute: options); - - return true; - } - - private static bool TryGetType( - this Compilation compilation, - string fullyQualifiedMetadataName, - [NotNullWhen(true)] out INamedTypeSymbol? result) - { - result = compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); - return result is not null; - } -} diff --git a/SpreadCheetah.SourceGenerator/Extensions/EnumerableExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/EnumerableExtensions.cs new file mode 100644 index 00000000..e9bfe177 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Extensions/EnumerableExtensions.cs @@ -0,0 +1,18 @@ +using SpreadCheetah.SourceGenerator.Helpers; + +namespace SpreadCheetah.SourceGenerator.Extensions; + +internal static class EnumerableExtensions +{ + public static IEnumerable<(int Index, T Element)> Index(this IEnumerable elements) + { + return elements.Select((x, i) => (i, x)); + } + + public static EquatableArray ToEquatableArray(this IEnumerable elements) + where T : IEquatable + { + var array = elements is T[] arr ? arr : elements.ToArray(); + return new EquatableArray(array); + } +} diff --git a/SpreadCheetah.SourceGenerator/Extensions/SymbolExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/SymbolExtensions.cs new file mode 100644 index 00000000..08fb0f71 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Extensions/SymbolExtensions.cs @@ -0,0 +1,73 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using SpreadCheetah.SourceGenerator.Models; +using System.Diagnostics.CodeAnalysis; + +namespace SpreadCheetah.SourceGenerator.Extensions; + +internal static class SymbolExtensions +{ + public static bool IsSupportedType(this ITypeSymbol type) + { + return type.SpecialType switch + { + SpecialType.System_Boolean => true, + SpecialType.System_DateTime => true, + SpecialType.System_Decimal => true, + SpecialType.System_Double => true, + SpecialType.System_Int32 => true, + SpecialType.System_Int64 => true, + SpecialType.System_Single => true, + SpecialType.System_String => true, + _ => type.IsSupportedNullableType(), + }; + } + + private static bool IsSupportedNullableType(this ITypeSymbol type) + { + if (type.NullableAnnotation != NullableAnnotation.Annotated) + return false; + + return type.ToDisplayString() switch + { + "bool?" => true, + "decimal?" => true, + "double?" => true, + "float?" => true, + "int?" => true, + "long?" => true, + "System.DateTime?" => true, + _ => false, + }; + } + + public static bool IsPropertyWithPublicGetter( + this ISymbol symbol, + [NotNullWhen(true)] out IPropertySymbol? property) + { + if (symbol is IPropertySymbol + { + DeclaredAccessibility: Accessibility.Public, + IsStatic: false, + IsWriteOnly: false + } p) + { + property = p; + return true; + } + + property = null; + return false; + } + + public static RowTypeProperty ToRowTypeProperty( + this IPropertySymbol p, + TypedConstant? columnHeaderAttributeValue) + { + var columnHeader = columnHeaderAttributeValue?.ToCSharpString() ?? @$"""{p.Name}"""; + + return new RowTypeProperty( + ColumnHeader: columnHeader, + Name: p.Name); + } +} diff --git a/SpreadCheetah.SourceGenerator/Helpers/Attributes.cs b/SpreadCheetah.SourceGenerator/Helpers/Attributes.cs new file mode 100644 index 00000000..3d7e9669 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/Attributes.cs @@ -0,0 +1,8 @@ +namespace SpreadCheetah.SourceGenerator.Helpers; + +internal static class Attributes +{ + public const string ColumnHeader = "SpreadCheetah.SourceGeneration.ColumnHeaderAttribute"; + public const string ColumnOrder = "SpreadCheetah.SourceGeneration.ColumnOrderAttribute"; + public const string GenerationOptions = "SpreadCheetah.SourceGeneration.WorksheetRowGenerationOptionsAttribute"; +} diff --git a/SpreadCheetah.SourceGenerator/Helpers/ContextClass.cs b/SpreadCheetah.SourceGenerator/Helpers/ContextClass.cs index 51629ef7..0110059f 100644 --- a/SpreadCheetah.SourceGenerator/Helpers/ContextClass.cs +++ b/SpreadCheetah.SourceGenerator/Helpers/ContextClass.cs @@ -4,7 +4,8 @@ namespace SpreadCheetah.SourceGenerator.Helpers; internal sealed record ContextClass( - ITypeSymbol ContextClassType, - Dictionary RowTypes, - CompilationTypes CompilationTypes, + string Name, + Accessibility DeclaredAccessibility, + string? Namespace, + EquatableArray RowTypes, GeneratorOptions? Options); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Helpers/DiagnosticMap.cs b/SpreadCheetah.SourceGenerator/Helpers/DiagnosticMap.cs new file mode 100644 index 00000000..25d1abf2 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/DiagnosticMap.cs @@ -0,0 +1,12 @@ +using Microsoft.CodeAnalysis; +using SpreadCheetah.SourceGenerator.Models; + +namespace SpreadCheetah.SourceGenerator.Helpers; + +internal static class DiagnosticMap +{ + public static Diagnostic ToDiagnostic(this DiagnosticInfo info) + { + return Diagnostic.Create(info.Descriptor, info.Location?.ToLocation(), info.MessageArgs.GetArray()); + } +} diff --git a/SpreadCheetah.SourceGenerator/Helpers/EquatableArray.cs b/SpreadCheetah.SourceGenerator/Helpers/EquatableArray.cs new file mode 100644 index 00000000..4efa817a --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/EquatableArray.cs @@ -0,0 +1,63 @@ +using System.Collections; +using System.Diagnostics.CodeAnalysis; + +namespace SpreadCheetah.SourceGenerator.Helpers; + +/// +/// Based on the implementation from: +/// https://github.com/CommunityToolkit/dotnet/blob/main/src/CommunityToolkit.Mvvm.SourceGenerators/Helpers/EquatableArray%7BT%7D.cs +/// +[ExcludeFromCodeCoverage] +internal readonly struct EquatableArray(T[] underlyingArray) + : IEquatable>, IReadOnlyCollection + where T : IEquatable +{ + public static readonly EquatableArray Empty = new([]); + + private readonly T[]? _array = underlyingArray; + + public bool Equals(EquatableArray array) + { + return AsSpan().SequenceEqual(array.AsSpan()); + } + + public override bool Equals(object? obj) + { + return obj is EquatableArray array && Equals(this, array); + } + + public override int GetHashCode() + { + if (_array is not T[] array) + { + return 0; + } + + HashCode hashCode = default; + + foreach (T item in array) + { + hashCode.Add(item); + } + + return hashCode.ToHashCode(); + } + + public ReadOnlySpan AsSpan() => _array.AsSpan(); + public T[]? GetArray() => _array; + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)(_array ?? [])).GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)(_array ?? [])).GetEnumerator(); + } + + public int Count => _array?.Length ?? 0; + + public static bool operator ==(EquatableArray left, EquatableArray right) => left.Equals(right); + public static bool operator !=(EquatableArray left, EquatableArray right) => !left.Equals(right); +} \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Helpers/HashCode.cs b/SpreadCheetah.SourceGenerator/Helpers/HashCode.cs new file mode 100644 index 00000000..5435febb --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/HashCode.cs @@ -0,0 +1,191 @@ +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +namespace SpreadCheetah.SourceGenerator.Helpers; + +/// +/// Based on the implementation from: +/// https://github.com/CommunityToolkit/dotnet/blob/7b53ae23dfc6a7fb12d0fc058b89b6e948f48448/src/CommunityToolkit.Mvvm.SourceGenerators/Helpers/HashCode.cs +/// +[StructLayout(LayoutKind.Auto)] +[ExcludeFromCodeCoverage] +#pragma warning disable CA1066 // Implement IEquatable when overriding Object.Equals +internal struct HashCode +#pragma warning restore CA1066 // Implement IEquatable when overriding Object.Equals +{ + private const uint Prime1 = 2654435761U; + private const uint Prime2 = 2246822519U; + private const uint Prime3 = 3266489917U; + private const uint Prime4 = 668265263U; + private const uint Prime5 = 374761393U; + + private static readonly uint seed = GenerateGlobalSeed(); + + private uint v1, v2, v3, v4; + private uint queue1, queue2, queue3; + private uint length; + + /// + /// Initializes the default seed. + /// + /// A random seed. + private static uint GenerateGlobalSeed() + { + byte[] bytes = new byte[4]; + + using (RandomNumberGenerator generator = RandomNumberGenerator.Create()) + { + generator.GetBytes(bytes); + } + + return BitConverter.ToUInt32(bytes, 0); + } + + /// + /// Adds a single value to the current hash. + /// + /// The type of the value to add into the hash code. + /// The value to add into the hash code. + public void Add(T value) + { + Add(value?.GetHashCode() ?? 0); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Initialize(out uint v1, out uint v2, out uint v3, out uint v4) + { + v1 = seed + Prime1 + Prime2; + v2 = seed + Prime2; + v3 = seed; + v4 = seed - Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Round(uint hash, uint input) + { + return RotateLeft(hash + input * Prime2, 13) * Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint QueueRound(uint hash, uint queuedValue) + { + return RotateLeft(hash + queuedValue * Prime3, 17) * Prime4; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixState(uint v1, uint v2, uint v3, uint v4) + { + return RotateLeft(v1, 1) + RotateLeft(v2, 7) + RotateLeft(v3, 12) + RotateLeft(v4, 18); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixEmptyState() + { + return seed + Prime5; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixFinal(uint hash) + { + hash ^= hash >> 15; + hash *= Prime2; + hash ^= hash >> 13; + hash *= Prime3; + hash ^= hash >> 16; + + return hash; + } + + private void Add(int value) + { + uint val = (uint)value; + uint previousLength = length++; + uint position = previousLength % 4; + + if (position == 0) + { + queue1 = val; + } + else if (position == 1) + { + queue2 = val; + } + else if (position == 2) + { + queue3 = val; + } + else + { + if (previousLength == 3) + { + Initialize(out v1, out v2, out v3, out v4); + } + + v1 = Round(v1, queue1); + v2 = Round(v2, queue2); + v3 = Round(v3, queue3); + v4 = Round(v4, val); + } + } + + /// + /// Gets the resulting hashcode from the current instance. + /// + /// The resulting hashcode from the current instance. + public readonly int ToHashCode() + { + uint len = this.length; + uint position = len % 4; + uint hash = len < 4 ? MixEmptyState() : MixState(v1, v2, v3, v4); + + hash += len * 4; + + if (position > 0) + { + hash = QueueRound(hash, queue1); + + if (position > 1) + { + hash = QueueRound(hash, queue2); + + if (position > 2) + { + hash = QueueRound(hash, queue3); + } + } + } + + hash = MixFinal(hash); + + return (int)hash; + } + +#pragma warning disable CS0809 // Obsolete member overrides non-obsolete member +#pragma warning disable S3877 // Exceptions should not be thrown from unexpected methods + [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes. Use ToHashCode to retrieve the computed hash code.", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public override readonly int GetHashCode() => throw new NotSupportedException(); + + [Obsolete("HashCode is a mutable struct and should not be compared with other HashCodes.", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] + public override readonly bool Equals(object? obj) => throw new NotSupportedException(); +#pragma warning restore CS0809 // Obsolete member overrides non-obsolete member +#pragma warning restore S3877 // Exceptions should not be thrown from unexpected methods + + /// + /// Rotates the specified value left by the specified number of bits. + /// Similar in behavior to the x86 instruction ROL. + /// + /// The value to rotate. + /// The number of bits to rotate by. + /// Any value outside the range [0..31] is treated as congruent mod 32. + /// The rotated value. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint RotateLeft(uint value, int offset) + { + return (value << offset) | (value >> (32 - offset)); + } +} \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Helpers/LocationMap.cs b/SpreadCheetah.SourceGenerator/Helpers/LocationMap.cs new file mode 100644 index 00000000..a0aabce8 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/LocationMap.cs @@ -0,0 +1,20 @@ +using Microsoft.CodeAnalysis; +using SpreadCheetah.SourceGenerator.Models; + +namespace SpreadCheetah.SourceGenerator.Helpers; + +internal static class LocationMap +{ + public static Location ToLocation(this LocationInfo info) + { + return Location.Create(info.FilePath, info.TextSpan, info.LineSpan); + } + + public static LocationInfo? ToLocationInfo(this Location location) + { + if (location.SourceTree is null) + return null; + + return new LocationInfo(location.SourceTree.FilePath, location.SourceSpan, location.GetLineSpan().Span); + } +} diff --git a/SpreadCheetah.SourceGenerator/Helpers/StringBuilderExtensions.cs b/SpreadCheetah.SourceGenerator/Helpers/StringBuilderExtensions.cs deleted file mode 100644 index 46c70784..00000000 --- a/SpreadCheetah.SourceGenerator/Helpers/StringBuilderExtensions.cs +++ /dev/null @@ -1,30 +0,0 @@ -using Microsoft.CodeAnalysis; -using System.Text; - -namespace SpreadCheetah.SourceGenerator.Helpers; - -internal static class StringBuilderExtensions -{ - public static StringBuilder AppendIndentation(this StringBuilder sb, int indentationLevel) => indentationLevel switch - { - <= 0 => sb, - 1 => sb.Append(" "), - 2 => sb.Append(" "), - 3 => sb.Append(" "), - 4 => sb.Append(" "), - 5 => sb.Append(" "), - 6 => sb.Append(" "), - _ => sb.Append(new string(' ', 4 * indentationLevel)) - }; - - public static StringBuilder AppendLine(this StringBuilder sb, int indentationLevel, string value) - { - return sb.AppendIndentation(indentationLevel).AppendLine(value); - } - - public static StringBuilder AppendType(this StringBuilder sb, INamedTypeSymbol symbol) - { - sb.Append(symbol); - return symbol.IsReferenceType ? sb.Append('?') : sb; - } -} diff --git a/SpreadCheetah.SourceGenerator/Helpers/SymbolExtensions.cs b/SpreadCheetah.SourceGenerator/Helpers/SymbolExtensions.cs deleted file mode 100644 index 21cd0704..00000000 --- a/SpreadCheetah.SourceGenerator/Helpers/SymbolExtensions.cs +++ /dev/null @@ -1,12 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace SpreadCheetah.SourceGenerator.Helpers; - -internal static class SymbolExtensions -{ - public static string ToTypeString(this INamedTypeSymbol symbol) - { - var result = symbol.ToString(); - return symbol.IsReferenceType ? result + '?' : result; - } -} diff --git a/SpreadCheetah.SourceGenerator/Helpers/TrackingNames.cs b/SpreadCheetah.SourceGenerator/Helpers/TrackingNames.cs new file mode 100644 index 00000000..97d7c811 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Helpers/TrackingNames.cs @@ -0,0 +1,6 @@ +namespace SpreadCheetah.SourceGenerator.Helpers; + +internal static class TrackingNames +{ + public const string Transform = nameof(Transform); +} diff --git a/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs b/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs deleted file mode 100644 index 45c00034..00000000 --- a/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs +++ /dev/null @@ -1,7 +0,0 @@ -using Microsoft.CodeAnalysis; -using SpreadCheetah.SourceGenerator.Models; - -namespace SpreadCheetah.SourceGenerator.Helpers; -internal sealed record TypePropertiesInfo( - SortedDictionary Properties, - List UnsupportedProperties); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/ColumnOrder.cs b/SpreadCheetah.SourceGenerator/Models/ColumnOrder.cs new file mode 100644 index 00000000..7af07976 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/ColumnOrder.cs @@ -0,0 +1,3 @@ +namespace SpreadCheetah.SourceGenerator.Models; + +internal readonly record struct ColumnOrder(int Value, LocationInfo? Location); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs b/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs deleted file mode 100644 index f918cb08..00000000 --- a/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace SpreadCheetah.SourceGenerator.Models; - -internal sealed record ColumnProperty(string PropertyName, string ColumnHeader); diff --git a/SpreadCheetah.SourceGenerator/Models/CompilationTypes.cs b/SpreadCheetah.SourceGenerator/Models/CompilationTypes.cs deleted file mode 100644 index 8b76253b..00000000 --- a/SpreadCheetah.SourceGenerator/Models/CompilationTypes.cs +++ /dev/null @@ -1,9 +0,0 @@ -using Microsoft.CodeAnalysis; - -namespace SpreadCheetah.SourceGenerator.Models; - -internal sealed record CompilationTypes( - INamedTypeSymbol WorksheetRowGenerationOptionsAttribute, - INamedTypeSymbol ColumnHeaderAttribute, - INamedTypeSymbol ColumnOrderAttribute, - INamedTypeSymbol WorksheetRowContext); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/DiagnosticInfo.cs b/SpreadCheetah.SourceGenerator/Models/DiagnosticInfo.cs new file mode 100644 index 00000000..624f514d --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/DiagnosticInfo.cs @@ -0,0 +1,9 @@ +using Microsoft.CodeAnalysis; +using SpreadCheetah.SourceGenerator.Helpers; + +namespace SpreadCheetah.SourceGenerator.Models; + +internal sealed record DiagnosticInfo( + DiagnosticDescriptor Descriptor, + LocationInfo? Location, + EquatableArray MessageArgs); diff --git a/SpreadCheetah.SourceGenerator/Models/LocationInfo.cs b/SpreadCheetah.SourceGenerator/Models/LocationInfo.cs new file mode 100644 index 00000000..d7e8b467 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/LocationInfo.cs @@ -0,0 +1,8 @@ +using Microsoft.CodeAnalysis.Text; + +namespace SpreadCheetah.SourceGenerator.Models; + +internal sealed record LocationInfo( + string FilePath, + TextSpan TextSpan, + LinePositionSpan LineSpan); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/RowType.cs b/SpreadCheetah.SourceGenerator/Models/RowType.cs new file mode 100644 index 00000000..aa7dac06 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/RowType.cs @@ -0,0 +1,15 @@ +using SpreadCheetah.SourceGenerator.Helpers; + +namespace SpreadCheetah.SourceGenerator.Models; + +internal sealed record RowType( + string Name, + string FullName, + bool IsReferenceType, + LocationInfo? WorksheetRowAttributeLocation, + EquatableArray Properties, + EquatableArray UnsupportedPropertyTypeNames, + EquatableArray DiagnosticInfos) +{ + public string FullNameWithNullableAnnotation => IsReferenceType ? $"{FullName}?" : FullName; +} \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/RowTypeProperty.cs b/SpreadCheetah.SourceGenerator/Models/RowTypeProperty.cs new file mode 100644 index 00000000..ad54e740 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/RowTypeProperty.cs @@ -0,0 +1,5 @@ +namespace SpreadCheetah.SourceGenerator.Models; + +internal sealed record RowTypeProperty( + string Name, + string ColumnHeader); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs b/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs index 51b090d4..1d05d95f 100644 --- a/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs +++ b/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs @@ -5,9 +5,7 @@ using SpreadCheetah.SourceGenerator.Extensions; using SpreadCheetah.SourceGenerator.Helpers; using SpreadCheetah.SourceGenerator.Models; -using System.Collections.Immutable; using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; using System.Text; namespace SpreadCheetah.SourceGenerators; @@ -17,17 +15,15 @@ public class WorksheetRowGenerator : IIncrementalGenerator { public void Initialize(IncrementalGeneratorInitializationContext context) { - var filtered = context.SyntaxProvider + var contextClasses = context.SyntaxProvider .ForAttributeWithMetadataName( "SpreadCheetah.SourceGeneration.WorksheetRowAttribute", IsSyntaxTargetForGeneration, GetSemanticTargetForGeneration) .Where(static x => x is not null) - .Collect(); + .WithTrackingName(TrackingNames.Transform); - var source = context.CompilationProvider.Combine(filtered); - - context.RegisterSourceOutput(source, static (spc, source) => Execute(source.Left, source.Right, spc)); + context.RegisterSourceOutput(contextClasses, static (spc, source) => Execute(source, spc)); } private static bool IsSyntaxTargetForGeneration(SyntaxNode syntaxNode, CancellationToken _) => syntaxNode is ClassDeclarationSyntax @@ -47,449 +43,321 @@ private static bool IsSyntaxTargetForGeneration(SyntaxNode syntaxNode, Cancellat if (classSymbol is not { IsStatic: false, BaseType: { } baseType }) return null; - if (!context.SemanticModel.Compilation.TryGetCompilationTypes(out var compilationTypes)) + if (!string.Equals("SpreadCheetah.SourceGeneration.WorksheetRowContext", baseType.ToDisplayString(), StringComparison.Ordinal)) return null; - if (!SymbolEqualityComparer.Default.Equals(compilationTypes.WorksheetRowContext, baseType)) - return null; + var rowTypes = new List(); - var rowTypes = new Dictionary(SymbolEqualityComparer.Default); - GeneratorOptions? generatorOptions = null; - - foreach (var worksheetRowAttribute in context.Attributes) + foreach (var attribute in context.Attributes) { - if (TryParseWorksheetRowAttribute(worksheetRowAttribute, token, out var typeSymbol, out var location) - && !rowTypes.ContainsKey(typeSymbol)) - { - rowTypes[typeSymbol] = location; - } + if (!attribute.TryParseWorksheetRowAttribute(token, out var typeSymbol, out var location)) + continue; + + var rowType = AnalyzeTypeProperties(typeSymbol, location.ToLocationInfo(), token); + if (!rowTypes.Exists(x => string.Equals(x.FullName, rowType.FullName, StringComparison.Ordinal))) + rowTypes.Add(rowType); } if (rowTypes.Count == 0) return null; + GeneratorOptions? generatorOptions = null; + foreach (var attribute in classSymbol.GetAttributes()) { - if (TryParseOptionsAttribute(attribute, compilationTypes.WorksheetRowGenerationOptionsAttribute, out var options)) + if (attribute.TryParseOptionsAttribute(out var options)) + { generatorOptions = options; + break; + } } - return new ContextClass(classSymbol, rowTypes, compilationTypes, generatorOptions); - } - - private static bool TryParseWorksheetRowAttribute( - AttributeData attribute, - CancellationToken token, - [NotNullWhen(true)] out INamedTypeSymbol? typeSymbol, - [NotNullWhen(true)] out Location? location) - { - typeSymbol = null; - location = null; - - var args = attribute.ConstructorArguments; - if (args is not [{ Value: INamedTypeSymbol symbol }]) - return false; - - if (symbol.Kind == SymbolKind.ErrorType) - return false; - - var syntaxReference = attribute.ApplicationSyntaxReference; - if (syntaxReference is null) - return false; - - location = syntaxReference.GetSyntax(token).GetLocation(); - typeSymbol = symbol; - return true; + return new ContextClass( + DeclaredAccessibility: classSymbol.DeclaredAccessibility, + Namespace: classSymbol.ContainingNamespace is { IsGlobalNamespace: false } ns ? ns.ToString() : null, + Name: classSymbol.Name, + RowTypes: rowTypes.ToEquatableArray(), + Options: generatorOptions); } - private static bool TryParseOptionsAttribute( - AttributeData attribute, - INamedTypeSymbol expectedAttribute, - [NotNullWhen(true)] out GeneratorOptions? options) + private static RowType AnalyzeTypeProperties(ITypeSymbol classType, LocationInfo? worksheetRowAttributeLocation, CancellationToken token) { - options = null; + var implicitOrderProperties = new List(); + var explicitOrderProperties = new SortedDictionary(); + var unsupportedPropertyTypeNames = new HashSet(StringComparer.Ordinal); + var diagnosticInfos = new List(); - if (!SymbolEqualityComparer.Default.Equals(expectedAttribute, attribute.AttributeClass)) - return false; - - if (attribute.NamedArguments.IsDefaultOrEmpty) - return false; - - foreach (var (key, value) in attribute.NamedArguments) + foreach (var member in classType.GetMembers()) { - if (!string.Equals(key, "SuppressWarnings", StringComparison.Ordinal)) + if (!member.IsPropertyWithPublicGetter(out var p)) continue; - if (value.Value is bool suppressWarnings) + if (!p.Type.IsSupportedType()) { - options = new GeneratorOptions(suppressWarnings); - return true; + unsupportedPropertyTypeNames.Add(p.Type.Name); + continue; } - } - - return false; - } - - private static bool TryParseColumnHeaderAttribute( - AttributeData attribute, - INamedTypeSymbol expectedAttribute, - out TypedConstant attributeArg) - { - attributeArg = default; - - if (!SymbolEqualityComparer.Default.Equals(expectedAttribute, attribute.AttributeClass)) - return false; - - var args = attribute.ConstructorArguments; - if (args is not [{ Value: string } arg]) - return false; - - attributeArg = arg; - return true; - } - - private static bool TryParseColumnOrderAttribute( - AttributeData attribute, - INamedTypeSymbol expectedAttribute, - out int order) - { - order = 0; - - if (!SymbolEqualityComparer.Default.Equals(expectedAttribute, attribute.AttributeClass)) - return false; - - var args = attribute.ConstructorArguments; - if (args is not [{ Value: int attributeValue }]) - return false; - - order = attributeValue; - return true; - } - private static TypePropertiesInfo AnalyzeTypeProperties(Compilation compilation, CompilationTypes compilationTypes, - ITypeSymbol classType, SourceProductionContext context) - { - var implicitOrderProperties = new List(); - var explicitOrderProperties = new SortedDictionary(); - var unsupportedPropertyNames = new List(); + TypedConstant? columnHeaderAttributeValue = null; + ColumnOrder? columnOrder = null; - foreach (var member in classType.GetMembers()) - { - if (member is not IPropertySymbol - { - DeclaredAccessibility: Accessibility.Public, - IsStatic: false, - IsWriteOnly: false - } p) + foreach (var attribute in p.GetAttributes()) { - continue; - } + if (columnHeaderAttributeValue is null && attribute.TryParseColumnHeaderAttribute(out var arg)) + columnHeaderAttributeValue = arg; - if (!IsSupportedType(p.Type, compilation)) - { - unsupportedPropertyNames.Add(p); - continue; + if (columnOrder is null && attribute.TryParseColumnOrderAttribute(token, out var orderArg)) + columnOrder = orderArg; } - var columnHeader = GetColumnHeader(p, compilationTypes.ColumnHeaderAttribute); - var columnProperty = new ColumnProperty(p.Name, columnHeader); + var rowTypeProperty = p.ToRowTypeProperty(columnHeaderAttributeValue); - if (!TryGetExplicitColumnOrder(p, compilationTypes.ColumnOrderAttribute, context.CancellationToken, out var columnOrder, out var location)) - implicitOrderProperties.Add(columnProperty); - else if (!explicitOrderProperties.ContainsKey(columnOrder)) - explicitOrderProperties.Add(columnOrder, columnProperty); + if (columnOrder is not { } order) + implicitOrderProperties.Add(rowTypeProperty); + else if (!explicitOrderProperties.ContainsKey(order.Value)) + explicitOrderProperties.Add(order.Value, rowTypeProperty); else - context.ReportDiagnostic(Diagnostic.Create(Diagnostics.DuplicateColumnOrder, location, classType.Name)); + diagnosticInfos.Add(new DiagnosticInfo(Diagnostics.DuplicateColumnOrder, order.Location, new([classType.Name]))); } explicitOrderProperties.AddWithImplicitKeys(implicitOrderProperties); - return new TypePropertiesInfo(explicitOrderProperties, unsupportedPropertyNames); - } - - private static string GetColumnHeader(IPropertySymbol property, INamedTypeSymbol columnHeaderAttribute) - { - foreach (var attribute in property.GetAttributes()) - { - if (TryParseColumnHeaderAttribute(attribute, columnHeaderAttribute, out var arg)) - return arg.ToCSharpString(); - } - - return @$"""{property.Name}"""; - } - - private static bool TryGetExplicitColumnOrder(IPropertySymbol property, INamedTypeSymbol columnOrderAttribute, - CancellationToken token, out int columnOrder, out Location? location) - { - columnOrder = 0; - location = null; - - foreach (var attribute in property.GetAttributes()) - { - if (!TryParseColumnOrderAttribute(attribute, columnOrderAttribute, out columnOrder)) - continue; - - location = attribute.ApplicationSyntaxReference?.GetSyntax(token).GetLocation(); - return true; - } - - return false; - } - - private static bool IsSupportedType(ITypeSymbol type, Compilation compilation) - { - return type.SpecialType == SpecialType.System_String - || SupportedPrimitiveTypes.Contains(type.SpecialType) - || IsSupportedNullableType(compilation, type); - } - - private static bool IsSupportedNullableType(Compilation compilation, ITypeSymbol type) - { - if (type.NullableAnnotation != NullableAnnotation.Annotated) - return false; - - var nullableT = compilation.GetTypeByMetadataName("System.Nullable`1"); - - foreach (var primitiveType in SupportedPrimitiveTypes) - { - var nullableType = nullableT?.Construct(compilation.GetSpecialType(primitiveType)); - if (nullableType is null) - continue; - - if (nullableType.Equals(type, SymbolEqualityComparer.Default)) - return true; - } - - return false; + return new RowType( + DiagnosticInfos: diagnosticInfos.ToEquatableArray(), + FullName: classType.ToString(), + IsReferenceType: classType.IsReferenceType, + Name: classType.Name, + Properties: explicitOrderProperties.Values.ToEquatableArray(), + UnsupportedPropertyTypeNames: unsupportedPropertyTypeNames.ToEquatableArray(), + WorksheetRowAttributeLocation: worksheetRowAttributeLocation); } - private static readonly SpecialType[] SupportedPrimitiveTypes = - [ - SpecialType.System_Boolean, - SpecialType.System_DateTime, - SpecialType.System_Decimal, - SpecialType.System_Double, - SpecialType.System_Int32, - SpecialType.System_Int64, - SpecialType.System_Single - ]; - - private static void Execute(Compilation compilation, ImmutableArray classes, SourceProductionContext context) + private static void Execute(ContextClass? contextClass, SourceProductionContext context) { - if (classes.IsDefaultOrEmpty) + if (contextClass is null) return; var sb = new StringBuilder(); + GenerateCode(sb, contextClass, context); - foreach (var item in classes) - { - if (item is null) continue; - - context.CancellationToken.ThrowIfCancellationRequested(); + var hintName = contextClass.Namespace is { } ns + ? $"{ns}.{contextClass.Name}.g.cs" + : $"{contextClass.Name}.g.cs"; - sb.Clear(); - GenerateCode(sb, item, compilation, context); - context.AddSource($"{item.ContextClassType}.g.cs", sb.ToString()); - } + context.AddSource(hintName, sb.ToString()); } private static void GenerateHeader(StringBuilder sb) { - sb.AppendLine("// "); - sb.AppendLine("#nullable enable"); - sb.AppendLine("using SpreadCheetah;"); - sb.AppendLine("using SpreadCheetah.SourceGeneration;"); - sb.AppendLine("using System;"); - sb.AppendLine("using System.Buffers;"); - sb.AppendLine("using System.Collections.Generic;"); - sb.AppendLine("using System.Threading;"); - sb.AppendLine("using System.Threading.Tasks;"); - sb.AppendLine(); + sb.AppendLine(""" + // + #nullable enable + using SpreadCheetah; + using SpreadCheetah.SourceGeneration; + using System; + using System.Buffers; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + """); } - private static void GenerateCode(StringBuilder sb, ContextClass contextClass, Compilation compilation, SourceProductionContext context) + private static void GenerateCode(StringBuilder sb, ContextClass contextClass, SourceProductionContext context) { GenerateHeader(sb); - var contextType = contextClass.ContextClassType; - var contextTypeNamespace = contextType.ContainingNamespace; - if (contextTypeNamespace is { IsGlobalNamespace: false }) - sb.AppendLine($"namespace {contextTypeNamespace}"); + if (contextClass.Namespace is { } ns) + sb.Append("namespace ").AppendLine(ns); - var accessibility = SyntaxFacts.GetText(contextType.DeclaredAccessibility); + var accessibility = SyntaxFacts.GetText(contextClass.DeclaredAccessibility); - sb.AppendLine("{"); - sb.AppendLine($" {accessibility} partial class {contextType.Name}"); - sb.AppendLine(" {"); - sb.AppendLine($" private static {contextType.Name}? _default;"); - sb.AppendLine($" public static {contextType.Name} Default => _default ??= new {contextType.Name}();"); - sb.AppendLine(); - sb.AppendLine($" public {contextType.Name}()"); - sb.AppendLine(" {"); - sb.AppendLine(" }"); + sb.AppendLine($$""" + { + {{accessibility}} partial class {{contextClass.Name}} + { + private static {{contextClass.Name}}? _default; + public static {{contextClass.Name}} Default => _default ??= new {{contextClass.Name}}(); + + public {{contextClass.Name}}() + { + } + """); var rowTypeNames = new HashSet(StringComparer.Ordinal); var typeIndex = 0; - foreach (var (rowType, location) in contextClass.RowTypes) + foreach (var rowType in contextClass.RowTypes) { var rowTypeName = rowType.Name; if (!rowTypeNames.Add(rowTypeName)) continue; - GenerateCodeForType(sb, typeIndex, rowType, location, contextClass, compilation, context); + GenerateCodeForType(sb, typeIndex, rowType, contextClass, context); ++typeIndex; } - sb.AppendLine(" }"); - sb.AppendLine("}"); + sb.AppendLine(""" + } + } + """); } - private static void GenerateCodeForType(StringBuilder sb, int typeIndex, INamedTypeSymbol rowType, Location location, - ContextClass contextClass, Compilation compilation, SourceProductionContext context) + private static void GenerateCodeForType(StringBuilder sb, int typeIndex, RowType rowType, + ContextClass contextClass, SourceProductionContext context) { - var rowTypeName = rowType.Name; - var rowTypeFullName = rowType.ToString(); + ReportDiagnostics(rowType, rowType.WorksheetRowAttributeLocation, contextClass.Options, context); - var info = AnalyzeTypeProperties(compilation, contextClass.CompilationTypes, rowType, context); - ReportDiagnostics(info, rowType, location, contextClass.Options, context); - - sb.AppendLine().AppendLine(FormattableString.Invariant($$""" - private WorksheetRowTypeInfo<{{rowTypeFullName}}>? _{{rowTypeName}}; - public WorksheetRowTypeInfo<{{rowTypeFullName}}> {{rowTypeName}} => _{{rowTypeName}} - """)); + sb.AppendLine().AppendLine($$""" + private WorksheetRowTypeInfo<{{rowType.FullName}}>? _{{rowType.Name}}; + public WorksheetRowTypeInfo<{{rowType.FullName}}> {{rowType.Name}} => _{{rowType.Name}} + """); - if (info.Properties.Count == 0) + if (rowType.Properties.Count == 0) { sb.AppendLine($$""" - ??= EmptyWorksheetRowContext.CreateTypeInfo<{{rowTypeFullName}}>(); + ??= EmptyWorksheetRowContext.CreateTypeInfo<{{rowType.FullName}}>(); """); return; } sb.AppendLine(FormattableString.Invariant($$""" - ??= WorksheetRowMetadataServices.CreateObjectInfo<{{rowTypeFullName}}>(AddHeaderRow{{typeIndex}}Async, AddAsRowAsync, AddRangeAsRowsAsync); + ??= WorksheetRowMetadataServices.CreateObjectInfo<{{rowType.FullName}}>(AddHeaderRow{{typeIndex}}Async, AddAsRowAsync, AddRangeAsRowsAsync); """)); - var properties = info.Properties.Values.ToList(); - GenerateAddHeaderRow(sb, typeIndex, properties); - GenerateAddAsRow(sb, 2, rowType); - GenerateAddRangeAsRows(sb, 2, rowType); - GenerateAddAsRowInternal(sb, 2, rowTypeFullName, properties); - GenerateAddRangeAsRowsInternal(sb, rowType, properties); - GenerateAddEnumerableAsRows(sb, 2, rowType); - GenerateAddCellsAsRow(sb, 2, rowType, properties); + GenerateAddHeaderRow(sb, typeIndex, rowType.Properties); + GenerateAddAsRow(sb, rowType); + GenerateAddRangeAsRows(sb, rowType); + GenerateAddAsRowInternal(sb, rowType); + GenerateAddRangeAsRowsInternal(sb, rowType); + GenerateAddEnumerableAsRows(sb, rowType); + GenerateAddCellsAsRow(sb, rowType); } - private static void ReportDiagnostics(TypePropertiesInfo info, INamedTypeSymbol rowType, Location location, GeneratorOptions? options, SourceProductionContext context) + private static void ReportDiagnostics(RowType rowType, LocationInfo? locationInfo, GeneratorOptions? options, SourceProductionContext context) { - if (options?.SuppressWarnings ?? false) return; + var suppressWarnings = options?.SuppressWarnings ?? false; - if (info.Properties.Count == 0) + foreach (var diagnosticInfo in rowType.DiagnosticInfos) + { + var isWarning = diagnosticInfo.Descriptor.DefaultSeverity == DiagnosticSeverity.Warning; + if (isWarning && suppressWarnings) + continue; + + context.ReportDiagnostic(diagnosticInfo.ToDiagnostic()); + } + + if (suppressWarnings) return; + + var location = locationInfo?.ToLocation(); + + if (rowType.Properties.Count == 0) context.ReportDiagnostic(Diagnostic.Create(Diagnostics.NoPropertiesFound, location, rowType.Name)); - if (info.UnsupportedProperties.FirstOrDefault() is { } unsupportedProperty) - context.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnsupportedTypeForCellValue, location, rowType.Name, unsupportedProperty.Type.Name)); + if (rowType.UnsupportedPropertyTypeNames.FirstOrDefault() is { } unsupportedPropertyTypeName) + context.ReportDiagnostic(Diagnostic.Create(Diagnostics.UnsupportedTypeForCellValue, location, rowType.Name, unsupportedPropertyTypeName)); } - private static void GenerateAddHeaderRow(StringBuilder sb, int typeIndex, IReadOnlyList properties) + private static void GenerateAddHeaderRow(StringBuilder sb, int typeIndex, IReadOnlyCollection properties) { Debug.Assert(properties.Count > 0); sb.AppendLine().AppendLine(FormattableString.Invariant($$""" - private static async ValueTask AddHeaderRow{{typeIndex}}Async(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.Styling.StyleId? styleId, CancellationToken token) - { - var cells = ArrayPool.Shared.Rent({{properties.Count}}); - try + private static async ValueTask AddHeaderRow{{typeIndex}}Async(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.Styling.StyleId? styleId, CancellationToken token) { - """)); + var cells = ArrayPool.Shared.Rent({{properties.Count}}); + try + { + """)); - for (var i = 0; i < properties.Count; i++) + foreach (var (i, property) in properties.Index()) { - var property = properties[i]; sb.AppendLine(FormattableString.Invariant($""" cells[{i}] = new StyledCell({property.ColumnHeader}, styleId); """)); } sb.AppendLine($$""" - await spreadsheet.AddRowAsync(cells.AsMemory(0, {{properties.Count}}), token).ConfigureAwait(false); + await spreadsheet.AddRowAsync(cells.AsMemory(0, {{properties.Count}}), token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } } - finally - { - ArrayPool.Shared.Return(cells, true); - } - } - """); + """); } - private static void GenerateAddAsRow(StringBuilder sb, int indent, INamedTypeSymbol rowType) + private static void GenerateAddAsRow(StringBuilder sb, RowType rowType) { - sb.AppendLine() - .AppendIndentation(indent) - .Append("private static ValueTask AddAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, ") - .AppendType(rowType) - .AppendLine(" obj, CancellationToken token)"); + sb.AppendLine($$""" - sb.AppendLine(indent, "{"); - sb.AppendLine(indent, " if (spreadsheet is null)"); - sb.AppendLine(indent, " throw new ArgumentNullException(nameof(spreadsheet));"); + private static ValueTask AddAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, {{rowType.FullNameWithNullableAnnotation}} obj, CancellationToken token) + { + if (spreadsheet is null) + throw new ArgumentNullException(nameof(spreadsheet)); + """); if (rowType.IsReferenceType) { - sb.AppendLine(indent + 1, "if (obj is null)"); - sb.AppendLine(indent + 1, " return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token);"); + sb.AppendLine(""" + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + """); } - sb.AppendLine(indent, " return AddAsRowInternalAsync(spreadsheet, obj, token);"); - sb.AppendLine(indent, "}"); + sb.AppendLine(""" + return AddAsRowInternalAsync(spreadsheet, obj, token); + } + """); } - private static void GenerateAddAsRowInternal(StringBuilder sb, int indent, string rowTypeFullname, IReadOnlyCollection properties) + private static void GenerateAddAsRowInternal(StringBuilder sb, RowType rowType) { + var properties = rowType.Properties; Debug.Assert(properties.Count > 0); - sb.AppendLine(); - sb.AppendLine(indent, $"private static async ValueTask AddAsRowInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, {rowTypeFullname} obj, CancellationToken token)"); - sb.AppendLine(indent, "{"); - sb.AppendLine(indent, $" var cells = ArrayPool.Shared.Rent({properties.Count});"); - sb.AppendLine(indent, " try"); - sb.AppendLine(indent, " {"); - sb.AppendLine(indent, " await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false);"); - sb.AppendLine(indent, " }"); - sb.AppendLine(indent, " finally"); - sb.AppendLine(indent, " {"); - sb.AppendLine(indent, " ArrayPool.Shared.Return(cells, true);"); - sb.AppendLine(indent, " }"); - sb.AppendLine(indent, "}"); + sb.AppendLine($$""" + + private static async ValueTask AddAsRowInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, {{rowType.FullName}} obj, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent({{properties.Count}}); + try + { + await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + """); } - private static void GenerateAddRangeAsRows(StringBuilder sb, int indent, INamedTypeSymbol rowType) + private static void GenerateAddRangeAsRows(StringBuilder sb, RowType rowType) { - sb.AppendLine() - .AppendIndentation(indent) - .Append("private static ValueTask AddRangeAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<") - .AppendType(rowType) - .AppendLine("> objs, CancellationToken token)"); - - sb.AppendLine(indent, "{"); - sb.AppendLine(indent, " if (spreadsheet is null)"); - sb.AppendLine(indent, " throw new ArgumentNullException(nameof(spreadsheet));"); - sb.AppendLine(indent, " if (objs is null)"); - sb.AppendLine(indent, " throw new ArgumentNullException(nameof(objs));"); - sb.AppendLine(indent, " return AddRangeAsRowsInternalAsync(spreadsheet, objs, token);"); - sb.AppendLine(indent, "}"); + sb.AppendLine($$""" + + private static ValueTask AddRangeAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<{{rowType.FullNameWithNullableAnnotation}}> objs, CancellationToken token) + { + if (spreadsheet is null) + throw new ArgumentNullException(nameof(spreadsheet)); + if (objs is null) + throw new ArgumentNullException(nameof(objs)); + return AddRangeAsRowsInternalAsync(spreadsheet, objs, token); + } + """); } - private static void GenerateAddRangeAsRowsInternal(StringBuilder sb, INamedTypeSymbol rowType, IReadOnlyCollection properties) + private static void GenerateAddRangeAsRowsInternal(StringBuilder sb, RowType rowType) { + var properties = rowType.Properties; Debug.Assert(properties.Count > 0); - var typeString = rowType.ToTypeString(); sb.Append($$""" - private static async ValueTask AddRangeAsRowsInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<{{typeString}}> objs, CancellationToken token) + private static async ValueTask AddRangeAsRowsInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<{{rowType.FullNameWithNullableAnnotation}}> objs, CancellationToken token) { var cells = ArrayPool.Shared.Rent({{properties.Count}}); try @@ -505,52 +373,50 @@ private static async ValueTask AddRangeAsRowsInternalAsync(SpreadCheetah.Spreads """); } - private static void GenerateAddEnumerableAsRows(StringBuilder sb, int indent, INamedTypeSymbol rowType) + private static void GenerateAddEnumerableAsRows(StringBuilder sb, RowType rowType) { - sb.AppendLine() - .AppendIndentation(indent) - .Append("private static async ValueTask AddEnumerableAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<") - .AppendType(rowType) - .AppendLine("> objs, DataCell[] cells, CancellationToken token)"); - - sb.AppendLine(indent, "{"); - sb.AppendLine(indent, " foreach (var obj in objs)"); - sb.AppendLine(indent, " {"); - sb.AppendLine(indent, " await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false);"); - sb.AppendLine(indent, " }"); - sb.AppendLine(indent, "}"); + sb.AppendLine($$""" + + private static async ValueTask AddEnumerableAsRowsAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<{{rowType.FullNameWithNullableAnnotation}}> objs, DataCell[] cells, CancellationToken token) + { + foreach (var obj in objs) + { + await AddCellsAsRowAsync(spreadsheet, obj, cells, token).ConfigureAwait(false); + } + } + """); } - private static void GenerateAddCellsAsRow(StringBuilder sb, int indent, INamedTypeSymbol rowType, IReadOnlyList properties) + private static void GenerateAddCellsAsRow(StringBuilder sb, RowType rowType) { + var properties = rowType.Properties; Debug.Assert(properties.Count > 0); - sb.AppendLine() - .AppendIndentation(indent) - .Append("private static ValueTask AddCellsAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, ") - .AppendType(rowType) - .AppendLine(" obj, DataCell[] cells, CancellationToken token)"); + sb.AppendLine($$""" - sb.AppendLine(indent, "{"); + private static ValueTask AddCellsAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, {{rowType.FullNameWithNullableAnnotation}} obj, DataCell[] cells, CancellationToken token) + { + """); if (rowType.IsReferenceType) { - sb.AppendLine(indent, " if (obj is null)"); - sb.AppendLine(indent, " return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token);"); - sb.AppendLine(); + sb.AppendLine(""" + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + + """); } - for (var i = 0; i < properties.Count; i++) + foreach (var (i, property) in properties.Index()) { - sb.AppendIndentation(indent + 1) - .Append("cells[") - .Append(i) - .Append("] = new DataCell(obj.") - .Append(properties[i].PropertyName) - .AppendLine(");"); + sb.AppendLine(FormattableString.Invariant($$""" + cells[{{i}}] = new DataCell(obj.{{property.Name}}); + """)); } - sb.AppendLine(indent, $" return spreadsheet.AddRowAsync(cells.AsMemory(0, {properties.Count}), token);"); - sb.AppendLine(indent, "}"); + sb.AppendLine($$""" + return spreadsheet.AddRowAsync(cells.AsMemory(0, {{properties.Count}}), token); + } + """); } }