File: Resources\RoslynTestUtils.cs
Web Access
Project: src\test\Analyzers\Microsoft.Analyzers.Local.Tests\Microsoft.Analyzers.Local.Tests.csproj (Microsoft.Analyzers.Local.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
using Xunit;
 
namespace Microsoft.Extensions.LocalAnalyzers.Resource.Test;
 
internal static class RoslynTestUtils
{
    /// <summary>
    /// Creates a canonical Roslyn project for testing.
    /// </summary>
    /// <param name="references">Assembly references to include in the project.</param>
    /// <param name="includeBaseReferences">Whether to include references to the BCL assemblies.</param>
    public static Project CreateTestProject(IEnumerable<Assembly>? references, bool includeBaseReferences = true,
        string? testAssemblyName = null)
    {
        const string TestAssemblyName = "test.dll";
 
        var corelib = Assembly.GetAssembly(typeof(object))!.Location;
        var runtimeDir = Path.GetDirectoryName(corelib)!;
 
        var refs = new List<MetadataReference>();
        if (includeBaseReferences)
        {
            refs.Add(MetadataReference.CreateFromFile(corelib));
            refs.Add(MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "netstandard.dll")));
            refs.Add(MetadataReference.CreateFromFile(Path.Combine(runtimeDir, "System.Runtime.dll")));
        }
 
        if (references != null)
        {
            foreach (var r in references)
            {
                refs.Add(MetadataReference.CreateFromFile(r.Location));
            }
        }
 
#pragma warning disable CA2000 // Dispose objects before losing scope
        return new AdhocWorkspace()
                         .AddSolution(SolutionInfo.Create(SolutionId.CreateNewId(), VersionStamp.Create()))
                         .AddProject("Test", testAssemblyName ?? TestAssemblyName, "C#")
                         .WithMetadataReferences(refs)
                         .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
                         .WithNullableContextOptions(NullableContextOptions.Enable));
#pragma warning restore CA2000 // Dispose objects before losing scope
    }
 
    public static void CommitChanges(this Project proj)
    {
        Assert.True(proj.Solution.Workspace.TryApplyChanges(proj.Solution));
    }
 
    public static Project WithDocument(this Project proj, string name, string text)
    {
        return proj.AddDocument(name, text).Project;
    }
 
    public static Document FindDocument(this Project proj, string name)
    {
        foreach (var doc in proj.Documents)
        {
            if (doc.Name == name)
            {
                return doc;
            }
        }
 
        throw new FileNotFoundException(name);
    }
 
    /// <summary>
    /// Looks for /*N+*/ and /*-N*/ markers in a string and creates a TextSpan containing the enclosed text.
    /// </summary>
    public static TextSpan MakeTextSpan(this string text, int spanNum)
    {
        var seq = $"/*{spanNum}+*/";
        int start = text.IndexOf(seq, StringComparison.Ordinal);
        if (start < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(spanNum));
        }
 
        start += seq.Length;
 
        int end = text.IndexOf($"/*-{spanNum}*/", StringComparison.Ordinal);
        if (end < 0)
        {
            throw new ArgumentOutOfRangeException(nameof(spanNum));
        }
 
        return new TextSpan(start, end - start);
    }
 
    /// <summary>
    /// Counts the number of /*N+*/ and /*-N*/ markers in a string.
    /// </summary>
    public static int CountSpans(this string text)
    {
        int index = 0;
        while (true)
        {
            var seq = $"/*{index}+*/";
            int start = text.IndexOf(seq, StringComparison.Ordinal);
            if (start < 0)
            {
                return index;
            }
 
            start += seq.Length;
 
            int end = text.IndexOf($"/*-{index}*/", StringComparison.Ordinal);
            if (end < 0)
            {
                throw new InvalidDataException($"Missing end marker for span {index}");
            }
 
            index++;
        }
    }
 
    public static void AssertDiagnostic(this string text, int spanNum, DiagnosticDescriptor expected, Diagnostic actual)
    {
        try
        {
            var expectedSpan = text.MakeTextSpan(spanNum);
            Assert.True(expected.Id == actual.Id,
                $"Span {spanNum} doesn't match: expected {expected.Id} but got {actual}");
            Assert.True(expectedSpan.Equals(actual.Location.SourceSpan),
                $"Span {spanNum} doesn't match: expected {expectedSpan} but got {actual.Location.SourceSpan}");
        }
        catch (ArgumentOutOfRangeException)
        {
            Assert.Fail($"Unexpected warning {actual}");
        }
    }
 
    public static IList<Diagnostic> FilterDiagnostics(this IEnumerable<Diagnostic> diagnostics, params DiagnosticDescriptor[] filter)
    {
        var filtered = new List<Diagnostic>();
        foreach (Diagnostic diagnostic in diagnostics)
        {
            foreach (var f in filter)
            {
                if (diagnostic.Id.Equals(f.Id, StringComparison.Ordinal))
                {
                    filtered.Add(diagnostic);
                    break;
                }
            }
        }
 
        return filtered;
    }
 
    /// <summary>
    /// Runs a Roslyn generator over a set of source files.
    /// </summary>
    public static async Task<(IReadOnlyList<Diagnostic> diagnostics, ImmutableArray<GeneratedSourceResult> generatedSources)> RunGenerator(
        ISourceGenerator generator,
        IEnumerable<Assembly>? references,
        IEnumerable<string> sources,
        AnalyzerConfigOptionsProvider? optionsProvider = null,
        bool includeBaseReferences = true,
        CancellationToken cancellationToken = default)
    {
        var proj = CreateTestProject(references, includeBaseReferences);
 
        var count = 0;
        foreach (var s in sources)
        {
            proj = proj.WithDocument($"src-{count++}.cs", s);
        }
 
        proj.CommitChanges();
        var comp = await proj!.GetCompilationAsync(CancellationToken.None).ConfigureAwait(false);
 
        var cgd = CSharpGeneratorDriver.Create(new[] { generator }, optionsProvider: optionsProvider);
        var gd = cgd.RunGenerators(comp!, cancellationToken);
 
        var r = gd.GetRunResult();
        return (Sort(r.Results[0].Diagnostics), r.Results[0].GeneratedSources);
    }
 
    /// <summary>
    /// Runs a Roslyn generator over a set of source files.
    /// </summary>
    public static async Task<(IReadOnlyList<Diagnostic> diagnostics, ImmutableArray<GeneratedSourceResult> generatedSources)> RunGenerator(
        IIncrementalGenerator generator,
        IEnumerable<Assembly>? references,
        IEnumerable<string> sources,
        bool includeBaseReferences = true,
        CancellationToken cancellationToken = default)
    {
        var proj = CreateTestProject(references, includeBaseReferences);
 
        var count = 0;
        foreach (var s in sources)
        {
            proj = proj.WithDocument($"src-{count++}.cs", s);
        }
 
        proj.CommitChanges();
        var comp = await proj!.GetCompilationAsync(CancellationToken.None).ConfigureAwait(false);
 
        // workaround https://github.com/dotnet/roslyn/pull/55866. We can remove "LangVersion=Preview" when we get a Roslyn build with that change.
        CSharpParseOptions options = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview);
        CSharpGeneratorDriver cgd = CSharpGeneratorDriver.Create(new[] { generator.AsSourceGenerator() }, parseOptions: options);
 
        var gd = cgd.RunGenerators(comp!, cancellationToken);
 
        var r = gd.GetRunResult();
        return (Sort(r.Results[0].Diagnostics), r.Results[0].GeneratedSources);
    }
 
    /// <summary>
    /// Runs a Roslyn analyzer over a set of source files.
    /// </summary>
    public static async Task<IReadOnlyList<Diagnostic>> RunAnalyzer(
        DiagnosticAnalyzer analyzer,
        IEnumerable<Assembly>? references,
        IEnumerable<string> sources,
        bool asExecutable = false,
        AnalyzerOptions? options = null,
        string? testAssemblyName = null)
    {
        var proj = CreateTestProject(references, testAssemblyName: testAssemblyName);
 
        var count = 0;
        foreach (var s in sources)
        {
            proj = proj.WithDocument($"src-{count++}.cs", s);
        }
 
        if (asExecutable)
        {
            proj = proj.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.ConsoleApplication));
        }
 
        proj.CommitChanges();
 
        var analyzers = ImmutableArray.Create(analyzer);
 
        var comp = await proj!.GetCompilationAsync().ConfigureAwait(false);
        var result = await comp!.WithAnalyzers(analyzers, options)
            .GetAnalysisResultAsync(CancellationToken.None).ConfigureAwait(false);
 
        return Sort(result.GetAllDiagnostics());
    }
 
    private static IReadOnlyList<Diagnostic> Sort(ImmutableArray<Diagnostic> diags)
    {
        return diags.Sort((x, y) =>
        {
            if (x.Location.SourceSpan.Start < y.Location.SourceSpan.Start)
            {
                return -1;
            }
            else if (x.Location.SourceSpan.Start > y.Location.SourceSpan.Start)
            {
                return 1;
            }
 
            return 0;
        });
    }
 
    /// <summary>
    /// Runs a Roslyn analyzer and fixer.
    /// </summary>
    [SuppressMessage("Major Code Smell", "S107:Methods should not have too many parameters", Justification = "Hey, that's life")]
    public static async Task<IReadOnlyList<string>> RunAnalyzerAndFixer(
        DiagnosticAnalyzer analyzer,
        CodeFixProvider fixer,
        IEnumerable<Assembly>? references,
        IEnumerable<string> sources,
        IEnumerable<string>? sourceNames = null,
        string? defaultNamespace = null,
        string? extraFile = null,
        bool asExecutable = false,
        string? testAssemblyName = null,
        AnalyzerOptions? analyzerOptions = null)
    {
        var proj = CreateTestProject(references, testAssemblyName: testAssemblyName);
 
        var count = 0;
        if (sourceNames != null)
        {
            var l = sourceNames.ToList();
            foreach (var s in sources)
            {
                proj = proj.WithDocument(l[count++], s);
            }
        }
        else
        {
            foreach (var s in sources)
            {
                proj = proj.WithDocument($"src-{count++}.cs", s);
            }
        }
 
        if (asExecutable)
        {
            proj = proj.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.ConsoleApplication));
        }
 
        if (defaultNamespace != null)
        {
            proj = proj.WithDefaultNamespace(defaultNamespace);
        }
 
        proj.CommitChanges();
 
        var analyzers = ImmutableArray.Create(analyzer);
        int numberOfActionsInPreviousIteration = 0;
        while (true)
        {
            var comp = await proj!.GetCompilationAsync().ConfigureAwait(false);
            var diags = await comp!.WithAnalyzers(analyzers, analyzerOptions).GetAllDiagnosticsAsync().ConfigureAwait(false);
 
            if (diags.IsEmpty)
            {
                // no more diagnostics reported by the analyzers
                break;
            }
 
            var actions = new List<CodeAction>();
            foreach (var d in diags)
            {
                // apply CodeFix action only if diagnostic is fixable by the fixer
                if (fixer.FixableDiagnosticIds.Contains(d.Id))
                {
                    var doc = proj.GetDocument(d.Location.SourceTree);
 
                    var context = new CodeFixContext(doc!, d, (action, _) => actions.Add(action), CancellationToken.None);
                    await fixer.RegisterCodeFixesAsync(context).ConfigureAwait(false);
                }
            }
 
            if (actions.Count == 0 || numberOfActionsInPreviousIteration == actions.Count)
            {
                // nothing to fix or expected fix was not applied
                break;
            }
 
            var operations = await actions[0].GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
            var solution = operations.OfType<ApplyChangesOperation>().Single().ChangedSolution;
            var changedProj = solution.GetProject(proj.Id);
            if (changedProj != proj)
            {
                proj = await RecreateProjectDocumentsAsync(changedProj!).ConfigureAwait(false);
            }
 
            numberOfActionsInPreviousIteration = actions.Count;
        }
 
        var results = new List<string>();
 
        if (sourceNames != null)
        {
            var l = sourceNames.ToList();
            for (int i = 0; i < count; i++)
            {
                var s = await proj.FindDocument(l[i]).GetTextAsync().ConfigureAwait(false);
                results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
            }
        }
        else
        {
            for (int i = 0; i < count; i++)
            {
                var s = await proj.FindDocument($"src-{i}.cs").GetTextAsync().ConfigureAwait(false);
                results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
            }
        }
 
        if (extraFile != null)
        {
            var s = await proj.FindDocument(extraFile).GetTextAsync().ConfigureAwait(false);
            results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
        }
 
        return results;
    }
 
    /// <summary>
    /// Runs a Roslyn analyzer and FixAll code action.
    /// </summary>
    public static async Task<(IReadOnlyList<string> results, string title)> RunAnalyzerAndFixAllCodeAction(
        DiagnosticAnalyzer analyzer,
        CodeFixProvider fixer,
        IEnumerable<Assembly>? references,
        IEnumerable<string> sources,
        IEnumerable<string>? sourceNames = null,
        string? defaultNamespace = null,
        string? extraFile = null)
    {
        var proj = CreateTestProject(references);
 
        var count = 0;
        if (sourceNames != null)
        {
            var l = sourceNames.ToList();
            foreach (var s in sources)
            {
                proj = proj.WithDocument(l[count++], s);
            }
        }
        else
        {
            foreach (var s in sources)
            {
                proj = proj.WithDocument($"src-{count++}.cs", s);
            }
        }
 
        if (defaultNamespace != null)
        {
            proj = proj.WithDefaultNamespace(defaultNamespace);
        }
 
        proj.CommitChanges();
 
        // set up FixAllProvider and corresponding FixAll code action
        var diagsProvider = new TestDiagnosticProvider(proj, ImmutableArray.Create(analyzer), fixer);
        var context = new FixAllContext(
            project: proj,
            codeFixProvider: fixer,
            scope: FixAllScope.Project,
            codeActionEquivalenceKey: fixer.GetType().FullName!,
            diagnosticIds: fixer.FixableDiagnosticIds,
            fixAllDiagnosticProvider: diagsProvider,
            cancellationToken: CancellationToken.None);
 
        var fixAllProvider = fixer.GetFixAllProvider();
        var fixAllCodeAction = await fixAllProvider!.GetFixAsync(context);
        var title = fixAllCodeAction!.Title;
 
        // apply fixAllCodeAction and process changed project
        var operations = await fixAllCodeAction!.GetOperationsAsync(CancellationToken.None).ConfigureAwait(false);
        var solution = operations.OfType<ApplyChangesOperation>().Single().ChangedSolution;
        var changedProj = solution.GetProject(proj.Id);
        if (changedProj != proj)
        {
            proj = await RecreateProjectDocumentsAsync(changedProj!).ConfigureAwait(false);
        }
 
        var results = new List<string>();
 
        if (sourceNames != null)
        {
            var l = sourceNames.ToList();
            for (int i = 0; i < count; i++)
            {
                var s = await proj.FindDocument(l[i]).GetTextAsync().ConfigureAwait(false);
                results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
            }
        }
        else
        {
            for (int i = 0; i < count; i++)
            {
                var s = await proj.FindDocument($"src-{i}.cs").GetTextAsync().ConfigureAwait(false);
                results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
            }
        }
 
        if (extraFile != null)
        {
            var s = await proj.FindDocument(extraFile).GetTextAsync().ConfigureAwait(false);
            results.Add(s.ToString().Replace("\r\n", "\n", StringComparison.Ordinal));
        }
 
        return (results, title);
    }
 
    private static async Task<Project> RecreateProjectDocumentsAsync(Project project)
    {
        foreach (var documentId in project.DocumentIds)
        {
            var document = project.GetDocument(documentId);
            document = await RecreateDocumentAsync(document!).ConfigureAwait(false);
            project = document.Project;
        }
 
        return project;
    }
 
    private static async Task<Document> RecreateDocumentAsync(Document document)
    {
        var newText = await document.GetTextAsync().ConfigureAwait(false);
        return document.WithText(SourceText.From(newText.ToString(), newText.Encoding, newText.ChecksumAlgorithm));
    }
 
    internal class TestDiagnosticProvider : FixAllContext.DiagnosticProvider
    {
        public TestDiagnosticProvider(
            Project project,
            ImmutableArray<DiagnosticAnalyzer> analyzers,
            CodeFixProvider fixer)
        {
            _analyzers = analyzers;
            _fixer = fixer;
            _project = project;
        }
 
        public override async Task<IEnumerable<Diagnostic>> GetAllDiagnosticsAsync(Project project, CancellationToken cancellationToken)
        {
            return await GetProjectDiagnosticsAsync(project, cancellationToken).ConfigureAwait(false);
        }
 
        public override async Task<IEnumerable<Diagnostic>> GetDocumentDiagnosticsAsync(Document document, CancellationToken cancellationToken)
        {
            var diagnostics = await GetProjectDiagnosticsAsync(document.Project, cancellationToken).ConfigureAwait(false);
            return diagnostics.Where(d => d.Location.SourceTree!.FilePath.EndsWith(document.Name));
        }
 
        public override async Task<IEnumerable<Diagnostic>> GetProjectDiagnosticsAsync(Project project, CancellationToken cancellationToken)
        {
            if (_project != project)
            {
                _project = await RecreateProjectDocumentsAsync(project!).ConfigureAwait(false);
            }
 
            var comp = await _project.GetCompilationAsync(cancellationToken).ConfigureAwait(false);
            var diags = await comp!.WithAnalyzers(_analyzers)
                                   .GetAllDiagnosticsAsync(cancellationToken).ConfigureAwait(false);
 
            return diags.Where(d => _fixer.FixableDiagnosticIds.Contains(d.Id));
        }
 
        private readonly ImmutableArray<DiagnosticAnalyzer> _analyzers;
        private readonly CodeFixProvider _fixer;
        private Project _project;
    }
}