diff --git a/Documentation/src/analyzer-rules/analyzer-rules.md b/Documentation/src/analyzer-rules/analyzer-rules.md index 04d77ba59..25f472c9b 100644 --- a/Documentation/src/analyzer-rules/analyzer-rules.md +++ b/Documentation/src/analyzer-rules/analyzer-rules.md @@ -59,6 +59,22 @@ contains a rule. |------|-------|------| | λ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..91962140c 100644 --- "a/Documentation/src/analyzer-rules/\316\2731009.md" +++ "b/Documentation/src/analyzer-rules/\316\2731009.md" @@ -34,3 +34,34 @@ 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. The rule itself is built into `Funcky`; the generator that writes the copy +ships with the `Funcky.Analyzers` package. Install it and list 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 d2a52d285..b2fe49519 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..7a0e14f08 --- /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..b8dfb4cf4 --- /dev/null +++ b/Funcky.Analyzers/Funcky.Analyzers.Test/CodeAnalysisAttributes/CodeAnalysisAttributesGeneratorTest.cs @@ -0,0 +1,125 @@ +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 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() + { + 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, LanguageVersion languageVersion = LanguageVersion.Latest) + { + var parseOptions = new CSharpParseOptions(languageVersion); + var compilation = CSharpCompilation.Create( + "Consumer", + [CSharpSyntaxTree.ParseText(source, parseOptions)], + [MetadataReference.CreateFromFile(typeof(object).Assembly.Location)], + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + var driver = CSharpGeneratorDriver + .Create([new CodeAnalysisAttributesGenerator().AsSourceGenerator()], parseOptions: parseOptions) + .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..34ff6ecc2 --- /dev/null +++ b/Funcky.Analyzers/Funcky.Analyzers/CodeAnalysisAttributes/CodeAnalysisAttributesGenerator.cs @@ -0,0 +1,80 @@ +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}"; + + // 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 = + """ + // + 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 = + """ + // + 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/UseWithArgumentNamesAnalyzer.cs b/Funcky.Analyzers/Funcky.Analyzers/UseWithArgumentNamesAnalyzer.cs index 44801e342..7776f5a50 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 = DiagnosticName.Category; - 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 = DiagnosticName.Category; 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/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableAnalyzer.cs b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableAnalyzer.cs index 90e58ada7..0e38347aa 100644 --- a/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableAnalyzer.cs +++ b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableAnalyzer.cs @@ -1,4 +1,4 @@ -using System.Collections.Immutable; +using System.Collections.Immutable; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using Microsoft.CodeAnalysis.Operations; @@ -22,6 +22,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); @@ -30,41 +31,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.BuiltinAnalyzers/NonDefaultableStyleCopSuppressor.cs b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableStyleCopSuppressor.cs index 3242927bd..64363d25e 100644 --- a/Funcky.Analyzers/Funcky.BuiltinAnalyzers/NonDefaultableStyleCopSuppressor.cs +++ b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/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.BuiltinAnalyzers/SymbolExtensions.cs b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/SymbolExtensions.cs index cbc02cab1..3e3d586cd 100644 --- a/Funcky.Analyzers/Funcky.BuiltinAnalyzers/SymbolExtensions.cs +++ b/Funcky.Analyzers/Funcky.BuiltinAnalyzers/SymbolExtensions.cs @@ -6,4 +6,24 @@ internal static class SymbolExtensions { public static bool HasAttribute(this ISymbol symbol, INamedTypeSymbol attributeClass) => symbol.GetAttributes().Any(attribute => SymbolEqualityComparer.Default.Equals(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/changelog.md b/changelog.md index bd62f7e30..c555aece6 100644 --- a/changelog.md +++ b/changelog.md @@ -59,6 +59,10 @@ warnings (an info for pure performance hints), come with a code fix and carry th ## 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. * New: rule `λ1017` reports a lambda that spells out one of the named functions in `Funcky.Functional`: `x => x` is `Identity`, `_ => true` is `True`, `_ => false` is `False` and `() => { }` is `NoOperation`. It only reports where the method group binds the same way as the lambda, so an ambiguous `Where(True)` is left alone. The code fix replaces the