Skip to content

Commit

Permalink
RC: initial functionality, tests, build/CD, README
Browse files Browse the repository at this point in the history
  • Loading branch information
yugabe committed Jul 12, 2024
1 parent bc405cd commit a9feab1
Show file tree
Hide file tree
Showing 17 changed files with 551 additions and 2 deletions.
13 changes: 13 additions & 0 deletions .github/workflows/cd-nuget.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<LangVersion>12.0</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UseArtifactsOutput>true</UseArtifactsOutput>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>
55 changes: 55 additions & 0 deletions PodNet.EmbeddedTexts.sln
Original file line number Diff line number Diff line change
@@ -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
106 changes: 104 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<ItemGroup>`:
```csproj
<ItemGroup>
<AdditionalFiles Include="Files/**" />
</ItemGroup>
```
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 (<kbd>F4</kbd> 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
<Project>
<!-- Additional properties, items and targets omitted -->
<PropertyGroup>
<!-- Setting this to false makes the default behavior opt-in for the generator. -->
<PodNetAutoEmbedAdditionalTexts>false</PodNetAutoEmbedAdditionalTexts>
</PropertyGroup>
<ItemGroup>
<!-- Because automatic embedding is disabled, these files won't be embedded in the source... -->
<AdditionalFiles Include="Files/**" />

<!-- ...unless you opt-in to them being embedded. -->
<AdditionalFiles Update="Files/Embed/**" PodNet_EmbedText="true" />

<!-- But you can still opt-out individually. -->
<AdditionalFiles Update="Files/Embed/.gitkeep" PodNet_EmbedText="false" />
</ItemGroup>
</Project>
```

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
<Project>
<!-- Additional properties, items and targets omitted -->
<ItemGroup>
<!-- You can opt-out of generation for any pattern or individual file. -->
<AdditionalFiles Include="Files/NoEmbed/**" PodNet_EmbedText="false" />
</ItemGroup>
</Project>
```

### 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
<Project>
<!-- Additional properties, items and targets omitted -->
<ItemGroup>
<!-- The default namespace for the file would be "MyProject.Files" and the class would be "My_File_txt". -->
<AdditionalFiles Include="Files/My File.txt" PodNet_EmbedTextNamespace="OtherNamespace" PodNet_EmbedTextClassName="MyFileTXT" />
</ItemGroup>
</Project>
```

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.

```
<AdditionalFiles Include="@(Compile)" PodNet_EmbedTextNamespace="$(RootNamespace).CompiledFiles" PodNet_EmbedTextClassName="%(Filename)" />
```

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!
103 changes: 103 additions & 0 deletions src/EmbeddedTexts/EmbeddedTextsGenerator.cs
Original file line number Diff line number Diff line change
@@ -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($$"""
// <auto-generated />
namespace {{@namespace}};
public static partial class {{className}}
{
/// <summary>
/// Contents of the file at '{{relativeFilePath}}':
/// <code>
""");

foreach (var line in lines)
{
sourceBuilder.AppendLine($$"""
/// {{line.ToString().Replace("<", "&lt;").Replace(">", "&gt;")}}
""");
}

sourceBuilder.AppendLine($$"""
/// </code>
/// </summary>
public static string Content { get; } = {{separator}}
{{text}}
{{separator}};
}
""");

context.AddSource($"{@namespace}/{className}.g.cs", sourceBuilder.ToString());
});
}
}
20 changes: 20 additions & 0 deletions src/EmbeddedTexts/PodNet.EmbeddedTexts.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<PackageId>PodNet.EmbeddedTexts</PackageId>
<Description>A simple C# code generator that allows for embedding text file content into your code.</Description>
<PackageTags>EmbeddedTexts, PodNet, generator, embedded, texts, resx, resource, awesome</PackageTags>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
</PropertyGroup>

<ItemGroup>
<InternalsVisibleTo Include="PodNet.EmbeddedTexts.Tests" />

<PackageReference Include="PodNet.Analyzers.Core" Version="1.0.9" PrivateAssets="all" />
<PackageReference Include="PodNet.NuGet.Core" Version="1.0.4" PrivateAssets="all" />

<Content Include="build/*" PackagePath="build" />
</ItemGroup>

</Project>
12 changes: 12 additions & 0 deletions src/EmbeddedTexts/build/PodNet.EmbeddedTexts.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Project>
<PropertyGroup>
<PodNetAutoEmbedAdditionalTexts>true</PodNetAutoEmbedAdditionalTexts>
</PropertyGroup>

<ItemGroup>
<CompilerVisibleProperty Include="PodNetAutoEmbedAdditionalTexts" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="PodNet_EmbedText" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="PodNet_EmbedTextNamespace" />
<CompilerVisibleItemMetadata Include="AdditionalFiles" MetadataName="PodNet_EmbedTextClassName" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
namespace PodNet.EmbeddedTexts.IntegrationTests;

/// <summary>
/// This tests that, given the provided files are correctly added to <c>AdditionaFiles</c>, 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.
/// </summary>
[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);
}
}
1 change: 1 addition & 0 deletions tests/EmbeddedTexts.IntegrationTests/Files/CustomClass.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomClass
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomClassAndNamespace
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
CustomNamespace
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Ignored
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Unignored
Loading

0 comments on commit a9feab1

Please sign in to comment.