feat: add Roslyn-based compile-time request handler registration

Introduce compile-time registration capabilities with source generation, enabling automatic handler registration.
This commit is contained in:
Louis Seubert 2026-05-23 21:22:30 +02:00
commit f77c1ff29b
Signed by: louis9902
GPG key ID: 4B9DB28F826553BD
20 changed files with 918 additions and 133 deletions

View file

@ -0,0 +1,147 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using Geekeey.Request.Generator;
using Microsoft.Extensions.DependencyInjection;
namespace Geekeey.Request.Tests;
internal sealed class CompileTimeRequestDispatchGeneratorTests
{
[Test]
public async Task I_can_generate_extensions_for_top_level_request_handlers()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).IsEmpty();
await Assert.That(content).Contains("AddExampleProject");
await Assert.That(content).Contains("typeof(global::Sample.PingHandler)");
await Assert.That(content).Contains("typeof(global::Sample.OpenPingHandler<>)");
await Assert.That(content).DoesNotContain("PingBehavior");
}
[Test]
public async Task I_can_override_the_generated_extension_name()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerName"] = "CustomRegistration";
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
await Assert.That(content).Contains("AddCustomRegistration");
}
[Test]
public async Task I_get_an_error_for_an_invalid_explicit_name()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerName"] = "not.valid";
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidExplicitName]);
await Assert.That(result.GeneratedTrees).IsEmpty();
await Assert.That(content).IsNullOrEmpty();
}
[Test]
public async Task I_get_an_error_when_the_assembly_name_cannot_be_sanitized_to_a_valid_identifier()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "...";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.InvalidDerivedName]);
await Assert.That(result.GeneratedTrees).IsEmpty();
await Assert.That(content).IsNullOrEmpty();
}
[Test]
public async Task I_get_an_error_for_nested_handlers_and_they_are_ignored()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorNestedHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(result.Diagnostics).Count().IsEqualTo(1);
await Assert.That(result.Diagnostics.Select(diagnostic => diagnostic.Descriptor))
.IsEquivalentTo([CompileTimeRequestDispatchGenerator.NestedHandlerIgnored]);
await Assert.That(content).DoesNotContain("Container.PingHandler");
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
}
[Test]
public async Task I_still_generate_a_no_op_extension_when_no_handlers_exist()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorNoHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
});
var content = result.File(@"Geekeey\.Request\.CompileTimeRegistration\.(.*)\.g\.cs");
using var scope = Assert.Multiple();
await Assert.That(content).Contains("AddExampleProject");
await Assert.That(content).DoesNotContain("builder.Add(typeof(");
}
[Test]
public async Task I_can_disable_CompileTime_registration_generation()
{
var result = Roslyn.SourceGenerator<CompileTimeRequestDispatchGenerator>(options =>
{
options.AddFromEmbeddedSource("GeneratorTopLevelHandlers.cs");
options.AssemblyName = "Example.Project";
options.References.Add(typeof(IRequestDispatcherBuilder).Assembly);
options.References.Add(typeof(ServiceLifetime).Assembly);
options.Properties["CompileTimeRequestDispatchHandlerRegistration"] = "false";
});
await Assert.That(result.GeneratedTrees).IsEmpty();
}
}

View file

@ -18,6 +18,12 @@
</ItemGroup>
<ItemGroup>
<Compile Remove="_fixtures/roslyn/**/*.cs" />
<EmbeddedResource Include="_fixtures/roslyn/**/*.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\request.generator\Geekeey.Request.Generator.csproj" />
<ProjectReference Include="..\request\Geekeey.Request.csproj" />
</ItemGroup>

View file

@ -0,0 +1,51 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Diagnostics;
using System.IO.Compression;
namespace Geekeey.Request.Tests;
internal sealed class PackageContentsTests
{
private static string RepoRoot => Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..");
private static string ArtifactsDir => Path.Combine(RepoRoot, "artifacts");
private static string ProjectPath => Path.Combine(RepoRoot, "src", "request", "Geekeey.Request.csproj");
private readonly string _outputDir;
public PackageContentsTests()
{
_outputDir = Path.Combine(ArtifactsDir, Path.GetRandomFileName());
}
[Before(Test)]
public async Task SetUpAsync()
{
Directory.CreateDirectory(_outputDir);
}
[After(Test)]
public async Task CleanUpAsync()
{
Directory.Delete(_outputDir, true);
}
[Test]
public async Task I_can_verify_the_package_contains_the_expected_contents()
{
using var process = Process.Start("dotnet", $"pack \"{ProjectPath}\" -o \"{_outputDir}\" -c Release");
await process.WaitForExitAsync();
var pkgs = Directory.GetFiles(_outputDir, "*.nupkg");
await Assert.That(pkgs.Length).IsGreaterThanOrEqualTo(1);
await using var archive = await ZipFile.OpenReadAsync(pkgs[0]);
var entries = archive.Entries.Select(entry => entry.FullName.Replace('\\', '/')).ToHashSet();
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.dll");
await Assert.That(entries).Contains("lib/net10.0/Geekeey.Request.xml");
await Assert.That(entries).Contains("build/Geekeey.Request.props");
await Assert.That(entries).Contains("analyzers/dotnet/cs/Geekeey.Request.Generator.dll");
}
}

View file

@ -100,9 +100,43 @@ internal sealed class RequestDispatcherBuilderExtensionsTests
using (Assert.Multiple())
{
await Assert.That(() => builder.Add(typeof(TestHandler))).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient)).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add(typeof(TestHandler), ServiceLifetime.Transient))
.Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)])).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient)).Throws<ArgumentNullException>();
await Assert.That(() => builder.Add([typeof(TestHandler)], ServiceLifetime.Transient))
.Throws<ArgumentNullException>();
}
}
[Test]
public async Task I_get_an_exception_when_adding_a_nested_request_handler()
{
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var assembly = Roslyn.Compile(options =>
{
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
});
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
var exception = await Assert.That(() => builder.Add(nestedHandlerType)).Throws<InvalidOperationException>();
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
}
[Test]
public async Task I_get_an_exception_when_adding_nested_request_handlers_with_a_lifetime()
{
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
var assembly = Roslyn.Compile(options =>
{
options.AddFromEmbeddedSource("BuilderNestedHandler.cs");
});
var nestedHandlerType = assembly.GetType("Sample.Container+PingHandler", throwOnError: true)!;
var exception = await Assert
.That(() => builder.Add([typeof(TestHandler), nestedHandlerType], ServiceLifetime.Transient))
.Throws<InvalidOperationException>();
await Assert.That(exception?.Message).Contains(nestedHandlerType.FullName!);
}
}

View file

@ -13,32 +13,15 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_can_search_handlers_in_an_assembly()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
var services = new ServiceCollection();
services.AddRequestDispatcher(builder => builder
.SearchHandlerInAssembly(assembly.Assembly));
.SearchHandlerInAssembly(assembly));
await using var provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<RequestDispatcherOptions>>().Value;
var requestType = assembly.Assembly.GetType("Sample.Ping", throwOnError: true)!;
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var requestType = assembly.GetType("Sample.Ping", throwOnError: true)!;
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var handlerInterface = typeof(IScalarRequestHandler<,>).MakeGenericType(requestType, typeof(string));
var handlers = GetRequestHandlers(options, handlerInterface, provider).ToArray();
@ -50,29 +33,12 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_can_search_handlers_in_an_assembly_with_a_lifetime()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchTopLevelHandler.cs"));
var services = new ServiceCollection();
services.AddRequestDispatcher(builder => builder
.SearchHandlerInAssembly(assembly.Assembly, ServiceLifetime.Singleton));
var handlerType = assembly.Assembly.GetType("Sample.PingHandler", throwOnError: true)!;
.SearchHandlerInAssembly(assembly, ServiceLifetime.Singleton));
var handlerType = assembly.GetType("Sample.PingHandler", throwOnError: true)!;
var descriptor = services.SingleOrDefault(service => service.ServiceType == handlerType);
using var scope = Assert.Multiple();
@ -83,32 +49,12 @@ internal sealed class SearchHandlerInAssemblyTests
[Test]
public async Task I_get_an_exception_when_nested_handlers_are_present()
{
const string source =
"""
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}
""";
using var assembly = DynamicTestAssembly.Compile(source);
var assembly = Roslyn.Compile(options => options.AddFromEmbeddedSource("SearchNestedHandler.cs"));
var services = new ServiceCollection();
var builder = services.AddRequestDispatcher();
await Assert.That(() => builder.SearchHandlerInAssembly(assembly.Assembly))
await Assert.That(() => builder.SearchHandlerInAssembly(assembly))
.Throws<InvalidOperationException>().And.HasMessageContaining("Sample.Container+PingHandler");
}

View file

@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,17 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,5 @@
namespace Sample;
public sealed class Marker
{
}

View file

@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
public sealed class GenericPing<T> : IScalarRequest<string>
{
}
public sealed class OpenPingHandler<T> : IScalarRequestHandler<GenericPing<T>, string>
{
public Task<string> HandleAsync(GenericPing<T> request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
public sealed class PingBehavior : IScalarRequestBehavior<Ping, string>
{
public Task<string> HandleAsync(Ping request, ScalarHandlerDelegate<string> next, CancellationToken cancellationToken) => next(request, cancellationToken);
}

View file

@ -0,0 +1,18 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class Container
{
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}
}

View file

@ -0,0 +1,15 @@
using System.Threading;
using System.Threading.Tasks;
using Geekeey.Request;
namespace Sample;
public sealed class Ping : IScalarRequest<string>
{
}
public sealed class PingHandler : IScalarRequestHandler<Ping, string>
{
public Task<string> HandleAsync(Ping request, CancellationToken cancellationToken) => Task.FromResult("pong");
}

View file

@ -1,67 +0,0 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Reflection;
using System.Runtime.Loader;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
namespace Geekeey.Request.Tests;
internal sealed class DynamicTestAssembly : IDisposable
{
private readonly WeakReference _context;
private DynamicTestAssembly(Assembly assembly, CollectibleAssemblyLoadContext context)
{
Assembly = assembly;
_context = new WeakReference(context);
}
public Assembly Assembly { get; }
public static DynamicTestAssembly Compile(string source)
{
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Concat([typeof(IRequestDispatcherBuilder).Assembly.Location])
.Distinct(StringComparer.Ordinal)
.Select(static path => MetadataReference.CreateFromFile(path));
var syntax = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default
.WithLanguageVersion(LanguageVersion.Preview));
var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
var compilation = CSharpCompilation.Create($"Dynamic_{Guid.NewGuid():N}", [syntax], references, options);
using var stream = new MemoryStream();
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
{
throw new InvalidOperationException(string.Join(Environment.NewLine,
diagnostics.Select(static diagnostic => diagnostic.ToString())));
}
stream.Seek(0, SeekOrigin.Begin);
var context = new CollectibleAssemblyLoadContext();
return new DynamicTestAssembly(context.LoadFromStream(stream), context);
}
public void Dispose()
{
if (_context.Target is AssemblyLoadContext context)
{
context.Unload();
}
for (var iteration = 0; iteration < 10 && _context.IsAlive; iteration++)
{
GC.Collect();
GC.WaitForPendingFinalizers();
}
}
private sealed class CollectibleAssemblyLoadContext()
: AssemblyLoadContext(nameof(DynamicTestAssembly), isCollectible: true);
}

View file

@ -0,0 +1,177 @@
// Copyright (c) The Geekeey Authors
// SPDX-License-Identifier: EUPL-1.2
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Assembly = System.Reflection.Assembly;
namespace Geekeey.Request.Tests;
internal static class Roslyn
{
private static CSharpCompilation CreateCompilation(CompilerOptions options)
{
if (options.SourceCode.Count is 0)
{
throw new InvalidOperationException("At least one source file must be configured.");
}
var syntax = options.SourceCode
.Select(static sourceCode => CSharpSyntaxTree.ParseText(sourceCode, CSharpParseOptions.Default
.WithLanguageVersion(LanguageVersion.Preview)))
.ToArray();
var references = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!)
.Split(Path.PathSeparator)
.Concat(options.References.Select(static assembly => assembly.Location))
.Distinct(StringComparer.Ordinal)
.Select(static path => MetadataReference.CreateFromFile(path));
var compilationOptions = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary);
return CSharpCompilation.Create(options.AssemblyName, syntax, references, compilationOptions);
}
public sealed class CompilerOptions
{
public string AssemblyName { get; set; } = Guid.NewGuid().ToString();
public ICollection<Assembly> References { get; } = new HashSet<Assembly>();
public IDictionary<string, string?> Properties { get; } = new Dictionary<string, string?>(StringComparer.Ordinal);
public ICollection<string> SourceCode { get; } = new HashSet<string>(StringComparer.Ordinal);
public void AddFromEmbeddedSource(string fileName)
{
var assembly = Assembly.GetExecutingAssembly();
var relativePath = $"_fixtures/roslyn/{fileName}";
var suffix = relativePath.Replace(Path.DirectorySeparatorChar, '.')
.Replace(Path.AltDirectorySeparatorChar, '.');
var resourceName = assembly.GetManifestResourceNames()
.SingleOrDefault(name => name.EndsWith(suffix, StringComparison.Ordinal));
if (resourceName is null)
{
throw new InvalidOperationException($"Embedded fixture '{relativePath}' was not found.");
}
if (assembly.GetManifestResourceStream(resourceName) is not { } stream)
{
throw new InvalidOperationException($"Embedded fixture '{resourceName}' could not be opened.");
}
using var reader = new StreamReader(stream);
SourceCode.Add(reader.ReadToEnd());
}
}
public static Assembly Compile(Action<CompilerOptions>? configure = null)
{
var options = new CompilerOptions();
configure?.Invoke(options);
var compilation = CreateCompilation(options);
using var stream = new MemoryStream();
if (compilation.Emit(stream) is { Success: false, Diagnostics: var diagnostics })
{
throw new InvalidOperationException(string.Join(Environment.NewLine,
diagnostics.Select(static diagnostic => diagnostic.ToString())));
}
stream.Seek(0, SeekOrigin.Begin);
return Assembly.Load(stream.ToArray());
}
public static GeneratorDriverRunResult SourceGenerator<TGenerator>(Action<CompilerOptions>? configure = null)
where TGenerator : IIncrementalGenerator, new()
{
var options = new CompilerOptions();
configure?.Invoke(options);
var compilation = CreateCompilation(options);
GeneratorDriver driver = CSharpGeneratorDriver.Create([new TGenerator().AsSourceGenerator()],
parseOptions: (CSharpParseOptions)compilation.SyntaxTrees.First().Options,
optionsProvider: new TestAnalyzerConfigOptionsProvider(
new Dictionary<string, string?>(options.Properties, StringComparer.Ordinal)
{
["build_property.AssemblyName"] = options.AssemblyName,
}));
driver = driver.RunGeneratorsAndUpdateCompilation(compilation, out _, out _);
return driver.GetRunResult();
}
extension(GeneratorDriverRunResult result)
{
public string? File([StringSyntax(StringSyntaxAttribute.Regex)] string name)
{
foreach (var value in result.Results)
{
foreach (var source in value.GeneratedSources)
{
if (Regex.IsMatch(source.HintName, name))
{
return source.SourceText.ToString();
}
}
}
return null;
}
}
private sealed class TestAnalyzerConfigOptionsProvider : AnalyzerConfigOptionsProvider
{
public TestAnalyzerConfigOptionsProvider(IDictionary<string, string?> values)
{
GlobalOptions = new TestAnalyzerConfigOptions(values);
}
public override AnalyzerConfigOptions GlobalOptions { get; }
public override AnalyzerConfigOptions GetOptions(SyntaxTree tree)
{
return GlobalOptions;
}
public override AnalyzerConfigOptions GetOptions(AdditionalText textFile)
{
return GlobalOptions;
}
}
private sealed class TestAnalyzerConfigOptions : AnalyzerConfigOptions
{
private readonly ImmutableDictionary<string, string?> _values;
public TestAnalyzerConfigOptions(IDictionary<string, string?> values)
{
_values = values.ToImmutableDictionary(StringComparer.Ordinal);
}
public override bool TryGetValue(string key, out string value)
{
if (_values.TryGetValue(key, out var existingValue) && existingValue is not null)
{
value = existingValue;
return true;
}
if (key.StartsWith("build_property.", StringComparison.Ordinal) &&
_values.TryGetValue(key["build_property.".Length..], out existingValue) &&
existingValue is not null)
{
value = existingValue;
return true;
}
value = string.Empty;
return false;
}
}
}