Skip to content
Draft
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ publish/

# NuGet Packages
*.nupkg
.nuget/
# The packages folder can be ignored because of Package Restore
**/packages/*
# except build/, which is used as an MSBuild target.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing.XUnit" Version="1.1.2" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.10.0" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\CsvGenerator\CsvGenerator.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Testing;
using Microsoft.CodeAnalysis.Text;
using Xunit;
using System.Text;

namespace CsvGenerator.Test;

public class CsvGeneratorTests
{
[Fact]
public async Task CsvWithHeadersAndRows_GeneratesClass()
{
string csvContent = "Name,Country\nTokyo,Japan\nDelhi,India\n";

string expectedGenerated = @"// <auto-generated />

namespace CsvGenerated
{
/// <summary>Strongly-typed class generated from Cities.csv.</summary>
public sealed class Cities
{
public string Name { get; }
public string Country { get; }

private Cities(string name, string country)
{
Name = name;
Country = country;
}

/// <summary>All rows from the CSV data.</summary>
public static global::System.Collections.Generic.IReadOnlyList<Cities> All { get; } =
new Cities[]
{
new Cities(""Tokyo"", ""Japan""),
new Cities(""Delhi"", ""India""),
};

public override string ToString() =>
$""Name={Name}, Country={Country}"";
}
}
";

var test = new CSharpSourceGeneratorTest<CsvIncrementalGenerator, DefaultVerifier>
{
TestCode = "// Intentionally empty, CSV generator does not need C# input.",
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
};

test.TestState.AdditionalFiles.Add(("Data/Cities.csv", csvContent));

test.TestState.GeneratedSources.Add(
(typeof(CsvIncrementalGenerator), "Cities.g.cs", expectedGenerated));

await test.RunAsync();
}

[Fact]
public async Task CsvWithHeaderOnly_NoOutput()
{
// Only a header row, no data rows – generator should not produce output.
string csvContent = "Name,Country\n";

var test = new CSharpSourceGeneratorTest<CsvIncrementalGenerator, DefaultVerifier>
{
TestCode = "// Intentionally empty.",
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
};

test.TestState.AdditionalFiles.Add(("Data/Cities.csv", csvContent));

// No generated sources expected.
await test.RunAsync();
}

[Fact]
public async Task NonCsvAdditionalFile_IsIgnored()
{
var test = new CSharpSourceGeneratorTest<CsvIncrementalGenerator, DefaultVerifier>
{
TestCode = "// Intentionally empty.",
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
};

test.TestState.AdditionalFiles.Add(("Data/readme.txt", "This is not a CSV file."));

// No generated sources expected.
await test.RunAsync();
}

[Fact]
public async Task CsvWithThreeColumns_GeneratesAllProperties()
{
string csvContent = "Id,Name,Score\n1,Alice,95\n2,Bob,87\n";

string expectedGenerated = @"// <auto-generated />

namespace CsvGenerated
{
/// <summary>Strongly-typed class generated from Students.csv.</summary>
public sealed class Students
{
public string Id { get; }
public string Name { get; }
public string Score { get; }

private Students(string id, string name, string score)
{
Id = id;
Name = name;
Score = score;
}

/// <summary>All rows from the CSV data.</summary>
public static global::System.Collections.Generic.IReadOnlyList<Students> All { get; } =
new Students[]
{
new Students(""1"", ""Alice"", ""95""),
new Students(""2"", ""Bob"", ""87""),
};

public override string ToString() =>
$""Id={Id}, Name={Name}, Score={Score}"";
}
}
";

var test = new CSharpSourceGeneratorTest<CsvIncrementalGenerator, DefaultVerifier>
{
TestCode = "// Intentionally empty.",
ReferenceAssemblies = ReferenceAssemblies.Net.Net80,
};

test.TestState.AdditionalFiles.Add(("Data/Students.csv", csvContent));

test.TestState.GeneratedSources.Add(
(typeof(CsvIncrementalGenerator), "Students.g.cs", expectedGenerated));

await test.RunAsync();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
#nullable enable

using System.Collections.Immutable;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace CsvGenerator;

/// <summary>
/// A source generator that reads <c>.csv</c> additional files and produces a
/// strongly-typed C# class for each one. The first row of the CSV is treated
/// as column headers (property names) and every subsequent row becomes a static
/// instance exposed through a generated <c>All</c> property.
/// </summary>
[Generator]
public class CsvIncrementalGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Filter additional files to only .csv files.
IncrementalValuesProvider<AdditionalText> csvFiles =
context.AdditionalTextsProvider
.Where(static file => Path.GetExtension(file.Path)
.Equals(".csv", System.StringComparison.OrdinalIgnoreCase));

// Read file content and pair with file name.
IncrementalValuesProvider<(string ClassName, string Text)?> csvData =
csvFiles.Select(static (file, ct) =>
{
string? text = file.GetText(ct)?.ToString();
if (text is null)
return null;

string className = Path.GetFileNameWithoutExtension(file.Path);
return ((string ClassName, string Text)?)(className, text);
});

// Generate source for each CSV file.
context.RegisterSourceOutput(csvData, static (spc, csv) =>
{
if (csv is null)
return;

string? source = GenerateClassFromCsv(csv.Value.ClassName, csv.Value.Text);
if (source is not null)
{
spc.AddSource($"{csv.Value.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8));
}
});
}

private static string? GenerateClassFromCsv(string className, string csvText)
{
string[] lines = csvText.Split(new[] { "\r\n", "\n" }, System.StringSplitOptions.RemoveEmptyEntries);
if (lines.Length < 2) // Need at least a header and one data row.
return null;

string[] headers = ParseCsvLine(lines[0]);
if (headers.Length == 0)
return null;

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated />");
sb.AppendLine();
sb.AppendLine("namespace CsvGenerated");
sb.AppendLine("{");
sb.AppendLine($" /// <summary>Strongly-typed class generated from {className}.csv.</summary>");
sb.AppendLine($" public sealed class {className}");
sb.AppendLine(" {");

// Properties (all strings for simplicity)
foreach (string header in headers)
{
sb.AppendLine($" public string {SanitizeIdentifier(header)} {{ get; }}");
}

sb.AppendLine();

// Constructor
sb.Append($" private {className}(");
sb.Append(string.Join(", ", headers.Select(h => $"string {CamelCase(SanitizeIdentifier(h))}")));
sb.AppendLine(")");
sb.AppendLine(" {");
foreach (string header in headers)
{
string prop = SanitizeIdentifier(header);
sb.AppendLine($" {prop} = {CamelCase(prop)};");
}
sb.AppendLine(" }");
sb.AppendLine();

// All property – static list of rows
sb.AppendLine(" /// <summary>All rows from the CSV data.</summary>");
sb.AppendLine($" public static global::System.Collections.Generic.IReadOnlyList<{className}> All {{ get; }} =");
sb.AppendLine($" new {className}[]");
sb.AppendLine(" {");

for (int i = 1; i < lines.Length; i++)
{
string[] values = ParseCsvLine(lines[i]);
// Pad or trim to match header count.
string[] row = new string[headers.Length];
for (int j = 0; j < headers.Length; j++)
{
row[j] = j < values.Length ? values[j] : string.Empty;
}

sb.Append($" new {className}(");
sb.Append(string.Join(", ", row.Select(EscapeString)));
sb.AppendLine("),");
}

sb.AppendLine(" };");

// ToString override
sb.AppendLine();
sb.AppendLine(" public override string ToString() =>");
sb.Append(" $\"");
sb.Append(string.Join(", ", headers.Select(h =>
{
string prop = SanitizeIdentifier(h);
return $"{prop}={{{prop}}}";
})));
sb.AppendLine("\";");

sb.AppendLine(" }");
sb.AppendLine("}");

return sb.ToString();
}

private static string[] ParseCsvLine(string line)
{
// Simple CSV parser – handles quoted fields but not escaped quotes inside quotes.
var fields = new System.Collections.Generic.List<string>();
var current = new StringBuilder();
bool inQuotes = false;

for (int i = 0; i < line.Length; i++)
{
char c = line[i];
if (c == '"')
{
inQuotes = !inQuotes;
}
else if (c == ',' && !inQuotes)
{
fields.Add(current.ToString().Trim());
current.Clear();
}
else
{
current.Append(c);
}
}
fields.Add(current.ToString().Trim());
return fields.ToArray();
}

private static string SanitizeIdentifier(string name)
{
var sb = new StringBuilder();
foreach (char c in name)
{
if (char.IsLetterOrDigit(c) || c == '_')
sb.Append(c);
}

string result = sb.ToString();
if (result.Length == 0)
return "_";

// Ensure starts with letter or underscore.
if (char.IsDigit(result[0]))
result = "_" + result;

// PascalCase first letter.
return char.ToUpperInvariant(result[0]) + result.Substring(1);
}

private static string CamelCase(string name)
{
if (name.Length == 0)
return name;
return char.ToLowerInvariant(name[0]) + name.Substring(1);
}

private static string EscapeString(string value) =>
$"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
}
Loading