diff --git a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs index 9f02466..d7a1fd7 100644 --- a/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs +++ b/src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs @@ -34,7 +34,8 @@ public override void Initialize(AnalysisContext context) packageId == "SponsorableLib"; }).Select(x => File.GetLastWriteTime(x.Path)).OrderByDescending(x => x).FirstOrDefault(); - var status = Diagnostics.GetStatus(Funding.Product); + var status = Diagnostics.GetOrSetStatus(() => c.Options); + if (installed != default) Tracing.Trace($"Status: {status}, Installed: {(DateTime.Now - installed).Humanize()} ago"); else diff --git a/src/SponsorLink/Analyzer/StatusReportingGenerator.cs b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs new file mode 100644 index 0000000..0a13b1c --- /dev/null +++ b/src/SponsorLink/Analyzer/StatusReportingGenerator.cs @@ -0,0 +1,20 @@ +using Devlooped.Sponsors; +using Microsoft.CodeAnalysis; +using static Devlooped.Sponsors.SponsorLink; + +namespace Analyzer; + +[Generator] +public class StatusReportingGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + context.RegisterSourceOutput( + context.GetSponsorManifests(), + (spc, source) => + { + var status = Diagnostics.GetOrSetStatus(source); + spc.AddSource("StatusReporting.cs", $"// Status: {status}"); + }); + } +} diff --git a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs index c22ecc8..1d31b39 100644 --- a/src/SponsorLink/SponsorLink/DiagnosticsManager.cs +++ b/src/SponsorLink/SponsorLink/DiagnosticsManager.cs @@ -2,9 +2,16 @@ #nullable enable using System; using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; using System.Globalization; +using System.IO; +using System.Linq; using Humanizer; +using Humanizer.Localisation; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using static Devlooped.Sponsors.SponsorLink; namespace Devlooped.Sponsors; @@ -14,41 +21,22 @@ namespace Devlooped.Sponsors; /// class DiagnosticsManager { - /// - /// Acceses the diagnostics dictionary for the current . - /// - ConcurrentDictionary Diagnostics - => AppDomainDictionary.Get>(nameof(Diagnostics)); - - /// - /// Creates a descriptor from well-known diagnostic kinds. - /// - /// The names of the sponsorable accounts that can be funded for the given product. - /// The product or project developed by the sponsorable(s). - /// Custom prefix to use for diagnostic IDs. - /// The kind of status diagnostic to create. - /// The given . - /// The is not one of the known ones. - public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch + public static Dictionary KnownDescriptors { get; } = new() { - SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix), - SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix), - SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix), - SponsorStatus.Expired => CreateExpired(sponsorable, prefix), - _ => throw new NotImplementedException(), + // Requires: + // + // + { SponsorStatus.Unknown, CreateUnknown([.. Sponsorables.Keys], Funding.Product, Funding.Prefix) }, + { SponsorStatus.Sponsor, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix) }, + { SponsorStatus.Expiring, CreateExpiring([.. Sponsorables.Keys], Funding.Prefix) }, + { SponsorStatus.Expired, CreateExpired([.. Sponsorables.Keys], Funding.Prefix) }, }; /// - /// Pushes a diagnostic for the given product. If an existing one exists, it is replaced. + /// Acceses the diagnostics dictionary for the current . /// - /// The same diagnostic that was pushed, for chained invocations. - public Diagnostic Push(string product, Diagnostic diagnostic) - { - // Directly sets, since we only expect to get one warning per sponsorable+product - // combination. - Diagnostics[product] = diagnostic; - return diagnostic; - } + ConcurrentDictionary Diagnostics + => AppDomainDictionary.Get>(nameof(Diagnostics)); /// /// Attemps to remove a diagnostic for the given product. @@ -62,17 +50,19 @@ public Diagnostic Push(string product, Diagnostic diagnostic) } /// - /// Gets the status of the given product based on a previously stored diagnostic. + /// Gets the status of the given product based on a previously stored diagnostic. + /// To ensure the value is always set before returning, use . + /// This method is safe to use (and would get a non-null value) in analyzers that run after CompilationStartAction(see + /// https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md under Ordering of actions). /// - /// The product to check status for. /// Optional that was reported, if any. - public SponsorStatus? GetStatus(string product) + public SponsorStatus? GetStatus() { // NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the // kind of diagnostic as a simple string instead of the enum. We do this so that // multiple analyzers or versions even across multiple products, which all would // have their own enum, can still share the same diagnostic kind. - if (Diagnostics.TryGetValue(product, out var diagnostic) && + if (Diagnostics.TryGetValue(Funding.Product, out var diagnostic) && diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value)) { // Switch on value matching DiagnosticKind names @@ -89,7 +79,76 @@ public Diagnostic Push(string product, Diagnostic diagnostic) return null; } - static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new( + /// + /// Gets the status of the , or sets it from + /// the given set of if not already set. + /// + public SponsorStatus GetOrSetStatus(ImmutableArray manifests) + => GetOrSetStatus(() => manifests); + + /// + /// Gets the status of the , or sets it from + /// the given analyzer if not already set. + /// + public SponsorStatus GetOrSetStatus(Func options) + => GetOrSetStatus(() => options().GetSponsorManifests()); + + SponsorStatus GetOrSetStatus(Func> getManifests) + { + if (GetStatus() is { } status) + return status; + + if (!SponsorLink.TryRead(out var claims, getManifests().Select(text => + (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) || + claims.GetExpiration() is not DateTime exp) + { + // report unknown, either unparsed manifest or one with no expiration (which we never emit). + Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), + Funding.Product, Sponsorables.Keys.Humanize(Resources.Or))); + return SponsorStatus.Unknown; + } + else if (exp < DateTime.Now) + { + // report expired or expiring soon if still within the configured days of grace period + if (exp.AddDays(Funding.Grace) < DateTime.Now) + { + // report expiring soon + Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring)))); + return SponsorStatus.Expiring; + } + else + { + // report expired + Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired)))); + return SponsorStatus.Expired; + } + } + else + { + // report sponsor + Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null, + properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)), + Funding.Product)); + return SponsorStatus.Sponsor; + } + } + + /// + /// Pushes a diagnostic for the given product. + /// + /// The same diagnostic that was pushed, for chained invocations. + Diagnostic Push(string product, Diagnostic diagnostic) + { + // We only expect to get one warning per sponsorable+product + // combination, and first one to set wins. + Diagnostics.TryAdd(product, diagnostic); + return diagnostic; + } + + internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new( $"{prefix}100", Resources.Sponsor_Title, Resources.Sponsor_Message, @@ -100,7 +159,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic) helpLinkUri: "https://github.com/devlooped#sponsorlink", "DoesNotSupportF1Help"); - static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( + internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new( $"{prefix}101", Resources.Unknown_Title, Resources.Unknown_Message, @@ -113,7 +172,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic) helpLinkUri: "https://github.com/devlooped#sponsorlink", WellKnownDiagnosticTags.NotConfigurable); - static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( + internal static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new( $"{prefix}103", Resources.Expiring_Title, Resources.Expiring_Message, @@ -124,7 +183,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic) helpLinkUri: "https://github.com/devlooped#autosync", "DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable); - static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( + internal static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new( $"{prefix}104", Resources.Expired_Title, Resources.Expired_Message, diff --git a/src/SponsorLink/SponsorLink/SponsorLink.cs b/src/SponsorLink/SponsorLink/SponsorLink.cs index a4c0675..b3b1cf3 100644 --- a/src/SponsorLink/SponsorLink/SponsorLink.cs +++ b/src/SponsorLink/SponsorLink/SponsorLink.cs @@ -2,10 +2,14 @@ #nullable enable using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Diagnostics.CodeAnalysis; +using System.IO; using System.Linq; using System.Reflection; using System.Security.Claims; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.IdentityModel.JsonWebTokens; using Microsoft.IdentityModel.Tokens; @@ -59,6 +63,32 @@ static partial class SponsorLink .Select(DateTimeOffset.FromUnixTimeSeconds) .Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp; + /// + /// Gets all sponsor manifests from the provided analyzer options. + /// + public static ImmutableArray GetSponsorManifests(this AnalyzerOptions? options) + => options == null ? ImmutableArray.Create() : options.AdditionalFiles + .Where(x => + options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) && + itemType == "SponsorManifest" && + Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path))) + .ToImmutableArray(); + + /// + /// Gets all sponsor manifests from the provided analyzer options. + /// + public static IncrementalValueProvider> GetSponsorManifests(this IncrementalGeneratorInitializationContext context) + => context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider) + .Where(source => + { + var (text, options) = source; + return options.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) && + itemType == "SponsorManifest" && + Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path)); + }) + .Select((source, c) => source.Left) + .Collect(); + /// /// Reads all manifests, validating their signatures. /// diff --git a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs index d87c238..dc2a9d3 100644 --- a/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs +++ b/src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs @@ -1,13 +1,9 @@ // #nullable enable using System; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; using System.IO; using System.Linq; -using Humanizer; -using Humanizer.Localisation; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using static Devlooped.Sponsors.SponsorLink; @@ -20,18 +16,7 @@ namespace Devlooped.Sponsors; [DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)] public class SponsorLinkAnalyzer : DiagnosticAnalyzer { - static readonly Dictionary descriptors = new() - { - // Requires: - // - // - { SponsorStatus.Unknown, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Unknown) }, - { SponsorStatus.Sponsor, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Sponsor) }, - { SponsorStatus.Expiring, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expiring) }, - { SponsorStatus.Expired, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expired) }, - }; - - public override ImmutableArray SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray(); + public override ImmutableArray SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.Values.ToImmutableArray(); #pragma warning disable RS1026 // Enable concurrent execution public override void Initialize(AnalysisContext context) @@ -49,15 +34,8 @@ public override void Initialize(AnalysisContext context) // analyzers can report the same diagnostic and we want to avoid duplicates. context.RegisterCompilationStartAction(ctx => { - var manifests = ctx.Options.AdditionalFiles - .Where(x => - ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) && - itemType == "SponsorManifest" && - Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path))) - .ToImmutableArray(); - // Setting the status early allows other analyzers to potentially check for it. - var status = SetStatus(manifests); + var status = Diagnostics.GetOrSetStatus(() => ctx.Options); // Never report any diagnostic unless we're in an editor. if (IsEditor) @@ -108,44 +86,4 @@ public override void Initialize(AnalysisContext context) }); #pragma warning restore RS1013 // Start action has no registered non-end actions } - - SponsorStatus SetStatus(ImmutableArray manifests) - { - if (!SponsorLink.TryRead(out var claims, manifests.Select(text => - (text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) || - claims.GetExpiration() is not DateTime exp) - { - // report unknown, either unparsed manifest or one with no expiration (which we never emit). - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Unknown], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)), - Funding.Product, Sponsorables.Keys.Humanize(Resources.Or))); - return SponsorStatus.Unknown; - } - else if (exp < DateTime.Now) - { - // report expired or expiring soon if still within the configured days of grace period - if (exp.AddDays(Funding.Grace) < DateTime.Now) - { - // report expiring soon - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expiring], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring)))); - return SponsorStatus.Expiring; - } - else - { - // report expired - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expired], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired)))); - return SponsorStatus.Expired; - } - } - else - { - // report sponsor - Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Sponsor], null, - properties: ImmutableDictionary.Create().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)), - Funding.Product)); - return SponsorStatus.Sponsor; - } - } } diff --git a/src/SponsorLink/Tests/Sample.cs b/src/SponsorLink/Tests/Sample.cs index 6249e62..3ea4a32 100644 --- a/src/SponsorLink/Tests/Sample.cs +++ b/src/SponsorLink/Tests/Sample.cs @@ -4,6 +4,7 @@ using System.Runtime.CompilerServices; using System.Security.Cryptography; using Analyzer::Devlooped.Sponsors; +using Microsoft.CodeAnalysis; using Xunit; using Xunit.Abstractions; @@ -29,7 +30,7 @@ public void Test(string culture, SponsorStatus kind) Thread.CurrentThread.CurrentCulture = Thread.CurrentThread.CurrentUICulture = culture == "" ? CultureInfo.InvariantCulture : CultureInfo.GetCultureInfo(culture); - var diag = new DiagnosticsManager().GetDescriptor(["foo"], "bar", "FB", kind); + var diag = GetDescriptor(["foo"], "bar", "FB", kind); output.WriteLine(diag.Title.ToString()); output.WriteLine(diag.MessageFormat.ToString()); @@ -56,4 +57,13 @@ public void RenderSponsorables() }); } } + + DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch + { + SponsorStatus.Unknown => DiagnosticsManager.CreateUnknown(sponsorable, product, prefix), + SponsorStatus.Sponsor => DiagnosticsManager.CreateSponsor(sponsorable, prefix), + SponsorStatus.Expiring => DiagnosticsManager.CreateExpiring(sponsorable, prefix), + SponsorStatus.Expired => DiagnosticsManager.CreateExpired(sponsorable, prefix), + _ => throw new NotImplementedException(), + }; } \ No newline at end of file diff --git a/src/SponsorLink/Tests/Tests.csproj b/src/SponsorLink/Tests/Tests.csproj index 1f5dd37..5082c97 100644 --- a/src/SponsorLink/Tests/Tests.csproj +++ b/src/SponsorLink/Tests/Tests.csproj @@ -2,6 +2,7 @@ net8.0 + true