Skip to content

Commit 1b5514f

Browse files
Copilotlinkdotnet
andcommitted
feat: Add bUnit.Analyzers project with BUNIT0002 analyzer
Co-authored-by: linkdotnet <[email protected]>
1 parent b4d93f3 commit 1b5514f

File tree

10 files changed

+322
-3
lines changed

10 files changed

+322
-3
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
3838
</PropertyGroup>
3939

40-
<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.template' AND $(MSBuildProjectName) != 'bunit.generators'">
40+
<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.template' AND $(MSBuildProjectName) != 'bunit.generators' AND $(MSBuildProjectName) != 'bunit.analyzers' AND $(MSBuildProjectName) != 'bunit.analyzers.tests'">
4141
<Using Include="Microsoft.AspNetCore.Components" />
4242
<Using Include="Microsoft.AspNetCore.Components.RenderTree" />
4343
<Using Include="Microsoft.AspNetCore.Components.Rendering" />

bunit.slnx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<Folder Name="/src/">
2323
<File Path="src/.editorconfig" />
2424
<File Path="src/Directory.Build.props" />
25+
<Project Path="src/bunit.analyzers/bunit.analyzers.csproj" />
2526
<Project Path="src/bunit.generators.internal/bunit.generators.internal.csproj" />
2627
<Project Path="src/bunit.generators/bunit.generators.csproj" />
2728
<Project Path="src/bunit.template/bunit.template.csproj">
@@ -35,6 +36,7 @@
3536
<File Path="tests/Directory.Build.props" />
3637
<File Path="tests/run-tests.ps1" />
3738
<File Path="tests/xunit.runner.json" />
39+
<Project Path="tests/bunit.analyzers.tests/bunit.analyzers.tests.csproj" />
3840
<Project Path="tests/bunit.generators.tests/bunit.generators.tests.csproj" />
3941
<Project Path="tests/bunit.testassets/bunit.testassets.csproj" />
4042
<Project Path="tests/bunit.tests/bunit.tests.csproj" />
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"$schema": "https://raw.githubusercontent.com/dotnet/roslyn/main/src/Compilers/Core/Portable/DiagnosticAnalyzers/AnalyzerReleases.Unshipped.schema.json",
3+
"document": [
4+
{
5+
"id": "BUNIT0001",
6+
"isEnabledByDefault": true,
7+
"severity": "warning"
8+
},
9+
{
10+
"id": "BUNIT0002",
11+
"isEnabledByDefault": true,
12+
"severity": "info"
13+
}
14+
]
15+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
using System.Collections.Immutable;
2+
using Microsoft.CodeAnalysis;
3+
using Microsoft.CodeAnalysis.CSharp;
4+
using Microsoft.CodeAnalysis.CSharp.Syntax;
5+
using Microsoft.CodeAnalysis.Diagnostics;
6+
7+
namespace Bunit.Analyzers;
8+
9+
/// <summary>
10+
/// Analyzer that detects cast expressions from Find() and suggests using Find{T}() instead.
11+
/// </summary>
12+
[DiagnosticAnalyzer(LanguageNames.CSharp)]
13+
public class PreferGenericFindAnalyzer : DiagnosticAnalyzer
14+
{
15+
/// <inheritdoc/>
16+
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(DiagnosticDescriptors.PreferGenericFind);
17+
18+
/// <inheritdoc/>
19+
public override void Initialize(AnalysisContext context)
20+
{
21+
if (context is null)
22+
{
23+
throw new System.ArgumentNullException(nameof(context));
24+
}
25+
26+
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
27+
context.EnableConcurrentExecution();
28+
29+
context.RegisterSyntaxNodeAction(AnalyzeCastExpression, SyntaxKind.CastExpression);
30+
}
31+
32+
private static void AnalyzeCastExpression(SyntaxNodeAnalysisContext context)
33+
{
34+
var castExpression = (CastExpressionSyntax)context.Node;
35+
36+
// Check if the cast is on a Find() invocation
37+
if (castExpression.Expression is not InvocationExpressionSyntax invocation)
38+
{
39+
return;
40+
}
41+
42+
// Check if it's a member access expression (e.g., cut.Find(...))
43+
if (invocation.Expression is not MemberAccessExpressionSyntax memberAccess)
44+
{
45+
return;
46+
}
47+
48+
// Check if the method name is "Find"
49+
if (memberAccess.Name.Identifier.ValueText != "Find")
50+
{
51+
return;
52+
}
53+
54+
// Get the method symbol to verify it's the bUnit Find method
55+
var methodSymbol = context.SemanticModel.GetSymbolInfo(memberAccess, context.CancellationToken).Symbol as IMethodSymbol;
56+
if (methodSymbol is null)
57+
{
58+
return;
59+
}
60+
61+
// Check if the method is from IRenderedFragment or related types
62+
var containingType = methodSymbol.ContainingType;
63+
if (containingType is null || !IsRenderedFragmentType(containingType))
64+
{
65+
return;
66+
}
67+
68+
// Get the selector argument if present
69+
var selector = invocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString() ?? "selector";
70+
71+
// Get the cast type
72+
var castType = castExpression.Type.ToString();
73+
74+
var diagnostic = Diagnostic.Create(
75+
DiagnosticDescriptors.PreferGenericFind,
76+
castExpression.GetLocation(),
77+
castType,
78+
selector);
79+
80+
context.ReportDiagnostic(diagnostic);
81+
}
82+
83+
private static bool IsRenderedFragmentType(INamedTypeSymbol type)
84+
{
85+
// Check if the type or any of its interfaces is IRenderedFragment
86+
var typeName = type.Name;
87+
if (typeName is "IRenderedFragment" or "IRenderedComponent" or "RenderedFragment" or "RenderedComponent")
88+
{
89+
return true;
90+
}
91+
92+
foreach (var @interface in type.AllInterfaces)
93+
{
94+
if (@interface.Name is "IRenderedFragment" or "IRenderedComponent")
95+
{
96+
return true;
97+
}
98+
}
99+
100+
return false;
101+
}
102+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
using Microsoft.CodeAnalysis;
2+
3+
namespace Bunit.Analyzers;
4+
5+
/// <summary>
6+
/// Diagnostic descriptors for bUnit analyzers.
7+
/// </summary>
8+
internal static class DiagnosticDescriptors
9+
{
10+
private const string Category = "Usage";
11+
12+
/// <summary>
13+
/// BUNIT0001: Razor test files should inherit from BunitContext when using local variables in component parameters.
14+
/// </summary>
15+
public static readonly DiagnosticDescriptor MissingInheritsInRazorFile = new(
16+
id: "BUNIT0001",
17+
title: "Razor test files should inherit from BunitContext",
18+
messageFormat: "Razor test file should inherit from BunitContext using @inherits BunitContext to avoid render handle errors",
19+
category: Category,
20+
defaultSeverity: DiagnosticSeverity.Warning,
21+
isEnabledByDefault: true,
22+
description: "When writing tests in Razor files that use variables or event callbacks from the test code, the file must inherit from BunitContext. Otherwise, you may encounter the error: The render handle is not yet assigned.",
23+
helpLinkUri: "https://bunit.dev/docs/analyzers/bunit0001.html");
24+
25+
/// <summary>
26+
/// BUNIT0002: Prefer Find{T} over casting.
27+
/// </summary>
28+
public static readonly DiagnosticDescriptor PreferGenericFind = new(
29+
id: "BUNIT0002",
30+
title: "Prefer Find<T> over casting",
31+
messageFormat: "Use Find<{0}>(\"{1}\") instead of casting",
32+
category: Category,
33+
defaultSeverity: DiagnosticSeverity.Info,
34+
isEnabledByDefault: true,
35+
description: "When finding elements with a specific type, use Find<T>(selector) instead of casting the result of Find(selector).",
36+
helpLinkUri: "https://bunit.dev/docs/analyzers/bunit0002.html");
37+
}

src/bunit.analyzers/README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# bUnit Analyzers
2+
3+
This package contains Roslyn analyzers for bUnit that help identify common mistakes and anti-patterns when writing bUnit tests.
4+
5+
## Analyzers
6+
7+
### BUNIT0001: Razor test files should inherit from BunitContext
8+
9+
When writing tests in `.razor` files that use variables or event callbacks from the test code, the file must inherit from `BunitContext` using `@inherits BunitContext`. Otherwise, you may encounter the error "The render handle is not yet assigned."
10+
11+
**Bad:**
12+
```razor
13+
@code
14+
{
15+
[Fact]
16+
public void Test()
17+
{
18+
using var ctx = new BunitContext();
19+
Action<MouseEventArgs> onClickHandler = _ => { Assert.True(true); };
20+
var cut = ctx.Render(@<MyComponent OnClick="onClickHandler" />);
21+
}
22+
}
23+
```
24+
25+
**Good:**
26+
```razor
27+
@inherits BunitContext
28+
@code
29+
{
30+
[Fact]
31+
public void Test()
32+
{
33+
Action<MouseEventArgs> onClickHandler = _ => { Assert.True(true); };
34+
var cut = Render(@<MyComponent OnClick="onClickHandler" />);
35+
}
36+
}
37+
```
38+
39+
### BUNIT0002: Prefer Find<T> over casting
40+
41+
When finding elements with a specific type, use `Find<T>(selector)` instead of casting the result of `Find(selector)`.
42+
43+
**Bad:**
44+
```csharp
45+
IHtmlAnchorElement elem = (IHtmlAnchorElement)cut.Find("a");
46+
```
47+
48+
**Good:**
49+
```csharp
50+
var elem = cut.Find<IHtmlAnchorElement>("a");
51+
```
52+
53+
## Installation
54+
55+
Install the package via NuGet:
56+
57+
```bash
58+
dotnet add package bunit.analyzers
59+
```
60+
61+
The analyzers will automatically run during compilation and provide warnings and code fixes in your IDE.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>netstandard2.0</TargetFramework>
5+
<LangVersion>12.0</LangVersion>
6+
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
7+
<RootNamespace>Bunit</RootNamespace>
8+
<ImplicitUsings>disable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<PropertyGroup Label="Build instructions">
12+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
13+
<!-- Creates a regular package and a symbols package -->
14+
<IncludeSymbols>true</IncludeSymbols>
15+
<!-- Creates symbol package in the new .snupkg format -->
16+
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
17+
<!--
18+
Instruct the build system to embed project source files that are not tracked by the source control
19+
or imported from a source package to the generated PDB.
20+
-->
21+
<EmbedUntrackedSources>true</EmbedUntrackedSources>
22+
<!-- Recommended: Embed symbols containing Source Link in the main file (exe/dll) -->
23+
<DebugType>embedded</DebugType>
24+
<Deterministic>true</Deterministic>
25+
26+
<EnablePackageValidation>false</EnablePackageValidation>
27+
<GenerateCompatibilitySuppressionFile>false</GenerateCompatibilitySuppressionFile>
28+
</PropertyGroup>
29+
30+
<PropertyGroup Label="NuGet package information">
31+
<PackageId>bunit.analyzers</PackageId>
32+
<Title>bUnit.analyzers</Title>
33+
<Description>
34+
bUnit.analyzers is an extension to bUnit that provides Roslyn analyzers to help identify common mistakes and anti-patterns when writing bUnit tests.
35+
</Description>
36+
</PropertyGroup>
37+
38+
<ItemGroup>
39+
<None Include="README.md">
40+
<Pack>true</Pack>
41+
<PackagePath>\</PackagePath>
42+
</None>
43+
</ItemGroup>
44+
45+
<ItemGroup>
46+
<!-- Package the analyzer in the analyzer directory of the nuget package -->
47+
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false"/>
48+
</ItemGroup>
49+
50+
<ItemGroup>
51+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"/>
52+
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers">
53+
<PrivateAssets>all</PrivateAssets>
54+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
55+
</PackageReference>
56+
<PackageReference Include="Meziantou.Polyfill">
57+
<PrivateAssets>all</PrivateAssets>
58+
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
59+
</PackageReference>
60+
</ItemGroup>
61+
</Project>

tests/Directory.Build.props

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
<NoWarn>NU1903</NoWarn>
2424
</PropertyGroup>
2525

26-
<ItemGroup Condition="$(MSBuildProjectName) != 'bunit.testassets'">
26+
<ItemGroup Condition="$(MSBuildProjectName) != 'bunit.testassets' AND $(MSBuildProjectName) != 'bunit.analyzers.tests'">
2727
<PackageReference Include="AutoFixture" />
2828
<PackageReference Include="AutoFixture.Xunit3" />
2929
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
@@ -35,7 +35,7 @@
3535
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
3636
</ItemGroup>
3737

38-
<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.testassets' AND $(MSBuildProjectName) != 'bunit.generators.tests'">
38+
<ItemGroup Label="Implicit usings" Condition="$(MSBuildProjectName) != 'bunit.testassets' AND $(MSBuildProjectName) != 'bunit.generators.tests' AND $(MSBuildProjectName) != 'bunit.analyzers.tests'">
3939
<Using Include="AutoFixture" />
4040
<Using Include="AutoFixture.Xunit3" />
4141
<Using Include="Bunit.TestAssets.SampleComponents" />
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
using Xunit;
2+
3+
namespace Bunit.Analyzers.Tests;
4+
5+
public class AnalyzerTests
6+
{
7+
[Fact]
8+
public void AnalyzerPackage_Exists()
9+
{
10+
// Placeholder test to ensure the test project builds
11+
Assert.True(true);
12+
}
13+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0;net10.0</TargetFrameworks>
5+
<RootNamespace>Bunit</RootNamespace>
6+
<AssemblyName>Bunit.Analyzers.Tests</AssemblyName>
7+
<ImplicitUsings>disable</ImplicitUsings>
8+
<SonarQubeTestProject>true</SonarQubeTestProject>
9+
<IsPackable>false</IsPackable>
10+
<IsTestProject>true</IsTestProject>
11+
<SignAssembly>false</SignAssembly>
12+
</PropertyGroup>
13+
14+
<ItemGroup>
15+
<PackageReference Include="Microsoft.NET.Test.Sdk"/>
16+
<PackageReference Include="xunit.v3" />
17+
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="All" IncludeAssets="Runtime;Build;Native;contentFiles;Analyzers" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<ProjectReference Include="..\..\src\bunit.analyzers\bunit.analyzers.csproj" />
22+
</ItemGroup>
23+
24+
<ItemGroup>
25+
<Content Include="../xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
26+
</ItemGroup>
27+
28+
</Project>

0 commit comments

Comments
 (0)