using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
namespace AIStudio.Tests.Models.Generation;
///
/// Compiles a snippet in memory so that a generator or an analyzer can be asked what it makes of it.
///
///
/// Both of them are code which runs while the app is being built, and both fail quietly when they
/// are wrong: a generator which finds nothing produces an empty registry, and an analyzer which
/// recognizes nothing reports nothing. Neither shows up as a broken build, so neither can be
/// checked by building the app. It has to be done here, against source written for the purpose.
///
public static class CompilationHarness
{
///
/// Everything the test process itself was loaded with, which includes the app assembly.
///
///
/// Gathered once. Reading a couple of hundred assemblies off disk per test case would make
/// these tests slow enough that somebody stops running them.
///
private static readonly Lazy REFERENCES = new(GatherReferences);
///
/// Compiles a snippet against the same assemblies the app is built against.
///
/// The C# source to compile.
/// The compilation.
public static CSharpCompilation Compile(string source)
{
var tree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest));
var options = new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable);
return CSharpCompilation.Create("SnippetUnderTest", [tree], REFERENCES.Value, options);
}
///
/// Compiles a snippet and reports what it does not even parse or bind.
///
///
/// Worth asking before believing a generator found nothing: a snippet with a typo in it also
/// produces an empty result, and the two look exactly alike from the outside.
///
/// The compilation to check.
/// The errors, each on its own line, or an empty string.
public static string ErrorsOf(Compilation compilation)
{
var errors = compilation.GetDiagnostics()
.Where(diagnostic => diagnostic.Severity is DiagnosticSeverity.Error)
.Select(diagnostic => diagnostic.ToString());
return string.Join(Environment.NewLine, errors);
}
///
/// Runs one analyzer over a snippet.
///
/// The C# source to analyze.
/// The analyzer to run.
/// What the analyzer reported.
public static async Task> AnalyzeAsync(string source, DiagnosticAnalyzer analyzer)
{
var compilation = Compile(source);
Assert.That(ErrorsOf(compilation), Is.Empty, "The snippet has to compile, or the analyzer is being asked about code which does not exist.");
var reported = await compilation.WithAnalyzers([analyzer]).GetAnalyzerDiagnosticsAsync();
return reported;
}
///
/// Runs one analyzer over a snippet and applies its code fix to the first diagnostic it reports.
///
///
/// The code fix runs in a workspace of its own, as it does in the IDE. The fixed source still has to
/// compile, since a code fix which breaks the code is worse than none.
///
/// The C# source to fix.
/// The analyzer which reports what to fix.
/// The code fix to apply.
/// The fixed source.
public static async Task FixAsync(string source, DiagnosticAnalyzer analyzer, CodeFixProvider codeFix)
{
using var workspace = new AdhocWorkspace();
var projectInfo = ProjectInfo.Create(
ProjectId.CreateNewId(),
VersionStamp.Default,
"SnippetUnderTest",
"SnippetUnderTest",
LanguageNames.CSharp,
compilationOptions: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary, nullableContextOptions: NullableContextOptions.Enable),
parseOptions: new CSharpParseOptions(LanguageVersion.Latest),
metadataReferences: REFERENCES.Value);
var project = workspace.AddProject(projectInfo);
var document = workspace.AddDocument(project.Id, "Snippet.cs", SourceText.From(source));
var compilation = await document.Project.GetCompilationAsync();
Assert.That(ErrorsOf(compilation!), Is.Empty, "The snippet has to compile, or the code fix is being asked about code which does not exist.");
var reported = await compilation!.WithAnalyzers([analyzer]).GetAnalyzerDiagnosticsAsync();
Assert.That(reported, Is.Not.Empty, "The analyzer has to report something, or there is nothing for the code fix to do.");
var actions = new List();
var context = new CodeFixContext(document, reported[0], (action, _) => actions.Add(action), CancellationToken.None);
await codeFix.RegisterCodeFixesAsync(context);
Assert.That(actions, Has.Count.EqualTo(1), "The code fix has to offer exactly one action.");
var operations = await actions[0].GetOperationsAsync(CancellationToken.None);
var changedSolution = operations.OfType().Single().ChangedSolution;
var fixedText = await changedSolution.GetDocument(document.Id)!.GetTextAsync();
var fixedSource = fixedText.ToString();
Assert.That(ErrorsOf(Compile(fixedSource)), Is.Empty, "The fixed source has to compile.");
return fixedSource;
}
private static MetadataReference[] GatherReferences()
{
//
// The set the runtime resolves types from, which is exactly what this test assembly was
// built against: the framework, the NuGet packages, and the app itself.
//
if (AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES") is not string assemblyPaths)
throw new InvalidOperationException("The test host did not say which assemblies it trusts, so no compilation can be built against them.");
return assemblyPaths
.Split(Path.PathSeparator)
.Where(path => path.EndsWith(".dll", StringComparison.OrdinalIgnoreCase) && File.Exists(path))
.Select(path => (MetadataReference) MetadataReference.CreateFromFile(path))
.ToArray();
}
}