diff --git a/.editorconfig b/.editorconfig index 1c5d225b..ad55b2f8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -148,7 +148,7 @@ dotnet_diagnostic.CA1815.severity = warning # CA2201: Do not raise reserved exception types dotnet_diagnostic.CA2201.severity = warning -[{ColumnOrderAttribute.cs,WorksheetRowAttribute.cs}] +[{ColumnHeaderAttribute.cs,ColumnOrderAttribute.cs,WorksheetRowAttribute.cs}] # CA1019: Define accessors for attribute arguments dotnet_diagnostic.CA1019.severity = none # CS9113: Parameter is unread diff --git a/Directory.Packages.props b/Directory.Packages.props index 6c8ec584..1b724c73 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,11 +5,11 @@ - + - + - + @@ -31,7 +31,7 @@ - + diff --git a/SpreadCheetah.SourceGenerator.CSharp8Test/Models/ClassWithMultipleProperties.cs b/SpreadCheetah.SourceGenerator.CSharp8Test/Models/ClassWithMultipleProperties.cs index 2d14ecb7..8138441d 100644 --- a/SpreadCheetah.SourceGenerator.CSharp8Test/Models/ClassWithMultipleProperties.cs +++ b/SpreadCheetah.SourceGenerator.CSharp8Test/Models/ClassWithMultipleProperties.cs @@ -1,9 +1,13 @@ +using SpreadCheetah.SourceGeneration; + namespace SpreadCheetah.SourceGenerator.CSharp8Test.Models { public class ClassWithMultipleProperties { - public string FirstName { get; } + [ColumnHeader("Last name")] public string LastName { get; } + [ColumnOrder(1)] + public string FirstName { get; } public int Age { get; } public ClassWithMultipleProperties(string firstName, string lastName, int age) diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithColumnHeaderForAllProperties.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithColumnHeaderForAllProperties.cs new file mode 100644 index 00000000..3afb0131 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithColumnHeaderForAllProperties.cs @@ -0,0 +1,24 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader; + +public class ClassWithColumnHeaderForAllProperties +{ + [ColumnHeader("First name")] + public string FirstName { get; set; } = ""; + + [ColumnHeader("Middle name")] + public string? MiddleName { get; set; } + + [ColumnHeader("Last name")] + public string LastName { get; set; } = ""; + + [ColumnHeader("Age")] + public int Age { get; set; } + + [ColumnHeader("Employed (yes/no)")] + public bool Employed { get; set; } + + [ColumnHeader("Score (decimal)")] + public double Score { get; set; } +} diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs new file mode 100644 index 00000000..e269d2c0 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs @@ -0,0 +1,39 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader; + +public class ClassWithSpecialCharacterColumnHeaders +{ + [ColumnHeader("First name")] + public string? FirstName { get; set; } + + [ColumnHeader("")] + public string? LastName { get; set; } + + [ColumnHeader("Nationality (escaped characters \", \', \\)")] + public string? Nationality { get; set; } + + [ColumnHeader("Address line 1 (escaped characters \r\n, \t)")] + public string? AddressLine1 { get; set; } + + [ColumnHeader(@"Address line 2 (verbatim +string: "", \)")] + public string? AddressLine2 { get; set; } + + [ColumnHeader(""" + Age ( + raw + string + literal + ) + """)] + public int Age { get; set; } + + [ColumnHeader("Note (unicode escape sequence 🌉, \ud83d\udc4d, \xE7)")] + public string? Note { get; set; } + + private const string Constant = "This is a constant"; + + [ColumnHeader($"Note 2 (constant interpolated string: {Constant})")] + public string? Note2 { get; set; } +} diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithColumnHeaderForAllProperties#MyNamespace.MyGenRowContext.g.verified.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithColumnHeaderForAllProperties#MyNamespace.MyGenRowContext.g.verified.cs new file mode 100644 index 00000000..139bc426 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithColumnHeaderForAllProperties#MyNamespace.MyGenRowContext.g.verified.cs @@ -0,0 +1,112 @@ +//HintName: MyNamespace.MyGenRowContext.g.cs +// +#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? _ClassWithColumnHeaderForAllProperties; + public WorksheetRowTypeInfo ClassWithColumnHeaderForAllProperties => _ClassWithColumnHeaderForAllProperties + ??= WorksheetRowMetadataServices.CreateObjectInfo(AddHeaderRow0Async, AddAsRowAsync, AddRangeAsRowsAsync); + + private static async ValueTask AddHeaderRow0Async(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.Styling.StyleId? styleId, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(6); + try + { + cells[0] = new StyledCell("First name", styleId); + cells[1] = new StyledCell("Middle name", styleId); + cells[2] = new StyledCell("Last name", styleId); + cells[3] = new StyledCell("Age", styleId); + cells[4] = new StyledCell("Employed (yes/no)", styleId); + cells[5] = new StyledCell("Score (decimal)", styleId); + await spreadsheet.AddRowAsync(cells.AsMemory(0, 6), token).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + + private static ValueTask AddAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader.ClassWithColumnHeaderForAllProperties? 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.ColumnHeader.ClassWithColumnHeaderForAllProperties obj, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(6); + 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(6); + 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.ColumnHeader.ClassWithColumnHeaderForAllProperties? obj, DataCell[] cells, CancellationToken token) + { + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + + cells[0] = new DataCell(obj.FirstName); + cells[1] = new DataCell(obj.MiddleName); + cells[2] = new DataCell(obj.LastName); + cells[3] = new DataCell(obj.Age); + cells[4] = new DataCell(obj.Employed); + cells[5] = new DataCell(obj.Score); + return spreadsheet.AddRowAsync(cells.AsMemory(0, 6), token); + } + } +} 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 new file mode 100644 index 00000000..efe144d1 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Snapshots/WorksheetRowGeneratorColumnHeaderTests.WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders#MyNamespace.MyGenRowContext.g.verified.cs @@ -0,0 +1,116 @@ +//HintName: MyNamespace.MyGenRowContext.g.cs +// +#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? _ClassWithSpecialCharacterColumnHeaders; + public WorksheetRowTypeInfo ClassWithSpecialCharacterColumnHeaders => _ClassWithSpecialCharacterColumnHeaders + ??= WorksheetRowMetadataServices.CreateObjectInfo(AddHeaderRow0Async, AddAsRowAsync, AddRangeAsRowsAsync); + + private static async ValueTask AddHeaderRow0Async(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.Styling.StyleId? styleId, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(8); + try + { + 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[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); + } + finally + { + ArrayPool.Shared.Return(cells, true); + } + } + + private static ValueTask AddAsRowAsync(SpreadCheetah.Spreadsheet spreadsheet, SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader.ClassWithSpecialCharacterColumnHeaders? 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.ColumnHeader.ClassWithSpecialCharacterColumnHeaders obj, CancellationToken token) + { + var cells = ArrayPool.Shared.Rent(8); + 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(8); + 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.ColumnHeader.ClassWithSpecialCharacterColumnHeaders? obj, DataCell[] cells, CancellationToken token) + { + if (obj is null) + return spreadsheet.AddRowAsync(ReadOnlyMemory.Empty, token); + + cells[0] = new DataCell(obj.FirstName); + cells[1] = new DataCell(obj.LastName); + cells[2] = new DataCell(obj.Nationality); + cells[3] = new DataCell(obj.AddressLine1); + cells[4] = new DataCell(obj.AddressLine2); + cells[5] = new DataCell(obj.Age); + cells[6] = new DataCell(obj.Note); + cells[7] = new DataCell(obj.Note2); + return spreadsheet.AddRowAsync(cells.AsMemory(0, 8), token); + } + } +} diff --git a/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs new file mode 100644 index 00000000..cbcdf11b --- /dev/null +++ b/SpreadCheetah.SourceGenerator.SnapshotTest/Tests/WorksheetRowGeneratorColumnHeaderTests.cs @@ -0,0 +1,43 @@ +using SpreadCheetah.SourceGenerator.SnapshotTest.Helpers; +using SpreadCheetah.SourceGenerators; + +namespace SpreadCheetah.SourceGenerator.SnapshotTest.Tests; + +public class WorksheetRowGeneratorColumnHeaderTests +{ + [Fact] + public Task WorksheetRowGenerator_Generate_ClassWithColumnHeaderForAllProperties() + { + // Arrange + const string source = """ + using SpreadCheetah.SourceGeneration; + using SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader; + + namespace MyNamespace; + + [WorksheetRow(typeof(ClassWithColumnHeaderForAllProperties))] + public partial class MyGenRowContext : WorksheetRowContext; + """; + + // Act & Assert + return TestHelper.CompileAndVerify(source); + } + + [Fact] + public Task WorksheetRowGenerator_Generate_ClassWithSpecialCharacterColumnHeaders() + { + // Arrange + const string source = """ + using SpreadCheetah.SourceGeneration; + using SpreadCheetah.SourceGenerator.SnapshotTest.Models.ColumnHeader; + + namespace MyNamespace; + + [WorksheetRow(typeof(ClassWithSpecialCharacterColumnHeaders))] + public partial class MyGenRowContext : WorksheetRowContext; + """; + + // Act & Assert + return TestHelper.CompileAndVerify(source); + } +} diff --git a/SpreadCheetah.SourceGenerator.Test/Helpers/EnumerableExtensions.cs b/SpreadCheetah.SourceGenerator.Test/Helpers/EnumerableExtensions.cs index 6edf75ca..b2ca7a46 100644 --- a/SpreadCheetah.SourceGenerator.Test/Helpers/EnumerableExtensions.cs +++ b/SpreadCheetah.SourceGenerator.Test/Helpers/EnumerableExtensions.cs @@ -1,4 +1,4 @@ -using Xunit; +using Xunit; namespace SpreadCheetah.SourceGenerator.Test.Helpers; diff --git a/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs b/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs new file mode 100644 index 00000000..d03a69f4 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ClassWithSpecialCharacterColumnHeaders.cs @@ -0,0 +1,39 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.Test.Models.ColumnHeader; + +public class ClassWithSpecialCharacterColumnHeaders +{ + [ColumnHeader("First name")] + public string? FirstName { get; set; } + + [ColumnHeader("")] + public string? LastName { get; set; } + + [ColumnHeader("Nationality (escaped characters \", \', \\)")] + public string? Nationality { get; set; } + + [ColumnHeader("Address line 1 (escaped characters \r\n, \t)")] + public string? AddressLine1 { get; set; } + + [ColumnHeader(@"Address line 2 (verbatim +string: "", \)")] + public string? AddressLine2 { get; set; } + + [ColumnHeader(""" + Age ( + raw + string + literal + ) + """)] + public int Age { get; set; } + + [ColumnHeader("Note (unicode escape sequence 🌉, \ud83d\udc4d, \xE7)")] + public string? Note { get; set; } + + private const string Constant = "This is a constant"; + + [ColumnHeader($"Note 2 (constant interpolated string: {Constant})")] + public string? Note2 { get; set; } +} diff --git a/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ColumnHeaderContext.cs b/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ColumnHeaderContext.cs new file mode 100644 index 00000000..2b4e569c --- /dev/null +++ b/SpreadCheetah.SourceGenerator.Test/Models/ColumnHeader/ColumnHeaderContext.cs @@ -0,0 +1,6 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.Test.Models.ColumnHeader; + +[WorksheetRow(typeof(ClassWithSpecialCharacterColumnHeaders))] +public partial class ColumnHeaderContext : WorksheetRowContext; \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ClassWithColumnAttributes.cs b/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ClassWithColumnAttributes.cs new file mode 100644 index 00000000..996d85a7 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ClassWithColumnAttributes.cs @@ -0,0 +1,19 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.Test.Models.Combinations; + +public class ClassWithColumnAttributes(string model, string make, int year, decimal kW) +{ + public string Model { get; } = model; + + [ColumnOrder(2)] + [ColumnHeader("The make")] + public string Make { get; } = make; + + [ColumnOrder(1)] + public int Year { get; } = year; + +#pragma warning disable IDE1006 // Naming Styles + public decimal kW { get; } = kW; +#pragma warning restore IDE1006 // Naming Styles +} diff --git a/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ColumnAttributesContext.cs b/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ColumnAttributesContext.cs new file mode 100644 index 00000000..c2edb9e9 --- /dev/null +++ b/SpreadCheetah.SourceGenerator.Test/Models/Combinations/ColumnAttributesContext.cs @@ -0,0 +1,6 @@ +using SpreadCheetah.SourceGeneration; + +namespace SpreadCheetah.SourceGenerator.Test.Models.Combinations; + +[WorksheetRow(typeof(ClassWithColumnAttributes))] +public partial class ColumnAttributesContext : WorksheetRowContext; \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs b/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs index 2955d474..76a566d7 100644 --- a/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs +++ b/SpreadCheetah.SourceGenerator.Test/Tests/WorksheetRowGeneratorTests.cs @@ -4,7 +4,9 @@ using SpreadCheetah.SourceGenerator.Test.Helpers; using SpreadCheetah.SourceGenerator.Test.Models; using SpreadCheetah.SourceGenerator.Test.Models.Accessibility; +using SpreadCheetah.SourceGenerator.Test.Models.ColumnHeader; using SpreadCheetah.SourceGenerator.Test.Models.ColumnOrdering; +using SpreadCheetah.SourceGenerator.Test.Models.Combinations; using SpreadCheetah.SourceGenerator.Test.Models.Contexts; using SpreadCheetah.SourceGenerator.Test.Models.MultipleProperties; using SpreadCheetah.SourceGenerator.Test.Models.NoProperties; @@ -558,4 +560,69 @@ public async Task Spreadsheet_AddHeaderRow_ThrowsWhenNoWorksheet() // Act & Assert await Assert.ThrowsAsync(() => spreadsheet.AddHeaderRowAsync(typeInfo).AsTask()); } + + [Fact] + public async Task Spreadsheet_AddHeaderRow_SpecialCharacterColumnHeaders() + { + // Arrange + var ctx = ColumnHeaderContext.Default; + + using var stream = new MemoryStream(); + await using var s = await Spreadsheet.CreateNewAsync(stream); + await s.StartWorksheetAsync("Sheet"); + + IList expectedValues = + [ + "First name", + "", + "Nationality (escaped characters \", \', \\)", + "Address line 1 (escaped characters \r\n, \t)", + @"Address line 2 (verbatim +string: "", \)", + """ + Age ( + raw + string + literal + ) + """, + "Note (unicode escape sequence 🌉, \ud83d\udc4d, \xE7)", + "Note 2 (constant interpolated string: This is a constant)" + ]; + + // Act + await s.AddHeaderRowAsync(ctx.ClassWithSpecialCharacterColumnHeaders); + await s.FinishAsync(); + + // Assert + using var sheet = SpreadsheetAssert.SingleSheet(stream); + Assert.Equal(expectedValues, sheet.Row(1).Select(x => x.StringValue)); + } + + [Fact] + public async Task Spreadsheet_AddHeaderRow_ObjectWithMultipleColumnAttributes() + { + // Arrange + var ctx = ColumnAttributesContext.Default; + + using var stream = new MemoryStream(); + await using var s = await Spreadsheet.CreateNewAsync(stream); + await s.StartWorksheetAsync("Sheet"); + + IList expectedValues = + [ + "Year", + "The make", + "Model", + "kW" + ]; + + // Act + await s.AddHeaderRowAsync(ctx.ClassWithColumnAttributes); + await s.FinishAsync(); + + // Assert + using var sheet = SpreadsheetAssert.SingleSheet(stream); + Assert.Equal(expectedValues, sheet.Row(1).Select(x => x.StringValue)); + } } diff --git a/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs index 7f3fed32..17d3acad 100644 --- a/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs +++ b/SpreadCheetah.SourceGenerator/Extensions/CompilationExtensions.cs @@ -14,6 +14,8 @@ public static bool TryGetCompilationTypes( 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)) @@ -22,6 +24,7 @@ public static bool TryGetCompilationTypes( return false; result = new CompilationTypes( + ColumnHeaderAttribute: columnHeader, ColumnOrderAttribute: columnOrder, WorksheetRowContext: context, WorksheetRowGenerationOptionsAttribute: options); diff --git a/SpreadCheetah.SourceGenerator/Extensions/SortedDictionaryExtensions.cs b/SpreadCheetah.SourceGenerator/Extensions/SortedDictionaryExtensions.cs index c84eb0aa..2c6fc71e 100644 --- a/SpreadCheetah.SourceGenerator/Extensions/SortedDictionaryExtensions.cs +++ b/SpreadCheetah.SourceGenerator/Extensions/SortedDictionaryExtensions.cs @@ -2,9 +2,9 @@ namespace SpreadCheetah.SourceGenerator.Extensions; internal static class SortedDictionaryExtensions { - public static void AddWithImplicitKeys( - this SortedDictionary dictionary, - IEnumerable values) + public static void AddWithImplicitKeys( + this SortedDictionary dictionary, + IEnumerable values) { var implicitKey = 1; foreach (var value in values) diff --git a/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs b/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs index fd5849be..45c00034 100644 --- a/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs +++ b/SpreadCheetah.SourceGenerator/Helpers/TypePropertiesInfo.cs @@ -1,7 +1,7 @@ using Microsoft.CodeAnalysis; +using SpreadCheetah.SourceGenerator.Models; namespace SpreadCheetah.SourceGenerator.Helpers; - internal sealed record TypePropertiesInfo( - SortedDictionary PropertyNames, + SortedDictionary Properties, List UnsupportedProperties); \ No newline at end of file diff --git a/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs b/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs new file mode 100644 index 00000000..f918cb08 --- /dev/null +++ b/SpreadCheetah.SourceGenerator/Models/ColumnProperty.cs @@ -0,0 +1,3 @@ +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 index 70d11fbc..8b76253b 100644 --- a/SpreadCheetah.SourceGenerator/Models/CompilationTypes.cs +++ b/SpreadCheetah.SourceGenerator/Models/CompilationTypes.cs @@ -4,5 +4,6 @@ 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/WorksheetRowGenerator.cs b/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs index 897a5988..51b090d4 100644 --- a/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs +++ b/SpreadCheetah.SourceGenerator/WorksheetRowGenerator.cs @@ -20,8 +20,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var filtered = context.SyntaxProvider .ForAttributeWithMetadataName( "SpreadCheetah.SourceGeneration.WorksheetRowAttribute", - static (s, _) => IsSyntaxTargetForGeneration(s), - static (ctx, token) => GetSemanticTargetForGeneration(ctx, token)) + IsSyntaxTargetForGeneration, + GetSemanticTargetForGeneration) .Where(static x => x is not null) .Collect(); @@ -30,7 +30,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput(source, static (spc, source) => Execute(source.Left, source.Right, spc)); } - private static bool IsSyntaxTargetForGeneration(SyntaxNode syntaxNode) => syntaxNode is ClassDeclarationSyntax + private static bool IsSyntaxTargetForGeneration(SyntaxNode syntaxNode, CancellationToken _) => syntaxNode is ClassDeclarationSyntax { BaseList.Types.Count: > 0 }; @@ -130,6 +130,24 @@ private static bool TryParseOptionsAttribute( 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, @@ -151,8 +169,8 @@ private static bool TryParseColumnOrderAttribute( private static TypePropertiesInfo AnalyzeTypeProperties(Compilation compilation, CompilationTypes compilationTypes, ITypeSymbol classType, SourceProductionContext context) { - var implicitOrderPropertyNames = new List(); - var explicitOrderPropertyNames = new SortedDictionary(); + var implicitOrderProperties = new List(); + var explicitOrderProperties = new SortedDictionary(); var unsupportedPropertyNames = new List(); foreach (var member in classType.GetMembers()) @@ -173,17 +191,31 @@ private static TypePropertiesInfo AnalyzeTypeProperties(Compilation compilation, continue; } + var columnHeader = GetColumnHeader(p, compilationTypes.ColumnHeaderAttribute); + var columnProperty = new ColumnProperty(p.Name, columnHeader); + if (!TryGetExplicitColumnOrder(p, compilationTypes.ColumnOrderAttribute, context.CancellationToken, out var columnOrder, out var location)) - implicitOrderPropertyNames.Add(p.Name); - else if (!explicitOrderPropertyNames.ContainsKey(columnOrder)) - explicitOrderPropertyNames.Add(columnOrder, p.Name); + implicitOrderProperties.Add(columnProperty); + else if (!explicitOrderProperties.ContainsKey(columnOrder)) + explicitOrderProperties.Add(columnOrder, columnProperty); else context.ReportDiagnostic(Diagnostic.Create(Diagnostics.DuplicateColumnOrder, location, classType.Name)); } - explicitOrderPropertyNames.AddWithImplicitKeys(implicitOrderPropertyNames); + 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 new TypePropertiesInfo(explicitOrderPropertyNames, unsupportedPropertyNames); + return @$"""{property.Name}"""; } private static bool TryGetExplicitColumnOrder(IPropertySymbol property, INamedTypeSymbol columnOrderAttribute, @@ -327,7 +359,7 @@ private static void GenerateCodeForType(StringBuilder sb, int typeIndex, INamedT public WorksheetRowTypeInfo<{{rowTypeFullName}}> {{rowTypeName}} => _{{rowTypeName}} """)); - if (info.PropertyNames.Count == 0) + if (info.Properties.Count == 0) { sb.AppendLine($$""" ??= EmptyWorksheetRowContext.CreateTypeInfo<{{rowTypeFullName}}>(); @@ -340,49 +372,49 @@ private static void GenerateCodeForType(StringBuilder sb, int typeIndex, INamedT ??= WorksheetRowMetadataServices.CreateObjectInfo<{{rowTypeFullName}}>(AddHeaderRow{{typeIndex}}Async, AddAsRowAsync, AddRangeAsRowsAsync); """)); - var propertyNames = info.PropertyNames.Values.ToList(); - GenerateAddHeaderRow(sb, typeIndex, propertyNames); + var properties = info.Properties.Values.ToList(); + GenerateAddHeaderRow(sb, typeIndex, properties); GenerateAddAsRow(sb, 2, rowType); GenerateAddRangeAsRows(sb, 2, rowType); - GenerateAddAsRowInternal(sb, 2, rowTypeFullName, propertyNames); - GenerateAddRangeAsRowsInternal(sb, rowType, propertyNames); + GenerateAddAsRowInternal(sb, 2, rowTypeFullName, properties); + GenerateAddRangeAsRowsInternal(sb, rowType, properties); GenerateAddEnumerableAsRows(sb, 2, rowType); - GenerateAddCellsAsRow(sb, 2, rowType, propertyNames); + GenerateAddCellsAsRow(sb, 2, rowType, properties); } private static void ReportDiagnostics(TypePropertiesInfo info, INamedTypeSymbol rowType, Location location, GeneratorOptions? options, SourceProductionContext context) { if (options?.SuppressWarnings ?? false) return; - if (info.PropertyNames.Count == 0) + if (info.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)); } - private static void GenerateAddHeaderRow(StringBuilder sb, int typeIndex, IReadOnlyList propertyNames) + private static void GenerateAddHeaderRow(StringBuilder sb, int typeIndex, IReadOnlyList properties) { - Debug.Assert(propertyNames.Count > 0); + 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({{propertyNames.Count}}); + var cells = ArrayPool.Shared.Rent({{properties.Count}}); try { """)); - for (var i = 0; i < propertyNames.Count; i++) + for (var i = 0; i < properties.Count; i++) { - var propertyName = propertyNames[i]; + var property = properties[i]; sb.AppendLine(FormattableString.Invariant($""" - cells[{i}] = new StyledCell("{propertyName}", styleId); + cells[{i}] = new StyledCell({property.ColumnHeader}, styleId); """)); } sb.AppendLine($$""" - await spreadsheet.AddRowAsync(cells.AsMemory(0, {{propertyNames.Count}}), token).ConfigureAwait(false); + await spreadsheet.AddRowAsync(cells.AsMemory(0, {{properties.Count}}), token).ConfigureAwait(false); } finally { @@ -414,14 +446,14 @@ private static void GenerateAddAsRow(StringBuilder sb, int indent, INamedTypeSym sb.AppendLine(indent, "}"); } - private static void GenerateAddAsRowInternal(StringBuilder sb, int indent, string rowTypeFullname, IReadOnlyCollection propertyNames) + private static void GenerateAddAsRowInternal(StringBuilder sb, int indent, string rowTypeFullname, IReadOnlyCollection properties) { - Debug.Assert(propertyNames.Count > 0); + 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({propertyNames.Count});"); + 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);"); @@ -450,16 +482,16 @@ private static void GenerateAddRangeAsRows(StringBuilder sb, int indent, INamedT sb.AppendLine(indent, "}"); } - private static void GenerateAddRangeAsRowsInternal(StringBuilder sb, INamedTypeSymbol rowType, IReadOnlyCollection propertyNames) + private static void GenerateAddRangeAsRowsInternal(StringBuilder sb, INamedTypeSymbol rowType, IReadOnlyCollection properties) { - Debug.Assert(propertyNames.Count > 0); + Debug.Assert(properties.Count > 0); var typeString = rowType.ToTypeString(); sb.Append($$""" private static async ValueTask AddRangeAsRowsInternalAsync(SpreadCheetah.Spreadsheet spreadsheet, IEnumerable<{{typeString}}> objs, CancellationToken token) { - var cells = ArrayPool.Shared.Rent({{propertyNames.Count}}); + var cells = ArrayPool.Shared.Rent({{properties.Count}}); try { await AddEnumerableAsRowsAsync(spreadsheet, objs, cells, token).ConfigureAwait(false); @@ -489,9 +521,9 @@ private static void GenerateAddEnumerableAsRows(StringBuilder sb, int indent, IN sb.AppendLine(indent, "}"); } - private static void GenerateAddCellsAsRow(StringBuilder sb, int indent, INamedTypeSymbol rowType, IReadOnlyCollection propertyNames) + private static void GenerateAddCellsAsRow(StringBuilder sb, int indent, INamedTypeSymbol rowType, IReadOnlyList properties) { - Debug.Assert(propertyNames.Count > 0); + Debug.Assert(properties.Count > 0); sb.AppendLine() .AppendIndentation(indent) @@ -508,17 +540,17 @@ private static void GenerateAddCellsAsRow(StringBuilder sb, int indent, INamedTy sb.AppendLine(); } - foreach (var (propertyName, index) in propertyNames.Select((x, i) => (x, i))) + for (var i = 0; i < properties.Count; i++) { sb.AppendIndentation(indent + 1) .Append("cells[") - .Append(index) + .Append(i) .Append("] = new DataCell(obj.") - .Append(propertyName) + .Append(properties[i].PropertyName) .AppendLine(");"); } - sb.AppendLine(indent, $" return spreadsheet.AddRowAsync(cells.AsMemory(0, {propertyNames.Count}), token);"); + sb.AppendLine(indent, $" return spreadsheet.AddRowAsync(cells.AsMemory(0, {properties.Count}), token);"); sb.AppendLine(indent, "}"); } } diff --git a/SpreadCheetah.TestHelpers/Assertions/ClosedXmlAssertSheet.cs b/SpreadCheetah.TestHelpers/Assertions/ClosedXmlAssertSheet.cs index 8d1145db..82cf42cc 100644 --- a/SpreadCheetah.TestHelpers/Assertions/ClosedXmlAssertSheet.cs +++ b/SpreadCheetah.TestHelpers/Assertions/ClosedXmlAssertSheet.cs @@ -26,8 +26,10 @@ public IEnumerable Column(string columnName) public IEnumerable Row(int rowNumber) { - var cells = sheet.Row(rowNumber).CellsUsed(); - return cells.Select(x => new ClosedXmlAssertCell(x)); + var row = sheet.Row(rowNumber); + return row.IsEmpty() + ? [] + : row.Cells(false).Select(x => new ClosedXmlAssertCell(x)); } public void Dispose() => workbook.Dispose(); diff --git a/SpreadCheetah/SourceGeneration/ColumnHeaderAttribute.cs b/SpreadCheetah/SourceGeneration/ColumnHeaderAttribute.cs new file mode 100644 index 00000000..05712bc7 --- /dev/null +++ b/SpreadCheetah/SourceGeneration/ColumnHeaderAttribute.cs @@ -0,0 +1,4 @@ +namespace SpreadCheetah.SourceGeneration; + +[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)] +public sealed class ColumnHeaderAttribute(string name) : Attribute;