Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` are also supported. When a property is an array or `List<T>`, 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<int>? Numbers { get; set; }

[GenerateCsvParser(IncludeProperties = true)]
public static partial IEnumerable<MyRecord> 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.

Expand Down
6 changes: 5 additions & 1 deletion src/SepCsvSourceGenerator.Analyzer/CsvPropertyDefinition.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,8 @@ internal record CsvPropertyDefinition(
string? DateFormat,
bool IsRequiredMember,
bool IsInitOnly,
CsvPropertyKind Kind);
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
1 change: 1 addition & 0 deletions src/SepCsvSourceGenerator.Analyzer/CsvPropertyKind.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ internal enum CsvPropertyKind
DateOrTime,
String,
Enum,
List,
}
115 changes: 115 additions & 0 deletions src/SepCsvSourceGenerator.Analyzer/Emitter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -104,6 +111,68 @@ private void AppendLine(string line)
return _builder.ToString();
}

private void GenerateListHelperMethods()
{
AppendLine("");
AppendLine("private static T[] ParseListToArray<T>(global::System.ReadOnlySpan<char> span, char delimiter, global::System.Func<global::System.ReadOnlySpan<char>, T> parser)");
AppendLine("{");
IncreaseIndent();
AppendLine("if (span.IsEmpty)");
AppendLine("{");
IncreaseIndent();
AppendLine("return global::System.Array.Empty<T>();");
DecreaseIndent();
AppendLine("}");
AppendLine("");
AppendLine("var list = new global::System.Collections.Generic.List<T>();");
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<T> ParseListToList<T>(global::System.ReadOnlySpan<char> span, char delimiter, global::System.Func<global::System.ReadOnlySpan<char>, T> parser)");
AppendLine("{");
IncreaseIndent();
AppendLine("if (span.IsEmpty)");
AppendLine("{");
IncreaseIndent();
AppendLine("return new global::System.Collections.Generic.List<T>();");
DecreaseIndent();
AppendLine("}");
AppendLine("");
AppendLine("var list = new global::System.Collections.Generic.List<T>();");
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);
Expand Down Expand Up @@ -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)",
Expand All @@ -266,4 +341,44 @@ 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);

// 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
{
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<T>
bool isArray = prop.UnderlyingTypeName.EndsWith("[]");

if (isArray)
{
return $"ParseListToArray({spanAccess}, '{delimiterLiteral}', static element => {elementParseExpr})";
}
else
{
return $"ParseListToList({spanAccess}, '{delimiterLiteral}', static element => {elementParseExpr})";
}
}
}
148 changes: 121 additions & 27 deletions src/SepCsvSourceGenerator.Analyzer/Parser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ internal sealed class Parser(Compilation compilation, Action<Diagnostic> 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");
Expand Down Expand Up @@ -183,49 +184,78 @@ public List<CsvMethodDefinition> GetCsvMethodDefinitions(ImmutableArray<MethodDe
}

string? dateFormat = null;
string? elementTypeName = null;
CsvPropertyKind? elementKind = null;
string? elementDateFormat = null;
char listDelimiter = ',';
var kind = CsvPropertyKind.SpanParsable;

bool isNullableType = IsNullableType(propertySymbol.Type, out ITypeSymbol underlyingType);

bool isDateOrTime = SymbolEqualityComparer.Default.Equals(underlyingType, _dateTimeSymbol) ||
SymbolEqualityComparer.Default.Equals(underlyingType, _dateTimeOffsetSymbol) ||
SymbolEqualityComparer.Default.Equals(underlyingType, _dateOnlySymbol) ||
SymbolEqualityComparer.Default.Equals(underlyingType, _timeOnlySymbol);
// Check if this is a list type (array or List<T>)
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;
}
}
}

Expand All @@ -237,7 +267,11 @@ public List<CsvMethodDefinition> GetCsvMethodDefinitions(ImmutableArray<MethodDe
dateFormat,
propertySymbol.IsRequired,
propertySymbol.SetMethod?.IsInitOnly ?? false,
kind
kind,
elementTypeName,
elementKind,
elementDateFormat,
listDelimiter
));
}
currentType = currentType.BaseType;
Expand Down Expand Up @@ -268,6 +302,66 @@ private bool IsNullableType(ITypeSymbol type, out ITypeSymbol underlyingType)
return false;
}

private bool IsListType(ITypeSymbol type, out ITypeSymbol elementType)
{
// Check for arrays (e.g., string[], int[])
if (type is IArrayTypeSymbol arrayType)
{
elementType = arrayType.ElementType;
return true;
}

// Check for List<T>
if (type is INamedTypeSymbol namedType &&
namedType.IsGenericType &&
SymbolEqualityComparer.Default.Equals(namedType.OriginalDefinition, _listSymbol))
{
elementType = namedType.TypeArguments[0];
return true;
}

elementType = null!;
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;
Expand Down
Loading