Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

OSOE-819: Add Source Generator for keeping version numbers in sync #245

Merged
merged 13 commits into from
Mar 14, 2024
Merged
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Lombiq.HelpfulLibraries.SourceGenerators;

[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)]
public sealed class ConstantFromJsonAttribute : System.Attribute
{
public ConstantFromJsonAttribute(string constantName, string fileName, string propertyName)
{
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using System.Text.Json;

namespace Lombiq.HelpfulLibraries.SourceGenerators;

/// <summary>
/// A generator that exposes a value from a JSON file at compile time.
/// The target class should be annotated with the 'Generators.ConstantFromJsonAttribute' attribute.
/// </summary>
[Generator]
public class ConstantFromJsonGenerator : IIncrementalGenerator
{
private const string AttributeName = nameof(ConstantFromJsonAttribute);
private static readonly string? Namespace = typeof(ConstantFromJsonAttribute).Namespace;

private readonly Dictionary<string, string> _fileContents = [];

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Filter classes annotated with the [ConstantFromJson] attribute.
// Only filtered Syntax Nodes can trigger code generation.
var provider = context.SyntaxProvider
.CreateSyntaxProvider(
(node, _) => node is ClassDeclarationSyntax,
(syntaxContext, _) => GetClassDeclarationForSourceGen(syntaxContext))
.Where(tuple => tuple.ReportAttributeFound)
.Select((tuple, _) => (tuple.Syntax, tuple.AttributesData));

var additionalFiles = context.AdditionalTextsProvider
.Where(static file => file.Path.EndsWith(".json", StringComparison.OrdinalIgnoreCase));

var namesAndContents = additionalFiles
.Select((file, cancellationToken) =>
(Content: file.GetText(cancellationToken)?.ToString(),
file.Path));

context.RegisterSourceOutput(namesAndContents.Collect(), (_, contents) =>
{
foreach ((string? content, string path) in contents)
{
// Add to the dictionary
_fileContents.Add(path, content ?? string.Empty);
}
});

// Generate the source code.
context.RegisterSourceOutput(
context.CompilationProvider.Combine(provider.Collect()),
(productionContext, tuple) => GenerateCode(productionContext, tuple.Left, tuple.Right));
}

/// <summary>
/// Checks whether the Node is annotated with the [ConstantFromJson] attribute and maps syntax context to
/// the specific node type (ClassDeclarationSyntax).
/// </summary>
/// <param name="context">Syntax context, based on CreateSyntaxProvider predicate.</param>
/// <returns>The specific cast and whether the attribute was found.</returns>
private static (ClassDeclarationSyntax Syntax, bool ReportAttributeFound, List<Dictionary<string, string>> AttributesData)
GetClassDeclarationForSourceGen(GeneratorSyntaxContext context)
{
var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node;
var attributesData = classDeclarationSyntax.AttributeLists
.SelectMany(list => list.Attributes)
.Select(attributeSyntax => GetAttributeArguments(context, attributeSyntax))
.OfType<Dictionary<string, string>>().ToList();

return (classDeclarationSyntax, attributesData.Count > 0, attributesData);
}

private static Dictionary<string, string>? GetAttributeArguments(GeneratorSyntaxContext context, AttributeSyntax attributeSyntax)
{
if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol)
{
return null; // if we can't get the symbol, ignore it
}

var attributeName = attributeSymbol.ContainingType.ToDisplayString();
// Check the full name of the [ConstantFromJson] attribute.
if (attributeName != $"{Namespace}.{AttributeName}")
{
return null;
}

var arguments = attributeSyntax.ArgumentList?.Arguments
.Select(argument => argument.Expression)
.OfType<LiteralExpressionSyntax>()
.Select((literalExpression, index) => new
{
Key = attributeSymbol.Parameters[index].Name,
Value = literalExpression.Token.Text,
})
.ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value) ?? [];

return arguments;
}

/// <summary>
/// Generate code action.
/// It will be executed on specific nodes (ClassDeclarationSyntax annotated with the [ConstantFromJson] attribute)
/// changed by the user.
/// </summary>
/// <param name="context">Source generation context used to add source files.</param>
/// <param name="compilation">Compilation used to provide access to the Semantic Model.</param>
/// <param name="classDeclarations">
/// Nodes annotated with the [ConstantFromJson] attribute that trigger the
/// generate action.
/// </param>
private void GenerateCode(
SourceProductionContext context,
Compilation compilation,
ImmutableArray<(ClassDeclarationSyntax Syntax, List<Dictionary<string, string>> Dictionary)> classDeclarations)
{
// Go through all filtered class declarations.
foreach (var (classDeclarationSyntax, attributeData) in classDeclarations)
{
// We need to get semantic model of the class to retrieve metadata.
var semanticModel = compilation.GetSemanticModel(classDeclarationSyntax.SyntaxTree);

// Symbols allow us to get the compile-time information.
if (semanticModel.GetDeclaredSymbol(classDeclarationSyntax, cancellationToken: context.CancellationToken)
is not INamedTypeSymbol classSymbol)
{
continue;
}

var namespaceName = classSymbol.ContainingNamespace.ToDisplayString();

// 'Identifier' means the token of the node. Get class name from the syntax node.
var className = classDeclarationSyntax.Identifier.Text;

var partialBody = new StringBuilder();

// It's possible that a single class is annotated with our marker attribute multiple times
foreach (var dictionary in attributeData)
{
// Get values from dictionary
var constantName = dictionary["constantName"].Trim('"');
var fileName = dictionary["fileName"].Trim('"');
var propertyName = dictionary["propertyName"].Trim('"');

// Try get content of file from dictionary where key ends with filename
var fileContent = _fileContents
.FirstOrDefault(keyValuePair =>
keyValuePair.Key.EndsWith(fileName, StringComparison.OrdinalIgnoreCase));

// If the file content is empty, skip
if (string.IsNullOrEmpty(fileContent.Value))
{
return;
}

var jsonDocument = JsonDocument.Parse(fileContent.Value);

if (FindProperty(jsonDocument.RootElement, propertyName) is { } jsonValue)
partialBody.AppendLine($"public const string {constantName} = \"{jsonValue}\";");
}

// Create a new partial class with the same name as the original class.
// Build up the source code
var code = $@"// <auto-generated/>

using System;
using System.Collections.Generic;

namespace {namespaceName};

partial class {className}
{{
{partialBody}
}}
";
// Add the source code to the compilation.
context.AddSource($"{className}.g.cs", SourceText.From(code, Encoding.UTF8));
}
}

/// <summary>
/// Find a property in a JSON document recursively.
/// </summary>
/// <param name="element">The JSON element to search in.</param>
/// <param name="propertyName">The property name to look for.</param>
private static JsonElement? FindProperty(JsonElement element, string propertyName)
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved
{
foreach (var property in element.EnumerateObject())
{
if (property.Name == propertyName)
{
return property.Value;
}

if (property.Value.ValueKind == JsonValueKind.Object)
{
var result = FindProperty(property.Value, propertyName);
if (result != null)
{
return result;
}
}
else if (property.Value.ValueKind == JsonValueKind.Array)
{
var result = property.Value.EnumerateArray()
.Where(arrayElement => arrayElement.ValueKind == JsonValueKind.Object)
.Select(arrayElement => FindProperty(arrayElement, propertyName))
.FirstOrDefault(jsonProperty => jsonProperty != null);

if (result != null)
{
return result;
}
}
}

return null;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
Copyright © 2011, [Lombiq Technologies Ltd.](https://lombiq.com)

All rights reserved.

Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:

- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.

- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.

- Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<IsPackable>false</IsPackable>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>

<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>

<RootNamespace>Lombiq.HelpfulLibraries.SourceGenerators</RootNamespace>
<PackageId>Lombiq.HelpfulLibraries.SourceGenerators</PackageId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0"/>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp.Workspaces" Version="4.3.0"/>
<PackageReference Include="System.Text.Json" Version="7.0.3" />
</ItemGroup>


</Project>
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"profiles": {
"DebugRoslynSourceGenerator": {
"commandName": "DebugRoslynComponent",
"targetProject": "../Lombiq.HelpfulLibraries.SourceGenerators.Sample/Lombiq.HelpfulLibraries.SourceGenerators.Sample.csproj"
}
}
}
sarahelsaig marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Lombiq HelpfulLibraries - Source Generators

## About

A collection of helpful source generators.
> ⚠ When using one of the generators you must run a build before errors will go away.

- [ConstantFromJsonGenerator.cs](ConstantFromJsonGenerator.cs): A source generator that creates a constant from a JSON file.

For general details about and on using the Helpful Libraries see the [root Readme](../Readme.md).

## Documentation

### How to use the `ConstantFromJsonGenerator`?

1. Add a JSON file to your project.
2. Set the `Build Action` of the JSON file to `AdditionalFiles` for example:

```xml
<ItemGroup>
<AdditionalFiles Include="package.json" />
</ItemGroup>
```

3. Wherever you want to use the JSON file, make sure to use a `partial class` and add the `ConstantFromJsonGenerator` attribute to it.
Where the first parameter is the name of the constant and the second parameter is the path to the JSON file, the last parameter is the name or 'key' for the value we are looking for.

```csharp
[ConstantFromJson("GulpVersion", "package.json", "gulp")]
public partial class YourClass
{

}
```

4. Run a build and the constant will be generated .
5. Use the constant in your code, full example:

```csharp
using System;
using Generators;

namespace Lombiq.HelpfulLibraries.SourceGenerators.Sample;

[ConstantFromJson("GulpUglifyVersion", "package.json", "gulp-uglify")]
[ConstantFromJson("GulpVersion", "package.json", "gulp")]
public partial class Examples
{
// Show usage of the generated constants
public void LogVersions()
{
Console.WriteLine(GulpUglifyVersion);
Console.WriteLine(GulpVersion);
}
}
```
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\test\Lombiq.Tests\Lombiq.Tests.csproj" />
<ProjectReference Include="..\Lombiq.HelpfulLibraries\Lombiq.HelpfulLibraries.csproj" />
<ProjectReference Include="..\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators.csproj" />
<ProjectReference Include="..\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<AdditionalFiles Include="package.json"/>
</ItemGroup>

</Project>
19 changes: 19 additions & 0 deletions Lombiq.HelpfulLibraries.Tests/Models/Examples.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
using Lombiq.HelpfulLibraries.SourceGenerators;

namespace Lombiq.HelpfulLibraries.Tests.Models;

/// <summary>
/// Shows how to use the <see cref="ConstantFromJsonAttribute" />.
/// </summary>
[ConstantFromJson(constantName: "GulpUglifyVersion", fileName: "package.json", propertyName: "gulp-uglify")]
[ConstantFromJson(constantName: "GulpVersion", fileName: "package.json", propertyName: "gulp")]
public partial class Examples
{
public string ReturnVersions()
{
var stringBuilder = new System.Text.StringBuilder();
stringBuilder.AppendLine($"Gulp version: {GulpVersion}");
stringBuilder.AppendLine($"Gulp-uglify version: {GulpUglifyVersion}");
return stringBuilder.ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Lombiq.HelpfulLibraries.Tests.Models;
using Shouldly;
using System;
using Xunit;

namespace Lombiq.HelpfulLibraries.Tests.UnitTests.SourceGenerators;

public class ConstantFromJsonTests
{
[Fact]
public void TestGeneratedConstants()
{
Examples.GulpVersion.ShouldBe("3.9.0");
Examples.GulpUglifyVersion.ShouldBe("1.4.1");
new Examples()
.ReturnVersions()
.Split(["\n", "\r"], StringSplitOptions.RemoveEmptyEntries)
.ShouldBe(["Gulp version: 3.9.0", "Gulp-uglify version: 1.4.1"]);
}
}
Loading