diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index a27b86d..740c78c 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -51,6 +51,7 @@ The analyzers enforce two families of rules: | `PSGFR37` | One extension class per receiver type; split classes that extend multiple types. | | `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | | `PSGFR39` | A non-packable Roslyn component that explicitly opts out of the default self-contained analyzer output (`PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false`) while embedding the framework, otherwise the package embeds the loose framework DLL under `analyzers/`. | +| `PSGFR40` | In Roslyn components (`IsRoslynComponent=true`), qualify XML doc `cref` references to SGF public types with `global::Purview.SourceGeneratorFramework...`. | ## Type-library and attribute-model diagnostics @@ -80,6 +81,7 @@ analyzer rules above, including: - `PreferStructuredCodeWriterIfBlockCodeFixProvider` — rewrites raw `if`/`else if`/`else` block text to the structured `IfBlock`/`ElseIf`/`Else` APIs (`PSGFR23`). - `CodeWriterToStringCodeFixProvider` — replaces embedded `CodeWriter` string interpolation (`PSGFR29`). +- `QualifyFrameworkCrefCodeFixProvider` — rewrites SGF XML doc `cref` targets to fully qualified `global::Purview.SourceGeneratorFramework...` names (`PSGFR40`). - `AttributeDataModelSymbolPropertyCodeFixProvider` — fixes attribute-data-model symbol properties. - `ReorganizeExtensionClassCodeFixProvider` — renames (`PSGFR35`), splits multi-receiver classes (`PSGFR37`), moves the class under `Extensions/{ReceiverNamespace}/`, and updates referencing files @@ -113,4 +115,4 @@ reload the project for the fixes to appear. ## License -This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. \ No newline at end of file +This documentation is part of the MIT-licensed `Purview.SourceGeneratorFramework` project. diff --git a/package.json b/package.json index ef06027..168f5f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-sourcegenerator-framework", - "version": "1.0.0-prerelease.49", + "version": "1.0.0-prerelease.50", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/src/SourceGeneratorFramework.Analyzers/AmbiguousFrameworkCrefAnalyzer.cs b/src/src/SourceGeneratorFramework.Analyzers/AmbiguousFrameworkCrefAnalyzer.cs new file mode 100644 index 0000000..19f47aa --- /dev/null +++ b/src/src/SourceGeneratorFramework.Analyzers/AmbiguousFrameworkCrefAnalyzer.cs @@ -0,0 +1,229 @@ +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 unqualified XML documentation cref references to SGF public types in Roslyn components. +/// Qualifying these cref targets avoids the duplicate-framework ambiguity that can arise when the +/// same SGF type is visible through multiple assembly identities. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class AmbiguousFrameworkCrefAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "PSGFR40"; + internal const string QualifiedTypePropertyName = "QualifiedTypeName"; + const string FrameworkAssemblyName = "Purview.SourceGeneratorFramework"; + + static readonly DiagnosticDescriptor Rule = new( + DiagnosticId, + "Qualify SGF cref with global::", + "XML documentation cref '{0}' refers to SGF type '{1}'; qualify the cref with 'global::'", + "Purview.SourceGeneratorFramework", + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Detects unqualified XML documentation cref references to Purview.SourceGeneratorFramework public types in Roslyn components so they can be rewritten to a fully qualified global:: name." + ); + + 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(static compilationStartContext => + { + var analyzerOptions = compilationStartContext.Options.AnalyzerConfigOptionsProvider.GlobalOptions; + + if (!IsExplicitlyTrue(analyzerOptions, "IsRoslynComponent")) + return; + + var frameworkAssembly = ResolveFrameworkAssembly(compilationStartContext.Compilation); + if (frameworkAssembly is null) + return; + + var publicFrameworkTypes = CollectPublicFrameworkTypes(frameworkAssembly.GlobalNamespace); + if (publicFrameworkTypes.IsEmpty) + return; + + compilationStartContext.RegisterSemanticModelAction(context => + AnalyzeSemanticModel(context, publicFrameworkTypes) + ); + }); + } + + static void AnalyzeSemanticModel( + SemanticModelAnalysisContext context, + ImmutableDictionary publicFrameworkTypes + ) + { + var semanticModel = context.SemanticModel; + var root = semanticModel.SyntaxTree.GetRoot(context.CancellationToken); + + foreach (var trivia in root.DescendantTrivia(descendIntoTrivia: true)) + { + if ( + !trivia.IsKind(SyntaxKind.SingleLineDocumentationCommentTrivia) + && !trivia.IsKind(SyntaxKind.MultiLineDocumentationCommentTrivia) + ) + { + continue; + } + + foreach (var cref in trivia.GetStructure()?.DescendantNodesAndSelf().OfType() ?? []) + { + if (IsAlreadyQualified(cref.Cref)) + continue; + + if (!TryGetUnqualifiedTypeName(cref.Cref, out var shortTypeName)) + continue; + + if (!publicFrameworkTypes.TryGetValue(shortTypeName, out var frameworkType)) + continue; + + if (!ReferencesFrameworkType(semanticModel, cref.Cref, frameworkType, context.CancellationToken)) + continue; + + var qualifiedTypeName = frameworkType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat); + var diagnostic = Diagnostic.Create( + Rule, + cref.GetLocation(), + ImmutableDictionary.Empty.Add(QualifiedTypePropertyName, qualifiedTypeName), + cref.Cref.ToString(), + qualifiedTypeName.Substring("global::".Length) + ); + + context.ReportDiagnostic(diagnostic); + } + } + } + + static bool IsAlreadyQualified(CrefSyntax cref) => + cref switch + { + TypeCrefSyntax { Type: QualifiedNameSyntax or AliasQualifiedNameSyntax } => true, + NameMemberCrefSyntax { Name: QualifiedNameSyntax or AliasQualifiedNameSyntax } => true, + _ => false, + }; + + static bool ReferencesFrameworkType( + SemanticModel semanticModel, + CrefSyntax cref, + INamedTypeSymbol frameworkType, + CancellationToken cancellationToken + ) + { + var symbolInfo = semanticModel.GetSymbolInfo(cref, cancellationToken); + if (SymbolMatchesFrameworkType(symbolInfo.Symbol, frameworkType)) + return true; + + foreach (var candidate in symbolInfo.CandidateSymbols) + { + if (SymbolMatchesFrameworkType(candidate, frameworkType)) + return true; + } + + return false; + } + + static bool SymbolMatchesFrameworkType(ISymbol? symbol, INamedTypeSymbol frameworkType) + { + if (symbol is null) + return false; + + var containingType = symbol switch + { + INamedTypeSymbol namedType => namedType, + IMethodSymbol method => method.ContainingType, + IPropertySymbol property => property.ContainingType, + IFieldSymbol field => field.ContainingType, + IEventSymbol @event => @event.ContainingType, + _ => null, + }; + + return containingType is not null + && SymbolEqualityComparer.Default.Equals( + containingType.OriginalDefinition, + frameworkType.OriginalDefinition + ); + } + + static bool TryGetUnqualifiedTypeName(CrefSyntax cref, out string typeName) + { + typeName = string.Empty; + + if (cref is TypeCrefSyntax { Type: IdentifierNameSyntax identifierName }) + { + typeName = identifierName.Identifier.ValueText; + return true; + } + + if (cref is TypeCrefSyntax { Type: GenericNameSyntax genericName }) + { + typeName = genericName.Identifier.ValueText; + return true; + } + + if (cref is NameMemberCrefSyntax { Name: IdentifierNameSyntax identifierMemberName }) + { + typeName = identifierMemberName.Identifier.ValueText; + return true; + } + + if (cref is NameMemberCrefSyntax { Name: GenericNameSyntax genericMemberName }) + { + typeName = genericMemberName.Identifier.ValueText; + return true; + } + + return false; + } + + static IAssemblySymbol? ResolveFrameworkAssembly(Compilation compilation) => + compilation + .References.Select(compilation.GetAssemblyOrModuleSymbol) + .OfType() + .FirstOrDefault(static assembly => + string.Equals(assembly.Identity.Name, FrameworkAssemblyName, StringComparison.Ordinal) + ); + + static ImmutableDictionary CollectPublicFrameworkTypes(INamespaceSymbol rootNamespace) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + CollectPublicFrameworkTypes(rootNamespace, builder); + return builder.ToImmutable(); + } + + static void CollectPublicFrameworkTypes( + INamespaceOrTypeSymbol container, + ImmutableDictionary.Builder builder + ) + { + foreach (var member in container.GetMembers()) + { + if (member is INamespaceSymbol namespaceSymbol) + { + CollectPublicFrameworkTypes(namespaceSymbol, builder); + continue; + } + + if (member is not INamedTypeSymbol namedType) + continue; + + if (RoslynComponentDiscovery.IsEffectivelyPublic(namedType) && !builder.ContainsKey(namedType.Name)) + builder.Add(namedType.Name, namedType); + + CollectPublicFrameworkTypes(namedType, builder); + } + } + + static bool IsExplicitlyTrue(AnalyzerConfigOptions options, string propertyName) => + options.TryGetValue("build_property." + propertyName, out var value) + && string.Equals(value, "true", StringComparison.OrdinalIgnoreCase); +} diff --git a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md index 9ce7aaf..39dcd15 100644 --- a/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md +++ b/src/src/SourceGeneratorFramework.Analyzers/AnalyzerReleases.Unshipped.md @@ -15,10 +15,11 @@ PSGFR36 | Purview.SourceGeneratorFramework | Warning | Extension class is not pl PSGFR37 | Purview.SourceGeneratorFramework | Warning | Extension class extends multiple receiver types PSGFR38 | Purview.SourceGeneratorFramework | Warning | Extension class is missing EditorBrowsable PSGFR39 | Purview.SourceGeneratorFramework | Error | Roslyn component must produce a self-contained analyzer +PSGFR40 | Purview.SourceGeneratorFramework | Warning | Unqualified SGF cref is ambiguous TLB0014 | TypeLibrary | Warning | Type library partial extension is declared in a different namespace TLB0015 | TypeLibrary | Info | Type library partial extension must be declared 'public static partial' TLB0016 | TypeLibrary | Error | Enum value member type must be TypeIdentity or EnumValueDefinition TLB0017 | TypeLibrary | Error | Enum value member references an enum type that is not declared TLB0018 | TypeLibrary | Error | Duplicate enum value member TLB0019 | TypeLibrary | Info | Duplicate enum value -TLB0020 | TypeLibrary | Error | Enum values member references a type that is not an enum \ No newline at end of file +TLB0020 | TypeLibrary | Error | Enum values member references a type that is not an enum diff --git a/src/src/SourceGeneratorFramework.CodeFixers/QualifyFrameworkCrefCodeFixProvider.cs b/src/src/SourceGeneratorFramework.CodeFixers/QualifyFrameworkCrefCodeFixProvider.cs new file mode 100644 index 0000000..0fb4900 --- /dev/null +++ b/src/src/SourceGeneratorFramework.CodeFixers/QualifyFrameworkCrefCodeFixProvider.cs @@ -0,0 +1,77 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Purview.SourceGeneratorFramework.Analyzers; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +/// +/// Qualifies SGF XML documentation cref targets with their global:: name. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(QualifyFrameworkCrefCodeFixProvider))] +public sealed class QualifyFrameworkCrefCodeFixProvider : CodeFixProvider +{ + internal const string EquivalenceKey = "QualifyFrameworkCref"; + + public override ImmutableArray FixableDiagnosticIds => [AmbiguousFrameworkCrefAnalyzer.DiagnosticId]; + + 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) + { + if ( + !diagnostic.Properties.TryGetValue( + AmbiguousFrameworkCrefAnalyzer.QualifiedTypePropertyName, + out var qualifiedTypeName + ) || string.IsNullOrWhiteSpace(qualifiedTypeName) + ) + continue; + + var resolvedQualifiedTypeName = qualifiedTypeName!; + + var node = root.FindNode( + diagnostic.Location.SourceSpan, + getInnermostNodeForTie: true, + findInsideTrivia: true + ); + var crefAttribute = + node as XmlCrefAttributeSyntax + ?? node.AncestorsAndSelf().OfType().FirstOrDefault(); + if (crefAttribute is null) + continue; + + context.RegisterCodeFix( + CodeAction.Create( + "Qualify SGF cref with global::", + _ => QualifyCrefAsync(context.Document, root, crefAttribute, resolvedQualifiedTypeName), + EquivalenceKey + ), + diagnostic + ); + } + } + + static Task QualifyCrefAsync( + Document document, + SyntaxNode root, + XmlCrefAttributeSyntax crefAttribute, + string qualifiedTypeName + ) + { + var replacement = crefAttribute + .WithCref(SyntaxFactory.TypeCref(SyntaxFactory.ParseTypeName(qualifiedTypeName))) + .WithTriviaFrom(crefAttribute); + + var newRoot = root.ReplaceNode(crefAttribute, replacement); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } +} diff --git a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs index a8a8e34..3c0af06 100644 --- a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs +++ b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs @@ -3,6 +3,9 @@ static class MergeToolRunner { + const string IsExternalInitName = "IsExternalInit"; + const string IsExternalInitNamespace = "System.Runtime.CompilerServices"; + public static int Run(string[] args, TextWriter error, ILogger? logger = null) { if (args.Length < 3) @@ -64,7 +67,6 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) SearchDirectories = searchDirectories, Internalize = true, InternalizeAssemblies = [Path.GetFileNameWithoutExtension(frameworkPath)], - RenameInternalized = false, UnionMerge = true, Parallel = true, DebugInfo = File.Exists( @@ -82,8 +84,8 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) new ILRepack(options, logger).Repack(); } - var frameworkTypeNames = ReadTypeNames(frameworkPath, searchDirectories); - InternalizeFrameworkTypes(outputPath, frameworkTypeNames, searchDirectories); + RestoreCanonicalIsExternalInit(outputPath, searchDirectories); + return 0; } finally @@ -92,24 +94,27 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) } } - static HashSet ReadTypeNames(string assemblyPath, IEnumerable searchDirectories) + internal static IEnumerable Flatten(TypeDefinition type) { - using var resolver = CreateResolver(searchDirectories); - using var assembly = AssemblyDefinition.ReadAssembly( - assemblyPath, - new ReaderParameters { AssemblyResolver = resolver } - ); - return assembly - .MainModule.Types.SelectMany(Flatten) - .Select(static type => type.FullName) - .ToHashSet(StringComparer.Ordinal); + yield return type; + foreach (var nestedType in type.NestedTypes.SelectMany(Flatten)) + { + yield return nestedType; + } } - static void InternalizeFrameworkTypes( - string assemblyPath, - HashSet frameworkTypeNames, - IEnumerable searchDirectories - ) + internal static DefaultAssemblyResolver CreateResolver(IEnumerable searchDirectories) + { + DefaultAssemblyResolver resolver = new(); + foreach (var searchDirectory in searchDirectories) + { + resolver.AddSearchDirectory(searchDirectory); + } + + return resolver; + } + + static void RestoreCanonicalIsExternalInit(string assemblyPath, IEnumerable searchDirectories) { var pdbPath = Path.ChangeExtension(assemblyPath, ".pdb"); var hasSymbols = File.Exists(pdbPath); @@ -124,38 +129,26 @@ IEnumerable searchDirectories } ); - foreach (var type in assembly.MainModule.Types.SelectMany(Flatten)) - { - if (!frameworkTypeNames.Contains(type.FullName)) - { - continue; - } + var marker = assembly + .MainModule.Types.SelectMany(Flatten) + .SingleOrDefault(static type => + type.Name == IsExternalInitName + || type.Name.EndsWith(IsExternalInitName, StringComparison.Ordinal) + || ( + type.Namespace.Contains(IsExternalInitNamespace, StringComparison.Ordinal) + && type.Name.Contains(IsExternalInitName, StringComparison.Ordinal) + ) + ); - type.Attributes = type.IsNested - ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedAssembly - : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NotPublic; - } + if (marker is null) + return; - assembly.Write(assemblyPath, new WriterParameters { WriteSymbols = hasSymbols }); - } + marker.Namespace = IsExternalInitNamespace; + marker.Name = IsExternalInitName; + marker.Attributes = marker.IsNested + ? (marker.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedAssembly + : (marker.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NotPublic; - internal static IEnumerable Flatten(TypeDefinition type) - { - yield return type; - foreach (var nestedType in type.NestedTypes.SelectMany(Flatten)) - { - yield return nestedType; - } - } - - internal static DefaultAssemblyResolver CreateResolver(IEnumerable searchDirectories) - { - DefaultAssemblyResolver resolver = new(); - foreach (var searchDirectory in searchDirectories) - { - resolver.AddSearchDirectory(searchDirectory); - } - - return resolver; + assembly.Write(assemblyPath, new WriterParameters { WriteSymbols = hasSymbols }); } } diff --git a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs index 93e4778..2639df7 100644 --- a/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs +++ b/src/src/SourceGeneratorFramework.Testing/RoslynTestRunner.cs @@ -34,12 +34,53 @@ SourceGeneratorTestOptions options ); } + if (options.CompilationWithAnalyzersOptions is null && options.AnalyzerOptions is null) + { + var analyzerConfigOptions = BuildAnalyzerConfig(options); + if (analyzerConfigOptions.Count > 0) + { + return compilation.WithAnalyzers( + analyzers, + new AnalyzerOptions( + ImmutableArray.Empty, + new TestAnalyzerConfigOptionsProvider(analyzerConfigOptions) + ) + ); + } + } + // If the caller provided CompilationWithAnalyzersOptions, use that; otherwise, use AnalyzerOptions. return options.CompilationWithAnalyzersOptions is not null ? compilation.WithAnalyzers(analyzers, options.CompilationWithAnalyzersOptions) : compilation.WithAnalyzers(analyzers, options.AnalyzerOptions); } + static Dictionary BuildAnalyzerConfig(SourceGeneratorTestOptions options) + { + Dictionary analyzerOptions = new(options.AnalyzerConfigOptions) + { + [SourceGeneratorBuildProperties.ValidateCodeWriterScopes] = options.ValidateCodeWriterScopes.ToString(), + [SourceGeneratorBuildProperties.EnableLogging] = options.EnableLogging.ToString(), + }; + + foreach (var pair in options.AnalyzerConfigOptions) + { + if (!pair.Key.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + analyzerOptions[SourceGeneratorBuildProperties.BuildProperty + pair.Key] = pair.Value; + } + + if (options.DisableSourceGeneratorPropertyName is not null && options.DisableSourceGeneratorValue is not null) + { + var disablePropertyName = options.DisableSourceGeneratorPropertyName; + if (!disablePropertyName.StartsWith(SourceGeneratorBuildProperties.BuildProperty, StringComparison.Ordinal)) + disablePropertyName = SourceGeneratorBuildProperties.BuildProperty + disablePropertyName; + + analyzerOptions[disablePropertyName] = options.DisableSourceGeneratorValue.Value.ToString(); + } + + return analyzerOptions; + } + /// /// Creates a project containing the supplied sources. /// diff --git a/src/src/SourceGeneratorFramework.Testing/TestAnalyzerConfigOptionsProvider.cs b/src/src/SourceGeneratorFramework.Testing/TestAnalyzerConfigOptionsProvider.cs index 07c8746..d6467e6 100644 --- a/src/src/SourceGeneratorFramework.Testing/TestAnalyzerConfigOptionsProvider.cs +++ b/src/src/SourceGeneratorFramework.Testing/TestAnalyzerConfigOptionsProvider.cs @@ -20,7 +20,7 @@ sealed class TestAnalyzerConfigOptionsProvider(Dictionary option public override AnalyzerConfigOptions GlobalOptions => _globalOptions; /// - public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => TestAnalyzerConfigOptions.Empty; + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => _globalOptions; /// public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => TestAnalyzerConfigOptions.Empty; diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index f29faa3..3689549 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -989,6 +989,7 @@ The `Purview.SourceGeneratorFramework` package includes the `Purview.SourceGener | `PSGFR36` | Extension classes must be placed in the extended type's namespace under an `Extensions` folder. | | `PSGFR37` | One extension class per receiver type; split classes that extend multiple types. | | `PSGFR38` | Extension classes should carry `[EditorBrowsable(EditorBrowsableState.Never)]`. | +| `PSGFR40` | In Roslyn components (`IsRoslynComponent=true`), qualify XML doc `cref` references to SGF public types with `global::Purview.SourceGeneratorFramework...`. | ## Documentation diff --git a/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AmbiguousFrameworkCrefAnalyzerTests.cs b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AmbiguousFrameworkCrefAnalyzerTests.cs new file mode 100644 index 0000000..d29e36e --- /dev/null +++ b/src/tests/SourceGeneratorFramework.Analyzers.UnitTests/AmbiguousFrameworkCrefAnalyzerTests.cs @@ -0,0 +1,165 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.Analyzers; + +public sealed class AmbiguousFrameworkCrefAnalyzerTests + : TUnitDiagnosticAnalyzerTestBase +{ + static readonly AnalyzerTestOptions RoslynComponentOptions = new() + { + AnalyzerConfigOptions = new Dictionary + { + ["build_property.IsRoslynComponent"] = "true", + }.ToImmutableDictionary(), + AdditionalAssemblyTypes = + [ + typeof(Purview.SourceGeneratorFramework.CodeWriter), + typeof(Purview.SourceGeneratorFramework.GenerationSettings), + typeof(Purview.SourceGeneratorFramework.TypeIdentity), + typeof(Purview.SourceGeneratorFramework.TypeReference), + typeof(Purview.SourceGeneratorFramework.XmlCommentWriter), + typeof(Purview.SourceGeneratorFramework.Helpers.IncrementalPipeline), + typeof(Purview.SourceGeneratorFramework.CodeWriterScopeValidationException), + ], + }; + + [Test] + public async Task BareSgfCrefs_ReportDiagnosticsForMultipleTypes(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + using Purview.SourceGeneratorFramework.Helpers; + + /// + /// Shared building blocks. + /// Writes with . + /// Configures . + /// Tracks . + /// Emits docs with . + /// Composes providers with . + /// Throws when scope validation fails. + /// + public static class TypeRefs + { + } + """; + + var result = await AnalyzeAsync(source, RoslynComponentOptions, cancellationToken); + + await Assert.That(result).HasDiagnostics(7); + await Assert.That(result).HasDiagnostic(AmbiguousFrameworkCrefAnalyzer.DiagnosticId); + } + + [Test] + public async Task QualifiedSgfCrefs_DoNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var result = await AnalyzeAsync(source, RoslynComponentOptions, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task NonSgfType_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using System; + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var result = await AnalyzeAsync(source, RoslynComponentOptions, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task ShadowTypeWithSgfName_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + namespace Example; + + public sealed class TypeReference + { + } + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var result = await AnalyzeAsync(source, RoslynComponentOptions, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task MissingRoslynComponentOptIn_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var result = await AnalyzeAsync( + source, + new AnalyzerTestOptions { AdditionalAssemblyTypes = RoslynComponentOptions.AdditionalAssemblyTypes }, + cancellationToken + ); + + await Assert.That(result).HasNoDiagnostics(); + } + + [Test] + public async Task RoslynComponentOptOut_DoesNotReportDiagnostic(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var options = RoslynComponentOptions with + { + AnalyzerConfigOptions = new Dictionary + { + ["build_property.IsRoslynComponent"] = "false", + }.ToImmutableDictionary(), + }; + + var result = await AnalyzeAsync(source, options, cancellationToken); + + await Assert.That(result).HasNoDiagnostics(); + } +} diff --git a/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/QualifyFrameworkCrefCodeFixProviderTests.cs b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/QualifyFrameworkCrefCodeFixProviderTests.cs new file mode 100644 index 0000000..d3fd9a8 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.CodeFixers.UnitTests/QualifyFrameworkCrefCodeFixProviderTests.cs @@ -0,0 +1,93 @@ +using System.Collections.Immutable; +using Purview.SourceGeneratorFramework.Analyzers; +using Purview.SourceGeneratorFramework.Testing; +using Purview.SourceGeneratorFramework.Testing.TUnit; + +namespace Purview.SourceGeneratorFramework.CodeFixers; + +public sealed class QualifyFrameworkCrefCodeFixProviderTests + : TUnitCodeFixTestBase +{ + static CodeFixTestOptions Options => + new() + { + EquivalenceKey = QualifyFrameworkCrefCodeFixProvider.EquivalenceKey, + AnalyzerConfigOptions = new Dictionary + { + ["build_property.IsRoslynComponent"] = "true", + }.ToImmutableDictionary(), + AdditionalAssemblyTypes = [typeof(CodeWriter), typeof(TypeReference)], + }; + + [Test] + public async Task BareTypeReferenceCref_IsQualified(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Shared building blocks. + /// + public static class TypeRefs + { + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result).HasDiagnostic(AmbiguousFrameworkCrefAnalyzer.DiagnosticId); + await Assert + .That(result.FixedSource) + .Contains(""); + } + + [Test] + public async Task BareCodeWriterCref_IsQualified(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Writes with . + /// + public static class TypeRefs + { + } + """; + + var result = await ApplyCodeFixAsync(source, Options, cancellationToken); + + await Assert.That(result).HasDiagnostic(AmbiguousFrameworkCrefAnalyzer.DiagnosticId); + await Assert + .That(result.FixedSource) + .Contains(""); + } + + [Test] + public async Task FixAll_QualifiesMultipleCrefs(CancellationToken cancellationToken) + { + const string source = """ + using Purview.SourceGeneratorFramework; + + /// + /// Shared building blocks. + /// Writes with . + /// + public static class TypeRefs + { + } + """; + + var result = await ApplyFixAllAsync(source, Options, cancellationToken); + + await Assert + .That(result.Diagnostics.Select(static d => d.Id).ToArray()) + .Contains(AmbiguousFrameworkCrefAnalyzer.DiagnosticId); + await Assert + .That(result.FixedSources["Test1.cs"]) + .Contains(""); + await Assert + .That(result.FixedSources["Test1.cs"]) + .Contains(""); + } +} diff --git a/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs index 7394c32..07c2325 100644 --- a/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs +++ b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs @@ -10,7 +10,6 @@ namespace Purview.SourceGeneratorFramework.MergeTool; public sealed class MergeToolRunnerTests { const string MarkerFullName = "System.Runtime.CompilerServices.IsExternalInit"; - const string FrameworkSource = """ namespace System.Runtime.CompilerServices { @@ -19,6 +18,13 @@ public static class IsExternalInit; namespace Fixture.Framework { + public sealed class TypeReference + { + public string Name { get; } + + public TypeReference(string name) => Name = name; + } + public enum OptionKind { None, @@ -45,6 +51,8 @@ namespace Fixture.Component public static class Consumer { public static Options Create() => new() { Kind = OptionKind.Enabled }; + + public static string Describe() => new TypeReference("Merged").Name; } public sealed class ComponentOptions @@ -62,6 +70,8 @@ namespace Fixture.Component public static class Consumer { public static Options Create() => new() { Kind = OptionKind.Enabled }; + + public static string Describe() => new TypeReference("Merged").Name; } } """; @@ -150,6 +160,10 @@ await Assert .That(merged.MainModule.AssemblyReferences.Any(static reference => reference.Name == "Fixture.Framework")) .IsFalse(); + var mergedTypeReference = merged.MainModule.GetType("Fixture.Framework.TypeReference"); + await Assert.That(mergedTypeReference).IsNotNull(); + await Assert.That(mergedTypeReference!.IsNotPublic).IsTrue(); + var consumer = merged.MainModule.GetType("Fixture.Component.Consumer"); var create = consumer.Methods.Single(static method => method.Name == "Create"); var setter = create @@ -183,6 +197,11 @@ static async Task AssertMergedAssemblyExecutesAsync(string outputPath) .Invoke(null, null); var kind = created!.GetType().GetProperty("Kind")!.GetValue(created); await Assert.That(kind!.ToString()).IsEqualTo("Enabled"); + var described = loaded + .GetType("Fixture.Component.Consumer", throwOnError: true)! + .GetMethod("Describe", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, null); + await Assert.That(described).IsEqualTo("Merged"); } finally { @@ -274,23 +293,29 @@ public void Dispose() static string GetNetStandardReferenceDirectory() { - var packageRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); - if (string.IsNullOrWhiteSpace(packageRoot)) + var candidateRoots = new[] { - packageRoot = Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), - ".nuget", - "packages" - ); + Environment.GetEnvironmentVariable("NUGET_PACKAGES"), + Environment.GetEnvironmentVariable("RestorePackagesPath"), + Environment.GetEnvironmentVariable("NuGetPackageRoot"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"), } + .Where(static path => !string.IsNullOrWhiteSpace(path)) + .Select(static path => path!) + .Select(static path => path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)) + .Distinct(StringComparer.OrdinalIgnoreCase); - var path = Path.Combine(packageRoot, "netstandard.library", "2.0.3", "build", "netstandard2.0", "ref"); + foreach (var packageRoot in candidateRoots) + { + var path = Path.Combine(packageRoot, "netstandard.library", "2.0.3", "build", "netstandard2.0", "ref"); - return Directory.Exists(path) - ? path - : throw new DirectoryNotFoundException( - $"The .NET Standard 2.0 reference directory was not found: {path}" - ); + if (Directory.Exists(path)) + return path; + } + + throw new DirectoryNotFoundException( + $"The .NET Standard 2.0 reference directory was not found under any known package roots: {string.Join(", ", candidateRoots)}" + ); } } }