Skip to content

Commit 3cc4958

Browse files
Copilotagocke
andcommitted
Add Roslyn source generator samples with attribute-based member generation and CSV-to-C# generation
Co-authored-by: agocke <515774+agocke@users.noreply.github.com>
1 parent 8f65d72 commit 3cc4958

File tree

13 files changed

+649
-0
lines changed

13 files changed

+649
-0
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
11+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
12+
</ItemGroup>
13+
14+
</Project>
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
#nullable enable
2+
3+
using System.Collections.Immutable;
4+
using System.IO;
5+
using System.Linq;
6+
using System.Text;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.Text;
9+
10+
namespace CsvGenerator;
11+
12+
/// <summary>
13+
/// A source generator that reads <c>.csv</c> additional files and produces a
14+
/// strongly-typed C# class for each one. The first row of the CSV is treated
15+
/// as column headers (property names) and every subsequent row becomes a static
16+
/// instance exposed through a generated <c>All</c> property.
17+
/// </summary>
18+
[Generator]
19+
public class CsvIncrementalGenerator : IIncrementalGenerator
20+
{
21+
public void Initialize(IncrementalGeneratorInitializationContext context)
22+
{
23+
// Filter additional files to only .csv files.
24+
IncrementalValuesProvider<AdditionalText> csvFiles =
25+
context.AdditionalTextsProvider
26+
.Where(static file => Path.GetExtension(file.Path)
27+
.Equals(".csv", System.StringComparison.OrdinalIgnoreCase));
28+
29+
// Read file content and pair with file name.
30+
IncrementalValuesProvider<(string ClassName, string Text)?> csvData =
31+
csvFiles.Select(static (file, ct) =>
32+
{
33+
string? text = file.GetText(ct)?.ToString();
34+
if (text is null)
35+
return null;
36+
37+
string className = Path.GetFileNameWithoutExtension(file.Path);
38+
return ((string ClassName, string Text)?)(className, text);
39+
});
40+
41+
// Generate source for each CSV file.
42+
context.RegisterSourceOutput(csvData, static (spc, csv) =>
43+
{
44+
if (csv is null)
45+
return;
46+
47+
string? source = GenerateClassFromCsv(csv.Value.ClassName, csv.Value.Text);
48+
if (source is not null)
49+
{
50+
spc.AddSource($"{csv.Value.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8));
51+
}
52+
});
53+
}
54+
55+
private static string? GenerateClassFromCsv(string className, string csvText)
56+
{
57+
string[] lines = csvText.Split(new[] { "\r\n", "\n" }, System.StringSplitOptions.RemoveEmptyEntries);
58+
if (lines.Length < 2) // Need at least a header and one data row.
59+
return null;
60+
61+
string[] headers = ParseCsvLine(lines[0]);
62+
if (headers.Length == 0)
63+
return null;
64+
65+
var sb = new StringBuilder();
66+
sb.AppendLine("// <auto-generated />");
67+
sb.AppendLine();
68+
sb.AppendLine("namespace CsvGenerated");
69+
sb.AppendLine("{");
70+
sb.AppendLine($" /// <summary>Strongly-typed class generated from {className}.csv.</summary>");
71+
sb.AppendLine($" public sealed class {className}");
72+
sb.AppendLine(" {");
73+
74+
// Properties (all strings for simplicity)
75+
foreach (string header in headers)
76+
{
77+
sb.AppendLine($" public string {SanitizeIdentifier(header)} {{ get; }}");
78+
}
79+
80+
sb.AppendLine();
81+
82+
// Constructor
83+
sb.Append($" private {className}(");
84+
sb.Append(string.Join(", ", headers.Select(h => $"string {CamelCase(SanitizeIdentifier(h))}")));
85+
sb.AppendLine(")");
86+
sb.AppendLine(" {");
87+
foreach (string header in headers)
88+
{
89+
string prop = SanitizeIdentifier(header);
90+
sb.AppendLine($" {prop} = {CamelCase(prop)};");
91+
}
92+
sb.AppendLine(" }");
93+
sb.AppendLine();
94+
95+
// All property – static list of rows
96+
sb.AppendLine(" /// <summary>All rows from the CSV data.</summary>");
97+
sb.AppendLine($" public static global::System.Collections.Generic.IReadOnlyList<{className}> All {{ get; }} =");
98+
sb.AppendLine($" new {className}[]");
99+
sb.AppendLine(" {");
100+
101+
for (int i = 1; i < lines.Length; i++)
102+
{
103+
string[] values = ParseCsvLine(lines[i]);
104+
// Pad or trim to match header count.
105+
string[] row = new string[headers.Length];
106+
for (int j = 0; j < headers.Length; j++)
107+
{
108+
row[j] = j < values.Length ? values[j] : string.Empty;
109+
}
110+
111+
sb.Append($" new {className}(");
112+
sb.Append(string.Join(", ", row.Select(EscapeString)));
113+
sb.AppendLine("),");
114+
}
115+
116+
sb.AppendLine(" };");
117+
118+
// ToString override
119+
sb.AppendLine();
120+
sb.AppendLine(" public override string ToString() =>");
121+
sb.Append(" $\"");
122+
sb.Append(string.Join(", ", headers.Select(h =>
123+
{
124+
string prop = SanitizeIdentifier(h);
125+
return $"{prop}={{{prop}}}";
126+
})));
127+
sb.AppendLine("\";");
128+
129+
sb.AppendLine(" }");
130+
sb.AppendLine("}");
131+
132+
return sb.ToString();
133+
}
134+
135+
private static string[] ParseCsvLine(string line)
136+
{
137+
// Simple CSV parser – handles quoted fields but not escaped quotes inside quotes.
138+
var fields = new System.Collections.Generic.List<string>();
139+
var current = new StringBuilder();
140+
bool inQuotes = false;
141+
142+
for (int i = 0; i < line.Length; i++)
143+
{
144+
char c = line[i];
145+
if (c == '"')
146+
{
147+
inQuotes = !inQuotes;
148+
}
149+
else if (c == ',' && !inQuotes)
150+
{
151+
fields.Add(current.ToString().Trim());
152+
current.Clear();
153+
}
154+
else
155+
{
156+
current.Append(c);
157+
}
158+
}
159+
fields.Add(current.ToString().Trim());
160+
return fields.ToArray();
161+
}
162+
163+
private static string SanitizeIdentifier(string name)
164+
{
165+
var sb = new StringBuilder();
166+
foreach (char c in name)
167+
{
168+
if (char.IsLetterOrDigit(c) || c == '_')
169+
sb.Append(c);
170+
}
171+
172+
string result = sb.ToString();
173+
if (result.Length == 0)
174+
return "_";
175+
176+
// Ensure starts with letter or underscore.
177+
if (char.IsDigit(result[0]))
178+
result = "_" + result;
179+
180+
// PascalCase first letter.
181+
return char.ToUpperInvariant(result[0]) + result.Substring(1);
182+
}
183+
184+
private static string CamelCase(string name)
185+
{
186+
if (name.Length == 0)
187+
return name;
188+
return char.ToLowerInvariant(name[0]) + name.Substring(1);
189+
}
190+
191+
private static string EscapeString(string value) =>
192+
$"\"{value.Replace("\\", "\\\\").Replace("\"", "\\\"")}\"";
193+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\CsvGenerator\CsvGenerator.csproj"
12+
OutputItemType="Analyzer"
13+
ReferenceOutputAssembly="false" />
14+
</ItemGroup>
15+
16+
<!-- Expose .csv files as additional files to the source generator -->
17+
<ItemGroup>
18+
<AdditionalFiles Include="Data\*.csv" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
Name,Country,Population
2+
Tokyo,Japan,13960000
3+
Delhi,India,11030000
4+
Shanghai,China,24870000
5+
São Paulo,Brazil,12330000
6+
Mexico City,Mexico,9210000
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
// Demonstrate using the strongly-typed class generated from Cities.csv.
2+
3+
using CsvGenerated;
4+
5+
Console.WriteLine("=== Cities loaded from CSV ===");
6+
Console.WriteLine();
7+
8+
foreach (Cities city in Cities.All)
9+
{
10+
Console.WriteLine(city);
11+
}
12+
13+
Console.WriteLine();
14+
Console.WriteLine($"Total cities: {Cities.All.Count}");
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
languages:
3+
- csharp
4+
products:
5+
- dotnet
6+
page_type: sample
7+
name: "CSV to C# Source Generator"
8+
urlFragment: "roslyn-csv-generator"
9+
description: "A Roslyn incremental source generator that compiles CSV files into strongly-typed C# classes at build time."
10+
---
11+
12+
# CsvGenerator – Non-C# File to C# Source Generator
13+
14+
This sample demonstrates how to write an [incremental source generator](https://learn.microsoft.com/dotnet/csharp/roslyn-sdk/source-generators-overview) that reads a non-C# source file (CSV) and generates C# code from it at compile time.
15+
16+
## What it does
17+
18+
The generator scans for `.csv` files registered as `AdditionalFiles` in the project. For each CSV file it finds, it generates a strongly-typed C# class where:
19+
20+
- The **first row** is treated as column headers, which become property names.
21+
- Each **subsequent row** becomes a static instance accessible through a generated `All` property.
22+
- A `ToString()` override is generated for easy display.
23+
24+
## Project structure
25+
26+
| Project | Description |
27+
|---------|-------------|
28+
| `CsvGenerator` | The source generator (targets `netstandard2.0`). |
29+
| `CsvGeneratorDemo` | A console application that uses the generator with a sample CSV file. |
30+
31+
## How CSV files are exposed to the generator
32+
33+
In the demo project's `.csproj`, CSV files are included as additional files:
34+
35+
```xml
36+
<ItemGroup>
37+
<AdditionalFiles Include="Data\*.csv" />
38+
</ItemGroup>
39+
```
40+
41+
## Running
42+
43+
```bash
44+
dotnet run --project CsvGeneratorDemo
45+
```
46+
47+
### Expected output
48+
49+
```text
50+
=== Cities loaded from CSV ===
51+
52+
Name=Tokyo, Country=Japan, Population=13960000
53+
Name=Delhi, Country=India, Population=11030000
54+
Name=Shanghai, Country=China, Population=24870000
55+
Name=São Paulo, Country=Brazil, Population=12330000
56+
Name=Mexico City, Country=Mexico, Population=9210000
57+
58+
Total cities: 5
59+
```
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
</PropertyGroup>
9+
10+
<ItemGroup>
11+
<ProjectReference Include="..\GenerateMembersGenerator\GenerateMembersGenerator.csproj"
12+
OutputItemType="Analyzer"
13+
ReferenceOutputAssembly="false" />
14+
</ItemGroup>
15+
16+
</Project>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
using GenerateMembersGenerator;
2+
3+
// Apply the [GenerateMembers] attribute so the source generator creates
4+
// a Describe() method and a PropertyNames list for this type.
5+
6+
var person = new Person { FirstName = "Alice", LastName = "Smith", Age = 30 };
7+
8+
Console.WriteLine(person.Describe());
9+
Console.WriteLine("Properties:");
10+
foreach (string name in Person.PropertyNames)
11+
{
12+
Console.WriteLine($" {name}");
13+
}
14+
15+
[GenerateMembers]
16+
public partial class Person
17+
{
18+
public string FirstName { get; set; } = string.Empty;
19+
public string LastName { get; set; } = string.Empty;
20+
public int Age { get; set; }
21+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>latest</LangVersion>
6+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
11+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
12+
</ItemGroup>
13+
14+
</Project>

0 commit comments

Comments
 (0)