diff --git a/docs/code-writer.md b/docs/code-writer.md index a3aea23..6332e21 100644 --- a/docs/code-writer.md +++ b/docs/code-writer.md @@ -108,6 +108,16 @@ writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeR // factory.Create(x); ``` +When a statement or declaration must embed a runtime or user-supplied string — for example a +regular-expression pattern or error message — emit it through the `StringLiteral()` extension rather +than wrapping it in quotes by hand. It returns a quoted, escaped C# string literal: + +```csharp +body.Field("regex", regexType, TypeDeclarationAccessibility.Private, + options => options with { IsStatic = true, Initializer = $"new({pattern.StringLiteral()})" }); +// pattern = ^[\w\-.]+$ => new("^[\\w\\-.]+$") +``` + A **chained** invocation — where the result of each call is the receiver of the next, and a postfix is applied to the final result — is expressed with `MethodCallChain`/`AwaitedMethodCallChain`. The chain is written as an expression (no terminating semicolon), so it composes as the value of an @@ -279,6 +289,12 @@ Emits: namespace Purview.Telemetry; ``` +The generator version in the header and the `GeneratedCode` attribute comes from the +`GenerationSettings` used to create the writer. When settings are created via +`GenerationSettings.Create()`, the full assembly informational version is used, so any +pre-release suffix (such as `-alpha`) and build metadata (such as `+commit-hash`) are preserved rather +than being reduced to the numeric assembly version. + ### Conditional compilation returns `NetConditionalReturn` writes a `return` for an interpolated string using the best invariant-culture diff --git a/package.json b/package.json index 107268b..05d7e10 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.35", + "version": "1.0.0-prerelease.36", "private": true } diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs index e27c430..7d0a64b 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/ServiceRegistrationEmitter.cs @@ -162,7 +162,12 @@ static void EmitServiceInfo(SourceProductionContext spc, ServiceRegistrationGene "Name", TypeIdentity.Create().AsTypeReference(), TypeDeclarationAccessibility.Public, - options => options with { IsStatic = true, ExpressionBody = $"\"{target.Name}\"" } + options => + options with + { + IsStatic = true, + ExpressionBody = target.Name.StringLiteral(), + } ); inner.Property( @@ -173,7 +178,7 @@ static void EmitServiceInfo(SourceProductionContext spc, ServiceRegistrationGene options with { IsStatic = true, - ExpressionBody = $"\"{target.LifetimeMemberName}\"", + ExpressionBody = target.LifetimeMemberName.StringLiteral(), } ); diff --git a/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs b/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs index 9196d2d..34f0910 100644 --- a/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs +++ b/src/src/SourceGeneratorFramework/Extensions/System/StringExtension.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using Microsoft.CodeAnalysis.CSharp; namespace System; @@ -15,6 +16,15 @@ public static class StringExtension /// The surrounded string. public string Surround(string surroundWith = "\"") => $"{surroundWith}{value}{surroundWith}"; + /// + /// Returns a quoted, escaped C# string literal for the value so it can be safely emitted into + /// generated source. Backslashes, quotes, and other characters requiring escaping are escaped; + /// e.g. ^[\w\-.]+$ becomes "^[\\w\\-.]+$". A null value is emitted as the + /// null keyword. + /// + /// The value as a C# string literal, or the null keyword if the value is null. + public string StringLiteral() => value is null ? "null" : SymbolDisplay.FormatLiteral(value, true); + /// /// Returns the string value or "null" if the value is null. If is true, then it will also return "null" if the value is whitespace. /// diff --git a/src/src/SourceGeneratorShared/GenerationSettings.cs b/src/src/SourceGeneratorShared/GenerationSettings.cs index b506839..929b881 100644 --- a/src/src/SourceGeneratorShared/GenerationSettings.cs +++ b/src/src/SourceGeneratorShared/GenerationSettings.cs @@ -1,3 +1,4 @@ +using System.Reflection; using Microsoft.CodeAnalysis.CSharp; namespace Purview.SourceGeneratorFramework; @@ -171,7 +172,10 @@ public GenerationSettings( TypeDeclarationAccessibility.Public; /// - /// Creates a new generation settings instance for the specified generator type, using the type name and assembly version. + /// Creates a new generation settings instance for the specified generator type, using the type name and + /// the assembly's informational version. The informational version carries the full SemVer details, + /// including any pre-release suffix (such as -alpha) and build metadata (such as +hash), + /// which are not present in the numeric assembly version. /// /// The type of the generator. /// An optional MSBuild property name that disables the generator when set to true. @@ -180,10 +184,15 @@ public static GenerationSettings Create(string? disabledSourceGenMSB { var generatorType = typeof(TGenerator); - return new( - generatorType.Name, - generatorType.Assembly.GetName().Version?.ToString(), - disabledSourceGenMSBuildProperty - ); + return new(generatorType.Name, GetGeneratorVersion(generatorType.Assembly), disabledSourceGenMSBuildProperty); } + + /// + /// Gets the full version for an assembly, preferring the informational version (which includes any + /// pre-release suffix and build metadata) and falling back to the numeric assembly version. + /// + static string GetGeneratorVersion(Assembly assembly) => + assembly.GetCustomAttribute()?.InformationalVersion + ?? assembly.GetName().Version?.ToString() + ?? "1.0.0.0"; } diff --git a/src/tests/SourceGeneratorFramework.UnitTests/StringExtensionTests.cs b/src/tests/SourceGeneratorFramework.UnitTests/StringExtensionTests.cs new file mode 100644 index 0000000..b9aeb07 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.UnitTests/StringExtensionTests.cs @@ -0,0 +1,23 @@ +namespace Purview.SourceGeneratorFramework; + +public class StringExtensionTests +{ + [Test] + [Arguments("Hello, World!", "\"Hello, World!\"")] + [Arguments(@"^[\w\-.]+$", "\"^[\\\\w\\\\-.]+$\"")] + [Arguments("say \"hi\"", "\"say \\\"hi\\\"\"")] + [Arguments("", "\"\"")] + [Arguments("a\nb", "\"a\\nb\"")] + [Arguments("a\tb", "\"a\\tb\"")] + [Arguments("a\r\nb", "\"a\\r\\nb\"")] + public async Task StringLiteral_GivenValue_EscapesItAsCSharpStringLiteral(string value, string expected) + { + await Assert.That(value.StringLiteral()).IsEqualTo(expected); + } + + [Test] + public async Task StringLiteral_GivenNull_ReturnsNullKeyword() + { + await Assert.That(((string?)null).StringLiteral()).IsEqualTo("null"); + } +} diff --git a/src/tests/SourceGeneratorShared.UnitTests/GenerationSettingsTests.cs b/src/tests/SourceGeneratorShared.UnitTests/GenerationSettingsTests.cs new file mode 100644 index 0000000..cc83c43 --- /dev/null +++ b/src/tests/SourceGeneratorShared.UnitTests/GenerationSettingsTests.cs @@ -0,0 +1,23 @@ +using System.Reflection; +using Purview.SourceGeneratorFramework.TestGenerators; + +namespace Purview.SourceGeneratorFramework; + +public class GenerationSettingsTests +{ + [Test] + public async Task Create_GivenGeneratorType_UsesFullInformationalVersion() + { + var assembly = typeof(AlwaysNullableContextTestGenerator).Assembly; + var informationalVersion = assembly + .GetCustomAttribute() + ?.InformationalVersion; + + await Assert.That(informationalVersion).IsNotNull(); + + var settings = GenerationSettings.Create(); + + await Assert.That(settings.GeneratorVersion).IsEqualTo(informationalVersion); + await Assert.That(settings.GeneratorVersion).IsNotEqualTo(assembly.GetName().Version?.ToString()); + } +}