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
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ public static void M(DomainError error) {
Check.That(diagnostics[0].Id).IsEqualTo("FCE016");
}

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

public static class Sample {
public static void M(DomainError error) {
_ = error.ToException();
}
}
""";

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

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

[Fact]
public async Task Does_not_report_when_thrown() {
const string source = """
Expand Down
18 changes: 5 additions & 13 deletions FirstClassErrors.Analyzers/DuplicateErrorCodeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ namespace FirstClassErrors.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class DuplicateErrorCodeAnalyzer : DiagnosticAnalyzer {

private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";
private const string CreateMethodName = "Create";

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(Descriptors.DuplicateErrorCode);
Expand All @@ -32,7 +29,7 @@ public override void Initialize(AnalysisContext context) {
}

private static void OnCompilationStart(CompilationStartAnalysisContext context) {
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName);
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeFacts.ErrorCodeMetadataName);
if (errorCodeType is null) { return; }

// Per-compilation state; ErrorCode registers with ordinal comparison, so duplicates are case-sensitive.
Expand All @@ -48,17 +45,12 @@ private static void Collect(
ConcurrentDictionary<string, ConcurrentBag<Location>> occurrences) {

IInvocationOperation invocation = (IInvocationOperation)context.Operation;
IMethodSymbol method = invocation.TargetMethod;

if (!method.IsStatic || method.Name != CreateMethodName) { return; }
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; }
if (invocation.Arguments.Length != 1) { return; }

IOperation argument = invocation.Arguments[0].Value;
Optional<object?> constant = argument.ConstantValue;
IOperation? argument = ErrorCodeFacts.GetCreateArgument(invocation, errorCodeType);
if (argument is null) { return; }

if (!constant.HasValue) { return; } // non-literal → FCE003
if (constant.Value is not string code || string.IsNullOrWhiteSpace(code)) { return; } // empty → FCE002
// Non-literal codes are FCE003's concern, empty ones FCE002's; only real codes can collide.
if (!ErrorCodeFacts.TryGetNonEmptyLiteralCode(argument, out string code)) { return; }

occurrences.GetOrAdd(code, _ => new ConcurrentBag<Location>()).Add(argument.Syntax.GetLocation());
}
Expand Down
17 changes: 4 additions & 13 deletions FirstClassErrors.Analyzers/EmptyErrorCodeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ namespace FirstClassErrors.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class EmptyErrorCodeAnalyzer : DiagnosticAnalyzer {

private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";
private const string CreateMethodName = "Create";

/// <inheritdoc />
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; } =
ImmutableArray.Create(Descriptors.EmptyErrorCode);
Expand All @@ -29,26 +26,20 @@ public override void Initialize(AnalysisContext context) {
}

private static void OnCompilationStart(CompilationStartAnalysisContext context) {
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName);
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeFacts.ErrorCodeMetadataName);
if (errorCodeType is null) { return; }

context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation);
}

private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) {
IInvocationOperation invocation = (IInvocationOperation)context.Operation;
IMethodSymbol method = invocation.TargetMethod;

if (!method.IsStatic || method.Name != CreateMethodName) { return; }
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; }
if (invocation.Arguments.Length != 1) { return; }

IOperation argument = invocation.Arguments[0].Value;
Optional<object?> constant = argument.ConstantValue;
IOperation? argument = ErrorCodeFacts.GetCreateArgument(invocation, errorCodeType);
if (argument is null) { return; }

// A non-constant argument cannot be judged statically; FCE003 flags that separately.
if (!constant.HasValue) { return; }
if (constant.Value is string value && !string.IsNullOrWhiteSpace(value)) { return; }
if (!ErrorCodeFacts.IsBlankLiteralCode(argument)) { return; }

context.ReportDiagnostic(Diagnostic.Create(Descriptors.EmptyErrorCode, argument.Syntax.GetLocation()));
}
Expand Down
63 changes: 63 additions & 0 deletions FirstClassErrors.Analyzers/ErrorCodeFacts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Operations;

namespace FirstClassErrors.Analyzers;

/// <summary>
/// Recognizes <c>ErrorCode.Create("...")</c> invocations and extracts the literal code, centralizing the
/// <see cref="Optional{T}" /> handling shared by the error-code analyzers (FCE001, FCE002, FCE004, FCE005).
/// </summary>
internal static class ErrorCodeFacts {

/// <summary>
/// Metadata name of the <c>ErrorCode</c> type the analyzers key off of.
/// </summary>
public const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";

private const string CreateMethodName = "Create";

/// <summary>
/// Returns the single argument operation of a static <c>ErrorCode.Create(x)</c> call, or <c>null</c> when the
/// invocation is not such a call.
/// </summary>
public static IOperation? GetCreateArgument(IInvocationOperation invocation, INamedTypeSymbol errorCodeType) {
IMethodSymbol method = invocation.TargetMethod;

if (!method.IsStatic || method.Name != CreateMethodName) { return null; }
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return null; }
if (invocation.Arguments.Length != 1) { return null; }

return invocation.Arguments[0].Value;
}

/// <summary>
/// Extracts the code when <paramref name="argument" /> is a non-empty literal string constant. A non-literal
/// argument (<see cref="Optional{T}.HasValue" /> is <c>false</c>) is FCE003's concern; a <c>null</c>, empty, or
/// whitespace literal is FCE002's concern. Both yield <c>false</c> here.
/// </summary>
public static bool TryGetNonEmptyLiteralCode(IOperation argument, out string code) {
Optional<object?> constant = argument.ConstantValue;

if (constant.HasValue && constant.Value is string value && !string.IsNullOrWhiteSpace(value)) {
code = value;
return true;
}

code = string.Empty;
return false;
}

/// <summary>
/// True when <paramref name="argument" /> is a literal constant whose string value is missing (<c>null</c>) or
/// blank. A non-literal argument (<see cref="Optional{T}.HasValue" /> is <c>false</c>) yields <c>false</c>; that
/// case is FCE003's concern.
/// </summary>
public static bool IsBlankLiteralCode(IOperation argument) {
Optional<object?> constant = argument.ConstantValue;

if (!constant.HasValue) { return false; }

return constant.Value is not string value || string.IsNullOrWhiteSpace(value);
}

}
16 changes: 5 additions & 11 deletions FirstClassErrors.Analyzers/InvalidErrorCodeFormatAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ namespace FirstClassErrors.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class InvalidErrorCodeFormatAnalyzer : DiagnosticAnalyzer {

private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";
private const string CreateMethodName = "Create";

private static readonly Regex UpperSnakeCase = new("^[A-Z][A-Z0-9]*(_[A-Z0-9]+)*$", RegexOptions.CultureInvariant);

/// <inheritdoc />
Expand All @@ -31,23 +28,20 @@ public override void Initialize(AnalysisContext context) {
}

private static void OnCompilationStart(CompilationStartAnalysisContext context) {
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName);
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeFacts.ErrorCodeMetadataName);
if (errorCodeType is null) { return; }

context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation);
}

private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) {
IInvocationOperation invocation = (IInvocationOperation)context.Operation;
IMethodSymbol method = invocation.TargetMethod;

if (!method.IsStatic || method.Name != CreateMethodName) { return; }
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; }
if (invocation.Arguments.Length != 1) { return; }
IOperation? argument = ErrorCodeFacts.GetCreateArgument(invocation, errorCodeType);
if (argument is null) { return; }

IOperation argument = invocation.Arguments[0].Value;
if (argument.ConstantValue.Value is not string code) { return; } // non-literal → FCE003
if (string.IsNullOrWhiteSpace(code)) { return; } // empty → FCE002
// Non-literal codes are FCE003's concern, empty ones FCE002's.
if (!ErrorCodeFacts.TryGetNonEmptyLiteralCode(argument, out string code)) { return; }
if (UpperSnakeCase.IsMatch(code)) { return; }

context.ReportDiagnostic(Diagnostic.Create(Descriptors.InvalidErrorCodeFormat, argument.Syntax.GetLocation(), code));
Expand Down
14 changes: 4 additions & 10 deletions FirstClassErrors.Analyzers/TooGenericErrorCodeAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,6 @@ namespace FirstClassErrors.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class TooGenericErrorCodeAnalyzer : DiagnosticAnalyzer {

private const string ErrorCodeMetadataName = "FirstClassErrors.ErrorCode";
private const string CreateMethodName = "Create";

private static readonly ImmutableHashSet<string> GenericCodes = ImmutableHashSet.Create(
StringComparer.OrdinalIgnoreCase,
"ERROR", "INVALID", "FAILED", "FAILURE", "UNKNOWN", "EXCEPTION", "BAD", "WRONG", "GENERIC", "PROBLEM");
Expand All @@ -32,22 +29,19 @@ public override void Initialize(AnalysisContext context) {
}

private static void OnCompilationStart(CompilationStartAnalysisContext context) {
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeMetadataName);
INamedTypeSymbol? errorCodeType = context.Compilation.GetTypeByMetadataName(ErrorCodeFacts.ErrorCodeMetadataName);
if (errorCodeType is null) { return; }

context.RegisterOperationAction(operationContext => Analyze(operationContext, errorCodeType), OperationKind.Invocation);
}

private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol errorCodeType) {
IInvocationOperation invocation = (IInvocationOperation)context.Operation;
IMethodSymbol method = invocation.TargetMethod;

if (!method.IsStatic || method.Name != CreateMethodName) { return; }
if (!SymbolEqualityComparer.Default.Equals(method.ContainingType, errorCodeType)) { return; }
if (invocation.Arguments.Length != 1) { return; }
IOperation? argument = ErrorCodeFacts.GetCreateArgument(invocation, errorCodeType);
if (argument is null) { return; }

IOperation argument = invocation.Arguments[0].Value;
if (argument.ConstantValue.Value is not string code) { return; }
if (!ErrorCodeFacts.TryGetNonEmptyLiteralCode(argument, out string code)) { return; }
if (!GenericCodes.Contains(code.Trim())) { return; }

context.ReportDiagnostic(Diagnostic.Create(Descriptors.TooGenericErrorCode, argument.Syntax.GetLocation(), code));
Expand Down
13 changes: 11 additions & 2 deletions FirstClassErrors.Analyzers/UnusedToExceptionResultAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,19 @@ private static void Analyze(OperationAnalysisContext context, INamedTypeSymbol e
if (method.Name != ToExceptionMethodName || method.Parameters.Length != 0) { return; }
if (!SymbolFacts.IsOrInheritsFrom(method.ContainingType, errorType)) { return; }

// The result is used unless the invocation stands alone as an expression statement.
if (invocation.Parent is not IExpressionStatementOperation) { return; }
if (!IsResultDiscarded(invocation)) { return; }

context.ReportDiagnostic(Diagnostic.Create(Descriptors.UnusedToExceptionResult, invocation.Syntax.GetLocation()));
}

// The result is thrown away either when the call stands alone as a statement or when it is explicitly discarded
// (`_ = error.ToException();`). ToException() is a pure builder, so an explicit discard is just as pointless.
private static bool IsResultDiscarded(IInvocationOperation invocation) {
return invocation.Parent switch {
IExpressionStatementOperation => true,
ISimpleAssignmentOperation { Target: IDiscardOperation } => true,
_ => false,
};
}

}
8 changes: 7 additions & 1 deletion doc/analyzers/FCE016.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| **Severity** | 🟠 Warning |
| **Enabled by default** | Yes |

`Error.ToException()` is called as a standalone statement and its result is discarded. `ToException()` only builds the exception; without a `throw` (or capturing the result) nothing happens and the error is lost.
`Error.ToException()` is called as a standalone statement, or its result is explicitly discarded with `_ =`. Either way the result is thrown away. `ToException()` only builds the exception; without a `throw` (or capturing the result) nothing happens and the error is lost.

## Noncompliant

Expand All @@ -19,6 +19,12 @@ public void Guard(DomainError error) {
}
```

```csharp
public void Guard(DomainError error) {
_ = error.ToException(); // FCE016: result discarded
}
```

## Compliant

```csharp
Expand Down
8 changes: 7 additions & 1 deletion doc/analyzers/FCE016.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
| **Sévérité** | 🟠 Warning |
| **Activée par défaut** | Oui |

`Error.ToException()` est appelé comme instruction isolée et son résultat est ignoré. `ToException()` ne fait que construire l'exception : sans `throw` (ou sans capturer le résultat), rien ne se passe et l'erreur est perdue.
`Error.ToException()` est appelé comme instruction isolée, ou son résultat est explicitement ignoré avec `_ =`. Dans les deux cas le résultat est jeté. `ToException()` ne fait que construire l'exception : sans `throw` (ou sans capturer le résultat), rien ne se passe et l'erreur est perdue.

## Non conforme

Expand All @@ -19,6 +19,12 @@ public void Guard(DomainError error) {
}
```

```csharp
public void Guard(DomainError error) {
_ = error.ToException(); // FCE016: result discarded
}
```

## Conforme

```csharp
Expand Down
2 changes: 1 addition & 1 deletion doc/analyzers/README.fr.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Chaque règle a un identifiant stable `FCExxx`. Les erreurs sont des défauts du

| Règle | Sévérité | Défaut | Description |
|------|----------|---------|-------------|
| [FCE016 UnusedToExceptionResult](FCE016.fr.md) | 🟠 Warning | activée | Error.ToException() est appelé comme instruction isolée et son résultat est ignoré. |
| [FCE016 UnusedToExceptionResult](FCE016.fr.md) | 🟠 Warning | activée | Error.ToException() est appelé comme instruction isolée, ou son résultat est explicitement ignoré avec `_ =`. |

## Configuration

Expand Down
2 changes: 1 addition & 1 deletion doc/analyzers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Each rule has a stable id `FCExxx`. Errors are hard defects; warnings flag likel

| Rule | Severity | Default | Description |
|------|----------|---------|-------------|
| [FCE016 UnusedToExceptionResult](FCE016.en.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement and its result is discarded. |
| [FCE016 UnusedToExceptionResult](FCE016.en.md) | 🟠 Warning | on | Error.ToException() is called as a standalone statement, or its result is explicitly discarded with `_ =`. |

## Configuring

Expand Down
Loading