request/src/request.tests/_helpers/Roslyn.cs
Louis Seubert f77c1ff29b
feat: add Roslyn-based compile-time request handler registration
Introduce compile-time registration capabilities with source generation, enabling automatic handler registration.
2026-05-23 21:25:14 +02:00

177 lines
5.4 KiB
C#

// 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;
}
}
}