From dd163e989e9fe3e9f27940d44ff9388a1b18d2e1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:43:22 +0000 Subject: [PATCH 1/5] Initial plan From 7c49ed5cc53c9c4de127550261c9c383a442d5a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:56:10 +0000 Subject: [PATCH 2/5] Add support for list-like types (arrays and List) for properties Co-authored-by: AustinWise <7751+AustinWise@users.noreply.github.com> --- .../CsvPropertyDefinition.cs | 6 +- .../CsvPropertyKind.cs | 1 + src/SepCsvSourceGenerator.Analyzer/Emitter.cs | 106 +++++++++++++ src/SepCsvSourceGenerator.Analyzer/Parser.cs | 149 ++++++++++++++---- .../Baselines/IntListProperty.generated.txt | 81 ++++++++++ .../StringArrayProperty.generated.txt | 81 ++++++++++ .../CsvGeneratorEmitterTests.cs | 52 ++++++ .../RunGeneratedParserTests.cs | 60 +++++++ 8 files changed, 508 insertions(+), 28 deletions(-) create mode 100644 tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/IntListProperty.generated.txt create mode 100644 tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/StringArrayProperty.generated.txt diff --git a/src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs b/src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs index f371305..496e41c 100644 --- a/src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs +++ b/src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs @@ -8,4 +8,8 @@ internal record CsvPropertyDefinition( string? DateFormat, bool IsRequiredMember, bool IsInitOnly, - CsvPropertyKind Kind); \ No newline at end of file + CsvPropertyKind Kind, + string? ElementTypeName = null, // For List types: the type of the elements + CsvPropertyKind? ElementKind = null, // For List types: the kind of the elements + string? ElementDateFormat = null, // For List types with date elements + char ListDelimiter = ','); // For List types: the delimiter to split elements \ No newline at end of file diff --git a/src/SepCsvSourceGenerator.Analyzer/CsvPropertyKind.cs b/src/SepCsvSourceGenerator.Analyzer/CsvPropertyKind.cs index cb30479..5336f45 100644 --- a/src/SepCsvSourceGenerator.Analyzer/CsvPropertyKind.cs +++ b/src/SepCsvSourceGenerator.Analyzer/CsvPropertyKind.cs @@ -6,4 +6,5 @@ internal enum CsvPropertyKind DateOrTime, String, Enum, + List, } diff --git a/src/SepCsvSourceGenerator.Analyzer/Emitter.cs b/src/SepCsvSourceGenerator.Analyzer/Emitter.cs index f06966c..3660c0f 100644 --- a/src/SepCsvSourceGenerator.Analyzer/Emitter.cs +++ b/src/SepCsvSourceGenerator.Analyzer/Emitter.cs @@ -86,6 +86,13 @@ private void AppendLine(string line) GenerateMethod(methodDef); } + // Generate helper methods if any method uses list properties + bool needsListHelpers = methodsToGenerate.Any(m => m.PropertiesToParse.Any(p => p.Kind == CsvPropertyKind.List)); + if (needsListHelpers) + { + GenerateListHelperMethods(); + } + // Close class and namespace braces currentClass = containingClassSymbol; while (currentClass != null) @@ -104,6 +111,68 @@ private void AppendLine(string line) return _builder.ToString(); } + private void GenerateListHelperMethods() + { + AppendLine(""); + AppendLine("private static T[] ParseListToArray(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("if (span.IsEmpty)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("return global::System.Array.Empty();"); + DecreaseIndent(); + AppendLine("}"); + AppendLine(""); + AppendLine("var list = new global::System.Collections.Generic.List();"); + AppendLine("int start = 0;"); + AppendLine("for (int i = 0; i < span.Length; i++)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("if (span[i] == delimiter)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("list.Add(parser(span.Slice(start, i - start)));"); + AppendLine("start = i + 1;"); + DecreaseIndent(); + AppendLine("}"); + DecreaseIndent(); + AppendLine("}"); + AppendLine("list.Add(parser(span.Slice(start)));"); + AppendLine("return list.ToArray();"); + DecreaseIndent(); + AppendLine("}"); + AppendLine(""); + AppendLine("private static global::System.Collections.Generic.List ParseListToList(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("if (span.IsEmpty)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("return new global::System.Collections.Generic.List();"); + DecreaseIndent(); + AppendLine("}"); + AppendLine(""); + AppendLine("var list = new global::System.Collections.Generic.List();"); + AppendLine("int start = 0;"); + AppendLine("for (int i = 0; i < span.Length; i++)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("if (span[i] == delimiter)"); + AppendLine("{"); + IncreaseIndent(); + AppendLine("list.Add(parser(span.Slice(start, i - start)));"); + AppendLine("start = i + 1;"); + DecreaseIndent(); + AppendLine("}"); + DecreaseIndent(); + AppendLine("}"); + AppendLine("list.Add(parser(span.Slice(start)));"); + AppendLine("return list;"); + DecreaseIndent(); + AppendLine("}"); + } + private void GenerateMethod(CsvMethodDefinition methodDef) { var itemTypeName = methodDef.ItemTypeSymbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); @@ -257,6 +326,12 @@ private static bool MustAssignInObjectInitializer(CsvPropertyDefinition def) private static string GetParseExpression(CsvPropertyDefinition prop, string indexVarName) { string spanAccess = $"row[{indexVarName}].Span"; + + if (prop.Kind == CsvPropertyKind.List) + { + return GetListParseExpression(prop, spanAccess); + } + return prop.Kind switch { CsvPropertyKind.DateOrTime => $"{prop.UnderlyingTypeName}.ParseExact({spanAccess}, \"{prop.DateFormat}\", CultureInfo.InvariantCulture)", @@ -266,4 +341,35 @@ private static string GetParseExpression(CsvPropertyDefinition prop, string inde _ => throw new InvalidOperationException() }; } + + private static string GetListParseExpression(CsvPropertyDefinition prop, string spanAccess) + { + Debug.Assert(prop.Kind == CsvPropertyKind.List); + Debug.Assert(prop.ElementKind != null); + Debug.Assert(prop.ElementTypeName != null); + + string delimiter = prop.ListDelimiter == '\'' ? "\\'" : prop.ListDelimiter.ToString(); + + // Generate element parsing expression + string elementParseExpr = prop.ElementKind switch + { + CsvPropertyKind.DateOrTime => $"{prop.ElementTypeName}.ParseExact(element, \"{prop.ElementDateFormat}\", CultureInfo.InvariantCulture)", + CsvPropertyKind.String => "element.ToString()", + CsvPropertyKind.Enum => $"global::System.Enum.Parse<{prop.ElementTypeName}>(element)", + CsvPropertyKind.SpanParsable => $"{prop.ElementTypeName}.Parse(element, CultureInfo.InvariantCulture)", + _ => throw new InvalidOperationException() + }; + + // Check if this is an array or List + bool isArray = prop.UnderlyingTypeName.EndsWith("[]"); + + if (isArray) + { + return $"ParseListToArray({spanAccess}, '{delimiter}', static element => {elementParseExpr})"; + } + else + { + return $"ParseListToList({spanAccess}, '{delimiter}', static element => {elementParseExpr})"; + } + } } diff --git a/src/SepCsvSourceGenerator.Analyzer/Parser.cs b/src/SepCsvSourceGenerator.Analyzer/Parser.cs index c651c7e..602b09e 100644 --- a/src/SepCsvSourceGenerator.Analyzer/Parser.cs +++ b/src/SepCsvSourceGenerator.Analyzer/Parser.cs @@ -25,6 +25,7 @@ internal sealed class Parser(Compilation compilation, Action reportD private readonly INamedTypeSymbol? _sepReaderHeaderSymbol = compilation.GetTypeByMetadataName("nietras.SeparatedValues.SepReaderHeader"); private readonly INamedTypeSymbol? _iAsyncEnumerableSymbol = compilation.GetTypeByMetadataName("System.Collections.Generic.IAsyncEnumerable`1"); private readonly INamedTypeSymbol? _iEnumerableSymbol = compilation.GetTypeByMetadataName("System.Collections.Generic.IEnumerable`1"); + private readonly INamedTypeSymbol? _listSymbol = compilation.GetTypeByMetadataName("System.Collections.Generic.List`1"); private readonly INamedTypeSymbol? _cancellationTokenSymbol = compilation.GetTypeByMetadataName("System.Threading.CancellationToken"); private readonly INamedTypeSymbol? _dateTimeSymbol = compilation.GetSpecialType(SpecialType.System_DateTime); private readonly INamedTypeSymbol? _dateTimeOffsetSymbol = compilation.GetTypeByMetadataName("System.DateTimeOffset"); @@ -183,49 +184,78 @@ public List GetCsvMethodDefinitions(ImmutableArray) + bool isListType = IsListType(underlyingType, out ITypeSymbol elementType); - if (isDateOrTime) + ITypeSymbol typeToAnalyze = isListType ? elementType : underlyingType; + + if (isListType) { - kind = CsvPropertyKind.DateOrTime; - AttributeData? dateFormatAttr = propertySymbol.GetAttributes().FirstOrDefault(ad => - SymbolEqualityComparer.Default.Equals(ad.AttributeClass, _csvDateFormatAttributeSymbol)); - if (dateFormatAttr == null || dateFormatAttr.ConstructorArguments.Length == 0 || - string.IsNullOrWhiteSpace(dateFormatAttr.ConstructorArguments[0].Value as string)) + kind = CsvPropertyKind.List; + elementTypeName = elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + + // Determine the kind of the element type + var (elemKind, elemDateFmt) = DetermineTypeKind(elementType, propertySymbol, true); + + if (elemKind == CsvPropertyKind.DateOrTime && elemDateFmt == null) { Diag(Diagnostic.Create(DiagnosticDescriptors.MissingDateFormatAttribute, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name)); continue; } - dateFormat = dateFormatAttr.ConstructorArguments[0].Value as string; - } - else if (underlyingType.BaseType != null && SymbolEqualityComparer.Default.Equals(underlyingType.BaseType, _enumSymbol)) - { - kind = CsvPropertyKind.Enum; + + elementKind = elemKind; + elementDateFormat = elemDateFmt; + + // Validate that element type is parsable + if (elemKind == CsvPropertyKind.SpanParsable) + { + bool isSpanParsable = elementType.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, _iSpanParsableSymbol)); + if (!isSpanParsable && elementType is ITypeParameterSymbol typeParameter) + { + isSpanParsable = typeParameter.ConstraintTypes.SelectMany(t => t.AllInterfaces.Concat([t.OriginalDefinition as INamedTypeSymbol])).Any(i => SymbolEqualityComparer.Default.Equals(i?.OriginalDefinition, _iSpanParsableSymbol)); + } + + if (!isSpanParsable) + { + Diag(Diagnostic.Create(DiagnosticDescriptors.PropertyNotParsable, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name, elementType.Name)); + continue; + } + } } - else if (SymbolEqualityComparer.Default.Equals(underlyingType.OriginalDefinition, _stringSymbol)) + else { - kind = CsvPropertyKind.String; - } + // Not a list type - handle as scalar + var (scalarKind, scalarDateFmt) = DetermineTypeKind(typeToAnalyze, propertySymbol, true); + kind = scalarKind; + dateFormat = scalarDateFmt; - if (kind == CsvPropertyKind.SpanParsable) - { - bool isSpanParsable = underlyingType.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, _iSpanParsableSymbol)); - if (!isSpanParsable && underlyingType is ITypeParameterSymbol typeParameter) + if (kind == CsvPropertyKind.DateOrTime && dateFormat == null) { - isSpanParsable = typeParameter.ConstraintTypes.SelectMany(t => t.AllInterfaces.Concat([t.OriginalDefinition as INamedTypeSymbol])).Any(i => SymbolEqualityComparer.Default.Equals(i?.OriginalDefinition, _iSpanParsableSymbol)); + Diag(Diagnostic.Create(DiagnosticDescriptors.MissingDateFormatAttribute, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name)); + continue; } - if (!isSpanParsable) + if (kind == CsvPropertyKind.SpanParsable) { - Diag(Diagnostic.Create(DiagnosticDescriptors.PropertyNotParsable, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name, underlyingType.Name)); - continue; + bool isSpanParsable = typeToAnalyze.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, _iSpanParsableSymbol)); + if (!isSpanParsable && typeToAnalyze is ITypeParameterSymbol typeParameter) + { + isSpanParsable = typeParameter.ConstraintTypes.SelectMany(t => t.AllInterfaces.Concat([t.OriginalDefinition as INamedTypeSymbol])).Any(i => SymbolEqualityComparer.Default.Equals(i?.OriginalDefinition, _iSpanParsableSymbol)); + } + + if (!isSpanParsable) + { + Diag(Diagnostic.Create(DiagnosticDescriptors.PropertyNotParsable, propertySymbol.Locations.FirstOrDefault()!, propertySymbol.Name, typeToAnalyze.Name)); + continue; + } } } @@ -237,7 +267,11 @@ public List GetCsvMethodDefinitions(ImmutableArray + if (type is INamedTypeSymbol namedType && + namedType.IsGenericType && + SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, _listSymbol)) + { + elementType = namedType.TypeArguments[0]; + return true; + } + + return false; + } + + private (CsvPropertyKind kind, string? dateFormat) DetermineTypeKind(ITypeSymbol type, IPropertySymbol propertySymbol, bool allowDateFormat) + { + string? dateFormat = null; + var kind = CsvPropertyKind.SpanParsable; + + bool isDateOrTime = SymbolEqualityComparer.Default.Equals(type, _dateTimeSymbol) || + SymbolEqualityComparer.Default.Equals(type, _dateTimeOffsetSymbol) || + SymbolEqualityComparer.Default.Equals(type, _dateOnlySymbol) || + SymbolEqualityComparer.Default.Equals(type, _timeOnlySymbol); + + if (isDateOrTime) + { + kind = CsvPropertyKind.DateOrTime; + if (allowDateFormat) + { + AttributeData? dateFormatAttr = propertySymbol.GetAttributes().FirstOrDefault(ad => + SymbolEqualityComparer.Default.Equals(ad.AttributeClass, _csvDateFormatAttributeSymbol)); + if (dateFormatAttr != null && dateFormatAttr.ConstructorArguments.Length > 0 && + !string.IsNullOrWhiteSpace(dateFormatAttr.ConstructorArguments[0].Value as string)) + { + dateFormat = dateFormatAttr.ConstructorArguments[0].Value as string; + } + // If date format not found, dateFormat remains null, and kind is DateOrTime + // The caller will check for this and emit a diagnostic + } + } + else if (type.BaseType != null && SymbolEqualityComparer.Default.Equals(type.BaseType, _enumSymbol)) + { + kind = CsvPropertyKind.Enum; + } + else if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, _stringSymbol)) + { + kind = CsvPropertyKind.String; + } + + return (kind, dateFormat); + } + private bool ValidateMethodSignature(IMethodSymbol methodSymbol, MethodDeclarationSyntax methodSyntax, out bool isAsync, [NotNullWhen(true)] out string? readerParameterName, out string? headersParameterName, out string? ctParameterName) { isAsync = false; diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/IntListProperty.generated.txt b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/IntListProperty.generated.txt new file mode 100644 index 0000000..3e29286 --- /dev/null +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/IntListProperty.generated.txt @@ -0,0 +1,81 @@ +// +#nullable enable +using nietras.SeparatedValues; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Test +{ + public partial class MyRecord + { + public static async partial System.Collections.Generic.IAsyncEnumerable ParseRecords(nietras.SeparatedValues.SepReader reader, [EnumeratorCancellation] System.Threading.CancellationToken ct) + { + int NumbersNdx; + + if (!reader.Header.TryIndexOf("Numbers", out NumbersNdx)) + { + NumbersNdx = -1; + } + + await foreach (SepReader.Row row in reader + #if NET10_0_OR_GREATER + .WithCancellation(ct).ConfigureAwait(false) + #endif + ) + { + ct.ThrowIfCancellationRequested(); + + global::Test.MyRecord ret = new global::Test.MyRecord() + { + }; + if (NumbersNdx != -1) + { + ret.Numbers = ParseListToList(row[NumbersNdx].Span, ',', static element => int.Parse(element, CultureInfo.InvariantCulture)); + } + yield return ret; + } + } + + private static T[] ParseListToArray(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return global::System.Array.Empty(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list.ToArray(); + } + + private static global::System.Collections.Generic.List ParseListToList(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return new global::System.Collections.Generic.List(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list; + } + } +} diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/StringArrayProperty.generated.txt b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/StringArrayProperty.generated.txt new file mode 100644 index 0000000..1fe31da --- /dev/null +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/StringArrayProperty.generated.txt @@ -0,0 +1,81 @@ +// +#nullable enable +using nietras.SeparatedValues; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Test +{ + public partial class MyRecord + { + public static async partial System.Collections.Generic.IAsyncEnumerable ParseRecords(nietras.SeparatedValues.SepReader reader, [EnumeratorCancellation] System.Threading.CancellationToken ct) + { + int TagsNdx; + + if (!reader.Header.TryIndexOf("Tags", out TagsNdx)) + { + TagsNdx = -1; + } + + await foreach (SepReader.Row row in reader + #if NET10_0_OR_GREATER + .WithCancellation(ct).ConfigureAwait(false) + #endif + ) + { + ct.ThrowIfCancellationRequested(); + + global::Test.MyRecord ret = new global::Test.MyRecord() + { + }; + if (TagsNdx != -1) + { + ret.Tags = ParseListToArray(row[TagsNdx].Span, ',', static element => element.ToString()); + } + yield return ret; + } + } + + private static T[] ParseListToArray(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return global::System.Array.Empty(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list.ToArray(); + } + + private static global::System.Collections.Generic.List ParseListToList(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return new global::System.Collections.Generic.List(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list; + } + } +} diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs b/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs index a563e2a..620a10d 100644 --- a/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs @@ -516,6 +516,58 @@ public partial class MyRecord RunTestAsync(source, "MultipleHeaderNames.generated.txt"); } + [Fact] + public void Emitter_GeneratesCorrectCode_ForStringArrayProperty() + { + var source = """ + using System; + using System.Collections.Generic; + using System.Threading; + using AWise.SepCsvSourceGenerator; + using nietras.SeparatedValues; + + namespace Test + { + public partial class MyRecord + { + [CsvHeaderName("Tags")] + public string[]? Tags { get; set; } + + [GenerateCsvParser] + public static partial IAsyncEnumerable ParseRecords(SepReader reader, CancellationToken ct); + } + } + + """; + RunTestAsync(source, "StringArrayProperty.generated.txt"); + } + + [Fact] + public void Emitter_GeneratesCorrectCode_ForIntListProperty() + { + var source = """ + using System; + using System.Collections.Generic; + using System.Threading; + using AWise.SepCsvSourceGenerator; + using nietras.SeparatedValues; + + namespace Test + { + public partial class MyRecord + { + [CsvHeaderName("Numbers")] + public List? Numbers { get; set; } + + [GenerateCsvParser] + public static partial IAsyncEnumerable ParseRecords(SepReader reader, CancellationToken ct); + } + } + + """; + RunTestAsync(source, "IntListProperty.generated.txt"); + } + private static void RunTestAsync(string source, string baselineFileName) { source = "#nullable enable\n" + source; diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs index cf9f91c..5cf17dc 100644 --- a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs @@ -220,4 +220,64 @@ public void MissingAliasedColumns() Assert.Equal("Missing required column with any of the following names: 'a', 'b' for required property 'Value'.", ex.Message); } } + + public partial class MyRecordWithStringArray + { + [CsvHeaderName("Tags")] + public string[]? Tags { get; set; } + + [GenerateCsvParser(IncludeProperties = true)] + public static partial IEnumerable Parse(SepReader reader); + } + + [Fact] + public void ParseStringArray() + { + using var reader = Sep.Reader().FromText("Tags\ntag1,tag2,tag3\na,b"); + var list = MyRecordWithStringArray.Parse(reader).ToList(); + Assert.Equal(2, list.Count); + + Assert.NotNull(list[0].Tags); + Assert.Equal(new[] { "tag1", "tag2", "tag3" }, list[0].Tags); + + Assert.NotNull(list[1].Tags); + Assert.Equal(new[] { "a", "b" }, list[1].Tags); + } + + [Fact] + public void ParseEmptyStringArray() + { + using var reader = Sep.Reader().FromText("Tags\n\na,b,c"); + var list = MyRecordWithStringArray.Parse(reader).ToList(); + Assert.Equal(2, list.Count); + + Assert.NotNull(list[0].Tags); + Assert.Empty(list[0].Tags!); + + Assert.NotNull(list[1].Tags); + Assert.Equal(new[] { "a", "b", "c" }, list[1].Tags); + } + + public partial class MyRecordWithIntList + { + [CsvHeaderName("Numbers")] + public List? Numbers { get; set; } + + [GenerateCsvParser(IncludeProperties = true)] + public static partial IEnumerable Parse(SepReader reader); + } + + [Fact] + public void ParseIntList() + { + using var reader = Sep.Reader().FromText("Numbers\n1,2,3\n10,20"); + var list = MyRecordWithIntList.Parse(reader).ToList(); + Assert.Equal(2, list.Count); + + Assert.NotNull(list[0].Numbers); + Assert.Equal(new List { 1, 2, 3 }, list[0].Numbers); + + Assert.NotNull(list[1].Numbers); + Assert.Equal(new List { 10, 20 }, list[1].Numbers); + } } From 3e182f4db092eb5a78a5e9008b908a9d43e045ff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 22:57:45 +0000 Subject: [PATCH 3/5] Add tests for enum arrays and update documentation Co-authored-by: AustinWise <7751+AustinWise@users.noreply.github.com> --- README.md | 24 ++++++ .../Baselines/EnumArrayProperty.generated.txt | 81 +++++++++++++++++++ .../CsvGeneratorEmitterTests.cs | 28 +++++++ .../RunGeneratedParserTests.cs | 23 ++++++ 4 files changed, 156 insertions(+) create mode 100644 tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/EnumArrayProperty.generated.txt diff --git a/README.md b/README.md index ddd281f..22da41a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,30 @@ exists in the CSV file. The only types that are supported for parsing are enums and types that implement [ISpanParsable](https://learn.microsoft.com/en-us/dotnet/api/system.ispanparsable-1). +Arrays and `List` are also supported. When a property is an array or `List`, the CSV column value is split by commas +and each element is parsed according to the element type. For example: + +```csharp +public partial class MyRecord +{ + [CsvHeaderName("Tags")] + public string[]? Tags { get; set; } + + [CsvHeaderName("Numbers")] + public List? Numbers { get; set; } + + [GenerateCsvParser(IncludeProperties = true)] + public static partial IEnumerable Parse(SepReader reader); +} +``` + +With CSV data like: +``` +Tags,Numbers +tag1,tag2,tag3,1,2,3 +a,b,10,20 +``` + `DateTime`, `DateTimeOffset`, `DateOnly`, and `TimeOnly` are given special treatment. They are parsed with their respective `ParseExact` methods using `CultureInfo.InvariantCulture`. Specify the date-time format using the `CsvDateFormat` attribute. diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/EnumArrayProperty.generated.txt b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/EnumArrayProperty.generated.txt new file mode 100644 index 0000000..d0e2f9e --- /dev/null +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/Baselines/EnumArrayProperty.generated.txt @@ -0,0 +1,81 @@ +// +#nullable enable +using nietras.SeparatedValues; +using System.Globalization; +using System.Runtime.CompilerServices; + +namespace Test +{ + public partial class MyRecord + { + public static async partial System.Collections.Generic.IAsyncEnumerable ParseRecords(nietras.SeparatedValues.SepReader reader, [EnumeratorCancellation] System.Threading.CancellationToken ct) + { + int StatusesNdx; + + if (!reader.Header.TryIndexOf("Statuses", out StatusesNdx)) + { + StatusesNdx = -1; + } + + await foreach (SepReader.Row row in reader + #if NET10_0_OR_GREATER + .WithCancellation(ct).ConfigureAwait(false) + #endif + ) + { + ct.ThrowIfCancellationRequested(); + + global::Test.MyRecord ret = new global::Test.MyRecord() + { + }; + if (StatusesNdx != -1) + { + ret.Statuses = ParseListToArray(row[StatusesNdx].Span, ',', static element => global::System.Enum.Parse(element)); + } + yield return ret; + } + } + + private static T[] ParseListToArray(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return global::System.Array.Empty(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list.ToArray(); + } + + private static global::System.Collections.Generic.List ParseListToList(global::System.ReadOnlySpan span, char delimiter, global::System.Func, T> parser) + { + if (span.IsEmpty) + { + return new global::System.Collections.Generic.List(); + } + + var list = new global::System.Collections.Generic.List(); + int start = 0; + for (int i = 0; i < span.Length; i++) + { + if (span[i] == delimiter) + { + list.Add(parser(span.Slice(start, i - start))); + start = i + 1; + } + } + list.Add(parser(span.Slice(start))); + return list; + } + } +} diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs b/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs index 620a10d..a47797f 100644 --- a/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/CsvGeneratorEmitterTests.cs @@ -568,6 +568,34 @@ public partial class MyRecord RunTestAsync(source, "IntListProperty.generated.txt"); } + [Fact] + public void Emitter_GeneratesCorrectCode_ForEnumArrayProperty() + { + var source = """ + using System; + using System.Collections.Generic; + using System.Threading; + using AWise.SepCsvSourceGenerator; + using nietras.SeparatedValues; + + namespace Test + { + public enum Status { Active, Inactive, Pending } + + public partial class MyRecord + { + [CsvHeaderName("Statuses")] + public Status[]? Statuses { get; set; } + + [GenerateCsvParser] + public static partial IAsyncEnumerable ParseRecords(SepReader reader, CancellationToken ct); + } + } + + """; + RunTestAsync(source, "EnumArrayProperty.generated.txt"); + } + private static void RunTestAsync(string source, string baselineFileName) { source = "#nullable enable\n" + source; diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs index 5cf17dc..6497195 100644 --- a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs @@ -280,4 +280,27 @@ public void ParseIntList() Assert.NotNull(list[1].Numbers); Assert.Equal(new List { 10, 20 }, list[1].Numbers); } + + public partial class MyRecordWithEnumArray + { + [CsvHeaderName("Values")] + public MyEnum[]? Values { get; set; } + + [GenerateCsvParser(IncludeProperties = true)] + public static partial IEnumerable Parse(SepReader reader); + } + + [Fact] + public void ParseEnumArray() + { + using var reader = Sep.Reader().FromText("Values\nA,B,C\nB,A"); + var list = MyRecordWithEnumArray.Parse(reader).ToList(); + Assert.Equal(2, list.Count); + + Assert.NotNull(list[0].Values); + Assert.Equal(new[] { MyEnum.A, MyEnum.B, MyEnum.C }, list[0].Values); + + Assert.NotNull(list[1].Values); + Assert.Equal(new[] { MyEnum.B, MyEnum.A }, list[1].Values); + } } From d29510332504deeedb46b93206d18566c84ee737 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 14 Dec 2025 23:00:44 +0000 Subject: [PATCH 4/5] Fix delimiter escaping and improve code quality based on review feedback Co-authored-by: AustinWise <7751+AustinWise@users.noreply.github.com> --- src/SepCsvSourceGenerator.Analyzer/Emitter.cs | 15 ++++++++++++--- src/SepCsvSourceGenerator.Analyzer/Parser.cs | 3 +-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/SepCsvSourceGenerator.Analyzer/Emitter.cs b/src/SepCsvSourceGenerator.Analyzer/Emitter.cs index 3660c0f..37caacc 100644 --- a/src/SepCsvSourceGenerator.Analyzer/Emitter.cs +++ b/src/SepCsvSourceGenerator.Analyzer/Emitter.cs @@ -348,7 +348,16 @@ private static string GetListParseExpression(CsvPropertyDefinition prop, string Debug.Assert(prop.ElementKind != null); Debug.Assert(prop.ElementTypeName != null); - string delimiter = prop.ListDelimiter == '\'' ? "\\'" : prop.ListDelimiter.ToString(); + // Properly escape the delimiter for a C# character literal + string delimiterLiteral = prop.ListDelimiter switch + { + '\'' => @"\'", + '\\' => @"\\", + '\n' => @"\n", + '\r' => @"\r", + '\t' => @"\t", + _ => prop.ListDelimiter.ToString() + }; // Generate element parsing expression string elementParseExpr = prop.ElementKind switch @@ -365,11 +374,11 @@ private static string GetListParseExpression(CsvPropertyDefinition prop, string if (isArray) { - return $"ParseListToArray({spanAccess}, '{delimiter}', static element => {elementParseExpr})"; + return $"ParseListToArray({spanAccess}, '{delimiterLiteral}', static element => {elementParseExpr})"; } else { - return $"ParseListToList({spanAccess}, '{delimiter}', static element => {elementParseExpr})"; + return $"ParseListToList({spanAccess}, '{delimiterLiteral}', static element => {elementParseExpr})"; } } } diff --git a/src/SepCsvSourceGenerator.Analyzer/Parser.cs b/src/SepCsvSourceGenerator.Analyzer/Parser.cs index 602b09e..5427207 100644 --- a/src/SepCsvSourceGenerator.Analyzer/Parser.cs +++ b/src/SepCsvSourceGenerator.Analyzer/Parser.cs @@ -304,8 +304,6 @@ private bool IsNullableType(ITypeSymbol type, out ITypeSymbol underlyingType) private bool IsListType(ITypeSymbol type, out ITypeSymbol elementType) { - elementType = null!; - // Check for arrays (e.g., string[], int[]) if (type is IArrayTypeSymbol arrayType) { @@ -322,6 +320,7 @@ private bool IsListType(ITypeSymbol type, out ITypeSymbol elementType) return true; } + elementType = null!; return false; } From 4059a72d2bbfae856f9835a1b83243b0417cee28 Mon Sep 17 00:00:00 2001 From: Austin Wise Date: Tue, 16 Dec 2025 04:59:02 +0000 Subject: [PATCH 5/5] Add a test that shows how to use Unescape. --- .../RunGeneratedParserTests.cs | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs index 6497195..7eefe88 100644 --- a/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs +++ b/tests/SepCsvSourceGenerator.Analyzer.Tests/RunGeneratedParserTests.cs @@ -303,4 +303,40 @@ public void ParseEnumArray() Assert.NotNull(list[1].Values); Assert.Equal(new[] { MyEnum.B, MyEnum.A }, list[1].Values); } + + public partial class MyRecordWithMultipleLists + { + [CsvHeaderName("Tags")] + public string[]? Tags { get; set; } + + [CsvHeaderName("Numbers")] + public List? Numbers { get; set; } + + [GenerateCsvParser(IncludeProperties = true)] + public static partial IEnumerable Parse(SepReader reader); + } + + [Fact] + public void MultipleLists() + { + var options = new SepReaderOptions() + { + Unescape = true, + }; + using var reader = options.FromText(""" +Tags,Numbers +"tag1,tag2,tag3","1,2,3" +"a,b","10,20" +"""); + var list = MyRecordWithMultipleLists.Parse(reader).ToList(); + Assert.Equal(2, list.Count); + + Assert.NotNull(list[0].Tags); + Assert.Equal(new[] { "tag1", "tag2", "tag3" }, list[0].Tags); + Assert.Equal(new[] { 1, 2, 3 }, list[0].Numbers); + + Assert.NotNull(list[1].Tags); + Assert.Equal(new[] { "a", "b" }, list[1].Tags); + Assert.Equal(new[] { 10, 20 }, list[1].Numbers); + } }