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
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.3" />
<PackageVersion Include="Microsoft.NETFramework.ReferenceAssemblies.net472" Version="1.0.3" />
<PackageVersion Include="Microsoft.Sbom.Targets" Version="4.1.5" />
<PackageVersion Include="NFluent" Version="3.1.0" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
Expand Down
79 changes: 69 additions & 10 deletions FirstClassErrors.Analyzers.UnitTests/AnalyzerTestHarness.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Immutable;
using System.Reflection;

using FirstClassErrors;

Expand All @@ -9,18 +10,44 @@
namespace FirstClassErrors.Analyzers.UnitTests;

/// <summary>
/// Minimal in-process harness: compiles a C# snippet against the running runtime plus the FirstClassErrors core,
/// runs a single analyzer over it, and returns the analyzer diagnostics. Deliberately dependency-free (no
/// Microsoft.CodeAnalysis.Testing) so it composes cleanly with xUnit v3 and NFluent.
/// Minimal in-process harness: compiles a C# snippet against a reference set, runs a single analyzer over it, and
/// returns the analyzer diagnostics. Deliberately dependency-free (no Microsoft.CodeAnalysis.Testing) so it composes
/// cleanly with xUnit v3 and NFluent.
/// </summary>
/// <remarks>
/// <see cref="GetDiagnosticsAsync" /> compiles against the running runtime, the default for the analyzer suite.
/// <see cref="GetDiagnosticsAgainstNet472Async" /> compiles against the .NET Framework 4.7.2 reference assemblies
/// instead — the analyzed code's <i>target framework</i>, not the test's runtime, is what a framework-aware rule
/// reacts to — so a rule that resolves a counterpart from the compilation (FCE021) can be proven silent where that
/// counterpart does not exist for an older framework.
/// </remarks>
internal static class AnalyzerTestHarness {

private const string Net472ReferenceAssembliesMetadataKey = "Net472ReferenceAssemblies";

private static readonly ImmutableArray<MetadataReference> BaseReferences = BuildBaseReferences();

public static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(
DiagnosticAnalyzer analyzer,
string source,
params string[] enabledDiagnosticIds) {
public static Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(
DiagnosticAnalyzer analyzer,
string source,
params string[] enabledDiagnosticIds) {

return RunAsync(analyzer, source, BaseReferences, enabledDiagnosticIds);
}

public static Task<ImmutableArray<Diagnostic>> GetDiagnosticsAgainstNet472Async(
DiagnosticAnalyzer analyzer,
string source,
params string[] enabledDiagnosticIds) {

return RunAsync(analyzer, source, BuildNet472References(), enabledDiagnosticIds);
}

private static async Task<ImmutableArray<Diagnostic>> RunAsync(
DiagnosticAnalyzer analyzer,
string source,
ImmutableArray<MetadataReference> references,
string[] enabledDiagnosticIds) {

SyntaxTree syntaxTree = CSharpSyntaxTree.ParseText(source);

Expand All @@ -35,7 +62,7 @@ public static async Task<ImmutableArray<Diagnostic>> GetDiagnosticsAsync(
CSharpCompilation compilation = CSharpCompilation.Create(
assemblyName: "FirstClassErrors.Analyzers.TestSnippet",
syntaxTrees: new[] { syntaxTree },
references: BaseReferences,
references: references,
options: options);

CompilationWithAnalyzers withAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer));
Expand All @@ -57,10 +84,42 @@ private static ImmutableArray<MetadataReference> BuildBaseReferences() {
}
}

// The FirstClassErrors core, so ErrorCode / DomainError / DescribeError resolve inside the snippet.
references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location));
AddCore(references);

return references.ToImmutableArray();
}

private static ImmutableArray<MetadataReference> BuildNet472References() {
List<MetadataReference> references = new();

// The .NET Framework 4.7.2 reference assemblies (including the netstandard facade, so the netstandard2.0 core
// resolves). The directory is baked in at build time via the Net472ReferenceAssemblies assembly metadata.
foreach (string dll in Directory.EnumerateFiles(Net472ReferenceDirectory(), "*.dll", SearchOption.AllDirectories)) {
try {
references.Add(MetadataReference.CreateFromFile(dll));
} catch {
// Skip anything Roslyn cannot read as a metadata reference.
}
}

AddCore(references);

return references.ToImmutableArray();
}

private static void AddCore(List<MetadataReference> references) {
// The FirstClassErrors core, so Outcome / ErrorCode / DomainError resolve inside the snippet.
references.Add(MetadataReference.CreateFromFile(typeof(ErrorCode).Assembly.Location));
}

private static string Net472ReferenceDirectory() {
foreach (AssemblyMetadataAttribute attribute in typeof(AnalyzerTestHarness).Assembly.GetCustomAttributes<AssemblyMetadataAttribute>()) {
if (attribute.Key == Net472ReferenceAssembliesMetadataKey && !string.IsNullOrWhiteSpace(attribute.Value)) {
return attribute.Value!;
}
}

throw new InvalidOperationException($"The '{Net472ReferenceAssembliesMetadataKey}' assembly metadata was not found; the net472 reference-assemblies package is not wired into the test project.");
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
using System.Collections.Immutable;

using FirstClassErrors.Analyzers;

using Microsoft.CodeAnalysis;

using NFluent;

namespace FirstClassErrors.Analyzers.UnitTests;

public class Fce019TryCatchesTooBroadlyTests {

[Fact]
public async Task Reports_when_the_value_overload_catches_Exception() {
const string source = """
using System;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, Exception>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
}

[Fact]
public async Task Reports_when_the_void_overload_catches_Exception() {
const string source = """
using System;
using FirstClassErrors;

public static class Sample {
public static void M() {
Outcome.Try<Exception>(() => { }, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
}

[Fact]
public async Task Reports_when_the_async_overload_catches_Exception() {
const string source = """
using System;
using System.Threading;
using System.Threading.Tasks;
using FirstClassErrors;

public static class Sample {
public static Task<Outcome<int>> M() {
return Outcome.Try<int, Exception>(ct => Task.FromResult(1), _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE019");
}

[Fact]
public async Task Does_not_report_when_a_specific_exception_is_caught() {
const string source = """
using System;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, FormatException>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(0);
}

[Fact]
public async Task Does_not_report_a_Try_that_is_not_on_Outcome() {
const string source = """
using System;

public static class Other {
public static void Try<TException>(Action action) where TException : Exception { }
}

public static class Sample {
public static void M() {
Other.Try<Exception>(() => { });
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesTooBroadlyAnalyzer(), source);

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
using System.Collections.Immutable;

using FirstClassErrors.Analyzers;

using Microsoft.CodeAnalysis;

using NFluent;

namespace FirstClassErrors.Analyzers.UnitTests;

public class Fce020TryCatchesRichProtocolExceptionTests {

[Fact]
public async Task Reports_when_Try_catches_HttpRequestException() {
const string source = """
using System.Net.Http;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, HttpRequestException>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
}

[Fact]
public async Task Reports_when_Try_catches_a_subtype_of_a_protocol_exception() {
const string source = """
using System.Net.Http;
using FirstClassErrors;

public class TransportException : HttpRequestException { }

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, TransportException>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
}

[Fact]
public async Task Reports_when_Try_catches_SocketException() {
const string source = """
using System.Net.Sockets;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, SocketException>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");

Check.That(diagnostics.Length).IsEqualTo(1);
Check.That(diagnostics[0].Id).IsEqualTo("FCE020");
}

[Fact]
public async Task Does_not_report_a_domain_exception() {
const string source = """
using System;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, FormatException>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");

Check.That(diagnostics.Length).IsEqualTo(0);
}

[Fact]
public async Task Does_not_report_a_broad_Exception_which_is_FCE019s_concern() {
const string source = """
using System;
using FirstClassErrors;

public static class Sample {
public static Outcome<int> M() {
return Outcome.Try<int, Exception>(() => 1, _ => (Error)null);
}
}
""";

ImmutableArray<Diagnostic> diagnostics = await AnalyzerTestHarness.GetDiagnosticsAsync(new TryCatchesRichProtocolExceptionAnalyzer(), source, "FCE020");

Check.That(diagnostics.Length).IsEqualTo(0);
}

}
Loading