diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonAttribute.cs b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonAttribute.cs new file mode 100644 index 00000000..0679458c --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonAttribute.cs @@ -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) + { + } +} diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonGenerator.cs b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonGenerator.cs new file mode 100644 index 00000000..f7395b6d --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/ConstantFromJsonGenerator.cs @@ -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; + +/// +/// A generator that exposes a value from a JSON file at compile time. +/// The target class should be annotated with the 'Generators.ConstantFromJsonAttribute' attribute. +/// +[Generator] +public class ConstantFromJsonGenerator : IIncrementalGenerator +{ + private const string AttributeName = nameof(ConstantFromJsonAttribute); + private static readonly string? Namespace = typeof(ConstantFromJsonAttribute).Namespace; + + private readonly Dictionary _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)); + } + + /// + /// Checks whether the Node is annotated with the [ConstantFromJson] attribute and maps syntax context to + /// the specific node type (ClassDeclarationSyntax). + /// + /// Syntax context, based on CreateSyntaxProvider predicate. + /// The specific cast and whether the attribute was found. + private static (ClassDeclarationSyntax Syntax, bool ReportAttributeFound, List> AttributesData) + GetClassDeclarationForSourceGen(GeneratorSyntaxContext context) + { + var classDeclarationSyntax = (ClassDeclarationSyntax)context.Node; + var attributesData = classDeclarationSyntax.AttributeLists + .SelectMany(list => list.Attributes) + .Select(attributeSyntax => GetAttributeArguments(context, attributeSyntax)) + .OfType>().ToList(); + + return (classDeclarationSyntax, attributesData.Count > 0, attributesData); + } + + private static Dictionary? 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() + .Select((literalExpression, index) => new + { + Key = attributeSymbol.Parameters[index].Name, + Value = literalExpression.Token.Text, + }) + .ToDictionary(keyValuePair => keyValuePair.Key, keyValuePair => keyValuePair.Value) ?? []; + + return arguments; + } + + /// + /// Generate code action. + /// It will be executed on specific nodes (ClassDeclarationSyntax annotated with the [ConstantFromJson] attribute) + /// changed by the user. + /// + /// Source generation context used to add source files. + /// Compilation used to provide access to the Semantic Model. + /// + /// Nodes annotated with the [ConstantFromJson] attribute that trigger the + /// generate action. + /// + private void GenerateCode( + SourceProductionContext context, + Compilation compilation, + ImmutableArray<(ClassDeclarationSyntax Syntax, List> 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 = $@"// + +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)); + } + } + + /// + /// Find a property in a JSON document recursively. + /// + /// The JSON element to search in. + /// The property name to look for. + 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; + } +} diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/License.md b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/License.md new file mode 100644 index 00000000..f516dbf7 --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/License.md @@ -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. diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators.csproj b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators.csproj new file mode 100644 index 00000000..133e66a9 --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators.csproj @@ -0,0 +1,27 @@ + + + + netstandard2.0 + false + enable + latest + + true + true + + Lombiq.HelpfulLibraries.SourceGenerators + Lombiq.HelpfulLibraries.SourceGenerators + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/NuGetIcon.png b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/NuGetIcon.png new file mode 100644 index 00000000..162a0050 Binary files /dev/null and b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/NuGetIcon.png differ diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Properties/launchSettings.json b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Properties/launchSettings.json new file mode 100644 index 00000000..d2da76e1 --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Properties/launchSettings.json @@ -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" + } + } +} diff --git a/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Readme.md b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Readme.md new file mode 100644 index 00000000..33f025fc --- /dev/null +++ b/Lombiq.HelpfulLibraries.SourceGenerators/Lombiq.HelpfulLibraries.SourceGenerators/Readme.md @@ -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 + + + + ``` + +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); + } + } + ``` diff --git a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj index c508da1f..c1af1677 100644 --- a/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj +++ b/Lombiq.HelpfulLibraries.Tests/Lombiq.HelpfulLibraries.Tests.csproj @@ -15,6 +15,12 @@ + + + + + + diff --git a/Lombiq.HelpfulLibraries.Tests/Models/Examples.cs b/Lombiq.HelpfulLibraries.Tests/Models/Examples.cs new file mode 100644 index 00000000..9949e5d8 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/Models/Examples.cs @@ -0,0 +1,19 @@ +using Lombiq.HelpfulLibraries.SourceGenerators; + +namespace Lombiq.HelpfulLibraries.Tests.Models; + +/// +/// Shows how to use the . +/// +[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(); + } +} diff --git a/Lombiq.HelpfulLibraries.Tests/UnitTests/SourceGenerators/ConstantFromJsonTests.cs b/Lombiq.HelpfulLibraries.Tests/UnitTests/SourceGenerators/ConstantFromJsonTests.cs new file mode 100644 index 00000000..64a904ca --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/UnitTests/SourceGenerators/ConstantFromJsonTests.cs @@ -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"]); + } +} diff --git a/Lombiq.HelpfulLibraries.Tests/package.json b/Lombiq.HelpfulLibraries.Tests/package.json new file mode 100644 index 00000000..2662e523 --- /dev/null +++ b/Lombiq.HelpfulLibraries.Tests/package.json @@ -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": { } +} diff --git a/Lombiq.HelpfulLibraries.sln b/Lombiq.HelpfulLibraries.sln index 0d83cdf7..d43a03dd 100644 --- a/Lombiq.HelpfulLibraries.sln +++ b/Lombiq.HelpfulLibraries.sln @@ -21,6 +21,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.HelpfulLibraries.Cli EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.HelpfulLibraries.Refit", "Lombiq.HelpfulLibraries.Refit\Lombiq.HelpfulLibraries.Refit.csproj", "{5DC1A3D5-0626-4258-95C6-7E5CA5495A80}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Lombiq.HelpfulLibraries.SourceGenerators", "Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators\Lombiq.HelpfulLibraries.SourceGenerators.csproj", "{FB53DB02-5C86-44C9-A88E-294B95C1F979}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -63,6 +65,10 @@ Global {5DC1A3D5-0626-4258-95C6-7E5CA5495A80}.Debug|Any CPU.Build.0 = Debug|Any CPU {5DC1A3D5-0626-4258-95C6-7E5CA5495A80}.Release|Any CPU.ActiveCfg = Release|Any CPU {5DC1A3D5-0626-4258-95C6-7E5CA5495A80}.Release|Any CPU.Build.0 = Release|Any CPU + {FB53DB02-5C86-44C9-A88E-294B95C1F979}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB53DB02-5C86-44C9-A88E-294B95C1F979}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB53DB02-5C86-44C9-A88E-294B95C1F979}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB53DB02-5C86-44C9-A88E-294B95C1F979}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE