diff --git a/Arguments.slnx b/Arguments.slnx index db2af55..cffb032 100644 --- a/Arguments.slnx +++ b/Arguments.slnx @@ -17,8 +17,10 @@ + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 5402ba9..f02dfec 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,9 @@ + + + diff --git a/docs/analysers/NEA0001.md b/docs/analysers/NEA0001.md new file mode 100644 index 0000000..59c8fad --- /dev/null +++ b/docs/analysers/NEA0001.md @@ -0,0 +1,93 @@ +# NEA0001: Use ArgumentNullException.ThrowIfNull + +| Property | Value | +| ----------------------------------- | ------------------------------------- | +| **Rule ID** | NEA0001 | +| **Title** | Use ArgumentNullException.ThrowIfNull | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether an argument is `null` and then conditionally throws an [ArgumentNullException](https://learn.microsoft.com/dotnet/api/system.argumentnullexception). + +## Rule description + +This rule mirrors the built-in [CA1510](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1510), but fires on every target framework supported by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) — including .NET Standard 2.0 and .NET Framework 4.7.2+, which predate the real `ArgumentNullException.ThrowIfNull` member. NEA0001 stays silent whenever the compilation already exposes the real BCL member (.NET 6+), since CA1510 already covers that case. + +Recognized shapes: + +- `if (arg is null) throw new ArgumentNullException(nameof(arg));` +- `if (arg == null) throw ...` / `if (null == arg) throw ...` +- `if (ReferenceEquals(arg, null)) throw ...` / `if (ReferenceEquals(null, arg)) throw ...` +- Any of the above negated, e.g. `if (!(arg != null)) throw ...`, `if (!(arg is not null)) throw ...` +- Null-coalescing throw: `arg ?? throw new ArgumentNullException(nameof(arg));` + +Only fires when the thrown exception takes no arguments, or a single `paramName` argument that matches the checked expression (`nameof(arg)` or a matching string literal). Violations with a custom message argument are not flagged, since `ThrowIfNull` doesn't support one. + +## Example + +```csharp +void M(string arg) +{ + if (arg is null) + throw new ArgumentNullException(nameof(arg)); +} +``` + +```csharp +class C +{ + private readonly string _value; + + public C(string? arg) + { + _value = arg ?? throw new ArgumentNullException(nameof(arg)); + } +} +``` + +## Fix + +```csharp +void M(string arg) +{ + ArgumentNullException.ThrowIfNull(arg); +} +``` + +```csharp +class C +{ + private readonly string _value; + + public C(string? arg) + { + ArgumentNullException.ThrowIfNull(arg); + _value = arg; + } +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block / coalescing throw manually with a call to `ArgumentNullException.ThrowIfNull`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0001 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0001 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0001.severity = none +``` diff --git a/docs/analysers/NEA0002.md b/docs/analysers/NEA0002.md new file mode 100644 index 0000000..5057ac3 --- /dev/null +++ b/docs/analysers/NEA0002.md @@ -0,0 +1,65 @@ +# NEA0002: Use ArgumentException throw helper + +| Property | Value | +| ----------------------------------- | ---------------------------------- | +| **Rule ID** | NEA0002 | +| **Title** | Use ArgumentException throw helper | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether a string is `null`/empty or `null`/empty/whitespace-only and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +This rule mirrors the built-in [CA1511](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1511), but fires on every target framework supported by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) — including frameworks that predate the real `ArgumentException.ThrowIfNullOrEmpty`/`ThrowIfNullOrWhiteSpace` members (.NET 8). NEA0002 stays silent whenever the compilation already exposes the real BCL member. + +Recognized shapes: + +- `if (string.IsNullOrEmpty(arg)) throw new ArgumentException(...);` → `ArgumentException.ThrowIfNullOrEmpty(arg)` +- `if (string.IsNullOrWhiteSpace(arg)) throw new ArgumentException(...);` → `ArgumentException.ThrowIfNullOrWhiteSpace(arg)` + +Any exception constructor arguments (including a custom message) are dropped by the fix, matching the upstream CA1511 fixer behavior. + +## Example + +```csharp +void M(string arg) +{ + if (string.IsNullOrEmpty(arg)) + throw new ArgumentException("", nameof(arg)); +} +``` + +## Fix + +```csharp +void M(string arg) +{ + ArgumentException.ThrowIfNullOrEmpty(arg); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with a call to `ArgumentException.ThrowIfNullOrEmpty` or `ArgumentException.ThrowIfNullOrWhiteSpace`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0002 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0002 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0002.severity = none +``` diff --git a/docs/analysers/NEA0003.md b/docs/analysers/NEA0003.md new file mode 100644 index 0000000..02210f9 --- /dev/null +++ b/docs/analysers/NEA0003.md @@ -0,0 +1,80 @@ +# NEA0003: Use ArgumentOutOfRangeException throw helper + +| Property | Value | +| ----------------------------------- | -------------------------------------------- | +| **Rule ID** | NEA0003 | +| **Title** | Use ArgumentOutOfRangeException throw helper | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether an argument is less than, greater than, or equal to a given value (or falls outside a range) and then conditionally throws an [ArgumentOutOfRangeException](https://learn.microsoft.com/dotnet/api/system.argumentoutofrangeexception). + +## Rule description + +This rule mirrors the built-in [CA1512](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1512), but fires on every target framework supported by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) — including frameworks that predate the real BCL throw-helper members (.NET 8). NEA0003 stays silent whenever the compilation already exposes the real BCL member. + +Recognized shapes (`value` on the left, `other`/`min`/`max` on the right): + +| Condition | Fix | +| ------------------------------ | ----------------------------------------- | +| `value < 0` | `ThrowIfNegative(value)` | +| `value <= 0` | `ThrowIfNegativeOrZero(value)` | +| `value == 0` | `ThrowIfZero(value)` | +| `value < other` | `ThrowIfLessThan(value, other)` | +| `value <= other` | `ThrowIfLessThanOrEqual(value, other)` | +| `value > other` | `ThrowIfGreaterThan(value, other)` | +| `value >= other` | `ThrowIfGreaterThanOrEqual(value, other)` | +| `value == other` | `ThrowIfEqual(value, other)` | +| `value != other` | `ThrowIfNotEqual(value, other)` | +| `value < min \|\| value > max` | `ThrowIfOutOfRange(value, min, max)` | + +Only the `value OP other` operand ordering is recognized (not the reversed `other OP value` form). + +## Example + +```csharp +void M(int arg) +{ + if (arg < 0) + throw new ArgumentOutOfRangeException(nameof(arg)); + + if (arg < 5 || arg > 100) + throw new ArgumentOutOfRangeException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(int arg) +{ + ArgumentOutOfRangeException.ThrowIfNegative(arg); + + ArgumentOutOfRangeException.ThrowIfOutOfRange(arg, 5, 100); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with the matching `ArgumentOutOfRangeException` throw-helper. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0003 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0003 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0003.severity = none +``` diff --git a/docs/analysers/NEA0004.md b/docs/analysers/NEA0004.md new file mode 100644 index 0000000..af6cb0a --- /dev/null +++ b/docs/analysers/NEA0004.md @@ -0,0 +1,64 @@ +# NEA0004: Use ArgumentException.ThrowIfDefault + +| Property | Value | +| ----------------------------------- | ------------------------------------ | +| **Rule ID** | NEA0004 | +| **Title** | Use ArgumentException.ThrowIfDefault | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether a value-type argument equals its default value and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +There is no built-in Roslyn analyzer for this pattern — `ArgumentException.ThrowIfDefault` is a throw-helper provided by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) itself, not the BCL, so this rule always fires regardless of target framework. + +Recognized shapes: + +- `if (arg.Equals(default)) throw new ArgumentException(...);` +- `if (arg.Equals(default(T))) throw new ArgumentException(...);` +- `if (arg == default) throw ...` / `if (default == arg) throw ...` (and the `default(T)` variants) + +## Example + +```csharp +void M(Guid arg) +{ + if (arg.Equals(default)) + throw new ArgumentException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(Guid arg) +{ + ArgumentException.ThrowIfDefault(arg); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with a call to `ArgumentException.ThrowIfDefault`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0004 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0004 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0004.severity = none +``` diff --git a/docs/analysers/NEA0005.md b/docs/analysers/NEA0005.md new file mode 100644 index 0000000..10ac8ba --- /dev/null +++ b/docs/analysers/NEA0005.md @@ -0,0 +1,70 @@ +# NEA0005: Use ObjectDisposedException.ThrowIf + +| Property | Value | +| ----------------------------------- | ----------------------------------- | +| **Rule ID** | NEA0005 | +| **Title** | Use ObjectDisposedException.ThrowIf | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether an object is disposed and then conditionally throws an [ObjectDisposedException](https://learn.microsoft.com/dotnet/api/system.objectdisposedexception). + +## Rule description + +This rule mirrors the built-in [CA1513](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1513), but fires on every target framework supported by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) — including frameworks that predate the real `ObjectDisposedException.ThrowIf` member (.NET 7). NEA0005 stays silent whenever the compilation already exposes the real BCL member, and only fires inside instance members (the fix needs `this`). + +## Example + +```csharp +class C +{ + private bool _disposed; + + void M() + { + if (_disposed) + throw new ObjectDisposedException(GetType().Name); + } +} +``` + +## Fix + +```csharp +class C +{ + private bool _disposed; + + void M() + { + ObjectDisposedException.ThrowIf(_disposed, this); + } +} +``` + +The fix always passes `this` as the instance, dropping whatever argument the original exception constructor used — matching the upstream CA1513 fixer behavior. + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with a call to `ObjectDisposedException.ThrowIf`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0005 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0005 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0005.severity = none +``` diff --git a/docs/analysers/NEA0006.md b/docs/analysers/NEA0006.md new file mode 100644 index 0000000..d9bc26d --- /dev/null +++ b/docs/analysers/NEA0006.md @@ -0,0 +1,66 @@ +# NEA0006: Use ArgumentException string-length throw helper + +| Property | Value | +| ----------------------------------- | ------------------------------------------------ | +| **Rule ID** | NEA0006 | +| **Title** | Use ArgumentException string-length throw helper | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code compares a string's `Length` against a bound and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +There is no built-in Roslyn analyzer for this pattern — `ArgumentException.ThrowIfLengthGreaterThan`/`ThrowIfLengthLessThan`/`ThrowIfLengthOutOfRange` are throw-helpers provided by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) itself, not the BCL, so this rule always fires regardless of target framework. + +Recognized shapes: + +| Condition | Fix | +| ---------------------------------------- | ---------------------------------------- | +| `arg.Length > max` | `ThrowIfLengthGreaterThan(arg, max)` | +| `arg.Length < min` | `ThrowIfLengthLessThan(arg, min)` | +| `arg.Length < min \|\| arg.Length > max` | `ThrowIfLengthOutOfRange(arg, min, max)` | + +## Example + +```csharp +void M(string arg) +{ + if (arg.Length > 100) + throw new ArgumentException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(string arg) +{ + ArgumentException.ThrowIfLengthGreaterThan(arg, 100); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with the matching `ArgumentException` length throw-helper. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0006 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0006 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0006.severity = none +``` diff --git a/docs/analysers/NEA0007.md b/docs/analysers/NEA0007.md new file mode 100644 index 0000000..0aabb6e --- /dev/null +++ b/docs/analysers/NEA0007.md @@ -0,0 +1,66 @@ +# NEA0007: Use ArgumentException collection-count throw helper + +| Property | Value | +| ----------------------------------- | --------------------------------------------------- | +| **Rule ID** | NEA0007 | +| **Title** | Use ArgumentException collection-count throw helper | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code compares a collection's element count against a bound and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +There is no built-in Roslyn analyzer for this pattern — `ArgumentException.ThrowIfCountGreaterThan`/`ThrowIfCountLessThan`/`ThrowIfCountOutOfRange` are throw-helpers provided by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) itself, not the BCL, so this rule always fires regardless of target framework. + +Recognized shapes (`.Count` property or `.Count()` LINQ extension method, both supported): + +| Condition | Fix | +| -------------------------------------- | --------------------------------------- | +| `arg.Count > max` | `ThrowIfCountGreaterThan(arg, max)` | +| `arg.Count < min` | `ThrowIfCountLessThan(arg, min)` | +| `arg.Count < min \|\| arg.Count > max` | `ThrowIfCountOutOfRange(arg, min, max)` | + +## Example + +```csharp +void M(ICollection arg) +{ + if (arg.Count > 100) + throw new ArgumentException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(ICollection arg) +{ + ArgumentException.ThrowIfCountGreaterThan(arg, 100); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with the matching `ArgumentException` count throw-helper. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0007 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0007 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0007.severity = none +``` diff --git a/docs/analysers/NEA0008.md b/docs/analysers/NEA0008.md new file mode 100644 index 0000000..0b912ce --- /dev/null +++ b/docs/analysers/NEA0008.md @@ -0,0 +1,63 @@ +# NEA0008: Use ArgumentException.ThrowIfContainsWhiteSpace + +| Property | Value | +| ----------------------------------- | ----------------------------------------------- | +| **Rule ID** | NEA0008 | +| **Title** | Use ArgumentException.ThrowIfContainsWhiteSpace | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether a string contains any white-space character and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +There is no built-in Roslyn analyzer for this pattern — `ArgumentException.ThrowIfContainsWhiteSpace` is a throw-helper provided by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) itself, not the BCL, so this rule always fires regardless of target framework. + +Recognized shapes: + +- `if (arg.Any(c => char.IsWhiteSpace(c))) throw new ArgumentException(...);` +- `if (arg.Any(char.IsWhiteSpace)) throw new ArgumentException(...);` + +## Example + +```csharp +void M(string arg) +{ + if (arg.Any(char.IsWhiteSpace)) + throw new ArgumentException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(string arg) +{ + ArgumentException.ThrowIfContainsWhiteSpace(arg); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with a call to `ArgumentException.ThrowIfContainsWhiteSpace`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0008 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0008 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0008.severity = none +``` diff --git a/docs/analysers/NEA0009.md b/docs/analysers/NEA0009.md new file mode 100644 index 0000000..38f28e3 --- /dev/null +++ b/docs/analysers/NEA0009.md @@ -0,0 +1,63 @@ +# NEA0009: Use ArgumentException.ThrowIfEmptyGuid + +| Property | Value | +| ----------------------------------- | -------------------------------------- | +| **Rule ID** | NEA0009 | +| **Title** | Use ArgumentException.ThrowIfEmptyGuid | +| **Category** | Maintainability | +| **Fix is breaking or non-breaking** | Non-breaking | +| **Enabled by default** | Yes, as suggestion | +| **Applicable languages** | C# | + +## Cause + +Code checks whether a `Guid` argument equals `Guid.Empty` and then conditionally throws an [ArgumentException](https://learn.microsoft.com/dotnet/api/system.argumentexception). + +## Rule description + +There is no built-in Roslyn analyzer for this pattern — `ArgumentException.ThrowIfEmptyGuid` is a throw-helper provided by [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) itself, not the BCL, so this rule always fires regardless of target framework. + +Recognized shapes: + +- `if (arg == Guid.Empty) throw new ArgumentException(...);` / `if (Guid.Empty == arg) throw ...` +- `if (arg.Equals(Guid.Empty)) throw new ArgumentException(...);` + +## Example + +```csharp +void M(Guid arg) +{ + if (arg == Guid.Empty) + throw new ArgumentException(nameof(arg)); +} +``` + +## Fix + +```csharp +void M(Guid arg) +{ + ArgumentException.ThrowIfEmptyGuid(arg); +} +``` + +## How to fix violations + +Use the code fix (lightbulb menu), or replace the `if` block manually with a call to `ArgumentException.ThrowIfEmptyGuid`. + +## When to suppress warnings + +It's safe to suppress a violation of this rule if you're not concerned about the maintainability of your code, or if the finding is a false positive. + +## Suppress a warning + +```csharp +#pragma warning disable NEA0009 +// The code that's violating the rule is on this line. +#pragma warning restore NEA0009 +``` + +```ini +[*.cs] +dotnet_diagnostic.NEA0009.severity = none +``` diff --git a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md new file mode 100644 index 0000000..92cf743 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Shipped.md @@ -0,0 +1,15 @@ +## Release 1.0 + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- +NEA0001 | Maintainability | Info | ThrowIfNullAnalyzer +NEA0002 | Maintainability | Info | ThrowIfNullOrEmptyAnalyzer +NEA0003 | Maintainability | Info | ThrowIfOutOfRangeAnalyzer +NEA0004 | Maintainability | Info | ThrowIfDefaultAnalyzer +NEA0005 | Maintainability | Info | ThrowIfDisposedAnalyzer +NEA0006 | Maintainability | Info | ThrowIfLengthAnalyzer +NEA0007 | Maintainability | Info | ThrowIfCountAnalyzer +NEA0008 | Maintainability | Info | ThrowIfContainsWhiteSpaceAnalyzer +NEA0009 | Maintainability | Info | ThrowIfEmptyGuidAnalyzer diff --git a/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md new file mode 100644 index 0000000..c903787 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/AnalyzerReleases.Unshipped.md @@ -0,0 +1,4 @@ +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|------- diff --git a/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs b/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs new file mode 100644 index 0000000..5a22e1b --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ComparisonResult.cs @@ -0,0 +1,37 @@ +namespace NetEvolve.Arguments.Analyser; + +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// Describes a recognized comparison-then-throw shape and the throw-helper member/arguments it maps to. +internal readonly struct ComparisonResult +{ + /// Initializes a new instance of the struct. + /// The name of the throw-helper member the comparison maps to. + /// The expression being validated (the left-hand side of the original comparison). + /// The bound the value is compared against, or for zero-only helpers such as ThrowIfZero. + /// The upper bound for combined-range comparisons (e.g. ThrowIfOutOfRange), or otherwise. + public ComparisonResult( + string helperName, + ExpressionSyntax valueExpression, + ExpressionSyntax? otherExpression, + ExpressionSyntax? otherExpression2 = null + ) + { + HelperName = helperName; + ValueExpression = valueExpression; + OtherExpression = otherExpression; + OtherExpression2 = otherExpression2; + } + + /// Gets the name of the throw-helper member the comparison maps to, e.g. ThrowIfNegative. + public string HelperName { get; } + + /// Gets the expression being validated (the left-hand side of the original comparison). + public ExpressionSyntax ValueExpression { get; } + + /// Gets the bound the value is compared against, or for zero-only helpers such as ThrowIfZero. + public ExpressionSyntax? OtherExpression { get; } + + /// Gets the upper bound for combined-range comparisons (e.g. ThrowIfOutOfRange), or otherwise. + public ExpressionSyntax? OtherExpression2 { get; } +} diff --git a/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs b/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs new file mode 100644 index 0000000..5ccdcd1 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/DiagnosticDescriptors.cs @@ -0,0 +1,118 @@ +namespace NetEvolve.Arguments.Analyser; + +using Microsoft.CodeAnalysis; + +/// Holds the for every rule (NEA0001-NEA0009) exposed by this analyzer package. +internal static class DiagnosticDescriptors +{ + /// The base URL under which every rule's Markdown documentation page is published. + private const string HelpLinkBase = "https://github.com/dailydevops/arguments/blob/main/docs/analysers"; + + /// NEA0001: reports a null-check-then-throw pattern that can be replaced by ArgumentNullException.ThrowIfNull. + public static readonly DiagnosticDescriptor ThrowIfNull = new( + id: "NEA0001", + title: "Use ArgumentNullException.ThrowIfNull", + messageFormat: "Use 'ArgumentNullException.ThrowIfNull({0})' instead of the explicit null-check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A null-check that throws ArgumentNullException can be replaced by the ArgumentNullException.ThrowIfNull throw-helper, which works on every target framework supported by NetEvolve.Arguments, including those that predate .NET 6.", + helpLinkUri: $"{HelpLinkBase}/NEA0001.md" + ); + + /// NEA0002: reports a string.IsNullOrEmpty/IsNullOrWhiteSpace-then-throw pattern that can be replaced by an ArgumentException throw-helper. + public static readonly DiagnosticDescriptor ThrowIfNullOrEmpty = new( + id: "NEA0002", + title: "Use ArgumentException throw helper", + messageFormat: "Use 'ArgumentException.{0}({1})' instead of the explicit check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A string.IsNullOrEmpty/IsNullOrWhiteSpace check that throws ArgumentException can be replaced by the ArgumentException.ThrowIfNullOrEmpty/ThrowIfNullOrWhiteSpace throw-helper, which works on every target framework supported by NetEvolve.Arguments, including those that predate .NET 8.", + helpLinkUri: $"{HelpLinkBase}/NEA0002.md" + ); + + /// NEA0003: reports a comparison-then-throw pattern that can be replaced by an ArgumentOutOfRangeException throw-helper. + public static readonly DiagnosticDescriptor ThrowIfOutOfRange = new( + id: "NEA0003", + title: "Use ArgumentOutOfRangeException throw helper", + messageFormat: "Use 'ArgumentOutOfRangeException.{0}({1})' instead of the explicit comparison and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A comparison that throws ArgumentOutOfRangeException can be replaced by an ArgumentOutOfRangeException throw-helper, which works on every target framework supported by NetEvolve.Arguments, including those that predate .NET 8.", + helpLinkUri: $"{HelpLinkBase}/NEA0003.md" + ); + + /// NEA0004: reports a default-value-check-then-throw pattern that can be replaced by ArgumentException.ThrowIfDefault. + public static readonly DiagnosticDescriptor ThrowIfDefault = new( + id: "NEA0004", + title: "Use ArgumentException.ThrowIfDefault", + messageFormat: "Use 'ArgumentException.ThrowIfDefault({0})' instead of the explicit default-value check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A default-value check that throws ArgumentException can be replaced by the ArgumentException.ThrowIfDefault throw-helper provided by NetEvolve.Arguments.", + helpLinkUri: $"{HelpLinkBase}/NEA0004.md" + ); + + /// NEA0005: reports a disposed-check-then-throw pattern that can be replaced by ObjectDisposedException.ThrowIf. + public static readonly DiagnosticDescriptor ThrowIfDisposed = new( + id: "NEA0005", + title: "Use ObjectDisposedException.ThrowIf", + messageFormat: "Use 'ObjectDisposedException.ThrowIf({0}, this)' instead of the explicit disposed-check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A disposed-check that throws ObjectDisposedException can be replaced by the ObjectDisposedException.ThrowIf throw-helper, which works on every target framework supported by NetEvolve.Arguments, including those that predate .NET 7.", + helpLinkUri: $"{HelpLinkBase}/NEA0005.md" + ); + + /// NEA0006: reports a string-length-comparison-then-throw pattern that can be replaced by an ArgumentException throw-helper. + public static readonly DiagnosticDescriptor ThrowIfLength = new( + id: "NEA0006", + title: "Use ArgumentException string-length throw helper", + messageFormat: "Use 'ArgumentException.{0}({1})' instead of the explicit string length comparison and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A string length comparison that throws ArgumentException can be replaced by the ArgumentException.ThrowIfLengthGreaterThan/ThrowIfLengthLessThan/ThrowIfLengthOutOfRange throw-helper provided by NetEvolve.Arguments.", + helpLinkUri: $"{HelpLinkBase}/NEA0006.md" + ); + + /// NEA0007: reports a collection-count-comparison-then-throw pattern that can be replaced by an ArgumentException throw-helper. + public static readonly DiagnosticDescriptor ThrowIfCount = new( + id: "NEA0007", + title: "Use ArgumentException collection-count throw helper", + messageFormat: "Use 'ArgumentException.{0}({1})' instead of the explicit collection count comparison and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A collection count comparison that throws ArgumentException can be replaced by the ArgumentException.ThrowIfCountGreaterThan/ThrowIfCountLessThan/ThrowIfCountOutOfRange throw-helper provided by NetEvolve.Arguments.", + helpLinkUri: $"{HelpLinkBase}/NEA0007.md" + ); + + /// NEA0008: reports a white-space-check-then-throw pattern that can be replaced by ArgumentException.ThrowIfContainsWhiteSpace. + public static readonly DiagnosticDescriptor ThrowIfContainsWhiteSpace = new( + id: "NEA0008", + title: "Use ArgumentException.ThrowIfContainsWhiteSpace", + messageFormat: "Use 'ArgumentException.ThrowIfContainsWhiteSpace({0})' instead of the explicit white-space check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A check for white-space characters that throws ArgumentException can be replaced by the ArgumentException.ThrowIfContainsWhiteSpace throw-helper provided by NetEvolve.Arguments.", + helpLinkUri: $"{HelpLinkBase}/NEA0008.md" + ); + + /// NEA0009: reports a Guid.Empty-check-then-throw pattern that can be replaced by ArgumentException.ThrowIfEmptyGuid. + public static readonly DiagnosticDescriptor ThrowIfEmptyGuid = new( + id: "NEA0009", + title: "Use ArgumentException.ThrowIfEmptyGuid", + messageFormat: "Use 'ArgumentException.ThrowIfEmptyGuid({0})' instead of the explicit Guid.Empty check and throw", + category: "Maintainability", + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "A Guid.Empty check that throws ArgumentException can be replaced by the ArgumentException.ThrowIfEmptyGuid throw-helper provided by NetEvolve.Arguments.", + helpLinkUri: $"{HelpLinkBase}/NEA0009.md" + ); +} diff --git a/src/NetEvolve.Arguments.Analyser/NetEvolve.Arguments.Analyser.csproj b/src/NetEvolve.Arguments.Analyser/NetEvolve.Arguments.Analyser.csproj new file mode 100644 index 0000000..aa4765f --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/NetEvolve.Arguments.Analyser.csproj @@ -0,0 +1,22 @@ + + + netstandard2.0 + true + true + false + $(NoWarn);RS1038 + Roslyn analyzers and code fixes that promote using NetEvolve.Arguments / BCL throw-helper APIs instead of manual argument-validation `if` blocks, on every target framework supported by NetEvolve.Arguments. + guard;clause;exceptions;argument-validation;analyzer;roslyn;code-fix;argument-exception;argumentnullexception + https://github.com/dailydevops/arguments + https://github.com/dailydevops/arguments.git + + + + + + + + + + + diff --git a/src/NetEvolve.Arguments.Analyser/README.md b/src/NetEvolve.Arguments.Analyser/README.md new file mode 100644 index 0000000..90550df --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/README.md @@ -0,0 +1,147 @@ +# NetEvolve.Arguments.Analyser + +[![NuGet Version](https://img.shields.io/nuget/v/NetEvolve.Arguments.Analyser.svg)](https://www.nuget.org/packages/NetEvolve.Arguments.Analyser/) +[![NuGet Downloads](https://img.shields.io/nuget/dt/NetEvolve.Arguments.Analyser.svg)](https://www.nuget.org/packages/NetEvolve.Arguments.Analyser/) +[![License](https://img.shields.io/github/license/dailydevops/arguments.svg)](https://github.com/dailydevops/arguments/blob/main/LICENSE) + +A Roslyn analyzer and code-fix package that promotes usage of [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) throw-helper APIs, rewriting manual argument-validation `if` blocks on every target framework — including .NET Standard 2.0 and .NET Framework 4.7.2+, which predate the built-in `CA1510`/`CA1511`/`CA1512`/`CA1513` analyzers. + +## Features + +- **9 analyzer rules (NEA0001-NEA0009)** - Covers null checks, string/collection validation, numeric ranges, `Guid`, and disposed-object checks +- **Works everywhere** - Unlike the built-in Roslyn rules, these analyzers fire regardless of target framework, since [NetEvolve.Arguments](https://www.nuget.org/packages/NetEvolve.Arguments/) polyfills the throw-helper APIs on frameworks that predate them +- **No duplicate diagnostics** - Rules that mirror a built-in CA rule (NEA0001, NEA0002, NEA0003, NEA0005) automatically stay silent once the compilation already exposes the real BCL member, so CA1510/CA1511/CA1512/CA1513 take over seamlessly +- **One-click code fixes** - Every diagnostic ships with a code fix that rewrites the offending `if` block in place, usable per-occurrence or via Fix All in Document/Project/Solution scope +- **Recognizes common variants** - Null checks match `is null`, `==`, `ReferenceEquals`, negated forms, and null-coalescing throws (`arg ?? throw ...`) + +## Installation + +### NuGet Package Manager + +```powershell +Install-Package NetEvolve.Arguments.Analyser +``` + +### .NET CLI + +```bash +dotnet add package NetEvolve.Arguments.Analyser +``` + +### PackageReference + +```xml + +``` + +## Quick Start + +```csharp +using System; + +public class UserService +{ + public void CreateUser(string username, int age) + { + // NEA0001 suggests replacing this with ArgumentNullException.ThrowIfNull(username) + if (username is null) + { + throw new ArgumentNullException(nameof(username)); + } + + // NEA0003 suggests replacing this with ArgumentOutOfRangeException.ThrowIfNegative(age) + if (age < 0) + { + throw new ArgumentOutOfRangeException(nameof(age)); + } + } +} +``` + +## Rules + +Each rule has a dedicated documentation page with the full list of recognized shapes, examples, and suppression instructions. + +| Rule | Title | Mirrors | +| --------------------------------------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------------- | +| [NEA0001](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0001.md) | Use ArgumentNullException.ThrowIfNull | [CA1510](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1510) | +| [NEA0002](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0002.md) | Use ArgumentException throw helper | [CA1511](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1511) | +| [NEA0003](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0003.md) | Use ArgumentOutOfRangeException throw helper | [CA1512](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1512) | +| [NEA0004](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0004.md) | Use ArgumentException.ThrowIfDefault | _(NetEvolve.Arguments-only, no CA equivalent)_ | +| [NEA0005](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0005.md) | Use ObjectDisposedException.ThrowIf | [CA1513](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca1513) | +| [NEA0006](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0006.md) | Use ArgumentException string-length throw helper | _(NetEvolve.Arguments-only, no CA equivalent)_ | +| [NEA0007](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0007.md) | Use ArgumentException collection-count throw helper | _(NetEvolve.Arguments-only, no CA equivalent)_ | +| [NEA0008](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0008.md) | Use ArgumentException.ThrowIfContainsWhiteSpace | _(NetEvolve.Arguments-only, no CA equivalent)_ | +| [NEA0009](https://github.com/dailydevops/arguments/blob/main/docs/analysers/NEA0009.md) | Use ArgumentException.ThrowIfEmptyGuid | _(NetEvolve.Arguments-only, no CA equivalent)_ | + +### Example: NEA0001 (null check) + +```csharp +// Before +if (argument is null) throw new ArgumentNullException(nameof(argument)); + +// After +ArgumentNullException.ThrowIfNull(argument); +``` + +### Example: NEA0003 (range check) + +```csharp +// Before +if (age < 0) throw new ArgumentOutOfRangeException(nameof(age)); + +// After +ArgumentOutOfRangeException.ThrowIfNegative(age); +``` + +### Example: NEA0007 (collection count) + +```csharp +// Before +if (items.Count > 100) throw new ArgumentException(nameof(items)); + +// After +ArgumentException.ThrowIfCountGreaterThan(items, 100); +``` + +## Not covered + +A few NetEvolve.Arguments throw-helpers don't have a dedicated rule, because their manual equivalents can be written in too many syntactically different ways to detect reliably without false negatives or false positives: + +- `ArgumentException.ThrowIfNullOrEmpty` for collections (`IEnumerable`/`ICollection`/`IReadOnlyCollection`/`T[]`) +- `ArgumentException.ThrowIfContainsDuplicates` +- `ArgumentOutOfRangeException.ThrowIfInPast`/`ThrowIfInFuture` + +See the [open issues](https://github.com/dailydevops/arguments/issues) for tracking status. + +## Requirements + +- .NET Standard 2.0 or higher for the analyzed project +- Visual Studio 2022 17.8+, Rider, or any IDE/CLI supporting Roslyn source analyzers + +## Related Packages + +- [**NetEvolve.Arguments**](https://www.nuget.org/packages/NetEvolve.Arguments/) - The polyfill library this analyzer promotes usage of + +## Documentation + +For complete solution documentation, architecture decisions, and contributing guidelines, visit the [Arguments Repository](https://github.com/dailydevops/arguments). + +## Contributing + +Contributions are welcome! Please read the [Contributing Guidelines](https://github.com/dailydevops/arguments/blob/main/CONTRIBUTING.md) before submitting a pull request. + +## Support + +- **Issues**: Report bugs or request features on [GitHub Issues](https://github.com/dailydevops/arguments/issues) +- **Documentation**: Read the full documentation at [https://github.com/dailydevops/arguments](https://github.com/dailydevops/arguments) + +## License + +This project is licensed under the MIT License - see the [LICENSE](https://github.com/dailydevops/arguments/blob/main/LICENSE) file for details. + +--- + +> [!NOTE] +> **Made with ❤️ by the NetEvolve Team** +> Visit us at [https://www.daily-devops.net](https://www.daily-devops.net) for more information about our services and solutions. diff --git a/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs new file mode 100644 index 0000000..638e389 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/SyntaxHelpers.cs @@ -0,0 +1,400 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Globalization; +using System.Linq; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +/// Shared syntax- and semantic-model helpers used by every analyzer and code fix in this package. +internal static class SyntaxHelpers +{ + /// + /// Determines whether the compilation's BCL already exposes the given static throw-helper member. + /// Used to stay silent where the built-in CA1510/CA1511/CA1512 analyzers already apply, avoiding duplicate diagnostics. + /// + /// The compilation to look up the member in. + /// The fully-qualified metadata name of the declaring type, e.g. System.ArgumentNullException. + /// The name of the static member to look for, e.g. ThrowIfNull. + /// if the type exists in the compilation and declares a static member with that name; otherwise, . + public static bool HasBuiltInMember(Compilation compilation, string typeMetadataName, string memberName) + { + var type = compilation.GetTypeByMetadataName(typeMetadataName); + + return type is not null && type.GetMembers(memberName).Any(member => member.IsStatic); + } + + /// Strips any enclosing parentheses from an expression. + /// The expression to unwrap. + /// The innermost expression once every enclosing has been removed. + public static ExpressionSyntax Unwrap(ExpressionSyntax expression) + { + while (expression is ParenthesizedExpressionSyntax parenthesized) + { + expression = parenthesized.Expression; + } + + return expression; + } + + /// Gets the single throw statement a statement consists of, whether written directly or as the sole statement of a block. + /// The statement to inspect, typically the body of an if statement. + /// The if is a throw statement, or a block containing exactly one; otherwise, . + public static ThrowStatementSyntax? GetSingleThrowStatement(StatementSyntax statement) + { + if (statement is ThrowStatementSyntax throwStatement) + { + return throwStatement; + } + + if ( + statement is BlockSyntax { Statements.Count: 1 } block + && block.Statements[0] is ThrowStatementSyntax single + ) + { + return single; + } + + return null; + } + + /// Determines whether an object-creation expression constructs exactly the given exception type. + /// The semantic model used to resolve the created type. + /// The new expression to inspect. + /// The fully-qualified metadata name of the expected exception type, e.g. System.ArgumentException. + /// The token used to cancel semantic-model lookups. + /// if constructs exactly the named type; otherwise, . + public static bool IsExceptionType( + SemanticModel semanticModel, + ObjectCreationExpressionSyntax objectCreation, + string fullyQualifiedMetadataName, + CancellationToken cancellationToken + ) + { + var typeInfo = semanticModel.GetTypeInfo(objectCreation, cancellationToken); + var exceptionType = semanticModel.Compilation.GetTypeByMetadataName(fullyQualifiedMetadataName); + + return exceptionType is not null && SymbolEqualityComparer.Default.Equals(typeInfo.Type, exceptionType); + } + + /// + /// Recognizes the common shape every rule in this package requires of the if statement's body: no else + /// clause, a single throw statement, and an object-creation expression of exactly the given exception type. + /// + /// The if statement to inspect. + /// The semantic model used to resolve the thrown exception's type. + /// The fully-qualified metadata name of the expected exception type. + /// The token used to cancel semantic-model lookups. + /// When this method returns , the matched new expression; otherwise, . + /// if the if statement matches the shape; otherwise, . + public static bool TryGetThrownException( + IfStatementSyntax ifStatement, + SemanticModel semanticModel, + string exceptionMetadataName, + CancellationToken cancellationToken, + out ObjectCreationExpressionSyntax? objectCreation + ) + { + objectCreation = null; + + if (ifStatement.Else is not null) + { + return false; + } + + var throwStatement = GetSingleThrowStatement(ifStatement.Statement); + + if (throwStatement?.Expression is not ObjectCreationExpressionSyntax creation) + { + return false; + } + + if (!IsExceptionType(semanticModel, creation, exceptionMetadataName, cancellationToken)) + { + return false; + } + + objectCreation = creation; + return true; + } + + /// + /// Recognizes an if condition that is true precisely when an expression is — covering + /// is null/is not null, ==/!=, ReferenceEquals, and any number of enclosing ! negations. + /// + /// The if statement's condition expression. + /// When this method returns , the expression being null-checked; otherwise, . + /// if is a recognized "argument is null" shape; otherwise, . + public static bool TryGetNullCheckedExpression(ExpressionSyntax condition, out ExpressionSyntax? argument) + { + var negated = false; + condition = Unwrap(condition); + + while (condition is PrefixUnaryExpressionSyntax { RawKind: (int)SyntaxKind.LogicalNotExpression } not) + { + negated = !negated; + condition = Unwrap(not.Operand); + } + + if (!TryGetNullCheckShape(condition, out argument, out var shapeMeansNull)) + { + argument = null; + return false; + } + + if (shapeMeansNull == negated) + { + // "is null" negated, or "is not null" not negated -> the condition is true when non-null. + argument = null; + return false; + } + + return true; + } + + /// + /// Matches a single (non-negated) null-check shape and reports both the checked expression and whether the shape + /// itself means "is null" (as opposed to "is not null") before any surrounding ! negation is applied. + /// + /// The already-unwrapped, not-yet-negation-adjusted condition expression. + /// When this method returns , the expression being checked; otherwise, . + /// When this method returns , if the shape means "is null", or if it means "is not null". + /// if matches a recognized null-check shape; otherwise, . + private static bool TryGetNullCheckShape( + ExpressionSyntax condition, + out ExpressionSyntax? argument, + out bool meansNull + ) + { + switch (condition) + { + case IsPatternExpressionSyntax + { + Pattern: ConstantPatternSyntax { Expression: LiteralExpressionSyntax literal }, + } isPattern when literal.IsKind(SyntaxKind.NullLiteralExpression): + argument = isPattern.Expression; + meansNull = true; + return true; + + case IsPatternExpressionSyntax + { + Pattern: UnaryPatternSyntax + { + RawKind: (int)SyntaxKind.NotPattern, + Pattern: ConstantPatternSyntax { Expression: LiteralExpressionSyntax notLiteral }, + }, + } isNotPattern when notLiteral.IsKind(SyntaxKind.NullLiteralExpression): + argument = isNotPattern.Expression; + meansNull = false; + return true; + + case BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.EqualsExpression): + if (IsNullLiteral(binary.Left)) + { + argument = binary.Right; + meansNull = true; + return true; + } + + if (IsNullLiteral(binary.Right)) + { + argument = binary.Left; + meansNull = true; + return true; + } + + break; + + case BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.NotEqualsExpression): + if (IsNullLiteral(binary.Left)) + { + argument = binary.Right; + meansNull = false; + return true; + } + + if (IsNullLiteral(binary.Right)) + { + argument = binary.Left; + meansNull = false; + return true; + } + + break; + + case InvocationExpressionSyntax { ArgumentList.Arguments.Count: 2 } referenceEqualsInvocation + when IsReferenceEqualsName(referenceEqualsInvocation.Expression): + var first = referenceEqualsInvocation.ArgumentList.Arguments[0].Expression; + var second = referenceEqualsInvocation.ArgumentList.Arguments[1].Expression; + + if (IsNullLiteral(first)) + { + argument = second; + meansNull = true; + return true; + } + + if (IsNullLiteral(second)) + { + argument = first; + meansNull = true; + return true; + } + + break; + } + + argument = null; + meansNull = false; + return false; + } + + /// Recognizes a null-coalescing throw expression of the shape arg ?? throw new ArgumentNullException(nameof(arg)). + /// The semantic model used to resolve the thrown exception's type. + /// The coalesce (??) expression to inspect. + /// The token used to cancel semantic-model lookups. + /// When this method returns , the left-hand side of the coalesce expression; otherwise, . + /// if is a recognized coalescing null-check-and-throw shape; otherwise, . + public static bool TryGetCoalesceNullCheck( + SemanticModel semanticModel, + BinaryExpressionSyntax binary, + CancellationToken cancellationToken, + out ExpressionSyntax? argument + ) + { + argument = null; + + if (!binary.IsKind(SyntaxKind.CoalesceExpression)) + { + return false; + } + + if ( + Unwrap(binary.Right) + is not ThrowExpressionSyntax { Expression: ObjectCreationExpressionSyntax objectCreation } + ) + { + return false; + } + + if (!IsExceptionType(semanticModel, objectCreation, "System.ArgumentNullException", cancellationToken)) + { + return false; + } + + if (objectCreation.ArgumentList is null || !IsSingleParamNameArgument(binary.Left, objectCreation.ArgumentList)) + { + return false; + } + + argument = binary.Left; + return true; + } + + /// Determines whether an expression is the literal, ignoring enclosing parentheses. + /// The expression to test. + /// if is the literal; otherwise, . + public static bool IsNullLiteral(ExpressionSyntax expression) => + Unwrap(expression).IsKind(SyntaxKind.NullLiteralExpression); + + /// Determines whether an expression refers to a member or identifier named ReferenceEquals, regardless of qualification. + /// The invocation target expression to test. + /// if is ReferenceEquals or *.ReferenceEquals; otherwise, . + private static bool IsReferenceEqualsName(ExpressionSyntax expression) => + Unwrap(expression) switch + { + IdentifierNameSyntax { Identifier.Text: "ReferenceEquals" } => true, + MemberAccessExpressionSyntax { Name.Identifier.Text: "ReferenceEquals" } => true, + _ => false, + }; + + /// Determines whether an expression is a numeric literal whose value is zero. + /// The expression to test. + /// if is a numeric literal equal to zero; otherwise, . + public static bool IsZeroLiteral(ExpressionSyntax expression) + { + if ( + Unwrap(expression) is not LiteralExpressionSyntax literal + || !literal.IsKind(SyntaxKind.NumericLiteralExpression) + ) + { + return false; + } + + var text = literal.Token.ValueText; + return double.TryParse(text, NumberStyles.Any, CultureInfo.InvariantCulture, out var value) && value == 0; + } + + /// Determines whether an expression is the default literal or an explicitly-typed default(T) expression. + /// The expression to test. + /// if is default or default(T); otherwise, . + public static bool IsDefaultLiteral(ExpressionSyntax expression) => + Unwrap(expression) switch + { + LiteralExpressionSyntax literal => literal.IsKind(SyntaxKind.DefaultLiteralExpression), + DefaultExpressionSyntax => true, + _ => false, + }; + + /// + /// Determines whether an exception constructor's argument list is either empty, or a single argument that names + /// — via nameof(argumentTarget) or a matching string literal. Constructor + /// calls with any other argument shape (e.g. a custom message) are rejected, since the throw-helper methods this + /// analyzer package targets don't support one. + /// + /// The expression being validated, whose name the constructor argument must match. + /// The exception constructor's argument list. + /// if the argument list is empty or names ; otherwise, . + public static bool IsSingleParamNameArgument(ExpressionSyntax argumentTarget, ArgumentListSyntax argumentList) + { + if (argumentList.Arguments.Count == 0) + { + return true; + } + + if (argumentList.Arguments.Count != 1) + { + return false; + } + + var argumentExpression = Unwrap(argumentList.Arguments[0].Expression); + + if ( + argumentExpression + is InvocationExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "nameof" } + } nameofInvocation + && nameofInvocation.ArgumentList.Arguments.Count == 1 + ) + { + var nameofTarget = Unwrap(nameofInvocation.ArgumentList.Arguments[0].Expression); + return AreSameReference(nameofTarget, argumentTarget); + } + + if ( + argumentExpression is LiteralExpressionSyntax { Token.Value: string literalText } + && argumentTarget is IdentifierNameSyntax identifierName + ) + { + return string.Equals(literalText, identifierName.Identifier.Text, StringComparison.Ordinal); + } + + return false; + } + + /// Determines whether two expressions are textually equivalent, used to check that a nameof(...) target matches the checked argument. + /// The first expression. + /// The second expression. + /// if both expressions render to the same source text; otherwise, . + private static bool AreSameReference(ExpressionSyntax left, ExpressionSyntax right) => AreEquivalent(left, right); + + /// Determines whether two expressions are textually equivalent, used to check that both sides of a combined-range condition target the same value. + /// The first expression. + /// The second expression. + /// if both expressions render to the same source text; otherwise, . + public static bool AreEquivalent(ExpressionSyntax left, ExpressionSyntax right) => + left.ToString() == right.ToString(); +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs new file mode 100644 index 0000000..754227c --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceAnalyzer.cs @@ -0,0 +1,132 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports white-space-check-then-throw patterns that can be replaced by ArgumentException.ThrowIfContainsWhiteSpace. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfContainsWhiteSpaceAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfContainsWhiteSpace); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0008 when it is a white-space-check-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetContainsWhiteSpaceTarget(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out _ + ) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfContainsWhiteSpace, + ifStatement.GetLocation(), + argument.ToString() + ) + ); + } + + /// Recognizes arg.Any(c => char.IsWhiteSpace(c)) and the method-group form arg.Any(char.IsWhiteSpace). + /// The if statement's condition expression. + /// When this method returns , the string argument being checked; otherwise, . + /// if is a recognized white-space-check shape; otherwise, . + internal static bool TryGetContainsWhiteSpaceTarget(ExpressionSyntax condition, out ExpressionSyntax? argument) + { + argument = null; + condition = SyntaxHelpers.Unwrap(condition); + + if ( + condition + is not InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "Any" } access, + ArgumentList.Arguments.Count: 1, + } invocation + ) + { + return false; + } + + var predicate = SyntaxHelpers.Unwrap(invocation.ArgumentList.Arguments[0].Expression); + + if (IsCharIsWhiteSpaceMemberAccess(predicate)) + { + argument = access.Expression; + return true; + } + + if ( + predicate is SimpleLambdaExpressionSyntax { ExpressionBody: { } body } lambda + && SyntaxHelpers.Unwrap(body) + is InvocationExpressionSyntax { Expression: var callee, ArgumentList.Arguments.Count: 1 } call + && IsCharIsWhiteSpaceMemberAccess(callee) + && SyntaxHelpers.Unwrap(call.ArgumentList.Arguments[0].Expression) is IdentifierNameSyntax paramRef + && paramRef.Identifier.Text == lambda.Parameter.Identifier.Text + ) + { + argument = access.Expression; + return true; + } + + return false; + } + + /// Determines whether an expression is a char.IsWhiteSpace member access, either via the char keyword or the Char identifier. + /// The expression to test. + /// if is char.IsWhiteSpace or Char.IsWhiteSpace; otherwise, . + private static bool IsCharIsWhiteSpaceMemberAccess(ExpressionSyntax expression) + { + if ( + SyntaxHelpers.Unwrap(expression) + is not MemberAccessExpressionSyntax { Expression: var typeReference, Name.Identifier.Text: "IsWhiteSpace" } + ) + { + return false; + } + + return SyntaxHelpers.Unwrap(typeReference) switch + { + PredefinedTypeSyntax predefinedType => predefinedType.Keyword.IsKind(SyntaxKind.CharKeyword), + IdentifierNameSyntax { Identifier.Text: "Char" } => true, + _ => false, + }; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs new file mode 100644 index 0000000..20221f0 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfContainsWhiteSpaceCodeFixProvider.cs @@ -0,0 +1,102 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a white-space-check-then-throw pattern with a call to ArgumentException.ThrowIfContainsWhiteSpace. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfContainsWhiteSpaceCodeFixProvider))] +[Shared] +public sealed class ThrowIfContainsWhiteSpaceCodeFixProvider : CodeFixProvider +{ + /// The display title shown for this fix in the lightbulb/quick-actions menu. + private const string Title = "Use ArgumentException.ThrowIfContainsWhiteSpace"; + + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfContainsWhiteSpace.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if (ifStatement is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single ArgumentException.ThrowIfContainsWhiteSpace call. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfContainsWhiteSpaceAnalyzer.TryGetContainsWhiteSpaceTarget( + ifStatement.Condition, + out var argument + ) + || argument is null + ) + { + return document; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName("ThrowIfContainsWhiteSpace") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs new file mode 100644 index 0000000..df5445b --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountAnalyzer.cs @@ -0,0 +1,145 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports collection-count-comparison-then-throw patterns that can be replaced by an ArgumentException throw-helper. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfCountAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfCount); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0007 when it is a collection-count-comparison-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetCountComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out _ + ) + ) + { + return; + } + + var value = comparison.Value; + var args = value.OtherExpression2 is null + ? $"{value.ValueExpression}, {value.OtherExpression}" + : $"{value.ValueExpression}, {value.OtherExpression}, {value.OtherExpression2}"; + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfCount, ifStatement.GetLocation(), value.HelperName, args) + ); + } + + /// Recognizes arg.Count > max, arg.Count < min, and the combined range arg.Count < min || arg.Count > max (both the .Count property and the .Count() LINQ extension method). + /// The if statement's condition expression. + /// When this method returns , the recognized comparison; otherwise, . + /// if is a recognized collection-count comparison shape; otherwise, . + internal static bool TryGetCountComparison(ExpressionSyntax condition, out ComparisonResult? comparison) + { + condition = SyntaxHelpers.Unwrap(condition); + comparison = null; + + if (condition is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LogicalOrExpression } orExpression) + { + if ( + SyntaxHelpers.Unwrap(orExpression.Left) + is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LessThanExpression } lessThan + && SyntaxHelpers.Unwrap(orExpression.Right) + is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.GreaterThanExpression } greaterThan + && TryGetCountTarget(lessThan.Left, out var target1) + && TryGetCountTarget(greaterThan.Left, out var target2) + && SyntaxHelpers.AreEquivalent(target1!, target2!) + ) + { + comparison = new ComparisonResult( + "ThrowIfCountOutOfRange", + target1!, + lessThan.Right, + greaterThan.Right + ); + return true; + } + + return false; + } + + if (condition is not BinaryExpressionSyntax binary || !TryGetCountTarget(binary.Left, out var target)) + { + return false; + } + + comparison = binary.Kind() switch + { + SyntaxKind.GreaterThanExpression => new ComparisonResult("ThrowIfCountGreaterThan", target!, binary.Right), + SyntaxKind.LessThanExpression => new ComparisonResult("ThrowIfCountLessThan", target!, binary.Right), + _ => null, + }; + + return comparison is not null; + } + + /// Recognizes a .Count property access or a parameterless .Count() LINQ extension method call, and reports its qualifying expression. + /// The expression to test, typically one side of a comparison. + /// When this method returns , the expression the count was taken of; otherwise, . + /// if is a recognized count-access shape; otherwise, . + private static bool TryGetCountTarget(ExpressionSyntax expression, out ExpressionSyntax? target) + { + var unwrapped = SyntaxHelpers.Unwrap(expression); + + if (unwrapped is MemberAccessExpressionSyntax { Name.Identifier.Text: "Count" } access) + { + target = access.Expression; + return true; + } + + if ( + unwrapped is InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "Count" } countAccess, + ArgumentList.Arguments.Count: 0, + } + ) + { + target = countAccess.Expression; + return true; + } + + target = null; + return false; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs new file mode 100644 index 0000000..9edab6e --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfCountCodeFixProvider.cs @@ -0,0 +1,110 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a collection-count-comparison-then-throw pattern with the matching ArgumentException throw-helper call. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfCountCodeFixProvider))] +[Shared] +public sealed class ThrowIfCountCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfCount.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if ( + ifStatement is null + || !ThrowIfCountAnalyzer.TryGetCountComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return; + } + + var title = $"Use ArgumentException.{comparison.Value.HelperName}"; + + context.RegisterCodeFix( + CodeAction.Create( + title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single call to the given collection-count throw-helper. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfCountAnalyzer.TryGetCountComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return document; + } + + var value = comparison.Value; + var arguments = value.OtherExpression2 is not null + ? new[] { value.ValueExpression, value.OtherExpression!, value.OtherExpression2 } + : new[] { value.ValueExpression, value.OtherExpression! }; + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName(value.HelperName) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + arguments.Select(argument => SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs new file mode 100644 index 0000000..268963e --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultAnalyzer.cs @@ -0,0 +1,105 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports default-value-check-then-throw patterns that can be replaced by ArgumentException.ThrowIfDefault. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfDefaultAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfDefault); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0004 when it is a default-value-check-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetDefaultCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null + ) + { + return; + } + + if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfDefault, ifStatement.GetLocation(), argument.ToString()) + ); + } + + /// Recognizes arg.Equals(default)/arg.Equals(default(T)) and arg == default/default == arg (and the default(T) variants). + /// The if statement's condition expression. + /// When this method returns , the expression being checked; otherwise, . + /// if is a recognized default-value-check shape; otherwise, . + internal static bool TryGetDefaultCheckedExpression(ExpressionSyntax condition, out ExpressionSyntax? argument) + { + condition = SyntaxHelpers.Unwrap(condition); + argument = null; + + switch (condition) + { + case InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "Equals" } access, + ArgumentList.Arguments.Count: 1, + } invocation when SyntaxHelpers.IsDefaultLiteral(invocation.ArgumentList.Arguments[0].Expression): + argument = access.Expression; + return true; + + case BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.EqualsExpression): + if (SyntaxHelpers.IsDefaultLiteral(binary.Right)) + { + argument = binary.Left; + return true; + } + + if (SyntaxHelpers.IsDefaultLiteral(binary.Left)) + { + argument = binary.Right; + return true; + } + + break; + } + + return false; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs new file mode 100644 index 0000000..03b67a7 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDefaultCodeFixProvider.cs @@ -0,0 +1,99 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a default-value-check-then-throw pattern with a call to ArgumentException.ThrowIfDefault. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfDefaultCodeFixProvider))] +[Shared] +public sealed class ThrowIfDefaultCodeFixProvider : CodeFixProvider +{ + /// The display title shown for this fix in the lightbulb/quick-actions menu. + private const string Title = "Use ArgumentException.ThrowIfDefault"; + + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfDefault.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if (ifStatement is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single call to ArgumentException.ThrowIfDefault. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfDefaultAnalyzer.TryGetDefaultCheckedExpression(ifStatement.Condition, out var argument) + || argument is null + ) + { + return document; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName("ThrowIfDefault") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs new file mode 100644 index 0000000..22dbdc7 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedAnalyzer.cs @@ -0,0 +1,88 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports disposed-check-then-throw patterns that can be replaced by ObjectDisposedException.ThrowIf. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfDisposedAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ObjectDisposedExceptionMetadataName = "System.ObjectDisposedException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfDisposed); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + /// Registers the syntax-node action for this rule, unless the compilation's BCL already exposes ObjectDisposedException.ThrowIf. + /// The compilation-start context supplied by the Roslyn analyzer driver. + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + // ObjectDisposedException.ThrowIf exists on the BCL since .NET 7; where it does, the + // built-in CA1513 analyzer already covers this pattern, so stay silent to avoid duplicates. + if (SyntaxHelpers.HasBuiltInMember(context.Compilation, ObjectDisposedExceptionMetadataName, "ThrowIf")) + { + return; + } + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// + /// Analyzes an if statement and reports NEA0005 when it is a disposed-check-then-throw of + /// inside an instance member (the fix requires this). + /// + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + var enclosingSymbol = context.SemanticModel.GetEnclosingSymbol( + ifStatement.SpanStart, + context.CancellationToken + ); + + if (enclosingSymbol is null || enclosingSymbol.IsStatic) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ObjectDisposedExceptionMetadataName, + context.CancellationToken, + out _ + ) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfDisposed, + ifStatement.GetLocation(), + ifStatement.Condition.ToString() + ) + ); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs new file mode 100644 index 0000000..b000f68 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfDisposedCodeFixProvider.cs @@ -0,0 +1,101 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a disposed-check-then-throw pattern with a call to ObjectDisposedException.ThrowIf. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfDisposedCodeFixProvider))] +[Shared] +public sealed class ThrowIfDisposedCodeFixProvider : CodeFixProvider +{ + /// The display title shown for this fix in the lightbulb/quick-actions menu. + private const string Title = "Use ObjectDisposedException.ThrowIf"; + + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfDisposed.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if (ifStatement is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single ObjectDisposedException.ThrowIf(condition, this); call. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the syntax tree can no longer be retrieved. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if (root is null) + { + return document; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ObjectDisposedException"), + SyntaxFactory.IdentifierName("ThrowIf") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + new[] + { + SyntaxFactory.Argument(ifStatement.Condition.WithoutTrivia()), + SyntaxFactory.Argument(SyntaxFactory.ThisExpression()), + } + ) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs new file mode 100644 index 0000000..67d30c4 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidAnalyzer.cs @@ -0,0 +1,116 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports Guid.Empty-check-then-throw patterns that can be replaced by ArgumentException.ThrowIfEmptyGuid. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfEmptyGuidAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfEmptyGuid); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0009 when it is a Guid.Empty-check-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetEmptyGuidCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null + ) + { + return; + } + + if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfEmptyGuid, ifStatement.GetLocation(), argument.ToString()) + ); + } + + /// Recognizes arg.Equals(Guid.Empty) and arg == Guid.Empty/Guid.Empty == arg. + /// The if statement's condition expression. + /// When this method returns , the expression being checked; otherwise, . + /// if is a recognized Guid.Empty-check shape; otherwise, . + internal static bool TryGetEmptyGuidCheckedExpression(ExpressionSyntax condition, out ExpressionSyntax? argument) + { + condition = SyntaxHelpers.Unwrap(condition); + argument = null; + + switch (condition) + { + case InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax { Name.Identifier.Text: "Equals" } access, + ArgumentList.Arguments.Count: 1, + } invocation when IsGuidEmpty(invocation.ArgumentList.Arguments[0].Expression): + argument = access.Expression; + return true; + + case BinaryExpressionSyntax binary when binary.IsKind(SyntaxKind.EqualsExpression): + if (IsGuidEmpty(binary.Right)) + { + argument = binary.Left; + return true; + } + + if (IsGuidEmpty(binary.Left)) + { + argument = binary.Right; + return true; + } + + break; + } + + return false; + } + + /// Determines whether an expression is a Guid.Empty member access. + /// The expression to test. + /// if is Guid.Empty; otherwise, . + private static bool IsGuidEmpty(ExpressionSyntax expression) => + SyntaxHelpers.Unwrap(expression) + is MemberAccessExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "Guid" }, + Name.Identifier.Text: "Empty", + }; +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs new file mode 100644 index 0000000..34d9edb --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfEmptyGuidCodeFixProvider.cs @@ -0,0 +1,99 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a Guid.Empty-check-then-throw pattern with a call to ArgumentException.ThrowIfEmptyGuid. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfEmptyGuidCodeFixProvider))] +[Shared] +public sealed class ThrowIfEmptyGuidCodeFixProvider : CodeFixProvider +{ + /// The display title shown for this fix in the lightbulb/quick-actions menu. + private const string Title = "Use ArgumentException.ThrowIfEmptyGuid"; + + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfEmptyGuid.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if (ifStatement is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single ArgumentException.ThrowIfEmptyGuid call. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfEmptyGuidAnalyzer.TryGetEmptyGuidCheckedExpression(ifStatement.Condition, out var argument) + || argument is null + ) + { + return document; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName("ThrowIfEmptyGuid") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs new file mode 100644 index 0000000..deb9ee3 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthAnalyzer.cs @@ -0,0 +1,131 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports string-length-comparison-then-throw patterns that can be replaced by an ArgumentException throw-helper. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfLengthAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfLength); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0006 when it is a string-length-comparison-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetLengthComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out _ + ) + ) + { + return; + } + + var value = comparison.Value; + var args = value.OtherExpression2 is null + ? $"{value.ValueExpression}, {value.OtherExpression}" + : $"{value.ValueExpression}, {value.OtherExpression}, {value.OtherExpression2}"; + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfLength, ifStatement.GetLocation(), value.HelperName, args) + ); + } + + /// Recognizes arg.Length > max, arg.Length < min, and the combined range arg.Length < min || arg.Length > max. + /// The if statement's condition expression. + /// When this method returns , the recognized comparison; otherwise, . + /// if is a recognized string-length comparison shape; otherwise, . + internal static bool TryGetLengthComparison(ExpressionSyntax condition, out ComparisonResult? comparison) + { + condition = SyntaxHelpers.Unwrap(condition); + comparison = null; + + if (condition is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LogicalOrExpression } orExpression) + { + if ( + SyntaxHelpers.Unwrap(orExpression.Left) + is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LessThanExpression } lessThan + && SyntaxHelpers.Unwrap(orExpression.Right) + is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.GreaterThanExpression } greaterThan + && TryGetLengthTarget(lessThan.Left, out var target1) + && TryGetLengthTarget(greaterThan.Left, out var target2) + && SyntaxHelpers.AreEquivalent(target1!, target2!) + ) + { + comparison = new ComparisonResult( + "ThrowIfLengthOutOfRange", + target1!, + lessThan.Right, + greaterThan.Right + ); + return true; + } + + return false; + } + + if (condition is not BinaryExpressionSyntax binary || !TryGetLengthTarget(binary.Left, out var target)) + { + return false; + } + + comparison = binary.Kind() switch + { + SyntaxKind.GreaterThanExpression => new ComparisonResult("ThrowIfLengthGreaterThan", target!, binary.Right), + SyntaxKind.LessThanExpression => new ComparisonResult("ThrowIfLengthLessThan", target!, binary.Right), + _ => null, + }; + + return comparison is not null; + } + + /// Recognizes a .Length property access and reports its qualifying expression. + /// The expression to test, typically one side of a comparison. + /// When this method returns , the expression the .Length property was accessed on; otherwise, . + /// if is a .Length access; otherwise, . + private static bool TryGetLengthTarget(ExpressionSyntax expression, out ExpressionSyntax? target) + { + if (SyntaxHelpers.Unwrap(expression) is MemberAccessExpressionSyntax { Name.Identifier.Text: "Length" } access) + { + target = access.Expression; + return true; + } + + target = null; + return false; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs new file mode 100644 index 0000000..e6e6119 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfLengthCodeFixProvider.cs @@ -0,0 +1,110 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a string-length-comparison-then-throw pattern with the matching ArgumentException throw-helper call. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfLengthCodeFixProvider))] +[Shared] +public sealed class ThrowIfLengthCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfLength.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if ( + ifStatement is null + || !ThrowIfLengthAnalyzer.TryGetLengthComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return; + } + + var title = $"Use ArgumentException.{comparison.Value.HelperName}"; + + context.RegisterCodeFix( + CodeAction.Create( + title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single call to the given string-length throw-helper. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfLengthAnalyzer.TryGetLengthComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return document; + } + + var value = comparison.Value; + var arguments = value.OtherExpression2 is not null + ? new[] { value.ValueExpression, value.OtherExpression!, value.OtherExpression2 } + : new[] { value.ValueExpression, value.OtherExpression! }; + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName(value.HelperName) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + arguments.Select(argument => SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs new file mode 100644 index 0000000..154da8e --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullAnalyzer.cs @@ -0,0 +1,110 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports null-check-then-throw patterns that can be replaced by ArgumentNullException.ThrowIfNull. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfNullAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentNullExceptionMetadataName = "System.ArgumentNullException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfNull); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + /// Registers the syntax-node actions for this rule, unless the compilation's BCL already exposes ArgumentNullException.ThrowIfNull. + /// The compilation-start context supplied by the Roslyn analyzer driver. + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + // ArgumentNullException.ThrowIfNull exists on the BCL since .NET 6; where it does, the + // built-in CA1510 analyzer already covers this pattern, so stay silent to avoid duplicates. + if (SyntaxHelpers.HasBuiltInMember(context.Compilation, ArgumentNullExceptionMetadataName, "ThrowIfNull")) + { + return; + } + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + context.RegisterSyntaxNodeAction(AnalyzeCoalesce, SyntaxKind.CoalesceExpression); + } + + /// Analyzes a ?? expression and reports NEA0001 when it is a null-coalescing throw of . + /// The syntax-node analysis context for the coalesce expression being visited. + private static void AnalyzeCoalesce(SyntaxNodeAnalysisContext context) + { + var binary = (BinaryExpressionSyntax)context.Node; + + if ( + !SyntaxHelpers.TryGetCoalesceNullCheck( + context.SemanticModel, + binary, + context.CancellationToken, + out var argument + ) || argument is null + ) + { + return; + } + + if (binary.FirstAncestorOrSelf() is null) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfNull, binary.GetLocation(), argument.ToString()) + ); + } + + /// Analyzes an if statement and reports NEA0001 when it is a null-check-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!SyntaxHelpers.TryGetNullCheckedExpression(ifStatement.Condition, out var argument) || argument is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentNullExceptionMetadataName, + context.CancellationToken, + out var objectCreation + ) || objectCreation!.ArgumentList is null + ) + { + return; + } + + if (!SyntaxHelpers.IsSingleParamNameArgument(argument, objectCreation.ArgumentList)) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create(DiagnosticDescriptors.ThrowIfNull, ifStatement.GetLocation(), argument.ToString()) + ); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs new file mode 100644 index 0000000..8fe21ae --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullCodeFixProvider.cs @@ -0,0 +1,156 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Editing; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a null-check-then-throw pattern with a call to ArgumentNullException.ThrowIfNull. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfNullCodeFixProvider))] +[Shared] +public sealed class ThrowIfNullCodeFixProvider : CodeFixProvider +{ + /// The display title shown for this fix in the lightbulb/quick-actions menu. + private const string Title = "Use ArgumentNullException.ThrowIfNull"; + + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfNull.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan); + + if (node.FirstAncestorOrSelf() is { } ifStatement) + { + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyIfStatementFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + return; + } + + if (node is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.CoalesceExpression } coalesce) + { + context.RegisterCodeFix( + CodeAction.Create( + Title, + cancellationToken => ApplyCoalesceFixAsync(context.Document, coalesce, cancellationToken), + equivalenceKey: Title + ), + diagnostic + ); + } + } + + /// Rewrites an if (arg is null) throw ...; statement into a single ArgumentNullException.ThrowIfNull(arg); call. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyIfStatementFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !SyntaxHelpers.TryGetNullCheckedExpression(ifStatement.Condition, out var argument) + || argument is null + ) + { + return document; + } + + var throwIfNullInvocation = SyntaxFactory + .ExpressionStatement(CreateThrowIfNullInvocation(argument)) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, throwIfNullInvocation); + + return document.WithSyntaxRoot(newRoot); + } + + /// + /// Rewrites arg ?? throw new ArgumentNullException(nameof(arg)) by inserting an + /// ArgumentNullException.ThrowIfNull(arg); statement before the containing statement and replacing the + /// coalesce expression with the now-guaranteed-non-null argument. + /// + /// The document containing the diagnostic. + /// The coalesce (??) expression to rewrite. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyCoalesceFixAsync( + Document document, + BinaryExpressionSyntax coalesce, + CancellationToken cancellationToken + ) + { + var semanticModel = await document.GetSemanticModelAsync(cancellationToken).ConfigureAwait(false); + var containingStatement = coalesce.FirstAncestorOrSelf(); + + if ( + semanticModel is null + || containingStatement is null + || !SyntaxHelpers.TryGetCoalesceNullCheck(semanticModel, coalesce, cancellationToken, out var argument) + || argument is null + ) + { + return document; + } + + var editor = await DocumentEditor.CreateAsync(document, cancellationToken).ConfigureAwait(false); + + var throwIfNullStatement = SyntaxFactory + .ExpressionStatement(CreateThrowIfNullInvocation(argument)) + .WithLeadingTrivia(containingStatement.GetLeadingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + editor.InsertBefore(containingStatement, throwIfNullStatement); + editor.ReplaceNode(coalesce, argument.WithoutTrivia()); + + return editor.GetChangedDocument(); + } + + /// Builds the ArgumentNullException.ThrowIfNull(argument) invocation expression used by both fix paths. + /// The expression to pass as the throw-helper's argument. + /// The constructed invocation expression. + private static InvocationExpressionSyntax CreateThrowIfNullInvocation(ExpressionSyntax argument) => + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentNullException"), + SyntaxFactory.IdentifierName("ThrowIfNull") + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ); +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs new file mode 100644 index 0000000..e257718 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyAnalyzer.cs @@ -0,0 +1,142 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports string.IsNullOrEmpty/IsNullOrWhiteSpace-then-throw patterns that can be replaced by an ArgumentException throw-helper. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfNullOrEmptyAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentExceptionMetadataName = "System.ArgumentException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfNullOrEmpty); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + /// Registers the syntax-node action for this rule, unless the compilation's BCL already exposes ArgumentException.ThrowIfNullOrEmpty. + /// The compilation-start context supplied by the Roslyn analyzer driver. + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + // The BCL exposes these throw-helpers since .NET 8; where it does, the built-in + // CA1511 analyzer already covers this pattern, so stay silent to avoid duplicates. + if (SyntaxHelpers.HasBuiltInMember(context.Compilation, ArgumentExceptionMetadataName, "ThrowIfNullOrEmpty")) + { + return; + } + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0002 when it is a string.IsNullOrEmpty/IsNullOrWhiteSpace-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetStringCheck(ifStatement.Condition, out var argument, out var helperName) || argument is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentExceptionMetadataName, + context.CancellationToken, + out _ + ) + ) + { + return; + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfNullOrEmpty, + ifStatement.GetLocation(), + helperName, + argument.ToString() + ) + ); + } + + /// Recognizes string.IsNullOrEmpty(arg)/IsNullOrWhiteSpace(arg) and reports the matching throw-helper member name and the checked argument. + /// The if statement's condition expression. + /// When this method returns , the string argument being checked; otherwise, . + /// When this method returns , the matching throw-helper member name; otherwise, . + /// if is a recognized shape; otherwise, . + internal static bool TryGetStringCheck( + ExpressionSyntax condition, + out ExpressionSyntax? argument, + out string? helperName + ) + { + condition = SyntaxHelpers.Unwrap(condition); + argument = null; + helperName = null; + + if ( + condition + is not InvocationExpressionSyntax + { + Expression: MemberAccessExpressionSyntax + { + Expression: var typeReference, + Name.Identifier.Text: var methodName + }, + ArgumentList.Arguments.Count: 1, + } invocation + || !IsStringTypeReference(typeReference) + ) + { + return false; + } + + var target = invocation.ArgumentList.Arguments[0].Expression; + + switch (methodName) + { + case "IsNullOrEmpty": + helperName = "ThrowIfNullOrEmpty"; + break; + case "IsNullOrWhiteSpace": + helperName = "ThrowIfNullOrWhiteSpace"; + break; + default: + return false; + } + + argument = target; + return true; + } + + /// Determines whether an expression refers to the type, either via the string keyword or the String identifier. + /// The invocation target's qualifier expression to test. + /// if refers to ; otherwise, . + private static bool IsStringTypeReference(ExpressionSyntax expression) => + SyntaxHelpers.Unwrap(expression) switch + { + PredefinedTypeSyntax predefinedType => predefinedType.Keyword.IsKind(SyntaxKind.StringKeyword), + IdentifierNameSyntax { Identifier.Text: "String" } => true, + _ => false, + }; +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs new file mode 100644 index 0000000..cdb1432 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfNullOrEmptyCodeFixProvider.cs @@ -0,0 +1,104 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a string.IsNullOrEmpty/IsNullOrWhiteSpace-then-throw pattern with the matching ArgumentException throw-helper call. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfNullOrEmptyCodeFixProvider))] +[Shared] +public sealed class ThrowIfNullOrEmptyCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfNullOrEmpty.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if ( + ifStatement is null + || !ThrowIfNullOrEmptyAnalyzer.TryGetStringCheck(ifStatement.Condition, out _, out var helperName) + || helperName is null + ) + { + return; + } + + var title = $"Use ArgumentException.{helperName}"; + + context.RegisterCodeFix( + CodeAction.Create( + title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, helperName, cancellationToken), + equivalenceKey: title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single call to the given throw-helper. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The throw-helper member name to invoke, e.g. ThrowIfNullOrEmpty. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + string helperName, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfNullOrEmptyAnalyzer.TryGetStringCheck(ifStatement.Condition, out var argument, out _) + || argument is null + ) + { + return document; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentException"), + SyntaxFactory.IdentifierName(helperName) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList(SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs new file mode 100644 index 0000000..cf4e803 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeAnalyzer.cs @@ -0,0 +1,185 @@ +namespace NetEvolve.Arguments.Analyser; + +using System; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; + +/// Reports comparison-then-throw patterns that can be replaced by an ArgumentOutOfRangeException throw-helper. +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class ThrowIfOutOfRangeAnalyzer : DiagnosticAnalyzer +{ + /// The fully-qualified metadata name of . + private const string ArgumentOutOfRangeExceptionMetadataName = "System.ArgumentOutOfRangeException"; + + /// + public override ImmutableArray SupportedDiagnostics => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfOutOfRange); + + /// + public override void Initialize(AnalysisContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterCompilationStartAction(OnCompilationStart); + } + + /// Registers the syntax-node action for this rule, unless the compilation's BCL already exposes the throw-helpers. + /// The compilation-start context supplied by the Roslyn analyzer driver. + private static void OnCompilationStart(CompilationStartAnalysisContext context) + { + // The ArgumentOutOfRangeException throw-helpers exist on the BCL since .NET 8; where they do, + // the built-in CA1512 analyzer already covers this pattern, so stay silent to avoid duplicates. + if (SyntaxHelpers.HasBuiltInMember(context.Compilation, ArgumentOutOfRangeExceptionMetadataName, "ThrowIfZero")) + { + return; + } + + context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.IfStatement); + } + + /// Analyzes an if statement and reports NEA0003 when it is a comparison-then-throw of . + /// The syntax-node analysis context for the if statement being visited. + private static void Analyze(SyntaxNodeAnalysisContext context) + { + var ifStatement = (IfStatementSyntax)context.Node; + + if (!TryGetComparison(ifStatement.Condition, out var comparison) || comparison is null) + { + return; + } + + if ( + !SyntaxHelpers.TryGetThrownException( + ifStatement, + context.SemanticModel, + ArgumentOutOfRangeExceptionMetadataName, + context.CancellationToken, + out var objectCreation + ) + || objectCreation!.ArgumentList is null + || objectCreation.ArgumentList.Arguments.Count is 0 or > 3 + ) + { + return; + } + + var value = comparison.Value; + string args; + + if (value.OtherExpression2 is not null) + { + args = $"{value.ValueExpression}, {value.OtherExpression}, {value.OtherExpression2}"; + } + else if (value.OtherExpression is not null) + { + args = $"{value.ValueExpression}, {value.OtherExpression}"; + } + else + { + args = value.ValueExpression.ToString(); + } + + context.ReportDiagnostic( + Diagnostic.Create( + DiagnosticDescriptors.ThrowIfOutOfRange, + ifStatement.GetLocation(), + value.HelperName, + args + ) + ); + } + + /// + /// Recognizes a comparison against zero, another expression, or a combined range (value < min || value > max) + /// and maps it to the matching throw-helper member and arguments. + /// + /// The if statement's condition expression. + /// When this method returns , the recognized comparison; otherwise, . + /// if is a recognized comparison shape; otherwise, . + internal static bool TryGetComparison(ExpressionSyntax condition, out ComparisonResult? comparison) + { + condition = SyntaxHelpers.Unwrap(condition); + comparison = null; + + if (condition is BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LogicalOrExpression } orExpression) + { + return TryGetCombinedRangeComparison(orExpression, out comparison); + } + + if (condition is not BinaryExpressionSyntax binary) + { + return false; + } + + var value = binary.Left; + var other = binary.Right; + + if (SyntaxHelpers.Unwrap(value) is LiteralExpressionSyntax) + { + return false; + } + + var isZero = SyntaxHelpers.IsZeroLiteral(other); + + comparison = binary.Kind() switch + { + SyntaxKind.LessThanExpression when isZero => new ComparisonResult("ThrowIfNegative", value, null), + SyntaxKind.LessThanExpression => new ComparisonResult("ThrowIfLessThan", value, other), + SyntaxKind.LessThanOrEqualExpression when isZero => new ComparisonResult( + "ThrowIfNegativeOrZero", + value, + null + ), + SyntaxKind.LessThanOrEqualExpression => new ComparisonResult("ThrowIfLessThanOrEqual", value, other), + SyntaxKind.GreaterThanExpression => new ComparisonResult("ThrowIfGreaterThan", value, other), + SyntaxKind.GreaterThanOrEqualExpression => new ComparisonResult("ThrowIfGreaterThanOrEqual", value, other), + SyntaxKind.EqualsExpression when isZero => new ComparisonResult("ThrowIfZero", value, null), + SyntaxKind.EqualsExpression => new ComparisonResult("ThrowIfEqual", value, other), + SyntaxKind.NotEqualsExpression => new ComparisonResult("ThrowIfNotEqual", value, other), + _ => null, + }; + + return comparison is not null; + } + + /// Recognizes the combined-range shape value < min || value > max and maps it to ThrowIfOutOfRange. + /// The || expression to inspect. + /// When this method returns , the recognized comparison; otherwise, . + /// if is a recognized combined-range shape; otherwise, . + private static bool TryGetCombinedRangeComparison( + BinaryExpressionSyntax orExpression, + out ComparisonResult? comparison + ) + { + comparison = null; + + if ( + SyntaxHelpers.Unwrap(orExpression.Left) + is not BinaryExpressionSyntax { RawKind: (int)SyntaxKind.LessThanExpression } lessThan + || SyntaxHelpers.Unwrap(orExpression.Right) + is not BinaryExpressionSyntax { RawKind: (int)SyntaxKind.GreaterThanExpression } greaterThan + ) + { + return false; + } + + var lessThanValue = SyntaxHelpers.Unwrap(lessThan.Left); + var greaterThanValue = SyntaxHelpers.Unwrap(greaterThan.Left); + + if (lessThanValue is LiteralExpressionSyntax || !SyntaxHelpers.AreEquivalent(lessThanValue, greaterThanValue)) + { + return false; + } + + comparison = new ComparisonResult("ThrowIfOutOfRange", lessThanValue, lessThan.Right, greaterThan.Right); + return true; + } +} diff --git a/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs new file mode 100644 index 0000000..7a376f9 --- /dev/null +++ b/src/NetEvolve.Arguments.Analyser/ThrowIfOutOfRangeCodeFixProvider.cs @@ -0,0 +1,121 @@ +namespace NetEvolve.Arguments.Analyser; + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +/// Replaces a comparison-then-throw pattern with the matching ArgumentOutOfRangeException throw-helper call. +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(ThrowIfOutOfRangeCodeFixProvider))] +[Shared] +public sealed class ThrowIfOutOfRangeCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + ImmutableArray.Create(DiagnosticDescriptors.ThrowIfOutOfRange.Id); + + /// + public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var ifStatement = root.FindNode(diagnostic.Location.SourceSpan).FirstAncestorOrSelf(); + + if ( + ifStatement is null + || !ThrowIfOutOfRangeAnalyzer.TryGetComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return; + } + + var title = $"Use ArgumentOutOfRangeException.{comparison.Value.HelperName}"; + + context.RegisterCodeFix( + CodeAction.Create( + title, + cancellationToken => ApplyFixAsync(context.Document, ifStatement, cancellationToken), + equivalenceKey: title + ), + diagnostic + ); + } + + /// Rewrites the matched if statement into a single call to the given throw-helper. + /// The document containing the diagnostic. + /// The if statement to replace. + /// The token used to cancel the fix. + /// The updated document, or the original document if the pattern can no longer be matched. + private static async Task ApplyFixAsync( + Document document, + IfStatementSyntax ifStatement, + CancellationToken cancellationToken + ) + { + var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); + + if ( + root is null + || !ThrowIfOutOfRangeAnalyzer.TryGetComparison(ifStatement.Condition, out var comparison) + || comparison is null + ) + { + return document; + } + + var value = comparison.Value; + ExpressionSyntax[] arguments; + + if (value.OtherExpression2 is not null) + { + arguments = new[] { value.ValueExpression, value.OtherExpression!, value.OtherExpression2 }; + } + else if (value.OtherExpression is not null) + { + arguments = new[] { value.ValueExpression, value.OtherExpression }; + } + else + { + arguments = new[] { value.ValueExpression }; + } + + var invocation = SyntaxFactory + .ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName("ArgumentOutOfRangeException"), + SyntaxFactory.IdentifierName(comparison.Value.HelperName) + ), + SyntaxFactory.ArgumentList( + SyntaxFactory.SeparatedList( + arguments.Select(argument => SyntaxFactory.Argument(argument.WithoutTrivia())) + ) + ) + ) + ) + .WithTriviaFrom(ifStatement) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(ifStatement, invocation); + + return document.WithSyntaxRoot(newRoot); + } +} diff --git a/src/NetEvolve.Arguments/NetEvolve.Arguments.csproj b/src/NetEvolve.Arguments/NetEvolve.Arguments.csproj index cb04da8..06bdb7b 100644 --- a/src/NetEvolve.Arguments/NetEvolve.Arguments.csproj +++ b/src/NetEvolve.Arguments/NetEvolve.Arguments.csproj @@ -9,4 +9,7 @@ guard;clause;exceptions;argument-validation;polyfill;null-check;parameter-validation;defensive-programming;argument-exception;argumentnullexception $(PackageProjectUrl)/releases/ + + + diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs new file mode 100644 index 0000000..6c6ec2e --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/AnalyzerVerifier.cs @@ -0,0 +1,151 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +using System; +using System.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Text; + +/// +/// Compiles test sources against two reference sets: the modern .NET runtime (where BCL throw-helpers +/// already exist, so the analyzers under test must stay silent) and the netstandard2.1 reference +/// assembly (where they don't, simulating the frameworks NetEvolve.Arguments polyfills). +/// +internal static class AnalyzerVerifier +{ + private static readonly ImmutableArray ModernReferences = CreateModernReferences(); + private static readonly Lazy> LegacyReferences = new(CreateLegacyReferences); + + public static Task> GetDiagnosticsAsync( + DiagnosticAnalyzer analyzer, + string source, + bool useLegacyReferences = true + ) => GetDiagnosticsCoreAsync(analyzer, source, useLegacyReferences); + + public static Task ApplyFixAsync( + DiagnosticAnalyzer analyzer, + CodeFixProvider codeFix, + string source, + bool useLegacyReferences = true + ) => ApplyFixCoreAsync(analyzer, codeFix, source, useLegacyReferences); + + private static async Task> GetDiagnosticsCoreAsync( + DiagnosticAnalyzer analyzer, + string source, + bool useLegacyReferences + ) + { + var compilation = CreateCompilation(source, useLegacyReferences); + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + + return await compilationWithAnalyzers.GetAnalyzerDiagnosticsAsync(CancellationToken.None).ConfigureAwait(false); + } + + private static async Task ApplyFixCoreAsync( + DiagnosticAnalyzer analyzer, + CodeFixProvider codeFix, + string source, + bool useLegacyReferences + ) + { + using var workspace = new AdhocWorkspace(); + + var initialProject = workspace.AddProject("TestProject", LanguageNames.CSharp); + var configuredProject = initialProject + .WithMetadataReferences(useLegacyReferences ? LegacyReferences.Value : ModernReferences) + .WithCompilationOptions(new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + if (!workspace.TryApplyChanges(configuredProject.Solution)) + { + throw new InvalidOperationException("Failed to apply project configuration to the workspace."); + } + + var document = workspace.AddDocument(configuredProject.Id, "Test.cs", SourceText.From(source)); + + var compilation = (CSharpCompilation)(await document.Project.GetCompilationAsync().ConfigureAwait(false))!; + var compilationWithAnalyzers = compilation.WithAnalyzers(ImmutableArray.Create(analyzer)); + var diagnostics = await compilationWithAnalyzers + .GetAnalyzerDiagnosticsAsync(CancellationToken.None) + .ConfigureAwait(false); + + var diagnostic = diagnostics.Single(); + + CodeAction? registeredAction = null; + var fixContext = new CodeFixContext( + document, + diagnostic, + (action, _) => registeredAction ??= action, + CancellationToken.None + ); + + await codeFix.RegisterCodeFixesAsync(fixContext).ConfigureAwait(false); + + var operations = await registeredAction!.GetOperationsAsync(CancellationToken.None).ConfigureAwait(false); + var applyChanges = operations.OfType().Single(); + var newDocument = applyChanges.ChangedSolution.GetDocument(document.Id)!; + var newRoot = await newDocument.GetSyntaxRootAsync().ConfigureAwait(false); + + return newRoot!.ToFullString(); + } + + private static CSharpCompilation CreateCompilation(string source, bool useLegacyReferences) + { + var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest)); + + return CSharpCompilation.Create( + "TestAssembly", + new[] { syntaxTree }, + useLegacyReferences ? LegacyReferences.Value : ModernReferences, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + } + + private static ImmutableArray CreateModernReferences() + { + var trustedAssemblies = (string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!; + + return trustedAssemblies + .Split(Path.PathSeparator) + .Select(path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToImmutableArray(); + } + + private static ImmutableArray CreateLegacyReferences() => + ImmutableArray.Create(MetadataReference.CreateFromFile(FindNetStandardReferenceAssembly())); + + private static string FindNetStandardReferenceAssembly() + { + var runtimeDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location)!; + var dotnetRoot = Directory.GetParent(runtimeDirectory)!.Parent!.Parent!.FullName; + var packRoot = Path.Combine(dotnetRoot, "packs", "NETStandard.Library.Ref"); + + if (!Directory.Exists(packRoot)) + { + throw new InvalidOperationException( + $"NETStandard.Library.Ref pack not found at '{packRoot}'. Install it via the .NET SDK workload/pack manager." + ); + } + + var versionDirectory = Directory + .GetDirectories(packRoot) + .OrderByDescending(directory => directory, StringComparer.OrdinalIgnoreCase) + .First(); + + var targetFrameworkDirectory = Directory.GetDirectories(Path.Combine(versionDirectory, "ref"))[0]; + var netstandardDll = Path.Combine(targetFrameworkDirectory, "netstandard.dll"); + + if (!File.Exists(netstandardDll)) + { + throw new InvalidOperationException($"netstandard.dll reference assembly not found under '{packRoot}'."); + } + + return netstandardDll; + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/NetEvolve.Arguments.Analyser.Tests.Unit.csproj b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/NetEvolve.Arguments.Analyser.Tests.Unit.csproj new file mode 100644 index 0000000..2c3ff5b --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/NetEvolve.Arguments.Analyser.Tests.Unit.csproj @@ -0,0 +1,19 @@ + + + net10.0 + Exe + + + + + + + + + + + + + + + diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs new file mode 100644 index 0000000..4f51953 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfContainsWhiteSpaceAnalyzerTests.cs @@ -0,0 +1,72 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfContainsWhiteSpaceAnalyzerTests +{ + [Test] + [Arguments("argument.Any(c => char.IsWhiteSpace(c))")] + [Arguments("argument.Any(char.IsWhiteSpace)")] + public async Task Analyze_WhenWhiteSpaceCheckThrowsArgumentException_ReportsDiagnosticAndFixes(string condition) + { + var source = $$""" + using System; + using System.Linq; + + class C + { + void M(string argument) + { + if ({{condition}}) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfContainsWhiteSpaceAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0008"); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfContainsWhiteSpaceAnalyzer(), + new ThrowIfContainsWhiteSpaceCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentException.ThrowIfContainsWhiteSpace(argument);"); + } + + [Test] + [Arguments("if (argument.Any(char.IsWhiteSpace)) throw new ArgumentNullException(nameof(argument));")] + [Arguments("if (argument.Any(c => c == ' ')) throw new ArgumentException(nameof(argument));")] + [Arguments("if (argument.Any(c => char.IsWhiteSpace(other))) throw new ArgumentException(nameof(argument));")] + [Arguments("if (argument.Contains(' ')) throw new ArgumentException(nameof(argument));")] + [Arguments( + """ + if (argument.Any(char.IsWhiteSpace)) + { + throw new ArgumentException(nameof(argument)); + } + else + { + } + """ + )] + public async Task Analyze_WhenConditionOrExceptionIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + using System.Linq; + + class C + { + void M(string argument, char other) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfContainsWhiteSpaceAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs new file mode 100644 index 0000000..8b08666 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfCountAnalyzerTests.cs @@ -0,0 +1,77 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfCountAnalyzerTests +{ + [Test] + [Arguments("argument.Count > 100", "ThrowIfCountGreaterThan(argument, 100);")] + [Arguments("argument.Count < 5", "ThrowIfCountLessThan(argument, 5);")] + [Arguments("argument.Count < 5 || argument.Count > 100", "ThrowIfCountOutOfRange(argument, 5, 100);")] + [Arguments("argument.Count() > 100", "ThrowIfCountGreaterThan(argument, 100);")] + public async Task Analyze_WhenCountComparisonThrowsArgumentException_ReportsDiagnosticAndFixes( + string condition, + string expectedInvocation + ) + { + var source = $$""" + using System; + using System.Collections.Generic; + using System.Linq; + + class C + { + void M(ICollection argument) + { + if ({{condition}}) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfCountAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0007"); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfCountAnalyzer(), + new ThrowIfCountCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains($"ArgumentException.{expectedInvocation}"); + } + + [Test] + [Arguments("if (argument.Count > 100) throw new ArgumentNullException(nameof(argument));")] + [Arguments("if (argument.Length > 100) throw new ArgumentException(nameof(argument));")] + [Arguments("if (argument.Count == 100) throw new ArgumentException(nameof(argument));")] + [Arguments( + """ + if (argument.Count > 100) + { + throw new ArgumentException(nameof(argument)); + } + else + { + } + """ + )] + public async Task Analyze_WhenConditionOrExceptionIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + using System.Collections.Generic; + + class C + { + void M(ICollection argument) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfCountAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs new file mode 100644 index 0000000..b17bc62 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDefaultAnalyzerTests.cs @@ -0,0 +1,108 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfDefaultAnalyzerTests +{ + [Test] + [Arguments("argument.Equals(default)")] + [Arguments("argument == default")] + [Arguments("default == argument")] + [Arguments("argument.Equals(default(Guid))")] + [Arguments("argument == default(Guid)")] + public async Task Analyze_WhenDefaultCheckThrowsArgumentException_ReportsDiagnostic(string condition) + { + var source = $$""" + using System; + + class C + { + void M(Guid argument) + { + if ({{condition}}) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDefaultAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0004"); + } + + [Test] + [Arguments("if (argument.Equals(default)) throw new ArgumentNullException(nameof(argument));")] + [Arguments("if (argument.Equals(default)) throw new ArgumentException(\"custom\", nameof(argument));")] + [Arguments("if (argument.Equals(1)) throw new ArgumentException(nameof(argument));")] + [Arguments( + """ + if (argument.Equals(default)) + { + throw new ArgumentException(nameof(argument)); + } + else + { + } + """ + )] + public async Task Analyze_WhenConditionOrExceptionIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + + class C + { + void M(Guid argument) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDefaultAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenParamNameIsStringLiteral_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(Guid argument) + { + if (argument.Equals(default)) throw new ArgumentException("argument"); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDefaultAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + } + + [Test] + public async Task CodeFix_WhenApplied_ReplacesWithThrowIfDefaultCall() + { + const string source = """ + using System; + + class C + { + void M(Guid argument) + { + if (argument.Equals(default)) throw new ArgumentException(nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfDefaultAnalyzer(), + new ThrowIfDefaultCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentException.ThrowIfDefault(argument);"); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs new file mode 100644 index 0000000..f4c6e02 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfDisposedAnalyzerTests.cs @@ -0,0 +1,135 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfDisposedAnalyzerTests +{ + [Test] + public async Task Analyze_WhenDisposedCheckThrowsObjectDisposedException_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + private bool _disposed; + + void M() + { + if (_disposed) throw new ObjectDisposedException(GetType().Name); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDisposedAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0005"); + } + + [Test] + public async Task Analyze_WhenInStaticMethod_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + private static bool _disposed; + + static void M() + { + if (_disposed) throw new ObjectDisposedException(nameof(C)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDisposedAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + [Arguments("if (_disposed) throw new ArgumentException(\"disposed\");")] + [Arguments( + """ + if (_disposed) + { + throw new ObjectDisposedException(GetType().Name); + } + else + { + } + """ + )] + public async Task Analyze_WhenExceptionTypeOrShapeIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + + class C + { + private bool _disposed; + + void M() + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfDisposedAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenBuiltInThrowIfAvailable_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + private bool _disposed; + + void M() + { + if (_disposed) throw new ObjectDisposedException(GetType().Name); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync( + new ThrowIfDisposedAnalyzer(), + source, + useLegacyReferences: false + ); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task CodeFix_WhenApplied_ReplacesWithThrowIfCall() + { + const string source = """ + using System; + + class C + { + private bool _disposed; + + void M() + { + if (_disposed) throw new ObjectDisposedException(GetType().Name); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfDisposedAnalyzer(), + new ThrowIfDisposedCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ObjectDisposedException.ThrowIf(_disposed, this);"); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs new file mode 100644 index 0000000..f5705e0 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfEmptyGuidAnalyzerTests.cs @@ -0,0 +1,86 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfEmptyGuidAnalyzerTests +{ + [Test] + [Arguments("argument == Guid.Empty")] + [Arguments("Guid.Empty == argument")] + [Arguments("argument.Equals(Guid.Empty)")] + public async Task Analyze_WhenEmptyGuidCheckThrowsArgumentException_ReportsDiagnostic(string condition) + { + var source = $$""" + using System; + + class C + { + void M(Guid argument) + { + if ({{condition}}) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfEmptyGuidAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0009"); + } + + [Test] + [Arguments("if (argument == Guid.Empty) throw new ArgumentNullException(nameof(argument));")] + [Arguments("if (argument == Guid.Empty) throw new ArgumentException(\"custom\", nameof(argument));")] + [Arguments("if (argument == Guid.NewGuid()) throw new ArgumentException(nameof(argument));")] + [Arguments( + """ + if (argument == Guid.Empty) + { + throw new ArgumentException(nameof(argument)); + } + else + { + } + """ + )] + public async Task Analyze_WhenConditionOrExceptionIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + + class C + { + void M(Guid argument) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfEmptyGuidAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task CodeFix_WhenApplied_ReplacesWithThrowIfEmptyGuidCall() + { + const string source = """ + using System; + + class C + { + void M(Guid argument) + { + if (argument == Guid.Empty) throw new ArgumentException(nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfEmptyGuidAnalyzer(), + new ThrowIfEmptyGuidCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentException.ThrowIfEmptyGuid(argument);"); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs new file mode 100644 index 0000000..b172f9f --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfLengthAnalyzerTests.cs @@ -0,0 +1,74 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfLengthAnalyzerTests +{ + [Test] + [Arguments("argument.Length > 100", "ThrowIfLengthGreaterThan(argument, 100);")] + [Arguments("argument.Length < 5", "ThrowIfLengthLessThan(argument, 5);")] + [Arguments("argument.Length < 5 || argument.Length > 100", "ThrowIfLengthOutOfRange(argument, 5, 100);")] + public async Task Analyze_WhenLengthComparisonThrowsArgumentException_ReportsDiagnosticAndFixes( + string condition, + string expectedInvocation + ) + { + var source = $$""" + using System; + + class C + { + void M(string argument) + { + if ({{condition}}) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfLengthAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0006"); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfLengthAnalyzer(), + new ThrowIfLengthCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains($"ArgumentException.{expectedInvocation}"); + } + + [Test] + [Arguments("if (argument.Length > 100) throw new ArgumentOutOfRangeException(nameof(argument));")] + [Arguments("if (argument.Count > 100) throw new ArgumentException(nameof(argument));")] + [Arguments("if (argument.Length == 100) throw new ArgumentException(nameof(argument));")] + [Arguments("if (argument.Length < 5 || other.Length > 100) throw new ArgumentException(nameof(argument));")] + [Arguments( + """ + if (argument.Length > 100) + { + throw new ArgumentException(nameof(argument)); + } + else + { + } + """ + )] + public async Task Analyze_WhenConditionOrExceptionIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + + class C + { + void M(string argument, string other) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfLengthAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs new file mode 100644 index 0000000..3584c9d --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullAnalyzerTests.cs @@ -0,0 +1,289 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfNullAnalyzerTests +{ + [Test] + public async Task Analyze_WhenIsNullCheckThrowsArgumentNullException_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0001"); + } + + [Test] + [Arguments("argument == null")] + [Arguments("null == argument")] + [Arguments("argument is null")] + [Arguments("ReferenceEquals(argument, null)")] + [Arguments("ReferenceEquals(null, argument)")] + [Arguments("!(argument != null)")] + [Arguments("!(argument is not null)")] + [Arguments("!(null != argument)")] + [Arguments("object.ReferenceEquals(argument, null)")] + [Arguments("(argument is null)")] + public async Task Analyze_WhenUsingRecognizedNullCheckVariant_ReportsDiagnostic(string condition) + { + var source = $$""" + using System; + + class C + { + void M(string? argument) + { + if ({{condition}}) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + } + + [Test] + [Arguments("argument is not null")] + [Arguments("!(argument is null)")] + [Arguments("argument != null")] + [Arguments("null != argument")] + public async Task Analyze_WhenConditionMeansNonNull_DoesNotReportDiagnostic(string condition) + { + var source = $$""" + using System; + + class C + { + void M(string? argument) + { + if ({{condition}}) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenCoalesceThrowsArgumentNullException_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + private readonly string _value; + + public C(string? argument) + { + _value = argument ?? throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0001"); + } + + [Test] + public async Task CodeFix_WhenAppliedToCoalesce_HoistsThrowIfNullAndKeepsAssignment() + { + const string source = """ + using System; + + class C + { + private readonly string _value; + + public C(string? argument) + { + _value = argument ?? throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfNullAnalyzer(), + new ThrowIfNullCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentNullException.ThrowIfNull(argument);"); + _ = await Assert.That(fixedSource).Contains("_value = argument;"); + _ = await Assert.That(fixedSource).DoesNotContain("throw new ArgumentNullException"); + } + + [Test] + public async Task Analyze_WhenBuiltInThrowIfNullAvailable_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync( + new ThrowIfNullAnalyzer(), + source, + useLegacyReferences: false + ); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenExceptionHasNoArguments_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) throw new ArgumentNullException(); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + } + + [Test] + public async Task Analyze_WhenParamNameDoesNotMatchCheckedArgument_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument, string other) + { + if (argument is null) throw new ArgumentNullException(nameof(other)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + [Arguments("_value = argument ?? throw new ArgumentException(nameof(argument));")] + [Arguments("_value = argument ?? throw new ArgumentNullException(nameof(argument), \"custom\");")] + public async Task Analyze_WhenCoalesceThrowIsNotRecognized_DoesNotReportDiagnostic(string statement) + { + var source = $$""" + using System; + + class C + { + private readonly string _value; + + public C(string? argument) + { + {{statement}} + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenExceptionHasCustomMessage_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) throw new ArgumentNullException(nameof(argument), "custom message"); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenIfHasElseClause_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) + { + throw new ArgumentNullException(nameof(argument)); + } + else + { + } + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task CodeFix_WhenApplied_ReplacesWithThrowIfNullCall() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (argument is null) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfNullAnalyzer(), + new ThrowIfNullCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentNullException.ThrowIfNull(argument);"); + _ = await Assert.That(fixedSource).DoesNotContain("throw new ArgumentNullException"); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs new file mode 100644 index 0000000..1a201c3 --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfNullOrEmptyAnalyzerTests.cs @@ -0,0 +1,228 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfNullOrEmptyAnalyzerTests +{ + [Test] + public async Task Analyze_WhenIsNullOrEmptyCheckThrowsArgumentException_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrEmpty(argument)) throw new ArgumentException("", nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0002"); + } + + [Test] + public async Task Analyze_WhenIsNullOrWhiteSpaceCheckThrowsArgumentException_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrWhiteSpace(argument)) throw new ArgumentException("", nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + } + + [Test] + public async Task Analyze_WhenThrowingArgumentNullException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrEmpty(argument)) throw new ArgumentNullException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenBuiltInThrowIfNullOrEmptyAvailable_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrEmpty(argument)) throw new ArgumentException("", nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync( + new ThrowIfNullOrEmptyAnalyzer(), + source, + useLegacyReferences: false + ); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenIfHasElseClause_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrEmpty(argument)) + { + throw new ArgumentException("", nameof(argument)); + } + else + { + } + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenMethodIsNotRecognized_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsInterned(argument) != null) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenQualifierIsNotString_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class Other + { + public static bool IsNullOrEmpty(string? value) => value is null; + } + + class C + { + void M(string? argument) + { + if (Other.IsNullOrEmpty(argument)) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenQualifiedAsSystemString_ReportsDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (String.IsNullOrEmpty(argument)) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfNullOrEmptyAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + } + + [Test] + public async Task CodeFix_WhenAppliedToIsNullOrEmpty_ReplacesWithThrowIfNullOrEmptyCall() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrEmpty(argument)) throw new ArgumentException("", nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfNullOrEmptyAnalyzer(), + new ThrowIfNullOrEmptyCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentException.ThrowIfNullOrEmpty(argument);"); + } + + [Test] + public async Task CodeFix_WhenAppliedToIsNullOrWhiteSpace_ReplacesWithThrowIfNullOrWhiteSpaceCall() + { + const string source = """ + using System; + + class C + { + void M(string? argument) + { + if (string.IsNullOrWhiteSpace(argument)) throw new ArgumentException("", nameof(argument)); + } + } + """; + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfNullOrEmptyAnalyzer(), + new ThrowIfNullOrEmptyCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentException.ThrowIfNullOrWhiteSpace(argument);"); + } +} diff --git a/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs new file mode 100644 index 0000000..afeab8b --- /dev/null +++ b/tests/NetEvolve.Arguments.Analyser.Tests.Unit/ThrowIfOutOfRangeAnalyzerTests.cs @@ -0,0 +1,239 @@ +namespace NetEvolve.Arguments.Analyser.Tests.Unit; + +public sealed class ThrowIfOutOfRangeAnalyzerTests +{ + [Test] + [Arguments("argument < 0", "ThrowIfNegative(argument);")] + [Arguments("argument <= 0", "ThrowIfNegativeOrZero(argument);")] + [Arguments("argument == 0", "ThrowIfZero(argument);")] + [Arguments("argument < 42", "ThrowIfLessThan(argument, 42);")] + [Arguments("argument <= 42", "ThrowIfLessThanOrEqual(argument, 42);")] + [Arguments("argument > 42", "ThrowIfGreaterThan(argument, 42);")] + [Arguments("argument >= 42", "ThrowIfGreaterThanOrEqual(argument, 42);")] + [Arguments("argument == 42", "ThrowIfEqual(argument, 42);")] + [Arguments("argument != 42", "ThrowIfNotEqual(argument, 42);")] + public async Task Analyze_WhenComparisonThrowsArgumentOutOfRangeException_ReportsDiagnosticAndFixes( + string condition, + string expectedInvocation + ) + { + var source = $$""" + using System; + + class C + { + void M(int argument) + { + if ({{condition}}) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0003"); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfOutOfRangeAnalyzer(), + new ThrowIfOutOfRangeCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains($"ArgumentOutOfRangeException.{expectedInvocation}"); + } + + [Test] + public async Task Analyze_WhenBoundIsNotALiteral_ReportsDiagnosticAndFixes() + { + const string source = """ + using System; + + class C + { + void M(int argument, int other) + { + if (argument < other) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfOutOfRangeAnalyzer(), + new ThrowIfOutOfRangeCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentOutOfRangeException.ThrowIfLessThan(argument, other);"); + } + + [Test] + public async Task Analyze_WhenValueOperandIsLiteral_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (0 > argument) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenThrowingArgumentException_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (argument < 0) throw new ArgumentException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenBuiltInThrowIfNegativeAvailable_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (argument < 0) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync( + new ThrowIfOutOfRangeAnalyzer(), + source, + useLegacyReferences: false + ); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenCombinedRangeThrowsArgumentOutOfRangeException_ReportsDiagnosticAndFixes() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (argument < 5 || argument > 100) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).Count().IsEqualTo(1); + _ = await Assert.That(diagnostics[0].Id).IsEqualTo("NEA0003"); + + var fixedSource = await AnalyzerVerifier.ApplyFixAsync( + new ThrowIfOutOfRangeAnalyzer(), + new ThrowIfOutOfRangeCodeFixProvider(), + source + ); + + _ = await Assert.That(fixedSource).Contains("ArgumentOutOfRangeException.ThrowIfOutOfRange(argument, 5, 100);"); + } + + [Test] + public async Task Analyze_WhenIfHasElseClause_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (argument < 0) + { + throw new ArgumentOutOfRangeException(nameof(argument)); + } + else + { + } + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + public async Task Analyze_WhenExceptionHasTooManyArguments_DoesNotReportDiagnostic() + { + const string source = """ + using System; + + class C + { + void M(int argument) + { + if (argument < 0) throw new ArgumentOutOfRangeException(nameof(argument), argument, "msg", 1); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } + + [Test] + [Arguments("bool argument", "argument")] + [Arguments("int argument, int other", "argument < 5 || other > 100")] + [Arguments("int argument", "argument > 100 || argument < 5")] + [Arguments("int argument", "argument == 5 || argument > 100")] + public async Task Analyze_WhenConditionIsUnrecognizedShape_DoesNotReportDiagnostic( + string parameters, + string condition + ) + { + var source = $$""" + using System; + + class C + { + void M({{parameters}}) + { + if ({{condition}}) throw new ArgumentOutOfRangeException(nameof(argument)); + } + } + """; + + var diagnostics = await AnalyzerVerifier.GetDiagnosticsAsync(new ThrowIfOutOfRangeAnalyzer(), source); + + _ = await Assert.That(diagnostics).IsEmpty(); + } +}