From d89f4dd171dc6c285d39c9b11f808157601a8009 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 29 Jul 2026 00:34:22 +0200 Subject: [PATCH 1/3] Restore the ThisAssemblyClass module as an incremental source generator Reintroduce the ThisAssemblyClass SDK module, removed in PR #158, as a dormant module (GenerateThisAssemblyClass defaults to false) built on a Roslyn incremental source generator instead of the old compiled-task code generation. C# only: enabling the module in any other language raises the new BVSDK2300 warning. - ThisAssemblyConstant items are serialized by the new WriteThisAssemblyConstantsFile task into a percent-encoded constants file under obj/, exposed to the generator as a tagged AdditionalFiles item; class name and namespace flow through CompilerVisibleProperty. - ThisAssemblyClassGenerator emits the class with CompilerGenerated and ExcludeFromCodeCoverage attributes; invalid constant values raise BVSDK2301 as a compiler diagnostic (with analyzer release tracking). - The constant-value parser is restored into the source generators assembly as the single shared implementation; VB type aliases (Integer, Boolean) are gone along with all VB support. - Default constants and the EnableDefaultThisAssemblyConstants knob are preserved from the pre-removal module. - New Buildvana.Sdk.SourceGenerators.Tests project (generator + parser tests); task tests added to Buildvana.Sdk.Tasks.Tests. - ConstantsSyntax.md trimmed to ThisAssembly constants only; new ThisAssemblyClass diagnostics range (2300-2399) in SdkDiagnostics.md. - markdownlint config: move "ignores" to its documented top-level position (it was inert inside "config") and exempt the machine-parsed AnalyzerReleases.*.md files. Closes #295 Co-Authored-By: Claude Fable 5 --- .markdownlint-cli2.jsonc | 7 +- Buildvana.slnx | 1 + CHANGELOG.md | 1 + docs/ConstantsSyntax.md | 72 +++---- docs/SdkDiagnostics.md | 8 + .../AnalyzerReleases.Shipped.md | 2 + .../AnalyzerReleases.Unshipped.md | 8 + .../Buildvana.Sdk.SourceGenerators.csproj | 9 + ...AnalyzerConfigOptionsProviderExtensions.cs | 15 +- .../Internal/ConstantValueParser.cs | 101 ++++++++++ .../ThisAssemblyClassGenerator.cs | 190 ++++++++++++++++++ .../Tasks/WriteThisAssemblyConstantsFile.cs | 68 +++++++ .../ThisAssemblyClass/Module.Core.targets | 89 ++++++++ .../Modules/ThisAssemblyClass/Module.targets | 32 +++ src/Buildvana.Sdk/Sdk/Sdk.props | 1 + ...uildvana.Sdk.SourceGenerators.Tests.csproj | 22 ++ .../ConstantValueParserTests.cs | 92 +++++++++ .../InMemoryAdditionalText.cs | 16 ++ .../TestAnalyzerConfigOptions.cs | 25 +++ .../TestAnalyzerConfigOptionsProvider.cs | 23 +++ .../ThisAssemblyClassGeneratorTests.cs | 166 +++++++++++++++ .../WriteThisAssemblyConstantsFileTests.cs | 106 ++++++++++ 22 files changed, 1001 insertions(+), 53 deletions(-) create mode 100644 src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Shipped.md create mode 100644 src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Unshipped.md create mode 100644 src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs create mode 100644 src/Buildvana.Sdk.SourceGenerators/ThisAssemblyClassGenerator.cs create mode 100644 src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs create mode 100644 src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets create mode 100644 src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.targets create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/Buildvana.Sdk.SourceGenerators.Tests.csproj create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/ConstantValueParserTests.cs create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/InMemoryAdditionalText.cs create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptions.cs create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptionsProvider.cs create mode 100644 tests/Buildvana.Sdk.SourceGenerators.Tests/ThisAssemblyClassGeneratorTests.cs create mode 100644 tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 8f4b7d8b..8a7ba1cf 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -1,5 +1,9 @@ { "gitignore": true, + + // Globs of files to ignore + "ignores": ["CLAUDE.md", ".claude/**/*.md", "**/AnalyzerReleases.*.md"], + "config":{ // Default state for all rules "default": true, @@ -7,9 +11,6 @@ // Path to configuration file to extend "extends": null, - // Globs of files to ignore - "ignores": ["CLAUDE.md", ".claude/**/*.md"], - // MD001/heading-increment : Heading levels should only increment by one level at a time : https://github.com/DavidAnson/markdownlint/blob/main/doc/md001.md "heading-increment": true, diff --git a/Buildvana.slnx b/Buildvana.slnx index f26b193e..1109e4fe 100644 --- a/Buildvana.slnx +++ b/Buildvana.slnx @@ -55,6 +55,7 @@ + diff --git a/CHANGELOG.md b/CHANGELOG.md index e42cd7f3..2d7d32c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - NuGet push feeds (`nuget.feeds`): a `release` channel and an optional `prerelease` channel, each `{ source, apiKeyEnv }`. `bv release` pushes prerelease versions to the `prerelease` feed — falling back to the `release` feed when `prerelease` is omitted — and stable versions to the `release` feed. The feed URL comes from `source`; the API key is read from the environment variable named by `apiKeyEnv`. Feed selection no longer depends on whether the repository is private: the old `private` channel is gone, although `bv` can still query a repository's visibility. - the GitHub token (`github.tokenEnv`, default `GITHUB_TOKEN`): names the environment variable that holds the token used for release operations. - `buildvana.json` accepts a `git.identity` (`{ name, email }`) section describing the author/committer for automated commits. It is validated and exposed, but not yet wired to release commits. +- The `ThisAssemblyClass` SDK module, removed along with code generation tasks after v1.0.0-alpha.20, has been reintroduced. Setting the `GenerateThisAssemblyClass` property to `true` (default: `false`) in a C# project generates a `ThisAssembly` static class containing constants defined via `ThisAssemblyConstant` items, using the syntax documented in [docs/ConstantsSyntax.md](docs/ConstantsSyntax.md). A set of default constants (assembly version, company, product, etc.) is defined unless the `EnableDefaultThisAssemblyConstants` property is set to `false`; the class name and namespace can be customized via the `ThisAssemblyClassName` and `ThisAssemblyClassNamespace` properties. Unlike its previous incarnation, the feature is implemented as a Roslyn incremental source generator, and supports C# projects only: setting `GenerateThisAssemblyClass` to `true` in a project in any other language raises warning BVSDK2300. ### Changes to existing features diff --git a/docs/ConstantsSyntax.md b/docs/ConstantsSyntax.md index f831c940..ae4d8341 100644 --- a/docs/ConstantsSyntax.md +++ b/docs/ConstantsSyntax.md @@ -1,16 +1,16 @@ -# Syntax of constants in ThisAssembly classes and LiteralAssemblyInfo parameters +# Syntax of constants in ThisAssembly classes **Table of contents** - [Overview](#overview) -- [How Buildvana SDK parses constant and parameter values](#how-buildvana-sdk-parses-constant-and-parameter-values) +- [How Buildvana SDK parses constant values](#how-buildvana-sdk-parses-constant-values) - [Allowed types](#allowed-types) ## Overview -Constants in ThisAssembly classes are specified via `ThisAssemblyConstant` items: +Constants in `ThisAssembly` classes are specified via `ThisAssemblyConstant` items: ```XML @@ -24,67 +24,43 @@ Constants in ThisAssembly classes are specified via `ThisAssemblyConstant` items ``` -Parameters for literal assembly attributes are specified as follows: - -```XML - - - - <_Parameter1>2 - <_Parameter2>true - <_Parameter3 /> - yes - bar+"baz" - - -``` - -The type of a constant or parameter may also be explicitly specified: +The type of a constant may also be explicitly specified: ```XML - - - - <_Parameter1>int:2 - <_Parameter2>bool:true - <_Parameter3 /> - string:yes - "bar+"""baz""" - - ``` -## How Buildvana SDK parses constant and parameter values +> **NOTE:** `ThisAssembly` class generation is only supported in C# projects. + +## How Buildvana SDK parses constant values -Given the `Value` metadata of a `ThisAssemblyConstant` item, or a metadata of a `LiteralAssemblyAttribute` item, Buildvana SDK performs the following steps: +Given the `Value` metadata of a `ThisAssemblyConstant` item, Buildvana SDK performs the following steps: -- If the metadata is empty, the resulting object is `null` (`Nothing` in VB). - **Examples:** `<_Parameter1 />` -> `null`; `<_Parameter1>` -> `null`. +- If the metadata is empty, the resulting constant is a null string (`public const string? Name = null;`). - If the first and last characters of the metadata are double quotes, the result is a `System.String` whose value is the string between the double quotes. In this case, _double quote characters within the metadata must be doubled._ - **Examples:** `<_Parameter1>""` -> `""` (the empty string); `<_Parameter1>"""Murder"", she wrote"` -> `"\"Murder\", she wrote"`. + **Examples:** `""` -> the empty string; `"""Murder"", she wrote"` -> `"Murder", she wrote`. - If the metadata contains a colon, it is assumed to be of the form `type:value`, where `type` must be one of the strings listed in the table [below](#allowed-types), and `value` must be parsable as the specified type. If `type` is not recognized, or `value` cannot be successfully parsed, an error is logged and the build stops. - **Examples:** `<_Parameter1>int:42` -> `42`; `<_Parameter1>long:42` -> `42L` (`42&` in VB). + **Examples:** `int:42` -> `42`; `long:42` -> `42L`. - If the metadata can be successfully parsed as a `System.Int32`, the result is the parsed value. - **Examples:** `<_Parameter1>42` -> `42`; `<_Parameter1>-13` -> `-13`. + **Examples:** `42` -> `42`; `-13` -> `-13`. - If the metadata can be successfully parsed as a `System.Int64`, the result is the parsed value. - **Examples:** `<_Parameter1>12345678901234567890` -> `12345678901234567890L` (`12345678901234567890&` in VB); `<_Parameter1>-99998888777766665555` -> `-99998888777766665555L` (`-99998888777766665555&` in VB). + **Examples:** `9999999999` -> `9999999999L`; `-9999999999` -> `-9999999999L`. - If the metadata can be successfully parsed as a `System.Boolean`, the result is the parsed value. - **Examples:** `<_Parameter1>true` -> `true`; `<_Parameter1>false` -> `false`. + **Examples:** `true` -> `true`; `false` -> `false`. - If none of the previous steps yields a result, the result is a `System.String` whose value is the metadata, unchanged. - **Examples:** `<_Parameter1>foo` -> `"foo"`; `<_Parameter1>false90` -> `"false90"`. + **Examples:** `foo` -> `"foo"`; `false90` -> `"false90"`. ## Allowed types -The following table lists the recognized types for constants and parameters, along with +The following table lists the recognized types for constants, along with the prefixes that select each of them in the `type:value` syntax. -| Type | Recognized prefixes (case-insensitive) | -| -------------- | ----------------------------------------- | -| System.UInt8 | `System.Byte`, `byte`, `uint8` | -| System.Int16 | `System.Int16`, `short`, `int16` | -| System.Int32 | `System.Int32`, `int`, `int32`, `Integer` | -| System.Int64 | `System.Int64`, `long`, `int64` | -| System.Boolean | `System.Boolean`, `bool`, `Boolean` | -| System.String | `System.String`, `string` | +| Type | Recognized prefixes (case-insensitive) | +| -------------- | -------------------------------------- | +| System.Byte | `System.Byte`, `byte`, `uint8` | +| System.Int16 | `System.Int16`, `short`, `int16` | +| System.Int32 | `System.Int32`, `int`, `int32` | +| System.Int64 | `System.Int64`, `long`, `int64` | +| System.Boolean | `System.Boolean`, `bool` | +| System.String | `System.String`, `string` | diff --git a/docs/SdkDiagnostics.md b/docs/SdkDiagnostics.md index e33cd43e..c746df99 100644 --- a/docs/SdkDiagnostics.md +++ b/docs/SdkDiagnostics.md @@ -19,6 +19,7 @@ - [NerdbankGitVersioning module (2000-2099)](#nerdbankgitversioning-module-2000-2099) - [ReleaseAssetList module (2100-2199)](#releaseassetlist-module-2100-2199) - [Wine module (2200-2299)](#wine-module-2200-2299) +- [ThisAssemblyClass module (2300-2399)](#thisassemblyclass-module-2300-2399) ## Overview @@ -123,3 +124,10 @@ This module has no associated diagnostics. | Code | Severity | Message | Description | | --------- | :------: | ------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BVSDK2200 | Error | One or more tools need Wine to run on this system, but no Wine invocation command has been defined: ...[;...] | One or more tools needed to build and/or distribute your project need [Wine](https://winehq.org) to run under a non-Windows operating system. In order to use Wine with Buildvana SDK, the `WineInvocationCommand` property must be set as explained in [the module documentation](./modules/Wine.md#configuration). | + +## ThisAssemblyClass module (2300-2399) + +| Code | Severity | Message | Description | +| --------- | :------: | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| BVSDK2300 | Warning | ThisAssembly class generation is only supported in C# projects. | Property `GenerateThisAssemblyClass` was set to `true` in a project whose language is not C#. No `ThisAssembly` class will be generated. | +| BVSDK2301 | Error | Constant '...' has invalid value '...'. | A `ThisAssemblyConstant` item has a `Value` metadata that cannot be parsed according to the [constants syntax](ConstantsSyntax.md): an unknown type prefix, or a value that cannot be parsed as the specified type. | diff --git a/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Shipped.md b/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Shipped.md new file mode 100644 index 00000000..f50bb1fe --- /dev/null +++ b/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Unshipped.md b/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Unshipped.md new file mode 100644 index 00000000..0fafc3b6 --- /dev/null +++ b/src/Buildvana.Sdk.SourceGenerators/AnalyzerReleases.Unshipped.md @@ -0,0 +1,8 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +----------|---------------|----------|-------------------------------------------------------------- +BVSDK2301 | Buildvana.Sdk | Error | ThisAssemblyClassGenerator: invalid ThisAssemblyConstant value diff --git a/src/Buildvana.Sdk.SourceGenerators/Buildvana.Sdk.SourceGenerators.csproj b/src/Buildvana.Sdk.SourceGenerators/Buildvana.Sdk.SourceGenerators.csproj index 650c84d1..27d21f71 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Buildvana.Sdk.SourceGenerators.csproj +++ b/src/Buildvana.Sdk.SourceGenerators/Buildvana.Sdk.SourceGenerators.csproj @@ -6,6 +6,15 @@ true + + + + + + + + + diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs index df81e587..02ad5661 100644 --- a/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/AnalyzerConfigOptionsProviderExtensions.cs @@ -6,8 +6,19 @@ namespace Buildvana.Sdk.SourceGenerators.Internal; +/// +/// Provides extension methods for AnalyzerConfigOptionsProvider instances. +/// +#pragma warning disable CA1034 // Nested types should not be visible — false positive on C# 14 extension blocks; fixed in .NET 11, backport to .NET 10 requested in https://github.com/dotnet/sdk/issues/53984 +#pragma warning disable CA1708 // Identifiers should differ by more than case — false positive on classes with C# 14 extension blocks; fixed in .NET 11, https://github.com/dotnet/sdk/issues/51716 internal static class AnalyzerConfigOptionsProviderExtensions { - public static bool? GetBooleanMSBuildProperty(this AnalyzerConfigOptionsProvider @this, string name) - => @this.GlobalOptions.TryGetValue($"build_property.{name}", out var value) ? value.Equals("true", StringComparison.OrdinalIgnoreCase) : null; + extension(AnalyzerConfigOptionsProvider @this) + { + public bool? GetBooleanMSBuildProperty(string name) + => @this.GlobalOptions.TryGetValue($"build_property.{name}", out var value) ? value.Equals("true", StringComparison.OrdinalIgnoreCase) : null; + + public string? GetMSBuildProperty(string name) + => @this.GlobalOptions.TryGetValue($"build_property.{name}", out var value) ? value : null; + } } diff --git a/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs b/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs new file mode 100644 index 00000000..ef10bb5f --- /dev/null +++ b/src/Buildvana.Sdk.SourceGenerators/Internal/ConstantValueParser.cs @@ -0,0 +1,101 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Buildvana.Sdk.SourceGenerators.Internal; + +/// +/// Parses constant values expressed in the syntax documented in docs/ConstantsSyntax.md: +/// an empty string yields ; a double-quoted string (with inner double quotes doubled) +/// yields the quoted text; a type:value pair yields a value of the specified type; +/// anything else is parsed by guessing the type (int, then long, then bool, then string). +/// +internal static class ConstantValueParser +{ + private static readonly Dictionary AllowedTypes = new(StringComparer.OrdinalIgnoreCase) + { + ["System.Byte"] = typeof(byte), + ["byte"] = typeof(byte), + ["uint8"] = typeof(byte), + ["System.Int16"] = typeof(short), + ["short"] = typeof(short), + ["int16"] = typeof(short), + ["System.Int32"] = typeof(int), + ["int"] = typeof(int), + ["int32"] = typeof(int), + ["System.Int64"] = typeof(long), + ["long"] = typeof(long), + ["int64"] = typeof(long), + ["System.Boolean"] = typeof(bool), + ["bool"] = typeof(bool), + ["System.String"] = typeof(string), + ["string"] = typeof(string), + }; + + public static bool TryParse(string? str, out object? result) + { + if (string.IsNullOrEmpty(str)) + { + result = null; + return true; + } + + if (str!.Length > 1 && str[0] == '"' && str[^1] == '"') + { + result = str.Substring(1, str.Length - 2).Replace("\"\"", "\""); + return true; + } + + var colonPos = str.IndexOf(':'); + return colonPos < 1 + ? TryParseGuessingType(str, out result) + : TryParseTyped(str.Substring(0, colonPos), str.Substring(colonPos + 1), out result); + } + + private static bool TryParseGuessingType(string str, out object? result) + { + if (int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt)) + { + result = parsedInt; + return true; + } + + if (long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedLong)) + { + result = parsedLong; + return true; + } + + if (bool.TryParse(str, out var parsedBool)) + { + result = parsedBool; + return true; + } + + result = str; + return true; + } + + private static bool TryParseTyped(string typeStr, string str, out object? result) + { + if (!AllowedTypes.TryGetValue(typeStr.Trim(), out var type)) + { + result = null; + return false; + } + + try + { + result = Convert.ChangeType(str, type, CultureInfo.InvariantCulture); + return true; + } + catch (Exception e) when (e is FormatException or OverflowException or InvalidCastException) + { + result = null; + return false; + } + } +} diff --git a/src/Buildvana.Sdk.SourceGenerators/ThisAssemblyClassGenerator.cs b/src/Buildvana.Sdk.SourceGenerators/ThisAssemblyClassGenerator.cs new file mode 100644 index 00000000..f1345c0e --- /dev/null +++ b/src/Buildvana.Sdk.SourceGenerators/ThisAssemblyClassGenerator.cs @@ -0,0 +1,190 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Globalization; +using System.Text; +using Buildvana.Sdk.SourceGenerators.Internal; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Buildvana.Sdk.SourceGenerators; + +/// +/// Generates a ThisAssembly static class containing constants defined via ThisAssemblyConstant items. +/// The constants are read from a file written by the WriteThisAssemblyConstantsFile task +/// and exposed to the generator as an AdditionalFiles item whose BV_ItemType metadata +/// is ThisAssemblyConstants. C# projects only. +/// +[Generator(LanguageNames.CSharp)] +public class ThisAssemblyClassGenerator : IIncrementalGenerator +{ + private const string DefaultClassName = "ThisAssembly"; + + private static readonly DiagnosticDescriptor InvalidConstantValueDescriptor = new( + "BVSDK2301", + "Invalid ThisAssembly constant value", + "Constant '{0}' has invalid value '{1}'", + "Buildvana.Sdk", + DiagnosticSeverity.Error, + isEnabledByDefault: true); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var constantsTextsProvider = context.AdditionalTextsProvider + .Combine(context.AnalyzerConfigOptionsProvider) + .Where(static pair => IsConstantsFile(pair.Right.GetOptions(pair.Left))) + .Select(static (pair, cancellationToken) => pair.Left.GetText(cancellationToken)?.ToString() ?? string.Empty) + .Collect(); + + var optionsProvider = context.AnalyzerConfigOptionsProvider + .Select(static (p, _) + => (ClassName: p.GetMSBuildProperty("ThisAssemblyClassName") ?? DefaultClassName, + Namespace: p.GetMSBuildProperty("ThisAssemblyClassNamespace"))); + + var finalProvider = constantsTextsProvider.Combine(optionsProvider); + context.RegisterSourceOutput(finalProvider, static (spc, data) => + { + var (constantsTexts, options) = data; + GenerateThisAssemblyClass(spc, constantsTexts, options.ClassName, options.Namespace); + }); + } + + private static void GenerateThisAssemblyClass( + SourceProductionContext context, + ImmutableArray constantsTexts, + string className, + string? namespaceName) + { + if (constantsTexts.IsEmpty) + { + return; + } + + if (!TryParseConstants(context, constantsTexts, out var constants) || constants.Count == 0) + { + return; + } + + var sb = new StringBuilder(); + _ = sb.AppendLine("// ") + .AppendLine() + .AppendLine("#nullable enable") + .AppendLine(); + + var indent = string.Empty; + if (!string.IsNullOrEmpty(namespaceName)) + { + _ = sb.AppendLine($"namespace {namespaceName}") + .AppendLine("{"); + indent = " "; + } + + _ = sb.AppendLine($"{indent}[global::System.Runtime.CompilerServices.CompilerGenerated]") + .AppendLine($"{indent}[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]") + .AppendLine($"{indent}internal static partial class {className}") + .AppendLine($"{indent}{{"); + + foreach (var (name, value) in constants) + { + _ = sb.AppendLine($"{indent} public const {GetTypeKeyword(value)} {name} = {GetLiteral(value)};"); + } + + _ = sb.AppendLine($"{indent}}}"); + if (indent.Length > 0) + { + _ = sb.AppendLine("}"); + } + + context.AddSource("BV_ThisAssembly", sb.ToString()); + } + + private static bool TryParseConstants( + SourceProductionContext context, + ImmutableArray constantsTexts, + out List<(string Name, object? Value)> constants) + { + constants = []; + var success = true; + foreach (var text in constantsTexts) + { + foreach (var line in text.Split('\n')) + { + var trimmedLine = line.TrimEnd('\r'); + if (trimmedLine.Length == 0) + { + continue; + } + + var equalsPos = trimmedLine.IndexOf('='); + var name = Uri.UnescapeDataString(equalsPos < 0 ? trimmedLine : trimmedLine.Substring(0, equalsPos)); + var rawValue = equalsPos < 0 ? string.Empty : Uri.UnescapeDataString(trimmedLine.Substring(equalsPos + 1)); + if (!ConstantValueParser.TryParse(rawValue, out var value)) + { + context.ReportDiagnostic(Diagnostic.Create(InvalidConstantValueDescriptor, Location.None, name, rawValue)); + success = false; + continue; + } + + constants.Add((name, value)); + } + } + + return success; + } + + private static bool IsConstantsFile(AnalyzerConfigOptions options) + => options.TryGetValue("build_metadata.AdditionalFiles.BV_ItemType", out var itemType) + && itemType == "ThisAssemblyConstants"; + + // NOTE: All supported types must be listed (check ConstantValueParser.AllowedTypes). + private static string GetTypeKeyword(object? value) + => value switch { + null => "string?", + byte => "byte", + short => "short", + int => "int", + long => "long", + bool => "bool", + string => "string", + _ => throw new ArgumentException("Unsupported type for a constant.", nameof(value)), + }; + + // NOTE: All supported types must be listed (check ConstantValueParser.AllowedTypes). + private static string GetLiteral(object? value) + => value switch { + null => "null", + byte byteValue => byteValue.ToString(CultureInfo.InvariantCulture), + short shortValue => shortValue.ToString(CultureInfo.InvariantCulture), + int intValue => intValue.ToString(CultureInfo.InvariantCulture), + long longValue => longValue.ToString(CultureInfo.InvariantCulture) + "L", + bool boolValue => boolValue ? "true" : "false", + string stringValue => GetStringLiteral(stringValue), + _ => throw new ArgumentException("Unsupported type for a constant.", nameof(value)), + }; + + private static string GetStringLiteral(string value) + { + var sb = new StringBuilder(value.Length + 2); + _ = sb.Append('"'); + foreach (var c in value) + { + _ = c switch { + '"' => sb.Append("\\\""), + '\\' => sb.Append("\\\\"), + '\n' => sb.Append("\\n"), + '\r' => sb.Append("\\r"), + '\t' => sb.Append("\\t"), + '\u2028' or '\u2029' => AppendEscaped(sb, c), // line separators end a line in C# but are not covered by char.IsControl + _ => char.IsControl(c) ? AppendEscaped(sb, c) : sb.Append(c), + }; + } + + return sb.Append('"').ToString(); + + static StringBuilder AppendEscaped(StringBuilder sb, char c) + => sb.Append("\\u").Append(((int)c).ToString("x4", CultureInfo.InvariantCulture)); + } +} diff --git a/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs b/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs new file mode 100644 index 00000000..e244f530 --- /dev/null +++ b/src/Buildvana.Sdk.Tasks/Tasks/WriteThisAssemblyConstantsFile.cs @@ -0,0 +1,68 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System; +using System.Globalization; +using System.IO; +using System.Text; +using Buildvana.Core; +using Buildvana.Sdk.Internal; +using Buildvana.Sdk.Resources; +using Microsoft.Build.Framework; + +namespace Buildvana.Sdk.Tasks; + +/// +/// Serializes ThisAssemblyConstant items into a constants file +/// consumed by the ThisAssemblyClass source generator. +/// Each line of the file has the form name=value, with both name and value percent-encoded, +/// so that arbitrary characters (including newlines and equal signs) survive the round trip. +/// The file is only rewritten when its content changes, so that up-to-date checks keep working. +/// +public sealed class WriteThisAssemblyConstantsFile : BuildvanaSdkTask +{ + [Required] + public string OutputPath { get; set; } = string.Empty; + +#pragma warning disable CA1819 // Properties should not return arrays - ITaskItem[] properties of MSBuild tasks are a known exception + public ITaskItem[] Constants { get; set; } = []; +#pragma warning restore CA1819 + + protected override Undefined Run() + { + BuildFailedException.ThrowIfNot( + !string.IsNullOrEmpty(OutputPath), + string.Format(CultureInfo.InvariantCulture, Strings.MissingParameterFmt, nameof(OutputPath))); + + var sb = new StringBuilder(); + foreach (var item in Constants) + { + var name = item.ItemSpec.Trim(); + var value = item.GetMetadata("Value").Trim(); + _ = sb.Append(Uri.EscapeDataString(name)) + .Append('=') + .Append(Uri.EscapeDataString(value)) + .Append('\n'); + } + + SaveIfDifferent(OutputPath, sb.ToString()); + return Undefined.Value; + } + + private static void SaveIfDifferent(string outputPath, string content) + { + try + { + if (File.Exists(outputPath) && File.ReadAllText(outputPath) == content) + { + return; + } + + File.WriteAllText(outputPath, content); + } + catch (Exception e) when (e.IsIORelatedException()) + { + throw new BuildFailedException(string.Format(CultureInfo.InvariantCulture, Strings.CouldNotWriteFileFmt, outputPath, e.Message), e); + } + } +} diff --git a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets new file mode 100644 index 00000000..6a359470 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets @@ -0,0 +1,89 @@ + + + + ThisAssembly + + + + + true + false + + + + + + + + + + + $(IntermediateOutputPath)$(MSBuildProjectName).ThisAssemblyConstants.txt + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + true + + + diff --git a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.targets b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.targets new file mode 100644 index 00000000..b1515713 --- /dev/null +++ b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.targets @@ -0,0 +1,32 @@ + + + + + $(GenerateThisAssemblyClass) + false + false + + + + + false + false + false + + + + + + + + + + diff --git a/src/Buildvana.Sdk/Sdk/Sdk.props b/src/Buildvana.Sdk/Sdk/Sdk.props index 855f85ad..54ee1c73 100644 --- a/src/Buildvana.Sdk/Sdk/Sdk.props +++ b/src/Buildvana.Sdk/Sdk/Sdk.props @@ -118,6 +118,7 @@ + diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/Buildvana.Sdk.SourceGenerators.Tests.csproj b/tests/Buildvana.Sdk.SourceGenerators.Tests/Buildvana.Sdk.SourceGenerators.Tests.csproj new file mode 100644 index 00000000..e1706f72 --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/Buildvana.Sdk.SourceGenerators.Tests.csproj @@ -0,0 +1,22 @@ + + + + Exe + $(StandardTfm) + false + true + + + + + + + + + + + + + + diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/ConstantValueParserTests.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/ConstantValueParserTests.cs new file mode 100644 index 00000000..9982c427 --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/ConstantValueParserTests.cs @@ -0,0 +1,92 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +extern alias Generators; + +using Generators::Buildvana.Sdk.SourceGenerators.Internal; + +internal sealed class ConstantValueParserTests +{ + [Test] + [Arguments(null)] + [Arguments("")] + public async Task TryParse_EmptyValue_YieldsNull(string? str) + { + var success = ConstantValueParser.TryParse(str, out var result); + await Assert.That(success).IsTrue(); + await Assert.That(result).IsNull(); + } + + [Test] + [Arguments("\"\"", "")] + [Arguments("\"foo\"", "foo")] + [Arguments("\"\"\"Murder\"\", she wrote\"", "\"Murder\", she wrote")] + [Arguments("\" spaces kept \"", " spaces kept ")] + public async Task TryParse_QuotedString_YieldsUnquotedString(string str, string expected) + { + var success = ConstantValueParser.TryParse(str, out var result); + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("byte:200", (byte)200)] + [Arguments("uint8:200", (byte)200)] + [Arguments("System.Byte:200", (byte)200)] + [Arguments("short:-5", (short)-5)] + [Arguments("int16:-5", (short)-5)] + [Arguments("System.Int16:-5", (short)-5)] + [Arguments("int:42", 42)] + [Arguments("int32:42", 42)] + [Arguments("System.Int32:42", 42)] + [Arguments("long:42", 42L)] + [Arguments("int64:42", 42L)] + [Arguments("System.Int64:42", 42L)] + [Arguments("bool:true", true)] + [Arguments("System.Boolean:false", false)] + [Arguments("string:foo", "foo")] + [Arguments("System.String:foo", "foo")] + [Arguments("string:", "")] + [Arguments("string:int:42", "int:42")] + [Arguments("INT:42", 42)] + [Arguments("String:foo", "foo")] + [Arguments(" int :42", 42)] + public async Task TryParse_TypedValue_YieldsTypedResult(string str, object? expected) + { + var success = ConstantValueParser.TryParse(str, out var result); + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("42", 42)] + [Arguments("-13", -13)] + [Arguments("9999999999", 9999999999L)] + [Arguments("-9999999999", -9999999999L)] + [Arguments("99998888777766665555", "99998888777766665555")] // too large even for long, so it stays a string + [Arguments("true", true)] + [Arguments("False", false)] + [Arguments("foo", "foo")] + [Arguments("false90", "false90")] + [Arguments(":foo", ":foo")] + public async Task TryParse_UntypedValue_GuessesType(string str, object expected) + { + var success = ConstantValueParser.TryParse(str, out var result); + await Assert.That(success).IsTrue(); + await Assert.That(result).IsEqualTo(expected); + } + + [Test] + [Arguments("integer:42")] + [Arguments("boolean:true")] + [Arguments("unknown:42")] + [Arguments("int:foo")] + [Arguments("byte:300")] + [Arguments("bool:yes")] + public async Task TryParse_InvalidValue_Fails(string str) + { + var success = ConstantValueParser.TryParse(str, out var result); + await Assert.That(success).IsFalse(); + await Assert.That(result).IsNull(); + } +} diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/InMemoryAdditionalText.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/InMemoryAdditionalText.cs new file mode 100644 index 00000000..2618c1b8 --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/InMemoryAdditionalText.cs @@ -0,0 +1,16 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; + +/// +/// An whose content is provided as a string. +/// +internal sealed class InMemoryAdditionalText(string path, string content) : AdditionalText +{ + public override string Path => path; + + public override SourceText GetText(CancellationToken cancellationToken = default) => SourceText.From(content, Encoding.UTF8); +} diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptions.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptions.cs new file mode 100644 index 00000000..b9c5d4f2 --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptions.cs @@ -0,0 +1,25 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// An backed by a dictionary. +/// +internal sealed class TestAnalyzerConfigOptions(IReadOnlyDictionary options) : AnalyzerConfigOptions +{ + public static TestAnalyzerConfigOptions Empty { get; } = new(new Dictionary()); + + public override bool TryGetValue(string key, [NotNullWhen(true)] out string? value) + { + if (options.TryGetValue(key, out var found)) + { + value = found; + return true; + } + + value = null; + return false; + } +} diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptionsProvider.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptionsProvider.cs new file mode 100644 index 00000000..b1efebae --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/TestAnalyzerConfigOptionsProvider.cs @@ -0,0 +1,23 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +/// +/// An backed by dictionaries, +/// providing global options and per-additional-file options. +/// +internal sealed class TestAnalyzerConfigOptionsProvider( + IReadOnlyDictionary globalOptions, + IReadOnlyDictionary> fileOptions) : AnalyzerConfigOptionsProvider +{ + public override AnalyzerConfigOptions GlobalOptions => new TestAnalyzerConfigOptions(globalOptions); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestAnalyzerConfigOptions.Empty; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + => fileOptions.TryGetValue(textFile.Path, out var options) + ? new TestAnalyzerConfigOptions(options) + : TestAnalyzerConfigOptions.Empty; +} diff --git a/tests/Buildvana.Sdk.SourceGenerators.Tests/ThisAssemblyClassGeneratorTests.cs b/tests/Buildvana.Sdk.SourceGenerators.Tests/ThisAssemblyClassGeneratorTests.cs new file mode 100644 index 00000000..0003fa41 --- /dev/null +++ b/tests/Buildvana.Sdk.SourceGenerators.Tests/ThisAssemblyClassGeneratorTests.cs @@ -0,0 +1,166 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +extern alias Generators; + +using System.Globalization; +using Generators::Buildvana.Sdk.SourceGenerators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +internal sealed class ThisAssemblyClassGeneratorTests +{ + private const string ConstantsFilePath = "/obj/Test.ThisAssemblyConstants.txt"; + + private static readonly IReadOnlyDictionary ConstantsFileMetadata = new Dictionary + { + ["build_metadata.AdditionalFiles.BV_ItemType"] = "ThisAssemblyConstants", + }; + + [Test] + public async Task NoAdditionalFiles_GeneratesNothing() + { + var result = RunGenerator(constantsContent: null); + await Assert.That(result.RunResult.GeneratedTrees.Length).IsEqualTo(0); + } + + [Test] + public async Task UntaggedConstantsFile_GeneratesNothing() + { + var result = RunGenerator("Answer=42\n", tagFile: false); + await Assert.That(result.RunResult.GeneratedTrees.Length).IsEqualTo(0); + } + + [Test] + public async Task EmptyConstantsFile_GeneratesNothing() + { + var result = RunGenerator(string.Empty); + await Assert.That(result.RunResult.GeneratedTrees.Length).IsEqualTo(0); + } + + [Test] + public async Task Constants_GenerateClassWithExpectedShape() + { + var result = RunGenerator("Answer=42\n"); + var source = GetSingleGeneratedSource(result); + await Assert.That(source).Contains("[global::System.Runtime.CompilerServices.CompilerGenerated]"); + await Assert.That(source).Contains("[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]"); + await Assert.That(source).Contains("internal static partial class ThisAssembly"); + await Assert.That(source).Contains("public const int Answer = 42;"); + await Assert.That(source).DoesNotContain("namespace"); + await AssertCompiles(result).ConfigureAwait(false); + } + + [Test] + public async Task Constants_OfAllTypes_GenerateExpectedFields() + { + var content = + "ByteValue=byte%3A200\n" + + "ShortValue=short%3A-5\n" + + "IntValue=42\n" + + "LongValue=long%3A42\n" + + "BoolValue=bool%3Atrue\n" + + "StringValue=string%3AHello%20World\n" + + "NullValue=\n"; + var result = RunGenerator(content); + var source = GetSingleGeneratedSource(result); + await Assert.That(source).Contains("public const byte ByteValue = 200;"); + await Assert.That(source).Contains("public const short ShortValue = -5;"); + await Assert.That(source).Contains("public const int IntValue = 42;"); + await Assert.That(source).Contains("public const long LongValue = 42L;"); + await Assert.That(source).Contains("public const bool BoolValue = true;"); + await Assert.That(source).Contains("public const string StringValue = \"Hello World\";"); + await Assert.That(source).Contains("public const string? NullValue = null;"); + await AssertCompiles(result).ConfigureAwait(false); + } + + [Test] + public async Task Constants_WithSpecialCharacters_GenerateEscapedLiterals() + { + // Value is "a=b" + newline + quote + backslash + tab, percent-encoded as the task would write it. + var result = RunGenerator("Tricky=a%3Db%0A%22%5C%09\n"); + var source = GetSingleGeneratedSource(result); + await Assert.That(source).Contains("public const string Tricky = \"a=b\\n\\\"\\\\\\t\";"); + await AssertCompiles(result).ConfigureAwait(false); + } + + [Test] + public async Task CustomClassNameAndNamespace_AreHonored() + { + var globalOptions = new Dictionary + { + ["build_property.ThisAssemblyClassName"] = "MyAssemblyInfo", + ["build_property.ThisAssemblyClassNamespace"] = "Some.Name.Space", + }; + var result = RunGenerator("Answer=42\n", globalOptions); + var source = GetSingleGeneratedSource(result); + await Assert.That(source).Contains("namespace Some.Name.Space"); + await Assert.That(source).Contains("internal static partial class MyAssemblyInfo"); + await AssertCompiles(result).ConfigureAwait(false); + } + + [Test] + public async Task InvalidConstantValue_ReportsDiagnosticAndGeneratesNothing() + { + var result = RunGenerator("Bad=int%3Afoo\nGood=42\n"); + await Assert.That(result.RunResult.GeneratedTrees.Length).IsEqualTo(0); + var diagnostics = result.RunResult.Results[0].Diagnostics; + await Assert.That(diagnostics.Length).IsEqualTo(1); + await Assert.That(diagnostics[0].Id).IsEqualTo("BVSDK2301"); + await Assert.That(diagnostics[0].GetMessage(CultureInfo.InvariantCulture)).Contains("'Bad'"); + await Assert.That(diagnostics[0].GetMessage(CultureInfo.InvariantCulture)).Contains("'int:foo'"); + } + + private static (GeneratorDriverRunResult RunResult, Compilation OutputCompilation) RunGenerator( + string? constantsContent, + IReadOnlyDictionary? globalOptions = null, + bool tagFile = true) + { + var compilation = CSharpCompilation.Create( + "TestAssembly", + references: GetReferences(), + options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var additionalTexts = constantsContent is null + ? [] + : new AdditionalText[] { new InMemoryAdditionalText(ConstantsFilePath, constantsContent) }; + + var fileOptions = new Dictionary>(); + if (tagFile) + { + fileOptions[ConstantsFilePath] = ConstantsFileMetadata; + } + + var optionsProvider = new TestAnalyzerConfigOptionsProvider( + globalOptions ?? new Dictionary(), + fileOptions); + + var driver = CSharpGeneratorDriver.Create( + [new ThisAssemblyClassGenerator().AsSourceGenerator()], + additionalTexts, + optionsProvider: optionsProvider); + + var runResult = driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _).GetRunResult(); + return (runResult, outputCompilation); + } + + private static string GetSingleGeneratedSource((GeneratorDriverRunResult RunResult, Compilation OutputCompilation) result) + => result.RunResult.GeneratedTrees.Single().ToString(); + + private static async Task AssertCompiles((GeneratorDriverRunResult RunResult, Compilation OutputCompilation) result) + { + var errors = result.OutputCompilation + .GetDiagnostics() + .Where(d => d.Severity == DiagnosticSeverity.Error) + .ToArray(); + await Assert.That(errors).IsEmpty(); + } + + private static IEnumerable GetReferences() + { + var trustedAssemblies = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator); + return trustedAssemblies + .Where(p => Path.GetFileName(p) is "System.Runtime.dll" or "System.Private.CoreLib.dll" or "netstandard.dll") + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)); + } +} diff --git a/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs b/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs new file mode 100644 index 00000000..e4fe718e --- /dev/null +++ b/tests/Buildvana.Sdk.Tasks.Tests/WriteThisAssemblyConstantsFileTests.cs @@ -0,0 +1,106 @@ +// Copyright (C) Tenacom and Contributors. Licensed under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Buildvana.Sdk.Tasks; +using Microsoft.Build.Framework; +using TaskItem = Microsoft.Build.Utilities.TaskItem; + +internal sealed class WriteThisAssemblyConstantsFileTests +{ + [Test] + public async Task Execute_WritesEncodedConstants() + { + await RunInTempDirectory(async (engine, outputPath) => + { + var task = CreateTask(engine, outputPath, ("Answer", "42"), ("My Constant", "string:Hello World"), ("Tricky", "a=b\nc;d")); + await Assert.That(task.Execute()).IsTrue(); + var content = await File.ReadAllTextAsync(outputPath).ConfigureAwait(false); + await Assert.That(content).IsEqualTo("Answer=42\nMy%20Constant=string%3AHello%20World\nTricky=a%3Db%0Ac%3Bd\n"); + }).ConfigureAwait(false); + } + + [Test] + public async Task Execute_TrimsNamesAndValues() + { + await RunInTempDirectory(async (engine, outputPath) => + { + var task = CreateTask(engine, outputPath, (" Answer ", " 42 ")); + await Assert.That(task.Execute()).IsTrue(); + var content = await File.ReadAllTextAsync(outputPath).ConfigureAwait(false); + await Assert.That(content).IsEqualTo("Answer=42\n"); + }).ConfigureAwait(false); + } + + [Test] + public async Task Execute_WithNoConstants_WritesEmptyFile() + { + await RunInTempDirectory(async (engine, outputPath) => + { + var task = CreateTask(engine, outputPath); + await Assert.That(task.Execute()).IsTrue(); + await Assert.That(File.Exists(outputPath)).IsTrue(); + var content = await File.ReadAllTextAsync(outputPath).ConfigureAwait(false); + await Assert.That(content).IsEqualTo(string.Empty); + }).ConfigureAwait(false); + } + + [Test] + public async Task Execute_WithUnchangedContent_DoesNotRewriteFile() + { + await RunInTempDirectory(async (engine, outputPath) => + { + await Assert.That(CreateTask(engine, outputPath, ("Answer", "42")).Execute()).IsTrue(); + var pastTime = DateTime.UtcNow.AddHours(-1); + File.SetLastWriteTimeUtc(outputPath, pastTime); + await Assert.That(CreateTask(engine, outputPath, ("Answer", "42")).Execute()).IsTrue(); + await Assert.That(File.GetLastWriteTimeUtc(outputPath)).IsEqualTo(pastTime); + }).ConfigureAwait(false); + } + + [Test] + public async Task Execute_WithChangedContent_RewritesFile() + { + await RunInTempDirectory(async (engine, outputPath) => + { + await Assert.That(CreateTask(engine, outputPath, ("Answer", "42")).Execute()).IsTrue(); + await Assert.That(CreateTask(engine, outputPath, ("Answer", "13")).Execute()).IsTrue(); + var content = await File.ReadAllTextAsync(outputPath).ConfigureAwait(false); + await Assert.That(content).IsEqualTo("Answer=13\n"); + }).ConfigureAwait(false); + } + + [Test] + public async Task Execute_WithMissingOutputPath_LogsError() + { + var engine = new RecordingBuildEngine(); + var task = CreateTask(engine, string.Empty, ("Answer", "42")); + await Assert.That(task.Execute()).IsFalse(); + await Assert.That(engine.Errors.Count).IsEqualTo(1); + await Assert.That(engine.Errors[0].Message!).Contains("BVSDK1050"); + } + + private static WriteThisAssemblyConstantsFile CreateTask( + IBuildEngine engine, + string outputPath, + params (string Name, string Value)[] constants) + => new() + { + BuildEngine = engine, + OutputPath = outputPath, + Constants = [.. constants.Select(c => new TaskItem(c.Name, new Dictionary { ["Value"] = c.Value }))], + }; + + private static async Task RunInTempDirectory(Func test) + { + var tempDirectory = Directory.CreateTempSubdirectory(); + try + { + var outputPath = Path.Combine(tempDirectory.FullName, "Test.ThisAssemblyConstants.txt"); + await test(new RecordingBuildEngine(), outputPath).ConfigureAwait(false); + } + finally + { + tempDirectory.Delete(recursive: true); + } + } +} From 4853167215fd403c90672e471309563c0d611c6c Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 29 Jul 2026 01:22:28 +0200 Subject: [PATCH 2/3] Run version-computing targets before defining default ThisAssembly constants BV_DefineDefaultThisAssemblyConstants reads AssemblyVersion, FileVersion, and InformationalVersion, but those are computed by the GetAssemblyVersion target (with the source revision appended by AddSourceRevisionToInformationalVersion), which had no ordering relation with the constants-writing target: both hooked BeforeTargets=CoreCompile. Declare both as dependencies so the default constants never capture empty or incomplete version values. Co-Authored-By: Claude Fable 5 --- .../Modules/ThisAssemblyClass/Module.Core.targets | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets index 6a359470..403de0ed 100644 --- a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets +++ b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets @@ -33,8 +33,14 @@ - + From f38dfd6364626d4e19453a93addbee6db871bc10 Mon Sep 17 00:00:00 2001 From: Riccardo De Agostini Date: Wed, 29 Jul 2026 01:26:20 +0200 Subject: [PATCH 3/3] Name default ThisAssembly constants after assembly attributes, NBGV-style The default constant for $(AssemblyVersion) was named Version, which is ambiguous: it could just as easily be read as the NuGet package version. Follow the naming convention of Nerdbank.GitVersioning ThisAssembly classes instead, where constants are named after the corresponding assembly attributes (AssemblyVersion, AssemblyFileVersion, AssemblyInformationalVersion, AssemblyTitle, and so on). Also add the AssemblyName, AssemblyConfiguration, and RootNamespace constants that NBGV provides and cost nothing to supply. Co-Authored-By: Claude Fable 5 --- .../ThisAssemblyClass/Module.Core.targets | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets index 403de0ed..247f32f7 100644 --- a/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets +++ b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets @@ -44,25 +44,33 @@ Condition="$(EnableDefaultThisAssemblyConstants)"> - - - - - - - - + + + + + + + + + + + + - - - - - - - - + + + + + + + + + + +