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..247f32f7
--- /dev/null
+++ b/src/Buildvana.Sdk/Modules/ThisAssemblyClass/Module.Core.targets
@@ -0,0 +1,103 @@
+
+
+
+ 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);
+ }
+ }
+}