From a9feab1692b1a9a9f3ce52b6ec7f5081f63226cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Szab=C3=B3?= Date: Fri, 12 Jul 2024 13:52:52 +0200 Subject: [PATCH] RC: initial functionality, tests, build/CD, README --- .github/workflows/cd-nuget.yml | 13 ++ Directory.Build.props | 9 ++ PodNet.EmbeddedTexts.sln | 55 ++++++++ README.md | 106 +++++++++++++++- src/EmbeddedTexts/EmbeddedTextsGenerator.cs | 103 +++++++++++++++ src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj | 20 +++ .../build/PodNet.EmbeddedTexts.props | 12 ++ .../EmbeddedTextsGeneratorIntegrationTest.cs | 55 ++++++++ .../Files/CustomClass.txt | 1 + .../Files/CustomClassAndNamespace.txt | 1 + .../Files/CustomNamespace.txt | 1 + .../Files/Ignored/Ignored.txt | 1 + .../Files/Ignored/Unignored.txt | 1 + .../Files/Text.txt | 2 + ...dNet.EmbeddedTexts.IntegrationTests.csproj | 36 ++++++ .../EmbeddedTextGeneratorTests.cs | 119 ++++++++++++++++++ .../PodNet.EmbeddedTexts.Tests.csproj | 18 +++ 17 files changed, 551 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/cd-nuget.yml create mode 100644 Directory.Build.props create mode 100644 PodNet.EmbeddedTexts.sln create mode 100644 src/EmbeddedTexts/EmbeddedTextsGenerator.cs create mode 100644 src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj create mode 100644 src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props create mode 100644 tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/CustomClass.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/CustomClassAndNamespace.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/CustomNamespace.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Ignored.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Unignored.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/Files/Text.txt create mode 100644 tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj create mode 100644 tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs create mode 100644 tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj diff --git a/.github/workflows/cd-nuget.yml b/.github/workflows/cd-nuget.yml new file mode 100644 index 0000000..3c26d3f --- /dev/null +++ b/.github/workflows/cd-nuget.yml @@ -0,0 +1,13 @@ +on: { push: { tags: ["v[0-9]+.[0-9]+.[0-9]+*"] } } + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-dotnet@v3 + - run: + dotnet build src -c debug + dotnet test + - run: dotnet pack -p:Version=$(echo ${{ github.ref_name }} | sed 's/^v//') -p:RepositoryCommit=${{ github.sha }} + - run: dotnet nuget push ./artifacts/package/release/*.nupkg -k ${{ secrets.NUGET_API_KEY }} -s https://api.nuget.org/v3/index.json diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..34fc7c7 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + 12.0 + enable + enable + true + false + + \ No newline at end of file diff --git a/PodNet.EmbeddedTexts.sln b/PodNet.EmbeddedTexts.sln new file mode 100644 index 0000000..91dd921 --- /dev/null +++ b/PodNet.EmbeddedTexts.sln @@ -0,0 +1,55 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".root", ".root", "{3ED2BD42-60E2-4576-90EF-B70F1FADCEC1}" + ProjectSection(SolutionItems) = preProject + .gitignore = .gitignore + .github\workflows\cd-nuget.yml = .github\workflows\cd-nuget.yml + Directory.Build.props = Directory.Build.props + LICENSE = LICENSE + README.md = README.md + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{52B4873F-9533-4C80-A473-C7B3B4510C13}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodNet.EmbeddedTexts", "src\EmbeddedTexts\PodNet.EmbeddedTexts.csproj", "{AB59FA90-615E-4BEC-8986-B8087EE570CF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{58E83F42-E7F1-4A20-9E52-D856C9AD933E}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodNet.EmbeddedTexts.Tests", "tests\EmbeddedTexts.Tests\PodNet.EmbeddedTexts.Tests.csproj", "{E70BE6A5-0A94-41BD-86B8-E217793342C8}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PodNet.EmbeddedTexts.IntegrationTests", "tests\EmbeddedTexts.IntegrationTests\PodNet.EmbeddedTexts.IntegrationTests.csproj", "{16360558-324A-4BD1-A565-8EF9ACB090A3}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {AB59FA90-615E-4BEC-8986-B8087EE570CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {AB59FA90-615E-4BEC-8986-B8087EE570CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {AB59FA90-615E-4BEC-8986-B8087EE570CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {AB59FA90-615E-4BEC-8986-B8087EE570CF}.Release|Any CPU.Build.0 = Release|Any CPU + {E70BE6A5-0A94-41BD-86B8-E217793342C8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E70BE6A5-0A94-41BD-86B8-E217793342C8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E70BE6A5-0A94-41BD-86B8-E217793342C8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E70BE6A5-0A94-41BD-86B8-E217793342C8}.Release|Any CPU.Build.0 = Release|Any CPU + {16360558-324A-4BD1-A565-8EF9ACB090A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {16360558-324A-4BD1-A565-8EF9ACB090A3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {16360558-324A-4BD1-A565-8EF9ACB090A3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {16360558-324A-4BD1-A565-8EF9ACB090A3}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {AB59FA90-615E-4BEC-8986-B8087EE570CF} = {52B4873F-9533-4C80-A473-C7B3B4510C13} + {E70BE6A5-0A94-41BD-86B8-E217793342C8} = {58E83F42-E7F1-4A20-9E52-D856C9AD933E} + {16360558-324A-4BD1-A565-8EF9ACB090A3} = {58E83F42-E7F1-4A20-9E52-D856C9AD933E} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {099F2425-924C-4626-91B8-3025DE1D4B3D} + EndGlobalSection +EndGlobal diff --git a/README.md b/README.md index d861a49..90b8551 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,104 @@ -# PodNet.EmbeddedTexts -A simple C# code generator that allows for embedding text file content into your code. +# PodNet.EmbeddedTexts [![Nuget](https://img.shields.io/nuget/v/PodNet.EmbeddedTexts)](https://www.nuget.org/packages/PodNet.EmbeddedTexts/) +A simple C# incremental code generator that allows for efficiently embedding text file content into your code. + +## Usage +1. Add the [`PodNet.EmbeddedTexts`](https://www.nuget.org/packages/PodNet.EmbeddedTexts/) NuGet package to your .NET project. +2. Add some text files (can be code files, can be markdown, plain text etc.) to your project you want the contents of to be available to you **at compile time**. Mark the files as having a build action of `AdditionalFile`. The most straightforward way to do this is by editing the `.csproj` project file and adding a pattern in an ``: + ```csproj + + + + ``` + You can do the above for individual files as well if you so choose. In this case you can even use the Visual Studio Properties Window (F4 by default, while a file is being selected in Solution Explorer) and set the `Build action` property of the file(s) to *"C# analyzer additional file"*. Once you do, Visual Studio will edit your `.csproj` file accordingly. +3. Once you set this up, you'll immediately find a `public static string Content { get; }` property generated on a class in a namespace according to the structure of your project, which returns the string content of the file. So, if your root namespace for the project is `MyProject`, and you have an `AdditionalFiles` element in your project's `Files/MyFile.txt` file with content `file contents`, you'll be able to call: + ```csharp + Console.WriteLine(Files.MyFile_txt.Content); // Writes: "file contents" + ``` + +## Additional configuration + +The generator is **on by default** for every `AdditionalFiles` text file you have in your project, and will include the file contents in your compilation. If this is not your desired use-case, you have a few options. + +Additionally, you can configure the name of the generated namespace and class for every piece of content. + +> [!NOTE] +> It's imporant to mention that the below is standard configuration practice for MSBuild items. The MSBuild project files (like .csproj or Directory.build.props) define items in the children of `ItemGroup` elements. The `AdditionalFiles` element can be used by all modern source generators and are not specific to `PodNet.EmbeddedTexts`. One quirk of MSBuild items is that they can be `Include`d individually or by globbing patterns, but the execution and parsing of these files is (mostly) sequential, so if you have any files `Include`d by any patterns, you then have to `Update` them instead of `Include`-ing again. + + +### Make the generator opt-in + +Set the MSBuild property `PodNetAutoEmbedAdditionalTexts` to `false` to disable automatic generation for all `AdditionalFiles`. Then, for each file you wish to embed in the compilation, add a `PodNet_EmbedText="true"` attribute its `AdditionalFiles` item. + +```csproj + + + + + false + + + + + + + + + + + + +``` + +This is useful if you have any source generators enabled that work on text files you wish to not include in the compilation. + +> [!WARNING] +> Remember that including all text files in the compilation will essentially make your assemblies/executables larger by about the size of the file. The generated `static` class and property won't be loaded into memory until first being referenced, but this can incur a performance hit when embedding larger files. + +### Opt-out of generation for files or folders + +Whether you have the generator automatically generating or not, you can explicitly opt-out of generation by setting `PodNet_EmbedText="false"`. + +```csproj + + + + + + + +``` + +### Configuring the generated class name and namespace + +You can set the `PodNet_EmbedTextNamespace` and `PodNet_EmbedTextClassName` properties on the items to override the default namespace and class name. + +```csproj + + + + + + + +``` + +The above results in: `OtherNamespace.MyFileTXT.Content` being the property that holds the file content. + +### Advanced parameterization + +Don't be shy to use MSBuild properties, well-known metadata and such to configure the generator. + +``` + +``` + +The above includes all `.cs` files (and other files that are at that point included in the compilation) into the source itself, in the `MyProject.CompiledFiles` namespace, with the class name being that of the filename without the extension. + + +## Contributing and Support + +This project is intended to be widely usable, but no warranties are provided. If you want to contact us, feel free to do so in the repo's [[Discussions](https://github.com/podNET-Hungary/PodNet.EnumValues/discussions)], at our website at [podnet.hu](https://podnet.hu), or find us anywhere from [LinkedIn](https://www.linkedin.com/company/podnet-hungary/) and [Patreon](https://www.patreon.com/podNETHungary) to [Meetup](https://www.meetup.com/budapest-net-meetup/), [YouTube](https://www.youtube.com/@podNET) or [X](https://twitter.com/podNET_Hungary). + +Any kinds of contributions from issues to PRs and open discussions are welcome! + +Don't forget to give us a ⭐ if you like this repo (it's free to give kudos!) or share it on socials, but we're not averse to offering you some benefits at our [🍻 Patreon 🍻](https://www.patreon.com/podNETHungary) either, if you're so inclined! \ No newline at end of file diff --git a/src/EmbeddedTexts/EmbeddedTextsGenerator.cs b/src/EmbeddedTexts/EmbeddedTextsGenerator.cs new file mode 100644 index 0000000..8aac830 --- /dev/null +++ b/src/EmbeddedTexts/EmbeddedTextsGenerator.cs @@ -0,0 +1,103 @@ +using Microsoft.CodeAnalysis; +using PodNet.Analyzers.CodeAnalysis; +using System.Text; + +namespace PodNet.EmbeddedTexts; + +[Generator(LanguageNames.CSharp)] +public sealed class EmbeddedTextsGenerator : IIncrementalGenerator +{ + public const string EmbedAdditionalTextsConfigProperty = "PodNetAutoEmbedAdditionalTexts"; + public const string EmbedTextMetadataProperty = "PodNet_EmbedText"; + public const string EmbedTextNamespaceMetadataProperty = "PodNet_EmbedTextNamespace"; + public const string EmbedTextClassNameMetadataProperty = "PodNet_EmbedTextClassName"; + + public record EmbeddedTextItemOptions( + string? RootNamespace, + string? ProjectDirectory, + string? ItemNamespace, + string? ItemClassName, + bool Enabled, + AdditionalText Text); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var allTexts = context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider) + .Select((item, _) => + { + var (text, options) = item; + var itemOptions = options.GetOptions(text); + var globalEnabled = string.Equals(options.GlobalOptions.GetBuildProperty(EmbedAdditionalTextsConfigProperty) ?? "true", "true", StringComparison.OrdinalIgnoreCase); + var itemSwitch = itemOptions.GetAdditionalTextMetadata(EmbedTextMetadataProperty); + var itemEnabled = string.Equals(itemSwitch, "true", StringComparison.OrdinalIgnoreCase); + var itemDisabled = string.Equals(itemSwitch, "false", StringComparison.OrdinalIgnoreCase); + return new EmbeddedTextItemOptions( + RootNamespace: options.GlobalOptions.GetRootNamespace(), + ProjectDirectory: options.GlobalOptions.GetProjectDirectory(), + ItemNamespace: itemOptions.GetAdditionalTextMetadata(EmbedTextNamespaceMetadataProperty), + ItemClassName: itemOptions.GetAdditionalTextMetadata(EmbedTextClassNameMetadataProperty), + Enabled: (globalEnabled || itemEnabled) && !itemDisabled, + Text: text); + }); + + var enabledTexts = allTexts.Where(e => e.Enabled); + + context.RegisterSourceOutput(enabledTexts, static (context, item) => + { + if (item.Text.GetText() is not { Lines: var lines } text) + return; + + if (item.ProjectDirectory is not { Length: > 0 }) + throw new InvalidOperationException("No project directory."); + if (item.Text.Path is not { Length: > 0 }) + throw new InvalidOperationException("Path not found for file."); + + var className = TextProcessing.GetClassName(item.ItemClassName is { Length: > 0 } + ? item.ItemClassName + : Path.GetFileName(item.Text.Path)); + + var relativeFolderPath = PathProcessing.GetRelativePath(item.ProjectDirectory, Path.GetDirectoryName(item.Text.Path)); + var relativeFilePath = PathProcessing.GetRelativePath(item.ProjectDirectory, item.Text.Path); + + var @namespace = TextProcessing.GetNamespace(item.ItemNamespace is { Length: > 0 } + ? item.ItemNamespace + : $"{item.RootNamespace}.{relativeFolderPath}"); + + var separator = new string('"', 3); + while (lines.Any(l => l.Text?.ToString().Contains(separator) == true)) + separator += '\"'; + + var sourceBuilder = new StringBuilder(text.Length * 2 + lines.Count * 8 + 300); + + sourceBuilder.AppendLine($$""" + // + + namespace {{@namespace}}; + + public static partial class {{className}} + { + /// + /// Contents of the file at '{{relativeFilePath}}': + /// + """); + + foreach (var line in lines) + { + sourceBuilder.AppendLine($$""" + /// {{line.ToString().Replace("<", "<").Replace(">", ">")}} + """); + } + + sourceBuilder.AppendLine($$""" + /// + /// + public static string Content { get; } = {{separator}} + {{text}} + {{separator}}; + } + """); + + context.AddSource($"{@namespace}/{className}.g.cs", sourceBuilder.ToString()); + }); + } +} diff --git a/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj b/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj new file mode 100644 index 0000000..c9a2eeb --- /dev/null +++ b/src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj @@ -0,0 +1,20 @@ + + + + netstandard2.0 + PodNet.EmbeddedTexts + A simple C# code generator that allows for embedding text file content into your code. + EmbeddedTexts, PodNet, generator, embedded, texts, resx, resource, awesome + true + + + + + + + + + + + + \ No newline at end of file diff --git a/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props b/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props new file mode 100644 index 0000000..e287d49 --- /dev/null +++ b/src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs b/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs new file mode 100644 index 0000000..4f5e582 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/EmbeddedTextsGeneratorIntegrationTest.cs @@ -0,0 +1,55 @@ +namespace PodNet.EmbeddedTexts.IntegrationTests; + +/// +/// This tests that, given the provided files are correctly added to AdditionaFiles, the correct +/// classes are generated in the correct namespaces with the correct API surface (property), and it returns +/// the file content as the result (unless ignored). These "tests" won't even compile if the correct +/// APIs are not generated. +/// +[TestClass] +public class EmbeddedTextsGeneratorIntegrationTest +{ + [TestMethod] + public void TestContentIsEmbedded() + { + Assert.AreEqual(""" + Text Content + Is Embedded + """, Files.Text_txt.Content); + } + + [TestMethod] + public void IgnoredContentIsNotEmbedded() + { + // Given that the Files\Ignored\** pattern is excluded by setting "PodNet_EmbedText" to "false", the following shouldn't compile: + // Files.Ignored.Ignored_txt.Content; + var undefined = "PodNet.EmbeddedTexts.IntegrationTests.Files.Ignored.Ignored_txt"; + var compilation = Analyzers.Testing.CSharp.PodCSharpCompilation.Create([$$""" + class ShouldError + { + string WontCompile() => {{undefined}}.Content; + }; + """]); + var diagnostics = compilation.CurrentCompilation.GetDiagnostics(); + Assert.IsTrue(diagnostics.Any(d => d is + { + Severity: Microsoft.CodeAnalysis.DiagnosticSeverity.Error, + Id: "CS0234", // {The type or namespace name '{0}' does not exist in the namespace '{1}' (are you missing an assembly reference?)} + Location: { SourceSpan: var span, SourceTree: var tree } + } && tree?.GetText().GetSubText(span).ToString() == undefined)); + } + + [TestMethod] + public void UnignoredContentIsEmbedded() + { + Assert.AreEqual("Unignored", Files.Ignored.Unignored_txt.Content); + } + + [TestMethod] + public void CustomClassAndNamespaceNamesCanBeSupplied() + { + Assert.IsNotNull(Files.TestClass.Content); + Assert.IsNotNull(TestNamespace.TestSubNamespace.CustomNamespace_txt.Content); + Assert.IsNotNull(TestNamespace.TestSubNamespace.TestClass.Content); + } +} diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/CustomClass.txt b/tests/EmbeddedTexts.IntegrationTests/Files/CustomClass.txt new file mode 100644 index 0000000..a9bed00 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/CustomClass.txt @@ -0,0 +1 @@ +CustomClass \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/CustomClassAndNamespace.txt b/tests/EmbeddedTexts.IntegrationTests/Files/CustomClassAndNamespace.txt new file mode 100644 index 0000000..d1711a3 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/CustomClassAndNamespace.txt @@ -0,0 +1 @@ +CustomClassAndNamespace \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/CustomNamespace.txt b/tests/EmbeddedTexts.IntegrationTests/Files/CustomNamespace.txt new file mode 100644 index 0000000..a7e0d81 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/CustomNamespace.txt @@ -0,0 +1 @@ +CustomNamespace \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Ignored.txt b/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Ignored.txt new file mode 100644 index 0000000..bf65270 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Ignored.txt @@ -0,0 +1 @@ +Ignored \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Unignored.txt b/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Unignored.txt new file mode 100644 index 0000000..991a4b6 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/Ignored/Unignored.txt @@ -0,0 +1 @@ +Unignored \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/Files/Text.txt b/tests/EmbeddedTexts.IntegrationTests/Files/Text.txt new file mode 100644 index 0000000..0706e54 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/Files/Text.txt @@ -0,0 +1,2 @@ +Text Content +Is Embedded \ No newline at end of file diff --git a/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj b/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj new file mode 100644 index 0000000..9c73a46 --- /dev/null +++ b/tests/EmbeddedTexts.IntegrationTests/PodNet.EmbeddedTexts.IntegrationTests.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + $(ArtifactsPath)/package + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs b/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs new file mode 100644 index 0000000..27c7685 --- /dev/null +++ b/tests/EmbeddedTexts.Tests/EmbeddedTextGeneratorTests.cs @@ -0,0 +1,119 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using PodNet.Analyzers.Testing.CSharp; +using System.Collections.Immutable; +using Fakes = PodNet.Analyzers.Testing.CodeAnalysis.Fakes; + +namespace PodNet.EmbeddedTexts.Tests; + +[TestClass] +public class EmbeddedTextGeneratorTests +{ + [TestMethod] + public void DoesntGenerateWhenDisabled() + { + var result = RunGeneration(false, false); + Assert.AreEqual(1, result.Results.Length); + Assert.AreEqual(0, result.Results[0].GeneratedSources.Length); + } + + [TestMethod] + public void GeneratesForOptInOnly() + { + var result = RunGeneration(false, true); + // This is a complex assertion that makes sure there is a single result that contains a single property declaration with the expected content in its initializer. Doesn't check for other structural correctness. + Assert.AreEqual("Test File 2 Content", ((LiteralExpressionSyntax)result.Results.Single().GeneratedSources.Single().SyntaxTree.GetRoot().DescendantNodes().OfType().Single().Initializer!.Value).ToString().Trim('"').Trim()); + } + + [TestMethod] + public void DoesntGenerateForOptOut() + { + var result = RunGeneration(true, false); + Assert.AreEqual(1, result.Results.Length); + Assert.AreEqual(5, result.Results[0].GeneratedSources.Length); + } + + [TestMethod] + public void GenerateAllWhenGloballyEnabledAndItemIsOptIn() + { + var result = RunGeneration(true, true); + Assert.AreEqual(1, result.Results.Length); + Assert.AreEqual(6, result.Results[0].GeneratedSources.Length); + } + + [TestMethod] + public void GeneratesStructurallyEquivalentResult() + { + // Generates a single source as per GeneratesForOptInOnly + var result = RunGeneration(false, true); + var source = result.Results.Single().GeneratedSources.Single(); + + var expected = CSharpSyntaxTree.ParseText("""" + namespace Project; + + public static partial class Parameterized_Enabled_cs + { + public static string Content { get; } = """ + Test File 2 Content + """; + } + """").GetRoot(); + var actual = source.SyntaxTree.GetRoot(); + Assert.IsTrue(SyntaxFactory.AreEquivalent(expected, actual, ignoreChildNode: SyntaxFacts.IsTrivia)); + } + + [DataTestMethod] + [DataRow("Default_txt", "Project")] + [DataRow("Parameterized_Enabled_cs", "Project")] + [DataRow("CustomNamespace_n", "TestNamespace")] + [DataRow("TestClassName", "Project")] + [DataRow("Empty", "Project")] + [DataRow("Empty_ini", "Project.Subdirectory._2_Another____Subdirectory")] + public void GeneratesCorrectClassNamesAndNamespaces(string expectedClassName, string expectedNamespace) + { + var result = RunGeneration(true, true); + var source = result.Results.Single().GeneratedSources.SingleOrDefault(s => s.HintName.EndsWith($"{expectedClassName}.g.cs")); + Assert.IsNotNull(source); + var @namespace = source.SyntaxTree.GetRoot().DescendantNodes().OfType().FirstOrDefault()?.Name.ToString(); + var className = source.SyntaxTree.GetRoot().DescendantNodes().OfType().FirstOrDefault()?.Identifier.ToString(); + Assert.AreEqual(expectedNamespace, @namespace); + Assert.AreEqual(expectedClassName, className); + } + + public static GeneratorDriverRunResult RunGeneration(bool globalEnabled, bool oneItemEnabled) + { + var additionalTextsLookup = GetOptionsForTexts(oneItemEnabled) + .ToDictionary(f => (AdditionalText)f.Key, f => f.Value); + var globalOptions = GetGlobalOptions(globalEnabled); + var optionsProvider = new Fakes.AnalyzerConfigOptionsProvider(globalOptions, [], additionalTextsLookup); + var compilation = PodCSharpCompilation.Create([]); + var generator = new EmbeddedTextsGenerator(); + return compilation.RunGenerators( + [generator], + driver => (CSharpGeneratorDriver)driver + .AddAdditionalTexts(additionalTextsLookup.Select(a => a.Key).ToImmutableArray()) + .WithUpdatedAnalyzerConfigOptions(optionsProvider)); + } + + private static Fakes.AnalyzerConfigOptions GetGlobalOptions(bool globalEnabled) => new() + { + ["build_property.rootnamespace"] = "Project", + ["build_property.projectdir"] = @"C:\Users\source\Project", + [$"build_property.{EmbeddedTextsGenerator.EmbedAdditionalTextsConfigProperty}"] = globalEnabled.ToString() + }; + + private static Dictionary GetOptionsForTexts(bool oneItemEnabled) => new() + { + [new(@"C:\Users\source\Project\Default.txt", "Test File 1 Content")] + = [], + [new(@"C:\Users\source\Project\Parameterized Enabled.cs", "Test File 2 Content")] + = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextMetadataProperty}"] = oneItemEnabled.ToString() }, + [new(@"C:\Users\source\Project\CustomNamespace.n", "Test File 3 Content")] + = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextNamespaceMetadataProperty}"] = "TestNamespace" }, + [new(@"C:\Users\source\Project\CustomClassName.n", "Test File 4 Content")] + = new() { [$"build_metadata.additionalfiles.{EmbeddedTextsGenerator.EmbedTextClassNameMetadataProperty}"] = "TestClassName" }, + [new(@"C:\Users\source\Project\Empty", "")] = [], + [new(@"C:\Users\source\Project\Subdirectory\2 Another & Subdirectory/Empty.ini", "")] = [], + }; +} \ No newline at end of file diff --git a/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj b/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj new file mode 100644 index 0000000..5627f9d --- /dev/null +++ b/tests/EmbeddedTexts.Tests/PodNet.EmbeddedTexts.Tests.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + + + + + + + + + + + + + +