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