From 0ed483f9d0d39e087fef2f885d3de70ed502721e Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 5 Sep 2026 09:15:52 +0100 Subject: [PATCH 1/2] fix: code fixers weren't included in the package --- docs/code-writer.md | 40 ++ docs/guide.md | 24 ++ package.json | 2 +- purview-build.json | 1 + .../AnalyzerReleases.Unshipped.md | 6 + .../CodeWriterLiteralClassifier.cs | 39 ++ ...singDiagnosticAnalyzerAttributeAnalyzer.cs | 75 ++++ ...gExportCodeFixProviderAttributeAnalyzer.cs | 74 ++++ .../MissingGeneratorAttributeAnalyzer.cs | 93 +++++ .../NonPublicRoslynComponentAnalyzer.cs | 126 +++++++ .../OrphanedFixableDiagnosticIdAnalyzer.cs | 174 +++++++++ ...eferStructuredCodeWriterIfBlockAnalyzer.cs | 93 +++++ .../RoslynComponentDiscovery.cs | 134 +++++++ ...gnosticAnalyzerAttributeCodeFixProvider.cs | 61 +++ ...CodeFixProviderAttributeCodeFixProvider.cs | 61 +++ .../AddGeneratorAttributeCodeFixProvider.cs | 61 +++ ...akeRoslynComponentPublicCodeFixProvider.cs | 59 +++ ...ucturedCodeWriterIfBlockCodeFixProvider.cs | 260 +++++++++++++ ...hanedFixableDiagnosticIdCodeFixProvider.cs | 63 ++++ .../RoslynComponentFixHelpers.cs | 144 +++++++ .../CodeWriterSampleEmitter.cs | 17 + .../SourceGeneratorFramework/Sdk/README.md | 5 + .../SourceGeneratorFramework.csproj | 6 + src/src/SourceGeneratorShared/CodeWriter.cs | 37 ++ ...iagnosticAnalyzerAttributeAnalyzerTests.cs | 61 +++ ...rtCodeFixProviderAttributeAnalyzerTests.cs | 69 ++++ .../MissingGeneratorAttributeAnalyzerTests.cs | 76 ++++ .../NonPublicRoslynComponentAnalyzerTests.cs | 130 +++++++ ...rphanedFixableDiagnosticIdAnalyzerTests.cs | 110 ++++++ ...tructuredCodeWriterIfBlockAnalyzerTests.cs | 275 ++++++++++++++ ...ucturedCodeWriterStatementAnalyzerTests.cs | 75 ++++ ...icAnalyzerAttributeCodeFixProviderTests.cs | 34 ++ ...ixProviderAttributeCodeFixProviderTests.cs | 43 +++ ...dGeneratorAttributeCodeFixProviderTests.cs | 32 ++ ...slynComponentPublicCodeFixProviderTests.cs | 89 +++++ ...edCodeWriterIfBlockCodeFixProviderTests.cs | 357 ++++++++++++++++++ ...FixableDiagnosticIdCodeFixProviderTests.cs | 57 +++ .../CodeWriterSampleGeneratorTests.cs | 22 ++ .../CodeWriterTests.cs | 114 ++++++ 39 files changed, 3198 insertions(+), 1 deletion(-) create mode 100644 src/src/SourceGeneratorFramework.Analyzers/MissingDiagnosticAnalyzerAttributeAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/MissingExportCodeFixProviderAttributeAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/MissingGeneratorAttributeAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/NonPublicRoslynComponentAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs create mode 100644 src/src/SourceGeneratorFramework.Analyzers/RoslynComponentDiscovery.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/AddDiagnosticAnalyzerAttributeCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/AddExportCodeFixProviderAttributeCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/AddGeneratorAttributeCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/MakeRoslynComponentPublicCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/RemoveOrphanedFixableDiagnosticIdCodeFixProvider.cs create mode 100644 src/src/SourceGeneratorFramework.CodeFixers/RoslynComponentFixHelpers.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingDiagnosticAnalyzerAttributeAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingExportCodeFixProviderAttributeAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingGeneratorAttributeAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/NonPublicRoslynComponentAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/OrphanedFixableDiagnosticIdAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterIfBlockAnalyzerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddDiagnosticAnalyzerAttributeCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddExportCodeFixProviderAttributeCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddGeneratorAttributeCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/MakeRoslynComponentPublicCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterIfBlockCodeFixProviderTests.cs create mode 100644 src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/RemoveOrphanedFixableDiagnosticIdCodeFixProviderTests.cs diff --git a/docs/code-writer.md b/docs/code-writer.md index 24c3870..922054c 100644 --- a/docs/code-writer.md +++ b/docs/code-writer.md @@ -93,6 +93,9 @@ writer.Return("value"); // return value; writer.Throw(TypeIdentity.Create(), "Failed."); // throw new ...; writer.Assignment("_total", "value"); // _total = value; writer.IfBlock("value is null", body => body.Return("null")); +writer.IfBlock("value is null", body => body.Return("null")) + .ElseIf("value is 0", body => body.Return("zero")) + .Else(body => body.Return("value")); writer.Foreach("var item in items", body => body.MethodCallOn("item", "Process")); ``` @@ -105,6 +108,40 @@ writer.MethodCall("Create", ["x"], receiver: "factory", genericArguments: [TypeR // factory.Create(x); ``` +### Conditional statements + +`IfBlock`/`IfBlockScope` write an `if` block. `ElseIf`/`ElseIfScope` chain an `else if` block after an +`if` or another `else if`, and `Else`/`ElseScope` close the chain with an `else` block. The methods +return the writer, so branches can be chained fluently: + +```csharp +writer + .IfBlock("value is null", body => body.Return("null")) + .ElseIf("value is 0", body => body.Return("zero")) + .Else(body => body.Return("value")); +``` + +Emits: + +```csharp +if (value is null) +{ + return null; +} +else if (value is 0) +{ + return zero; +} +else +{ + return value; +} +``` + +`IfElse(condition, ifBody, elseBody)` is the compact two-branch form. The scope forms +`IfBlockScope`, `ElseIfScope`, and `ElseScope` write the header and return the body scope for +content that spans multiple calls. + ### Conditional compilation blocks `HashDefines`/`HashDefinesScope` write a `#if`/`#endif` block with both directives at **column zero**. @@ -288,6 +325,9 @@ writer.Property("Name", TypeReference.Create(), TypeDeclarationAccessibi values manually — the `PreferMinimalCodeWriterOverloadAnalyzer` (PSGFR20) flags the verbose form. - Prefer structured declarations and statements over raw text — `PreferStructuredCodeWriterApiAnalyzer` (PSGFR18) and `PreferStructuredCodeWriterStatementAnalyzer` (PSGFR19) flag raw emission. +- Prefer `IfBlock`/`ElseIf`/`Else` over generic block methods for conditional content — the + `PreferStructuredCodeWriterIfBlockAnalyzer` (PSGFR23) flags `OpenBlockScope`/`OpenBlock` headers that + write an `if`, `else if`, or `else` statement, and its code fix rewrites them. - Always consume scope-returning methods with `using` (PSGFR17). - Keep every value emitted through the structured API so layout stays deterministic and the analyzers can guide callers back to the best practice. diff --git a/docs/guide.md b/docs/guide.md index b124011..6765e2e 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -1954,6 +1954,28 @@ This prevents Roslyn dependencies leaking into runtime package assets. --- +## Roslyn Component Discovery + +The compiler host only loads a source generator, diagnostic analyser, or code fix provider when +three conditions hold. Missing any one means the component is **silently ignored**, which is why +"nothing shows up in Visual Studio" is usually a setup problem, not a code problem: + +1. **The type is public.** Non-public component types cannot be instantiated by Roslyn + (`PSGFR27`). +2. **The type is decorated.** A generator needs `[Generator]` (`PSGFR26`), an analyser needs + `[DiagnosticAnalyzer]` (`PSGFR25`), and a code fix provider needs `[ExportCodeFixProvider]` + (`PSGFR24`). +3. **The assembly is loaded as an analyser.** In a package the component assembly must be packed + under `analyzers/dotnet/cs/`; in a project reference it must be referenced with + `OutputItemType="Analyser"`. A normal library reference never surfaces a component to Roslyn. + +A code fix provider also only appears when the diagnostic ID in `FixableDiagnosticIds` is actually +produced by an analyser that is loaded alongside it (`PSGFR28`). Visual Studio MEF-composes fix +providers when the analyser set loads, so after adding or updating a fixer assembly you must +restart Visual Studio or reload the project for the fixes to appear. + +--- + # 19. Review Checklist ## Analyser @@ -1970,6 +1992,8 @@ This prevents Roslyn dependencies leaking into runtime package assets. - [ ] Is whole-compilation analysis genuinely necessary? - [ ] Could the diagnostic reasonably have a code fix? - [ ] Are diagnostic IDs release-tracked? +- [ ] Is the analyser type `public` and decorated with `[DiagnosticAnalyzer]`? +- [ ] Do the code fix's `FixableDiagnosticIds` match an ID the analyser actually produces? --- diff --git a/package.json b/package.json index 96e43df..94b15df 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { "name": "purview-sourcegeneratorframework", - "version": "1.0.0-prerelease.33", + "version": "1.0.0-prerelease.34", "private": true } diff --git a/purview-build.json b/purview-build.json index 286a5c3..b38b747 100644 --- a/purview-build.json +++ b/purview-build.json @@ -15,6 +15,7 @@ "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.dll", "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Generators.dll", "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Analyzers.dll", + "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.CodeFixers.dll", "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Shared.dll", "build/Purview.SourceGeneratorFramework.props", "build/Purview.SourceGeneratorFramework.targets" diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 1395e11..4c37e27 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -14,6 +14,12 @@ PSGFR19 | Purview.SourceGeneratorFramework | Info | Prefer a structured CodeWrit PSGFR20 | Purview.SourceGeneratorFramework | Info | Prefer the minimal CodeWriter overload PSGFR21 | Purview.SourceGeneratorFramework | Info | Prefer HashDefines for conditional compilation PSGFR22 | Purview.SourceGeneratorFramework | Info | Prefer PragmaDisable for warning suppression +PSGFR23 | Purview.SourceGeneratorFramework | Info | Prefer the structured CodeWriter conditional API +PSGFR24 | Purview.SourceGeneratorFramework | Warning | CodeFixProvider is not marked with ExportCodeFixProvider +PSGFR25 | Purview.SourceGeneratorFramework | Warning | DiagnosticAnalyzer is not marked with DiagnosticAnalyzer +PSGFR26 | Purview.SourceGeneratorFramework | Error | Source generator is not marked with Generator +PSGFR27 | Purview.SourceGeneratorFramework | Warning | Roslyn component type must be public +PSGFR28 | Purview.SourceGeneratorFramework | Info | Code fixer targets a diagnostic no analyzer produces ADM0001 | Target | Error | Target attribute type cannot be resolved ADM0002 | Property | Error | Property type is not supported for attribute extraction ADM0003 | Source | Error | Specified constructor index/name does not exist on the target attribute diff --git a/src/src/SourceGeneratorFramework.Analyzers/CodeWriterLiteralClassifier.cs b/src/src/SourceGeneratorFramework.Analyzers/CodeWriterLiteralClassifier.cs index 947b8fc..85f2ae0 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/CodeWriterLiteralClassifier.cs +++ b/src/src/SourceGeneratorFramework.Analyzers/CodeWriterLiteralClassifier.cs @@ -191,6 +191,12 @@ public static bool IsPragmaWarningDirective(string value) if (trimmed.StartsWith("if (", StringComparison.Ordinal)) return "IfBlock"; + if (trimmed.StartsWith("else if (", StringComparison.Ordinal)) + return "ElseIf"; + + if (trimmed == "else") + return "Else"; + if (trimmed.StartsWith("foreach (", StringComparison.Ordinal)) return "Foreach"; @@ -218,4 +224,37 @@ static bool HasReceiver(string trimmed) var openParen = trimmed.IndexOf('('); return openParen > 0 && trimmed.LastIndexOf('.', openParen) >= 0; } + + /// + /// Classifies the header of a block- or scope-opening CodeWriter method and returns the + /// structured if/else if/else API that can express it, or + /// when the header is not a conditional block. + /// + /// The header text resolved from the first argument of the block method. + /// Whether the block method is the scope-returning form, which selects the + /// Scope-suffixed suggestion. + /// + /// The structured API name, or when the header does not describe a + /// conditional block. + /// + public static string? ClassifyBlockHeader(string? header, bool isScopeForm) + { + var trimmed = header?.Trim(); + if (trimmed is null || trimmed.Length == 0) + return null; + + if (trimmed.EndsWith(";", StringComparison.Ordinal) || trimmed.EndsWith(")", StringComparison.Ordinal)) + trimmed = trimmed.TrimEnd(';', ')').Trim(); + + if (trimmed.StartsWith("else if (", StringComparison.Ordinal)) + return isScopeForm ? "ElseIfScope" : "ElseIf"; + + if (trimmed == "else") + return isScopeForm ? "ElseScope" : "Else"; + + if (trimmed.StartsWith("if (", StringComparison.Ordinal)) + return isScopeForm ? "IfBlockScope" : "IfBlock"; + + return null; + } } diff --git a/src/src/SourceGeneratorFramework.Analyzers/MissingDiagnosticAnalyzerAttributeAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/MissingDiagnosticAnalyzerAttributeAnalyzer.cs new file mode 100644 index 0000000..a71b201 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/MissingDiagnosticAnalyzerAttributeAnalyzer.cs @@ -0,0 +1,75 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags DiagnosticAnalyzer subclasses that are not decorated with +/// [DiagnosticAnalyzer], so the analyzer is never loaded and its diagnostics (and their +/// code fixes) never appear. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MissingDiagnosticAnalyzerAttributeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR25"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "DiagnosticAnalyzer is not registered", + "Type '{0}' derives from DiagnosticAnalyzer but is not marked [DiagnosticAnalyzer]; the analyzer will never run", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Diagnostic analyzers must be decorated with [DiagnosticAnalyzer] so the compiler host loads them. Without the attribute the analyzer is silently ignored." + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var diagnosticAnalyzerType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer" + ); + var diagnosticAnalyzerAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzerAttribute" + ); + + context.RegisterSymbolAction( + context => AnalyzeNamedType(context, diagnosticAnalyzerType, diagnosticAnalyzerAttributeType), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? diagnosticAnalyzerType, + INamedTypeSymbol? diagnosticAnalyzerAttributeType + ) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + if (!RoslynComponentDiscovery.IsDiagnosticAnalyzer(type, diagnosticAnalyzerType)) + return; + + if (RoslynComponentDiscovery.HasAttribute(type, diagnosticAnalyzerAttributeType)) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None, + type.Name + ) + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/MissingExportCodeFixProviderAttributeAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/MissingExportCodeFixProviderAttributeAnalyzer.cs new file mode 100644 index 0000000..3e3463f --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/MissingExportCodeFixProviderAttributeAnalyzer.cs @@ -0,0 +1,74 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags CodeFixProvider subclasses that are not decorated with +/// [ExportCodeFixProvider], so Visual Studio can never discover their fixes. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MissingExportCodeFixProviderAttributeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR24"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "CodeFixProvider is not exported", + "Type '{0}' derives from CodeFixProvider but is not marked [ExportCodeFixProvider]; Visual Studio will never discover its code fixes", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Code fix providers must be decorated with [ExportCodeFixProvider] so Visual Studio can discover them. Without the attribute the type is silently ignored and its fixes never appear." + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var codeFixProviderType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider" + ); + var exportAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.CodeFixes.ExportCodeFixProviderAttribute" + ); + + context.RegisterSymbolAction( + context => AnalyzeNamedType(context, codeFixProviderType, exportAttributeType), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? codeFixProviderType, + INamedTypeSymbol? exportAttributeType + ) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + if (!RoslynComponentDiscovery.IsCodeFixProvider(type, codeFixProviderType)) + return; + + if (RoslynComponentDiscovery.HasAttribute(type, exportAttributeType)) + return; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None, + type.Name + ) + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/MissingGeneratorAttributeAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/MissingGeneratorAttributeAnalyzer.cs new file mode 100644 index 0000000..e9ce63d --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/MissingGeneratorAttributeAnalyzer.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags types that implement a generator interface without the [Generator] attribute, so +/// the generator is silently never executed. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class MissingGeneratorAttributeAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR26"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Source generator is not marked [Generator]", + "Type '{0}' implements {1} but is not marked [Generator]; the generator will never run", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Error, + isEnabledByDefault: true, + description: "Source generators must implement IIncrementalGenerator or ISourceGenerator AND be decorated with [Generator]. Without the attribute the generator is silently ignored." + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var incrementalGeneratorType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.IIncrementalGenerator" + ); + var legacyGeneratorType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.ISourceGenerator" + ); + var generatorAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.GeneratorAttribute" + ); + + context.RegisterSymbolAction( + context => + AnalyzeNamedType(context, incrementalGeneratorType, legacyGeneratorType, generatorAttributeType), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? incrementalGeneratorType, + INamedTypeSymbol? legacyGeneratorType, + INamedTypeSymbol? generatorAttributeType + ) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + var implementsIncremental = RoslynComponentDiscovery.IsSourceGenerator( + type, + incrementalGeneratorType, + legacyGeneratorType + ); + if (!implementsIncremental) + return; + + if (RoslynComponentDiscovery.HasAttribute(type, generatorAttributeType)) + return; + + var interfaceName = + type.AllInterfaces.FirstOrDefault(i => + SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, incrementalGeneratorType) + || SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, legacyGeneratorType) + ) + ?.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat) + ?? "a generator interface"; + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None, + type.Name, + interfaceName + ) + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/NonPublicRoslynComponentAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/NonPublicRoslynComponentAnalyzer.cs new file mode 100644 index 0000000..0db9d1e --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/NonPublicRoslynComponentAnalyzer.cs @@ -0,0 +1,126 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags Roslyn component types (source generators, diagnostic analyzers, code fix providers) that +/// are not effectively public, because the compiler host can only instantiate public types. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class NonPublicRoslynComponentAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR27"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Roslyn component type must be public", + "Type '{0}' is a {1} but is not public; Roslyn cannot instantiate non-public components, so it will never run", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "The Roslyn compiler host can only instantiate public source generator, diagnostic analyzer, and code fix provider types. Non-public component types are silently ignored." + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(context => + { + var codeFixProviderType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.CodeFixes.CodeFixProvider" + ); + var diagnosticAnalyzerType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzer" + ); + var incrementalGeneratorType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.IIncrementalGenerator" + ); + var legacyGeneratorType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.ISourceGenerator" + ); + var exportAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.CodeFixes.ExportCodeFixProviderAttribute" + ); + var diagnosticAnalyzerAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.Diagnostics.DiagnosticAnalyzerAttribute" + ); + var generatorAttributeType = context.Compilation.GetTypeByMetadataName( + "Microsoft.CodeAnalysis.GeneratorAttribute" + ); + + context.RegisterSymbolAction( + context => + AnalyzeNamedType( + context, + codeFixProviderType, + diagnosticAnalyzerType, + incrementalGeneratorType, + legacyGeneratorType, + exportAttributeType, + diagnosticAnalyzerAttributeType, + generatorAttributeType + ), + SymbolKind.NamedType + ); + }); + } + + static void AnalyzeNamedType( + SymbolAnalysisContext context, + INamedTypeSymbol? codeFixProviderType, + INamedTypeSymbol? diagnosticAnalyzerType, + INamedTypeSymbol? incrementalGeneratorType, + INamedTypeSymbol? legacyGeneratorType, + INamedTypeSymbol? exportAttributeType, + INamedTypeSymbol? diagnosticAnalyzerAttributeType, + INamedTypeSymbol? generatorAttributeType + ) + { + if (context.Symbol is not INamedTypeSymbol type) + return; + + if ( + !RoslynComponentDiscovery.IsRoslynComponent( + type, + codeFixProviderType, + diagnosticAnalyzerType, + incrementalGeneratorType, + legacyGeneratorType, + exportAttributeType, + diagnosticAnalyzerAttributeType, + generatorAttributeType + ) + ) + return; + + if (RoslynComponentDiscovery.IsEffectivelyPublic(type)) + return; + + var kind = RoslynComponentDiscovery.DescribeKind( + type, + codeFixProviderType, + diagnosticAnalyzerType, + incrementalGeneratorType, + legacyGeneratorType, + exportAttributeType, + generatorAttributeType + ); + + context.ReportDiagnostic( + Diagnostic.Create( + Rule, + type.Locations.FirstOrDefault(static loc => loc.IsInSource) ?? Location.None, + type.Name, + kind + ) + ); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs new file mode 100644 index 0000000..e30343f --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/OrphanedFixableDiagnosticIdAnalyzer.cs @@ -0,0 +1,174 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags [ExportCodeFixProvider] types whose FixableDiagnosticIds reference a +/// diagnostic ID that no analyzer in the same compilation produces. Because the compiler host only +/// shows fixes for diagnostics an analyzer actually reports, such a fixer is never offered. +/// +/// +/// The rule is deliberately scoped to co-located analyzers: it only fires when the compilation +/// contains at least one [DiagnosticAnalyzer] in source, so fixers that target analyzers +/// supplied by a referenced assembly are never false-flagged. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class OrphanedFixableDiagnosticIdAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR28"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Code fixer targets a diagnostic no analyzer produces", + "Code fixer '{0}' fixes diagnostic '{1}', which is not produced by any analyzer in this compilation; the fix will never be shown in Visual Studio", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Visual Studio only offers a code fix when the analyzer that produces the diagnostic is loaded. A FixableDiagnosticIds entry that no analyzer in this compilation produces is dead configuration.", + customTags: [WellKnownDiagnosticTags.CompilationEnd] + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationAction(AnalyzeCompilation); + } + + static void AnalyzeCompilation(CompilationAnalysisContext context) + { + var descriptorIds = new HashSet(StringComparer.Ordinal); + var fixerTargets = new List<(string TypeName, Location Location, string Id)>(); + var hasSourceAnalyzer = false; + + foreach (var tree in context.Compilation.SyntaxTrees) + { + var root = tree.GetRoot(context.CancellationToken); + + foreach (var typeDeclaration in root.DescendantNodes().OfType()) + { + var attributeNames = GetAttributeNames(typeDeclaration).ToList(); + if (attributeNames.Contains("ExportCodeFixProvider")) + { + foreach (var (location, id) in GetFixableDiagnosticIds(typeDeclaration)) + fixerTargets.Add((typeDeclaration.Identifier.Text, location, id)); + } + + if (!hasSourceAnalyzer && attributeNames.Contains("DiagnosticAnalyzer")) + hasSourceAnalyzer = true; + } + + foreach (var node in root.DescendantNodes()) + { + string? id = null; + + if (node is ObjectCreationExpressionSyntax { ArgumentList: not null } objectCreation) + { + if (objectCreation.Type is not NameSyntax name || !IsDiagnosticDescriptorName(name)) + continue; + + id = GetFirstStringArgumentId(objectCreation.ArgumentList); + } + else if ( + node is ImplicitObjectCreationExpressionSyntax { ArgumentList: not null } implicitCreation + && IsDiagnosticDescriptorDeclaration(implicitCreation) + ) + { + id = GetFirstStringArgumentId(implicitCreation.ArgumentList); + } + + if (id is not null) + descriptorIds.Add(id); + } + } + + if (!hasSourceAnalyzer) + return; + + foreach (var (typeName, location, id) in fixerTargets) + { + if (descriptorIds.Contains(id)) + continue; + + context.ReportDiagnostic(Diagnostic.Create(Rule, location, typeName, id)); + } + } + + static bool IsDiagnosticDescriptorName(NameSyntax name) + { + var simpleName = name switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.Text, + _ => name.ToString(), + }; + + return simpleName == "DiagnosticDescriptor"; + } + + static string? GetFirstStringArgumentId(ArgumentListSyntax argumentList) => + argumentList.Arguments.FirstOrDefault()?.Expression is LiteralExpressionSyntax literal + && literal.Kind() == SyntaxKind.StringLiteralExpression + && literal.Token.Value is string id + ? id + : null; + + /// + /// True when the target-typed creation is assigned to a field, property, or local declared as + /// DiagnosticDescriptor, e.g. static readonly DiagnosticDescriptor Rule = new(...). + /// + static bool IsDiagnosticDescriptorDeclaration(ImplicitObjectCreationExpressionSyntax creation) + { + var variableDeclaration = creation.FirstAncestorOrSelf(); + if (variableDeclaration is null) + return false; + + var typeName = variableDeclaration.Type.ToString(); + return typeName == "DiagnosticDescriptor" + || typeName.EndsWith(".DiagnosticDescriptor", StringComparison.Ordinal); + } + + static IEnumerable GetAttributeNames(MemberDeclarationSyntax declaration) + { + foreach (var attributeList in declaration.AttributeLists) + { + foreach (var attribute in attributeList.Attributes) + { + var name = attribute.Name switch + { + IdentifierNameSyntax identifier => identifier.Identifier.Text, + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + AliasQualifiedNameSyntax alias => alias.Name.Identifier.Text, + _ => attribute.Name.ToString(), + }; + + yield return name; + } + } + } + + static IEnumerable<(Location Location, string Id)> GetFixableDiagnosticIds(TypeDeclarationSyntax typeDeclaration) + { + foreach (var member in typeDeclaration.Members) + { + if (member is not PropertyDeclarationSyntax { Identifier.Text: "FixableDiagnosticIds" } property) + continue; + + foreach (var literal in property.DescendantNodes().OfType()) + { + if (literal.Kind() == SyntaxKind.StringLiteralExpression && literal.Token.Value is string id) + yield return (literal.GetLocation(), id); + } + } + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs new file mode 100644 index 0000000..f0a44aa --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/PreferStructuredCodeWriterIfBlockAnalyzer.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Flags block- and scope-opening CodeWriter methods whose header writes a conditional +/// statement, suggesting the structured IfBlock, ElseIf, or Else API instead. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class PreferStructuredCodeWriterIfBlockAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR23"; + + public static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Prefer the structured CodeWriter conditional API", + "'{0}' with a conditional block should use '{1}'", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Emitting an if, else if, or else block through a generic block method bypasses the structured IfBlock, ElseIf, and Else APIs on CodeWriter." + ); + + static readonly string[] BlockMethods = + [ + "OpenBlockScope", + "OpenDelimitedBlockScope", + "OpenBlock", + "OpenDelimitedBlock", + "Block", + "DelimitedBlock", + ]; + + public override ImmutableArray SupportedDiagnostics => [Rule]; + + public override void Initialize(AnalysisContext context) + { + if (context is null) + throw new ArgumentNullException(nameof(context)); + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(AnalyzeInvocation, SyntaxKind.InvocationExpression); + } + + static void AnalyzeInvocation(SyntaxNodeAnalysisContext context) + { + if ( + context.Node + is not InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax member } invocation + ) + return; + + var name = member.Name.Identifier.Text; + if (Array.IndexOf(BlockMethods, name) < 0) + return; + + if (invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not ExpressionSyntax expression) + return; + + if ( + context.SemanticModel.GetSymbolInfo(invocation, context.CancellationToken).Symbol + is not IMethodSymbol method + ) + return; + + if (method.ContainingType?.ToDisplayString() != "Purview.SourceGeneratorFramework.CodeWriter") + return; + + if ( + !CodeWriterLiteralClassifier.TryGetLiteralText( + expression, + context.SemanticModel, + context.CancellationToken, + out var value + ) + ) + return; + + var structuredApi = CodeWriterLiteralClassifier.ClassifyBlockHeader( + value, + isScopeForm: name.EndsWith("Scope", StringComparison.Ordinal) + ); + if (structuredApi is null) + return; + + context.ReportDiagnostic(Diagnostic.Create(Rule, invocation.GetLocation(), name, structuredApi)); + } +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/RoslynComponentDiscovery.cs b/src/src/SourceGeneratorFramework.Analyzers/RoslynComponentDiscovery.cs new file mode 100644 index 0000000..e00a719 --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/RoslynComponentDiscovery.cs @@ -0,0 +1,134 @@ +using Microsoft.CodeAnalysis; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +/// +/// Classifies named types as Roslyn components (source generators, diagnostic analyzers, or code +/// fix providers) so the setup diagnostics can share one set of rules. +/// +static class RoslynComponentDiscovery +{ + public static bool IsCodeFixProvider(INamedTypeSymbol type, INamedTypeSymbol? codeFixProviderType) + { + if (codeFixProviderType is null) + return false; + + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, codeFixProviderType)) + return true; + } + + return false; + } + + public static bool IsDiagnosticAnalyzer(INamedTypeSymbol type, INamedTypeSymbol? diagnosticAnalyzerType) + { + if (diagnosticAnalyzerType is null) + return false; + + for (var current = type; current is not null; current = current.BaseType) + { + if (SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, diagnosticAnalyzerType)) + return true; + } + + return false; + } + + public static bool IsSourceGenerator( + INamedTypeSymbol type, + INamedTypeSymbol? incrementalGeneratorType, + INamedTypeSymbol? legacyGeneratorType + ) + { + if (incrementalGeneratorType is null && legacyGeneratorType is null) + return false; + + foreach (var implemented in type.AllInterfaces) + { + if ( + SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, incrementalGeneratorType) + || SymbolEqualityComparer.Default.Equals(implemented.OriginalDefinition, legacyGeneratorType) + ) + return true; + } + + return false; + } + + public static bool HasAttribute(INamedTypeSymbol type, INamedTypeSymbol? attributeType) + { + if (attributeType is null) + return false; + + return type.GetAttributes() + .Any(a => + a.AttributeClass is not null + && SymbolEqualityComparer.Default.Equals(a.AttributeClass.OriginalDefinition, attributeType) + ); + } + + /// + /// True when the type must be instantiated by the Roslyn compiler host: it derives from a + /// component base type, implements a generator interface, or carries a component attribute. + /// + public static bool IsRoslynComponent( + INamedTypeSymbol type, + INamedTypeSymbol? codeFixProviderType, + INamedTypeSymbol? diagnosticAnalyzerType, + INamedTypeSymbol? incrementalGeneratorType, + INamedTypeSymbol? legacyGeneratorType, + INamedTypeSymbol? exportCodeFixProviderAttributeType, + INamedTypeSymbol? diagnosticAnalyzerAttributeType, + INamedTypeSymbol? generatorAttributeType + ) + { + if (IsCodeFixProvider(type, codeFixProviderType)) + return true; + + if (IsDiagnosticAnalyzer(type, diagnosticAnalyzerType)) + return true; + + if (IsSourceGenerator(type, incrementalGeneratorType, legacyGeneratorType)) + return true; + + return HasAttribute(type, exportCodeFixProviderAttributeType) + || HasAttribute(type, diagnosticAnalyzerAttributeType) + || HasAttribute(type, generatorAttributeType); + } + + public static bool IsEffectivelyPublic(INamedTypeSymbol type) + { + if (type.DeclaredAccessibility != Accessibility.Public) + return false; + + if (type.ContainingType is not null) + return IsEffectivelyPublic(type.ContainingType); + + return true; + } + + public static string DescribeKind( + INamedTypeSymbol type, + INamedTypeSymbol? codeFixProviderType, + INamedTypeSymbol? diagnosticAnalyzerType, + INamedTypeSymbol? incrementalGeneratorType, + INamedTypeSymbol? legacyGeneratorType, + INamedTypeSymbol? exportCodeFixProviderAttributeType, + INamedTypeSymbol? generatorAttributeType + ) + { + if (IsCodeFixProvider(type, codeFixProviderType) || HasAttribute(type, exportCodeFixProviderAttributeType)) + return "code fix provider"; + if (IsDiagnosticAnalyzer(type, diagnosticAnalyzerType)) + return "diagnostic analyzer"; + if ( + IsSourceGenerator(type, incrementalGeneratorType, legacyGeneratorType) + || HasAttribute(type, generatorAttributeType) + ) + return "source generator"; + + return "Roslyn component"; + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/AddDiagnosticAnalyzerAttributeCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/AddDiagnosticAnalyzerAttributeCodeFixProvider.cs new file mode 100644 index 0000000..b2413fb --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/AddDiagnosticAnalyzerAttributeCodeFixProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Adds [DiagnosticAnalyzer] to a DiagnosticAnalyzer subclass so the compiler host +/// loads it (fixes PSGFR25). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddDiagnosticAnalyzerAttributeCodeFixProvider))] +public sealed class AddDiagnosticAnalyzerAttributeCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "AddDiagnosticAnalyzer"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR25"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node.FirstAncestorOrSelf() is not { } typeDeclaration) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Add [DiagnosticAnalyzer]", + _ => AddDiagnosticAnalyzerAsync(context.Document, typeDeclaration, context.CancellationToken), + EquivalenceKey + ), + diagnostic + ); + } + } + + static Task AddDiagnosticAnalyzerAsync( + Document document, + TypeDeclarationSyntax typeDeclaration, + CancellationToken cancellationToken + ) + { + var attribute = RoslynComponentFixHelpers.CreateAttribute("DiagnosticAnalyzer", "LanguageNames.CSharp"); + + return RoslynComponentFixHelpers.AddAttributeAsync( + document, + typeDeclaration, + attribute, + ["Microsoft.CodeAnalysis"], + cancellationToken + ); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/AddExportCodeFixProviderAttributeCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/AddExportCodeFixProviderAttributeCodeFixProvider.cs new file mode 100644 index 0000000..382c345 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/AddExportCodeFixProviderAttributeCodeFixProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Adds [ExportCodeFixProvider] to a CodeFixProvider subclass so Visual Studio can +/// discover it (fixes PSGFR24). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddExportCodeFixProviderAttributeCodeFixProvider))] +public sealed class AddExportCodeFixProviderAttributeCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "AddExportCodeFixProvider"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR24"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node.FirstAncestorOrSelf() is not { } typeDeclaration) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Add [ExportCodeFixProvider]", + _ => AddExportCodeFixProviderAsync(context.Document, typeDeclaration, context.CancellationToken), + EquivalenceKey + ), + diagnostic + ); + } + } + + static Task AddExportCodeFixProviderAsync( + Document document, + TypeDeclarationSyntax typeDeclaration, + CancellationToken cancellationToken + ) + { + var attribute = RoslynComponentFixHelpers.CreateAttribute("ExportCodeFixProvider", "LanguageNames.CSharp"); + + return RoslynComponentFixHelpers.AddAttributeAsync( + document, + typeDeclaration, + attribute, + ["Microsoft.CodeAnalysis", "Microsoft.CodeAnalysis.CodeFixes"], + cancellationToken + ); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/AddGeneratorAttributeCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/AddGeneratorAttributeCodeFixProvider.cs new file mode 100644 index 0000000..ae2617d --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/AddGeneratorAttributeCodeFixProvider.cs @@ -0,0 +1,61 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Adds [Generator] to a type implementing a generator interface so it actually runs +/// (fixes PSGFR26). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(AddGeneratorAttributeCodeFixProvider))] +public sealed class AddGeneratorAttributeCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "AddGenerator"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR26"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node.FirstAncestorOrSelf() is not { } typeDeclaration) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Add [Generator]", + _ => AddGeneratorAsync(context.Document, typeDeclaration, context.CancellationToken), + EquivalenceKey + ), + diagnostic + ); + } + } + + static Task AddGeneratorAsync( + Document document, + TypeDeclarationSyntax typeDeclaration, + CancellationToken cancellationToken + ) + { + var attribute = RoslynComponentFixHelpers.CreateAttribute("Generator"); + + return RoslynComponentFixHelpers.AddAttributeAsync( + document, + typeDeclaration, + attribute, + ["Microsoft.CodeAnalysis"], + cancellationToken + ); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/MakeRoslynComponentPublicCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/MakeRoslynComponentPublicCodeFixProvider.cs new file mode 100644 index 0000000..4137c42 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/MakeRoslynComponentPublicCodeFixProvider.cs @@ -0,0 +1,59 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Makes a Roslyn component type public so the compiler host can instantiate it +/// (fixes PSGFR27). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MakeRoslynComponentPublicCodeFixProvider))] +public sealed class MakeRoslynComponentPublicCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "MakePublic"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR27"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node.FirstAncestorOrSelf() is not { } typeDeclaration) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Make public", + _ => MakePublicAsync(context.Document, typeDeclaration, context.CancellationToken), + EquivalenceKey + ), + diagnostic + ); + } + } + + static async Task MakePublicAsync( + Document document, + TypeDeclarationSyntax typeDeclaration, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var updated = RoslynComponentFixHelpers.MakePublic(typeDeclaration); + + return document.WithSyntaxRoot(root.ReplaceNode(typeDeclaration, updated)); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs new file mode 100644 index 0000000..03fb79d --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/PreferStructuredCodeWriterIfBlockCodeFixProvider.cs @@ -0,0 +1,260 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Fixes a block- or scope-opening CodeWriter method whose header writes an if, +/// else if, or else statement by rewriting it to the structured IfBlock, +/// ElseIf, or Else API. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(PreferStructuredCodeWriterIfBlockCodeFixProvider))] +public sealed class PreferStructuredCodeWriterIfBlockCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "UseStructuredConditionalBlock"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR23"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + var semanticModel = await context + .Document.GetSemanticModelAsync(context.CancellationToken) + .ConfigureAwait(false); + if (semanticModel is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + if (node is not InvocationExpressionSyntax invocation) + continue; + + if ( + invocation.Expression + is not MemberAccessExpressionSyntax { Expression: ExpressionSyntax receiver } member + ) + continue; + + if (!TryGetReplacement(invocation, semanticModel, context.CancellationToken, out var replacement)) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Use the structured conditional block API", + _ => Task.FromResult(context.Document.WithSyntaxRoot(root.ReplaceNode(invocation, replacement))), + EquivalenceKey + ), + diagnostic + ); + } + } + + static bool TryGetReplacement( + InvocationExpressionSyntax invocation, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out InvocationExpressionSyntax replacement + ) + { + replacement = invocation; + + if (invocation.Expression is not MemberAccessExpressionSyntax { Expression: ExpressionSyntax receiver } member) + return false; + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count == 0) + return false; + + var methodName = member.Name.Identifier.Text; + var isScopeForm = methodName.EndsWith("Scope", StringComparison.Ordinal); + var isDelimited = methodName is "OpenDelimitedBlockScope" or "OpenDelimitedBlock" or "DelimitedBlock"; + + if ( + !TryGetLiteralText(arguments[0].Expression, semanticModel, cancellationToken, out var header) + || !TryClassifyHeader(header, isScopeForm, out var structuredApi, out var condition) + ) + return false; + + // The delimited forms must use brace delimiters; only then is the header an if-style block. + if ( + isDelimited + && ( + arguments.Count < 3 + || !TryGetLiteralText(arguments[1].Expression, semanticModel, cancellationToken, out var openingToken) + || !TryGetLiteralText(arguments[2].Expression, semanticModel, cancellationToken, out var closingToken) + || openingToken != "{" + || closingToken != "}" + ) + ) + return false; + + ArgumentListSyntax newArgumentList; + if (isScopeForm) + { + newArgumentList = condition is null + ? SyntaxFactory.ArgumentList() + : SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(WithCondition(arguments[0], condition)) + ); + } + else + { + // Action forms drop the header (and any delimiter arguments), keeping only the body callback. + var bodyIndex = isDelimited ? 3 : 1; + if (bodyIndex >= arguments.Count) + return false; + + if (condition is null) + { + newArgumentList = SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(arguments[bodyIndex]) + ); + } + else + { + newArgumentList = SyntaxFactory.ArgumentList( + CreateSeparatedList([WithCondition(arguments[0], condition), arguments[bodyIndex]]) + ); + } + } + + newArgumentList = newArgumentList.WithTriviaFrom(invocation.ArgumentList); + + var newMemberAccess = SyntaxFactory + .MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + receiver, + SyntaxFactory.IdentifierName(structuredApi) + ) + .WithTriviaFrom(member); + + replacement = SyntaxFactory.InvocationExpression(newMemberAccess, newArgumentList).WithTriviaFrom(invocation); + + return true; + } + + static ArgumentSyntax WithCondition(ArgumentSyntax original, string condition) => + original.WithExpression( + SyntaxFactory.LiteralExpression(SyntaxKind.StringLiteralExpression, SyntaxFactory.Literal(condition)) + ); + + static SeparatedSyntaxList CreateSeparatedList(List arguments) + { + if (arguments.Count == 0) + return SyntaxFactory.SeparatedList(); + + var nodesAndTokens = new List((arguments.Count * 2) - 1); + for (var index = 0; index < arguments.Count; index++) + { + if (index > 0) + nodesAndTokens.Add(SyntaxFactory.Token(SyntaxKind.CommaToken)); + nodesAndTokens.Add(arguments[index]); + } + + return SyntaxFactory.SeparatedList(nodesAndTokens); + } + + static bool TryGetLiteralText( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out string text + ) + { + switch (expression) + { + case LiteralExpressionSyntax { RawKind: (int)SyntaxKind.StringLiteralExpression } literal: + text = literal.Token.ValueText; + return true; + + case InterpolatedStringExpressionSyntax interpolated: + { + var builder = new System.Text.StringBuilder(); + foreach (var content in interpolated.Contents) + { + if (content is InterpolatedStringTextSyntax textPart) + builder.Append(textPart.TextToken.ValueText); + } + + text = builder.ToString(); + return true; + } + + default: + break; + } + + var constant = semanticModel.GetConstantValue(expression, cancellationToken); + if (constant.HasValue && constant.Value is string value) + { + text = value; + return true; + } + + text = string.Empty; + return false; + } + + static bool TryClassifyHeader(string header, bool isScopeForm, out string structuredApi, out string? condition) + { + structuredApi = string.Empty; + condition = null; + + var trimmed = header.Trim(); + if (trimmed.StartsWith("else if (", StringComparison.Ordinal)) + { + if (!TryExtractCondition(trimmed, out condition)) + return false; + structuredApi = isScopeForm ? "ElseIfScope" : "ElseIf"; + return true; + } + + if (trimmed == "else") + { + structuredApi = isScopeForm ? "ElseScope" : "Else"; + return true; + } + + if (trimmed.StartsWith("if (", StringComparison.Ordinal)) + { + if (!TryExtractCondition(trimmed, out condition)) + return false; + structuredApi = isScopeForm ? "IfBlockScope" : "IfBlock"; + return true; + } + + return false; + } + + static bool TryExtractCondition(string header, out string condition) + { + condition = string.Empty; + + var trimmed = header; + if (trimmed.EndsWith(")", StringComparison.Ordinal)) + trimmed = trimmed.Substring(0, trimmed.Length - 1).TrimEnd(); + else if (trimmed.EndsWith(");", StringComparison.Ordinal)) + trimmed = trimmed.Substring(0, trimmed.Length - 2).TrimEnd(); + + var openParen = trimmed.IndexOf('('); + if (openParen < 0) + return false; + + var inner = trimmed.Substring(openParen + 1).Trim(); + if (inner.Length == 0) + return false; + + condition = inner; + return true; + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/RemoveOrphanedFixableDiagnosticIdCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/RemoveOrphanedFixableDiagnosticIdCodeFixProvider.cs new file mode 100644 index 0000000..8e9368e --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/RemoveOrphanedFixableDiagnosticIdCodeFixProvider.cs @@ -0,0 +1,63 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Removes a FixableDiagnosticIds entry that no analyzer in the compilation produces +/// (fixes PSGFR28). +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(RemoveOrphanedFixableDiagnosticIdCodeFixProvider))] +public sealed class RemoveOrphanedFixableDiagnosticIdCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "RemoveOrphanedDiagnostic"; + + public override ImmutableArray FixableDiagnosticIds => ["PSGFR28"]; + + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + return; + + foreach (var diagnostic in context.Diagnostics) + { + var node = root.FindNode(diagnostic.Location.SourceSpan); + var literal = + node.DescendantNodesAndSelf().OfType().FirstOrDefault() + ?? node.FirstAncestorOrSelf(); + if (literal is null) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Remove unused diagnostic ID", + _ => RemoveOrphanedIdAsync(context.Document, literal, context.CancellationToken), + EquivalenceKey + ), + diagnostic + ); + } + } + + static async Task RemoveOrphanedIdAsync( + Document document, + LiteralExpressionSyntax literal, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + SyntaxNode removable = literal.Parent is ExpressionElementSyntax element ? element : literal; + var updatedRoot = root.RemoveNode(removable, SyntaxRemoveOptions.KeepNoTrivia); + + return updatedRoot is null ? document : document.WithSyntaxRoot(updatedRoot); + } +} diff --git a/src/src/SourceGeneratorFramework.CodeFixers/RoslynComponentFixHelpers.cs b/src/src/SourceGeneratorFramework.CodeFixers/RoslynComponentFixHelpers.cs new file mode 100644 index 0000000..b0d8fdd --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/RoslynComponentFixHelpers.cs @@ -0,0 +1,144 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Shared syntax helpers for the Roslyn component setup code fix providers. +/// +static class RoslynComponentFixHelpers +{ + public static AttributeSyntax CreateAttribute(string name, string? argumentExpression = null) + { + if (argumentExpression is null) + return SyntaxFactory.Attribute(SyntaxFactory.ParseName(name)); + + return SyntaxFactory.Attribute( + SyntaxFactory.ParseName(name), + SyntaxFactory.AttributeArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.AttributeArgument(SyntaxFactory.ParseExpression(argumentExpression)) + ) + ) + ); + } + + /// + /// Adds to the front of 's + /// attribute lists and inserts any missing imports. + /// + public static async Task AddAttributeAsync( + Document document, + TypeDeclarationSyntax typeDeclaration, + AttributeSyntax attribute, + ImmutableArray requiredNamespaces, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + if (root is null) + return document; + + var attributeList = SyntaxFactory.AttributeList(SyntaxFactory.SingletonSeparatedList(attribute)); + var updatedType = typeDeclaration.WithAttributeLists(typeDeclaration.AttributeLists.Insert(0, attributeList)); + var updatedRoot = root.ReplaceNode(typeDeclaration, updatedType); + + if (requiredNamespaces.IsDefaultOrEmpty) + return document.WithSyntaxRoot(updatedRoot); + + return document.WithSyntaxRoot(AddMissingUsings(updatedRoot, requiredNamespaces) ?? updatedRoot); + } + + static SyntaxNode? AddMissingUsings(SyntaxNode root, ImmutableArray requiredNamespaces) + { + var container = FindUsingContainer(root); + if (container is null) + return null; + + var existing = container switch + { + CompilationUnitSyntax compilationUnit => compilationUnit.Usings, + BaseNamespaceDeclarationSyntax @namespace => @namespace.Usings, + _ => default, + }; + + var missing = requiredNamespaces + .Where(namespaceName => !existing.Any(usingDirective => usingDirective.Name?.ToString() == namespaceName)) + .Select(namespaceName => + SyntaxFactory.UsingDirective(SyntaxFactory.ParseName(namespaceName)).NormalizeWhitespace() + ) + .ToList(); + + if (missing.Count == 0) + return root; + + return container switch + { + CompilationUnitSyntax compilationUnit => root.ReplaceNode( + compilationUnit, + compilationUnit.WithUsings(compilationUnit.Usings.AddRange(missing)) + ), + BaseNamespaceDeclarationSyntax @namespace => root.ReplaceNode( + @namespace, + @namespace.WithUsings(@namespace.Usings.AddRange(missing)) + ), + _ => root, + }; + } + + static SyntaxNode? FindUsingContainer(SyntaxNode root) + { + if (root is CompilationUnitSyntax compilationUnit) + { + if (compilationUnit.Usings.Any()) + return compilationUnit; + + if (compilationUnit.Members.FirstOrDefault() is FileScopedNamespaceDeclarationSyntax fileScoped) + return fileScoped; + + return compilationUnit; + } + + return null; + } + + /// + /// Replaces the type's existing accessibility modifier with public, or inserts one when + /// the type has none (types default to internal). + /// + public static TypeDeclarationSyntax MakePublic(TypeDeclarationSyntax typeDeclaration) + { + var modifiers = typeDeclaration.Modifiers; + var accessibilityIndex = -1; + + for (var i = 0; i < modifiers.Count; i++) + { + if (IsAccessibilityModifier(modifiers[i])) + { + accessibilityIndex = i; + break; + } + } + + var publicToken = SyntaxFactory.Token(SyntaxKind.PublicKeyword); + + if (accessibilityIndex >= 0) + { + var updated = modifiers.Replace(modifiers[accessibilityIndex], publicToken); + return typeDeclaration.WithModifiers(updated); + } + + var inserted = modifiers.Insert(0, publicToken); + return typeDeclaration.WithModifiers(inserted); + } + + static bool IsAccessibilityModifier(SyntaxToken token) => + token.Kind() + is SyntaxKind.PublicKeyword + or SyntaxKind.InternalKeyword + or SyntaxKind.PrivateKeyword + or SyntaxKind.ProtectedKeyword + or SyntaxKind.FileKeyword; +} diff --git a/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs b/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs index d4d967d..eb184c0 100644 --- a/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs +++ b/src/src/SourceGeneratorFramework.ExampleGenerator/CodeWriterSampleEmitter.cs @@ -116,6 +116,23 @@ options with ) ) body.NetConditionalReturn("Value: {_value}"); + + body.Method( + "Categorize", + TypeIdentity.Create().AsTypeReference(), + TypeDeclarationAccessibility.Public, + options => + options with + { + IsStatic = true, + Parameters = [new("value", TypeIdentity.Create().AsTypeReference())], + }, + methodBody => + methodBody + .IfBlock("value < 0", branch => branch.Return("\"negative\"")) + .ElseIf("value == 0", branch => branch.Return("\"zero\"")) + .Else(branch => branch.Return("\"positive\"")) + ); } ); diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index fb22957..edde3c5 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -874,6 +874,11 @@ The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGener | `PSGFR20` | Prefer the minimal `CodeWriter` overloads over constructing `*DeclarationOptions` values manually. | | `PSGFR21` | Prefer `HashDefines`/`HashDefinesScope` for `#if`/`#endif` conditional-compilation directives. | | `PSGFR22` | Prefer `PragmaDisable`/`OpenPragmasScope` for `#pragma warning` directives. | +| `PSGFR24` | `CodeFixProvider` is not marked `[ExportCodeFixProvider]`; Visual Studio will never discover it. | +| `PSGFR25` | `DiagnosticAnalyzer` is not marked `[DiagnosticAnalyzer]`; it will never run. | +| `PSGFR26` | A generator type is not marked `[Generator]`; it will never run. | +| `PSGFR27` | A Roslyn component type is not public; the compiler host cannot instantiate it. | +| `PSGFR28` | `FixableDiagnosticIds` references a diagnostic ID no analyzer in the compilation produces; the fix will never be shown. | ## License diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index 03e68ae..5575ba4 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -32,6 +32,12 @@ ReferenceOutputAssembly="false" OutputItemType="Analyzer" /> + + /// Writes an else if block following an if or another else if and invokes a + /// callback for its body. + /// + /// The else-if condition. + /// The action to invoke for the body of the else-if block. + /// The current writer. + /// Thrown if the condition is null or whitespace. + /// Thrown if the bodyWriter is null. + /// writer.IfBlock("enabled", body => body.Return("value")).ElseIf("retry", body => body.Return("retry")); + public CodeWriter ElseIf(string condition, Action bodyWriter) + { + if (bodyWriter is null) + throw new ArgumentNullException(nameof(bodyWriter)); + + using (ElseIfScope(condition)) + bodyWriter(this); + + return this; + } + + /// + /// Writes an else if block and returns its body scope. + /// + /// The else-if condition. + /// The else-if body scope. + /// using (writer.IfBlockScope("enabled")) writer.Return("value"); using (writer.ElseIfScope("retry")) writer.Return("retry"); + public BlockScope ElseIfScope(string condition) + { + ValidateStatementPart(condition, nameof(condition)); + EnsureNewLine(); + Write("else if ("); + Expression(condition, expressionWriter: null); + Line(")"); + return OpenBlockScope(); + } + /// /// Writes a foreach statement and invokes a callback for its body. /// diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingDiagnosticAnalyzerAttributeAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingDiagnosticAnalyzerAttributeAnalyzerTests.cs new file mode 100644 index 0000000..ec5849b --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingDiagnosticAnalyzerAttributeAnalyzerTests.cs @@ -0,0 +1,61 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class MissingDiagnosticAnalyzerAttributeAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task DiagnosticAnalyzer_WithoutAttribute_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; + + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => []; + public override void Initialize(AnalysisContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(MissingDiagnosticAnalyzerAttributeAnalyzer.Rule.Id); + } + + [Test] + public async Task DiagnosticAnalyzer_WithAttribute_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => []; + public override void Initialize(AnalysisContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task UnrelatedType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + public sealed class NotAComponent + { + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingExportCodeFixProviderAttributeAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingExportCodeFixProviderAttributeAnalyzerTests.cs new file mode 100644 index 0000000..7d04bbf --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingExportCodeFixProviderAttributeAnalyzerTests.cs @@ -0,0 +1,69 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class MissingExportCodeFixProviderAttributeAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task CodeFixProvider_WithoutExportAttribute_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(MissingExportCodeFixProviderAttributeAnalyzer.Rule.Id); + } + + [Test] + public async Task CodeFixProvider_WithExportAttribute_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(MyFixer))] + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task UnrelatedType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + public sealed class NotAComponent + { + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) => base.OnBeforeRun(sources, options.WithAdditionalAssemblyTypes(typeof(CodeFixProvider)), cancellationToken); +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingGeneratorAttributeAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingGeneratorAttributeAnalyzerTests.cs new file mode 100644 index 0000000..914a0a0 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/MissingGeneratorAttributeAnalyzerTests.cs @@ -0,0 +1,76 @@ +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class MissingGeneratorAttributeAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task IncrementalGenerator_WithoutAttribute_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + public sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(MissingGeneratorAttributeAnalyzer.Rule.Id); + } + + [Test] + public async Task LegacyGenerator_WithoutAttribute_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + public sealed class MyGenerator : ISourceGenerator + { + public void Initialize(GeneratorInitializationContext context) { } + public void Execute(GeneratorExecutionContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(MissingGeneratorAttributeAnalyzer.Rule.Id); + } + + [Test] + public async Task IncrementalGenerator_WithAttribute_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + [Generator] + public sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task UnrelatedType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + public sealed class NotAComponent + { + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/NonPublicRoslynComponentAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/NonPublicRoslynComponentAnalyzerTests.cs new file mode 100644 index 0000000..1981a8e --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/NonPublicRoslynComponentAnalyzerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class NonPublicRoslynComponentAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + [Test] + public async Task InternalGenerator_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + [Generator] + internal sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + } + + [Test] + public async Task InternalAnalyzer_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + internal sealed class MyAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => []; + public override void Initialize(AnalysisContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + } + + [Test] + public async Task InternalCodeFixProvider_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + [ExportCodeFixProvider(LanguageNames.CSharp)] + internal sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + } + + [Test] + public async Task NestedInInternalType_ReportsDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + internal sealed class Container + { + [Generator] + public sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + } + + [Test] + public async Task PublicGenerator_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + [Generator] + public sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task UnrelatedInternalType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + internal sealed class NotAComponent + { + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) => base.OnBeforeRun(sources, options.WithAdditionalAssemblyTypes(typeof(CodeFixProvider)), cancellationToken); +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/OrphanedFixableDiagnosticIdAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/OrphanedFixableDiagnosticIdAnalyzerTests.cs new file mode 100644 index 0000000..4f9e0e4 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/OrphanedFixableDiagnosticIdAnalyzerTests.cs @@ -0,0 +1,110 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class OrphanedFixableDiagnosticIdAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + const string AnalyzerAndFixer = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public static readonly DiagnosticDescriptor Rule = new( + "MY001", + "Title", + "Message", + "Category", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + public override void Initialize(AnalysisContext context) { } + } + + [ExportCodeFixProvider(LanguageNames.CSharp)] + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001", "ORPHAN"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + [Test] + public async Task Fixer_WithOrphanedDiagnosticId_ReportsDiagnostic(CancellationToken cancellationToken) + { + var result = await AnalyzeAsync(AnalyzerAndFixer, cancellationToken); + + await Assert.That(result).HasDiagnostics(1); + await Assert.That(result).HasDiagnostic(OrphanedFixableDiagnosticIdAnalyzer.Rule.Id); + } + + [Test] + public async Task Fixer_WithMatchingDiagnosticId_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public static readonly DiagnosticDescriptor Rule = new( + "MY001", + "Title", + "Message", + "Category", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + public override void Initialize(AnalysisContext context) { } + } + + [ExportCodeFixProvider(LanguageNames.CSharp)] + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task Fixer_WithoutSourceAnalyzer_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + [ExportCodeFixProvider(LanguageNames.CSharp)] + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["ORPHAN"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await AnalyzeAsync(source, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + protected override AnalyzerTestOptions OnBeforeRun( + IEnumerable sources, + AnalyzerTestOptions options, + CancellationToken cancellationToken + ) => base.OnBeforeRun(sources, options.WithAdditionalAssemblyTypes(typeof(CodeFixProvider)), cancellationToken); +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterIfBlockAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterIfBlockAnalyzerTests.cs new file mode 100644 index 0000000..0fedcc8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterIfBlockAnalyzerTests.cs @@ -0,0 +1,275 @@ +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class PreferStructuredCodeWriterIfBlockAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + static readonly AnalyzerTestOptions Options = new() + { + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }; + + [Test] + public async Task OpenBlockScope_WithIfHeader_SuggestsIfBlockScope(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("if (enabled)")) + writer.Return(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert + .That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)) + .Contains("IfBlockScope"); + } + + [Test] + public async Task OpenBlock_WithIfHeader_SuggestsIfBlock(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.OpenBlock("if (enabled)", body => body.Return()); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("IfBlock"); + } + + [Test] + public async Task OpenBlockScope_WithElseIfHeader_SuggestsElseIfScope(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("else if (retry)")) + writer.Return(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert + .That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)) + .Contains("ElseIfScope"); + } + + [Test] + public async Task OpenBlock_WithElseHeader_SuggestsElse(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.OpenBlock("else", body => body.Return()); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("Else"); + } + + [Test] + public async Task DelimitedBlock_WithIfHeader_SuggestsIfBlock(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.DelimitedBlock("if (enabled)", "{", "}", body => body.Return()); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("IfBlock"); + } + + [Test] + public async Task OpenDelimitedBlockScope_WithIfHeader_SuggestsIfBlockScope(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenDelimitedBlockScope("if (enabled)", "{", "}")) + writer.Return(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert + .That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)) + .Contains("IfBlockScope"); + } + + [Test] + public async Task OpenBlockScope_WithForHeader_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("for (int i = 0; i < 3; i++)")) + writer.Return(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task OpenBlockScope_WithNullHeader_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope(null)) + writer.Return(); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task IfBlock_StructuredCall_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.IfBlock("enabled", body => body.Return()); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task OpenBlockScope_OnOtherType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + class Emitter + { + public void Emit() + { + var writer = new OtherWriter(); + using (writer.OpenBlockScope("if (enabled)")) + { + } + } + } + + class OtherWriter + { + public System.IDisposable OpenBlockScope(string header) => null!; + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs index 84cdc8f..18afce9 100644 --- a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/PreferStructuredCodeWriterStatementAnalyzerTests.cs @@ -113,6 +113,81 @@ await Assert .Contains("MethodCall"); } + [Test] + public async Task Line_WithIfStatement_SuggestsIfBlock(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("if (enabled)"); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterStatementAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("IfBlock"); + } + + [Test] + public async Task Line_WithElseIfStatement_SuggestsElseIf(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("else if (retry)"); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterStatementAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("ElseIf"); + } + + [Test] + public async Task Line_WithElseStatement_SuggestsElse(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Line("else"); + } + } + """; + + // Act + var result = await AnalyzeAsync(source, Options, cancellationToken); + + // Assert + var diagnostic = await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterStatementAnalyzer.Rule.Id); + await Assert.That(diagnostic.GetMessage(System.Globalization.CultureInfo.InvariantCulture)).Contains("Else"); + } + [Test] public async Task Line_WithReceiverMethodCall_SuggestsMethodCallOn(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddDiagnosticAnalyzerAttributeCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddDiagnosticAnalyzerAttributeCodeFixProviderTests.cs new file mode 100644 index 0000000..cedd5a8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddDiagnosticAnalyzerAttributeCodeFixProviderTests.cs @@ -0,0 +1,34 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class AddDiagnosticAnalyzerAttributeCodeFixProviderTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task AddsDiagnosticAnalyzerAttribute(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.Diagnostics; + + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public override ImmutableArray SupportedDiagnostics => []; + public override void Initialize(AnalysisContext context) { } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = AddDiagnosticAnalyzerAttributeCodeFixProvider.EquivalenceKey }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(MissingDiagnosticAnalyzerAttributeAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("[DiagnosticAnalyzer(LanguageNames.CSharp)]"); + await Assert.That(result.FixedSource).Contains("public sealed class MyAnalyzer : DiagnosticAnalyzer"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddExportCodeFixProviderAttributeCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddExportCodeFixProviderAttributeCodeFixProviderTests.cs new file mode 100644 index 0000000..c3a7131 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddExportCodeFixProviderAttributeCodeFixProviderTests.cs @@ -0,0 +1,43 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class AddExportCodeFixProviderAttributeCodeFixProviderTests + : TUnitCodeFixTestBase< + MissingExportCodeFixProviderAttributeAnalyzer, + AddExportCodeFixProviderAttributeCodeFixProvider + > +{ + [Test] + public async Task AddsExportCodeFixProviderAttribute(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Immutable; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = AddExportCodeFixProviderAttributeCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeFixProvider)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(MissingExportCodeFixProviderAttributeAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("[ExportCodeFixProvider(LanguageNames.CSharp)]"); + await Assert.That(result.FixedSource).Contains("public sealed class MyFixer : CodeFixProvider"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddGeneratorAttributeCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddGeneratorAttributeCodeFixProviderTests.cs new file mode 100644 index 0000000..7d8500f --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/AddGeneratorAttributeCodeFixProviderTests.cs @@ -0,0 +1,32 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class AddGeneratorAttributeCodeFixProviderTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task AddsGeneratorAttribute(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + public sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = AddGeneratorAttributeCodeFixProvider.EquivalenceKey }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(MissingGeneratorAttributeAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("[Generator]"); + await Assert.That(result.FixedSource).Contains("public sealed class MyGenerator : IIncrementalGenerator"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/MakeRoslynComponentPublicCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/MakeRoslynComponentPublicCodeFixProviderTests.cs new file mode 100644 index 0000000..2703d0b --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/MakeRoslynComponentPublicCodeFixProviderTests.cs @@ -0,0 +1,89 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class MakeRoslynComponentPublicCodeFixProviderTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task InternalGenerator_BecomesPublic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + [Generator] + internal sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = MakeRoslynComponentPublicCodeFixProvider.EquivalenceKey }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("[Generator]"); + await Assert.That(result.FixedSource).Contains("public sealed class MyGenerator"); + await Assert.That(result.FixedSource).DoesNotContain("internal sealed class MyGenerator"); + } + + [Test] + public async Task GeneratorWithoutModifier_BecomesPublic(CancellationToken cancellationToken) + { + const string source = """ + using Microsoft.CodeAnalysis; + + [Generator] + sealed class MyGenerator : IIncrementalGenerator + { + public void Initialize(IncrementalGeneratorInitializationContext context) { } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions { EquivalenceKey = MakeRoslynComponentPublicCodeFixProvider.EquivalenceKey }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("public sealed class MyGenerator"); + } + + [Test] + public async Task InternalCodeFixProvider_BecomesPublic(CancellationToken cancellationToken) + { + const string source = """ + using System.Collections.Immutable; + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + + [ExportCodeFixProvider(LanguageNames.CSharp)] + internal sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = MakeRoslynComponentPublicCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeFixProvider)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(NonPublicRoslynComponentAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("public sealed class MyFixer"); + await Assert.That(result.FixedSource).DoesNotContain("internal sealed class MyFixer"); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterIfBlockCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterIfBlockCodeFixProviderTests.cs new file mode 100644 index 0000000..f47281d --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/PreferStructuredCodeWriterIfBlockCodeFixProviderTests.cs @@ -0,0 +1,357 @@ +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class PreferStructuredCodeWriterIfBlockCodeFixProviderTests + : TUnitCodeFixTestBase +{ + [Test] + public async Task OpenBlockScope_WithIfHeader_UsesIfBlockScope(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("if (enabled)")) + writer.Return(); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("using (writer.IfBlockScope(\"enabled\"))"); + } + + [Test] + public async Task OpenBlock_WithIfHeader_UsesIfBlock(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.OpenBlock("if (enabled)", body => body.Return()); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.IfBlock(\"enabled\", body => body.Return())"); + } + + [Test] + public async Task Block_WithIfHeader_UsesIfBlock(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.Block("if (enabled)", body => body.Return()); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.IfBlock(\"enabled\", body => body.Return())"); + } + + [Test] + public async Task OpenDelimitedBlock_WithIfHeaderAndBraceDelimiters_UsesIfBlock(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.OpenDelimitedBlock("if (enabled)", "{", "}", body => body.Return()); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.IfBlock(\"enabled\", body => body.Return())"); + } + + [Test] + public async Task OpenDelimitedBlockScope_WithIfHeaderAndBraceDelimiters_UsesIfBlockScope( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenDelimitedBlockScope("if (enabled)", "{", "}")) + writer.Return(); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("using (writer.IfBlockScope(\"enabled\"))"); + } + + [Test] + public async Task OpenBlockScope_WithElseIfHeader_UsesElseIfScope(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("else if (retry)")) + writer.Return(); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("using (writer.ElseIfScope(\"retry\"))"); + } + + [Test] + public async Task OpenBlockScope_WithElseHeader_UsesElseScope(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("else")) + writer.Return(); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("using (writer.ElseScope())"); + } + + [Test] + public async Task OpenBlock_WithElseHeader_UsesElse(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.OpenBlock("else", body => body.Return()); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.Else(body => body.Return())"); + } + + [Test] + public async Task OpenDelimitedBlock_WithElseHeaderAndBraceDelimiters_UsesElse(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + writer.DelimitedBlock("else", "{", "}", body => body.Return()); + } + } + """; + + var result = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(PreferStructuredCodeWriterIfBlockAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("writer.Else(body => body.Return())"); + } + + [Test] + public async Task OpenDelimitedBlockScope_WithIfHeaderAndNonBraceDelimiters_DoesNotRegisterFix( + CancellationToken cancellationToken + ) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenDelimitedBlockScope("if (enabled)", "(", ");")) + writer.Return(); + } + } + """; + + await Assert + .That(async () => + { + _ = await ApplyCodeFixAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + }) + .Throws(); + } + + [Test] + public async Task FixAll_WithMultipleIfScopes_FixesAll(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + class Emitter + { + public void Emit() + { + var writer = new CodeWriter(new GenerationSettings("G")); + using (writer.OpenBlockScope("if (first)")) + writer.Return(); + using (writer.OpenBlockScope("if (second)")) + writer.Return(); + } + } + """; + + var result = await ApplyFixAllAsync( + source, + new CodeFixTestOptions + { + EquivalenceKey = PreferStructuredCodeWriterIfBlockCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(GenerationSettings)], + }, + cancellationToken + ); + + await Assert.That(result.Diagnostics).IsNotEmpty(); + foreach (var fixedSource in result.FixedSources.Values) + { + await Assert.That(fixedSource).Contains("using (writer.IfBlockScope(\"first\"))"); + await Assert.That(fixedSource).Contains("using (writer.IfBlockScope(\"second\"))"); + } + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/RemoveOrphanedFixableDiagnosticIdCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/RemoveOrphanedFixableDiagnosticIdCodeFixProviderTests.cs new file mode 100644 index 0000000..42c1aa2 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/RemoveOrphanedFixableDiagnosticIdCodeFixProviderTests.cs @@ -0,0 +1,57 @@ +using Microsoft.CodeAnalysis.CodeFixes; +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class RemoveOrphanedFixableDiagnosticIdCodeFixProviderTests + : TUnitCodeFixTestBase +{ + const string Source = """ + using Microsoft.CodeAnalysis; + using Microsoft.CodeAnalysis.CodeFixes; + using Microsoft.CodeAnalysis.Diagnostics; + + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MyAnalyzer : DiagnosticAnalyzer + { + public static readonly DiagnosticDescriptor Rule = new( + "MY001", + "Title", + "Message", + "Category", + DiagnosticSeverity.Warning, + isEnabledByDefault: true + ); + + public override ImmutableArray SupportedDiagnostics => [Rule]; + public override void Initialize(AnalysisContext context) { } + } + + [ExportCodeFixProvider(LanguageNames.CSharp)] + public sealed class MyFixer : CodeFixProvider + { + public override ImmutableArray FixableDiagnosticIds => ["MY001", "ORPHAN"]; + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; + } + """; + + [Test] + public async Task RemovesOrphanedDiagnosticId(CancellationToken cancellationToken) + { + var result = await ApplyCodeFixAsync( + Source, + new CodeFixTestOptions + { + EquivalenceKey = RemoveOrphanedFixableDiagnosticIdCodeFixProvider.EquivalenceKey, + AdditionalAssemblyTypes = [typeof(CodeFixProvider)], + }, + cancellationToken + ); + + await Assert.That(result).HasDiagnostic(OrphanedFixableDiagnosticIdAnalyzer.Rule.Id); + await Assert.That(result.FixedSource).Contains("\"MY001\""); + await Assert.That(result.FixedSource).DoesNotContain("\"ORPHAN\""); + } +} diff --git a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs index 010d52d..32ddf36 100644 --- a/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs +++ b/src/tests/SourceGeneratorFramework.ExampleGenerator.UnitTests/CodeWriterSampleGeneratorTests.cs @@ -27,6 +27,7 @@ public class SampleTarget { } await Assert.That(result).HasGeneratedProperty("DefaultAccessibility"); await Assert.That(result).HasGeneratedMethod("Describe"); await Assert.That(result).HasGeneratedMethod("Format"); + await Assert.That(result).HasGeneratedMethod("Categorize"); var defaultAccessibility = await Assert.That(result).HasGeneratedProperty("DefaultAccessibility"); await Assert.That(defaultAccessibility.Modifiers.ToString()).IsEqualTo("public"); @@ -62,6 +63,27 @@ public class SampleTarget { } await Assert.That(constructor.ToString()).Contains("_value = value;"); } + [Test] + public async Task GenerateSample_EmitsConditionalBranches(CancellationToken cancellationToken) + { + // Arrange + const string source = """ + [GenerateCodeWriterSample] + public class SampleTarget { } + """; + + // Act + var result = await GenerateAsync(source, cancellationToken); + + // Assert + var categorize = await Assert.That(result).HasGeneratedMethod("Categorize"); + var categorizeText = categorize.ToString(); + await Assert.That(categorizeText).Contains("if (value < 0)\n\t\t{\n\t\t\treturn \"negative\";\n\t\t}"); + await Assert.That(categorizeText).Contains("else if (value == 0)"); + await Assert.That(categorizeText).Contains("return \"zero\";"); + await Assert.That(categorizeText).Contains("return \"positive\";"); + } + [Test] public async Task GenerateSample_EmitsNetConditionalReturn(CancellationToken cancellationToken) { diff --git a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs index d57d696..a1e9095 100644 --- a/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs +++ b/src/tests/SourceGeneratorShared.UnitTests/CodeWriterTests.cs @@ -2539,6 +2539,120 @@ await Assert .IsEqualTo("if (value != null\n\t&& value.IsValid)\n{\n\treturn value;\n}\n"); } + [Test] + public async Task ElseIf_WritesChainedIfElseIfElse() + { + var writer = CodeWriterFactory.ForTests(); + + writer + .IfBlock("value is null", body => body.Return("null")) + .ElseIf("value is 0", body => body.Return("zero")) + .Else(body => body.Return("value")); + + await Assert + .That(writer.ToString()) + .IsEqualTo( + "if (value is null)\n" + + "{\n" + + "\treturn null;\n" + + "}\n" + + "else if (value is 0)\n" + + "{\n" + + "\treturn zero;\n" + + "}\n" + + "else\n" + + "{\n" + + "\treturn value;\n" + + "}\n" + ); + } + + [Test] + public async Task ElseIf_WritesMultipleElseIfBranches() + { + var writer = CodeWriterFactory.ForTests(); + + writer + .IfBlock("kind is 1", body => body.Return("\"one\"")) + .ElseIf("kind is 2", body => body.Return("\"two\"")) + .ElseIf("kind is 3", body => body.Return("\"three\"")); + + await Assert + .That(writer.ToString()) + .IsEqualTo( + "if (kind is 1)\n" + + "{\n" + + "\treturn \"one\";\n" + + "}\n" + + "else if (kind is 2)\n" + + "{\n" + + "\treturn \"two\";\n" + + "}\n" + + "else if (kind is 3)\n" + + "{\n" + + "\treturn \"three\";\n" + + "}\n" + ); + } + + [Test] + public async Task ElseIfScope_WritesConditionAndScopedBody() + { + var writer = CodeWriterFactory.ForTests(); + + using (writer.IfBlockScope("enabled")) + writer.Return("value"); + using (writer.ElseIfScope("retry")) + writer.Return("retry"); + + await Assert + .That(writer.ToString()) + .IsEqualTo("if (enabled)\n{\n\treturn value;\n}\nelse if (retry)\n{\n\treturn retry;\n}\n"); + } + + [Test] + public async Task ElseIf_WritesMultilineConditionWithContinuationIndent() + { + var writer = CodeWriterFactory.ForTests(); + + writer + .IfBlock("value != null", body => body.Return("value")) + .ElseIf("value < 0\n|| value > 100", body => body.Return("\"invalid\"")); + + await Assert + .That(writer.ToString()) + .IsEqualTo( + "if (value != null)\n" + + "{\n" + + "\treturn value;\n" + + "}\n" + + "else if (value < 0\n" + + "\t|| value > 100)\n" + + "{\n" + + "\treturn \"invalid\";\n" + + "}\n" + ); + } + + [Test] + public async Task ElseIf_GivenWhitespaceCondition_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert.That(() => writer.ElseIf(" ", _ => { })).Throws(); + } + + [Test] + public async Task ElseIf_GivenNullBody_Throws() + { + var writer = CodeWriterFactory.ForTests(); + + await Assert + .That(() => writer.ElseIf("enabled", null!)) + .Throws() + .WithParameterName("bodyWriter"); + } + [Test] public async Task Assignment_WritesDeclarationAndMultilineInitializer() { From f9e52ae4c197bf05e8b2065ba32c7cdf67c7c902 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Sat, 5 Sep 2026 09:19:44 +0100 Subject: [PATCH 2/2] build: removed version for purview build --- .github/workflows/pr.yml | 3 +-- .github/workflows/release.yml | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index a0969d9..f3632c2 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -13,7 +13,6 @@ jobs: name: Build and test uses: purview-dev/build/.github/workflows/purview-build.yml@main with: - build-version: "0.2.1" run-pack: true validate-pack: true - secrets: inherit \ No newline at end of file + secrets: inherit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 97abb5f..5f4c7eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,7 +13,6 @@ jobs: name: Release packages uses: purview-dev/build/.github/workflows/purview-release.yml@main with: - build-version: "0.2.1" release-mode: NuGet release-branch: main - secrets: inherit \ No newline at end of file + secrets: inherit