Skip to content

Commit

Permalink
Added ConstantFromJson for #238
Browse files Browse the repository at this point in the history
  • Loading branch information
AydinE committed Mar 10, 2024
1 parent 749343b commit 6e6577e
Show file tree
Hide file tree
Showing 10 changed files with 427 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
using Generators;
using System;

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
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<RootNamespace>Lombiq.HelpfulLibraries.SourceGenerators.Sample</RootNamespace>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false"/>
</ItemGroup>

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

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"private": true,
"devDependencies": {
"fs": "0.0.2",
"glob": "5.0.15",
"path-posix": "1.0.0",
"merge-stream": "1.0.0",
"gulp-if": "2.0.0",
"gulp": "3.9.0",
"gulp-newer": "0.5.1",
"gulp-plumber": "1.0.1",
"gulp-sourcemaps": "1.6.0",
"gulp-less": "3.0.3",
"gulp-autoprefixer": "2.2.0",
"gulp-minify-css": "1.2.1",
"gulp-typescript": "2.9.2",
"gulp-uglify": "1.4.1",
"gulp-rename": "1.2.2",
"gulp-concat": "2.6.0",
"gulp-header": "1.7.1"
},
"dependencies": { }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
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 Namespace = "Generators";
private const string AttributeName = "ConstantFromJsonAttribute";

private const string AttributeSourceCode = $@"// <auto-generated/>
namespace {Namespace}
{{
[System.AttributeUsage(System.AttributeTargets.Class, AllowMultiple = true)]
public class {AttributeName} : System.Attribute
{{
public string Value {{ get; }}
public {AttributeName}(string constantName, string fileName, string jsonPath)
{{
Value = ""testvaluetje"";
}}
}}
}}";

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

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Add the marker attribute to the compilation.
context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
$"{AttributeName}.g.cs",
SourceText.From(AttributeSourceCode, Encoding.UTF8)));

// Filter classes annotated with the [ConstantFromJson] attribute.
// Only filtered Syntax Nodes can trigger code generation.
var provider = context.SyntaxProvider
.CreateSyntaxProvider(
(s, _) => s is ClassDeclarationSyntax,
(ctx, _) => GetClassDeclarationForSourceGen(ctx))
.Where(t => t.reportAttributeFound)
.Select((t, _) => (t.Item1, t.Item3));

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()),
(ctx, t) => GenerateCode(ctx, t.Left, t.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, bool reportAttributeFound, List<Dictionary<string, string>>)
GetClassDeclarationForSourceGen(
GeneratorSyntaxContext context)
{
var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node;
var attributesData = new List<Dictionary<string, string>>();

// Go through all attributes of the class.
foreach (var attributeListSyntax in classDeclarationSyntax.AttributeLists)
foreach (var attributeSyntax in attributeListSyntax.Attributes)
{
if (context.SemanticModel.GetSymbolInfo(attributeSyntax).Symbol is not IMethodSymbol attributeSymbol)
{
continue; // if we can't get the symbol, ignore it
}

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

var arguments = new Dictionary<string, string>();
int idx = 0;
foreach (var argumentSyntax in attributeSyntax.ArgumentList?.Arguments!)
{
if (argumentSyntax.Expression is LiteralExpressionSyntax literalExpression)
arguments.Add(attributeSymbol.Parameters[idx].Name, literalExpression.Token.Text);

idx += 1;
}

attributesData.Add(arguments);
}

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

/// <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, List<Dictionary<string, string>>)> 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;
}

string namespaceName = classSymbol.ContainingNamespace.ToDisplayString();

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

string partialBody = string.Empty;

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

// Try get content of file from dictionary where key ends with filename
var fileContent = _fileContents
.FirstOrDefault(kvp =>
kvp.Key.EndsWith(fileName.Replace($"\"", string.Empty), StringComparison.Ordinal));

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

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

// try to find the value in the jsonDocument
var jsonValue = FindProperty(jsonDocument.RootElement, jsonPath.Replace("\"", ""));

if (jsonValue == null)
{
return;
}

partialBody += $"""
public const string {constantName.Replace("\"", "")} = "{jsonValue.Value}";
""";
}

// Create a new partial class with the same name as the original class.
// Build up the source code
string 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;
}

switch (property.Value.ValueKind)
{
case JsonValueKind.Object:
{
var result = FindProperty(property.Value, propertyName);
if (result != null)
{
return result;
}

break;
}

case JsonValueKind.Array:
{
foreach (var arrayElement in property.Value.EnumerateArray())
{
if (arrayElement.ValueKind == JsonValueKind.Object)
{
var result = FindProperty(arrayElement, propertyName);
if (result != null)
{
return result;
}
}
}

break;
}
}
}

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"
}
}
}
Loading

0 comments on commit 6e6577e

Please sign in to comment.