From 4eaf7e8f21fa725a5d8c998fca1cb8cfe01f0f0a Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Mon, 19 Oct 2026 14:12:18 +0200 Subject: [PATCH 1/2] Let users apply [UseWithArgumentNames] and [NonDefaultable] to their own code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The analyzers λ1003 and λ1009 now match their attribute by full name instead of resolving it through GetTypeByMetadataName. A copy of the attribute in any assembly therefore counts, and the rules no longer go silent in a project that references two assemblies which both define the attribute (the lookup returns null on that ambiguity). Funcky.Analyzers gains an incremental generator that emits the internal attributes into the consuming project when it opts in through the MSBuild property FunckyCodeAnalysisAttributes. The package targets expose the property to the compiler (comma-separated, since a semicolon starts a comment in the generated .editorconfig) and warn with λ.BUILD.0002 about unknown names. Closes #750 Co-Authored-By: Claude Fable 5.1 --- .../src/analyzer-rules/analyzer-rules.md | 16 +++ .../src/analyzer-rules/\316\2731003.md" | 31 +++++ .../src/analyzer-rules/\316\2731009.md" | 30 +++++ .../buildTransitive/Funcky.Analyzers.targets | 23 ++++ .../Funcky.Analyzers.Package/package.md | 9 ++ .../AttributeInSeveralAssembliesTest.cs | 93 ++++++++++++++ .../CodeAnalysisAttributesGeneratorTest.cs | 113 ++++++++++++++++++ .../CodeAnalysisAttributesGenerator.cs | 82 +++++++++++++ .../SymbolExtensions.cs | 20 ++++ .../NonDefaultable/NonDefaultableAnalyzer.cs | 43 +++---- .../NonDefaultableStyleCopSuppressor.cs | 10 +- .../UseWithArgumentNamesAnalyzer.cs | 17 ++- changelog.md | 8 ++ 13 files changed, 451 insertions(+), 44 deletions(-) create mode 100644 Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/AttributeInSeveralAssembliesTest.cs create mode 100644 Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs create mode 100644 Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs diff --git a/Documentation/src/analyzer-rules/analyzer-rules.md b/Documentation/src/analyzer-rules/analyzer-rules.md index b56571295..2186f6634 100644 --- a/Documentation/src/analyzer-rules/analyzer-rules.md +++ b/Documentation/src/analyzer-rules/analyzer-rules.md @@ -40,6 +40,22 @@ Rules in the `λ11xx` range concern the `FunctionalAssert` helpers from the [`Fu |------|-------|------| | λ0002 | Use of `Option.None()` method group | `Option.None()` was replaced by the `Option.None` property in 3.0. The rule and its code fix were removed once the method was gone. See the [migration guide](../migration-guide.md). | +## Using the attributes in your own code +Two rules are driven by attributes: [λ1003](./λ1003.md) looks for `[UseWithArgumentNames]` on methods and +[λ1009](./λ1009.md) for `[NonDefaultable]` on structs. The attributes are internal to Funcky, but the analyzers match them +by their full name in the `Funcky.CodeAnalysis` namespace, so a copy in any assembly counts. `Funcky.Analyzers` can +generate that copy into your project; list the attributes you want in the project file: + +```xml + + UseWithArgumentNames;NonDefaultable + +``` + +The generated attributes are `internal`, so projects that need them generate their own. The generation is opt-in because +a project that already has its own copy of an attribute would otherwise get a duplicate type. An unknown name in the list +produces the build warning `λ.BUILD.0002`. + ## Configuring severity All rules can be configured in an `.editorconfig` like any other Roslyn diagnostic, for example: diff --git "a/Documentation/src/analyzer-rules/\316\2731003.md" "b/Documentation/src/analyzer-rules/\316\2731003.md" index a1f347a32..6a2549290 100644 --- "a/Documentation/src/analyzer-rules/\316\2731003.md" +++ "b/Documentation/src/analyzer-rules/\316\2731003.md" @@ -32,3 +32,34 @@ static string Example(Option option) none: () => "none", some: value => value.ToString()); ``` + +## Using the attribute on your own methods +The attribute is internal to Funcky, but the analyzer matches it by its full name `Funcky.CodeAnalysis.UseWithArgumentNamesAttribute`, +so a copy in your own assembly works just as well. Let `Funcky.Analyzers` generate that copy by listing the attribute in your project file: + +```xml + + UseWithArgumentNames + +``` + +Alternatively, add the attribute by hand: + +```cs +namespace Funcky.CodeAnalysis; + +[AttributeUsage(AttributeTargets.Method)] +internal sealed class UseWithArgumentNamesAttribute : Attribute; +``` + +Either way, the rule then applies to every method you annotate: + +```cs +[UseWithArgumentNames] +public static Money Create(decimal amount, string currency) => ... + +Money.Create(amount: 10m, currency: "CHF"); // ok +Money.Create(10m, "CHF"); // λ1003 +``` + +See [Using the attributes in your own code](./analyzer-rules.md#using-the-attributes-in-your-own-code) for the details of the generator. diff --git "a/Documentation/src/analyzer-rules/\316\2731009.md" "b/Documentation/src/analyzer-rules/\316\2731009.md" index 92a309414..0883c8394 100644 --- "a/Documentation/src/analyzer-rules/\316\2731009.md" +++ "b/Documentation/src/analyzer-rules/\316\2731009.md" @@ -34,3 +34,33 @@ var eitherOrBoth = EitherOrBoth.Both("left", 42); // Option has a valid default state. Option none = default; ``` + +## Using the attribute on your own structs +The attribute is internal to Funcky, but the analyzer matches it by its full name `Funcky.CodeAnalysis.NonDefaultableAttribute`, +so a copy in your own assembly works just as well. Let `Funcky.Analyzers` generate that copy by listing the attribute in your project file: + +```xml + + NonDefaultable + +``` + +Alternatively, add the attribute by hand: + +```cs +namespace Funcky.CodeAnalysis; + +[AttributeUsage(AttributeTargets.Struct)] +internal sealed class NonDefaultableAttribute : Attribute; +``` + +Either way, the rule then applies to every struct you annotate: + +```cs +[NonDefaultable] +public readonly struct Money { ... } + +Money money = default; // λ1009 +``` + +See [Using the attributes in your own code](./analyzer-rules.md#using-the-attributes-in-your-own-code) for the details of the generator. diff --git a/Funcky.Analyzers/Funcky.Analyzers.Package/buildTransitive/Funcky.Analyzers.targets b/Funcky.Analyzers/Funcky.Analyzers.Package/buildTransitive/Funcky.Analyzers.targets index d3ee9576f..a90b340a8 100644 --- a/Funcky.Analyzers/Funcky.Analyzers.Package/buildTransitive/Funcky.Analyzers.targets +++ b/Funcky.Analyzers/Funcky.Analyzers.Package/buildTransitive/Funcky.Analyzers.targets @@ -9,4 +9,27 @@ + + + + + + + + <_FunckyCodeAnalysisAttributes>$(FunckyCodeAnalysisAttributes.Replace(';', ',')) + + <_FunckyCodeAnalysisAttributesList>$([MSBuild]::Unescape($(FunckyCodeAnalysisAttributes.Replace(',', ';')))) + + + <_FunckyUnknownCodeAnalysisAttribute Include="$(_FunckyCodeAnalysisAttributesList)" Exclude="UseWithArgumentNames;NonDefaultable" /> + + + diff --git a/Funcky.Analyzers/Funcky.Analyzers.Package/package.md b/Funcky.Analyzers/Funcky.Analyzers.Package/package.md index 569fd1b00..e3d7317f6 100644 --- a/Funcky.Analyzers/Funcky.Analyzers.Package/package.md +++ b/Funcky.Analyzers/Funcky.Analyzers.Package/package.md @@ -4,4 +4,13 @@ Analyzers and code fixes that guide you towards idiomatic usage of [Funcky]. ``` +The attributes behind the rules λ1003 (`[UseWithArgumentNames]`) and λ1009 (`[NonDefaultable]`) can be generated into your own +project, so that the rules also apply to your own members: + +```xml + + UseWithArgumentNames;NonDefaultable + +``` + [Funcky]: https://www.nuget.org/packages/Funcky diff --git a/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/AttributeInSeveralAssembliesTest.cs b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/AttributeInSeveralAssembliesTest.cs new file mode 100644 index 000000000..4ef4b1f48 --- /dev/null +++ b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/AttributeInSeveralAssembliesTest.cs @@ -0,0 +1,93 @@ +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using NonDefaultableVerifier = Funcky.Analyzers.Test.CSharpAnalyzerVerifier; +using UseWithArgumentNamesVerifier = Funcky.Analyzers.Test.CSharpAnalyzerVerifier; + +namespace Funcky.Analyzers.Test.CodeAnalysisAttributes; + +/// Users may define their own copy of the attributes (by hand or through the generator) in a library next to the +/// one in Funcky. A project referencing both must still get the diagnostics, which rules out a symbol lookup by metadata +/// name: that lookup returns nothing as soon as two referenced assemblies define the same name. +public sealed class AttributeInSeveralAssembliesTest +{ + private const string LibraryProjectName = "Library"; + + private const string OtherLibraryProjectName = "OtherLibrary"; + + // language=csharp + private const string AttributeSource = + """ + namespace Funcky.CodeAnalysis + { + [System.AttributeUsage(System.AttributeTargets.Method)] + internal sealed class UseWithArgumentNamesAttribute : System.Attribute { } + + [System.AttributeUsage(System.AttributeTargets.Struct)] + internal sealed class NonDefaultableAttribute : System.Attribute { } + } + """; + + // language=csharp + private const string LibrarySource = + """ + using Funcky.CodeAnalysis; + + namespace Library + { + public static class Api + { + [UseWithArgumentNames] + public static void Method(int x, int y) { } + } + + [NonDefaultable] + public struct Foo { } + } + """; + + [Fact] + public async Task UseWithArgumentNamesIsReportedWhenTheAttributeIsDefinedInTwoReferencedAssemblies() + { + // language=csharp + const string inputCode = + """ + class Test + { + private void Syntax() + { + Library.Api.Method({|λ1003:10|}, {|λ1003:20|}); + } + } + """; + + await RunAsync(new UseWithArgumentNamesVerifier.Test(), inputCode); + } + + [Fact] + public async Task NonDefaultableIsReportedWhenTheAttributeIsDefinedInTwoReferencedAssemblies() + { + // language=csharp + const string inputCode = + """ + class Test + { + private void Usage() + { + _ = {|λ1009:default(Library.Foo)|}; + } + } + """; + + await RunAsync(new NonDefaultableVerifier.Test(), inputCode); + } + + private static async Task RunAsync(AnalyzerTest test, string inputCode) + { + test.TestState.Sources.Add(inputCode); + test.TestState.AdditionalProjects[LibraryProjectName].Sources.Add(LibrarySource + Environment.NewLine + AttributeSource); + test.TestState.AdditionalProjects[OtherLibraryProjectName].Sources.Add(AttributeSource); + test.TestState.AdditionalProjectReferences.Add(LibraryProjectName); + test.TestState.AdditionalProjectReferences.Add(OtherLibraryProjectName); + await test.RunAsync(); + } +} diff --git a/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs new file mode 100644 index 000000000..ed01c6089 --- /dev/null +++ b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs @@ -0,0 +1,113 @@ +using System.Collections.Immutable; +using Funcky.Analyzers.CodeAnalysisAttributes; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Xunit; + +namespace Funcky.Analyzers.Test.CodeAnalysisAttributes; + +public sealed class CodeAnalysisAttributesGeneratorTest +{ + // language=csharp + private const string ConsumerCode = + """ + using Funcky.CodeAnalysis; + + [NonDefaultable] + public struct Money + { + [UseWithArgumentNames] + public static Money Create(decimal amount, string currency) => throw null!; + } + """; + + [Fact] + public void NothingIsGeneratedWhenThePropertyIsNotSet() + { + var (compilation, diagnostics) = Generate("class C { }", property: null); + + Assert.Empty(diagnostics); + Assert.Single(compilation.SyntaxTrees); + } + + [Fact] + public void TheRequestedAttributesAreGeneratedAndCompile() + { + var (compilation, diagnostics) = Generate(ConsumerCode, property: "UseWithArgumentNames; NonDefaultable"); + + Assert.Empty(diagnostics); + Assert.Equal(3, compilation.SyntaxTrees.Count()); + Assert.Empty(compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.UseWithArgumentNamesAttribute")); + Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.NonDefaultableAttribute")); + } + + [Fact] + public void OnlyTheRequestedAttributeIsGenerated() + { + var (compilation, diagnostics) = Generate("class C { }", property: "NonDefaultable"); + + Assert.Empty(diagnostics); + Assert.Null(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.UseWithArgumentNamesAttribute")); + Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.NonDefaultableAttribute")); + } + + [Fact] + public void UnknownNamesAreIgnored() + { + var (compilation, diagnostics) = Generate("class C { }", property: "Unknown,NonDefaultable"); + + Assert.Empty(diagnostics); + Assert.Equal(2, compilation.SyntaxTrees.Count()); + } + + [Theory] + [InlineData("", new string[0])] + [InlineData("UseWithArgumentNames", new[] { "UseWithArgumentNames" })] + [InlineData("UseWithArgumentNames;NonDefaultable", new[] { "UseWithArgumentNames", "NonDefaultable" })] + [InlineData(" UseWithArgumentNames , NonDefaultable ;; NonDefaultable", new[] { "UseWithArgumentNames", "NonDefaultable" })] + public void ThePropertyIsSplitTrimmedAndDeduplicated(string value, string[] expected) + => Assert.Equal(expected, CodeAnalysisAttributesGenerator.ParseAttributeNames(value)); + + private static (Compilation Compilation, ImmutableArray Diagnostics) Generate(string source, string? property) + { + var compilation = CSharpCompilation.Create( + "Consumer", + [CSharpSyntaxTree.ParseText(source)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver + .Create(new CodeAnalysisAttributesGenerator()) + .WithUpdatedAnalyzerConfigOptions(new BuildPropertyOptionsProvider(property)) + .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); + + _ = driver; + return (outputCompilation, diagnostics); + } + + private sealed class BuildPropertyOptionsProvider(string? value) : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new BuildPropertyOptions(value); + + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => GlobalOptions; + + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => GlobalOptions; + } + + private sealed class BuildPropertyOptions(string? value) : AnalyzerConfigOptions + { + public override bool TryGetValue(string key, out string result) + { + if (key == CodeAnalysisAttributesGenerator.BuildPropertyKey && value is not null) + { + result = value; + return true; + } + + result = null!; + return false; + } + } +} diff --git a/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs new file mode 100644 index 000000000..acafaac8d --- /dev/null +++ b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs @@ -0,0 +1,82 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Funcky.Analyzers.CodeAnalysisAttributes; + +/// Emits the attributes that Funcky's analyzers look for into the user's own assembly, so that they can be used on +/// their own members. The attributes are opt-in through the MSBuild property FunckyCodeAnalysisAttributes, a list of +/// attribute names without the Attribute suffix, because a project that already has its own copy would otherwise get a duplicate. +[Generator(LanguageNames.CSharp)] +public sealed class CodeAnalysisAttributesGenerator : IIncrementalGenerator +{ + /// The property the user sets. The package targets hand it to the generator as _FunckyCodeAnalysisAttributes, + /// comma-separated, because a semicolon starts a comment in the .editorconfig that carries properties to the compiler. + public const string PropertyName = "FunckyCodeAnalysisAttributes"; + + public const string BuildPropertyKey = $"build_property._{PropertyName}"; + + private const string UseWithArgumentNamesSource = + """ + // + #nullable enable + + namespace Funcky.CodeAnalysis + { + /// Methods annotated with this attribute should be called with named arguments. Funcky.Analyzers reports λ1003 otherwise. + [global::System.AttributeUsage(global::System.AttributeTargets.Method)] + internal sealed class UseWithArgumentNamesAttribute : global::System.Attribute + { + } + } + + """; + + private const string NonDefaultableSource = + """ + // + #nullable enable + + namespace Funcky.CodeAnalysis + { + /// Structs annotated with this attribute should not be instantiated with . Funcky.Analyzers reports λ1009 otherwise. + [global::System.AttributeUsage(global::System.AttributeTargets.Struct)] + internal sealed class NonDefaultableAttribute : global::System.Attribute + { + } + } + + """; + + private static readonly char[] Separators = [';', ',']; + + public static ImmutableDictionary Attributes { get; } = ImmutableDictionary.CreateRange( + StringComparer.Ordinal, + [ + new KeyValuePair("UseWithArgumentNames", UseWithArgumentNamesSource), + new KeyValuePair("NonDefaultable", NonDefaultableSource), + ]); + + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var requestedAttributes = context.AnalyzerConfigOptionsProvider + .Select(static (provider, _) => provider.GlobalOptions.TryGetValue(BuildPropertyKey, out var value) ? value : string.Empty) + .SelectMany(static (value, _) => ParseAttributeNames(value)); + + context.RegisterSourceOutput(requestedAttributes, static (context, attributeName) => + { + if (Attributes.TryGetValue(attributeName, out var source)) + { + context.AddSource($"Funcky.CodeAnalysis.{attributeName}Attribute.g.cs", source); + } + }); + } + + /// Unknown names are ignored here; the package's .targets warns about them at build time. + public static ImmutableArray ParseAttributeNames(string value) + => value + .Split(Separators, StringSplitOptions.RemoveEmptyEntries) + .Select(static name => name.Trim()) + .Where(static name => name.Length > 0) + .Distinct(StringComparer.Ordinal) + .ToImmutableArray(); +} diff --git a/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisExtensions/SymbolExtensions.cs b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisExtensions/SymbolExtensions.cs index 49e9c7db2..651ba9b4f 100644 --- a/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisExtensions/SymbolExtensions.cs +++ b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisExtensions/SymbolExtensions.cs @@ -6,4 +6,24 @@ internal static class SymbolExtensions { public static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol attributeClass) => symbol.GetAttributes().Any(attribute => SymbolEquals(attribute.AttributeClass, attributeClass)); + + /// Matches the attribute by its fully qualified name rather than by symbol, so that a copy of the attribute + /// in any assembly counts: the one in Funcky, one pasted into the user's project or one emitted by the generator. + /// (GetTypeByMetadataName returns as soon as two assemblies define the same name.) + public static bool HasAttribute(this ISymbol symbol, string attributeFullName) + => symbol.GetAttributes().Any(attribute => attribute.AttributeClass is { } attributeClass && IsNamed(attributeClass, attributeFullName)); + + private static bool IsNamed(INamedTypeSymbol type, string fullName) + => type is { ContainingType: null, IsGenericType: false } + && fullName.EndsWith(type.Name, StringComparison.Ordinal) + && IsNamespace(type.ContainingNamespace, fullName, fullName.Length - type.Name.Length); + + /// Checks that the first characters of spell the namespace followed by a dot. + private static bool IsNamespace(INamespaceSymbol @namespace, string fullName, int length) + => @namespace.IsGlobalNamespace + ? length == 0 + : length > @namespace.Name.Length + && fullName[length - 1] == '.' + && string.CompareOrdinal(fullName, length - 1 - @namespace.Name.Length, @namespace.Name, 0, @namespace.Name.Length) == 0 + && IsNamespace(@namespace.ContainingNamespace, fullName, length - 1 - @namespace.Name.Length); } diff --git a/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableAnalyzer.cs b/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableAnalyzer.cs index 50c819432..d2dbd8ff7 100644 --- a/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableAnalyzer.cs +++ b/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableAnalyzer.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Funcky.Analyzers.CodeAnalysisExtensions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; @@ -21,6 +21,7 @@ public sealed class NonDefaultableAnalyzer : DiagnosticAnalyzer description: "Values instantiated with default are in an invalid state; any member may throw an exception.", helpLinkUri: DiagnosticName.HelpLink(DoNotUseDefaultId)); + /// The attribute is matched by name, so any assembly may define it: Funcky, the user's own project or the generator. internal const string AttributeFullName = "Funcky.CodeAnalysis.NonDefaultableAttribute"; public override ImmutableArray SupportedDiagnostics => ImmutableArray.Create(DoNotUseDefault); @@ -29,41 +30,31 @@ public override void Initialize(AnalysisContext context) { context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.Analyze | GeneratedCodeAnalysisFlags.ReportDiagnostics); context.EnableConcurrentExecution(); - context.RegisterCompilationStartAction(OnCompilationStart); + context.RegisterOperationAction(AnalyzeDefaultValueOperation, OperationKind.DefaultValue); + context.RegisterOperationAction(AnalyzeObjectCreationOperation, OperationKind.ObjectCreation); } - internal static bool IsParameterlessObjectCreationOfNonDefaultableStruct(IObjectCreationOperation operation, INamedTypeSymbol nonDefaultableAttribute) + internal static bool IsParameterlessObjectCreationOfNonDefaultableStruct(IObjectCreationOperation operation) => operation is { Type: { } type, Arguments.Length: 0, Initializer: null } - && type.HasAttribute(nonDefaultableAttribute); + && type.HasAttribute(AttributeFullName); - private static void OnCompilationStart(CompilationStartAnalysisContext context) + private static void AnalyzeDefaultValueOperation(OperationAnalysisContext context) { - if (context.Compilation.GetTypeByMetadataName(AttributeFullName) is { } nonDefaultableAttribute) + var operation = (IDefaultValueOperation)context.Operation; + if (operation.Type is { } type && type.HasAttribute(AttributeFullName)) { - context.RegisterOperationAction(AnalyzeDefaultValueOperation(nonDefaultableAttribute), OperationKind.DefaultValue); - context.RegisterOperationAction(AnalyzeObjectCreationOperation(nonDefaultableAttribute), OperationKind.ObjectCreation); + ReportDiagnostic(context); } } - private static Action AnalyzeDefaultValueOperation(INamedTypeSymbol nonDefaultableAttribute) - => context => - { - var operation = (IDefaultValueOperation)context.Operation; - if (operation.Type is { } type && type.HasAttribute(nonDefaultableAttribute)) - { - ReportDiagnostic(context); - } - }; - - private static Action AnalyzeObjectCreationOperation(INamedTypeSymbol nonDefaultableAttribute) - => context => + private static void AnalyzeObjectCreationOperation(OperationAnalysisContext context) + { + var operation = (IObjectCreationOperation)context.Operation; + if (IsParameterlessObjectCreationOfNonDefaultableStruct(operation)) { - var operation = (IObjectCreationOperation)context.Operation; - if (IsParameterlessObjectCreationOfNonDefaultableStruct(operation, nonDefaultableAttribute)) - { - ReportDiagnostic(context); - } - }; + ReportDiagnostic(context); + } + } private static void ReportDiagnostic(OperationAnalysisContext context) => context.ReportDiagnostic(Diagnostic.Create( diff --git a/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableStyleCopSuppressor.cs b/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableStyleCopSuppressor.cs index f655b0fba..1c662da89 100644 --- a/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableStyleCopSuppressor.cs +++ b/Funcky.Analyzers/Funcky.Analyzers/NonDefaultable/NonDefaultableStyleCopSuppressor.cs @@ -17,20 +17,12 @@ public sealed class NonDefaultableStyleCopSuppressor : DiagnosticSuppressor public override ImmutableArray SupportedSuppressions => ImmutableArray.Create(DoNotUseDefaultValueTypeConstructorSuppression); public override void ReportSuppressions(SuppressionAnalysisContext context) - { - if (context.Compilation.GetTypeByMetadataName(AttributeFullName) is { } nonDefaultableAttribute) - { - ReportSuppressions(context, nonDefaultableAttribute); - } - } - - private static void ReportSuppressions(SuppressionAnalysisContext context, INamedTypeSymbol nonDefaultableAttribute) { var styleCopDiagnostics = context.ReportedDiagnostics.Where(diagnostic => diagnostic.Id == DoNotUseDefaultValueTypeConstructorSuppression.SuppressedDiagnosticId); foreach (var diagnostic in styleCopDiagnostics) { if (GetOperation(context, diagnostic) is IObjectCreationOperation operation - && IsParameterlessObjectCreationOfNonDefaultableStruct(operation, nonDefaultableAttribute)) + && IsParameterlessObjectCreationOfNonDefaultableStruct(operation)) { context.ReportSuppression(Suppression.Create(DoNotUseDefaultValueTypeConstructorSuppression, diagnostic)); } diff --git a/Funcky.Analyzers/Funcky.Analyzers/UseWithArgumentNamesAnalyzer.cs b/Funcky.Analyzers/Funcky.Analyzers/UseWithArgumentNamesAnalyzer.cs index 521bea3dc..eccd900d8 100644 --- a/Funcky.Analyzers/Funcky.Analyzers/UseWithArgumentNamesAnalyzer.cs +++ b/Funcky.Analyzers/Funcky.Analyzers/UseWithArgumentNamesAnalyzer.cs @@ -11,9 +11,11 @@ namespace Funcky.Analyzers; public sealed class UseWithArgumentNamesAnalyzer : DiagnosticAnalyzer { public const string DiagnosticId = $"{DiagnosticName.Prefix}{DiagnosticName.Usage}03"; - private const string Category = nameof(Funcky); - private const string AttributeFullName = "Funcky.CodeAnalysis.UseWithArgumentNamesAttribute"; + /// The attribute is matched by name, so any assembly may define it: Funcky, the user's own project or the generator. + internal const string AttributeFullName = "Funcky.CodeAnalysis.UseWithArgumentNamesAttribute"; + + private const string Category = nameof(Funcky); private static readonly DiagnosticDescriptor Descriptor = new( id: DiagnosticId, @@ -33,21 +35,18 @@ public override void Initialize(AnalysisContext context) context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); context.RegisterCompilationStartAction(static context => { - if (context.Compilation.GetTypeByMetadataName(AttributeFullName) is { } attributeSymbol) - { - var expressionOfTType = context.Compilation.GetExpressionOfTType(); - context.RegisterOperationAction(AnalyzeInvocation(attributeSymbol, expressionOfTType), OperationKind.Invocation); - } + var expressionOfTType = context.Compilation.GetExpressionOfTType(); + context.RegisterOperationAction(AnalyzeInvocation(expressionOfTType), OperationKind.Invocation); }); } - private static Action AnalyzeInvocation(INamedTypeSymbol attributeSymbol, INamedTypeSymbol? expressionOfTType) + private static Action AnalyzeInvocation(INamedTypeSymbol? expressionOfTType) => context => { var invocation = (IInvocationOperation)context.Operation; var semanticModel = invocation.SemanticModel ?? throw new InvalidOperationException("Semantic model is never be null for operations passed to an analyzer (according to docs)"); - if (invocation.TargetMethod.HasAttribute(attributeSymbol) + if (invocation.TargetMethod.HasAttribute(AttributeFullName) && !invocation.Syntax.IsInExpressionTree(semanticModel, expressionOfTType, context.CancellationToken)) { foreach (var argument in invocation.Arguments) diff --git a/changelog.md b/changelog.md index 9add34c90..9f62bb740 100644 --- a/changelog.md +++ b/changelog.md @@ -3,6 +3,14 @@ All notable changes to this project will be documented in this file. Funcky adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased: Funcky 3.7.0 | Funcky.Analyzers 1.5.0 + +### Analyzers +* New: the attributes behind `λ1003` (`[UseWithArgumentNames]`) and `λ1009` (`[NonDefaultable]`) can be used on your own + members. The analyzers now match the attributes by name, so a copy in any assembly counts, and `Funcky.Analyzers` + generates that copy when the project sets `UseWithArgumentNames;NonDefaultable`. +* Fixed: `λ1003` and `λ1009` went silent in a project referencing two assemblies that both define the attribute. + ## Funcky 3.6.1 | Funcky.Async 1.5.0 | Funcky.Xunit 2.2.0 | Funcky.Xunit.v3 1.0.0 | Funcky.Analyzers 1.4.2 * New: `AggregateOrNone` and `AggregateOrNoneAsync`, the `…OrNone` variant of the seedless `Aggregate`, which throws for an From 60727ac7688f866380cfa1bdfa0bd2dab9dc8565 Mon Sep 17 00:00:00 2001 From: Thomas Bruderer Date: Tue, 20 Oct 2026 10:01:18 +0200 Subject: [PATCH 2/2] Drop #nullable enable from the generated attributes so C# 7.3 projects compile The directive is an error (CS8370) before C# 8, which .NET Framework projects use by default, and the attributes have no members that would need annotations. A new test generates into a C# 7.3 compilation. Co-Authored-By: Claude Fable 5.1 --- .../CodeAnalysisAttributesGeneratorTest.cs | 18 +++++++++++++++--- .../CodeAnalysisAttributesGenerator.cs | 6 ++---- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs index ed01c6089..b8dfb4cf4 100644 --- a/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs +++ b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs @@ -43,6 +43,17 @@ public void TheRequestedAttributesAreGeneratedAndCompile() Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.NonDefaultableAttribute")); } + [Fact] + public void TheGeneratedAttributesCompileInCSharp73Projects() + { + var (compilation, diagnostics) = Generate(ConsumerCode.Replace("throw null!", "throw null"), property: "UseWithArgumentNames; NonDefaultable", LanguageVersion.CSharp7_3); + + Assert.Empty(diagnostics); + Assert.Empty(compilation.GetDiagnostics().Where(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error)); + Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.UseWithArgumentNamesAttribute")); + Assert.NotNull(compilation.GetTypeByMetadataName("Funcky.CodeAnalysis.NonDefaultableAttribute")); + } + [Fact] public void OnlyTheRequestedAttributeIsGenerated() { @@ -70,16 +81,17 @@ public void UnknownNamesAreIgnored() public void ThePropertyIsSplitTrimmedAndDeduplicated(string value, string[] expected) => Assert.Equal(expected, CodeAnalysisAttributesGenerator.ParseAttributeNames(value)); - private static (Compilation Compilation, ImmutableArray Diagnostics) Generate(string source, string? property) + private static (Compilation Compilation, ImmutableArray Diagnostics) Generate(string source, string? property, LanguageVersion languageVersion = LanguageVersion.Latest) { + var parseOptions = new CSharpParseOptions(languageVersion); var compilation = CSharpCompilation.Create( "Consumer", - [CSharpSyntaxTree.ParseText(source)], + [CSharpSyntaxTree.ParseText(source, parseOptions)], [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); var driver = CSharpGeneratorDriver - .Create(new CodeAnalysisAttributesGenerator()) + .Create([new CodeAnalysisAttributesGenerator().AsSourceGenerator()], parseOptions: parseOptions) .WithUpdatedAnalyzerConfigOptions(new BuildPropertyOptionsProvider(property)) .RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out var diagnostics); diff --git a/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs index acafaac8d..34ff6ecc2 100644 --- a/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs +++ b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs @@ -15,11 +15,11 @@ public sealed class CodeAnalysisAttributesGenerator : IIncrementalGenerator public const string BuildPropertyKey = $"build_property._{PropertyName}"; + // The templates deliberately have no #nullable directive: it is an error (CS8370) in C# 7.3, which .NET Framework projects + // use by default, and the attributes have no members that would need annotations. Gate it on the language version if one ever does. private const string UseWithArgumentNamesSource = """ // - #nullable enable - namespace Funcky.CodeAnalysis { /// Methods annotated with this attribute should be called with named arguments. Funcky.Analyzers reports λ1003 otherwise. @@ -34,8 +34,6 @@ internal sealed class UseWithArgumentNamesAttribute : global::System.Attribute private const string NonDefaultableSource = """ // - #nullable enable - namespace Funcky.CodeAnalysis { /// Structs annotated with this attribute should not be instantiated with . Funcky.Analyzers reports λ1009 otherwise.