diff --git a/tools/ExampleTester.Tests/AssemblyInfo.cs b/tools/ExampleTester.Tests/AssemblyInfo.cs new file mode 100644 index 000000000..72eb61577 --- /dev/null +++ b/tools/ExampleTester.Tests/AssemblyInfo.cs @@ -0,0 +1 @@ +[assembly: Parallelizable(ParallelScope.Children)] diff --git a/tools/ExampleTester.Tests/ExampleTester.Tests.csproj b/tools/ExampleTester.Tests/ExampleTester.Tests.csproj new file mode 100644 index 000000000..350242942 --- /dev/null +++ b/tools/ExampleTester.Tests/ExampleTester.Tests.csproj @@ -0,0 +1,25 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/tools/ExampleTester.Tests/FastCsprojCompilationParserTests.cs b/tools/ExampleTester.Tests/FastCsprojCompilationParserTests.cs new file mode 100644 index 000000000..c07faaf74 --- /dev/null +++ b/tools/ExampleTester.Tests/FastCsprojCompilationParserTests.cs @@ -0,0 +1,267 @@ +using System.Collections.Immutable; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.MSBuild; +using Shouldly; + +namespace ExampleTester.Tests; + +#pragma warning disable CS8509 // The switch expression does not handle all possible values of its input type (it is not exhaustive). + +public static class FastCsprojCompilationParserTests +{ + private const string CsprojFileName = "Test.csproj"; + + private static CsprojParseResult ParseCsproj(string csprojContents) + { + var result = FastCsprojCompilationParser.ParseCsproj(XDocument.Parse(csprojContents), CsprojFileName); + CompareMSBuildWorkspaceCompilation(csprojContents, result); + return result; + } + + private static void CompareMSBuildWorkspaceCompilation(string csprojContents, CsprojParseResult result) + { + var msbuildCompilation = GetMSBuildWorkspaceCompilation(csprojContents); + + result.AssemblyName.ShouldBe(msbuildCompilation.AssemblyName); + + var sanitizedCompilationOptions = msbuildCompilation.Options + .WithAssemblyIdentityComparer(result.CompilationOptions.AssemblyIdentityComparer) + .WithMetadataReferenceResolver(null) + .WithSourceReferenceResolver(null) + .WithStrongNameProvider(null) + .WithSyntaxTreeOptionsProvider(null) + .WithXmlReferenceResolver(null); + + result.CompilationOptions.Equals(sanitizedCompilationOptions).ShouldBeTrue(); + + var sanitizedParseOptions = msbuildCompilation.SyntaxTrees.First().Options; + + result.ParseOptions.Equals(sanitizedParseOptions).ShouldBeTrue(); + + var sanitizedSyntaxTrees = msbuildCompilation.SyntaxTrees + .Where(tree => !new[] { ".AssemblyAttributes.cs", ".AssemblyInfo.cs" }.Any(ending => + Path.GetFileName(tree.FilePath).EndsWith(ending, StringComparison.OrdinalIgnoreCase))) + .ToImmutableArray(); + + result.GeneratedSources.SequenceEqual(sanitizedSyntaxTrees, (a, b) => + Path.GetFileName(a.FilePath).Equals(Path.GetFileName(b.FilePath), StringComparison.OrdinalIgnoreCase) + && a.Options.Equals(b.Options) + && a.GetText().ToString() == b.GetText().ToString()) + .ShouldBeTrue(); + } + + private static Compilation GetMSBuildWorkspaceCompilation(string csprojContents) + { + using var workspace = MSBuildWorkspace.Create(new Dictionary { ["Configuration"] = "Release" }); + + var tempFolder = Path.Join(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(tempFolder); + try + { + var csprojPath = Path.Join(tempFolder, CsprojFileName); + File.WriteAllText(csprojPath, csprojContents); + var project = workspace.OpenProjectAsync(csprojPath).GetAwaiter().GetResult(); + return project.GetCompilationAsync().GetAwaiter().GetResult().ShouldNotBeNull(); + } + finally + { + Directory.Delete(tempFolder, recursive: true); + } + } + + [Test] + public static void Defaults() + { + var result = ParseCsproj(""" + + + + net6.0 + + + + """); + + result.CompilationOptions.OutputKind.ShouldBe(OutputKind.DynamicallyLinkedLibrary); + result.CompilationOptions.NullableContextOptions.ShouldBe(NullableContextOptions.Disable); + result.AssemblyName.ShouldBe(Path.GetFileNameWithoutExtension(CsprojFileName)); + result.CompilationOptions.AllowUnsafe.ShouldBeFalse(); + result.ParseOptions.LanguageVersion.ShouldBe(LanguageVersion.CSharp10); // Due to net6.0 + result.CompilationOptions.WarningLevel.ShouldBe(6); // Due to net6.0 + result.GeneratedSources.ShouldBeEmpty(); + } + + [Test] + public static void ParsesTargetFramework() + { + ParseCsproj(""" + + + + net6.0 + + + + """).TargetFramework.ShouldBe("net6.0"); + } + + [Test] + public static void ParsesOutputType([Values("Library", "Exe", "WinExe")] string outputType) + { + ParseCsproj($""" + + + + net6.0 + {outputType} + + + + """).CompilationOptions.OutputKind.ShouldBe(outputType switch + { + "Library" => OutputKind.DynamicallyLinkedLibrary, + "Exe" => OutputKind.ConsoleApplication, + "WinExe" => OutputKind.WindowsApplication, + }); + } + + [Test] + public static void ParsesNullable([Values("enable", "disable", "annotations", "warnings")] string nullable) + { + ParseCsproj($""" + + + + net6.0 + {nullable} + + + + """).CompilationOptions.NullableContextOptions.ShouldBe(nullable switch + { + "enable" => NullableContextOptions.Enable, + "disable" => NullableContextOptions.Disable, + "annotations" => NullableContextOptions.Annotations, + "warnings" => NullableContextOptions.Warnings, + }); + } + + [Test] + public static void ParsesAssemblyName() + { + ParseCsproj($""" + + + + net6.0 + Xyz + + + + """).AssemblyName.ShouldBe("Xyz"); + } + + [Test] + public static void ParsesAllowUnsafeBlocks([Values("true", "false")] string allowUnsafeBlocks) + { + ParseCsproj($""" + + + + net6.0 + {allowUnsafeBlocks} + + + + """).CompilationOptions.AllowUnsafe.ShouldBe(allowUnsafeBlocks switch + { + "true" => true, + "false" => false, + }); + } + + [Test] + public static void ParsesImplicitUsings([Values("true", "enable", "false", "disable")] string implicitUsings) + { + var hasImplicitUsings = implicitUsings switch + { + "true" or "enable" => true, + "false" or "disable" => false, + }; + + var result = ParseCsproj($""" + + + + net6.0 + {implicitUsings} + + + + """); + + if (hasImplicitUsings) + { + var source = result.GeneratedSources.ShouldHaveSingleItem(); + source.Options.ShouldBe(result.ParseOptions); + source.FilePath.ShouldBe("Test.GlobalUsings.g.cs"); + source.GetText().ToString().ShouldBe(""" + // + global using global::System; + global using global::System.Collections.Generic; + global using global::System.IO; + global using global::System.Linq; + global using global::System.Net.Http; + global using global::System.Threading; + global using global::System.Threading.Tasks; + + """); + } + else + { + result.GeneratedSources.ShouldBeEmpty(); + } + } + + [Test] + public static void ThrowsNotImplementedExceptionForUnrecognizedProperty() + { + Should.Throw(() => ParseCsproj(""" + + + + + + + + """)); + } + + [Test] + public static void ThrowsNotImplementedExceptionForUnrecognizedItem() + { + Should.Throw(() => ParseCsproj(""" + + + + + + + + """)); + } + + [Test] + public static void ThrowsNotImplementedExceptionForUnrecognizedTopLevelElement() + { + Should.Throw(() => ParseCsproj(""" + + + + + + """)); + } +} diff --git a/tools/ExampleTester/CsprojParseResult.cs b/tools/ExampleTester/CsprojParseResult.cs new file mode 100644 index 000000000..11c2e8da9 --- /dev/null +++ b/tools/ExampleTester/CsprojParseResult.cs @@ -0,0 +1,12 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace ExampleTester; + +public sealed record CsprojParseResult( + string? AssemblyName, + string TargetFramework, + CSharpParseOptions ParseOptions, + CSharpCompilationOptions CompilationOptions, + ImmutableArray GeneratedSources); diff --git a/tools/ExampleTester/ExampleTester.csproj b/tools/ExampleTester/ExampleTester.csproj index 8642c9cad..fc3b39c55 100644 --- a/tools/ExampleTester/ExampleTester.csproj +++ b/tools/ExampleTester/ExampleTester.csproj @@ -8,6 +8,7 @@ + diff --git a/tools/ExampleTester/FastCsprojCompilationParser.cs b/tools/ExampleTester/FastCsprojCompilationParser.cs new file mode 100644 index 000000000..51c7f1098 --- /dev/null +++ b/tools/ExampleTester/FastCsprojCompilationParser.cs @@ -0,0 +1,163 @@ +using System.Collections.Frozen; +using System.Collections.Immutable; +using System.Globalization; +using System.Xml.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; + +namespace ExampleTester; + +public static class FastCsprojCompilationParser +{ + public static CSharpCompilation CreateCompilation(string csprojPath) + { + var csproj = ParseCsproj(XDocument.Load(csprojPath), csprojPath); + + var syntaxTrees = Directory.GetFiles(Path.GetDirectoryName(csprojPath)!, "*.cs").AsParallel().Select(path => + { + using var stream = File.OpenRead(path); + return CSharpSyntaxTree.ParseText(SourceText.From(stream), csproj.ParseOptions, path); + }); + + return CSharpCompilation.Create( + csproj.AssemblyName, + csproj.GeneratedSources.AddRange(syntaxTrees), + SupportedTargetFrameworks[csproj.TargetFramework].References, + csproj.CompilationOptions); + } + + private static readonly FrozenDictionary SupportedOutputKinds = new KeyValuePair[] + { + new("Exe", OutputKind.ConsoleApplication), + new("Library", OutputKind.DynamicallyLinkedLibrary), + new("WinExe", OutputKind.WindowsApplication), + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + private sealed record TargetFrameworkInfo( + ImmutableArray References, + LanguageVersion DefaultLanguageVersion, + int DefaultWarningLevel); + + private static readonly FrozenDictionary SupportedTargetFrameworks = new KeyValuePair[] + { + new("net6.0", new(Basic.Reference.Assemblies.Net60.References.All, LanguageVersion.CSharp10, DefaultWarningLevel: 6)), + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + private static readonly CSharpParseOptions DefaultParseOptions = new(preprocessorSymbols: [ + "TRACE", "RELEASE", "NET", "NET6_0", "NETCOREAPP", "NET5_0_OR_GREATER", "NET6_0_OR_GREATER", + "NETCOREAPP1_0_OR_GREATER", "NETCOREAPP1_1_OR_GREATER", "NETCOREAPP2_0_OR_GREATER", + "NETCOREAPP2_1_OR_GREATER", "NETCOREAPP2_2_OR_GREATER", "NETCOREAPP3_0_OR_GREATER", + "NETCOREAPP3_1_OR_GREATER"]); + + private static readonly CSharpCompilationOptions DefaultCompilationOptions = new( + OutputKind.DynamicallyLinkedLibrary, + deterministic: true, + optimizationLevel: OptimizationLevel.Release, + specificDiagnosticOptions: [ + new("NU1605", ReportDiagnostic.Error), + new("CS1702", ReportDiagnostic.Suppress), + new("CS1701", ReportDiagnostic.Suppress)]); + + public static CsprojParseResult ParseCsproj(XDocument csprojDocument, string filePath) + { + var projectName = Path.GetFileNameWithoutExtension(filePath); + var assemblyName = projectName; + var targetFramework = (string?)null; + var parseOptions = DefaultParseOptions; + var compilationOptions = DefaultCompilationOptions; + var implicitUsings = false; + + foreach (var element in csprojDocument.Root!.Elements()) + { + switch (element.Name.LocalName) + { + case "PropertyGroup": + foreach (var property in element.Elements()) + { + switch (property.Name.LocalName) + { + case "OutputType": + compilationOptions = compilationOptions.WithOutputKind(SupportedOutputKinds[property.Value]); + break; + case "TargetFramework": + targetFramework = property.Value; + break; + case "Nullable": + compilationOptions = compilationOptions.WithNullableContextOptions( + Enum.Parse(property.Value, ignoreCase: true)); + break; + case "AssemblyName": + assemblyName = property.Value; + break; + case "AllowUnsafeBlocks": + compilationOptions = compilationOptions.WithAllowUnsafe(bool.Parse(property.Value)); + break; + case "LangVersion": + var enumMemberName = property.Value; + + if (decimal.TryParse( + property.Value, + NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowDecimalPoint, + CultureInfo.InvariantCulture, + out var decimalValue)) + { + enumMemberName = "CSharp" + decimalValue.ToString("0.#", CultureInfo.InvariantCulture).Replace('.', '_'); + } + + parseOptions = parseOptions.WithLanguageVersion(Enum.Parse(enumMemberName, ignoreCase: true)); + break; + case "ImplicitUsings": + implicitUsings = ParseFeatureBool(property.Value); + break; + default: + throw new NotImplementedException($"Review whether support is required for <{property.Name}> ({filePath})"); + } + } + break; + + default: + throw new NotImplementedException($"Review whether support is required for <{element.Name}> ({filePath})"); + } + } + + if (parseOptions.SpecifiedLanguageVersion == LanguageVersion.Default) + parseOptions = parseOptions.WithLanguageVersion(SupportedTargetFrameworks[targetFramework!].DefaultLanguageVersion); + + compilationOptions = compilationOptions + .WithWarningLevel(SupportedTargetFrameworks[targetFramework!].DefaultWarningLevel) + .WithModuleName(assemblyName + ".dll"); + + var generatedSources = ImmutableArray.CreateBuilder(); + + if (implicitUsings) + { + generatedSources.Add(SyntaxFactory.ParseSyntaxTree(""" + // + global using global::System; + global using global::System.Collections.Generic; + global using global::System.IO; + global using global::System.Linq; + global using global::System.Net.Http; + global using global::System.Threading; + global using global::System.Threading.Tasks; + + """, + parseOptions, + projectName + ".GlobalUsings.g.cs")); + } + + return new CsprojParseResult( + assemblyName, + targetFramework!, + parseOptions, + compilationOptions, + generatedSources.DrainToImmutable()); + } + + private static bool ParseFeatureBool(string propertyValue) + { + return propertyValue.Equals("enable", StringComparison.OrdinalIgnoreCase) + || propertyValue.Equals("true", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tools/ExampleTester/GeneratedExample.cs b/tools/ExampleTester/GeneratedExample.cs index aeea299d9..09f6e1107 100644 --- a/tools/ExampleTester/GeneratedExample.cs +++ b/tools/ExampleTester/GeneratedExample.cs @@ -1,9 +1,9 @@ -using ExampleExtractor; +using System.Reflection; +using System.Text; +using ExampleExtractor; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.MSBuild; using Newtonsoft.Json; -using System.Reflection; -using System.Text; using Utilities; namespace ExampleTester; @@ -32,18 +32,25 @@ internal async Task Test(TesterConfiguration configuration, StatusCheckLog { logger.ConsoleOnlyLog(Metadata.Source, Metadata.StartLine, Metadata.EndLine, $"Testing {Metadata.Name} from {Metadata.Source}", "ExampleTester"); - // Explicitly do a release build, to avoid implicitly defining DEBUG. - var properties = new Dictionary { { "Configuration", "Release" } }; - using var workspace = MSBuildWorkspace.Create(properties); // TODO: Validate this more cleanly. var projectFile = Metadata.Project is string specifiedProject ? Path.Combine(directory, $"{specifiedProject}.csproj") : Directory.GetFiles(directory, "*.csproj").Single(); - var project = await workspace.OpenProjectAsync(projectFile); - var compilation = await project.GetCompilationAsync(); - if (compilation is null) + + Compilation compilation; + try { - throw new InvalidOperationException("Project has no Compilation"); + compilation = FastCsprojCompilationParser.CreateCompilation(projectFile); + } + catch (NotImplementedException) + { + // Explicitly do a release build, to avoid implicitly defining DEBUG. + var properties = new Dictionary { { "Configuration", "Release" } }; + using var workspace = MSBuildWorkspace.Create(properties); + + var project = await workspace.OpenProjectAsync(projectFile); + compilation = await project.GetCompilationAsync() + ?? throw new InvalidOperationException("Project has no Compilation"); } bool ret = true; diff --git a/tools/tools.sln b/tools/tools.sln index be1786ffb..89bc77573 100644 --- a/tools/tools.sln +++ b/tools/tools.sln @@ -20,6 +20,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleTester", "ExampleTes EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExampleFormatter", "ExampleFormatter\ExampleFormatter.csproj", "{82D1A159-5637-48C4-845D-CC1390995CC2}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExampleTester.Tests", "ExampleTester.Tests\ExampleTester.Tests.csproj", "{6AE7A04F-85DE-46EA-8696-F748B6130971}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -114,6 +116,18 @@ Global {82D1A159-5637-48C4-845D-CC1390995CC2}.Release|x64.Build.0 = Release|Any CPU {82D1A159-5637-48C4-845D-CC1390995CC2}.Release|x86.ActiveCfg = Release|Any CPU {82D1A159-5637-48C4-845D-CC1390995CC2}.Release|x86.Build.0 = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|x64.ActiveCfg = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|x64.Build.0 = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|x86.ActiveCfg = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Debug|x86.Build.0 = Debug|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|Any CPU.Build.0 = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|x64.ActiveCfg = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|x64.Build.0 = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|x86.ActiveCfg = Release|Any CPU + {6AE7A04F-85DE-46EA-8696-F748B6130971}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE