using System; using System.Collections.Generic; using System.Collections.Immutable; using System.IO; using System.Linq; using System.Runtime.InteropServices; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Emit; namespace Ariadne.Generator.UnitTests { /// /// Compiles scripts in memory the way a build does: through ArcScriptGenerator, then StepLabelSuppressor. /// public class GeneratorRun { /// /// This field holds the usings put ahead of every script file. /// public const string Usings = "using System.Collections.Generic;\nusing System.Linq;\nusing Ariadne;\n\n"; /// /// This field holds the context type every test script runs against. /// public const string World = "public class World\n{\n public readonly System.Collections.Generic.HashSet Open = " + "new System.Collections.Generic.HashSet();\n}\n"; public static readonly CSharpParseOptions ParseOptions = new CSharpParseOptions(LanguageVersion.CSharp13); public static readonly MetadataReference[] References = { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(Uri).Assembly.Location), MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location), MetadataReference.CreateFromFile( Path.Combine(RuntimeEnvironment.GetRuntimeDirectory(), "netstandard.dll")), MetadataReference.CreateFromFile(typeof(Arc<>).Assembly.Location), MetadataReference.CreateFromFile(typeof(Amphora.Duration).Assembly.Location), }; /// /// This field holds the generator's own diagnostics, each as "Id:the text it points at". /// public readonly List Reported; /// /// Holds the same diagnostics as Reported, each as "Id:the message". /// public readonly List Messages; /// /// This field holds the compiler's errors and unsuppressed warnings with the generated code included, each as /// "Id:the text it points at". /// public readonly List Compiled; /// /// This field holds the same diagnostics as Compiled, each as "Id:File(line,column)" where #line maps it. /// public readonly List CompiledAt; /// /// This field holds each generated code-behind's text, by file name. /// public readonly Dictionary CodeBehinds; private GeneratorRun(List reported, List messages, List compiled, List compiledAt, Dictionary codeBehinds) { Reported = reported; Messages = messages; Compiled = compiled; CompiledAt = compiledAt; CodeBehinds = codeBehinds; } /// /// Gets the only code-behind, or "" if nothing was generated. /// public string CodeBehind { get { return CodeBehinds.Values.SingleOrDefault() ?? ""; } } public static string Describe(Diagnostic diagnostic) { Location location = diagnostic.Location; if (location.SourceTree == null) { return diagnostic.Id; } return diagnostic.Id + ":" + location.SourceTree.GetText().ToString(location.SourceSpan); } public static string DescribeWhere(Diagnostic diagnostic) { FileLinePositionSpan span = diagnostic.Location.GetMappedLineSpan(); return diagnostic.Id + ":" + Path.GetFileName(span.Path) + "(" + (span.StartLinePosition.Line + 1) + "," + (span.StartLinePosition.Character + 1) + ")"; } private static List Sorted(IEnumerable diagnostics, Func describe) { return diagnostics .OrderBy(diagnostic => diagnostic.Location.SourceTree?.FilePath, StringComparer.Ordinal) .ThenBy(diagnostic => diagnostic.Location.SourceSpan.Start) .ThenBy(diagnostic => diagnostic.Id, StringComparer.Ordinal) .Select(describe).ToList(); } /// /// Compiles the World context type plus , each a path and a script with Usings put /// ahead of it. /// public static CSharpCompilation Compile(MetadataReference[] libraries, params (string Path, string Script)[] files) { var trees = new List { CSharpSyntaxTree.ParseText(World, ParseOptions, "World.cs") }; foreach ((string path, string script) in files) { trees.Add(CSharpSyntaxTree.ParseText(Usings + script, ParseOptions, path)); } return CSharpCompilation.Create("Scripts", trees, References.Concat(libraries), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); } public static CSharpCompilation Compile(params (string Path, string Script)[] files) { return Compile(new MetadataReference[0], files); } /// /// Compiles , with Usings ahead of it, into an assembly a script can reference, eg: /// a game's base class for its scripts. /// /// The source doesn't compile. public static MetadataReference CompileLibrary(string name, string source) { CSharpCompilation library = CSharpCompilation.Create(name, new[] { CSharpSyntaxTree.ParseText(Usings + source, ParseOptions) }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); using (var image = new MemoryStream()) { EmitResult result = library.Emit(image); if (!result.Success) { throw new InvalidOperationException(string.Join("\n", result.Diagnostics)); } return MetadataReference.CreateFromImage(image.ToArray()); } } /// /// Compiles as does, but through ArcScriptGenerator /// first, so the assembly holds the passages and frames a project that uses the generator would build. /// /// The source or its generated code doesn't compile. public static MetadataReference CompileGeneratedLibrary(string name, string source) { GeneratorDriver driver = CSharpGeneratorDriver.Create( new[] { new ArcScriptGenerator().AsSourceGenerator() }, parseOptions: ParseOptions, optionsProvider: new ProjectOptions(null)); CSharpCompilation library = CSharpCompilation.Create(name, new[] { CSharpSyntaxTree.ParseText(Usings + source, ParseOptions, name + ".cs") }, References, new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); Compilation generated; ImmutableArray reported; driver.RunGeneratorsAndUpdateCompilation(library, out generated, out reported); using (var image = new MemoryStream()) { EmitResult result = generated.Emit(image); if (!result.Success || reported.Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)) { throw new InvalidOperationException(string.Join("\n", reported.Concat(result.Diagnostics))); } return MetadataReference.CreateFromImage(image.ToArray()); } } /// /// Holds the build properties a build passes the compiler, which here is only where the project is. /// public class ProjectOptions : AnalyzerConfigOptionsProvider { private class Options : AnalyzerConfigOptions { private readonly string projectDirectory; public Options(string projectDirectory) { this.projectDirectory = projectDirectory; } public override bool TryGetValue(string key, out string value) { value = null; if (key == ArcScriptGenerator.ProjectDirectoryOption && projectDirectory != null) { value = projectDirectory; } return value != null; } } private readonly AnalyzerConfigOptions options; public ProjectOptions(string projectDirectory) { options = new Options(projectDirectory); } public override AnalyzerConfigOptions GlobalOptions { get { return options; } } public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) { return options; } public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) { return options; } } /// /// Compiles in a project at , which is null for a /// build that doesn't say. /// public static GeneratorRun OfProject(string projectDirectory, params (string Path, string Script)[] files) { return Of(null, new MetadataReference[0], projectDirectory, files); } /// Another analyzer to run alongside, eg: a save library's, or null for none. public static GeneratorRun Of(DiagnosticAnalyzer analyzer, MetadataReference[] libraries, params (string Path, string Script)[] files) { return Of(analyzer, libraries, null, files); } private static GeneratorRun Of(DiagnosticAnalyzer analyzer, MetadataReference[] libraries, string projectDirectory, (string Path, string Script)[] files) { GeneratorDriver driver = CSharpGeneratorDriver.Create( new[] { new ArcScriptGenerator().AsSourceGenerator() }, parseOptions: ParseOptions, optionsProvider: new ProjectOptions(projectDirectory)); Compilation generated; ImmutableArray reported; driver = driver.RunGeneratorsAndUpdateCompilation(Compile(libraries, files), out generated, out reported); var analyzers = new List { new StepLabelSuppressor() }; if (analyzer != null) { analyzers.Add(analyzer); } List compiled = generated .WithAnalyzers(analyzers.ToImmutableArray()) .GetAllDiagnosticsAsync().Result .Where(diagnostic => diagnostic.Severity >= DiagnosticSeverity.Warning && !diagnostic.IsSuppressed) .ToList(); var codeBehinds = new Dictionary(StringComparer.Ordinal); foreach (GeneratedSourceResult source in driver.GetRunResult().Results.Single().GeneratedSources) { codeBehinds.Add(source.HintName, source.SourceText.ToString()); } return new GeneratorRun(Sorted(reported, Describe), Sorted(reported, diagnostic => diagnostic.Id + ":" + diagnostic.GetMessage()), Sorted(compiled, Describe), Sorted(compiled, DescribeWhere), codeBehinds); } public static GeneratorRun Of(MetadataReference[] libraries, params (string Path, string Script)[] files) { return Of(null, libraries, files); } public static GeneratorRun Of(params (string Path, string Script)[] files) { return Of(new MetadataReference[0], files); } public static GeneratorRun Of(string script) { return Of(("Script.cs", script)); } /// /// Runs in a compilation that also references . /// public static GeneratorRun Of(MetadataReference library, string script) { return Of(new[] { library }, ("Script.cs", script)); } public static GeneratorRun Of(DiagnosticAnalyzer analyzer, string script) { return Of(analyzer, new MetadataReference[0], ("Script.cs", script)); } } }