Skip to content

Commit c8e3a82

Browse files
committed
Documentation improved, code generation using Scriban added and implemented a way to use custom Scriban templates in consuming projects
1 parent 1606d57 commit c8e3a82

16 files changed

+314
-65
lines changed

Directory.Build.props

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,6 @@
33
<LangVersion>9.0</LangVersion>
44
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
55
<Nullable>enable</Nullable>
6-
<Version>0.3.0</Version>
6+
<Version>0.4.0</Version>
77
</PropertyGroup>
8-
<ItemGroup>
9-
<PackageReference Include="Microsoft.CodeAnalysis.FxCopAnalyzers" Version="3.3.1">
10-
<PrivateAssets>all</PrivateAssets>
11-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
12-
</PackageReference>
13-
</ItemGroup>
148
</Project>

JsonByExampleGenerator.Example/JsonByExampleGenerator.Example.csproj

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,14 @@
33
<PropertyGroup>
44
<TargetFramework>net5.0</TargetFramework>
55
<OutputType>Exe</OutputType>
6+
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
7+
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)\GeneratedFiles</CompilerGeneratedFilesOutputPath>
68
</PropertyGroup>
79

810
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
911
<NoWarn>1701;1702;CA2227;</NoWarn>
1012
</PropertyGroup>
1113

12-
1314
<!-- Add this as a new ItemGroup, replacing paths and names appropriately -->
1415
<ItemGroup>
1516
<!-- Note that this is not a "normal" ProjectReference.

JsonByExampleGenerator.Example/Program.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55

66
namespace JsonByExampleGenerator.Example
77
{
8+
/// <summary>
9+
/// This program just shows an example of how you can use the JsonByExampleGenerator.
10+
/// It's not required for testing, as there are unit tests in JsonByExampleGenerator.Tests.
11+
/// </summary>
812
class Program
913
{
1014
static void Main()

JsonByExampleGenerator.Generator/JsonByExampleGenerator.Generator.csproj

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,17 @@
2020
<PackFolder>analyzers\cs</PackFolder>
2121
<DebugType>embedded</DebugType>
2222
<DebugSymbols>true</DebugSymbols>
23+
<PackageScribanIncludeSource>true</PackageScribanIncludeSource>
2324
</PropertyGroup>
2425

2526
<ItemGroup>
26-
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.1">
27-
<PrivateAssets>all</PrivateAssets>
28-
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
29-
</PackageReference>
27+
<PackageReference Include="Microsoft.CSharp" Version="4.7.0" />
28+
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.5.4" />
3029
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="3.8.0" />
3130
<PackageReference Include="NuGetizer" Version="0.4.11" />
3231
<PackageReference Include="Pluralize.NET" Version="1.0.2" GeneratePathProperty="true" PrivateAssets="all" />
33-
<PackageReference Include="System.Text.Json" Version="5.0.0" GeneratePathProperty="true" PrivateAssets="all"/>
32+
<PackageReference Include="Scriban" Version="3.4.0" GeneratePathProperty="true" IncludeAssets="Build" PrivateAssets="all" />
33+
<PackageReference Include="System.Text.Json" Version="5.0.0" GeneratePathProperty="true" PrivateAssets="all" />
3434
</ItemGroup>
3535

3636
<PropertyGroup>
@@ -41,7 +41,12 @@
4141
<ItemGroup>
4242
<TargetPathWithTargetPlatformMoniker Include="$(PKGPluralize_NET)\lib\netstandard2.0\Pluralize.NET.dll" IncludeRuntimeDependency="false" />
4343
<TargetPathWithTargetPlatformMoniker Include="$(PKGSystem_Text_Json)\lib\netstandard2.0\System.Text.Json.dll" IncludeRuntimeDependency="false" />
44+
<TargetPathWithTargetPlatformMoniker Include="$(PKGScriban)\lib\netstandard2.0\Scriban.dll" IncludeRuntimeDependency="false" />
4445
</ItemGroup>
4546
</Target>
4647

48+
<ItemGroup>
49+
<EmbeddedResource Include="@(None -&gt; WithMetadataValue('Extension', '.sbntxt'))" />
50+
</ItemGroup>
51+
4752
</Project>
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#nullable disable
2+
using System.Collections.Generic;
3+
using System.Text.Json.Serialization;{{ for OptionalDependency in OptionalDependencies}}
4+
using {{ OptionalDependency }};
5+
{{ end }}
6+
namespace {{ NamespaceName }}.Json
7+
{
8+
{{ for ClassModel in ClassModels }}
9+
public partial class {{ ClassModel.ClassName }}
10+
{
11+
{{ for Property in ClassModel.Properties }}[JsonPropertyName("{{ Property.PropertyNameOriginal }}")]
12+
public {{ Property.PropertyType }} {{ Property.PropertyName }} { get; set; }{{ if Property.Init }} = {{ Property.Init }};{{ end }}
13+
{{ end }}{{ if ConfigEnabled }}
14+
public static {{ ClassModel.ClassName }} FromConfig([System.Diagnostics.CodeAnalysis.NotNull] IConfiguration config)
15+
{
16+
return config.Get<{{ ClassModel.ClassName }}>();
17+
}{{ end }}
18+
}
19+
{{ end }}
20+
}
21+
#nullable enable

JsonByExampleGenerator.Generator/JsonGenerator.cs

Lines changed: 101 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -13,86 +13,97 @@
1313
using Pluralize.NET;
1414
using JsonByExampleGenerator.Generator.Models;
1515
using System.Globalization;
16+
using Scriban;
17+
using JsonByExampleGenerator.Generator.Utils;
1618

1719
namespace JsonByExampleGenerator.Generator
1820
{
21+
/// <summary>
22+
/// Source generator that generates C# code based on an example json file.
23+
/// </summary>
1924
[Generator]
2025
public class JsonGenerator : ISourceGenerator
2126
{
2227
private static readonly IPluralize _pluralizer = new Pluralizer();
2328

29+
/// <summary>
30+
/// Executes the generator logic during compilation
31+
/// </summary>
32+
/// <param name="context">Generator context that contains info about the compilation</param>
2433
public void Execute(GeneratorExecutionContext context)
2534
{
2635
try
2736
{
37+
// Resolve all json files that are added to the AdditionalFiles in the compilation
2838
foreach (var jsonFile in context.AdditionalFiles.Where(f => f.Path.EndsWith(".json", StringComparison.InvariantCultureIgnoreCase)))
2939
{
3040
var jsonFileText = jsonFile.GetText(context.CancellationToken);
31-
if(jsonFileText == null)
41+
if (jsonFileText == null)
3242
{
3343
continue;
3444
}
3545

36-
bool configEnabled = context.Compilation?.ReferencedAssemblyNames
37-
.Any(r => string.Equals("Microsoft.Extensions.Configuration.Json", r.Name, StringComparison.InvariantCulture))
38-
?? false;
39-
configEnabled = configEnabled
40-
&& (context.Compilation?.ReferencedAssemblyNames
41-
.Any(r => string.Equals("Microsoft.Extensions.Configuration.Binder", r.Name, StringComparison.InvariantCulture)) ?? false);
46+
// Determine if the functionality for easy access to configuration should be enabled
47+
bool configEnabled = IsConfigurationEnabled(context);
4248

4349
var json = JsonDocument.Parse(jsonFileText.ToString());
4450

51+
// The namespace of the code is determined by the assembly name of the compilation
4552
string namespaceName = context.Compilation?.AssemblyName ?? "JsonByExample";
4653

54+
// Read the json and build a list of models that can be used to generate classes
4755
var classModels = new List<ClassModel>();
4856
var jsonElement = json.RootElement;
4957
string rootTypeName = GetValidName(Path.GetFileNameWithoutExtension(jsonFile.Path).Replace(" ", string.Empty), true);
50-
RenderType(context, classModels, jsonElement, namespaceName, rootTypeName);
58+
ResolveTypeRecursive(context, classModels, jsonElement, rootTypeName);
5159

52-
var generatedClasses = classModels.Select(c =>
53-
{
54-
string extensionMethod = string.Empty;
55-
string configReadMethod = string.Empty;
56-
57-
if (configEnabled)
58-
{
59-
60-
configReadMethod = $@"
61-
public static {c.ClassName} FromConfig([System.Diagnostics.CodeAnalysis.NotNull] IConfiguration config)
62-
{{
63-
return config.Get<{c.ClassName}>();
64-
}}";
65-
}
66-
67-
string result = $@"
68-
{extensionMethod}
69-
public partial class {c.ClassName}
70-
{{
71-
{string.Join("\r\n", c.Properties.Select(p => RenderProperty(p)))}
72-
{configReadMethod}
73-
}}";
60+
// A list of dependencies to be added to the using statements in the code generation
61+
var optionalDependencies = new List<string>();
62+
if (configEnabled)
63+
{
64+
optionalDependencies.Add("Microsoft.Extensions.Configuration");
65+
}
7466

75-
return result;
76-
});
67+
// Attempt to find a Scriban template in the AdditionalFiles that has the same name as the json
68+
string templateFileName = $"{Path.GetFileNameWithoutExtension(jsonFile.Path)}.sbntxt";
69+
string? templateContent = context
70+
.AdditionalFiles
71+
.FirstOrDefault(f => Path
72+
.GetFileName(f.Path)
73+
.Equals(templateFileName, StringComparison.InvariantCultureIgnoreCase))
74+
?.GetText(context.CancellationToken)
75+
?.ToString();
7776

78-
var optionalDependencies = configEnabled ? "\r\nusing Microsoft.Extensions.Configuration;" : string.Empty;
77+
Template template;
78+
if (templateContent != null)
79+
{
80+
// Parse the template that is in the compilation
81+
template = Template.Parse(templateContent, templateFileName);
82+
}
83+
else
84+
{
85+
// Fallback to the default template
86+
const string defaultTemplatePath = "JsonByExampleTemplate.sbntxt";
87+
template = Template.Parse(EmbeddedResource.GetContent(defaultTemplatePath), defaultTemplatePath);
88+
}
7989

80-
string generatedCode = $@"#nullable disable
81-
using System.Collections.Generic;
82-
using System.Text.Json.Serialization;{optionalDependencies}
90+
// Use Scriban to render the code using the model that was built
91+
string generatedCode = template.Render(new
92+
{
93+
OptionalDependencies = optionalDependencies,
94+
NamespaceName = namespaceName,
95+
ConfigEnabled = configEnabled,
96+
ClassModels = classModels
97+
}, member => member.Name);
8398

84-
namespace {namespaceName}.Json
85-
{{{string.Join("\r\n", generatedClasses)}
86-
}}
87-
#nullable enable";
99+
// Add the generated code to the compilation
88100
context.AddSource($"{namespaceName}_{rootTypeName}.gen.cs",
89101
SourceText.From(generatedCode, Encoding.UTF8));
90102
}
91103
}
92-
#pragma warning disable CA1031 // Do not catch general exception types
93104
catch (Exception ex)
94-
#pragma warning restore CA1031 // Do not catch general exception types
95105
{
106+
// Report a diagnostic if an exception occurs while generating code; allows consumers to know what is going on
96107
string message = $"Exception: {ex.Message} - {ex.StackTrace}";
97108
context.ReportDiagnostic(Diagnostic.Create(
98109
new DiagnosticDescriptor(
@@ -106,40 +117,61 @@ namespace {namespaceName}.Json
106117
}
107118
}
108119

109-
private static string RenderProperty(PropertyModel propertyModel)
120+
/// <summary>
121+
/// Find out if Microsoft.Extensions.Configuration.Json is used.
122+
/// </summary>
123+
/// <param name="context">The generator execution context</param>
124+
/// <returns>True if Microsoft.Extensions.Configuration.Json is referenced from the assembly</returns>
125+
private static bool IsConfigurationEnabled(GeneratorExecutionContext context)
110126
{
111-
string init = propertyModel.Init != null ? $" = {propertyModel.Init};" : string.Empty;
112-
return $"[JsonPropertyName(\"{propertyModel.PropertyNameOriginal}\")]\r\n public {propertyModel.PropertyType} {propertyModel.PropertyName} {{ get; set; }}{init}";
127+
bool configEnabled = context.Compilation?.ReferencedAssemblyNames
128+
.Any(r => string.Equals("Microsoft.Extensions.Configuration.Json", r.Name, StringComparison.InvariantCulture))
129+
?? false;
130+
configEnabled = configEnabled
131+
&& (context.Compilation?.ReferencedAssemblyNames
132+
.Any(r => string.Equals("Microsoft.Extensions.Configuration.Binder", r.Name, StringComparison.InvariantCulture)) ?? false);
133+
return configEnabled;
113134
}
114135

115-
private static void RenderType(GeneratorExecutionContext context, List<ClassModel> classModels, JsonElement jsonElement, string namespaceName, string typeName)
136+
/// <summary>
137+
/// Reads json and fills the classModels list with relevant type definitions.
138+
/// </summary>
139+
/// <param name="context">The source generator context</param>
140+
/// <param name="classModels">A list that needs to be populated with resolved types</param>
141+
/// <param name="jsonElement">The current json element that is being read</param>
142+
/// <param name="typeName">The current type name that is being read</param>
143+
private static void ResolveTypeRecursive(GeneratorExecutionContext context, List<ClassModel> classModels, JsonElement jsonElement, string typeName)
116144
{
117145
var classModel = new ClassModel(typeName);
118146

147+
// Arrays should be enumerated and handled individually
119148
if (jsonElement.ValueKind == JsonValueKind.Array)
120149
{
121150
var jsonArrayEnumerator = jsonElement.EnumerateArray();
122151
while (jsonArrayEnumerator.MoveNext())
123152
{
124-
RenderType(context, classModels, jsonArrayEnumerator.Current, namespaceName, typeName);
153+
ResolveTypeRecursive(context, classModels, jsonArrayEnumerator.Current, typeName);
125154
}
126155

127156
return;
128157
}
129158

159+
// Iterate the properties of the json element, they will become model properties
130160
foreach (JsonProperty prop in jsonElement.EnumerateObject())
131161
{
132162
string propName = GetValidName(prop.Name);
133163
if (propName.Length > 0)
134164
{
135165
PropertyModel propertyModel;
136166

167+
// The json value kind of the property determines how to map it to a C# type
137168
switch (prop.Value.ValueKind)
138169
{
139170
case JsonValueKind.Array:
140171
{
141172
string arrPropName = GetValidName(prop.Name, true);
142173

174+
// Look at the first element in the array to determine the type of the array
143175
var arrEnumerator = prop.Value.EnumerateArray();
144176
if(arrEnumerator.MoveNext())
145177
{
@@ -157,7 +189,7 @@ private static void RenderType(GeneratorExecutionContext context, List<ClassMode
157189
}
158190
else
159191
{
160-
RenderType(context, classModels, prop.Value, namespaceName, arrPropName);
192+
ResolveTypeRecursive(context, classModels, prop.Value, arrPropName);
161193
}
162194

163195
propertyModel = new PropertyModel(prop.Name, $"IList<{arrPropName}>", propName)
@@ -182,7 +214,9 @@ private static void RenderType(GeneratorExecutionContext context, List<ClassMode
182214
case JsonValueKind.Object:
183215
{
184216
string objectPropName = GetValidName(prop.Name, true);
185-
RenderType(context, classModels, prop.Value, namespaceName, objectPropName);
217+
218+
// Create a separate type for objects
219+
ResolveTypeRecursive(context, classModels, prop.Value, objectPropName);
186220

187221
propertyModel = new PropertyModel(prop.Name, objectPropName, propName);
188222
break;
@@ -196,19 +230,30 @@ private static void RenderType(GeneratorExecutionContext context, List<ClassMode
196230
}
197231
}
198232

233+
// If there is already a model defined that matches by name, then we add any new properties by merging the models
199234
var matchingClassModel = classModels.FirstOrDefault(c => string.Equals(c.ClassName, classModel.ClassName, StringComparison.InvariantCulture));
200235
if (matchingClassModel != null)
201236
{
202237
matchingClassModel.Merge(classModel);
203238
}
204239
else
205240
{
241+
// No need to merge, just add the new class model
206242
classModels.Add(classModel);
207243
}
208244
}
209245

246+
/// <summary>
247+
/// Gets a name that is valid in C# and makes it Pascal-case.
248+
/// Optionally, it can singularize the name, so that a list property has a proper model class.
249+
/// E.g. Cars will have a model type of Car.
250+
/// </summary>
251+
/// <param name="typeName">The type name that is possibly not valid in C#</param>
252+
/// <param name="singularize">If true, the name will be singularized if it is plural</param>
253+
/// <returns>A valid C# Pascal-case name</returns>
210254
private static string GetValidName(string typeName, bool singularize = false)
211255
{
256+
// Make a plural form singular using Pluralize.NET
212257
if (singularize && _pluralizer.IsPlural(typeName))
213258
{
214259
typeName = _pluralizer.Singularize(typeName);
@@ -218,12 +263,14 @@ private static string GetValidName(string typeName, bool singularize = false)
218263
bool nextCharUpper = true;
219264
for(int i = 0; i < typeName.Length; i++)
220265
{
266+
// Strip spaces
221267
if (typeName[i] == ' ')
222268
{
223269
nextCharUpper = true;
224270
continue;
225271
}
226272

273+
// Pascal casing
227274
if (nextCharUpper)
228275
{
229276
nextCharUpper = false;
@@ -240,8 +287,13 @@ private static string GetValidName(string typeName, bool singularize = false)
240287
return new string(newTypeName.ToArray());
241288
}
242289

290+
/// <summary>
291+
/// Initialization of the generator; allows to setup visitors for syntax.
292+
/// </summary>
293+
/// <param name="context">Code generator context</param>
243294
public void Initialize(GeneratorInitializationContext context)
244295
{
296+
// No implementation needed here; the generator is entirely driven by use of AdditionalFiles
245297
}
246298
}
247299
}

0 commit comments

Comments
 (0)