From d4ccfb84c9df9a2d09e546a06d945758ecaaa8cd Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Fri, 13 Sep 2024 18:33:09 -0300 Subject: [PATCH] After grace period, emit code with warnings It was made quite clear that (some?) folks would rather just disable our friendly warning rather than sponsor. So we'll instead just make sponsorship mandatory. See https://github.com/devlooped/ThisAssembly/issues/352#issuecomment-2325054917 I worked hard to accomodate very flexible options for sponsoring, and this project ain't maintaining itself. It's fine if folks use something else. I made this initially just for myself, and I'm glad it's been useful for others. We also add remarks to the emitted APIs during the grace period, so it's not surprising they turn to warnings later. --- src/Directory.targets | 5 +- src/Shared/EmbeddedResource.cs | 15 +++++- src/Shared/PathSanitizer.cs | 9 +++- .../AssemblyInfoGenerator.cs | 26 ++++++++-- src/ThisAssembly.AssemblyInfo/CSharp.sbntxt | 27 +++++++++-- src/ThisAssembly.AssemblyInfo/Model.cs | 3 ++ src/ThisAssembly.Constants/CSharp.sbntxt | 22 ++++++++- .../ConstantsGenerator.cs | 48 +++++++++++++------ src/ThisAssembly.Constants/Model.cs | 3 ++ src/ThisAssembly.Strings/CSharp.sbntxt | 16 ++++++- src/ThisAssembly.Strings/Model.cs | 7 ++- src/ThisAssembly.Strings/StringsGenerator.cs | 25 ++++++++-- src/ThisAssembly.Tests/Funding.cs | 11 +++++ src/ThisAssembly.Tests/ScribanTests.cs | 38 +++++++++++++++ src/ThisAssembly.Tests/Tests.cs | 6 +-- .../ThisAssembly.Tests.csproj | 18 +++++-- 16 files changed, 236 insertions(+), 43 deletions(-) create mode 100644 src/ThisAssembly.Tests/Funding.cs create mode 100644 src/ThisAssembly.Tests/ScribanTests.cs diff --git a/src/Directory.targets b/src/Directory.targets index 190069e0..d2a8362b 100644 --- a/src/Directory.targets +++ b/src/Directory.targets @@ -5,6 +5,7 @@ > This project uses SponsorLink and may issue IDE-only warnings if no active sponsorship is detected. Learn more at https://github.com/devlooped#sponsorlink. + $(PackFolder.StartsWith('analyzers/')) @@ -12,12 +13,14 @@ + + - + + /// Gets the content of the embedded resource at the specified relative path. + /// public static string GetContent(string relativePath) { using var stream = GetStream(relativePath); @@ -17,6 +23,9 @@ public static string GetContent(string relativePath) return reader.ReadToEnd(); } + /// + /// Gets the bytes of the embedded resource at the specified relative path. + /// public static byte[] GetBytes(string relativePath) { using var stream = GetStream(relativePath); @@ -25,6 +34,10 @@ public static byte[] GetBytes(string relativePath) return bytes; } + /// + /// Gets the stream of the embedded resource at the specified relative path. + /// + /// public static Stream GetStream(string relativePath) { #if DEBUG diff --git a/src/Shared/PathSanitizer.cs b/src/Shared/PathSanitizer.cs index 992cda7b..ff8b0d29 100644 --- a/src/Shared/PathSanitizer.cs +++ b/src/Shared/PathSanitizer.cs @@ -1,8 +1,15 @@ using System.Text.RegularExpressions; -static class PathSanitizer +/// +/// Sanitizes paths for use as identifiers. +/// +public static class PathSanitizer { static readonly Regex invalidCharsRegex = new(@"\W"); + + /// + /// Sanitizes the specified path for use as an identifier. + /// public static string Sanitize(string path, string parent) { var partStr = invalidCharsRegex.Replace(path, "_"); diff --git a/src/ThisAssembly.AssemblyInfo/AssemblyInfoGenerator.cs b/src/ThisAssembly.AssemblyInfo/AssemblyInfoGenerator.cs index ffbe6e28..d5e3ec6a 100644 --- a/src/ThisAssembly.AssemblyInfo/AssemblyInfoGenerator.cs +++ b/src/ThisAssembly.AssemblyInfo/AssemblyInfoGenerator.cs @@ -1,14 +1,19 @@ using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; using System.Linq; using System.Reflection; using System.Text; using System.Threading; +using Devlooped.Sponsors; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Text; using Scriban; +using static Devlooped.Sponsors.SponsorLink; +using Resources = Devlooped.Sponsors.Resources; namespace ThisAssembly; @@ -41,7 +46,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) // Read the ThisAssemblyNamespace property or default to null var right = context.AnalyzerConfigOptionsProvider .Select((c, t) => c.GlobalOptions.TryGetValue("build_property.ThisAssemblyNamespace", out var ns) && !string.IsNullOrEmpty(ns) ? ns : null) - .Combine(context.ParseOptionsProvider); + .Combine(context.ParseOptionsProvider.Combine(context.GetStatusOptions())); context.RegisterSourceOutput( metadata.Combine(right), @@ -74,11 +79,24 @@ public void Initialize(IncrementalGeneratorInitializationContext context) } static void GenerateSource(SourceProductionContext spc, - (ImmutableArray> attributes, (string? ns, ParseOptions parse)) arg) + (ImmutableArray> attributes, (string? ns, (ParseOptions parse, StatusOptions options))) arg) { - var (attributes, (ns, parse)) = arg; + var (attributes, (ns, (parse, options))) = arg; + var model = new Model([.. attributes], ns); + if (IsEditor) + { + var status = Diagnostics.GetOrSetStatus(options); + if (status == SponsorStatus.Unknown || status == SponsorStatus.Expired) + { + model.Warn = string.Format(CultureInfo.CurrentCulture, Resources.Editor_Disabled, Funding.Product, Funding.HelpUrl); + model.Remarks = Resources.Editor_DisabledRemarks; + } + else if (status == SponsorStatus.Grace && Diagnostics.TryGet() is { } grace && grace.Properties.TryGetValue(nameof(SponsorStatus.Grace), out var days)) + { + model.Remarks = string.Format(CultureInfo.CurrentCulture, Resources.Editor_GraceRemarks, days); + } + } - var model = new Model(attributes.ToList(), ns); if (parse is CSharpParseOptions cs && (int)cs.LanguageVersion >= 1100) model.RawStrings = true; diff --git a/src/ThisAssembly.AssemblyInfo/CSharp.sbntxt b/src/ThisAssembly.AssemblyInfo/CSharp.sbntxt index c4c7ed08..f2d3d736 100644 --- a/src/ThisAssembly.AssemblyInfo/CSharp.sbntxt +++ b/src/ThisAssembly.AssemblyInfo/CSharp.sbntxt @@ -7,6 +7,7 @@ // //------------------------------------------------------------------------------ +using System; using System.CodeDom.Compiler; using System.Runtime.CompilerServices; {{ if Namespace }} @@ -21,19 +22,35 @@ partial class ThisAssembly /// /// Gets the AssemblyInfo attributes. /// + {{~ if Remarks ~}} + {{ Remarks }} + /// + {{~ end ~}} [GeneratedCode("ThisAssembly.AssemblyInfo", "{{ Version }}")] [CompilerGenerated] public static partial class Info { - {{~ for prop in Properties ~}} + {{- for prop in Properties ~}} + + {{~ if Remarks ~}} + {{ Remarks }} + /// + {{~ end ~}} + {{~ if Warn ~}} + [Obsolete("{{ Warn }}", false + #if NET6_0_OR_GREATER + , UrlFormat = "{{ Url }}" + #endif + )] + {{~ end ~}} {{~ if RawStrings ~}} public const string {{ prop.Key }} = -""" -{{ prop.Value }} -"""; + """ + {{ prop.Value }} + """; {{~ else ~}} public const string {{ prop.Key }} = @"{{ prop.Value }}"; {{~ end ~}} {{~ end ~}} } -} \ No newline at end of file +} diff --git a/src/ThisAssembly.AssemblyInfo/Model.cs b/src/ThisAssembly.AssemblyInfo/Model.cs index da0ab9c2..1a988760 100644 --- a/src/ThisAssembly.AssemblyInfo/Model.cs +++ b/src/ThisAssembly.AssemblyInfo/Model.cs @@ -13,6 +13,9 @@ public Model(IEnumerable> properties, string? ns) public string? Namespace { get; } public bool RawStrings { get; set; } = false; public string Version => Assembly.GetExecutingAssembly().GetName().Version.ToString(3); + public string Url => Devlooped.Sponsors.SponsorLink.Funding.HelpUrl; + public string? Warn { get; set; } + public string? Remarks { get; set; } public List> Properties { get; } } diff --git a/src/ThisAssembly.Constants/CSharp.sbntxt b/src/ThisAssembly.Constants/CSharp.sbntxt index a5af0287..e0231946 100644 --- a/src/ThisAssembly.Constants/CSharp.sbntxt +++ b/src/ThisAssembly.Constants/CSharp.sbntxt @@ -8,7 +8,7 @@ // the code is regenerated. // //------------------------------------------------------------------------------ -{{ func summary }} +{{- func summary -}} /// {{~ if $0.Comment ~}} /// {{ $0.Comment }} @@ -16,12 +16,30 @@ /// => @"{{ $0.Value }}" {{~ end ~}} /// +{{- end -}} +{{ func obsolete }} +{{~ if Warn ~}} +[Obsolete("{{ Warn }}", false +#if NET6_0_OR_GREATER + , UrlFormat = "{{ Url }}" +#endif +)] + +{{~ end }} +{{ end }} +{{ func remarks }} +{{~ if Remarks ~}} +{{ Remarks }} +/// +{{~ end ~}} {{ end }} {{ func render }} public static partial class {{ $0.Name | string.replace "-" "_" | string.replace " " "_" }} { {{~ for value in $0.Values ~}} - {{- summary value ~}} + {{- summary value -}} + {{- remarks -}} + {{ obsolete }} {{~ if RawStrings ~}} public const string {{ value.Name | string.replace "-" "_" | string.replace " " "_" }} = """ diff --git a/src/ThisAssembly.Constants/ConstantsGenerator.cs b/src/ThisAssembly.Constants/ConstantsGenerator.cs index a8aa44ee..c865fdd7 100644 --- a/src/ThisAssembly.Constants/ConstantsGenerator.cs +++ b/src/ThisAssembly.Constants/ConstantsGenerator.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System.Diagnostics.Tracing; +using System.Globalization; using System.IO; using System.Linq; using System.Text; +using Devlooped.Sponsors; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; @@ -58,15 +60,26 @@ public void Initialize(IncrementalGeneratorInitializationContext context) .Select((c, t) => c.GlobalOptions.TryGetValue("build_property.ThisAssemblyNamespace", out var ns) && !string.IsNullOrEmpty(ns) ? ns : null) .Combine(context.ParseOptionsProvider); - context.RegisterSourceOutput( - files.Combine(right), - GenerateConstant); + var inputs = files.Combine(right); + // this is required to ensure status is registered properly independently of analyzer runs. + var options = context.GetStatusOptions(); + context.RegisterSourceOutput(inputs.Combine(options), GenerateConstant); + //(spc, source) => + //{ + // var status = Diagnostics.GetOrSetStatus(source.Right); + // var warn = IsEditor && + // (status == Devlooped.Sponsors.SponsorStatus.Unknown || status == Devlooped.Sponsors.SponsorStatus.Expired); + + // GenerateConstant(spc, source.Left, warn ? string.Format( + // CultureInfo.CurrentCulture, Resources.Editor_Disabled, Funding.Product, Funding.HelpUrl) : null); + //}); } - void GenerateConstant(SourceProductionContext spc, ((string name, string value, string? comment, string root), (string? ns, ParseOptions parse)) args) + void GenerateConstant(SourceProductionContext spc, + (((string name, string value, string? comment, string root), (string? ns, ParseOptions parse)), StatusOptions options) args) { - var ((name, value, comment, root), (ns, parse)) = args; + var (((name, value, comment, root), (ns, parse)), options) = args; var cs = (CSharpParseOptions)parse; if (!string.IsNullOrWhiteSpace(ns) && @@ -87,24 +100,31 @@ void GenerateConstant(SourceProductionContext spc, ((string name, string value, if ((int)cs.LanguageVersion >= 1100) model.RawStrings = true; + if (IsEditor) + { + var status = Diagnostics.GetOrSetStatus(options); + if (status == SponsorStatus.Unknown || status == SponsorStatus.Expired) + { + model.Warn = string.Format(CultureInfo.CurrentCulture, Resources.Editor_Disabled, Funding.Product, Funding.HelpUrl); + model.Remarks = Resources.Editor_DisabledRemarks; + } + else if (status == SponsorStatus.Grace && Diagnostics.TryGet() is { } grace && grace.Properties.TryGetValue(nameof(SponsorStatus.Grace), out var days)) + { + model.Remarks = string.Format(CultureInfo.CurrentCulture, Resources.Editor_GraceRemarks, days); + } + } + var output = template.Render(model, member => member.Name); // Apply formatting since indenting isn't that nice in Scriban when rendering nested // structures via functions. if (parse.Language == LanguageNames.CSharp) { - output = SyntaxFactory.ParseCompilationUnit(output) + output = SyntaxFactory.ParseCompilationUnit(output, options: cs) .NormalizeWhitespace() .GetText() .ToString(); } - //else if (language == LanguageNames.VisualBasic) - //{ - // output = Microsoft.CodeAnalysis.VisualBasic.SyntaxFactory.ParseCompilationUnit(output) - // .NormalizeWhitespace() - // .GetText() - // .ToString(); - //} spc.AddSource($"{root}.{name}.g.cs", SourceText.From(output, Encoding.UTF8)); } diff --git a/src/ThisAssembly.Constants/Model.cs b/src/ThisAssembly.Constants/Model.cs index 8945f776..9e7f3cde 100644 --- a/src/ThisAssembly.Constants/Model.cs +++ b/src/ThisAssembly.Constants/Model.cs @@ -13,6 +13,9 @@ record Model(Area RootArea, string? Namespace) { public bool RawStrings { get; set; } = false; public string Version => Assembly.GetExecutingAssembly().GetName().Version.ToString(3); + public string Url => Devlooped.Sponsors.SponsorLink.Funding.HelpUrl; + public string? Warn { get; set; } + public string? Remarks { get; set; } } [DebuggerDisplay("Name = {Name}, NestedAreas = {NestedAreas.Count}, Values = {Values.Count}")] diff --git a/src/ThisAssembly.Strings/CSharp.sbntxt b/src/ThisAssembly.Strings/CSharp.sbntxt index 039d67b6..2a467d9a 100644 --- a/src/ThisAssembly.Strings/CSharp.sbntxt +++ b/src/ThisAssembly.Strings/CSharp.sbntxt @@ -16,11 +16,22 @@ /// = "{{ $0.Value }}" {{~ end ~}} /// + {{~ if Remarks ~}} + {{ Remarks }} + /// + {{~ end ~}} + {{~ if Warn ~}} + [Obsolete("{{ Warn }}", false + #if NET6_0_OR_GREATER + , UrlFormat = "{{ Url }}" + #endif + )] + {{~ end ~}} {{ end }} {{ func render }} public static partial class {{ $0.Id }} { - {{~ for value in $0.Values ~}} + {{~ for value in $0.Values }} {{~ if!(value.HasFormat) ~}} {{- summary value ~}} public static string {{ value.Id }} => Strings.GetResourceManager("{{ $1 }}").GetString("{{ value.Name }}"); @@ -70,7 +81,7 @@ using System; using System.Globalization; {{ if Namespace }} namespace {{ Namespace }}; -{{~ end ~}} +{{ end }} /// /// Provides access to the current assembly information. @@ -80,5 +91,6 @@ partial class ThisAssembly /// /// Provides access to the assembly strings. /// + {{- remarks -}} {{ render RootArea ResourceName }} } \ No newline at end of file diff --git a/src/ThisAssembly.Strings/Model.cs b/src/ThisAssembly.Strings/Model.cs index 46475a61..2169d992 100644 --- a/src/ThisAssembly.Strings/Model.cs +++ b/src/ThisAssembly.Strings/Model.cs @@ -10,6 +10,9 @@ record Model(ResourceArea RootArea, string ResourceName, string? Namespace) { public string? Version => Assembly.GetExecutingAssembly().GetName().Version?.ToString(3); + public string Url => Devlooped.Sponsors.SponsorLink.Funding.HelpUrl; + public string? Warn { get; set; } + public string? Remarks { get; set; } } static class ResourceFile @@ -131,8 +134,8 @@ static ResourceValue GetValue(string resourceId, string resourceName, string res [DebuggerDisplay("Id = {Id}, NestedAreas = {NestedAreas.Count}, Values = {Values.Count}")] record ResourceArea(string Id, string Prefix) { - public List NestedAreas { get; init; } = new List(); - public List Values { get; init; } = new List(); + public List NestedAreas { get; init; } = []; + public List Values { get; init; } = []; } [DebuggerDisplay("{Id} = {Value}")] diff --git a/src/ThisAssembly.Strings/StringsGenerator.cs b/src/ThisAssembly.Strings/StringsGenerator.cs index 73aae59f..97945aba 100644 --- a/src/ThisAssembly.Strings/StringsGenerator.cs +++ b/src/ThisAssembly.Strings/StringsGenerator.cs @@ -1,11 +1,15 @@ using System; +using System.Globalization; using System.IO; using System.Linq; using System.Resources; using System.Text; +using Devlooped.Sponsors; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.Text; using Scriban; +using static Devlooped.Sponsors.SponsorLink; namespace ThisAssembly; @@ -46,24 +50,37 @@ public void Initialize(IncrementalGeneratorInitializationContext context) context.RegisterSourceOutput( - files.Combine(right), + files.Combine(right).Combine(context.ParseOptionsProvider.Combine(context.GetStatusOptions())), GenerateSource); } static void GenerateSource(SourceProductionContext spc, - ((string fileName, SourceText? text, string resourceName), (string? ns, string language)) arg) + (((string fileName, SourceText? text, string resourceName), (string? ns, string language)),(ParseOptions parse, StatusOptions options)) arg) { - var ((fileName, resourceText, resourceName), (ns, language)) = arg; + var (((fileName, resourceText, resourceName), (ns, language)), (parse, options)) = arg; var file = language.Replace("#", "Sharp") + ".sbntxt"; var template = Template.Parse(EmbeddedResource.GetContent(file), file); var rootArea = ResourceFile.LoadText(resourceText!.ToString(), "Strings"); var model = new Model(rootArea, resourceName, ns); + if (IsEditor) + { + var status = Diagnostics.GetOrSetStatus(options); + if (status == SponsorStatus.Unknown || status == SponsorStatus.Expired) + { + model.Warn = string.Format(CultureInfo.CurrentCulture, Resources.Editor_Disabled, Funding.Product, Funding.HelpUrl); + model.Remarks = Resources.Editor_DisabledRemarks; + } + else if (status == SponsorStatus.Grace && Diagnostics.TryGet() is { } grace && grace.Properties.TryGetValue(nameof(SponsorStatus.Grace), out var days)) + { + model.Remarks = string.Format(CultureInfo.CurrentCulture, Resources.Editor_GraceRemarks, days); + } + } var output = template.Render(model, member => member.Name); - output = Microsoft.CodeAnalysis.CSharp.SyntaxFactory.ParseCompilationUnit(output) + output = SyntaxFactory.ParseCompilationUnit(output, options: parse as CSharpParseOptions) .NormalizeWhitespace() .GetText() .ToString(); diff --git a/src/ThisAssembly.Tests/Funding.cs b/src/ThisAssembly.Tests/Funding.cs new file mode 100644 index 00000000..dc856909 --- /dev/null +++ b/src/ThisAssembly.Tests/Funding.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace Devlooped.Sponsors; + +partial class SponsorLink +{ + public partial class Funding + { + public const string HelpUrl = "https://github.com/devlooped#sponsorlink"; + } +} diff --git a/src/ThisAssembly.Tests/ScribanTests.cs b/src/ThisAssembly.Tests/ScribanTests.cs new file mode 100644 index 00000000..c0831e46 --- /dev/null +++ b/src/ThisAssembly.Tests/ScribanTests.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Scriban; +using Xunit; +using Xunit.Abstractions; + +namespace ThisAssemblyTests; + +public class ScribanTests(ITestOutputHelper Console) +{ + [Fact] + public void CanRenderModel() + { + var source = + """ + {{ func remarks }} + /// + {{ end }} + /// + /// {{ summary }} + /// + {{ remarks }} + """; + + var template = Template.Parse(source); + var output = template.Render(new + { + summary = "This is a summary", + url = "https://github.com/devlooped#sponsorlink" + }); + + Assert.Contains("https://github.com/devlooped#sponsorlink", output); + Console.WriteLine(output); + } +} diff --git a/src/ThisAssembly.Tests/Tests.cs b/src/ThisAssembly.Tests/Tests.cs index b29f5a85..3b596946 100644 --- a/src/ThisAssembly.Tests/Tests.cs +++ b/src/ThisAssembly.Tests/Tests.cs @@ -1,8 +1,9 @@ -using System.Diagnostics.CodeAnalysis; +using System; +using System.Diagnostics.CodeAnalysis; using System.IO; -using Devlooped; using Xunit; using Xunit.Abstractions; +//using ThisAssembly = ThisAssemblyTests [assembly: SuppressMessage("SponsorLink", "SL04")] @@ -10,7 +11,6 @@ namespace ThisAssemblyTests; public record class Tests(ITestOutputHelper Output) { - DateTime dxt = DateTime.Now; [Fact] public void CanReadResourceFile() => Assert.NotNull(ResourceFile.Load("Resources.resx", "Strings")); diff --git a/src/ThisAssembly.Tests/ThisAssembly.Tests.csproj b/src/ThisAssembly.Tests/ThisAssembly.Tests.csproj index 6e72472b..d5b132b7 100644 --- a/src/ThisAssembly.Tests/ThisAssembly.Tests.csproj +++ b/src/ThisAssembly.Tests/ThisAssembly.Tests.csproj @@ -3,7 +3,7 @@ false net8.0 - Devlooped + ThisAssemblyTests A Description with a newline and @@ -17,10 +17,11 @@ net472 ThisAssemblyTests true - CS8981;$(NoWarn) + CS0618;CS8981;TA100;$(NoWarn) + false - + @@ -40,6 +41,7 @@ + @@ -81,7 +83,7 @@ - + @@ -93,4 +95,12 @@ + + + $(Version) + + + + +