From 29c2dd1af82e490ee322defb285c5cb131e43222 Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 11 Aug 2026 06:29:56 +0200 Subject: [PATCH 1/2] EditorConfig Support --- README.md | 9 ++-- src/FakeAnalyzers/DiagnosticDescriptors.cs | 8 ++++ src/FakeAnalyzers/DiagnosticIds.cs | 1 + .../EditorConfigOptionAnalyzer.cs | 35 ++++++++++++++ src/FakeAnalyzers/TestFlagAnalyzer.cs | 4 +- .../AnalyzerConfigOptionsFactory.cs | 47 +++++++++++++++---- .../BaseAnalyzerTest.cs | 2 +- .../BaseCompilationTest.cs | 27 ++++++++++- .../CompilationExtensions.cs | 9 +++- .../SourceGeneratorTest.cs | 2 +- src/Tests/Analyzers/TestFlagAnalyzerTests.cs | 16 +++++++ .../ApiApproval.ApproveApi.approved.txt | 1 + 12 files changed, 144 insertions(+), 17 deletions(-) create mode 100644 src/FakeAnalyzers/EditorConfigOptionAnalyzer.cs diff --git a/README.md b/README.md index 1b5476d..a539d38 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,8 @@ Multiple source files can be added with separate `WithSource` calls. | `BuildAs(outputKind)` | Change the compilation output kind (defaults to `DynamicallyLinkedLibrary`). | | `SuppressCompilationErrors()` | Ignore compilation errors, useful when testing analyzers that run on code that does not compile. | | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | -| `WithProperty(name, value)` | Add an arbitrary MSBuild-style build property (feature flag) to the compilation. | +| `WithProperty(name, value)` | Add an arbitrary MSBuild-style build property (feature flag) to the compilation. The property is available through global and syntax-tree analyzer config options. | +| `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | | `AssertDiagnostics(expectedDiagnosticIds)` | Run the analyzer and assert that the diagnostics match the `[|…|]`-marked locations. | ## Testing code fixes @@ -253,7 +254,8 @@ To configure all code fix tests in a project, use `CodeFixTest.ConfigureAllCodeF | `BuildAs(outputKind)` | Change the compilation output kind. | | `SuppressCompilationErrors()` | Ignore compilation errors. | | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | -| `WithProperty(name, value)` | Add an arbitrary build property to the compilation. | +| `WithProperty(name, value)` | Add an arbitrary build property to the compilation. The property is available through global and syntax-tree analyzer config options. | +| `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | | `AssertCodeFixes()` | Apply code fixes iteratively and assert that the final source matches the expected output. | ## Testing source generators @@ -308,7 +310,8 @@ To configure all source generator tests in a project, use `SourceGeneratorTest.C | `SuppressCompilationErrors()` | Ignore compilation warnings and errors. | | `SuppressDiagnosticErrors()` | Ignore errors raised by the source generator itself. | | `WithInterceptorNamespace(ns)` | Add an interceptors namespace feature flag to the compilation. | -| `WithProperty(name, value)` | Add an arbitrary build property to the compilation. | +| `WithProperty(name, value)` | Add an arbitrary build property to the compilation. The property is available through global and syntax-tree analyzer config options. | +| `WithEditorConfigOption(name, value, filename)` | Add an EditorConfig option to syntax-tree analyzer config options. Omit `filename` to apply it to all source files; specify a filename to scope it to that source file. EditorConfig options are not available through global analyzer config options. | | `Run()` | Run the source generator without running an approval test. | | `Approve(scrubber)` | Run the generator (if not already run) and perform an approval test on the generated output. | | `ShouldNotGenerateCode()` | Assert that the source generator produces no output for the given sources. | diff --git a/src/FakeAnalyzers/DiagnosticDescriptors.cs b/src/FakeAnalyzers/DiagnosticDescriptors.cs index 5263669..bd99fd5 100644 --- a/src/FakeAnalyzers/DiagnosticDescriptors.cs +++ b/src/FakeAnalyzers/DiagnosticDescriptors.cs @@ -35,4 +35,12 @@ public static class DiagnosticDescriptors category: "Code", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true); + + public static readonly DiagnosticDescriptor EditorConfigOptionEnabled = new( + id: DiagnosticIds.EditorConfigOptionEnabled, + title: "EditorConfig option is enabled", + messageFormat: "The EditorConfig option is enabled for '{0}'", + category: "Code", + defaultSeverity: DiagnosticSeverity.Error, + isEnabledByDefault: true); } \ No newline at end of file diff --git a/src/FakeAnalyzers/DiagnosticIds.cs b/src/FakeAnalyzers/DiagnosticIds.cs index fb06723..6166541 100644 --- a/src/FakeAnalyzers/DiagnosticIds.cs +++ b/src/FakeAnalyzers/DiagnosticIds.cs @@ -10,4 +10,5 @@ public static class DiagnosticIds public const string AsyncVoid = "FAKE0002"; public const string IdentifierContainsFoo = "FAKE0003"; public const string TestFlagEnabled = "FAKE0004"; + public const string EditorConfigOptionEnabled = "FAKE0005"; } \ No newline at end of file diff --git a/src/FakeAnalyzers/EditorConfigOptionAnalyzer.cs b/src/FakeAnalyzers/EditorConfigOptionAnalyzer.cs new file mode 100644 index 0000000..5fdeb6c --- /dev/null +++ b/src/FakeAnalyzers/EditorConfigOptionAnalyzer.cs @@ -0,0 +1,35 @@ +namespace FakeAnalyzers; + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class EditorConfigOptionAnalyzer : DiagnosticAnalyzer +{ + const string OptionName = "nservicebus_enable_message_overload_migration_diagnostics"; + + public override ImmutableArray SupportedDiagnostics => [DiagnosticDescriptors.EditorConfigOptionEnabled]; + + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxNodeAction(AnalyzeClass, SyntaxKind.ClassDeclaration); + } + + static void AnalyzeClass(SyntaxNodeAnalysisContext context) + { + var optionsProvider = context.Options.AnalyzerConfigOptionsProvider; + if (!optionsProvider.GetOptions(context.Node.SyntaxTree).TryGetValue(OptionName, out var value) || value != "true" || optionsProvider.GlobalOptions.TryGetValue(OptionName, out _)) + { + return; + } + + var classDeclaration = (ClassDeclarationSyntax)context.Node; + var diagnostic = Diagnostic.Create(DiagnosticDescriptors.EditorConfigOptionEnabled, classDeclaration.Identifier.GetLocation(), classDeclaration.Identifier.Text); + context.ReportDiagnostic(diagnostic); + } +} diff --git a/src/FakeAnalyzers/TestFlagAnalyzer.cs b/src/FakeAnalyzers/TestFlagAnalyzer.cs index 0f2dfe5..2ba69cb 100644 --- a/src/FakeAnalyzers/TestFlagAnalyzer.cs +++ b/src/FakeAnalyzers/TestFlagAnalyzer.cs @@ -29,7 +29,9 @@ public override void Initialize(AnalysisContext context) static void AnalyzeClass(SyntaxNodeAnalysisContext context) { - if (context.Node is not ClassDeclarationSyntax classDeclaration) + if (context.Node is not ClassDeclarationSyntax classDeclaration || + !context.Options.AnalyzerConfigOptionsProvider.GetOptions(context.Node.SyntaxTree).TryGetValue("build_property.TestFlag", out var value) || + value != "enabled") { return; } diff --git a/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs b/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs index f9ed5d2..7c9c818 100644 --- a/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs +++ b/src/Particular.AnalyzerTesting/AnalyzerConfigOptionsFactory.cs @@ -6,17 +6,48 @@ namespace Particular.AnalyzerTesting; static class AnalyzerConfigOptionsFactory { - public static AnalyzerConfigOptionsProvider CreateOptionsProvider(IReadOnlyDictionary properties) - => new OptionsProvider(new DictionaryAnalyzerConfigOptions(properties)); + public static AnalyzerConfigOptionsProvider CreateOptionsProvider( + IReadOnlyDictionary globalProperties, + IReadOnlyDictionary? sourceProperties = null, + IReadOnlyDictionary>? sourceFileProperties = null) + => new OptionsProvider(globalProperties, sourceProperties ?? new Dictionary(), sourceFileProperties ?? new Dictionary>()); - public static AnalyzerOptions CreateAnalyzerOptions(IReadOnlyDictionary properties) - => new([], CreateOptionsProvider(properties)); + public static AnalyzerOptions CreateAnalyzerOptions( + IReadOnlyDictionary globalProperties, + IReadOnlyDictionary? sourceProperties = null, + IReadOnlyDictionary>? sourceFileProperties = null) + => new([], CreateOptionsProvider(globalProperties, sourceProperties, sourceFileProperties)); - sealed class OptionsProvider(AnalyzerConfigOptions options) : AnalyzerConfigOptionsProvider + sealed class OptionsProvider( + IReadOnlyDictionary globalProperties, + IReadOnlyDictionary sourceProperties, + IReadOnlyDictionary> sourceFileProperties) : AnalyzerConfigOptionsProvider { - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => options; - public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => options; - public override AnalyzerConfigOptions GlobalOptions => options; + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) + { + var properties = new Dictionary(globalProperties); + AddProperties(properties, sourceProperties); + + if (sourceFileProperties.TryGetValue(tree.FilePath, out var fileProperties)) + { + AddProperties(properties, fileProperties); + } + + return new DictionaryAnalyzerConfigOptions(properties); + } + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) + => new DictionaryAnalyzerConfigOptions(globalProperties); + + public override AnalyzerConfigOptions GlobalOptions { get; } = new DictionaryAnalyzerConfigOptions(globalProperties); + + static void AddProperties(Dictionary destination, IReadOnlyDictionary source) + { + foreach (var (key, value) in source) + { + destination[key] = value; + } + } } sealed class DictionaryAnalyzerConfigOptions(IReadOnlyDictionary properties) : AnalyzerConfigOptions diff --git a/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs b/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs index 15f96d2..824d2ad 100644 --- a/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs +++ b/src/Particular.AnalyzerTesting/BaseAnalyzerTest.cs @@ -86,7 +86,7 @@ private protected static async Task GetCompilerDiagnostics(Project private protected async Task GetAnalyzerDiagnostics(Compilation compilation, string[] ignoreDiagnosticIds, CancellationToken cancellationToken = default) { var analyzerTasks = analyzers - .Select(analyzer => compilation.GetAnalyzerDiagnostics(analyzer, features, cancellationToken)) + .Select(analyzer => compilation.GetAnalyzerDiagnostics(analyzer, features, editorConfigOptions, editorConfigOptionsByFilename, cancellationToken)) .ToArray(); await Task.WhenAll(analyzerTasks); diff --git a/src/Particular.AnalyzerTesting/BaseCompilationTest.cs b/src/Particular.AnalyzerTesting/BaseCompilationTest.cs index 0cd3e61..dd3f16f 100644 --- a/src/Particular.AnalyzerTesting/BaseCompilationTest.cs +++ b/src/Particular.AnalyzerTesting/BaseCompilationTest.cs @@ -20,6 +20,8 @@ public abstract class BaseCompilationTest where TSelf : BaseCompilationTe private protected OutputKind buildOutputType = OutputKind.DynamicallyLinkedLibrary; private protected bool suppressCompilationErrors; private protected readonly Dictionary features = []; + private protected readonly Dictionary editorConfigOptions = []; + private protected readonly Dictionary> editorConfigOptionsByFilename = []; private protected BaseCompilationTest(string? outputAssemblyName = null) { @@ -128,11 +130,34 @@ public TSelf WithInterceptorNamespace(string interceptorNamespace) } /// - /// Add a build property to the compilation. + /// Add a build property to the compilation. The property is available through both the global and syntax-tree analyzer config options. /// public TSelf WithProperty(string name, string value) { features.Add(name, value); return Self; } + + /// + /// Add an EditorConfig option that is available through syntax-tree analyzer config options. + /// Omit to apply the option to every source file. + /// The option is not added to global analyzer config options. + /// + public TSelf WithEditorConfigOption(string name, string value, string? filename = null) + { + if (filename is null) + { + editorConfigOptions.Add(name, value); + return Self; + } + + if (!editorConfigOptionsByFilename.TryGetValue(filename, out var fileOptions)) + { + fileOptions = []; + editorConfigOptionsByFilename.Add(filename, fileOptions); + } + + fileOptions.Add(name, value); + return Self; + } } \ No newline at end of file diff --git a/src/Particular.AnalyzerTesting/CompilationExtensions.cs b/src/Particular.AnalyzerTesting/CompilationExtensions.cs index e63e46c..8206ae4 100644 --- a/src/Particular.AnalyzerTesting/CompilationExtensions.cs +++ b/src/Particular.AnalyzerTesting/CompilationExtensions.cs @@ -32,12 +32,17 @@ public void Compile(bool throwOnFailure = true) Debug.WriteLine("Compilation failed."); } - public async Task> GetAnalyzerDiagnostics(DiagnosticAnalyzer analyzer, IReadOnlyDictionary properties, CancellationToken cancellationToken = default) + public async Task> GetAnalyzerDiagnostics( + DiagnosticAnalyzer analyzer, + IReadOnlyDictionary globalProperties, + IReadOnlyDictionary sourceProperties, + IReadOnlyDictionary> sourceFileProperties, + CancellationToken cancellationToken = default) { var exceptions = new List(); var analysisOptions = new CompilationWithAnalyzersOptions( - AnalyzerConfigOptionsFactory.CreateAnalyzerOptions(properties), + AnalyzerConfigOptionsFactory.CreateAnalyzerOptions(globalProperties, sourceProperties, sourceFileProperties), (exception, _, __) => exceptions.Add(exception), concurrentAnalysis: false, logAnalyzerExecutionTime: false); diff --git a/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs b/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs index 47480d7..4defd4f 100644 --- a/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs +++ b/src/Particular.AnalyzerTesting/SourceGeneratorTest.cs @@ -170,7 +170,7 @@ public SourceGeneratorTestResult Run() disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true); - var optsProvider = AnalyzerConfigOptionsFactory.CreateOptionsProvider(features); + var optsProvider = AnalyzerConfigOptionsFactory.CreateOptionsProvider(features, editorConfigOptions, editorConfigOptionsByFilename); var driver = CSharpGeneratorDriver.Create(generators, driverOptions: driverOpts, diff --git a/src/Tests/Analyzers/TestFlagAnalyzerTests.cs b/src/Tests/Analyzers/TestFlagAnalyzerTests.cs index 37880fe..da882da 100644 --- a/src/Tests/Analyzers/TestFlagAnalyzerTests.cs +++ b/src/Tests/Analyzers/TestFlagAnalyzerTests.cs @@ -19,4 +19,20 @@ public Task ReportsDiagnosticWhenPropertyIsEnabled() => .WithProperty("build_property.TestFlag", "enabled") .WithSource(Code) .AssertDiagnostics(DiagnosticIds.TestFlagEnabled); + + [Test] + public Task ReportsDiagnosticWhenEditorConfigOptionIsEnabledForAllSources() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("nservicebus_enable_message_overload_migration_diagnostics", "true") + .WithSource("public class [|First|] { }", "First.cs") + .WithSource("public class [|Second|] { }", "Second.cs") + .AssertDiagnostics(DiagnosticIds.EditorConfigOptionEnabled); + + [Test] + public Task ReportsDiagnosticOnlyForEditorConfigOptionSourceFile() => + AnalyzerTest.ForAnalyzer() + .WithEditorConfigOption("nservicebus_enable_message_overload_migration_diagnostics", "true", "Selected.cs") + .WithSource("public class [|Selected|] { }", "Selected.cs") + .WithSource("public class Other { }", "Other.cs") + .AssertDiagnostics(DiagnosticIds.EditorConfigOptionEnabled); } \ No newline at end of file diff --git a/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt b/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt index f622727..f4948e6 100644 --- a/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt +++ b/src/Tests/ApprovalFiles/ApiApproval.ApproveApi.approved.txt @@ -37,6 +37,7 @@ namespace Particular.AnalyzerTesting public TSelf SuppressCompilationErrors() { } public TSelf WithAnalyzer() where TAnalyzer : Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer, new () { } + public TSelf WithEditorConfigOption(string name, string value, string? filename = null) { } public TSelf WithInterceptorNamespace(string interceptorNamespace) { } public TSelf WithLangVersion(Microsoft.CodeAnalysis.CSharp.LanguageVersion langVersion) { } public TSelf WithProperty(string name, string value) { } From 6d5597d7ae6a37af1d3010acb7e44d996e8fa9fe Mon Sep 17 00:00:00 2001 From: Daniel Marbach Date: Tue, 11 Aug 2026 07:59:28 +0200 Subject: [PATCH 2/2] SourceGen EditorConfig test --- .../EditorConfigOptionSourceGenerator.cs | 34 +++++++++++++++++++ .../BasicSourceGeneratorTest.cs | 15 ++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/FakeAnalyzers/EditorConfigOptionSourceGenerator.cs diff --git a/src/FakeAnalyzers/EditorConfigOptionSourceGenerator.cs b/src/FakeAnalyzers/EditorConfigOptionSourceGenerator.cs new file mode 100644 index 0000000..1a9ab40 --- /dev/null +++ b/src/FakeAnalyzers/EditorConfigOptionSourceGenerator.cs @@ -0,0 +1,34 @@ +namespace FakeAnalyzers; + +using System.Text; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +[Generator(LanguageNames.CSharp)] +public sealed class EditorConfigOptionSourceGenerator : IIncrementalGenerator +{ + const string AllSourceOption = "test_all_source_option"; + const string FilenameOption = "test_filename_option"; + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var compilationAndOptions = context.CompilationProvider.Combine(context.AnalyzerConfigOptionsProvider); + context.RegisterSourceOutput(compilationAndOptions, static (productionContext, input) => + { + var output = new StringBuilder(); + + foreach (var tree in input.Left.SyntaxTrees) + { + var options = input.Right.GetOptions(tree); + var allSourceValue = GetOptionValue(options, AllSourceOption); + var filenameValue = GetOptionValue(options, FilenameOption); + output.AppendLine($"// {tree.FilePath}: all-source={allSourceValue}; filename={filenameValue}"); + } + + productionContext.AddSource("EditorConfigOptions.g.cs", output.ToString()); + }); + } + + static string GetOptionValue(AnalyzerConfigOptions options, string name) + => options.TryGetValue(name, out var value) ? value : ""; +} diff --git a/src/Tests/SourceGenerators/BasicSourceGeneratorTest.cs b/src/Tests/SourceGenerators/BasicSourceGeneratorTest.cs index a2b12ba..e8f2b50 100644 --- a/src/Tests/SourceGenerators/BasicSourceGeneratorTest.cs +++ b/src/Tests/SourceGenerators/BasicSourceGeneratorTest.cs @@ -64,4 +64,19 @@ public void AnalyzerSeesPropertyDuringSourceGeneratorRun() Assert.That(result.AnalyzerDiagnostics.Select(diagnostic => diagnostic.Id), Contains.Item(DiagnosticIds.TestFlagEnabled)); } + + [Test] + public void SourceGeneratorSeesEditorConfigOptionsForEachSourceFile() + { + var result = SourceGeneratorTest.ForIncrementalGenerator(["CompilationAndOptions"]) + .WithEditorConfigOption("test_all_source_option", "all-sources") + .WithEditorConfigOption("test_filename_option", "selected-only", "Selected.cs") + .WithSource("public class Selected { }", "Selected.cs") + .WithSource("public class Other { }", "Other.cs") + .Run(); + + var output = result.GetCompilationOutput(); + Assert.That(output, Does.Contain("// Selected.cs: all-source=all-sources; filename=selected-only")); + Assert.That(output, Does.Contain("// Other.cs: all-source=all-sources; filename=")); + } }