Skip to content

Commit

Permalink
Introduce lazy-init of sponsoring status, simplify diagnostics
Browse files Browse the repository at this point in the history
Incremental generators can run before CompilationStart actions, so we cannot solely rely on that having run first.

In order to guarantee set up for incremental generators too, we instead switch to a lazy-init approach, so that regardless of analyzer/generator ordering, the proper status will be set regardless.
  • Loading branch information
kzu committed Jun 29, 2024
1 parent 5813f21 commit 5009784
Show file tree
Hide file tree
Showing 7 changed files with 163 additions and 104 deletions.
3 changes: 2 additions & 1 deletion src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions src/SponsorLink/Analyzer/StatusReportingGenerator.cs
Original file line number Diff line number Diff line change
@@ -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}");
});
}
}
135 changes: 97 additions & 38 deletions src/SponsorLink/SponsorLink/DiagnosticsManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,41 +21,22 @@ namespace Devlooped.Sponsors;
/// </summary>
class DiagnosticsManager
{
/// <summary>
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
/// </summary>
ConcurrentDictionary<string, Diagnostic> Diagnostics
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));

/// <summary>
/// Creates a descriptor from well-known diagnostic kinds.
/// </summary>
/// <param name="sponsorable">The names of the sponsorable accounts that can be funded for the given product.</param>
/// <param name="product">The product or project developed by the sponsorable(s).</param>
/// <param name="prefix">Custom prefix to use for diagnostic IDs.</param>
/// <param name="status">The kind of status diagnostic to create.</param>
/// <returns>The given <see cref="DiagnosticDescriptor"/>.</returns>
/// <exception cref="NotImplementedException">The <paramref name="status"/> is not one of the known ones.</exception>
public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch
public static Dictionary<SponsorStatus, DiagnosticDescriptor> 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:
// <Constant Include="Funding.Product" Value="[PRODUCT_NAME]" />
// <Constant Include="Funding.AnalyzerPrefix" Value="[PREFIX]" />
{ 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) },
};

/// <summary>
/// Pushes a diagnostic for the given product. If an existing one exists, it is replaced.
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
/// </summary>
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
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<string, Diagnostic> Diagnostics
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));

/// <summary>
/// Attemps to remove a diagnostic for the given product.
Expand All @@ -62,17 +50,19 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
}

/// <summary>
/// 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 <see cref="GetOrSetStatus"/>.
/// 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).
/// </summary>
/// <param name="product">The product to check status for.</param>
/// <returns>Optional <see cref="SponsorStatus"/> that was reported, if any.</returns>
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
Expand All @@ -89,7 +79,76 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
return null;
}

static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
/// <summary>
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
/// the given set of <paramref name="manifests"/> if not already set.
/// </summary>
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests)
=> GetOrSetStatus(() => manifests);

/// <summary>
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
/// the given analyzer <paramref name="options"/> if not already set.
/// </summary>
public SponsorStatus GetOrSetStatus(Func<AnalyzerOptions?> options)
=> GetOrSetStatus(() => options().GetSponsorManifests());

SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> 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<string, string?>().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<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
return SponsorStatus.Expiring;
}
else
{
// report expired
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
return SponsorStatus.Expired;
}
}
else
{
// report sponsor
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
Funding.Product));
return SponsorStatus.Sponsor;
}
}

/// <summary>
/// Pushes a diagnostic for the given product.
/// </summary>
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/SponsorLink/SponsorLink/SponsorLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -59,6 +63,32 @@ static partial class SponsorLink
.Select(DateTimeOffset.FromUnixTimeSeconds)
.Max().DateTime is var exp && exp == DateTime.MinValue ? null : exp;

/// <summary>
/// Gets all sponsor manifests from the provided analyzer options.
/// </summary>
public static ImmutableArray<AdditionalText> GetSponsorManifests(this AnalyzerOptions? options)
=> options == null ? ImmutableArray.Create<AdditionalText>() : 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();

/// <summary>
/// Gets all sponsor manifests from the provided analyzer options.
/// </summary>
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> 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();

/// <summary>
/// Reads all manifests, validating their signatures.
/// </summary>
Expand Down
66 changes: 2 additions & 64 deletions src/SponsorLink/SponsorLink/SponsorLinkAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
// <autogenerated />
#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;
Expand All @@ -20,18 +16,7 @@ namespace Devlooped.Sponsors;
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
public class SponsorLinkAnalyzer : DiagnosticAnalyzer
{
static readonly Dictionary<SponsorStatus, DiagnosticDescriptor> descriptors = new()
{
// Requires:
// <Constant Include="Funding.Product" Value="[PRODUCT_NAME]" />
// <Constant Include="Funding.AnalyzerPrefix" Value="[PREFIX]" />
{ 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<DiagnosticDescriptor> SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray();
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.Values.ToImmutableArray();

#pragma warning disable RS1026 // Enable concurrent execution
public override void Initialize(AnalysisContext context)
Expand All @@ -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)
Expand Down Expand Up @@ -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<AdditionalText> 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<string, string?>().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<string, string?>().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<string, string?>().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<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
Funding.Product));
return SponsorStatus.Sponsor;
}
}
}
Loading

0 comments on commit 5009784

Please sign in to comment.