Skip to content
Draft
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
4 changes: 4 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,12 @@
<PackageVersion Include="Prism.Unity" Version="9.0.537" />
<PackageVersion Include="Prism.Wpf" Version="9.0.537" />

<!-- Analyzer Dependencies -->
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<!-- Test Dependencies -->
<PackageVersion Include="bunit" Version="1.40.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp.Analyzer.Testing.MSTest" Version="1.1.2" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="Microsoft.CSharp" Version="4.7.0" />
<PackageVersion Include="Microsoft.Extensions.TimeProvider.Testing" Version="10.0.0" />
Expand Down
6 changes: 6 additions & 0 deletions ReactiveProperty.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@
<Project Path="Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Source/ReactiveProperty.NETStandard/ReactiveProperty.NETStandard.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
Expand All @@ -82,6 +85,9 @@
<Project Path="Test/ReactiveProperty.Blazor.Tests/ReactiveProperty.Blazor.Tests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Test/ReactiveProperty.Analyzer.Tests/ReactiveProperty.Analyzer.Tests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
<Project Path="Test/ReactiveProperty.NETStandard.Tests/ReactiveProperty.NETStandard.Tests.csproj">
<BuildType Solution="Documentation|*" Project="Debug" />
</Project>
Expand Down
2 changes: 2 additions & 0 deletions Source/ReactiveProperty.Analyzer/AnalyzerReleases.Shipped.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
; Unshipped analyzer release
; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md

### New Rules

Rule ID | Category | Severity | Notes
--------|----------|----------|-------
RP0001 | Usage | Warning | ReactivePropertyNullableAnalyzer
28 changes: 28 additions & 0 deletions Source/ReactiveProperty.Analyzer/ReactiveProperty.Analyzer.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<AssemblyName>ReactiveProperty.Analyzer</AssemblyName>
<RootNamespace>Reactive.Bindings.Analyzer</RootNamespace>
<Description>Roslyn analyzer for ReactiveProperty that supports nullable reference types.</Description>
<IsRoslynComponent>true</IsRoslynComponent>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<!-- This project ships as an analyzer inside other packages; it is not packed on its own. -->
<GeneratePackageOnBuild>false</GeneratePackageOnBuild>
<IsPackable>false</IsPackable>
<IncludeBuildOutput>false</IncludeBuildOutput>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>..\ReactiveProperty.Core\key.snk</AssemblyOriginatorKeyFile>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<AdditionalFiles Include="AnalyzerReleases.Shipped.md" />
<AdditionalFiles Include="AnalyzerReleases.Unshipped.md" />
</ItemGroup>

</Project>
199 changes: 199 additions & 0 deletions Source/ReactiveProperty.Analyzer/ReactivePropertyNullableAnalyzer.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;

namespace Reactive.Bindings.Analyzer;

/// <summary>
/// Reports usages of ReactiveProperty family types for a non-nullable reference type that are
/// created without an initial value. In that situation the <c>Value</c> property is initialized to
/// <c>null</c> even though its type is a non-nullable reference type, which the C# compiler cannot
/// detect on its own.
/// </summary>
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class ReactivePropertyNullableAnalyzer : DiagnosticAnalyzer
{
/// <summary>
/// The diagnostic id reported by this analyzer.
/// </summary>
public const string DiagnosticId = "RP0001";

private const string Category = "Usage";

private static readonly DiagnosticDescriptor s_rule = new(
DiagnosticId,
"Provide an initial value or use a nullable type argument",
"'{0}' is created for the non-nullable reference type '{1}' without an initial value, so its Value will be null. Provide an initial value or use a nullable type argument such as '{1}?'.",
Category,
DiagnosticSeverity.Warning,
isEnabledByDefault: true,
description: "ReactiveProperty family types initialize Value to default (null for reference types) when no initial value is supplied. The C# compiler cannot detect this, so this analyzer reports it for non-nullable reference types.");

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics => ImmutableArray.Create(s_rule);

/// <inheritdoc />
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(OnCompilationStart);
}

private static void OnCompilationStart(CompilationStartAnalysisContext context)
{
var compilation = context.Compilation;

var targetTypes = new[]
{
compilation.GetTypeByMetadataName("Reactive.Bindings.ReactiveProperty`1"),
compilation.GetTypeByMetadataName("Reactive.Bindings.ReactivePropertySlim`1"),
compilation.GetTypeByMetadataName("Reactive.Bindings.ReadOnlyReactivePropertySlim`1"),
compilation.GetTypeByMetadataName("Reactive.Bindings.ReadOnlyReactiveProperty`1"),
}
.Where(x => x is not null)
.Select(x => (ISymbol?)x!.OriginalDefinition)
.ToImmutableHashSet(SymbolEqualityComparer.Default);

if (targetTypes.IsEmpty)
{
return;
}

context.RegisterOperationAction(ctx => AnalyzeObjectCreation(ctx, targetTypes), OperationKind.ObjectCreation);
context.RegisterOperationAction(AnalyzeInvocation, OperationKind.Invocation);
}

private static void AnalyzeObjectCreation(OperationAnalysisContext context, ImmutableHashSet<ISymbol?> targetTypes)
{
var operation = (IObjectCreationOperation)context.Operation;
if (operation.Type is not INamedTypeSymbol createdType || createdType.TypeArguments.Length != 1)
{
return;
}

if (!targetTypes.Contains(createdType.OriginalDefinition))
{
return;
}

var typeArgument = createdType.TypeArguments[0];
if (!IsNonNullableReferenceType(typeArgument) || HasInitialValue(operation.Arguments))
{
return;
}

Report(context, operation.Syntax.GetLocation(), createdType, typeArgument);
}

private static void AnalyzeInvocation(OperationAnalysisContext context)
{
var operation = (IInvocationOperation)context.Operation;
var method = operation.TargetMethod;
if (method.Name is not ("ToReadOnlyReactivePropertySlim" or "ToReadOnlyReactiveProperty"))
{
return;
}

if (method.ContainingNamespace?.ToDisplayString() != "Reactive.Bindings")
{
return;
}

if (operation.Type is not INamedTypeSymbol returnType)
{
return;
}

// The compiler infers the return type argument as nullable because of the
// 'initialValue = default' parameter, so the element type is taken from the source
// observable instead, which reflects what the caller actually provided.
ITypeSymbol? typeArgument = null;
foreach (var argument in operation.Arguments)
{
typeArgument = GetObservableElementType(argument.Value.Type);
if (typeArgument is not null)
{
break;
}
}

if (typeArgument is null || !IsNonNullableReferenceType(typeArgument) || HasInitialValue(operation.Arguments))
{
return;
}

Report(context, operation.Syntax.GetLocation(), returnType, typeArgument);
}

private static ITypeSymbol? GetObservableElementType(ITypeSymbol? type)
{
if (type is null)
{
return null;
}

if (type is INamedTypeSymbol named &&
named.OriginalDefinition.SpecialType == SpecialType.None &&
named.OriginalDefinition.ToDisplayString() == "System.IObservable<T>" &&
named.TypeArguments.Length == 1)
{
return named.TypeArguments[0];
}

foreach (var @interface in type.AllInterfaces)
{
if (@interface.OriginalDefinition.ToDisplayString() == "System.IObservable<T>" &&
@interface.TypeArguments.Length == 1)
{
return @interface.TypeArguments[0];
}
}

return null;
}

private static void Report(OperationAnalysisContext context, Location location, INamedTypeSymbol type, ITypeSymbol typeArgument)
{
var diagnostic = Diagnostic.Create(
s_rule,
location,
type.OriginalDefinition.Name,
typeArgument.ToDisplayString());
context.ReportDiagnostic(diagnostic);
}

private static bool HasInitialValue(ImmutableArray<IArgumentOperation> arguments)
{
foreach (var argument in arguments)
{
if (argument.Parameter?.Name == "initialValue")
{
return argument.ArgumentKind != ArgumentKind.DefaultValue;
}
}

return false;
}

private static bool IsNonNullableReferenceType(ITypeSymbol typeSymbol)
{
// Skip open type parameters (e.g. usages inside generic code) to avoid false positives.
if (typeSymbol.TypeKind == TypeKind.TypeParameter)
{
return false;
}

// Only reference types can be null.
if (!typeSymbol.IsReferenceType)
{
return false;
}

// NotAnnotated: nullable context is enabled and the type is non-nullable (e.g. string).
// Annotated (string?) and None (nullable context disabled) are intentionally ignored.
return typeSymbol.NullableAnnotation == NullableAnnotation.NotAnnotated;
}
}
15 changes: 15 additions & 0 deletions Source/ReactiveProperty.Core/ReactiveProperty.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,19 @@
<ItemGroup>
<PackageReference Include="System.ComponentModel.Annotations" />
</ItemGroup>

<ItemGroup>
<!-- Build the analyzer first, but do not add it as a compile-time reference. -->
<ProjectReference Include="..\ReactiveProperty.Analyzer\ReactiveProperty.Analyzer.csproj"
ReferenceOutputAssembly="false"
PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<!-- Ship the analyzer inside this package. -->
<None Include="..\ReactiveProperty.Analyzer\bin\$(Configuration)\netstandard2.0\ReactiveProperty.Analyzer.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@

<ItemGroup>
<ProjectReference Include="..\ReactiveProperty.Core\ReactiveProperty.Core.csproj" />
<!-- Build the analyzer first, but do not add it as a compile-time reference. -->
<ProjectReference Include="..\ReactiveProperty.Analyzer\ReactiveProperty.Analyzer.csproj"
ReferenceOutputAssembly="false"
PrivateAssets="all" />
</ItemGroup>

<ItemGroup>
<!-- Ship the analyzer inside this package. -->
<None Include="..\ReactiveProperty.Analyzer\bin\$(Configuration)\netstandard2.0\ReactiveProperty.Analyzer.dll"
Pack="true"
PackagePath="analyzers/dotnet/cs"
Visible="false" />
</ItemGroup>


Expand Down
68 changes: 68 additions & 0 deletions Test/ReactiveProperty.Analyzer.Tests/AnalyzerVerifier.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Testing;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Testing;

namespace ReactiveProperty.Analyzer.Tests;

internal static class AnalyzerVerifier<TAnalyzer>
where TAnalyzer : DiagnosticAnalyzer, new()
{
public static DiagnosticResult Diagnostic(string diagnosticId) =>
CSharpAnalyzerVerifier<TAnalyzer, DefaultVerifier>.Diagnostic(diagnosticId);

public static async Task VerifyAnalyzerAsync(string source, params DiagnosticResult[] expected)
{
var test = new Test
{
TestCode = source,
};
test.ExpectedDiagnostics.AddRange(expected);
await test.RunAsync(CancellationToken.None);
}

private sealed class Test : CSharpAnalyzerTest<TAnalyzer, DefaultVerifier>
{
public Test()
{
ReferenceAssemblies = new ReferenceAssemblies(
"net9.0",
new PackageIdentity("Microsoft.NETCore.App.Ref", "9.0.0"),
Path.Combine("ref", "net9.0"));
SolutionTransforms.Add((solution, projectId) =>
{
var project = solution.GetProject(projectId)!;
var parseOptions = ((CSharpParseOptions)project.ParseOptions!).WithLanguageVersion(LanguageVersion.Latest);
var compilationOptions = ((CSharpCompilationOptions)project.CompilationOptions!)
.WithNullableContextOptions(NullableContextOptions.Enable);

solution = solution
.WithProjectParseOptions(projectId, parseOptions)
.WithProjectCompilationOptions(projectId, compilationOptions);

foreach (var reference in ReactivePropertyReferences.Value)
{
solution = solution.AddMetadataReference(projectId, reference);
}

return solution;
});
}
}

private static readonly Lazy<ImmutableArray<MetadataReference>> ReactivePropertyReferences = new(() =>
{
var assemblies = new[]
{
typeof(Reactive.Bindings.ReactivePropertySlim<>).Assembly,
typeof(Reactive.Bindings.ReactiveProperty<>).Assembly,
typeof(System.Reactive.Linq.Observable).Assembly,
};

return assemblies
.Select(x => (MetadataReference)MetadataReference.CreateFromFile(x.Location))
.ToImmutableArray();
});
}
1 change: 1 addition & 0 deletions Test/ReactiveProperty.Analyzer.Tests/GlobalUsings.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
global using Microsoft.VisualStudio.TestTools.UnitTesting;
Loading