Skip to content

Commit 5009784

Browse files
committed
Introduce lazy-init of sponsoring status, simplify diagnostics
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.
1 parent 5813f21 commit 5009784

7 files changed

Lines changed: 163 additions & 104 deletions

File tree

src/SponsorLink/Analyzer/StatusReportingAnalyzer.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@ public override void Initialize(AnalysisContext context)
3434
packageId == "SponsorableLib";
3535
}).Select(x => File.GetLastWriteTime(x.Path)).OrderByDescending(x => x).FirstOrDefault();
3636

37-
var status = Diagnostics.GetStatus(Funding.Product);
37+
var status = Diagnostics.GetOrSetStatus(() => c.Options);
38+
3839
if (installed != default)
3940
Tracing.Trace($"Status: {status}, Installed: {(DateTime.Now - installed).Humanize()} ago");
4041
else
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
using Devlooped.Sponsors;
2+
using Microsoft.CodeAnalysis;
3+
using static Devlooped.Sponsors.SponsorLink;
4+
5+
namespace Analyzer;
6+
7+
[Generator]
8+
public class StatusReportingGenerator : IIncrementalGenerator
9+
{
10+
public void Initialize(IncrementalGeneratorInitializationContext context)
11+
{
12+
context.RegisterSourceOutput(
13+
context.GetSponsorManifests(),
14+
(spc, source) =>
15+
{
16+
var status = Diagnostics.GetOrSetStatus(source);
17+
spc.AddSource("StatusReporting.cs", $"// Status: {status}");
18+
});
19+
}
20+
}

src/SponsorLink/SponsorLink/DiagnosticsManager.cs

Lines changed: 97 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,16 @@
22
#nullable enable
33
using System;
44
using System.Collections.Concurrent;
5+
using System.Collections.Generic;
6+
using System.Collections.Immutable;
57
using System.Globalization;
8+
using System.IO;
9+
using System.Linq;
610
using Humanizer;
11+
using Humanizer.Localisation;
712
using Microsoft.CodeAnalysis;
13+
using Microsoft.CodeAnalysis.Diagnostics;
14+
using static Devlooped.Sponsors.SponsorLink;
815

916
namespace Devlooped.Sponsors;
1017

@@ -14,41 +21,22 @@ namespace Devlooped.Sponsors;
1421
/// </summary>
1522
class DiagnosticsManager
1623
{
17-
/// <summary>
18-
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
19-
/// </summary>
20-
ConcurrentDictionary<string, Diagnostic> Diagnostics
21-
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));
22-
23-
/// <summary>
24-
/// Creates a descriptor from well-known diagnostic kinds.
25-
/// </summary>
26-
/// <param name="sponsorable">The names of the sponsorable accounts that can be funded for the given product.</param>
27-
/// <param name="product">The product or project developed by the sponsorable(s).</param>
28-
/// <param name="prefix">Custom prefix to use for diagnostic IDs.</param>
29-
/// <param name="status">The kind of status diagnostic to create.</param>
30-
/// <returns>The given <see cref="DiagnosticDescriptor"/>.</returns>
31-
/// <exception cref="NotImplementedException">The <paramref name="status"/> is not one of the known ones.</exception>
32-
public DiagnosticDescriptor GetDescriptor(string[] sponsorable, string product, string prefix, SponsorStatus status) => status switch
24+
public static Dictionary<SponsorStatus, DiagnosticDescriptor> KnownDescriptors { get; } = new()
3325
{
34-
SponsorStatus.Unknown => CreateUnknown(sponsorable, product, prefix),
35-
SponsorStatus.Sponsor => CreateSponsor(sponsorable, prefix),
36-
SponsorStatus.Expiring => CreateExpiring(sponsorable, prefix),
37-
SponsorStatus.Expired => CreateExpired(sponsorable, prefix),
38-
_ => throw new NotImplementedException(),
26+
// Requires:
27+
// <Constant Include="Funding.Product" Value="[PRODUCT_NAME]" />
28+
// <Constant Include="Funding.AnalyzerPrefix" Value="[PREFIX]" />
29+
{ SponsorStatus.Unknown, CreateUnknown([.. Sponsorables.Keys], Funding.Product, Funding.Prefix) },
30+
{ SponsorStatus.Sponsor, CreateSponsor([.. Sponsorables.Keys], Funding.Prefix) },
31+
{ SponsorStatus.Expiring, CreateExpiring([.. Sponsorables.Keys], Funding.Prefix) },
32+
{ SponsorStatus.Expired, CreateExpired([.. Sponsorables.Keys], Funding.Prefix) },
3933
};
4034

4135
/// <summary>
42-
/// Pushes a diagnostic for the given product. If an existing one exists, it is replaced.
36+
/// Acceses the diagnostics dictionary for the current <see cref="AppDomain"/>.
4337
/// </summary>
44-
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
45-
public Diagnostic Push(string product, Diagnostic diagnostic)
46-
{
47-
// Directly sets, since we only expect to get one warning per sponsorable+product
48-
// combination.
49-
Diagnostics[product] = diagnostic;
50-
return diagnostic;
51-
}
38+
ConcurrentDictionary<string, Diagnostic> Diagnostics
39+
=> AppDomainDictionary.Get<ConcurrentDictionary<string, Diagnostic>>(nameof(Diagnostics));
5240

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

6452
/// <summary>
65-
/// Gets the status of the given product based on a previously stored diagnostic.
53+
/// Gets the status of the given product based on a previously stored diagnostic.
54+
/// To ensure the value is always set before returning, use <see cref="GetOrSetStatus"/>.
55+
/// This method is safe to use (and would get a non-null value) in analyzers that run after CompilationStartAction(see
56+
/// https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Analyzer%20Actions%20Semantics.md under Ordering of actions).
6657
/// </summary>
67-
/// <param name="product">The product to check status for.</param>
6858
/// <returns>Optional <see cref="SponsorStatus"/> that was reported, if any.</returns>
69-
public SponsorStatus? GetStatus(string product)
59+
public SponsorStatus? GetStatus()
7060
{
7161
// NOTE: the SponsorLinkAnalyzer.SetStatus uses diagnostic properties to store the
7262
// kind of diagnostic as a simple string instead of the enum. We do this so that
7363
// multiple analyzers or versions even across multiple products, which all would
7464
// have their own enum, can still share the same diagnostic kind.
75-
if (Diagnostics.TryGetValue(product, out var diagnostic) &&
65+
if (Diagnostics.TryGetValue(Funding.Product, out var diagnostic) &&
7666
diagnostic.Properties.TryGetValue(nameof(SponsorStatus), out var value))
7767
{
7868
// Switch on value matching DiagnosticKind names
@@ -89,7 +79,76 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
8979
return null;
9080
}
9181

92-
static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
82+
/// <summary>
83+
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
84+
/// the given set of <paramref name="manifests"/> if not already set.
85+
/// </summary>
86+
public SponsorStatus GetOrSetStatus(ImmutableArray<AdditionalText> manifests)
87+
=> GetOrSetStatus(() => manifests);
88+
89+
/// <summary>
90+
/// Gets the status of the <see cref="Funding.Product"/>, or sets it from
91+
/// the given analyzer <paramref name="options"/> if not already set.
92+
/// </summary>
93+
public SponsorStatus GetOrSetStatus(Func<AnalyzerOptions?> options)
94+
=> GetOrSetStatus(() => options().GetSponsorManifests());
95+
96+
SponsorStatus GetOrSetStatus(Func<ImmutableArray<AdditionalText>> getManifests)
97+
{
98+
if (GetStatus() is { } status)
99+
return status;
100+
101+
if (!SponsorLink.TryRead(out var claims, getManifests().Select(text =>
102+
(text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
103+
claims.GetExpiration() is not DateTime exp)
104+
{
105+
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
106+
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Unknown], null,
107+
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
108+
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
109+
return SponsorStatus.Unknown;
110+
}
111+
else if (exp < DateTime.Now)
112+
{
113+
// report expired or expiring soon if still within the configured days of grace period
114+
if (exp.AddDays(Funding.Grace) < DateTime.Now)
115+
{
116+
// report expiring soon
117+
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expiring], null,
118+
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
119+
return SponsorStatus.Expiring;
120+
}
121+
else
122+
{
123+
// report expired
124+
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Expired], null,
125+
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
126+
return SponsorStatus.Expired;
127+
}
128+
}
129+
else
130+
{
131+
// report sponsor
132+
Push(Funding.Product, Diagnostic.Create(KnownDescriptors[SponsorStatus.Sponsor], null,
133+
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
134+
Funding.Product));
135+
return SponsorStatus.Sponsor;
136+
}
137+
}
138+
139+
/// <summary>
140+
/// Pushes a diagnostic for the given product.
141+
/// </summary>
142+
/// <returns>The same diagnostic that was pushed, for chained invocations.</returns>
143+
Diagnostic Push(string product, Diagnostic diagnostic)
144+
{
145+
// We only expect to get one warning per sponsorable+product
146+
// combination, and first one to set wins.
147+
Diagnostics.TryAdd(product, diagnostic);
148+
return diagnostic;
149+
}
150+
151+
internal static DiagnosticDescriptor CreateSponsor(string[] sponsorable, string prefix) => new(
93152
$"{prefix}100",
94153
Resources.Sponsor_Title,
95154
Resources.Sponsor_Message,
@@ -100,7 +159,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
100159
helpLinkUri: "https://github.com/devlooped#sponsorlink",
101160
"DoesNotSupportF1Help");
102161

103-
static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
162+
internal static DiagnosticDescriptor CreateUnknown(string[] sponsorable, string product, string prefix) => new(
104163
$"{prefix}101",
105164
Resources.Unknown_Title,
106165
Resources.Unknown_Message,
@@ -113,7 +172,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
113172
helpLinkUri: "https://github.com/devlooped#sponsorlink",
114173
WellKnownDiagnosticTags.NotConfigurable);
115174

116-
static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new(
175+
internal static DiagnosticDescriptor CreateExpiring(string[] sponsorable, string prefix) => new(
117176
$"{prefix}103",
118177
Resources.Expiring_Title,
119178
Resources.Expiring_Message,
@@ -124,7 +183,7 @@ public Diagnostic Push(string product, Diagnostic diagnostic)
124183
helpLinkUri: "https://github.com/devlooped#autosync",
125184
"DoesNotSupportF1Help", WellKnownDiagnosticTags.NotConfigurable);
126185

127-
static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new(
186+
internal static DiagnosticDescriptor CreateExpired(string[] sponsorable, string prefix) => new(
128187
$"{prefix}104",
129188
Resources.Expired_Title,
130189
Resources.Expired_Message,

src/SponsorLink/SponsorLink/SponsorLink.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,14 @@
22
#nullable enable
33
using System;
44
using System.Collections.Generic;
5+
using System.Collections.Immutable;
56
using System.Diagnostics.CodeAnalysis;
7+
using System.IO;
68
using System.Linq;
79
using System.Reflection;
810
using System.Security.Claims;
11+
using Microsoft.CodeAnalysis;
12+
using Microsoft.CodeAnalysis.Diagnostics;
913
using Microsoft.IdentityModel.JsonWebTokens;
1014
using Microsoft.IdentityModel.Tokens;
1115

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

66+
/// <summary>
67+
/// Gets all sponsor manifests from the provided analyzer options.
68+
/// </summary>
69+
public static ImmutableArray<AdditionalText> GetSponsorManifests(this AnalyzerOptions? options)
70+
=> options == null ? ImmutableArray.Create<AdditionalText>() : options.AdditionalFiles
71+
.Where(x =>
72+
options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
73+
itemType == "SponsorManifest" &&
74+
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path)))
75+
.ToImmutableArray();
76+
77+
/// <summary>
78+
/// Gets all sponsor manifests from the provided analyzer options.
79+
/// </summary>
80+
public static IncrementalValueProvider<ImmutableArray<AdditionalText>> GetSponsorManifests(this IncrementalGeneratorInitializationContext context)
81+
=> context.AdditionalTextsProvider.Combine(context.AnalyzerConfigOptionsProvider)
82+
.Where(source =>
83+
{
84+
var (text, options) = source;
85+
return options.GetOptions(text).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
86+
itemType == "SponsorManifest" &&
87+
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(text.Path));
88+
})
89+
.Select((source, c) => source.Left)
90+
.Collect();
91+
6292
/// <summary>
6393
/// Reads all manifests, validating their signatures.
6494
/// </summary>
Lines changed: 2 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11
// <autogenerated />
22
#nullable enable
33
using System;
4-
using System.Collections.Generic;
54
using System.Collections.Immutable;
6-
using System.Diagnostics;
75
using System.IO;
86
using System.Linq;
9-
using Humanizer;
10-
using Humanizer.Localisation;
117
using Microsoft.CodeAnalysis;
128
using Microsoft.CodeAnalysis.Diagnostics;
139
using static Devlooped.Sponsors.SponsorLink;
@@ -20,18 +16,7 @@ namespace Devlooped.Sponsors;
2016
[DiagnosticAnalyzer(LanguageNames.CSharp, LanguageNames.VisualBasic)]
2117
public class SponsorLinkAnalyzer : DiagnosticAnalyzer
2218
{
23-
static readonly Dictionary<SponsorStatus, DiagnosticDescriptor> descriptors = new()
24-
{
25-
// Requires:
26-
// <Constant Include="Funding.Product" Value="[PRODUCT_NAME]" />
27-
// <Constant Include="Funding.AnalyzerPrefix" Value="[PREFIX]" />
28-
{ SponsorStatus.Unknown, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Unknown) },
29-
{ SponsorStatus.Sponsor, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Sponsor) },
30-
{ SponsorStatus.Expiring, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expiring) },
31-
{ SponsorStatus.Expired, Diagnostics.GetDescriptor([.. Sponsorables.Keys], Funding.Product, Funding.Prefix, SponsorStatus.Expired) },
32-
};
33-
34-
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = descriptors.Values.ToImmutableArray();
19+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } = DiagnosticsManager.KnownDescriptors.Values.ToImmutableArray();
3520

3621
#pragma warning disable RS1026 // Enable concurrent execution
3722
public override void Initialize(AnalysisContext context)
@@ -49,15 +34,8 @@ public override void Initialize(AnalysisContext context)
4934
// analyzers can report the same diagnostic and we want to avoid duplicates.
5035
context.RegisterCompilationStartAction(ctx =>
5136
{
52-
var manifests = ctx.Options.AdditionalFiles
53-
.Where(x =>
54-
ctx.Options.AnalyzerConfigOptionsProvider.GetOptions(x).TryGetValue("build_metadata.SponsorManifest.ItemType", out var itemType) &&
55-
itemType == "SponsorManifest" &&
56-
Sponsorables.ContainsKey(Path.GetFileNameWithoutExtension(x.Path)))
57-
.ToImmutableArray();
58-
5937
// Setting the status early allows other analyzers to potentially check for it.
60-
var status = SetStatus(manifests);
38+
var status = Diagnostics.GetOrSetStatus(() => ctx.Options);
6139

6240
// Never report any diagnostic unless we're in an editor.
6341
if (IsEditor)
@@ -108,44 +86,4 @@ public override void Initialize(AnalysisContext context)
10886
});
10987
#pragma warning restore RS1013 // Start action has no registered non-end actions
11088
}
111-
112-
SponsorStatus SetStatus(ImmutableArray<AdditionalText> manifests)
113-
{
114-
if (!SponsorLink.TryRead(out var claims, manifests.Select(text =>
115-
(text.GetText()?.ToString() ?? "", Sponsorables[Path.GetFileNameWithoutExtension(text.Path)]))) ||
116-
claims.GetExpiration() is not DateTime exp)
117-
{
118-
// report unknown, either unparsed manifest or one with no expiration (which we never emit).
119-
Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Unknown], null,
120-
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Unknown)),
121-
Funding.Product, Sponsorables.Keys.Humanize(Resources.Or)));
122-
return SponsorStatus.Unknown;
123-
}
124-
else if (exp < DateTime.Now)
125-
{
126-
// report expired or expiring soon if still within the configured days of grace period
127-
if (exp.AddDays(Funding.Grace) < DateTime.Now)
128-
{
129-
// report expiring soon
130-
Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expiring], null,
131-
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expiring))));
132-
return SponsorStatus.Expiring;
133-
}
134-
else
135-
{
136-
// report expired
137-
Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Expired], null,
138-
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Expired))));
139-
return SponsorStatus.Expired;
140-
}
141-
}
142-
else
143-
{
144-
// report sponsor
145-
Diagnostics.Push(Funding.Product, Diagnostic.Create(descriptors[SponsorStatus.Sponsor], null,
146-
properties: ImmutableDictionary.Create<string, string?>().Add(nameof(SponsorStatus), nameof(SponsorStatus.Sponsor)),
147-
Funding.Product));
148-
return SponsorStatus.Sponsor;
149-
}
150-
}
15189
}

0 commit comments

Comments
 (0)