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: Constant from JSON source generator #251

Merged
merged 27 commits into from
May 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e6da6c3
Recreated source generator
AydinE Mar 28, 2024
78cd848
Move attribute to separate project
AydinE Mar 28, 2024
3e13545
Rename project for max length reasons
AydinE Mar 28, 2024
8b8740f
Potential fix for VS builds
AydinE Mar 28, 2024
634d37d
Merge remote-tracking branch 'origin/dev' into issue/OSOE-819
sarahelsaig Mar 28, 2024
ddc7a73
Update Readme.md
AydinE Apr 4, 2024
49cb386
Update Readme.md
AydinE Apr 4, 2024
99ea7c5
Suppress warning
AydinE Apr 8, 2024
cafd677
Update Lombiq.HelpfulLibraries.SourceGenerators.csproj
AydinE Apr 9, 2024
6eb71ac
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 24, 2024
cea388b
Update Lombiq.HelpfulLibraries.csproj
AydinE Apr 25, 2024
4745526
Update package
AydinE Apr 25, 2024
1fe7a7d
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 25, 2024
788969c
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 26, 2024
1f669c7
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 26, 2024
f683efb
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 26, 2024
dd14507
Update Lombiq.HelpfulLibraries.SourceGenerators.csproj
AydinE Apr 26, 2024
e5f73ff
Update Lombiq.HelpfulLibraries.Tests.csproj
AydinE Apr 26, 2024
59a0377
Update readme and add license
AydinE Apr 29, 2024
ede0498
Update Readme.md
AydinE Apr 29, 2024
55ecf08
Update Lombiq.HelpfulLibraries.SourceGenerators/Properties/launchSett…
AydinE Apr 30, 2024
d169d8f
Update Lombiq.HelpfulLibraries.SourceGenerators/Readme.md
AydinE Apr 30, 2024
99f5d95
Update Readme.md
AydinE Apr 30, 2024
5eec9f6
Update Readme.md
AydinE Apr 30, 2024
057a12f
Merge branch 'issue/OSOE-819' of https://github.com/Lombiq/Helpful-Li…
AydinE Apr 30, 2024
f1a4c5c
Update Readme.md
AydinE May 1, 2024
4849fd0
Update Readme.md
AydinE May 1, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Lombiq.HelpfulLibraries.Attributes;

[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,10 @@
<Project Sdk="Microsoft.NET.Sdk">

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

</Project>
223 changes: 223 additions & 0 deletions Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
using Lombiq.HelpfulLibraries.Attributes;
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;

[Generator]
public class ConstantFromJsonGenerator : IIncrementalGenerator
{
private const string AttributeName = nameof(ConstantFromJsonAttribute);

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));

var filesAndContents = new Dictionary<string, string>();

context.RegisterSourceOutput(namesAndContents.Collect(), (_, contents) =>
{
foreach ((string? content, string path) in contents)
{
// Check if path already exists
if (!filesAndContents.ContainsKey(path))
{
// Add to the dictionary
filesAndContents.Add(path, content ?? string.Empty);
}
}
});

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

/// <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 != $"{typeof(ConstantFromJsonAttribute).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 static void GenerateCode(
SourceProductionContext context,
Compilation compilation,
ImmutableArray<(ClassDeclarationSyntax Syntax, List<Dictionary<string, string>> Dictionary)> classDeclarations,
Dictionary<string, string> additionalFiles)
{
// 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 = additionalFiles
.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)
{
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;
}
}
13 changes: 13 additions & 0 deletions Lombiq.HelpfulLibraries.SourceGenerators/License.md
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,64 @@
<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>

<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<IncludeBuildOutput>false</IncludeBuildOutput>

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

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.0.0" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Lombiq.HelpfulLibraries.Attributes\Lombiq.HelpfulLibraries.Attributes.csproj" PrivateAssets="all" GeneratePathProperty="true" />
</ItemGroup>

<ItemGroup>
<!-- Take a private dependency on with (PrivateAssets=all) Consumers of this generator will not reference it.
Set GeneratePathProperty=true so we can reference the binaries -->
<PackageReference Include="System.Text.Json" Version="8.0.0" PrivateAssets="all" GeneratePathProperty="true" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" GeneratePathProperty="true">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

<!-- Package the generator in the analyzer directory of the nuget package -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />

<!-- Package the dependencies alongside the generator assembly -->
<None Include="$(OutputPath)\Lombiq.HelpfulLibraries.Attributes.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="$(PKGSystem_Text_Json)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="$(PKGMicrosoft_CodeAnalysis_Analyzers)\lib\netstandard2.0\*.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>

<ItemGroup>
<None Remove="bin\Debug\netstandard2.0\\Lombiq.HelpfulLibraries.Attributes.dll" />
<None Remove="bin\Debug\netstandard2.0\\Lombiq.HelpfulLibraries.SourceGenerators.dll" />
</ItemGroup>

<!-- For local referencing we will need this too -->
<PropertyGroup>
<GetTargetPathDependsOn>$(GetTargetPathDependsOn);GetDependencyTargetPaths</GetTargetPathDependsOn>
</PropertyGroup>

<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<!-- These will now generate a path property because we did the GeneratePathProperty="true"-->
<TargetPathWithTargetPlatformMoniker Include="$(Lombiq_HelpfulLibraries_Attributes)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
<TargetPathWithTargetPlatformMoniker Include="$(PkgSystem_Text_Json)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
<TargetPathWithTargetPlatformMoniker Include="$(PkgMicrosoft_CodeAnalysis_Analyzers)\lib\netstandard2.0\*.dll" IncludeRuntimeDependency="false" />
</ItemGroup>
</Target>

</Project>
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"
}
}
}