Skip to content

Commit 5a781d7

Browse files
authored
Add analyzer and code fix for [Reactive] misuse (#365)
* Add analyzer and code fix for [Reactive] misuse Introduces ReactiveAttributeMisuseAnalyzer and ReactiveAttributeMisuseCodeFixProvider to warn and fix cases where [Reactive] is used on non-partial properties or types. Adds corresponding unit tests and updates diagnostic descriptors and analyzer release notes. * Refactor test references and improve analyzer type checks Introduces TestCompilationReferences to centralize default metadata references for test compilation setup. Updates analyzer and code fix provider tests to use this helper, improving maintainability. Enhances PropertyToReactiveFieldAnalyzer to better handle cases where ReactiveUI types may not be referenced, falling back to syntax-based checks for base types and attributes.
1 parent 6222fa4 commit 5a781d7

11 files changed

+739
-3
lines changed

src/ReactiveUI.SourceGenerator.Tests/ReactiveUI.SourceGenerators.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
</ItemGroup>
4646

4747
<ItemGroup>
48+
<ProjectReference Include="..\ReactiveUI.SourceGenerators.Analyzers.CodeFixes\ReactiveUI.SourceGenerators.Analyzers.CodeFixes.csproj" />
4849
<ProjectReference Include="..\ReactiveUI.SourceGenerators.Roslyn4120\ReactiveUI.SourceGenerators.Roslyn4120.csproj" />
4950
</ItemGroup>
5051

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) 2026 ReactiveUI and contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and contributors under one or more agreements.
3+
// The ReactiveUI and contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Collections.Immutable;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CSharp;
9+
using Microsoft.CodeAnalysis.Diagnostics;
10+
using ReactiveUI.SourceGenerators.CodeFixers;
11+
12+
namespace ReactiveUI.SourceGenerator.Tests;
13+
14+
/// <summary>
15+
/// Unit tests for <see cref="PropertyToReactiveFieldAnalyzer" />.
16+
/// </summary>
17+
[TestFixture]
18+
public sealed class PropertyToReactiveFieldAnalyzerTests
19+
{
20+
/// <summary>
21+
/// Validates a public auto-property triggers the suggestion to convert it into a reactive field.
22+
/// </summary>
23+
[Test]
24+
public void WhenPublicAutoPropertyThenReportsDiagnostic()
25+
{
26+
const string source = """
27+
using ReactiveUI;
28+
29+
namespace TestNs;
30+
31+
public partial class TestVM : ReactiveObject
32+
{
33+
public bool IsVisible { get; set; }
34+
}
35+
""";
36+
37+
var diagnostics = GetDiagnostics(source);
38+
39+
Assert.That(diagnostics.Any(d => d.Id == "RXUISG0016"), Is.True);
40+
}
41+
42+
/// <summary>
43+
/// Validates a property already annotated with <c>[Reactive]</c> is ignored.
44+
/// </summary>
45+
[Test]
46+
public void WhenReactiveAttributePresentThenDoesNotReportDiagnostic()
47+
{
48+
const string source = """
49+
using ReactiveUI;
50+
using ReactiveUI.SourceGenerators;
51+
52+
namespace TestNs;
53+
54+
public partial class TestVM : ReactiveObject
55+
{
56+
[Reactive]
57+
public bool IsVisible { get; set; }
58+
}
59+
""";
60+
61+
var diagnostics = GetDiagnostics(source);
62+
63+
Assert.That(diagnostics.Any(d => d.Id == "RXUISG0016"), Is.False);
64+
}
65+
66+
private static Diagnostic[] GetDiagnostics(string source)
67+
{
68+
var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13));
69+
70+
var compilation = CSharpCompilation.Create(
71+
assemblyName: "AnalyzerTests",
72+
syntaxTrees: [syntaxTree],
73+
references: TestCompilationReferences.CreateDefault(),
74+
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
75+
76+
var analyzer = new PropertyToReactiveFieldAnalyzer();
77+
78+
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
79+
return compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult().ToArray();
80+
}
81+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ReactiveUI and contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and contributors under one or more agreements.
3+
// The ReactiveUI and contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Collections.Immutable;
7+
using Microsoft.CodeAnalysis;
8+
using Microsoft.CodeAnalysis.CodeFixes;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.Diagnostics;
11+
using ReactiveUI.SourceGenerators.CodeFixers;
12+
13+
namespace ReactiveUI.SourceGenerator.Tests;
14+
15+
/// <summary>
16+
/// Unit tests for <see cref="PropertyToReactiveFieldCodeFixProvider" />.
17+
/// </summary>
18+
[TestFixture]
19+
public sealed class PropertyToReactiveFieldCodeFixProviderTests
20+
{
21+
/// <summary>
22+
/// Validates a public auto-property is converted to a private field annotated with <c>[Reactive]</c>.
23+
/// </summary>
24+
[Test]
25+
public void WhenApplyingFixThenConvertsPropertyToReactiveField()
26+
{
27+
const string source = """
28+
using ReactiveUI;
29+
30+
namespace TestNs;
31+
32+
public partial class TestVM : ReactiveObject
33+
{
34+
public bool IsVisible { get; set; }
35+
}
36+
""";
37+
38+
var fixedSource = ApplyFix(source);
39+
40+
Assert.That(fixedSource, Does.Contain("[ReactiveUI.SourceGenerators.Reactive]"));
41+
Assert.That(fixedSource, Does.Contain("private bool _isVisible"));
42+
Assert.That(fixedSource, Does.Not.Contain("public bool IsVisible"));
43+
}
44+
45+
private static string ApplyFix(string source)
46+
{
47+
var tree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13));
48+
49+
var analyzer = new PropertyToReactiveFieldAnalyzer();
50+
var compilation = CSharpCompilation.Create(
51+
"CodeFixTests",
52+
syntaxTrees: [tree],
53+
references: TestCompilationReferences.CreateDefault(),
54+
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
55+
56+
var diagnostic = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
57+
.GetAnalyzerDiagnosticsAsync()
58+
.GetAwaiter().GetResult()
59+
.Single(d => d.Id == "RXUISG0016");
60+
61+
using var workspace = new AdhocWorkspace();
62+
var project = workspace.CurrentSolution
63+
.AddProject("p", "p", LanguageNames.CSharp)
64+
.WithParseOptions(CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13))
65+
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
66+
67+
foreach (var reference in TestCompilationReferences.CreateDefault())
68+
{
69+
project = project.AddMetadataReference(reference);
70+
}
71+
72+
var document = project.AddDocument("t.cs", source);
73+
74+
CodeFixProvider provider = new PropertyToReactiveFieldCodeFixProvider();
75+
76+
var actions = new List<Microsoft.CodeAnalysis.CodeActions.CodeAction>();
77+
var context = new CodeFixContext(
78+
document,
79+
diagnostic,
80+
(a, _) => actions.Add(a),
81+
CancellationToken.None);
82+
83+
provider.RegisterCodeFixesAsync(context).GetAwaiter().GetResult();
84+
85+
var operation = actions.Single().GetOperationsAsync(CancellationToken.None).GetAwaiter().GetResult().Single();
86+
operation.Apply(document.Project.Solution.Workspace, CancellationToken.None);
87+
88+
var updatedDoc = document.Project.Solution.Workspace.CurrentSolution.GetDocument(document.Id);
89+
return updatedDoc!.GetTextAsync(CancellationToken.None).GetAwaiter().GetResult().ToString();
90+
}
91+
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ReactiveUI and contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and contributors under one or more agreements.
3+
// The ReactiveUI and contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System.Collections.Immutable;
7+
using System.Linq;
8+
using Microsoft.CodeAnalysis;
9+
using Microsoft.CodeAnalysis.CSharp;
10+
using Microsoft.CodeAnalysis.Diagnostics;
11+
using NUnit.Framework;
12+
using ReactiveUI.SourceGenerators.CodeFixers;
13+
14+
namespace ReactiveUI.SourceGenerator.Tests;
15+
16+
/// <summary>
17+
/// Unit tests for <see cref="ReactiveAttributeMisuseAnalyzer" />.
18+
/// </summary>
19+
[TestFixture]
20+
public sealed class ReactiveAttributeMisuseAnalyzerTests
21+
{
22+
/// <summary>
23+
/// Verifies a non-partial property annotated with <c>[Reactive]</c> produces a warning.
24+
/// </summary>
25+
[Test]
26+
public void WhenReactiveOnNonPartialPropertyThenWarn()
27+
{
28+
const string source = """
29+
using ReactiveUI;
30+
using ReactiveUI.SourceGenerators;
31+
32+
namespace TestNs;
33+
34+
public partial class TestVM : ReactiveObject
35+
{
36+
[Reactive]
37+
public bool IsVisible { get; set; }
38+
}
39+
""";
40+
41+
var diagnostics = GetDiagnostics(source);
42+
43+
Assert.That(diagnostics.Any(d => d.Id == "RXUISG0020"), Is.True);
44+
}
45+
46+
/// <summary>
47+
/// Verifies a non-partial containing type annotated with a <c>[Reactive]</c> property produces a warning.
48+
/// </summary>
49+
[Test]
50+
public void WhenReactiveOnNonPartialContainingTypeThenWarn()
51+
{
52+
const string source = """
53+
using ReactiveUI;
54+
using ReactiveUI.SourceGenerators;
55+
56+
namespace TestNs;
57+
58+
public class TestVM : ReactiveObject
59+
{
60+
[Reactive]
61+
public partial bool IsVisible { get; set; }
62+
}
63+
""";
64+
65+
var diagnostics = GetDiagnostics(source);
66+
67+
Assert.That(diagnostics.Any(d => d.Id == "RXUISG0020"), Is.True);
68+
}
69+
70+
/// <summary>
71+
/// Verifies no warning is produced when both property and containing type are partial.
72+
/// </summary>
73+
[Test]
74+
public void WhenReactiveOnPartialPropertyAndTypeThenNoWarn()
75+
{
76+
const string source = """
77+
using ReactiveUI;
78+
using ReactiveUI.SourceGenerators;
79+
80+
namespace TestNs;
81+
82+
public partial class TestVM : ReactiveObject
83+
{
84+
[Reactive]
85+
public partial bool IsVisible { get; set; }
86+
}
87+
""";
88+
89+
var diagnostics = GetDiagnostics(source);
90+
91+
Assert.That(diagnostics.Any(d => d.Id == "RXUISG0020"), Is.False);
92+
}
93+
94+
private static Diagnostic[] GetDiagnostics(string source)
95+
{
96+
var syntaxTree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13));
97+
98+
var compilation = CSharpCompilation.Create(
99+
assemblyName: "AnalyzerTests",
100+
syntaxTrees: [syntaxTree],
101+
references: [
102+
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
103+
MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
104+
],
105+
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
106+
107+
var analyzer = new ReactiveAttributeMisuseAnalyzer();
108+
109+
var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer));
110+
return compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync().GetAwaiter().GetResult().ToArray();
111+
}
112+
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) 2026 ReactiveUI and contributors. All rights reserved.
2+
// Licensed to the ReactiveUI and contributors under one or more agreements.
3+
// The ReactiveUI and contributors licenses this file to you under the MIT license.
4+
// See the LICENSE file in the project root for full license information.
5+
6+
using System;
7+
using System.Collections.Generic;
8+
using System.Collections.Immutable;
9+
using System.Linq;
10+
using System.Threading;
11+
using Microsoft.CodeAnalysis;
12+
using Microsoft.CodeAnalysis.CodeFixes;
13+
using Microsoft.CodeAnalysis.CSharp;
14+
using Microsoft.CodeAnalysis.CSharp.Syntax;
15+
using Microsoft.CodeAnalysis.Diagnostics;
16+
using NUnit.Framework;
17+
using ReactiveUI.SourceGenerators.CodeFixers;
18+
19+
namespace ReactiveUI.SourceGenerator.Tests;
20+
21+
/// <summary>
22+
/// Unit tests for <see cref="ReactiveAttributeMisuseCodeFixProvider" />.
23+
/// </summary>
24+
[TestFixture]
25+
public sealed class ReactiveAttributeMisuseCodeFixProviderTests
26+
{
27+
/// <summary>
28+
/// Verifies `required` stays before `partial` when applying the code fix.
29+
/// </summary>
30+
[Test]
31+
public void WhenRequiredPropertyThenPartialInsertedAfterRequired()
32+
{
33+
const string source = """
34+
using ReactiveUI;
35+
using ReactiveUI.SourceGenerators;
36+
37+
namespace TestNs;
38+
39+
public partial class TestVM : ReactiveObject
40+
{
41+
[Reactive(UseRequired = true)]
42+
public required string? PartialRequiredPropertyTest { get; set; }
43+
}
44+
""";
45+
46+
var fixedSource = ApplyFix(source);
47+
48+
Assert.That(fixedSource, Does.Contain("public required partial string? PartialRequiredPropertyTest"));
49+
Assert.That(fixedSource, Does.Not.Contain("public partial required string? PartialRequiredPropertyTest"));
50+
}
51+
52+
private static string ApplyFix(string source)
53+
{
54+
var tree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13));
55+
var root = tree.GetRoot();
56+
57+
var analyzer = new ReactiveAttributeMisuseAnalyzer();
58+
var compilation = CSharpCompilation.Create(
59+
"CodeFixTests",
60+
syntaxTrees: [tree],
61+
references: [
62+
MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
63+
MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location),
64+
],
65+
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
66+
67+
var diagnostic = compilation.WithAnalyzers(ImmutableArray.Create<DiagnosticAnalyzer>(analyzer))
68+
.GetAnalyzerDiagnosticsAsync()
69+
.GetAwaiter().GetResult()
70+
.Single(d => d.Id == "RXUISG0020");
71+
72+
using var workspace = new AdhocWorkspace();
73+
var project = workspace.CurrentSolution
74+
.AddProject("p", "p", LanguageNames.CSharp)
75+
.WithParseOptions(CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13))
76+
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary))
77+
.AddMetadataReference(MetadataReference.CreateFromFile(typeof(object).Assembly.Location))
78+
.AddMetadataReference(MetadataReference.CreateFromFile(typeof(Enumerable).Assembly.Location));
79+
80+
var document = project.AddDocument("t.cs", source);
81+
82+
CodeFixProvider provider = new ReactiveAttributeMisuseCodeFixProvider();
83+
84+
var actions = new List<Microsoft.CodeAnalysis.CodeActions.CodeAction>();
85+
var context = new CodeFixContext(
86+
document,
87+
diagnostic,
88+
(a, _) => actions.Add(a),
89+
CancellationToken.None);
90+
91+
provider.RegisterCodeFixesAsync(context).GetAwaiter().GetResult();
92+
93+
var operation = actions.Single().GetOperationsAsync(CancellationToken.None).GetAwaiter().GetResult().Single();
94+
operation.Apply(document.Project.Solution.Workspace, CancellationToken.None);
95+
96+
var updatedDoc = document.Project.Solution.Workspace.CurrentSolution.GetDocument(document.Id);
97+
return updatedDoc!.GetTextAsync(CancellationToken.None).GetAwaiter().GetResult().ToString();
98+
}
99+
}

0 commit comments

Comments
 (0)