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
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,9 +170,10 @@ All diagnostics use the `RXUISG` prefix. All suppressions use the `RXUISPR` pref

| Class | ID range | Purpose |
|-------|----------|---------|
| `PropertyToReactiveFieldAnalyzer` | RXUISG0016 | Suggests converting auto-properties to `[Reactive]` fields |
| `PropertyToReactiveFieldAnalyzer` | RXUISG0016 | Suggests converting auto-properties to `[Reactive]` properties |
| `ReactiveAttributeMisuseAnalyzer` | RXUISG0020 | Detects `[Reactive]` on non-partial or non-partial-type members |
| `PropertyToReactiveFieldCodeFixProvider` | — | Converts auto-property → `[Reactive]` field |
| `ReactiveCommandAnalyzer` | RXUISG0002, RXUISG0008, RXUISG0021 | Reports `[ReactiveCommand]` methods and schedulers the generator skips |
| `PropertyToReactiveFieldCodeFixProvider` | — | Converts auto-property → `[Reactive]` partial property (C# 13+; C# 14+ with an initializer), else a `[Reactive]` field |
| `ReactiveAttributeMisuseCodeFixProvider` | — | Fixes misuse of `[Reactive]` attribute |

Suppressors silence noisy Roslyn/Roslynator diagnostics that are expected for generator-backed patterns (e.g. fields never read, methods that don't need to be static).
Expand All @@ -187,6 +188,8 @@ Suppressors silence noisy Roslyn/Roslynator diagnostics that are expected for ge
- The `ReactiveUI.SourceGenerators.Analyzers.CodeFixes` project owns every other `RXUISG*` diagnostic (such as
RXUISG0016 and RXUISG0020) and all code fixers.
- `DiagnosticDescriptors.cs` and related files are compiled from the shared Roslyn source via the linked `<Compile>` items.
- When an analyzer reports what a generator skips, both compile the same rules file (for commands,
`ReactiveCommand/ReactiveCommandRules.cs`, linked into the code-fix project), so the two cannot disagree.

## Testing

Expand Down Expand Up @@ -244,7 +247,9 @@ Analyzer and helper tests use direct `CSharpCompilation` / `CompilationWithAnaly
### Adding a New Analyzer Diagnostic

1. Add a `DiagnosticDescriptor` to `DiagnosticDescriptors.cs`.
2. Update `AnalyzerReleases.Unshipped.md`.
2. Record the rule in `AnalyzerReleases.Shipped.md`, under the release section for the current major version. Never use
`AnalyzerReleases.Unshipped.md`: it stays empty apart from its header, and every new, changed or removed rule is
recorded as released.
3. Implement the analyzer in `ReactiveUI.SourceGenerators.Analyzers.CodeFixes/`.
4. Add unit tests in `ReactiveUI.SourceGenerator.Tests/UnitTests/`.

Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,13 @@ public partial class MyReactiveClass
```

### Usage ReactiveCommand with ReactiveUI OutputScheduler

`OutputScheduler` and `BackgroundScheduler` name a scheduler as you would write it in the class: a field, property or
parameterless method of the class, or a static one elsewhere, such as `RxSchedulers.MainThreadScheduler` with
`using ReactiveUI;`. A name that does not resolve to a scheduler is reported as RXUISG0021.

```csharp
using ReactiveUI;
using ReactiveUI.SourceGenerators;

public partial class MyReactiveClass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public partial class TestVM
private global::ReactiveUI.ReactiveCommand<global::ReactiveUI.Primitives.RxVoid, int>? _test1Command;

[global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage]
public global::ReactiveUI.ReactiveCommand<global::ReactiveUI.Primitives.RxVoid, int> Test1Command { get => _test1Command ??= global::ReactiveUI.ReactiveCommand.Create(Test1); }
public global::ReactiveUI.ReactiveCommand<global::ReactiveUI.Primitives.RxVoid, int> Test1Command { get => _test1Command ??= global::ReactiveUI.ReactiveCommand.Create(Test1, outputScheduler: global::ReactiveUI.RxSchedulers.MainThreadScheduler); }
}
}
#nullable restore
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,22 @@ namespace ReactiveUI.SourceGenerator.Tests;
/// <summary>Unit tests for <see cref="PropertyToReactiveFieldCodeFixProvider" />.</summary>
public sealed class PropertyToReactiveFieldCodeFixProviderTests
{
/// <summary>A documented property in a view model nested in another type, neither of them partial.</summary>
private const string PropertySource = """
using ReactiveUI;

namespace TestNs;

public static class Outer
{
public class TestVM : ReactiveObject
{
/// <summary>Gets or sets a value indicating whether it is visible.</summary>
public bool IsVisible { get; set; }
}
}
""";

/// <summary>Validates the code fix provider advertises the expected diagnostic ID.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
Expand All @@ -27,10 +43,38 @@ public async Task GetFixAllProviderReturnsBatchFixer()
await Assert.That(provider.GetFixAllProvider()).IsNotNull();
}

/// <summary>Validates a public auto-property is converted to a private field annotated with <c>[Reactive]</c>.</summary>
/// <summary>Before C# 13 a public auto-property is converted to a private field annotated with <c>[Reactive]</c>.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task WhenApplyingFixThenConvertsPropertyToReactiveField()
public async Task BeforeCSharp13ConvertsPropertyToReactiveField()
{
var fixedSource = await ApplyFix(PropertySource, LanguageVersion.CSharp12);

await Assert.That(fixedSource).Contains("[ReactiveUI.SourceGenerators.Reactive]");
await Assert.That(fixedSource).Contains("private bool _isVisible");
await Assert.That(fixedSource).DoesNotContain("public bool IsVisible");
}

/// <summary>From C# 13 the property becomes a <c>[Reactive]</c> partial property, in a type made partial, and generates.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task FromCSharp13ConvertsPropertyToReactivePartialProperty()
{
var fixedSource = await ApplyFix(PropertySource, LanguageVersion.CSharp13);

await Assert.That(fixedSource).Contains("[ReactiveUI.SourceGenerators.Reactive]");
await Assert.That(fixedSource).Contains("/// <summary>Gets or sets a value indicating whether it is visible.</summary>");
await Assert.That(fixedSource).Contains("public partial bool IsVisible { get; set; }");
await Assert.That(fixedSource).Contains("public partial class TestVM");
await Assert.That(fixedSource).Contains("public static partial class Outer");
await Assert.That(fixedSource).DoesNotContain("_isVisible");
await Assert.That(GetErrorsAfterGeneration(fixedSource, LanguageVersion.CSharp13)).IsEmpty();
}

/// <summary>A property with an initializer becomes a partial property only where C# allows it one.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task PropertyWithInitializerBecomesPartialOnlyWhereTheLanguageAllowsIt()
{
const string source = """
using ReactiveUI;
Expand All @@ -39,23 +83,75 @@ namespace TestNs;

public partial class TestVM : ReactiveObject
{
public bool IsVisible { get; set; }
public string Name { get; set; } = "none";
}
""";

var fixedSource = await ApplyFix(source);
var csharp13 = await ApplyFix(source, LanguageVersion.CSharp13);
var preview = await ApplyFix(source, LanguageVersion.Preview);

await Assert.That(csharp13).Contains("private string _name = \"none\";");
await Assert.That(preview).Contains("public partial string Name { get; set; } = \"none\";");
}

/// <summary>A diagnostic that is not on a property offers no fix.</summary>
/// <returns>A task that represents the asynchronous test operation.</returns>
[Test]
public async Task DiagnosticOutsideAPropertyOffersNoFix()
{
using var workspace = new AdhocWorkspace();
var document = workspace.CurrentSolution
.AddProject("p", "p", LanguageNames.CSharp)
.AddDocument("t.cs", PropertySource);
var tree = await document.GetSyntaxTreeAsync();
const string className = "Outer";
var classLocation = Location.Create(tree!, new(PropertySource.IndexOf(className, StringComparison.Ordinal), className.Length));
var actions = new List<Microsoft.CodeAnalysis.CodeActions.CodeAction>();
var context = new CodeFixContext(
document,
Diagnostic.Create(new PropertyToReactiveFieldAnalyzer().SupportedDiagnostics[0], classLocation),
(action, _) => actions.Add(action),
CancellationToken.None);

await new PropertyToReactiveFieldCodeFixProvider().RegisterCodeFixesAsync(context);

await Assert.That(actions).IsEmpty();
}

/// <summary>Compiles fixed source with the <c>[Reactive]</c> generator and gets the errors.</summary>
/// <param name="source">The fixed source.</param>
/// <param name="languageVersion">The C# version.</param>
/// <returns>The errors, one per line.</returns>
private static string GetErrorsAfterGeneration(string source, LanguageVersion languageVersion)
{
var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(languageVersion);
var compilation = CSharpCompilation.Create(
"CodeFixGenerationTests",
[CSharpSyntaxTree.ParseText(source, parseOptions)],
TestCompilationReferences.CreateDefault(),
new(OutputKind.DynamicallyLinkedLibrary));
_ = CSharpGeneratorDriver.Create([new ReactiveGenerator().AsSourceGenerator()], parseOptions: parseOptions)
.RunGeneratorsAndUpdateCompilation(compilation, out var output, out _);

var errors = new List<string>();
foreach (var diagnostic in output.GetDiagnostics())
{
if (diagnostic.Severity == DiagnosticSeverity.Error)
{
errors.Add(diagnostic.ToString());
}
}

await Assert.That(fixedSource.Contains("[ReactiveUI.SourceGenerators.Reactive]", StringComparison.Ordinal)).IsTrue();
await Assert.That(fixedSource.Contains("private bool _isVisible", StringComparison.Ordinal)).IsTrue();
await Assert.That(fixedSource.Contains("public bool IsVisible", StringComparison.Ordinal)).IsFalse();
return string.Join(Environment.NewLine, errors);
}

/// <summary>Applies the code fix to the supplied source.</summary>
/// <param name="source">The source to fix.</param>
/// <param name="languageVersion">The C# version the project uses.</param>
/// <returns>A task that resolves to the fixed source.</returns>
private static async Task<string> ApplyFix(string source)
private static async Task<string> ApplyFix(string source, LanguageVersion languageVersion)
{
var tree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13));
var tree = CSharpSyntaxTree.ParseText(source, CSharpParseOptions.Default.WithLanguageVersion(languageVersion));

var analyzer = new PropertyToReactiveFieldAnalyzer();
var compilation = CSharpCompilation.Create(
Expand All @@ -70,7 +166,7 @@ private static async Task<string> ApplyFix(string source)
using var workspace = new AdhocWorkspace();
var project = workspace.CurrentSolution
.AddProject("p", "p", LanguageNames.CSharp)
.WithParseOptions(CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp13))
.WithParseOptions(CSharpParseOptions.Default.WithLanguageVersion(languageVersion))
.WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

foreach (var reference in TestCompilationReferences.CreateDefault())
Expand Down
Loading
Loading