diff --git a/CODEOWNERS b/CODEOWNERS
index b9bfa732520f..8b8ed15f06e6 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -84,10 +84,15 @@
# Compatibility tools owned by runtime team
/src/Compatibility/ @dotnet/area-infrastructure-libraries
+
+# Area-ApiCompat
/test/Microsoft.DotNet.ApiCompatibility*/ @dotnet/area-infrastructure-libraries
/test/Microsoft.DotNet.ApiCompat*/ @dotnet/area-infrastructure-libraries
/test/Microsoft.DotNet.PackageValidation*/ @dotnet/area-infrastructure-libraries
+# Area-ApiDiff
+/test/Microsoft.DotNet.ApiDiff.Tests/ @dotnet/area-infrastructure-libraries
+
# Area-GenAPI
/src/Compatibility/GenAPI/ @dotnet/area-infrastructure-libraries
/src/Compatibility/Microsoft.DotNet.ApiSymbolExtensions/ @dotnet/area-infrastructure-libraries
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 8b7dd4136423..ab0b8d7a7afa 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -101,6 +101,7 @@
+
diff --git a/eng/Signing.props b/eng/Signing.props
index ee5649c45ddf..484697efecbc 100644
--- a/eng/Signing.props
+++ b/eng/Signing.props
@@ -22,7 +22,7 @@
-
+
@@ -71,6 +71,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/eng/Versions.props b/eng/Versions.props
index 07d2c69f2dfb..a09f426bf54c 100644
--- a/eng/Versions.props
+++ b/eng/Versions.props
@@ -64,6 +64,7 @@
1.2.0-beta.435
4.0.5
2.0.0-beta5.25227.101
+ 2.0.0-beta5.25227.101
1.1.2-beta1.22216.1
10.3.0
3.2.2146
diff --git a/sdk.sln b/sdk.sln
index 153855a4c2f3..78ffd859f272 100644
--- a/sdk.sln
+++ b/sdk.sln
@@ -1,4 +1,4 @@
-Microsoft Visual Studio Solution File, Format Version 12.00
+Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.1.31903.286
MinimumVisualStudioVersion = 10.0.40219.1
@@ -537,6 +537,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VS.Redist.Common.Net.Core.S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "VS.Redist.Common.NetCore.Templates", "src\Layout\VS.Redist.Common.NetCore.Templates\VS.Redist.Common.NetCore.Templates.proj", "{6A7E16BE-FF2F-7927-AAF7-366CC0C42AE4}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ApiDiff", "ApiDiff", "{C66B5859-B05E-5DF4-58E9-78CA919DB89A}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ApiDiff", "ApiDiff", "{AFA55F45-CFCB-9821-A210-2D3496088416}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff.Tool", "src\Compatibility\ApiDiff\Microsoft.DotNet.ApiDiff.Tool\Microsoft.DotNet.ApiDiff.Tool.csproj", "{3F0093BF-A64D-4EE8-8A2A-22800BB25CFF}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff.Tests", "test\Microsoft.DotNet.ApiDiff.Tests\Microsoft.DotNet.ApiDiff.Tests.csproj", "{A57B724D-D12B-483E-82F2-2183DEA2DFA7}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.DotNet.ApiDiff", "src\Compatibility\ApiDiff\Microsoft.DotNet.ApiDiff\Microsoft.DotNet.ApiDiff.csproj", "{4F23A9C8-945A-A4F4-51E9-FCA215943C0D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -1023,7 +1033,18 @@ Global
{6A7E16BE-FF2F-7927-AAF7-366CC0C42AE4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6A7E16BE-FF2F-7927-AAF7-366CC0C42AE4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6A7E16BE-FF2F-7927-AAF7-366CC0C42AE4}.Release|Any CPU.Build.0 = Release|Any CPU
- EndGlobalSection
+ {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A57B724D-D12B-483E-82F2-2183DEA2DFA7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A57B724D-D12B-483E-82F2-2183DEA2DFA7}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A57B724D-D12B-483E-82F2-2183DEA2DFA7}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A57B724D-D12B-483E-82F2-2183DEA2DFA7}.Release|Any CPU.Build.0 = Release|Any CPU
+ {4F23A9C8-945A-A4F4-51E9-FCA215943C0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {4F23A9C8-945A-A4F4-51E9-FCA215943C0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {4F23A9C8-945A-A4F4-51E9-FCA215943C0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {4F23A9C8-945A-A4F4-51E9-FCA215943C0D}.Release|Any CPU.Build.0 = Release|Any CPU
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
@@ -1211,6 +1232,11 @@ Global
{93A4A3DA-9D0F-FAEA-5AD7-A34650D326D0} = {71C279BD-E850-4A8D-9775-11CA26B8E5BA}
{C5C4F9CA-25B8-2B20-826D-EA93F667C316} = {71C279BD-E850-4A8D-9775-11CA26B8E5BA}
{6A7E16BE-FF2F-7927-AAF7-366CC0C42AE4} = {71C279BD-E850-4A8D-9775-11CA26B8E5BA}
+ {C66B5859-B05E-5DF4-58E9-78CA919DB89A} = {44E564E1-AE0D-4313-A4E9-CBF2109397E3}
+ {AFA55F45-CFCB-9821-A210-2D3496088416} = {3AD322BF-405B-4A53-9858-51CF66E8509F}
+ {3F0093BF-A64D-4EE8-8A2A-22800BB25CFF} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A}
+ {A57B724D-D12B-483E-82F2-2183DEA2DFA7} = {AFA55F45-CFCB-9821-A210-2D3496088416}
+ {4F23A9C8-945A-A4F4-51E9-FCA215943C0D} = {C66B5859-B05E-5DF4-58E9-78CA919DB89A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {FB8F26CE-4DE6-433F-B32A-79183020BBD6}
diff --git a/src/Compatibility/ApiDiff/Directory.Build.props b/src/Compatibility/ApiDiff/Directory.Build.props
new file mode 100644
index 000000000000..0e5e89296832
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Directory.Build.props
@@ -0,0 +1,9 @@
+
+
+
+
+
+ true
+
+
+
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/AttributesToExclude.txt b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/AttributesToExclude.txt
new file mode 100644
index 000000000000..4b26bf1f6a67
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/AttributesToExclude.txt
@@ -0,0 +1,4 @@
+T:System.AttributeUsageAttribute
+T:System.ComponentModel.EditorBrowsableAttribute
+T:System.Diagnostics.CodeAnalysis.RequiresDynamicCodeAttribute
+T:System.Diagnostics.CodeAnalysis.RequiresUnreferencedCodeAttribute
\ No newline at end of file
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Microsoft.DotNet.ApiDiff.Tool.csproj b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Microsoft.DotNet.ApiDiff.Tool.csproj
new file mode 100644
index 000000000000..21c512fd7fcf
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Microsoft.DotNet.ApiDiff.Tool.csproj
@@ -0,0 +1,31 @@
+
+
+
+ $(NetToolMinimum)
+ Exe
+ true
+ false
+ true
+ apidiff
+ Tool to emit markdown diffs between sets of assemblies.
+ false
+ true
+
+
+
+
+ PreserveNewest
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Program.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Program.cs
new file mode 100644
index 000000000000..a8955d1f2f96
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff.Tool/Program.cs
@@ -0,0 +1,213 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.CommandLine;
+using System.ComponentModel;
+using System.Diagnostics;
+using Microsoft.DotNet.ApiSymbolExtensions.Logging;
+
+namespace Microsoft.DotNet.ApiDiff.Tool;
+
+///
+/// Entrypoint for the genapidiff tool, which generates a markdown diff of two
+/// different versions of the same assembly, using the specified command line options.
+///
+public static class Program
+{
+ private static readonly string AttributesToExcludeDefaultFileName = "AttributesToExclude.txt";
+
+ public static async Task Main(string[] args)
+ {
+ RootCommand rootCommand = new("genapidiff");
+
+ Option optionBeforeAssembliesFolderPath = new(name: "", aliases: ["--before", "-b"])
+ {
+ Description = "The path to the folder containing the old (before) assemblies to be included in the diff.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true
+ };
+
+ Option optionBeforeRefAssembliesFolderPath = new(name: "", aliases: ["--refbefore", "-rb"])
+ {
+ Description = "The path to the folder containing the references required by old (before) assemblies, not to be included in the diff.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = false
+ };
+
+ Option optionAfterAssembliesFolderPath = new(name: "", aliases: ["--after", "-a"])
+ {
+ Description = "The path to the folder containing the new (after) assemblies to be included in the diff.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true
+ };
+
+ Option optionAfterRefAssembliesFolderPath = new(name: "", aliases: ["--refafter", "-ra"])
+ {
+ Description = "The path to the folder containing references required by the new (after) reference assemblies, not to be included in the diff.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = false
+ };
+
+ Option optionOutputFolderPath = new(name: "", aliases: ["--output", "-o"])
+ {
+ Description = "The path to the output folder.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true
+ };
+
+ Option optionBeforeFriendlyName = new(name: "", aliases: ["--beforeFriendlyName", "-bfn"])
+ {
+ Description = "The friendly name to describe the 'before' assembly.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true
+ };
+
+ Option optionAfterFriendlyName = new(name: "", aliases: ["--afterFriendlyName", "-afn"])
+ {
+ Description = "The friendly name to describe the 'after' assembly.",
+ Arity = ArgumentArity.ExactlyOne,
+ Required = true
+ };
+
+ Option optionTableOfContentsTitle = new(name: "", aliases: ["--tableOfContentsTitle", "-tc"])
+ {
+ Description = $"The optional title of the markdown table of contents file that is placed in the output folder.",
+ Arity = ArgumentArity.ZeroOrMore,
+ Required = false,
+ DefaultValueFactory = _ => "api_diff"
+ };
+
+ Option optionFilesWithAssembliesToExclude = new(name: "", aliases: ["--assembliesToExclude", "-eas"])
+ {
+ Description = "An optional array of filepaths, each containing a list of assemblies that should be excluded from the diff. Each file should contain one assembly name per line, with no extensions.",
+ Arity = ArgumentArity.ZeroOrMore,
+ Required = false,
+ DefaultValueFactory = _ => null
+ };
+
+ Option optionFilesWithAttributesToExclude = new(name: "", aliases: ["--attributesToExclude", "-eattrs"])
+ {
+ Description = $"An optional array of filepaths, each containing a list of attributes to exclude from the diff. Each file should contain one API full name per line. You can either modify the default file '{AttributesToExcludeDefaultFileName}' to add your own attributes, or include additional files using this command line option.",
+ Arity = ArgumentArity.ZeroOrMore,
+ Required = false,
+ DefaultValueFactory = _ => [new FileInfo(AttributesToExcludeDefaultFileName)]
+ };
+
+ Option optionFilesWithApisToExclude = new(name: "", aliases: ["--apisToExclude", "-eapis"])
+ {
+ Description = "An optional array of filepaths, each containing a list of APIs to exclude from the diff. Each file should contain one API full name per line.",
+ Arity = ArgumentArity.ZeroOrMore,
+ Required = false,
+ DefaultValueFactory = _ => null
+ };
+
+ Option optionAddPartialModifier = new(name: "", aliases: ["--addPartialModifier", "-apm"])
+ {
+ Description = "Add the 'partial' modifier to types.",
+ DefaultValueFactory = _ => false
+ };
+
+ Option optionAttachDebugger = new(name: "", aliases: ["--attachDebugger", "-d"])
+ {
+ Description = "Stops the tool at startup, prints the process ID and waits for a debugger to attach.",
+ DefaultValueFactory = _ => false
+ };
+
+ // Custom ordering for the help menu.
+ rootCommand.Options.Add(optionBeforeAssembliesFolderPath);
+ rootCommand.Options.Add(optionBeforeRefAssembliesFolderPath);
+ rootCommand.Options.Add(optionAfterAssembliesFolderPath);
+ rootCommand.Options.Add(optionAfterRefAssembliesFolderPath);
+ rootCommand.Options.Add(optionOutputFolderPath);
+ rootCommand.Options.Add(optionBeforeFriendlyName);
+ rootCommand.Options.Add(optionAfterFriendlyName);
+ rootCommand.Options.Add(optionTableOfContentsTitle);
+ rootCommand.Options.Add(optionFilesWithAssembliesToExclude);
+ rootCommand.Options.Add(optionFilesWithAttributesToExclude);
+ rootCommand.Options.Add(optionFilesWithApisToExclude);
+ rootCommand.Options.Add(optionAddPartialModifier);
+ rootCommand.Options.Add(optionAttachDebugger);
+
+ rootCommand.SetAction(async (ParseResult result) =>
+ {
+ DiffConfiguration c = new(
+ BeforeAssembliesFolderPath: result.GetValue(optionBeforeAssembliesFolderPath) ?? throw new NullReferenceException("Null before assemblies directory"),
+ BeforeAssemblyReferencesFolderPath: result.GetValue(optionBeforeRefAssembliesFolderPath),
+ AfterAssembliesFolderPath: result.GetValue(optionAfterAssembliesFolderPath) ?? throw new NullReferenceException("Null after assemblies directory"),
+ AfterAssemblyReferencesFolderPath: result.GetValue(optionAfterRefAssembliesFolderPath),
+ OutputFolderPath: result.GetValue(optionOutputFolderPath) ?? throw new NullReferenceException("Null output directory"),
+ BeforeFriendlyName: result.GetValue(optionBeforeFriendlyName) ?? throw new NullReferenceException("Null before friendly name"),
+ AfterFriendlyName: result.GetValue(optionAfterFriendlyName) ?? throw new NullReferenceException("Null after friendly name"),
+ TableOfContentsTitle: result.GetValue(optionTableOfContentsTitle) ?? throw new NullReferenceException("Null table of contents title"),
+ FilesWithAssembliesToExclude: result.GetValue(optionFilesWithAssembliesToExclude),
+ FilesWithAttributesToExclude: result.GetValue(optionFilesWithAttributesToExclude),
+ FilesWithApisToExclude: result.GetValue(optionFilesWithApisToExclude),
+ AddPartialModifier: result.GetValue(optionAddPartialModifier),
+ AttachDebugger: result.GetValue(optionAttachDebugger)
+ );
+ await HandleCommandAsync(c).ConfigureAwait(false);
+ });
+ await rootCommand.Parse(args).InvokeAsync();
+ }
+
+ private static Task HandleCommandAsync(DiffConfiguration diffConfig)
+ {
+ var log = new ConsoleLog(MessageImportance.Normal);
+
+ string assembliesToExclude = string.Join(", ", diffConfig.FilesWithAssembliesToExclude?.Select(a => a.FullName) ?? []);
+ string attributesToExclude = string.Join(", ", diffConfig.FilesWithAttributesToExclude?.Select(a => a.FullName) ?? []);
+ string apisToExclude = string.Join(", ", diffConfig.FilesWithApisToExclude?.Select(a => a.FullName) ?? []);
+
+ // Custom ordering to match help menu.
+ log.LogMessage("Selected options:");
+ log.LogMessage($" - 'Before' source assemblies: {diffConfig.BeforeAssembliesFolderPath}");
+ log.LogMessage($" - 'After' source assemblies: {diffConfig.AfterAssembliesFolderPath}");
+ log.LogMessage($" - 'Before' reference assemblies: {diffConfig.BeforeAssemblyReferencesFolderPath}");
+ log.LogMessage($" - 'After' reference assemblies: {diffConfig.AfterAssemblyReferencesFolderPath}");
+ log.LogMessage($" - Output: {diffConfig.OutputFolderPath}");
+ log.LogMessage($" - Files with assemblies to exclude: {assembliesToExclude}");
+ log.LogMessage($" - Files with attributes to exclude: {attributesToExclude}");
+ log.LogMessage($" - Files with APIs to exclude: {apisToExclude}");
+ log.LogMessage($" - 'Before' friendly name: {diffConfig.BeforeFriendlyName}");
+ log.LogMessage($" - 'After' friendly name: {diffConfig.AfterFriendlyName}");
+ log.LogMessage($" - Table of contents title: {diffConfig.TableOfContentsTitle}");
+ log.LogMessage($" - Add partial modifier to types: {diffConfig.AddPartialModifier}");
+ log.LogMessage($" - Attach debugger: {diffConfig.AttachDebugger}");
+ log.LogMessage("");
+
+ if (diffConfig.AttachDebugger)
+ {
+ WaitForDebugger();
+ }
+
+ IDiffGenerator diffGenerator = DiffGeneratorFactory.Create(log,
+ diffConfig.BeforeAssembliesFolderPath,
+ diffConfig.BeforeAssemblyReferencesFolderPath,
+ diffConfig.AfterAssembliesFolderPath,
+ diffConfig.AfterAssemblyReferencesFolderPath,
+ diffConfig.OutputFolderPath,
+ diffConfig.BeforeFriendlyName,
+ diffConfig.AfterFriendlyName,
+ diffConfig.TableOfContentsTitle,
+ diffConfig.FilesWithAssembliesToExclude,
+ diffConfig.FilesWithAttributesToExclude,
+ diffConfig.FilesWithApisToExclude,
+ diffConfig.AddPartialModifier,
+ writeToDisk: true,
+ diagnosticOptions: null // TODO: If needed, add CLI option to pass specific diagnostic options
+ );
+
+ return diffGenerator.RunAsync();
+ }
+
+ private static void WaitForDebugger()
+ {
+ while (!Debugger.IsAttached)
+ {
+ Console.WriteLine($"Attach to process {Environment.ProcessId}...");
+ Thread.Sleep(1000);
+ }
+ Console.WriteLine("Debugger attached!");
+ Debugger.Break();
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffConfiguration.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffConfiguration.cs
new file mode 100644
index 000000000000..030d7dd38713
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffConfiguration.cs
@@ -0,0 +1,23 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.ApiDiff;
+
+///
+/// Defines the necessary configuration options for API diff.
+///
+public record DiffConfiguration(
+ string AfterAssembliesFolderPath,
+ string? AfterAssemblyReferencesFolderPath,
+ string BeforeAssembliesFolderPath,
+ string? BeforeAssemblyReferencesFolderPath,
+ string OutputFolderPath,
+ string BeforeFriendlyName,
+ string AfterFriendlyName,
+ string TableOfContentsTitle,
+ FileInfo[]? FilesWithAssembliesToExclude,
+ FileInfo[]? FilesWithAttributesToExclude,
+ FileInfo[]? FilesWithApisToExclude,
+ bool AddPartialModifier,
+ bool AttachDebugger
+);
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffGeneratorFactory.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffGeneratorFactory.cs
new file mode 100644
index 000000000000..bc30f66251da
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/DiffGeneratorFactory.cs
@@ -0,0 +1,109 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.DotNet.ApiSymbolExtensions;
+using Microsoft.DotNet.ApiSymbolExtensions.Logging;
+
+namespace Microsoft.DotNet.ApiDiff;
+
+public static class DiffGeneratorFactory
+{
+ ///
+ /// The default diagnostic options to use when generating the diff.
+ ///
+ public static readonly IEnumerable> DefaultDiagnosticOptions = [
+ new ("CS8019", ReportDiagnostic.Suppress), // CS8019: Unnecessary using directive.
+ new ("CS8597", ReportDiagnostic.Suppress), // CS8597: Thrown value may be null.
+ new ("CS0067", ReportDiagnostic.Suppress), // CS0067: The API is never used.
+ new ("CS9113", ReportDiagnostic.Suppress), // CS9113: Parameter is unread.
+ new ("CS0501", ReportDiagnostic.Suppress), // CS0501: Method must declare a body because it is not marked abstract.
+ ];
+
+ ///
+ /// Creates a new instance of that writes the diff to disk.
+ ///
+ /// The logger to use for logging messages.
+ /// The folder path containing the assemblies before the change.
+ /// The folder path containing the assembly references before the change.
+ /// The folder path containing the assemblies after the change.
+ /// The folder path containing the assembly references after the change.
+ /// The folder path where the output will be written.
+ /// The friendly name for the assemblies before the change.
+ /// The friendly name for the assemblies after the change.
+ /// The title for the table of contents in the generated diff.
+ /// An optional array of filepaths each containing a list of assemblies to avoid showing in the diff. If , no assemblies are excluded.
+ /// An optional array of filepaths each containing a list of attributes to avoid showing in the diff.
+ /// An optional array of filepaths each containing a list of APIs to avoid showing in the diff.
+ /// Indicates whether to add the partial modifier to types.
+ /// If , when calling , the generated markdown files get written to disk, and no item is added to the dictionary. If , when calling , the generated markdown files get added to the dictionary (with the file path as the dictionary key) and none of them is written to disk. This is meant for testing purposes.
+ /// An optional list of diagnostic options to use when generating the diff.
+ /// A new instance of that writes the diff to disk.
+ ///
+ public static IDiffGenerator Create(ILog log,
+ string beforeAssembliesFolderPath,
+ string? beforeAssemblyReferencesFolderPath,
+ string afterAssembliesFolderPath,
+ string? afterAssemblyReferencesFolderPath,
+ string outputFolderPath,
+ string beforeFriendlyName,
+ string afterFriendlyName,
+ string tableOfContentsTitle,
+ FileInfo[]? filesWithAssembliesToExclude,
+ FileInfo[]? filesWithAttributesToExclude,
+ FileInfo[]? filesWithApisToExclude,
+ bool addPartialModifier,
+ bool writeToDisk,
+ IEnumerable>? diagnosticOptions = null)
+ {
+ return new FileOutputDiffGenerator(log,
+ beforeAssembliesFolderPath,
+ beforeAssemblyReferencesFolderPath,
+ afterAssembliesFolderPath,
+ afterAssemblyReferencesFolderPath,
+ outputFolderPath,
+ beforeFriendlyName,
+ afterFriendlyName,
+ tableOfContentsTitle,
+ filesWithAssembliesToExclude,
+ filesWithAttributesToExclude,
+ filesWithApisToExclude,
+ addPartialModifier,
+ writeToDisk,
+ diagnosticOptions);
+ }
+
+ ///
+ /// Creates a new instance of that writes the diff to memory.
+ ///
+ /// The logger to use for logging messages.
+ /// The loader to use for loading the assemblies before the change.
+ /// The loader to use for loading the assemblies after the change.
+ /// The dictionary containing the assembly symbols before the change.
+ /// The dictionary containing the assembly symbols after the change.
+ /// An optional list of attributes to avoid showing in the diff.
+ /// An optional list of APIs to avoid showing in the diff.
+ /// Indicates whether to add the partial modifier to types.
+ /// An optional list of diagnostic options to use when generating the diff.
+ /// A new instance of that writes the diff to memory.
+ public static IDiffGenerator Create(ILog log,
+ IAssemblySymbolLoader beforeLoader,
+ IAssemblySymbolLoader afterLoader,
+ Dictionary beforeAssemblySymbols,
+ Dictionary afterAssemblySymbols,
+ string[]? attributesToExclude,
+ string[]? apisToExclude,
+ bool addPartialModifier,
+ IEnumerable>? diagnosticOptions = null)
+ {
+ return new MemoryOutputDiffGenerator(log,
+ beforeLoader,
+ afterLoader,
+ beforeAssemblySymbols,
+ afterAssemblySymbols,
+ attributesToExclude,
+ apisToExclude,
+ addPartialModifier,
+ diagnosticOptions);
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/FileOutputDiffGenerator.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/FileOutputDiffGenerator.cs
new file mode 100644
index 000000000000..b86a24d77cd7
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/FileOutputDiffGenerator.cs
@@ -0,0 +1,192 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the
+
+using Microsoft.CodeAnalysis;
+using Microsoft.DotNet.ApiSymbolExtensions.Logging;
+using Microsoft.DotNet.ApiSymbolExtensions;
+using System.Diagnostics;
+
+namespace Microsoft.DotNet.ApiDiff;
+
+///
+/// Generates a markdown diff of two different versions of the same assembly.
+///
+internal sealed class FileOutputDiffGenerator : IDiffGenerator
+{
+ private readonly ILog _log;
+ private readonly string[] _beforeAssembliesFolderPaths;
+ private readonly string[] _beforeAssemblyReferencesFolderPaths;
+ private readonly string[] _afterAssembliesFolderPaths;
+ private readonly string[] _afterAssemblyReferencesFolderPaths;
+ private readonly string _outputFolderPath;
+ private readonly string _beforeFriendlyName;
+ private readonly string _afterFriendlyName;
+ private readonly string _tableOfContentsTitle;
+ private readonly string[] _assembliesToExclude;
+ private readonly string[] _attributesToExclude;
+ private readonly string[] _apisToExclude;
+ private readonly bool _addPartialModifier;
+ private readonly bool _writeToDisk;
+ private readonly IEnumerable>? _diagnosticOptions;
+ private readonly Dictionary _results;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger to use for logging messages.
+ /// The folder path containing the assemblies before the changes.
+ /// The folder path containing the assembly references before the changes.
+ /// The folder path containing the assemblies after the changes.
+ /// The folder path containing the assembly references after the changes.
+ /// The folder path where the output files will be written.
+ /// The friendly name for the before version of the assemblies.
+ /// The friendly name for the after version of the assemblies.
+ /// The title for the table of contents.
+ /// An optional array of filepaths each containing a list of assemblies to avoid showing in the diff.
+ /// An optional array of filepaths each containing a list of attributes to avoid showing in the diff.
+ /// An optional array of filepaths each containing a list of APIs to avoid showing in the diff.
+ /// A value indicating whether to add the partial modifier to types.
+ /// If , when calling , the generated markdown files get written to disk, and no item is added to the dictionary. If , when calling , the generated markdown files get added to the dictionary (with the file path as the dictionary key) and none of them is written to disk. This is meant for testing purposes.
+ /// An optional set of diagnostic options.
+ internal FileOutputDiffGenerator(ILog log,
+ string beforeAssembliesFolderPath,
+ string? beforeAssemblyReferencesFolderPath,
+ string afterAssembliesFolderPath,
+ string? afterAssemblyReferencesFolderPath,
+ string outputFolderPath,
+ string beforeFriendlyName,
+ string afterFriendlyName,
+ string tableOfContentsTitle,
+ FileInfo[]? filesWithAssembliesToExclude,
+ FileInfo[]? filesWithAttributesToExclude,
+ FileInfo[]? filesWithApisToExclude,
+ bool addPartialModifier,
+ bool writeToDisk,
+ IEnumerable>? diagnosticOptions = null)
+
+ {
+ _log = log;
+ _beforeAssembliesFolderPaths = [beforeAssembliesFolderPath];
+ _beforeAssemblyReferencesFolderPaths = beforeAssemblyReferencesFolderPath != null ? [beforeAssemblyReferencesFolderPath] : [];
+ _afterAssembliesFolderPaths = [afterAssembliesFolderPath];
+ _afterAssemblyReferencesFolderPaths = afterAssemblyReferencesFolderPath != null ? [afterAssemblyReferencesFolderPath] : [];
+ _outputFolderPath = outputFolderPath;
+ _beforeFriendlyName = beforeFriendlyName;
+ _afterFriendlyName = afterFriendlyName;
+ _tableOfContentsTitle = tableOfContentsTitle;
+ _assembliesToExclude = CollectListsFromFiles(filesWithAssembliesToExclude);
+ _attributesToExclude = filesWithAttributesToExclude != null ? CollectListsFromFiles(filesWithAttributesToExclude) : [];
+ _apisToExclude = CollectListsFromFiles(filesWithApisToExclude);
+ _addPartialModifier = addPartialModifier;
+ _writeToDisk = writeToDisk;
+ _diagnosticOptions = diagnosticOptions ?? DiffGeneratorFactory.DefaultDiagnosticOptions;
+ _results = [];
+ }
+
+ ///
+ public IReadOnlyDictionary Results => _results.AsReadOnly();
+
+ ///
+ public async Task RunAsync()
+ {
+ Debug.Assert(_beforeAssembliesFolderPaths.Length == 1);
+ Debug.Assert(_afterAssembliesFolderPaths.Length == 1);
+
+ (IAssemblySymbolLoader beforeLoader, Dictionary beforeAssemblySymbols) =
+ AssemblySymbolLoader.CreateFromFiles(
+ _log,
+ assembliesPaths: _beforeAssembliesFolderPaths,
+ assemblyReferencesPaths: _beforeAssemblyReferencesFolderPaths,
+ assembliesToExclude: _assembliesToExclude,
+ diagnosticOptions: _diagnosticOptions);
+
+ (IAssemblySymbolLoader afterLoader, Dictionary afterAssemblySymbols) =
+ AssemblySymbolLoader.CreateFromFiles(
+ _log,
+ assembliesPaths: _afterAssembliesFolderPaths,
+ assemblyReferencesPaths: _afterAssemblyReferencesFolderPaths,
+ assembliesToExclude: _assembliesToExclude,
+ diagnosticOptions: _diagnosticOptions);
+
+ MemoryOutputDiffGenerator generator = new(_log,
+ beforeLoader,
+ afterLoader,
+ beforeAssemblySymbols,
+ afterAssemblySymbols,
+ _attributesToExclude,
+ _apisToExclude,
+ _addPartialModifier,
+ _diagnosticOptions);
+
+ await generator.RunAsync().ConfigureAwait(false);
+
+ // If true, output is disk. Otherwise, it's the Results dictionary.
+ if (_writeToDisk)
+ {
+ Directory.CreateDirectory(_outputFolderPath);
+ }
+
+ StringBuilder tableOfContents = new();
+ tableOfContents.AppendLine($"# API difference between {_beforeFriendlyName} and {_afterFriendlyName}");
+ tableOfContents.AppendLine();
+ tableOfContents.AppendLine("API listing follows standard diff formatting.");
+ tableOfContents.AppendLine("Lines preceded by a '+' are additions and a '-' indicates removal.");
+ tableOfContents.AppendLine();
+
+ foreach ((string assemblyName, string text) in generator.Results.OrderBy(r => r.Key))
+ {
+ string fileName = $"{_tableOfContentsTitle}_{assemblyName}.md";
+ tableOfContents.AppendLine($"* [{assemblyName}]({fileName})");
+
+ string filePath = Path.Combine(_outputFolderPath, fileName);
+ if (_writeToDisk)
+ {
+ await File.WriteAllTextAsync(filePath, text).ConfigureAwait(false);
+ }
+ else
+ {
+ _results.Add(filePath, text);
+ }
+
+ _log.LogMessage($"Wrote '{filePath}'.");
+ }
+
+ tableOfContents.AppendLine();
+
+ string tableOfContentsFilePath = Path.Combine(_outputFolderPath, $"{_tableOfContentsTitle}.md");
+
+ if (_writeToDisk)
+ {
+ await File.WriteAllTextAsync(tableOfContentsFilePath, tableOfContents.ToString()).ConfigureAwait(false);
+ }
+ else
+ {
+ _results.Add(tableOfContentsFilePath, tableOfContents.ToString());
+ }
+
+ _log.LogMessage($"Wrote table of contents to '{tableOfContentsFilePath}'.");
+ }
+
+ private static string[] CollectListsFromFiles(FileInfo[]? filesWithLists)
+ {
+ List list = [];
+
+ if (filesWithLists != null)
+ {
+ foreach (FileInfo file in filesWithLists)
+ {
+ // This will throw if file does not exist.
+ foreach (string line in File.ReadLines(file.FullName))
+ {
+ if (!list.Contains(line))
+ {
+ // Prevent duplicates.
+ list.Add(line);
+ }
+ }
+ }
+ }
+
+ return [.. list.Order()];
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/IDiffGenerator.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/IDiffGenerator.cs
new file mode 100644
index 000000000000..32c7f2ceb478
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/IDiffGenerator.cs
@@ -0,0 +1,17 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.DotNet.ApiDiff;
+
+public interface IDiffGenerator
+{
+ ///
+ /// Gets the results of the diff. The key is the assembly name and the value is the diff. This dictionary might get populated after calling , depending on the use case.
+ ///
+ IReadOnlyDictionary Results { get; }
+
+ ///
+ /// Asynchronously runs the diff generator and may populate the dictionary depending on the use case.
+ ///
+ Task RunAsync();
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/MemoryOutputDiffGenerator.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/MemoryOutputDiffGenerator.cs
new file mode 100644
index 000000000000..a5cb01b16ef6
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/MemoryOutputDiffGenerator.cs
@@ -0,0 +1,776 @@
+// 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.Concurrent;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using DiffPlex.DiffBuilder;
+using DiffPlex.DiffBuilder.Model;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+using Microsoft.CodeAnalysis.Formatting;
+using Microsoft.DotNet.ApiDiff.SyntaxRewriter;
+using Microsoft.DotNet.ApiSymbolExtensions;
+using Microsoft.DotNet.ApiSymbolExtensions.Filtering;
+using Microsoft.DotNet.ApiSymbolExtensions.Logging;
+using Microsoft.DotNet.GenAPI;
+using Microsoft.DotNet.GenAPI.SyntaxRewriter;
+
+namespace Microsoft.DotNet.ApiDiff;
+
+///
+/// Generates a markdown diff of two different versions of the same assembly.
+///
+public class MemoryOutputDiffGenerator : IDiffGenerator
+{
+ private readonly ILog _log;
+ private readonly IAssemblySymbolLoader _beforeLoader;
+ private readonly IAssemblySymbolLoader _afterLoader;
+ private readonly ConcurrentDictionary _beforeAssemblySymbols;
+ private readonly ConcurrentDictionary _afterAssemblySymbols;
+ private readonly bool _addPartialModifier;
+ private readonly ISymbolFilter _attributeSymbolFilter;
+ private readonly ISymbolFilter _symbolFilter;
+ private readonly SyntaxTriviaList _twoSpacesTrivia;
+ private readonly SyntaxToken _missingCloseBrace;
+ private readonly SyntaxTrivia _endOfLineTrivia;
+ private readonly SyntaxList _emptyAttributeList;
+ private readonly IEnumerable> _diagnosticOptions;
+ private readonly ConcurrentDictionary _results;
+ private readonly ChildrenNodesComparer _childrenNodesComparer;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The logger to use for logging messages.
+ /// The loader for the "before" assembly symbols.
+ /// The loader for the "after" assembly symbols.
+ /// The dictionary of "before" assembly symbols.
+ /// The dictionary of "after" assembly symbols.
+ /// An optional list of attributes to avoid showing in the diff.
+ /// An optional list of APIs to avoid showing in the diff.
+ /// A boolean indicating whether to add the partial modifier to types.
+ /// An optional dictionary of diagnostic options.
+ internal MemoryOutputDiffGenerator(
+ ILog log,
+ IAssemblySymbolLoader beforeLoader,
+ IAssemblySymbolLoader afterLoader,
+ Dictionary beforeAssemblySymbols,
+ Dictionary afterAssemblySymbols,
+ string[]? attributesToExclude,
+ string[]? apisToExclude,
+ bool addPartialModifier,
+ IEnumerable>? diagnosticOptions = null)
+ {
+ _log = log;
+ _beforeLoader = beforeLoader;
+ _afterLoader = afterLoader;
+ _beforeAssemblySymbols = new ConcurrentDictionary(beforeAssemblySymbols);
+ _afterAssemblySymbols = new ConcurrentDictionary(afterAssemblySymbols);
+ _addPartialModifier = addPartialModifier;
+ _diagnosticOptions = diagnosticOptions ?? DiffGeneratorFactory.DefaultDiagnosticOptions;
+ _attributeSymbolFilter = SymbolFilterFactory.GetFilterFromList(attributesToExclude ?? [], includeExplicitInterfaceImplementationSymbols: true);
+ _symbolFilter = SymbolFilterFactory.GetFilterFromList(apisToExclude ?? [], includeExplicitInterfaceImplementationSymbols: true);
+ _twoSpacesTrivia = SyntaxFactory.TriviaList(SyntaxFactory.Space, SyntaxFactory.Space);
+ _missingCloseBrace = SyntaxFactory.MissingToken(SyntaxKind.CloseBraceToken);
+ _emptyAttributeList = SyntaxFactory.List();
+ _results = [];
+ _endOfLineTrivia = Environment.NewLine == "\r\n" ? SyntaxFactory.CarriageReturnLineFeed : SyntaxFactory.LineFeed;
+ _childrenNodesComparer = new ChildrenNodesComparer();
+ }
+
+ ///
+ public IReadOnlyDictionary Results => _results.AsReadOnly();
+
+ ///
+ public async Task RunAsync()
+ {
+ Stopwatch swRun = Stopwatch.StartNew();
+
+ foreach ((string beforeAssemblyName, IAssemblySymbol beforeAssemblySymbol) in _beforeAssemblySymbols)
+ {
+ // Needs to block so the _afterAssemblySymbols dictionary gets updated.
+ await ProcessBeforeAndAfterAssemblyAsync(beforeAssemblyName, beforeAssemblySymbol).ConfigureAwait(false);
+ }
+
+ // Needs to happen after processing the before and after assemblies and filtering out the existing ones.
+ foreach ((string afterAssemblyName, IAssemblySymbol afterAssemblySymbol) in _afterAssemblySymbols)
+ {
+ await ProcessNewAssemblyAsync(afterAssemblyName, afterAssemblySymbol).ConfigureAwait(false);
+ }
+
+ _log.LogMessage($"FINAL TOTAL TIME: {swRun.Elapsed.TotalMilliseconds / 1000.0 / 60.0:F2} mins.");
+ swRun.Stop();
+ }
+
+ private async Task ProcessBeforeAndAfterAssemblyAsync(string beforeAssemblyName, IAssemblySymbol beforeAssemblySymbol)
+ {
+ StringBuilder sb = new();
+
+ _log.LogMessage($"Visiting assembly {beforeAssemblySymbol.Name} (before vs after)...");
+
+ Stopwatch swAssembly = Stopwatch.StartNew();
+ (SyntaxNode beforeAssemblyNode, SemanticModel beforeModel) = await GetAssemblyRootNodeAndModelAsync(_beforeLoader, beforeAssemblySymbol).ConfigureAwait(false);
+ string finalMessage = $"Finished visiting {beforeAssemblySymbol.Name} (before) in {swAssembly.Elapsed.TotalMilliseconds / 1000.0:F2}s{Environment.NewLine}";
+
+ // See if an assembly with the same name can be found in the diff folder.
+ if (_afterAssemblySymbols.TryGetValue(beforeAssemblyName, out IAssemblySymbol? afterAssemblySymbol))
+ {
+ string afterAssemblyString = await ProcessAfterAssemblyAsync(afterAssemblySymbol, beforeAssemblyNode, beforeModel).ConfigureAwait(false);
+ sb.Append(afterAssemblyString);
+
+ // Remove the found ones. The remaining ones will be processed at the end because they're new.
+ _afterAssemblySymbols.Remove(beforeAssemblyName, out _);
+ }
+ else
+ {
+ // The assembly was removed in the diff.
+ //sb.Append(GenerateDeletedDiff(beforeAssemblyNode));
+ sb.Append(VisitChildren(beforeAssemblyNode, afterParentNode: null, beforeModel, afterModel: null, ChangeType.Deleted, wereParentAttributesChanged: false));
+ }
+
+ if (sb.Length > 0)
+ {
+ _results.TryAdd(beforeAssemblyName, GetFinalAssemblyDiff(beforeAssemblyName, sb.ToString()));
+ }
+ }
+
+ private async Task ProcessNewAssemblyAsync(string afterAssemblyName, IAssemblySymbol afterAssemblySymbol)
+ {
+ string afterAssemblyString = await ProcessAfterAssemblyAsync(afterAssemblySymbol).ConfigureAwait(false);
+ if (afterAssemblyString.Length > 0)
+ {
+ _results.TryAdd(afterAssemblyName, GetFinalAssemblyDiff(afterAssemblyName, afterAssemblyString));
+ }
+ }
+
+ private async Task ProcessAfterAssemblyAsync(IAssemblySymbol afterAssemblySymbol, SyntaxNode? beforeParentNode = null, SemanticModel? beforeModel = null)
+ {
+ StringBuilder sb = new();
+ var swNew = Stopwatch.StartNew();
+ (SyntaxNode afterAssemblyNode, SemanticModel afterModel) = await GetAssemblyRootNodeAndModelAsync(_afterLoader, afterAssemblySymbol).ConfigureAwait(false);
+ string assemblyDescriptor = beforeParentNode == null ? "new" : "existing";
+ string finalMessage = $"Finished visiting *{assemblyDescriptor}* assembly {afterAssemblySymbol.Name} in {swNew.Elapsed.TotalMilliseconds / 1000.0:F2}s{Environment.NewLine}";
+
+ swNew = Stopwatch.StartNew();
+ ChangeType parentChangeType = beforeParentNode == null ? ChangeType.Inserted : ChangeType.Unchanged;
+ // We don't care about changed assembly attributes (for now).
+ sb.Append(VisitChildren(beforeParentNode, afterAssemblyNode, beforeModel, afterModel, parentChangeType, wereParentAttributesChanged: false));
+ finalMessage += $"Finished generating diff for *{assemblyDescriptor}* assembly {afterAssemblySymbol.Name} in {swNew.Elapsed.TotalMilliseconds / 1000.0:F2}s{Environment.NewLine}";
+
+ _log.LogMessage(finalMessage);
+ swNew.Stop();
+
+ return sb.ToString();
+ }
+
+ private async Task<(SyntaxNode rootNode, SemanticModel model)> GetAssemblyRootNodeAndModelAsync(IAssemblySymbolLoader loader, IAssemblySymbol assemblySymbol)
+ {
+ CSharpAssemblyDocumentGeneratorOptions options = new(loader, _symbolFilter, _attributeSymbolFilter)
+ {
+ HideImplicitDefaultConstructors = false,
+ ShouldFormat = true,
+ ShouldReduce = false,
+ MetadataReferences = loader.MetadataReferences,
+ DiagnosticOptions = _diagnosticOptions,
+ SyntaxRewriters = [
+ // IMPORTANT: The order of these elements matters!
+ new TypeDeclarationCSharpSyntaxRewriter(_addPartialModifier), // This must be visited BEFORE GlobalPrefixRemover as it depends on the 'global::' prefix to be found
+ GlobalPrefixRemover.Singleton, // And then call this ASAP afterwards so there are fewer identifiers to visit
+ PrimitiveSimplificationRewriter.Singleton,
+ RemoveBodyCSharpSyntaxRewriter.Singleton,
+ SingleLineStatementCSharpSyntaxRewriter.Singleton,
+ ],
+ AdditionalAnnotations = [Formatter.Annotation] // Formatter is needed to fix some spacing
+ };
+
+ CSharpAssemblyDocumentGenerator docGenerator = new(_log, options);
+
+ Document document = await docGenerator.GetDocumentForAssemblyAsync(assemblySymbol).ConfigureAwait(false); // Super hot and resource-intensive path
+ SyntaxNode root = await document.GetSyntaxRootAsync().ConfigureAwait(false) ?? throw new InvalidOperationException($"Root node could not be found for document of assembly '{assemblySymbol.Name}'.");
+ SemanticModel model = await document.GetSemanticModelAsync().ConfigureAwait(false) ?? throw new InvalidOperationException($"Semantic model could not be found for document of assembly '{assemblySymbol.Name}'.");
+
+ return (root, model);
+ }
+
+ private static string GetFinalAssemblyDiff(string assemblyName, string diffText)
+ {
+ StringBuilder sbAssembly = new();
+ sbAssembly.AppendLine($"# {assemblyName}");
+ sbAssembly.AppendLine();
+ sbAssembly.AppendLine("```diff");
+ sbAssembly.Append(diffText.TrimEnd()); // Leading text stays, it's valid indentation.
+ sbAssembly.AppendLine();
+ sbAssembly.AppendLine("```");
+ return sbAssembly.ToString();
+ }
+
+ private string? VisitChildren(SyntaxNode? beforeParentNode, SyntaxNode? afterParentNode, SemanticModel? beforeModel, SemanticModel? afterModel, ChangeType parentChangeType, bool wereParentAttributesChanged)
+ {
+ Debug.Assert(beforeParentNode != null || afterParentNode != null);
+
+ ConcurrentDictionary beforeChildrenNodes = CollectChildrenNodes(beforeParentNode, beforeModel);
+ ConcurrentDictionary afterChildrenNodes = CollectChildrenNodes(afterParentNode, afterModel);
+
+ StringBuilder sb = new();
+ // Traverse all the elements found on the left side. This only visits unchanged, modified and deleted APIs.
+ // In other words, this loop excludes those that are new on the right. Those are handled later.
+ foreach ((string beforeMemberName, MemberDeclarationSyntax beforeMemberNode) in beforeChildrenNodes.Order(_childrenNodesComparer))
+ {
+ if (afterChildrenNodes.TryGetValue(beforeMemberName, out MemberDeclarationSyntax? afterMemberNode) &&
+ beforeMemberNode.Kind() == afterMemberNode.Kind())
+ {
+ if (beforeMemberNode is BaseTypeDeclarationSyntax && afterMemberNode is BaseTypeDeclarationSyntax ||
+ beforeMemberNode is BaseNamespaceDeclarationSyntax && afterMemberNode is BaseNamespaceDeclarationSyntax)
+ {
+ // At this point, the current API (which is a type or a namespace) is considered unmodified in its signature.
+ // Before visiting its children, which might have changed, we need to check if the current API has attributes that changed, and append the diff if needed.
+ // The children visitation should take care of wrapping the children with the current 'unmodified' parent API.
+ string? attributes = VisitAttributes(beforeMemberNode, afterMemberNode, beforeModel, afterModel);
+ if (attributes != null)
+ {
+ sb.Append(attributes);
+ }
+ sb.Append(VisitChildren(beforeMemberNode, afterMemberNode, beforeModel, afterModel, ChangeType.Unchanged, wereParentAttributesChanged: attributes != null));
+ }
+ else
+ {
+ // This returns the current member (as changed) topped with its added/deleted/changed attributes.
+ sb.Append(VisitLeafNode(beforeMemberNode, afterMemberNode, beforeModel, afterModel, ChangeType.Modified));
+ }
+ // Remove the found ones. The remaining ones will be processed at the end because they're new.
+ afterChildrenNodes.Remove(beforeMemberName, out _);
+ }
+ else
+ {
+ // This returns the current member (as deleted) topped with its attributes as deleted.
+ if (beforeMemberNode is BaseTypeDeclarationSyntax or BaseNamespaceDeclarationSyntax)
+ {
+ string? attributes = VisitAttributes(beforeMemberNode, afterMemberNode, beforeModel, afterModel);
+ if (attributes != null)
+ {
+ sb.Append(attributes);
+ }
+ sb.Append(VisitChildren(beforeMemberNode, afterParentNode: null, beforeModel, afterModel: null, ChangeType.Deleted, wereParentAttributesChanged: true));
+ }
+ else
+ {
+ sb.Append(VisitLeafNode(beforeMemberNode, afterNode: null, beforeModel, afterModel, ChangeType.Deleted));
+ }
+ }
+ }
+
+ if (!afterChildrenNodes.IsEmpty)
+ {
+ // Traverse all the elements that are new on the right side which were not found on the left. They are all treated as new APIs.
+ foreach ((string newMemberName, MemberDeclarationSyntax newMemberNode) in afterChildrenNodes.Order(_childrenNodesComparer))
+ {
+ // Need to do a full visit of each member of namespaces and types anyway, so that leaf bodies are removed
+ if (newMemberNode is BaseTypeDeclarationSyntax or BaseNamespaceDeclarationSyntax)
+ {
+ string? attributes = VisitAttributes(beforeNode: null, newMemberNode, beforeModel, afterModel);
+ if (attributes != null)
+ {
+ sb.Append(attributes);
+ }
+ sb.Append(VisitChildren(beforeParentNode: null, newMemberNode, beforeModel, afterModel, ChangeType.Inserted, wereParentAttributesChanged: attributes != null));
+ }
+ else
+ {
+ // This returns the current member (as changed) topped with its added/deleted/changed attributes.
+ sb.Append(VisitLeafNode(beforeNode: null, newMemberNode, beforeModel, afterModel, ChangeType.Inserted));
+ }
+ }
+ }
+
+ if (sb.Length > 0 || wereParentAttributesChanged || parentChangeType != ChangeType.Unchanged)
+ {
+ if (afterParentNode is not null and not CompilationUnitSyntax)
+ {
+ return GetCodeWrappedByParent(sb.ToString(), afterParentNode, parentChangeType);
+ }
+ else if (beforeParentNode is not null and not CompilationUnitSyntax)
+ {
+ return GetCodeWrappedByParent(sb.ToString(), beforeParentNode, parentChangeType);
+ }
+ }
+
+ return sb.ToString();
+ }
+
+ private ConcurrentDictionary CollectChildrenNodes(SyntaxNode? parentNode, SemanticModel? model)
+ {
+ if (parentNode == null)
+ {
+ return [];
+ }
+
+ Debug.Assert(model != null);
+
+ ConcurrentDictionary dictionary = [];
+
+ if (parentNode is BaseNamespaceDeclarationSyntax)
+ {
+ // Find all types
+ foreach (BaseTypeDeclarationSyntax typeNode in GetMembersOfType(parentNode))
+ {
+ dictionary.TryAdd(GetDocId(typeNode, model), typeNode);
+ }
+
+ // Find all delegates
+ foreach (DelegateDeclarationSyntax delegateNode in GetMembersOfType(parentNode))
+ {
+ dictionary.TryAdd(GetDocId(delegateNode, model), delegateNode);
+ }
+ }
+ else if (parentNode is BaseTypeDeclarationSyntax)
+ {
+ // Special case for records that have members
+ if (parentNode is RecordDeclarationSyntax record && record.Members.Any())
+ {
+ foreach (MemberDeclarationSyntax memberNode in GetMembersOfType(parentNode))
+ {
+ // Note that these could also be nested types
+ dictionary.TryAdd(GetDocId(memberNode, model), memberNode);
+ }
+ }
+ else
+ {
+ foreach (MemberDeclarationSyntax memberNode in GetMembersOfType(parentNode))
+ {
+ dictionary.TryAdd(GetDocId(memberNode, model), memberNode);
+ }
+ }
+ }
+ else if (parentNode is CompilationUnitSyntax)
+ {
+ foreach (BaseNamespaceDeclarationSyntax namespaceNode in parentNode.DescendantNodes().OfType())
+ {
+ dictionary.TryAdd(GetDocId(namespaceNode, model), namespaceNode);
+ }
+ }
+ else
+ {
+ throw new InvalidOperationException($"Unexpected node type '{parentNode.Kind()}'");
+ }
+
+ return dictionary;
+ }
+
+ // Returns the specified leaf node as changed (type, member or namespace) topped with its added/deleted/changed attributes and the correct leading trivia.
+ private string? VisitLeafNode(MemberDeclarationSyntax? beforeNode, MemberDeclarationSyntax? afterNode, SemanticModel? beforeModel, SemanticModel? afterModel, ChangeType changeType)
+ {
+ Debug.Assert(beforeNode != null || afterModel != null);
+ Debug.Assert(beforeNode == null || (beforeNode != null && beforeModel != null));
+ Debug.Assert(afterNode == null || (afterNode != null && afterModel != null));
+
+ StringBuilder sb = new();
+
+ // If the leaf node was added or deleted, the visited attributes will also show as added or deleted.
+ // If the attributes were changed, even if the leaf node wasn't, they should also show up as added+deleted.
+ string? attributes = VisitAttributes(beforeNode, afterNode, beforeModel, afterModel);
+ bool wereAttributesModified = attributes != null;
+ if (wereAttributesModified)
+ {
+ sb.Append(attributes);
+ }
+
+ // The string builder contains the attributes. Now append them on top of the member without their default attributes.
+ MemberDeclarationSyntax? deconstructedBeforeNode = GetNodeWithoutAttributes(beforeNode);
+ MemberDeclarationSyntax? deconstructedAfterNode = GetNodeWithoutAttributes(afterNode);
+
+ Debug.Assert(deconstructedBeforeNode != null || deconstructedAfterNode != null);
+
+ switch (changeType)
+ {
+ case ChangeType.Deleted:
+ Debug.Assert(deconstructedBeforeNode != null);
+ sb.Append(GenerateDeletedDiff(deconstructedBeforeNode));
+ break;
+ case ChangeType.Inserted:
+ Debug.Assert(deconstructedAfterNode != null);
+ sb.Append(GenerateAddedDiff(deconstructedAfterNode));
+ break;
+ case ChangeType.Modified:
+ Debug.Assert(beforeNode != null && afterNode != null);
+ string? changed = GenerateChangedDiff(deconstructedBeforeNode!, deconstructedAfterNode!);
+ // At this stage, the attributes that decorated this API have already been analyzed and appended.
+ // If the API itself also changed, then we can directly append this API's diff under the attributes (if any).
+ if (changed != null)
+ {
+ sb.Append(changed);
+ }
+ // Otherwise, we still need to show the API as unmodified, but only if the attributes also changed.
+ else if (attributes != null)
+ {
+ Debug.Assert(deconstructedAfterNode != null);
+ sb.Append(GenerateUnchangedDiff(deconstructedAfterNode.WithoutTrailingTrivia()));
+ }
+ break;
+ default:
+ throw new NotSupportedException($"Unexpected change type '{changeType}'.");
+ }
+
+ return sb.Length > 0 ? sb.ToString() : null;
+ }
+
+ [return: NotNullIfNotNull(nameof(node))]
+ private MemberDeclarationSyntax? GetNodeWithoutAttributes(MemberDeclarationSyntax? node)
+ {
+ if (node == null)
+ {
+ return null;
+ }
+
+ if (node is EnumMemberDeclarationSyntax enumMember)
+ {
+ SyntaxTriviaList commaTrivia = SyntaxFactory.TriviaList(SyntaxFactory.SyntaxTrivia(SyntaxKind.EndOfLineTrivia, ","));
+ return enumMember
+ .WithAttributeLists(_emptyAttributeList)
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(commaTrivia);
+ }
+ else if (node is BaseNamespaceDeclarationSyntax namespaceNode)
+ {
+ return namespaceNode.WithAttributeLists(_emptyAttributeList).WithLeadingTrivia(node.GetLeadingTrivia());
+ }
+ else if (node is MemberDeclarationSyntax memberDeclaration)
+ {
+ return memberDeclaration.WithAttributeLists(_emptyAttributeList).WithLeadingTrivia(node.GetLeadingTrivia());
+ }
+
+ return node.WithAttributeLists(_emptyAttributeList).WithLeadingTrivia(node.GetLeadingTrivia());
+ }
+
+ // Returns a non-null string if any attribute was changed (added, deleted or modified). Returns null if all attributes were the same before and after.
+ private string? VisitAttributes(MemberDeclarationSyntax? beforeNode, MemberDeclarationSyntax? afterNode, SemanticModel? beforeModel, SemanticModel? afterModel)
+ {
+ Dictionary? beforeAttributeNodes = CollectAttributeNodes(beforeNode, beforeModel);
+ Dictionary? afterAttributeNodes = CollectAttributeNodes(afterNode, afterModel);
+
+ StringBuilder sb = new();
+ if (beforeAttributeNodes != null)
+ {
+ foreach ((string attributeName, AttributeSyntax beforeAttributeNode) in beforeAttributeNodes.OrderBy(x => x.Key))
+ {
+ if (afterAttributeNodes != null &&
+ afterAttributeNodes.TryGetValue(attributeName, out AttributeSyntax? afterAttributeNode))
+ {
+ AttributeListSyntax beforeAttributeList = GetAttributeAsAttributeList(beforeAttributeNode)
+ .WithLeadingTrivia(beforeNode!.GetLeadingTrivia());
+ AttributeListSyntax afterAttributeList = GetAttributeAsAttributeList(afterAttributeNode)
+ .WithLeadingTrivia(afterNode!.GetLeadingTrivia());
+
+ // We found the same after attribute. Retrieve the comparison string.
+ sb.Append(GenerateChangedDiff(beforeAttributeList, afterAttributeList));
+ // Remove the found ones. The remaining ones will be processed at the end because they're new.
+ afterAttributeNodes.Remove(attributeName);
+ }
+ else
+ {
+ // Retrieve the attribute string as removed.
+ AttributeListSyntax beforeAttributeList = GetAttributeAsAttributeList(beforeAttributeNode)
+ .WithLeadingTrivia(beforeNode!.GetLeadingTrivia());
+ sb.Append(GenerateDeletedDiff(beforeAttributeList));
+ }
+ }
+ }
+ // At this point, we may have found changed or deleted attributes (or none).
+ // Check now if there were any added attributes.
+ if (afterAttributeNodes != null)
+ {
+ foreach ((_, AttributeSyntax newAttributeNode) in afterAttributeNodes)
+ {
+ // Retrieve the attribute string as added.
+ AttributeListSyntax newAttributeList = GetAttributeAsAttributeList(newAttributeNode)
+ .WithLeadingTrivia(afterNode!.GetLeadingTrivia());
+ sb.Append(GenerateAddedDiff(newAttributeList));
+ }
+ }
+
+ return sb.Length > 0 ? sb.ToString() : null;
+ }
+
+ // For types, members and namespaces.
+ private Dictionary CollectAttributeNodes(MemberDeclarationSyntax? memberNode, SemanticModel? model)
+ {
+ if (memberNode == null)
+ {
+ return [];
+ }
+
+ Debug.Assert(model != null);
+
+ Dictionary dictionary = [];
+
+ foreach (AttributeListSyntax attributeListNode in memberNode.AttributeLists)
+ {
+ foreach (AttributeSyntax attributeNode in attributeListNode.Attributes)
+ {
+ IMethodSymbol? constructor = model.GetSymbolInfo(attributeNode).Symbol as IMethodSymbol;
+ if (constructor is null || !_attributeSymbolFilter.Include(constructor.ContainingType))
+ {
+ // The attributes decorating an API are actually calls to their
+ // constructor method, but the attribute filter needs type docIDs.
+ continue;
+ }
+ var realAttributeNode = attributeNode.WithArgumentList(attributeNode.ArgumentList);
+
+ if (!dictionary.TryAdd(GetAttributeDocId(constructor, attributeNode, model), realAttributeNode))
+ {
+ _log.LogWarning($"Attribute already exists in dictionary. Attribute: '{realAttributeNode.ToFullString()}', Member: '{GetDocId(memberNode, model)}'");
+ }
+ }
+ }
+
+ return dictionary;
+ }
+
+ private static AttributeListSyntax GetAttributeAsAttributeList(AttributeSyntax attributeNode) =>
+ SyntaxFactory.AttributeList(SyntaxFactory.SeparatedList([attributeNode.WithArgumentList(attributeNode.ArgumentList)]));
+
+ private string GetCodeWrappedByParent(string diffedChildrenCode, SyntaxNode parentNode, ChangeType parentChangeType)
+ {
+ if (parentNode is CompilationUnitSyntax)
+ {
+ // Nothing to wrap, parent is the actual assembly
+ return diffedChildrenCode;
+ }
+
+ MemberDeclarationSyntax? memberParentNode = parentNode as MemberDeclarationSyntax;
+ Debug.Assert(memberParentNode != null);
+
+ StringBuilder sb = new();
+
+ MemberDeclarationSyntax attributelessParentNode = GetNodeWithoutAttributes(memberParentNode);
+ SyntaxNode childlessParentNode = GetChildlessNode(attributelessParentNode);
+
+ SyntaxTriviaList parentLeadingTrivia = parentChangeType == ChangeType.Unchanged ? _twoSpacesTrivia.AddRange(parentNode.GetLeadingTrivia()) : parentNode.GetLeadingTrivia();
+
+ string openingBraceCode = GetDeclarationAndOpeningBraceCode(childlessParentNode, parentLeadingTrivia);
+ string? diffedOpeningBraceCode = GetDiffedCode(openingBraceCode);
+
+ string closingBraceCode = GetClosingBraceCode(childlessParentNode, parentLeadingTrivia);
+ string? diffedClosingBraceCode = GetDiffedCode(closingBraceCode);
+
+ sb.Append(diffedOpeningBraceCode);
+ sb.Append(diffedChildrenCode);
+ sb.Append(diffedClosingBraceCode);
+
+ return sb.ToString();
+
+ string? GetDiffedCode(string codeToDiff)
+ {
+ return parentChangeType switch
+ {
+ ChangeType.Inserted => GenerateAddedDiff(codeToDiff),
+ ChangeType.Deleted => GenerateDeletedDiff(codeToDiff),
+ ChangeType.Unchanged => codeToDiff,
+ _ => throw new InvalidOperationException($"Unexpected change type '{parentChangeType}'."),
+ };
+ }
+ }
+
+ private static SyntaxNode GetChildlessNode(MemberDeclarationSyntax node)
+ {
+ SyntaxNode childlessNode = node.RemoveNodes(node.ChildNodes().Where(
+ c => c is MemberDeclarationSyntax or BaseTypeDeclarationSyntax or DelegateDeclarationSyntax), SyntaxRemoveOptions.KeepNoTrivia)!;
+
+ return childlessNode.WithLeadingTrivia(node.GetLeadingTrivia());
+ }
+
+ private string GetDeclarationAndOpeningBraceCode(SyntaxNode childlessNode, SyntaxTriviaList leadingTrivia)
+ {
+ SyntaxToken openBrace = SyntaxFactory.Token(SyntaxKind.OpenBraceToken)
+ .WithTrailingTrivia(_endOfLineTrivia);
+
+ SyntaxNode unclosedNode;
+
+ if (childlessNode is RecordDeclarationSyntax recordDecl)
+ {
+ if (recordDecl.OpenBraceToken.IsKind(SyntaxKind.None))
+ {
+ unclosedNode = recordDecl;
+ }
+ else
+ {
+ // Add the missing newline after the declaration
+ SyntaxTriviaList endLineAndLeadingTrivia = SyntaxFactory.TriviaList(_endOfLineTrivia).AddRange(leadingTrivia);
+ openBrace = openBrace.WithLeadingTrivia(endLineAndLeadingTrivia);
+ unclosedNode = recordDecl.WithOpenBraceToken(openBrace).WithCloseBraceToken(_missingCloseBrace);
+ }
+ }
+ else
+ {
+ openBrace = openBrace.WithLeadingTrivia(leadingTrivia);
+ unclosedNode = childlessNode switch
+ {
+ BaseTypeDeclarationSyntax typeDecl => typeDecl.WithOpenBraceToken(openBrace)
+ .WithCloseBraceToken(_missingCloseBrace),
+
+ NamespaceDeclarationSyntax nsDecl => nsDecl.WithOpenBraceToken(openBrace)
+ .WithCloseBraceToken(_missingCloseBrace),
+
+ _ => childlessNode
+ };
+ }
+
+ return unclosedNode.WithLeadingTrivia(leadingTrivia).ToFullString();
+ }
+
+ private string GetClosingBraceCode(SyntaxNode childlessNode, SyntaxTriviaList leadingTrivia)
+ {
+ SyntaxToken closeBrace = childlessNode switch
+ {
+ RecordDeclarationSyntax recordDecl => recordDecl.CloseBraceToken.IsMissing ? _missingCloseBrace : recordDecl.CloseBraceToken,
+ BaseTypeDeclarationSyntax typeDecl => typeDecl.CloseBraceToken,
+ NamespaceDeclarationSyntax nsDecl => nsDecl.CloseBraceToken,
+ _ => throw new InvalidOperationException($"Unexpected node type '{childlessNode.Kind()}'")
+ };
+
+ return closeBrace.WithTrailingTrivia(_endOfLineTrivia)
+ .WithLeadingTrivia(leadingTrivia)
+ .ToFullString();
+ }
+
+ private static bool IsEnumMemberOrHasPublicOrProtectedModifierOrIsDestructor(MemberDeclarationSyntax m) =>
+ // Destructors don't have visibility modifiers so they're special-cased
+ m.Modifiers.Any(SyntaxKind.PublicKeyword) || m.Modifiers.Any(SyntaxKind.ProtectedKeyword) ||
+ // Enum member declarations don't have any modifiers
+ m is EnumMemberDeclarationSyntax ||
+ m.IsKind(SyntaxKind.DestructorDeclaration);
+
+ private static IEnumerable GetMembersOfType(SyntaxNode node) where T : MemberDeclarationSyntax => node
+ .ChildNodes()
+ .Where(n => n is T m &&
+ // Interface members have no visibility modifiers
+ (node.IsKind(SyntaxKind.InterfaceDeclaration) ||
+ // For the rest of the types, analyze each member directly
+ IsEnumMemberOrHasPublicOrProtectedModifierOrIsDestructor(m)))
+ .Cast();
+
+ private string GetAttributeDocId(IMethodSymbol attributeConstructorSymbol, AttributeSyntax attribute, SemanticModel model)
+ {
+ Debug.Assert(_attributeSymbolFilter.Include(attributeConstructorSymbol.ContainingType));
+
+ string? attributeDocId = null;
+
+ if (attribute.ArgumentList is AttributeArgumentListSyntax list && list.Arguments.Any())
+ {
+ // If the constructor has arguments, it might be added multiple times on top of an API,
+ // so we need to get the unique full string which includes the actual value of the arguments.
+ attributeDocId = attribute.ToFullString();
+ }
+ else
+ {
+ try
+ {
+ attributeDocId = attributeConstructorSymbol.ContainingType.GetDocumentationCommentId();
+ }
+ catch (Exception e)
+ {
+ _log.LogWarning($"Could not retrieve docId of the attribute constructor of {attribute.ToFullString()}: {e.Message} ");
+ }
+ }
+
+ // Temporary workaround because some rare cases of attributes in WinForms cannot be found in the current model.
+ attributeDocId ??= attribute.ToFullString();
+
+ return attributeDocId;
+ }
+
+ private string GetDocId(SyntaxNode node, SemanticModel model)
+ {
+ ISymbol? symbol = node switch
+ {
+ DestructorDeclarationSyntax destructorDeclaration => model.GetDeclaredSymbol(destructorDeclaration),
+ FieldDeclarationSyntax fieldDeclaration => model.GetDeclaredSymbol(fieldDeclaration.Declaration.Variables.First()),
+ EventDeclarationSyntax eventDeclaration => model.GetDeclaredSymbol(eventDeclaration),
+ EventFieldDeclarationSyntax eventFieldDeclaration => model.GetDeclaredSymbol(eventFieldDeclaration.Declaration.Variables.First()),
+ PropertyDeclarationSyntax propertyDeclaration => model.GetDeclaredSymbol(propertyDeclaration),
+ _ => model.GetDeclaredSymbol(node)
+ };
+
+ if (symbol?.GetDocumentationCommentId() is string docId)
+ {
+ if (node is RecordDeclarationSyntax record && record.ParameterList != null && record.ParameterList.Parameters.Any())
+ {
+ // Special case for when a record has a parameter list, we need to be able to differentiate the signature's parameters too,
+ // but the regular DocId does not differentiate that.
+ return $"{docId}({record.ParameterList.Parameters.ToFullString()})";
+ }
+ return docId;
+ }
+
+ throw new NullReferenceException($"Could not get the DocID for node: {node}");
+ }
+
+ private static string? GenerateAddedDiff(SyntaxNode afterNode) => GenerateAddedDiff(afterNode.ToFullString());
+
+ private static string? GenerateDeletedDiff(SyntaxNode beforeNode) => GenerateDeletedDiff(beforeNode.ToFullString());
+
+ private static string? GenerateChangedDiff(SyntaxNode beforeNode, SyntaxNode afterNode) =>
+ GenerateDiff(InlineDiffBuilder.Diff(oldText: beforeNode.ToFullString(), newText: afterNode.ToFullString()));
+
+ private static string? GenerateAddedDiff(string afterNodeText) =>
+ GenerateDiff(InlineDiffBuilder.Diff(oldText: string.Empty, newText: afterNodeText));
+
+ private static string? GenerateDeletedDiff(string beforeNodeText) =>
+ GenerateDiff(InlineDiffBuilder.Diff(oldText:beforeNodeText, newText: string.Empty));
+
+ private static string? GenerateUnchangedDiff(SyntaxNode unchangedNode)
+ {
+ StringBuilder sb = new();
+ string unchangedText = unchangedNode.ToFullString();
+ foreach (var line in InlineDiffBuilder.Diff(oldText: unchangedText, newText: unchangedText).Lines)
+ {
+ sb.AppendLine($" {line.Text}");
+ }
+ return sb.ToString();
+ }
+
+ private static string? GenerateDiff(DiffPaneModel diff)
+ {
+ StringBuilder sb = new();
+
+ foreach (var line in diff.Lines)
+ {
+ if (string.IsNullOrWhiteSpace(line.Text))
+ {
+ continue;
+ }
+ switch (line.Type)
+ {
+ case ChangeType.Inserted:
+ sb.AppendLine($"+ {line.Text}");
+ break;
+ case ChangeType.Deleted:
+ sb.AppendLine($"- {line.Text}");
+ break;
+ default:
+ break;
+ }
+ }
+ return sb.Length == 0 ? null : sb.ToString();
+ }
+
+ private class ChildrenNodesComparer : IComparer>
+ {
+ public int Compare(KeyValuePair first, KeyValuePair second)
+ {
+ // Enum members need to be sorted by their value, not alphabetically, so they need to be special-cased.
+ if (first.Value is EnumMemberDeclarationSyntax beforeMember && second.Value is EnumMemberDeclarationSyntax afterMember &&
+ beforeMember.EqualsValue is EqualsValueClauseSyntax beforeEVCS && afterMember.EqualsValue is EqualsValueClauseSyntax afterEVCS &&
+ beforeEVCS.Value is LiteralExpressionSyntax beforeLes && afterEVCS.Value is LiteralExpressionSyntax afterLes)
+ {
+ return beforeLes.Token.ValueText.CompareTo(afterLes.Token.ValueText);
+ }
+
+ // Everything else is shown alphabetically.
+ return first.Key.CompareTo(second.Key);
+ }
+
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/Microsoft.DotNet.ApiDiff.csproj b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/Microsoft.DotNet.ApiDiff.csproj
new file mode 100644
index 000000000000..7dc8391ede44
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/Microsoft.DotNet.ApiDiff.csproj
@@ -0,0 +1,19 @@
+
+
+
+ $(NetToolMinimum)
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/GlobalPrefixRemover.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/GlobalPrefixRemover.cs
new file mode 100644
index 000000000000..74b1fdd5d3b1
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/GlobalPrefixRemover.cs
@@ -0,0 +1,25 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.DotNet.ApiDiff.SyntaxRewriter;
+
+internal class GlobalPrefixRemover : CSharpSyntaxRewriter
+{
+ public static readonly GlobalPrefixRemover Singleton = new();
+
+ private const string GlobalPrefix = "global";
+
+ public override SyntaxNode? VisitQualifiedName(QualifiedNameSyntax node)
+ {
+ if (node.Left is AliasQualifiedNameSyntax alias &&
+ alias.Alias.Identifier.Text == GlobalPrefix)
+ {
+ node = SyntaxFactory.QualifiedName(alias.Name, node.Right).WithTriviaFrom(node);
+ }
+ return base.VisitQualifiedName(node);
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/PrimitiveSimplificationRewriter.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/PrimitiveSimplificationRewriter.cs
new file mode 100644
index 000000000000..8a48e3d6f990
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/PrimitiveSimplificationRewriter.cs
@@ -0,0 +1,56 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.DotNet.ApiDiff.SyntaxRewriter;
+
+internal class PrimitiveSimplificationRewriter : CSharpSyntaxRewriter
+{
+ public static readonly PrimitiveSimplificationRewriter Singleton = new();
+
+ private static readonly Dictionary s_primitives = new() {
+ { "Boolean", SyntaxKind.BoolKeyword },
+ { "Byte", SyntaxKind.ByteKeyword },
+ { "Char", SyntaxKind.CharKeyword },
+ { "Decimal", SyntaxKind.DecimalKeyword },
+ { "Double", SyntaxKind.DoubleKeyword },
+ { "Int16", SyntaxKind.ShortKeyword },
+ { "Int32", SyntaxKind.IntKeyword },
+ { "Int64", SyntaxKind.LongKeyword },
+ { "Object", SyntaxKind.ObjectKeyword },
+ { "SByte", SyntaxKind.SByteKeyword },
+ { "Single", SyntaxKind.FloatKeyword },
+ { "String", SyntaxKind.StringKeyword },
+ { "UInt16", SyntaxKind.UShortKeyword },
+ { "UInt32", SyntaxKind.UIntKeyword },
+ { "UInt64", SyntaxKind.ULongKeyword },
+ { "System.Boolean", SyntaxKind.BoolKeyword },
+ { "System.Byte", SyntaxKind.ByteKeyword },
+ { "System.Char", SyntaxKind.CharKeyword },
+ { "System.Decimal", SyntaxKind.DecimalKeyword },
+ { "System.Double", SyntaxKind.DoubleKeyword },
+ { "System.Int16", SyntaxKind.ShortKeyword },
+ { "System.Int32", SyntaxKind.IntKeyword },
+ { "System.Int64", SyntaxKind.LongKeyword },
+ { "System.Object", SyntaxKind.ObjectKeyword },
+ { "System.SByte", SyntaxKind.SByteKeyword },
+ { "System.Single", SyntaxKind.FloatKeyword },
+ { "System.String", SyntaxKind.StringKeyword },
+ { "System.UInt16", SyntaxKind.UShortKeyword },
+ { "System.UInt32", SyntaxKind.UIntKeyword },
+ { "System.UInt64", SyntaxKind.ULongKeyword },
+ };
+
+ public override SyntaxNode? VisitQualifiedName(QualifiedNameSyntax node)
+ {
+ if (s_primitives.TryGetValue(node.Right.Identifier.Text, out SyntaxKind keyword))
+ {
+ return SyntaxFactory.PredefinedType(SyntaxFactory.Token(keyword)).WithTriviaFrom(node);
+ }
+
+ return base.VisitQualifiedName(node);
+ }
+}
diff --git a/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/RemoveBodySyntaxRewriter.cs b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/RemoveBodySyntaxRewriter.cs
new file mode 100644
index 000000000000..62fb53acbd54
--- /dev/null
+++ b/src/Compatibility/ApiDiff/Microsoft.DotNet.ApiDiff/SyntaxRewriter/RemoveBodySyntaxRewriter.cs
@@ -0,0 +1,161 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.CSharp.Syntax;
+
+namespace Microsoft.DotNet.ApiDiff.SyntaxRewriter;
+
+///
+/// Replaces the bodies of nodes that have one (methods, properties, events, etc.) with a semicolon.
+///
+internal class RemoveBodyCSharpSyntaxRewriter : CSharpSyntaxRewriter
+{
+ public static readonly RemoveBodyCSharpSyntaxRewriter Singleton = new();
+
+ private readonly SyntaxToken _semiColonToken = SyntaxFactory.Token(SyntaxKind.SemicolonToken);
+ private readonly SyntaxToken _noneToken = SyntaxFactory.Token(SyntaxKind.None);
+ private readonly SyntaxTriviaList _endLineTrivia = SyntaxFactory.TriviaList((Environment.NewLine == "\r\n") ? SyntaxFactory.CarriageReturnLineFeed : SyntaxFactory.LineFeed);
+
+ public override SyntaxNode? VisitConstructorDeclaration(ConstructorDeclarationSyntax node)
+ {
+ var result = node
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutLeadingTrivia()
+ .WithoutTrailingTrivia() // Remove the single space that follows this new declaration
+ .WithSemicolonToken(SyntaxFactory.Token(SyntaxKind.SemicolonToken))
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(node.GetTrailingTrivia());
+ return base.VisitConstructorDeclaration(result);
+ }
+
+ // These bad boys look like: 'public static explicit operator int(MyClass value)'
+ public override SyntaxNode? VisitConversionOperatorDeclaration(ConversionOperatorDeclarationSyntax node)
+ {
+ var result = node
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutLeadingTrivia()
+ .WithoutTrailingTrivia() // Remove the single space that follows this new declaration
+ .WithSemicolonToken(_semiColonToken)
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(node.GetTrailingTrivia());
+ return base.VisitConversionOperatorDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitDestructorDeclaration(DestructorDeclarationSyntax node)
+ {
+ var result = node
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutLeadingTrivia()
+ .WithoutTrailingTrivia() // Remove the single space that follows this new declaration
+ .WithSemicolonToken(_semiColonToken)
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(node.GetTrailingTrivia());
+ return base.VisitDestructorDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitEventDeclaration(EventDeclarationSyntax node)
+ {
+ var result = node
+ .WithIdentifier(node.Identifier.WithoutTrivia())
+ .WithAccessorList(GetEmptiedAccessors(node.AccessorList));
+ return base.VisitEventDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node)
+ {
+ var result = node
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutLeadingTrivia()
+ .WithoutTrailingTrivia() // Remove the single space that follows this new declaration
+ .WithSemicolonToken(_semiColonToken)
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(node.GetTrailingTrivia());
+ return base.VisitMethodDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitOperatorDeclaration(OperatorDeclarationSyntax node)
+ {
+ if (node.OperatorToken.IsKind(SyntaxKind.GreaterThanToken))
+ {
+ // Takes care of the missing space before the greater than
+ node = node.WithOperatorToken(SyntaxFactory.Token(SyntaxFactory.TriviaList(SyntaxFactory.Space), node.OperatorToken.Kind(), SyntaxTriviaList.Empty));
+ }
+ var result = node
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutLeadingTrivia()
+ .WithoutTrailingTrivia() // Remove the single space that follows this new declaration
+ .WithSemicolonToken(_semiColonToken)
+ .WithLeadingTrivia(node.GetLeadingTrivia())
+ .WithTrailingTrivia(node.GetTrailingTrivia());
+ return base.VisitOperatorDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitPropertyDeclaration(PropertyDeclarationSyntax node)
+ {
+ var result = node.WithAccessorList(GetEmptiedAccessors(node.AccessorList));
+ return base.VisitPropertyDeclaration(result);
+ }
+
+ public override SyntaxNode? VisitRecordDeclaration(RecordDeclarationSyntax node)
+ {
+ if (node.Members.Any())
+ {
+ // Fix the spaces and lack of newline
+ var replacedOpenBrace = SyntaxFactory.Token(_endLineTrivia.AddRange(node.GetLeadingTrivia()), SyntaxKind.OpenBraceToken, node.OpenBraceToken.TrailingTrivia);
+ node = node.WithOpenBraceToken(replacedOpenBrace);
+ }
+ else
+ {
+ // Replace braces with semicolon
+ node = node.WithOpenBraceToken(_noneToken)
+ .WithCloseBraceToken(_noneToken)
+ .WithSemicolonToken(_semiColonToken);
+ }
+
+ if (node.ParameterList != null && node.ParameterList.Parameters.Any())
+ {
+ // Fix the extra space
+ node = node.WithParameterList(node.ParameterList.WithoutTrailingTrivia());
+ }
+ else
+ {
+ // Remove the parentheses if there are no arguments
+ node = node.WithParameterList(null);
+ }
+
+ return base.VisitRecordDeclaration(node);
+ }
+
+ private AccessorListSyntax? GetEmptiedAccessors(AccessorListSyntax? accessorList)
+ {
+ if (accessorList == null)
+ {
+ return null;
+ }
+
+ List newAccessors = new();
+
+
+ for (int i = 0; i < accessorList.Accessors.Count; i++)
+ {
+ AccessorDeclarationSyntax accessorDeclaration = accessorList.Accessors[i]
+ .WithBody(null) // remove the default empty body wrapped by brackets
+ .WithoutTrivia() // Important
+ .WithLeadingTrivia(SyntaxFactory.Space) // Add a space before the accessor
+ .WithSemicolonToken(_semiColonToken); // Append a semicolon at the end
+
+ if (i == accessorList.Accessors.Count - 1) // Second to last
+ {
+ // Add a space after the semicolon only on the last accessor
+ accessorDeclaration = accessorDeclaration.WithTrailingTrivia(SyntaxFactory.Space);
+ }
+
+ newAccessors.Add(accessorDeclaration);
+ }
+
+ return SyntaxFactory.AccessorList(SyntaxFactory.List(newAccessors)).WithLeadingTrivia(SyntaxFactory.Space);
+ }
+
+}
diff --git a/src/Compatibility/ApiDiff/apidiff.slnf b/src/Compatibility/ApiDiff/apidiff.slnf
new file mode 100644
index 000000000000..44b080591de5
--- /dev/null
+++ b/src/Compatibility/ApiDiff/apidiff.slnf
@@ -0,0 +1,11 @@
+{
+ "solution": {
+ "path": "..\\..\\..\\sdk.sln",
+ "projects": [
+ "src\\Compatibility\\ApiDiff\\Microsoft.DotNet.ApiDiff\\Microsoft.DotNet.ApiDiff.csproj",
+ "src\\Compatibility\\ApiDiff\\Microsoft.DotNet.ApiDiff.Tool\\Microsoft.DotNet.ApiDiff.Tool.csproj",
+ "src\\Compatibility\\Microsoft.DotNet.ApiSymbolExtensions\\Microsoft.DotNet.ApiSymbolExtensions.csproj",
+ "test\\Microsoft.DotNet.ApiDiff.Tests\\Microsoft.DotNet.ApiDiff.Tests.csproj"
+ ]
+ }
+}
diff --git a/src/Compatibility/Directory.Packages.props b/src/Compatibility/Directory.Packages.props
index 9898fda73ffd..be120d4cea62 100644
--- a/src/Compatibility/Directory.Packages.props
+++ b/src/Compatibility/Directory.Packages.props
@@ -8,8 +8,4 @@
-
-
-
-
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGenerator.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGenerator.cs
index 7df552851bd3..f22d09f2b7f7 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGenerator.cs
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGenerator.cs
@@ -4,7 +4,9 @@
using System.Collections.Immutable;
using System.Reflection;
using System.Runtime.CompilerServices;
+using System.Xml.Linq;
using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CodeStyle;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Formatting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
@@ -13,9 +15,7 @@
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Simplification;
using Microsoft.DotNet.ApiSymbolExtensions;
-using Microsoft.DotNet.ApiSymbolExtensions.Filtering;
using Microsoft.DotNet.ApiSymbolExtensions.Logging;
-using Microsoft.DotNet.GenAPI.SyntaxRewriter;
namespace Microsoft.DotNet.GenAPI;
@@ -25,58 +25,28 @@ namespace Microsoft.DotNet.GenAPI;
public sealed class CSharpAssemblyDocumentGenerator
{
private readonly ILog _log;
- private readonly IAssemblySymbolLoader _loader;
- private readonly ISymbolFilter _symbolFilter;
- private readonly ISymbolFilter _attributeDataSymbolFilter;
- private readonly string? _exceptionMessage;
- private readonly bool _includeAssemblyAttributes;
+ private readonly CSharpAssemblyDocumentGeneratorOptions _options;
private readonly AdhocWorkspace _adhocWorkspace;
private readonly SyntaxGenerator _syntaxGenerator;
- private readonly IEnumerable? _metadataReferences;
- private readonly bool _addPartialModifier;
- private readonly bool _hideImplicitDefaultConstructors;
private readonly CSharpCompilationOptions _compilationOptions;
///
/// Initializes a new instance of the class.
///
/// The logger to use.
- /// The assembly symbol loader to use.
- /// The symbol filter to use.
- /// The attribute data symbol filter to use.
- /// The optional exception message to use.
- /// Whether to include assembly attributes or not.
- /// The metadata references to use. The default value is .
- /// The optional diagnostic options to use. The default value is .
- /// Whether to add the partial modifier or not. The default value is .
- /// Whether to hide implicit default constructors or not. The default value is .
- public CSharpAssemblyDocumentGenerator(ILog log,
- IAssemblySymbolLoader loader,
- ISymbolFilter symbolFilter,
- ISymbolFilter attributeDataSymbolFilter,
- string? exceptionMessage,
- bool includeAssemblyAttributes,
- IEnumerable? metadataReferences = null,
- IEnumerable>? diagnosticOptions = null,
- bool addPartialModifier = true,
- bool hideImplicitDefaultConstructors = true)
+ /// The options to configure the generator.
+ public CSharpAssemblyDocumentGenerator(ILog log, CSharpAssemblyDocumentGeneratorOptions options)
{
_log = log;
- _loader = loader;
- _symbolFilter = symbolFilter;
- _attributeDataSymbolFilter = attributeDataSymbolFilter;
- _exceptionMessage = exceptionMessage;
- _includeAssemblyAttributes = includeAssemblyAttributes;
+ _options = options;
+
_adhocWorkspace = new AdhocWorkspace();
_syntaxGenerator = SyntaxGenerator.GetGenerator(_adhocWorkspace, LanguageNames.CSharp);
- _metadataReferences = metadataReferences;
- _addPartialModifier = addPartialModifier;
- _hideImplicitDefaultConstructors = hideImplicitDefaultConstructors;
_compilationOptions = new CSharpCompilationOptions(
OutputKind.DynamicallyLinkedLibrary,
nullableContextOptions: NullableContextOptions.Enable,
- specificDiagnosticOptions: diagnosticOptions);
+ specificDiagnosticOptions: _options.DiagnosticOptions);
}
///
@@ -84,16 +54,16 @@ public CSharpAssemblyDocumentGenerator(ILog log,
///
/// The assembly symbol that represents the loaded assembly.
/// The source code document instance of the specified assembly symbol.
- public Document GetDocumentForAssembly(IAssemblySymbol assemblySymbol)
+ public async Task GetDocumentForAssemblyAsync(IAssemblySymbol assemblySymbol)
{
Project project = _adhocWorkspace.AddProject(ProjectInfo.Create(
ProjectId.CreateNewId(), VersionStamp.Create(), assemblySymbol.Name, assemblySymbol.Name, LanguageNames.CSharp,
compilationOptions: _compilationOptions));
- project = project.AddMetadataReferences(_metadataReferences ?? _loader.MetadataReferences);
+ project = project.AddMetadataReferences(_options.MetadataReferences ?? _options.Loader.MetadataReferences);
- IEnumerable namespaceSymbols = EnumerateNamespaces(assemblySymbol).Where(_symbolFilter.Include);
- List namespaceSyntaxNodes = [];
+ IEnumerable namespaceSymbols = EnumerateNamespaces(assemblySymbol).Where(_options.SymbolFilter.Include);
+ List namespaceSyntaxNodes = [];
foreach (INamespaceSymbol namespaceSymbol in namespaceSymbols.Order())
{
SyntaxNode? syntaxNode = Visit(namespaceSymbol);
@@ -104,38 +74,48 @@ public Document GetDocumentForAssembly(IAssemblySymbol assemblySymbol)
}
}
- SyntaxNode compilationUnit = _syntaxGenerator.CompilationUnit(namespaceSyntaxNodes)
- .WithAdditionalAnnotations(Formatter.Annotation, Simplifier.Annotation)
- .Rewrite(new TypeDeclarationCSharpSyntaxRewriter(_addPartialModifier))
- .Rewrite(new BodyBlockCSharpSyntaxRewriter(_exceptionMessage));
+ SyntaxNode compilationUnit = _syntaxGenerator.CompilationUnit(namespaceSyntaxNodes);
- if (_includeAssemblyAttributes)
+ if (_options.AdditionalAnnotations.Any())
+ {
+ compilationUnit = compilationUnit.WithAdditionalAnnotations(_options.AdditionalAnnotations);
+ }
+
+ if (_options.IncludeAssemblyAttributes)
{
compilationUnit = GenerateAssemblyAttributes(assemblySymbol, compilationUnit);
}
+ // This depends on finding attribute by their fully qualified names, so do not rewrite the syntax tree yet.
compilationUnit = GenerateForwardedTypeAssemblyAttributes(assemblySymbol, compilationUnit);
compilationUnit = compilationUnit.NormalizeWhitespace(eol: Environment.NewLine);
+ // Rewrite after performing all the necessary compilationUnit alterations,
+ // but right before generating the final document.
+ foreach (CSharpSyntaxRewriter rewriter in _options.SyntaxRewriters)
+ {
+ compilationUnit = compilationUnit.Rewrite(rewriter);
+ }
+
Document document = project.AddDocument(assemblySymbol.Name, compilationUnit);
- document = Simplifier.ReduceAsync(document).Result;
- document = Formatter.FormatAsync(document, DefineFormattingOptions()).Result;
+
+ if (_options.ShouldReduce)
+ {
+ document = await Simplifier.ReduceAsync(document).ConfigureAwait(false);
+ }
+ if (_options.ShouldFormat)
+ {
+ document = await Formatter.FormatAsync(document, DefineFormattingOptions()).ConfigureAwait(false);
+ }
return document;
}
- ///
- /// Returns the formatted root syntax node for the specified document.
- ///
- /// A source code document instance.
- /// The root syntax node of the specified document.
- public SyntaxNode GetFormattedRootNodeForDocument(Document document) => document.GetSyntaxRootAsync().Result!.Rewrite(new SingleLineStatementCSharpSyntaxRewriter());
-
private SyntaxNode? Visit(INamespaceSymbol namespaceSymbol)
{
SyntaxNode namespaceNode = _syntaxGenerator.NamespaceDeclaration(namespaceSymbol.ToDisplayString());
- IEnumerable typeMembers = namespaceSymbol.GetTypeMembers().Where(_symbolFilter.Include);
+ IEnumerable typeMembers = namespaceSymbol.GetTypeMembers().Where(_options.SymbolFilter.Include);
if (!typeMembers.Any())
{
return null;
@@ -144,8 +124,8 @@ public Document GetDocumentForAssembly(IAssemblySymbol assemblySymbol)
foreach (INamedTypeSymbol typeMember in typeMembers.Order())
{
SyntaxNode typeDeclaration = _syntaxGenerator
- .DeclarationExt(typeMember, _symbolFilter)
- .AddMemberAttributes(_syntaxGenerator, typeMember, _attributeDataSymbolFilter);
+ .DeclarationExt(typeMember, _options.SymbolFilter)
+ .AddMemberAttributes(_syntaxGenerator, typeMember, _options.AttributeSymbolFilter);
typeDeclaration = Visit(typeDeclaration, typeMember);
@@ -180,7 +160,7 @@ private bool HidesBaseMember(ISymbol member)
// If they're methods, compare their names and signatures.
return baseType.GetMembers(member.Name)
- .Any(baseMember => _symbolFilter.Include(baseMember) &&
+ .Any(baseMember => _options.SymbolFilter.Include(baseMember) &&
(baseMember.Kind != SymbolKind.Method ||
method.SignatureEquals((IMethodSymbol)baseMember)));
}
@@ -189,7 +169,7 @@ private bool HidesBaseMember(ISymbol member)
// If they're indexers, compare their signatures.
return baseType.GetMembers(member.Name)
.Any(baseMember => baseMember is IPropertySymbol baseProperty &&
- _symbolFilter.Include(baseMember) &&
+ _options.SymbolFilter.Include(baseMember) &&
(prop.GetMethod.SignatureEquals(baseProperty.GetMethod) ||
prop.SetMethod.SignatureEquals(baseProperty.SetMethod)));
}
@@ -197,21 +177,21 @@ private bool HidesBaseMember(ISymbol member)
{
// For all other kinds of members, compare their names.
return baseType.GetMembers(member.Name)
- .Any(_symbolFilter.Include);
+ .Any(_options.SymbolFilter.Include);
}
}
private SyntaxNode Visit(SyntaxNode namedTypeNode, INamedTypeSymbol namedType)
{
- IEnumerable members = namedType.GetMembers().Where(_symbolFilter.Include);
+ IEnumerable members = namedType.GetMembers().Where(_options.SymbolFilter.Include);
// If it's a value type
if (namedType.TypeKind == TypeKind.Struct)
{
- namedTypeNode = _syntaxGenerator.AddMembers(namedTypeNode, namedType.SynthesizeDummyFields(_symbolFilter, _attributeDataSymbolFilter));
+ namedTypeNode = _syntaxGenerator.AddMembers(namedTypeNode, namedType.SynthesizeDummyFields(_options.SymbolFilter, _options.AttributeSymbolFilter));
}
- namedTypeNode = _syntaxGenerator.AddMembers(namedTypeNode, namedType.TryGetInternalDefaultConstructor(_symbolFilter));
+ namedTypeNode = _syntaxGenerator.AddMembers(namedTypeNode, namedType.TryGetInternalDefaultConstructor(_options.SymbolFilter));
foreach (ISymbol member in members.Order())
{
@@ -219,15 +199,15 @@ private SyntaxNode Visit(SyntaxNode namedTypeNode, INamedTypeSymbol namedType)
{
// If the method is ExplicitInterfaceImplementation and is derived from an interface that was filtered out, we must filter it out as well.
if (method.MethodKind == MethodKind.ExplicitInterfaceImplementation &&
- method.ExplicitInterfaceImplementations.Any(m => !_symbolFilter.Include(m.ContainingSymbol) ||
+ method.ExplicitInterfaceImplementations.Any(m => !_options.SymbolFilter.Include(m.ContainingSymbol) ||
// if explicit interface implementation method has inaccessible type argument
- m.ContainingType.HasInaccessibleTypeArgument(_symbolFilter)))
+ m.ContainingType.HasInaccessibleTypeArgument(_options.SymbolFilter)))
{
continue;
}
// Filter out default constructors since these will be added automatically
- if (_hideImplicitDefaultConstructors && method.IsImplicitDefaultConstructor(_symbolFilter))
+ if (_options.HideImplicitDefaultConstructors && method.IsImplicitDefaultConstructor(_options.SymbolFilter))
{
continue;
}
@@ -235,14 +215,14 @@ private SyntaxNode Visit(SyntaxNode namedTypeNode, INamedTypeSymbol namedType)
// If the property is derived from an interface that was filtered out, we must not filter it out either.
if (member is IPropertySymbol property && !property.ExplicitInterfaceImplementations.IsEmpty &&
- property.ExplicitInterfaceImplementations.Any(m => !_symbolFilter.Include(m.ContainingSymbol)))
+ property.ExplicitInterfaceImplementations.Any(m => !_options.SymbolFilter.Include(m.ContainingSymbol)))
{
continue;
}
SyntaxNode memberDeclaration = _syntaxGenerator
- .DeclarationExt(member, _symbolFilter)
- .AddMemberAttributes(_syntaxGenerator, member, _attributeDataSymbolFilter);
+ .DeclarationExt(member, _options.SymbolFilter)
+ .AddMemberAttributes(_syntaxGenerator, member, _options.AttributeSymbolFilter);
if (member is INamedTypeSymbol nestedTypeSymbol)
{
@@ -275,14 +255,13 @@ private SyntaxNode Visit(SyntaxNode namedTypeNode, INamedTypeSymbol namedType)
private SyntaxNode GenerateAssemblyAttributes(IAssemblySymbol assembly, SyntaxNode compilationUnit)
{
// When assembly references aren't available, assembly attributes with foreign types won't be resolved.
- ImmutableArray attributes = assembly.GetAttributes().ExcludeNonVisibleOutsideOfAssembly(_attributeDataSymbolFilter);
+ ImmutableArray attributes = assembly.GetAttributes().ExcludeNonVisibleOutsideOfAssembly(_options.AttributeSymbolFilter);
// Emit assembly attributes from the IAssemblySymbol
- List attributeSyntaxNodes = attributes
+ List attributeSyntaxNodes = [.. attributes
.Where(attribute => !attribute.IsReserved())
.Select(attribute => _syntaxGenerator.Attribute(attribute)
- .WithTrailingTrivia(SyntaxFactory.LineFeed))
- .ToList();
+ .WithTrailingTrivia(SyntaxFactory.LineFeed))];
// [assembly: System.Reflection.AssemblyVersion("x.x.x.x")]
if (attributes.All(attribute => attribute.AttributeClass?.ToDisplayString() != typeof(AssemblyVersionAttribute).FullName))
@@ -312,7 +291,7 @@ private SyntaxNode GenerateAssemblyAttributes(IAssemblySymbol assembly, SyntaxNo
private SyntaxNode GenerateForwardedTypeAssemblyAttributes(IAssemblySymbol assembly, SyntaxNode compilationUnit)
{
- foreach (INamedTypeSymbol symbol in assembly.GetForwardedTypes().Where(_symbolFilter.Include))
+ foreach (INamedTypeSymbol symbol in assembly.GetForwardedTypes().Where(_options.SymbolFilter.Include))
{
if (symbol.TypeKind != TypeKind.Error)
{
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGeneratorOptions.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGeneratorOptions.cs
new file mode 100644
index 000000000000..1ad7af0208da
--- /dev/null
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpAssemblyDocumentGeneratorOptions.cs
@@ -0,0 +1,36 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Formatting;
+using Microsoft.CodeAnalysis.Simplification;
+using Microsoft.DotNet.ApiSymbolExtensions;
+using Microsoft.DotNet.ApiSymbolExtensions.Filtering;
+
+namespace Microsoft.DotNet.GenAPI;
+
+///
+/// Options for generating C# assembly documents with , allowing customization of various aspects of the generation process.
+///
+public sealed class CSharpAssemblyDocumentGeneratorOptions
+{
+ public CSharpAssemblyDocumentGeneratorOptions(IAssemblySymbolLoader loader, ISymbolFilter symbolFilter, ISymbolFilter attributeSymbolFilter)
+ {
+ Loader = loader;
+ SymbolFilter = symbolFilter;
+ AttributeSymbolFilter = attributeSymbolFilter;
+ }
+
+ public IAssemblySymbolLoader Loader { get; set; }
+ public ISymbolFilter SymbolFilter { get; set; }
+ public ISymbolFilter AttributeSymbolFilter { get; set; }
+ public bool HideImplicitDefaultConstructors { get; set; }
+ public bool IncludeAssemblyAttributes { get; set; }
+ public bool ShouldFormat { get; set; }
+ public bool ShouldReduce { get; set; }
+ public IEnumerable>? DiagnosticOptions { get; set; }
+ public IEnumerable? MetadataReferences { get; set; }
+ public List SyntaxRewriters { get; set; } = [];
+ public List AdditionalAnnotations { get; set; } = [Formatter.Annotation, Simplifier.Annotation];
+}
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpFileBuilder.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpFileBuilder.cs
index ee5fe5b29b86..d72ae859b89e 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpFileBuilder.cs
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/CSharpFileBuilder.cs
@@ -8,6 +8,7 @@
using Microsoft.DotNet.ApiSymbolExtensions;
using Microsoft.DotNet.ApiSymbolExtensions.Filtering;
using Microsoft.DotNet.ApiSymbolExtensions.Logging;
+using Microsoft.DotNet.GenAPI.SyntaxRewriter;
namespace Microsoft.DotNet.GenAPI
{
@@ -45,15 +46,31 @@ public CSharpFileBuilder(ILog log,
{
_textWriter = textWriter;
_header = header;
- _docGenerator = new CSharpAssemblyDocumentGenerator(log, loader, symbolFilter, attributeDataSymbolFilter, exceptionMessage, includeAssemblyAttributes, metadataReferences, addPartialModifier: addPartialModifier);
+
+ CSharpAssemblyDocumentGeneratorOptions options = new(loader, symbolFilter, attributeDataSymbolFilter)
+ {
+ HideImplicitDefaultConstructors = true,
+ ShouldFormat = true,
+ ShouldReduce = true,
+ IncludeAssemblyAttributes = includeAssemblyAttributes,
+ MetadataReferences = metadataReferences,
+ SyntaxRewriters = [
+ new TypeDeclarationCSharpSyntaxRewriter(addPartialModifier),
+ new BodyBlockCSharpSyntaxRewriter(exceptionMessage),
+ SingleLineStatementCSharpSyntaxRewriter.Singleton
+ ]
+ };
+
+ _docGenerator = new CSharpAssemblyDocumentGenerator(log, options);
}
///
public void WriteAssembly(IAssemblySymbol assemblySymbol)
{
_textWriter.Write(GetFormattedHeader(_header));
- Document document = _docGenerator.GetDocumentForAssembly(assemblySymbol);
- _docGenerator.GetFormattedRootNodeForDocument(document).WriteTo(_textWriter);
+ Document document = _docGenerator.GetDocumentForAssemblyAsync(assemblySymbol).Result;
+ SyntaxNode root = document.GetSyntaxRootAsync().Result ?? throw new InvalidOperationException(Resources.SyntaxNodeNotFound);
+ root.WriteTo(_textWriter);
}
private static string GetFormattedHeader(string? customHeader)
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/GenAPIApp.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/GenAPIApp.cs
index d11a9631be15..25ef58d6481b 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/GenAPIApp.cs
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/GenAPIApp.cs
@@ -35,6 +35,7 @@ public static void Run(ILog log,
log,
assembliesPaths,
assemblyReferencesPaths,
+ assembliesToExclude: [],
respectInternals: respectInternals);
Run(log,
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/Resources.resx b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/Resources.resx
index e1f8c81a3f6d..d57c7e2a29b9 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/Resources.resx
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/Resources.resx
@@ -123,4 +123,7 @@
Could not resolve type '{0}' in containing assembly '{1}' via type forward. Make sure that the assembly is provided as a reference and contains the type.
-
\ No newline at end of file
+
+ Syntax node not found.
+
+
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxGeneratorExtensions.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxGeneratorExtensions.cs
index 6618d47a20b1..d4938f265c35 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxGeneratorExtensions.cs
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxGeneratorExtensions.cs
@@ -105,7 +105,7 @@ public static SyntaxNode DeclarationExt(this SyntaxGenerator syntaxGenerator, IS
catch (ArgumentException ex)
{
// re-throw the ArgumentException with the symbol that caused it.
- throw new ArgumentException(ex.Message, symbol.ToDisplayString());
+ throw new ArgumentException(ex.Message, symbol.ToDisplayString(), innerException: ex);
}
}
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxRewriter/SingleLineStatementCSharpSyntaxRewriter.cs b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxRewriter/SingleLineStatementCSharpSyntaxRewriter.cs
index a002d48663b1..66990ea85300 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxRewriter/SingleLineStatementCSharpSyntaxRewriter.cs
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/SyntaxRewriter/SingleLineStatementCSharpSyntaxRewriter.cs
@@ -17,6 +17,13 @@ namespace Microsoft.DotNet.GenAPI.SyntaxRewriter
///
public class SingleLineStatementCSharpSyntaxRewriter : CSharpSyntaxRewriter
{
+ // Use the singleton instead.
+ private SingleLineStatementCSharpSyntaxRewriter()
+ {
+ }
+
+ public static readonly SingleLineStatementCSharpSyntaxRewriter Singleton = new();
+
///
public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node)
=> VisitBaseMethodDeclarationSyntax(node);
diff --git a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/xlf/Resources.cs.xlf b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/xlf/Resources.cs.xlf
index cf42d11103aa..c0cbbea14d49 100644
--- a/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/xlf/Resources.cs.xlf
+++ b/src/Compatibility/GenAPI/Microsoft.DotNet.GenAPI/xlf/Resources.cs.xlf
@@ -12,6 +12,11 @@
Nepodařilo se přeložit typ {0} v sestavení {1}, které ho obsahuje, pomocí předávání typů. Ujistěte se, že sestavení je uvedeno jako odkaz a obsahuje typ.
+
+ Syntax node not found.
+ Syntax node not found.
+
+