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); + } + } +}