Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions Documentation/src/analyzer-rules/analyzer-rules.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,22 @@ contains a rule.
|------|-------|------|
| λ0002 | Use of `Option<T>.None()` method group | `Option<T>.None()` was replaced by the `Option<T>.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
<PropertyGroup>
<FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>
</PropertyGroup>
```

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:

Expand Down
31 changes: 31 additions & 0 deletions Documentation/src/analyzer-rules/λ1003.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,34 @@ static string Example(Option<int> 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
<PropertyGroup>
<FunckyCodeAnalysisAttributes>UseWithArgumentNames</FunckyCodeAnalysisAttributes>
</PropertyGroup>
```

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.
31 changes: 31 additions & 0 deletions Documentation/src/analyzer-rules/λ1009.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,34 @@ var eitherOrBoth = EitherOrBoth<string, int>.Both("left", 42);
// Option<T> has a valid default state.
Option<int> 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
<PropertyGroup>
<FunckyCodeAnalysisAttributes>NonDefaultable</FunckyCodeAnalysisAttributes>
</PropertyGroup>
```

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.
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,27 @@
<Analyzer Remove="@(Analyzer->WithMetadataValue('NuGetPackageId', 'Funcky.Analyzers'))" />
</ItemGroup>
</Target>

<!-- Opt-in: <FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>
makes the generator emit these attributes into the project, so they can be used on its own members.
The generator reads the list from a comma-separated copy of the property, because a semicolon starts
a comment in the .editorconfig through which properties reach the compiler. -->
<ItemGroup>
<CompilerVisibleProperty Include="_FunckyCodeAnalysisAttributes" />
</ItemGroup>
<Target Name="_Funcky_Analyzers_PrepareCodeAnalysisAttributes"
Condition="'$(FunckyCodeAnalysisAttributes)' != ''"
BeforeTargets="GenerateMSBuildEditorConfigFileCore;CoreCompile">
<PropertyGroup>
<_FunckyCodeAnalysisAttributes>$(FunckyCodeAnalysisAttributes.Replace(';', ','))</_FunckyCodeAnalysisAttributes>
<!-- Property functions escape the semicolons they produce; unescaped they split the list into items. -->
<_FunckyCodeAnalysisAttributesList>$([MSBuild]::Unescape($(FunckyCodeAnalysisAttributes.Replace(',', ';'))))</_FunckyCodeAnalysisAttributesList>
</PropertyGroup>
<ItemGroup>
<_FunckyUnknownCodeAnalysisAttribute Include="$(_FunckyCodeAnalysisAttributesList)" Exclude="UseWithArgumentNames;NonDefaultable" />
</ItemGroup>
<Warning Code="λ.BUILD.0002"
Condition="'@(_FunckyUnknownCodeAnalysisAttribute)' != ''"
Text="Funcky.Analyzers has no attribute named '@(_FunckyUnknownCodeAnalysisAttribute)' to generate (FunckyCodeAnalysisAttributes). Known attributes: UseWithArgumentNames, NonDefaultable." />
</Target>
</Project>
9 changes: 9 additions & 0 deletions Funcky.Analyzers/Funcky.Analyzers.Package/package.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,13 @@ Analyzers and code fixes that guide you towards idiomatic usage of [Funcky].
<PackageReference Include="Funcky.Analyzers" Version="..." PrivateAssets="all" />
```

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
<PropertyGroup>
<FunckyCodeAnalysisAttributes>UseWithArgumentNames;NonDefaultable</FunckyCodeAnalysisAttributes>
</PropertyGroup>
```

[Funcky]: https://www.nuget.org/packages/Funcky
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
using Microsoft.CodeAnalysis.Testing;
using Xunit;
using NonDefaultableVerifier = Funcky.Analyzers.Test.CSharpAnalyzerVerifier<Funcky.BuiltinAnalyzers.NonDefaultableAnalyzer>;
using UseWithArgumentNamesVerifier = Funcky.Analyzers.Test.CSharpAnalyzerVerifier<Funcky.Analyzers.UseWithArgumentNamesAnalyzer>;

namespace Funcky.Analyzers.Test.CodeAnalysisAttributes;

/// <summary>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.</summary>
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<DefaultVerifier> 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();
}
}
Original file line number Diff line number Diff line change
@@ -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<Diagnostic> 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;
}
}
}
Loading
Loading