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;